From 8f8cd89a4d51361418ba79681adc085351143450 Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 8 Apr 2026 02:41:40 +0200 Subject: [PATCH 01/10] feat(schemas): add detection_count to SequenceRead schema --- src/app/schemas/sequences.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/schemas/sequences.py b/src/app/schemas/sequences.py index 2555dd57..3b0db886 100644 --- a/src/app/schemas/sequences.py +++ b/src/app/schemas/sequences.py @@ -22,4 +22,4 @@ class SequenceLabel(BaseModel): class SequenceRead(Sequence): - pass + detections_count: int = 0 From c3587bdf9afb50a3dc2dd74127a60294a6a565f2 Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 8 Apr 2026 02:42:07 +0200 Subject: [PATCH 02/10] feat(endpoints): add helper function to get sequence count --- .../api/api_v1/endpoints/_sequence_counts.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/app/api/api_v1/endpoints/_sequence_counts.py diff --git a/src/app/api/api_v1/endpoints/_sequence_counts.py b/src/app/api/api_v1/endpoints/_sequence_counts.py new file mode 100644 index 00000000..16d44ac9 --- /dev/null +++ b/src/app/api/api_v1/endpoints/_sequence_counts.py @@ -0,0 +1,25 @@ +# Copyright (C) 2025-2026, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + + +from typing import Any, Dict, List, cast + +from sqlmodel import func, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models import Detection + + +async def get_detection_counts_by_sequence_ids(session: AsyncSession, sequence_ids: List[int]) -> Dict[int, int]: + if not sequence_ids: + return {} + + stmt: Any = ( + select(cast(Any, Detection.sequence_id), func.count(Detection.id)) + .where(cast(Any, Detection.sequence_id).in_(sequence_ids)) + .group_by(cast(Any, Detection.sequence_id)) + ) + res = await session.exec(stmt) + return {int(sequence_id): int(detections_count) for sequence_id, detections_count in res.all() if sequence_id is not None} From 747c755ac7131fdc9cedb0b2d147703322540a7a Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 8 Apr 2026 02:42:32 +0200 Subject: [PATCH 03/10] feat(endpoints): add sequence count to alerts endpoints --- src/app/api/api_v1/endpoints/alerts.py | 34 ++++++++++++++++----- src/tests/endpoints/test_alerts.py | 41 ++++++++++++++++++++++---- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index 8af09c05..b497db9d 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -12,6 +12,7 @@ from sqlmodel import delete, func, select from sqlmodel.ext.asyncio.session import AsyncSession +from app.api.api_v1.endpoints._sequence_counts import get_detection_counts_by_sequence_ids from app.api.dependencies import get_alert_crud, get_jwt from app.core.time import utcnow from app.crud import AlertCRUD @@ -46,10 +47,16 @@ async def _fetch_sequences_by_alert_ids(session: AsyncSession, alert_ids: List[i return mapping -def _serialize_alert(alert: Alert, sequences: List[Sequence]) -> AlertReadWithSequences: +def _serialize_sequence(sequence: Sequence, detection_counts: Dict[int, int]) -> SequenceRead: + return SequenceRead(**sequence.model_dump(), detections_count=detection_counts.get(int(sequence.id), 0)) + + +def _serialize_alert( + alert: Alert, sequences: List[Sequence], detection_counts: Dict[int, int] +) -> AlertReadWithSequences: return AlertReadWithSequences( **alert.model_dump(), - sequences=[SequenceRead(**seq.model_dump()) for seq in sequences], + sequences=[_serialize_sequence(sequence, detection_counts) for sequence in sequences], ) @@ -68,7 +75,10 @@ async def get_alert( alert_id_int = int(alert.id) seq_map = await _fetch_sequences_by_alert_ids(session, [alert_id_int]) - return _serialize_alert(alert, seq_map.get(alert_id_int, [])) + detection_counts = await get_detection_counts_by_sequence_ids( + session, [int(sequence.id) for sequence in seq_map.get(alert_id_int, [])] + ) + return _serialize_alert(alert, seq_map.get(alert_id_int, []), detection_counts) @router.get( @@ -81,7 +91,7 @@ async def fetch_alert_sequences( alerts: AlertCRUD = Depends(get_alert_crud), session: AsyncSession = Depends(get_session), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), -) -> List[Sequence]: +) -> List[SequenceRead]: telemetry_client.capture(token_payload.sub, event="alerts-sequences-get", properties={"alert_id": alert_id}) alert = cast(Alert, await alerts.get(alert_id, strict=True)) if UserRole.ADMIN not in token_payload.scopes: @@ -93,7 +103,9 @@ async def fetch_alert_sequences( seq_stmt = seq_stmt.where(AlertSequence.alert_id == alert_id).order_by(order_clause).limit(limit) res = await session.exec(seq_stmt) - return list(res.all()) + sequences = list(res.all()) + detection_counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id) for sequence in sequences]) + return [_serialize_sequence(sequence, detection_counts) for sequence in sequences] @router.get( @@ -120,7 +132,11 @@ async def fetch_latest_unlabeled_alerts( alerts = alerts_res.unique().all() alert_ids = [int(alert.id) for alert in alerts] seq_map = await _fetch_sequences_by_alert_ids(session, alert_ids) - return [_serialize_alert(alert, seq_map.get(int(alert.id), [])) for alert in alerts] + detection_counts = await get_detection_counts_by_sequence_ids( + session, + list({int(sequence.id) for sequences in seq_map.values() for sequence in sequences}), + ) + return [_serialize_alert(alert, seq_map.get(int(alert.id), []), detection_counts) for alert in alerts] @router.get("/all/fromdate", status_code=status.HTTP_200_OK, summary="Fetch all the alerts for a specific date") @@ -145,7 +161,11 @@ async def fetch_alerts_from_date( alerts = alerts_res.all() alert_ids = [int(alert.id) for alert in alerts] seq_map = await _fetch_sequences_by_alert_ids(session, alert_ids) - return [_serialize_alert(alert, seq_map.get(int(alert.id), [])) for alert in alerts] + detection_counts = await get_detection_counts_by_sequence_ids( + session, + list({int(sequence.id) for sequences in seq_map.values() for sequence in sequences}), + ) + return [_serialize_alert(alert, seq_map.get(int(alert.id), []), detection_counts) for alert in alerts] @router.delete("/{alert_id}", status_code=status.HTTP_200_OK, summary="Delete an alert") diff --git a/src/tests/endpoints/test_alerts.py b/src/tests/endpoints/test_alerts.py index 77bb9724..78ff2105 100644 --- a/src/tests/endpoints/test_alerts.py +++ b/src/tests/endpoints/test_alerts.py @@ -14,14 +14,18 @@ from app.core.config import settings from app.core.time import utcnow -from app.models import Alert, AlertSequence, AnnotationType, Camera, Organization, Pose, Sequence +from app.models import Alert, AlertSequence, AnnotationType, Camera, Detection, Organization, Pose, Sequence from app.services.overlap import compute_overlap async def _create_alert_with_sequences( session: AsyncSession, org_id: int, camera_id: int, lat: float, lon: float -) -> Tuple[Alert, List[int]]: +) -> Tuple[Alert, List[int], List[int]]: now = utcnow() + pose = ( + await session.exec(select(Pose).where(Pose.camera_id == camera_id).order_by(Pose.id)) # type: ignore[attr-defined] + ).first() + assert pose is not None seq_payloads = [ { "camera_id": camera_id, @@ -48,6 +52,7 @@ async def _create_alert_with_sequences( "cone_angle": 3.0, }, ] + detections_count_by_sequence = [2, 1, 0] sequences: List[Sequence] = [] for idx, payload in enumerate(seq_payloads): seq = Sequence( @@ -60,6 +65,20 @@ async def _create_alert_with_sequences( await session.commit() for seq in sequences: await session.refresh(seq) + for sequence, detections_count in zip(sequences, detections_count_by_sequence, strict=False): + for det_idx in range(detections_count): + session.add( + Detection( + camera_id=sequence.camera_id, + pose_id=pose.id, + sequence_id=sequence.id, + bucket_key=f"alert-seq-{sequence.id}-{det_idx}.jpg", + bbox="[(.1,.1,.7,.8,.9)]", + others_bboxes=None, + created_at=now - timedelta(seconds=det_idx), + ) + ) + await session.commit() alert = Alert( organization_id=org_id, @@ -75,14 +94,15 @@ async def _create_alert_with_sequences( for seq in sequences: session.add(AlertSequence(alert_id=alert.id, sequence_id=seq.id)) await session.commit() - return alert, [seq.id for seq in sequences] + return alert, [seq.id for seq in sequences], detections_count_by_sequence @pytest.mark.asyncio async def test_get_alert_and_sequences(async_client: AsyncClient, detection_session: AsyncSession): - alert, seq_ids = await _create_alert_with_sequences( + alert, seq_ids, detections_count_by_sequence = await _create_alert_with_sequences( detection_session, org_id=1, camera_id=1, lat=48.3856355, lon=2.7323256 ) + expected_counts = dict(zip(seq_ids, detections_count_by_sequence, strict=False)) auth = pytest.get_token( pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] @@ -97,19 +117,23 @@ async def test_get_alert_and_sequences(async_client: AsyncClient, detection_sess assert payload["started_at"] == alert.started_at.isoformat() assert payload["last_seen_at"] == alert.last_seen_at.isoformat() assert {seq["id"] for seq in payload["sequences"]} == set(seq_ids) + assert {seq["id"]: seq["detections_count"] for seq in payload["sequences"]} == expected_counts resp = await async_client.get(f"/alerts/{alert.id}/sequences?limit=5&desc=true", headers=auth) assert resp.status_code == 200, resp.text returned = resp.json() last_seen_times = [item["last_seen_at"] for item in returned] assert last_seen_times == sorted(last_seen_times, reverse=True) + assert {sequence["id"]: sequence["detections_count"] for sequence in returned} == expected_counts + assert any(sequence["detections_count"] == 0 for sequence in returned) @pytest.mark.asyncio async def test_alerts_unlabeled_latest(async_client: AsyncClient, detection_session: AsyncSession): - alert, seq_ids = await _create_alert_with_sequences( + alert, seq_ids, detections_count_by_sequence = await _create_alert_with_sequences( detection_session, org_id=1, camera_id=1, lat=48.3856355, lon=2.7323256 ) + expected_counts = dict(zip(seq_ids, detections_count_by_sequence, strict=False)) auth = pytest.get_token( pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), pytest.user_table[0]["organization_id"] @@ -124,13 +148,16 @@ async def test_alerts_unlabeled_latest(async_client: AsyncClient, detection_sess assert returned["started_at"] == alert.started_at.isoformat() assert returned["last_seen_at"] == alert.last_seen_at.isoformat() assert {seq["id"] for seq in returned["sequences"]} == set(seq_ids) + assert {seq["id"]: seq["detections_count"] for seq in returned["sequences"]} == expected_counts + assert any(seq["detections_count"] == 0 for seq in returned["sequences"]) @pytest.mark.asyncio async def test_alerts_from_date(async_client: AsyncClient, detection_session: AsyncSession): - alert, seq_ids = await _create_alert_with_sequences( + alert, seq_ids, detections_count_by_sequence = await _create_alert_with_sequences( detection_session, org_id=1, camera_id=1, lat=48.3856355, lon=2.7323256 ) + expected_counts = dict(zip(seq_ids, detections_count_by_sequence, strict=False)) date_str = alert.started_at.date().isoformat() auth = pytest.get_token( @@ -146,6 +173,8 @@ async def test_alerts_from_date(async_client: AsyncClient, detection_session: As assert started_times == sorted(started_times, reverse=True) alert_payload = next(item for item in returned if item["id"] == alert.id) assert {seq["id"] for seq in alert_payload["sequences"]} == set(seq_ids) + assert {seq["id"]: seq["detections_count"] for seq in alert_payload["sequences"]} == expected_counts + assert any(seq["detections_count"] == 0 for seq in alert_payload["sequences"]) @pytest.mark.asyncio From 687e93829b3c3dfc86de676ea10e0010d9623de7 Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Wed, 8 Apr 2026 02:42:46 +0200 Subject: [PATCH 04/10] feat(endpoints): add sequence count to sequences endpoint --- src/app/api/api_v1/endpoints/sequences.py | 17 +++-- src/tests/endpoints/test_sequences.py | 88 ++++++++++++++++++++++- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index 1f3e8841..9a109372 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -12,6 +12,7 @@ from sqlmodel import delete, func, select from sqlmodel.ext.asyncio.session import AsyncSession +from app.api.api_v1.endpoints._sequence_counts import get_detection_counts_by_sequence_ids from app.api.dependencies import get_alert_crud, get_camera_crud, get_detection_crud, get_jwt, get_sequence_crud from app.core.time import utcnow from app.crud import AlertCRUD, CameraCRUD, DetectionCRUD, SequenceCRUD @@ -83,20 +84,26 @@ async def _refresh_alert_state(alert_id: int, session: AsyncSession, alerts: Ale ) +def _serialize_sequence(sequence: Sequence, detections_count: int = 0) -> SequenceRead: + return SequenceRead(**sequence.model_dump(), detections_count=detections_count) + + @router.get("/{sequence_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific sequence") async def get_sequence( sequence_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), sequences: SequenceCRUD = Depends(get_sequence_crud), + session: AsyncSession = Depends(get_session), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), -) -> Sequence: +) -> SequenceRead: telemetry_client.capture(token_payload.sub, event="sequences-get", properties={"sequence_id": sequence_id}) sequence = cast(Sequence, await sequences.get(sequence_id, strict=True)) if UserRole.ADMIN not in token_payload.scopes: await verify_org_rights(token_payload.organization_id, sequence.camera_id, cameras) - return SequenceRead(**sequence.model_dump()) + counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id)]) + return _serialize_sequence(sequence, counts.get(int(sequence.id), 0)) @router.get( @@ -155,7 +162,8 @@ async def fetch_latest_unlabeled_sequences( .limit(15) ) ).all() - return [SequenceRead(**elt.model_dump()) for elt in fetched_sequences] + counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id) for sequence in fetched_sequences]) + return [_serialize_sequence(sequence, counts.get(int(sequence.id), 0)) for sequence in fetched_sequences] @router.get("/all/fromdate", status_code=status.HTTP_200_OK, summary="Fetch all the sequences for a specific date") @@ -180,7 +188,8 @@ async def fetch_sequences_from_date( .offset(offset) ) ).all() - return [SequenceRead(**elt.model_dump()) for elt in fetched_sequences] + counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id) for sequence in fetched_sequences]) + return [_serialize_sequence(sequence, counts.get(int(sequence.id), 0)) for sequence in fetched_sequences] @router.delete("/{sequence_id}", status_code=status.HTTP_200_OK, summary="Delete a sequence") diff --git a/src/tests/endpoints/test_sequences.py b/src/tests/endpoints/test_sequences.py index 6e9f0d17..28d83802 100644 --- a/src/tests/endpoints/test_sequences.py +++ b/src/tests/endpoints/test_sequences.py @@ -15,6 +15,30 @@ from app.schemas.sequences import SequenceLabel +@pytest.mark.parametrize( + ("sequence_id", "expected_idx", "expected_detections_count"), + [ + (1, 0, 3), + (2, 1, 1), + ], +) +@pytest.mark.asyncio +async def test_get_sequence(async_client: AsyncClient, detection_session: AsyncSession, sequence_id: int, expected_idx: int, expected_detections_count: int): + auth = pytest.get_token( + pytest.user_table[0]["id"], + pytest.user_table[0]["role"].split(), + pytest.user_table[0]["organization_id"], + ) + + response = await async_client.get(f"/sequences/{sequence_id}", headers=auth) + + assert response.status_code == 200, print(response.__dict__) + assert response.json() == { + **pytest.sequence_table[expected_idx], + "detections_count": expected_detections_count, + } + + @pytest.mark.parametrize( ("user_idx", "sequence_id", "status_code", "status_detail", "expected_result"), [ @@ -158,9 +182,9 @@ async def test_label_sequence( # datetime != date, weird, but works (0, "2018-06-06T00:00:00", 200, None, []), (0, "2018-06-06", 200, None, []), - (0, "2023-11-07", 200, None, pytest.sequence_table[:1]), - (1, "2023-11-07", 200, None, pytest.sequence_table[:1]), - (2, "2023-11-07", 200, None, pytest.sequence_table[1:2]), + (0, "2023-11-07", 200, None, [{**pytest.sequence_table[0], "detections_count": 3}]), + (1, "2023-11-07", 200, None, [{**pytest.sequence_table[0], "detections_count": 3}]), + (2, "2023-11-07", 200, None, [{**pytest.sequence_table[1], "detections_count": 1}]), ], ) @pytest.mark.asyncio @@ -190,6 +214,7 @@ async def test_fetch_sequences_from_date( assert response.json() == expected_result assert all(isinstance(elt["sequence_azimuth"], float) for elt in response.json()) assert all(isinstance(elt["cone_angle"], float) for elt in response.json()) + assert all(isinstance(elt["detections_count"], int) for elt in response.json()) @pytest.mark.parametrize( @@ -229,6 +254,63 @@ async def test_latest_sequences( assert all(isinstance(elt["cone_angle"], float) for elt in response.json()) +@pytest.mark.asyncio +async def test_latest_sequences_include_detections_count(async_client: AsyncClient, detection_session: AsyncSession): + now = datetime.utcnow() + sequence_with_detections = Sequence( + camera_id=pytest.camera_table[0]["id"], + pose_id=pytest.pose_table[0]["id"], + camera_azimuth=180.0, + sequence_azimuth=175.0, + cone_angle=5.0, + is_wildfire=None, + started_at=now - timedelta(minutes=15), + last_seen_at=now - timedelta(minutes=5), + ) + sequence_without_detections = Sequence( + camera_id=pytest.camera_table[0]["id"], + pose_id=pytest.pose_table[0]["id"], + camera_azimuth=182.0, + sequence_azimuth=176.0, + cone_angle=6.0, + is_wildfire=None, + started_at=now - timedelta(minutes=10), + last_seen_at=now - timedelta(minutes=2), + ) + detection_session.add(sequence_with_detections) + detection_session.add(sequence_without_detections) + await detection_session.commit() + await detection_session.refresh(sequence_with_detections) + await detection_session.refresh(sequence_without_detections) + + for idx in range(2): + detection_session.add( + Detection( + camera_id=sequence_with_detections.camera_id, + pose_id=pytest.pose_table[0]["id"], + sequence_id=sequence_with_detections.id, + bucket_key=f"sequence-latest-{sequence_with_detections.id}-{idx}.jpg", + bbox="[(.1,.1,.7,.8,.9)]", + others_bboxes=None, + created_at=now - timedelta(minutes=4 - idx), + ) + ) + await detection_session.commit() + + auth = pytest.get_token( + pytest.user_table[0]["id"], + pytest.user_table[0]["role"].split(), + pytest.user_table[0]["organization_id"], + ) + response = await async_client.get("/sequences/unlabeled/latest", headers=auth) + + assert response.status_code == 200, print(response.__dict__) + returned = response.json() + counts_by_sequence_id = {item["id"]: item["detections_count"] for item in returned} + assert counts_by_sequence_id[sequence_with_detections.id] == 2 + assert counts_by_sequence_id[sequence_without_detections.id] == 0 + + @pytest.mark.asyncio async def test_sequence_label_updates_alerts(async_client: AsyncClient, detection_session: AsyncSession): # Create a sequence linked to a camera and an alert From 71460a797c85b176571c79fdf3fa338a38f48db7 Mon Sep 17 00:00:00 2001 From: Thibault Lemery Date: Thu, 9 Apr 2026 17:20:51 +0200 Subject: [PATCH 05/10] Fix local CI ordering and ruff issues --- src/app/api/api_v1/endpoints/_sequence_counts.py | 6 +++++- src/app/api/api_v1/endpoints/detections.py | 2 +- src/tests/endpoints/test_sequences.py | 8 +++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app/api/api_v1/endpoints/_sequence_counts.py b/src/app/api/api_v1/endpoints/_sequence_counts.py index 16d44ac9..afeb53b9 100644 --- a/src/app/api/api_v1/endpoints/_sequence_counts.py +++ b/src/app/api/api_v1/endpoints/_sequence_counts.py @@ -22,4 +22,8 @@ async def get_detection_counts_by_sequence_ids(session: AsyncSession, sequence_i .group_by(cast(Any, Detection.sequence_id)) ) res = await session.exec(stmt) - return {int(sequence_id): int(detections_count) for sequence_id, detections_count in res.all() if sequence_id is not None} + return { + int(sequence_id): int(detections_count) + for sequence_id, detections_count in res.all() + if sequence_id is not None + } diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index 477aa57e..60ae87ed 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -554,7 +554,7 @@ async def fetch_detections( ) -> List[DetectionRead]: telemetry_client.capture(token_payload.sub, event="detections-fetch") if UserRole.ADMIN in token_payload.scopes: - return [DetectionRead(**elt.model_dump()) for elt in await detections.fetch_all()] + return [DetectionRead(**elt.model_dump()) for elt in await detections.fetch_all(order_by="id")] cameras_list = await cameras.fetch_all(filters=("organization_id", token_payload.organization_id)) camera_ids = [camera.id for camera in cameras_list] diff --git a/src/tests/endpoints/test_sequences.py b/src/tests/endpoints/test_sequences.py index 28d83802..eeef181e 100644 --- a/src/tests/endpoints/test_sequences.py +++ b/src/tests/endpoints/test_sequences.py @@ -23,7 +23,13 @@ ], ) @pytest.mark.asyncio -async def test_get_sequence(async_client: AsyncClient, detection_session: AsyncSession, sequence_id: int, expected_idx: int, expected_detections_count: int): +async def test_get_sequence( + async_client: AsyncClient, + detection_session: AsyncSession, + sequence_id: int, + expected_idx: int, + expected_detections_count: int, +): auth = pytest.get_token( pytest.user_table[0]["id"], pytest.user_table[0]["role"].split(), From d59fc46af53d68dbc64a3d3d1b1fdae52d456f01 Mon Sep 17 00:00:00 2001 From: ThbltLmr Date: Sun, 3 May 2026 12:18:32 +0200 Subject: [PATCH 06/10] refactor(services): move detection-count helper to services/ Per review feedback on PR #559: get_detection_counts_by_sequence_ids is an aggregation query, not a CRUD/route helper, so it belongs in services/ where it can be reused outside of endpoint modules. --- src/app/api/api_v1/endpoints/alerts.py | 2 +- src/app/api/api_v1/endpoints/sequences.py | 2 +- .../_sequence_counts.py => services/sequence_counts.py} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/app/{api/api_v1/endpoints/_sequence_counts.py => services/sequence_counts.py} (100%) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index b497db9d..d4f53ee2 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -12,7 +12,6 @@ from sqlmodel import delete, func, select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.api_v1.endpoints._sequence_counts import get_detection_counts_by_sequence_ids from app.api.dependencies import get_alert_crud, get_jwt from app.core.time import utcnow from app.crud import AlertCRUD @@ -21,6 +20,7 @@ from app.schemas.alerts import AlertReadWithSequences from app.schemas.login import TokenPayload from app.schemas.sequences import SequenceRead +from app.services.sequence_counts import get_detection_counts_by_sequence_ids from app.services.telemetry import telemetry_client router = APIRouter() diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index 9a109372..3265b3fb 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -12,7 +12,6 @@ from sqlmodel import delete, func, select from sqlmodel.ext.asyncio.session import AsyncSession -from app.api.api_v1.endpoints._sequence_counts import get_detection_counts_by_sequence_ids from app.api.dependencies import get_alert_crud, get_camera_crud, get_detection_crud, get_jwt, get_sequence_crud from app.core.time import utcnow from app.crud import AlertCRUD, CameraCRUD, DetectionCRUD, SequenceCRUD @@ -23,6 +22,7 @@ from app.schemas.login import TokenPayload from app.schemas.sequences import SequenceLabel, SequenceRead from app.services.overlap import compute_overlap +from app.services.sequence_counts import get_detection_counts_by_sequence_ids from app.services.storage import s3_service from app.services.telemetry import telemetry_client diff --git a/src/app/api/api_v1/endpoints/_sequence_counts.py b/src/app/services/sequence_counts.py similarity index 100% rename from src/app/api/api_v1/endpoints/_sequence_counts.py rename to src/app/services/sequence_counts.py From 65c37346640c154eea7863091994d31bfedfc09c Mon Sep 17 00:00:00 2001 From: ThbltLmr Date: Sun, 3 May 2026 12:18:52 +0200 Subject: [PATCH 07/10] refactor(alerts): align _serialize_sequence signature across endpoints Per review feedback on PR #559: _serialize_sequence in alerts.py took the full Dict[int, int] of counts and did the lookup inside. Match the sequences.py version (scalar int param, caller does the lookup) so the helper has one shape repo-wide and only does serialization. --- src/app/api/api_v1/endpoints/alerts.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index d4f53ee2..d84fe276 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -47,8 +47,8 @@ async def _fetch_sequences_by_alert_ids(session: AsyncSession, alert_ids: List[i return mapping -def _serialize_sequence(sequence: Sequence, detection_counts: Dict[int, int]) -> SequenceRead: - return SequenceRead(**sequence.model_dump(), detections_count=detection_counts.get(int(sequence.id), 0)) +def _serialize_sequence(sequence: Sequence, detections_count: int = 0) -> SequenceRead: + return SequenceRead(**sequence.model_dump(), detections_count=detections_count) def _serialize_alert( @@ -56,7 +56,9 @@ def _serialize_alert( ) -> AlertReadWithSequences: return AlertReadWithSequences( **alert.model_dump(), - sequences=[_serialize_sequence(sequence, detection_counts) for sequence in sequences], + sequences=[ + _serialize_sequence(sequence, detection_counts.get(int(sequence.id), 0)) for sequence in sequences + ], ) @@ -105,7 +107,7 @@ async def fetch_alert_sequences( res = await session.exec(seq_stmt) sequences = list(res.all()) detection_counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id) for sequence in sequences]) - return [_serialize_sequence(sequence, detection_counts) for sequence in sequences] + return [_serialize_sequence(sequence, detection_counts.get(int(sequence.id), 0)) for sequence in sequences] @router.get( From d8b1d5b856896e2aaf11acf027a51ca644080a1c Mon Sep 17 00:00:00 2001 From: ThbltLmr Date: Sun, 3 May 2026 12:20:21 +0200 Subject: [PATCH 08/10] refactor: drop redundant int() casts on model id attributes Per review feedback on PR #559: Sequence.id and Alert.id are typed int on the model, so int(sequence.id) / int(alert.id) is redundant. Same for the dict comprehension in get_detection_counts_by_sequence_ids where the row values are already int. The int(alert_id) at alerts.py:46 stays because alert_id there is unpacked from a raw SQL row tuple, not a model attribute. --- src/app/api/api_v1/endpoints/alerts.py | 27 ++++++++++------------- src/app/api/api_v1/endpoints/sequences.py | 12 +++++----- src/app/services/sequence_counts.py | 6 +---- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/app/api/api_v1/endpoints/alerts.py b/src/app/api/api_v1/endpoints/alerts.py index d84fe276..7cf64074 100644 --- a/src/app/api/api_v1/endpoints/alerts.py +++ b/src/app/api/api_v1/endpoints/alerts.py @@ -56,9 +56,7 @@ def _serialize_alert( ) -> AlertReadWithSequences: return AlertReadWithSequences( **alert.model_dump(), - sequences=[ - _serialize_sequence(sequence, detection_counts.get(int(sequence.id), 0)) for sequence in sequences - ], + sequences=[_serialize_sequence(sequence, detection_counts.get(sequence.id, 0)) for sequence in sequences], ) @@ -75,12 +73,11 @@ async def get_alert( if UserRole.ADMIN not in token_payload.scopes: verify_org_rights(token_payload.organization_id, alert) - alert_id_int = int(alert.id) - seq_map = await _fetch_sequences_by_alert_ids(session, [alert_id_int]) + seq_map = await _fetch_sequences_by_alert_ids(session, [alert.id]) detection_counts = await get_detection_counts_by_sequence_ids( - session, [int(sequence.id) for sequence in seq_map.get(alert_id_int, [])] + session, [sequence.id for sequence in seq_map.get(alert.id, [])] ) - return _serialize_alert(alert, seq_map.get(alert_id_int, []), detection_counts) + return _serialize_alert(alert, seq_map.get(alert.id, []), detection_counts) @router.get( @@ -106,8 +103,8 @@ async def fetch_alert_sequences( res = await session.exec(seq_stmt) sequences = list(res.all()) - detection_counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id) for sequence in sequences]) - return [_serialize_sequence(sequence, detection_counts.get(int(sequence.id), 0)) for sequence in sequences] + detection_counts = await get_detection_counts_by_sequence_ids(session, [sequence.id for sequence in sequences]) + return [_serialize_sequence(sequence, detection_counts.get(sequence.id, 0)) for sequence in sequences] @router.get( @@ -132,13 +129,13 @@ async def fetch_latest_unlabeled_alerts( ) alerts_res = await session.exec(alerts_stmt) alerts = alerts_res.unique().all() - alert_ids = [int(alert.id) for alert in alerts] + alert_ids = [alert.id for alert in alerts] seq_map = await _fetch_sequences_by_alert_ids(session, alert_ids) detection_counts = await get_detection_counts_by_sequence_ids( session, - list({int(sequence.id) for sequences in seq_map.values() for sequence in sequences}), + list({sequence.id for sequences in seq_map.values() for sequence in sequences}), ) - return [_serialize_alert(alert, seq_map.get(int(alert.id), []), detection_counts) for alert in alerts] + return [_serialize_alert(alert, seq_map.get(alert.id, []), detection_counts) for alert in alerts] @router.get("/all/fromdate", status_code=status.HTTP_200_OK, summary="Fetch all the alerts for a specific date") @@ -161,13 +158,13 @@ async def fetch_alerts_from_date( ) alerts_res = await session.exec(alerts_stmt) alerts = alerts_res.all() - alert_ids = [int(alert.id) for alert in alerts] + alert_ids = [alert.id for alert in alerts] seq_map = await _fetch_sequences_by_alert_ids(session, alert_ids) detection_counts = await get_detection_counts_by_sequence_ids( session, - list({int(sequence.id) for sequences in seq_map.values() for sequence in sequences}), + list({sequence.id for sequences in seq_map.values() for sequence in sequences}), ) - return [_serialize_alert(alert, seq_map.get(int(alert.id), []), detection_counts) for alert in alerts] + return [_serialize_alert(alert, seq_map.get(alert.id, []), detection_counts) for alert in alerts] @router.delete("/{alert_id}", status_code=status.HTTP_200_OK, summary="Delete an alert") diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index 3265b3fb..edacaa46 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -102,8 +102,8 @@ async def get_sequence( if UserRole.ADMIN not in token_payload.scopes: await verify_org_rights(token_payload.organization_id, sequence.camera_id, cameras) - counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id)]) - return _serialize_sequence(sequence, counts.get(int(sequence.id), 0)) + counts = await get_detection_counts_by_sequence_ids(session, [sequence.id]) + return _serialize_sequence(sequence, counts.get(sequence.id, 0)) @router.get( @@ -162,8 +162,8 @@ async def fetch_latest_unlabeled_sequences( .limit(15) ) ).all() - counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id) for sequence in fetched_sequences]) - return [_serialize_sequence(sequence, counts.get(int(sequence.id), 0)) for sequence in fetched_sequences] + counts = await get_detection_counts_by_sequence_ids(session, [sequence.id for sequence in fetched_sequences]) + return [_serialize_sequence(sequence, counts.get(sequence.id, 0)) for sequence in fetched_sequences] @router.get("/all/fromdate", status_code=status.HTTP_200_OK, summary="Fetch all the sequences for a specific date") @@ -188,8 +188,8 @@ async def fetch_sequences_from_date( .offset(offset) ) ).all() - counts = await get_detection_counts_by_sequence_ids(session, [int(sequence.id) for sequence in fetched_sequences]) - return [_serialize_sequence(sequence, counts.get(int(sequence.id), 0)) for sequence in fetched_sequences] + counts = await get_detection_counts_by_sequence_ids(session, [sequence.id for sequence in fetched_sequences]) + return [_serialize_sequence(sequence, counts.get(sequence.id, 0)) for sequence in fetched_sequences] @router.delete("/{sequence_id}", status_code=status.HTTP_200_OK, summary="Delete a sequence") diff --git a/src/app/services/sequence_counts.py b/src/app/services/sequence_counts.py index afeb53b9..195c8b52 100644 --- a/src/app/services/sequence_counts.py +++ b/src/app/services/sequence_counts.py @@ -22,8 +22,4 @@ async def get_detection_counts_by_sequence_ids(session: AsyncSession, sequence_i .group_by(cast(Any, Detection.sequence_id)) ) res = await session.exec(stmt) - return { - int(sequence_id): int(detections_count) - for sequence_id, detections_count in res.all() - if sequence_id is not None - } + return {sequence_id: detections_count for sequence_id, detections_count in res.all() if sequence_id is not None} From b598e19658c7c87c774e533230af67e174621251 Mon Sep 17 00:00:00 2001 From: ThbltLmr Date: Sun, 3 May 2026 12:20:31 +0200 Subject: [PATCH 09/10] test(alerts): use strict=True when zipping fixture sequences and counts Per review feedback on PR #559: strict=False silently swallows length mismatches. Both lists have length 3 today, so flipping to strict=True guards against future fixture edits drifting out of sync. --- src/tests/endpoints/test_alerts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/endpoints/test_alerts.py b/src/tests/endpoints/test_alerts.py index 78ff2105..4c681630 100644 --- a/src/tests/endpoints/test_alerts.py +++ b/src/tests/endpoints/test_alerts.py @@ -65,7 +65,7 @@ async def _create_alert_with_sequences( await session.commit() for seq in sequences: await session.refresh(seq) - for sequence, detections_count in zip(sequences, detections_count_by_sequence, strict=False): + for sequence, detections_count in zip(sequences, detections_count_by_sequence, strict=True): for det_idx in range(detections_count): session.add( Detection( From a01110409b8bf333b784c7f64d52e87b8a2942b1 Mon Sep 17 00:00:00 2001 From: ThbltLmr Date: Sun, 3 May 2026 12:29:29 +0200 Subject: [PATCH 10/10] test(sequences): use utcnow() helper in detections-count test Rebase fallout from #574 (datetime.utcnow deprecation): one new test function still called datetime.utcnow() while the datetime import had been removed from the file, causing NameError on collection. --- src/tests/endpoints/test_sequences.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/endpoints/test_sequences.py b/src/tests/endpoints/test_sequences.py index eeef181e..1d593382 100644 --- a/src/tests/endpoints/test_sequences.py +++ b/src/tests/endpoints/test_sequences.py @@ -262,7 +262,7 @@ async def test_latest_sequences( @pytest.mark.asyncio async def test_latest_sequences_include_detections_count(async_client: AsyncClient, detection_session: AsyncSession): - now = datetime.utcnow() + now = utcnow() sequence_with_detections = Sequence( camera_id=pytest.camera_table[0]["id"], pose_id=pytest.pose_table[0]["id"],