Fix cross-model queue items being misrepresented and editable into a broken state (#671)

The edit dialog offered a printer picker and target-model dropdown for
an item with alternatives. Saving left a row with variants AND a
printer_id, and the scheduler's fixed-printer branch wins that race, so
it dispatched a row whose library_file_id is still null and failed in
the upload. PATCH now refuses printer/model changes on such an item —
comparing against the current value, since the dialog re-sends
target_model unchanged — and the route eager-loads variants, without
which the guard could not see them and every PATCH response dropped the
alternatives from its payload.

Names come from a shared helper now. A cross-model item holds neither
archive_id nor library_file_id until dispatch, so five separate inlined
fallbacks all rendered "File #null"; they now read "x1c.gcode.3mf +1
more".

The queue also grouped these under "Any H2D" — the first candidate
mirrored onto the row — filing a job under a printer it might never run
on. It groups as "Any H2D / X1C", matching the row beneath it.
This commit is contained in:
maziggy
2026-08-03 12:33:19 +02:00
parent ef7c1b21f1
commit ea63355fde
25 changed files with 593 additions and 198 deletions
+26 -1
View File
@@ -1348,7 +1348,14 @@ async def update_queue_item(
"""Update a queue item."""
user, can_modify_all = auth_result
result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
result = await db.execute(
select(PrintQueueItem)
# Needed by the cross-model guard below, and by the response builder —
# without it _variant_summaries falls back to [] and a PATCH would strip
# the alternatives out of the payload it echoes back.
.options(selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file))
.where(PrintQueueItem.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(404, "Queue item not found")
@@ -1376,6 +1383,24 @@ async def update_queue_item(
if "target_model" in update_data and update_data["target_model"]:
update_data["target_model"] = normalize_model_name(update_data["target_model"])
# A cross-model item (#671) owns its own printer decision: each candidate
# carries its model, and the resolver folds the winner onto the row at
# dispatch. Assigning a printer here would leave a row with variants *and* a
# printer_id, and the fixed-printer branch of the scheduler wins that race —
# so it would dispatch a row whose library_file_id is still null and die in
# the upload. Narrowing target_model is refused for the same reason: it
# would silently discard every alternative the user queued.
#
# Compared against the current value rather than merely present, because the
# edit dialog re-sends target_model unchanged on every save.
if item.variants:
for field in ("printer_id", "target_model"):
if field in update_data and update_data[field] != getattr(item, field):
raise HTTPException(
400,
"This job has printer alternatives — remove them before assigning a printer or model",
)
# Cannot specify both printer_id and target_model
new_printer_id = update_data.get("printer_id", item.printer_id)
new_target_model = update_data.get("target_model", item.target_model)