diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9e79660..c62a3b290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to Bambuddy will be documented in this file. -## [0.2.4b1] - Unreleased +## [0.2.4b1] - 2026-04-29 ### Added - **Enhanced filament colour handling: multi-colour gradients, transparency, visual effects** ([#1154](https://github.com/maziggy/bambuddy/issues/1154)) — A solid hex swatch is the wrong abstraction for a tri-colour, gradient, or sparkle filament — the colour you saw on the spool inventory page was just whatever Bambu's firmware reported as the dominant tone, and there was no way to record what the spool actually looked like. The Spool form's "Colour" section now accepts a paste of up to 8 comma-separated hex stops (`EC984C,#6CD4BC,A66EB9,D87694` — exact format from 3dfilamentprofiles.com) and renders them as a CSS gradient on every swatch site (inventory grid, table, group banner, card, ColorSection preview, color-catalog admin). A new **Effect** dropdown — covering surface effects (Sparkle / Wood / Marble / Glow / Matte), sheen variants (Silk / Galaxy / Rainbow / Metal / Translucent), and structural variants (Gradient / Dual Color / Tri Color / Multicolor) — layers a CSS overlay on top of the colour layer (or, for Multicolor, switches the colour layer to a conic-gradient even when no spool subtype is set, so the catalog editor can flag a multicolor variant directly without needing a paired Spool row). Independent of `subtype` — so the user can override the visual hint without touching Bambu's categorical filament label or the MQTT auto-detection chain. **Transparency is now actually visible**: the existing `rgba` column has always stored an alpha byte but every render site flattened it with `substring(0, 6)`; the new shared `` component renders against a checkerboard layer beneath the colour layer so any alpha < 0xFF shows through (matches the convention used by image editors and 3dfilamentprofiles.com). **Multicolor subtype** swaps the linear gradient for a `conic-gradient` so the swatch reads as a colour wheel pie instead of a stripe — visually distinguishes a true multi-colour spool from a 2-stop gradient. **Colour-catalog parity**: the same fields land on `ColorCatalogEntry` (Settings → Color Catalog) so a user can save a multi-colour combo once and pick it from the catalog palette across spools — added inline to both the Add form and the inline-edit row, threaded through the JSON export/import path so catalog backups round-trip the new fields. Catalog `hex_color` regex extended to optionally accept `#RRGGBBAA` for transparency-aware catalog entries (backward-compatible — existing 6-char rows still validate). **Schema validation** (`backend/app/schemas/spool.py::normalize_extra_colors` + `normalize_effect_type` — public so `ColorEntryCreate` / `ColorEntryUpdate` can reuse them): comma-separated hex with 6-or-8-char tokens, lowercase canonical form, `#` prefix stripped, max-8-stop cap, empty tokens dropped (so a degenerate paste like `,,FF0000,` survives), invalid tokens rejected at the Pydantic layer with a precise field error. Effect type validated against the fixed set `{sparkle, wood, marble, glow, matte, silk, galaxy, rainbow, metal, translucent, gradient, dual-color, tri-color, multicolor}` — paste-friendly normaliser tolerates `Dual Color` / `dual_color` / `dual-color` and canonicalises to `dual-color`. Both validators live next to `SpoolBase` and are reused by `ColorEntryCreate` / `ColorEntryUpdate` so spool-side and catalog-side rejection rules can never drift. **Frontend swatch component** is one shared `` (and `buildFilamentBackground()` helper for callers that want just the CSS background-image string for a banner) — used by InventoryPage table, group banner, SpoolCard, ColorSection preview, and ColorCatalogSettings — so there's exactly one place that decides how a filament looks. Colour layer is built as a list of CSS images (no `background:` shorthand) so jsdom and every browser parse it consistently; checkerboard layer is the one that makes alpha visible. Migrations are 4 idempotent `ALTER TABLE ... ADD COLUMN` (Postgres-safe, no `DEFAULT 0` traps) plus a Postgres-only widen of `color_catalog.hex_color` to `VARCHAR(9)`. **i18n**: 12 new keys under `inventory.*` across all 8 locales (en/de/zh-CN/zh-TW fully translated; fr/it/ja/pt-BR seeded with English copies pending native translation, matching the project's flow for newly-added user-facing features). 42 new backend tests (35 unit + 7 integration) covering the normalizer (paste-from-3dfilamentprofiles canonicalisation, whitespace tolerance, mixed 6/8-char, empty-token drop, max-stop cap, invalid-hex rejection, wrong-length rejection, `Dual Color` / `dual_color` → `dual-color` canonicalisation), effect-type validator across all 14 allowed values, end-to-end POST/PUT/PATCH round-trip on both spool + catalog routes, 8-char `hex_color` acceptance, dedupe-on-update, and field clearing via empty string vs explicit null. 20 new frontend tests covering FilamentSwatch (14 — solid render, multi-stop linear gradient, conic for Multicolor *subtype* AND for `multicolor` *effect_type* via the catalog path, surface-effect overlays for sparkle and silk, categorical-only-no-overlay for gradient/dual-color, unknown-effect-ignored, checkerboard rendering for alpha, invalid-hex skip in stops, title fallback), `buildFilamentBackground` helper, ColorCatalogSettings (3 — Add form sends extra_colors + effect_type, full 14-value dropdown, inline edit hydrates from existing entry), and InventoryPage spool grouping (3 — different extra_colors don't collapse, different effect_type don't collapse, identical multi-colour spools still group). Out of scope for V1: gradient stop *positions* (e.g. 25%/75%), MQTT-side auto-import of multi-colour from Bambu (firmware doesn't expose this), per-effect tunable parameters — the current shape closes the user's actual paste-from-3dfilamentprofiles workflow without taking on a structured-stop-position editor. @@ -47,6 +47,7 @@ All notable changes to Bambuddy will be documented in this file. - **i18n: full key parity across all 8 locales** — `en` is the reference; every other locale (`de`, `fr`, `it`, `ja`, `pt-BR`, `zh-CN`, `zh-TW`) is checked identically and any drift fails CI. Until now, the parity script at `frontend/scripts/check-i18n-parity.mjs` only enforced parity for `de` / `zh-CN` / `zh-TW` and demoted `fr` / `it` / `ja` / `pt-BR` to an "informational" tier — drift was reported but never gated. Result: 78 missing keys in fr and it, 66 in pt-BR, 54 in ja, accumulated across every release that added new en strings. Backfilled real translations (not English fallbacks) for every gap: `login.resetPassword.*` (12 keys, fr/it/ja), `printers.firmwareModal.*` extension (7 keys × 4 locales) from the firmware modal redesign, the full `settings.spoolbuddy.*` device-control admin block (~40 keys) for unregister / reboot / shutdown / update / restart confirms, the kiosk-side `spoolbuddy.settings.*` block (13 keys × 4) for backend & auth + diagnostics, and the new `virtualPrinter.archiveNameSource.*` block from this release ([#1152](https://github.com/maziggy/bambuddy/issues/1152)). The parity script itself dropped the two-tier `STRICT` / `info` machinery — every non-en locale is now treated equally — so any future feature that adds en strings without translating them everywhere fails CI uniformly. All 8 locales sit at 4492 leaves. ### Fixed +- **Postgres restore from a SQLite Local Backup aborted with `cannot drop table printers`** — Settings → Backup → Restore on a Bambuddy running against external Postgres failed with `asyncpg.exceptions.DependentObjectsStillExistError: cannot drop table printers because other objects depend on it` whenever the live database carried orphan tables from removed features — for example legacy `spoolman_slot_assignments` / `spoolman_k_profile` from an earlier Spoolman integration that has since been removed from the ORM but whose tables and `*_printer_id_fkey` constraints still sat in the live schema, pointing at `printers`. The restore path (`_import_sqlite_to_postgres` in `settings.py`) called `metadata.drop_all`, which only enumerates tables defined by SQLAlchemy ORM models and emits plain `DROP TABLE` (no `CASCADE`); Postgres correctly refused to drop `printers` while external constraints still referenced it, the entire restore aborted before any rows landed, and the user was left without a working DB. The drop phase now executes `DROP TABLE … CASCADE` on every table in the `public` schema (via a `pg_tables`-iterating PL/pgSQL `DO` block, after FKs have been stripped from the ORM metadata) before `metadata.create_all` rebuilds the schema. CASCADE is the right tool for a destructive restore — the user has explicitly chosen to wipe the DB and replace it from backup, so taking out orphan tables alongside ORM tables is correct behaviour, not surprise data loss. SQLite restores are unaffected (they go through a separate path). Discovered while attempting to restore a 0.2.4b1 backup onto a Postgres instance that had been upgraded across the Spoolman integration rewrite. Two regression tests in `test_postgres_restore_drop_cascade.py` mock the Postgres engine, run `_import_sqlite_to_postgres` against a tiny SQLite source, and assert (1) the captured SQL stream contains a CASCADE-aware iteration over `pg_tables` (so a regression to `metadata.drop_all` fails CI loudly, before another user trips on it) and (2) the CASCADE drop is scoped to `schemaname = 'public'` so a shared Postgres instance holding non-Bambuddy data in other schemas isn't taken out by a restore. All 44 existing settings-API tests still pass unchanged. - **H2D Pro multi-plate dispatch double-/triple-fire** ([#1157](https://github.com/maziggy/bambuddy/issues/1157)) — Scheduling 3 plates of a multi-plate file to the same H2D Pro caused the scheduler to fire all three `project_file` commands within ~60 seconds, even though the printer hadn't transitioned out of `FINISH` for the first one yet. The H2D Pro can sit at `FINISH` for 80–210 s after accepting `project_file` before the `gcode_state` flips to `PREPARE`, and during that window the existing DB `busy_printers` seed (querying queue items in `printing` status) was empirically missing the in-flight item — observed in support logs as items 139/140/141 all dispatching with status='printing' yet only the third actually triggering a state transition. User-visible symptoms: layer count flapping, all queued plates showing as printing simultaneously, MQTT disconnect storms (33 in a single 5-minute window), eventual print failure. Root-cause fix is a defensive in-memory dispatch hold layer in `print_scheduler.py`: when `_start_print` succeeds we record `(printer_id, dispatched_at, pre_state, pre_subtask_id)`, and the next `check_queue` tick adds that printer to `busy_printers` until either (a) the watchdog observes a state/subtask transition (success path — release immediately past a 60 s minimum cooldown), or (b) a 180 s hard timeout expires (escape hatch for lost MQTT sessions). The minimum cooldown also prevents a spurious double-dispatch if the printer pulses through PREPARE→RUNNING→PREPARE in the first second after acceptance. The hold is purely additive — sits alongside the existing seed query and `_is_printer_idle` checks, doesn't depend on DB row visibility, doesn't depend on `on_print_complete` firing correctly. Per-printer isolation: a hold on printer A never blocks printer B. Edge cases covered by 12 new unit tests (`test_scheduler_dispatch_hold.py`): no-pre-state fallback (printer was offline at dispatch time), status-unavailable keeps hold (printer disconnected post-dispatch — don't release on missing data), idempotent release, hard-timeout self-cleanup, transition-during-cooldown still holds. The 90 s watchdog still owns the unhappy-path revert (queue item back to `pending` for retry) — this fix runs alongside it, not instead of it. All 179 existing scheduler tests still pass unchanged. - **Project picker UX in archives** ([#1151](https://github.com/maziggy/bambuddy/issues/1151)) — The "Add to Project" submenu in the archive context menu was unusable past the visible fold once a project library exceeded the 300px scroll cap: any wheel scroll, arrow-key navigation, or scrollbar click slammed the entire context menu shut. Root cause was a capture-phase `document.scroll` listener in `ContextMenu` that fired on internal submenu scrolls too — the listener now checks `menuRef.current.contains(e.target)` and ignores scrolls inside its own subtree. Project lists are now sorted alphabetically by name (`localeCompare`) at every assignment site (Archives context-menu submenu ×2, BatchProjectModal, EditArchiveModal, "review new uploads" panel, FileManagerPage project-picker) instead of newest-first from the API. The Archives "Add to Project" submenu and BatchProjectModal both gain a search input (rendered only when there are >5 projects, so small libraries stay clean) that filters the list by name as you type — Enter picks the first match. New `archives.menu.searchProjects` i18n key in all 8 locales (en/de fully translated, the six others seeded with English copies pending native translation, matching the project's existing flow). diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 9fb27d355..c86c42a8b 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -541,7 +541,26 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str): table.constraints.discard(fk) async with pg_engine.begin() as conn: - await conn.run_sync(metadata.drop_all) + # Drop every existing table in the public schema with CASCADE + # rather than `metadata.drop_all`. Two reasons: + # 1. The user's live DB may carry orphan tables from removed + # features (e.g. the legacy `spoolman_slot_assignments`, + # `spoolman_k_profile`) that hold FK constraints back to + # ORM tables. `drop_all` doesn't know they exist and emits + # `DROP TABLE printers` without CASCADE — Postgres refuses + # and the whole restore aborts (#XXXX). + # 2. Even within the metadata, `drop_all` is FK-ordered and + # breaks if a future schema rename leaves old constraints + # around. CASCADE is the right tool for a destructive + # restore: the user is intentionally wiping state. + await conn.execute( + text( + "DO $$ DECLARE r RECORD; BEGIN " + "FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP " + "EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(r.tablename) || ' CASCADE'; " + "END LOOP; END $$;" + ) + ) await conn.run_sync(metadata.create_all) # Restore FK definitions in metadata (needed for re-adding later) diff --git a/backend/tests/unit/test_postgres_restore_drop_cascade.py b/backend/tests/unit/test_postgres_restore_drop_cascade.py new file mode 100644 index 000000000..516c91c15 --- /dev/null +++ b/backend/tests/unit/test_postgres_restore_drop_cascade.py @@ -0,0 +1,163 @@ +"""Regression test for the Postgres restore drop-tables-with-CASCADE fix. + +The bug: the restore path called `metadata.drop_all`, which only drops +tables defined in the SQLAlchemy ORM and emits plain `DROP TABLE` (no +CASCADE). When the live DB carries orphan tables from removed features +(e.g. legacy `spoolman_slot_assignments` whose `_printer_id_fkey` +constraint still references `printers`), Postgres refuses with +`DependentObjectsStillExistError` and the entire restore aborts before +any rows land. + +The fix: drop every table in the `public` schema with `CASCADE` via a +`pg_tables`-iterating PL/pgSQL `DO` block, then re-create from the +ORM metadata. CASCADE removes external constraints alongside the table, +so orphan tables can no longer block the restore. + +These tests guard against a regression to `metadata.drop_all` (which +would re-introduce the bug for any user with orphan tables). +""" + +from __future__ import annotations + +import sqlite3 +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _make_sqlite_source() -> Path: + """Build a tiny SQLite file with one ORM-known table so the restore + function progresses past its `tables_to_import & metadata.tables` gate.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + path = Path(tmp.name) + conn = sqlite3.connect(str(path)) + # `users` is in the ORM metadata so `tables_to_import` is non-empty. + conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)") + conn.commit() + conn.close() + return path + + +@pytest.mark.asyncio +async def test_restore_drops_tables_with_cascade_not_metadata_drop_all(): + """Verify the restore drop phase issues a CASCADE-aware DROP TABLE + iteration over `public` schema rather than `metadata.drop_all`. + + Regression: prior to the fix, an orphan table holding an FK back to + `printers` (e.g. legacy `spoolman_slot_assignments_printer_id_fkey`) + would cause `metadata.drop_all` to fail with + `DependentObjectsStillExistError`, aborting the whole restore.""" + from backend.app.api.routes import settings as settings_module + + sqlite_path = _make_sqlite_source() + try: + executed_sql: list[str] = [] + run_sync_calls: list[str] = [] + + # Capture the exact SQL emitted on the Postgres connection. + mock_conn = MagicMock() + mock_conn.execute = AsyncMock( + side_effect=lambda stmt, *a, **k: executed_sql.append(getattr(stmt, "text", str(stmt))) + ) + + # `await conn.run_sync(metadata.create_all)` is the only run_sync + # the fix should issue. `metadata.drop_all` must never appear. + async def _run_sync(fn, *args, **kw): + name = getattr(fn, "__name__", repr(fn)) + run_sync_calls.append(name) + return None + + mock_conn.run_sync = AsyncMock(side_effect=_run_sync) + + # `pg_engine.begin()` is used twice (drop+create, then import). + # Both must yield the same captured-conn so we observe everything. + begin_cm = MagicMock() + begin_cm.__aenter__ = AsyncMock(return_value=mock_conn) + begin_cm.__aexit__ = AsyncMock(return_value=False) + + mock_engine = MagicMock() + mock_engine.begin = MagicMock(return_value=begin_cm) + mock_engine.dispose = AsyncMock() + + # `_create_engine` is imported lazily inside the function via + # `from backend.app.core.database import ... _create_engine`, + # so we patch the module it's imported FROM, not settings.py. + with patch( + "backend.app.core.database._create_engine", + new=MagicMock(return_value=mock_engine), + ): + await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test") + + # 1. CASCADE drop is emitted, hitting every public-schema table. + cascade_drops = [s for s in executed_sql if "CASCADE" in s and "pg_tables" in s] + assert cascade_drops, ( + "Expected a CASCADE-aware DROP TABLE iteration over the public " + "schema in the restore SQL stream. Without it, orphan tables " + "with FK constraints back to ORM tables (e.g. legacy " + "spoolman_slot_assignments) abort the restore. Captured SQL: " + "; ".join(s[:120] for s in executed_sql) + ) + # 2. The DO block iterates pg_tables (not just one DROP) so every + # table is handled, including orphan ones not in the ORM. + do_block = cascade_drops[0] + assert "DROP TABLE" in do_block + assert "schemaname = 'public'" in do_block + + # 3. `metadata.drop_all` is never invoked — that was the buggy + # path. `metadata.create_all` is fine; it rebuilds the schema + # after the CASCADE drop. + assert "drop_all" not in run_sync_calls, ( + f"metadata.drop_all should not be called (regression): {run_sync_calls}" + ) + assert "create_all" in run_sync_calls, f"metadata.create_all should still be called: {run_sync_calls}" + + # 4. Drop runs before create. The captured SQL is in execution order + # within the same pg_engine.begin() block, and run_sync_calls is + # in invocation order across both blocks. + first_create_idx = run_sync_calls.index("create_all") + # No drop_all anywhere — the cascade DO block (executed via .execute, + # not run_sync) is what runs first. Its presence is confirmed above. + assert first_create_idx >= 0 + finally: + sqlite_path.unlink(missing_ok=True) + + +@pytest.mark.asyncio +async def test_restore_cascade_drop_targets_only_public_schema(): + """Defensive: the CASCADE drop must scope to the `public` schema so a + shared Postgres holding non-Bambuddy tables in other schemas doesn't + lose data on restore.""" + from backend.app.api.routes import settings as settings_module + + sqlite_path = _make_sqlite_source() + try: + executed_sql: list[str] = [] + mock_conn = MagicMock() + mock_conn.execute = AsyncMock( + side_effect=lambda stmt, *a, **k: executed_sql.append(getattr(stmt, "text", str(stmt))) + ) + mock_conn.run_sync = AsyncMock() + + begin_cm = MagicMock() + begin_cm.__aenter__ = AsyncMock(return_value=mock_conn) + begin_cm.__aexit__ = AsyncMock(return_value=False) + mock_engine = MagicMock() + mock_engine.begin = MagicMock(return_value=begin_cm) + mock_engine.dispose = AsyncMock() + + with patch( + "backend.app.core.database._create_engine", + new=MagicMock(return_value=mock_engine), + ): + await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test") + + cascade = next((s for s in executed_sql if "CASCADE" in s), None) + assert cascade is not None + # Schema scope check: we're not iterating `pg_class` / + # `information_schema.tables` without a schema filter, which + # would catch system catalogs or other-app tables. + assert "schemaname = 'public'" in cascade, f"CASCADE drop must filter to public schema; got: {cascade[:200]}" + assert "schemaname = '*'" not in cascade + finally: + sqlite_path.unlink(missing_ok=True)