Skip to content
Open
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
122 changes: 58 additions & 64 deletions scripts/dbdiagram.txt
Original file line number Diff line number Diff line change
@@ -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
14 changes: 11 additions & 3 deletions scripts/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand All @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down
48 changes: 36 additions & 12 deletions src/app/api/api_v1/endpoints/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")]
Expand All @@ -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)]

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")
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/api_v1/endpoints/detections.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions src/app/api/api_v1/endpoints/poses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copyright (C) 2020-2025, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> 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)
Loading
Loading