print_queue.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. from datetime import datetime
  2. from typing import Annotated, Literal
  3. from pydantic import AfterValidator, BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
  4. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  5. from backend.app.utils.local_time import to_naive_utc
  6. from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
  7. # Custom serializer to ensure UTC datetimes have Z suffix
  8. def serialize_utc_datetime(dt: datetime | None) -> str | None:
  9. if dt is None:
  10. return None
  11. # Add Z suffix to indicate UTC
  12. return dt.isoformat() + "Z"
  13. UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
  14. def _naive_utc(value: datetime | None) -> datetime | None:
  15. try:
  16. return to_naive_utc(value)
  17. except OverflowError:
  18. # At the edge of the date range: a 422, not a 500.
  19. raise ValueError("date is out of range") from None
  20. # Coerces any client-sent UTC offset to the naive UTC the DB stores. Without
  21. # it the offset was dropped, not converted: 10:00+02:00 was stored as 10:00 UTC.
  22. NaiveUTCDatetime = Annotated[datetime | None, AfterValidator(_naive_utc)]
  23. def _coerce_tristate(v: object) -> object:
  24. """Map legacy on/off booleans onto the tri-state calibration options.
  25. bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
  26. added BambuStudio's third "auto" state (skip if recently done). Rows and API
  27. payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
  28. coerce them so old clients and un-migrated rows still validate. getValueInt
  29. parity: off=0, on=1, auto=2.
  30. """
  31. if isinstance(v, bool):
  32. return "on" if v else "off"
  33. if isinstance(v, int):
  34. return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
  35. if isinstance(v, str):
  36. low = v.strip().lower()
  37. if low in ("true", "1"):
  38. return "on"
  39. if low in ("false", "0"):
  40. return "off"
  41. return v
  42. # Tri-state calibration option: "auto" (printer decides / skip if recent),
  43. # "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
  44. TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
  45. class QueueVariantCreate(BaseModel):
  46. """One candidate file for a cross-model queue item (#671).
  47. Per-file rather than per-item because the settings genuinely differ between
  48. candidates: an H2C slice is dual-nozzle and will not share slot count, AMS
  49. mapping or nozzle mapping with the H2S slice of the same model.
  50. ``target_model`` is normally omitted and read from the file's own
  51. ``sliced_for_model``; supply it only for a legacy 3MF that declares none.
  52. """
  53. library_file_id: int
  54. target_model: str | None = None
  55. plate_id: int | None = None
  56. ams_mapping: list[int] | None = None
  57. nozzle_mapping: list[int] | None = None
  58. # Which rack position each filament group prints from (#1784), as
  59. # {group_id: 1-based position}. The operator's pick, re-checked against the
  60. # live rack at dispatch; null means "assign them for me".
  61. nozzle_rack_choice: dict[int, int] | None = None
  62. filament_overrides: list[dict] | None = None
  63. class PrintQueueItemCreate(BaseModel):
  64. printer_id: int | None = None # None = unassigned, user assigns later
  65. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  66. target_location: str | None = None # Target location filter (only used with target_model)
  67. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  68. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  69. # Either archive_id OR library_file_id must be provided
  70. archive_id: int | None = None
  71. library_file_id: int | None = None
  72. scheduled_time: NaiveUTCDatetime = None # None = ASAP (next when idle)
  73. require_previous_success: bool = False
  74. auto_off_after: bool = False # Power off printer after print completes
  75. manual_start: bool = False # Requires manual trigger to start (staged)
  76. insert_at_top: bool = False # Insert ahead of other pending items (one queue order across all printers, #3200)
  77. insert_position: int | None = None # 1-indexed insertion position for priority queueing
  78. # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
  79. # PrintModal already showed the deficit warning and the user confirmed,
  80. # so the scheduler does not re-flag this item on the next tick.
  81. skip_filament_check: bool = False
  82. # AMS mapping: list of global tray IDs for each filament slot
  83. # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
  84. ams_mapping: list[int] | None = None
  85. # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
  86. plate_id: int | None = None
  87. # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
  88. # (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
  89. # layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
  90. bed_levelling: TriState = "auto"
  91. flow_cali: TriState = "auto"
  92. vibration_cali: bool = True
  93. layer_inspect: bool = False
  94. timelapse: bool = False
  95. use_ams: bool = True
  96. # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
  97. # layer ignores the value on single-nozzle printers so the wire stays "skip".
  98. nozzle_offset_cali: TriState = "auto"
  99. # Ask for a post-print outcome verdict when this job completes (#1898)
  100. confirm_outcome: bool = False
  101. # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
  102. # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
  103. # target falls through: this override → max(filament-map[loaded tray]) → 0.
  104. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  105. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  106. # Auto-print G-code injection
  107. gcode_injection: bool = False
  108. # Batch: create multiple copies (creates a batch if > 1)
  109. quantity: int = 1
  110. # Existing batch to add this item into. When set, the item's batch_id is
  111. # populated on insert so the queue UI groups it with its siblings. Used by
  112. # the multi-plate auto-batch flow and by the "Group as batch" action.
  113. batch_id: int | None = None
  114. # Project to associate the resulting archive with
  115. project_id: int | None = None
  116. cost_center_id: int | None = None
  117. estimated_cost: float | None = None
  118. # Which rack position each filament group prints from (#1784), as
  119. # {group_id: 1-based position}. The operator's pick, re-checked against the
  120. # live rack at dispatch; null means "assign them for me".
  121. nozzle_rack_choice: dict[int, int] | None = None
  122. # Direct printer-card uploads are temporary library files. The scheduler
  123. # deletes them after creating the durable archive copy.
  124. cleanup_library_after_dispatch: bool = False
  125. # Cross-model alternatives (#671): several sliced files, one job, whichever
  126. # printer frees up first. Mutually exclusive with printer_id (a specific
  127. # printer defeats the purpose) and with archive_id/library_file_id (the
  128. # candidates ARE the files). The scheduler resolves one onto the row at
  129. # dispatch, after which the item is an ordinary single-file job.
  130. variants: list[QueueVariantCreate] | None = None
  131. class PrintQueueItemUpdate(IgnoresNullForRequiredColumns):
  132. __orm_table__ = "print_queue"
  133. printer_id: int | None = None
  134. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  135. target_location: str | None = None # Target location filter (only used with target_model)
  136. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  137. position: int | None = None
  138. scheduled_time: NaiveUTCDatetime = None
  139. require_previous_success: bool | None = None
  140. auto_off_after: bool | None = None
  141. manual_start: bool | None = None
  142. # Set by the print dialog when the mapping deliberately puts a slot on a
  143. # tray of another material, so the dispatch re-check keeps it (#2799).
  144. skip_filament_check: bool | None = None
  145. ams_mapping: list[int] | None = None
  146. plate_id: int | None = None
  147. # Print options
  148. bed_levelling: TriState | None = None
  149. flow_cali: TriState | None = None
  150. vibration_cali: bool | None = None
  151. layer_inspect: bool | None = None
  152. timelapse: bool | None = None
  153. use_ams: bool | None = None
  154. nozzle_offset_cali: TriState | None = None
  155. confirm_outcome: bool | None = None
  156. preheat_override: Literal["inherit", "on", "off"] | None = None
  157. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  158. # Auto-print G-code injection
  159. gcode_injection: bool | None = None
  160. cost_center_id: int | None = None
  161. estimated_cost: float | None = None
  162. # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
  163. # physical nozzle position IDs from BambuStudio's project_file MQTT
  164. # body; sent back to the printer verbatim on dispatch.
  165. nozzle_mapping: list[int] | None = None
  166. # Which rack position each filament group prints from (#1784), as
  167. # {group_id: 1-based position}. The operator's pick, re-checked against the
  168. # live rack at dispatch; null means "assign them for me".
  169. nozzle_rack_choice: dict[int, int] | None = None
  170. class QueueVariantSummary(BaseModel):
  171. """One candidate on a cross-model queue item, for display (#671)."""
  172. library_file_id: int
  173. filename: str
  174. target_model: str
  175. position: int
  176. class ColorSubstitution(BaseModel):
  177. """A slot sent with a spool of another colour than the file asked for (#3279)."""
  178. slot_id: int
  179. type: str
  180. wanted: str
  181. got: str
  182. tray: int
  183. class PrintQueueItemResponse(BaseModel):
  184. id: int
  185. printer_id: int | None # None = unassigned
  186. target_model: str | None = None # Target printer model for model-based assignment
  187. target_location: str | None = None # Target location filter for model-based assignment
  188. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  189. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  190. waiting_reason: str | None = None # Why this job hasn't started yet (empty once it can)
  191. archive_id: int | None # None if library_file_id is set (archive created at print start)
  192. library_file_id: int | None # For queue items from library files
  193. cost_center_id: int | None = None
  194. estimated_cost: float | None = None
  195. position: int
  196. scheduled_time: UTCDatetime
  197. require_previous_success: bool
  198. auto_off_after: bool
  199. manual_start: bool
  200. # True when the dispatch scheduler last evaluated this item and the
  201. # assigned spool could not satisfy at least one slot's required grams
  202. # (#1496). Display-only — the ▶ click recomputes deficit against live
  203. # spool state.
  204. filament_short: bool = False
  205. # User has acknowledged "Print Anyway" — scheduler skips the deficit check
  206. # for this item (#1698-followup).
  207. skip_filament_check: bool = False
  208. ams_mapping: list[int] | None = None
  209. plate_id: int | None = None # Plate ID for multi-plate 3MF files
  210. # Print options
  211. bed_levelling: TriState = "auto"
  212. flow_cali: TriState = "auto"
  213. vibration_cali: bool = True
  214. layer_inspect: bool = False
  215. timelapse: bool = False
  216. use_ams: bool = True
  217. nozzle_offset_cali: TriState = "auto"
  218. confirm_outcome: bool = False
  219. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  220. preheat_chamber_target_override: int | None = None
  221. status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
  222. started_at: UTCDatetime
  223. completed_at: UTCDatetime
  224. error_message: str | None
  225. created_at: UTCDatetime
  226. # Nested info for UI (populated in route)
  227. archive_name: str | None = None
  228. archive_thumbnail: str | None = None
  229. # True when the linked archive has been soft-deleted (its files are gone
  230. # from disk). In that case the *archive_name* / *archive_thumbnail* /
  231. # downstream metadata fields are intentionally left None so the frontend
  232. # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
  233. # endpoints (#1348 follow-up). Frontends can render a "source deleted"
  234. # badge based on this flag.
  235. archive_deleted: bool = False
  236. library_file_name: str | None = None # Name of library file (if library_file_id is set)
  237. library_file_thumbnail: str | None = None # Thumbnail of library file
  238. printer_name: str | None = None
  239. print_time_seconds: int | None = None # Estimated print time from archive or library file
  240. filament_used_grams: float | None = None # Estimated print weight from archive or library file
  241. filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
  242. filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
  243. layer_height: float | None = None # e.g. 0.2 (from archive/library file)
  244. nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
  245. sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
  246. # Build plate type (e.g. "Textured PEI Plate") so the user knows which
  247. # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
  248. # 3MFs: when `plate_id` is set, the value is the matching plate's
  249. # `curr_bed_type` rather than the archive-level first-plate default.
  250. bed_type: str | None = None
  251. # True when the source archive carries the slicer's own live-resolved
  252. # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
  253. # against this row's own printer — the only case where dispatch actually
  254. # reuses that exact physical spool instead of the scheduler re-deriving one
  255. # from the file's static type/color.
  256. archive_has_slicer_ams_mapping: bool = False
  257. # A virtual printer's job not printed yet: the slicer's AMS pick it keeps
  258. # for the archive its print will create (#2700), when it was resolved
  259. # against this row's printer. The badge above is set for it too.
  260. slicer_ams_mapping: list[int] | None = None
  261. # User tracking (Issue #206)
  262. created_by_id: int | None = None
  263. created_by_username: str | None = None
  264. # Batch grouping
  265. batch_id: int | None = None
  266. batch_name: str | None = None
  267. # Cross-model alternatives (#671), in priority order. Empty for every
  268. # ordinary item. Present until dispatch resolves one onto the row, after
  269. # which library_file_id / target_model name the candidate that actually ran.
  270. variants: list[QueueVariantSummary] = []
  271. # Shortest-job-first scheduling
  272. been_jumped: bool = False
  273. # Auto-print G-code injection
  274. gcode_injection: bool = False
  275. cleanup_library_after_dispatch: bool = False
  276. # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
  277. # "edit print → choose nozzle" UI; null on every model except O1C2
  278. # uploads from BambuStudio.
  279. nozzle_mapping: list[int] | None = None
  280. # Which rack position each filament group prints from (#1784), as
  281. # {group_id: 1-based position}. The operator's pick, re-checked against the
  282. # live rack at dispatch; null means "assign them for me".
  283. nozzle_rack_choice: dict[int, int] | None = None
  284. # Slots that printed in another colour than the file asked for (#3279).
  285. color_substitutions: list[ColorSubstitution] | None = None
  286. class Config:
  287. from_attributes = True
  288. class PrintQueueHistoryResponse(BaseModel):
  289. """One page of the queue history, plus what the page needs around it.
  290. ``items`` holds the page in display order, followed by any other history
  291. runs of the batches on that page, so a batch row can count all of its runs.
  292. """
  293. items: list[PrintQueueItemResponse]
  294. # Matching history items in all, not just this page.
  295. total: int
  296. # Every target_location in the caller's history, for the location filter.
  297. locations: list[str]
  298. class PrintQueueHistoryClearResponse(BaseModel):
  299. cleared: int
  300. # Runs a batch order still needs, cancelled rather than deleted (#2960).
  301. kept: int
  302. class PrintQueueReorderItem(BaseModel):
  303. id: int
  304. position: int
  305. class PrintQueueReorder(BaseModel):
  306. items: list[PrintQueueReorderItem]
  307. @model_validator(mode="after")
  308. def _validate_positions_unique(self) -> "PrintQueueReorder":
  309. """Reject reorder requests with duplicate positions in the payload
  310. (#1625-followup).
  311. The /reorder route is the drag-drop renumber path on the queue UI;
  312. a well-behaved client sends a contiguous renumbering of a single
  313. printer's pending queue. A buggy client that sends two items at
  314. the same position would leave the queue in an inconsistent state
  315. (scheduler's ORDER BY (printer_id, position) ties get broken by
  316. physical row order). Fail closed at the schema boundary so the
  317. bug is caught before any DB mutation.
  318. Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
  319. reorders that intentionally share positions across different
  320. printer queues are a non-goal of the drag-drop UI, so this is the
  321. right scope.
  322. """
  323. positions = [it.position for it in self.items]
  324. if len(positions) != len(set(positions)):
  325. duplicates = sorted({p for p in positions if positions.count(p) > 1})
  326. raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
  327. return self
  328. class PrintQueueBulkUpdate(IgnoresNullForRequiredColumns):
  329. """Bulk update multiple queue items with the same values."""
  330. __orm_table__ = "print_queue"
  331. item_ids: list[int]
  332. # Fields to update (all optional - only set fields are applied)
  333. printer_id: int | None = None
  334. scheduled_time: NaiveUTCDatetime = None
  335. require_previous_success: bool | None = None
  336. auto_off_after: bool | None = None
  337. manual_start: bool | None = None
  338. # Print options
  339. bed_levelling: TriState | None = None
  340. flow_cali: TriState | None = None
  341. vibration_cali: bool | None = None
  342. layer_inspect: bool | None = None
  343. timelapse: bool | None = None
  344. use_ams: bool | None = None
  345. nozzle_offset_cali: TriState | None = None
  346. confirm_outcome: bool | None = None
  347. preheat_override: Literal["inherit", "on", "off"] | None = None
  348. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
  349. # Auto-print G-code injection
  350. gcode_injection: bool | None = None
  351. cost_center_id: int | None = None
  352. estimated_cost: float | None = None
  353. class PrintQueueBulkUpdateResponse(BaseModel):
  354. """Response for bulk update operation."""
  355. updated_count: int
  356. skipped_count: int # Items that were not pending
  357. message: str
  358. class PrintBatchPlateTarget(BaseModel):
  359. """How many runs of one plate an order wants (#342).
  360. ``plate_id`` is the plate index inside the source 3MF, or null for a
  361. single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
  362. legal and means "this plate is not required (yet)".
  363. """
  364. plate_id: int | None = None
  365. plate_name: str | None = None
  366. quantity_target: int = Field(default=1, ge=0, le=999)
  367. sort_order: int = 0
  368. class PrintBatchCreate(BaseModel):
  369. """Create a batch, either empty (multi-plate pre-batch flow) or by
  370. assigning existing pending queue items into it (manual "Group as batch")."""
  371. name: str
  372. archive_id: int | None = None
  373. library_file_id: int | None = None
  374. # Existing pending queue items to assign to this batch. None / empty for
  375. # the empty-batch flow (client passes the returned id on subsequent
  376. # addToQueue calls).
  377. item_ids: list[int] | None = None
  378. # Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
  379. # a batch that reports progress but owes nothing.
  380. plates: list[PrintBatchPlateTarget] | None = None
  381. # Planning metadata. Projects own the heavier fields (BOM, attachments,
  382. # tags); these two are the ones that are useless without a Project to
  383. # hang them on, so the order carries them directly.
  384. project_id: int | None = None
  385. due_date: datetime | None = None
  386. notes: str | None = None
  387. # The external record this batch fulfils, for integrations. ``external_ref``
  388. # is unique within ``external_source``: creating a second batch with the
  389. # same pair is a 409, which makes a retried create safe.
  390. external_source: str | None = Field(default=None, min_length=1, max_length=32, pattern=r"^[a-z0-9_-]+$")
  391. external_ref: str | None = Field(default=None, min_length=1, max_length=255)
  392. @model_validator(mode="after")
  393. def _external_link_is_complete(self) -> "PrintBatchCreate":
  394. # A ref without its source can't be looked up, and a source without a
  395. # ref would escape the uniqueness guarantee (NULLs never collide).
  396. if (self.external_source is None) != (self.external_ref is None):
  397. raise ValueError("external_source and external_ref must be given together")
  398. return self
  399. class PrintBatchUpdate(IgnoresNullForRequiredColumns):
  400. """Edit an order's header or its per-plate targets while it runs.
  401. Every field is optional; ``plates`` replaces the full target set when
  402. given, so a plate omitted from the list has its target row removed.
  403. """
  404. __orm_table__ = "print_batches"
  405. name: str | None = None
  406. status: Literal["active", "cancelled"] | None = None
  407. plates: list[PrintBatchPlateTarget] | None = None
  408. project_id: int | None = None
  409. due_date: datetime | None = None
  410. notes: str | None = None
  411. class PrintBatchDispatchRequest(BaseModel):
  412. """Create queue items for the runs an order still owes."""
  413. # Restrict to one plate. Null is a legitimate plate_id (single-plate file),
  414. # so the caller opts in explicitly rather than us inferring from null.
  415. plate_id: int | None = None
  416. only_plate: bool = False
  417. # Cap on how many items to create across all plates. None = everything owed.
  418. limit: int | None = Field(default=None, ge=1, le=999)
  419. class PrintBatchUngroupResponse(BaseModel):
  420. """Response after ungrouping a batch."""
  421. ungrouped_count: int
  422. message: str
  423. class PrintBatchPlateProgress(BaseModel):
  424. """Per-plate progress within a batch."""
  425. plate_id: int | None = None
  426. plate_name: str | None = None
  427. quantity_target: int = 0
  428. dispatched: int = 0
  429. remaining: int = 0
  430. pending_count: int = 0
  431. printing_count: int = 0
  432. completed_count: int = 0
  433. failed_count: int = 0
  434. cancelled_count: int = 0
  435. skipped_count: int = 0
  436. # Measured from finished runs, never estimated from the file. Null until
  437. # at least one run of this plate has produced a cost.
  438. actual_cost: float | None = None
  439. estimated_remaining_cost: float | None = None
  440. filament_used_grams: float | None = None
  441. print_time_seconds: int = 0
  442. # False when this plate owes runs but has no queue item left to clone
  443. # their configuration from, so offering to queue it would only fail.
  444. can_dispatch: bool = False
  445. class PrintBatchResponse(BaseModel):
  446. """Response for a print batch with progress stats."""
  447. id: int
  448. name: str
  449. archive_id: int | None = None
  450. library_file_id: int | None = None
  451. quantity: int
  452. status: str
  453. created_at: UTCDatetime
  454. completed_at: UTCDatetime | None = None
  455. created_by_id: int | None = None
  456. created_by_username: str | None = None
  457. project_id: int | None = None
  458. due_date: UTCDatetime | None = None
  459. notes: str | None = None
  460. external_source: str | None = None
  461. external_ref: str | None = None
  462. # Derived counts
  463. pending_count: int = 0
  464. printing_count: int = 0
  465. completed_count: int = 0
  466. failed_count: int = 0
  467. cancelled_count: int = 0
  468. skipped_count: int = 0
  469. # Planning roll-up. has_targets is false for batches created before
  470. # per-plate targets existed: they report progress but owe nothing, and the
  471. # dispatch endpoint is a no-op for them.
  472. has_targets: bool = False
  473. target_count: int = 0
  474. remaining_count: int = 0
  475. # Of ``remaining_count``, how many runs can actually be queued. Lower when
  476. # a plate's last queue item was deleted (#2960).
  477. dispatchable_count: int = 0
  478. actual_cost: float | None = None
  479. estimated_remaining_cost: float | None = None
  480. filament_used_grams: float | None = None
  481. print_time_seconds: int = 0
  482. plates: list[PrintBatchPlateProgress] = []
  483. class Config:
  484. from_attributes = True