Parcourir la source

fix(db): give older PostgreSQL databases a new install's defaults and NOT NULLs

On PostgreSQL databases older than them, 51 columns were nullable where the
model says NOT NULL, or had a server default only those databases had: the
migrations that added them spelled them differently from the models.

The 35 model columns without a server default get one equal to their
default, so new installs have it too. A new migration sets each listed
column's default from the model and, while the column is still nullable,
fills its NULLs and sets NOT NULL. A NULL never gains access: API key
permissions are filled with false (the code read NULL as denied), and an
OIDC provider whose missing email claim gets filled has auto-linking
turned off, since it never linked an account.

An upgraded database now matches a fresh install, so
scripts/postgres_schema_drift.txt is empty. The upgrade check gets a
scenario with NULL permissions, a NULL integer and OIDC providers without
an email claim; a unit test keeps the list in step with the models.
maziggy il y a 1 semaine
Parent
commit
4588e7632c

+ 1 - 1
CHANGELOG.md

@@ -161,7 +161,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
-- **PostgreSQL databases from older versions get the links a new install has** — A database created before v0.2.4.1 (or before v1.2.5.2 for the last one) was missing three links: from a print log entry to its archive and to the user who started it, and from an archive to the library file it was printed from. Deleting an archive, a user or a library file left their ID behind instead of clearing it, so such entries didn't show up under "no user", for example. The links are added on the next start, and IDs of things already deleted are cleared. A print log entry without an archive also stays that way now: every start used to attach it to the newest archive with the same name on the same printer, which could be someone else's. Apart from that, SQLite installs are unchanged.
+- **PostgreSQL databases from older versions get the links a new install has** — A database created before v0.2.4.1 (or before v1.2.5.2 for the last one) was missing three links: from a print log entry to its archive and to the user who started it, and from an archive to the library file it was printed from. Deleting an archive, a user or a library file left their ID behind instead of clearing it, so such entries didn't show up under "no user", for example. The links are added on the next start, and IDs of things already deleted are cleared. A print log entry without an archive also stays that way now: every start used to attach it to the newest archive with the same name on the same printer, which could be someone else's. 51 columns, mostly settings flags, also accepted an empty value there or had a default only those databases had. They now match a new install, and an empty value gets the column's default, with two exceptions so nothing gains access: an empty API key permission becomes "off", and an OIDC provider without an email claim, which so far linked no accounts, gets auto-linking turned off instead of starting to link by email. Apart from that, SQLite installs are unchanged.
 - **Spoolman: colour, material and cost from the spool weren't applied on older PostgreSQL installs** — On a PostgreSQL database created before v0.2.4.8, the start snapshot of the AMS levels was stored as text instead of JSON and read back as text. Reporting usage to Spoolman then failed after charging the spools from the 3MF, with "Spoolman usage reporting failed" in the log. Prints without a 3MF charged nothing, and no archive got the colour, material and cost of its Spoolman spools. The column is converted on the next start. Two number columns also only had single precision on PostgreSQL databases older than them and are widened, keeping the values as entered: a spool's "Reset usage" baseline (databases created before v0.2.4.2) and a REST smart plug's energy total multiplier (before v1.2.5). SQLite installs aren't affected.
 - **Every install now gets the dependency versions that were tested** — Bambuddy's Python dependencies only had minimum versions, so each Docker build, install and update took whatever was newest that day: a fresh install could run library versions nobody had tested Bambuddy with (SQLAlchemy 2.1 arrived that way). They are now pinned in `requirements.lock`, which Docker, the installers, the update scripts and the tests all use, and newer versions are taken on deliberately, after testing. Nothing changes for an install today: the pins are the versions a fresh install gets now. A native install moves to the pinned versions with the update after the one that brings this change, since an update still runs the previous version of the update script.
 - **Native installs need Python 3.11 or newer** — Since v0.2.4.8 Bambuddy hasn't started on Python 3.10, while the installer, README and wiki still said 3.10 was enough. The requirement now says 3.11. The installer uses the system's `python3` when it is new enough and otherwise looks for a newer Python next to it: on Ubuntu 22.04, whose `python3` is 3.10, it installs and uses the `python3.11` package. An environment left from an older Python is rebuilt. The update scripts check the install's Python first and, if it is too old, stop before changing anything and link to the steps for moving it to 3.11. Docker and the Windows installer ship Python 3.13 and are not affected. Wiki updated.

+ 5 - 5
CONTRIBUTING.md

@@ -395,11 +395,11 @@ that existing rows violate, gets a scenario in `SCENARIOS` in the same script:
 a fixed chain of old releases to build the database, rows to add, and a query
 that must find nothing wrong after the upgrade.
 
-`scripts/postgres_schema_drift.txt` lists the drift that already existed when
-the check was introduced, each line an object and exactly how it differs. A
-listed object that starts to differ in another way fails like new drift. The
-list only shrinks: the check also fails on a line that no longer drifts, so a
-fix removes its line.
+`scripts/postgres_schema_drift.txt` can list known drift, each line an object
+and exactly how it differs; a listed object that starts to differ in another
+way fails like new drift, and so does a line that no longer drifts. The drift
+found when the check was introduced has been fixed and the list is empty: fix
+new drift with a migration rather than listing it.
 
 **Frontend** — tests use [Vitest](https://vitest.dev/) and are in `frontend/src/__tests__/`:
 

+ 107 - 0
backend/app/core/database.py

@@ -5424,6 +5424,113 @@ async def _run_migrations(conn):
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_pg_column_types(conn)
+    await _migrate_pg_columns_to_model(conn)
+
+
+# Columns that migrations added nullable or with a server default the model
+# didn't have, so on a PostgreSQL database older than them they differ from a
+# fresh install. Default and nullability are taken from the model itself.
+_PG_COLUMNS_SET_FROM_MODEL = (
+    ("api_keys", "can_access_cloud"),
+    ("api_keys", "can_manage_archives"),
+    ("api_keys", "can_manage_inventory"),
+    ("api_keys", "can_manage_library"),
+    ("api_keys", "can_manage_maintenance"),
+    ("api_keys", "can_manage_projects"),
+    ("api_keys", "can_send_notifications"),
+    ("api_keys", "can_update_energy_cost"),
+    ("github_backup_config", "allow_insecure_http"),
+    ("github_backup_config", "provider"),
+    ("groups", "restrict_printers"),
+    ("library_files", "variant_position"),
+    ("library_folders", "shared"),
+    ("notification_providers", "attach_photo"),
+    ("notification_providers", "on_ai_failure_detection"),
+    ("notification_providers", "on_ams_drying_suspended"),
+    ("notification_providers", "on_app_message"),
+    ("notification_providers", "on_billing_charge_failed"),
+    ("notification_providers", "on_ha_sensor_alert"),
+    ("notification_providers", "on_location_ha_sensor_alert"),
+    ("notification_providers", "on_plate_clear_required"),
+    ("notification_providers", "on_print_confirm_request"),
+    ("notification_providers", "on_stock_break_alert"),
+    ("notification_providers", "on_stock_reorder_alert"),
+    ("notification_providers", "telegram_verdict_mode"),
+    ("oidc_providers", "require_email_verified"),
+    ("oidc_providers", "email_claim"),
+    ("oidc_providers", "is_autologin"),
+    ("oidc_providers", "is_env_managed"),
+    ("print_archives", "bambuddy_forced_timelapse"),
+    ("print_archives", "wallet_charge_skipped"),
+    ("print_queue", "bed_levelling"),
+    ("print_queue", "cleanup_library_after_dispatch"),
+    ("print_queue", "confirm_outcome"),
+    ("print_queue", "dispatch_attempts"),
+    ("print_queue", "filament_short"),
+    ("print_queue", "flow_cali"),
+    ("print_queue", "gate_acknowledged"),
+    ("print_queue", "nozzle_offset_cali"),
+    ("print_queue", "preheat_override"),
+    ("print_queue", "skip_filament_check"),
+    ("printers", "camera_light_auto"),
+    ("smart_plugs", "auto_off_after_drying"),
+    ("smart_plugs", "controls_printer_power"),
+    ("smart_plugs", "off_delay_after_drying_minutes"),
+    ("smart_plugs", "rest_energy_total_multiplier"),
+    ("spool", "weight_used_baseline"),
+    ("virtual_printers", "gcode_injection"),
+    ("virtual_printers", "queue_force_color_match"),
+    ("virtual_printers", "save_ams_mapping"),
+    ("virtual_printers", "tailscale_disabled"),
+)
+
+
+async def _migrate_pg_columns_to_model(conn) -> None:
+    """Give the columns in _PG_COLUMNS_SET_FROM_MODEL the model's default and NOT NULL.
+
+    PostgreSQL only; SQLite can't change either, and its ADD COLUMN ... DEFAULT
+    filled every existing row. The default is set every start (a catalog
+    change, no rewrite); NOT NULL only while the column is still nullable, after
+    filling its NULLs with the model's default. Two exceptions keep what the
+    code made of a NULL rather than granting more:
+
+    - an API key's permissions are filled with false: the code reads NULL as
+      denied, and several default to true;
+    - an OIDC provider whose email_claim is filled gets auto-linking off. With
+      no email claim it never resolved an email, so it never linked an
+      account; with 'email' it would start to (and with require_email_verified
+      false, in the combination the CHECK constraint forbids).
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        return
+    compiler = conn.dialect.ddl_compiler(conn.dialect, None)
+    for table, column in _PG_COLUMNS_SET_FROM_MODEL:
+        model_column = Base.metadata.tables[table].c[column]
+        default = compiler.get_column_default_string(model_column)
+        await _safe_execute(conn, f"ALTER TABLE {table} ALTER COLUMN {column} SET DEFAULT {default}")
+        if model_column.nullable:
+            continue
+        nullable = (
+            await conn.execute(
+                text(
+                    "SELECT is_nullable FROM information_schema.columns "
+                    "WHERE table_schema = current_schema() AND table_name = :t AND column_name = :c"
+                ),
+                {"t": table, "c": column},
+            )
+        ).scalar_one()
+        if nullable != "YES":
+            continue
+        fill = "false" if table == "api_keys" else default
+        extra = ""
+        if (table, column) == ("oidc_providers", "email_claim"):
+            extra = ", auto_link_existing_accounts = false"
+        filled = await conn.execute(text(f"UPDATE {table} SET {column} = {fill}{extra} WHERE {column} IS NULL"))
+        if filled.rowcount:
+            logger.info("Filled %d NULL %s.%s value(s) with %s", filled.rowcount, table, column, fill)
+        await _safe_execute(conn, f"ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL")
 
 
 # Columns a migration added with another type than the model declares, so a

+ 11 - 9
backend/app/models/api_key.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, false, func, true
 from sqlalchemy.orm import Mapped, mapped_column
 
 from backend.app.core.database import Base
@@ -31,30 +31,32 @@ class APIKey(Base):
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
     can_manage_library: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Upload/rename/delete own library files + MakerWorld import
     can_manage_inventory: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Inventory write ops (incl. SpoolBuddy kiosk NFC/scale/system)
     can_manage_maintenance: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Log/reset per-printer maintenance, edit intervals, manage the type catalog (#1832 follow-up)
     can_manage_archives: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Create/update/delete print archives (not purge) (#1888)
     can_manage_projects: Mapped[bool] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # Create/update/delete projects + manage membership (add archives) (#1893)
-    can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
+    can_access_cloud: Mapped[bool] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # Read /cloud/* on the owner's behalf
     # Narrowly-scoped settings write: only POST /settings/electricity-price.
     # Lets HA/Tibber-style automations push dynamic tariff updates without
     # granting full SETTINGS_UPDATE (which is denied for API keys because it
     # could rewrite SMTP/LDAP/MQTT credentials).
-    can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False)
+    can_update_energy_cost: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     # Send a message through the notification channels that accept app
     # messages (POST /notifications/app-message). Nothing else: no reading or
     # changing the channels themselves.
-    can_send_notifications: Mapped[bool] = mapped_column(Boolean, default=False)
+    can_send_notifications: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Optional scope limits
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers

+ 2 - 2
backend/app/models/archive.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
+from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -104,7 +104,7 @@ class PrintArchive(Base):
 
     # User additions
     is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
-    wallet_charge_skipped: Mapped[bool] = mapped_column(Boolean, default=False)
+    wallet_charge_skipped: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     tags: Mapped[str | None] = mapped_column(Text)
     notes: Mapped[str | None] = mapped_column(Text)
     cost: Mapped[float | None] = mapped_column(Float)

+ 3 - 3
backend/app/models/github_backup.py

@@ -2,7 +2,7 @@
 
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -17,8 +17,8 @@ class GitHubBackupConfig(Base):
     repository_url: Mapped[str] = mapped_column(String(500))  # Full GitHub URL
     access_token: Mapped[str] = mapped_column(Text)  # Personal Access Token
     branch: Mapped[str] = mapped_column(String(100), default="main")
-    provider: Mapped[str] = mapped_column(String(30), default="github")
-    allow_insecure_http: Mapped[bool] = mapped_column(Boolean, default=False)
+    provider: Mapped[str] = mapped_column(String(30), default="github", server_default="github")
+    allow_insecure_http: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Schedule configuration
     schedule_enabled: Mapped[bool] = mapped_column(Boolean, default=False)

+ 15 - 13
backend/app/models/notification.py

@@ -3,7 +3,7 @@
 from datetime import datetime
 from typing import TYPE_CHECKING
 
-from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, false, true
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -76,7 +76,7 @@ class NotificationProvider(Base):
     # Attach a camera snapshot when one was captured for the event. Defaults
     # True so ntfy/Pushover/Telegram/Discord keep behaving as they always
     # did; this is the opt-out for whoever doesn't want it.
-    attach_photo: Mapped[bool | None] = mapped_column(Boolean, default=True)
+    attach_photo: Mapped[bool | None] = mapped_column(Boolean, default=True, server_default=true())
 
     # Event triggers - print lifecycle
     on_print_start: Mapped[bool | None] = mapped_column(Boolean, default=False)
@@ -88,14 +88,14 @@ class NotificationProvider(Base):
         Boolean, default=False
     )  # Print started with unassigned required tray(s)
     on_billing_charge_failed: Mapped[bool | None] = mapped_column(
-        Boolean, default=True
+        Boolean, default=True, server_default=true()
     )  # A completed/stopped print could not be charged
 
     # Event triggers - printer status
     on_printer_offline: Mapped[bool | None] = mapped_column(Boolean, default=False)
     on_printer_error: Mapped[bool | None] = mapped_column(Boolean, default=False)  # AMS issues, etc.
     on_ai_failure_detection: Mapped[bool | None] = mapped_column(
-        Boolean, default=False
+        Boolean, default=False, server_default=false()
     )  # Obico spaghetti / failure detection (#1794)
     on_filament_low: Mapped[bool | None] = mapped_column(Boolean, default=False)
     on_maintenance_due: Mapped[bool | None] = mapped_column(Boolean, default=False)  # Maintenance reminder
@@ -107,7 +107,7 @@ class NotificationProvider(Base):
     )  # AMS temperature above threshold
     # Auto-drying gave up on a unit (#2770). Defaults True: it reports that
     # Bambuddy has stopped acting, which nothing else in the UI would say.
-    on_ams_drying_suspended: Mapped[bool | None] = mapped_column(Boolean, default=True)
+    on_ams_drying_suspended: Mapped[bool | None] = mapped_column(Boolean, default=True, server_default=true())
 
     # Event triggers - AMS-HT environmental alarms (single slot heated AMS)
     on_ams_ht_humidity_high: Mapped[bool | None] = mapped_column(
@@ -119,7 +119,7 @@ class NotificationProvider(Base):
 
     # Event triggers - Home Assistant sensors bound to a printer (#1148)
     on_ha_sensor_alert: Mapped[bool | None] = mapped_column(
-        Boolean, default=False
+        Boolean, default=False, server_default=false()
     )  # Bound HA sensor entered its alert state
 
     # Event triggers - Home Assistant sensors bound to a storage location (#2824)
@@ -127,7 +127,7 @@ class NotificationProvider(Base):
     # be scoped to a single printer, and a location alert has no printer to
     # scope by, so sharing it would leak drybox alerts to a provider narrowed
     # to one printer's sensors.
-    on_location_ha_sensor_alert: Mapped[bool | None] = mapped_column(Boolean, default=False)
+    on_location_ha_sensor_alert: Mapped[bool | None] = mapped_column(Boolean, default=False, server_default=false())
 
     # Event triggers - Build plate detection
     on_plate_not_empty: Mapped[bool | None] = mapped_column(
@@ -135,19 +135,19 @@ class NotificationProvider(Base):
     )  # Objects detected on plate before print
     # Off by default: fires after every print, alongside the print-complete alert (#2525)
     on_plate_clear_required: Mapped[bool | None] = mapped_column(
-        Boolean, default=False
+        Boolean, default=False, server_default=false()
     )  # Print ended, queue gated until plate is confirmed clear
 
     # Print asked for an outcome verdict (#1898). Defaults ON: it only ever
     # fires for prints where the user opted in per-job, so the provider-level
     # toggle exists to silence a channel, not to enable the feature.
-    on_print_confirm_request: Mapped[bool | None] = mapped_column(Boolean, default=True)
+    on_print_confirm_request: Mapped[bool | None] = mapped_column(Boolean, default=True, server_default=true())
     # How a Telegram provider collects that verdict (#3046): inline URL
     # buttons ("buttons", the original behaviour), a thumbs-up/down reaction
     # on the message ("reactions"), or both. Reactions need no inbound
     # connectivity — the poller fetches them — so they work without an
     # external_url. Ignored for every other provider type.
-    telegram_verdict_mode: Mapped[str | None] = mapped_column(String(16), default="buttons")
+    telegram_verdict_mode: Mapped[str | None] = mapped_column(String(16), default="buttons", server_default="buttons")
 
     # Event triggers - Bed cooled after print
     on_bed_cooled: Mapped[bool | None] = mapped_column(Boolean, default=False)  # Bed cooled below threshold after print
@@ -156,12 +156,14 @@ class NotificationProvider(Base):
     )  # First layer finished printing
     # Messages another application sends through Bambuddy (POST /notifications/app-message),
     # e.g. Bambuddy Orders' "an order needs you". Off by default: nothing new arrives on upgrade.
-    on_app_message: Mapped[bool | None] = mapped_column(Boolean, default=False)
+    on_app_message: Mapped[bool | None] = mapped_column(Boolean, default=False, server_default=false())
 
     # Event triggers - Inventory stock alerts
-    on_stock_reorder_alert: Mapped[bool | None] = mapped_column(Boolean, default=False)  # SKU hits reorder point
+    on_stock_reorder_alert: Mapped[bool | None] = mapped_column(
+        Boolean, default=False, server_default=false()
+    )  # SKU hits reorder point
     on_stock_break_alert: Mapped[bool | None] = mapped_column(
-        Boolean, default=False
+        Boolean, default=False, server_default=false()
     )  # Stock will run out before replenishment
 
     # Event triggers - Print queue

+ 3 - 2
backend/app/models/oidc_provider.py

@@ -14,6 +14,7 @@ from sqlalchemy import (
     Text,
     UniqueConstraint,
     func,
+    true,
 )
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
@@ -90,14 +91,14 @@ class OIDCProvider(Base):
     # and is the recommended Azure configuration.
     # Has no interaction with require_email_verified when set to a non-"email" value:
     # custom claims never perform an email_verified check regardless of that setting.
-    email_claim: Mapped[str] = mapped_column(String(64), default="email")
+    email_claim: Mapped[str] = mapped_column(String(64), default="email", server_default="email")
     # When True (default), the "email" claim is only trusted when email_verified=True.
     # Set to False to accept the email even when email_verified is absent — required
     # for providers like Azure Entra ID that never send email_verified and where a
     # custom claim (email_claim != "email") is not preferred.
     # Has no effect when email_claim is not "email": the custom-claim path never
     # performs an email_verified check regardless of this setting.
-    require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True)
+    require_email_verified: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
     # Nullable FK — configurable default group for auto-created OIDC users.
     # Falls back to "Viewers" when None. ON DELETE SET NULL fires on PostgreSQL;
     # SQLite ignores it (no PRAGMA foreign_keys=ON), so runtime resolution handles dangling refs.

+ 9 - 9
backend/app/models/print_queue.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -105,19 +105,19 @@ class PrintQueueItem(Base):
 
     # Printer-card direct uploads create transient library rows. When this is
     # true, the scheduler deletes the source row/files after archiving a copy.
-    cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
+    cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
     # strings (off/on/auto) matching BambuStudio; "auto" = skip if recently done.
     # The remaining three stay boolean (BambuStudio exposes no auto for them).
-    bed_levelling: Mapped[str] = mapped_column(String(8), default="auto")
-    flow_cali: Mapped[str] = mapped_column(String(8), default="auto")
+    bed_levelling: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
+    flow_cali: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
     vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
     layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
     timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
     use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
     # Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
-    nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
+    nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto")
     # Ask for a post-print outcome verdict when this job completes (#1898).
     # Copied onto the archive as confirm_requested at dispatch, like plate_id.
     confirm_outcome: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
@@ -128,7 +128,7 @@ class PrintQueueItem(Base):
     # tray type]) → 0 (skips chamber phase). 'inherit' + global off + override
     # null = no preheat. Default 'inherit' so existing queue items behave
     # exactly as before the migration.
-    preheat_override: Mapped[str] = mapped_column(String(10), default="inherit")
+    preheat_override: Mapped[str] = mapped_column(String(10), default="inherit", server_default="inherit")
     preheat_chamber_target_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
     # Status: pending, printing, completed, failed, skipped, cancelled
@@ -151,14 +151,14 @@ class PrintQueueItem(Base):
     # lookback excluded `skipped` but had no way to dismiss the originating
     # failure. The flag is per-item, not per-printer, so a fresh failure
     # after a resume re-gates downstream items independently.
-    gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
+    gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Set by the dispatch scheduler when the assigned spool can't satisfy
     # this print's per-slot filament weight (#1496). Display-only flag — the
     # actual deficit is recomputed live every time the user clicks ▶, so
     # swapping a spool to a fuller one between flag and dispatch clears the
     # block automatically.
-    filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
+    filament_short: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # User has acknowledged the filament-shortage warning for this item
     # ("Print Anyway"). Set by the start route when the user passes
@@ -166,7 +166,7 @@ class PrintQueueItem(Base):
     # frontend deficit warning was acknowledged. Survives scheduler ticks so
     # the dispatch no longer bounces between "user said anyway" and
     # "scheduler re-flagged" (#1698-followup).
-    skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False)
+    skip_filament_check: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
 
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 2 - 2
backend/app/models/printer.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, String, func
+from sqlalchemy import Boolean, DateTime, Float, String, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -41,7 +41,7 @@ class Printer(Base):
     camera_rotation: Mapped[int] = mapped_column(default=0)  # 0, 90, 180, 270 degrees
     # Picked for the chamber light while the camera is in use (#1655). Only
     # counts when the app setting camera_light_mode is "selected".
-    camera_light_auto: Mapped[bool] = mapped_column(Boolean, default=False)
+    camera_light_auto: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
     # Plate detection - check if build plate is empty before starting print
     plate_detection_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
     # ROI for plate detection (percentages: 0.0-1.0)

+ 1 - 1
backend/app/models/spool.py

@@ -36,7 +36,7 @@ class Spool(Base):
     # usage to 0" action stamps baseline = weight_used so the counter zeroes
     # without disturbing remaining (= label_weight - weight_used). Matches
     # Spoolman's split between used_weight and remaining_weight (#1390).
-    weight_used_baseline: Mapped[float] = mapped_column(Float, default=0)
+    weight_used_baseline: Mapped[float] = mapped_column(Float, default=0, server_default="0")
     weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
     last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)
     last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed

+ 26 - 0
backend/tests/unit/test_pg_columns_set_from_model.py

@@ -0,0 +1,26 @@
+"""_migrate_pg_columns_to_model takes each listed column's default from the model.
+
+It runs on PostgreSQL only (covered by scripts/check_postgres_upgrade.py); this
+checks that the list still matches the models, which a model change could break
+without any PostgreSQL at hand: a column without a server_default would be given
+DEFAULT None.
+"""
+
+from sqlalchemy.dialects import postgresql
+
+from backend.app.core.database import _PG_COLUMNS_SET_FROM_MODEL
+from backend.tests.unit.test_code_quality import _load_all_models
+
+
+def test_every_listed_column_exists_and_has_a_server_default():
+    tables = _load_all_models().metadata.tables
+    dialect = postgresql.dialect()
+    compiler = dialect.ddl_compiler(dialect, None)
+    missing = []
+    for table, column in _PG_COLUMNS_SET_FROM_MODEL:
+        model_column = tables[table].c.get(column) if table in tables else None
+        if model_column is None:
+            missing.append(f"{table}.{column}: not in the models")
+        elif compiler.get_column_default_string(model_column) is None:
+            missing.append(f"{table}.{column}: no server_default")
+    assert missing == []

+ 2 - 2
backend/tests/unit/test_smart_plug_power_flag_migration_2629.py

@@ -167,7 +167,7 @@ class TestPostgresBranch:
     @pytest.mark.asyncio
     async def test_pg_branch_uses_true_and_if_not_exists(self):
         executed = await self._capture_sql(is_sqlite_value=False)
-        stmts = [s for s in executed if "controls_printer_power" in s]
+        stmts = [s for s in executed if "ADD COLUMN" in s and "controls_printer_power" in s]
 
         assert len(stmts) == 1, f"expected exactly one statement, got: {stmts!r}"
         assert "IF NOT EXISTS" in stmts[0]  # idempotent on PG, which has no _safe_execute retry semantics
@@ -177,7 +177,7 @@ class TestPostgresBranch:
     async def test_sqlite_branch_uses_numeric_default(self):
         """SQLite has no true/false literal — the switch must not be inverted."""
         executed = await self._capture_sql(is_sqlite_value=True)
-        stmts = [s for s in executed if "controls_printer_power" in s]
+        stmts = [s for s in executed if "ADD COLUMN" in s and "controls_printer_power" in s]
 
         assert len(stmts) == 1
         assert "DEFAULT 1" in stmts[0]

+ 55 - 7
scripts/check_postgres_upgrade.py

@@ -16,11 +16,11 @@ leave every schema, fresh or upgraded, exactly as the first start left it.
 Some upgrades only go wrong with data in the database; SCENARIOS builds those
 along a fixed chain of releases, adds the rows, upgrades and checks the data.
 
-Drift that existed when this check was introduced is listed in
-scripts/postgres_schema_drift.txt, each line the object and exactly how it
-differs. Drift not listed there fails, including a listed object that now
-differs in another way, and so does a listed line that no longer drifts:
-remove it.
+scripts/postgres_schema_drift.txt can list known drift, each line the object
+and exactly how it differs. Drift not listed there fails, including a listed
+object that now differs in another way, and so does a listed line that no
+longer drifts. The drift found when this check was introduced has been fixed
+and the list is empty: fix new drift with a migration rather than listing it.
 
 Usage (needs git with the release tags and a PostgreSQL server the given user
 may create databases on; every database it creates is dropped again):
@@ -160,6 +160,53 @@ SCENARIOS = (
             FROM spool WHERE id = 1 AND weight_used_baseline IS DISTINCT FROM CAST(1234.567 AS double precision)
         """,
     ),
+    Scenario(
+        name="NULLs in columns that become NOT NULL",
+        # A v0.2.3 database upgraded by v1.2.5.7 has these columns nullable.
+        # A NULL is filled with the model's default, except an API key's
+        # permissions: the code reads NULL as denied, and that must stay so
+        # rather than become the default (granted). An OIDC provider with no
+        # email claim never resolved an email, so it never linked an account:
+        # filling the claim must not start that, so auto-linking goes off.
+        chain=("v0.2.3", "v1.2.5.7"),
+        seed="""
+            INSERT INTO api_keys (id, name, key_hash, key_prefix, enabled, can_queue, can_control_printer,
+                                  can_read_status, can_manage_archives, can_access_cloud)
+            VALUES (1, 'k', 'hash', 'bb_', true, false, false, true, NULL, NULL);
+            INSERT INTO library_files (id, filename, file_path, file_type, file_size, print_count, is_external,
+                                       variant_position)
+            VALUES (1, 'f.3mf', 'l/f.3mf', '3mf', 1, 0, false, NULL);
+            INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret, scopes, is_enabled,
+                                        auto_create_users, auto_link_existing_accounts, email_claim,
+                                        require_email_verified)
+            VALUES (1, 'unsafe once filled', 'https://idp.example', 'c', 's', 'openid', true, false, true, NULL,
+                    false),
+                   (2, 'strict once filled', 'https://idp2.example', 'c', 's', 'openid', true, false, true, NULL,
+                    NULL),
+                   (3, 'has a claim', 'https://idp3.example', 'c', 's', 'openid', true, false, true, 'upn', NULL);
+        """,
+        check="""
+            SELECT 'api_keys.can_manage_archives is ' || coalesce(can_manage_archives::text, 'NULL')
+            FROM api_keys WHERE id = 1 AND can_manage_archives IS DISTINCT FROM false
+            UNION ALL
+            SELECT 'api_keys.can_access_cloud is ' || coalesce(can_access_cloud::text, 'NULL')
+            FROM api_keys WHERE id = 1 AND can_access_cloud IS DISTINCT FROM false
+            UNION ALL
+            SELECT 'library_files.variant_position is ' || coalesce(variant_position::text, 'NULL')
+            FROM library_files WHERE id = 1 AND variant_position IS DISTINCT FROM 0
+            UNION ALL
+            SELECT 'oidc_providers ' || id || ': ' || coalesce(email_claim, 'NULL') || ', verified '
+                   || coalesce(require_email_verified::text, 'NULL') || ', auto-link '
+                   || auto_link_existing_accounts::text
+            FROM oidc_providers
+            WHERE (id = 1 AND (email_claim IS DISTINCT FROM 'email' OR require_email_verified IS DISTINCT FROM false
+                               OR auto_link_existing_accounts))
+               OR (id = 2 AND (email_claim IS DISTINCT FROM 'email' OR require_email_verified IS DISTINCT FROM true
+                               OR auto_link_existing_accounts))
+               OR (id = 3 AND (email_claim IS DISTINCT FROM 'upn' OR require_email_verified IS DISTINCT FROM true
+                               OR NOT auto_link_existing_accounts))
+        """,
+    ),
 )
 
 _INIT_DB = """
@@ -366,7 +413,8 @@ def main() -> int:
     finally:
         asyncio.run(_admin(admin_url, *(f"DROP DATABASE IF EXISTS {db}" for db in databases.values())))
 
-    problems += compare(found, read_allowlist(ALLOWLIST))
+    allowed = read_allowlist(ALLOWLIST)
+    problems += compare(found, allowed)
     if problems:
         print("\nSchema problems:\n  " + "\n  ".join(problems))
         print(
@@ -374,7 +422,7 @@ def main() -> int:
             "model does. See the docstring of scripts/check_postgres_upgrade.py."
         )
         return 1
-    print("Upgraded schemas match a fresh install (apart from the listed known drift).")
+    print("Upgraded schemas match a fresh install" + (" (apart from the listed known drift)." if allowed else "."))
     return 0
 
 

+ 3 - 53
scripts/postgres_schema_drift.txt

@@ -6,56 +6,6 @@
 # differs in another way) and on a line that no longer drifts: a fix that brings
 # an upgraded database in line with the models removes its line.
 # Comment lines start with "#".
-
-# columns
-column api_keys.can_access_cloud | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column api_keys.can_manage_archives | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
-column api_keys.can_manage_inventory | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
-column api_keys.can_manage_library | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
-column api_keys.can_manage_maintenance | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
-column api_keys.can_manage_projects | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
-column api_keys.can_send_notifications | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column api_keys.can_update_energy_cost | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column github_backup_config.allow_insecure_http | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column github_backup_config.provider | fresh install: character varying (udt varchar, length 30) NOT NULL default None; upgraded: character varying (udt varchar, length 30) NULL default 'github'::character varying
-column groups.restrict_printers | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column library_files.variant_position | fresh install: integer (udt int4, precision 32, scale 0) NOT NULL default 0; upgraded: integer (udt int4, precision 32, scale 0) NULL default 0
-column library_folders.shared | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column notification_providers.attach_photo | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
-column notification_providers.on_ai_failure_detection | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
-column notification_providers.on_ams_drying_suspended | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
-column notification_providers.on_app_message | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
-column notification_providers.on_billing_charge_failed | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
-column notification_providers.on_ha_sensor_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
-column notification_providers.on_location_ha_sensor_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
-column notification_providers.on_plate_clear_required | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
-column notification_providers.on_print_confirm_request | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default true
-column notification_providers.on_stock_break_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
-column notification_providers.on_stock_reorder_alert | fresh install: boolean (udt bool) NULL default None; upgraded: boolean (udt bool) NULL default false
-column notification_providers.telegram_verdict_mode | fresh install: character varying (udt varchar, length 16) NULL default None; upgraded: character varying (udt varchar, length 16) NULL default 'buttons'::character varying
-column oidc_providers.email_claim | fresh install: character varying (udt varchar, length 64) NOT NULL default None; upgraded: character varying (udt varchar, length 64) NULL default 'email'::character varying
-column oidc_providers.is_autologin | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column oidc_providers.is_env_managed | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column oidc_providers.require_email_verified | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default true
-column print_archives.bambuddy_forced_timelapse | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column print_archives.wallet_charge_skipped | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column print_queue.bed_levelling | fresh install: character varying (udt varchar, length 8) NOT NULL default None; upgraded: character varying (udt varchar, length 8) NOT NULL default 'auto'::character varying
-column print_queue.cleanup_library_after_dispatch | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column print_queue.confirm_outcome | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column print_queue.dispatch_attempts | fresh install: integer (udt int4, precision 32, scale 0) NOT NULL default 0; upgraded: integer (udt int4, precision 32, scale 0) NULL default 0
-column print_queue.filament_short | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column print_queue.flow_cali | fresh install: character varying (udt varchar, length 8) NOT NULL default None; upgraded: character varying (udt varchar, length 8) NOT NULL default 'auto'::character varying
-column print_queue.gate_acknowledged | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column print_queue.nozzle_offset_cali | fresh install: character varying (udt varchar, length 8) NOT NULL default None; upgraded: character varying (udt varchar, length 8) NULL default 'auto'::character varying
-column print_queue.preheat_override | fresh install: character varying (udt varchar, length 10) NOT NULL default None; upgraded: character varying (udt varchar, length 10) NULL default 'inherit'::character varying
-column print_queue.skip_filament_check | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column printers.camera_light_auto | fresh install: boolean (udt bool) NOT NULL default None; upgraded: boolean (udt bool) NULL default false
-column smart_plugs.auto_off_after_drying | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column smart_plugs.controls_printer_power | fresh install: boolean (udt bool) NOT NULL default true; upgraded: boolean (udt bool) NULL default true
-column smart_plugs.off_delay_after_drying_minutes | fresh install: integer (udt int4, precision 32, scale 0) NOT NULL default 10; upgraded: integer (udt int4, precision 32, scale 0) NULL default 10
-column smart_plugs.rest_energy_total_multiplier | fresh install: double precision (udt float8, precision 53) NOT NULL default '1'::double precision; upgraded: double precision (udt float8, precision 53) NULL default 1.0
-column spool.weight_used_baseline | fresh install: double precision (udt float8, precision 53) NOT NULL default None; upgraded: double precision (udt float8, precision 53) NULL default 0
-column virtual_printers.gcode_injection | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column virtual_printers.queue_force_color_match | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column virtual_printers.save_ams_mapping | fresh install: boolean (udt bool) NOT NULL default false; upgraded: boolean (udt bool) NULL default false
-column virtual_printers.tailscale_disabled | fresh install: boolean (udt bool) NOT NULL default true; upgraded: boolean (udt bool) NULL default true
+#
+# Empty: all drift found then was fixed on 2026-10-11. Fix new drift with a
+# migration instead of listing it here.