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
6 changes: 6 additions & 0 deletions apps/backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from app.api.deps.auth import get_session
from app.schemas.auth import AuthBootstrapResponse, AuthTokenResponse, StaffLineLoginRequest
from app.services.auth import AuthService, AuthTokenService, LineIdentityProvider
from app.services.auth.line_provider import LineTokenVerifyError
from app.services.auth.line_verify_http import line_verify_http_error
from app.services.auth.service import AuthFlowPermissionError


Expand Down Expand Up @@ -53,6 +55,8 @@ async def login_staff_or_admin(request: Request, payload: StaffLineLoginRequest)
session,
line_id_token=payload.line_id_token,
)
except LineTokenVerifyError as exc:
raise line_verify_http_error(exc) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except AuthFlowPermissionError as exc:
Expand Down Expand Up @@ -86,6 +90,8 @@ async def auth_bootstrap(request: Request, payload: StaffLineLoginRequest) -> Au
session,
line_id_token=payload.line_id_token,
)
except LineTokenVerifyError as exc:
raise line_verify_http_error(exc) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

Expand Down
18 changes: 10 additions & 8 deletions apps/backend/app/api/routes/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
)
from app.schemas.identity import BindIdentityRequest, IdentityBindResponse, IdentityStatusRequest, IdentityStatusResponse
from app.services.auth import LineIdentityProvider
from app.services.auth.line_provider import LineTokenVerifyError
from app.services.auth.line_verify_http import line_verify_http_error
from app.services.admin_user_management import (
create_or_replace_healthcare_permission_request,
get_latest_healthcare_permission_request_status,
Expand Down Expand Up @@ -45,8 +47,8 @@ async def bind_patient_identity(request: Request, payload: BindIdentityRequest)
line_provider = _build_line_provider(request)
try:
profile = line_provider.verify_id_token(line_id_token=payload.line_id_token)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LineTokenVerifyError as exc:
raise line_verify_http_error(exc) from exc
status, patient_id, can_upload = bind_identity(
session,
line_user_id=profile.line_user_id,
Expand All @@ -70,8 +72,8 @@ async def patient_identity_status(
line_provider = _build_line_provider(request)
try:
profile = line_provider.verify_id_token(line_id_token=payload.line_id_token)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LineTokenVerifyError as exc:
raise line_verify_http_error(exc) from exc
status, patient_id, can_upload = get_identity_status(session, line_user_id=profile.line_user_id)
return IdentityStatusResponse(status=status, patient_id=patient_id, can_upload=can_upload)
finally:
Expand All @@ -88,8 +90,8 @@ async def create_healthcare_access_request(
line_provider = _build_line_provider(request)
try:
profile = line_provider.verify_id_token(line_id_token=payload.line_id_token)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LineTokenVerifyError as exc:
raise line_verify_http_error(exc) from exc
access_request = create_or_replace_healthcare_permission_request(
session,
line_user_id=profile.line_user_id,
Expand All @@ -114,8 +116,8 @@ async def get_healthcare_access_request_status(
line_provider = _build_line_provider(request)
try:
profile = line_provider.verify_id_token(line_id_token=payload.line_id_token)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LineTokenVerifyError as exc:
raise line_verify_http_error(exc) from exc
access_request = get_latest_healthcare_permission_request_status(session, line_user_id=profile.line_user_id)
if access_request is None:
return HealthcarePermissionRequestStatusResponse(
Expand Down
72 changes: 72 additions & 0 deletions apps/backend/app/api/routes/staff.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
StaffPatientUploadsResponse,
StaffUploadQueueItem,
StaffUploadQueueResponse,
StaffTodayAttentionPatientItem,
StaffTodayAttentionResponse,
StaffTodayAttentionRiskHighlight,
StaffUploadRecord,
StaffHistoryOverviewCalendarItem,
StaffHistoryOverviewCalendarResponse,
Expand All @@ -76,6 +79,7 @@
list_patient_upload_records_page,
list_pending_bindings,
list_staff_patients,
list_today_attention_patients,
list_upload_queue,
preview_delete_inactive_patients,
update_pending_binding_status,
Expand Down Expand Up @@ -495,6 +499,71 @@ async def get_staff_upload_queue(
session.close()


@router.get("/v1/staff/uploads/today-attention", response_model=StaffTodayAttentionResponse)
async def get_staff_today_attention(
request: Request,
local_date: date | None = Query(default=None),
credentials=Depends(bearer_scheme),
) -> StaffTodayAttentionResponse:
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,
)
today, total_uploads, rows = list_today_attention_patients(
session,
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
),
)
for row in rows
],
)
finally:
session.close()


@router.get("/v1/staff/uploads/history-overview/days", response_model=StaffHistoryOverviewDaysResponse)
async def get_staff_history_overview_days(
request: Request,
Expand Down Expand Up @@ -680,10 +749,13 @@ async def get_staff_history_overview_calendar(
items=[
StaffHistoryOverviewCalendarItem(
local_date=item.local_date.isoformat(),
upload_count=item.upload_count,
uploaded_users=item.uploaded_users,
risky_patient_count=item.risky_patient_count,
has_infection_risk=item.has_infection_risk,
symptom_elevated_patient_count=item.symptom_elevated_patient_count,
has_symptom_elevated_risk=item.has_symptom_elevated_risk,
unhandled_patient_count=item.unhandled_patient_count,
)
for item in rows
],
Expand Down
40 changes: 40 additions & 0 deletions apps/backend/app/schemas/staff_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,43 @@ class StaffUploadQueueResponse(BaseModel):
items: list[StaffUploadQueueItem]


class StaffTodayAttentionRiskHighlight(BaseModel):
upload_id: int
screening_result: str
probability: float | None
threshold: float | None
symptom_pain: bool
symptom_discharge: bool
symptom_pus: bool
symptom_cloudy_dialysate: bool
has_high_risk_symptoms: bool
symptom_aware_priority: Literal["normal", "suspected"]
created_at: datetime


class StaffTodayAttentionPatientItem(BaseModel):
patient_id: int
case_number: str
full_name: str | None
tier: Literal["suspected", "elevated", "other"]
representative_upload_id: int
sort_upload_at: datetime
has_annotation: bool
picture_url: str | None = None
day_upload_count: int = 0
preview_upload_ids: list[int] = Field(default_factory=list)
risk_highlight: StaffTodayAttentionRiskHighlight | None = None


class StaffTodayAttentionResponse(BaseModel):
date: str
total_uploads: int
suspected_patients: int
elevated_patients: int
other_patients: int
items: list[StaffTodayAttentionPatientItem]


class StaffHistoryOverviewDayItem(BaseModel):
local_date: str
upload_count: int
Expand Down Expand Up @@ -182,10 +219,13 @@ class StaffHistoryOverviewResponse(BaseModel):

class StaffHistoryOverviewCalendarItem(BaseModel):
local_date: str
upload_count: int = 0
uploaded_users: int = 0
risky_patient_count: int
has_infection_risk: bool
symptom_elevated_patient_count: int
has_symptom_elevated_risk: bool
unhandled_patient_count: int = 0


class StaffHistoryOverviewCalendarResponse(BaseModel):
Expand Down
44 changes: 44 additions & 0 deletions apps/backend/app/services/attention_triage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Shared attention triage helpers for calendar unhandled counts and today-attention.

Representative selection for risk patients: earliest upload in suspected tier, else elevated.
Unhandled = risk patient whose representative has no annotation.
"""

from __future__ import annotations

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

from app.services.taipei_dates import normalize_datetime

AttentionTier = Literal["suspected", "elevated", "other"]


class TriageUploadRef(NamedTuple):
upload_id: int
created_at: datetime
tier: AttentionTier
has_annotation: bool


def select_risk_representative(refs: Sequence[TriageUploadRef]) -> TriageUploadRef | None:
"""Return earliest upload in suspected tier, else elevated; None if neither."""
if not refs:
return None
has_suspected = any(ref.tier == "suspected" for ref in refs)
has_elevated = any(ref.tier == "elevated" for ref in refs)
if not has_suspected and not has_elevated:
return None
target_tier: AttentionTier = "suspected" if has_suspected else "elevated"
candidates = [ref for ref in refs if ref.tier == target_tier]
return min(candidates, key=lambda ref: (normalize_datetime(ref.created_at), ref.upload_id))


def count_unhandled_patients(groups: Mapping[int, Sequence[TriageUploadRef]]) -> int:
"""Count patients with a risk representative that has no annotation."""
unhandled = 0
for refs in groups.values():
representative = select_risk_representative(refs)
if representative is not None and not representative.has_annotation:
unhandled += 1
return unhandled
Loading
Loading