Add virtual printer Queue mode and sidebar badge indicators

Virtual Printer Changes:
- Add new "Queue" mode that archives files and adds them to print queue
- Rename modes: "Immediate" → "Archive", "Queue for Review" → "Review"
- Three modes now: Archive (immediate), Review (pending), Queue (print queue)
- Queue mode creates unassigned queue items (printer_id=null)

Print Queue Changes:
- Make printer_id nullable in PrintQueueItem model/schema
- Add support for unassigned queue items (no printer assigned)
- Queue page shows "Unassigned" filter option
- Edit modal allows assigning printer to unassigned items
- Unassigned items highlighted in orange

Sidebar Badge Indicators:
- Queue icon shows yellow badge with pending queue item count
- Archive icon shows blue badge with pending upload count
- Badges auto-update every 5 seconds and on window focus

Other:
- Update backup/restore to handle nullable printer_id and ams_mapping
- Add database migration for nullable printer_id column
- Update VirtualPrinterSettings tests for new modes
- Update virtual printer API integration tests
- Remove speed controls documentation from wiki (not implemented)
This commit is contained in:
maziggy
2026-01-17 13:30:00 +01:00
parent 699d7b0c71
commit 6fd11ddc77
19 changed files with 452 additions and 149 deletions
+29 -16
View File
@@ -64,7 +64,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
@router.get("/", response_model=list[PrintQueueItemResponse])
async def list_queue(
printer_id: int | None = Query(None, description="Filter by printer"),
printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
status: str | None = Query(None, description="Filter by status"),
db: AsyncSession = Depends(get_db),
):
@@ -72,11 +72,15 @@ async def list_queue(
query = (
select(PrintQueueItem)
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
.order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
.order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
)
if printer_id is not None:
query = query.where(PrintQueueItem.printer_id == printer_id)
if printer_id == -1:
# Special value: filter for unassigned items
query = query.where(PrintQueueItem.printer_id.is_(None))
else:
query = query.where(PrintQueueItem.printer_id == printer_id)
if status:
query = query.where(PrintQueueItem.status == status)
@@ -91,22 +95,31 @@ async def add_to_queue(
db: AsyncSession = Depends(get_db),
):
"""Add an item to the print queue."""
# Validate printer exists
result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
# Validate printer exists (if assigned)
if data.printer_id is not None:
result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
# Validate archive exists
result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
if not result.scalar_one_or_none():
raise HTTPException(400, "Archive not found")
# Get next position for this printer
result = await db.execute(
select(func.max(PrintQueueItem.position))
.where(PrintQueueItem.printer_id == data.printer_id)
.where(PrintQueueItem.status == "pending")
)
# Get next position for this printer (or for unassigned items)
if data.printer_id is not None:
result = await db.execute(
select(func.max(PrintQueueItem.position))
.where(PrintQueueItem.printer_id == data.printer_id)
.where(PrintQueueItem.status == "pending")
)
else:
# For unassigned items, get max position across all unassigned
result = await db.execute(
select(func.max(PrintQueueItem.position))
.where(PrintQueueItem.printer_id.is_(None))
.where(PrintQueueItem.status == "pending")
)
max_pos = result.scalar() or 0
item = PrintQueueItem(
@@ -127,7 +140,7 @@ async def add_to_queue(
# Load relationships for response
await db.refresh(item, ["archive", "printer"])
logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id}")
logger.info(f"Added archive {data.archive_id} to queue for printer {data.printer_id or 'unassigned'}")
# MQTT relay - publish queue job added
try:
@@ -176,8 +189,8 @@ async def update_queue_item(
update_data = data.model_dump(exclude_unset=True)
# Validate new printer_id if being changed
if "printer_id" in update_data:
# Validate new printer_id if being changed (and not None)
if "printer_id" in update_data and update_data["printer_id"] is not None:
result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
if not result.scalar_one_or_none():
raise HTTPException(400, "Printer not found")
+27 -9
View File
@@ -513,7 +513,7 @@ async def export_backup(
for qi in queue_items:
backup["print_queue"].append(
{
"printer_serial": printer_id_to_serial.get(qi.printer_id),
"printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None,
"archive_hash": archive_id_to_hash.get(qi.archive_id),
"project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None,
"position": qi.position,
@@ -521,6 +521,7 @@ async def export_backup(
"require_previous_success": qi.require_previous_success,
"auto_off_after": qi.auto_off_after,
"manual_start": qi.manual_start,
"ams_mapping": qi.ams_mapping,
"status": qi.status,
"started_at": qi.started_at.isoformat() if qi.started_at else None,
"completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
@@ -1519,32 +1520,44 @@ async def import_backup(
skipped_details["print_queue"] = []
for qi_data in backup["print_queue"]:
printer_serial = qi_data.get("printer_serial")
printer_serial = qi_data.get("printer_serial") # Can be None for unassigned items
archive_hash = qi_data.get("archive_hash")
if not printer_serial or not archive_hash:
# Archive is required, but printer can be None (unassigned)
if not archive_hash:
skipped["print_queue"] += 1
continue
printer_id = printer_serial_to_id.get(printer_serial)
# Look up printer_id (None if unassigned or printer not found)
printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
archive_id = archive_hash_to_id.get(archive_hash)
if not printer_id or not archive_id:
# Archive must exist, but printer is optional (unassigned items)
if not archive_id:
skipped["print_queue"] += 1
skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8] if archive_hash else 'N/A'}")
skipped_details["print_queue"].append(
f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}"
)
continue
# If printer_serial was specified but printer not found, skip
if printer_serial and not printer_id:
skipped["print_queue"] += 1
skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}")
continue
project_name = qi_data.get("project_name")
project_id = project_name_to_id.get(project_name) if project_name else None
qi = PrintQueueItem(
printer_id=printer_id,
printer_id=printer_id, # Can be None for unassigned items
archive_id=archive_id,
project_id=project_id,
position=qi_data.get("position", 0),
require_previous_success=qi_data.get("require_previous_success", False),
auto_off_after=qi_data.get("auto_off_after", False),
manual_start=qi_data.get("manual_start", False),
ams_mapping=qi_data.get("ams_mapping"),
status=qi_data.get("status", "pending"),
error_message=qi_data.get("error_message"),
)
@@ -1887,11 +1900,16 @@ async def update_virtual_printer_settings(
new_model = model if model is not None else current_model
# Validate mode
if new_mode not in ("immediate", "queue"):
# "review" is the new name for "queue" (pending review before archiving)
# "print_queue" archives and adds to print queue (unassigned)
if new_mode not in ("immediate", "queue", "review", "print_queue"):
return JSONResponse(
status_code=400,
content={"detail": "Mode must be 'immediate' or 'queue'"},
content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"},
)
# Normalize legacy "queue" to "review" for storage
if new_mode == "queue":
new_mode = "review"
# Validate model
if model is not None and model not in VIRTUAL_PRINTER_MODELS: