From 9825489781d01507b80436233b4907427abad967 Mon Sep 17 00:00:00 2001 From: CCS Admin Date: Thu, 26 Feb 2026 16:07:30 +0000 Subject: [PATCH] feat: add case masking for dak_mitarbeiter and disclosure endpoints Co-Authored-By: Claude Opus 4.6 --- backend/app/api/admin.py | 86 +++++++++++++++++++++++++++++++- backend/app/api/cases.py | 105 +++++++++++++++++++++++++++++++-------- 2 files changed, 170 insertions(+), 21 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 170ab01..719cc41 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -11,10 +11,16 @@ from sqlalchemy.orm import Session from app.core.dependencies import require_admin from app.core.security import hash_password from app.database import get_db -from app.models.audit import AuditLog +from app.models.audit import AuditLog, DisclosureRequest from app.models.user import InvitationLink, User +from app.schemas.disclosure import ( + DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate, +) from app.schemas.user import UserCreate, UserResponse, UserUpdate from app.services.audit_service import log_action +from app.services.disclosure_service import ( + get_pending_count, review_disclosure_request, +) router = APIRouter() @@ -298,3 +304,81 @@ def get_audit_log( .all() ) return entries + + +# --------------------------------------------------------------------------- +# Disclosure requests +# --------------------------------------------------------------------------- + +@router.get("/disclosure-requests", response_model=list[DisclosureRequestResponse]) +def list_disclosure_requests( + status_filter: Optional[str] = Query(None, alias="status"), + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """List disclosure requests (default: pending only).""" + query = db.query(DisclosureRequest) + if status_filter: + query = query.filter(DisclosureRequest.status == status_filter) + else: + query = query.filter(DisclosureRequest.status == "pending") + + requests = query.order_by(DisclosureRequest.created_at.desc()).all() + + 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.get("/disclosure-requests/count", response_model=DisclosureCountResponse) +def disclosure_request_count( + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Return count of pending disclosure requests.""" + return DisclosureCountResponse(pending_count=get_pending_count(db)) + + +@router.put("/disclosure-requests/{request_id}", response_model=DisclosureRequestResponse) +def review_disclosure( + request_id: int, + payload: DisclosureRequestUpdate, + request: Request, + admin: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Approve or reject a disclosure request.""" + if payload.status not in ("approved", "rejected"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Status must be 'approved' or 'rejected'", + ) + try: + dr = review_disclosure_request(db, request_id, admin.id, payload.status) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + log_action( + db, user_id=admin.id, action=f"disclosure_{payload.status}", + entity_type="disclosure_request", entity_id=request_id, + new_values={"status": payload.status, "case_id": dr.case_id}, + 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, + ) diff --git a/backend/app/api/cases.py b/backend/app/api/cases.py index 54aacdf..08268ad 100644 --- a/backend/app/api/cases.py +++ b/backend/app/api/cases.py @@ -19,9 +19,14 @@ from app.schemas.case import ( 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.disclosure_service import ( + create_disclosure_request, has_active_disclosure, has_pending_request, +) from app.services.icd_service import generate_coding_template, get_pending_icd_cases, save_icd_for_case settings = get_settings() @@ -31,6 +36,17 @@ 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. @@ -53,12 +69,11 @@ def list_pending_icd( 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, - ) + 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) @@ -146,6 +161,47 @@ def download_coding_template( ) +# --------------------------------------------------------------------------- +# 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, + ) + + # --------------------------------------------------------------------------- # Paginated case list # --------------------------------------------------------------------------- @@ -194,14 +250,22 @@ def list_cases( 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), + 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), + ) ) - ) total = query.count() cases = ( @@ -211,12 +275,11 @@ def list_cases( .all() ) - return CaseListResponse( - items=cases, - total=total, - 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} # --------------------------------------------------------------------------- @@ -237,7 +300,9 @@ def get_case( status_code=status.HTTP_404_NOT_FOUND, detail="Case not found", ) - return case + if user.role == "admin": + return case + return _mask_case_response(case, user, db) # ---------------------------------------------------------------------------