"""Cases API routes — list, detail, update, ICD entry, coding, template download.""" import logging from datetime import date, datetime, timezone from io import BytesIO from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import StreamingResponse from openpyxl import Workbook from openpyxl.styles import Font, PatternFill from sqlalchemy import or_ from sqlalchemy.orm import Session from app.core.dependencies import get_current_user, require_admin from app.database import get_db from app.models.case import Case from app.models.user import User from app.schemas.case import ( CaseListResponse, CaseResponse, CaseUpdate, CodingUpdate, ICDUpdate, mask_case_for_mitarbeiter, ) from app.schemas.disclosure import DisclosureRequestCreate, DisclosureRequestResponse from app.config import get_settings from app.services.audit_service import log_action from app.services.import_service import has_random_suffix from app.services.disclosure_service import ( create_disclosure_request, delete_disclosure_request, get_my_disclosure_requests, has_active_disclosure, has_pending_request, revoke_disclosure, ) from app.services.icd_service import generate_coding_template, get_pending_icd_cases, save_icd_for_case settings = get_settings() logger = logging.getLogger(__name__) router = APIRouter() def _mask_case_response(case: Case, user: User, db: Session) -> dict: """Serialize a single case, masking sensitive fields for dak_mitarbeiter.""" case_dict = CaseResponse.model_validate(case).model_dump() if user.role != "admin": granted, expires_at = has_active_disclosure(db, case.id, user.id) case_dict = mask_case_for_mitarbeiter(case_dict, disclosure_granted=granted) if expires_at: case_dict["disclosure_expires_at"] = expires_at.isoformat() return case_dict # --------------------------------------------------------------------------- # Static routes MUST be defined BEFORE the parameterised /{case_id} routes # to avoid FastAPI interpreting "pending-icd" etc. as a case_id. # --------------------------------------------------------------------------- @router.get("/pending-icd", response_model=CaseListResponse) def list_pending_icd( page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200), jahr: Optional[int] = Query(None), fallgruppe: Optional[str] = Query(None), db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Return cases that still need ICD codes (icd IS NULL). Accessible to both admin and dak_mitarbeiter users. """ cases, total = get_pending_icd_cases( db, jahr=jahr, fallgruppe=fallgruppe, page=page, per_page=per_page ) if user.role == "admin": return CaseListResponse(items=cases, total=total, page=page, per_page=per_page) masked_items = [_mask_case_response(c, user, db) for c in cases] return {"items": masked_items, "total": total, "page": page, "per_page": per_page} @router.get("/pending-coding", response_model=CaseListResponse) def list_pending_coding( page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200), jahr: Optional[int] = Query(None), fallgruppe: Optional[str] = Query(None), db: Session = Depends(get_db), user: User = Depends(require_admin), ): """Return cases with gutachten=True but no gutachten_typ yet (admin only). These cases have received a Gutachten but still need classification (Bestätigung/Alternative) and therapy-change coding. """ query = db.query(Case).filter( Case.versicherung == settings.VERSICHERUNG_FILTER, Case.gutachten == True, # noqa: E712 Case.gutachten_typ == None, # noqa: E711 ) if jahr: query = query.filter(Case.jahr == jahr) if fallgruppe: query = query.filter(Case.fallgruppe == fallgruppe) total = query.count() cases = ( query.order_by(Case.datum.desc()) .offset((page - 1) * per_page) .limit(per_page) .all() ) return CaseListResponse( items=cases, total=total, page=page, per_page=per_page, ) @router.get("/coding-template") def download_coding_template( request: Request, token: Optional[str] = Query(None), jahr: Optional[int] = Query(None), fallgruppe: Optional[str] = Query(None), db: Session = Depends(get_db), ): """Download an Excel template for ICD coding. Admin only. Supports both ``Authorization: Bearer`` header and ``?token=`` query parameter for direct browser downloads. """ from app.core.security import decode_access_token from jose import JWTError raw_token = token if not raw_token: auth = request.headers.get("authorization", "") if auth.lower().startswith("bearer "): raw_token = auth[7:] if not raw_token: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated") try: payload = decode_access_token(raw_token) user_id = int(payload["sub"]) except (JWTError, KeyError, ValueError): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token") user = db.query(User).filter(User.id == user_id, User.is_active == True).first() # noqa: E712 if not user: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found") if user.role != "admin": raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin access required") xlsx_bytes = generate_coding_template(db, jahr=jahr, fallgruppe=fallgruppe) from io import BytesIO return StreamingResponse( BytesIO(xlsx_bytes), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={ "Content-Disposition": 'attachment; filename="ICD_Coding_Template.xlsx"' }, ) # --------------------------------------------------------------------------- # Excel export # --------------------------------------------------------------------------- EXPORT_HEADER_FILL = PatternFill(start_color="FFD9D9D9", end_color="FFD9D9D9", fill_type="solid") EXPORT_HEADER_FONT = Font(bold=True, name="Calibri", size=11) FALLGRUPPEN_LABELS = { "onko": "Onkologie", "kardio": "Kardiologie", "intensiv": "Intensivmedizin", "galle": "Gallenblase", "sd": "Schilddrüse", } def _build_case_query(db: Session, user: User, *, jahr=None, fallgruppe=None, has_icd=None, search=None): """Build a filtered Case query (shared by list and export).""" query = db.query(Case).filter(Case.versicherung == settings.VERSICHERUNG_FILTER) if jahr is not None: query = query.filter(Case.jahr == jahr) if fallgruppe is not None: query = query.filter(Case.fallgruppe == fallgruppe) if has_icd is True: query = query.filter(Case.icd != None) # noqa: E711 elif has_icd is False: query = query.filter(Case.icd == None) # noqa: E711 if search: like_pattern = f"%{search}%" if user.role == "admin": query = query.filter( or_(Case.nachname.ilike(like_pattern), Case.vorname.ilike(like_pattern), Case.fall_id.ilike(like_pattern), Case.kvnr.ilike(like_pattern)) ) else: query = query.filter( or_(Case.fall_id.ilike(like_pattern), Case.kvnr.ilike(like_pattern)) ) return query.order_by(Case.datum.desc(), Case.id.desc()) @router.get("/export") def export_cases( request: Request, jahr: Optional[int] = Query(None), fallgruppe: Optional[str] = Query(None), has_icd: Optional[bool] = Query(None), search: Optional[str] = Query(None, min_length=1, max_length=200), db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Export filtered cases as .xlsx download.""" query = _build_case_query(db, user, jahr=jahr, fallgruppe=fallgruppe, has_icd=has_icd, search=search) cases = query.all() is_admin = user.role == "admin" # Define columns based on role if is_admin: columns = [ ("Fall-ID", lambda c: c.fall_id or ""), ("Datum", lambda c: c.datum.strftime("%d.%m.%Y") if isinstance(c.datum, date) else str(c.datum or "")), ("Nachname", lambda c: c.nachname or ""), ("Vorname", lambda c: c.vorname or ""), ("KVNR", lambda c: c.kvnr or ""), ("Fallgruppe", lambda c: FALLGRUPPEN_LABELS.get(c.fallgruppe, c.fallgruppe or "")), ("ICD", lambda c: c.icd or ""), ("Gutachten-Typ", lambda c: c.gutachten_typ or ""), ("Therapieänderung", lambda c: c.therapieaenderung or ""), ("Unterlagen", lambda c: "Ja" if c.unterlagen else "Nein"), ("Gutachten", lambda c: "Ja" if c.gutachten else "Nein"), ("Abgelehnt", lambda c: "Ja" if c.ablehnung else "Nein"), ("Abgerechnet", lambda c: "Ja" if c.abgerechnet else "Nein"), ("Abbruch", lambda c: "Ja" if c.abbruch else "Nein"), ] else: columns = [ ("Fall-ID", lambda c: c.fall_id or ""), ("Datum", lambda c: c.datum.strftime("%d.%m.%Y") if isinstance(c.datum, date) else str(c.datum or "")), ("KVNR", lambda c: c.kvnr or ""), ("Fallgruppe", lambda c: FALLGRUPPEN_LABELS.get(c.fallgruppe, c.fallgruppe or "")), ("ICD", lambda c: c.icd or ""), ("Gutachten-Typ", lambda c: c.gutachten_typ or ""), ("Therapieänderung", lambda c: c.therapieaenderung or ""), ("Unterlagen", lambda c: "Ja" if c.unterlagen else "Nein"), ("Gutachten", lambda c: "Ja" if c.gutachten else "Nein"), ("Abgelehnt", lambda c: "Ja" if c.ablehnung else "Nein"), ("Abgerechnet", lambda c: "Ja" if c.abgerechnet else "Nein"), ("Abbruch", lambda c: "Ja" if c.abbruch else "Nein"), ] wb = Workbook() ws = wb.active ws.title = "Fallliste" # Header row for col_idx, (header, _) in enumerate(columns, 1): cell = ws.cell(row=1, column=col_idx, value=header) cell.font = EXPORT_HEADER_FONT cell.fill = EXPORT_HEADER_FILL # Data rows for row_idx, case in enumerate(cases, 2): for col_idx, (_, getter) in enumerate(columns, 1): ws.cell(row=row_idx, column=col_idx, value=getter(case)) # Auto-width columns for col_idx, (header, _) in enumerate(columns, 1): from openpyxl.utils import get_column_letter col_letter = get_column_letter(col_idx) max_len = len(header) for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx): for cell in row: if cell.value: max_len = max(max_len, len(str(cell.value))) ws.column_dimensions[col_letter].width = min(max_len + 2, 40) buf = BytesIO() wb.save(buf) buf.seek(0) filename = f"Fallliste_{date.today().strftime('%Y-%m-%d')}.xlsx" return StreamingResponse( buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) # --------------------------------------------------------------------------- # Disclosure request # --------------------------------------------------------------------------- @router.post("/{case_id}/disclosure-request", response_model=DisclosureRequestResponse) def request_disclosure( case_id: int, payload: DisclosureRequestCreate, request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Request disclosure of personal data for a case.""" case = db.query(Case).filter(Case.id == case_id).first() if not case: raise HTTPException(status.HTTP_404_NOT_FOUND, "Case not found") if has_pending_request(db, case_id, user.id): raise HTTPException(status.HTTP_409_CONFLICT, "Anfrage bereits ausstehend") granted, _ = has_active_disclosure(db, case_id, user.id) if granted: raise HTTPException(status.HTTP_409_CONFLICT, "Freigabe bereits aktiv") dr = create_disclosure_request(db, case_id, user.id, payload.reason) log_action( db, user_id=user.id, action="disclosure_requested", entity_type="case", entity_id=case_id, new_values={"reason": payload.reason}, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return DisclosureRequestResponse( id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id, reason=dr.reason, status=dr.status, created_at=dr.created_at, ) # --------------------------------------------------------------------------- # My disclosure requests (for dak_mitarbeiter) # --------------------------------------------------------------------------- @router.get("/my-disclosure-requests", response_model=list[DisclosureRequestResponse]) def list_my_disclosure_requests( db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Return all disclosure requests made by the current user.""" requests = get_my_disclosure_requests(db, user.id) result = [] for dr in requests: result.append(DisclosureRequestResponse( id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id, requester_username=dr.requester.username if dr.requester else None, fall_id=dr.case.fall_id if dr.case else None, reason=dr.reason, status=dr.status, reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at, expires_at=dr.expires_at, created_at=dr.created_at, )) return result @router.put("/disclosure-requests/{request_id}/revoke", response_model=DisclosureRequestResponse) def revoke_my_disclosure( request_id: int, request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Revoke own active disclosure (sets expires_at to now).""" try: dr = revoke_disclosure(db, request_id, user.id, admin=False) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) log_action( db, user_id=user.id, action="disclosure_self_revoked", entity_type="disclosure_request", entity_id=request_id, new_values={"expires_at": dr.expires_at.isoformat() if dr.expires_at else None}, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return DisclosureRequestResponse( id=dr.id, case_id=dr.case_id, requester_id=dr.requester_id, requester_username=dr.requester.username if dr.requester else None, fall_id=dr.case.fall_id if dr.case else None, reason=dr.reason, status=dr.status, reviewed_by=dr.reviewed_by, reviewed_at=dr.reviewed_at, expires_at=dr.expires_at, created_at=dr.created_at, ) @router.delete("/disclosure-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_my_disclosure( request_id: int, request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Delete own inactive disclosure request (rejected, expired, revoked).""" try: delete_disclosure_request(db, request_id, user.id) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) log_action( db, user_id=user.id, action="disclosure_deleted", entity_type="disclosure_request", entity_id=request_id, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) # --------------------------------------------------------------------------- # Paginated case list # --------------------------------------------------------------------------- @router.get("/", response_model=CaseListResponse) def list_cases( page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200), jahr: Optional[int] = Query(None), kw: Optional[int] = Query(None), fallgruppe: Optional[str] = Query(None), has_icd: Optional[bool] = Query(None), has_coding: Optional[bool] = Query(None), search: Optional[str] = Query(None, min_length=1, max_length=200), db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Return a paginated, filterable list of cases. Accessible to both admin and dak_mitarbeiter users. Filters: - ``jahr``: calendar year - ``kw``: calendar week - ``fallgruppe``: one of onko, kardio, intensiv, galle, sd - ``has_icd``: True = only cases WITH icd; False = only cases WITHOUT - ``has_coding``: True = only with gutachten_typ; False = only without - ``search``: free-text search across nachname, vorname, fall_id, kvnr """ query = _build_case_query(db, user, jahr=jahr, fallgruppe=fallgruppe, has_icd=has_icd, search=search) if kw is not None: query = query.filter(Case.kw == kw) if has_coding is True: query = query.filter(Case.gutachten_typ != None) # noqa: E711 elif has_coding is False: query = query.filter(Case.gutachten_typ == None) # noqa: E711 total = query.count() cases = ( query.order_by(Case.datum.desc(), Case.id.desc()) .offset((page - 1) * per_page) .limit(per_page) .all() ) if user.role == "admin": return CaseListResponse(items=cases, total=total, page=page, per_page=per_page) masked_items = [_mask_case_response(c, user, db) for c in cases] return {"items": masked_items, "total": total, "page": page, "per_page": per_page} # --------------------------------------------------------------------------- # Single case detail # --------------------------------------------------------------------------- @router.get("/{case_id}", response_model=CaseResponse) def get_case( case_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Return a single case by its database ID.""" case = db.query(Case).filter(Case.id == case_id, Case.versicherung == settings.VERSICHERUNG_FILTER).first() if not case: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Case not found", ) if user.role == "admin": return case return _mask_case_response(case, user, db) # --------------------------------------------------------------------------- # Update case (admin only) # --------------------------------------------------------------------------- @router.put("/{case_id}", response_model=CaseResponse) def update_case( case_id: int, payload: CaseUpdate, request: Request, db: Session = Depends(get_db), user: User = Depends(require_admin), ): """Update arbitrary case fields (admin only). Only fields included in the request body are applied. Changes are recorded in the audit log. """ case = db.query(Case).filter(Case.id == case_id).first() if not case: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Case not found", ) update_data = payload.model_dump(exclude_unset=True) if not update_data: return case # Nothing to update old_values: dict = {} new_values: dict = {} for field, value in update_data.items(): current = getattr(case, field) # Convert date objects to strings for JSON-safe audit log current_cmp = current value_cmp = value if current_cmp != value_cmp: old_values[field] = str(current) if current is not None else None new_values[field] = str(value) if value is not None else None setattr(case, field, value) if new_values: case.updated_by = user.id db.commit() db.refresh(case) log_action( db, user_id=user.id, action="case_updated", entity_type="case", entity_id=case.id, old_values=old_values, new_values=new_values, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return case # --------------------------------------------------------------------------- # ICD entry # --------------------------------------------------------------------------- @router.put("/{case_id}/icd", response_model=CaseResponse) def set_case_icd( case_id: int, payload: ICDUpdate, request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Set ICD codes for a case. Accessible to both admin and dak_mitarbeiter. Replaces any existing ICD codes. The raw ICD string is validated, normalised, and split into individual ``CaseICDCode`` entries. """ try: case = save_icd_for_case(db, case_id, payload.icd, user.id) except HTTPException: raise except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) log_action( db, user_id=user.id, action="icd_entered", entity_type="case", entity_id=case.id, new_values={"icd": payload.icd}, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return case # --------------------------------------------------------------------------- # KVNR update # --------------------------------------------------------------------------- @router.put("/{case_id}/kvnr", response_model=CaseResponse) def set_case_kvnr( case_id: int, payload: dict, request: Request, db: Session = Depends(get_db), user: User = Depends(get_current_user), ): """Update the KVNR for a case. Accessible to both admin and dak_mitarbeiter.""" case = db.query(Case).filter(Case.id == case_id).first() if not case: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Case not found", ) old_kvnr = case.kvnr new_kvnr = payload.get("kvnr") case.kvnr = new_kvnr # Auto-update fall_id if it currently has a non-KVNR suffix old_fall_id = case.fall_id if new_kvnr and case.fall_id and has_random_suffix(case.fall_id): case.fall_id = f"{case.jahr}-{case.kw:02d}-{case.fallgruppe}-{new_kvnr}" case.updated_by = user.id db.commit() db.refresh(case) log_action( db, user_id=user.id, action="kvnr_updated", entity_type="case", entity_id=case.id, old_values={"kvnr": old_kvnr, "fall_id": old_fall_id}, new_values={"kvnr": new_kvnr, "fall_id": case.fall_id}, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return case # --------------------------------------------------------------------------- # Coding (Gutachten classification) # --------------------------------------------------------------------------- @router.put("/{case_id}/coding", response_model=CaseResponse) def set_case_coding( case_id: int, payload: CodingUpdate, request: Request, db: Session = Depends(get_db), user: User = Depends(require_admin), ): """Set coding fields on a case (admin only). Records the Gutachten classification (Bestätigung/Alternative) and therapy-change flags. """ case = db.query(Case).filter(Case.id == case_id).first() if not case: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Case not found", ) # Validate gutachten_typ if payload.gutachten_typ not in ("Bestätigung", "Alternative"): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="gutachten_typ must be 'Bestätigung' or 'Alternative'", ) # Validate therapieaenderung if payload.therapieaenderung not in ("Ja", "Nein"): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="therapieaenderung must be 'Ja' or 'Nein'", ) old_values = { "gutachten_typ": case.gutachten_typ, "therapieaenderung": case.therapieaenderung, "ta_diagnosekorrektur": case.ta_diagnosekorrektur, "ta_unterversorgung": case.ta_unterversorgung, "ta_uebertherapie": case.ta_uebertherapie, } case.gutachten_typ = payload.gutachten_typ case.therapieaenderung = payload.therapieaenderung case.ta_diagnosekorrektur = payload.ta_diagnosekorrektur case.ta_unterversorgung = payload.ta_unterversorgung case.ta_uebertherapie = payload.ta_uebertherapie case.coding_completed_by = user.id case.coding_completed_at = datetime.now(timezone.utc) case.updated_by = user.id db.commit() db.refresh(case) new_values = { "gutachten_typ": case.gutachten_typ, "therapieaenderung": case.therapieaenderung, "ta_diagnosekorrektur": case.ta_diagnosekorrektur, "ta_unterversorgung": case.ta_unterversorgung, "ta_uebertherapie": case.ta_uebertherapie, } log_action( db, user_id=user.id, action="coding_completed", entity_type="case", entity_id=case.id, old_values=old_values, new_values=new_values, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) return case