From e059f88df55d1551b1a8c7632ea321b7ab625a5a Mon Sep 17 00:00:00 2001 From: Majyles <123649730+Majyles@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:34:12 +0200 Subject: [PATCH] feat(cameras): allow admins to update a camera's angle of view Add PATCH /cameras/{camera_id}/angle_of_view (admin only), following the existing /name and /trustable routes, so a camera's field of view can be corrected after calibration without recreating the camera. Existing sequences keep the cone computed with the previous angle; only new detections use the corrected value. Closes #694 Co-Authored-By: Claude Opus 5.5 --- src/app/api/api_v1/endpoints/cameras.py | 19 +++++++++++ src/app/crud/crud_camera.py | 7 +++- src/app/schemas/cameras.py | 10 ++++++ src/tests/endpoints/test_cameras.py | 43 +++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/app/api/api_v1/endpoints/cameras.py b/src/app/api/api_v1/endpoints/cameras.py index d291ac71..045bb4b4 100644 --- a/src/app/api/api_v1/endpoints/cameras.py +++ b/src/app/api/api_v1/endpoints/cameras.py @@ -16,6 +16,7 @@ from app.crud.crud_pose import PoseCRUD from app.models import Camera, Role, UserRole from app.schemas.cameras import ( + CameraAngleOfView, CameraCreate, CameraDeviceConfig, CameraEdit, @@ -199,6 +200,24 @@ async def update_camera_name( return CameraOut(**camera.model_dump()) +@router.patch( + "/{camera_id}/angle_of_view", status_code=status.HTTP_200_OK, summary="Update the angle of view of a camera" +) +async def update_camera_angle_of_view( + payload: CameraAngleOfView, + camera_id: int = Path(..., gt=0), + cameras: CameraCRUD = Depends(get_camera_crud), + token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]), +) -> CameraOut: + telemetry_client.capture( + token_payload.sub, + event="cameras-update-angle-of-view", + properties={"camera_id": camera_id, "angle_of_view": payload.angle_of_view}, + ) + camera = await cameras.update(camera_id, payload) + return CameraOut(**camera.model_dump()) + + @router.patch( "/{camera_id}/trustable", status_code=status.HTTP_200_OK, summary="Update the trustable status of a camera" ) diff --git a/src/app/crud/crud_camera.py b/src/app/crud/crud_camera.py index 79ffef73..a7e9d196 100644 --- a/src/app/crud/crud_camera.py +++ b/src/app/crud/crud_camera.py @@ -8,6 +8,7 @@ from app.crud.base import BaseCRUD from app.models import Camera from app.schemas.cameras import ( + CameraAngleOfView, CameraCreate, CameraDeviceConfig, CameraEdit, @@ -20,7 +21,11 @@ class CameraCRUD( - BaseCRUD[Camera, CameraCreate, LastActive | CameraEdit | CameraName | CameraDeviceConfig | CameraTrustable] + BaseCRUD[ + Camera, + CameraCreate, + LastActive | CameraEdit | CameraName | CameraAngleOfView | CameraDeviceConfig | CameraTrustable, + ] ): def __init__(self, session: AsyncSession) -> None: super().__init__(session, Camera) diff --git a/src/app/schemas/cameras.py b/src/app/schemas/cameras.py index 736c8a3d..8547a74c 100644 --- a/src/app/schemas/cameras.py +++ b/src/app/schemas/cameras.py @@ -59,6 +59,16 @@ class CameraName(BaseModel): name: str = Field(..., min_length=5, max_length=100, description="name of the camera") +class CameraAngleOfView(BaseModel): + angle_of_view: float = Field( + ..., + gt=0, + le=360, + description="angle between left and right camera view", + json_schema_extra={"examples": [51.36]}, + ) + + class CameraTrustable(BaseModel): is_trustable: bool = Field(..., description="whether the detection from this camera can be trusted") diff --git a/src/tests/endpoints/test_cameras.py b/src/tests/endpoints/test_cameras.py index 4789977c..46a28aa1 100644 --- a/src/tests/endpoints/test_cameras.py +++ b/src/tests/endpoints/test_cameras.py @@ -616,6 +616,49 @@ async def test_update_camera_name( assert all(response.json()[k] == v for k, v in payload.items()) +@pytest.mark.parametrize( + ("user_idx", "cam_id", "payload", "status_code", "status_detail"), + [ + (None, 1, {"angle_of_view": 51.36}, 401, "Not authenticated"), + (0, 1, {"angle_of_view": 0}, 422, None), + (0, 1, {"angle_of_view": 360.5}, 422, None), + (0, 1, {}, 422, None), + (0, 999, {"angle_of_view": 51.36}, 404, "Table Camera has no corresponding entry."), + (0, 1, {"angle_of_view": 51.36}, 200, None), + (0, 2, {"angle_of_view": 360}, 200, None), + (1, 1, {"angle_of_view": 51.36}, 403, "Incompatible token scope."), + (2, 2, {"angle_of_view": 51.36}, 403, "Incompatible token scope."), + ], +) +@pytest.mark.asyncio +async def test_update_camera_angle_of_view( + async_client: AsyncClient, + camera_session: AsyncSession, + user_idx: Union[int, None], + cam_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"/cameras/{cam_id}/angle_of_view", 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 // 100 == 2: + assert response.json()["angle_of_view"] == payload["angle_of_view"] + # Only the angle of view changes + before = next(cam for cam in pytest.camera_table if cam["id"] == cam_id) + assert all(response.json()[k] == before[k] for k in ("name", "lat", "lon", "elevation")) + + @pytest.mark.asyncio async def test_update_camera_trustable( async_client: AsyncClient,