diff --git a/docs/PRODUCER-INTEGRATION.md b/docs/PRODUCER-INTEGRATION.md index 542c466..bf65657 100644 --- a/docs/PRODUCER-INTEGRATION.md +++ b/docs/PRODUCER-INTEGRATION.md @@ -262,6 +262,13 @@ required tables. The default is a no-op. shutdown. Close the HTTP session, close the sqlite3 cursor DB. The default is a no-op. +**`def bump_last_seen(self, event_id: str) -> None`** โ€” Optional. Called by the +supervisor on every dedup hit (an already-published event re-seen upstream) so +the periodic `sweep_old_ids` purge doesn't expire long-lived events. The base +class implements the standard `published_ids` bump; adapters using that table +inherit it and need not override. It is a safe no-op when the adapter has no +`_db` handle. + **`async def preview_for_settings(self, settings: BaseModel) -> list[dict] | None`** โ€” Optional. The settings-page preview hook. The default returns `None` (no preview). See [ยง11](#11-settings-preview-hook) for the contract. diff --git a/src/central/adapter.py b/src/central/adapter.py index b923c7c..b53665f 100644 --- a/src/central/adapter.py +++ b/src/central/adapter.py @@ -86,6 +86,27 @@ class SourceAdapter(ABC): """Optional lifecycle hook called on graceful shutdown.""" pass + def bump_last_seen(self, event_id: str) -> None: + """Refresh the dedup ``last_seen`` for an already-published event. + + The supervisor calls this on every dedup hit so the periodic + ``sweep_old_ids`` purge doesn't expire long-lived events that upstream + keeps re-listing (e.g. EONET open events, NWS active alerts). Adapters + that maintain the standard ``published_ids`` table inherit this; it is a + safe no-op for any adapter without a ``_db`` handle. Previously only + four adapters defined it, so adapters that re-emit (eonet, etc.) raised + ``AttributeError`` here and the supervisor surfaced it on /dashboard. + """ + db = getattr(self, "_db", None) + if db is None: + return + db.execute( + "UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP " + "WHERE adapter = ? AND event_id = ?", + (self.name, event_id), + ) + db.commit() + async def preview_for_settings(self, settings: BaseModel) -> list[dict] | None: """Optional. Override to surface a settings-driven preview on the edit page. diff --git a/src/central/adapters/inciweb.py b/src/central/adapters/inciweb.py index 853b120..af6bf74 100644 --- a/src/central/adapters/inciweb.py +++ b/src/central/adapters/inciweb.py @@ -271,16 +271,6 @@ class InciWebAdapter(SourceAdapter): ) self._db.commit() - def bump_last_seen(self, event_id: str) -> None: - """Bump the last_seen timestamp for an event.""" - if not self._db: - return - self._db.execute( - "UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP WHERE adapter = ? AND event_id = ?", - (self.name, event_id), - ) - self._db.commit() - def sweep_old_ids(self) -> int: """Remove published_ids older than 14 days. Returns count deleted.""" if not self._db: diff --git a/src/central/adapters/nws.py b/src/central/adapters/nws.py index e50922e..6c8cf91 100644 --- a/src/central/adapters/nws.py +++ b/src/central/adapters/nws.py @@ -418,16 +418,6 @@ class NWSAdapter(SourceAdapter): ) self._db.commit() - def bump_last_seen(self, event_id: str) -> None: - """Bump the last_seen timestamp for an event.""" - if not self._db: - return - self._db.execute( - "UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP WHERE adapter = ? AND event_id = ?", - (self.name, event_id) - ) - self._db.commit() - def sweep_old_ids(self) -> int: """Remove published_ids older than 8 days. Returns count deleted.""" if not self._db: diff --git a/src/central/adapters/wfigs_incidents.py b/src/central/adapters/wfigs_incidents.py index be31f20..3c26505 100644 --- a/src/central/adapters/wfigs_incidents.py +++ b/src/central/adapters/wfigs_incidents.py @@ -158,16 +158,6 @@ class WFIGSIncidentsAdapter(SourceAdapter): ) self._db.commit() - def bump_last_seen(self, event_id: str) -> None: - """Bump the last_seen timestamp for an event.""" - if not self._db: - return - self._db.execute( - "UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP WHERE adapter = ? AND event_id = ?", - (self.name, event_id), - ) - self._db.commit() - def sweep_old_ids(self) -> int: """Remove published_ids older than 14 days. Returns count deleted.""" if not self._db: diff --git a/src/central/adapters/wfigs_perimeters.py b/src/central/adapters/wfigs_perimeters.py index 1a1d336..d31540d 100644 --- a/src/central/adapters/wfigs_perimeters.py +++ b/src/central/adapters/wfigs_perimeters.py @@ -158,16 +158,6 @@ class WFIGSPerimetersAdapter(SourceAdapter): ) self._db.commit() - def bump_last_seen(self, event_id: str) -> None: - """Bump the last_seen timestamp for an event.""" - if not self._db: - return - self._db.execute( - "UPDATE published_ids SET last_seen = CURRENT_TIMESTAMP WHERE adapter = ? AND event_id = ?", - (self.name, event_id), - ) - self._db.commit() - def sweep_old_ids(self) -> int: """Remove published_ids older than 14 days. Returns count deleted.""" if not self._db: diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index 74a6d6b..7dca84c 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -2700,6 +2700,17 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]: except ValueError: return None, "Region parameters must be valid numbers" + # Defense in depth: the map JS normalizes coordinates, but a degenerate + # or out-of-range bbox (e.g. Leaflet pan-past-dateline artifacts like + # east=411.3, west=-608.2) must never reach ST_MakeEnvelope. Treat an + # invalid envelope as "no bbox" rather than erroring or querying a bogus + # envelope that silently matches the wrong rows. + if not ( + -90 <= bbox["south"] < bbox["north"] <= 90 + and -180 <= bbox["west"] < bbox["east"] <= 180 + ): + bbox = None + # Parse cursor cursor_time = None cursor_id = None diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index 82734c8..d52a400 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -274,12 +274,30 @@ viewportDebounceTimer = setTimeout(applyViewportFilter, 400); }); + // Normalize a longitude into [-180, 180]. Leaflet returns out-of-range + // values (e.g. east=411, west=-608) when the map is panned past the + // dateline / wrapped at low zoom; those must not reach the bbox filter. + function wrapLon(lon) { + return ((((lon + 180) % 360) + 360) % 360) - 180; + } + function applyViewportFilter() { var bounds = map.getBounds(); - northInput.value = bounds.getNorth().toFixed(4); - southInput.value = bounds.getSouth().toFixed(4); - eastInput.value = bounds.getEast().toFixed(4); - westInput.value = bounds.getWest().toFixed(4); + var rawWest = bounds.getWest(); + var rawEast = bounds.getEast(); + // When the visible map spans more than ~350 deg of longitude (zoomed + // far out / wrapped), a bbox filter is meaningless โ€” treat as no bbox. + if (rawEast - rawWest > 350) { + northInput.value = ""; + southInput.value = ""; + eastInput.value = ""; + westInput.value = ""; + } else { + northInput.value = Math.min(90, bounds.getNorth()).toFixed(4); + southInput.value = Math.max(-90, bounds.getSouth()).toFixed(4); + eastInput.value = wrapLon(rawEast).toFixed(4); + westInput.value = wrapLon(rawWest).toFixed(4); + } htmx.trigger(document.getElementById("filter-form"), "submit"); } diff --git a/tests/test_enrichment_locations_coverage.py b/tests/test_enrichment_locations_coverage.py index 2be4c50..e17bafd 100644 --- a/tests/test_enrichment_locations_coverage.py +++ b/tests/test_enrichment_locations_coverage.py @@ -108,3 +108,52 @@ def test_nwis_event_mirrors_centroid_into_data(): assert event.data["longitude"] == -90.25 # Geo.centroid retained for existing rendering uses. assert event.geo.centroid == (-90.25, 41.78) + + +# --- bump_last_seen contract (v0.7.0 Bug 1) --------------------------------- + +def test_every_adapter_resolves_bump_last_seen(): + """The supervisor calls adapter.bump_last_seen on every dedup hit. Every + registered adapter must resolve a callable (inherited from SourceAdapter or + overridden) -- otherwise the AttributeError leaks to /dashboard (the eonet + bug). Registry-derived, no hardcoded list.""" + missing = [ + name for name, cls in discover_adapters().items() + if not callable(getattr(cls, "bump_last_seen", None)) + ] + assert not missing, f"adapters missing bump_last_seen: {missing}" + + +def test_base_bump_last_seen_updates_and_is_noop_without_db(): + """SourceAdapter.bump_last_seen updates published_ids.last_seen when a _db + handle is present, and is a safe no-op when it is not.""" + import sqlite3 + + from central.adapter import SourceAdapter + + class _StubAdapter(SourceAdapter): + name = "stub" + + async def poll(self): ... # never called in this test + async def apply_config(self, new_config): ... + def subject_for(self, event): return "" + + adapter = _StubAdapter() + adapter.bump_last_seen("e1") # no _db attribute -> must not raise + + adapter._db = sqlite3.connect(":memory:") + adapter._db.execute( + "CREATE TABLE published_ids (adapter TEXT, event_id TEXT, " + "first_seen TIMESTAMP, last_seen TIMESTAMP)" + ) + adapter._db.execute( + "INSERT INTO published_ids VALUES ('stub', 'e1', " + "'2020-01-01 00:00:00', '2020-01-01 00:00:00')" + ) + adapter._db.commit() + + adapter.bump_last_seen("e1") + row = adapter._db.execute( + "SELECT last_seen FROM published_ids WHERE event_id = 'e1'" + ).fetchone() + assert row[0] != "2020-01-01 00:00:00" # bumped to CURRENT_TIMESTAMP diff --git a/tests/test_events_bbox_guard.py b/tests/test_events_bbox_guard.py new file mode 100644 index 0000000..236a129 --- /dev/null +++ b/tests/test_events_bbox_guard.py @@ -0,0 +1,47 @@ +"""Bug 2 (v0.7.0): out-of-range map bbox must not reach the SQL envelope. + +Leaflet returns longitudes outside [-180, 180] when the map is panned past the +dateline at low zoom (e.g. east=411.3281, west=-608.2031). The map JS now +normalizes/omits those, but _parse_events_params also defends in depth: a +degenerate or out-of-range region is treated as "no bbox" rather than erroring +or building a bogus ST_MakeEnvelope that silently matches the wrong rows. +""" +from central.gui.routes import _parse_events_params + + +def _params(**kw): + base = {"limit": "50"} + base.update(kw) + return base + + +def test_out_of_range_bbox_is_dropped_not_errored(): + """The exact pan-past-dateline artifact from the bug report -> no bbox.""" + parsed, error = _parse_events_params(_params( + region_north="42.0", region_south="31.0", + region_east="411.3281", region_west="-608.2031", + )) + assert error is None + assert parsed is not None + assert parsed["bbox"] is None + + +def test_valid_bbox_is_preserved(): + parsed, error = _parse_events_params(_params( + region_north="42.0", region_south="31.0", + region_east="-102.0", region_west="-124.5", + )) + assert error is None + assert parsed["bbox"] == { + "north": 42.0, "south": 31.0, "east": -102.0, "west": -124.5, + } + + +def test_inverted_bbox_is_dropped(): + """west >= east (a wrapped dateline-straddle) is degenerate -> no bbox.""" + parsed, error = _parse_events_params(_params( + region_north="42.0", region_south="31.0", + region_east="-124.5", region_west="-102.0", + )) + assert error is None + assert parsed["bbox"] is None