print_log.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  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. from collections.abc import Set as AbstractSet
  6. from datetime import datetime, timezone
  7. from sqlalchemy import delete, func, or_, select
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
  10. from backend.app.utils.local_time import utcnow_naive
  11. logger = logging.getLogger(__name__)
  12. def wear_cost_for_run(duration_seconds: int | None, wear_cost_per_hour: float | None) -> float | None:
  13. """Printer wear for one run: its duration at the printer's hourly rate (#694).
  14. None when the printer has no rate or the run has no measured duration (a
  15. reconciled run stores 0 because its real end time is unknown).
  16. """
  17. if not isinstance(wear_cost_per_hour, int | float) or not isinstance(duration_seconds, int):
  18. return None
  19. if wear_cost_per_hour <= 0 or duration_seconds <= 0:
  20. return None
  21. return round(duration_seconds / 3600 * wear_cost_per_hour, 3)
  22. async def write_log_entry(
  23. db: AsyncSession,
  24. *,
  25. status: str,
  26. archive_id: int | None = None,
  27. queue_item_id: int | None = None,
  28. print_name: str | None = None,
  29. plate_id: int | None = None,
  30. printer_name: str | None = None,
  31. printer_id: int | None = None,
  32. started_at: datetime | None = None,
  33. completed_at: datetime | None = None,
  34. filament_type: str | None = None,
  35. filament_color: str | None = None,
  36. filament_used_grams: float | None = None,
  37. cost: float | None = None,
  38. energy_kwh: float | None = None,
  39. energy_cost: float | None = None,
  40. wear_cost_per_hour: float | None = None,
  41. failure_reason: str | None = None,
  42. thumbnail_path: str | None = None,
  43. created_by_id: int | None = None,
  44. created_by_username: str | None = None,
  45. reconciled: bool = False,
  46. duration_unmeasured: bool = False,
  47. ) -> PrintLogEntry:
  48. """Write a print log entry.
  49. ``reconciled`` marks a synthetic completion written when a stale
  50. ``status="printing"`` archive is closed out at reconnect. Its real end time
  51. is unknown — the print stopped somewhere during the disconnect and
  52. ``completed_at`` is only the reconnect moment — so ``completed_at -
  53. started_at`` would bank the entire disconnect gap as print time, adding
  54. hundreds of fictitious hours across a farm of stale rows (#2592). For those
  55. entries we store an explicit ``0`` ("no measured runtime") rather than a
  56. fabricated duration; the stats total trusts a stored 0 instead of
  57. recomputing from the stale timestamps.
  58. ``duration_unmeasured`` marks a run closed at reconnect, whose times were
  59. not measured (#3310): ``completed_at`` is its start plus the slicer's
  60. estimate, or the reconnect. The time accuracy leaves it out.
  61. """
  62. entry = await _entry_of_this_run(db, archive_id, printer_id, started_at)
  63. filling_open_run = entry is not None and entry.status == RUN_OPEN
  64. if entry is not None and filling_open_run and entry.started_at is not None:
  65. # The run's own start. The archive's is its latest run's: another
  66. # plate of it may have started on another printer since.
  67. started_at = entry.started_at
  68. if reconciled:
  69. duration: int | None = 0
  70. elif started_at and completed_at:
  71. duration = int((_utc(completed_at) - _utc(started_at)).total_seconds())
  72. else:
  73. duration = None
  74. fields = {
  75. "archive_id": archive_id,
  76. "queue_item_id": queue_item_id,
  77. "print_name": print_name,
  78. "plate_id": plate_id,
  79. "printer_name": printer_name,
  80. "printer_id": printer_id,
  81. "status": status,
  82. "started_at": started_at,
  83. "completed_at": completed_at,
  84. "duration_seconds": duration,
  85. "filament_type": filament_type,
  86. "filament_color": filament_color,
  87. "filament_used_grams": filament_used_grams,
  88. "cost": cost,
  89. "energy_kwh": energy_kwh,
  90. "energy_cost": energy_cost,
  91. "wear_cost": wear_cost_for_run(duration, wear_cost_per_hour),
  92. "duration_unmeasured": True if duration_unmeasured else None,
  93. "failure_reason": failure_reason,
  94. "thumbnail_path": thumbnail_path,
  95. "created_by_id": created_by_id,
  96. "created_by_username": created_by_username,
  97. }
  98. if filling_open_run:
  99. # When the run was logged, as a run written at its end always was:
  100. # the date ranges of statistics and the print log go by it.
  101. fields["created_at"] = utcnow_naive()
  102. if entry is None:
  103. if archive_id is not None and started_at is not None and status != RUN_OPEN:
  104. # Shadow check while the run's start moves over (archive redesign):
  105. # every print start that binds an archive opens its run, so a run
  106. # ending without one is a start path that does not yet.
  107. logger.info("[RUN] Archive %s: no run was opened when the print started; written at its end", archive_id)
  108. entry = PrintLogEntry(**fields)
  109. db.add(entry)
  110. elif entry.status != status:
  111. if entry.status == RUN_OPEN and None not in (entry.plate_id, plate_id) and entry.plate_id != plate_id:
  112. logger.warning(
  113. "[RUN] Archive %s: the run opened for plate %s ends as plate %s", archive_id, entry.plate_id, plate_id
  114. )
  115. # The run ended differently from how it was first logged: a print
  116. # stopped while its printer was offline that finished after all. The
  117. # real outcome replaces the stop rather than counting the run twice.
  118. for name, value in fields.items():
  119. if value is None and name in _KEPT_WHEN_NOT_REPORTED:
  120. continue
  121. setattr(entry, name, value)
  122. # The same outcome reported again (a repeated completion after a reconnect)
  123. # changes nothing: the repeat no longer has the run's tracked usage, and the
  124. # entry may have been edited or given its energy since.
  125. await db.flush()
  126. return entry
  127. async def mirror_archive_cost(
  128. db: AsyncSession, archive, previous_cost: float | None, *, skip_runs: AbstractSet[int] = frozenset()
  129. ) -> None:
  130. """Give the archive's newest run the archive's new cost, if it had the old one.
  131. Statistics, project totals and batch sums read a run's cost. A completed
  132. run without its own spool price took the archive's (or none), so a new
  133. archive cost belongs to it too. A run its spools priced keeps that price,
  134. and a failed or cancelled run keeps its own: it printed only part of it.
  135. A run in ``skip_runs`` was given its own new cost already.
  136. """
  137. entry = await db.scalar(
  138. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
  139. )
  140. if entry is None or entry.id in skip_runs:
  141. return
  142. if entry.status == "completed" and entry.cost in (None, previous_cost):
  143. entry.cost = archive.cost
  144. async def other_runs_of(db: AsyncSession, archive) -> int:
  145. """How many log entries *archive* has from runs other than its current one.
  146. The archive card keeps its first run's figures; a run whose own entry is
  147. already written (its stop, logged while the printer was offline) is still
  148. the first run.
  149. """
  150. # Without a start time or printer on the archive, ``!= None`` reads as
  151. # ``IS NOT NULL``: every entry is another run's.
  152. query = select(func.count(PrintLogEntry.id)).where(
  153. PrintLogEntry.archive_id == archive.id,
  154. or_(
  155. PrintLogEntry.started_at.is_(None),
  156. PrintLogEntry.started_at != archive.started_at,
  157. PrintLogEntry.printer_id.is_(None),
  158. PrintLogEntry.printer_id != archive.printer_id,
  159. ),
  160. )
  161. return await db.scalar(query) or 0
  162. # Who and what the run was, which a repeated report may no longer know: a
  163. # completion that finds the queue item already closed reports none. Its
  164. # outcome (status, reason, grams, cost) is replaced as reported.
  165. _KEPT_WHEN_NOT_REPORTED = frozenset(
  166. {
  167. "queue_item_id",
  168. "print_name",
  169. "plate_id",
  170. "printer_name",
  171. "thumbnail_path",
  172. "created_by_id",
  173. "created_by_username",
  174. }
  175. )
  176. def _utc(moment: datetime) -> datetime:
  177. """Naive UTC, as the columns store it; an aware time is converted first."""
  178. return moment.astimezone(timezone.utc).replace(tzinfo=None) if moment.tzinfo else moment
  179. async def _entry_of_this_run(
  180. db: AsyncSession,
  181. archive_id: int | None,
  182. printer_id: int | None,
  183. started_at: datetime | None,
  184. *,
  185. exact: bool = False,
  186. ) -> PrintLogEntry | None:
  187. """The entry already written for this run, if any.
  188. A run is its archive, printer and start time together: plates of one
  189. archive printing on two printers share the archive row (and can share its
  190. start time), and a reprint starts at a new time. The queue item is not
  191. part of it: a repeated completion finds the item already closed and
  192. reports none. Without an archive or printer a run can't be told apart,
  193. so nothing is matched.
  194. A run opened at its start is also found as the archive's run open on the
  195. printer, unless ``exact``: the archive's start is its latest run's, and
  196. another plate of it may have started on another printer since.
  197. """
  198. if archive_id is None or printer_id is None:
  199. return None
  200. if started_at is None:
  201. return None if exact else await _open_run_on(db, archive_id, printer_id)
  202. query = (
  203. select(PrintLogEntry)
  204. .where(
  205. PrintLogEntry.archive_id == archive_id,
  206. PrintLogEntry.printer_id == printer_id,
  207. PrintLogEntry.started_at == started_at,
  208. )
  209. # The run opened when the print started is the one this fills in.
  210. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  211. )
  212. entry = (await db.scalars(query.order_by(PrintLogEntry.id.desc()).limit(1))).first()
  213. # The archive's start is its latest run's. With another plate of it
  214. # started on another printer since, this printer's run is found as the
  215. # one open there.
  216. if entry is not None or exact:
  217. return entry
  218. return await _open_run_on(db, archive_id, printer_id)
  219. async def _open_run_on(db: AsyncSession, archive_id: int, printer_id: int) -> PrintLogEntry | None:
  220. """The run of the archive open on the printer: there is one at most, a
  221. printer prints one thing at a time (``open_run``)."""
  222. return await db.scalar(
  223. select(PrintLogEntry)
  224. .where(
  225. PrintLogEntry.archive_id == archive_id,
  226. PrintLogEntry.printer_id == printer_id,
  227. PrintLogEntry.status == RUN_OPEN,
  228. )
  229. .order_by(PrintLogEntry.id.desc())
  230. .limit(1)
  231. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  232. )
  233. async def open_run_on_printer(
  234. db: AsyncSession, printer_id: int, *, subtask_id: str | None = None
  235. ) -> PrintLogEntry | None:
  236. """The run open on the printer: the print it is running now, which
  237. Bambuddy saw start. One at most (``open_run``). With ``subtask_id``, only
  238. a run of that job."""
  239. query = select(PrintLogEntry).where(PrintLogEntry.printer_id == printer_id, PrintLogEntry.status == RUN_OPEN)
  240. if subtask_id is not None:
  241. query = query.where(PrintLogEntry.subtask_id == subtask_id)
  242. return await db.scalar(
  243. query.order_by(PrintLogEntry.id.desc()).limit(1).execution_options(**{INCLUDE_OPEN_RUNS: True})
  244. )
  245. async def discard_open_runs(db: AsyncSession, printer_id: int) -> None:
  246. """Drop the runs left open on the printer by prints that ended without
  247. their end reaching a run: cancelled at the next start as stale, or whose
  248. archive was deleted. As before runs were opened at the start, such a
  249. print leaves no run. Does not commit."""
  250. await db.execute(
  251. delete(PrintLogEntry)
  252. .where(PrintLogEntry.printer_id == printer_id, PrintLogEntry.status == RUN_OPEN)
  253. .execution_options(**{INCLUDE_OPEN_RUNS: True})
  254. )
  255. async def open_run(
  256. db: AsyncSession,
  257. archive,
  258. *,
  259. printer_id: int,
  260. printer_name: str | None = None,
  261. plate_id: int | None = None,
  262. ) -> PrintLogEntry | None:
  263. """The run of the print that just started on ``archive``, written now with
  264. the open status; its end fills it in (``write_log_entry``).
  265. Idempotent: a print start seen again (a restart resuming the print, a
  266. repeated report) finds the run it opened, keyed as its end will key it, by
  267. archive, printer and start time. The queue item running it, if any, gives
  268. the dispatch's own billing run and cost centre. Does not commit.
  269. """
  270. from backend.app.models.print_queue import PrintQueueItem
  271. if archive.started_at is None:
  272. return None
  273. # Only the run of this very start: one still open on the printer from an
  274. # earlier start of the archive is of a print that ended unseen.
  275. entry = await _entry_of_this_run(db, archive.id, printer_id, archive.started_at, exact=True)
  276. if entry is not None:
  277. return entry
  278. # A printer prints one thing at a time: a run still open on it is of a
  279. # print that ended without its end reaching it.
  280. await discard_open_runs(db, printer_id)
  281. item = await db.scalar(
  282. select(PrintQueueItem)
  283. .where(
  284. PrintQueueItem.archive_id == archive.id,
  285. PrintQueueItem.printer_id == printer_id,
  286. PrintQueueItem.status == "printing",
  287. )
  288. .order_by(PrintQueueItem.id.desc())
  289. .limit(1)
  290. )
  291. entry = PrintLogEntry(
  292. status=RUN_OPEN,
  293. archive_id=archive.id,
  294. queue_item_id=item.id if item is not None else None,
  295. print_name=archive.print_name,
  296. plate_id=plate_id if plate_id is not None else archive.plate_id,
  297. printer_name=printer_name,
  298. printer_id=printer_id,
  299. started_at=archive.started_at,
  300. filament_type=archive.filament_type,
  301. filament_color=archive.filament_color,
  302. thumbnail_path=archive.thumbnail_path,
  303. created_by_id=archive.created_by_id,
  304. subtask_id=archive.subtask_id,
  305. billing_run_id=(item.billing_run_id if item is not None else None) or archive.billing_run_id,
  306. cost_center_id=(item.cost_center_id if item is not None else None) or archive.cost_center_id,
  307. confirm_requested=archive.confirm_requested,
  308. quantity=archive.quantity,
  309. )
  310. db.add(entry)
  311. await db.flush()
  312. return entry
  313. async def mirror_energy_start(db: AsyncSession, archive, *, printer_id: int) -> None:
  314. """Copy the archive's energy start reading onto the run it was taken for.
  315. Does not commit."""
  316. entry = await _entry_of_this_run(db, archive.id, printer_id, archive.started_at)
  317. if entry is None or entry.status != RUN_OPEN:
  318. return
  319. entry.energy_start_kwh = archive.energy_start_kwh
  320. entry.energy_start_at = archive.energy_start_at
  321. entry.energy_start_plug_id = archive.energy_start_plug_id
  322. entry.energy_start_price = archive.energy_start_price
  323. async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -> None:
  324. """Copy a run's wear cost onto its archive when it is the archive's first run.
  325. Same rule as energy (#1378): the archive shows its first print, and a
  326. reprint's wear stays on its own log entry (#694). ``entry`` must already be
  327. flushed, so it counts as one of the archive's runs.
  328. """
  329. if entry.wear_cost is None:
  330. return
  331. runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
  332. if (runs or 0) <= 1:
  333. archive.wear_cost = entry.wear_cost