mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(queue): scope force-colour overrides to the plate the item prints (#2551)
Queueing several plates of one 3MF built a single filament-override list from every selected plate and posted that same list with each plate's item. A force_color_match entry blocks dispatch until the printer has that exact colour loaded, so a single-colour plate waited on the whole batch's palette. The same shared list also widened required_filament_types, making a PLA plate refuse every printer that lacked a sibling plate's PETG. Narrow the overrides to the slots the plate actually consumes, on create and on update -- in the backend, where the 3MF is, so it holds for every writer of the queue. Dispatch already re-parsed requirements per plate and keyed overrides by slot, so the dropped entries were inert there. When the plate's slots cannot be read the overrides are kept whole: an item waiting on a colour it does not need is visible, one that silently lost a forced colour prints in the wrong filament. Items queued before this would stay stuck with a waiting reason that explains nothing, so a startup migration re-scopes the pending ones. Printing and finished items keep their overrides -- that is a record of what they dispatched with, not an instruction.
This commit is contained in:
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.5b2] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- **Queueing several plates of one file with Force color match made every plate wait for every colour (#2551, reporter @bondjw07)** — A sliced multi-plate `.gcode.3mf`, each plate a single different PLA colour, queued to **Any X1C** with **Force color match** on. A printer with Army Blue loaded and idle should take the Army Blue plate. Instead every plate sat at Waiting on `PLA (Army Blue), PLA (Ash Grey), PLA (Sunshine Yellow)` — the colours of *all* the plates. Queue the same plates one at a time and it works, which is the tell. **One override list, handed to every plate.** The print dialog only tracks a selected plate when exactly one is selected; pick several and it asks the backend for the filaments of the *whole file*, which is the union across all plates. It builds its override list from that union — correctly, because the user does need to tick each colour once — and then posts **that same list** with each plate's queue item. A `force_color_match` entry means "do not dispatch until this printer has this exact colour loaded", and the scheduler enforces *all* of them, so each single-colour plate demanded the whole batch's palette. The reporter's own guess in the issue was exactly right. **The API is what fixes it.** The overrides are now narrowed to the slots the item's plate actually prints, at write time, on both create and edit — the backend is where the 3MF is, so this holds for every writer of the queue and not just the one dialog. Nothing changes for a single-plate job or for a whole-file job, where the union *is* the requirement. **A second, quieter version of the same bug.** Override types are merged into the item's `required_filament_types`, which is the gate that runs *before* colours are even considered. A shared list therefore also widened that gate: queue a PLA plate and a PETG plate together and the PLA one would refuse every printer that didn't also have PETG loaded, with no mention of colour anywhere in the reason. Narrowing the overrides closes that too. **Fails strict, never silent.** When the plate's slots can't be established — corrupt 3MF, source file gone — the overrides are kept whole rather than dropped. An item waiting on a colour it doesn't need is visible and fixable in ten seconds; an item that silently lost its forced colour prints in the wrong filament. **The plates already in your queue are repaired on upgrade.** Fixing the write path alone would have left every item queued before this release sitting exactly where it is — stuck, with a waiting reason that explains nothing — until the user worked out for himself that deleting and re-adding them was the cure. A startup migration re-scopes the pending items instead. Items that are already printing or done are left untouched: their overrides are a record of what they dispatched with, not an instruction. **Tests.** 6 cases on the API (each of three plates keeps only its own colour and its own slot id; a whole-file job still keeps all three; an unreadable 3MF keeps all three; a PLA plate's required types stay PLA when a PETG plate is queued alongside it; editing an item narrows its overrides too; moving an item to another plate re-scopes it). 5 on the repair (three stuck items each come back to their own colour; a second boot is a no-op; a printing item is not rewritten; a whole-file item keeps all three; a missing source file strips nothing). Mutation-verified — six of the eleven fail against the old code. The migration was run against a real PostgreSQL 16 as well as SQLite, twice over, to confirm it is dialect-neutral and idempotent.
|
||||
- **A project's tags vanished from the edit dialog when you opened it from the projects list — and its priority was quietly reset when you saved (#2536, reporter @fireboyff)** — Editing a project from the Projects list showed an empty tags field; opening the same project first and editing it from inside showed the tags correctly. **One dialog, two callers.** `ProjectModal` is shared: the detail page hands it a full project, the list hands it a list item. The list endpoint's payload never carried `tags`, `due_date` or `priority`, so from the list the dialog seeded those three fields from `undefined` and rendered them blank. It compiled because the component read them through a cast (`project as ProjectListItem & { tags?: string }`), which asserts a field the type does not have — so TypeScript never pointed out that the value was always missing. The fields are now on `ProjectListResponse` and on `ProjectListItem`, the casts are gone, and the compiler enforces the two shapes agreeing from here on. **The part nobody reported.** The dialog does not send tags when the field is empty, so the tags themselves survived — they were only invisible. Priority is not so lucky: it is *always* sent, defaulting to `normal`. So editing a **high** or **urgent** project from the list silently demoted it, and the reporter would have had no reason to connect that to the empty field he did see. Fixing the payload fixes both, since the dialog now receives the real priority to send back. **Clearing a tag list also never worked, from either view.** An emptied field was sent as `undefined`, which drops the key from the request, and the backend only applied values that were not null — so the old tags came straight back. Tags and due date now behave like budget and URL already did: sent as null, cleared explicitly, and an omitted key still means "leave it alone". **Tests.** 4 backend cases (the list and the template list both carry the fields the dialog renders; a partial update does not disturb a stored priority or tags; an explicit null clears tags and due date) — mutation-verified, three of them fail against the old payload. 3 frontend cases pin the dialog: it prefills all three from a list item, it round-trips a stored `high` instead of submitting its default, and clearing the tags field sends null. The templates list was missing `target_parts_count` too, which the same dialog edits; that is fixed in passing.
|
||||
- **Scheduled backups to a NAS failed with "Read-only file system" — and our own systemd unit was the reason (#2544, reporter @pwostran)** — Nightly backups to a mounted NAS share had run since May and then stopped, failing every night with `[Errno 30] Read-only file system`. The reporter checked the folder permissions, which were correct: his mount is `gid=backup,dir_mode=0775`, the service user is in `backup`, and his own shell writes to the share fine. **Errno 30 is EROFS, and EROFS is not a permission error** — a permission problem is errno 13. EROFS means the filesystem itself refused the write, and the filesystem refused it because *we told it to*. Bambuddy's systemd unit ships `ProtectSystem=strict`, which mounts the entire filesystem read-only inside the service's own mount namespace and carves back out only `ReadWritePaths=<install> <data> <logs>`. A NAS share is not one of those three. Reads still work — which is why the UI happily listed his existing backups from the share while being unable to create a new one — and the operator's shell is outside the namespace entirely, so every check he could think to run said the directory was fine. **How a working install broke.** Both installers write `/etc/systemd/system/bambuddy.service` wholesale, so any `ReadWritePaths` an operator had added by hand disappeared on the next install, along with their backups. That is now fixed at the source: the installers back the old unit up (`.bak-<timestamp>`) and **carry the operator's extra writable paths forward** into the new one, reporting which ones they kept. The unit template also documents the carve-out, since the next person to read it has to be able to work out why a directory they can write to is read-only for the service. **The failure is no longer silent, or cryptic.** The output directory is now probed with a real write when you save it and when the backup card loads, so a directory Bambuddy cannot write to is caught there and then rather than at 03:00 for a week. When the probe fails, the card names the actual cause and hands over the exact fix with the operator's own path already in it — `sudo systemctl edit bambuddy` → `[Service]` → `ReadWritePaths=/mnt/nasbackup` — instead of quoting an errno. A failed backup run reports the same diagnosis rather than the raw OSError. EROFS outside systemd, permission-denied, out-of-space, not-a-directory and missing are told apart and worded accordingly, in all 11 locales. **A Docker trap caught on the way past.** A backup path inside the container that was never bind-mounted from the host is *writable* — the write lands in the container's ephemeral layer and vanishes on the next `compose up`. A backup that silently goes nowhere is the one failure mode a backup feature must not have, so the probe compares the directory's device against the container root and warns when they match, with the compose snippet that mounts it properly. **Tests.** 15 backend cases: EROFS under systemd is diagnosed as the sandbox and yields a copy-pasteable drop-in; EROFS *outside* systemd does not blame a unit that doesn't exist; EACCES stays a permission problem; the unit name is read from the cgroup (plain, templated, and the fallback when there's no `.service` in it); the probe leaves no file behind in the backup list; a container-layer path is flagged while a mounted volume is not; a failed run surfaces the diagnosis and not the errno; and four pin the installers, so a reinstall can never again drop a writable path or overwrite a unit without a backup. Verified against a real read-only mount, not a mocked one — the classifier was run against an actual `mount -o ro` tmpfs and returned the reporter's exact errno with the right remedy. 4 frontend cases on the banner.
|
||||
- **Docker never shut down gracefully — every stop, restart and update was a SIGKILL** — `CMD ["sh", "-c", "uvicorn ..."]` left the shell as PID 1 with uvicorn as its child, and dash does not forward signals. So `docker stop` SIGTERMed the shell and **uvicorn never heard about it**. Measured on the shipped image: the stop ran the full 10-second grace period, the container exited **137** (SIGKILL), and the log contained no "Shutting down" line at all — it simply stopped dead after `Uvicorn running on ...`. That means the entire shutdown path had never once executed in Docker: no SQLite WAL checkpoint, no MQTT disconnect (the broker saw an ungraceful drop every time), no virtual-printer teardown, no printer disconnect, no `engine.dispose()`. Not "when a camera is streaming" — *always*, on every `docker stop`, `docker restart`, `compose down` and image update. The fix is one word: `CMD ["sh", "-c", "exec uvicorn ..."]`. With `exec`, uvicorn *is* PID 1 and receives the signal. Verified on a rebuilt image: PID 1 is now `uvicorn`, `docker stop` completes in **1 second** with **exit code 0**, and the log shows `Shutting down` → `WAL checkpoint completed` → `Application shutdown complete`.
|
||||
|
||||
@@ -35,6 +35,7 @@ from backend.app.schemas.print_queue import (
|
||||
PrintQueueReorder,
|
||||
)
|
||||
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
|
||||
from backend.app.services.filament_requirements import overrides_for_plate
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
|
||||
from backend.app.utils.threemf_tools import (
|
||||
@@ -115,6 +116,22 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
|
||||
_extract_print_time_from_3mf = extract_print_time_from_3mf
|
||||
|
||||
|
||||
async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path | None:
|
||||
"""Resolve an existing queue item's source 3MF on disk, or None."""
|
||||
if item.archive_id:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
if archive:
|
||||
return settings.base_dir / archive.file_path
|
||||
elif item.library_file_id:
|
||||
result = await db.execute(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
|
||||
library_file = result.scalar_one_or_none()
|
||||
if library_file:
|
||||
lib_path = Path(library_file.file_path)
|
||||
return lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
"""Add nested archive/printer/library_file info to response."""
|
||||
# Parse ams_mapping from JSON string BEFORE model_validate
|
||||
@@ -451,9 +468,9 @@ async def add_to_queue(
|
||||
|
||||
# Extract filament types for model-based assignment (used by scheduler for validation)
|
||||
required_filament_types = None
|
||||
file_path = None
|
||||
if target_model_norm:
|
||||
# Get file path from archive or library file
|
||||
file_path = None
|
||||
if archive:
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
elif library_file:
|
||||
@@ -469,15 +486,17 @@ async def add_to_queue(
|
||||
# If filament overrides are provided, update required_filament_types to match override types
|
||||
filament_overrides_json = None
|
||||
if data.filament_overrides and target_model_norm:
|
||||
filament_overrides_json = json.dumps(data.filament_overrides)
|
||||
# Update required_filament_types from overrides so scheduler validates against overridden types
|
||||
override_types = sorted({o["type"] for o in data.filament_overrides if "type" in o})
|
||||
if override_types:
|
||||
# Merge with existing types (overrides may only cover some slots)
|
||||
existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
|
||||
# Replace types for overridden slots, keep others
|
||||
all_types = existing_types | set(override_types)
|
||||
required_filament_types = json.dumps(sorted(all_types))
|
||||
plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
|
||||
if plate_overrides:
|
||||
filament_overrides_json = json.dumps(plate_overrides)
|
||||
# Update required_filament_types from overrides so scheduler validates against overridden types
|
||||
override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
|
||||
if override_types:
|
||||
# Merge with existing types (overrides may only cover some slots)
|
||||
existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
|
||||
# Replace types for overridden slots, keep others
|
||||
all_types = existing_types | set(override_types)
|
||||
required_filament_types = json.dumps(sorted(all_types))
|
||||
|
||||
# Validate quantity
|
||||
quantity = max(1, data.quantity)
|
||||
@@ -1084,11 +1103,18 @@ async def update_queue_item(
|
||||
if "ams_mapping" in update_data:
|
||||
update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
|
||||
|
||||
# Serialize filament_overrides to JSON for TEXT column storage
|
||||
# Serialize filament_overrides to JSON for TEXT column storage, keeping only
|
||||
# the slots this item's plate actually prints (#2551 — same shared-override
|
||||
# list the create path narrows).
|
||||
if "filament_overrides" in update_data:
|
||||
update_data["filament_overrides"] = (
|
||||
json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
|
||||
)
|
||||
overrides = update_data["filament_overrides"]
|
||||
if overrides:
|
||||
overrides = overrides_for_plate(
|
||||
overrides,
|
||||
await _resolve_source_path(db, item),
|
||||
update_data.get("plate_id", item.plate_id),
|
||||
)
|
||||
update_data["filament_overrides"] = json.dumps(overrides) if overrides else None
|
||||
|
||||
# Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
|
||||
# storage; same Text-as-opaque-blob convention as ams_mapping above.
|
||||
|
||||
@@ -439,6 +439,77 @@ async def _migrate_normalize_printer_ids(conn) -> None:
|
||||
await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
|
||||
|
||||
|
||||
async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
|
||||
"""Re-scope queue items that carry another plate's filament overrides (#2551).
|
||||
|
||||
Queueing several plates of one 3MF used to store the union of every selected
|
||||
plate's overrides on each item, so a ``force_color_match`` plate printing one
|
||||
colour sat at Waiting until a printer had the whole batch's palette loaded.
|
||||
The write paths now narrow to the plate, but items queued before the fix would
|
||||
stay stuck until the user deleted and re-added them by hand — with a waiting
|
||||
reason that gives no hint as to why. Repair them here instead.
|
||||
|
||||
Only pending items are touched: a printing or finished item's overrides are a
|
||||
record of what it dispatched with, not an instruction. An item whose plate we
|
||||
cannot read keeps every override, per ``overrides_for_plate``. Idempotent —
|
||||
an already-scoped item narrows to itself and is not rewritten.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from backend.app.services.filament_requirements import overrides_for_plate
|
||||
|
||||
rows = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT q.id, q.plate_id, q.filament_overrides, "
|
||||
"a.file_path AS archive_path, l.file_path AS library_path "
|
||||
"FROM print_queue q "
|
||||
"LEFT JOIN print_archives a ON a.id = q.archive_id "
|
||||
"LEFT JOIN library_files l ON l.id = q.library_file_id "
|
||||
"WHERE q.status = 'pending' "
|
||||
"AND q.plate_id IS NOT NULL "
|
||||
"AND q.filament_overrides IS NOT NULL"
|
||||
)
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
repaired = 0
|
||||
for row in rows:
|
||||
try:
|
||||
overrides = json.loads(row.filament_overrides)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(overrides, list) or not overrides:
|
||||
continue
|
||||
|
||||
stored_path = row.archive_path or row.library_path
|
||||
if not stored_path:
|
||||
continue
|
||||
path = Path(stored_path)
|
||||
if not path.is_absolute():
|
||||
path = settings.base_dir / stored_path
|
||||
|
||||
scoped = overrides_for_plate(overrides, path, row.plate_id)
|
||||
if len(scoped) == len(overrides):
|
||||
continue
|
||||
|
||||
async with conn.begin_nested():
|
||||
await conn.execute(
|
||||
text("UPDATE print_queue SET filament_overrides = :overrides WHERE id = :id"),
|
||||
{"overrides": json.dumps(scoped) if scoped else None, "id": row.id},
|
||||
)
|
||||
repaired += 1
|
||||
|
||||
if repaired:
|
||||
logger.info(
|
||||
"Re-scoped the filament overrides of %d queued item(s) to the plate they print (#2551)",
|
||||
repaired,
|
||||
)
|
||||
|
||||
|
||||
async def _migrate_drop_library_print_name(conn) -> None:
|
||||
"""Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
|
||||
|
||||
@@ -3179,6 +3250,10 @@ async def run_migrations(conn):
|
||||
# file metadata so the FileManager displays the filename, not the title (#1489).
|
||||
await _migrate_drop_library_print_name(conn)
|
||||
|
||||
# Data migration: queue items written before #2551 carry every selected plate's
|
||||
# filament overrides, so a force-colour plate waits on colours it never prints.
|
||||
await _migrate_scope_force_color_overrides_to_plate(conn)
|
||||
|
||||
# Backfill NULL print_archives.created_at — older rows (and rows imported
|
||||
# via the SQLite ↔ Postgres cross-DB restore path) can land with NULL
|
||||
# because the column was originally created without a DEFAULT clause and
|
||||
|
||||
@@ -102,6 +102,57 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
|
||||
return filaments
|
||||
|
||||
|
||||
def overrides_for_plate(
|
||||
overrides: list[dict],
|
||||
file_path: Path | None,
|
||||
plate_id: int | None,
|
||||
) -> list[dict]:
|
||||
"""Drop the filament overrides whose slots this plate never prints.
|
||||
|
||||
Queueing several plates of one 3MF builds a single override list out of every
|
||||
selected plate's filaments and hands that same list to each plate's item. A
|
||||
``force_color_match`` entry blocks dispatch until the printer has that exact
|
||||
colour loaded, so a single-colour plate ended up waiting on every colour in
|
||||
the batch (#2551). Each item may only demand what its own plate consumes.
|
||||
|
||||
Overrides are kept as-is when the plate's slots cannot be established (whole
|
||||
file selected, source gone, unreadable 3MF, malformed entry): an item that
|
||||
waits on a colour it does not need is visible and fixable, whereas one that
|
||||
silently loses a forced colour can dispatch the print in the wrong filament.
|
||||
"""
|
||||
if not overrides or plate_id is None or file_path is None or not file_path.exists():
|
||||
return overrides
|
||||
|
||||
plate_slots = {f["slot_id"] for f in extract_filament_requirements(file_path, plate_id)}
|
||||
if not plate_slots:
|
||||
logger.warning(
|
||||
"Cannot read the filaments of plate %s in %s; keeping all %d filament override(s)",
|
||||
plate_id,
|
||||
file_path.name,
|
||||
len(overrides),
|
||||
)
|
||||
return overrides
|
||||
|
||||
narrowed = []
|
||||
for override in overrides:
|
||||
try:
|
||||
slot_id = int(override["slot_id"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
narrowed.append(override)
|
||||
continue
|
||||
if slot_id in plate_slots:
|
||||
narrowed.append(override)
|
||||
|
||||
if len(narrowed) != len(overrides):
|
||||
logger.info(
|
||||
"Plate %s: kept %d of %d filament override(s) — the rest belong to other plates",
|
||||
plate_id,
|
||||
len(narrowed),
|
||||
len(overrides),
|
||||
)
|
||||
return narrowed
|
||||
|
||||
|
||||
def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
|
||||
"""Walk every `./filament` child under `parent` and append normalised
|
||||
entries to `into`. Skips filaments with `used_g <= 0` (slot present in
|
||||
|
||||
@@ -2807,3 +2807,260 @@ class TestReorderEndpoint:
|
||||
await db_session.refresh(item2)
|
||||
assert item1.position == 2
|
||||
assert item2.position == 1
|
||||
|
||||
|
||||
class TestForceColorOverridesAreScopedToThePlate:
|
||||
"""Queueing several plates of one 3MF must not make each plate wait on the
|
||||
colours of its siblings (#2551).
|
||||
|
||||
The print dialog builds one override list from every selected plate and posts
|
||||
that same list with each plate's item, so the API is what has to keep only the
|
||||
slots the plate prints -- a ``force_color_match`` entry blocks dispatch until
|
||||
the printer has that exact colour loaded.
|
||||
"""
|
||||
|
||||
THREE_PLATES = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<filament id="1" used_g="50.0" type="PLA" color="#0B2C7A"/>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="2"/>
|
||||
<filament id="2" used_g="40.0" type="PLA" color="#9B9EA0"/>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="3"/>
|
||||
<filament id="3" used_g="30.0" type="PLA" color="#F4EE2A"/>
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
|
||||
# What the dialog posts for every plate: the union of all three plates'
|
||||
# filaments, each one force-matched.
|
||||
ALL_THREE_COLORS = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#0B2C7A", "color_name": "Army Blue", "force_color_match": True},
|
||||
{"slot_id": 2, "type": "PLA", "color": "#9B9EA0", "color_name": "Ash Grey", "force_color_match": True},
|
||||
{"slot_id": 3, "type": "PLA", "color": "#F4EE2A", "color_name": "Sunshine Yellow", "force_color_match": True},
|
||||
]
|
||||
|
||||
@pytest.fixture
|
||||
async def multi_plate_archive(self, db_session, tmp_path):
|
||||
"""An archive whose 3MF really exists on disk, one colour per plate."""
|
||||
import zipfile
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
file_path = tmp_path / "three_plates.gcode.3mf"
|
||||
with zipfile.ZipFile(file_path, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", self.THREE_PLATES)
|
||||
|
||||
archive = PrintArchive(
|
||||
filename="three_plates.gcode.3mf",
|
||||
print_name="Three Plates",
|
||||
file_path=str(file_path),
|
||||
file_size=file_path.stat().st_size,
|
||||
content_hash="platehash0001",
|
||||
status="completed",
|
||||
)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
return archive
|
||||
|
||||
@pytest.fixture
|
||||
async def x1c(self, db_session):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
printer = Printer(
|
||||
name="Force Color X1C",
|
||||
ip_address="192.168.1.210",
|
||||
serial_number="FORCECOLOR01",
|
||||
access_code="12345678",
|
||||
model="X1C",
|
||||
)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
return printer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_each_plate_keeps_only_the_colour_it_prints(
|
||||
self, async_client: AsyncClient, multi_plate_archive, x1c
|
||||
):
|
||||
"""The bug: plate 1 prints Army Blue only, but was stored demanding all three."""
|
||||
stored = {}
|
||||
for plate_id in (1, 2, 3):
|
||||
response = await async_client.post(
|
||||
"/api/v1/queue/",
|
||||
json={
|
||||
"target_model": "X1C",
|
||||
"archive_id": multi_plate_archive.id,
|
||||
"plate_id": plate_id,
|
||||
"filament_overrides": self.ALL_THREE_COLORS,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
stored[plate_id] = response.json()["filament_overrides"]
|
||||
|
||||
assert [o["color_name"] for o in stored[1]] == ["Army Blue"]
|
||||
assert [o["color_name"] for o in stored[2]] == ["Ash Grey"]
|
||||
assert [o["color_name"] for o in stored[3]] == ["Sunshine Yellow"]
|
||||
# The slot each entry maps to has to survive narrowing untouched, or the
|
||||
# dispatch-time AMS mapping would key the override onto the wrong slot.
|
||||
assert [o["slot_id"] for o in stored[2]] == [2]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_whole_file_queue_keeps_every_colour(self, async_client: AsyncClient, multi_plate_archive, x1c):
|
||||
"""No plate_id means the job prints the whole file, so every colour is needed."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/queue/",
|
||||
json={
|
||||
"target_model": "X1C",
|
||||
"archive_id": multi_plate_archive.id,
|
||||
"filament_overrides": self.ALL_THREE_COLORS,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["filament_overrides"]) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unreadable_3mf_keeps_every_colour(self, async_client: AsyncClient, db_session, tmp_path, x1c):
|
||||
"""When the plate's slots can't be read, keep the overrides rather than drop them.
|
||||
|
||||
An item waiting on a colour it doesn't need is visible and fixable; one that
|
||||
silently lost its forced colour would dispatch in the wrong filament.
|
||||
"""
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
file_path = tmp_path / "not_a_zip.gcode.3mf"
|
||||
file_path.write_text("this is not a 3mf")
|
||||
archive = PrintArchive(
|
||||
filename="not_a_zip.gcode.3mf",
|
||||
print_name="Corrupt",
|
||||
file_path=str(file_path),
|
||||
file_size=file_path.stat().st_size,
|
||||
content_hash="platehash0002",
|
||||
status="completed",
|
||||
)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/queue/",
|
||||
json={
|
||||
"target_model": "X1C",
|
||||
"archive_id": archive.id,
|
||||
"plate_id": 1,
|
||||
"filament_overrides": self.ALL_THREE_COLORS,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["filament_overrides"]) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_required_types_stay_scoped_to_the_plate(self, async_client: AsyncClient, db_session, tmp_path, x1c):
|
||||
"""Override types are merged into required_filament_types, so a shared list
|
||||
also widened the type gate -- a PLA-only plate demanded PETG as well."""
|
||||
import zipfile
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<filament id="1" used_g="50.0" type="PLA" color="#0B2C7A"/>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="2"/>
|
||||
<filament id="2" used_g="40.0" type="PETG" color="#9B9EA0"/>
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
file_path = tmp_path / "mixed_types.gcode.3mf"
|
||||
with zipfile.ZipFile(file_path, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", xml)
|
||||
archive = PrintArchive(
|
||||
filename="mixed_types.gcode.3mf",
|
||||
print_name="Mixed",
|
||||
file_path=str(file_path),
|
||||
file_size=file_path.stat().st_size,
|
||||
content_hash="platehash0003",
|
||||
status="completed",
|
||||
)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/queue/",
|
||||
json={
|
||||
"target_model": "X1C",
|
||||
"archive_id": archive.id,
|
||||
"plate_id": 1,
|
||||
"filament_overrides": [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#0B2C7A", "force_color_match": True},
|
||||
{"slot_id": 2, "type": "PETG", "color": "#9B9EA0", "force_color_match": True},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["required_filament_types"] == ["PLA"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_editing_an_item_narrows_the_overrides_too(
|
||||
self, async_client: AsyncClient, db_session, multi_plate_archive, x1c
|
||||
):
|
||||
"""The edit dialog posts the same shared list, so PATCH narrows it as well."""
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
item = PrintQueueItem(
|
||||
target_model="X1C",
|
||||
archive_id=multi_plate_archive.id,
|
||||
plate_id=2,
|
||||
status="pending",
|
||||
position=1,
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/queue/{item.id}",
|
||||
json={"filament_overrides": self.ALL_THREE_COLORS},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Ash Grey"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_editing_the_plate_renarrows_against_the_new_plate(
|
||||
self, async_client: AsyncClient, db_session, multi_plate_archive, x1c
|
||||
):
|
||||
"""Moving an item to another plate must re-scope its colours to that plate."""
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
item = PrintQueueItem(
|
||||
target_model="X1C",
|
||||
archive_id=multi_plate_archive.id,
|
||||
plate_id=1,
|
||||
status="pending",
|
||||
position=1,
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/queue/{item.id}",
|
||||
json={"plate_id": 3, "filament_overrides": self.ALL_THREE_COLORS},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Sunshine Yellow"]
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Regression test for the force-colour override re-scoping migration (#2551).
|
||||
|
||||
Queueing several plates of one 3MF used to store the union of every selected
|
||||
plate's filament overrides on each item, so a `force_color_match` plate printing
|
||||
a single colour sat at Waiting until a printer had the whole batch's palette
|
||||
loaded. The write paths now scope overrides to the plate; this migration repairs
|
||||
the items queued before the fix, which would otherwise stay stuck forever with a
|
||||
waiting reason that explains nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from backend.app.core.database import run_migrations
|
||||
|
||||
THREE_PLATES = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<filament id="1" used_g="50.0" type="PLA" color="#0B2C7A"/>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="2"/>
|
||||
<filament id="2" used_g="40.0" type="PLA" color="#9B9EA0"/>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="3"/>
|
||||
<filament id="3" used_g="30.0" type="PLA" color="#F4EE2A"/>
|
||||
</plate>
|
||||
</config>
|
||||
"""
|
||||
|
||||
ALL_THREE_COLORS = [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#0B2C7A", "color_name": "Army Blue", "force_color_match": True},
|
||||
{"slot_id": 2, "type": "PLA", "color": "#9B9EA0", "color_name": "Ash Grey", "force_color_match": True},
|
||||
{"slot_id": 3, "type": "PLA", "color": "#F4EE2A", "color_name": "Sunshine Yellow", "force_color_match": True},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def force_sqlite_dialect(monkeypatch):
|
||||
"""Force the SQLite branch regardless of test env settings."""
|
||||
from backend.app.core import database as database_module, db_dialect
|
||||
|
||||
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
|
||||
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
|
||||
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
|
||||
|
||||
|
||||
def _register_all_models():
|
||||
from backend.app.models import ( # noqa: F401
|
||||
ams_history,
|
||||
ams_label,
|
||||
api_key,
|
||||
archive,
|
||||
color_catalog,
|
||||
external_link,
|
||||
filament,
|
||||
group,
|
||||
kprofile_note,
|
||||
library,
|
||||
maintenance,
|
||||
notification,
|
||||
notification_template,
|
||||
print_log,
|
||||
print_queue,
|
||||
printer,
|
||||
project,
|
||||
project_bom,
|
||||
settings,
|
||||
slot_preset,
|
||||
smart_plug,
|
||||
smart_plug_energy_snapshot,
|
||||
spool,
|
||||
spool_assignment,
|
||||
spool_catalog,
|
||||
spool_k_profile,
|
||||
spool_usage_history,
|
||||
spoolbuddy_device,
|
||||
user,
|
||||
user_email_pref,
|
||||
virtual_printer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def engine():
|
||||
from backend.app.core.database import Base
|
||||
|
||||
_register_all_models()
|
||||
|
||||
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield eng
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def three_plate_3mf(tmp_path):
|
||||
path = tmp_path / "three_plates.gcode.3mf"
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr("Metadata/slice_info.config", THREE_PLATES)
|
||||
return path
|
||||
|
||||
|
||||
async def _seed(engine, *, file_path, items) -> None:
|
||||
"""Insert one archive plus the queue items pointing at it.
|
||||
|
||||
Goes through the models rather than raw INSERTs so the columns this test
|
||||
doesn't care about get their defaults.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
|
||||
session.add(
|
||||
PrintArchive(
|
||||
id=1,
|
||||
filename="three_plates.gcode.3mf",
|
||||
file_path=str(file_path),
|
||||
file_size=0,
|
||||
status="completed",
|
||||
)
|
||||
)
|
||||
for item_id, plate_id, overrides, status in items:
|
||||
session.add(
|
||||
PrintQueueItem(
|
||||
id=item_id,
|
||||
archive_id=1,
|
||||
target_model="X1C",
|
||||
plate_id=plate_id,
|
||||
filament_overrides=json.dumps(overrides),
|
||||
status=status,
|
||||
position=item_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _overrides(conn, item_id: int):
|
||||
raw = (
|
||||
await conn.execute(text("SELECT filament_overrides FROM print_queue WHERE id = :id"), {"id": item_id})
|
||||
).scalar_one()
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_each_stuck_item_is_rescoped_to_its_own_plate(engine, three_plate_3mf):
|
||||
"""The reporter's queue: three plates, each item demanding all three colours."""
|
||||
await _seed(
|
||||
engine,
|
||||
file_path=three_plate_3mf,
|
||||
items=[
|
||||
(1, 1, ALL_THREE_COLORS, "pending"),
|
||||
(2, 2, ALL_THREE_COLORS, "pending"),
|
||||
(3, 3, ALL_THREE_COLORS, "pending"),
|
||||
],
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert [o["color_name"] for o in await _overrides(conn, 1)] == ["Army Blue"]
|
||||
assert [o["color_name"] for o in await _overrides(conn, 2)] == ["Ash Grey"]
|
||||
assert [o["color_name"] for o in await _overrides(conn, 3)] == ["Sunshine Yellow"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_it_is_idempotent(engine, three_plate_3mf):
|
||||
"""Every boot re-runs the migration set; an already-scoped item narrows to
|
||||
itself and must not be rewritten or emptied."""
|
||||
await _seed(engine, file_path=three_plate_3mf, items=[(1, 2, ALL_THREE_COLORS, "pending")])
|
||||
|
||||
for _ in range(2):
|
||||
async with engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert [o["slot_id"] for o in await _overrides(conn, 1)] == [2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_dispatched_item_is_left_alone(engine, three_plate_3mf):
|
||||
"""A printing item's overrides record what it dispatched with — that is
|
||||
history, not an instruction, and rewriting it would falsify the record."""
|
||||
await _seed(engine, file_path=three_plate_3mf, items=[(1, 1, ALL_THREE_COLORS, "printing")])
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert len(await _overrides(conn, 1)) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_whole_file_item_keeps_every_colour(engine, three_plate_3mf):
|
||||
"""No plate_id means the item prints the whole file, so it really does need
|
||||
all three colours."""
|
||||
await _seed(engine, file_path=three_plate_3mf, items=[(1, None, ALL_THREE_COLORS, "pending")])
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert len(await _overrides(conn, 1)) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_source_file_does_not_strip_the_overrides(engine, tmp_path):
|
||||
"""The archive's 3MF is gone, so the plate's slots cannot be read. Keep the
|
||||
overrides: an item waiting on a colour it does not need is visible and
|
||||
fixable, one that silently lost a forced colour prints in the wrong filament.
|
||||
"""
|
||||
await _seed(engine, file_path=tmp_path / "deleted.gcode.3mf", items=[(1, 1, ALL_THREE_COLORS, "pending")])
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await run_migrations(conn)
|
||||
|
||||
async with engine.connect() as conn:
|
||||
assert len(await _overrides(conn, 1)) == 3
|
||||
Reference in New Issue
Block a user