print_log.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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 datetime import datetime, timezone
  6. from sqlalchemy import func, or_, select
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from backend.app.models.print_log import PrintLogEntry
  9. logger = logging.getLogger(__name__)
  10. def wear_cost_for_run(duration_seconds: int | None, wear_cost_per_hour: float | None) -> float | None:
  11. """Printer wear for one run: its duration at the printer's hourly rate (#694).
  12. None when the printer has no rate or the run has no measured duration (a
  13. reconciled run stores 0 because its real end time is unknown).
  14. """
  15. if not isinstance(wear_cost_per_hour, int | float) or not isinstance(duration_seconds, int):
  16. return None
  17. if wear_cost_per_hour <= 0 or duration_seconds <= 0:
  18. return None
  19. return round(duration_seconds / 3600 * wear_cost_per_hour, 3)
  20. async def write_log_entry(
  21. db: AsyncSession,
  22. *,
  23. status: str,
  24. archive_id: int | None = None,
  25. queue_item_id: int | None = None,
  26. print_name: str | None = None,
  27. printer_name: str | None = None,
  28. printer_id: int | None = None,
  29. started_at: datetime | None = None,
  30. completed_at: datetime | None = None,
  31. filament_type: str | None = None,
  32. filament_color: str | None = None,
  33. filament_used_grams: float | None = None,
  34. cost: float | None = None,
  35. energy_kwh: float | None = None,
  36. energy_cost: float | None = None,
  37. wear_cost_per_hour: float | None = None,
  38. failure_reason: str | None = None,
  39. thumbnail_path: str | None = None,
  40. created_by_id: int | None = None,
  41. created_by_username: str | None = None,
  42. reconciled: bool = False,
  43. ) -> PrintLogEntry:
  44. """Write a print log entry.
  45. ``reconciled`` marks a synthetic completion written when a stale
  46. ``status="printing"`` archive is closed out at reconnect. Its real end time
  47. is unknown — the print stopped somewhere during the disconnect and
  48. ``completed_at`` is only the reconnect moment — so ``completed_at -
  49. started_at`` would bank the entire disconnect gap as print time, adding
  50. hundreds of fictitious hours across a farm of stale rows (#2592). For those
  51. entries we store an explicit ``0`` ("no measured runtime") rather than a
  52. fabricated duration; the stats total trusts a stored 0 instead of
  53. recomputing from the stale timestamps.
  54. """
  55. if reconciled:
  56. duration: int | None = 0
  57. elif started_at and completed_at:
  58. duration = int((_utc(completed_at) - _utc(started_at)).total_seconds())
  59. else:
  60. duration = None
  61. fields = {
  62. "archive_id": archive_id,
  63. "queue_item_id": queue_item_id,
  64. "print_name": print_name,
  65. "printer_name": printer_name,
  66. "printer_id": printer_id,
  67. "status": status,
  68. "started_at": started_at,
  69. "completed_at": completed_at,
  70. "duration_seconds": duration,
  71. "filament_type": filament_type,
  72. "filament_color": filament_color,
  73. "filament_used_grams": filament_used_grams,
  74. "cost": cost,
  75. "energy_kwh": energy_kwh,
  76. "energy_cost": energy_cost,
  77. "wear_cost": wear_cost_for_run(duration, wear_cost_per_hour),
  78. "failure_reason": failure_reason,
  79. "thumbnail_path": thumbnail_path,
  80. "created_by_id": created_by_id,
  81. "created_by_username": created_by_username,
  82. }
  83. entry = await _entry_of_this_run(db, archive_id, printer_id, started_at)
  84. if entry is None:
  85. entry = PrintLogEntry(**fields)
  86. db.add(entry)
  87. elif entry.status != status:
  88. # The run ended differently from how it was first logged: a print
  89. # stopped while its printer was offline that finished after all. The
  90. # real outcome replaces the stop rather than counting the run twice.
  91. for name, value in fields.items():
  92. if value is None and name in _KEPT_WHEN_NOT_REPORTED:
  93. continue
  94. setattr(entry, name, value)
  95. # The same outcome reported again (a repeated completion after a reconnect)
  96. # changes nothing: the repeat no longer has the run's tracked usage, and the
  97. # entry may have been edited or given its energy since.
  98. await db.flush()
  99. return entry
  100. async def mirror_archive_cost(db: AsyncSession, archive, previous_cost: float | None) -> None:
  101. """Give the archive's newest run the archive's new cost, if it had the old one.
  102. Statistics, project totals and batch sums read a run's cost. A completed
  103. run without its own spool price took the archive's (or none), so a new
  104. archive cost belongs to it too. A run its spools priced keeps that price,
  105. and a failed or cancelled run keeps its own: it printed only part of it.
  106. """
  107. entry = await db.scalar(
  108. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id).order_by(PrintLogEntry.id.desc()).limit(1)
  109. )
  110. if entry is not None and entry.status == "completed" and entry.cost in (None, previous_cost):
  111. entry.cost = archive.cost
  112. async def other_runs_of(db: AsyncSession, archive) -> int:
  113. """How many log entries *archive* has from runs other than its current one.
  114. The archive card keeps its first run's figures; a run whose own entry is
  115. already written (its stop, logged while the printer was offline) is still
  116. the first run.
  117. """
  118. # Without a start time or printer on the archive, ``!= None`` reads as
  119. # ``IS NOT NULL``: every entry is another run's.
  120. query = select(func.count(PrintLogEntry.id)).where(
  121. PrintLogEntry.archive_id == archive.id,
  122. or_(
  123. PrintLogEntry.started_at.is_(None),
  124. PrintLogEntry.started_at != archive.started_at,
  125. PrintLogEntry.printer_id.is_(None),
  126. PrintLogEntry.printer_id != archive.printer_id,
  127. ),
  128. )
  129. return await db.scalar(query) or 0
  130. # Who and what the run was, which a repeated report may no longer know: a
  131. # completion that finds the queue item already closed reports none. Its
  132. # outcome (status, reason, grams, cost) is replaced as reported.
  133. _KEPT_WHEN_NOT_REPORTED = frozenset(
  134. {"queue_item_id", "print_name", "printer_name", "thumbnail_path", "created_by_id", "created_by_username"}
  135. )
  136. def _utc(moment: datetime) -> datetime:
  137. """Naive UTC, as the columns store it; an aware time is converted first."""
  138. return moment.astimezone(timezone.utc).replace(tzinfo=None) if moment.tzinfo else moment
  139. async def _entry_of_this_run(
  140. db: AsyncSession,
  141. archive_id: int | None,
  142. printer_id: int | None,
  143. started_at: datetime | None,
  144. ) -> PrintLogEntry | None:
  145. """The entry already written for this run, if any.
  146. A run is its archive, printer and start time together: plates of one
  147. archive printing on two printers share the archive row (and can share its
  148. start time), and a reprint starts at a new time. The queue item is not
  149. part of it: a repeated completion finds the item already closed and
  150. reports none. Without an archive, printer or start time a run can't be
  151. told apart, so nothing is matched.
  152. """
  153. if archive_id is None or printer_id is None or started_at is None:
  154. return None
  155. query = select(PrintLogEntry).where(
  156. PrintLogEntry.archive_id == archive_id,
  157. PrintLogEntry.printer_id == printer_id,
  158. PrintLogEntry.started_at == started_at,
  159. )
  160. return (await db.scalars(query.order_by(PrintLogEntry.id.desc()).limit(1))).first()
  161. async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -> None:
  162. """Copy a run's wear cost onto its archive when it is the archive's first run.
  163. Same rule as energy (#1378): the archive shows its first print, and a
  164. reprint's wear stays on its own log entry (#694). ``entry`` must already be
  165. flushed, so it counts as one of the archive's runs.
  166. """
  167. if entry.wear_cost is None:
  168. return
  169. runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
  170. if (runs or 0) <= 1:
  171. archive.wear_cost = entry.wear_cost