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.
This commit is contained in:
maziggy
2026-08-25 13:09:24 +02:00
parent df57e5213b
commit d9bc7ae47a
4 changed files with 113 additions and 1 deletions
+28
View File
@@ -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