feat: add case masking for dak_mitarbeiter and disclosure endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CCS Admin 2026-02-26 16:07:30 +00:00
parent 00076e2c00
commit 9825489781
2 changed files with 170 additions and 21 deletions

View file

@ -11,10 +11,16 @@ from sqlalchemy.orm import Session
from app.core.dependencies import require_admin from app.core.dependencies import require_admin
from app.core.security import hash_password from app.core.security import hash_password
from app.database import get_db 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.models.user import InvitationLink, User
from app.schemas.disclosure import (
DisclosureCountResponse, DisclosureRequestResponse, DisclosureRequestUpdate,
)
from app.schemas.user import UserCreate, UserResponse, UserUpdate from app.schemas.user import UserCreate, UserResponse, UserUpdate
from app.services.audit_service import log_action from app.services.audit_service import log_action
from app.services.disclosure_service import (
get_pending_count, review_disclosure_request,
)
router = APIRouter() router = APIRouter()
@ -298,3 +304,81 @@ def get_audit_log(
.all() .all()
) )
return entries 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,
)

View file

@ -19,9 +19,14 @@ from app.schemas.case import (
CaseUpdate, CaseUpdate,
CodingUpdate, CodingUpdate,
ICDUpdate, ICDUpdate,
mask_case_for_mitarbeiter,
) )
from app.schemas.disclosure import DisclosureRequestCreate, DisclosureRequestResponse
from app.config import get_settings from app.config import get_settings
from app.services.audit_service import log_action 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 from app.services.icd_service import generate_coding_template, get_pending_icd_cases, save_icd_for_case
settings = get_settings() settings = get_settings()
@ -31,6 +36,17 @@ logger = logging.getLogger(__name__)
router = APIRouter() 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 # Static routes MUST be defined BEFORE the parameterised /{case_id} routes
# to avoid FastAPI interpreting "pending-icd" etc. as a case_id. # 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( cases, total = get_pending_icd_cases(
db, jahr=jahr, fallgruppe=fallgruppe, page=page, per_page=per_page db, jahr=jahr, fallgruppe=fallgruppe, page=page, per_page=per_page
) )
return CaseListResponse( if user.role == "admin":
items=cases, return CaseListResponse(items=cases, total=total, page=page, per_page=per_page)
total=total,
page=page, masked_items = [_mask_case_response(c, user, db) for c in cases]
per_page=per_page, return {"items": masked_items, "total": total, "page": page, "per_page": per_page}
)
@router.get("/pending-coding", response_model=CaseListResponse) @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 # Paginated case list
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -194,14 +250,22 @@ def list_cases(
query = query.filter(Case.gutachten_typ == None) # noqa: E711 query = query.filter(Case.gutachten_typ == None) # noqa: E711
if search: if search:
like_pattern = f"%{search}%" like_pattern = f"%{search}%"
query = query.filter( if user.role == "admin":
or_( query = query.filter(
Case.nachname.ilike(like_pattern), or_(
Case.vorname.ilike(like_pattern), Case.nachname.ilike(like_pattern),
Case.fall_id.ilike(like_pattern), Case.vorname.ilike(like_pattern),
Case.kvnr.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() total = query.count()
cases = ( cases = (
@ -211,12 +275,11 @@ def list_cases(
.all() .all()
) )
return CaseListResponse( if user.role == "admin":
items=cases, return CaseListResponse(items=cases, total=total, page=page, per_page=per_page)
total=total,
page=page, masked_items = [_mask_case_response(c, user, db) for c in cases]
per_page=per_page, 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, status_code=status.HTTP_404_NOT_FOUND,
detail="Case not found", detail="Case not found",
) )
return case if user.role == "admin":
return case
return _mask_case_response(case, user, db)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------