mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 06:02:05 +02:00
A job queued as "Any X1C" explains itself when it cannot start: the model-based branch builds a reason for every candidate printer and puts it on the row, so the queue shows "Busy: X1C-01" or "Waiting for filament: X1C-02 (needs PETG)". The same job pinned to one printer showed nothing. It sat at Pending with waiting_reason NULL for as long as that printer was busy, which from the outside is indistinguishable from a queue that has stopped working -- the reporter watched fourteen minutes of it while his X1C ran a print he had started from its own screen. The fixed-printer branch had six ways out and none of them wrote the field. The sensor interlock (#1148) was its only writer, and it cleared the field up front on every pass where no sensor was holding the printer, so NULL was not an oversight on those paths but a guarantee. Every exit now writes, through one helper. The reasons reuse the model-based branch's vocabulary so _is_busy_only() keeps deciding what is worth a notification: a printer that is printing, drying, or working through the item ahead of this one reads as "Busy: <printer>" and stays silent, because it resolves itself. A printer that is off with no Auto On plug, and one whose plug could not switch it on, are worth saying. A finished plate nobody has acknowledged is split out from plain busy and named as itself. _is_printer_idle() returns the same plain False for that and for a running print, but they are not the same thing to the person looking at the queue: one clears itself and the other needs somebody to walk over to the printer. That notification fires on the transition into asking, where a busy-only reason counts as not asking. Testing whether the item was waiting at all -- which is what the model-based branch does -- would never fire it here: nobody's queue goes straight from idle to an unconfirmed plate, it waits behind the print first. The cost is that a printer dropping offline, returning busy and dropping again asks twice rather than once. The interlock stays silent. It has never sent this notification, and a change about what the queue displays is not the place to start. Clearing the field up front is gone with it. It existed so a shut door could not leave "Waiting on Enclosure Door" standing while the printer stayed busy with something else, and the new rule carries that guarantee instead -- whichever exit runs next overwrites it, and the dispatch path clears it. Two paths clear it that the report did not mention. A staged item and a future-scheduled one skip before this branch and never reach it again, so anything written on an earlier pass would outlive its condition for the life of the row. That includes the filament-deficit check, which stages the item itself. The notification is wrapped: a queue that cannot say why it is waiting is the bug being fixed, and a queue that stops dispatching because a provider timed out would be a worse one. On the frontend, the queue timeline drops any pending item carrying a reason, on the grounds that such an item will not auto-dispatch. That held while only the model-based branch wrote the field; "Busy: <printer>" is now the commonest reason there is, and it describes the very chain the timeline forecasts, so the rule would have emptied the view for anyone whose queue is pinned. It now asks whether the reason needs the user, via a small shared reader of the same shape the scheduler encodes. Which job goes out, and when, is unchanged: running the previous scheduler and this one over the same 768 states dispatches the same items in the same order with the same statuses, across 1452 rows that now carry a reason.
492 lines
21 KiB
Python
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 this job hasn't started yet (empty once it can)
|
|
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
|