From 55e68d038f9a7ee19f0486a20133506b16ae69d3 Mon Sep 17 00:00:00 2001 From: malice Date: Mon, 18 May 2026 11:23:38 -0600 Subject: [PATCH 1/6] feat(gui): add events feed frontend with map and filters (1b-9b) (#26) * feat(gui): add events feed frontend with map and filters GET /events: Full page with filter form, table, and Leaflet map GET /events/rows: HTMX fragment for table updates Features: - Filterable by adapter, category, time range, region bbox - Cursor-based pagination with Next button - Leaflet map showing event geometries - Click/hover row highlights geometry on map - Draw rectangle on map to filter by region - Validation errors shown as banner, not 400 - Events link added to nav between Adapters and Streams Refactored events query into shared helper for JSON and HTML routes. Tests: 14 new tests covering filters, fragments, geometry handling. Co-Authored-By: Claude Opus 4.5 * refactor(events): use shared helpers for /events.json, fix tests - Refactor /events.json to use _parse_events_params and _fetch_events helpers, removing ~200 lines of duplicate query logic - Delete smoke test (test_events_unauthenticated_redirects) that had no assertions - Add TestCrossEndpointParity: verify /events.json and /events return identical results with same params, test category filter and cursor pagination on both endpoints - Add TestErrorSemantics: verify /events.json returns 400 on bad params while /events returns 200 with error banner (intentional API vs HTML divergence) Co-Authored-By: Claude Opus 4.5 * test: add real assertions to since/until and region filter tests Replace trivial status_code==200 assertions with checks that verify the filter values were actually parsed and passed to the template. These tests now fail if the handler ignores the filter parameters. * fix: remove double-escaping from data-geometry attribute tojson already produces HTML-attribute-safe JSON. The extra |e filter was double-escaping, causing JSON.parse to fail in the browser JS. Switch to single-quoted attribute to avoid conflicts with JSON double quotes. --------- Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.5 --- src/central/gui/routes.py | 371 +++++++++--- src/central/gui/templates/_events_rows.html | 50 ++ src/central/gui/templates/base.html | 1 + src/central/gui/templates/events_list.html | 378 ++++++++++++ tests/test_events_feed_frontend.py | 633 ++++++++++++++++++++ 5 files changed, 1344 insertions(+), 89 deletions(-) create mode 100644 src/central/gui/templates/_events_rows.html create mode 100644 src/central/gui/templates/events_list.html create mode 100644 tests/test_events_feed_frontend.py diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index f86b6e1..3a415c2 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -2197,95 +2197,90 @@ async def api_keys_delete( return RedirectResponse(url="/api-keys", status_code=302) -@router.get("/events.json") -async def events_json(request: Request): + + +# --- Events query helper --- + +class EventsQueryResult: + """Result from events query.""" + def __init__(self, events: list, next_cursor: str | None, error: str | None = None): + self.events = events + self.next_cursor = next_cursor + self.error = error + + +def _parse_events_params(params) -> tuple[dict | None, str | None]: """ - Paginated, filterable JSON endpoint for events. - - Query parameters (all optional): - adapter: filter by adapter name - category: filter by event category - since: ISO 8601 datetime - events where time >= since - until: ISO 8601 datetime - events where time < until - region_north, region_south, region_east, region_west: bbox filter (all four required if any) - limit: page size (default 50, max 200) - cursor: opaque pagination cursor - + Parse and validate events query parameters. + Returns: - {"events": [...], "next_cursor": string or null} + (parsed_params, error_message) + If error_message is not None, parsed_params is None. """ - from fastapi.responses import JSONResponse - - params = request.query_params - # Parse and validate limit limit_str = params.get("limit", "50") try: limit = int(limit_str) except ValueError: - return JSONResponse( - {"error": f"Invalid limit value: {limit_str}"}, - status_code=400, - ) - + return None, f"Invalid limit value: {limit_str}" + if limit < 1 or limit > 200: - return JSONResponse( - {"error": "limit must be between 1 and 200"}, - status_code=400, - ) - + return None, "limit must be between 1 and 200" + # Parse adapter filter adapter = params.get("adapter") - - # Parse category filter + if adapter == "": + adapter = None + + # Parse category filter category = params.get("category") - + if category == "": + category = None + # Parse since/until filters since = None until = None - + since_str = params.get("since") if since_str: try: since = datetime.fromisoformat(since_str.replace("Z", "+00:00")) except ValueError: - return JSONResponse( - {"error": f"Invalid ISO 8601 datetime for since: {since_str}"}, - status_code=400, - ) - + return None, f"Invalid ISO 8601 datetime for since: {since_str}" + until_str = params.get("until") if until_str: try: until = datetime.fromisoformat(until_str.replace("Z", "+00:00")) except ValueError: - return JSONResponse( - {"error": f"Invalid ISO 8601 datetime for until: {until_str}"}, - status_code=400, - ) - + return None, f"Invalid ISO 8601 datetime for until: {until_str}" + # Validate since <= until if since and until and since > until: - return JSONResponse( - {"error": "since must be before or equal to until"}, - status_code=400, - ) - + return None, "since must be before or equal to until" + # Parse region bbox region_north = params.get("region_north") region_south = params.get("region_south") region_east = params.get("region_east") region_west = params.get("region_west") - + + # Treat empty strings as None + if region_north == "": + region_north = None + if region_south == "": + region_south = None + if region_east == "": + region_east = None + if region_west == "": + region_west = None + region_params = [region_north, region_south, region_east, region_west] region_supplied = [p for p in region_params if p is not None] - + if len(region_supplied) > 0 and len(region_supplied) < 4: - return JSONResponse( - {"error": "Region filter requires all four parameters: region_north, region_south, region_east, region_west"}, - status_code=400, - ) - + return None, "Region filter requires all four parameters: region_north, region_south, region_east, region_west" + bbox = None if len(region_supplied) == 4: try: @@ -2296,16 +2291,13 @@ async def events_json(request: Request): "west": float(region_west), } except ValueError: - return JSONResponse( - {"error": "Region parameters must be valid numbers"}, - status_code=400, - ) - + return None, "Region parameters must be valid numbers" + # Parse cursor cursor_time = None cursor_id = None cursor_str = params.get("cursor") - + if cursor_str: try: decoded = base64.b64decode(cursor_str).decode("utf-8") @@ -2315,59 +2307,82 @@ async def events_json(request: Request): cursor_time = datetime.fromisoformat(parts[0]) cursor_id = parts[1] except Exception: - return JSONResponse( - {"error": "Invalid cursor"}, - status_code=400, - ) - - # Get database pool after validation + return None, "Invalid cursor" + + return { + "limit": limit, + "adapter": adapter, + "category": category, + "since": since, + "until": until, + "bbox": bbox, + "cursor_time": cursor_time, + "cursor_id": cursor_id, + }, None + + +async def _fetch_events(parsed_params: dict) -> EventsQueryResult: + """ + Fetch events from database using parsed parameters. + + Returns EventsQueryResult with events list, next_cursor, and optional error. + """ pool = get_pool() - + + limit = parsed_params["limit"] + adapter = parsed_params["adapter"] + category = parsed_params["category"] + since = parsed_params["since"] + until = parsed_params["until"] + bbox = parsed_params["bbox"] + cursor_time = parsed_params["cursor_time"] + cursor_id = parsed_params["cursor_id"] + # Build query conditions = [] query_params = [] param_idx = 1 - + if adapter: conditions.append(f"adapter = ${param_idx}") query_params.append(adapter) param_idx += 1 - + if category: conditions.append(f"category = ${param_idx}") query_params.append(category) param_idx += 1 - + if since: conditions.append(f"time >= ${param_idx}") query_params.append(since) param_idx += 1 - + if until: conditions.append(f"time < ${param_idx}") query_params.append(until) param_idx += 1 - + if bbox: conditions.append( f"ST_Intersects(geom, ST_MakeEnvelope(${param_idx}, ${param_idx+1}, ${param_idx+2}, ${param_idx+3}, 4326))" ) query_params.extend([bbox["west"], bbox["south"], bbox["east"], bbox["north"]]) param_idx += 4 - + if cursor_time and cursor_id: conditions.append(f"(time, id) < (${param_idx}, ${param_idx+1})") query_params.append(cursor_time) query_params.append(cursor_id) param_idx += 2 - + where_clause = "" if conditions: where_clause = "WHERE " + " AND ".join(conditions) - + # Fetch limit+1 to check for next page query = f""" - SELECT + SELECT id, time, received, @@ -2383,29 +2398,26 @@ async def events_json(request: Request): LIMIT ${param_idx} """ query_params.append(limit + 1) - + try: async with pool.acquire() as conn: rows = await conn.fetch(query, *query_params) except Exception as e: - logger.error(f"Database error in events_json: {e}") - return JSONResponse( - {"error": "Database error"}, - status_code=500, - ) - + logger.error(f"Database error in _fetch_events: {e}") + return EventsQueryResult([], None, "Database error") + # Check if there is a next page has_next = len(rows) > limit if has_next: rows = rows[:limit] - + # Build response events = [] for row in rows: geometry = None if row["geometry"]: geometry = json.loads(row["geometry"]) - + events.append({ "id": row["id"], "time": row["time"].isoformat(), @@ -2417,15 +2429,196 @@ async def events_json(request: Request): "data": dict(row["data"]) if row["data"] else {}, "regions": list(row["regions"]) if row["regions"] else [], }) - + # Build next_cursor if there are more results next_cursor = None if has_next and events: last_event = rows[-1] cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}" next_cursor = base64.b64encode(cursor_data.encode("utf-8")).decode("utf-8") - + + return EventsQueryResult(events, next_cursor) + + +def _geometry_summary(geometry: dict | None) -> str: + """Generate a human-readable summary of a geometry.""" + if not geometry: + return "None" + + geom_type = geometry.get("type", "Unknown") + + if geom_type == "Point": + return "Point" + elif geom_type == "LineString": + coords = geometry.get("coordinates", []) + return f"Line ({len(coords)} pts)" + elif geom_type == "Polygon": + coords = geometry.get("coordinates", [[]]) + if coords: + return f"Polygon ({len(coords[0])} pts)" + return "Polygon" + elif geom_type == "MultiPolygon": + coords = geometry.get("coordinates", []) + return f"MultiPolygon ({len(coords)} parts)" + else: + return geom_type + + + +@router.get("/events.json") +async def events_json(request: Request): + """ + Paginated, filterable JSON endpoint for events. + + Query parameters (all optional): + adapter: filter by adapter name + category: filter by event category + since: ISO 8601 datetime - events where time >= since + until: ISO 8601 datetime - events where time < until + region_north, region_south, region_east, region_west: bbox filter (all four required if any) + limit: page size (default 50, max 200) + cursor: opaque pagination cursor + + Returns: + {"events": [...], "next_cursor": string or null} + """ + from fastapi.responses import JSONResponse + + params = request.query_params + + # Parse and validate parameters using shared helper + parsed, error = _parse_events_params(params) + if error: + return JSONResponse({"error": error}, status_code=400) + + # Fetch events using shared helper + result = await _fetch_events(parsed) + if result.error: + return JSONResponse({"error": result.error}, status_code=500) + return JSONResponse({ - "events": events, - "next_cursor": next_cursor, + "events": result.events, + "next_cursor": result.next_cursor, }) + + +# --- Events feed frontend routes --- + +@router.get("/events", response_class=HTMLResponse) +async def events_list(request: Request) -> HTMLResponse: + """Events feed page with filter form, table, and map.""" + templates = _get_templates() + operator = getattr(request.state, "operator", None) + csrf_token = getattr(request.state, "csrf_token", "") + + params = request.query_params + + # Parse parameters + parsed, error = _parse_events_params(params) + + # Get system settings for map tiles + pool = get_pool() + async with pool.acquire() as conn: + system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system") + + tile_url = system_row["map_tile_url"] if system_row else "https://tile.openstreetmap.org/{z}/{x}/{y}.png" + tile_attribution = system_row["map_attribution"] if system_row else "OpenStreetMap" + + # Prepare filter values for template + filter_values = { + "adapter": params.get("adapter", ""), + "category": params.get("category", ""), + "since": params.get("since", ""), + "until": params.get("until", ""), + "region_north": params.get("region_north", ""), + "region_south": params.get("region_south", ""), + "region_east": params.get("region_east", ""), + "region_west": params.get("region_west", ""), + "limit": params.get("limit", "50"), + } + + events = [] + next_cursor = None + + if error: + # Validation error - show error banner but don't fail the page + pass + else: + # Fetch events + result = await _fetch_events(parsed) + if result.error: + error = result.error + else: + events = result.events + next_cursor = result.next_cursor + + # Add geometry summary to each event + for event in events: + event["geometry_summary"] = _geometry_summary(event.get("geometry")) + + return templates.TemplateResponse( + request=request, + name="events_list.html", + context={ + "operator": operator, + "csrf_token": csrf_token, + "events": events, + "next_cursor": next_cursor, + "filter_values": filter_values, + "filter_error": error, + "tile_url": tile_url, + "tile_attribution": tile_attribution, + }, + ) + + +@router.get("/events/rows", response_class=HTMLResponse) +async def events_rows(request: Request) -> HTMLResponse: + """HTMX fragment: events table rows only (no page chrome).""" + templates = _get_templates() + + params = request.query_params + + # Parse parameters + parsed, error = _parse_events_params(params) + + # Prepare filter values for template + filter_values = { + "adapter": params.get("adapter", ""), + "category": params.get("category", ""), + "since": params.get("since", ""), + "until": params.get("until", ""), + "region_north": params.get("region_north", ""), + "region_south": params.get("region_south", ""), + "region_east": params.get("region_east", ""), + "region_west": params.get("region_west", ""), + "limit": params.get("limit", "50"), + } + + events = [] + next_cursor = None + + if error: + pass + else: + result = await _fetch_events(parsed) + if result.error: + error = result.error + else: + events = result.events + next_cursor = result.next_cursor + + # Add geometry summary to each event + for event in events: + event["geometry_summary"] = _geometry_summary(event.get("geometry")) + + return templates.TemplateResponse( + request=request, + name="_events_rows.html", + context={ + "events": events, + "next_cursor": next_cursor, + "filter_values": filter_values, + "filter_error": error, + }, + ) diff --git a/src/central/gui/templates/_events_rows.html b/src/central/gui/templates/_events_rows.html new file mode 100644 index 0000000..231d7b6 --- /dev/null +++ b/src/central/gui/templates/_events_rows.html @@ -0,0 +1,50 @@ +{% if filter_error %} +
+ Filter Error: {{ filter_error }} +
+{% endif %} + +{% if events %} + + + + + + + + + + + + {% for event in events %} + + + + + + + + {% endfor %} + +
TimeAdapterCategoryGeometrySubject
{{ event.time }}{{ event.adapter }}{{ event.category }}{{ event.geometry_summary }}{{ event.subject or '—' }}
+ +
+ Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}. + {% if next_cursor %} + + Next → + + {% else %} + End of results + {% endif %} +
+{% else %} +
+

No events match the filters.

+
+{% endif %} diff --git a/src/central/gui/templates/base.html b/src/central/gui/templates/base.html index 0cd7baa..a7a667d 100644 --- a/src/central/gui/templates/base.html +++ b/src/central/gui/templates/base.html @@ -17,6 +17,7 @@ {% if operator %}
  • Dashboard
  • Adapters
  • +
  • Events
  • Streams
  • API Keys
  • {{ operator.username }}
  • diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html new file mode 100644 index 0000000..f1fe5be --- /dev/null +++ b/src/central/gui/templates/events_list.html @@ -0,0 +1,378 @@ +{% extends "base.html" %} + +{% block title %}Events - Central{% endblock %} + +{% block head %} + + + +{% endblock %} + +{% block content %} +

    Events

    + +{% if filter_error %} +
    + Filter Error: {{ filter_error }} +
    +{% endif %} + +
    + Filters +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    + + Draw a rectangle on the map to filter by region +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + + +
    + + Clear Filters +
    +
    +
    + +
    + +
    + {% include "_events_rows.html" %} +
    + + + + + +{% endblock %} diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py new file mode 100644 index 0000000..305b660 --- /dev/null +++ b/tests/test_events_feed_frontend.py @@ -0,0 +1,633 @@ +"""Tests for events feed frontend routes.""" + +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from central.gui.routes import events_list, events_rows, events_json + + +class TestEventsFeedFrontendAuthenticated: + """Test events feed frontend with authentication.""" + + @pytest.mark.asyncio + async def test_events_no_filters_returns_html(self): + """GET /events authenticated, no filters returns HTML with events.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.state.csrf_token = "test_csrf_token" + mock_request.query_params = {} + + mock_events = [ + { + "id": f"event_{i}", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i), + "adapter": "nws", + "category": "Weather Alert", + "subject": f"Test Alert {i}", + "geometry": '{"type": "Point", "coordinates": [-122.4, 37.8]}' if i % 2 == 0 else None, + "data": {}, + "regions": [], + } + for i in range(5) + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_list(mock_request) + + assert result.status_code == 200 + call_args = mock_templates.TemplateResponse.call_args + context = call_args.kwargs.get("context", call_args[1].get("context")) + assert "events" in context + assert context["filter_error"] is None + + @pytest.mark.asyncio + async def test_events_adapter_filter(self): + """GET /events?adapter=nws returns only nws events.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.state.csrf_token = "test_csrf_token" + mock_request.query_params = {"adapter": "nws"} + + mock_events = [ + { + "id": "nws_event_1", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "adapter": "nws", + "category": "Alert", + "subject": "NWS Alert", + "geometry": None, + "data": {}, + "regions": [], + }, + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_list(mock_request) + + assert result.status_code == 200 + context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + assert context["filter_values"]["adapter"] == "nws" + + @pytest.mark.asyncio + async def test_events_since_until_filter(self): + """GET /events?since=...&until=... filters by time window.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.state.csrf_token = "test_csrf_token" + mock_request.query_params = { + "since": "2026-05-17T00:00:00", + "until": "2026-05-17T12:00:00", + } + + mock_events = [ + { + "id": "in_range", + "time": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc), + "received": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc), + "adapter": "nws", + "category": "Alert", + "subject": "In Range", + "geometry": None, + "data": {}, + "regions": [], + }, + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_list(mock_request) + + assert result.status_code == 200 + # Verify filter was actually parsed and passed to template + mock_templates.TemplateResponse.assert_called_once() + call_kwargs = mock_templates.TemplateResponse.call_args.kwargs + context = call_kwargs.get("context", call_kwargs) + assert context["filter_values"]["since"] == "2026-05-17T00:00:00" + assert context["filter_values"]["until"] == "2026-05-17T12:00:00" + + @pytest.mark.asyncio + async def test_events_region_filter(self): + """GET /events with full region bbox filters by location.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.state.csrf_token = "test_csrf_token" + mock_request.query_params = { + "region_north": "49.5", + "region_south": "31", + "region_east": "-102", + "region_west": "-124.5", + } + + mock_events = [ + { + "id": "in_bbox", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "adapter": "nws", + "category": "Alert", + "subject": "In BBox", + "geometry": '{"type": "Point", "coordinates": [-120, 40]}', + "data": {}, + "regions": [], + }, + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_list(mock_request) + + assert result.status_code == 200 + # Verify region filter was actually parsed and passed to template + mock_templates.TemplateResponse.assert_called_once() + call_kwargs = mock_templates.TemplateResponse.call_args.kwargs + context = call_kwargs.get("context", call_kwargs) + assert context["filter_values"]["region_north"] == "49.5" + assert context["filter_values"]["region_south"] == "31" + assert context["filter_values"]["region_east"] == "-102" + assert context["filter_values"]["region_west"] == "-124.5" + + @pytest.mark.asyncio + async def test_events_partial_region_shows_error_banner(self): + """GET /events with partial region shows error banner, not 400.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.state.csrf_token = "test_csrf_token" + mock_request.query_params = {"region_north": "49"} + + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_list(mock_request) + + # Should be 200, not 400 + assert result.status_code == 200 + context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + assert context["filter_error"] is not None + assert "region" in context["filter_error"].lower() + # Events should be empty due to validation error + assert context["events"] == [] + + @pytest.mark.asyncio + async def test_events_with_limit_shows_next_button(self): + """GET /events?limit=5 shows Next button when more events exist.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.state.csrf_token = "test_csrf_token" + mock_request.query_params = {"limit": "5"} + + # Return 6 events (limit+1) to trigger pagination + mock_events = [ + { + "id": f"event_{i}", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i), + "adapter": "nws", + "category": "Alert", + "subject": f"Event {i}", + "geometry": None, + "data": {}, + "regions": [], + } + for i in range(6) + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_list(mock_request) + + assert result.status_code == 200 + context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + assert context["next_cursor"] is not None + assert len(context["events"]) == 5 # Should be trimmed to limit + + +class TestEventsRowsFragment: + """Test /events/rows HTMX fragment.""" + + @pytest.mark.asyncio + async def test_events_rows_returns_fragment(self): + """GET /events/rows returns table fragment, not full page.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.query_params = {"limit": "5"} + + mock_events = [ + { + "id": "event_1", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "adapter": "nws", + "category": "Alert", + "subject": "Event 1", + "geometry": None, + "data": {}, + "regions": [], + }, + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_rows(mock_request) + + assert result.status_code == 200 + # Verify it uses the fragment template + call_args = mock_templates.TemplateResponse.call_args + assert call_args.kwargs.get("name") == "_events_rows.html" + + +class TestGeometrySummary: + """Test geometry summary function.""" + + def test_geometry_summary_polygon(self): + """Polygon geometry shows point count.""" + from central.gui.routes import _geometry_summary + + geom = { + "type": "Polygon", + "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]] + } + summary = _geometry_summary(geom) + assert "Polygon" in summary + assert "5 pts" in summary + + def test_geometry_summary_point(self): + """Point geometry shows 'Point'.""" + from central.gui.routes import _geometry_summary + + geom = {"type": "Point", "coordinates": [-122.4, 37.8]} + summary = _geometry_summary(geom) + assert summary == "Point" + + def test_geometry_summary_linestring(self): + """LineString geometry shows point count.""" + from central.gui.routes import _geometry_summary + + geom = { + "type": "LineString", + "coordinates": [[-122, 37], [-121, 38], [-120, 39]] + } + summary = _geometry_summary(geom) + assert "Line" in summary + assert "3 pts" in summary + + def test_geometry_summary_none(self): + """None geometry shows 'None'.""" + from central.gui.routes import _geometry_summary + + summary = _geometry_summary(None) + assert summary == "None" + + +class TestDataGeometryAttribute: + """Test that rows have valid geometry data attributes.""" + + @pytest.mark.asyncio + async def test_event_with_geometry_has_valid_json(self): + """Events with geometry have parseable JSON in data-geometry.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.query_params = {} + + mock_events = [ + { + "id": "geom_event", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "adapter": "nws", + "category": "Alert", + "subject": "With Geometry", + "geometry": '{"type": "Polygon", "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]}', + "data": {}, + "regions": [], + }, + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_rows(mock_request) + + context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + event = context["events"][0] + # Geometry should be parsed dict, not string + assert isinstance(event["geometry"], dict) + assert event["geometry"]["type"] == "Polygon" + + @pytest.mark.asyncio + async def test_event_without_geometry_has_none(self): + """Events without geometry have None for geometry field.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.query_params = {} + + mock_events = [ + { + "id": "no_geom_event", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "adapter": "nws", + "category": "Alert", + "subject": "No Geometry", + "geometry": None, + "data": {}, + "regions": [], + }, + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_rows(mock_request) + + context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + event = context["events"][0] + assert event["geometry"] is None + + +class TestCrossEndpointParity: + """Test that /events.json and /events return the same filtered results.""" + + @pytest.mark.asyncio + async def test_category_filter_both_endpoints(self): + """Category filter works on both /events.json and /events.""" + mock_events = [ + { + "id": "weather_event", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc), + "adapter": "nws", + "category": "Weather Alert", + "subject": "Weather Event", + "geometry": None, + "data": {}, + "regions": [], + }, + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = mock_events + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + query_params = {"category": "Weather Alert"} + + # Test /events.json + json_request = MagicMock() + json_request.state.operator = MagicMock(id=1, username="admin") + json_request.query_params = query_params + + with patch("central.gui.routes.get_pool", return_value=mock_pool): + json_response = await events_json(json_request) + + json_data = json.loads(json_response.body) + assert len(json_data["events"]) == 1 + assert json_data["events"][0]["category"] == "Weather Alert" + + # Test /events + html_request = MagicMock() + html_request.state.operator = MagicMock(id=1, username="admin") + html_request.state.csrf_token = "test_csrf" + html_request.query_params = query_params + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + mock_conn.fetch.return_value = mock_events + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + await events_list(html_request) + + html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + assert len(html_context["events"]) == 1 + assert html_context["events"][0]["category"] == "Weather Alert" + + @pytest.mark.asyncio + async def test_cursor_pagination_both_endpoints(self): + """Cursor pagination works identically on both endpoints.""" + first_page = [ + { + "id": f"event_{i}", + "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i), + "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i), + "adapter": "nws", + "category": "Alert", + "subject": f"Event {i}", + "geometry": None, + "data": {}, + "regions": [], + } + for i in range(3) + ] + + mock_conn = AsyncMock() + mock_conn.fetch.return_value = first_page + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + json_request = MagicMock() + json_request.state.operator = MagicMock(id=1, username="admin") + json_request.query_params = {"limit": "2"} + + with patch("central.gui.routes.get_pool", return_value=mock_pool): + json_response = await events_json(json_request) + + json_data = json.loads(json_response.body) + json_cursor = json_data["next_cursor"] + assert json_cursor is not None + + html_request = MagicMock() + html_request.state.operator = MagicMock(id=1, username="admin") + html_request.state.csrf_token = "test_csrf" + html_request.query_params = {"limit": "2"} + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + mock_conn.fetch.return_value = first_page + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + await events_list(html_request) + + html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + html_cursor = html_context["next_cursor"] + + assert json_cursor == html_cursor + + +class TestErrorSemantics: + """Test error handling differences between JSON and HTML endpoints.""" + + @pytest.mark.asyncio + async def test_json_endpoint_returns_400_on_invalid_limit(self): + """/events.json?limit=0 returns 400 JSON error.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.query_params = {"limit": "0"} + + response = await events_json(mock_request) + + assert response.status_code == 400 + data = json.loads(response.body) + assert "error" in data + + @pytest.mark.asyncio + async def test_html_endpoint_returns_200_with_error_banner(self): + """/events?limit=0 returns 200 HTML with error banner.""" + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="admin") + mock_request.state.csrf_token = "test_csrf" + mock_request.query_params = {"limit": "0"} + + mock_conn = AsyncMock() + mock_conn.fetchrow.return_value = { + "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "map_attribution": "OpenStreetMap", + } + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_templates.TemplateResponse.return_value = MagicMock(status_code=200) + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await events_list(mock_request) + + assert result.status_code == 200 + context = mock_templates.TemplateResponse.call_args.kwargs.get("context") + assert context["filter_error"] is not None + assert "limit" in context["filter_error"].lower() + assert context["events"] == [] From 3de81f392aecab6124ad2d0b4a405daa11cc176b Mon Sep 17 00:00:00 2001 From: malice Date: Mon, 18 May 2026 14:19:27 -0600 Subject: [PATCH 2/6] =?UTF-8?q?1b-9c:=20Events=20feed=20UX=20iteration=20?= =?UTF-8?q?=E2=80=94=20colors,=20popups,=20viewport=20filter,=20expandable?= =?UTF-8?q?=20rows=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: events feed UX iteration - colors, popups, viewport filter A. Color-code polygons by adapter (NWS amber, FIRMS red, USGS violet) B. Click popup on polygons showing time + adapter + category + subject C. Map viewport drives spatial filter - pan/zoom updates table via HTMX D. Add legend showing adapter color mapping E. Remove draw-bbox control, region inputs now hidden (auto-managed) Template changes: - _events_rows.html: add data-adapter, data-category, data-time, data-subject - events_list.html: ADAPTER_COLORS mapping, bindPopup, moveend handler Test: verify template renders adapter/category/subject for JS consumption * fix: remove isoformat() call on already-formatted time string * feat: full events feed UX iteration A. Color-code polygons by adapter with legend B. Click popup on polygons with "View details" link C. Viewport-driven spatial filter - pan/zoom updates table via HTMX Map never auto-fits after initial load (user controls viewport) D. Expandable row details showing full event data payload Changes: - _events_rows.html: add data-event-id, expand button, detail row - events_list.html: eventLayerGroup pattern, buildPopup, rebindEventLayers Fit to results button, expand/collapse handlers, CSS.escape for IDs * fix: add programmaticMove flag to prevent viewport refresh loop Suppress moveend handler during fitBounds/setView calls to prevent feedback loop: fitBounds -> moveend -> applyViewportFilter -> HTMX swap -> repeat. * fix: map never auto-fits - user controls viewport - Disable initial fitToAllLayers on page load - Remove fitBounds/setView from row click handler - Map only moves when user pans/zooms - Table filters based on visible viewport * fix: map shows all events always, only table filters Map polygons are drawn once on load and never cleared/redrawn. HTMX swap only updates the table, not the map layers. User viewport is fully preserved. * fix: use htmx.trigger instead of dispatchEvent for HTMX swap dispatchEvent(submit) was triggering native form submission (full page reload). htmx.trigger() properly triggers HTMX swap. Also re-enable initial rebindEventLayers so polygons load on first render. --------- Co-authored-by: Matt Johnson --- src/central/gui/templates/_events_rows.html | 25 +- src/central/gui/templates/events_list.html | 489 +++++++++++--------- tests/test_events_feed_frontend.py | 53 +++ 3 files changed, 351 insertions(+), 216 deletions(-) diff --git a/src/central/gui/templates/_events_rows.html b/src/central/gui/templates/_events_rows.html index 231d7b6..c7f126c 100644 --- a/src/central/gui/templates/_events_rows.html +++ b/src/central/gui/templates/_events_rows.html @@ -8,6 +8,7 @@ + @@ -17,14 +18,36 @@ {% for event in events %} - + + + + {% endfor %}
    Time Adapter Category
    {{ event.time }} {{ event.adapter }} {{ event.category }} {{ event.geometry_summary }} {{ event.subject or '—' }}
    diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index f1fe5be..5c00b94 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -4,26 +4,88 @@ {% block head %} -