Commit Graph
275 Commits
Author SHA1 Message Date
maziggy 74f759468c Bumped version 2026-05-19 15:11:38 +02:00
MartinNYHC 12a352e5b8 Merge branch 'main' into dev 2026-05-19 14:12:50 +02:00
maziggy e61a454a0f fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
  "Spools and remaining weights are not changed" - the dialog promised
  this; the implementation did the opposite. Root cause was an
  architectural conflation: `weight_used` did double duty as the
  resettable "consumed since tracking started" counter AND as the basis
  for the displayed remaining (`label_weight - weight_used`), so zeroing
  it correctly cleared the stat but unavoidably reset remaining to full.

  Spoolman has separate `used_weight` and `remaining_weight` fields, so
  the API call there was correct - but Bambuddy's frontend was also
  computing remaining as `label_weight - weight_used` for Spoolman
  spools (ignoring Spoolman's real `remaining_weight` field), so the
  same visual bug bit there too. Inventory-mode parity required fixing
  both halves in one drop.

  Internal mode

  - New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
  - Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
  - Displayed consumed = `weight_used - baseline`; remaining =
    `label_weight - weight_used` (unchanged).
  - Subsequent prints continue to grow `weight_used`, so the resettable
    counter naturally tracks post-reset delta and remaining keeps
    decrementing across the reset.

  Spoolman mode

  - `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
    and returns a synthetic `weight_used = label - remaining` so the
    frontend's remaining calc matches Spoolman's real stored value;
    `weight_used_baseline = synthetic - real_used_weight` so the consumed
    counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
  - Fallback path (no `remaining_weight` set) preserves the old behavior.
  - Related fix: `update_spool` (Spoolman PATCH) was deriving the default
    `weight_used` from `used_weight`, so editing unrelated fields AFTER
    a reset would patch Spoolman with `remaining_weight = label - 0 =
    label`, trampling the real value. Now derives from
    `remaining_weight` so non-weight edits preserve physical state.

  Frontend

  - `InventoryPage` `totalConsumed` aggregate switched to
    `Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
  - `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
    "consumed" table cell got the same treatment so forecast and
    inventory aggregates stay coherent across a reset.
  - `?? 0` keeps pre-migration installs rendering correctly until
    `init_db()` runs the idempotent ALTER TABLE.

  Migration

  - `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
    via `_safe_execute` - SQLite and Postgres both accept it; verified
    end-to-end on Postgres 16.
2026-05-18 08:51:27 +02:00
maziggy 6f2cec5eb3 feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing
  print-finish auto-off but triggered when AMS drying ends.

  Two new SmartPlug columns: auto_off_after_drying (default false),
  off_delay_after_drying_minutes (default 10 — AMS chamber is hot
  post-cycle so longer cooldown than the print-finish default of 5).
  SQLite + Postgres migrations both idempotent.

  Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
  tracks the dry_time > 0 → 0 falling edge and fires a new
  on_drying_complete(ams_id) callback. Plumbed through
  PrinterManager.set_drying_complete_callback to
  SmartPlugManager.on_drying_complete(printer_id, db), which walks
  linked plugs and respects the per-plug toggle. Catches queue,
  ambient and manual drying identically because it observes firmware
  state, not scheduler intent.

  Frontend: single "Auto Off After Drying" toggle + delay input on
  the smart plug card, next to the existing print-finish auto-off
  section.

  Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
  on dual-AMS printers) deferred — Bambuddy's plug model is
  plug→printer, so the trigger fires whenever any AMS on the linked
  printer finishes a cycle.
2026-05-17 12:26:42 +02:00
maziggy 8e4f815b37 fix(stats): backfill PrintLogEntry.cost/energy/archive_id for pre-#1378 rows (#1390)
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
  per-event aggregation rewrite from #1378) and saw Quick Stats split
  between consistent values (Total Prints, Print Time, Filament Used,
  Energy, Success Rate matched the archive list) and zero-or-empty ones
  (Filament Cost, Time Accuracy).

  Root cause: #1378's migration added six columns to print_log_entries
  - archive_id, cost, energy_kwh, energy_cost, failure_reason,
  created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
  on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
  on legacy data); the time-accuracy query JOINs PrintArchive ON
  archive_id (drops every legacy run from the average). Counts and the
  pre-existing per-row fields (status, duration_seconds,
  filament_used_grams) kept working - which is why some panels looked
  right and others didn't.

  Two-step backfill added inside run_migrations next to the existing
  column-add block, as DML inside begin_nested() (not _safe_execute,
  which is documented DDL-only):

    Step 1: link each orphan log entry to its archive via
            print_name + printer_id (highest archive id wins on
            tiebreak - newest matches the overwrite-then-stop shape
            pre-#1378 reprints left behind).

    Step 2: copy archive.cost / energy_kwh / energy_cost onto the
            latest matching log entry per archive, BUT only for
            archives where no log entry yet carries a cost. That
            second clause is the idempotency anchor and the
            double-count guard for users running this after #1378
            has already written cost-bearing rows for new runs -
            those archives are left untouched.

  Earlier reprints stay NULL, matching the "first/latest writes, rest
  stay NULL" convention #1378 introduced for new prints. Sum across the
  legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
  Stats Filament Cost matches the pre-upgrade total instead of dropping
  to zero.

  SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
  subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
  SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
  end-to-end on SQLite (4 new unit tests in
  test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
  cost, idempotent, skip-archives-with-any-costed-run) and against a
  live postgres:16-alpine + asyncpg container (first-pass and second-
  pass produce identical state).

  The other widgets the reporter listed (Printer Stats, Filament
  Trends, By Material, Success by Material, Color Distribution) iterate
  the archives list on the frontend rather than calling /stats - they
  read consistent pre-upgrade data and aren't part of this fix; the
  inconsistency between them and Quick Stats resolves once the backfill
  brings Quick Stats in line.
2026-05-17 09:30:02 +02:00
maziggy 829fbc4dcf Bumped version 2026-05-16 13:09:03 +02:00
maziggy 856b849ffa fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
  the same table backing the global Print Log) rather than PrintArchive
  (one row per file). A reprint creates a new PrintLogEntry instead of
  overwriting the source archive's runtime fields, so:

  - a 100 g successful print + a 10 g failed reprint correctly sums to
    110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
    Prometheus /metrics endpoint (previously the failed reprint silently
    replaced the source archive's data; totals dropped from 100 g to 10 g)
  - the archive's card cost/energy_kwh are preserved on reprints (only
    the first run writes them); per-run actuals live on PrintLogEntry
  - failed/cancelled/stopped reprints record partial-aware filament: sum
    of tracked spool deltas when inventory is set up, else estimate
    scaled to progress%, else None — prevents the full slicer estimate
    from inflating totals on a print that stopped at 10 % progress

  PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
  SET NULL so log entries survive archive deletion preserving #1343
  soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
  failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.

  New per-archive surface:

  - archive list response carries run_count / last_run_at /
    total_filament_actual_grams / successful_run_count / failed_run_count
    via a single batch JOIN, no N+1
  - new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
    the archive (ARCHIVES_READ permission, newest-first ordering)
  - archive cards render an orange "N prints" badge for archives with
    more than one run; clicking the badge opens a dedicated PrintLogModal
    with date/status/duration/filament/cost columns plus failure_reason
    under failed runs. Also reachable via the context menu's new "Print
    Log" entry (works for single-run archives too), and embedded at the
    top of the Edit Archive modal for context.

  The purge_stats=true delete path now hard-deletes linked PrintLogEntry
  rows up front so the archive's contribution truly leaves the totals;
  without it, ON DELETE SET NULL would orphan the runs and leave them
  counting toward stats.
2026-05-16 11:34:40 +02:00
maziggy ae29a7dcd3 fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
  key with Write Settings permission, PATCH /api/v1/settings with
  {energy_cost_per_kwh: ...}" - and hit:
  {"detail":"API keys cannot be used for administrative operations"}.

  Triage showed three independent drifts:
  1. Wiki listed nine fictional API-key permissions (Read Printers / Write
     Settings / Admin / ...) but the UI only ever exposed four toggles
     (Read Status, Manage Queue, Control Printer, Allow Cloud Access).
     There was no Write Settings toggle to tick.
  2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
     every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
     because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
     HA access token. Wider surface than any documented use case needs.
  3. So the wiki had been promising a workflow that was never deliverable.

  Fix: introduce a narrowly-scoped door rather than relax the deny list.

    - New column can_update_energy_cost (default FALSE - existing keys
      never silently gain settings-write capability on upgrade).
    - New route POST /api/v1/settings/electricity-price accepting
      {"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
      wiki already documented so the HA rest_command example needs only a
      URL+method change, not a payload change.
    - Custom dependency require_energy_cost_update() bypasses
      _APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
      flag set. JWT users still go through standard SETTINGS_UPDATE.
    - General PATCH /settings remains denied for API keys - flipping the
      narrow flag does NOT widen general settings-write access. Pinned by
      test_patch_settings_still_denied_with_energy_flag.

  Frontend: fifth "Update electricity price" toggle on the create-API-key
  card + amber "Energy" badge on existing keys with the flag set. Three
  new i18n keys across all 8 locales (German translated, English fallbacks
  elsewhere).
2026-05-15 13:12:41 +02:00
maziggy 9ba1e34729 fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
  then deleting nine archive entries (to keep the file list tidy) silently
  rewound the totals on the Statistics page — total prints, filament,
  cost, and per-print energy all dropped back to whatever the surviving
  row contributed, as if the other nine prints had never happened.

  Root cause: every metric in get_archive_stats is recomputed live from
  PrintArchive rows via COUNT / SUM, so removing a row removes its
  contribution. Energy in the default "Total" mode already survived
  deletion because it reads the smart-plug lifetime counters — that's
  the architectural shape we now generalise to the rest.

  Fix: soft delete with opt-in hard purge.

  Backend:
  - New nullable, indexed deleted_at column on print_archives, dialect-
    conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
  - ArchiveService.soft_delete_archive flips deleted_at and removes the
    files from disk (still reclaims storage); the path-safety checks were
    extracted into _resolve_archive_dir_for_delete so soft and hard delete
    share the rules.
  - DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
  - Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
    fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
    detection (so a 1-live + 9-soft-deleted group no longer marks the
    survivor as a duplicate), and ArchiveComparisonService's "similar"
    suggestions. GET /stats and GET /slim deliberately do NOT filter so
    Quick Stats and the dashboard widgets keep counting deleted prints.

  Frontend:
  - ConfirmModal gained an optional children slot.
  - ArchivesPage (both card and detail views) own a per-instance
    deletePurgeStats boolean and render an opt-in checkbox in the delete
    dialog; resets to off on every close so the destructive option is
    never sticky.
  - api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
    when the box is ticked.
  - One new i18n key archives.modal.deletePurgeStats added across all 8
    locales (full German, English fallbacks elsewhere).
2026-05-15 09:21:43 +02:00
Sn0rrii 8a7598f6b5 feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333)

Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
2026-05-15 08:50:37 +02:00
Sn0rrii 4d8dbc8336 fix(auth): cleanup orphan OIDC/MFA rows when user is deleted (#1285) (#1295)
fix(auth): cleanup orphan OIDC/MFA rows on user delete (#1285)

Three User-FK tables (user_oidc_links, user_totp, user_otp_codes)
declare ON DELETE CASCADE in their models, but SQLite ships with
PRAGMA foreign_keys=OFF (the project's existing pattern, mirrored
for APIKey in PR #1182). Without explicit DELETEs, deleting a user
on SQLite leaves orphan rows behind:
2026-05-13 13:23:21 +02:00
maziggy af52c4f2ff fix(spoolman): allow AMS-HT ams_id range in slot-assignment table (#1274)
H2C / H2D AMS-HT units report ams_id 128+ (one ams_id per unit, single
  tray), but spoolman_slot_assignments.ck_ams_id_range only admitted 0-7
  and 255. Every attempt to link a Spoolman spool to an AMS-HT slot died
  with `CHECK constraint failed: ck_ams_id_range`. The internal
  spool_assignment table has no such constraint and works fine.

  Widen the formula to (0-7) OR (128-191) OR 255 in the model, the
  CREATE TABLE DDL, and an idempotent in-place migration for existing
  installs (Postgres: DROP/ADD CONSTRAINT; SQLite: detect stale formula
  in sqlite_master, rebuild via _v2 rename pattern).
2026-05-12 08:48:30 +02:00
maziggy 71ac077c0c Bumped version 2026-05-11 14:52:24 +02:00
maziggy 79d54a8d53 feat(archives): build-plate icon on cards + uniform printer/model line (#1253)
Show an OrcaSlicer-style bed icon in the archive card's printer-name row
  indicating which build plate the print was sliced for (Cool /
  Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
  with the full plate name in the hover tooltip. Closes the gap where
  users had to remember which plate matched a re-print or open the
  source 3MF in a slicer just to read the bed setting.

  Card row also unified: archives with a real Bambuddy-printer
  association used to render "H2D-1 GCODE ..." while slicer-only uploads
  rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
  Drop the "Sliced for " prefix so both render as a uniform
  "<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
  regardless of provenance.

  Backend: new bed_type column on print_archives (idempotent ALTER TABLE
  migration; SQLite + Postgres safe). Populated from curr_bed_type in
  Metadata/slice_info.config (per-plate, authoritative -- that's what
  got sent to the printer for the exported plate) with a fallback to
  project_settings.config for older 3MF shapes. Wired through both
  archive_to_response() (the hand-rolled dict converter that bypasses
  from_attributes -- easy to miss) and the /rescan endpoint, so old
  archives can be re-parsed via the existing per-archive Rescan button.

  Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
  supported) re-opens every NULL archive's 3MF on disk to populate the
  column. Auto-loads .env from project root before importing backend
  modules (config.py reads DATABASE_URL from os.environ at import time,
  not from pydantic-settings at Settings() time) and prints the resolved
  DB URL with credentials redacted, so operators can confirm they're
  hitting the intended database -- Postgres or SQLite.

  Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
  under /img/ because that path is already statically mounted; a
  toplevel /bed-icons/ tried first hit the SPA catch-all and returned
  index.html as text/html. New utils/bedType.ts maps slicer strings
  case-insensitively, covering both Bambu Studio and OrcaSlicer naming
  variants for the same physical plate. Unmapped or NULL bed_type
  simply omits the icon, so cards stay clean for pre-feature archives.
2026-05-10 09:54:10 +02:00
maziggy f5ecc61cda fix(spoolbuddy): lower /update permission to INVENTORY_UPDATE so kiosk's own Settings -> Update button works
The kiosk's Settings -> Update Daemon button returned "API keys cannot
  be used for administrative operations" because POST /spoolbuddy/devices/
  {id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
  is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
  Every kiosk-side request tripped the deny-list before the API key's
  scope set (Read / Print Queue / Control / Legacy) was even consulted.

  Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
  (Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
  in that audit on the reasoning "replaces the daemon binary, different
  threat surface" — but that's wrong: restart_daemon already replaces
  the running daemon process, so daemon-replacement is not a step up in
  blast radius. The SSH update is also strictly scoped to the one device
  the operator physically controls (git fetch + pip install + systemctl
  restart on that host) — same threat profile as the system commands
  already running on INVENTORY_UPDATE.

  Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
  INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
  (calibration/tare, display, cancel-write, system/command,
  system/command-result, update-status). The main Bambuddy in-app updater
  at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
  the Bambuddy host and is correctly fenced behind the deny-list.
2026-05-08 14:28:41 +02:00
maziggy 829bc2dd6a Bumped version 2026-05-08 12:49:12 +02:00
MartinNYHC b30a283184 Feature/spoolman inventory UI (#1241)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-08 11:52:42 +02:00
Sn0rrii 90743cfa39 feat(encryption): MFA at-rest encryption auto-bootstrap with status UI (#1219) (#1231)
chore(i18n): extend parity gate to all locales with strict/info tiers

  Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
  drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
  STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
  report informationally until their drift is caught up. ja notably has 27 real
  placeholder bugs worth fixing before promotion to strict.
2026-05-08 09:01:51 +02:00
MartinNYHC dac2a31192 Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)
This reverts commit 55d71498e9.
2026-05-07 11:30:31 +02:00
Sn0rrii 55d71498e9 feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support + Spoolman Filament Catalog Picker (#1114)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-07 11:15:24 +02:00
maziggy d5280ce21f Bumped version 2026-05-05 16:38:22 +02:00
Keybored 37c9d5f26d [Feature] Add Stock forecasting and Logistics view (#1184) 2026-05-05 16:23:42 +02:00
BurntOutHylian e45f967616 feat(backup): Extend Backup to other Git providers (#1160) 2026-05-03 08:12:36 +02:00
maziggy abc8e97050 feat(camera): optional snapshot URL override for external cameras (#1177)
go2rtc and several IP cameras still emit a warm-up / black frame on every
  fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
  slipped through intermittently for @nkm8's setup. His own bisect named
  the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
  single-frame endpoint that never returns the encoder's stale keyframe.

  Adds an optional external_camera_snapshot_url column on printers. When
  set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
  notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
  Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
  on the override URL via plain HTTP GET, bypassing the warm-up dance.

  Live view stays on the configured stream URL — only single-frame
  captures use the override. Override is camera-type-agnostic. SSRF guard
  applies (existing _sanitize_camera_url allowlist). Empty string treated
  as unset.

  Settings UI: new "Snapshot URL (optional)" input + Test button under
  External Cameras, hidden for camera_type=snapshot since the live URL is
  already a single-frame source. en + de fully translated; 6 other locales
  seeded with English copy.

  5 backend tests pin the routing contract; 3 frontend tests pin the
  input + debounced PATCH. Documented in
  bambuddy-wiki/docs/features/camera.md with the go2rtc example.
2026-05-03 07:59:02 +02:00
Sn0rrii d0d0be89ea fix(oidc): use preferred_username/name claim for auto-created username (#1173) (#1176)
fix(oidc): use preferred_username/name claim for auto-created username

When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
2026-05-02 12:14:32 +02:00
maziggy 459cfdc51f fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
  filament requirements from the 3MF and writes
  required_filament_types + filament_overrides + ams_mapping onto the
  queue item, but the VP queue-mode write path skipped all of that.
  Net effect: scheduler reached its model-only-matching fallback and
  auto-dispatched onto whatever printer was free regardless of loaded
  colour.

  Extract the scheduler's existing _get_filament_requirements 3MF
  parser into a shared helper so the VP path can reuse it. VP's
  _add_to_print_queue now populates required_filament_types
  unconditionally (cheap; helps the scheduler reject obvious type
  mismatches) and writes filament_overrides with force_color_match:
  true per consumed slot when a new per-VP queue_force_color_match
  toggle is on. Default off to preserve current behaviour for
  upgraders.

  UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
  mirroring the existing auto-dispatch toggle. i18n: en + de
  translated, other 6 locales seeded with English copy.

  Schema: one nullable column on virtual_printers
  (queue_force_color_match BOOLEAN, default 0/FALSE).

  11 new backend tests (8 for the extracted parser, 3 for the VP
  write path) + 6 new frontend tests (toggle render gating, default
  state, click posts queue_force_color_match in update body).
  Existing scheduler tests pass against the refactored helper.
  README, CHANGELOG, website features page, and wiki virtual-printer
  page all updated.
2026-05-02 08:38:46 +02:00
maziggy 01a7e6ee93 fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@smandon retested the original #1152 fix on the latest daily and surfaced
  two distinct holes:

  1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
     default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
     ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.

  2. The pending-uploads review card always showed the raw FTP filename,
     while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
     embedded title (or, with the toggle on ``filename``, the stripped stem).
     Net effect: same upload showed two different names depending on which
     view you were looking at, with no way for the toggle to flip both
     views in lockstep.

  Three changes:

  - ``resolve_display_stem`` helper in ``services/archive.py`` strips
    ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
    the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
    every flow that produces a ``PrintArchive`` row.

  - ``PendingUpload.metadata_print_name`` (new nullable column) is
    populated at FTP-receive time by peeking at the 3MF's embedded title
    via the existing ``ThreeMFParser``. Read happens once per upload —
    the list endpoint then doesn't have to reopen each 3MF on every
    render. Parser failures are swallowed and the column stays NULL;
    the response model gracefully falls back to the stripped filename.

  - ``PendingUploadResponse.display_name`` is a computed field that
    mirrors ``archive_print``'s exact precedence — ``filename`` toggle
    → stripped stem; ``metadata`` toggle (default) → cached title or
    stripped stem. The frontend's review card reads it (with
    ``upload.filename`` as a defensive fallback) and surfaces the raw
    FTP filename via tooltip so users can still inspect what arrived.

  Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
  metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
  rows have NULL and degrade to filename-stem behaviour without any
  operator action.

  Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
  canonical normalisation rules (Bambu Studio default name, mixed case,
  dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
  inputs); 6 integration tests in ``test_pending_upload_display_name.py``
  pinning the response contract (default toggle uses metadata title when
  present, falls back to stripped stem when absent, ``filename`` toggle
  overrides metadata, ``filename`` toggle still strips the double suffix,
  ``GET /{id}`` exposes the same field, whitespace-only metadata behaves
  like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
  pinning the review card's render path (resolved name shown, fallback
  to filename when display_name is empty, raw filename available via
  tooltip). Full backend suite: 3598 passed; frontend build clean; no
  regressions in any flow that previously processed ``.3mf`` /
  ``.gcode`` / non-3D filenames.
2026-05-01 12:34:56 +02:00
maziggy 133ec72527 feat(api-keys): per-user ownership + opt-in cloud access scope (#1182)
Tim (@turulix) is building a fully automated headless slicing pipeline
  against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes
  resolve cloud_token per-user from User.cloud_token, but the auth gate
  returned None for API-keyed requests, so the route fell back to the
  global Settings-table token, which only carries a value in auth-disabled
  deployments. Net effect on auth-enabled deployments: API keys reached
  the gate just fine, then /cloud/filaments always saw user=None and
  returned 401 / empty results — no path to read slicer presets or the
  filament catalogue that a CLI workflow needs.

  Make API keys carry an owner and route /cloud/* lookups through that
  owner; gate the new capability behind an explicit opt-in scope so
  existing automation doesn't gain cloud-read access on upgrade.

  - APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and
    can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an
    explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships
    FK enforcement off — same pattern as the existing created_by_id
    cleanup blocks.

  - New cloud_caller dep on /cloud/* routes resolves to the JWT user OR
    the API-key owner stashed by a router-level gate. The auth gate itself
    continues to return None for API keys so #1182's surface stays bounded
    to /cloud/* — without that bound, any route that fences API keys via
    `if current_user is None: raise 403` (e.g. long-lived-token
    management) would silently start accepting them.

  - The /cloud/* router-level dep enforces three independent fences for
    API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with
    recreate copy), can_access_cloud=True (otherwise 403), and owner has
    cloud_token (existing fence, unchanged). Two extra one-shot fence
    errors at create/update time refuse can_access_cloud=True when auth
    is disabled or the key is ownerless.

  - Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and
    "Legacy" badge on ownerless rows; create form gains an "Allow cloud
    access" toggle, default off. New i18n keys in all 8 locales (en + de
    fully translated, others seeded with English fallbacks pending native
    translation — matches the project's flow for newly-added features).

  Migration: two idempotent ALTER TABLE statements + an index on user_id
  for the auth gate's owner→keys lookup. Postgres-safe.

  Tests: 9 backend integration tests in test_api_key_cloud_access.py
  covering creation flags, the three /cloud/* fences, JWT no-op, and
  deletion CASCADE; 2 frontend SettingsPage tests pinning the badge
  matrix and the create-form contract; 5 daemon unit tests for the
  related SpoolBuddy ssh-key sync work that landed in the same branch.
  Full backend suite: 3578 passed; full frontend suite: 1597 passed; no
  regressions.

  Permission semantics for existing keys: keys created before this
  release become "legacy" and are rejected at /cloud/* with the recreate
  message. Every other endpoint they were used against — queue, status,
  control — is untouched.
2026-05-01 11:43:55 +02:00
maziggy a85855f2dd fix(updates): run pip install in app_dir, not base_dir, on native installs
Native-install upgrade via the in-app Apply Update button got the new
  code in via `git reset --hard origin/main` but then logged

    ERROR: Could not open requirements file:
    [Errno 2] No such file or directory: 'requirements.txt'

  and continued. The new deps never installed, leaving the user with
  new code but stale dependencies — surfaces as cryptic import errors
  on the next restart.

  Root cause: `pip install -r requirements.txt` ran with
  `cwd=settings.base_dir`. On a native install, systemd sets
  DATA_DIR=$INSTALL_PATH/data so base_dir resolves to the data dir
  (e.g. /opt/bambuddy/data), not the source tree. Pip doesn't walk up
  looking for the requirements file the way git walks up looking for
  .git, so it fails. Same bug affected the optional npm step
  (`frontend_dir = base_dir / "frontend"` doesn't exist).

  Fix: introduce `settings.app_dir` pointing at the source-tree root
  (distinct from `base_dir` only on native installs) and run pip +
  npm with `cwd=settings.app_dir`. Git ops keep using `base_dir`
  because they already work (git walks up).

  Docker users were unaffected — Docker doesn't use the in-app updater
  (image pull replaces it).

  Regression test in test_updates_api.py mocks every subprocess in
  _perform_update, captures their cwd, and asserts the pip step runs
  in app_dir and that requirements.txt actually exists there. Any
  future refactor that re-introduces cwd=base_dir for the pip step
  fails CI before another user trips over it.
2026-04-29 11:39:17 +02:00
maziggy eec9ef4a41 Bumped version 2026-04-29 11:28:34 +02:00
maziggy a34beaa599 feat(inventory): multi-colour gradients, transparency, visual effects (#1154)
Spool and color_catalog rows carry extra_colors (comma-separated hex
  stops) and effect_type (14 visual variants: surface effects, sheen,
  structural). The shared FilamentSwatch component renders gradient,
  conic, effect overlay, and alpha-checkerboard consistently across the
  inventory grid, table, group banner, card, ColorSection preview, and
  catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
  can carry transparency too.

  The paste field accepts the exact format 3dfilamentprofiles.com puts on
  its filament details pages, so users can copy a multi-colour combo
  directly. The effect dropdown spans the full filament-variant
  vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
  variants (silk/galaxy/rainbow/metal/translucent), and structural
  variants (gradient/dual-color/tri-color/multicolor). None of these
  fields touch MQTT/firmware -- pure visual hint.

  Spool group-key extended to include extra_colors + effect_type so
  "Group similar" no longer collapses visually distinct spools.

  Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
  ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
  VARCHAR length).

  Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
  FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
  regression). 3522 backend + 1582 frontend tests pass; ruff clean.
  Localised across all 8 UI locales.
2026-04-29 09:13:33 +02:00
maziggy 57af8a1c19 feat(projects): URL field + cover photo on project cards (#1155)
Two new project fields: a free-text URL rendered as a one-click
  external-link button beside the project name on every card (opens in a
  new tab, click is e.stopPropagation()-guarded so it doesn't enter the
  project), and a cover photo that replaces the status-icon box with a
  square thumbnail.

  URL is plumbed through ProjectCreate/Update/Response/ListResponse,
  including from-template + create-template flows so it inherits between
  a project and its template. Cover photo is not inherited because the
  file would be shared on disk between source and copy.

  Schema validator rejects anything other than http:// or https://
  prefixes -- <a href> rendering would otherwise execute javascript:
  / data: / file: URLs even with React's default escaping. PATCH uses
  model_fields_set for the URL field so users can clear it by sending
  {"url": null}.

  Cover image storage: Project.cover_image_filename references a file
  Cover image storage: Project.cover_image_filename references a file
  inside the existing archives/projects/{id}/attachments/ dir, but it's
  tracked separately from the attachments JSON list so swap/delete on
  the cover doesn't perturb the user's other attachments. Three routes
  (POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
  .png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
  place (prior file deleted before the new one lands so repeat uploads
  can't accumulate orphans), and self-heal when a DB reference points at
  a vanished disk file by clearing the column and 404'ing.

  GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
  (accepts ?token=... query string) -- not the bearer-token gate -- so
  <img src> requests work in both auth-on and auth-off configurations.
  The frontend wraps getProjectCoverImageUrl with withStreamToken(),
  matching the existing pattern from getArchiveThumbnail.

  Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
  gate is implicit via the stream-token credential. Migration: 2
  idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
  UI languages.
2026-04-29 07:42:09 +02:00
Sn0rrii 78408856cd fix(oidc): Allow auto_link_existing_accounts with custom email claims (Azure Entra ID) (#1142)
chore(i18n): extend parity gate to all locales with strict/info tiers
2026-04-28 17:37:48 +02:00
MartinNYHC 8829bc2cc6 Merge branch 'dev' into feature/slicer-api 2026-04-27 17:09:42 +02:00
maziggy 9884018497 fix: cancel-safe get_db + drop sqlalchemy.pool cancellation noise
@Carter3DP's support package showed bambuddy.log filling with two
  distinct cascades on long uploads:

    ERROR sqlalchemy.pool   Exception terminating connection ...
                            CancelledError: Cancelled via cancel scope
                            ... by starlette.middleware.base
                            .BaseHTTPMiddleware.__call__.call_next
    ERROR sqlalchemy.pool   The garbage collector is trying to clean up
                            non-checked-in connection ... will be
                            terminated.
    WARN  backend.app.main  Runtime tracking commit failed:
                            (sqlite3.OperationalError) database is locked

  Single root cause. Starlette's BaseHTTPMiddleware (used under the hood
  by every @app.middleware("http") decorator) cancels the inner task
  scope when a client disconnects mid-request — common on long
  multipart uploads where the client times out before the server's
  response. Pre-fix get_db only caught Exception, but CancelledError
  is BaseException, so cancellation skipped the rollback path entirely.
  The SQLite write lock stayed held until GC reclaimed the connection
  ages later, blocking every other writer in the meantime. On Postgres
  the leak shape is identical; the symptom would be "QueuePool limit
  ... overflow" instead of "database is locked".

  (1) get_db now catches BaseException so CancelledError triggers
      rollback. Both rollback() and close() are wrapped in
      asyncio.shield so the cleanup completes even when the await
      itself is being cancelled by the same cancel scope. SQLite write
      lock is released promptly; connection returns to the pool instead
      of leaking until GC.

  (2) CancelledPoolNoiseFilter (new filter on sqlalchemy.pool) drops
      the residual records that pre-existing pools still emit during
      their own cleanup. Two patterns suppressed:
        - "Exception terminating connection ..." with a CancelledError
          anywhere in the exc_info chain (walks __cause__/__context__
          with a seen-set guard against pathological cycles)
        - "The garbage collector is trying to clean up non-checked-in
          connection ..." (always symptomatic of cancellation; never
          independently actionable)
      Real pool problems — broken connections, OSError on terminate,
      pool exhaustion — keep flowing because they carry a different
      exception chain or a different message prefix.

  13 regression tests across test_get_db_cancel_safety.py (commit on
  clean exit, rollback on regular Exception, rollback on CancelledError,
  close runs even if rollback raises, close failure on clean exit
  doesn't propagate, rollback + close both go through asyncio.shield)
  and test_cancelled_pool_filter.py (drops cancellation-driven
  terminate, drops GC-cleanup, keeps real OSError terminate, keeps
  terminate without exc_info, keeps unrelated pool messages, drops
  chained-cause CancelledError, defensive guard against self-referential
  cause chains).

  Applies to SQLite and PostgreSQL — get_db is dialect-agnostic and
  the filtered messages come from base sqlalchemy.pool not from any
  specific dialect.
2026-04-27 16:32:10 +02:00
maziggy 56800589ff fix(#1113): silence Windows asyncio Proactor cleanup-RST noise
bambuddy.log on Windows fills with

    Exception in callback _ProactorBasePipeTransport._call_connection_lost()
    ConnectionResetError: [WinError 10054] An existing connection was
    forcibly closed by the remote host

  every time a printer / MQTT broker / camera RSTs a TCP socket instead
  of FINing it. The application-layer reconnect (paho-mqtt, httpx)
  handles the actual disconnect fine; the traceback is asyncio
  bookkeeping. Reported by @cadtoolbox who runs 9 printers including 5
  offline X1Es, so the log filled multiple times per minute.

  New backend/app/core/asyncio_handlers.py installs a custom
  loop.set_exception_handler on Windows that pattern-matches three
  signals together (platform == win32, exception is
  ConnectionResetError, asyncio message contains
  _call_connection_lost) and demotes the entry to DEBUG. Genuine
  ConnectionResetErrors raised inside application coroutines have a
  different message string and still surface; BrokenPipeError /
  ConnectionAbortedError on the same cleanup path also still surface.

  Wired from lifespan startup before any task can spawn that might
  trip it. Linux / macOS use the Selector loop, so install is an
  explicit no-op there with a False return.

  9 unit tests in test_asyncio_handlers.py covering signature match,
  rejection of unrelated resets, platform gate, suppress vs.
  pass-through to default handler.
2026-04-27 16:14:31 +02:00
maziggy 6deaa513af ● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
  Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
  Slicing runs as an in-memory background job (POST returns 202 + job_id,
  polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
  longer pins the modal; result lands as a new .gcode.3mf in the same
  folder (or new archive for archive sources) with the embedded
  thumbnail extracted.

  Backend
  - New services: slice_dispatch (in-memory dispatcher, 30min retention
    sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
    split that drives the 3MF embedded-settings fallback retry path).
  - New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
    SliceJobEnqueueResponse.
  - New routes: POST /library/files/{id}/slice,
    POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
    LIBRARY_READ since job IDs are sequential and the body leaks source
    filenames and result IDs).
  - AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
    bambu_studio_api_url. DB-stored values override env defaults.

  Frontend
  - New SliceModal handles preset gating; enqueues then closes
    immediately.
  - New SliceJobTrackerProvider polls active jobs at app level, surfaces
    a single toast per job (queued -> running -> completed / failed)
    and invalidates library/archives queries on terminal status.
  - Settings -> Workflow -> Slicer card: preferred slicer dropdown,
    Use Slicer API toggle, contextual sidecar URL field.
  - File Manager / Archives / MakerWorld get a Slice button gated on
    the Use Slicer API setting.
  - gcode-viewer adapter learns ?library_file=<id> so sliced library
    files preview inline.

  i18n
  - New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
    bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
    + fileManager.noPermissionSlice keys across all 8 locales (en, de,
    fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
    fully translated, the other six seeded with English fallbacks
    pending native translation.

  Tests
  - 10 backend integration tests in test_library_slice_api.py covering
    validation (404/400), happy-path enqueue, sidecar-down, 3MF
    embedded-settings fallback, STL no-fallback, and preset-error ->
    failed job paths.
  - New unit tests in test_slicer_api.py for the HTTP bridge.
  - 5 new SliceModal frontend tests covering preset gating, library +
    archive enqueue paths, error surface, and preset-load failure.
  - Existing SettingsPage tests adjusted: slicer dropdown asserts now
    switch to the Workflow tab first; added a beforeEach URL reset so
    one test's tab click doesn't bleed into sibling tests.

  Sidecar
  - New slicer-api/ folder is self-contained and optional. Two services
    (orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
    bambu) build via Docker git-build-context from
    maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
    the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
    resolver, from:User -> system rewrite, '# ' clone-prefix strip,
    sentinel-value strip) empirically required to slice real GUI
    exports without segfaulting the CLI.

  Docs
  - CHANGELOG entry under [0.2.4b1] - Unreleased Added.
  - README File Manager bullet for the new server-side Slice button.
  - bambuddy-website features.html: new card under "Configurable Slicer".
  - bambuddy-wiki: new page features/slicer-api.md + nav entry +
    features index card.

  Notes
  - Opt-in: with Use Slicer API off, the existing "open in desktop
    slicer via URI" flow is the default and unchanged.
  - 3MF inputs that segfault the CLI on --load-settings transparently
    retry with embedded settings; the resulting job carries
    used_embedded_settings: true.
  - Sliced files always export as .gcode.3mf so File Manager picks up
    the embedded thumbnail; file_type is set to "gcode" (blue badge).
2026-04-27 15:28:37 +02:00
maziggy 1878d2aab5 feat(observability): trace ID column on every log line + X-Trace-Id header
Builds on the recent uvicorn-access-log-into-bambuddy.log change.
  Until now the access line told us who called an endpoint, but there
  was no way to tie that line to the application records emitted on the
  server side while handling that request. The rogue stop_print mystery
  on 2026-04-26 left exactly that gap: even with access logs piped in,
  correlating "this POST landed" with "this MQTT publish went out 6 ms
  later" required eyeball-matching timestamps across different loggers.

  A new ContextVar + middleware + logging filter wire a trace ID through
  every record:

    * trace_id_middleware mints an 8-char hex ID per request (or honours
      a sane inbound X-Trace-Id for cross-system correlation), stores it
      in trace_id_var (ContextVar), echoes it on the response as
      X-Trace-Id, and resets the var in finally.
    * TraceIDFilter, attached to root + uvicorn.access, copies the
      current trace_id_var value onto every LogRecord so the format
      string [%(trace_id)s] resolves to the right ID per record.
    * Records emitted outside any request scope (startup, MQTT
      callbacks, scheduler) get a stable "-" placeholder so the column
      stays visually aligned and grep stays simple.

  ContextVars are the right plumbing because asyncio copies the current
  context into every asyncio.create_task, so background work spawned
  from inside a request inherits the same ID without explicit threading.
  request.state can't make that hop. The logging filter also has no
  access to the FastAPI request object — it runs synchronously inside
  the stdlib logging machinery — and the ContextVar is the only
  mechanism that bridges async request scope to sync log emission.

  Inbound X-Trace-Id is hard-validated against [A-Za-z0-9_-]+ (max 64
  chars) before being honoured — a hostile/buggy caller cannot smuggle
  log-injection payloads (newlines, control chars, megabyte blobs) into
  bambuddy.log via the trace ID column; values that fail the gate
  silently trigger a freshly minted server-side ID rather than failing
  the request.

  Middleware is decorated AFTER auth_middleware on purpose: Starlette
  stacks @app.middleware decorators LIFO so the last-decorated runs
  first inbound, making trace stamp the OUTERMOST layer — auth log
  lines and every record emitted on the way down to and back from the
  route handler all carry the same ID.

  Output now correlates as:

    2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] - "POST
      /api/v1/printers/1/print/stop HTTP/1.1" 200
    2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent
      stop print command

  One grep a4f3b1e7 returns the full causality chain.

  30 new tests: 22 unit (ContextVar placeholder, filter copies value,
  asyncio task propagation, concurrent-request isolation, hex generator
  uniqueness, hostile-payload validator, max-length boundary, all four
  write verbs survive, GET/HEAD/OPTIONS dropped, URL-substring false-
  match guards, edge cases) and 8 integration (X-Trace-Id round-trips,
  body matches header, hostile inbound replaced, overlong inbound
  replaced, ContextVar resets after request, generator format stable,
  each request gets unique ID).
2026-04-26 10:01:17 +02:00
Sn0rrii fdaec47378 feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1126)
feat(oidc): add Azure Entra ID support with configurable email claim resolution

Adds two new OIDC provider fields: email_claim and require_email_verified.
2026-04-25 13:32:42 +02:00
maziggy 4304a42542 feat(#729): per-spool category + low-stock threshold override
Two new optional fields on Spool: free-text `category` (max 50) and
  `low_stock_threshold_pct` (1-99). Powers the "differentiate critical
  spools from prototype spools and alert at different thresholds" use
  case from #729 without taking on the full multi-tag taxonomy + auto-
  apply rules + per-tag alert system the ticket originally proposed.

  Form gains:
  - Category input with datalist autocomplete sourced from categories
    already in use, so casing/spelling stays consistent.
  - Per-spool low-stock threshold input. Empty = global default; the
    global value renders as the placeholder.

  Inventory page:
  - New category filter chip (hidden until at least one spool carries
    a category — keeps the chip row uncluttered).
  - Stat-card "Low Stock" count and the "Low Stock" filter both honour
    the per-spool override.

  Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original
  ticket reporter mistook it for a taxonomy-tag delete; the button
  actually clears the RFID UID/UUID off the spool record). Toast key
  renamed from `tagDeleted` to `rfidCleared`.

  i18n: full translations across all 8 locales.

  Tests: 9 new backend schema tests (defaults, partial-update, range
  rejection, max-length); 2 new frontend tests (per-spool threshold
  pulls extra spools into low-stock count, filter chip hidden when no
  categories exist).
2026-04-25 13:24:49 +02:00
maziggy 12c01f029d Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)"
This reverts commit 50382006b3.
2026-04-25 11:05:32 +02:00
Sn0rrii 50382006b3 feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)
feat(oidc): add Azure Entra ID support with configurable email claim resolution
2026-04-25 11:02:06 +02:00
maziggy fcda728af4 feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
  V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
  shown to user exactly once on creation. New "Camera API Tokens" panel under
  Settings → API Keys with self-service create/revoke, styled confirm modal,
  admin "All users" view for leak triage. Auth path: /camera/stream tries the
  existing 60-min ephemeral table first, falls through to the long-lived path.
  Indexed lookup_prefix keeps verify O(1) per token.

  Permission audit: gated the existing API-keys-CRUD + Webhook docs + API
  Browser content behind api_keys:read so non-admins with camera:view land on
  the API Keys tab and see only the Camera Tokens panel they actually have
  permission to use. Grid layout collapses to single column for non-admins.

  Tests: 29 new backend (15 service + 14 integration covering create/list/
  revoke ownership rules, the auth fall-through, scope enforcement, prefix
  collisions) + 6 new frontend tests for the section UI including the new
  modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff
  clean (lint + format).

  Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a
  new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML
  example, security model, permission requirements, revoke flow). Website
  features.html gets the bullet under Camera Streaming.

  Also includes #1089 follow-up tweaks already merged in this branch:
  _stream_start_times.setdefault for accurate stream_uptime, subscribe()
  RuntimeError retry to close the grace-vs-subscribe race, atomic
  unsubscribe count via the iter_subscriber on_unsubscribe callback.
2026-04-25 10:44:37 +02:00
maziggy 7f11618e1e Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)"
This reverts commit 365c38483b.
2026-04-24 16:48:59 +02:00
Sn0rrii 365c38483b feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)
feat(oidc): add Azure Entra ID support with configurable email claim resolution
fix(oidc): harden email claim resolution, guards, and test coverage
2026-04-24 16:46:50 +02:00
maziggy 9e938cbc8c Revert "feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)"
This reverts commit 89f14c57ad.
2026-04-24 14:33:33 +02:00
Sn0rrii 89f14c57ad feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)
feat(inventory): replace Spoolman iframe with internal inventory UI

When Spoolman is enabled, the Inventory page now uses the same internal
UI (spool list, create/edit modal, archive, delete, weight sync) backed
by a new proxy layer instead of opening an iframe.
2026-04-24 14:00:45 +02:00
maziggy b99ceb26ed fix(db): dedupe legacy settings rows and add missing UNIQUE(key) index
Legacy SQLite installs created the `settings` table without a UNIQUE
  constraint on `key`. The seed loop's `INSERT OR IGNORE` silently
  degraded to a plain INSERT, so every `systemctl restart` added another
  row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful
  of restarts, `scalar_one_or_none()` in is_advanced_auth_enabled() and
  similar sites blew up with `MultipleResultsFound`, 500'ing the login
  flow.

  Run-migrations now deletes dup rows (keeping MIN(id) per key) and
  creates the missing `ix_settings_key` unique index before the seed
  loop. Both ops are idempotent — fresh installs and Postgres already
  have the index, so they no-op.
2026-04-24 11:07:55 +02:00
maziggy e8f252d2b8 Post work PR #701 2026-04-24 10:28:51 +02:00
lietschaend 91a3d391ff feat(virtual-printer): add Tailscale opt-out toggle (closes #701 point 3) (#1070)
* feat(virtual-printer): add Tailscale certificate provisioning
2026-04-24 09:59:09 +02:00