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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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/52] 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 99a3ef670aa0a49af557a44502efed4e8b4ef2f6 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:25:47 +0100 Subject: [PATCH 22/52] Added Occlusion Mask table in model --- src/app/models.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/models.py b/src/app/models.py index f4839f10..b6730dd6 100644 --- a/src/app/models.py +++ b/src/app/models.py @@ -67,6 +67,14 @@ class Pose(SQLModel, table=True): patrol_id: str | None = Field(default=None, max_length=100) +class OcclusionMask(SQLModel, table=True): + __tablename__ = "occlusion_masks" + id: int = Field(default=None, primary_key=True) + pose_id: int = Field(..., foreign_key="poses.id", nullable=False) + mask: str = Field(..., min_length=2, max_length=255, nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) + + class Detection(SQLModel, table=True): __tablename__ = "detections" id: int = Field(None, primary_key=True) From 5d8a2be3a0d42089aa18287927789045fd8d10de Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:26:38 +0100 Subject: [PATCH 23/52] Added occlusion mask crud and schema --- src/app/crud/crud_occlusion_mask.py | 25 +++++++++++++++++++ src/app/schemas/occlusion_masks.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/app/crud/crud_occlusion_mask.py create mode 100644 src/app/schemas/occlusion_masks.py diff --git a/src/app/crud/crud_occlusion_mask.py b/src/app/crud/crud_occlusion_mask.py new file mode 100644 index 00000000..8e55b0b0 --- /dev/null +++ b/src/app/crud/crud_occlusion_mask.py @@ -0,0 +1,25 @@ +# 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 typing import List + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.crud.base import BaseCRUD +from app.models import OcclusionMask +from app.schemas.occlusion_masks import OcclusionMaskCreate, OcclusionMaskUpdate + +__all__ = ["OcclusionMaskCRUD"] + + +class OcclusionMaskCRUD(BaseCRUD[OcclusionMask, OcclusionMaskCreate, OcclusionMaskUpdate]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, OcclusionMask) + + async def get_by_pose(self, pose_id: int) -> List[OcclusionMask]: + stmt = select(OcclusionMask).where(OcclusionMask.pose_id == pose_id) + results = await self.session.exec(stmt) + return results.all() diff --git a/src/app/schemas/occlusion_masks.py b/src/app/schemas/occlusion_masks.py new file mode 100644 index 00000000..0935e17d --- /dev/null +++ b/src/app/schemas/occlusion_masks.py @@ -0,0 +1,38 @@ +# 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 datetime import datetime + +from pydantic import BaseModel, Field + +__all__ = ["OcclusionMaskCreate", "OcclusionMaskRead", "OcclusionMaskUpdate"] + + +class OcclusionMaskCreate(BaseModel): + pose_id: int = Field(..., gt=0, description="ID of the pose") + mask: str = Field( + ..., + min_length=2, + max_length=255, + description="string representation of tuple where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf", + json_schema_extra={"examples": ["(0.1,0.1,0.9,0.9,0.5)"]}, + ) + + +class OcclusionMaskUpdate(BaseModel): + mask: str = Field( + ..., + min_length=2, + max_length=255, + description="string representation of tuple where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf", + json_schema_extra={"examples": ["(0.1,0.1,0.9,0.9,0.5)"]}, + ) + + +class OcclusionMaskRead(BaseModel): + id: int + pose_id: int + mask: str + created_at: datetime From 400f0a89bd18a1136b15ca390a29475b5c39d404 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:27:47 +0100 Subject: [PATCH 24/52] Added occlusion mask endpoint --- .../api/api_v1/endpoints/occlusion_masks.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/app/api/api_v1/endpoints/occlusion_masks.py diff --git a/src/app/api/api_v1/endpoints/occlusion_masks.py b/src/app/api/api_v1/endpoints/occlusion_masks.py new file mode 100644 index 00000000..5cc9feb6 --- /dev/null +++ b/src/app/api/api_v1/endpoints/occlusion_masks.py @@ -0,0 +1,141 @@ +# Copyright (C) 2020-2025, Pyronear. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. +import re +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_occlusion_mask_crud, + get_pose_crud, +) +from app.crud import CameraCRUD +from app.crud.crud_occlusion_mask import OcclusionMaskCRUD +from app.crud.crud_pose import PoseCRUD +from app.models import Camera, OcclusionMask, Pose, UserRole +from app.schemas.login import TokenPayload +from app.schemas.occlusion_masks import ( + OcclusionMaskCreate, + OcclusionMaskRead, + OcclusionMaskUpdate, +) +from app.services.telemetry import telemetry_client + +router = APIRouter() + +FLOAT_PATTERN = r"(0?\.[0-9]{1,3}|0|1)" +MASK_PATTERN = rf"^\({FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN}\)$" +mask_regex = re.compile(MASK_PATTERN) + + +def validate_mask(mask: str) -> None: + if not mask_regex.match(mask): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=("Invalid mask format. Expected: (xmin, ymin, xmax, ymax, conf) with float values in [0,1]."), + ) + + +@router.post( + "/", + status_code=status.HTTP_201_CREATED, + summary="Create an occlusion mask", +) +async def create_mask( + payload: OcclusionMaskCreate = Body(...), + poses: PoseCRUD = Depends(get_pose_crud), + cameras: CameraCRUD = Depends(get_camera_crud), + masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud), + token_payload: TokenPayload = Security( + get_jwt, + scopes=[UserRole.ADMIN, UserRole.AGENT], + ), +) -> OcclusionMaskRead: + # Validate mask format + validate_mask(payload.mask) + + pose = cast(Pose, await poses.get(payload.pose_id, strict=True)) + camera = cast(Camera, await cameras.get(pose.camera_id, strict=True)) + + if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + telemetry_client.capture( + token_payload.sub, + event="occlusion_masks-create", + properties={"pose_id": payload.pose_id}, + ) + + db_obj = await masks.create(payload) + return OcclusionMaskRead(**db_obj.model_dump()) + + +@router.patch( + "/{mask_id}", + status_code=status.HTTP_200_OK, + summary="Update an occlusion mask", +) +async def update_mask( + mask_id: int = Path(..., gt=0), + payload: OcclusionMaskUpdate = Body(...), + masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud), + poses: PoseCRUD = Depends(get_pose_crud), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security( + get_jwt, + scopes=[UserRole.ADMIN, UserRole.AGENT], + ), +) -> OcclusionMaskRead: + # Validate mask format + validate_mask(payload.mask) + + mask = cast(OcclusionMask, await masks.get(mask_id, strict=True)) + pose = cast(Pose, await poses.get(mask.pose_id, strict=True)) + camera = cast(Camera, await cameras.get(pose.camera_id, strict=True)) + + if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + telemetry_client.capture( + token_payload.sub, + event="occlusion_masks-update", + properties={"mask_id": mask_id}, + ) + + db_obj = await masks.update(mask_id, payload) + return OcclusionMaskRead(**db_obj.model_dump()) + + +@router.delete( + "/{mask_id}", + status_code=status.HTTP_200_OK, + summary="Delete an occlusion mask", +) +async def delete_mask( + mask_id: int = Path(..., gt=0), + masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud), + poses: PoseCRUD = Depends(get_pose_crud), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security( + get_jwt, + scopes=[UserRole.ADMIN, UserRole.AGENT], + ), +) -> None: + mask = cast(OcclusionMask, await masks.get(mask_id, strict=True)) + pose = cast(Pose, await poses.get(mask.pose_id, strict=True)) + camera = cast(Camera, await cameras.get(pose.camera_id, strict=True)) + + if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + telemetry_client.capture( + token_payload.sub, + event="occlusion_masks-delete", + properties={"mask_id": mask_id}, + ) + + await masks.delete(mask_id) From db5fd1a746ac477b919b09541d7808ba111dfb74 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:28:11 +0100 Subject: [PATCH 25/52] added occlusion mask to router and dependencies --- src/app/api/api_v1/router.py | 13 ++++++++++++- src/app/api/dependencies.py | 5 +++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/api/api_v1/router.py b/src/app/api/api_v1/router.py index e4efbf68..bc69cffb 100644 --- a/src/app/api/api_v1/router.py +++ b/src/app/api/api_v1/router.py @@ -5,13 +5,24 @@ from fastapi import APIRouter -from app.api.api_v1.endpoints import cameras, detections, login, organizations, poses, sequences, users, webhooks +from app.api.api_v1.endpoints import ( + cameras, + detections, + login, + occlusion_masks, + 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(occlusion_masks.router, prefix="/occlusion_masks", tags=["occlusion_masks"]) 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 ddb47bfe..55b24090 100644 --- a/src/app/api/dependencies.py +++ b/src/app/api/dependencies.py @@ -16,6 +16,7 @@ from app.core.config import settings from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, SequenceCRUD, UserCRUD, WebhookCRUD +from app.crud.crud_occlusion_mask import OcclusionMaskCRUD from app.crud.crud_pose import PoseCRUD from app.db import get_session from app.models import User, UserRole @@ -49,6 +50,10 @@ def get_pose_crud(session: AsyncSession = Depends(get_session)) -> PoseCRUD: return PoseCRUD(session=session) +def get_occlusion_mask_crud(session: AsyncSession = Depends(get_session)) -> OcclusionMaskCRUD: + return OcclusionMaskCRUD(session=session) + + def get_detection_crud(session: AsyncSession = Depends(get_session)) -> DetectionCRUD: return DetectionCRUD(session=session) From b11bd7314eec57074a40b58158b54840aa679d37 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:28:34 +0100 Subject: [PATCH 26/52] Update poses endpoint to list occlusion mask from a pose --- src/app/api/api_v1/endpoints/poses.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/app/api/api_v1/endpoints/poses.py b/src/app/api/api_v1/endpoints/poses.py index e150d690..3c127060 100644 --- a/src/app/api/api_v1/endpoints/poses.py +++ b/src/app/api/api_v1/endpoints/poses.py @@ -2,15 +2,17 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. -from typing import cast +from typing import List, 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.api.dependencies import get_camera_crud, get_jwt, get_occlusion_mask_crud, get_pose_crud from app.crud import CameraCRUD +from app.crud.crud_occlusion_mask import OcclusionMaskCRUD from app.crud.crud_pose import PoseCRUD -from app.models import Camera, Pose, UserRole +from app.models import Camera, Pose, Role, UserRole from app.schemas.login import TokenPayload +from app.schemas.occlusion_masks import OcclusionMaskRead from app.schemas.poses import PoseCreate, PoseRead, PoseUpdate from app.services.telemetry import telemetry_client @@ -83,3 +85,21 @@ async def delete_pose( ) -> None: telemetry_client.capture(token_payload.sub, event="poses-deletion", properties={"pose_id": pose_id}) await poses.delete(pose_id) + + +@router.get( + "/{pose_id}/occlusion_masks", + status_code=status.HTTP_200_OK, + summary="List occlusion masks for a pose", +) +async def list_pose_masks( + pose_id: int = Path(..., gt=0), + masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud), + token_payload: TokenPayload = Security( + get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER, Role.CAMERA] + ), +) -> List[OcclusionMaskRead]: + telemetry_client.capture(token_payload.sub, event="occlusion_masks-list", properties={"pose_id": pose_id}) + + rows = await masks.get_by_pose(pose_id) + return [OcclusionMaskRead(**row.model_dump()) for row in rows] From 254aba703a0883f1cb70d5d6a3c8dae7f2678086 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:28:53 +0100 Subject: [PATCH 27/52] Update tests and e2e tests --- scripts/test_e2e.py | 5 + src/tests/conftest.py | 47 ++++++- src/tests/endpoints/test_occlusion_masks.py | 131 ++++++++++++++++++++ src/tests/endpoints/test_poses.py | 44 +++++++ 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/tests/endpoints/test_occlusion_masks.py diff --git a/scripts/test_e2e.py b/scripts/test_e2e.py index 18303c91..722dbde1 100644 --- a/scripts/test_e2e.py +++ b/scripts/test_e2e.py @@ -96,6 +96,10 @@ def main(args): } pose_id = api_request("post", f"{args.endpoint}/poses/", agent_auth, payload)["id"] + # Create a pose occlusion mask + payload = {"pose_id": pose_id, "mask": "(0.1,0.1,0.9,0.9,1)"} + + occlusion_mask_id = api_request("post", f"{args.endpoint}/occlusion_masks/", agent_auth, payload)["id"] # Take a picture file_bytes = requests.get("https://pyronear.org/img/logo.png", timeout=5).content # Update cam last image @@ -180,6 +184,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}/occlusion_masks/{occlusion_mask_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) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index a7b80f8b..177790e3 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, Pose, Sequence, User, Webhook +from app.models import Camera, Detection, OcclusionMask, Organization, Pose, Sequence, User, Webhook from app.services.storage import s3_service dt_format = "%Y-%m-%dT%H:%M:%S.%f" @@ -115,6 +115,32 @@ }, ] +OCCLUSION_MASK_TABLE = [ + { + "id": 1, + "pose_id": 1, + "mask": "(0.1,0.1,0.9,0.9,0.5)", + "created_at": datetime.strptime("2025-01-01T00:00:00.000000", dt_format), + }, + { + "id": 2, + "pose_id": 1, + "mask": "(0.1,0.1,0.9,0.9,1)", + "created_at": datetime.strptime("2025-01-02T00:00:00.000000", dt_format), + }, + { + "id": 3, + "pose_id": 2, + "mask": "(1,0.1,0.9,0.9,0.5)", + "created_at": datetime.strptime("2025-01-03T00:00:00.000000", dt_format), + }, + { + "id": 4, + "pose_id": 3, + "mask": "(1,0.1,0.1,0.9,1)", + "created_at": datetime.strptime("2025-01-03T00:00:00.000000", dt_format), + }, +] DET_TABLE = [ { @@ -319,6 +345,21 @@ async def pose_session(camera_session: AsyncSession): await camera_session.rollback() +@pytest_asyncio.fixture(scope="function") +async def occlusion_mask_session(pose_session: AsyncSession): + for entry in OCCLUSION_MASK_TABLE: + pose_session.add(OcclusionMask(**entry)) + await pose_session.commit() + await pose_session.exec( + text( + f"ALTER SEQUENCE {OcclusionMask.__tablename__}_id_seq RESTART WITH {max(entry['id'] for entry in OCCLUSION_MASK_TABLE) + 1}" + ) + ) + await pose_session.commit() + yield pose_session + await pose_session.rollback() + + @pytest_asyncio.fixture(scope="function") async def sequence_session(pose_session: AsyncSession): for entry in SEQ_TABLE: @@ -387,6 +428,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 POSE_TABLE ] + pytest.occlusion_mask_table = [ + {k: datetime.strftime(v, dt_format) if isinstance(v, datetime) else v for k, v in entry.items()} + for entry in OCCLUSION_MASK_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 diff --git a/src/tests/endpoints/test_occlusion_masks.py b/src/tests/endpoints/test_occlusion_masks.py new file mode 100644 index 00000000..4e9ba080 --- /dev/null +++ b/src/tests/endpoints/test_occlusion_masks.py @@ -0,0 +1,131 @@ +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, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 401, "Not authenticated"), + (2, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 403, "Incompatible token scope."), + (0, {"pose_id": 0, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 422, None), + (0, {"pose_id": 999, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 404, "Table Pose has no corresponding entry."), + (1, {"pose_id": 3, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 403, "Access forbidden."), # agent org 1, camera org 2 + (0, {"pose_id": 1, "mask": "invalid"}, 422, None), + (0, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 201, None), + (1, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,1)"}, 201, None), + ], +) +@pytest.mark.asyncio +async def test_create_occlusion_mask( + async_client: AsyncClient, + pose_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("/occlusion_masks", 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["pose_id"] == payload["pose_id"] + assert json_resp["mask"] == payload["mask"] + assert "created_at" in json_resp + + +@pytest.mark.parametrize( + ("user_idx", "mask_id", "payload", "status_code", "status_detail"), + [ + (None, 1, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 401, "Not authenticated"), + (2, 1, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 403, "Incompatible token scope."), + (0, 0, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 422, None), + (0, 999, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 404, "Table OcclusionMask has no corresponding entry."), + (1, 4, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 403, "Access forbidden."), # agent org mismatch + (0, 1, {"mask": "bad_format"}, 422, None), + (0, 1, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 200, None), + (1, 1, {"mask": "(0.3,0.3,0.7,0.7,1)"}, 200, None), + ], +) +@pytest.mark.asyncio +async def test_update_occlusion_mask( + async_client: AsyncClient, + occlusion_mask_session: AsyncSession, + user_idx: Union[int, None], + mask_id: int, + 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.patch(f"/occlusion_masks/{mask_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["id"] == mask_id + assert json_resp["mask"] == payload["mask"] + + +@pytest.mark.parametrize( + ("user_idx", "mask_id", "status_code", "status_detail"), + [ + (None, 1, 401, "Not authenticated"), + (2, 1, 403, "Incompatible token scope."), + (0, 0, 422, None), + (0, 999, 404, "Table OcclusionMask has no corresponding entry."), + (1, 4, 403, "Access forbidden."), # agent wrong org + (1, 1, 200, None), + (0, 2, 200, None), + ], +) +@pytest.mark.asyncio +async def test_delete_occlusion_mask( + async_client: AsyncClient, + occlusion_mask_session: AsyncSession, + user_idx: Union[int, None], + mask_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"/occlusion_masks/{mask_id}", headers=auth) + + assert response.status_code == status_code, print(response.__dict__) + + if isinstance(status_detail, str): + assert response.json()["detail"] == status_detail diff --git a/src/tests/endpoints/test_poses.py b/src/tests/endpoints/test_poses.py index a0075881..9e19e759 100644 --- a/src/tests/endpoints/test_poses.py +++ b/src/tests/endpoints/test_poses.py @@ -221,3 +221,47 @@ async def test_delete_pose( if isinstance(status_detail, str): assert response.json()["detail"] == status_detail + + +@pytest.mark.parametrize( + ("user_idx", "cam_idx", "pose_id", "status_code", "expected_count"), + [ + (None, None, 1, 401, None), + (0, None, 1, 200, 2), # admin + (1, None, 1, 200, 2), # agent + (2, None, 1, 200, 2), # user + (None, 0, 1, 200, 2), # cam + (None, 1, 1, 200, 2), # cam from other org + ], +) +@pytest.mark.asyncio +async def test_list_pose_occlusion_masks( + async_client: AsyncClient, + occlusion_mask_session: AsyncSession, + user_idx: Union[int, None], + cam_idx: Union[int, None], + pose_id: int, + status_code: int, + expected_count: Union[int, 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"], + ) + elif isinstance(cam_idx, int): + auth = pytest.get_token( + pytest.camera_table[cam_idx]["id"], + ["camera"], + pytest.camera_table[cam_idx]["organization_id"], + ) + + response = await async_client.get(f"/poses/{pose_id}/occlusion_masks", headers=auth) + + assert response.status_code == status_code, print(response.__dict__) + + if status_code == 200: + assert isinstance(response.json(), list) + assert len(response.json()) == expected_count From 9828392b43e2f69ddddee12dbfc39a190ffeaba2 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:29:07 +0100 Subject: [PATCH 28/52] updates db diagram --- scripts/dbdiagram.txt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/dbdiagram.txt b/scripts/dbdiagram.txt index 28dd237f..40dc4653 100644 --- a/scripts/dbdiagram.txt +++ b/scripts/dbdiagram.txt @@ -1,8 +1,8 @@ Table organizations { id int [pk, increment] name varchar(100) [not null, unique] - telegram_id varchar [null] - slack_hook varchar [null] + telegram_id varchar + slack_hook varchar } Table users { @@ -35,6 +35,13 @@ Table poses { patrol_id varchar(100) } +Table occlusion_masks { + id int [pk, increment] + pose_id int [not null] + mask varchar(255) [not null] + created_at timestamp [not null] +} + Table sequences { id int [pk, increment] camera_id int [not null] @@ -64,8 +71,12 @@ Table webhooks { Ref: users.organization_id > organizations.id Ref: cameras.organization_id > organizations.id Ref: poses.camera_id > cameras.id + +Ref: occlusion_masks.pose_id > poses.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 8db4b5d73afe04ab74e1e3a043f96714e445d3f3 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:46:02 +0100 Subject: [PATCH 29/52] fix mypy --- src/app/crud/crud_occlusion_mask.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/crud/crud_occlusion_mask.py b/src/app/crud/crud_occlusion_mask.py index 8e55b0b0..4c60e6cd 100644 --- a/src/app/crud/crud_occlusion_mask.py +++ b/src/app/crud/crud_occlusion_mask.py @@ -7,6 +7,7 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql.expression import Select from app.crud.base import BaseCRUD from app.models import OcclusionMask @@ -20,6 +21,6 @@ def __init__(self, session: AsyncSession) -> None: super().__init__(session, OcclusionMask) async def get_by_pose(self, pose_id: int) -> List[OcclusionMask]: - stmt = select(OcclusionMask).where(OcclusionMask.pose_id == pose_id) + stmt: Select[OcclusionMask] = select(OcclusionMask).where(OcclusionMask.pose_id == pose_id) results = await self.session.exec(stmt) return results.all() From de99b2d952c312f7444ea1c02a15398e4f2f2a03 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:04:49 +0100 Subject: [PATCH 30/52] Revert "fix mypy" This reverts commit 8db4b5d73afe04ab74e1e3a043f96714e445d3f3. --- src/app/crud/crud_occlusion_mask.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/crud/crud_occlusion_mask.py b/src/app/crud/crud_occlusion_mask.py index 4c60e6cd..8e55b0b0 100644 --- a/src/app/crud/crud_occlusion_mask.py +++ b/src/app/crud/crud_occlusion_mask.py @@ -7,7 +7,6 @@ from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession -from sqlmodel.sql.expression import Select from app.crud.base import BaseCRUD from app.models import OcclusionMask @@ -21,6 +20,6 @@ def __init__(self, session: AsyncSession) -> None: super().__init__(session, OcclusionMask) async def get_by_pose(self, pose_id: int) -> List[OcclusionMask]: - stmt: Select[OcclusionMask] = select(OcclusionMask).where(OcclusionMask.pose_id == pose_id) + stmt = select(OcclusionMask).where(OcclusionMask.pose_id == pose_id) results = await self.session.exec(stmt) return results.all() From 8cf3d460d8edcf4c6ab55ca210e7ebd4e8afd0ab Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:07:32 +0100 Subject: [PATCH 31/52] mypy --- src/app/crud/crud_occlusion_mask.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/crud/crud_occlusion_mask.py b/src/app/crud/crud_occlusion_mask.py index 8e55b0b0..25c97c69 100644 --- a/src/app/crud/crud_occlusion_mask.py +++ b/src/app/crud/crud_occlusion_mask.py @@ -20,6 +20,5 @@ def __init__(self, session: AsyncSession) -> None: super().__init__(session, OcclusionMask) async def get_by_pose(self, pose_id: int) -> List[OcclusionMask]: - stmt = select(OcclusionMask).where(OcclusionMask.pose_id == pose_id) - results = await self.session.exec(stmt) + results = await self.session.exec(select(OcclusionMask).where(OcclusionMask.pose_id == pose_id)) return results.all() 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 32/52] 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 33/52] 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 34/52] 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 35/52] 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 36/52] 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 37/52] 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 38/52] 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 39/52] 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 40/52] 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 41/52] 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 42/52] 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 2d7aa51e36da10febf620bdc6ae5ef347766a02f Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Sat, 20 Dec 2025 12:18:51 +0100 Subject: [PATCH 43/52] typo --- src/app/api/dependencies.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/api/dependencies.py b/src/app/api/dependencies.py index 19d5150f..798f400f 100644 --- a/src/app/api/dependencies.py +++ b/src/app/api/dependencies.py @@ -21,9 +21,6 @@ from app.core.config import settings from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, SequenceCRUD, UserCRUD, WebhookCRUD -<< << << < HEAD -== == == = ->>>>>> > main JWTTemplate = TypeVar("JWTTemplate") logger = logging.getLogger("uvicorn.error") From d5edfb72b119e5ce827c68068c5de7797705a4bd Mon Sep 17 00:00:00 2001 From: Mateo Date: Wed, 31 Dec 2025 15:27:17 +0100 Subject: [PATCH 44/52] ruff --- scripts/test_e2e.py | 78 +++++++++------------------ src/app/api/api_v1/endpoints/poses.py | 33 ++++-------- src/app/api/api_v1/router.py | 15 ++---- src/app/api/dependencies.py | 25 ++++----- src/app/models.py | 42 +++++---------- src/tests/conftest.py | 45 ++++++---------- 6 files changed, 81 insertions(+), 157 deletions(-) diff --git a/scripts/test_e2e.py b/scripts/test_e2e.py index b97e50fe..18303c91 100644 --- a/scripts/test_e2e.py +++ b/scripts/test_e2e.py @@ -47,17 +47,14 @@ def main(args): # Create an organization org_name = "my_org" - org_id = api_request( - "post", f"{args.endpoint}/organizations/", superuser_auth, {"name": org_name})["id"] + org_id = api_request("post", f"{args.endpoint}/organizations/", superuser_auth, {"name": org_name})["id"] agent_login = "my_user" agent_pwd = "my_pwd" # noqa S105 # create a user - payload = {"organization_id": org_id, "login": agent_login, - "password": agent_pwd, "role": "agent"} - user_id = api_request( - "post", f"{args.endpoint}/users/", superuser_auth, payload)["id"] + payload = {"organization_id": org_id, "login": agent_login, "password": agent_pwd, "role": "agent"} + user_id = api_request("post", f"{args.endpoint}/users/", superuser_auth, payload)["id"] agent_auth = { "Authorization": f"Bearer {get_token(args.endpoint, agent_login, agent_pwd)}", "Content-Type": "application/json", @@ -69,8 +66,7 @@ def main(args): api_request("get", f"{args.endpoint}/users", superuser_auth) # Modify access new_pwd = "my_new_pwd" # noqa S105 - api_request("patch", f"{args.endpoint}/users/{user_id}/", - superuser_auth, {"password": new_pwd}) + api_request("patch", f"{args.endpoint}/users/{user_id}/", superuser_auth, {"password": new_pwd}) # Create a camera camera_name = "my_device" @@ -83,8 +79,7 @@ def main(args): "lon": 4.5, "azimuth": 110, } - cam_id = api_request( - "post", f"{args.endpoint}/cameras/", agent_auth, payload)["id"] + cam_id = api_request("post", f"{args.endpoint}/cameras/", agent_auth, payload)["id"] cam_token = requests.post( f"{args.endpoint}/cameras/{cam_id}/token", @@ -99,12 +94,10 @@ def main(args): "camera_id": cam_id, "azimuth": 45, } - pose_id = api_request( - "post", f"{args.endpoint}/poses/", agent_auth, payload)["id"] + 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 + file_bytes = requests.get("https://pyronear.org/img/logo.png", timeout=5).content # Update cam last image response = requests.patch( f"{args.endpoint}/cameras/image", @@ -115,19 +108,16 @@ def main(args): assert response.status_code == 200, response.text assert response.json()["last_image"] is not None # Check that URL is displayed when we fetch all cameras - response = requests.get(f"{args.endpoint}/cameras", - headers=agent_auth, timeout=5) + response = requests.get(f"{args.endpoint}/cameras", headers=agent_auth, timeout=5) assert response.status_code == 200, response.text assert response.json()[0]["last_image_url"] is not None - file_bytes = requests.get( - "https://pyronear.org/img/logo.png", timeout=5).content + file_bytes = requests.get("https://pyronear.org/img/logo.png", timeout=5).content # Create a detection 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)]", "pose_id": pose_id}, + 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, ) @@ -137,23 +127,20 @@ def main(args): # Fetch detections & their URLs api_request("get", f"{args.endpoint}/detections", agent_auth) - api_request( - "get", f"{args.endpoint}/detections/{detection_id}/url", agent_auth) + api_request("get", f"{args.endpoint}/detections/{detection_id}/url", agent_auth) # Create a sequence by adding two additional detections 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)]", "pose_id": pose_id}, + 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)]", "pose_id": pose_id}, + 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"] @@ -164,51 +151,39 @@ def main(args): assert sequence["last_seen_at"] > sequence["started_at"] assert sequence["azimuth"] == response.json()["azimuth"] # Fetch the latest sequence - assert len(api_request( - "get", f"{args.endpoint}/sequences/unlabeled/latest", agent_auth)) == 1 + assert len(api_request("get", f"{args.endpoint}/sequences/unlabeled/latest", agent_auth)) == 1 # Fetch from date - assert len(api_request( - "get", f"{args.endpoint}/sequences/all/fromdate?from_date=2019-09-10", agent_auth)) == 0 + assert len(api_request("get", f"{args.endpoint}/sequences/all/fromdate?from_date=2019-09-10", agent_auth)) == 0 assert ( - len(api_request( - "get", f"{args.endpoint}/sequences/all/fromdate?from_date={today.isoformat()}", agent_auth)) + len(api_request("get", f"{args.endpoint}/sequences/all/fromdate?from_date={today.isoformat()}", agent_auth)) == 1 ) # Label the sequence api_request( - "patch", f"{args.endpoint}/sequences/{sequence['id']}/label", agent_auth, { - "is_wildfire": "wildfire_smoke"} + "patch", f"{args.endpoint}/sequences/{sequence['id']}/label", agent_auth, {"is_wildfire": "wildfire_smoke"} ) # Check the sequence's detections - dets = api_request( - "get", f"{args.endpoint}/sequences/{sequence['id']}/detections", agent_auth) + dets = api_request("get", f"{args.endpoint}/sequences/{sequence['id']}/detections", agent_auth) assert len(dets) == 3 assert dets[0]["id"] == det_id_3 assert dets[1]["id"] == det_id_2 assert dets[2]["id"] == detection_id - dets = api_request( - "get", f"{args.endpoint}/sequences/{sequence['id']}/detections?limit=1", agent_auth) + dets = api_request("get", f"{args.endpoint}/sequences/{sequence['id']}/detections?limit=1", agent_auth) assert len(dets) == 1 assert dets[0]["id"] == det_id_3 - dets = api_request( - "get", f"{args.endpoint}/sequences/{sequence['id']}/detections?limit=1&desc=false", agent_auth) + dets = api_request("get", f"{args.endpoint}/sequences/{sequence['id']}/detections?limit=1&desc=false", agent_auth) assert len(dets) == 1 assert dets[0]["id"] == detection_id # Cleaning (order is important because of foreign key protection in existing tables) - api_request( - "delete", f"{args.endpoint}/detections/{detection_id}/", superuser_auth) - 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}/detections/{detection_id}/", superuser_auth) + 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) + api_request("delete", f"{args.endpoint}/organizations/{org_id}/", superuser_auth) print(f"SUCCESS in {time.time() - start_ts:.3}s") return @@ -219,8 +194,7 @@ def parse_args(): description="Pyronear API End-to-End test", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) - parser.add_argument("--endpoint", type=str, - default="http://localhost:5050/api/v1", help="the API endpoint") + parser.add_argument("--endpoint", type=str, default="http://localhost:5050/api/v1", help="the API endpoint") return parser.parse_args() diff --git a/src/app/api/api_v1/endpoints/poses.py b/src/app/api/api_v1/endpoints/poses.py index cbf2bc86..3c127060 100644 --- a/src/app/api/api_v1/endpoints/poses.py +++ b/src/app/api/api_v1/endpoints/poses.py @@ -24,21 +24,18 @@ async def create_pose( 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]), + 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}, + properties={"camera_id": payload.camera_id, "azimuth": payload.azimuth}, ) 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.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") db_pose = await poses.create(payload) return PoseRead(**db_pose.model_dump()) @@ -49,18 +46,15 @@ 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]), + 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}) + telemetry_client.capture(token_payload.sub, event="poses-get", properties={"pose_id": pose_id}) 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.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") return PoseRead(**pose.model_dump()) @@ -71,15 +65,13 @@ async def update_pose( 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]), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.AGENT, UserRole.ADMIN]), ) -> PoseRead: 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.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") db_pose = await poses.update(pose_id, payload) return PoseRead(**db_pose.model_dump()) @@ -91,8 +83,7 @@ async def delete_pose( 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}) + telemetry_client.capture(token_payload.sub, event="poses-deletion", properties={"pose_id": pose_id}) await poses.delete(pose_id) @@ -105,12 +96,10 @@ async def list_pose_masks( pose_id: int = Path(..., gt=0), masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud), token_payload: TokenPayload = Security( - get_jwt, scopes=[UserRole.ADMIN, - UserRole.AGENT, UserRole.USER, Role.CAMERA] + get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER, Role.CAMERA] ), ) -> List[OcclusionMaskRead]: - telemetry_client.capture( - token_payload.sub, event="occlusion_masks-list", properties={"pose_id": pose_id}) + telemetry_client.capture(token_payload.sub, event="occlusion_masks-list", properties={"pose_id": pose_id}) rows = await masks.get_by_pose(pose_id) return [OcclusionMaskRead(**row.model_dump()) for row in rows] diff --git a/src/app/api/api_v1/router.py b/src/app/api/api_v1/router.py index f793c77f..bc69cffb 100644 --- a/src/app/api/api_v1/router.py +++ b/src/app/api/api_v1/router.py @@ -22,13 +22,8 @@ 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(occlusion_masks.router, - prefix="/occlusion_masks", tags=["occlusion_masks"]) -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"]) -api_router.include_router( - webhooks.router, prefix="/webhooks", tags=["webhooks"]) +api_router.include_router(occlusion_masks.router, prefix="/occlusion_masks", tags=["occlusion_masks"]) +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"]) +api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"]) diff --git a/src/app/api/dependencies.py b/src/app/api/dependencies.py index 798f400f..55b24090 100644 --- a/src/app/api/dependencies.py +++ b/src/app/api/dependencies.py @@ -3,11 +3,6 @@ # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. -from app.schemas.login import TokenPayload -from app.models import User, UserRole -from app.db import get_session -from app.crud.crud_pose import PoseCRUD -from app.crud.crud_occlusion_mask import OcclusionMaskCRUD import logging from typing import Dict, Type, TypeVar, Union, cast @@ -21,6 +16,11 @@ from app.core.config import settings from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, SequenceCRUD, UserCRUD, WebhookCRUD +from app.crud.crud_occlusion_mask import OcclusionMaskCRUD +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 JWTTemplate = TypeVar("JWTTemplate") logger = logging.getLogger("uvicorn.error") @@ -72,21 +72,18 @@ def get_sequence_crud(session: AsyncSession = Depends(get_session)) -> SequenceC def decode_token(token: str, authenticate_value: Union[str, None] = None) -> Dict[str, str]: try: - payload = jwt_decode(token, settings.JWT_SECRET, - algorithms=[settings.JWT_ALGORITHM]) + payload = jwt_decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) except (ExpiredSignatureError, InvalidSignatureError): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Token has expired.", - headers={ - "WWW-Authenticate": authenticate_value} if authenticate_value else None, + headers={"WWW-Authenticate": authenticate_value} if authenticate_value else None, ) except DecodeError: raise HTTPException( status_code=status.HTTP_406_NOT_ACCEPTABLE, detail="Invalid token.", - headers={ - "WWW-Authenticate": authenticate_value} if authenticate_value else None, + headers={"WWW-Authenticate": authenticate_value} if authenticate_value else None, ) return payload @@ -102,8 +99,7 @@ def process_token( raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid token payload.", - headers={ - "WWW-Authenticate": authenticate_value} if authenticate_value else None, + headers={"WWW-Authenticate": authenticate_value} if authenticate_value else None, ) @@ -139,5 +135,4 @@ async def dispatch_webhook(url: str, payload: BaseModel) -> None: response.raise_for_status() logger.info(f"Successfully dispatched to {url}") except HTTPStatusError as e: - logger.error( - f"Error dispatching webhook to {url}: {e.response.status_code} - {e.response.text}") + logger.error(f"Error dispatching webhook to {url}: {e.response.status_code} - {e.response.text}") diff --git a/src/app/models.py b/src/app/models.py index afcbda1a..5638ee04 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,32 +72,26 @@ class OcclusionMask(SQLModel, table=True): id: int = Field(default=None, primary_key=True) pose_id: int = Field(..., foreign_key="poses.id", nullable=False) mask: str = Field(..., min_length=2, max_length=255, nullable=False) - created_at: datetime = Field( - default_factory=datetime.utcnow, nullable=False) + created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False) 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: Union[int, None] = Field( - None, foreign_key="poses.id", nullable=True) - sequence_id: Union[int, None] = Field( - None, foreign_key="sequences.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 - 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): __tablename__ = "sequences" id: int = Field(None, primary_key=True) camera_id: int = Field(..., foreign_key="cameras.id", nullable=False) - pose_id: Union[int, None] = Field( - None, 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) @@ -114,8 +101,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/tests/conftest.py b/src/tests/conftest.py index df68ef60..5485788e 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -238,8 +238,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(): @@ -313,8 +312,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 @@ -327,8 +325,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 @@ -341,8 +338,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 @@ -393,24 +389,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}"} @@ -420,42 +413,34 @@ 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.occlusion_mask_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 OCCLUSION_MASK_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 ] From bae4dea3b99bd19cef2fbbbe9276a18c7efa8d02 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:22:12 +0100 Subject: [PATCH 45/52] Added scope check --- src/app/api/api_v1/endpoints/poses.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/api/api_v1/endpoints/poses.py b/src/app/api/api_v1/endpoints/poses.py index 3c127060..3506ba5c 100644 --- a/src/app/api/api_v1/endpoints/poses.py +++ b/src/app/api/api_v1/endpoints/poses.py @@ -95,11 +95,18 @@ async def delete_pose( async def list_pose_masks( pose_id: int = Path(..., gt=0), masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud), + 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, Role.CAMERA] ), ) -> List[OcclusionMaskRead]: telemetry_client.capture(token_payload.sub, event="occlusion_masks-list", properties={"pose_id": pose_id}) + 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.") rows = await masks.get_by_pose(pose_id) return [OcclusionMaskRead(**row.model_dump()) for row in rows] From d8f4365ee7bd1d22be46acfb919efb13bd052d83 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:22:37 +0100 Subject: [PATCH 46/52] added get occlusion mask route --- .../api/api_v1/endpoints/occlusion_masks.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/app/api/api_v1/endpoints/occlusion_masks.py b/src/app/api/api_v1/endpoints/occlusion_masks.py index 5cc9feb6..e8deee88 100644 --- a/src/app/api/api_v1/endpoints/occlusion_masks.py +++ b/src/app/api/api_v1/endpoints/occlusion_masks.py @@ -74,6 +74,37 @@ async def create_mask( return OcclusionMaskRead(**db_obj.model_dump()) +@router.get( + "/{mask_id}", + status_code=status.HTTP_200_OK, + summary="Get info about an occlusion mask", +) +async def get_mask( + mask_id: int = Path(..., gt=0), + masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud), + poses: PoseCRUD = Depends(get_pose_crud), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security( + get_jwt, + scopes=[UserRole.ADMIN, UserRole.AGENT], + ), +) -> OcclusionMaskRead: + mask = cast(OcclusionMask, await masks.get(mask_id, strict=True)) + pose = cast(Pose, await poses.get(mask.pose_id, strict=True)) + camera = cast(Camera, await cameras.get(pose.camera_id, strict=True)) + + if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.") + + telemetry_client.capture( + token_payload.sub, + event="occlusion_masks-get", + properties={"mask_id": mask_id}, + ) + + return OcclusionMaskRead(**mask.model_dump()) + + @router.patch( "/{mask_id}", status_code=status.HTTP_200_OK, From 457ea2ab513c225dcc30ab9bee8b10195e995238 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:22:56 +0100 Subject: [PATCH 47/52] delete conf in occlusion mask format --- src/app/api/api_v1/endpoints/occlusion_masks.py | 4 ++-- src/app/schemas/occlusion_masks.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/api/api_v1/endpoints/occlusion_masks.py b/src/app/api/api_v1/endpoints/occlusion_masks.py index e8deee88..e0f41c09 100644 --- a/src/app/api/api_v1/endpoints/occlusion_masks.py +++ b/src/app/api/api_v1/endpoints/occlusion_masks.py @@ -28,7 +28,7 @@ router = APIRouter() FLOAT_PATTERN = r"(0?\.[0-9]{1,3}|0|1)" -MASK_PATTERN = rf"^\({FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN}\)$" +MASK_PATTERN = rf"^\({FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN}\)$" mask_regex = re.compile(MASK_PATTERN) @@ -36,7 +36,7 @@ def validate_mask(mask: str) -> None: if not mask_regex.match(mask): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=("Invalid mask format. Expected: (xmin, ymin, xmax, ymax, conf) with float values in [0,1]."), + detail=("Invalid mask format. Expected: (xmin, ymin, xmax, ymax) with float values in [0,1]."), ) diff --git a/src/app/schemas/occlusion_masks.py b/src/app/schemas/occlusion_masks.py index 0935e17d..6ce2b307 100644 --- a/src/app/schemas/occlusion_masks.py +++ b/src/app/schemas/occlusion_masks.py @@ -16,8 +16,8 @@ class OcclusionMaskCreate(BaseModel): ..., min_length=2, max_length=255, - description="string representation of tuple where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf", - json_schema_extra={"examples": ["(0.1,0.1,0.9,0.9,0.5)"]}, + description="string representation of tuple where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax", + json_schema_extra={"examples": ["(0.1,0.1,0.9,0.9)"]}, ) @@ -26,8 +26,8 @@ class OcclusionMaskUpdate(BaseModel): ..., min_length=2, max_length=255, - description="string representation of tuple where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf", - json_schema_extra={"examples": ["(0.1,0.1,0.9,0.9,0.5)"]}, + description="string representation of tuple where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax", + json_schema_extra={"examples": ["(0.1,0.1,0.9,0.9)"]}, ) From 02c15e5a6d571427fc0ddb2582f4aab7dafc43a0 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:23:13 +0100 Subject: [PATCH 48/52] Updates tests --- src/tests/conftest.py | 8 +-- src/tests/endpoints/test_occlusion_masks.py | 72 +++++++++++++++++---- src/tests/endpoints/test_poses.py | 4 +- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 5485788e..1f614baa 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -119,25 +119,25 @@ { "id": 1, "pose_id": 1, - "mask": "(0.1,0.1,0.9,0.9,0.5)", + "mask": "(0.1,0.1,0.9,0.9)", "created_at": datetime.strptime("2025-01-01T00:00:00.000000", dt_format), }, { "id": 2, "pose_id": 1, - "mask": "(0.1,0.1,0.9,0.9,1)", + "mask": "(0.1,0.1,0.9,0.9)", "created_at": datetime.strptime("2025-01-02T00:00:00.000000", dt_format), }, { "id": 3, "pose_id": 2, - "mask": "(1,0.1,0.9,0.9,0.5)", + "mask": "(1,0.1,0.9,0.9)", "created_at": datetime.strptime("2025-01-03T00:00:00.000000", dt_format), }, { "id": 4, "pose_id": 3, - "mask": "(1,0.1,0.1,0.9,1)", + "mask": "(1,0.1,0.1,0.9)", "created_at": datetime.strptime("2025-01-03T00:00:00.000000", dt_format), }, ] diff --git a/src/tests/endpoints/test_occlusion_masks.py b/src/tests/endpoints/test_occlusion_masks.py index 4e9ba080..d644d9b9 100644 --- a/src/tests/endpoints/test_occlusion_masks.py +++ b/src/tests/endpoints/test_occlusion_masks.py @@ -8,14 +8,14 @@ @pytest.mark.parametrize( ("user_idx", "payload", "status_code", "status_detail"), [ - (None, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 401, "Not authenticated"), - (2, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 403, "Incompatible token scope."), - (0, {"pose_id": 0, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 422, None), - (0, {"pose_id": 999, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 404, "Table Pose has no corresponding entry."), - (1, {"pose_id": 3, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 403, "Access forbidden."), # agent org 1, camera org 2 + (None, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9)"}, 401, "Not authenticated"), + (2, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9)"}, 403, "Incompatible token scope."), + (0, {"pose_id": 0, "mask": "(0.1,0.1,0.9,0.9)"}, 422, None), + (0, {"pose_id": 999, "mask": "(0.1,0.1,0.9,0.9)"}, 404, "Table Pose has no corresponding entry."), + (1, {"pose_id": 3, "mask": "(0.1,0.1,0.9,0.9)"}, 403, "Access forbidden."), # agent org 1, camera org 2 (0, {"pose_id": 1, "mask": "invalid"}, 422, None), - (0, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,0.5)"}, 201, None), - (1, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9,1)"}, 201, None), + (0, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9)"}, 201, None), + (1, {"pose_id": 1, "mask": "(0.1,0.1,0.9,0.9)"}, 201, None), ], ) @pytest.mark.asyncio @@ -50,17 +50,61 @@ async def test_create_occlusion_mask( assert "created_at" in json_resp +@pytest.mark.parametrize( + ("user_idx", "mask_id", "status_code", "status_detail"), + [ + (None, 1, 401, "Not authenticated"), + (2, 1, 403, "Incompatible token scope."), + (0, 0, 422, None), + (0, 999, 404, "Table OcclusionMask has no corresponding entry."), + (1, 4, 403, "Access forbidden."), # agent wrong org + (1, 1, 200, None), + (0, 2, 200, None), + ], +) +@pytest.mark.asyncio +async def test_get_occlusion_mask( + async_client: AsyncClient, + occlusion_mask_session: AsyncSession, + user_idx: Union[int, None], + mask_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.get(f"/occlusion_masks/{mask_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["id"] == mask_id + assert "pose_id" in json_resp + assert "mask" in json_resp + assert "created_at" in json_resp + + @pytest.mark.parametrize( ("user_idx", "mask_id", "payload", "status_code", "status_detail"), [ - (None, 1, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 401, "Not authenticated"), - (2, 1, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 403, "Incompatible token scope."), - (0, 0, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 422, None), - (0, 999, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 404, "Table OcclusionMask has no corresponding entry."), - (1, 4, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 403, "Access forbidden."), # agent org mismatch + (None, 1, {"mask": "(0.2,0.2,0.8,0.8)"}, 401, "Not authenticated"), + (2, 1, {"mask": "(0.2,0.2,0.8,0.8)"}, 403, "Incompatible token scope."), + (0, 0, {"mask": "(0.2,0.2,0.8,0.8)"}, 422, None), + (0, 999, {"mask": "(0.2,0.2,0.8,0.8)"}, 404, "Table OcclusionMask has no corresponding entry."), + (1, 4, {"mask": "(0.2,0.2,0.8,0.8)"}, 403, "Access forbidden."), # agent org mismatch (0, 1, {"mask": "bad_format"}, 422, None), - (0, 1, {"mask": "(0.2,0.2,0.8,0.8,0.5)"}, 200, None), - (1, 1, {"mask": "(0.3,0.3,0.7,0.7,1)"}, 200, None), + (0, 1, {"mask": "(0.2,0.2,0.8,0.8)"}, 200, None), + (1, 1, {"mask": "(0.3,0.3,0.7,0.7)"}, 200, None), ], ) @pytest.mark.asyncio diff --git a/src/tests/endpoints/test_poses.py b/src/tests/endpoints/test_poses.py index 46be0bf6..a9bdf1ba 100644 --- a/src/tests/endpoints/test_poses.py +++ b/src/tests/endpoints/test_poses.py @@ -229,9 +229,9 @@ async def test_delete_pose( (None, None, 1, 401, None), (0, None, 1, 200, 2), # admin (1, None, 1, 200, 2), # agent - (2, None, 1, 200, 2), # user + (2, None, 1, 403, 2), # user from other org (None, 0, 1, 200, 2), # cam - (None, 1, 1, 200, 2), # cam from other org + (None, 1, 1, 403, 2), # cam from other org ], ) @pytest.mark.asyncio From e6e70372a1f05aedea3153c87174d42c9f22ac4c Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:23:29 +0100 Subject: [PATCH 49/52] Updates client and tests --- client/pyroclient/client.py | 81 +++++++++++++++++++++++++++++++++++++ client/tests/test_client.py | 6 +++ 2 files changed, 87 insertions(+) diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 64024f17..da023a1e 100644 --- a/client/pyroclient/client.py +++ b/client/pyroclient/client.py @@ -25,6 +25,10 @@ class ClientRoute(str, Enum): # POSES POSES_CREATE = "poses/" POSES_BY_ID = "poses/{pose_id}" + POSES_MASKS_BY_ID = "poses/{pose_id}/occlusion_masks" + # OCCLUSION MASKS + OCCLUSION_MASKS_CREATE = "occlusion_masks" + OCCLUSION_MASKS_BY_ID = "occlusion_masks/{mask_id}" # DETECTIONS DETECTIONS_CREATE = "detections/" DETECTIONS_FETCH = "detections" @@ -210,6 +214,83 @@ def delete_pose(self, pose_id: int) -> Response: timeout=self.timeout, ) + def list_pose_masks(self, pose_id: int) -> Response: + """List occlusion masks for a pose (given its pose_id) + + >>> api_client.list_pose_masks(pose_id=1) + """ + return requests.get( + urljoin(self._route_prefix, ClientRoute.POSES_MASKS_BY_ID.format(pose_id=pose_id)), + headers=self.headers, + timeout=self.timeout, + ) + + # OCCLUSION MASKS + + def create_occlusion_mask( + self, + pose_id: int, + mask: str, + ) -> Response: + """Create a create occlusion_mask for a pose + + >>> api_client.create_occlusion_mask(pose_id=1, mask="(0.1,0.1,0.9,0.9)") + """ + payload = { + "pose_id": pose_id, + "mask": mask, + } + + return requests.post( + urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_CREATE), + headers=self.headers, + json=payload, + timeout=self.timeout, + ) + + def patch_occlusion_mask( + self, + mask_id: int, + mask: str, + ) -> Response: + """Update an occlusion mask + + >>> api_client.patch_occlusion_mask(mask_id=1, mask="(0.1,0.2,0.9,0.3)") + """ + payload = {"mask": mask} + + return requests.patch( + urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_BY_ID.format(mask_id=mask_id)), + headers=self.headers, + json=payload, + timeout=self.timeout, + ) + + def get_occlusion_mask( + self, + mask_id: int, + ) -> Response: + """get mask from occlusion mask + + >>> api_client.get_occlusion_mask(mask_id=1") + """ + return requests.get( + urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_BY_ID.format(mask_id=mask_id)), + headers=self.headers, + timeout=self.timeout, + ) + + def delete_occlusion_mask(self, mask_id: int) -> Response: + """Delete an occlusion mask + + >>> api_client.delete_occlusion_mask(mask_id=1) + """ + return requests.delete( + urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_BY_ID.format(mask_id=mask_id)), + headers=self.headers, + timeout=self.timeout, + ) + # DETECTIONS def create_detection( diff --git a/client/tests/test_client.py b/client/tests/test_client.py index 382f279b..d952fa28 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -56,6 +56,12 @@ def test_agent_workflow(test_cam_workflow, agent_token): assert len(response) == 1 response = agent_client.label_sequence(response[0]["id"], "wildfire_smoke") assert response.status_code == 200, response.__dict__ + response = agent_client.create_occlusion_mask(pose_id=1, mask="(0.1,0.1,0.9,0.9)") # occlusion mask creation + assert response.status_code == 201, response.__dict__ + print("reponseeee creation du mask") + print(response.json()) + response = agent_client.delete_occlusion_mask(mask_id=response.json()["id"]) # occlusion mask deletion + assert response.status_code == 200, response.__dict__ def test_user_workflow(test_cam_workflow, user_token): From cdf9722d7cef51f1243b9e25b0cf8672be1c55b3 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:35:01 +0100 Subject: [PATCH 50/52] headers --- src/app/schemas/occlusion_masks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/schemas/occlusion_masks.py b/src/app/schemas/occlusion_masks.py index 6ce2b307..5ff829bf 100644 --- a/src/app/schemas/occlusion_masks.py +++ b/src/app/schemas/occlusion_masks.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2025, Pyronear. +# Copyright (C) 2020-2026, Pyronear. # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. From 4c7e88ec0b1582548e4a9baef3f5d8adf881fdb8 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:40:58 +0100 Subject: [PATCH 51/52] fix headers --- src/app/api/api_v1/endpoints/occlusion_masks.py | 2 +- src/app/schemas/occlusion_masks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/api_v1/endpoints/occlusion_masks.py b/src/app/api/api_v1/endpoints/occlusion_masks.py index e0f41c09..3353e645 100644 --- a/src/app/api/api_v1/endpoints/occlusion_masks.py +++ b/src/app/api/api_v1/endpoints/occlusion_masks.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2025, Pyronear. +# Copyright (C) 2025-2026, Pyronear. # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. diff --git a/src/app/schemas/occlusion_masks.py b/src/app/schemas/occlusion_masks.py index 5ff829bf..5bd83fd6 100644 --- a/src/app/schemas/occlusion_masks.py +++ b/src/app/schemas/occlusion_masks.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2026, Pyronear. +# Copyright (C) 2025-2026, Pyronear. # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details. From 7d5074dc3cc931d464855c2ae9ad573c101aff90 Mon Sep 17 00:00:00 2001 From: fe51 <55736935+fe51@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:44:11 +0100 Subject: [PATCH 52/52] fix header --- src/app/crud/crud_occlusion_mask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/crud/crud_occlusion_mask.py b/src/app/crud/crud_occlusion_mask.py index 25c97c69..11cff3ff 100644 --- a/src/app/crud/crud_occlusion_mask.py +++ b/src/app/crud/crud_occlusion_mask.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024-2025, Pyronear. +# Copyright (C) 2025-2026, Pyronear. # This program is licensed under the Apache License 2.0. # See LICENSE or go to for full license details.