fix: add missing cases API, import router, and case schemas

These files were created by a subagent but not included in the commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-24 08:49:55 +00:00
parent 3f8f96097d
commit 93884f3c8d
3 changed files with 796 additions and 0 deletions

403
backend/app/api/cases.py Normal file
View file

@ -0,0 +1,403 @@
"""Cases API routes — list, detail, update, ICD entry, coding, template download."""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
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,
)
from app.services.audit_service import log_action
from app.services.icd_service import generate_coding_template, get_pending_icd_cases, save_icd_for_case
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# 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
)
return CaseListResponse(
items=cases,
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.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(
jahr: Optional[int] = Query(None),
fallgruppe: Optional[str] = Query(None),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Download an Excel template for ICD coding.
The template contains all cases that still need ICD codes, with an
empty ICD column for DAK users to fill in.
"""
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"'
},
)
# ---------------------------------------------------------------------------
# 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 = db.query(Case)
if jahr is not None:
query = query.filter(Case.jahr == jahr)
if kw is not None:
query = query.filter(Case.kw == kw)
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 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
if search:
like_pattern = f"%{search}%"
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),
)
)
total = query.count()
cases = (
query.order_by(Case.datum.desc(), Case.id.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return CaseListResponse(
items=cases,
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).first()
if not case:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Case not found",
)
return case
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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

View file

@ -0,0 +1,268 @@
"""Import API routes — CSV upload/preview/confirm, ICD xlsx upload, import log."""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core.dependencies import get_current_user, require_admin
from app.database import get_db
from app.models.audit import ImportLog
from app.models.user import User
from app.schemas.import_schemas import ImportPreview, ImportResult
from app.services.csv_parser import parse_csv
from app.services.icd_service import import_icd_from_xlsx
from app.services.import_service import confirm_import, preview_import
logger = logging.getLogger(__name__)
settings = get_settings()
router = APIRouter()
# ---------------------------------------------------------------------------
# Response schemas (co-located for simplicity)
# ---------------------------------------------------------------------------
class ICDImportResponse(BaseModel):
"""Result of importing ICD codes from an xlsx file."""
updated: int
errors: list[str] = []
class ImportLogResponse(BaseModel):
"""Single import-log entry."""
id: int
filename: str
import_type: str
cases_imported: int
cases_skipped: int
cases_updated: int
errors: Optional[str] = None
imported_by: Optional[int] = None
imported_at: datetime
model_config = {"from_attributes": True}
class ImportLogListResponse(BaseModel):
"""Paginated import-log response."""
items: list[ImportLogResponse]
total: int
page: int
per_page: int
# ---------------------------------------------------------------------------
# CSV Import — preview (dry-run)
# ---------------------------------------------------------------------------
@router.post("/csv", response_model=ImportPreview)
async def upload_csv_preview(
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(require_admin),
):
"""Upload a CRM CSV file and preview what will be imported.
Returns a list of rows with duplicate detection. No data is written
to the database the caller must confirm via ``POST /csv/confirm``.
"""
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be a .csv file",
)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB",
)
try:
parsed = parse_csv(content, file.filename)
except Exception as exc:
logger.exception("CSV parsing failed for %s", file.filename)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"CSV parsing failed: {exc}",
)
return preview_import(db, parsed, file.filename)
# ---------------------------------------------------------------------------
# CSV Import — confirm (write to DB)
# ---------------------------------------------------------------------------
@router.post("/csv/confirm", response_model=ImportResult)
async def confirm_csv_import(
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(require_admin),
):
"""Re-upload the same CSV file to confirm the import.
The file is re-parsed and non-duplicate cases are inserted into the
database. An entry is written to the ``import_log`` table.
"""
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be a .csv file",
)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB",
)
try:
parsed = parse_csv(content, file.filename)
except Exception as exc:
logger.exception("CSV parsing failed for %s", file.filename)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"CSV parsing failed: {exc}",
)
return confirm_import(db, parsed, file.filename, user.id)
# ---------------------------------------------------------------------------
# ICD Excel upload
# ---------------------------------------------------------------------------
@router.post("/icd-xlsx", response_model=ICDImportResponse)
async def upload_icd_xlsx(
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Upload a filled-in ICD coding Excel template.
Expects columns: Case_ID (col 1), ICD (col 7). Accessible to both
admin and dak_mitarbeiter users.
"""
if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an Excel file (.xlsx)",
)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB",
)
try:
result = import_icd_from_xlsx(db, content, user.id)
except Exception as exc:
logger.exception("ICD xlsx import failed for %s", file.filename)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"ICD import failed: {exc}",
)
# Log the import
log = ImportLog(
filename=file.filename,
import_type="icd_xlsx",
cases_imported=0,
cases_skipped=0,
cases_updated=result["updated"],
errors="; ".join(result["errors"]) if result["errors"] else None,
imported_by=user.id,
)
db.add(log)
db.commit()
return ICDImportResponse(**result)
# ---------------------------------------------------------------------------
# Historical Excel upload (placeholder — full logic in Task 12)
# ---------------------------------------------------------------------------
@router.post("/historical", response_model=ImportResult)
async def upload_historical_excel(
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(require_admin),
):
"""Upload the Abrechnung_DAK.xlsx for historical data import (admin only).
This is a placeholder endpoint. The full import logic will be
implemented in Task 12.
"""
if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an Excel file (.xlsx)",
)
content = await file.read()
if len(content) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum size of {settings.MAX_UPLOAD_SIZE // (1024 * 1024)}MB",
)
# TODO: Implement historical import in Task 12
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Historical Excel import is not yet implemented",
)
# ---------------------------------------------------------------------------
# Import log (history)
# ---------------------------------------------------------------------------
@router.get("/log", response_model=ImportLogListResponse)
def get_import_log(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
import_type: Optional[str] = Query(None),
db: Session = Depends(get_db),
user: User = Depends(require_admin),
):
"""Return a paginated list of past imports (admin only)."""
query = db.query(ImportLog)
if import_type:
query = query.filter(ImportLog.import_type == import_type)
total = query.count()
items = (
query.order_by(ImportLog.imported_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
return ImportLogListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
)

125
backend/app/schemas/case.py Normal file
View file

@ -0,0 +1,125 @@
"""Pydantic schemas for case list, detail, update, ICD, and coding endpoints."""
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel
class CaseResponse(BaseModel):
"""Full representation of a case returned by list and detail endpoints."""
id: int
fall_id: Optional[str] = None
crm_ticket_id: Optional[str] = None
jahr: int
kw: int
datum: date
anrede: Optional[str] = None
vorname: Optional[str] = None
nachname: str
geburtsdatum: Optional[date] = None
kvnr: Optional[str] = None
versicherung: str
icd: Optional[str] = None
fallgruppe: str
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
email: Optional[str] = None
ansprechpartner: Optional[str] = None
telefonnummer: Optional[str] = None
mobiltelefon: Optional[str] = None
unterlagen: bool
unterlagen_verschickt: Optional[date] = None
erhalten: Optional[bool] = None
unterlagen_erhalten: Optional[date] = None
unterlagen_an_gutachter: Optional[date] = None
gutachten: bool
gutachter: Optional[str] = None
gutachten_erstellt: Optional[date] = None
gutachten_versendet: Optional[date] = None
schweigepflicht: bool
ablehnung: bool
abbruch: bool
abbruch_datum: Optional[date] = None
gutachten_typ: Optional[str] = None
therapieaenderung: Optional[str] = None
ta_diagnosekorrektur: bool
ta_unterversorgung: bool
ta_uebertherapie: bool
kurzbeschreibung: Optional[str] = None
fragestellung: Optional[str] = None
kommentar: Optional[str] = None
sonstiges: Optional[str] = None
abgerechnet: bool
abrechnung_datum: Optional[date] = None
import_source: Optional[str] = None
imported_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class CaseListResponse(BaseModel):
"""Paginated case list response."""
items: list[CaseResponse]
total: int
page: int
per_page: int
class CaseUpdate(BaseModel):
"""Partial update for case fields (admin only).
All fields are optional; only provided fields are applied.
"""
anrede: Optional[str] = None
vorname: Optional[str] = None
nachname: Optional[str] = None
geburtsdatum: Optional[date] = None
kvnr: Optional[str] = None
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
email: Optional[str] = None
ansprechpartner: Optional[str] = None
telefonnummer: Optional[str] = None
mobiltelefon: Optional[str] = None
unterlagen: Optional[bool] = None
unterlagen_verschickt: Optional[date] = None
erhalten: Optional[bool] = None
unterlagen_erhalten: Optional[date] = None
unterlagen_an_gutachter: Optional[date] = None
gutachten: Optional[bool] = None
gutachter: Optional[str] = None
gutachten_erstellt: Optional[date] = None
gutachten_versendet: Optional[date] = None
schweigepflicht: Optional[bool] = None
ablehnung: Optional[bool] = None
abbruch: Optional[bool] = None
abbruch_datum: Optional[date] = None
kurzbeschreibung: Optional[str] = None
fragestellung: Optional[str] = None
kommentar: Optional[str] = None
sonstiges: Optional[str] = None
abgerechnet: Optional[bool] = None
abrechnung_datum: Optional[date] = None
class ICDUpdate(BaseModel):
"""Payload for setting ICD codes on a case."""
icd: str
class CodingUpdate(BaseModel):
"""Payload for setting coding/gutachten classification on a case."""
gutachten_typ: str # "Bestätigung" or "Alternative"
therapieaenderung: str # "Ja" or "Nein"
ta_diagnosekorrektur: bool = False
ta_unterversorgung: bool = False
ta_uebertherapie: bool = False