feat: add nowcast point alarm page client with SQLite store

Adds point_alarm.py (flattening, secret masking, SQLite upsert store, paged fetch with 403 fallback, CSV/TXT export, shared filter loader), nowcast-point-page.py CLI and unit tests. Register will read the shared filter from nowcast-point-alarm-filter.json instead of repeating it in the CSV.
This commit is contained in:
2026-10-23 19:11:34 +03:00
parent 7a875caa73
commit 0958d26e9f
7 changed files with 813 additions and 8 deletions
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import csv
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from common import ApiError, ConfigurationError
from point_alarm import (
PointStore,
fetch_pages,
flatten_registration,
load_point_alarm_filter,
mask_secrets,
parse_polygon,
timestamped_path,
write_records,
)
def registration(index: int, recipient: str = "r1") -> dict:
return {
"registrationId": f"id-{index}",
"recipientId": recipient,
"boundary": {"point": {"lat": 39.9, "lng": 32.8}, "radius": 1000},
"filter": {
"lightning": {"type": "PULSE_IN_CLOUD", "peakCurrent": 5},
"thunderstorm": {"intersectsCellPolygon": True, "severityThreshold": "LOW"},
"precipitation": {"intensities": ["LIGHT", "HEAVY"]},
},
"webhook": {"accountId": "acc", "url": "https://x", "authentication": {"password": "p"}},
}
class FakeService:
def __init__(self, registrations: list[dict], forbid_account: bool = False) -> None:
self.registrations = registrations
self.forbid_account = forbid_account
self.calls: list[dict] = []
def page(self, *, page_number, page_size, account_id=None, **_):
self.calls.append({"page": page_number, "account": account_id})
if self.forbid_account and account_id:
raise ApiError("forbidden", status_code=403)
start = page_number * page_size
return {
"registrations": self.registrations[start : start + page_size],
"totalRecords": len(self.registrations),
}
class PointAlarmTest(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = Path(self.tmp.name)
self.store = PointStore(self.dir / "t.db")
self.addCleanup(self.store.close)
def test_flatten(self) -> None:
flat = flatten_registration(registration(1))
self.assertEqual(flat["precipitationIntensities"], "LIGHT|HEAVY")
self.assertEqual(flat["intersectsCellPolygon"], "true")
self.assertIsNone(flat["speedThreshold"])
def test_mask(self) -> None:
masked = mask_secrets(registration(1)["webhook"])
self.assertEqual(masked["authentication"]["password"], "***")
self.assertEqual(masked["url"], "https://x")
def test_fetch_all_pages_upserts_without_duplicates(self) -> None:
service = FakeService([registration(i) for i in range(5)])
summary = fetch_pages(service, self.store, account_id="acc", page_size=2)
self.assertEqual(summary.distinct, 5)
self.assertEqual(summary.missing, 0)
fetch_pages(service, self.store, account_id="acc", page_size=2)
self.assertEqual(self.store.count(), 5)
self.assertEqual(self.store.webhooks("acc")[0]["authentication"]["password"], "***")
def test_single_page(self) -> None:
service = FakeService([registration(i) for i in range(5)])
summary = fetch_pages(service, self.store, account_id="acc", page_size=2, page_number=1)
self.assertEqual(summary.received, 2)
self.assertEqual(len(service.calls), 1)
def test_forbidden_account_retries_without_it(self) -> None:
service = FakeService([registration(1)], forbid_account=True)
summary = fetch_pages(service, self.store, account_id="acc")
self.assertEqual(summary.distinct, 1)
self.assertIsNone(service.calls[-1]["account"])
def test_csv_and_txt_output(self) -> None:
fetch_pages(FakeService([registration(1)]), self.store, account_id="acc")
records = self.store.records("acc")
write_records(self.dir / "o.csv", records, self.store.webhooks("acc"))
with (self.dir / "o.csv").open(encoding="utf-8-sig", newline="") as handle:
rows = list(csv.reader(handle, delimiter=";"))
self.assertEqual(rows[0][0], "#webhook")
self.assertEqual(rows[1][0], "registrationId")
self.assertEqual(rows[2][0], "id-1")
write_records(self.dir / "o.txt", records, [])
self.assertIn('"registrationId": "id-1"', (self.dir / "o.txt").read_text(encoding="utf-8"))
with self.assertRaises(ConfigurationError):
write_records(self.dir / "o.json", records, [])
def test_timestamped_path(self) -> None:
path = timestamped_path(Path("a/out.csv"), datetime(2026, 10, 23, 9, 5, 7))
self.assertEqual(path, Path("a/out-20261023-090507.csv"))
def test_polygon(self) -> None:
ring = parse_polygon("0,0;1,0;1,1;0,0")
self.assertEqual(ring[0], [0.0, 0.0])
with self.assertRaises(ConfigurationError):
parse_polygon("0,0;1,0;1,1")
def test_filter_validation(self) -> None:
good = load_point_alarm_filter(Path("nowcast-point-alarm-filter.example.json"))
self.assertIn("lightning", good)
bad = self.dir / "bad.json"
bad.write_text('{"thunderstorm": {"severityThreshold": "X"}}', encoding="utf-8")
with self.assertRaises(ConfigurationError):
load_point_alarm_filter(bad)
if __name__ == "__main__":
unittest.main()