print_log.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. from datetime import datetime
  2. from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, event, func
  3. from sqlalchemy.orm import Mapped, ORMExecuteState, Session, mapped_column, with_loader_criteria
  4. from backend.app.core.database import Base
  5. # The status of a run that has started and not ended. Such a run is written
  6. # when its print starts (archive redesign) and is not a print that happened
  7. # yet: statistics, the print log and everything else that reads runs leave it
  8. # out, without each having to say so (see _hide_open_runs below).
  9. RUN_OPEN = "printing"
  10. # Execution option for the few that need open runs too: the print's own start
  11. # and end, which find and fill in the run.
  12. INCLUDE_OPEN_RUNS = "include_open_runs"
  13. class PrintLogEntry(Base):
  14. """Independent print log entry. Written when print events occur.
  15. This is a separate table from archives/queue — clearing the log
  16. never touches archives or queue items.
  17. archive_id is a nullable FK so log entries survive archive deletion (ON
  18. DELETE SET NULL). Aggregating runs per archive — for the per-archive
  19. "Print Log" view and for statistics that should not double-count
  20. overwritten archives (#1378) — is done via WHERE archive_id = X.
  21. """
  22. __tablename__ = "print_log_entries"
  23. id: Mapped[int] = mapped_column(primary_key=True)
  24. archive_id: Mapped[int | None] = mapped_column(
  25. ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
  26. )
  27. # Which queue item produced this run, when one did. Printer-initiated
  28. # prints have none. Batch cost/energy roll-up joins on this (#342): the
  29. # archive alone can't attribute a run to an order because several orders
  30. # — and plain reprints — share one archive.
  31. queue_item_id: Mapped[int | None] = mapped_column(
  32. ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
  33. )
  34. print_name: Mapped[str | None] = mapped_column(String(255))
  35. # Which plate of a multi-plate 3MF this run printed (1-based). The archive
  36. # holds one plate, its latest run's, and every run of a file shares that
  37. # archive, so a run's own plate is kept here. NULL when not known.
  38. plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
  39. printer_name: Mapped[str | None] = mapped_column(String(255))
  40. printer_id: Mapped[int | None] = mapped_column(Integer)
  41. status: Mapped[str] = mapped_column(String(20)) # completed, failed, stopped, cancelled, skipped
  42. started_at: Mapped[datetime | None] = mapped_column(DateTime)
  43. completed_at: Mapped[datetime | None] = mapped_column(DateTime)
  44. duration_seconds: Mapped[int | None] = mapped_column(Integer)
  45. filament_type: Mapped[str | None] = mapped_column(String(50))
  46. filament_color: Mapped[str | None] = mapped_column(String(50))
  47. filament_used_grams: Mapped[float | None] = mapped_column(Float)
  48. cost: Mapped[float | None] = mapped_column(Float)
  49. energy_kwh: Mapped[float | None] = mapped_column(Float)
  50. energy_cost: Mapped[float | None] = mapped_column(Float)
  51. # Printer wear for this run: duration at the printer's hourly wear cost (#694).
  52. wear_cost: Mapped[float | None] = mapped_column(Float)
  53. # True when the run's times were not measured: a print closed at reconnect
  54. # (#3310), whose end is its start plus the slicer's estimate when it
  55. # completed, or the reconnect (duration 0). Counted in
  56. # the totals, left out of the time accuracy. NULL for a measured run.
  57. duration_unmeasured: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
  58. failure_reason: Mapped[str | None] = mapped_column(String(100))
  59. # User quality verdict ('good' / 'reject'), mirrored from the archive on
  60. # PATCH exactly like status/failure_reason (#1444 mirror) so verdict-aware
  61. # statistics can stay on this table (#1898).
  62. user_verdict: Mapped[str | None] = mapped_column(String(10), nullable=True)
  63. thumbnail_path: Mapped[str | None] = mapped_column(String(500))
  64. created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
  65. created_by_username: Mapped[str | None] = mapped_column(String(100))
  66. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  67. # What describes one print and today still lives on the archive, which
  68. # the archive redesign moves here. Written alongside the archive's own
  69. # columns for now; read from the archive until each part moves over.
  70. subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
  71. billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
  72. cost_center_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
  73. wallet_charge_skipped: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
  74. energy_start_kwh: Mapped[float | None] = mapped_column(Float, nullable=True)
  75. energy_start_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  76. energy_start_plug_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
  77. energy_start_price: Mapped[float | None] = mapped_column(Float, nullable=True)
  78. user_verdict_source: Mapped[str | None] = mapped_column(String(16), nullable=True)
  79. user_verdict_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  80. confirm_requested: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
  81. confirm_token: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
  82. confirm_token_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  83. photos: Mapped[list | None] = mapped_column(JSON, nullable=True)
  84. timelapse_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
  85. timelapse_baseline: Mapped[list | None] = mapped_column(JSON, nullable=True)
  86. timelapse_baseline_unread: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
  87. bambuddy_forced_timelapse: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
  88. notes: Mapped[str | None] = mapped_column(Text, nullable=True)
  89. quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
  90. @event.listens_for(Session, "do_orm_execute")
  91. def _hide_open_runs(state: ORMExecuteState) -> None:
  92. """Every ORM read, update and delete of runs leaves open runs out, unless
  93. it asks for them with ``execution_options(include_open_runs=True)``.
  94. Fail-closed on purpose: some 25 places read runs -- statistics, failure
  95. analysis, metrics, project and batch totals, the print log, repricing,
  96. purge -- and a run that has not ended counted in any of them is a print
  97. that did not happen. A new reader is covered without knowing about it.
  98. """
  99. if not (state.is_select or state.is_update or state.is_delete):
  100. return
  101. if state.execution_options.get(INCLUDE_OPEN_RUNS, False):
  102. return
  103. state.statement = state.statement.options(_FINISHED_RUNS_ONLY)
  104. # Built once: a new option per statement doubled the listener's cost.
  105. _FINISHED_RUNS_ONLY = with_loader_criteria(PrintLogEntry, PrintLogEntry.status != RUN_OPEN, include_aliases=True)