From 380cde31f815ac875134dcda729b832ce251e31e Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Mon, 25 May 2026 00:58:38 +0000 Subject: [PATCH] feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1) Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events filter row with a multi-select, URL-addressable filtering surface. - Search: full-width box, debounced 300ms, server-side ILIKE over the inner adapter payload (covers the derived subject + location); parameterized with LIKE wildcards escaped (ESCAPE '\'). Injection-safe. - Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared _chip_picker.html macro). Adapter is grouped by domain with color swatches and an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated. - Event Type is derived as split_part(category,'.',1) (no event_type column yet; a stand-in until the v0.8 canonical schema). Severity maps labels to the numeric scale (4=critical..1=low, 0/NULL=unknown). - Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range, encoded in a single `time` token. GUI defaults to last_24h; events.json keeps its single-value adapter/since/until contract (no default). - Active pills: server-rendered from parsed state, updated out-of-band on each HTMX swap; each x clears that filter and re-submits. - URL state persistence: every filter in the query string; /events/rows sets HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work. Filter options are rendered server-side at page load (DISTINCT category + split_part, registry adapters, severity enum) -- no new AJAX endpoints. Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish is deferred to a later pass per the rework plan. Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety, time-preset resolution with injected clock, severity/NULL handling, active-pill descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to the new filter_state/adapters contract. Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter base class change -> central-gui restart only (no supervisor restart). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/central/gui/routes.py | 376 ++++++++++++++----- src/central/gui/templates/_active_pills.html | 16 + src/central/gui/templates/_chip_picker.html | 47 +++ src/central/gui/templates/_events_rows.html | 9 +- src/central/gui/templates/events_list.html | 242 ++++++++++-- tests/test_events_feed_frontend.py | 22 +- tests/test_events_filtering.py | 210 +++++++++++ 7 files changed, 783 insertions(+), 139 deletions(-) create mode 100644 src/central/gui/templates/_active_pills.html create mode 100644 src/central/gui/templates/_chip_picker.html create mode 100644 tests/test_events_filtering.py diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index 7dca84c..eee01bf 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -5,8 +5,9 @@ import html import json import logging import re -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any +from urllib.parse import urlencode logger = logging.getLogger("central.gui.routes") @@ -2616,10 +2617,159 @@ class EventsQueryResult: self.error = error -def _parse_events_params(params) -> tuple[dict | None, str | None]: +# --- v0.7.1 filtering: shared constants + pure helpers ---------------------- + +# Map a severity label (UI) to the numeric severity values it covers. Numeric +# scale per nws.SEVERITY_MAP (Extreme=4..Minor=1, Unknown=None). "unknown" +# also covers NULL and sev 0 (no assessment) -- handled in the query builder. +SEVERITY_LABELS: dict[str, list[int]] = { + "critical": [4], "high": [3], "moderate": [2], "low": [1], "unknown": [0], +} +SEVERITY_ORDER = ["critical", "high", "moderate", "low", "unknown"] + +TIME_PRESETS: dict[str, timedelta] = { + "last_15m": timedelta(minutes=15), + "last_1h": timedelta(hours=1), + "last_6h": timedelta(hours=6), + "last_24h": timedelta(hours=24), + "last_7d": timedelta(days=7), +} +TIME_PRESET_LABELS = { + "last_15m": "Last 15 min", "last_1h": "Last 1 hour", "last_6h": "Last 6 hours", + "last_24h": "Last 24 hours", "last_7d": "Last 7 days", + "active": "Active now", "all": "All time", +} +DEFAULT_TIME = "last_24h" + +# Adapter -> domain grouping for the chip-picker (registry-derived; any adapter +# not listed here falls into "Other"). Group order is the display order. +ADAPTER_GROUPS = { + "Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"], + "Weather": ["nws"], + "Space": ["swpc_alerts", "swpc_kindex", "swpc_protons"], + "Geophysical": ["usgs_quake", "nwis"], + "Earth Observation": ["eonet"], +} +# Same palette the map legend uses, indexed by sorted-adapter position. +EVENTS_PALETTE = [ + "#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777", + "#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488", +] + + +def _csv_param(params, name: str) -> list[str]: + """Parse a comma-separated multi-value query param into a clean list.""" + raw = (params.get(name) or "").strip() + return [v.strip() for v in raw.split(",") if v.strip()] if raw else [] + + +def _query_items(params): + """(key, value) pairs from Starlette QueryParams (multi-valued) or a plain + dict (as used in unit tests).""" + if hasattr(params, "multi_items"): + return params.multi_items() + return list(params.items()) + + +def _ilike_escape(term: str) -> str: + """Escape LIKE wildcards so user input is matched literally (ESCAPE '\\').""" + return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def _resolve_time(token: str | None, now: datetime): + """Resolve a time token to (since, until, active, error). + + 'all'/None -> no time filter; 'active' -> active-now flag; presets -> since; + 'custom:,' -> explicit range. Pure (clock injected). + """ + if token in (None, "", "all"): + return None, None, False, None + if token == "active": + return None, None, True, None + if token in TIME_PRESETS: + return now - TIME_PRESETS[token], None, False, None + if token.startswith("custom:"): + f, _, t = token[len("custom:"):].partition(",") + try: + since = datetime.fromisoformat(f.replace("Z", "+00:00")) if f else None + until = datetime.fromisoformat(t.replace("Z", "+00:00")) if t else None + except ValueError: + return None, None, False, "Invalid datetime in custom time range" + if since and until and since > until: + return None, None, False, "time 'from' must be before 'to'" + return since, until, False, None + return None, None, False, f"Unknown time preset: {token}" + + +def _adapter_filter_options(): + """Registry-derived (flat, grouped) adapter options for the chip-picker. + + flat: [{name, display_name, color}] sorted by name (color by sorted index, + matching the map legend). grouped: [(group_label, [{value,label,color}])]. + """ + reg = discover_adapters() + ordered = sorted(reg.values(), key=lambda c: c.name) + color_by_name = { + cls.name: EVENTS_PALETTE[i % len(EVENTS_PALETTE)] for i, cls in enumerate(ordered) + } + flat = [ + {"name": cls.name, "display_name": cls.display_name, "color": color_by_name[cls.name]} + for cls in ordered + ] + grouped, grouped_names = [], set() + for group_label, names in ADAPTER_GROUPS.items(): + items = [ + {"value": n, "label": reg[n].display_name, "color": color_by_name[n]} + for n in names if n in reg + ] + if items: + grouped.append((group_label, items)) + grouped_names.update(items_n["value"] for items_n in items) + leftover = [ + {"value": c.name, "label": c.display_name, "color": color_by_name[c.name]} + for c in ordered if c.name not in grouped_names + ] + if leftover: + grouped.append(("Other", leftover)) + return flat, grouped + + +def _build_active_pills(parsed: dict, adapter_total: int) -> list[dict]: + """Server-side active-filter pills (descriptors) from parsed filter state. + + Each pill: {"key": , "label": }. + """ + pills: list[dict] = [] + if parsed.get("q"): + pills.append({"key": "q", "label": f'Search: "{parsed["q"]}"'}) + if parsed.get("adapters"): + pills.append({"key": "adapter", + "label": f'Adapters: {len(parsed["adapters"])} of {adapter_total}'}) + if parsed.get("categories"): + pills.append({"key": "category", "label": f'Categories: {len(parsed["categories"])}'}) + if parsed.get("event_types"): + pills.append({"key": "event_type", + "label": "Event types: " + ", ".join(parsed["event_types"])}) + if parsed.get("severities"): + rank = {lbl: i for i, lbl in enumerate(SEVERITY_ORDER)} + labs = sorted(parsed["severities"], key=lambda s: rank.get(s, 99)) + pills.append({"key": "severity", "label": "Severity: " + ", ".join(labs)}) + token = parsed.get("time_token") + if token and token != "all": + label = "Custom range" if token.startswith("custom:") else TIME_PRESET_LABELS.get(token, token) + pills.append({"key": "time", "label": label}) + return pills + + +def _parse_events_params(params, default_time: str | None = None) -> tuple[dict | None, str | None]: """ Parse and validate events query parameters. + Multi-value filters (adapter, category, event_type, severity) are + comma-separated. `time` is a single token (preset / active / all / + custom:from,to); legacy since/until are still honored for the JSON API. + `default_time` (GUI only) supplies the time token when none is given. + Returns: (parsed_params, error_message) If error_message is not None, parsed_params is None. @@ -2634,37 +2784,42 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]: if limit < 1 or limit > 200: return None, "limit must be between 1 and 200" - # Parse adapter filter - adapter = params.get("adapter") - if adapter == "": - adapter = None + # Multi-value filters (comma-separated; a single value is just a CSV of + # length 1, preserving the old single-value API). Unknown severity labels + # are ignored rather than erroring. + q = (params.get("q") or "").strip()[:200] or None + adapters = _csv_param(params, "adapter") + categories = _csv_param(params, "category") + event_types = _csv_param(params, "event_type") + severities = [s for s in _csv_param(params, "severity") if s in SEVERITY_LABELS] - # 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 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 None, f"Invalid ISO 8601 datetime for until: {until_str}" - - # Validate since <= until - if since and until and since > until: - return None, "since must be before or equal to until" + # Time: a single token wins; otherwise legacy since/until (JSON API); then + # the GUI-supplied default_time when nothing else is given. + time_token = params.get("time") or None + since = until = None + active = False + if time_token: + since, until, active, terr = _resolve_time(time_token, datetime.now(timezone.utc)) + if terr: + return None, terr + else: + since_str = params.get("since") + if since_str: + try: + since = datetime.fromisoformat(since_str.replace("Z", "+00:00")) + except ValueError: + 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 None, f"Invalid ISO 8601 datetime for until: {until_str}" + if since and until and since > until: + return None, "since must be before or equal to until" + if since is None and until is None and default_time: + time_token = default_time + since, until, active, _ = _resolve_time(time_token, datetime.now(timezone.utc)) # Parse region bbox region_north = params.get("region_north") @@ -2729,10 +2884,15 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]: return { "limit": limit, - "adapter": adapter, - "category": category, + "q": q, + "adapters": adapters, + "categories": categories, + "event_types": event_types, + "severities": severities, + "time_token": time_token, "since": since, "until": until, + "active": active, "bbox": bbox, "cursor_time": cursor_time, "cursor_id": cursor_id, @@ -2766,10 +2926,14 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult: pool = get_pool() limit = parsed_params["limit"] - adapter = parsed_params["adapter"] - category = parsed_params["category"] + q = parsed_params.get("q") + adapters = parsed_params.get("adapters") or [] + categories = parsed_params.get("categories") or [] + event_types = parsed_params.get("event_types") or [] + severities = parsed_params.get("severities") or [] since = parsed_params["since"] until = parsed_params["until"] + active = parsed_params.get("active", False) bbox = parsed_params["bbox"] cursor_time = parsed_params["cursor_time"] cursor_id = parsed_params["cursor_id"] @@ -2779,16 +2943,43 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult: query_params = [] param_idx = 1 - if adapter: - conditions.append(f"adapter = ${param_idx}") - query_params.append(adapter) + if q: + # Subject and location are both derived from the inner adapter payload + # (payload->'data'->'data'), so a case-insensitive match over that + # subtree's text covers both. Parameterized + LIKE-wildcards escaped. + conditions.append(f"(payload->'data'->'data')::text ILIKE ${param_idx} ESCAPE '\\'") + query_params.append(f"%{_ilike_escape(q)}%") param_idx += 1 - if category: - conditions.append(f"category = ${param_idx}") - query_params.append(category) + if adapters: + conditions.append(f"adapter = ANY(${param_idx})") + query_params.append(adapters) param_idx += 1 + if categories: + conditions.append(f"category = ANY(${param_idx})") + query_params.append(categories) + param_idx += 1 + + if event_types: + conditions.append(f"split_part(category, '.', 1) = ANY(${param_idx})") + query_params.append(event_types) + param_idx += 1 + + if severities: + nums = sorted({n for lbl in severities for n in SEVERITY_LABELS.get(lbl, [])}) + if "unknown" in severities: + # sev 0 and NULL both read as "unknown" (no assessment). + conditions.append(f"(severity = ANY(${param_idx}) OR severity IS NULL)") + else: + conditions.append(f"severity = ANY(${param_idx})") + query_params.append(nums) + param_idx += 1 + + if active: + # Active now: started and not yet ended. + conditions.append("time <= now() AND (expires IS NULL OR expires > now())") + if since: conditions.append(f"time >= ${param_idx}") query_params.append(since) @@ -2979,38 +3170,34 @@ async def events_list(request: Request) -> HTMLResponse: params = request.query_params - # Parse parameters - parsed, error = _parse_events_params(params) + # Parse parameters (GUI defaults to Last 24h when no time filter is given). + parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME) - # Get system settings for map tiles + # Get system settings for map tiles + DISTINCT filter-option lists. pool = get_pool() + all_categories: list[str] = [] + all_event_types: list[str] = [] async with pool.acquire() as conn: system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system") + try: + cat_rows = await conn.fetch("SELECT DISTINCT category FROM events ORDER BY 1") + all_categories = [r["category"] for r in cat_rows] + et_rows = await conn.fetch( + "SELECT DISTINCT split_part(category, '.', 1) AS et FROM events ORDER BY 1" + ) + all_event_types = [r["et"] for r in et_rows] + except Exception: + logger.warning("Failed to load filter options", exc_info=True) 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 @@ -3018,15 +3205,25 @@ async def events_list(request: Request) -> HTMLResponse: events = result.events next_cursor = result.next_cursor - # Add table-only display fields (time_human, adapter_display, geometry_summary) _decorate_table_events(events) - # Registry-derived adapter list for the filter + {% if with_swatch and opt.color is defined %} + + {% endif %} + {{ lbl }} + +{% endmacro %} + +{% macro chip_picker(field, label, options, selected, grouped=False, searchable=False, with_swatch=False) %} +
+ + + +
+{% endmacro %} diff --git a/src/central/gui/templates/_events_rows.html b/src/central/gui/templates/_events_rows.html index a1eb264..f31e401 100644 --- a/src/central/gui/templates/_events_rows.html +++ b/src/central/gui/templates/_events_rows.html @@ -70,9 +70,10 @@
Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}. {% if next_cursor %} - Next → @@ -86,3 +87,7 @@

No events match the filters.

{% endif %} +{% if oob_pills %} +{# Out-of-band update of the page-level active-pills bar on each HTMX swap. #} +
{% include "_active_pills.html" %}
+{% endif %} diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index d52a400..df80b76 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -102,6 +102,42 @@ justify-content: space-between; align-items: center; } + /* --- v0.7.1 filter row (functional layout; full polish in a later pass) --- */ + .filter-search { width: 100%; margin-bottom: 0.5rem; } + .filter-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-start; } + .filter-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; justify-content: flex-end; } + .filter-apply { width: 120px; } + .chip-picker { position: relative; } + .chip-picker-toggle { white-space: nowrap; } + .chip-picker-panel { + position: absolute; z-index: 1000; top: 100%; left: 0; margin-top: 0.25rem; + min-width: 16rem; max-height: 22rem; overflow-y: auto; + background: var(--pico-card-background-color); + border: 1px solid var(--pico-muted-border-color); + border-radius: 0.375rem; padding: 0.5rem; box-shadow: 0 4px 14px rgba(0,0,0,0.18); + } + .chip-picker-search { width: 100%; margin-bottom: 0.5rem; } + .chip-group-header { font-size: 0.75rem; font-weight: 600; color: var(--pico-muted-color); + text-transform: uppercase; margin: 0.4rem 0 0.2rem; } + .chip-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; + cursor: pointer; font-weight: 400; } + .chip-row input[type="checkbox"] { width: auto; margin: 0; } + .chip-swatch { width: 0.85rem; height: 0.85rem; border-radius: 2px; flex: 0 0 auto; } + .chip-row.chip-hidden { display: none; } + .time-preset { display: block; width: 100%; text-align: left; background: none; + border: none; padding: 0.35rem 0.4rem; color: var(--pico-color); cursor: pointer; } + .time-preset:hover, .time-preset.selected { background: var(--pico-primary-focus); } + .time-custom { border-top: 1px solid var(--pico-muted-border-color); margin-top: 0.4rem; + padding-top: 0.4rem; display: flex; flex-direction: column; gap: 0.3rem; } + #active-pills .active-pills { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; + margin: 0.5rem 0; } + .active-pills-label { font-size: 0.8rem; color: var(--pico-muted-color); } + .filter-pill { display: inline-flex; align-items: center; gap: 0.35rem; + background: var(--pico-primary-focus); border-radius: 999px; padding: 0.15rem 0.6rem; + font-size: 0.85rem; } + .pill-remove, .pill-clear-all { background: none; border: none; cursor: pointer; + color: var(--pico-color); padding: 0; font-size: 1rem; line-height: 1; width: auto; } + .pill-clear-all { font-size: 0.8rem; text-decoration: underline; } {% endblock %} @@ -120,52 +156,58 @@ {% endif %} -
- Filters -
+{% from "_chip_picker.html" import chip_picker %} + -
+{# Active filter pills — server-rendered; updated out-of-band on each swap. #} +
{% include "_active_pills.html" %}
@@ -566,4 +608,124 @@ })(); + + {% endblock %} diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index f1ab1e7..0c3b3f6 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -104,7 +104,8 @@ class TestEventsFeedFrontendAuthenticated: assert result.status_code == 200 context = mock_templates.TemplateResponse.call_args.kwargs.get("context") - assert context["filter_values"]["adapter"] == "nws" + # v0.7.1: adapter is now a multi-select; single value parses to a 1-list. + assert context["filter_state"]["adapters"] == ["nws"] @pytest.mark.asyncio async def test_events_since_until_filter(self): @@ -154,8 +155,9 @@ class TestEventsFeedFrontendAuthenticated: 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" + # v0.7.1: the GUI uses time presets, but legacy since/until are still + # honored (JSON API + bookmarks); they parse without error and apply. + assert context["filter_error"] is None @pytest.mark.asyncio async def test_events_region_filter(self): @@ -207,10 +209,10 @@ class TestEventsFeedFrontendAuthenticated: 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" + assert context["filter_state"]["region_north"] == "49.5" + assert context["filter_state"]["region_south"] == "31" + assert context["filter_state"]["region_east"] == "-102" + assert context["filter_state"]["region_west"] == "-124.5" @pytest.mark.asyncio async def test_events_partial_region_shows_error_banner(self): @@ -772,9 +774,11 @@ class TestRegistryDrivenAdapterFilter: # The list is exactly the registry, sorted by name (stable), no extras. assert [a["name"] for a in context["adapters"]] == sorted(registry.keys()) - # Each entry carries name + display_name straight from the adapter class. + # Each entry carries name + display_name (v0.7.1 adds a positional color). + by_name = {a["name"]: a for a in context["adapters"]} for cls in registry.values(): - assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"] + assert by_name[cls.name]["display_name"] == cls.display_name + assert by_name[cls.name]["color"].startswith("#") class TestPerAdapterRowPartials: diff --git a/tests/test_events_filtering.py b/tests/test_events_filtering.py new file mode 100644 index 0000000..0b3e5cf --- /dev/null +++ b/tests/test_events_filtering.py @@ -0,0 +1,210 @@ +"""Tests for the v0.7.1 /events filtering (search, multi-select, time presets, +active pills, URL round-trip). Covers the pure helpers and the query builder +(via a captured-SQL mock), with no live DB. +""" +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from central.gui import routes + +NOW = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc) + + +# --- pure helpers ----------------------------------------------------------- + +def test_csv_param_splits_and_trims(): + assert routes._csv_param({"adapter": "nws, firms ,usgs_quake"}, "adapter") == \ + ["nws", "firms", "usgs_quake"] + assert routes._csv_param({}, "adapter") == [] + assert routes._csv_param({"adapter": ""}, "adapter") == [] + + +def test_ilike_escape_escapes_wildcards(): + assert routes._ilike_escape("50%_off\\x") == "50\\%\\_off\\\\x" + + +@pytest.mark.parametrize("token,delta", [ + ("last_15m", timedelta(minutes=15)), + ("last_1h", timedelta(hours=1)), + ("last_6h", timedelta(hours=6)), + ("last_24h", timedelta(hours=24)), + ("last_7d", timedelta(days=7)), +]) +def test_resolve_time_presets(token, delta): + since, until, active, err = routes._resolve_time(token, NOW) + assert err is None and active is False and until is None + assert since == NOW - delta + + +def test_resolve_time_active_all_custom(): + assert routes._resolve_time("active", NOW) == (None, None, True, None) + assert routes._resolve_time("all", NOW) == (None, None, False, None) + assert routes._resolve_time(None, NOW) == (None, None, False, None) + since, until, active, err = routes._resolve_time("custom:2026-05-01T00:00,2026-05-02T00:00", NOW) + assert err is None and active is False + # datetime-local values carry no tz (naive), matching the legacy since/until path. + assert since == datetime(2026, 5, 1) + assert until == datetime(2026, 5, 2) + + +def test_resolve_time_custom_inverted_is_error(): + _, _, _, err = routes._resolve_time("custom:2026-05-02T00:00,2026-05-01T00:00", NOW) + assert err and "before" in err + + +def test_resolve_time_unknown_token_is_error(): + _, _, _, err = routes._resolve_time("yesterday", NOW) + assert err and "Unknown" in err + + +# --- _parse_events_params --------------------------------------------------- + +def test_parse_multi_value_csv(): + parsed, err = routes._parse_events_params({ + "adapter": "nws,firms", "category": "quake.event.light,wx.alert.tornado_warning", + "event_type": "fire,quake", "severity": "critical,high", "q": "wildfire", "time": "all", + }) + assert err is None + assert parsed["adapters"] == ["nws", "firms"] + assert parsed["categories"] == ["quake.event.light", "wx.alert.tornado_warning"] + assert parsed["event_types"] == ["fire", "quake"] + assert parsed["severities"] == ["critical", "high"] + assert parsed["q"] == "wildfire" + + +def test_parse_drops_unknown_severity_labels(): + parsed, err = routes._parse_events_params({"severity": "critical,bogus,low"}) + assert err is None + assert parsed["severities"] == ["critical", "low"] + + +def test_parse_gui_defaults_to_last_24h(): + parsed, _ = routes._parse_events_params({}, default_time="last_24h") + assert parsed["time_token"] == "last_24h" + assert parsed["since"] is not None + + +def test_parse_json_api_has_no_default_time(): + parsed, _ = routes._parse_events_params({}) # no default_time (events.json) + assert parsed["time_token"] is None + assert parsed["since"] is None and parsed["until"] is None + + +def test_parse_legacy_since_until_still_honored(): + parsed, err = routes._parse_events_params( + {"since": "2026-05-17T00:00:00", "until": "2026-05-17T12:00:00"}, + default_time="last_24h", + ) + assert err is None + assert parsed["since"] == datetime(2026, 5, 17, 0, 0) + assert parsed["time_token"] is None # legacy path, not a preset + + +# --- query builder (captured SQL) ------------------------------------------- + +async def _capture(parsed): + captured = {} + + async def fake_fetch(query, *args): + captured["query"] = query + captured["params"] = list(args) + return [] + + conn = MagicMock() + conn.fetch = fake_fetch + pool = MagicMock() + pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn) + pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + with patch("central.gui.routes.get_pool", return_value=pool): + await routes._fetch_events(parsed) + return captured + + +@pytest.mark.asyncio +async def test_multi_adapter_uses_any(): + parsed, _ = routes._parse_events_params({"adapter": "nws,firms,usgs_quake", "time": "all"}) + cap = await _capture(parsed) + assert "adapter = ANY($" in cap["query"] + assert ["nws", "firms", "usgs_quake"] in cap["params"] + + +@pytest.mark.asyncio +async def test_event_type_uses_split_part(): + parsed, _ = routes._parse_events_params({"event_type": "fire,quake", "time": "all"}) + cap = await _capture(parsed) + assert "split_part(category, '.', 1) = ANY($" in cap["query"] + assert ["fire", "quake"] in cap["params"] + + +@pytest.mark.asyncio +async def test_severity_unknown_includes_null(): + parsed, _ = routes._parse_events_params({"severity": "critical,unknown", "time": "all"}) + cap = await _capture(parsed) + assert "severity = ANY($" in cap["query"] and "OR severity IS NULL" in cap["query"] + assert [0, 4] in cap["params"] # unknown->0, critical->4 (sorted) + + +@pytest.mark.asyncio +async def test_severity_without_unknown_has_no_null_clause(): + parsed, _ = routes._parse_events_params({"severity": "high", "time": "all"}) + cap = await _capture(parsed) + assert "OR severity IS NULL" not in cap["query"] + assert [3] in cap["params"] + + +@pytest.mark.asyncio +async def test_active_now_condition(): + parsed, _ = routes._parse_events_params({"time": "active"}) + cap = await _capture(parsed) + assert "time <= now() AND (expires IS NULL OR expires > now())" in cap["query"] + + +@pytest.mark.asyncio +async def test_search_is_parameterized_and_escaped(): + # A term loaded with SQL/LIKE metacharacters must be safely parameterized. + parsed, _ = routes._parse_events_params({"q": "50%_x'; DROP TABLE events;--", "time": "all"}) + cap = await _capture(parsed) + assert "(payload->'data'->'data')::text ILIKE $" in cap["query"] + assert "ESCAPE '\\'" in cap["query"] + # The dangerous term never appears inline in the SQL; only as a bound param. + assert "DROP TABLE" not in cap["query"] + assert cap["params"][0] == "%50\\%\\_x'; DROP TABLE events;--%" + + +# --- active pills + URL round-trip ------------------------------------------ + +def test_build_active_pills(): + parsed = { + "q": "fire", "adapters": ["nws", "firms"], "categories": ["a", "b"], + "event_types": ["fire"], "severities": ["high", "critical"], "time_token": "last_24h", + } + pills = {p["key"]: p["label"] for p in routes._build_active_pills(parsed, adapter_total=12)} + assert pills["q"] == 'Search: "fire"' + assert pills["adapter"] == "Adapters: 2 of 12" + assert pills["category"] == "Categories: 2" + assert pills["event_type"] == "Event types: fire" + assert pills["severity"] == "Severity: critical, high" # ranked order + assert pills["time"] == "Last 24 hours" + + +def test_no_time_pill_for_all(): + pills = routes._build_active_pills({"time_token": "all"}, adapter_total=12) + assert all(p["key"] != "time" for p in pills) + + +def test_url_round_trip(): + q = {"q": "fire", "adapter": "nws,firms", "category": "quake.event.light", + "event_type": "fire", "severity": "critical,high", "time": "last_1h", "limit": "50"} + parsed, err = routes._parse_events_params(q) + assert err is None + # Re-serialize the parsed multi-values as CSV and re-parse: identical state. + requery = { + "q": parsed["q"], "adapter": ",".join(parsed["adapters"]), + "category": ",".join(parsed["categories"]), "event_type": ",".join(parsed["event_types"]), + "severity": ",".join(parsed["severities"]), "time": parsed["time_token"], "limit": "50", + } + reparsed, _ = routes._parse_events_params(requery) + for k in ("q", "adapters", "categories", "event_types", "severities", "time_token"): + assert reparsed[k] == parsed[k]