Files
maziggy 196fcaf15b Keep the last run a batch order can re-queue a plate from
An order produces what it still owes by cloning an existing queue item for
the same plate. That row is the only record of the printer target, AMS
mapping and print options the user chose, so deleting the last one left the
order reporting work outstanding that nothing could produce, and no way to
close it out: Cancel was hidden unless there were pending items to cancel,
which by then there were none.

Deleting an order's last surviving run for a plate now cancels it instead.
A cancelled run does not satisfy a target, so the order still owes the print
and can still make it. A completed run is exempt and still deletes outright
-- rewriting it as cancelled would falsify what the order produced.

Also: the response reports per plate whether anything is left to clone, so
the card explains a stranded plate rather than offering a button that can
only fail; dispatch skips a stranded plate instead of aborting the whole
order; and Cancel is offered for any active order.
2026-08-26 10:11:55 +02:00

492 lines
21 KiB
Python

from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
# Custom serializer to ensure UTC datetimes have Z suffix
def serialize_utc_datetime(dt: datetime | None) -> str | None:
if dt is None:
return None
# Add Z suffix to indicate UTC
return dt.isoformat() + "Z"
UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)]
def _coerce_tristate(v: object) -> object:
"""Map legacy on/off booleans onto the tri-state calibration options.
bed_levelling / flow_cali / nozzle_offset_cali were plain booleans before we
added BambuStudio's third "auto" state (skip if recently done). Rows and API
payloads created under the old scheme carry bool / 0-1 int / "true"/"false";
coerce them so old clients and un-migrated rows still validate. getValueInt
parity: off=0, on=1, auto=2.
"""
if isinstance(v, bool):
return "on" if v else "off"
if isinstance(v, int):
return {0: "off", 1: "on", 2: "auto"}.get(v, "auto")
if isinstance(v, str):
low = v.strip().lower()
if low in ("true", "1"):
return "on"
if low in ("false", "0"):
return "off"
return v
# Tri-state calibration option: "auto" (printer decides / skip if recent),
# "on" (force every print), "off" (never). Mirrors BambuStudio's ops_auto.
TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
class QueueVariantCreate(BaseModel):
"""One candidate file for a cross-model queue item (#671).
Per-file rather than per-item because the settings genuinely differ between
candidates: an H2C slice is dual-nozzle and will not share slot count, AMS
mapping or nozzle mapping with the H2S slice of the same model.
``target_model`` is normally omitted and read from the file's own
``sliced_for_model``; supply it only for a legacy 3MF that declares none.
"""
library_file_id: int
target_model: str | None = None
plate_id: int | None = None
ams_mapping: list[int] | None = None
nozzle_mapping: list[int] | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
filament_overrides: list[dict] | None = None
class PrintQueueItemCreate(BaseModel):
printer_id: int | None = None # None = unassigned, user assigns later
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
target_location: str | None = None # Target location filter (only used with target_model)
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
# Either archive_id OR library_file_id must be provided
archive_id: int | None = None
library_file_id: int | None = None
scheduled_time: datetime | None = None # None = ASAP (next when idle)
require_previous_success: bool = False
auto_off_after: bool = False # Power off printer after print completes
manual_start: bool = False # Requires manual trigger to start (staged)
insert_at_top: bool = False # Insert ahead of other pending items in the same queue scope
insert_position: int | None = None # 1-indexed insertion position for priority queueing
# Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
# PrintModal already showed the deficit warning and the user confirmed,
# so the scheduler does not re-flag this item on the next tick.
skip_filament_check: bool = False
# AMS mapping: list of global tray IDs for each filament slot
# Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused)
ams_mapping: list[int] | None = None
# Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
plate_id: int | None = None
# Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
# (off/on/auto), defaulting to "auto" to match BambuStudio. vibration_cali /
# layer_inspect / timelapse stay on/off (BambuStudio exposes no auto for them).
bed_levelling: TriState = "auto"
flow_cali: TriState = "auto"
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
# Nozzle offset calibration — dual-nozzle printers only (#1682). The MQTT
# layer ignores the value on single-nozzle printers so the wire stays "skip".
nozzle_offset_cali: TriState = "auto"
# Preheat / heat-soak per-item override (#1468). 'inherit' uses the global
# preheat_enabled setting; 'on' / 'off' force the decision. The chamber
# target falls through: this override → max(filament-map[loaded tray]) → 0.
preheat_override: Literal["inherit", "on", "off"] = "inherit"
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool = False
# Batch: create multiple copies (creates a batch if > 1)
quantity: int = 1
# Existing batch to add this item into. When set, the item's batch_id is
# populated on insert so the queue UI groups it with its siblings. Used by
# the multi-plate auto-batch flow and by the "Group as batch" action.
batch_id: int | None = None
# Project to associate the resulting archive with
project_id: int | None = None
cost_center_id: int | None = None
estimated_cost: float | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
# Direct printer-card uploads are temporary library files. The scheduler
# deletes them after creating the durable archive copy.
cleanup_library_after_dispatch: bool = False
# Cross-model alternatives (#671): several sliced files, one job, whichever
# printer frees up first. Mutually exclusive with printer_id (a specific
# printer defeats the purpose) and with archive_id/library_file_id (the
# candidates ARE the files). The scheduler resolves one onto the row at
# dispatch, after which the item is an ordinary single-file job.
variants: list[QueueVariantCreate] | None = None
class PrintQueueItemUpdate(BaseModel):
printer_id: int | None = None
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
target_location: str | None = None # Target location filter (only used with target_model)
filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
position: int | None = None
scheduled_time: datetime | None = None
require_previous_success: bool | None = None
auto_off_after: bool | None = None
manual_start: bool | None = None
ams_mapping: list[int] | None = None
plate_id: int | None = None
# Print options
bed_levelling: TriState | None = None
flow_cali: TriState | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
nozzle_offset_cali: TriState | None = None
preheat_override: Literal["inherit", "on", "off"] | None = None
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool | None = None
cost_center_id: int | None = None
estimated_cost: float | None = None
# H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
# physical nozzle position IDs from BambuStudio's project_file MQTT
# body; sent back to the printer verbatim on dispatch.
nozzle_mapping: list[int] | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
class QueueVariantSummary(BaseModel):
"""One candidate on a cross-model queue item, for display (#671)."""
library_file_id: int
filename: str
target_model: str
position: int
class PrintQueueItemResponse(BaseModel):
id: int
printer_id: int | None # None = unassigned
target_model: str | None = None # Target printer model for model-based assignment
target_location: str | None = None # Target location filter for model-based assignment
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
filament_overrides: list[dict] | None = None # Filament overrides for model-based assignment
waiting_reason: str | None = None # Why a model-based job hasn't started yet
archive_id: int | None # None if library_file_id is set (archive created at print start)
library_file_id: int | None # For queue items from library files
cost_center_id: int | None = None
estimated_cost: float | None = None
position: int
scheduled_time: UTCDatetime
require_previous_success: bool
auto_off_after: bool
manual_start: bool
# True when the dispatch scheduler last evaluated this item and the
# assigned spool could not satisfy at least one slot's required grams
# (#1496). Display-only — the ▶ click recomputes deficit against live
# spool state.
filament_short: bool = False
# User has acknowledged "Print Anyway" — scheduler skips the deficit check
# for this item (#1698-followup).
skip_filament_check: bool = False
ams_mapping: list[int] | None = None
plate_id: int | None = None # Plate ID for multi-plate 3MF files
# Print options
bed_levelling: TriState = "auto"
flow_cali: TriState = "auto"
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
nozzle_offset_cali: TriState = "auto"
preheat_override: Literal["inherit", "on", "off"] = "inherit"
preheat_chamber_target_override: int | None = None
status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
started_at: UTCDatetime
completed_at: UTCDatetime
error_message: str | None
created_at: UTCDatetime
# Nested info for UI (populated in route)
archive_name: str | None = None
archive_thumbnail: str | None = None
# True when the linked archive has been soft-deleted (its files are gone
# from disk). In that case the *archive_name* / *archive_thumbnail* /
# downstream metadata fields are intentionally left None so the frontend
# doesn't 404-storm the now-missing thumbnail / plates / plate-thumbnail
# endpoints (#1348 follow-up). Frontends can render a "source deleted"
# badge based on this flag.
archive_deleted: bool = False
library_file_name: str | None = None # Name of library file (if library_file_id is set)
library_file_thumbnail: str | None = None # Thumbnail of library file
printer_name: str | None = None
print_time_seconds: int | None = None # Estimated print time from archive or library file
filament_used_grams: float | None = None # Estimated print weight from archive or library file
filament_type: str | None = None # e.g. "PLA", "PETG" (from archive/library file)
filament_color: str | None = None # e.g. "#FFFFFF" (from archive/library file)
layer_height: float | None = None # e.g. 0.2 (from archive/library file)
nozzle_diameter: float | None = None # e.g. 0.4 (from archive/library file)
sliced_for_model: str | None = None # e.g. "P1S" (from archive/library file)
# Build plate type (e.g. "Textured PEI Plate") so the user knows which
# plate to mount on the printer (#1281). Per-plate accurate on multi-plate
# 3MFs: when `plate_id` is set, the value is the matching plate's
# `curr_bed_type` rather than the archive-level first-plate default.
bed_type: str | None = None
# True when the source archive carries the slicer's own live-resolved
# AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
# against this row's own printer — the only case where dispatch actually
# reuses that exact physical spool instead of the scheduler re-deriving one
# from the file's static type/color.
archive_has_slicer_ams_mapping: bool = False
# User tracking (Issue #206)
created_by_id: int | None = None
created_by_username: str | None = None
# Batch grouping
batch_id: int | None = None
batch_name: str | None = None
# Cross-model alternatives (#671), in priority order. Empty for every
# ordinary item. Present until dispatch resolves one onto the row, after
# which library_file_id / target_model name the candidate that actually ran.
variants: list[QueueVariantSummary] = []
# Shortest-job-first scheduling
been_jumped: bool = False
# Auto-print G-code injection
gcode_injection: bool = False
cleanup_library_after_dispatch: bool = False
# H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
# "edit print → choose nozzle" UI; null on every model except O1C2
# uploads from BambuStudio.
nozzle_mapping: list[int] | None = None
# Which rack position each filament group prints from (#1784), as
# {group_id: 1-based position}. The operator's pick, re-checked against the
# live rack at dispatch; null means "assign them for me".
nozzle_rack_choice: dict[int, int] | None = None
class Config:
from_attributes = True
class PrintQueueReorderItem(BaseModel):
id: int
position: int
class PrintQueueReorder(BaseModel):
items: list[PrintQueueReorderItem]
@model_validator(mode="after")
def _validate_positions_unique(self) -> "PrintQueueReorder":
"""Reject reorder requests with duplicate positions in the payload
(#1625-followup).
The /reorder route is the drag-drop renumber path on the queue UI;
a well-behaved client sends a contiguous renumbering of a single
printer's pending queue. A buggy client that sends two items at
the same position would leave the queue in an inconsistent state
(scheduler's ORDER BY (printer_id, position) ties get broken by
physical row order). Fail closed at the schema boundary so the
bug is caught before any DB mutation.
Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
reorders that intentionally share positions across different
printer queues are a non-goal of the drag-drop UI, so this is the
right scope.
"""
positions = [it.position for it in self.items]
if len(positions) != len(set(positions)):
duplicates = sorted({p for p in positions if positions.count(p) > 1})
raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
return self
class PrintQueueBulkUpdate(BaseModel):
"""Bulk update multiple queue items with the same values."""
item_ids: list[int]
# Fields to update (all optional - only set fields are applied)
printer_id: int | None = None
scheduled_time: datetime | None = None
require_previous_success: bool | None = None
auto_off_after: bool | None = None
manual_start: bool | None = None
# Print options
bed_levelling: TriState | None = None
flow_cali: TriState | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
nozzle_offset_cali: TriState | None = None
preheat_override: Literal["inherit", "on", "off"] | None = None
preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
# Auto-print G-code injection
gcode_injection: bool | None = None
cost_center_id: int | None = None
estimated_cost: float | None = None
class PrintQueueBulkUpdateResponse(BaseModel):
"""Response for bulk update operation."""
updated_count: int
skipped_count: int # Items that were not pending
message: str
class PrintBatchPlateTarget(BaseModel):
"""How many runs of one plate an order wants (#342).
``plate_id`` is the plate index inside the source 3MF, or null for a
single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
legal and means "this plate is not required (yet)".
"""
plate_id: int | None = None
plate_name: str | None = None
quantity_target: int = Field(default=1, ge=0, le=999)
sort_order: int = 0
class PrintBatchCreate(BaseModel):
"""Create a batch, either empty (multi-plate pre-batch flow) or by
assigning existing pending queue items into it (manual "Group as batch")."""
name: str
archive_id: int | None = None
library_file_id: int | None = None
# Existing pending queue items to assign to this batch. None / empty for
# the empty-batch flow (client passes the returned id on subsequent
# addToQueue calls).
item_ids: list[int] | None = None
# Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
# a batch that reports progress but owes nothing.
plates: list[PrintBatchPlateTarget] | None = None
# Planning metadata. Projects own the heavier fields (BOM, attachments,
# tags); these two are the ones that are useless without a Project to
# hang them on, so the order carries them directly.
project_id: int | None = None
due_date: datetime | None = None
notes: str | None = None
class PrintBatchUpdate(BaseModel):
"""Edit an order's header or its per-plate targets while it runs.
Every field is optional; ``plates`` replaces the full target set when
given, so a plate omitted from the list has its target row removed.
"""
name: str | None = None
status: Literal["active", "cancelled"] | None = None
plates: list[PrintBatchPlateTarget] | None = None
project_id: int | None = None
due_date: datetime | None = None
notes: str | None = None
class PrintBatchDispatchRequest(BaseModel):
"""Create queue items for the runs an order still owes."""
# Restrict to one plate. Null is a legitimate plate_id (single-plate file),
# so the caller opts in explicitly rather than us inferring from null.
plate_id: int | None = None
only_plate: bool = False
# Cap on how many items to create across all plates. None = everything owed.
limit: int | None = Field(default=None, ge=1, le=999)
class PrintBatchUngroupResponse(BaseModel):
"""Response after ungrouping a batch."""
ungrouped_count: int
message: str
class PrintBatchPlateProgress(BaseModel):
"""Per-plate progress within a batch."""
plate_id: int | None = None
plate_name: str | None = None
quantity_target: int = 0
dispatched: int = 0
remaining: int = 0
pending_count: int = 0
printing_count: int = 0
completed_count: int = 0
failed_count: int = 0
cancelled_count: int = 0
skipped_count: int = 0
# Measured from finished runs, never estimated from the file. Null until
# at least one run of this plate has produced a cost.
actual_cost: float | None = None
estimated_remaining_cost: float | None = None
filament_used_grams: float | None = None
print_time_seconds: int = 0
# False when this plate owes runs but has no queue item left to clone
# their configuration from, so offering to queue it would only fail.
can_dispatch: bool = False
class PrintBatchResponse(BaseModel):
"""Response for a print batch with progress stats."""
id: int
name: str
archive_id: int | None = None
library_file_id: int | None = None
quantity: int
status: str
created_at: UTCDatetime
completed_at: UTCDatetime | None = None
created_by_id: int | None = None
created_by_username: str | None = None
project_id: int | None = None
due_date: UTCDatetime | None = None
notes: str | None = None
# Derived counts
pending_count: int = 0
printing_count: int = 0
completed_count: int = 0
failed_count: int = 0
cancelled_count: int = 0
skipped_count: int = 0
# Planning roll-up. has_targets is false for batches created before
# per-plate targets existed: they report progress but owe nothing, and the
# dispatch endpoint is a no-op for them.
has_targets: bool = False
target_count: int = 0
remaining_count: int = 0
# Of ``remaining_count``, how many runs can actually be queued. Lower when
# a plate's last queue item was deleted (#2960).
dispatchable_count: int = 0
actual_cost: float | None = None
estimated_remaining_cost: float | None = None
filament_used_grams: float | None = None
print_time_seconds: int = 0
plates: list[PrintBatchPlateProgress] = []
class Config:
from_attributes = True