Browse Source

fix(settings): restoring an old SQLite backup onto PostgreSQL no longer fails on project values

Older versions stored any project priority or status. Restoring a backup of
such an install onto PostgreSQL copied the rows as they were, and a value
longer than the column made PostgreSQL refuse the row and the whole restore
fail ("value too long for type character varying(20)"). The import now gives
an unknown priority "normal" and an unknown status "active" while copying,
the fallbacks project import and the start-up repair use. Checked end to end
against a throwaway PostgreSQL, with and without the fix.
maziggy 1 week ago
parent
commit
369ce6e1b5

+ 1 - 1
CHANGELOG.md

@@ -175,7 +175,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **No more firmware-check errors in the log while the internet is down** — Without an internet connection, every firmware check of every printer logged a full error for each Bambu Lab page it tried (measured: 12 errors and 9 warnings for three printers checked three times). The outage is now logged once per Bambu Lab site as a warning, the checks keep retrying quietly, and a line in the log says when the site is reachable again.
 - **Bambu Cloud being unreachable is reported as such** — When Bambu Cloud failed (no internet, a blocked or failing connection, an error or an error page such as a Cloudflare challenge), the cloud preset, device and firmware views answered with a server error (500), and pasting an access token answered as if the token were wrong (401). Signing in or entering the email code without a connection to Bambu Cloud also answered 401, as if the password or code were wrong. These now answer 502 (Bambu Cloud unreachable) with the cause, as the preset lookups already did; a pasted token counts as invalid only when Bambu Cloud itself refuses it. An expired session is answered as such (401, sign in again) instead of with a server error.
 - **Editing a smart plug can no longer save a plug that cannot be read back** — Adding a plug checks its values: a Tasmota plug needs an IPv4 address, a Home Assistant plug an entity of the right kind, an MQTT plug a topic, a REST plug an ON or OFF URL, and the name must not be empty. Editing one did not, so clearing such a field, switching the type without filling in the new one, or typing a hostname as a Tasmota address saved a plug that the smart plug list, the plug itself and the edit all failed on with a server error until it was fixed in the database. Such an edit is now refused with the same message as when adding a plug, and the plug stays as it was. Switching a plug to Tasmota on a printer that already has a Tasmota plug is refused as well (adding one already was); before, it gave the printer a second one, and adding a plug to that printer then failed with a server error.
-- **Creating or importing a project gives it a priority and status the project editor accepts** — Importing a project from a file without a priority gave it the priority `0`: on PostgreSQL the import failed with a server error, on SQLite the project got a priority the project editor itself refuses. Creating a project through the API stored any priority, and importing one any status, the same way (a value longer than 20 characters failed with a server error on PostgreSQL). An imported project now keeps a valid priority (low, normal, high or urgent) and status (active, completed or archived) and gets **normal** and **active** otherwise, from a file and from the JSON import alike. Creating a project with an unknown priority is refused with "Invalid priority", as editing one already was. Such projects could not be edited at all (every save, even of the name alone, was refused as an invalid priority or status); existing ones get **normal** and **active** at the next start.
+- **Creating or importing a project gives it a priority and status the project editor accepts** — Importing a project from a file without a priority gave it the priority `0`: on PostgreSQL the import failed with a server error, on SQLite the project got a priority the project editor itself refuses. Creating a project through the API stored any priority, and importing one any status, the same way (a value longer than 20 characters failed with a server error on PostgreSQL). An imported project now keeps a valid priority (low, normal, high or urgent) and status (active, completed or archived) and gets **normal** and **active** otherwise, from a file and from the JSON import alike. Creating a project with an unknown priority is refused with "Invalid priority", as editing one already was. Such projects could not be edited at all (every save, even of the name alone, was refused as an invalid priority or status); existing ones get **normal** and **active** at the next start. Restoring a backup from such an older SQLite install onto PostgreSQL no longer fails on them either: they get the same values while restoring.
 - **The "Password Reset" email template is used again** — Since password resets send a link instead of a new password, the reset email (self-service and admin) was always the built-in one: editing the "Password Reset" template under Notifications changed nothing, and its default text still promised a new password. The reset email is now built from that template, and Bambuddy adds the reset link at the end of it (and as a button); the template itself cannot place or contain the link. `{password}` and `{login_url}` are gone from this template. The default text is updated on upgrade unless you edited it; if you did, reword it for a link instead of a new password.
 - **A cost centre is charged for a print whose user was deleted while it ran** — When a user was deleted during their print, the print was never charged: the cost centre's ledger missed its cost, and its budget reservation stayed active for good, holding back that much of the budget. The print is now charged to its cost centre, also to the deleted user's own private one, which their personal prints go through, and its reservation is settled. A queue print is charged under the user who queued it, never under the owner of the archive it reprints. Ledger entries without a user now read "No user" instead of "Deleted user": with login turned off, prints on a cost centre are now charged too and never had a user. Deleting a user together with their items now also releases the budget their queued prints still held; a print of theirs still running is kept until it ends (without a user) and billed to its cost centre like any other. Only a print with no cost centre at all (started while billing was off) has nobody left to charge.
 - **Deleting a printer no longer deletes an archive of a print starting at that moment (PostgreSQL)** — If a queue or reprint print started on the printer while it was being deleted, the delete could take the print's archive with it, even with "keep archives" and even when the archive belonged to another printer or a virtual printer queue. The delete now locks the printer while it runs, so such a print start waits and is turned away, and its archive stays as it was; this is logged as a print start for a deleted printer, not as an error, and sends no start notification. An archive that could not be written no longer leaves its copied 3MF behind in the archive folder.

+ 19 - 0
backend/app/api/routes/settings.py

@@ -1092,6 +1092,20 @@ def _read_backup_manifest(temp_path: Path) -> dict:
     return data if isinstance(data, dict) else {}
 
 
+def _restored_value_fixes(table_name: str) -> dict[str, tuple[tuple[str, ...], str]]:
+    """Columns of ``table_name`` whose backed-up value must be one of a set, with its fallback.
+
+    Older versions stored any project priority or status; one longer than the
+    column fails the whole import on PostgreSQL. The fallbacks are the ones
+    project import and the start-up repair use.
+    """
+    if table_name != "projects":
+        return {}
+    from backend.app.models.project import PROJECT_PRIORITIES, PROJECT_STATUSES
+
+    return {"priority": (PROJECT_PRIORITIES, "normal"), "status": (PROJECT_STATUSES, "active")}
+
+
 async def _import_sqlite_to_postgres(sqlite_path: Path):
     """Import data from a SQLite database file into the current PostgreSQL database.
 
@@ -1278,6 +1292,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
                                         not_null_defaults[col.name] = sd
 
                 now = dt.now()
+                value_fixes = _restored_value_fixes(table_name)
 
                 def _convert_row(
                     row,
@@ -1287,6 +1302,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
                     nn_defaults=not_null_defaults,
                     _now=now,
                     inject=injected,
+                    fixes=value_fixes,
                 ):
                     result = dict(inject)
                     for c in cols:
@@ -1302,6 +1318,9 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
                                 except ValueError:
                                     pass
                         result[c] = val
+                    for c, (allowed, fallback) in fixes.items():
+                        if c in result and result[c] not in allowed:
+                            result[c] = fallback
                     return result
 
                 batch = [_convert_row(row) for row in rows]

+ 19 - 0
backend/tests/unit/test_restore_project_values.py

@@ -0,0 +1,19 @@
+"""Restoring a SQLite backup onto PostgreSQL gives old projects a valid priority and status.
+
+Older versions stored any project priority or status; one longer than the
+column made PostgreSQL refuse the row and the whole restore failed (measured
+end to end against a throwaway PostgreSQL when this was written).
+"""
+
+from backend.app.api.routes.settings import _restored_value_fixes
+from backend.app.models.project import PROJECT_PRIORITIES, PROJECT_STATUSES
+
+
+def test_projects_fall_back_like_import_and_the_start_up_repair():
+    fixes = _restored_value_fixes("projects")
+
+    assert fixes == {"priority": (PROJECT_PRIORITIES, "normal"), "status": (PROJECT_STATUSES, "active")}
+
+
+def test_other_tables_are_copied_as_they_are():
+    assert _restored_value_fixes("print_queue") == {}