From 4469eefbca54f2fd1fe235236152a290d6991869 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Thu, 14 May 2026 19:50:19 +0200 Subject: [PATCH 1/9] feat(alerts/export): expand CSV with sequence and camera context (#591) Long format: one row per (alert, sequence). Alerts with no sequences still emit one row with empty sequence/camera cells. New columns: alert_started_at_date, alert_started_at_time, alert_duration_seconds, alert_triangulated_lat/lon, organization_id, sequence_id/started_at/last_seen_at/triangulated_azimuth/label, pose_id, camera_id, camera_name. is_wildfire is collapsed to wildfire / other / unknown via a dict mapper. Tests will be updated in the next commit. --- src/app/api/api_v1/endpoints/alerts.py | 117 ++++++++++++++++++++----- 1 file changed, 96 insertions(+), 21 deletions(-) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index fc156c8b..2a419b05 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -20,7 +20,7 @@ from app.core.time import utcnow from app.crud import AlertCRUD from app.db import get_session -from app.models import Alert, AlertSequence, Camera, Sequence, UserRole +from app.models import Alert, AlertSequence, AnnotationType, Camera, Sequence, UserRole from app.schemas.alerts import AlertReadWithSequences from app.schemas.login import TokenPayload from app.schemas.sequences import SequenceRead @@ -88,33 +88,102 @@ def _serialize_alert( ) -_ALERT_EXPORT_COLUMNS = ["id", "lat", "lon", "started_at", "last_seen_at"] - - -def _iter_alerts_csv(alerts: Iterable[Alert]) -> Iterator[str]: +_ALERT_EXPORT_COLUMNS = [ + "alert_id", + "alert_started_at_date", + "alert_started_at_time", + "alert_last_seen_at", + "alert_duration_seconds", + "alert_triangulated_lat", + "alert_triangulated_lon", + "organization_id", + "sequence_id", + "sequence_started_at", + "sequence_last_seen_at", + "sequence_triangulated_azimuth", + "sequence_label", + "pose_id", + "camera_id", + "camera_name", +] + +_WILDFIRE_LABELS: Dict[Union[AnnotationType, None], str] = { + AnnotationType.WILDFIRE_SMOKE: "wildfire", + AnnotationType.OTHER_SMOKE: "other", + AnnotationType.OTHER: "other", + None: "unknown", +} + + +async def _fetch_cameras_by_ids(session: AsyncSession, camera_ids: Iterable[int]) -> Dict[int, Camera]: + ids = list(set(camera_ids)) + if not ids: + return {} + stmt: Any = select(Camera).where(cast(Any, Camera.id).in_(ids)) + return {c.id: c for c in (await session.exec(stmt)).all()} + + +def _iter_alerts_csv( + alerts: Iterable[Alert], + seq_map: Dict[int, List[Sequence]], + cameras_by_id: Dict[int, Camera], +) -> Iterator[str]: buf = io.StringIO() writer = csv.writer(buf) writer.writerow(_ALERT_EXPORT_COLUMNS) yield buf.getvalue() buf.seek(0) buf.truncate(0) - for a in alerts: - writer.writerow([ - a.id, - "" if a.lat is None else a.lat, - "" if a.lon is None else a.lon, - a.started_at.isoformat(), - a.last_seen_at.isoformat(), - ]) - yield buf.getvalue() - buf.seek(0) - buf.truncate(0) - - -def _build_alerts_csv_response(alerts: List[Alert], from_date: date, to_date: date) -> StreamingResponse: + for alert in alerts: + alert_cells = [ + alert.id, + alert.started_at.date().isoformat(), + alert.started_at.time().isoformat(), + alert.last_seen_at.isoformat(), + int((alert.last_seen_at - alert.started_at).total_seconds()), + "" if alert.lat is None else alert.lat, + "" if alert.lon is None else alert.lon, + alert.organization_id, + ] + sequences = sorted(seq_map.get(alert.id, []), key=lambda s: s.started_at) + if not sequences: + writer.writerow([*alert_cells, "", "", "", "", "", "", "", ""]) + yield buf.getvalue() + buf.seek(0) + buf.truncate(0) + continue + for sequence in sequences: + camera = cameras_by_id.get(sequence.camera_id) + writer.writerow([ + *alert_cells, + sequence.id, + sequence.started_at.isoformat(), + sequence.last_seen_at.isoformat(), + "" if sequence.sequence_azimuth is None else sequence.sequence_azimuth, + _WILDFIRE_LABELS[sequence.is_wildfire], + "" if sequence.pose_id is None else sequence.pose_id, + sequence.camera_id, + "" if camera is None else camera.name, + ]) + yield buf.getvalue() + buf.seek(0) + buf.truncate(0) + + +def _build_alerts_csv_response( + alerts: List[Alert], + seq_map: Dict[int, List[Sequence]], + cameras_by_id: Dict[int, Camera], + from_date: date, + to_date: date, +) -> StreamingResponse: filename = f"alerts_{from_date.isoformat()}_{to_date.isoformat()}.csv" headers = {"Content-Disposition": f'attachment; filename="{filename}"'} - return StreamingResponse(_iter_alerts_csv(alerts), media_type="text/csv", headers=headers) + return StreamingResponse( + _iter_alerts_csv(alerts, seq_map, cameras_by_id), + media_type="text/csv", + headers=headers, + ) @router.get( @@ -152,7 +221,13 @@ async def export_alerts_csv( .where(Alert.started_at <= end_dt) .order_by(Alert.started_at.asc()) # type: ignore[attr-defined] ) - return _build_alerts_csv_response(list((await session.exec(stmt)).all()), from_date, to_date) + alerts = list((await session.exec(stmt)).all()) + seq_map = await _fetch_sequences_by_alert_ids(session, [alert.id for alert in alerts]) + cameras_by_id = await _fetch_cameras_by_ids( + session, + (sequence.camera_id for sequences in seq_map.values() for sequence in sequences), + ) + return _build_alerts_csv_response(alerts, seq_map, cameras_by_id, from_date, to_date) @router.get("/{alert_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific alert") From 0e1a6eafbd3b0bc1c58f92c17ce5f1684c536bb4 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Thu, 14 May 2026 19:56:55 +0200 Subject: [PATCH 2/9] refactor(alerts/export): fetch only camera names, not full rows Only camera_name is read in the CSV; selecting (Camera.id, Camera.name) and returning Dict[int, str] avoids loading columns we ignore. --- src/app/api/api_v1/endpoints/alerts.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index 2a419b05..de091db5 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -115,18 +115,18 @@ def _serialize_alert( } -async def _fetch_cameras_by_ids(session: AsyncSession, camera_ids: Iterable[int]) -> Dict[int, Camera]: +async def _fetch_camera_names_by_ids(session: AsyncSession, camera_ids: Iterable[int]) -> Dict[int, str]: ids = list(set(camera_ids)) if not ids: return {} - stmt: Any = select(Camera).where(cast(Any, Camera.id).in_(ids)) - return {c.id: c for c in (await session.exec(stmt)).all()} + stmt: Any = select(Camera.id, Camera.name).where(cast(Any, Camera.id).in_(ids)) + return {cid: name for cid, name in (await session.exec(stmt)).all()} def _iter_alerts_csv( alerts: Iterable[Alert], seq_map: Dict[int, List[Sequence]], - cameras_by_id: Dict[int, Camera], + camera_names_by_id: Dict[int, str], ) -> Iterator[str]: buf = io.StringIO() writer = csv.writer(buf) @@ -153,7 +153,6 @@ def _iter_alerts_csv( buf.truncate(0) continue for sequence in sequences: - camera = cameras_by_id.get(sequence.camera_id) writer.writerow([ *alert_cells, sequence.id, @@ -163,7 +162,7 @@ def _iter_alerts_csv( _WILDFIRE_LABELS[sequence.is_wildfire], "" if sequence.pose_id is None else sequence.pose_id, sequence.camera_id, - "" if camera is None else camera.name, + camera_names_by_id.get(sequence.camera_id, ""), ]) yield buf.getvalue() buf.seek(0) @@ -173,14 +172,14 @@ def _iter_alerts_csv( def _build_alerts_csv_response( alerts: List[Alert], seq_map: Dict[int, List[Sequence]], - cameras_by_id: Dict[int, Camera], + camera_names_by_id: Dict[int, str], from_date: date, to_date: date, ) -> StreamingResponse: filename = f"alerts_{from_date.isoformat()}_{to_date.isoformat()}.csv" headers = {"Content-Disposition": f'attachment; filename="{filename}"'} return StreamingResponse( - _iter_alerts_csv(alerts, seq_map, cameras_by_id), + _iter_alerts_csv(alerts, seq_map, camera_names_by_id), media_type="text/csv", headers=headers, ) @@ -223,11 +222,11 @@ async def export_alerts_csv( ) alerts = list((await session.exec(stmt)).all()) seq_map = await _fetch_sequences_by_alert_ids(session, [alert.id for alert in alerts]) - cameras_by_id = await _fetch_cameras_by_ids( + camera_names_by_id = await _fetch_camera_names_by_ids( session, (sequence.camera_id for sequences in seq_map.values() for sequence in sequences), ) - return _build_alerts_csv_response(alerts, seq_map, cameras_by_id, from_date, to_date) + return _build_alerts_csv_response(alerts, seq_map, camera_names_by_id, from_date, to_date) @router.get("/{alert_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific alert") From caba4944fe9351aec9bcabbe89bb274024c7b04d Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Thu, 14 May 2026 20:57:57 +0200 Subject: [PATCH 3/9] refactor(alerts/export): drop empty-row branch for sequence-less alerts Alerts are born from sequences and delete_alert wipes AlertSequence rows before the alert itself, so an alert with 0 sequences shouldn't exist outside a brief delete window. The defensive branch was dead code; if such an alert ever appears in production it's a data-integrity issue worth noticing rather than papering over with blank cells. --- src/app/api/api_v1/endpoints/alerts.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index de091db5..416c4cef 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -146,12 +146,6 @@ def _iter_alerts_csv( alert.organization_id, ] sequences = sorted(seq_map.get(alert.id, []), key=lambda s: s.started_at) - if not sequences: - writer.writerow([*alert_cells, "", "", "", "", "", "", "", ""]) - yield buf.getvalue() - buf.seek(0) - buf.truncate(0) - continue for sequence in sequences: writer.writerow([ *alert_cells, From 4034a371059b3bdfee34446b4f230f02aef2ed5e Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Fri, 15 May 2026 10:59:38 +0200 Subject: [PATCH 4/9] test(alerts/export): cover new long-format CSV schema (#591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the seven existing export tests to the 16-column header, swap the positional CSV parser for csv.DictReader so assertions look up by column name. Each existing test now attaches a sequence to its alerts via a new focused _attach_sequence helper (alerts always have ≥1 sequence in production). Three new tests cover the long-format semantics: - emits_one_row_per_sequence: 3 sequences -> 3 rows, ASC by started_at - wildfire_label_mapping: WILDFIRE_SMOKE/OTHER_SMOKE/None -> wildfire/other/unknown - camera_name_resolution: rows pick the correct camera_name --- src/tests/endpoints/test_alerts.py | 256 ++++++++++++++++++++++++----- 1 file changed, 219 insertions(+), 37 deletions(-) diff --git a/src/tests/endpoints/test_alerts.py b/src/tests/endpoints/test_alerts.py index feb8005c..d23a6435 100644 --- a/src/tests/endpoints/test_alerts.py +++ b/src/tests/endpoints/test_alerts.py @@ -6,7 +6,7 @@ import csv import io from datetime import datetime, timedelta -from typing import Any, List, Tuple, cast +from typing import Any, Dict, List, Tuple, cast import pandas as pd import pytest # type: ignore @@ -399,24 +399,70 @@ async def _create_alert( return alert -def _parse_csv_body(body: str) -> Tuple[List[str], List[List[str]]]: - reader = csv.reader(io.StringIO(body)) +async def _attach_sequence( + session: AsyncSession, + alert: Alert, + *, + camera_id: int = 1, + is_wildfire: AnnotationType | None = None, + sequence_azimuth: float | None = 100.0, + pose_id: int | None = None, + started_at: datetime | None = None, + last_seen_at: datetime | None = None, +) -> Sequence: + seq = Sequence( + camera_id=camera_id, + pose_id=pose_id, + camera_azimuth=100.0, + is_wildfire=is_wildfire, + sequence_azimuth=sequence_azimuth, + cone_angle=1.0, + started_at=started_at or alert.started_at, + last_seen_at=last_seen_at or alert.last_seen_at, + ) + session.add(seq) + await session.commit() + await session.refresh(seq) + session.add(AlertSequence(alert_id=alert.id, sequence_id=seq.id)) + await session.commit() + return seq + + +_EXPORT_COLUMNS = [ + "alert_id", + "alert_started_at_date", + "alert_started_at_time", + "alert_last_seen_at", + "alert_duration_seconds", + "alert_triangulated_lat", + "alert_triangulated_lon", + "organization_id", + "sequence_id", + "sequence_started_at", + "sequence_last_seen_at", + "sequence_triangulated_azimuth", + "sequence_label", + "pose_id", + "camera_id", + "camera_name", +] + + +def _parse_export_csv(body: str) -> Tuple[List[str], List[Dict[str, str]]]: + reader = csv.DictReader(io.StringIO(body)) rows = list(reader) - return rows[0], rows[1:] + return list(reader.fieldnames or []), rows @pytest.mark.asyncio async def test_alerts_export_happy_path(async_client: AsyncClient, detection_session: AsyncSession): base = datetime(2026, 4, 10, 12, 0, 0) - alerts = [ - await _create_alert(detection_session, 1, base, base + timedelta(minutes=5), 48.1, 2.1), - await _create_alert( - detection_session, 1, base + timedelta(days=1), base + timedelta(days=1, minutes=5), 48.2, 2.2 - ), - await _create_alert( - detection_session, 1, base + timedelta(days=2), base + timedelta(days=2, minutes=5), 48.3, 2.3 - ), - ] + alerts: List[Alert] = [] + for offset_days, (lat, lon) in enumerate([(48.1, 2.1), (48.2, 2.2), (48.3, 2.3)]): + started = base + timedelta(days=offset_days) + alert = await _create_alert(detection_session, 1, started, started + timedelta(minutes=5), lat, lon) + await _attach_sequence(detection_session, alert) + alerts.append(alert) auth = pytest.get_token( pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] @@ -430,25 +476,33 @@ async def test_alerts_export_happy_path(async_client: AsyncClient, detection_ses assert "attachment" in resp.headers["content-disposition"] assert "alerts_2026-04-10_2026-04-12.csv" in resp.headers["content-disposition"] - header, data_rows = _parse_csv_body(resp.text) - assert header == ["id", "lat", "lon", "started_at", "last_seen_at"] - assert [int(r[0]) for r in data_rows] == [a.id for a in alerts] - # ordering is ascending by started_at - started_values = [r[3] for r in data_rows] - assert started_values == sorted(started_values) - # spot-check values for the first row - assert float(data_rows[0][1]) == pytest.approx(48.1) - assert float(data_rows[0][2]) == pytest.approx(2.1) - assert data_rows[0][3] == alerts[0].started_at.isoformat() - assert data_rows[0][4] == alerts[0].last_seen_at.isoformat() + header, rows = _parse_export_csv(resp.text) + assert header == _EXPORT_COLUMNS + assert [int(r["alert_id"]) for r in rows] == [a.id for a in alerts] + # ordering is ascending by alert.started_at + started_iso = [f"{r['alert_started_at_date']}T{r['alert_started_at_time']}" for r in rows] + assert started_iso == sorted(started_iso) + first = rows[0] + assert float(first["alert_triangulated_lat"]) == pytest.approx(48.1) + assert float(first["alert_triangulated_lon"]) == pytest.approx(2.1) + assert first["alert_started_at_date"] == alerts[0].started_at.date().isoformat() + assert first["alert_started_at_time"] == alerts[0].started_at.time().isoformat() + assert first["alert_last_seen_at"] == alerts[0].last_seen_at.isoformat() + assert int(first["alert_duration_seconds"]) == int((alerts[0].last_seen_at - alerts[0].started_at).total_seconds()) + assert int(first["organization_id"]) == 1 + assert first["camera_name"] == "cam-1" + assert first["sequence_label"] == "unknown" @pytest.mark.asyncio async def test_alerts_export_window_narrows(async_client: AsyncClient, detection_session: AsyncSession): base = datetime(2026, 4, 10, 12, 0, 0) - await _create_alert(detection_session, 1, base, base + timedelta(minutes=5)) + a_before = await _create_alert(detection_session, 1, base, base + timedelta(minutes=5)) + await _attach_sequence(detection_session, a_before) a_in = await _create_alert(detection_session, 1, base + timedelta(days=1), base + timedelta(days=1, minutes=5)) - await _create_alert(detection_session, 1, base + timedelta(days=2), base + timedelta(days=2, minutes=5)) + await _attach_sequence(detection_session, a_in) + a_after = await _create_alert(detection_session, 1, base + timedelta(days=2), base + timedelta(days=2, minutes=5)) + await _attach_sequence(detection_session, a_after) auth = pytest.get_token( pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] @@ -458,8 +512,8 @@ async def test_alerts_export_window_narrows(async_client: AsyncClient, detection headers=auth, ) assert resp.status_code == 200, resp.text - _, data_rows = _parse_csv_body(resp.text) - returned_ids = {int(r[0]) for r in data_rows} + _, rows = _parse_export_csv(resp.text) + returned_ids = {int(r["alert_id"]) for r in rows} assert returned_ids == {a_in.id} @@ -467,7 +521,9 @@ async def test_alerts_export_window_narrows(async_client: AsyncClient, detection async def test_alerts_export_org_isolation(async_client: AsyncClient, detection_session: AsyncSession): base = datetime(2026, 4, 10, 12, 0, 0) org1_alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=5)) + await _attach_sequence(detection_session, org1_alert, camera_id=1) org2_alert = await _create_alert(detection_session, 2, base, base + timedelta(minutes=5)) + await _attach_sequence(detection_session, org2_alert, camera_id=2) # Call as a non-admin user from org 1 auth = pytest.get_token( @@ -478,8 +534,8 @@ async def test_alerts_export_org_isolation(async_client: AsyncClient, detection_ headers=auth, ) assert resp.status_code == 200, resp.text - _, data_rows = _parse_csv_body(resp.text) - returned_ids = {int(r[0]) for r in data_rows} + _, rows = _parse_export_csv(resp.text) + returned_ids = {int(r["alert_id"]) for r in rows} assert org1_alert.id in returned_ids assert org2_alert.id not in returned_ids @@ -494,9 +550,9 @@ async def test_alerts_export_empty_range(async_client: AsyncClient, detection_se headers=auth, ) assert resp.status_code == 200, resp.text - header, data_rows = _parse_csv_body(resp.text) - assert header == ["id", "lat", "lon", "started_at", "last_seen_at"] - assert data_rows == [] + header, rows = _parse_export_csv(resp.text) + assert header == _EXPORT_COLUMNS + assert rows == [] @pytest.mark.asyncio @@ -505,6 +561,7 @@ async def test_alerts_export_renders_null_coordinates_as_empty( ): base = datetime(2026, 4, 10, 12, 0, 0) alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=5), lat=None, lon=None) + await _attach_sequence(detection_session, alert) auth = pytest.get_token( pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] @@ -514,10 +571,10 @@ async def test_alerts_export_renders_null_coordinates_as_empty( headers=auth, ) assert resp.status_code == 200, resp.text - _, data_rows = _parse_csv_body(resp.text) - row = next(r for r in data_rows if int(r[0]) == alert.id) - assert row[1] == "" - assert row[2] == "" + _, rows = _parse_export_csv(resp.text) + row = next(r for r in rows if int(r["alert_id"]) == alert.id) + assert row["alert_triangulated_lat"] == "" + assert row["alert_triangulated_lon"] == "" @pytest.mark.asyncio @@ -536,3 +593,128 @@ async def test_alerts_export_invalid_range(async_client: AsyncClient, detection_ async def test_alerts_export_unauthenticated(async_client: AsyncClient, detection_session: AsyncSession): resp = await async_client.get("/alerts/export?from_date=2026-04-10&to_date=2026-04-12") assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_alerts_export_emits_one_row_per_sequence(async_client: AsyncClient, detection_session: AsyncSession): + base = datetime(2026, 4, 10, 12, 0, 0) + alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=30)) + # Attach in non-monotonic order to verify the export sorts ASC by sequence.started_at. + middle = await _attach_sequence( + detection_session, + alert, + started_at=base + timedelta(minutes=10), + last_seen_at=base + timedelta(minutes=20), + ) + last = await _attach_sequence( + detection_session, + alert, + started_at=base + timedelta(minutes=20), + last_seen_at=base + timedelta(minutes=30), + ) + first = await _attach_sequence( + detection_session, + alert, + started_at=base, + last_seen_at=base + timedelta(minutes=10), + ) + + auth = pytest.get_token( + pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] + ) + resp = await async_client.get( + "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", + headers=auth, + ) + assert resp.status_code == 200, resp.text + _, rows = _parse_export_csv(resp.text) + alert_rows = [r for r in rows if int(r["alert_id"]) == alert.id] + assert len(alert_rows) == 3 + assert [int(r["sequence_id"]) for r in alert_rows] == [first.id, middle.id, last.id] + # Alert-level cells repeat across rows + assert {r["alert_started_at_date"] for r in alert_rows} == {alert.started_at.date().isoformat()} + assert {r["alert_last_seen_at"] for r in alert_rows} == {alert.last_seen_at.isoformat()} + + +@pytest.mark.asyncio +async def test_alerts_export_wildfire_label_mapping(async_client: AsyncClient, detection_session: AsyncSession): + base = datetime(2026, 4, 10, 12, 0, 0) + alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=30)) + wf = await _attach_sequence( + detection_session, + alert, + is_wildfire=AnnotationType.WILDFIRE_SMOKE, + started_at=base, + last_seen_at=base + timedelta(minutes=10), + ) + other = await _attach_sequence( + detection_session, + alert, + is_wildfire=AnnotationType.OTHER_SMOKE, + started_at=base + timedelta(minutes=10), + last_seen_at=base + timedelta(minutes=20), + ) + unk = await _attach_sequence( + detection_session, + alert, + is_wildfire=None, + started_at=base + timedelta(minutes=20), + last_seen_at=base + timedelta(minutes=30), + ) + + auth = pytest.get_token( + pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] + ) + resp = await async_client.get( + "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", + headers=auth, + ) + assert resp.status_code == 200, resp.text + _, rows = _parse_export_csv(resp.text) + label_by_seq = {int(r["sequence_id"]): r["sequence_label"] for r in rows if int(r["alert_id"]) == alert.id} + assert label_by_seq == {wf.id: "wildfire", other.id: "other", unk.id: "unknown"} + + +@pytest.mark.asyncio +async def test_alerts_export_camera_name_resolution(async_client: AsyncClient, detection_session: AsyncSession): + extra_camera = Camera( + organization_id=1, + name="cam-extra", + angle_of_view=91.3, + elevation=110.0, + lat=3.7, + lon=-45.3, + is_trustable=True, + ) + detection_session.add(extra_camera) + await detection_session.commit() + await detection_session.refresh(extra_camera) + + base = datetime(2026, 4, 10, 12, 0, 0) + alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=20)) + seq_cam1 = await _attach_sequence( + detection_session, + alert, + camera_id=1, + started_at=base, + last_seen_at=base + timedelta(minutes=10), + ) + seq_extra = await _attach_sequence( + detection_session, + alert, + camera_id=extra_camera.id, + started_at=base + timedelta(minutes=10), + last_seen_at=base + timedelta(minutes=20), + ) + + auth = pytest.get_token( + pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] + ) + resp = await async_client.get( + "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", + headers=auth, + ) + assert resp.status_code == 200, resp.text + _, rows = _parse_export_csv(resp.text) + cam_by_seq = {int(r["sequence_id"]): r["camera_name"] for r in rows if int(r["alert_id"]) == alert.id} + assert cam_by_seq == {seq_cam1.id: "cam-1", seq_extra.id: "cam-extra"} From d2483e02abfb87357fb2b46ee597fe0e7dd4a515 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Fri, 15 May 2026 11:53:49 +0200 Subject: [PATCH 5/9] refactor(alerts/export tests): dedupe column list and collapse asserts Import _ALERT_EXPORT_COLUMNS from the endpoint module instead of maintaining a copy. In happy_path, replace nine per-cell asserts with a single dict equality on the first row (pytest renders the diff cleanly, and the equality implicitly covers the full column set). Keep the explicit header assertion only in empty_range, where it's the test's raison d'etre. --- src/tests/endpoints/test_alerts.py | 54 ++++++++++++------------------ 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/src/tests/endpoints/test_alerts.py b/src/tests/endpoints/test_alerts.py index d23a6435..aee346b9 100644 --- a/src/tests/endpoints/test_alerts.py +++ b/src/tests/endpoints/test_alerts.py @@ -14,6 +14,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from app.api.api_v1.endpoints.alerts import _ALERT_EXPORT_COLUMNS from app.core.config import settings from app.core.time import utcnow from app.models import Alert, AlertSequence, AnnotationType, Camera, Detection, Organization, Pose, Sequence @@ -428,26 +429,6 @@ async def _attach_sequence( return seq -_EXPORT_COLUMNS = [ - "alert_id", - "alert_started_at_date", - "alert_started_at_time", - "alert_last_seen_at", - "alert_duration_seconds", - "alert_triangulated_lat", - "alert_triangulated_lon", - "organization_id", - "sequence_id", - "sequence_started_at", - "sequence_last_seen_at", - "sequence_triangulated_azimuth", - "sequence_label", - "pose_id", - "camera_id", - "camera_name", -] - - def _parse_export_csv(body: str) -> Tuple[List[str], List[Dict[str, str]]]: reader = csv.DictReader(io.StringIO(body)) rows = list(reader) @@ -476,22 +457,31 @@ async def test_alerts_export_happy_path(async_client: AsyncClient, detection_ses assert "attachment" in resp.headers["content-disposition"] assert "alerts_2026-04-10_2026-04-12.csv" in resp.headers["content-disposition"] - header, rows = _parse_export_csv(resp.text) - assert header == _EXPORT_COLUMNS + _, rows = _parse_export_csv(resp.text) assert [int(r["alert_id"]) for r in rows] == [a.id for a in alerts] # ordering is ascending by alert.started_at started_iso = [f"{r['alert_started_at_date']}T{r['alert_started_at_time']}" for r in rows] assert started_iso == sorted(started_iso) + # One dict equality covers column set, names, and values in a single pytest diff. first = rows[0] - assert float(first["alert_triangulated_lat"]) == pytest.approx(48.1) - assert float(first["alert_triangulated_lon"]) == pytest.approx(2.1) - assert first["alert_started_at_date"] == alerts[0].started_at.date().isoformat() - assert first["alert_started_at_time"] == alerts[0].started_at.time().isoformat() - assert first["alert_last_seen_at"] == alerts[0].last_seen_at.isoformat() - assert int(first["alert_duration_seconds"]) == int((alerts[0].last_seen_at - alerts[0].started_at).total_seconds()) - assert int(first["organization_id"]) == 1 - assert first["camera_name"] == "cam-1" - assert first["sequence_label"] == "unknown" + assert first == { + "alert_id": str(alerts[0].id), + "alert_started_at_date": alerts[0].started_at.date().isoformat(), + "alert_started_at_time": alerts[0].started_at.time().isoformat(), + "alert_last_seen_at": alerts[0].last_seen_at.isoformat(), + "alert_duration_seconds": str(int((alerts[0].last_seen_at - alerts[0].started_at).total_seconds())), + "alert_triangulated_lat": "48.1", + "alert_triangulated_lon": "2.1", + "organization_id": "1", + "sequence_id": str(first["sequence_id"]), # id auto-generated, just round-trip + "sequence_started_at": alerts[0].started_at.isoformat(), + "sequence_last_seen_at": alerts[0].last_seen_at.isoformat(), + "sequence_triangulated_azimuth": "100.0", + "sequence_label": "unknown", + "pose_id": "", + "camera_id": "1", + "camera_name": "cam-1", + } @pytest.mark.asyncio @@ -551,7 +541,7 @@ async def test_alerts_export_empty_range(async_client: AsyncClient, detection_se ) assert resp.status_code == 200, resp.text header, rows = _parse_export_csv(resp.text) - assert header == _EXPORT_COLUMNS + assert header == _ALERT_EXPORT_COLUMNS assert rows == [] From 8d6ddc23589d553412166ddc210ef7bb2b10c410 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Fri, 15 May 2026 11:56:11 +0200 Subject: [PATCH 6/9] chore: gitignore tmp/ for local working notes Scratch drafts and other ephemeral notes live under tmp/ during local work and shouldn't ship in PR diffs. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 96ae21f0..df327d60 100644 --- a/.gitignore +++ b/.gitignore @@ -153,3 +153,6 @@ requirements.txt src/app/requirements.txt src/requirements.txt src/requirements-dev.txt + +# Local working notes (drafts of external messages, scratch files) +tmp/ From 3bf7fbffbd515d460548527fd1d3f6fa7c593552 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Fri, 15 May 2026 12:02:20 +0200 Subject: [PATCH 7/9] refactor(alerts/export tests): use fixtures, helper, and parametrize Replace the per-test boilerplate (datetime anchor, JWT token build, the HTTP request + status + parse triplet) with three fixtures and one helper: - export_base_dt: anchor datetime shared by 7 tests - org1_admin_auth / org1_agent_auth: token headers shared by 8 tests - _get_export: wraps the GET + 200 assert + CSV parse used by 6 tests Parametrize test_alerts_export_wildfire_label_mapping over all four (is_wildfire, expected_label) pairs. This also closes a coverage hole: the previous version exercised WILDFIRE_SMOKE / OTHER_SMOKE / None but not the AnnotationType.OTHER -> "other" mapping. --- src/tests/endpoints/test_alerts.py | 262 +++++++++++++---------------- 1 file changed, 120 insertions(+), 142 deletions(-) diff --git a/src/tests/endpoints/test_alerts.py b/src/tests/endpoints/test_alerts.py index aee346b9..a741d780 100644 --- a/src/tests/endpoints/test_alerts.py +++ b/src/tests/endpoints/test_alerts.py @@ -435,23 +435,47 @@ def _parse_export_csv(body: str) -> Tuple[List[str], List[Dict[str, str]]]: return list(reader.fieldnames or []), rows +async def _get_export( + async_client: AsyncClient, auth: Dict[str, str], from_date: str, to_date: str +) -> Tuple[List[str], List[Dict[str, str]]]: + resp = await async_client.get(f"/alerts/export?from_date={from_date}&to_date={to_date}", headers=auth) + assert resp.status_code == 200, resp.text + return _parse_export_csv(resp.text) + + +@pytest.fixture +def export_base_dt() -> datetime: + """Anchor datetime for export tests; date is stable so query windows in test bodies stay readable.""" + return datetime(2026, 4, 10, 12, 0, 0) + + +@pytest.fixture +def org1_admin_auth() -> Dict[str, str]: + user = pytest.user_table[0] + return pytest.get_token(user["id"], user["role"].split(), user["organization_id"]) + + +@pytest.fixture +def org1_agent_auth() -> Dict[str, str]: + user = pytest.user_table[1] + return pytest.get_token(user["id"], user["role"].split(), user["organization_id"]) + + @pytest.mark.asyncio -async def test_alerts_export_happy_path(async_client: AsyncClient, detection_session: AsyncSession): - base = datetime(2026, 4, 10, 12, 0, 0) +async def test_alerts_export_happy_path( + async_client: AsyncClient, + detection_session: AsyncSession, + export_base_dt: datetime, + org1_admin_auth: Dict[str, str], +): alerts: List[Alert] = [] for offset_days, (lat, lon) in enumerate([(48.1, 2.1), (48.2, 2.2), (48.3, 2.3)]): - started = base + timedelta(days=offset_days) + started = export_base_dt + timedelta(days=offset_days) alert = await _create_alert(detection_session, 1, started, started + timedelta(minutes=5), lat, lon) await _attach_sequence(detection_session, alert) alerts.append(alert) - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-10&to_date=2026-04-12", - headers=auth, - ) + resp = await async_client.get("/alerts/export?from_date=2026-04-10&to_date=2026-04-12", headers=org1_admin_auth) assert resp.status_code == 200, resp.text assert resp.headers["content-type"].startswith("text/csv") assert "attachment" in resp.headers["content-disposition"] @@ -485,97 +509,72 @@ async def test_alerts_export_happy_path(async_client: AsyncClient, detection_ses @pytest.mark.asyncio -async def test_alerts_export_window_narrows(async_client: AsyncClient, detection_session: AsyncSession): - base = datetime(2026, 4, 10, 12, 0, 0) - a_before = await _create_alert(detection_session, 1, base, base + timedelta(minutes=5)) - await _attach_sequence(detection_session, a_before) - a_in = await _create_alert(detection_session, 1, base + timedelta(days=1), base + timedelta(days=1, minutes=5)) - await _attach_sequence(detection_session, a_in) - a_after = await _create_alert(detection_session, 1, base + timedelta(days=2), base + timedelta(days=2, minutes=5)) - await _attach_sequence(detection_session, a_after) +async def test_alerts_export_window_narrows( + async_client: AsyncClient, + detection_session: AsyncSession, + export_base_dt: datetime, + org1_admin_auth: Dict[str, str], +): + for offset_days in range(3): + started = export_base_dt + timedelta(days=offset_days) + alert = await _create_alert(detection_session, 1, started, started + timedelta(minutes=5)) + await _attach_sequence(detection_session, alert) - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-11&to_date=2026-04-11", - headers=auth, - ) - assert resp.status_code == 200, resp.text - _, rows = _parse_export_csv(resp.text) - returned_ids = {int(r["alert_id"]) for r in rows} - assert returned_ids == {a_in.id} + _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-11", "2026-04-11") + returned_dates = {r["alert_started_at_date"] for r in rows} + assert returned_dates == {"2026-04-11"} @pytest.mark.asyncio -async def test_alerts_export_org_isolation(async_client: AsyncClient, detection_session: AsyncSession): - base = datetime(2026, 4, 10, 12, 0, 0) - org1_alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=5)) +async def test_alerts_export_org_isolation( + async_client: AsyncClient, + detection_session: AsyncSession, + export_base_dt: datetime, + org1_agent_auth: Dict[str, str], +): + org1_alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=5)) await _attach_sequence(detection_session, org1_alert, camera_id=1) - org2_alert = await _create_alert(detection_session, 2, base, base + timedelta(minutes=5)) + org2_alert = await _create_alert(detection_session, 2, export_base_dt, export_base_dt + timedelta(minutes=5)) await _attach_sequence(detection_session, org2_alert, camera_id=2) - # Call as a non-admin user from org 1 - auth = pytest.get_token( - pytest.user_table[1]["id"], pytest.user_table[1]["role"].split(), pytest.user_table[1]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", - headers=auth, - ) - assert resp.status_code == 200, resp.text - _, rows = _parse_export_csv(resp.text) + _, rows = await _get_export(async_client, org1_agent_auth, "2026-04-10", "2026-04-10") returned_ids = {int(r["alert_id"]) for r in rows} assert org1_alert.id in returned_ids assert org2_alert.id not in returned_ids @pytest.mark.asyncio -async def test_alerts_export_empty_range(async_client: AsyncClient, detection_session: AsyncSession): - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2099-01-01&to_date=2099-01-31", - headers=auth, - ) - assert resp.status_code == 200, resp.text - header, rows = _parse_export_csv(resp.text) +async def test_alerts_export_empty_range( + async_client: AsyncClient, detection_session: AsyncSession, org1_admin_auth: Dict[str, str] +): + header, rows = await _get_export(async_client, org1_admin_auth, "2099-01-01", "2099-01-31") assert header == _ALERT_EXPORT_COLUMNS assert rows == [] @pytest.mark.asyncio async def test_alerts_export_renders_null_coordinates_as_empty( - async_client: AsyncClient, detection_session: AsyncSession + async_client: AsyncClient, + detection_session: AsyncSession, + export_base_dt: datetime, + org1_admin_auth: Dict[str, str], ): - base = datetime(2026, 4, 10, 12, 0, 0) - alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=5), lat=None, lon=None) + alert = await _create_alert( + detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=5), lat=None, lon=None + ) await _attach_sequence(detection_session, alert) - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", - headers=auth, - ) - assert resp.status_code == 200, resp.text - _, rows = _parse_export_csv(resp.text) + _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") row = next(r for r in rows if int(r["alert_id"]) == alert.id) assert row["alert_triangulated_lat"] == "" assert row["alert_triangulated_lon"] == "" @pytest.mark.asyncio -async def test_alerts_export_invalid_range(async_client: AsyncClient, detection_session: AsyncSession): - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-12&to_date=2026-04-10", - headers=auth, - ) +async def test_alerts_export_invalid_range( + async_client: AsyncClient, detection_session: AsyncSession, org1_admin_auth: Dict[str, str] +): + resp = await async_client.get("/alerts/export?from_date=2026-04-12&to_date=2026-04-10", headers=org1_admin_auth) assert resp.status_code == 422, resp.text @@ -586,38 +585,34 @@ async def test_alerts_export_unauthenticated(async_client: AsyncClient, detectio @pytest.mark.asyncio -async def test_alerts_export_emits_one_row_per_sequence(async_client: AsyncClient, detection_session: AsyncSession): - base = datetime(2026, 4, 10, 12, 0, 0) - alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=30)) +async def test_alerts_export_emits_one_row_per_sequence( + async_client: AsyncClient, + detection_session: AsyncSession, + export_base_dt: datetime, + org1_admin_auth: Dict[str, str], +): + alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=30)) # Attach in non-monotonic order to verify the export sorts ASC by sequence.started_at. middle = await _attach_sequence( detection_session, alert, - started_at=base + timedelta(minutes=10), - last_seen_at=base + timedelta(minutes=20), + started_at=export_base_dt + timedelta(minutes=10), + last_seen_at=export_base_dt + timedelta(minutes=20), ) last = await _attach_sequence( detection_session, alert, - started_at=base + timedelta(minutes=20), - last_seen_at=base + timedelta(minutes=30), + started_at=export_base_dt + timedelta(minutes=20), + last_seen_at=export_base_dt + timedelta(minutes=30), ) first = await _attach_sequence( detection_session, alert, - started_at=base, - last_seen_at=base + timedelta(minutes=10), + started_at=export_base_dt, + last_seen_at=export_base_dt + timedelta(minutes=10), ) - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", - headers=auth, - ) - assert resp.status_code == 200, resp.text - _, rows = _parse_export_csv(resp.text) + _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") alert_rows = [r for r in rows if int(r["alert_id"]) == alert.id] assert len(alert_rows) == 3 assert [int(r["sequence_id"]) for r in alert_rows] == [first.id, middle.id, last.id] @@ -626,47 +621,39 @@ async def test_alerts_export_emits_one_row_per_sequence(async_client: AsyncClien assert {r["alert_last_seen_at"] for r in alert_rows} == {alert.last_seen_at.isoformat()} +@pytest.mark.parametrize( + ("is_wildfire", "expected_label"), + [ + (AnnotationType.WILDFIRE_SMOKE, "wildfire"), + (AnnotationType.OTHER_SMOKE, "other"), + (AnnotationType.OTHER, "other"), + (None, "unknown"), + ], +) @pytest.mark.asyncio -async def test_alerts_export_wildfire_label_mapping(async_client: AsyncClient, detection_session: AsyncSession): - base = datetime(2026, 4, 10, 12, 0, 0) - alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=30)) - wf = await _attach_sequence( - detection_session, - alert, - is_wildfire=AnnotationType.WILDFIRE_SMOKE, - started_at=base, - last_seen_at=base + timedelta(minutes=10), - ) - other = await _attach_sequence( - detection_session, - alert, - is_wildfire=AnnotationType.OTHER_SMOKE, - started_at=base + timedelta(minutes=10), - last_seen_at=base + timedelta(minutes=20), - ) - unk = await _attach_sequence( - detection_session, - alert, - is_wildfire=None, - started_at=base + timedelta(minutes=20), - last_seen_at=base + timedelta(minutes=30), - ) +async def test_alerts_export_wildfire_label_mapping( + async_client: AsyncClient, + detection_session: AsyncSession, + export_base_dt: datetime, + org1_admin_auth: Dict[str, str], + is_wildfire: AnnotationType | None, + expected_label: str, +): + alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=5)) + await _attach_sequence(detection_session, alert, is_wildfire=is_wildfire) - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", - headers=auth, - ) - assert resp.status_code == 200, resp.text - _, rows = _parse_export_csv(resp.text) - label_by_seq = {int(r["sequence_id"]): r["sequence_label"] for r in rows if int(r["alert_id"]) == alert.id} - assert label_by_seq == {wf.id: "wildfire", other.id: "other", unk.id: "unknown"} + _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") + row = next(r for r in rows if int(r["alert_id"]) == alert.id) + assert row["sequence_label"] == expected_label @pytest.mark.asyncio -async def test_alerts_export_camera_name_resolution(async_client: AsyncClient, detection_session: AsyncSession): +async def test_alerts_export_camera_name_resolution( + async_client: AsyncClient, + detection_session: AsyncSession, + export_base_dt: datetime, + org1_admin_auth: Dict[str, str], +): extra_camera = Camera( organization_id=1, name="cam-extra", @@ -680,31 +667,22 @@ async def test_alerts_export_camera_name_resolution(async_client: AsyncClient, d await detection_session.commit() await detection_session.refresh(extra_camera) - base = datetime(2026, 4, 10, 12, 0, 0) - alert = await _create_alert(detection_session, 1, base, base + timedelta(minutes=20)) + alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=20)) seq_cam1 = await _attach_sequence( detection_session, alert, camera_id=1, - started_at=base, - last_seen_at=base + timedelta(minutes=10), + started_at=export_base_dt, + last_seen_at=export_base_dt + timedelta(minutes=10), ) seq_extra = await _attach_sequence( detection_session, alert, camera_id=extra_camera.id, - started_at=base + timedelta(minutes=10), - last_seen_at=base + timedelta(minutes=20), + started_at=export_base_dt + timedelta(minutes=10), + last_seen_at=export_base_dt + timedelta(minutes=20), ) - auth = pytest.get_token( - pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] - ) - resp = await async_client.get( - "/alerts/export?from_date=2026-04-10&to_date=2026-04-10", - headers=auth, - ) - assert resp.status_code == 200, resp.text - _, rows = _parse_export_csv(resp.text) + _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") cam_by_seq = {int(r["sequence_id"]): r["camera_name"] for r in rows if int(r["alert_id"]) == alert.id} assert cam_by_seq == {seq_cam1.id: "cam-1", seq_extra.id: "cam-extra"} From cb7f1fe57ec4c148d9b6a59f812e3ee34da1e495 Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Fri, 15 May 2026 12:10:21 +0200 Subject: [PATCH 8/9] refactor(alerts/export tests): split serializer unit tests from route tests Five of the export tests were exercising _iter_alerts_csv behavior (header, null coords, row-per-sequence ordering, wildfire label, camera-name lookup) through the full HTTP route + JWT + DB stack. Move them to plain unit tests that call _iter_alerts_csv directly: - no async, no detection_session, no async_client, no fixtures - Alert / Sequence instances built with small in-memory helpers - failures point at the serializer, not the route Keep five integration tests that genuinely exercise route wiring (happy path with content headers, SQL date filter, JWT org scoping, 422 validation, 401 auth required). --- src/tests/endpoints/test_alerts.py | 261 ++++++++++++++--------------- 1 file changed, 128 insertions(+), 133 deletions(-) diff --git a/src/tests/endpoints/test_alerts.py b/src/tests/endpoints/test_alerts.py index a741d780..ad1e55cc 100644 --- a/src/tests/endpoints/test_alerts.py +++ b/src/tests/endpoints/test_alerts.py @@ -14,7 +14,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.api_v1.endpoints.alerts import _ALERT_EXPORT_COLUMNS +from app.api.api_v1.endpoints.alerts import _ALERT_EXPORT_COLUMNS, _iter_alerts_csv from app.core.config import settings from app.core.time import utcnow from app.models import Alert, AlertSequence, AnnotationType, Camera, Detection, Organization, Pose, Sequence @@ -435,6 +435,132 @@ def _parse_export_csv(body: str) -> Tuple[List[str], List[Dict[str, str]]]: return list(reader.fieldnames or []), rows +# ───────────────────────────────────────────────────────────────────────────── +# Unit tests for _iter_alerts_csv: pure serializer behavior, no DB / HTTP / auth +# ───────────────────────────────────────────────────────────────────────────── + + +_UNIT_BASE_DT = datetime(2026, 4, 10, 12, 0, 0) + + +def _make_alert( + *, + id_: int = 1, + organization_id: int = 1, + lat: float | None = 48.0, + lon: float | None = 2.0, + started_at: datetime | None = None, + last_seen_at: datetime | None = None, +) -> Alert: + return Alert( + id=id_, + organization_id=organization_id, + lat=lat, + lon=lon, + started_at=started_at or _UNIT_BASE_DT, + last_seen_at=last_seen_at or _UNIT_BASE_DT + timedelta(minutes=5), + ) + + +def _make_sequence( + *, + id_: int = 1, + camera_id: int = 1, + pose_id: int | None = None, + is_wildfire: AnnotationType | None = None, + sequence_azimuth: float | None = 100.0, + started_at: datetime | None = None, + last_seen_at: datetime | None = None, +) -> Sequence: + return Sequence( + id=id_, + camera_id=camera_id, + pose_id=pose_id, + camera_azimuth=100.0, + is_wildfire=is_wildfire, + sequence_azimuth=sequence_azimuth, + cone_angle=1.0, + started_at=started_at or _UNIT_BASE_DT, + last_seen_at=last_seen_at or _UNIT_BASE_DT + timedelta(minutes=5), + ) + + +def _run_iter( + alerts: List[Alert], + seq_map: Dict[int, List[Sequence]], + camera_names_by_id: Dict[int, str], +) -> Tuple[List[str], List[Dict[str, str]]]: + body = "".join(_iter_alerts_csv(alerts, seq_map, camera_names_by_id)) + return _parse_export_csv(body) + + +def test_iter_alerts_csv_emits_only_header_when_no_alerts(): + header, rows = _run_iter([], {}, {}) + assert header == _ALERT_EXPORT_COLUMNS + assert rows == [] + + +def test_iter_alerts_csv_renders_null_coordinates_as_empty(): + alert = _make_alert(lat=None, lon=None) + sequence = _make_sequence() + _, rows = _run_iter([alert], {alert.id: [sequence]}, {sequence.camera_id: "cam-1"}) + assert rows[0]["alert_triangulated_lat"] == "" + assert rows[0]["alert_triangulated_lon"] == "" + + +def test_iter_alerts_csv_emits_one_row_per_sequence_sorted_by_started_at(): + alert = _make_alert(id_=10, started_at=_UNIT_BASE_DT, last_seen_at=_UNIT_BASE_DT + timedelta(minutes=30)) + # Provided in non-monotonic order to verify the serializer sorts ASC by sequence.started_at. + sequences = [ + _make_sequence( + id_=20, + started_at=_UNIT_BASE_DT + timedelta(minutes=10), + last_seen_at=_UNIT_BASE_DT + timedelta(minutes=20), + ), + _make_sequence( + id_=30, + started_at=_UNIT_BASE_DT + timedelta(minutes=20), + last_seen_at=_UNIT_BASE_DT + timedelta(minutes=30), + ), + _make_sequence(id_=10, started_at=_UNIT_BASE_DT, last_seen_at=_UNIT_BASE_DT + timedelta(minutes=10)), + ] + _, rows = _run_iter([alert], {alert.id: sequences}, {1: "cam-1"}) + assert [int(r["sequence_id"]) for r in rows] == [10, 20, 30] + # Alert-level cells repeat across rows + assert {r["alert_started_at_date"] for r in rows} == {alert.started_at.date().isoformat()} + assert {r["alert_last_seen_at"] for r in rows} == {alert.last_seen_at.isoformat()} + + +@pytest.mark.parametrize( + ("is_wildfire", "expected_label"), + [ + (AnnotationType.WILDFIRE_SMOKE, "wildfire"), + (AnnotationType.OTHER_SMOKE, "other"), + (AnnotationType.OTHER, "other"), + (None, "unknown"), + ], +) +def test_iter_alerts_csv_wildfire_label_mapping(is_wildfire: AnnotationType | None, expected_label: str): + alert = _make_alert() + sequence = _make_sequence(is_wildfire=is_wildfire) + _, rows = _run_iter([alert], {alert.id: [sequence]}, {sequence.camera_id: "cam-1"}) + assert rows[0]["sequence_label"] == expected_label + + +def test_iter_alerts_csv_resolves_camera_name_per_sequence(): + alert = _make_alert() + seq_a = _make_sequence(id_=1, camera_id=1, started_at=_UNIT_BASE_DT) + seq_b = _make_sequence(id_=2, camera_id=99, started_at=_UNIT_BASE_DT + timedelta(minutes=10)) + _, rows = _run_iter([alert], {alert.id: [seq_a, seq_b]}, {1: "cam-a", 99: "cam-b"}) + cam_by_seq = {int(r["sequence_id"]): r["camera_name"] for r in rows} + assert cam_by_seq == {1: "cam-a", 2: "cam-b"} + + +# ───────────────────────────────────────────────────────────────────────────── +# Integration tests for GET /alerts/export: route wiring, SQL filter, JWT scope +# ───────────────────────────────────────────────────────────────────────────── + + async def _get_export( async_client: AsyncClient, auth: Dict[str, str], from_date: str, to_date: str ) -> Tuple[List[str], List[Dict[str, str]]]: @@ -445,7 +571,7 @@ async def _get_export( @pytest.fixture def export_base_dt() -> datetime: - """Anchor datetime for export tests; date is stable so query windows in test bodies stay readable.""" + """Anchor datetime for export integration tests; date is stable so query windows stay readable.""" return datetime(2026, 4, 10, 12, 0, 0) @@ -543,33 +669,6 @@ async def test_alerts_export_org_isolation( assert org2_alert.id not in returned_ids -@pytest.mark.asyncio -async def test_alerts_export_empty_range( - async_client: AsyncClient, detection_session: AsyncSession, org1_admin_auth: Dict[str, str] -): - header, rows = await _get_export(async_client, org1_admin_auth, "2099-01-01", "2099-01-31") - assert header == _ALERT_EXPORT_COLUMNS - assert rows == [] - - -@pytest.mark.asyncio -async def test_alerts_export_renders_null_coordinates_as_empty( - async_client: AsyncClient, - detection_session: AsyncSession, - export_base_dt: datetime, - org1_admin_auth: Dict[str, str], -): - alert = await _create_alert( - detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=5), lat=None, lon=None - ) - await _attach_sequence(detection_session, alert) - - _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") - row = next(r for r in rows if int(r["alert_id"]) == alert.id) - assert row["alert_triangulated_lat"] == "" - assert row["alert_triangulated_lon"] == "" - - @pytest.mark.asyncio async def test_alerts_export_invalid_range( async_client: AsyncClient, detection_session: AsyncSession, org1_admin_auth: Dict[str, str] @@ -582,107 +681,3 @@ async def test_alerts_export_invalid_range( async def test_alerts_export_unauthenticated(async_client: AsyncClient, detection_session: AsyncSession): resp = await async_client.get("/alerts/export?from_date=2026-04-10&to_date=2026-04-12") assert resp.status_code == 401, resp.text - - -@pytest.mark.asyncio -async def test_alerts_export_emits_one_row_per_sequence( - async_client: AsyncClient, - detection_session: AsyncSession, - export_base_dt: datetime, - org1_admin_auth: Dict[str, str], -): - alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=30)) - # Attach in non-monotonic order to verify the export sorts ASC by sequence.started_at. - middle = await _attach_sequence( - detection_session, - alert, - started_at=export_base_dt + timedelta(minutes=10), - last_seen_at=export_base_dt + timedelta(minutes=20), - ) - last = await _attach_sequence( - detection_session, - alert, - started_at=export_base_dt + timedelta(minutes=20), - last_seen_at=export_base_dt + timedelta(minutes=30), - ) - first = await _attach_sequence( - detection_session, - alert, - started_at=export_base_dt, - last_seen_at=export_base_dt + timedelta(minutes=10), - ) - - _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") - alert_rows = [r for r in rows if int(r["alert_id"]) == alert.id] - assert len(alert_rows) == 3 - assert [int(r["sequence_id"]) for r in alert_rows] == [first.id, middle.id, last.id] - # Alert-level cells repeat across rows - assert {r["alert_started_at_date"] for r in alert_rows} == {alert.started_at.date().isoformat()} - assert {r["alert_last_seen_at"] for r in alert_rows} == {alert.last_seen_at.isoformat()} - - -@pytest.mark.parametrize( - ("is_wildfire", "expected_label"), - [ - (AnnotationType.WILDFIRE_SMOKE, "wildfire"), - (AnnotationType.OTHER_SMOKE, "other"), - (AnnotationType.OTHER, "other"), - (None, "unknown"), - ], -) -@pytest.mark.asyncio -async def test_alerts_export_wildfire_label_mapping( - async_client: AsyncClient, - detection_session: AsyncSession, - export_base_dt: datetime, - org1_admin_auth: Dict[str, str], - is_wildfire: AnnotationType | None, - expected_label: str, -): - alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=5)) - await _attach_sequence(detection_session, alert, is_wildfire=is_wildfire) - - _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") - row = next(r for r in rows if int(r["alert_id"]) == alert.id) - assert row["sequence_label"] == expected_label - - -@pytest.mark.asyncio -async def test_alerts_export_camera_name_resolution( - async_client: AsyncClient, - detection_session: AsyncSession, - export_base_dt: datetime, - org1_admin_auth: Dict[str, str], -): - extra_camera = Camera( - organization_id=1, - name="cam-extra", - angle_of_view=91.3, - elevation=110.0, - lat=3.7, - lon=-45.3, - is_trustable=True, - ) - detection_session.add(extra_camera) - await detection_session.commit() - await detection_session.refresh(extra_camera) - - alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=20)) - seq_cam1 = await _attach_sequence( - detection_session, - alert, - camera_id=1, - started_at=export_base_dt, - last_seen_at=export_base_dt + timedelta(minutes=10), - ) - seq_extra = await _attach_sequence( - detection_session, - alert, - camera_id=extra_camera.id, - started_at=export_base_dt + timedelta(minutes=10), - last_seen_at=export_base_dt + timedelta(minutes=20), - ) - - _, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10") - cam_by_seq = {int(r["sequence_id"]): r["camera_name"] for r in rows if int(r["alert_id"]) == alert.id} - assert cam_by_seq == {seq_cam1.id: "cam-1", seq_extra.id: "cam-extra"} From 0d9462126e85c49ae420dd7fbc99e20003e8742f Mon Sep 17 00:00:00 2001 From: Alexis Cruveiller Date: Fri, 15 May 2026 12:26:53 +0200 Subject: [PATCH 9/9] refactor(alerts/export): split _iter_alerts_csv into pure cell builders The iterator was building alert-level cells, sequence-level cells (with wildfire label and camera-name lookups), and running the CSV streaming buffer in one body. Extract _alert_cells and _sequence_cells as pure helpers next to the data they shape, factor the buffer drain into a local closure, and leave _iter_alerts_csv as the orchestrator it should be: header, iterate, drain. --- src/app/api/api_v1/endpoints/alerts.py | 66 +++++++++++++++----------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index 416c4cef..3e6e17ee 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -123,6 +123,32 @@ async def _fetch_camera_names_by_ids(session: AsyncSession, camera_ids: Iterable return {cid: name for cid, name in (await session.exec(stmt)).all()} +def _alert_cells(alert: Alert) -> List[Any]: + return [ + alert.id, + alert.started_at.date().isoformat(), + alert.started_at.time().isoformat(), + alert.last_seen_at.isoformat(), + int((alert.last_seen_at - alert.started_at).total_seconds()), + "" if alert.lat is None else alert.lat, + "" if alert.lon is None else alert.lon, + alert.organization_id, + ] + + +def _sequence_cells(sequence: Sequence, camera_name: str) -> List[Any]: + return [ + sequence.id, + sequence.started_at.isoformat(), + sequence.last_seen_at.isoformat(), + "" if sequence.sequence_azimuth is None else sequence.sequence_azimuth, + _WILDFIRE_LABELS[sequence.is_wildfire], + "" if sequence.pose_id is None else sequence.pose_id, + sequence.camera_id, + camera_name, + ] + + def _iter_alerts_csv( alerts: Iterable[Alert], seq_map: Dict[int, List[Sequence]], @@ -130,37 +156,23 @@ def _iter_alerts_csv( ) -> Iterator[str]: buf = io.StringIO() writer = csv.writer(buf) + + def drain() -> str: + value = buf.getvalue() + buf.seek(0) + buf.truncate(0) + return value + writer.writerow(_ALERT_EXPORT_COLUMNS) - yield buf.getvalue() - buf.seek(0) - buf.truncate(0) + yield drain() + for alert in alerts: - alert_cells = [ - alert.id, - alert.started_at.date().isoformat(), - alert.started_at.time().isoformat(), - alert.last_seen_at.isoformat(), - int((alert.last_seen_at - alert.started_at).total_seconds()), - "" if alert.lat is None else alert.lat, - "" if alert.lon is None else alert.lon, - alert.organization_id, - ] + alert_cells = _alert_cells(alert) sequences = sorted(seq_map.get(alert.id, []), key=lambda s: s.started_at) for sequence in sequences: - writer.writerow([ - *alert_cells, - sequence.id, - sequence.started_at.isoformat(), - sequence.last_seen_at.isoformat(), - "" if sequence.sequence_azimuth is None else sequence.sequence_azimuth, - _WILDFIRE_LABELS[sequence.is_wildfire], - "" if sequence.pose_id is None else sequence.pose_id, - sequence.camera_id, - camera_names_by_id.get(sequence.camera_id, ""), - ]) - yield buf.getvalue() - buf.seek(0) - buf.truncate(0) + camera_name = camera_names_by_id.get(sequence.camera_id, "") + writer.writerow([*alert_cells, *_sequence_cells(sequence, camera_name)]) + yield drain() def _build_alerts_csv_response(