print_log.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  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
  6. from sqlalchemy import func, 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((completed_at - started_at).total_seconds())
  59. else:
  60. duration = None
  61. entry = PrintLogEntry(
  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. db.add(entry)
  84. await db.flush()
  85. return entry
  86. async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -> None:
  87. """Copy a run's wear cost onto its archive when it is the archive's first run.
  88. Same rule as energy (#1378): the archive shows its first print, and a
  89. reprint's wear stays on its own log entry (#694). ``entry`` must already be
  90. flushed, so it counts as one of the archive's runs.
  91. """
  92. if entry.wear_cost is None:
  93. return
  94. runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
  95. if (runs or 0) <= 1:
  96. archive.wear_cost = entry.wear_cost