Compare commits

...

1 commit

Author SHA1 Message Date
Matt Johnson
e8dc9f6063 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 <noreply@anthropic.com>
2026-05-18 04:36:32 +00:00
5 changed files with 1279 additions and 0 deletions

View file

@ -2236,6 +2236,274 @@ async def api_keys_delete(
return RedirectResponse(url="/api-keys", status_code=302)
# --- 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]:
"""
Parse and validate events query parameters.
Returns:
(parsed_params, error_message)
If error_message is not None, parsed_params is None.
"""
# Parse and validate limit
limit_str = params.get("limit", "50")
try:
limit = int(limit_str)
except ValueError:
return None, f"Invalid limit value: {limit_str}"
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
# 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"
# 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 None, "Region filter requires all four parameters: region_north, region_south, region_east, region_west"
bbox = None
if len(region_supplied) == 4:
try:
bbox = {
"north": float(region_north),
"south": float(region_south),
"east": float(region_east),
"west": float(region_west),
}
except ValueError:
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")
parts = decoded.split("|", 1)
if len(parts) != 2:
raise ValueError("Invalid cursor format")
cursor_time = datetime.fromisoformat(parts[0])
cursor_id = parts[1]
except Exception:
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
id,
time,
received,
adapter,
category,
payload->>'subject' as subject,
ST_AsGeoJSON(geom) as geometry,
payload as data,
regions
FROM public.events
{where_clause}
ORDER BY time DESC, id DESC
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 _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(),
"received": row["received"].isoformat(),
"adapter": row["adapter"],
"category": row["category"],
"subject": row["subject"],
"geometry": geometry,
"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):
"""
@ -2468,3 +2736,125 @@ async def events_json(request: Request):
"events": events,
"next_cursor": 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,
},
)

View file

@ -0,0 +1,50 @@
{% if filter_error %}
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
<strong>Filter Error:</strong> {{ filter_error }}
</article>
{% endif %}
{% if events %}
<table class="events-table">
<thead>
<tr>
<th>Time</th>
<th>Adapter</th>
<th>Category</th>
<th>Geometry</th>
<th>Subject</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr data-row-idx="{{ loop.index0 }}"
{% if event.geometry %}data-geometry="{{ event.geometry | tojson | e }}"{% endif %}>
<td>{{ event.time }}</td>
<td>{{ event.adapter }}</td>
<td>{{ event.category }}</td>
<td>{{ event.geometry_summary }}</td>
<td>{{ event.subject or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination-info">
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
{% if next_cursor %}
<a href="/events?cursor={{ next_cursor }}{% if filter_values.adapter %}&amp;adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&amp;category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&amp;since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&amp;until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&amp;region_north={{ filter_values.region_north }}&amp;region_south={{ filter_values.region_south }}&amp;region_east={{ filter_values.region_east }}&amp;region_west={{ filter_values.region_west }}{% endif %}&amp;limit={{ filter_values.limit }}"
role="button"
hx-get="/events/rows?cursor={{ next_cursor }}{% if filter_values.adapter %}&amp;adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&amp;category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&amp;since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&amp;until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&amp;region_north={{ filter_values.region_north }}&amp;region_south={{ filter_values.region_south }}&amp;region_east={{ filter_values.region_east }}&amp;region_west={{ filter_values.region_west }}{% endif %}&amp;limit={{ filter_values.limit }}"
hx-target="#events-rows"
hx-push-url="true">
Next &rarr;
</a>
{% else %}
<span><em>End of results</em></span>
{% endif %}
</div>
{% else %}
<article>
<p>No events match the filters.</p>
</article>
{% endif %}

View file

@ -17,6 +17,7 @@
{% if operator %}
<li><a href="/">Dashboard</a></li>
<li><a href="/adapters">Adapters</a></li>
<li><a href="/events">Events</a></li>
<li><a href="/streams">Streams</a></li>
<li><a href="/api-keys">API Keys</a></li>
<li>{{ operator.username }}</li>

View file

@ -0,0 +1,378 @@
{% extends "base.html" %}
{% block title %}Events - Central{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<style>
#events-map {
height: 400px;
margin-bottom: 1rem;
border-radius: var(--pico-border-radius);
}
.events-table {
font-size: 0.9rem;
}
.events-table td {
vertical-align: middle;
}
.events-table tr:hover {
background-color: var(--pico-primary-focus);
cursor: pointer;
}
.events-table tr.highlighted {
background-color: var(--pico-primary-background);
}
.filter-form .grid {
margin-bottom: 0.5rem;
}
.filter-form label {
margin-bottom: 0.25rem;
}
.filter-form input, .filter-form select {
margin-bottom: 0.5rem;
}
.region-inputs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.region-inputs input {
font-size: 0.85rem;
}
.region-controls {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.pagination-info {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
{% endblock %}
{% block content %}
<h1>Events</h1>
{% if filter_error %}
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
<strong>Filter Error:</strong> {{ filter_error }}
</article>
{% endif %}
<details open>
<summary>Filters</summary>
<form class="filter-form" action="/events" method="get"
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
<div class="grid">
<div>
<label for="adapter">Adapter</label>
<select id="adapter" name="adapter">
<option value="">All</option>
<option value="nws" {% if filter_values.adapter == 'nws' %}selected{% endif %}>nws</option>
<option value="firms" {% if filter_values.adapter == 'firms' %}selected{% endif %}>firms</option>
<option value="usgs_quake" {% if filter_values.adapter == 'usgs_quake' %}selected{% endif %}>usgs_quake</option>
</select>
</div>
<div>
<label for="category">Category</label>
<input type="text" id="category" name="category" placeholder="Exact match"
value="{{ filter_values.category }}">
</div>
<div>
<label for="since">From</label>
<input type="datetime-local" id="since" name="since"
value="{{ filter_values.since }}">
</div>
<div>
<label for="until">To</label>
<input type="datetime-local" id="until" name="until"
value="{{ filter_values.until }}">
</div>
</div>
<div>
<label>Region (draw on map or enter coordinates)</label>
<div class="region-controls">
<button type="button" id="clear-region-btn" class="outline secondary" style="width: auto; padding: 0.25rem 0.75rem;">
Clear Region
</button>
<small>Draw a rectangle on the map to filter by region</small>
</div>
<div class="region-inputs">
<div>
<label for="region_north">N</label>
<input type="number" id="region_north" name="region_north" step="0.0001" min="-90" max="90" readonly
value="{{ filter_values.region_north }}">
</div>
<div>
<label for="region_south">S</label>
<input type="number" id="region_south" name="region_south" step="0.0001" min="-90" max="90" readonly
value="{{ filter_values.region_south }}">
</div>
<div>
<label for="region_east">E</label>
<input type="number" id="region_east" name="region_east" step="0.0001" min="-180" max="180" readonly
value="{{ filter_values.region_east }}">
</div>
<div>
<label for="region_west">W</label>
<input type="number" id="region_west" name="region_west" step="0.0001" min="-180" max="180" readonly
value="{{ filter_values.region_west }}">
</div>
</div>
</div>
<input type="hidden" name="limit" value="{{ filter_values.limit }}">
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="submit">Apply</button>
<a href="/events" role="button" class="outline">Clear Filters</a>
</div>
</form>
</details>
<div id="events-map"></div>
<div id="events-rows">
{% include "_events_rows.html" %}
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script>
(function() {
const tileUrl = {{ tile_url | tojson }};
const tileAttr = {{ tile_attribution | tojson }};
// Initialize map
const map = L.map('events-map').setView([39, -98], 4);
L.tileLayer(tileUrl, {
attribution: tileAttr,
maxZoom: 18
}).addTo(map);
// Layer groups for event geometries
const eventLayers = {};
let highlightedRow = null;
let highlightedLayer = null;
// Styles
const defaultStyle = {
color: '#3388ff',
weight: 2,
fillOpacity: 0.2
};
const highlightStyle = {
color: '#ff3333',
weight: 4,
fillOpacity: 0.4
};
// Region filter rectangle
let filterRect = null;
const drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
// Draw control for region filter
const drawControl = new L.Control.Draw({
draw: {
rectangle: { shapeOptions: { color: '#ff7800', weight: 2, fillOpacity: 0.1 } },
polyline: false,
polygon: false,
circle: false,
marker: false,
circlemarker: false
},
edit: {
featureGroup: drawnItems,
edit: false,
remove: false
}
});
map.addControl(drawControl);
// Region input elements
const northInput = document.getElementById('region_north');
const southInput = document.getElementById('region_south');
const eastInput = document.getElementById('region_east');
const westInput = document.getElementById('region_west');
// Update inputs from rectangle
function updateRegionInputs(bounds) {
northInput.value = bounds.getNorth().toFixed(4);
southInput.value = bounds.getSouth().toFixed(4);
eastInput.value = bounds.getEast().toFixed(4);
westInput.value = bounds.getWest().toFixed(4);
}
// Handle new rectangle drawn
map.on(L.Draw.Event.CREATED, function(e) {
drawnItems.clearLayers();
filterRect = e.layer;
filterRect.setStyle({ color: '#ff7800', weight: 2, fillOpacity: 0.1 });
drawnItems.addLayer(filterRect);
updateRegionInputs(filterRect.getBounds());
// Auto-submit the form via HTMX
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
});
// Clear region button
document.getElementById('clear-region-btn').addEventListener('click', function() {
drawnItems.clearLayers();
filterRect = null;
northInput.value = '';
southInput.value = '';
eastInput.value = '';
westInput.value = '';
// Auto-submit to refresh
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
});
// If region values exist, show the filter rectangle
if (northInput.value && southInput.value && eastInput.value && westInput.value) {
const bounds = L.latLngBounds(
L.latLng(parseFloat(southInput.value), parseFloat(westInput.value)),
L.latLng(parseFloat(northInput.value), parseFloat(eastInput.value))
);
filterRect = L.rectangle(bounds, { color: '#ff7800', weight: 2, fillOpacity: 0.1 });
drawnItems.addLayer(filterRect);
}
// Function to add event geometries to map
function loadEventGeometries() {
// Clear existing event layers
Object.values(eventLayers).forEach(layer => map.removeLayer(layer));
Object.keys(eventLayers).forEach(key => delete eventLayers[key]);
const rows = document.querySelectorAll('#events-rows tr[data-geometry]');
const bounds = L.latLngBounds();
let hasGeometries = false;
rows.forEach((row, idx) => {
const geomStr = row.dataset.geometry;
if (!geomStr || geomStr === '') return;
try {
const geom = JSON.parse(geomStr);
if (!geom) return;
const layer = L.geoJSON(geom, {
style: defaultStyle,
pointToLayer: function(feature, latlng) {
return L.circleMarker(latlng, {
radius: 8,
...defaultStyle
});
}
});
layer.addTo(map);
eventLayers[idx] = layer;
// Extend bounds
try {
bounds.extend(layer.getBounds());
hasGeometries = true;
} catch (e) {
// Point geometries might not have getBounds
if (geom.type === 'Point' && geom.coordinates) {
bounds.extend(L.latLng(geom.coordinates[1], geom.coordinates[0]));
hasGeometries = true;
}
}
// Click on geometry highlights row
layer.on('click', function() {
highlightRow(row, layer);
});
} catch (e) {
console.error('Error parsing geometry:', e);
}
});
// Fit map to all geometries
if (hasGeometries) {
map.fitBounds(bounds.pad(0.1));
}
}
// Highlight a row and its geometry
function highlightRow(row, layer) {
// Reset previous highlight
if (highlightedRow) {
highlightedRow.classList.remove('highlighted');
}
if (highlightedLayer) {
highlightedLayer.setStyle(defaultStyle);
}
// Set new highlight
row.classList.add('highlighted');
highlightedRow = row;
if (layer) {
layer.setStyle(highlightStyle);
highlightedLayer = layer;
// Pan to geometry
try {
map.fitBounds(layer.getBounds().pad(0.2));
} catch (e) {
// For points
const geom = JSON.parse(row.dataset.geometry);
if (geom && geom.type === 'Point' && geom.coordinates) {
map.setView([geom.coordinates[1], geom.coordinates[0]], 10);
}
}
}
}
// Row hover/click handlers
function attachRowHandlers() {
const rows = document.querySelectorAll('#events-rows tr[data-row-idx]');
rows.forEach(row => {
const idx = parseInt(row.dataset.rowIdx);
row.addEventListener('click', function() {
const layer = eventLayers[idx];
highlightRow(row, layer);
});
row.addEventListener('mouseenter', function() {
const layer = eventLayers[idx];
if (layer && layer !== highlightedLayer) {
layer.setStyle({ ...defaultStyle, weight: 3 });
}
});
row.addEventListener('mouseleave', function() {
const layer = eventLayers[idx];
if (layer && layer !== highlightedLayer) {
layer.setStyle(defaultStyle);
}
});
});
}
// Initial load
loadEventGeometries();
attachRowHandlers();
// Re-attach handlers after HTMX swap
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'events-rows') {
loadEventGeometries();
attachRowHandlers();
}
});
// Fix map rendering after container shows
setTimeout(function() { map.invalidateSize(); }, 100);
})();
</script>
{% endblock %}

View file

@ -0,0 +1,460 @@
"""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
class TestEventsFeedFrontendUnauthenticated:
"""Test events feed frontend without authentication."""
@pytest.mark.asyncio
async def test_events_unauthenticated_redirects(self):
"""GET /events without auth redirects to /login."""
# This test verifies the session middleware behavior
# In practice, the middleware redirects before the route is called
mock_request = MagicMock()
mock_request.state.operator = None
# The middleware would redirect, verified via integration tests
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
@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
@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