archive_scope.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """What of an archive a printer-scoped caller sees and may change (#1727, archive redesign).
  2. An archive is a file; each print of it is a run, and plates of one archive
  3. can print on several printers. A caller limited to some printers sees an
  4. archive when it, or any of its runs, is on one of theirs, and then only
  5. their prints of it: the outcome, video and finish photos of a run on a
  6. printer they can't see stay hidden. What changes the whole archive needs
  7. every printer it printed on.
  8. """
  9. from __future__ import annotations
  10. from dataclasses import dataclass, field
  11. from datetime import datetime
  12. from fastapi import HTTPException, status
  13. from sqlalchemy import select
  14. from sqlalchemy.ext.asyncio import AsyncSession
  15. from backend.app.core.printer_scope import PrinterScope
  16. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
  17. # Finish photos are named so (main._background_finish_photo, camera); a
  18. # photo uploaded by hand is not, nor one carried over from the library.
  19. _FINISH_PHOTO_PREFIX = "finish_"
  20. @dataclass(frozen=True)
  21. class ScopedView:
  22. """What a scoped caller sees of one archive beyond what it holds.
  23. ``run`` is their latest print of it, shown instead of the archive's
  24. latest when that one is on a printer outside their scope. The photos
  25. and the video of prints outside it are left out.
  26. """
  27. run: PrintLogEntry | None = None
  28. # The print whose figures (energy, wear) it shows: ``run``, or the
  29. # archive's latest when that is in the scope.
  30. shown: PrintLogEntry | None = None
  31. hidden_photos: frozenset[str] = field(default_factory=frozenset)
  32. # The archive's video, when it is of a print outside the scope.
  33. hidden_timelapse: str | None = None
  34. async def runs_by_archive(db: AsyncSession, archive_ids: list[int]) -> dict[int, list[PrintLogEntry]]:
  35. """Every run of each archive, a printing one included."""
  36. if not archive_ids:
  37. return {}
  38. rows = await db.scalars(
  39. select(PrintLogEntry)
  40. .where(PrintLogEntry.archive_id.in_(archive_ids))
  41. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  42. )
  43. runs: dict[int, list[PrintLogEntry]] = {}
  44. for run in rows.all():
  45. if run.archive_id is not None:
  46. runs.setdefault(run.archive_id, []).append(run)
  47. return runs
  48. def _on_printer_in(scope: PrinterScope, run: PrintLogEntry) -> bool:
  49. """A print on a printer of the scope; one whose printer was deleted is no one's."""
  50. return run.printer_id is not None and scope.allows(run.printer_id)
  51. def view_of(archive, runs: list[PrintLogEntry], scope: PrinterScope) -> ScopedView | None:
  52. """What ``scope`` sees of ``archive`` given its ``runs``; None when it sees all of it."""
  53. if scope.is_unrestricted:
  54. return None
  55. inside = [r for r in runs if scope.allows(r.printer_id)]
  56. outside = [r for r in runs if not scope.allows(r.printer_id)]
  57. archive_inside = scope.allows(archive.printer_id)
  58. if archive_inside and not outside:
  59. return None
  60. shown = {p for r in inside for p in (r.photos or [])}
  61. hidden = {p for r in outside for p in (r.photos or []) if p not in shown}
  62. if not archive_inside:
  63. # A finish photo on no run's list is of a print logged before runs
  64. # kept their own: the archive's latest, here on a printer outside.
  65. on_runs = {p for r in runs for p in (r.photos or [])}
  66. hidden |= {p for p in archive.photos or [] if p not in on_runs and p.startswith(_FINISH_PHOTO_PREFIX)}
  67. hidden_timelapse = None
  68. if archive.timelapse_path:
  69. holders = [r for r in runs if r.timelapse_path == archive.timelapse_path]
  70. if not any(scope.allows(r.printer_id) for r in holders) if holders else not archive_inside:
  71. hidden_timelapse = archive.timelapse_path
  72. def latest(of: list[PrintLogEntry]) -> PrintLogEntry:
  73. return max(of, key=lambda r: (r.started_at is not None, r.started_at or r.created_at or datetime.min, r.id))
  74. run = shown = None
  75. if not archive_inside:
  76. theirs = [r for r in runs if _on_printer_in(scope, r)]
  77. if theirs:
  78. run = shown = latest(theirs)
  79. elif runs and scope.allows((newest := latest(runs)).printer_id):
  80. shown = newest
  81. return ScopedView(run=run, shown=shown, hidden_photos=frozenset(hidden), hidden_timelapse=hidden_timelapse)
  82. async def scoped_views(db: AsyncSession, archives, scope: PrinterScope) -> dict[int, ScopedView]:
  83. """``view_of`` for each archive that ``scope`` doesn't see whole."""
  84. if scope.is_unrestricted:
  85. return {}
  86. archives = list(archives)
  87. runs = await runs_by_archive(db, [a.id for a in archives])
  88. views = {}
  89. for archive in archives:
  90. view = view_of(archive, runs.get(archive.id, []), scope)
  91. if view is not None:
  92. views[archive.id] = view
  93. return views
  94. async def scoped_view(db: AsyncSession, archive, scope: PrinterScope) -> ScopedView | None:
  95. return (await scoped_views(db, [archive], scope)).get(archive.id)
  96. async def printed_in_scope(db: AsyncSession, archive, scope: PrinterScope) -> bool:
  97. """Whether ``scope`` sees ``archive``: on its own printer, or a run on one of theirs."""
  98. if scope.allows(archive.printer_id):
  99. return True
  100. runs = await runs_by_archive(db, [archive.id])
  101. return any(_on_printer_in(scope, r) for r in runs.get(archive.id, []))
  102. async def ensure_whole_archive_in_scope(db: AsyncSession, archive, scope: PrinterScope) -> None:
  103. """Refuse a change to the whole archive (its file, its video, the archive
  104. itself) unless ``scope`` sees every printer it printed on: it would change
  105. or remove those prints too. The caller sees the archive, so this says why
  106. (403) rather than hiding it."""
  107. if scope.is_unrestricted:
  108. return
  109. runs = (await runs_by_archive(db, [archive.id])).get(archive.id, [])
  110. if scope.allows(archive.printer_id) and all(scope.allows(r.printer_id) for r in runs):
  111. return
  112. raise HTTPException(
  113. status_code=status.HTTP_403_FORBIDDEN,
  114. detail="This archive was also printed on printers outside your access, so only someone "
  115. "who can see all of them can change or remove it",
  116. )