mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -222,6 +222,58 @@ class TestQueueWithVariants:
|
||||
assert r.status_code == 400
|
||||
assert "No active printers" in r.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_assigning_a_printer_is_refused(self, async_client, db_session, sliced_file_factory, printer_factory):
|
||||
"""The edit dialog offers a printer picker for every queue item. Taking it
|
||||
would leave a row with variants AND a printer_id — and the fixed-printer
|
||||
branch of the scheduler wins that race, dispatching a row whose
|
||||
library_file_id is still null."""
|
||||
printer = await printer_factory(model="H2S")
|
||||
await printer_factory(model="H2C")
|
||||
h2s = await sliced_file_factory("H2S")
|
||||
h2c = await sliced_file_factory("H2C")
|
||||
item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
|
||||
|
||||
r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"printer_id": printer.id})
|
||||
assert r.status_code == 400
|
||||
assert "alternatives" in r.json()["detail"]
|
||||
|
||||
assert len(await _variants_of(db_session, item_id)) == 2, "the alternatives survive the refusal"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_narrowing_to_one_model_is_refused(self, async_client, sliced_file_factory, printer_factory):
|
||||
"""Saving "Any H2C" over a two-candidate job would silently discard the
|
||||
H2S alternative the user deliberately queued."""
|
||||
await printer_factory(model="H2S")
|
||||
await printer_factory(model="H2C")
|
||||
h2s = await sliced_file_factory("H2S")
|
||||
h2c = await sliced_file_factory("H2C")
|
||||
item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
|
||||
|
||||
r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"target_model": "H2C"})
|
||||
assert r.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resending_the_unchanged_model_is_allowed(self, async_client, sliced_file_factory, printer_factory):
|
||||
"""The edit dialog re-sends target_model on every save, so an unchanged
|
||||
value must not block editing the schedule or print options."""
|
||||
await printer_factory(model="H2S")
|
||||
await printer_factory(model="H2C")
|
||||
h2s = await sliced_file_factory("H2S")
|
||||
h2c = await sliced_file_factory("H2C")
|
||||
created = (await _queue_variants(async_client, h2s.id, h2c.id)).json()
|
||||
|
||||
r = await async_client.patch(
|
||||
f"/api/v1/queue/{created['id']}",
|
||||
json={"target_model": created["target_model"], "timelapse": True},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["timelapse"] is True
|
||||
assert len(r.json()["variants"]) == 2, "the response still carries the alternatives"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_quantity_gives_each_copy_its_own_candidates(
|
||||
|
||||
Reference in New Issue
Block a user