print_log.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. """Service for writing independent print log entries.
  2. Log entries are written to a separate table and never touch archives or queue items.
  3. """
  4. import logging
  5. import uuid
  6. from collections.abc import Set as AbstractSet
  7. from datetime import datetime, timezone
  8. from pathlib import Path
  9. from types import SimpleNamespace
  10. from sqlalchemy import delete, func, or_, select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
  13. from backend.app.utils.local_time import utcnow_naive
  14. logger = logging.getLogger(__name__)
  15. def wear_cost_for_run(duration_seconds: int | None, wear_cost_per_hour: float | None) -> float | None:
  16. """Printer wear for one run: its duration at the printer's hourly rate (#694).
  17. None when the printer has no rate or the run has no measured duration (a
  18. reconciled run stores 0 because its real end time is unknown).
  19. """
  20. if not isinstance(wear_cost_per_hour, int | float) or not isinstance(duration_seconds, int):
  21. return None
  22. if wear_cost_per_hour <= 0 or duration_seconds <= 0:
  23. return None
  24. return round(duration_seconds / 3600 * wear_cost_per_hour, 3)
  25. async def write_log_entry(
  26. db: AsyncSession,
  27. *,
  28. status: str,
  29. archive_id: int | None = None,
  30. queue_item_id: int | None = None,
  31. print_name: str | None = None,
  32. plate_id: int | None = None,
  33. printer_name: str | None = None,
  34. printer_id: int | None = None,
  35. started_at: datetime | None = None,
  36. completed_at: datetime | None = None,
  37. filament_type: str | None = None,
  38. filament_color: str | None = None,
  39. filament_used_grams: float | None = None,
  40. cost: float | None = None,
  41. energy_kwh: float | None = None,
  42. energy_cost: float | None = None,
  43. wear_cost_per_hour: float | None = None,
  44. failure_reason: str | None = None,
  45. thumbnail_path: str | None = None,
  46. created_by_id: int | None = None,
  47. created_by_username: str | None = None,
  48. reconciled: bool = False,
  49. duration_unmeasured: bool = False,
  50. ended_offline: bool = False,
  51. replaces_offline_stop: bool = False,
  52. ) -> PrintLogEntry:
  53. """Write a print log entry.
  54. ``reconciled`` marks a synthetic completion written when a stale
  55. ``status="printing"`` archive is closed out at reconnect. Its real end time
  56. is unknown — the print stopped somewhere during the disconnect and
  57. ``completed_at`` is only the reconnect moment — so ``completed_at -
  58. started_at`` would bank the entire disconnect gap as print time, adding
  59. hundreds of fictitious hours across a farm of stale rows (#2592). For those
  60. entries we store an explicit ``0`` ("no measured runtime") rather than a
  61. fabricated duration; the stats total trusts a stored 0 instead of
  62. recomputing from the stale timestamps.
  63. ``duration_unmeasured`` marks a run closed at reconnect, whose times were
  64. not measured (#3310): ``completed_at`` is its start plus the slicer's
  65. estimate, or the reconnect. The time accuracy leaves it out.
  66. ``ended_offline`` marks a run logged by a stop that could not reach the
  67. printer. A print's end passes ``replaces_offline_stop``: it may then fill
  68. such a run of its printer, the printer's latest, which its archive's start
  69. (another plate's since, maybe) and its closed queue item no longer find.
  70. """
  71. if replaces_offline_stop:
  72. entry = await run_being_ended(db, archive_id, printer_id, started_at)
  73. else:
  74. entry = await _entry_of_this_run(db, archive_id, printer_id, started_at)
  75. filling_open_run = entry is not None and entry.status == RUN_OPEN
  76. if entry is not None and entry.started_at is not None:
  77. # The run's own start. The archive's is its latest run's: another
  78. # plate of it may have started on another printer since.
  79. started_at = entry.started_at
  80. if reconciled:
  81. duration: int | None = 0
  82. elif started_at and completed_at:
  83. duration = int((_utc(completed_at) - _utc(started_at)).total_seconds())
  84. else:
  85. duration = None
  86. fields = {
  87. "archive_id": archive_id,
  88. "queue_item_id": queue_item_id,
  89. "print_name": print_name,
  90. "plate_id": plate_id,
  91. "printer_name": printer_name,
  92. "printer_id": printer_id,
  93. "status": status,
  94. "started_at": started_at,
  95. "completed_at": completed_at,
  96. "duration_seconds": duration,
  97. "filament_type": filament_type,
  98. "filament_color": filament_color,
  99. "filament_used_grams": filament_used_grams,
  100. "cost": cost,
  101. "energy_kwh": energy_kwh,
  102. "energy_cost": energy_cost,
  103. "wear_cost": wear_cost_for_run(duration, wear_cost_per_hour),
  104. "duration_unmeasured": True if duration_unmeasured else None,
  105. "failure_reason": failure_reason,
  106. "thumbnail_path": thumbnail_path,
  107. "created_by_id": created_by_id,
  108. "created_by_username": created_by_username,
  109. "ended_offline": True if ended_offline else None,
  110. }
  111. if filling_open_run:
  112. # When the run was logged, as a run written at its end always was:
  113. # the date ranges of statistics and the print log go by it.
  114. fields["created_at"] = utcnow_naive()
  115. if entry is None:
  116. if archive_id is not None and started_at is not None and status != RUN_OPEN:
  117. # Shadow check while the run's start moves over (archive redesign):
  118. # every print start that binds an archive opens its run, so a run
  119. # ending without one is a start path that does not yet.
  120. logger.info("[RUN] Archive %s: no run was opened when the print started; written at its end", archive_id)
  121. entry = PrintLogEntry(**fields)
  122. db.add(entry)
  123. elif entry.status != status:
  124. if entry.status == RUN_OPEN and None not in (entry.plate_id, plate_id) and entry.plate_id != plate_id:
  125. logger.warning(
  126. "[RUN] Archive %s: the run opened for plate %s ends as plate %s", archive_id, entry.plate_id, plate_id
  127. )
  128. # The run ended differently from how it was first logged: a print
  129. # stopped while its printer was offline that finished after all. The
  130. # real outcome replaces the stop rather than counting the run twice.
  131. for name, value in fields.items():
  132. if value is None and name in _KEPT_WHEN_NOT_REPORTED:
  133. continue
  134. setattr(entry, name, value)
  135. # The same outcome reported again (a repeated completion after a reconnect)
  136. # changes nothing: the repeat no longer has the run's tracked usage, and the
  137. # entry may have been edited or given its energy since.
  138. await db.flush()
  139. if archive_id is not None:
  140. await mirror_latest_run(db, archive_id)
  141. return entry
  142. # What the archive card shows of its prints, its latest run's.
  143. _MIRRORED_FROM_RUN = ("status", "started_at", "completed_at", "failure_reason")
  144. async def mirror_latest_run(db: AsyncSession, archive_id: int) -> None:
  145. """Give the archive its latest run's outcome, by start: status, times,
  146. failure reason, plate and verdict. Only once none of its runs is printing: until
  147. the print's start, end, reconcile and timelapse look their print up by
  148. its run, they still go by the archive's printer, status and start, which
  149. a print running elsewhere must not move under them. While one prints,
  150. the archive keeps what its prints' starts and ends wrote. An archive
  151. without runs keeps its own (uploaded, or printed before runs were
  152. logged). Does not commit."""
  153. from backend.app.models.archive import PrintArchive
  154. archive = await db.get(PrintArchive, archive_id)
  155. if archive is None:
  156. return
  157. runs = select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id)
  158. still_printing = await db.scalar(
  159. runs.with_only_columns(PrintLogEntry.id)
  160. .where(PrintLogEntry.status == RUN_OPEN)
  161. .limit(1)
  162. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  163. )
  164. if still_printing is not None:
  165. return
  166. latest = await db.scalar(
  167. runs.order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc()).limit(1)
  168. )
  169. if latest is None:
  170. return
  171. for name in _MIRRORED_FROM_RUN:
  172. setattr(archive, name, getattr(latest, name))
  173. if latest.plate_id is not None:
  174. archive.plate_id = latest.plate_id
  175. # Its outcome verdict too (#1898), once the run holds its own: it has one,
  176. # or was opened at its start with its opt-in. A run logged before then
  177. # may never have been given the archive's, which stays. Runs logged
  178. # before they kept the verdict's provenance have only its value: the
  179. # archive's own provenance stays with that same verdict.
  180. owns_verdict = latest.user_verdict is not None or latest.confirm_requested is not None
  181. if owns_verdict and (latest.user_verdict != archive.user_verdict or latest.user_verdict_source is not None):
  182. archive.user_verdict = latest.user_verdict
  183. archive.user_verdict_source = latest.user_verdict_source
  184. archive.user_verdict_at = latest.user_verdict_at
  185. async def mirror_archive_cost(
  186. db: AsyncSession, archive, previous_cost: float | None, *, skip_runs: AbstractSet[int] = frozenset()
  187. ) -> None:
  188. """Give the archive's newest run the archive's new cost, if it had the old one.
  189. Statistics, project totals and batch sums read a run's cost. A completed
  190. run without its own spool price took the archive's (or none), so a new
  191. archive cost belongs to it too. A run its spools priced keeps that price,
  192. and a failed or cancelled run keeps its own: it printed only part of it.
  193. A run in ``skip_runs`` was given its own new cost already.
  194. """
  195. entry = await db.scalar(
  196. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
  197. )
  198. if entry is None or entry.id in skip_runs:
  199. return
  200. if entry.status == "completed" and entry.cost in (None, previous_cost):
  201. entry.cost = archive.cost
  202. async def other_runs_of(db: AsyncSession, archive) -> int:
  203. """How many log entries *archive* has from runs other than its current one.
  204. The archive card keeps its first run's figures; a run whose own entry is
  205. already written (its stop, logged while the printer was offline) is still
  206. the first run.
  207. """
  208. # Without a start time or printer on the archive, ``!= None`` reads as
  209. # ``IS NOT NULL``: every entry is another run's.
  210. query = select(func.count(PrintLogEntry.id)).where(
  211. PrintLogEntry.archive_id == archive.id,
  212. or_(
  213. PrintLogEntry.started_at.is_(None),
  214. PrintLogEntry.started_at != archive.started_at,
  215. PrintLogEntry.printer_id.is_(None),
  216. PrintLogEntry.printer_id != archive.printer_id,
  217. ),
  218. )
  219. return await db.scalar(query) or 0
  220. # Who and what the run was, which a repeated report may no longer know: a
  221. # completion that finds the queue item already closed reports none. Its
  222. # outcome (status, reason, grams, cost) is replaced as reported.
  223. _KEPT_WHEN_NOT_REPORTED = frozenset(
  224. {
  225. "queue_item_id",
  226. "print_name",
  227. "plate_id",
  228. "printer_name",
  229. "thumbnail_path",
  230. "created_by_id",
  231. "created_by_username",
  232. }
  233. )
  234. def _utc(moment: datetime) -> datetime:
  235. """Naive UTC, as the columns store it; an aware time is converted first."""
  236. return moment.astimezone(timezone.utc).replace(tzinfo=None) if moment.tzinfo else moment
  237. async def _entry_of_this_run(
  238. db: AsyncSession,
  239. archive_id: int | None,
  240. printer_id: int | None,
  241. started_at: datetime | None,
  242. *,
  243. exact: bool = False,
  244. ) -> PrintLogEntry | None:
  245. """The entry already written for this run, if any.
  246. A run is its archive, printer and start time together: plates of one
  247. archive printing on two printers share the archive row (and can share its
  248. start time), and a reprint starts at a new time. The queue item is not
  249. part of it: a repeated completion finds the item already closed and
  250. reports none. Without an archive or printer a run can't be told apart,
  251. so nothing is matched.
  252. A run opened at its start is also found as the archive's run open on the
  253. printer, unless ``exact``: the archive's start is its latest run's, and
  254. another plate of it may have started on another printer since.
  255. """
  256. if archive_id is None or printer_id is None:
  257. return None
  258. if started_at is None:
  259. return None if exact else await _open_run_on(db, archive_id, printer_id)
  260. query = (
  261. select(PrintLogEntry)
  262. .where(
  263. PrintLogEntry.archive_id == archive_id,
  264. PrintLogEntry.printer_id == printer_id,
  265. PrintLogEntry.started_at == started_at,
  266. )
  267. # The run opened when the print started is the one this fills in.
  268. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  269. )
  270. entry = (await db.scalars(query.order_by(PrintLogEntry.id.desc()).limit(1))).first()
  271. # The archive's start is its latest run's. With another plate of it
  272. # started on another printer since, this printer's run is found as the
  273. # one open there.
  274. if entry is not None or exact:
  275. return entry
  276. return await _open_run_on(db, archive_id, printer_id)
  277. async def run_being_ended(
  278. db: AsyncSession, archive_id: int | None, printer_id: int | None, started_at: datetime | None
  279. ) -> PrintLogEntry | None:
  280. """The run a print's end fills in (``write_log_entry`` with
  281. ``replaces_offline_stop``): the one written for it, else the one a stop
  282. logged while the printer was offline. Billing charges the same run."""
  283. entry = await _entry_of_this_run(db, archive_id, printer_id, started_at)
  284. if entry is None and archive_id is not None and printer_id is not None:
  285. entry = await _stopped_while_offline(db, archive_id, printer_id)
  286. return entry
  287. async def _stopped_while_offline(db: AsyncSession, archive_id: int, printer_id: int) -> PrintLogEntry | None:
  288. """The run a print's real end replaces after a stop logged while the
  289. printer was offline: the printer's latest run, when it is of this
  290. archive and was ended that way. A printer prints one thing at a time, so
  291. any later run there, of any file, means that print is long over."""
  292. latest = await db.scalar(
  293. select(PrintLogEntry)
  294. .where(PrintLogEntry.printer_id == printer_id)
  295. .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
  296. .limit(1)
  297. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  298. )
  299. if latest is not None and latest.archive_id == archive_id and latest.ended_offline:
  300. return latest
  301. return None
  302. async def _open_run_on(db: AsyncSession, archive_id: int, printer_id: int) -> PrintLogEntry | None:
  303. """The run of the archive open on the printer: there is one at most, a
  304. printer prints one thing at a time (``open_run``)."""
  305. return await db.scalar(
  306. select(PrintLogEntry)
  307. .where(
  308. PrintLogEntry.archive_id == archive_id,
  309. PrintLogEntry.printer_id == printer_id,
  310. PrintLogEntry.status == RUN_OPEN,
  311. )
  312. .order_by(PrintLogEntry.id.desc())
  313. .limit(1)
  314. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  315. )
  316. async def open_run_on_printer(
  317. db: AsyncSession, printer_id: int, *, subtask_id: str | None = None
  318. ) -> PrintLogEntry | None:
  319. """The run open on the printer: the print it is running now, which
  320. Bambuddy saw start. One at most (``open_run``). With ``subtask_id``, only
  321. a run of that job."""
  322. query = select(PrintLogEntry).where(PrintLogEntry.printer_id == printer_id, PrintLogEntry.status == RUN_OPEN)
  323. if subtask_id is not None:
  324. query = query.where(PrintLogEntry.subtask_id == subtask_id)
  325. return await db.scalar(
  326. query.order_by(PrintLogEntry.id.desc()).limit(1).execution_options(**{INCLUDE_OPEN_RUNS: True})
  327. )
  328. async def discard_open_runs(db: AsyncSession, printer_id: int) -> None:
  329. """Drop the runs left open on the printer by prints that ended without
  330. their end reaching a run: cancelled at the next start as stale, or whose
  331. archive was deleted. As before runs were opened at the start, such a
  332. print leaves no run. Does not commit."""
  333. await db.execute(
  334. delete(PrintLogEntry)
  335. .where(PrintLogEntry.printer_id == printer_id, PrintLogEntry.status == RUN_OPEN)
  336. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  337. )
  338. async def open_run(
  339. db: AsyncSession,
  340. archive,
  341. *,
  342. printer_id: int,
  343. printer_name: str | None = None,
  344. plate_id: int | None = None,
  345. subtask_id: str | None = None,
  346. ) -> PrintLogEntry | None:
  347. """The run of the print that just started on ``archive``, written now with
  348. the open status; its end fills it in (``write_log_entry``).
  349. Idempotent: a print start seen again (a restart resuming the print, a
  350. repeated report) finds the run it opened: by the printer's job
  351. (``subtask_id``) when it has one, else keyed as its end will key it, by
  352. archive, printer and start time. The queue item running it, if any, gives
  353. the dispatch's own billing run and cost centre; a run without one gets its
  354. own billing run id, so each print of the archive is charged as its own.
  355. Does not commit.
  356. """
  357. from backend.app.models.print_queue import PrintQueueItem
  358. if archive.started_at is None:
  359. return None
  360. if subtask_id:
  361. # The same job seen again: its run, though another plate of the
  362. # archive may have started on another printer since and moved the
  363. # archive's start.
  364. running = await open_run_on_printer(db, printer_id, subtask_id=subtask_id)
  365. if running is not None and running.archive_id == archive.id:
  366. return running
  367. # Only the run of this very start: one still open on the printer from an
  368. # earlier start of the archive is of a print that ended unseen.
  369. entry = await _entry_of_this_run(db, archive.id, printer_id, archive.started_at, exact=True)
  370. if entry is not None:
  371. return entry
  372. # A printer prints one thing at a time: a run still open on it is of a
  373. # print that ended without its end reaching it.
  374. await discard_open_runs(db, printer_id)
  375. item = await db.scalar(
  376. select(PrintQueueItem)
  377. .where(
  378. PrintQueueItem.archive_id == archive.id,
  379. PrintQueueItem.printer_id == printer_id,
  380. PrintQueueItem.status == "printing",
  381. )
  382. .order_by(PrintQueueItem.id.desc())
  383. .limit(1)
  384. )
  385. entry = PrintLogEntry(
  386. status=RUN_OPEN,
  387. archive_id=archive.id,
  388. queue_item_id=item.id if item is not None else None,
  389. print_name=archive.print_name,
  390. plate_id=plate_id if plate_id is not None else archive.plate_id,
  391. printer_name=printer_name,
  392. printer_id=printer_id,
  393. started_at=archive.started_at,
  394. filament_type=archive.filament_type,
  395. filament_color=archive.filament_color,
  396. thumbnail_path=archive.thumbnail_path,
  397. created_by_id=archive.created_by_id,
  398. subtask_id=archive.subtask_id,
  399. # Not the archive's: that is the latest dispatch's, or the id an
  400. # earlier print of the archive was charged under.
  401. billing_run_id=(item.billing_run_id if item is not None else None) or str(uuid.uuid4()),
  402. cost_center_id=(item.cost_center_id if item is not None else None) or archive.cost_center_id,
  403. confirm_requested=archive.confirm_requested,
  404. quantity=archive.quantity,
  405. # Its own finish photos from here on (``owns_media``).
  406. photos=[],
  407. )
  408. db.add(entry)
  409. await db.flush()
  410. return entry
  411. async def mirror_energy_start(db: AsyncSession, archive, *, printer_id: int) -> None:
  412. """Copy the archive's energy start reading onto the run it was taken for.
  413. Does not commit."""
  414. entry = await _entry_of_this_run(db, archive.id, printer_id, archive.started_at)
  415. if entry is None or entry.status != RUN_OPEN:
  416. return
  417. for name in _ENERGY_START:
  418. setattr(entry, name, getattr(archive, name))
  419. _ENERGY_START = ("energy_start_kwh", "energy_start_at", "energy_start_plug_id", "energy_start_price")
  420. def _reading_taken_for(archive, entry: PrintLogEntry) -> bool:
  421. """Whether the archive's energy start reading was taken for this run: the
  422. archive's latest start is this run's (another plate of it started since
  423. takes the reading), and it was read at or after it (an earlier print's is
  424. older). A reading without its time (taken before #1251) goes by the
  425. start alone."""
  426. if archive.energy_start_kwh is None:
  427. return False
  428. if entry.started_at is None or archive.started_at is None:
  429. return False
  430. if _utc(archive.started_at) != _utc(entry.started_at):
  431. return False
  432. return archive.energy_start_at is None or _utc(archive.energy_start_at) >= _utc(entry.started_at)
  433. async def run_needs_energy_start(db: AsyncSession, archive, *, printer_id: int) -> bool:
  434. """Whether the print running on the archive still lacks its energy start
  435. reading, at a resume. Its run's, or the archive's taken for it (copied
  436. onto the run then), counts; the archive's of an earlier print does not.
  437. Without a run, the archive's, as before. Does not commit."""
  438. entry = await _entry_of_this_run(db, archive.id, printer_id, archive.started_at)
  439. if entry is None or entry.status != RUN_OPEN:
  440. return archive.energy_start_kwh is None
  441. if entry.energy_start_kwh is not None:
  442. return False
  443. if _reading_taken_for(archive, entry):
  444. for name in _ENERGY_START:
  445. setattr(entry, name, getattr(archive, name))
  446. return False
  447. return True
  448. def energy_start_of(entry: PrintLogEntry | None, archive) -> SimpleNamespace | None:
  449. """The energy start reading of the run that ended: its own. Without one,
  450. the archive's only when taken for this run (a run written only at its
  451. end, or none written): another plate of the archive may have started on
  452. another printer since and taken it."""
  453. source = None
  454. if entry is not None and entry.energy_start_kwh is not None:
  455. source = entry
  456. elif entry is None or _reading_taken_for(archive, entry):
  457. source = archive
  458. if source is None or source.energy_start_kwh is None:
  459. return None
  460. return SimpleNamespace(
  461. kwh=source.energy_start_kwh,
  462. at=source.energy_start_at,
  463. plug_id=source.energy_start_plug_id,
  464. price=source.energy_start_price,
  465. )
  466. async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -> None:
  467. """Copy a run's wear cost onto its archive when it is the archive's first run.
  468. Same rule as energy (#1378): the archive shows its first print, and a
  469. reprint's wear stays on its own log entry (#694). ``entry`` must already be
  470. flushed, so it counts as one of the archive's runs.
  471. """
  472. if entry.wear_cost is None:
  473. return
  474. runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
  475. if (runs or 0) <= 1:
  476. archive.wear_cost = entry.wear_cost
  477. async def run_of(db: AsyncSession, archive_id: int, run_id: int) -> PrintLogEntry | None:
  478. """The archive's run ``run_id``, open or not; None when it is not one."""
  479. return await db.scalar(
  480. select(PrintLogEntry)
  481. .where(PrintLogEntry.id == run_id, PrintLogEntry.archive_id == archive_id)
  482. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  483. )
  484. async def newest_run(db: AsyncSession, archive_id: int) -> PrintLogEntry | None:
  485. """The archive's run started last, a printing one included: whose
  486. timelapse and photos the archive shows. A print starting clears the
  487. archive's video, so an earlier run's that lands after must not take it."""
  488. return await db.scalar(
  489. select(PrintLogEntry)
  490. .where(PrintLogEntry.archive_id == archive_id)
  491. .order_by(PrintLogEntry.started_at.desc().nulls_last(), PrintLogEntry.id.desc())
  492. .limit(1)
  493. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  494. )
  495. async def is_newest_run(db: AsyncSession, run: PrintLogEntry) -> bool:
  496. """Whether the archive shows ``run``'s timelapse and photos."""
  497. if run.archive_id is None:
  498. return False
  499. newest = await newest_run(db, run.archive_id)
  500. return newest is not None and newest.id == run.id
  501. async def runs_with_timelapse(db: AsyncSession, archive_id: int, relpath: str) -> list[PrintLogEntry]:
  502. """The archive's runs whose video is the file at ``relpath``."""
  503. return list(
  504. await db.scalars(
  505. select(PrintLogEntry)
  506. .where(PrintLogEntry.archive_id == archive_id, PrintLogEntry.timelapse_path == relpath)
  507. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  508. )
  509. )
  510. async def claimed_timelapse_stems(
  511. db: AsyncSession, printer_id: int | None, *, archive_id: int, run_id: int | None
  512. ) -> set[str]:
  513. """Video filenames already attached to another print of this printer (#2704).
  514. Lets the baseline diff drop a previous print's late-landing video from the
  515. candidate list without ordering the candidates: ordering could only be
  516. done on mtime or the filename timestamp, and both come from a clock the
  517. printer can't sync in LAN-only mode. ``attach_timelapse`` stores the video
  518. under the printer's own filename and the MP4 conversion keeps the stem, so
  519. the stem of a ``timelapse_path`` is what was claimed.
  520. Another print is another archive, or another run, also of this archive
  521. (another plate of it): ``run_id`` is the print looking. Without one, every
  522. run of the archive is its own.
  523. """
  524. from backend.app.models.archive import PrintArchive
  525. if printer_id is None:
  526. return set()
  527. archives = await db.scalars(
  528. select(PrintArchive.timelapse_path).where(
  529. PrintArchive.printer_id == printer_id,
  530. PrintArchive.id != archive_id,
  531. PrintArchive.timelapse_path.is_not(None),
  532. )
  533. )
  534. runs = select(PrintLogEntry.timelapse_path).where(
  535. PrintLogEntry.printer_id == printer_id, PrintLogEntry.timelapse_path.is_not(None)
  536. )
  537. if run_id is not None:
  538. runs = runs.where(PrintLogEntry.id != run_id)
  539. else:
  540. runs = runs.where(or_(PrintLogEntry.archive_id.is_(None), PrintLogEntry.archive_id != archive_id))
  541. run_paths = await db.scalars(runs.execution_options(**{INCLUDE_OPEN_RUNS: True}))
  542. return {Path(p).stem for p in [*archives.all(), *run_paths.all()] if p}
  543. def owns_media(run: PrintLogEntry) -> bool:
  544. """Whether ``run`` keeps its own timelapse: opened at its start with a
  545. photo list, or given a video or baseline of its own. A run logged before
  546. runs kept them shares the archive's. Its photos are its own only once it
  547. has a list (``run.photos`` not None)."""
  548. return run.photos is not None or run.timelapse_path is not None or run.timelapse_baseline is not None
  549. async def superseded(db: AsyncSession, run: PrintLogEntry) -> bool:
  550. """Whether a later print of the run's archive has started on its printer,
  551. or of its plate: a video landing now is not the run's to take. The one on
  552. its printer is that print's own, and a plate keeps one video, the latest
  553. print's (a reprint replaces it). Another plate on another printer leaves
  554. the run's video to it."""
  555. if run.archive_id is None or run.started_at is None:
  556. return False
  557. same = [PrintLogEntry.printer_id == run.printer_id] if run.printer_id is not None else []
  558. if run.plate_id is not None:
  559. same.append(PrintLogEntry.plate_id == run.plate_id)
  560. if not same:
  561. return False
  562. later = await db.scalar(
  563. select(PrintLogEntry.id)
  564. .where(
  565. PrintLogEntry.archive_id == run.archive_id,
  566. PrintLogEntry.id != run.id,
  567. PrintLogEntry.started_at > run.started_at,
  568. or_(*same),
  569. )
  570. .limit(1)
  571. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  572. )
  573. return later is not None
  574. def add_run_photo(run: PrintLogEntry | None, filename: str, *, first: bool = False) -> None:
  575. """Put a finish photo on its run's list too: last, or ``first``."""
  576. if run is None:
  577. return
  578. photos = [p for p in (run.photos or []) if p != filename]
  579. run.photos = [filename, *photos] if first else [*photos, filename]