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
50 changes: 31 additions & 19 deletions src/app/api/api_v1/endpoints/alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,13 @@ async def export_alerts_csv(

stmt: Any = (
select(Alert)
.where(Alert.organization_id == token_payload.organization_id)
.where(Alert.started_at >= start_dt)
.where(Alert.started_at <= end_dt)
.order_by(Alert.started_at.asc()) # type: ignore[attr-defined]
)
# Admins export every organization's alerts
if UserRole.ADMIN not in token_payload.scopes:
stmt = stmt.where(Alert.organization_id == token_payload.organization_id)
alerts = list((await session.exec(stmt)).all())
seq_map = await _fetch_sequences_by_alert_ids(session, [alert.id for alert in alerts])
camera_names_by_id = await _fetch_camera_names_by_ids(
Expand Down Expand Up @@ -292,17 +294,22 @@ async def fetch_latest_unlabeled_alerts(
limit: Union[int, None] = Query(15, ge=1, description="Maximum number of alerts to fetch"),
offset: Union[int, None] = Query(0, description="Number of alerts to skip before starting to fetch"),
risk_score: Union[FwiClass, None] = Query(
None, description="Override FWI class applied to every sequence; bypasses risk-api lookup."
None,
description="Override FWI class applied to every sequence; bypasses risk-api lookup. Ignored for admins.",
),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[AlertReadWithSequences]:
telemetry_client.capture(token_payload.sub, event="alerts-fetch-latest")
is_admin = UserRole.ADMIN in token_payload.scopes

fwi_classes_by_camera = await _resolve_fwi_class_per_camera(
session, token_payload.organization_id, override_class=risk_score
)
seq_filter = max_conf_filter_clause(fwi_classes_by_camera)
# Admins see every organization's alerts without risk-score filtering
seq_filter = None
if not is_admin:
fwi_classes_by_camera = await _resolve_fwi_class_per_camera(
session, token_payload.organization_id, override_class=risk_score
)
seq_filter = max_conf_filter_clause(fwi_classes_by_camera)

seq_match: Any = cast(
Any,
Expand All @@ -314,9 +321,11 @@ async def fetch_latest_unlabeled_alerts(
if seq_filter is not None:
seq_match = seq_match.where(seq_filter)

alerts_stmt: Any = (
select(Alert)
.where(Alert.organization_id == token_payload.organization_id)
alerts_stmt: Any = select(Alert)
if not is_admin:
alerts_stmt = alerts_stmt.where(Alert.organization_id == token_payload.organization_id)
alerts_stmt = (
alerts_stmt
.where(cast(Any, Alert.id).in_(seq_match))
.order_by(Alert.started_at.desc()) # type: ignore[attr-defined]
.limit(limit)
Expand All @@ -338,23 +347,26 @@ async def fetch_alerts_from_date(
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"),
risk_score: Union[FwiClass, None] = Query(
None, description="Override FWI class applied to every sequence; bypasses risk-api lookup."
None,
description="Override FWI class applied to every sequence; bypasses risk-api lookup. Ignored for admins.",
),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[AlertReadWithSequences]:
telemetry_client.capture(token_payload.sub, event="alerts-fetch-from-date")
is_admin = UserRole.ADMIN in token_payload.scopes

fwi_classes_by_camera = await _resolve_fwi_class_per_camera(
session, token_payload.organization_id, target_date=from_date, override_class=risk_score
)
seq_filter = max_conf_filter_clause(fwi_classes_by_camera)
# Admins see every organization's alerts without risk-score filtering
seq_filter = None
if not is_admin:
fwi_classes_by_camera = await _resolve_fwi_class_per_camera(
session, token_payload.organization_id, target_date=from_date, override_class=risk_score
)
seq_filter = max_conf_filter_clause(fwi_classes_by_camera)

alerts_stmt: Any = (
select(Alert)
.where(Alert.organization_id == token_payload.organization_id)
.where(func.date(Alert.started_at) == from_date)
)
alerts_stmt: Any = select(Alert).where(func.date(Alert.started_at) == from_date)
if not is_admin:
alerts_stmt = alerts_stmt.where(Alert.organization_id == token_payload.organization_id)
if seq_filter is not None:
seq_match: Any = select(AlertSequence.alert_id).join(
Sequence, cast(Any, Sequence.id == AlertSequence.sequence_id)
Expand Down
66 changes: 37 additions & 29 deletions src/app/api/api_v1/endpoints/sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,28 +109,32 @@ async def fetch_sequence_detections(
)
async def fetch_latest_unlabeled_sequences(
risk_score: Union[FwiClass, None] = Query(
None, description="Override FWI class applied to every sequence; bypasses risk-api lookup."
None,
description="Override FWI class applied to every sequence; bypasses risk-api lookup. Ignored for admins.",
),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[SequenceRead]:
telemetry_client.capture(token_payload.sub, event="sequence-fetch-latest")
camera_ids = (
await session.exec(select(Camera.id).where(Camera.organization_id == token_payload.organization_id))
).all()
classes: dict[int, Union[str, None]] = (
dict.fromkeys(camera_ids, risk_score) if risk_score is not None else dict(risk_service.scores())
)
is_admin = UserRole.ADMIN in token_payload.scopes

stmt: Any = (
select(Sequence)
.where(Sequence.started_at > utcnow() - timedelta(hours=24))
.where(Sequence.camera_id.in_(camera_ids)) # type: ignore[attr-defined]
.where(Sequence.is_wildfire.is_(None)) # type: ignore[union-attr]
)
seq_filter = max_conf_filter_clause(classes)
if seq_filter is not None:
stmt = stmt.where(seq_filter)
# Admins see every organization's sequences without risk-score filtering
if not is_admin:
camera_ids = (
await session.exec(select(Camera.id).where(Camera.organization_id == token_payload.organization_id))
).all()
classes: dict[int, Union[str, None]] = (
dict.fromkeys(camera_ids, risk_score) if risk_score is not None else dict(risk_service.scores())
)
stmt = stmt.where(Sequence.camera_id.in_(camera_ids)) # type: ignore[attr-defined]
seq_filter = max_conf_filter_clause(classes)
if seq_filter is not None:
stmt = stmt.where(seq_filter)
stmt = stmt.order_by(Sequence.started_at.desc()).limit(15) # type: ignore[attr-defined]

fetched_sequences = (await session.exec(stmt)).all()
Expand All @@ -144,29 +148,33 @@ async def fetch_sequences_from_date(
limit: Union[int, None] = Query(15, description="Maximum number of sequences to fetch"),
offset: Union[int, None] = Query(0, description="Number of sequences to skip before starting to fetch"),
risk_score: Union[FwiClass, None] = Query(
None, description="Override FWI class applied to every sequence; bypasses risk-api lookup."
None,
description="Override FWI class applied to every sequence; bypasses risk-api lookup. Ignored for admins.",
),
session: AsyncSession = Depends(get_session),
token_payload: TokenPayload = Security(get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER]),
) -> List[SequenceRead]:
telemetry_client.capture(token_payload.sub, event="sequence-fetch-from-date")
# Limit to cameras in the same organization
camera_ids = (
await session.exec(select(Camera.id).where(Camera.organization_id == token_payload.organization_id))
).all()
classes: dict[int, str | None]
if risk_score is not None:
classes = dict.fromkeys(camera_ids, risk_score)
else:
scores = await risk_service.get_scores_for_date(from_date, organization_id=token_payload.organization_id)
classes = dict(scores)

stmt: Any = (
select(Sequence).where(func.date(Sequence.started_at) == from_date).where(Sequence.camera_id.in_(camera_ids)) # type: ignore[attr-defined]
)
seq_filter = max_conf_filter_clause(classes)
if seq_filter is not None:
stmt = stmt.where(seq_filter)
is_admin = UserRole.ADMIN in token_payload.scopes

stmt: Any = select(Sequence).where(func.date(Sequence.started_at) == from_date)
# Admins see every organization's sequences without risk-score filtering
if not is_admin:
# Limit to cameras in the same organization
camera_ids = (
await session.exec(select(Camera.id).where(Camera.organization_id == token_payload.organization_id))
).all()
classes: dict[int, str | None]
if risk_score is not None:
classes = dict.fromkeys(camera_ids, risk_score)
else:
scores = await risk_service.get_scores_for_date(from_date, organization_id=token_payload.organization_id)
classes = dict(scores)

stmt = stmt.where(Sequence.camera_id.in_(camera_ids)) # type: ignore[attr-defined]
seq_filter = max_conf_filter_clause(classes)
if seq_filter is not None:
stmt = stmt.where(seq_filter)
stmt = stmt.order_by(Sequence.started_at.desc()).limit(limit).offset(offset) # type: ignore[attr-defined]

fetched_sequences = (await session.exec(stmt)).all()
Expand Down
53 changes: 53 additions & 0 deletions src/tests/endpoints/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,42 @@ async def test_alerts_from_date(async_client: AsyncClient, detection_session: As
assert any(seq["detections_count"] == 0 for seq in alert_payload["sequences"])


@pytest.mark.asyncio
async def test_alerts_admin_sees_all_organizations(async_client: AsyncClient, detection_session: AsyncSession):
org1_alert, _, _ = await _create_alert_with_sequences(detection_session, org_id=1, camera_id=1, lat=48.0, lon=2.0)
org2_alert, _, _ = await _create_alert_with_sequences(detection_session, org_id=2, camera_id=2, lat=44.0, lon=5.0)

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

# Admin of organization 1 sees both organizations' alerts
resp = await async_client.get("/alerts/unlabeled/latest", headers=admin_auth)
assert resp.status_code == 200, resp.text
assert {org1_alert.id, org2_alert.id}.issubset({item["id"] for item in resp.json()})

date_str = org1_alert.started_at.date().isoformat()
resp = await async_client.get(f"/alerts/all/fromdate?from_date={date_str}", headers=admin_auth)
assert resp.status_code == 200, resp.text
assert {org1_alert.id, org2_alert.id}.issubset({item["id"] for item in resp.json()})

# Agent of organization 1 only sees its own organization's alerts
resp = await async_client.get("/alerts/unlabeled/latest", headers=agent_auth)
assert resp.status_code == 200, resp.text
returned_ids = {item["id"] for item in resp.json()}
assert org1_alert.id in returned_ids
assert org2_alert.id not in returned_ids

resp = await async_client.get(f"/alerts/all/fromdate?from_date={date_str}", headers=agent_auth)
assert resp.status_code == 200, resp.text
returned_ids = {item["id"] for item in resp.json()}
assert org1_alert.id in returned_ids
assert org2_alert.id not in returned_ids


@pytest.mark.asyncio
async def test_triangulation_creates_single_alert(
async_client: AsyncClient, detection_session: AsyncSession, mock_img: bytes
Expand Down Expand Up @@ -830,6 +866,23 @@ async def test_alerts_export_org_isolation(
assert org2_alert.id not in returned_ids


@pytest.mark.asyncio
async def test_alerts_export_admin_sees_all_organizations(
async_client: AsyncClient,
detection_session: AsyncSession,
export_base_dt: datetime,
org1_admin_auth: Dict[str, str],
):
org1_alert = await _create_alert(detection_session, 1, export_base_dt, export_base_dt + timedelta(minutes=5))
await _attach_sequence(detection_session, org1_alert, camera_id=1)
org2_alert = await _create_alert(detection_session, 2, export_base_dt, export_base_dt + timedelta(minutes=5))
await _attach_sequence(detection_session, org2_alert, camera_id=2)

_, rows = await _get_export(async_client, org1_admin_auth, "2026-04-10", "2026-04-10")
returned_ids = {int(r["alert_id"]) for r in rows}
assert {org1_alert.id, org2_alert.id}.issubset(returned_ids)


@pytest.mark.asyncio
async def test_alerts_export_invalid_range(
async_client: AsyncClient, detection_session: AsyncSession, org1_admin_auth: Dict[str, str]
Expand Down
52 changes: 37 additions & 15 deletions src/tests/endpoints/test_risk_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ async def test_unlabeled_latest_drops_low_conf_when_camera_is_low_risk(
risk_service._scores = {camera_id: "low"}

auth = pytest.get_token(
pytest.user_table[0]["id"],
pytest.user_table[0]["role"].split(),
pytest.user_table[0]["organization_id"],
pytest.user_table[1]["id"],
pytest.user_table[1]["role"].split(),
pytest.user_table[1]["organization_id"],
)
response = await async_client.get("/sequences/unlabeled/latest", headers=auth)
assert response.status_code == 200, print(response.__dict__)
Expand All @@ -73,6 +73,28 @@ async def test_unlabeled_latest_drops_low_conf_when_camera_is_low_risk(
assert high_seq.id in returned_ids


@pytest.mark.asyncio
async def test_unlabeled_latest_admin_bypasses_risk_filter(
async_client: AsyncClient, detection_session: AsyncSession, reset_risk_cache
):
"""Admins skip the risk filter entirely; the ``risk_score`` override is ignored for them."""
camera_id = pytest.camera_table[0]["id"]
pose_id = pytest.pose_table[0]["id"]
low_seq = await _seed_unlabeled_sequence(detection_session, camera_id, pose_id, max_conf=0.40, minutes_ago=30)

risk_service._scores = {camera_id: "low"} # 0.45 threshold would drop the sequence

auth = pytest.get_token(
pytest.user_table[0]["id"],
pytest.user_table[0]["role"].split(),
pytest.user_table[0]["organization_id"],
)
for url in ("/sequences/unlabeled/latest", "/sequences/unlabeled/latest?risk_score=low"):
response = await async_client.get(url, headers=auth)
assert response.status_code == 200, print(response.__dict__)
assert low_seq.id in {item["id"] for item in response.json()}


@pytest.mark.asyncio
async def test_unlabeled_latest_drops_below_very_low_threshold(
async_client: AsyncClient, detection_session: AsyncSession, reset_risk_cache
Expand All @@ -85,9 +107,9 @@ async def test_unlabeled_latest_drops_below_very_low_threshold(
risk_service._scores = {camera_id: "very_low"}

auth = pytest.get_token(
pytest.user_table[0]["id"],
pytest.user_table[0]["role"].split(),
pytest.user_table[0]["organization_id"],
pytest.user_table[1]["id"],
pytest.user_table[1]["role"].split(),
pytest.user_table[1]["organization_id"],
)
response = await async_client.get("/sequences/unlabeled/latest", headers=auth)
assert response.status_code == 200, print(response.__dict__)
Expand All @@ -107,9 +129,9 @@ async def test_unlabeled_latest_keeps_all_when_class_is_moderate_or_above(
risk_service._scores = {camera_id: fwi_class}

auth = pytest.get_token(
pytest.user_table[0]["id"],
pytest.user_table[0]["role"].split(),
pytest.user_table[0]["organization_id"],
pytest.user_table[1]["id"],
pytest.user_table[1]["role"].split(),
pytest.user_table[1]["organization_id"],
)
response = await async_client.get("/sequences/unlabeled/latest", headers=auth)
assert response.status_code == 200, print(response.__dict__)
Expand All @@ -128,9 +150,9 @@ async def test_unlabeled_latest_keeps_seq_with_null_max_conf_under_filter(
risk_service._scores = {camera_id: "low"} # 0.45 threshold, would normally drop

auth = pytest.get_token(
pytest.user_table[0]["id"],
pytest.user_table[0]["role"].split(),
pytest.user_table[0]["organization_id"],
pytest.user_table[1]["id"],
pytest.user_table[1]["role"].split(),
pytest.user_table[1]["organization_id"],
)
response = await async_client.get("/sequences/unlabeled/latest", headers=auth)
assert response.status_code == 200, print(response.__dict__)
Expand All @@ -156,9 +178,9 @@ async def test_unlabeled_latest_keeps_seq_for_camera_unknown_to_risk_api(

# known_cam belongs to org 1; unknown_cam belongs to org 2 -> query both orgs.
auth_org1 = pytest.get_token(
pytest.user_table[0]["id"],
pytest.user_table[0]["role"].split(),
pytest.user_table[0]["organization_id"],
pytest.user_table[1]["id"],
pytest.user_table[1]["role"].split(),
pytest.user_table[1]["organization_id"],
)
auth_org2 = pytest.get_token(
pytest.user_table[2]["id"],
Expand Down
Loading
Loading