diff --git a/client/pyroclient/client.py b/client/pyroclient/client.py index 31de8cba..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 @@ -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,37 +151,102 @@ 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_ID.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_ID.format(pose_id=pose_id)), + headers=self.headers, + timeout=self.timeout, + ) + # DETECTIONS + def create_detection( self, 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/client/tests/conftest.py b/client/tests/conftest.py index 5de6c59f..143478a3 100644 --- a/client/tests/conftest.py +++ b/client/tests/conftest.py @@ -40,6 +40,11 @@ def cam_token(): response = requests.post(urljoin(API_URL, "cameras"), json=payload, headers=admin_headers, timeout=5) assert response.status_code == 201 cam_id = response.json()["id"] + # create a pose related to the cam + payload = {"azimuth": 359, "patrol_id": 1, "camera_id": cam_id} + response = requests.post(urljoin(API_URL, "poses"), json=payload, headers=admin_headers, timeout=5) + assert response.status_code == 201 + # Create a cam token return requests.post(urljoin(API_URL, f"cameras/{cam_id}/token"), headers=admin_headers, timeout=5).json()[ "access_token" diff --git a/client/tests/test_client.py b/client/tests/test_client.py index bb9ff1c1..382f279b 100644 --- a/client/tests/test_client.py +++ b/client/tests/test_client.py @@ -40,7 +40,7 @@ def test_cam_workflow(cam_token, mock_img): cam_client.create_detection(mock_img, 123.2, None) with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"): cam_client.create_detection(mock_img, 123.2, []) - response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)]) + response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)], pose_id=1) assert response.status_code == 201, response.__dict__ response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5), (0.2, 0.2, 0.7, 0.7, 0.8)]) assert response.status_code == 201, response.__dict__ diff --git a/scripts/dbdiagram.txt b/scripts/dbdiagram.txt index 94ac99ee..6a4038fc 100644 --- a/scripts/dbdiagram.txt +++ b/scripts/dbdiagram.txt @@ -4,19 +4,37 @@ Enum "userrole" { "user" } -Table "User" as U { +Enum "annotationtype" { + "wildfire_smoke" + "other_smoke" + "other" +} + +Table "organizations" as O { + "id" int [not null] + "name" varchar [not null] + "telegram_id" varchar + "slack_hook" varchar + + Indexes { + (id) [pk] + } +} + +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 "Camera" as C { +Table "cameras" as C { "id" int [not null] "organization_id" int [ref: > O.id, not null] "name" varchar [not null] @@ -28,49 +46,56 @@ Table "Camera" as C { "created_at" timestamp [not null] "last_active_at" timestamp "last_image" varchar + + Indexes { + (id) [pk] + } +} + +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 "Sequence" as S { +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 + "is_wildfire" annotationtype "started_at" timestamp [not null] "last_seen_at" timestamp [not null] + Indexes { (id) [pk] } } -Table "Detection" as D { +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] - } -} -Table "Organization" as O { - "id" int [not null] - "name" varchar [not null] - "telegram_id" varchar Indexes { (id) [pk] } } - -Table "Webhook" as W { +Table "webhooks" as W { "id" int [not null] "url" varchar [not null] + Indexes { (id) [pk] } 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) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index cdbb3ae9..a31f09e4 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -8,15 +8,23 @@ 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.crud_pose import 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 +43,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 +103,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, strict=False)] + + 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, strict=False) + ] @router.patch("/heartbeat", status_code=status.HTTP_200_OK, summary="Update last ping of a camera") diff --git a/src/app/api/api_v1/endpoints/detections.py b/src/app/api/api_v1/endpoints/detections.py index b2410d47..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,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: 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), @@ -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, 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..e150d690 --- /dev/null +++ b/src/app/api/api_v1/endpoints/poses.py @@ -0,0 +1,85 @@ +# 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 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 +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 +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 = Body(...), + 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 = 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.") + + 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") +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 = 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.") + + 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 = Body(...), + 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 = 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.") + + 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") +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) 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..ddb47bfe 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_pose import PoseCRUD from app.db import get_session from app.models import User, UserRole from app.schemas.login import TokenPayload @@ -44,6 +45,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) 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/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) diff --git a/src/app/models.py b/src/app/models.py index f157ce8d..64be6851 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: int | 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: 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 @@ -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: 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) 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 * 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( ..., diff --git a/src/app/schemas/poses.py b/src/app/schemas/poses.py new file mode 100644 index 00000000..917f8306 --- /dev/null +++ b/src/app/schemas/poses.py @@ -0,0 +1,33 @@ +# 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 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") + + +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") + + +class PoseRead(PoseBase): + id: int + camera_id: int diff --git a/src/tests/conftest.py b/src/tests/conftest.py index ed02ddcc..2f4d930e 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": 1, + }, + { + "id": 2, + "camera_id": 1, + "azimuth": 90.0, + "patrol_id": 1, + }, + { + "id": 3, + "camera_id": 2, + "azimuth": 180.0, + "patrol_id": 1, + }, +] + + 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 diff --git a/src/tests/endpoints/test_cameras.py b/src/tests/endpoints/test_cameras.py index ab2061e9..03a4ba06 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": 1}, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": 1}, + ], + ), ], ) @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 diff --git a/src/tests/endpoints/test_poses.py b/src/tests/endpoints/test_poses.py new file mode 100644 index 00000000..46091a96 --- /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": 1}, + 401, + "Not authenticated", + ), + ( + 0, + {"camera_id": 1, "patrol_id": 1}, + 422, + None, + ), + ( + 0, + {"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": 1}, # camera 1 = org 1 + 403, + "Incompatible token scope.", + ), + ( + 0, + {"camera_id": 1, "azimuth": 45.0, "patrol_id": 1}, + 201, + None, + ), + ( + 1, + {"camera_id": 1, "azimuth": 90.0, "patrol_id": 120}, + 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": 1}, + ), + ( + 1, + 2, + 200, + None, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": 1}, + ), + ], +) +@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": 123}, + 200, + None, + {"id": 1, "camera_id": 1, "azimuth": 123.4, "patrol_id": 123}, + ), + ( + 1, + 2, + {"patrol_id": 456}, + 200, + None, + {"id": 2, "camera_id": 1, "azimuth": 90.0, "patrol_id": 456}, + ), + ], +) +@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