Просмотр исходного кода

Read a NULL notification flag as off instead of dropping every provider (issue #2827)

Adding on_stock_reorder_alert and on_stock_break_alert to the provider
schema made them required on the way out as well as in: the response model
inherits the write model. Every on_* column on notification_providers is
nullable with no server default, and where the table was created from
Base.metadata before run_migrations, the ALTER ... DEFAULT false that
introduced those columns was swallowed as a duplicate and never backfilled
existing rows. Those NULLs were harmless until the flags were read, at
which point the row failed validation -- and a list is validated as a
whole, so one row took every provider with it. The route returned 500 and
the UI rendered an empty list, so configured providers looked deleted.

Backfill them to off, which is what the sender already assumed: it selects
providers with IS TRUE, so a NULL flag never sent anything. A NULL flag now
also reads as off rather than failing the response, across all of them, so
the next flag added to this schema cannot repeat it. Writes are unchanged.
maziggy 1 неделя назад
Родитель
Сommit
d9bc7ae47a

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 
 ### Fixed
+- **Every notification provider vanished from the list after the inventory toggles were wired up** — Adding `on_stock_reorder_alert` and `on_stock_break_alert` to the provider schema made them required on the way out as well as the way in, because the response model inherits the write model. Every `on_*` column on `notification_providers` is nullable with no server default, and on an install where the table had already been created from the ORM metadata before migrations ran, the `ALTER ... DEFAULT false` that introduced those two columns was swallowed as a duplicate and never backfilled the rows that were already there. Those NULLs sat harmless for as long as nothing read them; the moment the flags were declared on the response, the row failed validation, and since a list is validated as a whole, one such row took every provider down with it. The API returned a 500 and the UI rendered what it was given — an empty list — so correctly configured providers looked deleted while sitting untouched in the database. They are backfilled to off on the next start, matching what the sender already did with them: it selects providers with `IS TRUE`, so a NULL flag never sent anything. A NULL flag now also reads as off rather than failing the response, so the next flag added to that schema cannot repeat this.
 - **Two inventory notification toggles could never be turned on, so stock alerts have never been able to fire** — `on_stock_reorder_alert` and `on_stock_break_alert` exist as columns on a notification provider, have their own templates, and `notification_service` looks providers up under exactly those names before sending. The whole UI is there too: a toggle in Add/Edit Notification, a badge on the provider card, the field in the API client's types, and tests for all of it. The one thing missing was the schema. `NotificationProviderCreate`/`Update` never declared either field, and Pydantic drops what it does not declare, so every request that carried them came back `200 OK` with the row unchanged — and `_provider_to_dict`, which is a hand-maintained field-by-field map, never returned them either, so the toggle read back off no matter what the database held. Nothing errored anywhere along that path. Both directions are wired now, and the round-trip tests that already covered the Home Assistant toggles cover these too, because the failure is structural rather than particular to one field: any column missing from those two maps is invisible to a test that builds providers through the ORM, and only a create-then-re-read through the route catches it. This makes the setting stick and report itself honestly; the detection side that would *call* those two senders does not exist yet, so turning them on does not yet produce notifications.
 - **One Home Assistant sensor reporting a long text state could stop every printer sensor from updating** — `last_state` is a 64-character column, and the poller wrote whatever Home Assistant returned straight into it. A numeric entity that starts answering with free text - an enum, an error string from a template sensor - overflows that. SQLite stores it regardless, which is why this stayed quiet, but PostgreSQL rejects the row, and a poll pass commits every sensor at once: one such entity took the whole batch down on every tick, so no printer sensor's reading, timestamp or alert state advanced again, and the print interlock kept deciding against a frozen picture. What is persisted is now cut to the column, while the cached reading keeps the full state for display. The comparison that decides whether the state changed is made against the cut form too - comparing the stored value against the raw one would read as a difference on every single poll and churn `last_changed` forever. The storage-location poller was fixed the same way in the same release; both now go through one helper, each passing its own table's width.
 - **Configure Slot could bind the default K value for a profile the picker was visibly showing** — The AMS slot dialog sends `cali_idx` from `selectedKProfile`, which the mutation read through its own closure. React Query hands a mutation its options from an *effect*, so a click landing between a commit and that effect flushing runs the previous render's function - one that captured the selection as it was before the K-profile query resolved. The result is `cali_idx: -1`: the printer binds the default 0.020 rather than the calibrated K, while the dialog shows the right profile selected the whole time. It surfaced as an intermittent failure of the per-nozzle K-profile test, roughly one full-suite run in six, and reproducing it with staggered query resolution showed the divergence directly - the select element held the correct profile immediately before and after the click, and the payload still carried -1. That test's slot is the most exposed case in the file, a right-hotend slot carrying the left hotend's index, where the "keep showing the active profile" safety net cannot repair an empty recompute. The mutation now reads the selection from a ref written during render, so it resolves at execute time rather than at capture time; the same applies to the K value and the profile's ids, which travel in the same payload and had the same exposure. Measured over 27 runs of a staggered-resolution grid: 2 failures in 15 before, 0 in 12 after. The modal's printer-model query was also missing from the test file's mock, so it ran with no query function and rejected on every test in it - mocked now, though on its own that changed nothing, which is how the ref was confirmed as the fix rather than assumed.

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

@@ -3939,6 +3939,34 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
         )
 
+    # Backfill the two flags above. The DEFAULT on those ALTERs only reaches
+    # existing rows when the ALTER is the statement that adds the column -- and
+    # on an install whose notification_providers table was (re)created from
+    # Base.metadata, create_all() had already added them by the time migrations
+    # ran, so _safe_execute swallowed the ALTER as a duplicate column and every
+    # pre-existing row kept NULL. Harmless while nothing read the flags; a 500
+    # on the whole provider list once #2827 declared them on the response
+    # schema, because pydantic will not accept None for a bool.
+    #
+    # false matches both the intent of the DEFAULT above and the behaviour the
+    # rows already have: _get_providers_for_event filters on `.is_(True)`, so a
+    # NULL flag never sent anything. Idempotent -- the WHERE matches nothing on
+    # the second run.
+    async with conn.begin_nested():
+        stock_backfill = await conn.execute(
+            text(
+                "UPDATE notification_providers SET on_stock_reorder_alert = :off WHERE on_stock_reorder_alert IS NULL"
+            ),
+            {"off": False},
+        )
+        stock_backfill_break = await conn.execute(
+            text("UPDATE notification_providers SET on_stock_break_alert = :off WHERE on_stock_break_alert IS NULL"),
+            {"off": False},
+        )
+    repaired = (stock_backfill.rowcount or 0) + (stock_backfill_break.rowcount or 0)
+    if repaired:
+        logger.info("Backfilled %s NULL inventory stock alert flag(s) on notification_providers", repaired)
+
     # Migration: Heal orphan auth-related rows left behind by user-delete
     # on SQLite. user_oidc_links, user_totp, user_otp_codes (introduced in
     # PR #933) and long_lived_tokens (PR #1108) all declare ON DELETE

+ 37 - 1
backend/app/schemas/notification.py

@@ -3,7 +3,7 @@
 from datetime import datetime
 from typing import Any
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.core.compat import StrEnum
 
@@ -223,6 +223,42 @@ class NotificationProviderUpdate(BaseModel):
 class NotificationProviderResponse(NotificationProviderBase):
     """Schema for notification provider API responses."""
 
+    @model_validator(mode="before")
+    @classmethod
+    def _null_event_flags_read_as_off(cls, data: Any) -> Any:
+        """Read a NULL event flag as off instead of failing the whole response.
+
+        Every on_* column on notification_providers is nullable with no server
+        default -- the values come from the ORM at INSERT time. A row created
+        before a flag's column existed keeps NULL there forever unless a
+        migration backfills it, and one that did not (the column was created by
+        Base.metadata before run_migrations, so the ALTER ... DEFAULT false was
+        swallowed as a duplicate) leaves NULLs behind on a live install.
+
+        Those NULLs are harmless until the flag is declared on this schema: the
+        Response inherits the write model, so `bool` is then required on the way
+        out, pydantic rejects None, and every provider row fails at once -- the
+        list route 500s and the UI renders an empty list, which reads to the user
+        as "my providers are gone". That is exactly what shipped in #2827.
+
+        Off is not a guess: _get_providers_for_event selects on `.is_(True)`, so
+        the sender already skips a NULL flag. This makes the read agree with the
+        behaviour the row already has, rather than with the field's declared
+        default -- some of which are True, and none of which should switch a
+        notification on as a side effect of repairing a legacy row.
+
+        Writes are untouched: Create and Update inherit from the base, not here,
+        so a payload sending null for a flag is still a 422.
+        """
+        # Every route returns _provider_to_dict(); anything else (an ORM object
+        # via from_attributes) is passed through for pydantic to handle.
+        if not isinstance(data, dict):
+            return data
+        flags = [name for name, f in cls.model_fields.items() if f.annotation is bool]
+        if any(data.get(name, False) is None for name in flags):
+            data = {**data, **{name: False for name in flags if data.get(name, False) is None}}
+        return data
+
     id: int
     last_success: datetime | None = None
     last_error: str | None = None

+ 47 - 0
backend/tests/integration/test_notifications_api.py

@@ -5,6 +5,7 @@ Tests the full request/response cycle for /api/v1/notifications/ endpoints.
 
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import text
 
 
 class TestNotificationsAPI:
@@ -38,6 +39,52 @@ class TestNotificationsAPI:
         assert len(data) >= 1
         assert any(p["name"] == "Test Provider" for p in data)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_row_with_null_event_flags_is_still_listable(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """A legacy row whose flag columns were never backfilled must not 500 the list.
+
+        Every on_* column is nullable with no server default, so a row created
+        before a flag existed keeps NULL there until a migration backfills it --
+        and #1184's ALTER ... DEFAULT false silently did not, on any install
+        where create_all() had already added the column. Declaring those flags
+        on the response schema in #2827 turned those NULLs into a hard failure:
+        pydantic rejects None for a bool, so every provider row failed at once
+        and the list came back empty to the UI.
+
+        Written against the two flags that actually broke, but the whole set is
+        checked -- the next flag added to the schema has the same exposure.
+        """
+        provider = await notification_provider_factory(name="Legacy Provider")
+
+        flags = ["on_stock_reorder_alert", "on_stock_break_alert"]
+        await db_session.execute(
+            text(f"UPDATE notification_providers SET {', '.join(f'{f} = NULL' for f in flags)} WHERE id = :id"),
+            {"id": provider.id},
+        )
+        await db_session.commit()
+
+        stored = await db_session.execute(
+            text(f"SELECT {', '.join(flags)} FROM notification_providers WHERE id = :id"), {"id": provider.id}
+        )
+        assert all(value is None for value in stored.one()), "row under test must actually hold NULLs"
+
+        response = await async_client.get("/api/v1/notifications/")
+
+        assert response.status_code == 200
+        listed = next(p for p in response.json() if p["name"] == "Legacy Provider")
+        # Off, not the field default: the sender selects on `.is_(True)`, so a
+        # NULL flag never sent anything, and repairing the read must not switch
+        # a notification on.
+        assert all(listed[flag] is False for flag in flags)
+
+        # The single-provider route reads through the same schema.
+        single = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert single.status_code == 200
+        assert all(single.json()[flag] is False for flag in flags)
+
     # ========================================================================
     # Create endpoints
     # ========================================================================