Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ pytest-pretty = "^1.0.0"
httpx = ">=0.23.0"
aiosqlite = ">=0.16.0,<1.0.0"

[tool.bandit.assert_used]
skips = ["*/test_*.py", "*_test.py"]

[tool.coverage.run]
source = ["src/app", "client/pyroclient"]

Expand Down
29 changes: 24 additions & 5 deletions src/app/api/api_v1/endpoints/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ async def register_camera(
return CameraOut(**camera.model_dump())


@router.get("/{camera_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific camera")
@router.get(
"/{camera_id}",
status_code=status.HTTP_200_OK,
summary="Fetch the information of a specific camera",
description="Returns camera details including a presigned S3 URL for the last captured image. `last_image_url` is null if no image has been uploaded yet or if the image is temporarily unavailable in storage.",
)
async def get_camera(
camera_id: int = Path(..., gt=0),
cameras: CameraCRUD = Depends(get_camera_crud),
Expand All @@ -65,11 +70,19 @@ async def get_camera(
pose_reads = [PoseReadWithoutImgInfo(**p.model_dump()) for p in cam_poses]

bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(camera.organization_id))
last_image_url = bucket.get_public_url(camera.last_image) if camera.last_image else None
try:
last_image_url = bucket.get_public_url(camera.last_image) if camera.last_image else None
except HTTPException:
last_image_url = None
return CameraRead(**camera.model_dump(), last_image_url=last_image_url, poses=pose_reads)


@router.get("/", status_code=status.HTTP_200_OK, summary="Fetch all the cameras")
@router.get(
"/",
status_code=status.HTTP_200_OK,
summary="Fetch all the cameras",
description="Returns all cameras accessible to the current user. `last_image_url` is null for a given camera if no image has been uploaded yet or if the image is temporarily unavailable in storage.",
)
async def fetch_cameras(
cameras: CameraCRUD = Depends(get_camera_crud),
poses: PoseCRUD = Depends(get_pose_crud),
Expand All @@ -82,7 +95,10 @@ 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))
return bucket.get_public_url(cam.last_image)
try:
return bucket.get_public_url(cam.last_image)
except HTTPException:
return None
return None

urls = await asyncio.gather(*[get_url_for_cam(cam) for cam in cams])
Expand All @@ -97,7 +113,10 @@ async def get_url_for_cam(cam: Camera) -> str | None: # noqa: RUF029

async def get_url_for_cam_single_bucket(cam: Camera) -> str | None: # noqa: RUF029
if cam.last_image:
return bucket.get_public_url(cam.last_image)
try:
return bucket.get_public_url(cam.last_image)
except HTTPException:
return None
return None

urls = await asyncio.gather(*[get_url_for_cam_single_bucket(cam) for cam in cams])
Expand Down
7 changes: 5 additions & 2 deletions src/app/schemas/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,15 @@ class CameraOut(CameraCreate):
last_active_at: datetime | None
last_image: str | None
created_at: datetime
poses: list[PoseReadWithoutImgInfo] = Field(default_factory=list)


class CameraRead(CameraOut):
"""
Returned by read endpoints
"""

last_image_url: str | None = Field(None, description="URL of the last image of the camera")
poses: list[PoseReadWithoutImgInfo] = Field(default_factory=list)
last_image_url: str | None = Field(
None,
description="Presigned URL of the last image of the camera. Returns null if no image has been uploaded yet or if the image is temporarily unavailable in storage.",
)
72 changes: 67 additions & 5 deletions src/tests/endpoints/test_cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession

from app.services.storage import s3_service


@pytest.mark.parametrize(
("user_idx", "payload", "status_code", "status_detail"),
Expand Down Expand Up @@ -93,7 +95,7 @@ async def test_create_camera(
assert {
k: v
for k, v in response.json().items()
if k not in {"id", "created_at", "last_active_at", "is_trustable", "last_image"}
if k not in {"id", "created_at", "last_active_at", "is_trustable", "last_image", "poses"}
} == payload


Expand Down Expand Up @@ -315,8 +317,8 @@ async def test_heartbeat(
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 {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"
assert {k: v for k, v in response.json().items() if k not in {"last_active_at", "poses"}} == {
k: v for k, v in pytest.camera_table[cam_idx].items() if k not in {"last_active_at", "poses"}
}


Expand Down Expand Up @@ -358,8 +360,8 @@ async def test_update_image(
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 {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"}
assert {k: v for k, v in response.json().items() if k not in {"last_active_at", "last_image", "poses"}} == {
k: v for k, v in pytest.camera_table[cam_idx].items() if k not in {"last_active_at", "last_image", "poses"}
}


Expand Down Expand Up @@ -671,3 +673,63 @@ async def test_fetch_cameras_with_last_image(
assert camera_1["last_image_url"] is not None
assert isinstance(camera_1["last_image_url"], str)
assert "http" in camera_1["last_image_url"]


@pytest.mark.asyncio
async def test_get_camera_s3_unavailable_returns_null_url(
async_client: AsyncClient,
camera_session: AsyncSession,
pose_session: AsyncSession,
mock_img: bytes,
):
cam_auth = pytest.get_token(pytest.camera_table[0]["id"], ["camera"], pytest.camera_table[0]["organization_id"])
upload_response = await async_client.patch(
"/cameras/image", files={"file": ("img.png", mock_img, "image/png")}, headers=cam_auth
)
assert upload_response.status_code == 200
bucket_key = upload_response.json()["last_image"]

user_auth = pytest.get_token(
pytest.user_table[1]["id"], pytest.user_table[1]["role"].split(), pytest.user_table[1]["organization_id"]
)

# Verify the URL is accessible before deletion
response = await async_client.get("/cameras/1", headers=user_auth)
assert response.status_code == 200
assert response.json()["last_image_url"] is not None

# Delete the file from S3 then verify the endpoint handles it gracefully
bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(pytest.camera_table[0]["organization_id"]))
bucket.delete_file(bucket_key)

response = await async_client.get("/cameras/1", headers=user_auth)
assert response.status_code == 200
assert response.json()["last_image_url"] is None


@pytest.mark.asyncio
async def test_fetch_cameras_s3_unavailable_returns_null_url(
async_client: AsyncClient,
camera_session: AsyncSession,
pose_session: AsyncSession,
mock_img: bytes,
):
cam_auth = pytest.get_token(pytest.camera_table[0]["id"], ["camera"], pytest.camera_table[0]["organization_id"])
upload_response = await async_client.patch(
"/cameras/image", files={"file": ("img.png", mock_img, "image/png")}, headers=cam_auth
)
assert upload_response.status_code == 200
bucket_key = upload_response.json()["last_image"]

bucket = s3_service.get_bucket(s3_service.resolve_bucket_name(pytest.camera_table[0]["organization_id"]))
bucket.delete_file(bucket_key)

# Use a non-admin user (agent, org 1) to hit the get_url_for_cam_single_bucket path
user_auth = pytest.get_token(
pytest.user_table[1]["id"], pytest.user_table[1]["role"].split(), pytest.user_table[1]["organization_id"]
)
response = await async_client.get("/cameras", headers=user_auth)
assert response.status_code == 200
cameras = response.json()
camera_1 = next(c for c in cameras if c["id"] == 1)
assert camera_1["last_image_url"] is None
Loading