Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b0387f0
compute cone during seq creation
MateoLostanlen Dec 22, 2025
05c2930
round values
MateoLostanlen Dec 22, 2025
212bac9
drop seq with cones
MateoLostanlen Dec 22, 2025
2be61dd
create alerts
MateoLostanlen Dec 22, 2025
d504b35
add overlap from triangulation pr
MateoLostanlen Dec 22, 2025
c508900
new alerts strat
MateoLostanlen Dec 22, 2025
756038b
missing init
MateoLostanlen Dec 22, 2025
f6cf578
missings deps
MateoLostanlen Dec 22, 2025
086cd7d
use preset variable
MateoLostanlen Dec 22, 2025
b9e3faf
updates routes
MateoLostanlen Dec 22, 2025
2d8d6d0
update poetry
MateoLostanlen Dec 22, 2025
45e5cbb
update loc
MateoLostanlen Dec 22, 2025
f8523c4
error management
MateoLostanlen Dec 23, 2025
4fe12f8
fix on seq case
MateoLostanlen Dec 23, 2025
1bb2d20
use started_at and last_seens_at
MateoLostanlen Dec 23, 2025
2527baa
clean output
MateoLostanlen Dec 23, 2025
31a3960
missing READ
MateoLostanlen Dec 23, 2025
e5ab687
add test
MateoLostanlen Dec 23, 2025
92510a8
fix style
MateoLostanlen Dec 23, 2025
0140d29
test overlap
MateoLostanlen Dec 23, 2025
f1a4236
mypy
MateoLostanlen Dec 23, 2025
cf8a8ed
ruff on test overlap
MateoLostanlen Dec 23, 2025
0bc7de1
import issue
MateoLostanlen Dec 23, 2025
6ab2d8d
fix style
MateoLostanlen Dec 23, 2025
83626e0
fix deletions to respect fk
MateoLostanlen Dec 23, 2025
19e5762
cast
MateoLostanlen Dec 23, 2025
2bc5efb
recompyte alerts after seq annotation
MateoLostanlen Dec 23, 2025
56ad18b
fix alert update
MateoLostanlen Dec 23, 2025
761fefd
style
MateoLostanlen Dec 23, 2025
a306c0b
style
MateoLostanlen Dec 23, 2025
2ab57fd
adapt test
MateoLostanlen Dec 23, 2025
afda94e
ruff on test
MateoLostanlen Dec 23, 2025
1d9732f
add tests on detections
MateoLostanlen Dec 24, 2025
204bbb6
increase test on seq
MateoLostanlen Dec 24, 2025
662300e
ruff
MateoLostanlen Dec 24, 2025
3d3fa35
limit lat and lon
MateoLostanlen Dec 30, 2025
ea1994a
rename fonction
MateoLostanlen Dec 30, 2025
7c78792
rename to sequence_azimuth
MateoLostanlen Jan 3, 2026
a50fca8
rename sequence camera azimuth
MateoLostanlen Jan 3, 2026
420ac01
add AlertBase
MateoLostanlen Jan 3, 2026
3c67124
adapt e2e
MateoLostanlen Jan 3, 2026
318b54f
Merge branch 'main' into alerts
MateoLostanlen Jan 3, 2026
06895c0
new headers
MateoLostanlen Jan 3, 2026
e71b467
header fix
MateoLostanlen Jan 3, 2026
549ee5d
header fix
MateoLostanlen Jan 3, 2026
8c5dfac
chore(sequences): remove debug print statement
MateoLostanlen Jan 4, 2026
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
437 changes: 416 additions & 21 deletions poetry.lock

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ python-multipart = "==0.0.7"
python-magic = "^0.4.17"
boto3 = "^1.26.0"
httpx = "^0.24.0"
geopy = "^2.4.0"
networkx = "^3.2.0"
numpy = "^1.26.0"
pandas = "^2.2.0"
pyproj = "^3.6.0"
shapely = "^2.0.0"

[tool.poetry.group.quality]
optional = true
Expand Down Expand Up @@ -154,6 +160,20 @@ check_untyped_defs = true
implicit_reexport = false
explicit_package_bases = true
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = [
"pandas",
"pandas.*",
"numpy",
"pyproj",
"shapely.*",
"geopy.*",
"passlib",
"passlib.*",
"requests",
"requests.*",
]
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = [
Expand Down
2 changes: 1 addition & 1 deletion scripts/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def main(args):
assert sequence["camera_id"] == cam_id
assert sequence["started_at"] == response.json()["created_at"]
assert sequence["last_seen_at"] > sequence["started_at"]
assert sequence["azimuth"] == response.json()["azimuth"]
assert sequence["camera_azimuth"] == response.json()["azimuth"]
# Fetch the latest sequence
assert len(api_request("get", f"{args.endpoint}/sequences/unlabeled/latest", agent_auth)) == 1
# Fetch from date
Expand Down
135 changes: 135 additions & 0 deletions src/app/api/api_v1/endpoints/alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Copyright (C) 2025-2026, 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 datetime import date, datetime, timedelta
from typing import Any, List, Union, cast

from fastapi import APIRouter, Depends, HTTPException, Path, Query, Security, status
from sqlalchemy import asc, desc
from sqlmodel import delete, func, select
from sqlmodel.ext.asyncio.session import AsyncSession

from app.api.dependencies import get_alert_crud, get_jwt
from app.crud import AlertCRUD
from app.db import get_session
from app.models import Alert, AlertSequence, Sequence, UserRole
from app.schemas.alerts import AlertRead
from app.schemas.login import TokenPayload
from app.services.telemetry import telemetry_client

router = APIRouter()


def verify_org_rights(organization_id: int, alert: Alert) -> None:
if organization_id != alert.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")


@router.get("/{alert_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific alert")
async def get_alert(
alert_id: int = Path(..., gt=0),
alerts: AlertCRUD = Depends(get_alert_crud),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> AlertRead:
telemetry_client.capture(token_payload.sub, event="alerts-get", properties={"alert_id": alert_id})
alert = cast(Alert, await alerts.get(alert_id, strict=True))

if UserRole.ADMIN not in token_payload.scopes:
verify_org_rights(token_payload.organization_id, alert)

return AlertRead(**alert.model_dump())


@router.get(
"/{alert_id}/sequences", status_code=status.HTTP_200_OK, summary="Fetch the sequences associated to an alert"
)
async def fetch_alert_sequences(
alert_id: int = Path(..., gt=0),
limit: int = Query(10, description="Maximum number of sequences to fetch", ge=1, le=100),
order_desc: bool = Query(True, description="Whether to order the sequences by last_seen_at in descending order"),
alerts: AlertCRUD = Depends(get_alert_crud),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[Sequence]:
telemetry_client.capture(token_payload.sub, event="alerts-sequences-get", properties={"alert_id": alert_id})
alert = cast(Alert, await alerts.get(alert_id, strict=True))
if UserRole.ADMIN not in token_payload.scopes:
verify_org_rights(token_payload.organization_id, alert)

order_clause: Any = desc(cast(Any, Sequence.last_seen_at)) if order_desc else asc(cast(Any, Sequence.last_seen_at))

seq_stmt: Any = select(Sequence).join(AlertSequence, cast(Any, AlertSequence.sequence_id == Sequence.id))
seq_stmt = seq_stmt.where(AlertSequence.alert_id == alert_id).order_by(order_clause).limit(limit)

res = await session.exec(seq_stmt)
return list(res.all())


@router.get(
"/unlabeled/latest",
status_code=status.HTTP_200_OK,
summary="Fetch all the alerts with unlabeled sequences from the last 24 hours",
)
async def fetch_latest_unlabeled_alerts(
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[AlertRead]:
telemetry_client.capture(token_payload.sub, event="alerts-fetch-latest")

alerts_stmt: Any = select(Alert).join(AlertSequence, cast(Any, AlertSequence.alert_id == Alert.id))
alerts_stmt = alerts_stmt.join(Sequence, cast(Any, Sequence.id == AlertSequence.sequence_id))
alerts_stmt = (
alerts_stmt.where(Alert.organization_id == token_payload.organization_id)
.where(Sequence.last_seen_at > datetime.utcnow() - timedelta(hours=24))
.where(Sequence.is_wildfire.is_(None)) # type: ignore[union-attr]
.order_by(Alert.started_at.desc()) # type: ignore[attr-defined]
.limit(15)
)
alerts_res = await session.exec(alerts_stmt)
return [AlertRead(**a.model_dump()) for a in alerts_res.unique().all()] # unique to deduplicate joins


@router.get("/all/fromdate", status_code=status.HTTP_200_OK, summary="Fetch all the alerts for a specific date")
async def fetch_alerts_from_date(
from_date: date = Query(),
limit: Union[int, None] = Query(15, description="Maximum number of alerts to fetch"),
offset: Union[int, None] = Query(0, description="Number of alerts to skip before starting to fetch"),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[AlertRead]:
telemetry_client.capture(token_payload.sub, event="alerts-fetch-from-date")

alerts_stmt: Any = (
select(Alert)
.where(Alert.organization_id == token_payload.organization_id)
.where(func.date(Alert.started_at) == from_date)
.order_by(Alert.started_at.desc()) # type: ignore[attr-defined]
.limit(limit)
.offset(offset)
)
alerts_res = await session.exec(alerts_stmt)
return [AlertRead(**a.model_dump()) for a in alerts_res.all()]


@router.delete("/{alert_id}", status_code=status.HTTP_200_OK, summary="Delete an alert")
async def delete_alert(
alert_id: int = Path(..., gt=0),
alerts: AlertCRUD = Depends(get_alert_crud),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN]),
) -> None:
telemetry_client.capture(token_payload.sub, event="alert-deletion", properties={"alert_id": alert_id})

# Ensure alert exists and org is valid
alert = cast(Alert, await alerts.get(alert_id, strict=True))
verify_org_rights(token_payload.organization_id, alert)

# Delete associations
delete_stmt: Any = delete(AlertSequence).where(AlertSequence.alert_id == cast(Any, alert_id))
await session.exec(delete_stmt)
await session.commit()
# Delete alert
await alerts.delete(alert_id)
Loading
Loading