From cd61d640b86d4af922fa78160ddd71dec7d75fe2 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:47:54 +0100 Subject: [PATCH 01/33] Introduced poses and its fk (nullable before migration) --- src/app/models.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/models.py b/src/app/models.py index f157ce8d..f4839f10 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -11,7 +11,7 @@ from app.core.config import settings -__all__ = ["Camera", "Detection", "Organization", "User"] +__all__ = ["Camera", "Detection", "Organization", "Pose", "Sequence", "User"] class UserRole(str, Enum): @@ -59,10 +59,19 @@ class Camera(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) +class Pose(SQLModel, table=True): + __tablename__ = "poses" + id: int = Field(default=None, primary_key=True) + camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) + azimuth: float = Field(..., ge=0, lt=360) + patrol_id: str | None = Field(default=None, max_length=100) + + class Detection(SQLModel, table=True): __tablename__ = "detections" id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) + pose_id: int = Field(..., foreign_key="poses.id", nullable=True) sequence_id: Union[int, None] = Field(None, foreign_key="sequences.id", nullable=True) azimuth: float = Field(..., ge=0, lt=360) bucket_key: str @@ -74,6 +83,7 @@ class Sequence(SQLModel, table=True): __tablename__ = "sequences" id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) + pose_id: int = Field(..., foreign_key="poses.id", nullable=True) azimuth: float = Field(..., ge=0, lt=360) is_wildfire: Union[AnnotationType, None] = None started_at: datetime = Field(..., nullable=False) From 1faf81e01caaf3190899e702fdafb8eec59a7468 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:48:16 +0100 Subject: [PATCH 02/33] Created poses schema --- src/app/schemas/poses.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/app/schemas/poses.py diff --git a/src/app/schemas/poses.py b/src/app/schemas/poses.py new file mode 100644 index 00000000..614a7f5e --- /dev/null +++ b/src/app/schemas/poses.py @@ -0,0 +1,32 @@ +# Copyright (C) 2020-2025, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from typing import Optional + +from pydantic import BaseModel, Field + +__all__ = [ + "PoseCreate", + "PoseRead", + "PoseUpdate", +] + + +class PoseCreate(BaseModel): + camera_id: int = Field(..., gt=0, description="ID of the camera") + azimuth: float = Field(..., ge=0, lt=360, description="Azimuth of the centre of the position in degrees") + patrol_id: Optional[str] = Field(None, max_length=100, description="External patrol identifier") + + +class PoseUpdate(BaseModel): + azimuth: Optional[float] = Field(None, ge=0, lt=360) + patrol_id: Optional[str] = Field(None, max_length=100) + + +class PoseRead(BaseModel): + id: int + camera_id: int + azimuth: float + patrol_id: Optional[str] = None From ac1119c7d2c3242cacdcec49f449573bd529574b Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:48:42 +0100 Subject: [PATCH 03/33] Updated camaras and detections schemas --- src/app/schemas/cameras.py | 11 +++++++++++ src/app/schemas/detections.py | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/schemas/cameras.py b/src/app/schemas/cameras.py index 006eb1ca..d18d4645 100644 --- a/src/app/schemas/cameras.py +++ b/src/app/schemas/cameras.py @@ -7,6 +7,8 @@ from pydantic import BaseModel, Field +from app.schemas.poses import PoseRead + __all__ = [ "CameraCreate", "LastActive", @@ -54,3 +56,12 @@ class CameraCreate(CameraEdit): class CameraName(BaseModel): name: str = Field(..., min_length=5, max_length=100, description="name of the camera") + + +class CameraRead(CameraCreate): + id: int + last_active_at: datetime | None + last_image: str | None + last_image_url: str | None = Field(None, description="URL of the last image of the camera") + poses: list[PoseRead] = Field(default_factory=list) + created_at: datetime diff --git a/src/app/schemas/detections.py b/src/app/schemas/detections.py index f30fa6c5..ec335033 100644 --- a/src/app/schemas/detections.py +++ b/src/app/schemas/detections.py @@ -4,7 +4,7 @@ # See LICENSE or go to for full license details. import re -from typing import Union +from typing import Optional, Union from pydantic import BaseModel, Field @@ -37,6 +37,7 @@ class Azimuth(BaseModel): class DetectionCreate(Azimuth): camera_id: int = Field(..., gt=0) + pose_id: Optional[int] = Field(None, gt=0) bucket_key: str bboxes: str = Field( ..., From c6e081deb939e68971099c48686c8fc36848546c Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:48:54 +0100 Subject: [PATCH 04/33] Created crud_pose --- src/app/crud/crud_pose.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/app/crud/crud_pose.py diff --git a/src/app/crud/crud_pose.py b/src/app/crud/crud_pose.py new file mode 100644 index 00000000..b9af79ca --- /dev/null +++ b/src/app/crud/crud_pose.py @@ -0,0 +1,17 @@ +# Copyright (C) 2024-2025, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.crud.base import BaseCRUD +from app.models import Pose +from app.schemas.poses import PoseCreate, PoseUpdate + +__all__ = ["PoseCRUD"] + + +class PoseCRUD(BaseCRUD[Pose, PoseCreate, PoseUpdate]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, Pose) From 737fa0b02197fb284e556eab26d37d0bc103179e Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:49:13 +0100 Subject: [PATCH 05/33] Updated crud Init.py --- src/app/crud/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/crud/__init__.py b/src/app/crud/__init__.py index 690261f0..f343a7a8 100644 --- a/src/app/crud/__init__.py +++ b/src/app/crud/__init__.py @@ -1,5 +1,6 @@ from .crud_user import * from .crud_camera import * +from .crud_pose import * from .crud_detection import * from .crud_organization import * from .crud_sequence import * From 152d596fd31987d83d8bce666206136dd064babd Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:49:45 +0100 Subject: [PATCH 06/33] updated api router and dependencies --- src/app/api/api_v1/router.py | 3 ++- src/app/api/dependencies.py | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/api/api_v1/router.py b/src/app/api/api_v1/router.py index fa1acdd7..e4efbf68 100644 --- a/src/app/api/api_v1/router.py +++ b/src/app/api/api_v1/router.py @@ -5,12 +5,13 @@ from fastapi import APIRouter -from app.api.api_v1.endpoints import cameras, detections, login, organizations, sequences, users, webhooks +from app.api.api_v1.endpoints import cameras, detections, login, organizations, poses, sequences, users, webhooks api_router = APIRouter(redirect_slashes=True) api_router.include_router(login.router, prefix="/login", tags=["login"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(cameras.router, prefix="/cameras", tags=["cameras"]) +api_router.include_router(poses.router, prefix="/poses", tags=["poses"]) api_router.include_router(detections.router, prefix="/detections", tags=["detections"]) api_router.include_router(sequences.router, prefix="/sequences", tags=["sequences"]) api_router.include_router(organizations.router, prefix="/organizations", tags=["organizations"]) diff --git a/src/app/api/dependencies.py b/src/app/api/dependencies.py index e5704b7c..c9e482f1 100644 --- a/src/app/api/dependencies.py +++ b/src/app/api/dependencies.py @@ -15,7 +15,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.core.config import settings -from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, SequenceCRUD, UserCRUD, WebhookCRUD +from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, PoseCRUD, SequenceCRUD, UserCRUD, WebhookCRUD from app.db import get_session from app.models import User, UserRole from app.schemas.login import TokenPayload @@ -44,6 +44,10 @@ def get_camera_crud(session: AsyncSession = Depends(get_session)) -> CameraCRUD: return CameraCRUD(session=session) +def get_pose_crud(session: AsyncSession = Depends(get_session)) -> PoseCRUD: + return PoseCRUD(session=session) + + def get_detection_crud(session: AsyncSession = Depends(get_session)) -> DetectionCRUD: return DetectionCRUD(session=session) From 8773cc357e29622755cb79ce94c9d1b5881138fe Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:50:12 +0100 Subject: [PATCH 07/33] Created endpoints for poses --- src/app/api/api_v1/endpoints/poses.py | 81 +++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/app/api/api_v1/endpoints/poses.py diff --git a/src/app/api/api_v1/endpoints/poses.py b/src/app/api/api_v1/endpoints/poses.py new file mode 100644 index 00000000..5c305cac --- /dev/null +++ b/src/app/api/api_v1/endpoints/poses.py @@ -0,0 +1,81 @@ +# Copyright (C) 2020-2025, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +from fastapi import APIRouter, Depends, HTTPException, Path, Security, status + +from app.api.dependencies import get_camera_crud, get_jwt, get_pose_crud +from app.crud import CameraCRUD, PoseCRUD +from app.models import UserRole +from app.schemas.login import TokenPayload +from app.schemas.poses import PoseCreate, PoseRead, PoseUpdate +from app.services.telemetry import telemetry_client + +router = APIRouter() + + +@router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new pose for a camera") +async def create_pose( + payload: PoseCreate, + poses: PoseCRUD = Depends(get_pose_crud), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), +) -> PoseRead: + telemetry_client.capture( + token_payload.sub, + event="poses-create", + properties={"camera_id": payload.camera_id, "azimuth": payload.azimuth}, + ) + + camera = await cameras.get(payload.camera_id, strict=True) + + if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + return await poses.create(payload) + + +@router.get("/{pose_id}", status_code=status.HTTP_200_OK, summary="Fetch information of a specific pose") +async def get_pose( + pose_id: int = Path(..., gt=0), + poses: PoseCRUD = Depends(get_pose_crud), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), +) -> PoseRead: + telemetry_client.capture(token_payload.sub, event="poses-get", properties={"pose_id": pose_id}) + + pose = await poses.get(pose_id, strict=True) + camera = await cameras.get(pose.camera_id, strict=True) + + if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + return PoseRead(**pose.model_dump()) + + +@router.patch("/{pose_id}", status_code=status.HTTP_200_OK, summary="Update a pose") +async def update_pose( + pose_id: int = Path(..., gt=0), + payload: PoseUpdate = ..., + poses: PoseCRUD = Depends(get_pose_crud), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.AGENT, UserRole.ADMIN]), +) -> PoseRead: + pose = await poses.get(pose_id, strict=True) + camera = await cameras.get(pose.camera_id, strict=True) + + if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + return await poses.update(pose_id, payload) + + +@router.delete("/{pose_id}", status_code=status.HTTP_200_OK, summary="Delete a pose") +async def delete_pose( + pose_id: int = Path(..., gt=0), + poses: PoseCRUD = Depends(get_pose_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), +) -> None: + telemetry_client.capture(token_payload.sub, event="poses-deletion", properties={"pose_id": pose_id}) + await poses.delete(pose_id) From 88d3e7f06a5764f1905e79de786c10641b740541 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:50:29 +0100 Subject: [PATCH 08/33] updated endpoints detections --- src/app/api/api_v1/endpoints/detections.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index b2410d47..cadb182c 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -60,6 +60,7 @@ async def create_detection( max_length=settings.MAX_BBOX_STR_LENGTH, ), azimuth: float = Form(..., ge=0, lt=360, description="angle between north and direction in degrees"), + pose_id: int = Form(..., gt=0, description="pose id of the detection"), file: UploadFile = File(..., alias="file"), detections: DetectionCRUD = Depends(get_detection_crud), webhooks: WebhookCRUD = Depends(get_webhook_crud), @@ -80,7 +81,9 @@ async def create_detection( # Upload media bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) det = await detections.create( - DetectionCreate(camera_id=token_payload.sub, bucket_key=bucket_key, azimuth=azimuth, bboxes=bboxes) + DetectionCreate( + camera_id=token_payload.sub, pose_id=pose_id, bucket_key=bucket_key, azimuth=azimuth, bboxes=bboxes + ) ) # Sequence handling # Check if there is a sequence that was seen recently @@ -119,6 +122,7 @@ async def create_detection( sequence_ = await sequences.create( Sequence( camera_id=token_payload.sub, + pose_id=pose_id, azimuth=det.azimuth, started_at=dets_[0].created_at, last_seen_at=det.created_at, From c7e700548099cb790b142efbdc88929aa716c1f5 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:50:50 +0100 Subject: [PATCH 09/33] Updated camera endpoints --- src/app/api/api_v1/endpoints/cameras.py | 49 ++++++++++++++++++------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index 52e00b52..3a49c144 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -8,15 +8,22 @@ from typing import List, cast from fastapi import APIRouter, Depends, File, HTTPException, Path, Security, UploadFile, status -from pydantic import Field -from app.api.dependencies import get_camera_crud, get_jwt +from app.api.dependencies import get_camera_crud, get_jwt, get_pose_crud from app.core.config import settings from app.core.security import create_access_token -from app.crud import CameraCRUD +from app.crud import CameraCRUD, PoseCRUD from app.models import Camera, Role, UserRole -from app.schemas.cameras import CameraCreate, CameraEdit, CameraName, LastActive, LastImage +from app.schemas.cameras import ( + CameraCreate, + CameraEdit, + CameraName, + CameraRead, + LastActive, + LastImage, +) from app.schemas.login import Token, TokenPayload +from app.schemas.poses import PoseRead from app.services.storage import s3_service, upload_file from app.services.telemetry import telemetry_client @@ -35,34 +42,40 @@ async def register_camera( return await cameras.create(payload) -class CameraWithLastImgUrl(Camera): - last_image_url: str | None = Field(None, description="URL of the last image of the camera") - - @router.get("/{camera_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific camera") async def get_camera( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), + poses: PoseCRUD = Depends(get_pose_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), -) -> CameraWithLastImgUrl: +) -> CameraRead: telemetry_client.capture(token_payload.sub, event="cameras-get", properties={"camera_id": camera_id}) camera = cast(Camera, await cameras.get(camera_id, strict=True)) if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + cam_poses = await poses.fetch_all( + filters=("camera_id", camera_id), + order_by="id", + ) if camera.last_image is None: - return CameraWithLastImgUrl(**camera.model_dump(), last_image_url=None) + return CameraRead( + **camera.model_dump(), last_image_url=None, poses=[PoseRead(**p.model_dump()) for p in cam_poses] + ) bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) - return CameraWithLastImgUrl( + return CameraRead( **camera.model_dump(), last_image_url=bucket.get_public_url(camera.last_image), + poses=[PoseRead(**p.model_dump()) for p in cam_poses], ) @router.get("/", status_code=status.HTTP_200_OK, summary="Fetch all the cameras") async def fetch_cameras( cameras: CameraCRUD = Depends(get_camera_crud), + poses: PoseCRUD = Depends(get_pose_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), -) -> List[CameraWithLastImgUrl]: +) -> List[CameraRead]: telemetry_client.capture(token_payload.sub, event="cameras-fetch") if UserRole.ADMIN in token_payload.scopes: cams = [elt for elt in await cameras.fetch_all(order_by="id")] @@ -89,7 +102,17 @@ async def get_url_for_cam_single_bucket(cam: Camera) -> str | None: # noqa: RUF return None urls = await asyncio.gather(*[get_url_for_cam_single_bucket(cam) for cam in cams]) - return [CameraWithLastImgUrl(**cam.model_dump(), last_image_url=url) for cam, url in zip(cams, urls)] + + async def get_poses(cam: Camera) -> list[PoseRead]: + p = await poses.fetch_all(filters=("camera_id", cam.id)) + return [PoseRead(**elt.model_dump()) for elt in p] + + poses_list = await asyncio.gather(*[get_poses(cam) for cam in cams]) + + return [ + CameraRead(**cam.model_dump(), last_image_url=url, poses=cam_poses) + for cam, url, cam_poses in zip(cams, urls, poses_list) + ] @router.patch("/heartbeat", status_code=status.HTTP_200_OK, summary="Update last ping of a camera") From 889c9f659163d05eab66f125e68b245dfed553f9 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:51:17 +0100 Subject: [PATCH 10/33] Updated conftest --- src/tests/conftest.py | 73 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index ed02ddcc..a7b80f8b 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -18,7 +18,7 @@ from app.core.security import create_access_token from app.db import engine from app.main import app -from app.models import Camera, Detection, Organization, Sequence, User, Webhook +from app.models import Camera, Detection, Organization, Pose, Sequence, User, Webhook from app.services.storage import s3_service dt_format = "%Y-%m-%dT%H:%M:%S.%f" @@ -94,10 +94,33 @@ }, ] +POSE_TABLE = [ + { + "id": 1, + "camera_id": 1, + "azimuth": 45.0, + "patrol_id": "P1", + }, + { + "id": 2, + "camera_id": 1, + "azimuth": 90.0, + "patrol_id": "P1", + }, + { + "id": 3, + "camera_id": 2, + "azimuth": 180.0, + "patrol_id": "P1", + }, +] + + DET_TABLE = [ { "id": 1, "camera_id": 1, + "pose_id": 1, "sequence_id": 1, "azimuth": 43.7, "bucket_key": "my_file", @@ -107,6 +130,7 @@ { "id": 2, "camera_id": 1, + "pose_id": 1, "sequence_id": 1, "azimuth": 43.7, "bucket_key": "my_file", @@ -116,6 +140,7 @@ { "id": 3, "camera_id": 1, + "pose_id": 1, "sequence_id": 1, "azimuth": 43.7, "bucket_key": "my_file", @@ -125,6 +150,7 @@ { "id": 4, "camera_id": 2, + "pose_id": 3, "sequence_id": 2, "azimuth": 74.8, "bucket_key": "my_file", @@ -137,6 +163,7 @@ { "id": 1, "camera_id": 1, + "pose_id": 1, "azimuth": 43.7, "is_wildfire": "wildfire_smoke", "started_at": datetime.strptime("2023-11-07T15:08:19.226673", dt_format), @@ -145,6 +172,7 @@ { "id": 2, "camera_id": 2, + "pose_id": 3, "azimuth": 74.8, "is_wildfire": None, "started_at": datetime.strptime("2023-11-07T16:08:19.226673", dt_format), @@ -279,14 +307,12 @@ async def camera_session(user_session: AsyncSession, organization_session: Async @pytest_asyncio.fixture(scope="function") -async def sequence_session(camera_session: AsyncSession): - for entry in SEQ_TABLE: - camera_session.add(Sequence(**entry)) +async def pose_session(camera_session: AsyncSession): + for entry in POSE_TABLE: + camera_session.add(Pose(**entry)) await camera_session.commit() await camera_session.exec( - text( - f"ALTER SEQUENCE {Sequence.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in SEQ_TABLE) + 1}" - ) + text(f"ALTER SEQUENCE {Pose.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in POSE_TABLE) + 1}") ) await camera_session.commit() yield camera_session @@ -294,23 +320,38 @@ async def sequence_session(camera_session: AsyncSession): @pytest_asyncio.fixture(scope="function") -async def detection_session(user_session: AsyncSession, sequence_session: AsyncSession): +async def sequence_session(pose_session: AsyncSession): + for entry in SEQ_TABLE: + pose_session.add(Sequence(**entry)) + await pose_session.commit() + await pose_session.exec( + text( + f"ALTER SEQUENCE {Sequence.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in SEQ_TABLE) + 1}" + ) + ) + await pose_session.commit() + yield pose_session + await pose_session.rollback() + + +@pytest_asyncio.fixture(scope="function") +async def detection_session(pose_session: AsyncSession, sequence_session: AsyncSession): for entry in DET_TABLE: - user_session.add(Detection(**entry)) - await user_session.commit() + sequence_session.add(Detection(**entry)) + await sequence_session.commit() # Update the detection index count - await user_session.exec( + await sequence_session.exec( text( f"ALTER SEQUENCE {Detection.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in DET_TABLE) + 1}" ) ) - await user_session.commit() + await sequence_session.commit() # Create bucket files for entry in DET_TABLE: bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(entry["camera_id"])) bucket.upload_file(entry["bucket_key"], io.BytesIO(b"")) - yield user_session - await user_session.rollback() + yield sequence_session + await sequence_session.rollback() # Delete bucket files try: for entry in DET_TABLE: @@ -342,6 +383,10 @@ def pytest_configure(): {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in CAM_TABLE ] + pytest.pose_table = [ + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + for entry in POSE_TABLE + ] pytest.detection_table = [ {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in DET_TABLE From 013cb7fcd0d7af38fcd77b435cd71d22160ab398 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:51:43 +0100 Subject: [PATCH 11/33] Added tests for poses --- src/tests/endpoints/test_poses.py | 223 ++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/tests/endpoints/test_poses.py diff --git a/src/tests/endpoints/test_poses.py b/src/tests/endpoints/test_poses.py new file mode 100644 index 00000000..a0075881 --- /dev/null +++ b/src/tests/endpoints/test_poses.py @@ -0,0 +1,223 @@ +from typing import Any, Dict, Union + +import pytest +from httpx import AsyncClient +from sqlmodel.ext.asyncio.session import AsyncSession + + +@pytest.mark.parametrize( + ("user_idx", "payload", "status_code", "status_detail"), + [ + ( + None, + {"camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, + 401, + "Not authenticated", + ), + ( + 0, + {"camera_id": 1, "patrol_id": "P1"}, + 422, + None, + ), + ( + 0, + {"camera_id": 999, "azimuth": 45.0, "patrol_id": "P1"}, + 404, + "Table Camera has no corresponding entry.", + ), + ( + 2, # org 2 + {"camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, # camera 1 = org 1 + 403, + "Incompatible token scope.", + ), + ( + 0, + {"camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, + 201, + None, + ), + ( + 1, + {"camera_id": 1, "azimuth": 90.0, "patrol_id": "PX"}, + 201, + None, + ), + ], +) +@pytest.mark.asyncio +async def test_create_pose( + async_client: AsyncClient, + camera_session: AsyncSession, + user_idx: Union[int, None], + payload: Dict[str, Any], + status_code: int, + status_detail: Union[str, None], +): + auth = None + if isinstance(user_idx, int): + auth = pytest.get_token( + pytest.user_table[user_idx]["id"], + pytest.user_table[user_idx]["role"].split(), + pytest.user_table[user_idx]["organization_id"], + ) + + response = await async_client.post("/poses", json=payload, headers=auth) + assert response.status_code == status_code, print(response.__dict__) + if isinstance(status_detail, str): + assert response.json()["detail"] == status_detail + + if response.status_code == 201: + json_resp = response.json() + + assert "id" in json_resp + assert json_resp["camera_id"] == payload["camera_id"] + assert json_resp["azimuth"] == payload["azimuth"] + assert json_resp.get("patrol_id") == payload.get("patrol_id") + + +@pytest.mark.parametrize( + ("user_idx", "pose_id", "status_code", "status_detail", "expected_pose"), + [ + (None, 1, 401, "Not authenticated", None), + (0, 0, 422, None, None), + (0, 999, 404, "Table Pose has no corresponding entry.", None), + (2, 1, 403, "Access forbidden.", None), + ( + 0, + 1, + 200, + None, + {"id": 1, "camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, + ), + ( + 1, + 2, + 200, + None, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": "P1"}, + ), + ], +) +@pytest.mark.asyncio +async def test_get_pose( + async_client: AsyncClient, + camera_session: AsyncSession, + pose_session: AsyncSession, + user_idx: Union[int, None], + pose_id: int, + status_code: int, + status_detail: Union[str, None], + expected_pose: Union[dict, None], +): + auth = None + if isinstance(user_idx, int): + auth = pytest.get_token( + pytest.user_table[user_idx]["id"], + pytest.user_table[user_idx]["role"].split(), + pytest.user_table[user_idx]["organization_id"], + ) + + response = await async_client.get(f"/poses/{pose_id}", headers=auth) + assert response.status_code == status_code, print(response.__dict__) + + if isinstance(status_detail, str): + assert response.json()["detail"] == status_detail + + if response.status_code == 200: + json_resp = response.json() + assert json_resp == expected_pose + + +@pytest.mark.parametrize( + ("user_idx", "pose_id", "payload", "status_code", "status_detail", "expected_updated"), + [ + (None, 1, {"azimuth": 50.0}, 401, "Not authenticated", None), + (0, 0, {"azimuth": 50.0}, 422, None, None), + (0, 999, {"azimuth": 50.0}, 404, "Table Pose has no corresponding entry.", None), + (2, 1, {"azimuth": 50.0}, 403, "Incompatible token scope.", None), + ( + 0, + 1, + {"azimuth": 123.4, "patrol_id": "PX"}, + 200, + None, + {"id": 1, "camera_id": 1, "azimuth": 123.4, "patrol_id": "PX"}, + ), + ( + 1, + 2, + {"patrol_id": "UPDATED"}, + 200, + None, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": "UPDATED"}, + ), + ], +) +@pytest.mark.asyncio +async def test_update_pose( + async_client: AsyncClient, + camera_session: AsyncSession, + pose_session: AsyncSession, + user_idx: Union[int, None], + pose_id: int, + payload: dict, + status_code: int, + status_detail: Union[str, None], + expected_updated: Union[dict, None], +): + auth = None + if isinstance(user_idx, int): + auth = pytest.get_token( + pytest.user_table[user_idx]["id"], + pytest.user_table[user_idx]["role"].split(), + pytest.user_table[user_idx]["organization_id"], + ) + + response = await async_client.patch(f"/poses/{pose_id}", json=payload, headers=auth) + assert response.status_code == status_code, print(response.__dict__) + + if isinstance(status_detail, str): + assert response.json()["detail"] == status_detail + + if response.status_code == 200: + json_resp = response.json() + assert json_resp == expected_updated + + +@pytest.mark.parametrize( + ("user_idx", "pose_id", "status_code", "status_detail"), + [ + (None, 1, 401, "Not authenticated"), + (0, 0, 422, None), + (1, 1, 403, "Incompatible token scope."), + (2, 1, 403, "Incompatible token scope."), + (0, 999, 404, "Table Pose has no corresponding entry."), + (0, 1, 200, None), + ], +) +@pytest.mark.asyncio +async def test_delete_pose( + async_client: AsyncClient, + camera_session: AsyncSession, + pose_session: AsyncSession, + user_idx: Union[int, None], + pose_id: int, + status_code: int, + status_detail: Union[str, None], +): + auth = None + if isinstance(user_idx, int): + auth = pytest.get_token( + pytest.user_table[user_idx]["id"], + pytest.user_table[user_idx]["role"].split(), + pytest.user_table[user_idx]["organization_id"], + ) + + response = await async_client.delete(f"/poses/{pose_id}", headers=auth) + + assert response.status_code == status_code, print(response.__dict__) + + if isinstance(status_detail, str): + assert response.json()["detail"] == status_detail From e1818499a2c31a304a057473a977ea3f57b1b7d6 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:52:02 +0100 Subject: [PATCH 12/33] Updates detections and cameras tests --- src/tests/endpoints/test_cameras.py | 61 ++++++++++++++++++++------ src/tests/endpoints/test_detections.py | 27 ++++++++++-- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/tests/endpoints/test_cameras.py b/src/tests/endpoints/test_cameras.py index ab2061e9..41a2ea51 100644 --- a/src/tests/endpoints/test_cameras.py +++ b/src/tests/endpoints/test_cameras.py @@ -98,25 +98,38 @@ async def test_create_camera( @pytest.mark.parametrize( - ("user_idx", "cam_id", "status_code", "status_detail", "expected_idx"), + ("user_idx", "cam_id", "status_code", "status_detail", "expected_idx", "expected_poses"), [ - (None, 1, 401, "Not authenticated", None), - (0, 0, 422, None, None), - (0, 100, 404, "Table Camera has no corresponding entry.", None), - (0, 1, 200, None, 0), - (1, 1, 200, None, 0), - (2, 1, 403, "Access forbidden.", 0), + (None, 1, 401, "Not authenticated", None, None), + (0, 0, 422, None, None, None), + (0, 100, 404, "Table Camera has no corresponding entry.", None, None), + (0, 1, 200, None, 0, None), + (1, 1, 200, None, 0, None), + (2, 1, 403, "Access forbidden.", 0, None), + ( + 0, + 1, + 200, + None, + 0, + [ + {"id": 1, "camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": "P1"}, + ], + ), ], ) @pytest.mark.asyncio async def test_get_camera( async_client: AsyncClient, camera_session: AsyncSession, + pose_session: AsyncSession, user_idx: Union[int, None], cam_id: int, status_code: int, status_detail: Union[str, None], expected_idx: Union[int, None], + expected_poses: Union[list, None], ): auth = None if isinstance(user_idx, int): @@ -133,26 +146,34 @@ async def test_get_camera( if response.status_code // 100 == 2: json_response = response.json() assert isinstance(json_response["last_image_url"], str) or json_response["last_image_url"] is None - assert {k: v for k, v in json_response.items() if k != "last_image_url"} == pytest.camera_table[expected_idx] + + assert "poses" in json_response + + if expected_poses is not None: + assert "poses" in json_response + assert isinstance(json_response["poses"], list) + assert json_response["poses"] == expected_poses @pytest.mark.parametrize( - ("user_idx", "status_code", "status_detail", "expected_response"), + ("user_idx", "status_code", "status_detail", "expected_response", "expected_poses"), [ - (None, 401, "Not authenticated", None), - (0, 200, None, pytest.camera_table[0]), - (1, 200, None, pytest.camera_table[0]), - (2, 200, None, pytest.camera_table[1]), + (None, 401, "Not authenticated", None, None), + (0, 200, None, pytest.camera_table[0], [pytest.pose_table[0], pytest.pose_table[1]]), + (1, 200, None, pytest.camera_table[0], [pytest.pose_table[0], pytest.pose_table[1]]), + (2, 200, None, pytest.camera_table[1], [pytest.pose_table[2]]), ], ) @pytest.mark.asyncio async def test_fetch_cameras( async_client: AsyncClient, camera_session: AsyncSession, + pose_session: AsyncSession, user_idx: Union[int, None], status_code: int, status_detail: Union[str, None], expected_response: Union[List[Dict[str, Any]], None], + expected_poses: Union[list, None], ): auth = None if isinstance(user_idx, int): @@ -168,7 +189,19 @@ async def test_fetch_cameras( assert response.json()["detail"] == status_detail if response.status_code // 100 == 2: json_response = response.json() - assert {k: v for k, v in json_response[0].items() if k != "last_image_url"} == expected_response + + for cam in json_response: + assert "poses" in cam + assert isinstance(cam["poses"], list) + + assert json_response[0]["poses"] == expected_poses + + print("dico reformeted sans poses last image url ") + print({k: v for k, v in json_response[0].items() if k not in {"last_image_url", "poses"}}) + print("expected") + print(expected_response) + assert {k: v for k, v in json_response[0].items() if k not in {"last_image_url", "poses"}} == expected_response + assert isinstance(json_response[0]["last_image_url"], str) or json_response[0]["last_image_url"] is None diff --git a/src/tests/endpoints/test_detections.py b/src/tests/endpoints/test_detections.py index 6d32c87c..dd4864b1 100644 --- a/src/tests/endpoints/test_detections.py +++ b/src/tests/endpoints/test_detections.py @@ -18,10 +18,31 @@ (None, 1, {"azimuth": 45.6, "bboxes": (0.6, 0.6, 0.6, 0.6, 0.6)}, 422, None, None), (None, 1, {"azimuth": 45.6, "bboxes": "[(0.6, 0.6, 0.6, 0.6, 0.6)]"}, 422, None, None), (None, 1, {"azimuth": 360, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]"}, 422, None, None), - (None, 1, {"azimuth": 45.6, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]", "sequence_id": None}, 201, None, 0), - (None, 1, {"azimuth": 0, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]", "sequence_id": None}, 201, None, 0), + ( + None, + 1, + {"azimuth": 45.6, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]", "pose_id": 3, "sequence_id": None}, + 201, + None, + 0, + ), + ( + None, + 1, + {"azimuth": 0, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]", "pose_id": 3, "sequence_id": None}, + 201, + None, + 0, + ), # sequence creation - (None, 1, {"azimuth": 45.6, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]", "sequence_id": None}, 201, None, 2), + ( + None, + 1, + {"azimuth": 45.6, "bboxes": "[(0.6,0.6,0.7,0.7,0.6)]", "pose_id": 3, "sequence_id": None}, + 201, + None, + 2, + ), ], ) @pytest.mark.asyncio From fe7df7179d92edb2599ab971ae13c891e1e2fca1 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:52:39 +0100 Subject: [PATCH 13/33] Updated client --- client/pyroclient/client.py | 50 ++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 3a7af953..c80dcb54 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -22,6 +22,9 @@ class ClientRoute(str, Enum): CAMERAS_HEARTBEAT = "cameras/heartbeat" CAMERAS_IMAGE = "cameras/image" CAMERAS_FETCH = "cameras/" + # POSES + POSES_CREATE = "poses/" + POSES_ID = "poses/{pose_id}" # DETECTIONS DETECTIONS_CREATE = "detections/" DETECTIONS_FETCH = "detections" @@ -148,11 +151,54 @@ def update_last_image(self, media: bytes) -> Response: timeout=self.timeout, ) + # POSES + def create_pose(self, camera_id: int, azimuth: float, patrol_id: str) -> Response: + """Create a pose associated with a camera.""" + return requests.post( + urljoin(self._route_prefix, ClientRoute.POSES_CREATE), + headers=self.headers, + json={ + "camera_id": camera_id, + "azimuth": azimuth, + "patrol_id": patrol_id, + }, + timeout=self.timeout, + ) + + def get_pose(self, pose_id: int) -> Response: + """Retrieve a pose by its ID.""" + return requests.get( + urljoin(self._route_prefix, ClientRoute.POSES_ID.format(pose_id=pose_id)), + headers=self.headers, + timeout=self.timeout, + ) + + def patch_pose(self, pose_id: int, azimuth: float, patrol_id: str) -> Response: + """Update an existing pose.""" + return requests.patch( + urljoin(self._route_prefix, ClientRoute.POSES_ID.format(pose_id=pose_id)), + headers=self.headers, + json={ + "azimuth": azimuth, + "patrol_id": patrol_id, + }, + timeout=self.timeout, + ) + + def delete_pose(self, pose_id: int) -> Response: + """Delete a pose by its ID.""" + return requests.delete( + urljoin(self._route_prefix, ClientRoute.POSES_ID.format(pose_id=pose_id)), + headers=self.headers, + timeout=self.timeout, + ) + # DETECTIONS def create_detection( self, media: bytes, azimuth: float, + pose_id: int, bboxes: List[Tuple[float, float, float, float, float]], ) -> Response: """Notify the detection of a wildfire on the picture taken by a camera. @@ -160,11 +206,12 @@ def create_detection( >>> from pyroclient import Client >>> api_client = Client("MY_CAM_TOKEN") >>> with open("path/to/my/file.ext", "rb") as f: data = f.read() - >>> response = api_client.create_detection(data, azimuth=124.2, bboxes=[(.1,.1,.5,.8,.5)]) + >>> response = api_client.create_detection(data, azimuth=124.2, pose_id=1, bboxes=[(.1,.1,.5,.8,.5)]) Args: media: byte data of the picture azimuth: the azimuth of the camera when the picture was taken + pose_id: the pose_id of the camera when the picture was taken bboxes: list of tuples where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf Returns: @@ -177,6 +224,7 @@ def create_detection( headers=self.headers, data={ "azimuth": azimuth, + "pose_id": pose_id, "bboxes": _dump_bbox_to_json(bboxes), }, timeout=self.timeout, From f3ec02078cad9fbc92931c8782d2072902b89bea Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:17:28 +0100 Subject: [PATCH 14/33] Updates db diagram --- scripts/dbdiagram.txt | 122 ++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 64 deletions(-) diff --git a/scripts/dbdiagram.txt b/scripts/dbdiagram.txt index 94ac99ee..28dd237f 100644 --- a/scripts/dbdiagram.txt +++ b/scripts/dbdiagram.txt @@ -1,77 +1,71 @@ -Enum "userrole" { - "admin" - "agent" - "user" +Table organizations { + id int [pk, increment] + name varchar(100) [not null, unique] + telegram_id varchar [null] + slack_hook varchar [null] } -Table "User" as U { - "id" int [not null] - "organization_id" int [ref: > O.id, not null] - "role" userrole [not null] - "login" varchar [not null] - "hashed_password" varchar [not null] - "created_at" timestamp [not null] - Indexes { - (id, login) [pk] - } +Table users { + id int [pk, increment] + organization_id int [not null] + role varchar(50) [not null] + login varchar(50) [not null, unique] + hashed_password varchar(70) [not null] + created_at timestamp [not null] } -Table "Camera" as C { - "id" int [not null] - "organization_id" int [ref: > O.id, not null] - "name" varchar [not null] - "angle_of_view" float [not null] - "elevation" float [not null] - "lat" float [not null] - "lon" float [not null] - "is_trustable" bool [not null] - "created_at" timestamp [not null] - "last_active_at" timestamp - "last_image" varchar - Indexes { - (id) [pk] - } +Table cameras { + id int [pk, increment] + organization_id int [not null] + name varchar(100) [not null, unique] + angle_of_view float [not null] + elevation float [not null] + lat float [not null] + lon float [not null] + is_trustable boolean [not null] + last_active_at timestamp + last_image text + created_at timestamp [not null] } -Table "Sequence" as S { - "id" int [not null] - "camera_id" int [ref: > C.id, not null] - "azimuth" float [not null] - "is_wildfire" AnnotationType - "started_at" timestamp [not null] - "last_seen_at" timestamp [not null] - Indexes { - (id) [pk] - } +Table poses { + id int [pk, increment] + camera_id int [not null] + azimuth float [not null] + patrol_id varchar(100) } -Table "Detection" as D { - "id" int [not null] - "camera_id" int [ref: > C.id, not null] - "sequence_id" int [ref: > S.id] - "azimuth" float [not null] - "bucket_key" varchar [not null] - "bboxes" varchar [not null] - "created_at" timestamp [not null] - Indexes { - (id) [pk] - } +Table sequences { + id int [pk, increment] + camera_id int [not null] + pose_id int + azimuth float [not null] + is_wildfire varchar(50) + started_at timestamp [not null] + last_seen_at timestamp [not null] } -Table "Organization" as O { - "id" int [not null] - "name" varchar [not null] - "telegram_id" varchar - Indexes { - (id) [pk] - } +Table detections { + id int [pk, increment] + camera_id int [not null] + pose_id int + sequence_id int + azimuth float [not null] + bucket_key varchar [not null] + bboxes text [not null] + created_at timestamp [not null] } - -Table "Webhook" as W { - "id" int [not null] - "url" varchar [not null] - Indexes { - (id) [pk] - } +Table webhooks { + id int [pk, increment] + url varchar [not null, unique] } + +Ref: users.organization_id > organizations.id +Ref: cameras.organization_id > organizations.id +Ref: poses.camera_id > cameras.id +Ref: sequences.camera_id > cameras.id +Ref: sequences.pose_id > poses.id +Ref: detections.camera_id > cameras.id +Ref: detections.pose_id > poses.id +Ref: detections.sequence_id > sequences.id From 8377f01654719cf74883ef8afb37b43d18d83141 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:26:58 +0100 Subject: [PATCH 15/33] update test end to end --- scripts/test_e2e.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/test_e2e.py b/scripts/test_e2e.py index 74aea706..18303c91 100644 --- a/scripts/test_e2e.py +++ b/scripts/test_e2e.py @@ -89,6 +89,13 @@ def main(args): cam_auth = {"Authorization": f"Bearer {cam_token}"} + # Create a camera pose + payload = { + "camera_id": cam_id, + "azimuth": 45, + } + pose_id = api_request("post", f"{args.endpoint}/poses/", agent_auth, payload)["id"] + # Take a picture file_bytes = requests.get("https://pyronear.org/img/logo.png", timeout=5).content # Update cam last image @@ -110,7 +117,7 @@ def main(args): response = requests.post( f"{args.endpoint}/detections", headers=cam_auth, - data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]"}, + data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]", "pose_id": pose_id}, files={"file": ("logo.png", file_bytes, "image/png")}, timeout=5, ) @@ -126,14 +133,14 @@ def main(args): det_id_2 = requests.post( f"{args.endpoint}/detections", headers=cam_auth, - data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]"}, + data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]", "pose_id": pose_id}, files={"file": ("logo.png", file_bytes, "image/png")}, timeout=5, ).json()["id"] det_id_3 = requests.post( f"{args.endpoint}/detections", headers=cam_auth, - data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]"}, + data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]", "pose_id": pose_id}, files={"file": ("logo.png", file_bytes, "image/png")}, timeout=5, ).json()["id"] @@ -173,6 +180,7 @@ def main(args): api_request("delete", f"{args.endpoint}/detections/{det_id_2}/", superuser_auth) api_request("delete", f"{args.endpoint}/detections/{det_id_3}/", superuser_auth) api_request("delete", f"{args.endpoint}/sequences/{sequence['id']}/", superuser_auth) + api_request("delete", f"{args.endpoint}/poses/{pose_id}/", superuser_auth) api_request("delete", f"{args.endpoint}/cameras/{cam_id}/", superuser_auth) api_request("delete", f"{args.endpoint}/users/{user_id}/", superuser_auth) api_request("delete", f"{args.endpoint}/organizations/{org_id}/", superuser_auth) From c243e2a556e6ddd7b141987c0e9553d72f96b24d Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:40:59 +0100 Subject: [PATCH 16/33] mypy fixes --- src/app/api/api_v1/endpoints/poses.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/api/api_v1/endpoints/poses.py b/src/app/api/api_v1/endpoints/poses.py index 5c305cac..7a2bb8ca 100644 --- a/src/app/api/api_v1/endpoints/poses.py +++ b/src/app/api/api_v1/endpoints/poses.py @@ -3,7 +3,7 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. -from fastapi import APIRouter, Depends, HTTPException, Path, Security, status +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Security, status from app.api.dependencies import get_camera_crud, get_jwt, get_pose_crud from app.crud import CameraCRUD, PoseCRUD @@ -17,7 +17,7 @@ @router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new pose for a camera") async def create_pose( - payload: PoseCreate, + payload: PoseCreate = Body(...), poses: PoseCRUD = Depends(get_pose_crud), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), @@ -33,7 +33,8 @@ async def create_pose( if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") - return await poses.create(payload) + db_pose = await poses.create(payload) + return PoseRead(**db_pose.model_dump()) @router.get("/{pose_id}", status_code=status.HTTP_200_OK, summary="Fetch information of a specific pose") @@ -57,7 +58,7 @@ async def get_pose( @router.patch("/{pose_id}", status_code=status.HTTP_200_OK, summary="Update a pose") async def update_pose( pose_id: int = Path(..., gt=0), - payload: PoseUpdate = ..., + payload: PoseUpdate = Body(...), poses: PoseCRUD = Depends(get_pose_crud), cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.AGENT, UserRole.ADMIN]), @@ -68,7 +69,8 @@ async def update_pose( if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") - return await poses.update(pose_id, payload) + db_pose = await poses.update(pose_id, payload) + return PoseRead(**db_pose.model_dump()) @router.delete("/{pose_id}", status_code=status.HTTP_200_OK, summary="Delete a pose") From 293c730bd2c2ba2b3eb68f5320d5fe12ace63669 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:51:27 +0100 Subject: [PATCH 17/33] fix codacy --- src/app/crud/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/crud/__init__.py b/src/app/crud/__init__.py index f343a7a8..730a61ad 100644 --- a/src/app/crud/__init__.py +++ b/src/app/crud/__init__.py @@ -1,6 +1,6 @@ from .crud_user import * from .crud_camera import * -from .crud_pose import * +from .crud_pose import PoseCRUD from .crud_detection import * from .crud_organization import * from .crud_sequence import * From 03e2c01a8e5f87382f08988e8432b32c4a0e8223 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:06:56 +0100 Subject: [PATCH 18/33] fix mypy --- src/app/api/api_v1/endpoints/poses.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/api/api_v1/endpoints/poses.py b/src/app/api/api_v1/endpoints/poses.py index 7a2bb8ca..2d0fface 100644 --- a/src/app/api/api_v1/endpoints/poses.py +++ b/src/app/api/api_v1/endpoints/poses.py @@ -2,12 +2,13 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. +from typing import cast from fastapi import APIRouter, Body, Depends, HTTPException, Path, Security, status from app.api.dependencies import get_camera_crud, get_jwt, get_pose_crud from app.crud import CameraCRUD, PoseCRUD -from app.models import UserRole +from app.models import Camera, Pose, UserRole from app.schemas.login import TokenPayload from app.schemas.poses import PoseCreate, PoseRead, PoseUpdate from app.services.telemetry import telemetry_client @@ -28,7 +29,7 @@ async def create_pose( properties={"camera_id": payload.camera_id, "azimuth": payload.azimuth}, ) - camera = await cameras.get(payload.camera_id, strict=True) + camera = cast(Camera, await cameras.get(payload.camera_id, strict=True)) if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") @@ -46,8 +47,8 @@ async def get_pose( ) -> PoseRead: telemetry_client.capture(token_payload.sub, event="poses-get", properties={"pose_id": pose_id}) - pose = await poses.get(pose_id, strict=True) - camera = await cameras.get(pose.camera_id, strict=True) + pose = cast(Pose, await poses.get(pose_id, strict=True)) + camera = cast(Camera, await cameras.get(pose.camera_id, strict=True)) if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") @@ -63,8 +64,8 @@ async def update_pose( cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.AGENT, UserRole.ADMIN]), ) -> PoseRead: - pose = await poses.get(pose_id, strict=True) - camera = await cameras.get(pose.camera_id, strict=True) + pose = cast(Pose, await poses.get(pose_id, strict=True)) + camera = cast(Camera, await cameras.get(pose.camera_id, strict=True)) if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") From c38e738d02e583990e103869f48c91e937655da8 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:15:45 +0100 Subject: [PATCH 19/33] fix codacy and mypy with more explicit import --- src/app/api/api_v1/endpoints/cameras.py | 3 ++- src/app/api/api_v1/endpoints/poses.py | 3 ++- src/app/api/dependencies.py | 3 ++- src/app/crud/__init__.py | 1 - 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index 3a49c144..552c6562 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -12,7 +12,8 @@ from app.api.dependencies import get_camera_crud, get_jwt, get_pose_crud from app.core.config import settings from app.core.security import create_access_token -from app.crud import CameraCRUD, PoseCRUD +from app.crud import CameraCRUD +from app.crud.crud_pose import PoseCRUD from app.models import Camera, Role, UserRole from app.schemas.cameras import ( CameraCreate, diff --git a/src/app/api/api_v1/endpoints/poses.py b/src/app/api/api_v1/endpoints/poses.py index 2d0fface..e150d690 100644 --- a/src/app/api/api_v1/endpoints/poses.py +++ b/src/app/api/api_v1/endpoints/poses.py @@ -7,7 +7,8 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Path, Security, status from app.api.dependencies import get_camera_crud, get_jwt, get_pose_crud -from app.crud import CameraCRUD, PoseCRUD +from app.crud import CameraCRUD +from app.crud.crud_pose import PoseCRUD from app.models import Camera, Pose, UserRole from app.schemas.login import TokenPayload from app.schemas.poses import PoseCreate, PoseRead, PoseUpdate diff --git a/src/app/api/dependencies.py b/src/app/api/dependencies.py index c9e482f1..ddb47bfe 100644 --- a/src/app/api/dependencies.py +++ b/src/app/api/dependencies.py @@ -15,7 +15,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.core.config import settings -from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, PoseCRUD, SequenceCRUD, UserCRUD, WebhookCRUD +from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, SequenceCRUD, UserCRUD, WebhookCRUD +from app.crud.crud_pose import PoseCRUD from app.db import get_session from app.models import User, UserRole from app.schemas.login import TokenPayload diff --git a/src/app/crud/__init__.py b/src/app/crud/__init__.py index 730a61ad..690261f0 100644 --- a/src/app/crud/__init__.py +++ b/src/app/crud/__init__.py @@ -1,6 +1,5 @@ from .crud_user import * from .crud_camera import * -from .crud_pose import PoseCRUD from .crud_detection import * from .crud_organization import * from .crud_sequence import * From 693b4e1f185db9743f6216c3da6d539c5877c51c Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:22:39 +0100 Subject: [PATCH 20/33] quick fix test client --- client/tests/test_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/tests/test_client.py b/client/tests/test_client.py index bb9ff1c1..6a095469 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -37,14 +37,14 @@ def test_cam_workflow(cam_token, mock_img): assert isinstance(response.json()["last_image"], str) # Check that adding bboxes works with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): - cam_client.create_detection(mock_img, 123.2, None) + cam_client.create_detection(mock_img, 123.2, 1, None) with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): - cam_client.create_detection(mock_img, 123.2, []) - response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)]) + cam_client.create_detection(mock_img, 123.2, 1, []) + response = cam_client.create_detection(mock_img, 12.2, 2, [(0, 0, 1.0, 0.9, 0.5)]) assert response.status_code == 201, response.__dict__ - response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5), (0.2, 0.2, 0.7, 0.7, 0.8)]) + response = cam_client.create_detection(mock_img, 123.2, 1, [(0, 0, 1.0, 0.9, 0.5), (0.2, 0.2, 0.7, 0.7, 0.8)]) assert response.status_code == 201, response.__dict__ - response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)]) + response = cam_client.create_detection(mock_img, 123.2, 1, [(0, 0, 1.0, 0.9, 0.5)]) assert response.status_code == 201, response.__dict__ return response.json()["id"] From 729b4b62610d0c784cbb4f7e63e60272b1e45dfd Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:00:48 +0100 Subject: [PATCH 21/33] revert client updates --- client/pyroclient/client.py | 50 +------------------------------------ client/tests/test_client.py | 10 ++++---- 2 files changed, 6 insertions(+), 54 deletions(-) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index c80dcb54..3a7af953 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -22,9 +22,6 @@ class ClientRoute(str, Enum): CAMERAS_HEARTBEAT = "cameras/heartbeat" CAMERAS_IMAGE = "cameras/image" CAMERAS_FETCH = "cameras/" - # POSES - POSES_CREATE = "poses/" - POSES_ID = "poses/{pose_id}" # DETECTIONS DETECTIONS_CREATE = "detections/" DETECTIONS_FETCH = "detections" @@ -151,54 +148,11 @@ def update_last_image(self, media: bytes) -> Response: timeout=self.timeout, ) - # POSES - def create_pose(self, camera_id: int, azimuth: float, patrol_id: str) -> Response: - """Create a pose associated with a camera.""" - return requests.post( - urljoin(self._route_prefix, ClientRoute.POSES_CREATE), - headers=self.headers, - json={ - "camera_id": camera_id, - "azimuth": azimuth, - "patrol_id": patrol_id, - }, - timeout=self.timeout, - ) - - def get_pose(self, pose_id: int) -> Response: - """Retrieve a pose by its ID.""" - return requests.get( - urljoin(self._route_prefix, ClientRoute.POSES_ID.format(pose_id=pose_id)), - headers=self.headers, - timeout=self.timeout, - ) - - def patch_pose(self, pose_id: int, azimuth: float, patrol_id: str) -> Response: - """Update an existing pose.""" - return requests.patch( - urljoin(self._route_prefix, ClientRoute.POSES_ID.format(pose_id=pose_id)), - headers=self.headers, - json={ - "azimuth": azimuth, - "patrol_id": patrol_id, - }, - timeout=self.timeout, - ) - - def delete_pose(self, pose_id: int) -> Response: - """Delete a pose by its ID.""" - return requests.delete( - urljoin(self._route_prefix, ClientRoute.POSES_ID.format(pose_id=pose_id)), - headers=self.headers, - timeout=self.timeout, - ) - # DETECTIONS def create_detection( self, media: bytes, azimuth: float, - pose_id: int, bboxes: List[Tuple[float, float, float, float, float]], ) -> Response: """Notify the detection of a wildfire on the picture taken by a camera. @@ -206,12 +160,11 @@ def create_detection( >>> from pyroclient import Client >>> api_client = Client("MY_CAM_TOKEN") >>> with open("path/to/my/file.ext", "rb") as f: data = f.read() - >>> response = api_client.create_detection(data, azimuth=124.2, pose_id=1, bboxes=[(.1,.1,.5,.8,.5)]) + >>> response = api_client.create_detection(data, azimuth=124.2, bboxes=[(.1,.1,.5,.8,.5)]) Args: media: byte data of the picture azimuth: the azimuth of the camera when the picture was taken - pose_id: the pose_id of the camera when the picture was taken bboxes: list of tuples where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf Returns: @@ -224,7 +177,6 @@ def create_detection( headers=self.headers, data={ "azimuth": azimuth, - "pose_id": pose_id, "bboxes": _dump_bbox_to_json(bboxes), }, timeout=self.timeout, diff --git a/client/tests/test_client.py b/client/tests/test_client.py index 6a095469..bb9ff1c1 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -37,14 +37,14 @@ def test_cam_workflow(cam_token, mock_img): assert isinstance(response.json()["last_image"], str) # Check that adding bboxes works with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): - cam_client.create_detection(mock_img, 123.2, 1, None) + cam_client.create_detection(mock_img, 123.2, None) with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): - cam_client.create_detection(mock_img, 123.2, 1, []) - response = cam_client.create_detection(mock_img, 12.2, 2, [(0, 0, 1.0, 0.9, 0.5)]) + cam_client.create_detection(mock_img, 123.2, []) + response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)]) assert response.status_code == 201, response.__dict__ - response = cam_client.create_detection(mock_img, 123.2, 1, [(0, 0, 1.0, 0.9, 0.5), (0.2, 0.2, 0.7, 0.7, 0.8)]) + response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5), (0.2, 0.2, 0.7, 0.7, 0.8)]) assert response.status_code == 201, response.__dict__ - response = cam_client.create_detection(mock_img, 123.2, 1, [(0, 0, 1.0, 0.9, 0.5)]) + response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)]) assert response.status_code == 201, response.__dict__ return response.json()["id"] From 6d55aab883d130d84daca63ebb230c1c8ffc7797 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:08:07 +0100 Subject: [PATCH 22/33] fix typos db diagram docs --- scripts/dbdiagram.txt | 145 +++++++++++++++++++++++++----------------- 1 file changed, 88 insertions(+), 57 deletions(-) diff --git a/scripts/dbdiagram.txt b/scripts/dbdiagram.txt index 28dd237f..6a4038fc 100644 --- a/scripts/dbdiagram.txt +++ b/scripts/dbdiagram.txt @@ -1,71 +1,102 @@ -Table organizations { - id int [pk, increment] - name varchar(100) [not null, unique] - telegram_id varchar [null] - slack_hook varchar [null] +Enum "userrole" { + "admin" + "agent" + "user" } -Table users { - id int [pk, increment] - organization_id int [not null] - role varchar(50) [not null] - login varchar(50) [not null, unique] - hashed_password varchar(70) [not null] - created_at timestamp [not null] +Enum "annotationtype" { + "wildfire_smoke" + "other_smoke" + "other" } -Table cameras { - id int [pk, increment] - organization_id int [not null] - name varchar(100) [not null, unique] - angle_of_view float [not null] - elevation float [not null] - lat float [not null] - lon float [not null] - is_trustable boolean [not null] - last_active_at timestamp - last_image text - created_at timestamp [not null] +Table "organizations" as O { + "id" int [not null] + "name" varchar [not null] + "telegram_id" varchar + "slack_hook" varchar + + Indexes { + (id) [pk] + } } -Table poses { - id int [pk, increment] - camera_id int [not null] - azimuth float [not null] - patrol_id varchar(100) +Table "users" as U { + "id" int [not null] + "organization_id" int [ref: > O.id, not null] + "role" userrole [not null] + "login" varchar [not null] + "hashed_password" varchar [not null] + "created_at" timestamp [not null] + + Indexes { + (id, login) [pk] + } +} + +Table "cameras" as C { + "id" int [not null] + "organization_id" int [ref: > O.id, not null] + "name" varchar [not null] + "angle_of_view" float [not null] + "elevation" float [not null] + "lat" float [not null] + "lon" float [not null] + "is_trustable" bool [not null] + "created_at" timestamp [not null] + "last_active_at" timestamp + "last_image" varchar + + Indexes { + (id) [pk] + } } -Table sequences { - id int [pk, increment] - camera_id int [not null] - pose_id int - azimuth float [not null] - is_wildfire varchar(50) - started_at timestamp [not null] - last_seen_at timestamp [not null] +Table "poses" as P { + "id" int [not null] + "camera_id" int [ref: > C.id, not null] + "azimuth" float [not null] + "patrol_id" int + + Indexes { + (id) [pk] + } } -Table detections { - id int [pk, increment] - camera_id int [not null] - pose_id int - sequence_id int - azimuth float [not null] - bucket_key varchar [not null] - bboxes text [not null] - created_at timestamp [not null] +Table "sequences" as S { + "id" int [not null] + "camera_id" int [ref: > C.id, not null] + "pose_id" int [ref: > P.id] + "azimuth" float [not null] + "is_wildfire" annotationtype + "started_at" timestamp [not null] + "last_seen_at" timestamp [not null] + + Indexes { + (id) [pk] + } } -Table webhooks { - id int [pk, increment] - url varchar [not null, unique] +Table "detections" as D { + "id" int [not null] + "camera_id" int [ref: > C.id, not null] + "pose_id" int [ref: > P.id] + "sequence_id" int [ref: > S.id] + "azimuth" float [not null] + "bucket_key" varchar [not null] + "bboxes" varchar [not null] + "created_at" timestamp [not null] + + Indexes { + (id) [pk] + } } -Ref: users.organization_id > organizations.id -Ref: cameras.organization_id > organizations.id -Ref: poses.camera_id > cameras.id -Ref: sequences.camera_id > cameras.id -Ref: sequences.pose_id > poses.id -Ref: detections.camera_id > cameras.id -Ref: detections.pose_id > poses.id -Ref: detections.sequence_id > sequences.id +Table "webhooks" as W { + "id" int [not null] + "url" varchar [not null] + + Indexes { + (id) [pk] + } +} From 4cb14ff6813b046e02b6cb24427b93c93c2a5f6e Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:08:31 +0100 Subject: [PATCH 23/33] updates patrol_id type from str to int --- src/app/models.py | 35 +++++++++++++++++++++++------------ src/app/schemas/poses.py | 21 +++++++++++++-------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/app/models.py b/src/app/models.py index f4839f10..94ccb684 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -36,19 +36,25 @@ class AnnotationType(str, Enum): class User(SQLModel, table=True): __tablename__ = "users" id: int = Field(None, primary_key=True) - organization_id: int = Field(..., foreign_key="organizations.id", nullable=False) + organization_id: int = Field(..., + foreign_key="organizations.id", nullable=False) role: UserRole = Field(UserRole.USER, nullable=False) # Allow sign-up/in via login + password - login: str = Field(..., index=True, unique=True, min_length=2, max_length=50, nullable=False) - hashed_password: str = Field(..., min_length=5, max_length=70, nullable=False) - created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + login: str = Field(..., index=True, unique=True, + min_length=2, max_length=50, nullable=False) + hashed_password: str = Field(..., min_length=5, + max_length=70, nullable=False) + created_at: datetime = Field( + default_factory=datetime.utcnow, nullable=False) class Camera(SQLModel, table=True): __tablename__ = "cameras" id: int = Field(None, primary_key=True) - organization_id: int = Field(..., foreign_key="organizations.id", nullable=False) - name: str = Field(..., min_length=5, max_length=100, nullable=False, unique=True) + organization_id: int = Field(..., + foreign_key="organizations.id", nullable=False) + name: str = Field(..., min_length=5, max_length=100, + nullable=False, unique=True) angle_of_view: float = Field(..., gt=0, le=360, nullable=False) elevation: float = Field(..., gt=0, lt=10000, nullable=False) lat: float = Field(..., gt=-90, lt=90) @@ -56,7 +62,8 @@ class Camera(SQLModel, table=True): is_trustable: bool = True last_active_at: Union[datetime, None] = None last_image: Union[str, None] = None - created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + created_at: datetime = Field( + default_factory=datetime.utcnow, nullable=False) class Pose(SQLModel, table=True): @@ -64,7 +71,7 @@ class Pose(SQLModel, table=True): id: int = Field(default=None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) azimuth: float = Field(..., ge=0, lt=360) - patrol_id: str | None = Field(default=None, max_length=100) + patrol_id: int | None = Field(default=None, max_length=100) class Detection(SQLModel, table=True): @@ -72,11 +79,14 @@ class Detection(SQLModel, table=True): id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) pose_id: int = Field(..., foreign_key="poses.id", nullable=True) - sequence_id: Union[int, None] = Field(None, foreign_key="sequences.id", nullable=True) + sequence_id: Union[int, None] = Field( + None, foreign_key="sequences.id", nullable=True) azimuth: float = Field(..., ge=0, lt=360) bucket_key: str - bboxes: str = Field(..., min_length=2, max_length=settings.MAX_BBOX_STR_LENGTH, nullable=False) - created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + bboxes: str = Field(..., min_length=2, + max_length=settings.MAX_BBOX_STR_LENGTH, nullable=False) + created_at: datetime = Field( + default_factory=datetime.utcnow, nullable=False) class Sequence(SQLModel, table=True): @@ -93,7 +103,8 @@ class Sequence(SQLModel, table=True): class Organization(SQLModel, table=True): __tablename__ = "organizations" id: int = Field(None, primary_key=True) - name: str = Field(..., min_length=5, max_length=100, nullable=False, unique=True) + name: str = Field(..., min_length=5, max_length=100, + nullable=False, unique=True) telegram_id: Union[str, None] = Field(None, nullable=True) slack_hook: Union[str, None] = Field(None, nullable=True) diff --git a/src/app/schemas/poses.py b/src/app/schemas/poses.py index 614a7f5e..a6dc8f55 100644 --- a/src/app/schemas/poses.py +++ b/src/app/schemas/poses.py @@ -14,19 +14,24 @@ ] -class PoseCreate(BaseModel): +class PoseBase(BaseModel): + azimuth: float = Field(..., ge=0, lt=360, + description="Azimuth of the centre of the position in degrees") + patrol_id: Optional[int] = Field( + None, gt=0, description="External patrol identifier") + + +class PoseCreate(PoseBase): camera_id: int = Field(..., gt=0, description="ID of the camera") - azimuth: float = Field(..., ge=0, lt=360, description="Azimuth of the centre of the position in degrees") - patrol_id: Optional[str] = Field(None, max_length=100, description="External patrol identifier") class PoseUpdate(BaseModel): - azimuth: Optional[float] = Field(None, ge=0, lt=360) - patrol_id: Optional[str] = Field(None, max_length=100) + azimuth: Optional[float] = Field( + None, ge=0, lt=360, description="Azimuth of the centre of the position in degrees") + patrol_id: Optional[int] = Field( + None, gt=0, description="External patrol identifier") -class PoseRead(BaseModel): +class PoseRead(PoseBase): id: int camera_id: int - azimuth: float - patrol_id: Optional[str] = None From 7a0e80637dafc85b4107748bd5a3e1af43e3ea7c Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:08:46 +0100 Subject: [PATCH 24/33] updated tests patrol_id str to int --- src/tests/conftest.py | 48 +++++++++++++++++++---------- src/tests/endpoints/test_cameras.py | 43 +++++++++++++++++--------- src/tests/endpoints/test_poses.py | 30 +++++++++--------- 3 files changed, 75 insertions(+), 46 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index a7b80f8b..c27e0c9d 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -99,19 +99,19 @@ "id": 1, "camera_id": 1, "azimuth": 45.0, - "patrol_id": "P1", + "patrol_id": 1, }, { "id": 2, "camera_id": 1, "azimuth": 90.0, - "patrol_id": "P1", + "patrol_id": 1, }, { "id": 3, "camera_id": 2, "azimuth": 180.0, - "patrol_id": "P1", + "patrol_id": 1, }, ] @@ -212,7 +212,8 @@ async def async_session() -> AsyncSession: async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) - async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async_session_maker = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False) async with async_session_maker() as session: async with session.begin(): @@ -286,7 +287,8 @@ async def user_session(organization_session: AsyncSession, monkeypatch): organization_session.add(User(**entry)) await organization_session.commit() await organization_session.exec( - text(f"ALTER SEQUENCE {User.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in USER_TABLE) + 1}") + text( + f"ALTER SEQUENCE {User.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in USER_TABLE) + 1}") ) await organization_session.commit() yield organization_session @@ -299,7 +301,8 @@ async def camera_session(user_session: AsyncSession, organization_session: Async user_session.add(Camera(**entry)) await user_session.commit() await user_session.exec( - text(f"ALTER SEQUENCE {Camera.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in CAM_TABLE) + 1}") + text( + f"ALTER SEQUENCE {Camera.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in CAM_TABLE) + 1}") ) await user_session.commit() yield user_session @@ -312,7 +315,8 @@ async def pose_session(camera_session: AsyncSession): camera_session.add(Pose(**entry)) await camera_session.commit() await camera_session.exec( - text(f"ALTER SEQUENCE {Pose.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in POSE_TABLE) + 1}") + text( + f"ALTER SEQUENCE {Pose.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in POSE_TABLE) + 1}") ) await camera_session.commit() yield camera_session @@ -348,21 +352,24 @@ async def detection_session(pose_session: AsyncSession, sequence_session: AsyncS await sequence_session.commit() # Create bucket files for entry in DET_TABLE: - bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(entry["camera_id"])) + bucket = s3_service.get_bucket( + s3_service.resolve_bucket_name(entry["camera_id"])) bucket.upload_file(entry["bucket_key"], io.BytesIO(b"")) yield sequence_session await sequence_session.rollback() # Delete bucket files try: for entry in DET_TABLE: - bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(entry["camera_id"])) + bucket = s3_service.get_bucket( + s3_service.resolve_bucket_name(entry["camera_id"])) bucket.delete_file(entry["bucket_key"]) except ClientError: pass def get_token(access_id: int, scopes: str, organizationid: int) -> Dict[str, str]: - token_data = {"sub": str(access_id), "scopes": scopes, "organization_id": organizationid} + token_data = {"sub": str(access_id), "scopes": scopes, + "organization_id": organizationid} token = create_access_token(token_data) return {"Authorization": f"Bearer {token}"} @@ -372,30 +379,37 @@ def pytest_configure(): pytest.get_token = get_token # Table pytest.organization_table = [ - {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance( + v, datetime) else v for k, v in entry.items()} for entry in ORGANIZATION_TABLE ] pytest.user_table = [ - {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance( + v, datetime) else v for k, v in entry.items()} for entry in USER_TABLE ] pytest.camera_table = [ - {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance( + v, datetime) else v for k, v in entry.items()} for entry in CAM_TABLE ] pytest.pose_table = [ - {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance( + v, datetime) else v for k, v in entry.items()} for entry in POSE_TABLE ] pytest.detection_table = [ - {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance( + v, datetime) else v for k, v in entry.items()} for entry in DET_TABLE ] pytest.sequence_table = [ - {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance( + v, datetime) else v for k, v in entry.items()} for entry in SEQ_TABLE ] pytest.webhook_table = [ - {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance( + v, datetime) else v for k, v in entry.items()} for entry in WEBHOOK_TABLE ] diff --git a/src/tests/endpoints/test_cameras.py b/src/tests/endpoints/test_cameras.py index 41a2ea51..6f8966a8 100644 --- a/src/tests/endpoints/test_cameras.py +++ b/src/tests/endpoints/test_cameras.py @@ -23,7 +23,8 @@ ), ( 0, - {"name": "pyro-cam", "organization_id": 1, "angle_of_view": 90.0, "elevation": 30.0, "lat": 3.5}, + {"name": "pyro-cam", "organization_id": 1, + "angle_of_view": 90.0, "elevation": 30.0, "lat": 3.5}, 422, None, ), @@ -98,7 +99,8 @@ async def test_create_camera( @pytest.mark.parametrize( - ("user_idx", "cam_id", "status_code", "status_detail", "expected_idx", "expected_poses"), + ("user_idx", "cam_id", "status_code", + "status_detail", "expected_idx", "expected_poses"), [ (None, 1, 401, "Not authenticated", None, None), (0, 0, 422, None, None, None), @@ -113,8 +115,8 @@ async def test_create_camera( None, 0, [ - {"id": 1, "camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, - {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": "P1"}, + {"id": 1, "camera_id": 1, "azimuth": 45.0, "patrol_id": 1}, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": 1}, ], ), ], @@ -145,7 +147,8 @@ async def test_get_camera( assert response.json()["detail"] == status_detail if response.status_code // 100 == 2: json_response = response.json() - assert isinstance(json_response["last_image_url"], str) or json_response["last_image_url"] is None + assert isinstance( + json_response["last_image_url"], str) or json_response["last_image_url"] is None assert "poses" in json_response @@ -156,11 +159,14 @@ async def test_get_camera( @pytest.mark.parametrize( - ("user_idx", "status_code", "status_detail", "expected_response", "expected_poses"), + ("user_idx", "status_code", "status_detail", + "expected_response", "expected_poses"), [ (None, 401, "Not authenticated", None, None), - (0, 200, None, pytest.camera_table[0], [pytest.pose_table[0], pytest.pose_table[1]]), - (1, 200, None, pytest.camera_table[0], [pytest.pose_table[0], pytest.pose_table[1]]), + (0, 200, None, pytest.camera_table[0], [ + pytest.pose_table[0], pytest.pose_table[1]]), + (1, 200, None, pytest.camera_table[0], [ + pytest.pose_table[0], pytest.pose_table[1]]), (2, 200, None, pytest.camera_table[1], [pytest.pose_table[2]]), ], ) @@ -197,12 +203,15 @@ async def test_fetch_cameras( assert json_response[0]["poses"] == expected_poses print("dico reformeted sans poses last image url ") - print({k: v for k, v in json_response[0].items() if k not in {"last_image_url", "poses"}}) + print({k: v for k, v in json_response[0].items( + ) if k not in {"last_image_url", "poses"}}) print("expected") print(expected_response) - assert {k: v for k, v in json_response[0].items() if k not in {"last_image_url", "poses"}} == expected_response + assert {k: v for k, v in json_response[0].items() if k not in { + "last_image_url", "poses"}} == expected_response - assert isinstance(json_response[0]["last_image_url"], str) or json_response[0]["last_image_url"] is None + assert isinstance(json_response[0]["last_image_url"], + str) or json_response[0]["last_image_url"] is None @pytest.mark.parametrize( @@ -314,7 +323,8 @@ async def test_heartbeat( if response.status_code // 100 == 2: assert isinstance(response.json()["last_active_at"], str) if pytest.camera_table[cam_idx]["last_active_at"] is not None: - assert response.json()["last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] + assert response.json()[ + "last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] assert {k: v for k, v in response.json().items() if k != "last_active_at"} == { k: v for k, v in pytest.camera_table[cam_idx].items() if k != "last_active_at" } @@ -354,10 +364,12 @@ async def test_update_image( if response.status_code // 100 == 2: assert isinstance(response.json()["last_active_at"], str) if pytest.camera_table[cam_idx]["last_active_at"] is not None: - assert response.json()["last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] + assert response.json()[ + "last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] assert isinstance(response.json()["last_image"], str) if pytest.camera_table[cam_idx]["last_image"] is not None: - assert response.json()["last_image"] != pytest.camera_table[cam_idx]["last_image"] + assert response.json()[ + "last_image"] != pytest.camera_table[cam_idx]["last_image"] assert {k: v for k, v in response.json().items() if k not in {"last_active_at", "last_image"}} == { k: v for k, v in pytest.camera_table[cam_idx].items() if k not in {"last_active_at", "last_image"} } @@ -486,7 +498,8 @@ async def test_update_camera_location( if isinstance(status_detail, str): assert response.json()["detail"] == status_detail if response.status_code // 100 == 2: - assert {k: v for k, v in response.json().items() if k in {"lat", "lon", "elevation"}} == payload + assert {k: v for k, v in response.json().items() if k in { + "lat", "lon", "elevation"}} == payload @pytest.mark.parametrize( diff --git a/src/tests/endpoints/test_poses.py b/src/tests/endpoints/test_poses.py index a0075881..1f5cf59b 100644 --- a/src/tests/endpoints/test_poses.py +++ b/src/tests/endpoints/test_poses.py @@ -10,37 +10,37 @@ [ ( None, - {"camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, + {"camera_id": 1, "azimuth": 45.0, "patrol_id": 1}, 401, "Not authenticated", ), ( 0, - {"camera_id": 1, "patrol_id": "P1"}, + {"camera_id": 1, "patrol_id": 1}, 422, None, ), ( 0, - {"camera_id": 999, "azimuth": 45.0, "patrol_id": "P1"}, + {"camera_id": 999, "azimuth": 45.0, "patrol_id": 1}, 404, "Table Camera has no corresponding entry.", ), ( 2, # org 2 - {"camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, # camera 1 = org 1 + {"camera_id": 1, "azimuth": 45.0, "patrol_id": 1}, # camera 1 = org 1 403, "Incompatible token scope.", ), ( 0, - {"camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, + {"camera_id": 1, "azimuth": 45.0, "patrol_id": 1}, 201, None, ), ( 1, - {"camera_id": 1, "azimuth": 90.0, "patrol_id": "PX"}, + {"camera_id": 1, "azimuth": 90.0, "patrol_id": 120}, 201, None, ), @@ -89,14 +89,14 @@ async def test_create_pose( 1, 200, None, - {"id": 1, "camera_id": 1, "azimuth": 45.0, "patrol_id": "P1"}, + {"id": 1, "camera_id": 1, "azimuth": 45.0, "patrol_id": 1}, ), ( 1, 2, 200, None, - {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": "P1"}, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": 1}, ), ], ) @@ -131,27 +131,29 @@ async def test_get_pose( @pytest.mark.parametrize( - ("user_idx", "pose_id", "payload", "status_code", "status_detail", "expected_updated"), + ("user_idx", "pose_id", "payload", "status_code", + "status_detail", "expected_updated"), [ (None, 1, {"azimuth": 50.0}, 401, "Not authenticated", None), (0, 0, {"azimuth": 50.0}, 422, None, None), - (0, 999, {"azimuth": 50.0}, 404, "Table Pose has no corresponding entry.", None), + (0, 999, {"azimuth": 50.0}, 404, + "Table Pose has no corresponding entry.", None), (2, 1, {"azimuth": 50.0}, 403, "Incompatible token scope.", None), ( 0, 1, - {"azimuth": 123.4, "patrol_id": "PX"}, + {"azimuth": 123.4, "patrol_id": 123}, 200, None, - {"id": 1, "camera_id": 1, "azimuth": 123.4, "patrol_id": "PX"}, + {"id": 1, "camera_id": 1, "azimuth": 123.4, "patrol_id": 123}, ), ( 1, 2, - {"patrol_id": "UPDATED"}, + {"patrol_id": 456}, 200, None, - {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": "UPDATED"}, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": 456}, ), ], ) From 4e22cecd3bb27a51825e8059c9c56a202622143b Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:09:03 +0100 Subject: [PATCH 25/33] style --- src/app/models.py | 33 ++++++++--------------- src/app/schemas/poses.py | 12 +++------ src/tests/conftest.py | 42 ++++++++++------------------- src/tests/endpoints/test_cameras.py | 39 +++++++++------------------ src/tests/endpoints/test_poses.py | 6 ++--- 5 files changed, 44 insertions(+), 88 deletions(-) diff --git a/src/app/models.py b/src/app/models.py index 94ccb684..5e11f4c1 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -36,25 +36,19 @@ class AnnotationType(str, Enum): class User(SQLModel, table=True): __tablename__ = "users" id: int = Field(None, primary_key=True) - organization_id: int = Field(..., - foreign_key="organizations.id", nullable=False) + organization_id: int = Field(..., foreign_key="organizations.id", nullable=False) role: UserRole = Field(UserRole.USER, nullable=False) # Allow sign-up/in via login + password - login: str = Field(..., index=True, unique=True, - min_length=2, max_length=50, nullable=False) - hashed_password: str = Field(..., min_length=5, - max_length=70, nullable=False) - created_at: datetime = Field( - default_factory=datetime.utcnow, nullable=False) + login: str = Field(..., index=True, unique=True, min_length=2, max_length=50, nullable=False) + hashed_password: str = Field(..., min_length=5, max_length=70, nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) class Camera(SQLModel, table=True): __tablename__ = "cameras" id: int = Field(None, primary_key=True) - organization_id: int = Field(..., - foreign_key="organizations.id", nullable=False) - name: str = Field(..., min_length=5, max_length=100, - nullable=False, unique=True) + organization_id: int = Field(..., foreign_key="organizations.id", nullable=False) + name: str = Field(..., min_length=5, max_length=100, nullable=False, unique=True) angle_of_view: float = Field(..., gt=0, le=360, nullable=False) elevation: float = Field(..., gt=0, lt=10000, nullable=False) lat: float = Field(..., gt=-90, lt=90) @@ -62,8 +56,7 @@ class Camera(SQLModel, table=True): is_trustable: bool = True last_active_at: Union[datetime, None] = None last_image: Union[str, None] = None - created_at: datetime = Field( - default_factory=datetime.utcnow, nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) class Pose(SQLModel, table=True): @@ -79,14 +72,11 @@ class Detection(SQLModel, table=True): id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) pose_id: int = Field(..., foreign_key="poses.id", nullable=True) - sequence_id: Union[int, None] = Field( - None, foreign_key="sequences.id", nullable=True) + sequence_id: Union[int, None] = Field(None, foreign_key="sequences.id", nullable=True) azimuth: float = Field(..., ge=0, lt=360) bucket_key: str - bboxes: str = Field(..., min_length=2, - max_length=settings.MAX_BBOX_STR_LENGTH, nullable=False) - created_at: datetime = Field( - default_factory=datetime.utcnow, nullable=False) + bboxes: str = Field(..., min_length=2, max_length=settings.MAX_BBOX_STR_LENGTH, nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) class Sequence(SQLModel, table=True): @@ -103,8 +93,7 @@ class Sequence(SQLModel, table=True): class Organization(SQLModel, table=True): __tablename__ = "organizations" id: int = Field(None, primary_key=True) - name: str = Field(..., min_length=5, max_length=100, - nullable=False, unique=True) + name: str = Field(..., min_length=5, max_length=100, nullable=False, unique=True) telegram_id: Union[str, None] = Field(None, nullable=True) slack_hook: Union[str, None] = Field(None, nullable=True) diff --git a/src/app/schemas/poses.py b/src/app/schemas/poses.py index a6dc8f55..917f8306 100644 --- a/src/app/schemas/poses.py +++ b/src/app/schemas/poses.py @@ -15,10 +15,8 @@ class PoseBase(BaseModel): - azimuth: float = Field(..., ge=0, lt=360, - description="Azimuth of the centre of the position in degrees") - patrol_id: Optional[int] = Field( - None, gt=0, description="External patrol identifier") + azimuth: float = Field(..., ge=0, lt=360, description="Azimuth of the centre of the position in degrees") + patrol_id: Optional[int] = Field(None, gt=0, description="External patrol identifier") class PoseCreate(PoseBase): @@ -26,10 +24,8 @@ class PoseCreate(PoseBase): class PoseUpdate(BaseModel): - azimuth: Optional[float] = Field( - None, ge=0, lt=360, description="Azimuth of the centre of the position in degrees") - patrol_id: Optional[int] = Field( - None, gt=0, description="External patrol identifier") + azimuth: Optional[float] = Field(None, ge=0, lt=360, description="Azimuth of the centre of the position in degrees") + patrol_id: Optional[int] = Field(None, gt=0, description="External patrol identifier") class PoseRead(PoseBase): diff --git a/src/tests/conftest.py b/src/tests/conftest.py index c27e0c9d..2f4d930e 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -212,8 +212,7 @@ async def async_session() -> AsyncSession: async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) - async_session_maker = sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False) + async_session_maker = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with async_session_maker() as session: async with session.begin(): @@ -287,8 +286,7 @@ async def user_session(organization_session: AsyncSession, monkeypatch): organization_session.add(User(**entry)) await organization_session.commit() await organization_session.exec( - text( - f"ALTER SEQUENCE {User.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in USER_TABLE) + 1}") + text(f"ALTER SEQUENCE {User.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in USER_TABLE) + 1}") ) await organization_session.commit() yield organization_session @@ -301,8 +299,7 @@ async def camera_session(user_session: AsyncSession, organization_session: Async user_session.add(Camera(**entry)) await user_session.commit() await user_session.exec( - text( - f"ALTER SEQUENCE {Camera.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in CAM_TABLE) + 1}") + text(f"ALTER SEQUENCE {Camera.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in CAM_TABLE) + 1}") ) await user_session.commit() yield user_session @@ -315,8 +312,7 @@ async def pose_session(camera_session: AsyncSession): camera_session.add(Pose(**entry)) await camera_session.commit() await camera_session.exec( - text( - f"ALTER SEQUENCE {Pose.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in POSE_TABLE) + 1}") + text(f"ALTER SEQUENCE {Pose.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in POSE_TABLE) + 1}") ) await camera_session.commit() yield camera_session @@ -352,24 +348,21 @@ async def detection_session(pose_session: AsyncSession, sequence_session: AsyncS await sequence_session.commit() # Create bucket files for entry in DET_TABLE: - bucket = s3_service.get_bucket( - s3_service.resolve_bucket_name(entry["camera_id"])) + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(entry["camera_id"])) bucket.upload_file(entry["bucket_key"], io.BytesIO(b"")) yield sequence_session await sequence_session.rollback() # Delete bucket files try: for entry in DET_TABLE: - bucket = s3_service.get_bucket( - s3_service.resolve_bucket_name(entry["camera_id"])) + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(entry["camera_id"])) bucket.delete_file(entry["bucket_key"]) except ClientError: pass def get_token(access_id: int, scopes: str, organizationid: int) -> Dict[str, str]: - token_data = {"sub": str(access_id), "scopes": scopes, - "organization_id": organizationid} + token_data = {"sub": str(access_id), "scopes": scopes, "organization_id": organizationid} token = create_access_token(token_data) return {"Authorization": f"Bearer {token}"} @@ -379,37 +372,30 @@ def pytest_configure(): pytest.get_token = get_token # Table pytest.organization_table = [ - {k: datetime.strftime(v, dt_format) if isinstance( - v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in ORGANIZATION_TABLE ] pytest.user_table = [ - {k: datetime.strftime(v, dt_format) if isinstance( - v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in USER_TABLE ] pytest.camera_table = [ - {k: datetime.strftime(v, dt_format) if isinstance( - v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in CAM_TABLE ] pytest.pose_table = [ - {k: datetime.strftime(v, dt_format) if isinstance( - v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in POSE_TABLE ] pytest.detection_table = [ - {k: datetime.strftime(v, dt_format) if isinstance( - v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in DET_TABLE ] pytest.sequence_table = [ - {k: datetime.strftime(v, dt_format) if isinstance( - v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in SEQ_TABLE ] pytest.webhook_table = [ - {k: datetime.strftime(v, dt_format) if isinstance( - v, datetime) else v for k, v in entry.items()} + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} for entry in WEBHOOK_TABLE ] diff --git a/src/tests/endpoints/test_cameras.py b/src/tests/endpoints/test_cameras.py index 6f8966a8..03a4ba06 100644 --- a/src/tests/endpoints/test_cameras.py +++ b/src/tests/endpoints/test_cameras.py @@ -23,8 +23,7 @@ ), ( 0, - {"name": "pyro-cam", "organization_id": 1, - "angle_of_view": 90.0, "elevation": 30.0, "lat": 3.5}, + {"name": "pyro-cam", "organization_id": 1, "angle_of_view": 90.0, "elevation": 30.0, "lat": 3.5}, 422, None, ), @@ -99,8 +98,7 @@ async def test_create_camera( @pytest.mark.parametrize( - ("user_idx", "cam_id", "status_code", - "status_detail", "expected_idx", "expected_poses"), + ("user_idx", "cam_id", "status_code", "status_detail", "expected_idx", "expected_poses"), [ (None, 1, 401, "Not authenticated", None, None), (0, 0, 422, None, None, None), @@ -147,8 +145,7 @@ async def test_get_camera( assert response.json()["detail"] == status_detail if response.status_code // 100 == 2: json_response = response.json() - assert isinstance( - json_response["last_image_url"], str) or json_response["last_image_url"] is None + assert isinstance(json_response["last_image_url"], str) or json_response["last_image_url"] is None assert "poses" in json_response @@ -159,14 +156,11 @@ async def test_get_camera( @pytest.mark.parametrize( - ("user_idx", "status_code", "status_detail", - "expected_response", "expected_poses"), + ("user_idx", "status_code", "status_detail", "expected_response", "expected_poses"), [ (None, 401, "Not authenticated", None, None), - (0, 200, None, pytest.camera_table[0], [ - pytest.pose_table[0], pytest.pose_table[1]]), - (1, 200, None, pytest.camera_table[0], [ - pytest.pose_table[0], pytest.pose_table[1]]), + (0, 200, None, pytest.camera_table[0], [pytest.pose_table[0], pytest.pose_table[1]]), + (1, 200, None, pytest.camera_table[0], [pytest.pose_table[0], pytest.pose_table[1]]), (2, 200, None, pytest.camera_table[1], [pytest.pose_table[2]]), ], ) @@ -203,15 +197,12 @@ async def test_fetch_cameras( assert json_response[0]["poses"] == expected_poses print("dico reformeted sans poses last image url ") - print({k: v for k, v in json_response[0].items( - ) if k not in {"last_image_url", "poses"}}) + print({k: v for k, v in json_response[0].items() if k not in {"last_image_url", "poses"}}) print("expected") print(expected_response) - assert {k: v for k, v in json_response[0].items() if k not in { - "last_image_url", "poses"}} == expected_response + assert {k: v for k, v in json_response[0].items() if k not in {"last_image_url", "poses"}} == expected_response - assert isinstance(json_response[0]["last_image_url"], - str) or json_response[0]["last_image_url"] is None + assert isinstance(json_response[0]["last_image_url"], str) or json_response[0]["last_image_url"] is None @pytest.mark.parametrize( @@ -323,8 +314,7 @@ async def test_heartbeat( if response.status_code // 100 == 2: assert isinstance(response.json()["last_active_at"], str) if pytest.camera_table[cam_idx]["last_active_at"] is not None: - assert response.json()[ - "last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] + assert response.json()["last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] assert {k: v for k, v in response.json().items() if k != "last_active_at"} == { k: v for k, v in pytest.camera_table[cam_idx].items() if k != "last_active_at" } @@ -364,12 +354,10 @@ async def test_update_image( if response.status_code // 100 == 2: assert isinstance(response.json()["last_active_at"], str) if pytest.camera_table[cam_idx]["last_active_at"] is not None: - assert response.json()[ - "last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] + assert response.json()["last_active_at"] > pytest.camera_table[cam_idx]["last_active_at"] assert isinstance(response.json()["last_image"], str) if pytest.camera_table[cam_idx]["last_image"] is not None: - assert response.json()[ - "last_image"] != pytest.camera_table[cam_idx]["last_image"] + assert response.json()["last_image"] != pytest.camera_table[cam_idx]["last_image"] assert {k: v for k, v in response.json().items() if k not in {"last_active_at", "last_image"}} == { k: v for k, v in pytest.camera_table[cam_idx].items() if k not in {"last_active_at", "last_image"} } @@ -498,8 +486,7 @@ async def test_update_camera_location( if isinstance(status_detail, str): assert response.json()["detail"] == status_detail if response.status_code // 100 == 2: - assert {k: v for k, v in response.json().items() if k in { - "lat", "lon", "elevation"}} == payload + assert {k: v for k, v in response.json().items() if k in {"lat", "lon", "elevation"}} == payload @pytest.mark.parametrize( diff --git a/src/tests/endpoints/test_poses.py b/src/tests/endpoints/test_poses.py index 1f5cf59b..46091a96 100644 --- a/src/tests/endpoints/test_poses.py +++ b/src/tests/endpoints/test_poses.py @@ -131,13 +131,11 @@ async def test_get_pose( @pytest.mark.parametrize( - ("user_idx", "pose_id", "payload", "status_code", - "status_detail", "expected_updated"), + ("user_idx", "pose_id", "payload", "status_code", "status_detail", "expected_updated"), [ (None, 1, {"azimuth": 50.0}, 401, "Not authenticated", None), (0, 0, {"azimuth": 50.0}, 422, None, None), - (0, 999, {"azimuth": 50.0}, 404, - "Table Pose has no corresponding entry.", None), + (0, 999, {"azimuth": 50.0}, 404, "Table Pose has no corresponding entry.", None), (2, 1, {"azimuth": 50.0}, 403, "Incompatible token scope.", None), ( 0, From 834cd8c605a4e8c22ad1e87f1949cbd95528314c Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:23:14 +0100 Subject: [PATCH 26/33] fix to respect code convention --- src/app/crud/__init__.py | 1 + src/app/schemas/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/app/crud/__init__.py b/src/app/crud/__init__.py index 690261f0..f343a7a8 100644 --- a/src/app/crud/__init__.py +++ b/src/app/crud/__init__.py @@ -1,5 +1,6 @@ from .crud_user import * from .crud_camera import * +from .crud_pose import * from .crud_detection import * from .crud_organization import * from .crud_sequence import * diff --git a/src/app/schemas/__init__.py b/src/app/schemas/__init__.py index 98c21faf..93d4e0ea 100644 --- a/src/app/schemas/__init__.py +++ b/src/app/schemas/__init__.py @@ -1,6 +1,7 @@ from .base import * from .detections import * from .cameras import * +from .poses import * from .login import * from .users import * from .organizations import * From 02ff40c8efec487fd861f1d641114b58b30769ef Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:39:53 +0100 Subject: [PATCH 27/33] wip updates client --- client/pyroclient/client.py | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 3a7af953..001e066c 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -22,6 +22,9 @@ class ClientRoute(str, Enum): CAMERAS_HEARTBEAT = "cameras/heartbeat" CAMERAS_IMAGE = "cameras/image" CAMERAS_FETCH = "cameras/" + # POSES + POSES_CREATE = "poses/" + POSES_BY_ID = "poses/{pose_id}" # DETECTIONS DETECTIONS_CREATE = "detections/" DETECTIONS_FETCH = "detections" @@ -148,7 +151,67 @@ def update_last_image(self, media: bytes) -> Response: timeout=self.timeout, ) + # POSES + def create_pose( + self, + camera_id: int, + azimuth: float, + patrol_id: int | None = None, + ) -> Response: + """Create a pose for a camera + + >>> api_client.create_pose(camera_id=1, azimuth=120.5, patrol_id=3) + """ + payload = { + "camera_id": camera_id, + "azimuth": azimuth, + } + if patrol_id is not None: + payload["patrol_id"] = patrol_id + + return requests.post( + urljoin(self._route_prefix, ClientRoute.POSES_CREATE), + headers=self.headers, + json=payload, + timeout=self.timeout, + ) + + def patch_pose( + self, + pose_id: int, + azimuth: float | None = None, + patrol_id: int | None = None, + ) -> Response: + """Update a pose + + >>> api_client.patch_pose(pose_id=1, azimuth=90.0) + """ + payload = {} + if azimuth is not None: + payload["azimuth"] = azimuth + if patrol_id is not None: + payload["patrol_id"] = patrol_id + + return requests.patch( + urljoin(self._route_prefix, ClientRoute.POSES_BY_IDATCH.format(pose_id=pose_id)), + headers=self.headers, + json=payload, + timeout=self.timeout, + ) + + def delete_pose(self, pose_id: int) -> Response: + """Delete a pose + + >>> api_client.delete_pose(pose_id=1) + """ + return requests.delete( + urljoin(self._route_prefix, ClientRoute.POSES_BY_IDTE.format(pose_id=pose_id)), + headers=self.headers, + timeout=self.timeout, + ) + # DETECTIONS + def create_detection( self, media: bytes, From 6c7d46f69bdb144b06caf094f7093416adc43682 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:08:55 +0100 Subject: [PATCH 28/33] typo --- client/pyroclient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 001e066c..3e9729b6 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -193,7 +193,7 @@ def patch_pose( payload["patrol_id"] = patrol_id return requests.patch( - urljoin(self._route_prefix, ClientRoute.POSES_BY_IDATCH.format(pose_id=pose_id)), + urljoin(self._route_prefix, ClientRoute.POSES_BY_ID.format(pose_id=pose_id)), headers=self.headers, json=payload, timeout=self.timeout, @@ -205,7 +205,7 @@ def delete_pose(self, pose_id: int) -> Response: >>> api_client.delete_pose(pose_id=1) """ return requests.delete( - urljoin(self._route_prefix, ClientRoute.POSES_BY_IDTE.format(pose_id=pose_id)), + urljoin(self._route_prefix, ClientRoute.POSES_BY_ID.format(pose_id=pose_id)), headers=self.headers, timeout=self.timeout, ) From 1feee8f1fdf20438dca280aa695c46a4461e83dc Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:47:46 +0100 Subject: [PATCH 29/33] Specify None --- src/app/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/models.py b/src/app/models.py index 5e11f4c1..64be6851 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -71,7 +71,7 @@ class Detection(SQLModel, table=True): __tablename__ = "detections" id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) - pose_id: int = Field(..., foreign_key="poses.id", nullable=True) + pose_id: Union[int, None] = Field(None, foreign_key="poses.id", nullable=True) sequence_id: Union[int, None] = Field(None, foreign_key="sequences.id", nullable=True) azimuth: float = Field(..., ge=0, lt=360) bucket_key: str @@ -83,7 +83,7 @@ class Sequence(SQLModel, table=True): __tablename__ = "sequences" id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) - pose_id: int = Field(..., foreign_key="poses.id", nullable=True) + pose_id: Union[int, None] = Field(None, foreign_key="poses.id", nullable=True) azimuth: float = Field(..., ge=0, lt=360) is_wildfire: Union[AnnotationType, None] = None started_at: datetime = Field(..., nullable=False) From bbcb12743d5bb703568413bf7a39c169ffbf7642 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:03:55 +0100 Subject: [PATCH 30/33] optional pose id --- src/app/api/api_v1/endpoints/detections.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index cadb182c..bd88daf9 100644 --- a/src/app/api/api_v1/endpoints/detections.py +++ b/src/app/api/api_v1/endpoints/detections.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta -from typing import List, cast +from typing import List, Optional, cast from fastapi import ( APIRouter, @@ -60,7 +60,7 @@ async def create_detection( max_length=settings.MAX_BBOX_STR_LENGTH, ), azimuth: float = Form(..., ge=0, lt=360, description="angle between north and direction in degrees"), - pose_id: int = Form(..., gt=0, description="pose id of the detection"), + pose_id: Optional[int] = Form(None, gt=0, description="pose id of the detection"), file: UploadFile = File(..., alias="file"), detections: DetectionCRUD = Depends(get_detection_crud), webhooks: WebhookCRUD = Depends(get_webhook_crud), From 588ec277742902fa5f74990266c0af6358ada33c Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:08:10 +0100 Subject: [PATCH 31/33] resolve merge conflict merging main --- src/app/api/api_v1/endpoints/cameras.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index 1e4ea768..9334d3e1 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -122,7 +122,7 @@ async def get_poses(cam: Camera) -> list[PoseRead]: return [ CameraRead(**cam.model_dump(), last_image_url=url, poses=cam_poses) - for cam, url, cam_poses in zip(cams, urls, poses_list) + for cam, url, cam_poses in zip(cams, urls, poses_list, strict=False) ] From d5d07538473edd50e2a83131f703ea91a4ab6ffc Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:18:36 +0100 Subject: [PATCH 32/33] client: add pose_id param optionnal in create_detection --- client/pyroclient/client.py | 17 +++++---- src/app/api/api_v1/endpoints/cameras.py | 48 +++++++++---------------- 2 files changed, 27 insertions(+), 38 deletions(-) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 98a5dcb7..64024f17 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -4,7 +4,7 @@ # See LICENSE or go to for full license details. from enum import Enum -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple from urllib.parse import urljoin import requests @@ -217,31 +217,36 @@ def create_detection( media: bytes, azimuth: float, bboxes: List[Tuple[float, float, float, float, float]], + pose_id: Optional[int] = None, ) -> Response: """Notify the detection of a wildfire on the picture taken by a camera. >>> from pyroclient import Client >>> api_client = Client("MY_CAM_TOKEN") >>> with open("path/to/my/file.ext", "rb") as f: data = f.read() - >>> response = api_client.create_detection(data, azimuth=124.2, bboxes=[(.1,.1,.5,.8,.5)]) + >>> response = api_client.create_detection(data, azimuth=124.2, bboxes=[(.1,.1,.5,.8,.5)], pose_id=12) Args: media: byte data of the picture azimuth: the azimuth of the camera when the picture was taken bboxes: list of tuples where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf + pose_id: optional, pose_id of the detection Returns: HTTP response """ if not isinstance(bboxes, (list, tuple)) or len(bboxes) == 0 or len(bboxes) > 5: raise ValueError("bboxes must be a non-empty list of tuples with a maximum of 5 boxes") + data = { + "azimuth": azimuth, + "bboxes": _dump_bbox_to_json(bboxes), + } + if pose_id is not None: + data["pose_id"] = pose_id return requests.post( urljoin(self._route_prefix, ClientRoute.DETECTIONS_CREATE), headers=self.headers, - data={ - "azimuth": azimuth, - "bboxes": _dump_bbox_to_json(bboxes), - }, + data=data, timeout=self.timeout, files={"file": ("logo.png", media, "image/png")}, ) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index 9334d3e1..a31f09e4 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -35,14 +35,11 @@ async def register_camera( payload: CameraCreate, cameras: CameraCRUD = Depends(get_camera_crud), - token_payload: TokenPayload = Security( - get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]), ) -> Camera: - telemetry_client.capture( - token_payload.sub, event="cameras-create", properties={"device_login": payload.name}) + telemetry_client.capture(token_payload.sub, event="cameras-create", properties={"device_login": payload.name}) if token_payload.organization_id != payload.organization_id and UserRole.ADMIN not in token_payload.scopes: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") return await cameras.create(payload) @@ -51,15 +48,12 @@ async def get_camera( camera_id: int = Path(..., gt=0), cameras: CameraCRUD = Depends(get_camera_crud), poses: PoseCRUD = Depends(get_pose_crud), - token_payload: TokenPayload = Security( - get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), ) -> CameraRead: - telemetry_client.capture( - token_payload.sub, event="cameras-get", properties={"camera_id": camera_id}) + telemetry_client.capture(token_payload.sub, event="cameras-get", properties={"camera_id": camera_id}) camera = cast(Camera, await cameras.get(camera_id, strict=True)) if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") cam_poses = await poses.fetch_all( filters=("camera_id", camera_id), @@ -69,8 +63,7 @@ async def get_camera( return CameraRead( **camera.model_dump(), last_image_url=None, poses=[PoseRead(**p.model_dump()) for p in cam_poses] ) - bucket = s3_service.get_bucket( - s3_service.resolve_bucket_name(camera.organization_id)) + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id)) return CameraRead( **camera.model_dump(), last_image_url=bucket.get_public_url(camera.last_image), @@ -82,8 +75,7 @@ async def get_camera( async def fetch_cameras( cameras: CameraCRUD = Depends(get_camera_crud), poses: PoseCRUD = Depends(get_pose_crud), - token_payload: TokenPayload = Security( - get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]), ) -> List[CameraRead]: telemetry_client.capture(token_payload.sub, event="cameras-fetch") if UserRole.ADMIN in token_payload.scopes: @@ -91,15 +83,13 @@ async def fetch_cameras( async def get_url_for_cam(cam: Camera) -> str | None: # noqa: RUF029 if cam.last_image: - bucket = s3_service.get_bucket( - s3_service.resolve_bucket_name(cam.organization_id)) + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(cam.organization_id)) return bucket.get_public_url(cam.last_image) return None urls = await asyncio.gather(*[get_url_for_cam(cam) for cam in cams]) else: - bucket = s3_service.get_bucket( - s3_service.resolve_bucket_name(token_payload.organization_id)) + bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(token_payload.organization_id)) cams = [ elt for elt in await cameras.fetch_all( @@ -146,8 +136,7 @@ async def update_image( bucket_key = await upload_file(file, token_payload.organization_id, token_payload.sub) # If the upload succeeds, delete the previous image if isinstance(cam.last_image, str): - s3_service.get_bucket(s3_service.resolve_bucket_name( - token_payload.organization_id)).delete_file(cam.last_image) + s3_service.get_bucket(s3_service.resolve_bucket_name(token_payload.organization_id)).delete_file(cam.last_image) # Update the DB entry return await cameras.update(token_payload.sub, LastImage(last_image=bucket_key, last_active_at=datetime.utcnow())) @@ -158,12 +147,10 @@ async def create_camera_token( cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), ) -> Token: - telemetry_client.capture( - token_payload.sub, event="cameras-token", properties={"camera_id": camera_id}) + telemetry_client.capture(token_payload.sub, event="cameras-token", properties={"camera_id": camera_id}) camera = cast(Camera, await cameras.get(camera_id, strict=True)) # create access token using user user_id/user_scopes - token_data = {"sub": str(camera_id), "scopes": [ - "camera"], "organization_id": camera.organization_id} + token_data = {"sub": str(camera_id), "scopes": ["camera"], "organization_id": camera.organization_id} token = create_access_token(token_data, settings.JWT_UNLIMITED) return Token(access_token=token, token_type="bearer") # noqa S106 @@ -175,8 +162,7 @@ async def update_camera_location( cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), ) -> Camera: - telemetry_client.capture( - token_payload.sub, event="cameras-update-location", properties={"camera_id": camera_id}) + telemetry_client.capture(token_payload.sub, event="cameras-update-location", properties={"camera_id": camera_id}) return await cameras.update(camera_id, payload) @@ -187,8 +173,7 @@ async def update_camera_name( cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), ) -> Camera: - telemetry_client.capture( - token_payload.sub, event="cameras-update-name", properties={"camera_id": camera_id}) + telemetry_client.capture(token_payload.sub, event="cameras-update-name", properties={"camera_id": camera_id}) return await cameras.update(camera_id, payload) @@ -198,6 +183,5 @@ async def delete_camera( cameras: CameraCRUD = Depends(get_camera_crud), token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), ) -> None: - telemetry_client.capture( - token_payload.sub, event="cameras-deletion", properties={"camera_id": camera_id}) + telemetry_client.capture(token_payload.sub, event="cameras-deletion", properties={"camera_id": camera_id}) await cameras.delete(camera_id) From c321694528a85f0559691bc498157877581147cc Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Fri, 19 Dec 2025 18:38:22 +0100 Subject: [PATCH 33/33] update client tests --- client/tests/conftest.py | 5 +++++ client/tests/test_client.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/client/tests/conftest.py b/client/tests/conftest.py index 5de6c59f..143478a3 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -40,6 +40,11 @@ def cam_token(): response = requests.post(urljoin(API_URL, "cameras"), json=payload, headers=admin_headers, timeout=5) assert response.status_code == 201 cam_id = response.json()["id"] + # create a pose related to the cam + payload = {"azimuth": 359, "patrol_id": 1, "camera_id": cam_id} + response = requests.post(urljoin(API_URL, "poses"), json=payload, headers=admin_headers, timeout=5) + assert response.status_code == 201 + # Create a cam token return requests.post(urljoin(API_URL, f"cameras/{cam_id}/token"), headers=admin_headers, timeout=5).json()[ "access_token" diff --git a/client/tests/test_client.py b/client/tests/test_client.py index bb9ff1c1..382f279b 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -40,7 +40,7 @@ def test_cam_workflow(cam_token, mock_img): cam_client.create_detection(mock_img, 123.2, None) with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): cam_client.create_detection(mock_img, 123.2, []) - response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)]) + response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)], pose_id=1) assert response.status_code == 201, response.__dict__ response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5), (0.2, 0.2, 0.7, 0.7, 0.8)]) assert response.status_code == 201, response.__dict__