Capture alert snapshots independent of public pages (#129) #143

Merged
claude-bot merged 2 commits from feat/history-capture-ungate into main 2026-07-27 23:15:29 +00:00
Contributor

Closes #129. First implementation issue of the v2.0.0 Historical Explorer epic (#22).

Problem

nws_alert_snapshots holds the only copy of the full official product body, geometry and hazards, and it is the raw material the Explorer reads back. It was written only from the per-channel dispatch loop in _process_single_alert (alert_processor.py:351), or when a public page already existed (:281). _process_cancel was gated the same way.

So a location with no channel subscriptions retained nothing beyond its sent_alerts row — event, severity, headline and times, with no product text at all. Dashboard-only locations were exactly the ones whose history was emptiest.

Two corrections to how #129 was originally filed, now recorded on the issue:

  • Location.public_enabled never gated capture — get_or_create_public_alert_page does not inspect it. It gates serving only.
  • The effective gate was therefore the channel-dispatch coupling above, not just PUBLIC_ALERT_PAGES_ENABLED defaulting to false.

Change

  • New fail-soft _capture_alert_snapshot, savepoint-wrapped to match the existing _ensure_public_alert_page pattern.
  • Called for every matched location on the dashboard path, before and independent of channel dispatch.
  • Called on the Cancel path for every location holding records — a cancel is part of the lifecycle the event timeline reconstructs.
  • The resulting snapshot is threaded into get_or_create_public_alert_page via its existing snapshot= parameter, so the page path does not redundantly re-persist.
  • IntegrityError on the (location_id, lifecycle_id, content_hash) unique constraint is caught and logged at debug: overlapping poll runs can race, the row exists either way, and a traceback every poll would drown the log.

Public page and token creation stay gated on public_alert_pages_enabled exactly as before. Capture and exposure are now separate concerns.

Tests

New tests/test_history_capture.py:

  • capture with no channel subscriptions and public pages disabled, asserting zero PublicAlertPage / PublicToken rows
  • dedup across three identical polls (exactly one row)
  • append on a CON content change (two rows, one lifecycle)
  • capture on Cancel

Verification

Run locally in Docker, mirroring every CI gate:

Step Result
ruff check . passed
python -m compileall app OK
SQLite bulk tier 783 passed
alembic upgrade head on fresh Postgres 16 clean through 0030
Postgres integration tier 4 passed

Known nit

test_quiet_hours_suppress_channel_send_but_keep_dashboard_record now emits a RuntimeWarning (unawaited coroutine) because it passes a MagicMock session and _capture_alert_snapshot has no early return, so it reaches begin_nested() on the mock and the failure is swallowed fail-soft. Production behaviour is correct and covered by the new tests. The codebase already carries an identical warning in test_webhook_ssrf. Happy to fix the mock separately.

🤖 Generated with Claude Code

Closes #129. First implementation issue of the v2.0.0 Historical Explorer epic (#22). ## Problem `nws_alert_snapshots` holds the only copy of the full official product body, geometry and hazards, and it is the raw material the Explorer reads back. It was written **only** from the per-channel dispatch loop in `_process_single_alert` (`alert_processor.py:351`), or when a public page already existed (`:281`). `_process_cancel` was gated the same way. So a location with **no channel subscriptions** retained nothing beyond its `sent_alerts` row — event, severity, headline and times, with no product text at all. Dashboard-only locations were exactly the ones whose history was emptiest. Two corrections to how #129 was originally filed, now recorded on the issue: - `Location.public_enabled` never gated capture — `get_or_create_public_alert_page` does not inspect it. It gates serving only. - The effective gate was therefore the channel-dispatch coupling above, not just `PUBLIC_ALERT_PAGES_ENABLED` defaulting to false. ## Change - New fail-soft `_capture_alert_snapshot`, savepoint-wrapped to match the existing `_ensure_public_alert_page` pattern. - Called for **every matched location** on the dashboard path, before and independent of channel dispatch. - Called on the Cancel path for every location holding records — a cancel is part of the lifecycle the event timeline reconstructs. - The resulting snapshot is threaded into `get_or_create_public_alert_page` via its existing `snapshot=` parameter, so the page path does not redundantly re-persist. - `IntegrityError` on the `(location_id, lifecycle_id, content_hash)` unique constraint is caught and logged at debug: overlapping poll runs can race, the row exists either way, and a traceback every poll would drown the log. Public page and token creation stay gated on `public_alert_pages_enabled` exactly as before. **Capture and exposure are now separate concerns.** ## Tests New `tests/test_history_capture.py`: - capture with no channel subscriptions and public pages disabled, asserting zero `PublicAlertPage` / `PublicToken` rows - dedup across three identical polls (exactly one row) - append on a CON content change (two rows, one lifecycle) - capture on Cancel ## Verification Run locally in Docker, mirroring every CI gate: | Step | Result | |---|---| | `ruff check .` | passed | | `python -m compileall app` | OK | | SQLite bulk tier | 783 passed | | `alembic upgrade head` on fresh Postgres 16 | clean through `0030` | | Postgres integration tier | 4 passed | ## Known nit `test_quiet_hours_suppress_channel_send_but_keep_dashboard_record` now emits a `RuntimeWarning` (unawaited coroutine) because it passes a `MagicMock` session and `_capture_alert_snapshot` has no early return, so it reaches `begin_nested()` on the mock and the failure is swallowed fail-soft. Production behaviour is correct and covered by the new tests. The codebase already carries an identical warning in `test_webhook_ssrf`. Happy to fix the mock separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Added while working on the v1.7.0 radar products; mapservices.weather.noaa.gov
is the MapServer host behind the server-side radar images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Capture alert snapshots independent of public pages (#129)
All checks were successful
CI / test (pull_request) Successful in 4m30s
2947b43523
nws_alert_snapshots holds the only copy of the full official product body,
geometry and hazards, and is the raw material the v2.0.0 Historical Explorer
(#22) reads back. It was written only from the per-channel dispatch loop in
_process_single_alert, or when a public page already existed, so a location
with no channel subscriptions retained nothing beyond its sent_alerts row --
event, severity, headline and times, with no product text at all.

Add a fail-soft _capture_alert_snapshot and call it for every matched location
on the dashboard path, and for every location holding records on the Cancel
path (a cancel is part of the lifecycle the Explorer timeline reconstructs).
The resulting snapshot is threaded into get_or_create_public_alert_page via
its existing snapshot= parameter so the page path does not re-persist it.

Public page and token creation stay gated on public_alert_pages_enabled
exactly as before: capture and exposure are now separate concerns.

Note Location.public_enabled never gated capture -- it is enforced at serve
time only -- so the effective gate was the channel-dispatch coupling above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/WeatherBot!143
No description provided.