print_queue.py 23 KB

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