feat: Add Banners Table and API - #337
Open
camielvs wants to merge 1 commit into
Open
Conversation
camielvs
marked this pull request as ready for review
August 18, 2026 00:56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
A generic, site-wide announcement banner: a dedicated
bannertable, a public read endpoint for the frontend, and admin CRUD endpoints for managing banners. API only — there is no admin UI in this phase.Design objective
Give operators a way to show a short, timed message to every user of the app (planned maintenance, degraded functionality, a link to a status page) without a deploy, and give the frontend a single cheap endpoint it can poll to render whatever is currently live. The banner content is deliberately generic — title, body, severity variant, optional link — with no assumptions about who is producing it or why.
API
GET/api/banners/activeCache-Control: no-store.GET/api/admin/banners?include_deleted=truealso returns soft-deleted ones.POST/api/admin/bannersGET/api/admin/banners/{id}PATCH/api/admin/banners/{id}DELETE/api/admin/banners/{id}A banner is active when
deleted_at IS NULL AND is_enabled = true AND (starts_at IS NULL OR starts_at <= now) AND (ends_at IS NULL OR ends_at > now). Active banners are sorted bystarts_atdescending with un-scheduled banners last, thencreated_atdescending.You can exercise all of this from the
/docsroute.Key decisions
A dedicated
bannertable instead ofUserSettings.UserSettingsis a per-user key/value JSON blob keyed byuser_id; a banner is a global object with its own lifecycle, so it fits neither the key nor the shape. Storing banners there would mean either duplicating a banner into every user's settings row or inventing a magic pseudo-user to hold the global ones, and in both cases the scheduling window and the enabled/deleted flags would live inside opaque JSON — not queryable, not indexable, not constrainable. A table gives us a realWHEREclause for the active lookup, real indexes, and per-row audit columns.Soft delete only.
DELETEsetsdeleted_atand never removes the row, so a banner that was shown to users stays auditable and an accidental delete is recoverable.deleted_at IS NULLis part of both the active query and the default admin list;?include_deleted=trueopts back in. Delete is idempotent — deleting an already-deleted banner leaves the original timestamp alone.Two response shapes rather than one.
/api/banners/activereturns 11 display fields; the admin endpoints addis_enabled,created_by,updated_byanddeleted_at. Keeping them as separate response types (BannerResponse/AdminBannerResponse) means the public endpoint cannot leak operator identities by accident. This is also why the banner routes don't use the router'sdefault_config: that config strips null fields, and the frontend wantsurl: nullpresent rather than absent.Reusing
errors.ApiValidationErrorfor validation failures. Invalid input (bad URL, over-length title/body,url_textwithout aurl,ends_at <= starts_at) raises the existingApiValidationError, which the existing handler maps to422. No new error type and no new exception handler were added. An unknownvariantis rejected by FastAPI request validation, so it also returns422— one status code for all bad input.variantas astrenum column.BannerVariant(info/warning/success/error) is mapped onto the column withvalues_callable, matching howContainerExecutionStatusis handled, so the DB stores"warning"rather than"WARNING"and the valid set shows up in the OpenAPI schema instead of living in a hand-written validator.Client datetimes are normalized to UTC on the way in. The DB stores naive UTC (see
UtcDateTime), which means astarts_atof2026-01-01T12:00:00+02:00would otherwise be stored as if12:00were UTC. It is converted to10:00Zbefore being stored, and cross-field comparison normalizes both sides so a request-supplied aware datetime can be compared against a DB-loaded one.Two indexes, matching the two access paths.
(is_enabled, deleted_at, starts_at, ends_at)serves the active lookup that the frontend hits on every page load;created_at DESCserves the admin list.Partial update semantics.
PATCHfollows the existing convention in this codebase (PublishedComponentService.update): a field that isnull/absent is left unchanged. The trade-off is thatPATCHcannot currently clear a nullable field back to null —{"url": null}is a no-op, not a clear. Worth revisiting if that turns out to matter, but it would need a departure from the convention (a sentinel orexclude_unset).Schema migration
Purely additive: a new table plus its two indexes, created by the existing
metadata.create_all. No existing table, column or index is touched, so nomigrate_dbstep is needed. Verified by building a DB with the code atmaster, inserting a row, then runningcreate_db_engine_and_migrate_dbwith this branch:bannerand its indexes appear, every other table is unchanged, and the pre-existing row survives.Testing
tests/test_banners_api.pyadds 28 tests against a realTestClientapp over an in-memory SQLite DB, covering: empty active list and theno-storeheader; create and read-back through the admin endpoints; the active window (enabled/in-window included, disabled/future/expired excluded); the public response exposing exactly the 11 display fields; partial update including trimming,updated_atadvancing, and untouched fields staying put; soft delete removing the banner from both the active list and the default admin list while the row remains fetchable;403for a non-admin on every admin route while the public read still works;422for every invalid-input case;404for unknown ids; active sort order; and UTC conversion of offset datetimes.Also verified by hand against a running server: create → active → disable → active empty → delete → row still present in SQLite.