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
199 changes: 159 additions & 40 deletions apps/backend/app/api/routes/staff.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from collections.abc import Iterator
from collections.abc import Iterator, Sequence
from datetime import date

from fastapi import APIRouter, Depends, HTTPException, Query, Request
Expand Down Expand Up @@ -62,6 +62,11 @@
StaffHistoryOverviewResponse,
StaffHistoryOverviewUploadItem,
StaffHistoryOverviewUserGroupItem,
StaffUploadImageAccessBatchItem,
StaffUploadImageAccessBatchRequest,
StaffUploadImageAccessBatchResponse,
StaffWorkbenchDashboardResponse,
StaffWorkbenchWeekDayItem,
)
from app.db.models import LiffIdentity, Patient, PendingBinding, Upload
from app.services.staff_dashboard import (
Expand All @@ -82,6 +87,7 @@
list_today_attention_patients,
list_upload_queue,
preview_delete_inactive_patients,
TodayAttentionPatientRow,
update_pending_binding_status,
update_patient_active_status,
upsert_annotation_for_upload,
Expand All @@ -91,7 +97,9 @@
get_history_overview_calendar_month,
list_history_overview_days,
)
from app.services.staff_workbench import get_workbench_dashboard
from app.services.symptoms import derived_symptom_fields
from app.services.attention_triage import HistoryOverviewScope

router = APIRouter(tags=["Staff"])

Expand All @@ -101,6 +109,56 @@
router.include_router(notifications_router)


def _serialize_today_attention(
*,
today: date,
total_uploads: int,
rows: Sequence[TodayAttentionPatientRow],
) -> StaffTodayAttentionResponse:
suspected_patients = sum(1 for row in rows if row.tier == "suspected")
elevated_patients = sum(1 for row in rows if row.tier == "elevated")
other_patients = sum(1 for row in rows if row.tier == "other")
return StaffTodayAttentionResponse(
date=today.isoformat(),
total_uploads=total_uploads,
suspected_patients=suspected_patients,
elevated_patients=elevated_patients,
other_patients=other_patients,
items=[
StaffTodayAttentionPatientItem(
patient_id=row.patient.id,
case_number=row.patient.case_number,
full_name=row.patient.full_name,
tier=row.tier,
representative_upload_id=row.representative_upload_id,
sort_upload_at=row.sort_upload_at,
has_annotation=row.has_annotation,
picture_url=row.picture_url,
day_upload_count=row.day_upload_count,
preview_upload_ids=row.preview_upload_ids,
risk_highlight=(
StaffTodayAttentionRiskHighlight(
upload_id=row.risk_highlight.upload_id,
screening_result=row.risk_highlight.screening_result,
probability=row.risk_highlight.probability,
threshold=row.risk_highlight.threshold,
symptom_pain=row.risk_highlight.symptom_pain,
symptom_discharge=row.risk_highlight.symptom_discharge,
symptom_pus=row.risk_highlight.symptom_pus,
symptom_cloudy_dialysate=row.risk_highlight.symptom_cloudy_dialysate,
has_high_risk_symptoms=row.risk_highlight.has_high_risk_symptoms,
symptom_aware_priority=row.risk_highlight.symptom_aware_priority,
created_at=row.risk_highlight.created_at,
)
if row.risk_highlight is not None
else None
),
)
for row in rows
],
)


@router.get("/v1/staff/me")
async def get_staff_profile(
request: Request,
Expand Down Expand Up @@ -518,47 +576,51 @@ async def get_staff_today_attention(
accessible_patient_ids=accessible_patient_ids,
local_date=local_date,
)
suspected_patients = sum(1 for row in rows if row.tier == "suspected")
elevated_patients = sum(1 for row in rows if row.tier == "elevated")
other_patients = sum(1 for row in rows if row.tier == "other")
return StaffTodayAttentionResponse(
date=today.isoformat(),
total_uploads=total_uploads,
suspected_patients=suspected_patients,
elevated_patients=elevated_patients,
other_patients=other_patients,
items=[
StaffTodayAttentionPatientItem(
patient_id=row.patient.id,
case_number=row.patient.case_number,
full_name=row.patient.full_name,
tier=row.tier,
representative_upload_id=row.representative_upload_id,
sort_upload_at=row.sort_upload_at,
has_annotation=row.has_annotation,
picture_url=row.picture_url,
day_upload_count=row.day_upload_count,
preview_upload_ids=row.preview_upload_ids,
risk_highlight=(
StaffTodayAttentionRiskHighlight(
upload_id=row.risk_highlight.upload_id,
screening_result=row.risk_highlight.screening_result,
probability=row.risk_highlight.probability,
threshold=row.risk_highlight.threshold,
symptom_pain=row.risk_highlight.symptom_pain,
symptom_discharge=row.risk_highlight.symptom_discharge,
symptom_pus=row.risk_highlight.symptom_pus,
symptom_cloudy_dialysate=row.risk_highlight.symptom_cloudy_dialysate,
has_high_risk_symptoms=row.risk_highlight.has_high_risk_symptoms,
symptom_aware_priority=row.risk_highlight.symptom_aware_priority,
created_at=row.risk_highlight.created_at,
)
if row.risk_highlight is not None
else None
),
return _serialize_today_attention(today=today, total_uploads=total_uploads, rows=rows)
finally:
session.close()


@router.get("/v1/staff/dashboard/workbench", response_model=StaffWorkbenchDashboardResponse)
async def get_staff_dashboard_workbench(
request: Request,
local_date: date = Query(...),
week_start: date = Query(...),
credentials=Depends(bearer_scheme),
) -> StaffWorkbenchDashboardResponse:
principal = require_staff_or_admin(get_current_principal(request, credentials))
session = get_session(request)
try:
accessible_patient_ids = _get_accessible_patient_ids(
session,
role=principal.role,
identity_id=principal.identity_id,
)
data = get_workbench_dashboard(
session,
local_date=local_date,
week_start=week_start,
accessible_patient_ids=accessible_patient_ids,
)
return StaffWorkbenchDashboardResponse(
local_date=data.local_date.isoformat(),
week_start=data.week_start.isoformat(),
available_dates=[day.isoformat() for day in data.available_dates],
week_days=[
StaffWorkbenchWeekDayItem(
local_date=day.local_date.isoformat(),
upload_count=day.upload_count,
uploaded_users=day.uploaded_users,
risky_patient_count=day.risky_patient_count,
unhandled_patient_count=day.unhandled_patient_count,
)
for row in rows
for day in data.week_days
],
attention=_serialize_today_attention(
today=data.local_date,
total_uploads=data.attention_total_uploads,
rows=data.attention_rows,
),
)
finally:
session.close()
Expand All @@ -567,6 +629,7 @@ async def get_staff_today_attention(
@router.get("/v1/staff/uploads/history-overview/days", response_model=StaffHistoryOverviewDaysResponse)
async def get_staff_history_overview_days(
request: Request,
scope: HistoryOverviewScope = Query(default="all"),
credentials=Depends(bearer_scheme),
) -> StaffHistoryOverviewDaysResponse:
principal = require_staff_or_admin(get_current_principal(request, credentials))
Expand All @@ -580,6 +643,7 @@ async def get_staff_history_overview_days(
rows = list_history_overview_days(
session,
accessible_patient_ids=accessible_patient_ids,
scope=scope,
)
return StaffHistoryOverviewDaysResponse(
items=[
Expand Down Expand Up @@ -1065,6 +1129,61 @@ async def get_staff_upload_image_access(
session.close()


@router.post("/v1/staff/uploads/image-access/batch", response_model=StaffUploadImageAccessBatchResponse)
async def post_staff_upload_image_access_batch(
request: Request,
payload: StaffUploadImageAccessBatchRequest,
credentials=Depends(bearer_scheme),
) -> StaffUploadImageAccessBatchResponse:
principal = require_staff_or_admin(get_current_principal(request, credentials))
session = get_session(request)
try:
storage_service = getattr(request.app.state, "storage_service", None)
if storage_service is None:
raise HTTPException(status_code=503, detail="Storage is not initialized")
ttl_seconds = int(request.app.state.settings.image_access_token_ttl_seconds)
accessible_patient_ids = _get_accessible_patient_ids(
session,
role=principal.role,
identity_id=principal.identity_id,
)
# Preserve request order; dedupe while resolving.
seen: set[int] = set()
ordered_ids: list[int] = []
for upload_id in payload.upload_ids:
if upload_id in seen:
continue
seen.add(upload_id)
ordered_ids.append(upload_id)

uploads = {
upload.id: upload
for upload in session.execute(select(Upload).where(Upload.id.in_(ordered_ids))).scalars().all()
}
items: list[StaffUploadImageAccessBatchItem] = []
for upload_id in ordered_ids:
upload = uploads.get(upload_id)
if upload is None:
items.append(StaffUploadImageAccessBatchItem(upload_id=upload_id, error="not_found"))
continue
if accessible_patient_ids is not None and upload.patient_id not in accessible_patient_ids:
items.append(StaffUploadImageAccessBatchItem(upload_id=upload_id, error="forbidden"))
continue
token = storage_service.generate_access_token(
upload.object_key, subject="staff", ttl_seconds=ttl_seconds
)
items.append(
StaffUploadImageAccessBatchItem(
upload_id=upload_id,
image_url=f"/api/v1/staff/uploads/{upload_id}/image-public?token={token}",
expires_in=ttl_seconds,
)
)
return StaffUploadImageAccessBatchResponse(items=items)
finally:
session.close()


@router.get("/v1/staff/uploads/{upload_id}/image-public")
async def get_staff_upload_image_public(
request: Request,
Expand Down
31 changes: 31 additions & 0 deletions apps/backend/app/schemas/staff_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,37 @@ class StaffTodayAttentionResponse(BaseModel):
items: list[StaffTodayAttentionPatientItem]


class StaffWorkbenchWeekDayItem(BaseModel):
local_date: str
upload_count: int = 0
uploaded_users: int = 0
risky_patient_count: int = 0
unhandled_patient_count: int = 0


class StaffWorkbenchDashboardResponse(BaseModel):
local_date: str
week_start: str
available_dates: list[str]
week_days: list[StaffWorkbenchWeekDayItem]
attention: StaffTodayAttentionResponse


class StaffUploadImageAccessBatchRequest(BaseModel):
upload_ids: list[int] = Field(..., min_length=1, max_length=50)


class StaffUploadImageAccessBatchItem(BaseModel):
upload_id: int
image_url: str | None = None
expires_in: int | None = None
error: Literal["not_found", "forbidden"] | None = None


class StaffUploadImageAccessBatchResponse(BaseModel):
items: list[StaffUploadImageAccessBatchItem]


class StaffHistoryOverviewDayItem(BaseModel):
local_date: str
upload_count: int
Expand Down
12 changes: 11 additions & 1 deletion apps/backend/app/services/attention_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,22 @@
from __future__ import annotations

from datetime import datetime
from typing import Literal, Mapping, NamedTuple, Sequence
from typing import Any, Literal, Mapping, NamedTuple, Sequence

from app.db.models import AIResult, Patient
from app.services.symptoms import CalendarRiskTier
from app.services.taipei_dates import normalize_datetime

AttentionTier = Literal["suspected", "elevated", "other"]
HistoryOverviewScope = Literal["all", "workbench"]


def workbench_upload_where_clauses() -> tuple[Any, ...]:
"""SQLAlchemy filters for workbench-eligible uploads (active patient, non-rejected)."""
return (
AIResult.screening_result != "rejected",
Patient.is_active.is_(True),
)


def calendar_tier_to_attention_tier(tier: CalendarRiskTier) -> AttentionTier:
Expand Down
10 changes: 7 additions & 3 deletions apps/backend/app/services/staff_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
from sqlalchemy.orm import Session, aliased

from app.db.models import AIResult, Annotation, LiffIdentity, Notification, Patient, PendingBinding, StaffPatientAssignment, Upload
from app.services.attention_triage import TriageUploadRef, calendar_tier_to_attention_tier, select_risk_representative
from app.services.attention_triage import (
TriageUploadRef,
calendar_tier_to_attention_tier,
select_risk_representative,
workbench_upload_where_clauses,
)
from app.services.symptoms import calendar_risk_tier, has_high_risk_symptoms, symptom_aware_priority
from app.services.taipei_dates import TAIPEI_TIMEZONE, resolve_taipei_day_bounds, resolve_taipei_day_bounds_for_date, to_taipei_date
from app.services.upload_history import summarize_patient_upload_history
Expand Down Expand Up @@ -1139,8 +1144,7 @@ def list_today_attention_patients(
.where(
Upload.created_at >= today_start,
Upload.created_at < tomorrow_start,
AIResult.screening_result != "rejected",
Patient.is_active.is_(True),
*workbench_upload_where_clauses(),
)
)
if accessible_patient_ids is not None:
Expand Down
Loading
Loading