Files
bambuddy/backend/app/schemas/print_queue.py
T
maziggy 84ed28d5fa fix(queue): cancel pending items and hide stale archive surface when archive soft-deleted (#1348)
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
  /archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
  row pointing at a soft-deleted archive. Two underlying problems wearing
  one mask:

  1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
     archive_thumbnail without checking deleted_at. Soft-delete leaves the
     row (so the relationship resolves) but removes the file from disk, so
     the cached path was always stale.

  2. Functional: a queue item whose 3MF was removed can never dispatch.
     Without an explicit cancel, the item sits in 'pending' forever with
     no indication to the user about why nothing is printing.

  Fix in three parts:

  - New _cancel_pending_queue_items() helper, called from soft_delete_archive
    alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
    + waiting_reason='Source archive deleted' on every pending queue item
    linked to the archive. Only 'pending' is touched - completed/failed/
    cancelled rows are historical and untouched. Hard-delete is already
    covered by ON DELETE CASCADE on print_queue.archive_id.

  - Queue API serializer now checks item.archive.deleted_at before
    populating any archive-derived field. New archive_deleted: bool field
    on PrintQueueItemResponse signals the soft-deleted state.

  - Frontend's getArchivePlates query in QueuePage was gated on archive_id
    only - archive_id is the real FK and stays exposed for dispatch/audit,
    so added an explicit && !item.archive_deleted clause to respect the
    new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
    already gate on archive_thumbnail so the backend suppression alone
    covers them.

  Regression tests pin cancel-only-pending behavior, soft-deleted
  suppression + archive_deleted=True flag, and the sanity guard that
  live-archive fields keep flowing through unchanged.
2026-05-16 12:58:21 +02:00

202 lines
7.6 KiB
Python

from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, PlainSerializer
# 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)]
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)
# 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: bool = True
flow_cali: bool = False
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
# Auto-print G-code injection
gcode_injection: bool = False
# Batch: create multiple copies (creates a batch if > 1)
quantity: int = 1
# Project to associate the resulting archive with
project_id: int | 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: bool | None = None
flow_cali: bool | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
# Auto-print G-code injection
gcode_injection: bool | None = None
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
position: int
scheduled_time: UTCDatetime
require_previous_success: bool
auto_off_after: bool
manual_start: bool
ams_mapping: list[int] | None = None
plate_id: int | None = None # Plate ID for multi-plate 3MF files
# Print options
bed_levelling: bool = True
flow_cali: bool = False
vibration_cali: bool = True
layer_inspect: bool = False
timelapse: bool = False
use_ams: bool = True
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)
# 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
# Shortest-job-first scheduling
been_jumped: bool = False
# Auto-print G-code injection
gcode_injection: bool = False
class Config:
from_attributes = True
class PrintQueueReorderItem(BaseModel):
id: int
position: int
class PrintQueueReorder(BaseModel):
items: list[PrintQueueReorderItem]
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: bool | None = None
flow_cali: bool | None = None
vibration_cali: bool | None = None
layer_inspect: bool | None = None
timelapse: bool | None = None
use_ams: bool | None = None
# Auto-print G-code injection
gcode_injection: bool | None = None
class PrintQueueBulkUpdateResponse(BaseModel):
"""Response for bulk update operation."""
updated_count: int
skipped_count: int # Items that were not pending
message: str
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
created_by_id: int | None = None
created_by_username: 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
class Config:
from_attributes = True