claimflow-api / app /routers /documents.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
6.54 kB
import asyncio
import json
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.auth.deps import enforce_origin, get_current_user, get_settings_dep, require_role
from app.claimguard import audit
from app.config import Settings
from app.db import get_db
from app.models import (
AuditEventType,
Claim,
ClaimState,
Document,
DocumentKind,
Modality,
Role,
User,
)
from app.services import storage
from app.services.dicom_preview import process_dicom, sniff_kind
router = APIRouter()
DICOM_PROCESS_TIMEOUT_S = 30.0
# Claimants attach documents before triggering analysis (claims are created in
# SUBMITTED) and again when a reviewer sends the claim back for more evidence.
UPLOAD_STATES = frozenset(
{ClaimState.SUBMITTED, ClaimState.RETURNED_TO_CLAIMANT, ClaimState.PENDING_FURTHER_TESTING}
)
STAFF_ROLES = frozenset(
{Role.IMAGING_SPECIALIST, Role.MEDICAL_SPECIALIST, Role.INSURANCE_AGENT}
)
_MIME_BY_SNIFF = {
"dicom": "application/dicom",
"png": "image/png",
"jpeg": "image/jpeg",
"pdf": "application/pdf",
}
class DocumentOut(BaseModel):
id: int
filename: str
kind: str
modality: str | None
size_bytes: int
sha256: str
has_preview: bool
def _document_out(document: Document) -> DocumentOut:
return DocumentOut(
id=document.id,
filename=document.filename,
kind=document.kind.value,
modality=document.modality.value if document.modality else None,
size_bytes=document.size_bytes,
sha256=document.sha256,
has_preview=document.preview_path is not None,
)
@router.post("/upload/{claim_id}", dependencies=[Depends(enforce_origin)])
async def upload_document(
claim_id: int,
file: UploadFile = File(...),
kind: DocumentKind = Form(...),
modality: Modality | None = Form(None),
user: User = Depends(require_role(Role.CLAIMANT)),
session: Session = Depends(get_db),
settings: Settings = Depends(get_settings_dep),
) -> DocumentOut:
claim = session.get(Claim, claim_id)
if claim is None or claim.claimant_id != user.id:
raise HTTPException(status_code=404, detail="Claim not found")
if claim.state not in UPLOAD_STATES:
raise HTTPException(
status_code=409,
detail=f"documents cannot be uploaded while the claim is {claim.state.value}",
)
try:
stored = await asyncio.to_thread(storage.save_upload, settings, claim.id, file)
except storage.UploadTooLargeError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
stored_path = Path(stored.storage_path)
try:
detected = sniff_kind(stored_path, file.content_type or "")
except ValueError as exc:
stored_path.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=str(exc)) from exc
dicom_meta_json: str | None = None
preview_path: str | None = None
if detected == "dicom":
kind = DocumentKind.IMAGING
try:
meta, preview_path = await asyncio.wait_for(
asyncio.to_thread(process_dicom, stored_path),
timeout=DICOM_PROCESS_TIMEOUT_S,
)
except TimeoutError as exc:
stored_path.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="DICOM processing timed out") from exc
except ValueError as exc:
stored_path.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=str(exc)) from exc
dicom_meta_json = json.dumps(meta)
elif detected == "pdf":
if kind is DocumentKind.IMAGING:
kind = DocumentKind.MEDICAL_RECORD
modality = None
elif kind is not DocumentKind.IMAGING: # png/jpeg under a non-imaging kind
modality = None
document = Document(
claim_id=claim.id,
uploader_id=user.id,
kind=kind,
modality=modality,
filename=Path(file.filename or "upload.bin").name,
mime=_MIME_BY_SNIFF[detected],
size_bytes=stored.size_bytes,
sha256=stored.sha256,
storage_path=stored.storage_path,
preview_path=preview_path,
dicom_meta_json=dicom_meta_json,
)
session.add(document)
session.flush()
audit.append(
session,
AuditEventType.DOCUMENT_UPLOAD,
claim_id=claim.id,
actor_user_id=user.id,
actor_role=user.role.value,
payload={
"filename": document.filename,
"kind": document.kind.value,
"sha256": document.sha256,
},
)
session.commit()
return _document_out(document)
def _readable_document(document_id: int, user: User, session: Session) -> Document:
"""404 for unknown ids and for claimants who do not own the claim (no existence leak)."""
document = session.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="Document not found")
if user.role not in STAFF_ROLES and document.claim.claimant_id != user.id:
raise HTTPException(status_code=404, detail="Document not found")
return document
@router.get("/{document_id}/file")
def download_document(
document_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_db),
) -> FileResponse:
document = _readable_document(document_id, user, session)
path = Path(document.storage_path)
if not path.is_file():
raise HTTPException(status_code=404, detail="Stored file is missing")
return FileResponse(
path,
media_type=document.mime,
filename=f"document-{document.id}{path.suffix}", # server-side name, never the client's
)
@router.get("/{document_id}/preview")
def download_preview(
document_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_db),
) -> FileResponse:
document = _readable_document(document_id, user, session)
if document.preview_path is None:
raise HTTPException(status_code=404, detail="No preview available")
path = Path(document.preview_path)
if not path.is_file():
raise HTTPException(status_code=404, detail="No preview available")
return FileResponse(
path,
media_type="image/png",
filename=f"document-{document.id}-preview.png",
)