print_queue.py 22 KB

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