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
28 changes: 28 additions & 0 deletions client/pyroclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ class ClientRoute(str, Enum):
SEQUENCES_FETCH_DETECTIONS = "sequences/{seq_id}/detections"
SEQUENCES_FETCH_LATEST = "sequences/unlabeled/latest"
SEQUENCES_FETCH_FROMDATE = "sequences/all/fromdate"
# ALERTS
ALERTS_UNMATCH_SEQUENCE = "alerts/{alert_id}/sequences/{seq_id}/unmatch"
# ORGS
ORGS_FETCH = "organizations"

Expand Down Expand Up @@ -496,6 +498,32 @@ def fetch_sequences_detections(self, sequence_id: int, limit: int = 10, desc: bo
timeout=self.timeout,
)

# ALERTS

def unmatch_alert_sequence(self, alert_id: int, sequence_id: int) -> Response:
"""Detach a sequence from an alert. If the sequence is no longer linked to any alert,
a new alert is created for it.

>>> from pyroclient import client
>>> api_client = Client("MY_USER_TOKEN")
>>> response = api_client.unmatch_alert_sequence(1, 2)

Args:
alert_id: ID of the alert the sequence should be detached from
sequence_id: ID of the sequence to detach

Returns:
HTTP response
"""
return requests.post(
urljoin(
self._route_prefix,
ClientRoute.ALERTS_UNMATCH_SEQUENCE.format(alert_id=alert_id, seq_id=sequence_id),
),
headers=self.headers,
timeout=self.timeout,
)

# ORGANIZATIONS

def fetch_organizations(self) -> Response:
Expand Down
85 changes: 81 additions & 4 deletions src/app/api/api_v1/endpoints/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
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.api.dependencies import get_alert_crud, get_camera_crud, get_jwt, get_sequence_crud
from app.core.time import utcnow
from app.crud import AlertCRUD
from app.crud import AlertCRUD, CameraCRUD, SequenceCRUD
from app.db import get_session
from app.models import Alert, AlertSequence, Sequence, UserRole
from app.schemas.alerts import AlertReadWithSequences
from app.models import Alert, AlertSequence, Camera, Sequence, UserRole
from app.schemas.alerts import AlertCreate, AlertReadWithSequences
from app.schemas.login import TokenPayload
from app.schemas.sequences import SequenceRead
from app.services.alerts import refresh_alert_state
from app.services.telemetry import telemetry_client

router = APIRouter()
Expand Down Expand Up @@ -148,6 +149,82 @@ async def fetch_alerts_from_date(
return [_serialize_alert(alert, seq_map.get(int(alert.id), [])) for alert in alerts]


@router.post(
"/{alert_id}/sequences/{sequence_id}/unmatch",
status_code=status.HTTP_200_OK,
summary="Detach a sequence from an alert; create a fresh alert if the sequence becomes orphaned",
)
async def unmatch_alert_sequence(
alert_id: int = Path(..., gt=0),
sequence_id: int = Path(..., gt=0),
alerts: AlertCRUD = Depends(get_alert_crud),
sequences: SequenceCRUD = Depends(get_sequence_crud),
cameras: CameraCRUD = Depends(get_camera_crud),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT]),
) -> Union[AlertReadWithSequences, None]:
telemetry_client.capture(
token_payload.sub,
event="alerts-sequence-unmatch",
properties={"alert_id": alert_id, "sequence_id": sequence_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)

link_stmt: Any = select(AlertSequence).where(
AlertSequence.alert_id == alert_id, AlertSequence.sequence_id == sequence_id
)
link = (await session.exec(link_stmt)).first()
if link is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sequence is not attached to this alert.")

count_stmt: Any = select(func.count()).select_from(AlertSequence).where(AlertSequence.alert_id == alert_id)
sequence_count = int((await session.exec(count_stmt)).one())
if sequence_count <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot unmatch the only sequence of an alert.",
)

delete_stmt: Any = delete(AlertSequence).where(
cast(Any, AlertSequence.alert_id) == alert_id,
cast(Any, AlertSequence.sequence_id) == sequence_id,
)
await session.exec(delete_stmt)
await session.commit()

await refresh_alert_state(alert_id, session, alerts)

other_links_stmt: Any = (
select(func.count()).select_from(AlertSequence).where(AlertSequence.sequence_id == sequence_id)
)
other_links = int((await session.exec(other_links_stmt)).one())
if other_links > 0:
return None

sequence = cast(Sequence, await sequences.get(sequence_id, strict=True))
camera = cast(Camera, await cameras.get(sequence.camera_id, strict=True))
if camera.organization_id != alert.organization_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Sequence camera does not belong to the same organization as the alert.",
)
new_alert = await alerts.create(
AlertCreate(
organization_id=alert.organization_id,
started_at=sequence.started_at,
last_seen_at=sequence.last_seen_at,
lat=None,
lon=None,
)
)
session.add(AlertSequence(alert_id=new_alert.id, sequence_id=sequence_id))
await session.commit()
await session.refresh(new_alert)
return _serialize_alert(new_alert, [sequence])


@router.delete("/{alert_id}", status_code=status.HTTP_200_OK, summary="Delete an alert")
async def delete_alert(
alert_id: int = Path(..., gt=0),
Expand Down
56 changes: 4 additions & 52 deletions src/app/api/api_v1/endpoints/sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from datetime import date, timedelta
from typing import Any, List, Union, cast

import pandas as pd
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Security, status
from sqlmodel import delete, func, select
from sqlmodel.ext.asyncio.session import AsyncSession
Expand All @@ -17,11 +16,11 @@
from app.crud import AlertCRUD, CameraCRUD, DetectionCRUD, SequenceCRUD
from app.db import get_session
from app.models import AlertSequence, AnnotationType, Camera, Detection, Sequence, UserRole
from app.schemas.alerts import AlertCreate, AlertUpdate
from app.schemas.alerts import AlertCreate
from app.schemas.detections import DetectionRead, DetectionSequence, DetectionWithUrl
from app.schemas.login import TokenPayload
from app.schemas.sequences import SequenceLabel, SequenceRead
from app.services.overlap import compute_overlap
from app.services.alerts import refresh_alert_state
from app.services.storage import s3_service
from app.services.telemetry import telemetry_client

Expand All @@ -36,53 +35,6 @@ async def verify_org_rights(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")


async def _refresh_alert_state(alert_id: int, session: AsyncSession, alerts: AlertCRUD) -> None:
remaining_stmt: Any = (
select(Sequence, Camera)
.join(AlertSequence, cast(Any, AlertSequence.sequence_id) == Sequence.id)
.join(Camera, cast(Any, Camera.id) == Sequence.camera_id)
)
remaining_stmt = remaining_stmt.where(AlertSequence.alert_id == alert_id)
remaining_res = await session.exec(remaining_stmt)
rows = remaining_res.all()
if not rows:
await alerts.delete(alert_id)
return

seqs = [row[0] for row in rows]
cams = [row[1] for row in rows]
new_start = min(seq.started_at for seq in seqs)
new_last = max(seq.last_seen_at for seq in seqs)

loc: Union[tuple[float, float], None] = None
if len(rows) >= 2:
records = []
for seq, cam in zip(seqs, cams, strict=False):
records.append({
"id": seq.id,
"pose_id": seq.pose_id,
"lat": cam.lat,
"lon": cam.lon,
"sequence_azimuth": seq.sequence_azimuth,
"cone_angle": seq.cone_angle,
"is_wildfire": seq.is_wildfire,
"started_at": seq.started_at,
"last_seen_at": seq.last_seen_at,
})
df = compute_overlap(pd.DataFrame.from_records(records))
loc = next((loc for locs in df["event_smoke_locations"].tolist() for loc in locs if loc is not None), None)

await alerts.update(
alert_id,
AlertUpdate(
started_at=new_start,
last_seen_at=new_last,
lat=loc[0] if loc else None,
lon=loc[1] if loc else None,
),
)


@router.get("/{sequence_id}", status_code=status.HTTP_200_OK, summary="Fetch the information of a specific sequence")
async def get_sequence(
sequence_id: int = Path(..., gt=0),
Expand Down Expand Up @@ -207,7 +159,7 @@ async def delete_sequence(
await sequences.delete(sequence_id)
# Refresh affected alerts
for aid in alert_ids:
await _refresh_alert_state(aid, session, alerts)
await refresh_alert_state(aid, session, alerts)


@router.patch("/{sequence_id}/label", status_code=status.HTTP_200_OK, summary="Label the nature of the sequence")
Expand Down Expand Up @@ -239,7 +191,7 @@ async def label_sequence(
await session.exec(delete_links)
await session.commit()
for aid in alert_ids:
await _refresh_alert_state(aid, session, alerts)
await refresh_alert_state(aid, session, alerts)
# Create a fresh alert for this sequence alone
camera = cast(Camera, await cameras.get(sequence.camera_id, strict=True))
new_alert = await alerts.create(
Expand Down
66 changes: 66 additions & 0 deletions src/app/services/alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 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 typing import Any, Union, cast

import pandas as pd
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession

from app.crud import AlertCRUD
from app.models import AlertSequence, Camera, Sequence
from app.schemas.alerts import AlertUpdate
from app.services.overlap import compute_overlap

__all__ = ["refresh_alert_state"]


async def refresh_alert_state(alert_id: int, session: AsyncSession, alerts: AlertCRUD) -> None:
"""Recompute an alert's bounds and location from its remaining sequences, or delete it if empty."""
remaining_stmt: Any = (
select(Sequence, Camera)
.join(AlertSequence, cast(Any, AlertSequence.sequence_id) == Sequence.id)
.join(Camera, cast(Any, Camera.id) == Sequence.camera_id)
)
remaining_stmt = remaining_stmt.where(AlertSequence.alert_id == alert_id)
remaining_res = await session.exec(remaining_stmt)
rows = remaining_res.all()
if not rows:
await alerts.delete(alert_id)
return

seqs = [row[0] for row in rows]
cams = [row[1] for row in rows]
new_start = min(seq.started_at for seq in seqs)
new_last = max(seq.last_seen_at for seq in seqs)

loc: Union[tuple[float, float], None] = None
if len(rows) >= 2:
records = []
for seq, cam in zip(seqs, cams, strict=False):
records.append({
"id": seq.id,
"pose_id": seq.pose_id,
"lat": cam.lat,
"lon": cam.lon,
"sequence_azimuth": seq.sequence_azimuth,
"cone_angle": seq.cone_angle,
"is_wildfire": seq.is_wildfire,
"started_at": seq.started_at,
"last_seen_at": seq.last_seen_at,
})
df = compute_overlap(pd.DataFrame.from_records(records))
loc = next((loc for locs in df["event_smoke_locations"].tolist() for loc in locs if loc is not None), None)

await alerts.update(
alert_id,
AlertUpdate(
started_at=new_start,
last_seen_at=new_last,
lat=loc[0] if loc else None,
lon=loc[1] if loc else None,
),
)
Loading
Loading