mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 18:23:42 +00:00
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:
parent
00076e2c00
commit
9825489781
2 changed files with 170 additions and 21 deletions
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,6 +250,7 @@ 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}%"
|
||||||
|
if user.role == "admin":
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
or_(
|
or_(
|
||||||
Case.nachname.ilike(like_pattern),
|
Case.nachname.ilike(like_pattern),
|
||||||
|
|
@ -202,6 +259,13 @@ def list_cases(
|
||||||
Case.kvnr.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",
|
||||||
)
|
)
|
||||||
|
if user.role == "admin":
|
||||||
return case
|
return case
|
||||||
|
return _mask_case_response(case, user, db)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue