diff --git a/scripts/test_e2e.py b/scripts/test_e2e.py index 3abf2abc5..a9eaef1fe 100644 --- a/scripts/test_e2e.py +++ b/scripts/test_e2e.py @@ -148,7 +148,7 @@ def main(args): # Check that a sequence has been created sequence = api_request("get", f"{args.endpoint}/sequences/1", agent_auth) assert sequence["camera_id"] == cam_id - assert sequence["started_at"] == response.json()["created_at"] + assert sequence["started_at"] == response.json()["recorded_at"] assert sequence["last_seen_at"] > sequence["started_at"] assert sequence["camera_azimuth"] == pose_azimuth # Fetch the latest sequence diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index dad0b7f7a..677d4a5e7 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -33,7 +33,7 @@ get_sequence_crud, ) from app.core.config import settings -from app.core.time import utcnow +from app.core.time import to_utc_naive, utcnow from app.crud import AlertCRUD, CameraCRUD, DetectionCRUD, PoseCRUD, SequenceCRUD from app.models import Alert, AlertSequence, Camera, Detection, Pose, Role, Sequence, UserRole from app.schemas.alerts import AlertCreate, AlertUpdate @@ -94,7 +94,7 @@ async def _get_last_bbox_for_sequence( ) -> Optional[Tuple[float, float, float, float, float]]: dets = await detections.fetch_all( filters=("sequence_id", sequence_id), - order_by="created_at", + order_by="recorded_at", order_desc=True, limit=1, ) @@ -350,6 +350,13 @@ async def create_detection( max_length=settings.MAX_BBOX_STR_LENGTH, ), pose_id: int = Form(..., gt=0, description="pose id of the detection"), + recorded_at: Optional[datetime] = Form( + None, + description=( + "Timestamp of when the image was captured by the engine. Timezone-aware values are " + "converted to UTC; naive values are assumed UTC. Defaults to server now if omitted." + ), + ), file: UploadFile = File(..., alias="file"), crop_files: Optional[List[UploadFile]] = File(None, alias="crop"), detections: DetectionCRUD = Depends(get_detection_crud), @@ -402,6 +409,12 @@ async def create_detection( # sequences touched by this request, to mark due for validation (DB-backed queue). affected_sequences: Set[int] = set() + # The engine may report when the image was actually captured; fall back to now when it doesn't. + # Aware timestamps are normalized to UTC (naive timestamps are assumed to already be UTC) so + # the value matches the DB columns and the time-window comparisons below. + # All bboxes from a single upload share the same capture time. + effective_recorded_at = to_utc_naive(recorded_at) if recorded_at is not None else utcnow() + for idx, bbox_str in enumerate(bbox_strings): single_bboxes = _bbox_list_to_str([bbox_str]) other_bbox_strings = bbox_strings[:idx] + bbox_strings[idx + 1 :] @@ -414,6 +427,7 @@ async def create_detection( crop_bucket_key=crop_bucket_keys[idx], bbox=single_bboxes, others_bboxes=others_bboxes, + recorded_at=effective_recorded_at, ) ) @@ -425,7 +439,7 @@ async def create_detection( inequality_pair=( "last_seen_at", ">", - utcnow() - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS), + effective_recorded_at - timedelta(seconds=settings.SEQUENCE_RELAXATION_SECONDS), ), order_by="last_seen_at", order_desc=True, @@ -440,7 +454,7 @@ async def create_detection( break if matched_sequence is not None: - await sequences.update(matched_sequence.id, SequenceUpdate(last_seen_at=det.created_at)) + await sequences.update(matched_sequence.id, SequenceUpdate(last_seen_at=det.recorded_at)) det = await detections.update(det.id, DetectionSequence(sequence_id=matched_sequence.id)) # Only the primary bbox tracks the sequence; siblings in others_bboxes are unrelated detections. det_max_conf = max_conf_from_bboxes(det.bbox) @@ -456,11 +470,11 @@ async def create_detection( dets_ = await detections.fetch_all( filters=det_filters, inequality_pair=( - "created_at", + "recorded_at", ">", - utcnow() - timedelta(seconds=settings.SEQUENCE_MIN_INTERVAL_SECONDS), + effective_recorded_at - timedelta(seconds=settings.SEQUENCE_MIN_INTERVAL_SECONDS), ), - order_by="created_at", + order_by="recorded_at", order_desc=False, ) overlapping_dets: List[Detection] = [] @@ -473,7 +487,7 @@ async def create_detection( overlapping_dets.append(cand) if len(overlapping_dets) >= settings.SEQUENCE_MIN_INTERVAL_DETS: - first_det = min(overlapping_dets, key=lambda item: item.created_at) + first_det = min(overlapping_dets, key=lambda item: item.recorded_at) cone_azimuth, cone_angle = resolve_cone(pose.azimuth, first_det.bbox, camera.angle_of_view) seq_max_conf = max_conf_from_bboxes(*[d.bbox for d in overlapping_dets]) sequence_ = await sequences.create( @@ -483,8 +497,8 @@ async def create_detection( camera_azimuth=pose.azimuth, sequence_azimuth=cone_azimuth, cone_angle=cone_angle, - started_at=first_det.created_at, - last_seen_at=det.created_at, + started_at=first_det.recorded_at, + last_seen_at=det.recorded_at, max_conf=seq_max_conf, ) ) diff --git a/src/app/api/api_v1/endpoints/sequences.py b/src/app/api/api_v1/endpoints/sequences.py index f4f6133a6..3bbc68a0f 100644 --- a/src/app/api/api_v1/endpoints/sequences.py +++ b/src/app/api/api_v1/endpoints/sequences.py @@ -67,7 +67,7 @@ async def fetch_sequence_detections( sequence_id: int = Path(..., gt=0), limit: int = Query(10, description="Maximum number of detections to fetch", ge=1, le=100), offset: int = Query(0, description="Number of detections to skip", ge=0), - desc: bool = Query(True, description="Whether to order the detections by created_at in descending order"), + desc: bool = Query(True, description="Whether to order the detections by recorded_at in descending order"), with_crop: bool = Query( False, description="If true, presign and include crop_url for detections that have a crop. Defaults to false to skip the extra S3 head requests when crops are not needed.", @@ -87,7 +87,7 @@ async def fetch_sequence_detections( bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) fetched = await detections.fetch_all( filters=("sequence_id", sequence_id), - order_by="created_at", + order_by="recorded_at", order_desc=desc, limit=limit, offset=offset, diff --git a/src/app/core/time.py b/src/app/core/time.py index 82e9c60bb..01b4203ab 100644 --- a/src/app/core/time.py +++ b/src/app/core/time.py @@ -9,3 +9,15 @@ def utcnow() -> datetime: """UTC wall clock, returned as a naive datetime to match existing DB columns.""" return datetime.now(timezone.utc).replace(tzinfo=None) + + +def to_utc_naive(value: datetime) -> datetime: + """Normalize a datetime to naive UTC, matching how DB columns store time. + + A timezone-aware value (e.g. ``2026-05-27T10:00:00+02:00`` from a French engine) is + converted to UTC before the tzinfo is dropped, so it lands at ``08:00:00``. A naive value + is assumed to already be UTC and returned unchanged. + """ + if value.tzinfo is not None: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value diff --git a/src/app/models.py b/src/app/models.py index 22e0d9e94..89b12ab93 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -92,6 +92,11 @@ class Detection(SQLModel, table=True): bbox: str = Field(..., min_length=2, max_length=settings.MAX_BBOX_STR_LENGTH_SINGLE, nullable=False) others_bboxes: Union[str, None] = Field(default=None, max_length=settings.MAX_BBOX_STR_LENGTH_OTHERS, nullable=True) created_at: datetime = Field(default_factory=utcnow, nullable=False) + recorded_at: datetime = Field( + default_factory=utcnow, + nullable=False, + description="UTC timestamp of when the image was captured on-device. Defaults to created_at when unknown.", + ) # sequences.validation_status values — the single source of truth (enforced by a DB CHECK diff --git a/src/app/schemas/detections.py b/src/app/schemas/detections.py index 3c58dc5e6..671f382b0 100644 --- a/src/app/schemas/detections.py +++ b/src/app/schemas/detections.py @@ -4,6 +4,7 @@ # See LICENSE or go to for full license details. import re +from datetime import datetime from typing import Optional, Union from pydantic import BaseModel, Field @@ -38,6 +39,9 @@ class DetectionCreate(BaseModel): json_schema_extra={"examples": ["[(0.1, 0.1, 0.9, 0.9, 0.5)]"]}, ) others_bboxes: Optional[str] = Field(None, max_length=settings.MAX_BBOX_STR_LENGTH_OTHERS) + recorded_at: Optional[datetime] = Field( + None, description="UTC timestamp of when the image was captured on-device. Defaults to server now if omitted." + ) class DetectionUrl(BaseModel): diff --git a/src/migrations/versions/2026_05_27_1000-c4e9f1a2b3d5_add_recorded_at_to_detections.py b/src/migrations/versions/2026_05_27_1000-c4e9f1a2b3d5_add_recorded_at_to_detections.py new file mode 100644 index 000000000..93fa47b77 --- /dev/null +++ b/src/migrations/versions/2026_05_27_1000-c4e9f1a2b3d5_add_recorded_at_to_detections.py @@ -0,0 +1,31 @@ +"""add recorded_at column to detections and backfill from created_at + +Revision ID: c4e9f1a2b3d5 +Revises: c5e2f7a8b1d0 +Create Date: 2026-05-27 10:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c4e9f1a2b3d5" +down_revision: Union[str, None] = "c5e2f7a8b1d0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add as nullable first so existing rows don't violate the NOT NULL constraint. + op.add_column("detections", sa.Column("recorded_at", sa.DateTime(), nullable=True)) + # Backfill: legacy detections have no capture time, so fall back to the DB insertion time. + op.execute("UPDATE detections SET recorded_at = created_at WHERE recorded_at IS NULL") + # Tighten to NOT NULL to match the SQLModel definition. + op.alter_column("detections", "recorded_at", nullable=False) + + +def downgrade() -> None: + op.drop_column("detections", "recorded_at") diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 8882694d2..368fa742c 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -156,6 +156,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format), }, { "id": 2, @@ -167,6 +168,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:18:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T15:18:19.226673", dt_format), }, { "id": 3, @@ -178,6 +180,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T15:28:19.226673", dt_format), }, { "id": 4, @@ -189,6 +192,7 @@ "bbox": "[(.1,.1,.7,.8,.9)]", "others_bboxes": None, "created_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format), + "recorded_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format), }, ] diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index 5db7b4140..2685e12fe 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -3,7 +3,7 @@ import io from ast import literal_eval from collections import Counter -from datetime import timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Union import pytest # type: ignore @@ -939,6 +939,7 @@ async def test_create_detection_sequence_flow_direct(detection_session: AsyncSes det_read = await create_detection( bboxes="[(0.2,0.2,0.3,0.3,0.9)]", pose_id=pose_id, + recorded_at=None, file=upload, crop_files=None, detections=detections, @@ -973,6 +974,7 @@ async def fake_fetch_all(*args, **kwargs): det_read_2 = await create_detection( bboxes="[(0.25,0.25,0.35,0.35,0.9)]", pose_id=pose_id, + recorded_at=None, file=upload_again, crop_files=None, detections=detections, @@ -1422,6 +1424,177 @@ async def test_attach_sequence_does_not_bridge_to_distant_alert(detection_sessio assert seq_cam2.id not in seqs_in_a +@pytest.mark.asyncio +async def test_create_detection_uses_payload_recorded_at( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + recorded_at = datetime(2024, 1, 15, 10, 30, 0, 123456) + payload = { + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.2,0.2,0.9)]", + "recorded_at": recorded_at.isoformat(), + } + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, response.text + + det = await detection_session.get(Detection, response.json()["id"]) + assert det is not None + assert det.recorded_at == recorded_at + + +@pytest.mark.asyncio +async def test_create_detection_converts_aware_recorded_at_to_utc( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + # A France-local (UTC+2) capture time must be stored as the equivalent naive-UTC instant. + aware = datetime(2024, 7, 1, 10, 30, 0, 123456, tzinfo=timezone(timedelta(hours=2))) + payload = { + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.2,0.2,0.9)]", + "recorded_at": aware.isoformat(), + } + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, response.text + + det = await detection_session.get(Detection, response.json()["id"]) + assert det is not None + assert det.recorded_at == datetime(2024, 7, 1, 8, 30, 0, 123456) + assert det.recorded_at.tzinfo is None + + +@pytest.mark.asyncio +async def test_create_detection_defaults_recorded_at_to_now( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes +): + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + payload = {"pose_id": pytest.pose_table[0]["id"], "bboxes": "[(0.1,0.1,0.2,0.2,0.9)]"} + response = await async_client.post( + "/detections", data=payload, files={"file": ("logo.png", mock_img, "image/png")}, headers=auth + ) + assert response.status_code == 201, response.text + + det = await detection_session.get(Detection, response.json()["id"]) + assert det is not None + # When the engine omits recorded_at it falls back to the server clock, lining up with created_at. + assert abs((det.recorded_at - det.created_at).total_seconds()) < 5 + + +@pytest.mark.asyncio +async def test_sequence_linking_uses_recorded_at( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes, monkeypatch +): + monkeypatch.setattr(settings, "SEQUENCE_MIN_INTERVAL_DETS", 2) + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + + # Two detections uploaded back-to-back but captured ~2h ago, 30s apart (within SEQUENCE_MIN_INTERVAL_SECONDS). + t1 = utcnow() - timedelta(hours=2) + t2 = t1 + timedelta(seconds=30) + + resp1 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.3,0.3,0.9)]", + "recorded_at": t1.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp1.status_code == 201, resp1.text + assert resp1.json()["sequence_id"] is None + + resp2 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.15,0.15,0.35,0.35,0.9)]", + "recorded_at": t2.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp2.status_code == 201, resp2.text + seq_id = resp2.json()["sequence_id"] + assert isinstance(seq_id, int) + + seq = await detection_session.get(Sequence, seq_id) + assert seq is not None + # Sequence bounds come from recorded_at (capture time), not from created_at (~now). + assert abs((seq.started_at - t1).total_seconds()) < 1 + assert abs((seq.last_seen_at - t2).total_seconds()) < 1 + assert (utcnow() - seq.started_at).total_seconds() > 3600 + + +@pytest.mark.asyncio +async def test_distant_recorded_at_does_not_group_into_sequence( + async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes, monkeypatch +): + monkeypatch.setattr(settings, "SEQUENCE_MIN_INTERVAL_DETS", 2) + auth = pytest.get_token( + pytest.camera_table[0]["id"], + ["camera"], + pytest.camera_table[0]["organization_id"], + ) + + async def count_sequences() -> int: + res = await detection_session.exec(select(Sequence)) + return len(res.all()) + + base_seq = await count_sequences() + + # Same upload burst, but captured 1h apart — beyond SEQUENCE_MIN_INTERVAL_SECONDS, so they must NOT group. + t1 = utcnow() - timedelta(hours=3) + t2 = t1 + timedelta(hours=1) + + resp1 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.1,0.1,0.3,0.3,0.9)]", + "recorded_at": t1.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp1.status_code == 201, resp1.text + + resp2 = await async_client.post( + "/detections", + data={ + "pose_id": pytest.pose_table[0]["id"], + "bboxes": "[(0.15,0.15,0.35,0.35,0.9)]", + "recorded_at": t2.isoformat(), + }, + files={"file": ("logo.png", mock_img, "image/png")}, + headers=auth, + ) + assert resp2.status_code == 201, resp2.text + assert resp2.json()["sequence_id"] is None + assert await count_sequences() == base_seq + + @pytest.mark.asyncio async def test_create_detection_persists_crop_bucket_key( async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes