print_queue.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. from datetime import datetime
  2. from typing import Annotated, Literal
  3. from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
  4. # Custom serializer to ensure UTC datetimes have Z suffix
  5. def serialize_utc_datetime(dt: datetime | None) -> str | None:
  6. if dt is None:
  7. return None
  8. # Add Z suffix to indicate UTC
  9. return dt.isoformat() + "Z"
  10. UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
  11. def _coerce_tristate(v: object) -> object:
  12. """Map legacy on/off booleans onto the tri-state calibration options.
  13. bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
  14. added BambuStudio's third "auto" state (skip if recently done). Rows and API
  15. payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
  16. coerce them so old clients and un-migrated rows still validate. getValueInt
  17. parity: off=0, on=1, auto=2.
  18. """
  19. if isinstance(v, bool):
  20. return "on" if v else "off"
  21. if isinstance(v, int):
  22. return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
  23. if isinstance(v, str):
  24. low = v.strip().lower()
  25. if low in ("true", "1"):
  26. return "on"
  27. if low in ("false", "0"):
  28. return "off"
  29. return v
  30. # Tri-state calibration option: "auto" (printer decides / skip if recent),
  31. # "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
  32. TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
  33. class PrintQueueItemCreate(BaseModel):
  34. printer_id: int | None = None # None = unassigned, user assigns later
  35. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  36. target_location: str | None = None # Target location filter (only used with target_model)
  37. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  38. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  39. # Either archive_id OR library_file_id must be provided
  40. archive_id: int | None = None
  41. library_file_id: int | None = None
  42. scheduled_time: datetime | None = None # None = ASAP (next when idle)
  43. require_previous_success: bool = False
  44. auto_off_after: bool = False # Power off printer after print completes
  45. manual_start: bool = False # Requires manual trigger to start (staged)
  46. insert_at_top: bool = False # Insert ahead of other pending items in the same queue scope
  47. insert_position: int | None = None # 1-indexed insertion position for priority queueing
  48. # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
  49. # PrintModal already showed the deficit warning and the user confirmed,
  50. # so the scheduler does not re-flag this item on the next tick.
  51. skip_filament_check: bool = False
  52. # AMS mapping: list of global tray IDs for each filament slot
  53. # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
  54. ams_mapping: list[int] | None = None
  55. # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
  56. plate_id: int | None = None
  57. # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
  58. # (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
  59. # layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
  60. bed_levelling: TriState = "auto"
  61. flow_cali: TriState = "auto"
  62. vibration_cali: bool = True
  63. layer_inspect: bool = False
  64. timelapse: bool = False
  65. use_ams: bool = True
  66. # Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
  67. # layer ignores the value on single-nozzle printers so the wire stays "skip".
  68. nozzle_offset_cali: TriState = "auto"
  69. # Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
  70. # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
  71. # target falls through: this override → max(filament-map[loaded tray]) → 0.
  72. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  73. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
  74. # Auto-print G-code injection
  75. gcode_injection: bool = False
  76. # Batch: create multiple copies (creates a batch if > 1)
  77. quantity: int = 1
  78. # Existing batch to add this item into. When set, the item's batch_id is
  79. # populated on insert so the queue UI groups it with its siblings. Used by
  80. # the multi-plate auto-batch flow and by the "Group as batch" action.
  81. batch_id: int | None = None
  82. # Project to associate the resulting archive with
  83. project_id: int | None = None
  84. cost_center_id: int | None = None
  85. estimated_cost: float | None = None
  86. # Direct printer-card uploads are temporary library files. The scheduler
  87. # deletes them after creating the durable archive copy.
  88. cleanup_library_after_dispatch: bool = False
  89. class PrintQueueItemUpdate(BaseModel):
  90. printer_id: int | None = None
  91. target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
  92. target_location: str | None = None # Target location filter (only used with target_model)
  93. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  94. position: int | None = None
  95. scheduled_time: datetime | None = None
  96. require_previous_success: bool | None = None
  97. auto_off_after: bool | None = None
  98. manual_start: bool | None = None
  99. ams_mapping: list[int] | None = None
  100. plate_id: int | None = None
  101. # Print options
  102. bed_levelling: TriState | None = None
  103. flow_cali: TriState | None = None
  104. vibration_cali: bool | None = None
  105. layer_inspect: bool | None = None
  106. timelapse: bool | None = None
  107. use_ams: bool | None = None
  108. nozzle_offset_cali: TriState | None = None
  109. preheat_override: Literal["inherit", "on", "off"] | None = None
  110. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
  111. # Auto-print G-code injection
  112. gcode_injection: bool | None = None
  113. cost_center_id: int | None = None
  114. estimated_cost: float | None = None
  115. # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
  116. # physical nozzle position IDs from BambuStudio's project_file MQTT
  117. # body; sent back to the printer verbatim on dispatch.
  118. nozzle_mapping: list[int] | None = None
  119. class PrintQueueItemResponse(BaseModel):
  120. id: int
  121. printer_id: int | None # None = unassigned
  122. target_model: str | None = None # Target printer model for model-based assignment
  123. target_location: str | None = None # Target location filter for model-based assignment
  124. required_filament_types: list[str] | None = None # Required filament types for model-based assignment
  125. filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
  126. waiting_reason: str | None = None # Why a model-based job hasn't started yet
  127. archive_id: int | None # None if library_file_id is set (archive created at print start)
  128. library_file_id: int | None # For queue items from library files
  129. cost_center_id: int | None = None
  130. estimated_cost: float | None = None
  131. position: int
  132. scheduled_time: UTCDatetime
  133. require_previous_success: bool
  134. auto_off_after: bool
  135. manual_start: bool
  136. # True when the dispatch scheduler last evaluated this item and the
  137. # assigned spool could not satisfy at least one slot's required grams
  138. # (#1496). Display-only — the ▶ click recomputes deficit against live
  139. # spool state.
  140. filament_short: bool = False
  141. # User has acknowledged "Print Anyway" — scheduler skips the deficit check
  142. # for this item (#1698-followup).
  143. skip_filament_check: bool = False
  144. ams_mapping: list[int] | None = None
  145. plate_id: int | None = None # Plate ID for multi-plate 3MF files
  146. # Print options
  147. bed_levelling: TriState = "auto"
  148. flow_cali: TriState = "auto"
  149. vibration_cali: bool = True
  150. layer_inspect: bool = False
  151. timelapse: bool = False
  152. use_ams: bool = True
  153. nozzle_offset_cali: TriState = "auto"
  154. preheat_override: Literal["inherit", "on", "off"] = "inherit"
  155. preheat_chamber_target_override: int | None = None
  156. status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
  157. started_at: UTCDatetime
  158. completed_at: UTCDatetime
  159. error_message: str | None
  160. created_at: UTCDatetime
  161. # Nested info for UI (populated in route)
  162. archive_name: str | None = None
  163. archive_thumbnail: str | None = None
  164. # True when the linked archive has been soft-deleted (its files are gone
  165. # from disk). In that case the *archive_name* / *archive_thumbnail* /
  166. # downstream metadata fields are intentionally left None so the frontend
  167. # doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
  168. # endpoints (#1348 follow-up). Frontends can render a "source deleted"
  169. # badge based on this flag.
  170. archive_deleted: bool = False
  171. library_file_name: str | None = None # Name of library file (if library_file_id is set)
  172. library_file_thumbnail: str | None = None # Thumbnail of library file
  173. printer_name: str | None = None
  174. print_time_seconds: int | None = None # Estimated print time from archive or library file
  175. filament_used_grams: float | None = None # Estimated print weight from archive or library file
  176. filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
  177. filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
  178. layer_height: float | None = None # e.g. 0.2 (from archive/library file)
  179. nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
  180. sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
  181. # Build plate type (e.g. "Textured PEI Plate") so the user knows which
  182. # plate to mount on the printer (#1281). Per-plate accurate on multi-plate
  183. # 3MFs: when `plate_id` is set, the value is the matching plate's
  184. # `curr_bed_type` rather than the archive-level first-plate default.
  185. bed_type: str | None = None
  186. # User tracking (Issue #206)
  187. created_by_id: int | None = None
  188. created_by_username: str | None = None
  189. # Batch grouping
  190. batch_id: int | None = None
  191. batch_name: str | None = None
  192. # Shortest-job-first scheduling
  193. been_jumped: bool = False
  194. # Auto-print G-code injection
  195. gcode_injection: bool = False
  196. cleanup_library_after_dispatch: bool = False
  197. # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
  198. # "edit print → choose nozzle" UI; null on every model except O1C2
  199. # uploads from BambuStudio.
  200. nozzle_mapping: list[int] | None = None
  201. class Config:
  202. from_attributes = True
  203. class PrintQueueReorderItem(BaseModel):
  204. id: int
  205. position: int
  206. class PrintQueueReorder(BaseModel):
  207. items: list[PrintQueueReorderItem]
  208. @model_validator(mode="after")
  209. def _validate_positions_unique(self) -> "PrintQueueReorder":
  210. """Reject reorder requests with duplicate positions in the payload
  211. (#1625-followup).
  212. The /reorder route is the drag-drop renumber path on the queue UI;
  213. a well-behaved client sends a contiguous renumbering of a single
  214. printer's pending queue. A buggy client that sends two items at
  215. the same position would leave the queue in an inconsistent state
  216. (scheduler's ORDER BY (printer_id, position) ties get broken by
  217. physical row order). Fail closed at the schema boundary so the
  218. bug is caught before any DB mutation.
  219. Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
  220. reorders that intentionally share positions across different
  221. printer queues are a non-goal of the drag-drop UI, so this is the
  222. right scope.
  223. """
  224. positions = [it.position for it in self.items]
  225. if len(positions) != len(set(positions)):
  226. duplicates = sorted({p for p in positions if positions.count(p) > 1})
  227. raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
  228. return self
  229. class PrintQueueBulkUpdate(BaseModel):
  230. """Bulk update multiple queue items with the same values."""
  231. item_ids: list[int]
  232. # Fields to update (all optional - only set fields are applied)
  233. printer_id: int | None = None
  234. scheduled_time: datetime | None = None
  235. require_previous_success: bool | None = None
  236. auto_off_after: bool | None = None
  237. manual_start: bool | None = None
  238. # Print options
  239. bed_levelling: TriState | None = None
  240. flow_cali: TriState | None = None
  241. vibration_cali: bool | None = None
  242. layer_inspect: bool | None = None
  243. timelapse: bool | None = None
  244. use_ams: bool | None = None
  245. nozzle_offset_cali: TriState | None = None
  246. preheat_override: Literal["inherit", "on", "off"] | None = None
  247. preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
  248. # Auto-print G-code injection
  249. gcode_injection: bool | None = None
  250. cost_center_id: int | None = None
  251. estimated_cost: float | None = None
  252. class PrintQueueBulkUpdateResponse(BaseModel):
  253. """Response for bulk update operation."""
  254. updated_count: int
  255. skipped_count: int # Items that were not pending
  256. message: str
  257. class PrintBatchCreate(BaseModel):
  258. """Create a batch, either empty (multi-plate pre-batch flow) or by
  259. assigning existing pending queue items into it (manual "Group as batch")."""
  260. name: str
  261. archive_id: int | None = None
  262. library_file_id: int | None = None
  263. # Existing pending queue items to assign to this batch. None / empty for
  264. # the empty-batch flow (client passes the returned id on subsequent
  265. # addToQueue calls).
  266. item_ids: list[int] | None = None
  267. class PrintBatchUngroupResponse(BaseModel):
  268. """Response after ungrouping a batch."""
  269. ungrouped_count: int
  270. message: str
  271. class PrintBatchResponse(BaseModel):
  272. """Response for a print batch with progress stats."""
  273. id: int
  274. name: str
  275. archive_id: int | None = None
  276. library_file_id: int | None = None
  277. quantity: int
  278. status: str
  279. created_at: UTCDatetime
  280. created_by_id: int | None = None
  281. created_by_username: str | None = None
  282. # Derived counts
  283. pending_count: int = 0
  284. printing_count: int = 0
  285. completed_count: int = 0
  286. failed_count: int = 0
  287. cancelled_count: int = 0
  288. class Config:
  289. from_attributes = True