""" DSR (Data Subject Request) Routes — Betroffenenanfragen nach DSGVO Art. 15-21. Native Python/FastAPI Implementierung, ersetzt Go consent-service Proxy. """ import io import csv import uuid from datetime import datetime, timedelta from typing import Optional, List, Dict, Any from fastapi import APIRouter, Depends, HTTPException, Query, Header from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import text, func, and_, or_ from classroom_engine.database import get_db from ..db.dsr_models import ( DSRRequestDB, DSRStatusHistoryDB, DSRCommunicationDB, DSRTemplateDB, DSRTemplateVersionDB, DSRExceptionCheckDB, ) router = APIRouter(prefix="/dsr", tags=["compliance-dsr"]) # Default-Tenant DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" # Art. 17(3) Ausnahmen ART17_EXCEPTIONS = [ { "check_code": "art17_3_a", "article": "17(3)(a)", "label": "Meinungs- und Informationsfreiheit", "description": "Ausuebung des Rechts auf freie Meinungsaeusserung und Information", }, { "check_code": "art17_3_b", "article": "17(3)(b)", "label": "Rechtliche Verpflichtung", "description": "Erfuellung einer rechtlichen Verpflichtung (z.B. Aufbewahrungspflichten)", }, { "check_code": "art17_3_c", "article": "17(3)(c)", "label": "Oeffentliches Interesse", "description": "Gruende des oeffentlichen Interesses im Bereich Gesundheit", }, { "check_code": "art17_3_d", "article": "17(3)(d)", "label": "Archivzwecke", "description": "Archivzwecke, wissenschaftliche/historische Forschung, Statistik", }, { "check_code": "art17_3_e", "article": "17(3)(e)", "label": "Rechtsansprueche", "description": "Geltendmachung, Ausuebung oder Verteidigung von Rechtsanspruechen", }, ] VALID_REQUEST_TYPES = ["access", "rectification", "erasure", "restriction", "portability", "objection"] VALID_STATUSES = ["intake", "identity_verification", "processing", "completed", "rejected", "cancelled"] VALID_PRIORITIES = ["low", "normal", "high", "critical"] VALID_SOURCES = ["web_form", "email", "letter", "phone", "in_person", "other"] # Deadline-Tage pro Typ (DSGVO Art. 12 Abs. 3) DEADLINE_DAYS = { "access": 30, "rectification": 14, "erasure": 14, "restriction": 14, "portability": 30, "objection": 30, } # ============================================================================= # Pydantic Schemas # ============================================================================= class DSRCreate(BaseModel): request_type: str = "access" requester_name: str requester_email: str requester_phone: Optional[str] = None requester_address: Optional[str] = None requester_customer_id: Optional[str] = None source: str = "email" source_details: Optional[str] = None request_text: Optional[str] = None priority: Optional[str] = "normal" notes: Optional[str] = None class DSRUpdate(BaseModel): priority: Optional[str] = None notes: Optional[str] = None internal_notes: Optional[str] = None assigned_to: Optional[str] = None request_text: Optional[str] = None affected_systems: Optional[List[str]] = None erasure_checklist: Optional[List[Dict[str, Any]]] = None rectification_details: Optional[Dict[str, Any]] = None objection_details: Optional[Dict[str, Any]] = None class StatusChange(BaseModel): status: str comment: Optional[str] = None class VerifyIdentity(BaseModel): method: str notes: Optional[str] = None document_ref: Optional[str] = None class AssignRequest(BaseModel): assignee_id: str class ExtendDeadline(BaseModel): reason: str days: Optional[int] = 60 class CompleteDSR(BaseModel): summary: Optional[str] = None result_data: Optional[Dict[str, Any]] = None class RejectDSR(BaseModel): reason: str legal_basis: Optional[str] = None class SendCommunication(BaseModel): communication_type: str = "outgoing" channel: str = "email" subject: Optional[str] = None content: str template_used: Optional[str] = None class UpdateExceptionCheck(BaseModel): applies: bool notes: Optional[str] = None class CreateTemplateVersion(BaseModel): version: str = "1.0" language: Optional[str] = "de" subject: str body_html: str body_text: Optional[str] = None # ============================================================================= # Helpers # ============================================================================= def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str: return x_tenant_id or DEFAULT_TENANT def _generate_request_number(db: Session, tenant_id: str) -> str: """Generate next request number: DSR-YYYY-NNNNNN""" year = datetime.utcnow().year try: result = db.execute(text("SELECT nextval('compliance_dsr_request_number_seq')")) seq = result.scalar() except Exception: # Fallback for non-PostgreSQL (e.g. SQLite tests): count existing + 1 count = db.query(DSRRequestDB).count() seq = count + 1 return f"DSR-{year}-{str(seq).zfill(6)}" def _record_history(db: Session, dsr: DSRRequestDB, new_status: str, changed_by: str = "system", comment: str = None): """Record status change in history.""" entry = DSRStatusHistoryDB( tenant_id=dsr.tenant_id, dsr_id=dsr.id, previous_status=dsr.status, new_status=new_status, changed_by=changed_by, comment=comment, ) db.add(entry) def _dsr_to_dict(dsr: DSRRequestDB) -> dict: """Convert DSR DB record to API response dict.""" return { "id": str(dsr.id), "tenant_id": str(dsr.tenant_id), "request_number": dsr.request_number, "request_type": dsr.request_type, "status": dsr.status, "priority": dsr.priority, "requester_name": dsr.requester_name, "requester_email": dsr.requester_email, "requester_phone": dsr.requester_phone, "requester_address": dsr.requester_address, "requester_customer_id": dsr.requester_customer_id, "source": dsr.source, "source_details": dsr.source_details, "request_text": dsr.request_text, "notes": dsr.notes, "internal_notes": dsr.internal_notes, "received_at": dsr.received_at.isoformat() if dsr.received_at else None, "deadline_at": dsr.deadline_at.isoformat() if dsr.deadline_at else None, "extended_deadline_at": dsr.extended_deadline_at.isoformat() if dsr.extended_deadline_at else None, "extension_reason": dsr.extension_reason, "extension_approved_by": dsr.extension_approved_by, "extension_approved_at": dsr.extension_approved_at.isoformat() if dsr.extension_approved_at else None, "identity_verified": dsr.identity_verified, "verification_method": dsr.verification_method, "verified_at": dsr.verified_at.isoformat() if dsr.verified_at else None, "verified_by": dsr.verified_by, "verification_notes": dsr.verification_notes, "verification_document_ref": dsr.verification_document_ref, "assigned_to": dsr.assigned_to, "assigned_at": dsr.assigned_at.isoformat() if dsr.assigned_at else None, "assigned_by": dsr.assigned_by, "completed_at": dsr.completed_at.isoformat() if dsr.completed_at else None, "completion_notes": dsr.completion_notes, "rejection_reason": dsr.rejection_reason, "rejection_legal_basis": dsr.rejection_legal_basis, "erasure_checklist": dsr.erasure_checklist or [], "data_export": dsr.data_export or {}, "rectification_details": dsr.rectification_details or {}, "objection_details": dsr.objection_details or {}, "affected_systems": dsr.affected_systems or [], "created_at": dsr.created_at.isoformat() if dsr.created_at else None, "updated_at": dsr.updated_at.isoformat() if dsr.updated_at else None, "created_by": dsr.created_by, "updated_by": dsr.updated_by, } def _get_dsr_or_404(db: Session, dsr_id: str, tenant_id: str) -> DSRRequestDB: """Get DSR by ID or raise 404.""" try: uid = uuid.UUID(dsr_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid DSR ID format") dsr = db.query(DSRRequestDB).filter( DSRRequestDB.id == uid, DSRRequestDB.tenant_id == uuid.UUID(tenant_id), ).first() if not dsr: raise HTTPException(status_code=404, detail="DSR not found") return dsr # ============================================================================= # DSR CRUD Endpoints # ============================================================================= @router.post("") async def create_dsr( body: DSRCreate, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Erstellt eine neue Betroffenenanfrage.""" if body.request_type not in VALID_REQUEST_TYPES: raise HTTPException(status_code=400, detail=f"Invalid request_type. Must be one of: {VALID_REQUEST_TYPES}") if body.source not in VALID_SOURCES: raise HTTPException(status_code=400, detail=f"Invalid source. Must be one of: {VALID_SOURCES}") if body.priority and body.priority not in VALID_PRIORITIES: raise HTTPException(status_code=400, detail=f"Invalid priority. Must be one of: {VALID_PRIORITIES}") now = datetime.utcnow() deadline_days = DEADLINE_DAYS.get(body.request_type, 30) request_number = _generate_request_number(db, tenant_id) dsr = DSRRequestDB( tenant_id=uuid.UUID(tenant_id), request_number=request_number, request_type=body.request_type, status="intake", priority=body.priority or "normal", requester_name=body.requester_name, requester_email=body.requester_email, requester_phone=body.requester_phone, requester_address=body.requester_address, requester_customer_id=body.requester_customer_id, source=body.source, source_details=body.source_details, request_text=body.request_text, notes=body.notes, received_at=now, deadline_at=now + timedelta(days=deadline_days), created_at=now, updated_at=now, ) db.add(dsr) db.flush() # Ensure dsr.id is assigned before referencing # Initial history entry history = DSRStatusHistoryDB( tenant_id=uuid.UUID(tenant_id), dsr_id=dsr.id, previous_status=None, new_status="intake", changed_by="system", comment="DSR erstellt", ) db.add(history) db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) @router.get("") async def list_dsrs( status: Optional[str] = Query(None), request_type: Optional[str] = Query(None), assigned_to: Optional[str] = Query(None), priority: Optional[str] = Query(None), overdue_only: bool = Query(False), search: Optional[str] = Query(None), from_date: Optional[str] = Query(None), to_date: Optional[str] = Query(None), limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Liste aller DSRs mit Filtern.""" query = db.query(DSRRequestDB).filter( DSRRequestDB.tenant_id == uuid.UUID(tenant_id), ) if status: query = query.filter(DSRRequestDB.status == status) if request_type: query = query.filter(DSRRequestDB.request_type == request_type) if assigned_to: query = query.filter(DSRRequestDB.assigned_to == assigned_to) if priority: query = query.filter(DSRRequestDB.priority == priority) if overdue_only: query = query.filter( DSRRequestDB.deadline_at < datetime.utcnow(), DSRRequestDB.status.notin_(["completed", "rejected", "cancelled"]), ) if search: search_term = f"%{search.lower()}%" query = query.filter( or_( func.lower(func.coalesce(DSRRequestDB.requester_name, '')).like(search_term), func.lower(func.coalesce(DSRRequestDB.requester_email, '')).like(search_term), func.lower(func.coalesce(DSRRequestDB.request_number, '')).like(search_term), func.lower(func.coalesce(DSRRequestDB.request_text, '')).like(search_term), ) ) if from_date: query = query.filter(DSRRequestDB.received_at >= from_date) if to_date: query = query.filter(DSRRequestDB.received_at <= to_date) total = query.count() dsrs = query.order_by(DSRRequestDB.created_at.desc()).offset(offset).limit(limit).all() return { "requests": [_dsr_to_dict(d) for d in dsrs], "total": total, "limit": limit, "offset": offset, } @router.get("/stats") async def get_dsr_stats( tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Dashboard-Statistiken fuer DSRs.""" tid = uuid.UUID(tenant_id) base = db.query(DSRRequestDB).filter(DSRRequestDB.tenant_id == tid) total = base.count() # By status by_status = {} for s in VALID_STATUSES: by_status[s] = base.filter(DSRRequestDB.status == s).count() # By type by_type = {} for t in VALID_REQUEST_TYPES: by_type[t] = base.filter(DSRRequestDB.request_type == t).count() # Overdue now = datetime.utcnow() overdue = base.filter( DSRRequestDB.deadline_at < now, DSRRequestDB.status.notin_(["completed", "rejected", "cancelled"]), ).count() # Due this week week_from_now = now + timedelta(days=7) due_this_week = base.filter( DSRRequestDB.deadline_at >= now, DSRRequestDB.deadline_at <= week_from_now, DSRRequestDB.status.notin_(["completed", "rejected", "cancelled"]), ).count() # Completed this month month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) completed_this_month = base.filter( DSRRequestDB.status == "completed", DSRRequestDB.completed_at >= month_start, ).count() # Average processing days (completed DSRs) completed = base.filter(DSRRequestDB.status == "completed", DSRRequestDB.completed_at.isnot(None)).all() if completed: total_days = sum( (d.completed_at - d.received_at).days for d in completed if d.completed_at and d.received_at ) avg_days = total_days / len(completed) else: avg_days = 0 return { "total": total, "by_status": by_status, "by_type": by_type, "overdue": overdue, "due_this_week": due_this_week, "average_processing_days": round(avg_days, 1), "completed_this_month": completed_this_month, } # ============================================================================= # Export # ============================================================================= @router.get("/export") async def export_dsrs( format: str = Query("csv", pattern="^(csv|json)$"), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Exportiert alle DSRs als CSV oder JSON.""" tid = uuid.UUID(tenant_id) dsrs = db.query(DSRRequestDB).filter( DSRRequestDB.tenant_id == tid, ).order_by(DSRRequestDB.created_at.desc()).all() if format == "json": return { "exported_at": datetime.utcnow().isoformat(), "total": len(dsrs), "requests": [_dsr_to_dict(d) for d in dsrs], } # CSV export (semicolon-separated, matching Go format + extended fields) output = io.StringIO() writer = csv.writer(output, delimiter=';', quoting=csv.QUOTE_MINIMAL) writer.writerow([ "ID", "Referenznummer", "Typ", "Name", "E-Mail", "Status", "Prioritaet", "Eingegangen", "Frist", "Abgeschlossen", "Quelle", "Zugewiesen", ]) for dsr in dsrs: writer.writerow([ str(dsr.id), dsr.request_number or "", dsr.request_type or "", dsr.requester_name or "", dsr.requester_email or "", dsr.status or "", dsr.priority or "", dsr.received_at.strftime("%Y-%m-%d") if dsr.received_at else "", dsr.deadline_at.strftime("%Y-%m-%d") if dsr.deadline_at else "", dsr.completed_at.strftime("%Y-%m-%d") if dsr.completed_at else "", dsr.source or "", dsr.assigned_to or "", ]) output.seek(0) return StreamingResponse( output, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": "attachment; filename=dsr_export.csv"}, ) # ============================================================================= # Deadline Processing (MUST be before /{dsr_id} to avoid path conflicts) # ============================================================================= @router.post("/deadlines/process") async def process_deadlines( tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Verarbeitet Fristen und markiert ueberfaellige DSRs.""" now = datetime.utcnow() tid = uuid.UUID(tenant_id) overdue = db.query(DSRRequestDB).filter( DSRRequestDB.tenant_id == tid, DSRRequestDB.status.notin_(["completed", "rejected", "cancelled"]), or_( and_(DSRRequestDB.extended_deadline_at.isnot(None), DSRRequestDB.extended_deadline_at < now), and_(DSRRequestDB.extended_deadline_at.is_(None), DSRRequestDB.deadline_at < now), ), ).all() processed = [] for dsr in overdue: processed.append({ "id": str(dsr.id), "request_number": dsr.request_number, "status": dsr.status, "deadline_at": dsr.deadline_at.isoformat() if dsr.deadline_at else None, "extended_deadline_at": dsr.extended_deadline_at.isoformat() if dsr.extended_deadline_at else None, "days_overdue": (now - (dsr.extended_deadline_at or dsr.deadline_at)).days, }) return { "processed": len(processed), "overdue_requests": processed, } # ============================================================================= # DSR Templates (MUST be before /{dsr_id} to avoid path conflicts) # ============================================================================= @router.get("/templates") async def get_templates( tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Gibt alle DSR-Vorlagen zurueck.""" templates = db.query(DSRTemplateDB).filter( DSRTemplateDB.tenant_id == uuid.UUID(tenant_id), ).order_by(DSRTemplateDB.template_type).all() return [ { "id": str(t.id), "name": t.name, "template_type": t.template_type, "request_type": t.request_type, "language": t.language, "is_active": t.is_active, "created_at": t.created_at.isoformat() if t.created_at else None, "updated_at": t.updated_at.isoformat() if t.updated_at else None, } for t in templates ] @router.get("/templates/published") async def get_published_templates( request_type: Optional[str] = Query(None), language: str = Query("de"), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Gibt publizierte Vorlagen zurueck.""" query = db.query(DSRTemplateDB).filter( DSRTemplateDB.tenant_id == uuid.UUID(tenant_id), DSRTemplateDB.is_active, DSRTemplateDB.language == language, ) if request_type: query = query.filter( or_( DSRTemplateDB.request_type == request_type, DSRTemplateDB.request_type.is_(None), ) ) templates = query.all() result = [] for t in templates: latest = db.query(DSRTemplateVersionDB).filter( DSRTemplateVersionDB.template_id == t.id, DSRTemplateVersionDB.status == "published", ).order_by(DSRTemplateVersionDB.created_at.desc()).first() result.append({ "id": str(t.id), "name": t.name, "template_type": t.template_type, "request_type": t.request_type, "language": t.language, "latest_version": { "id": str(latest.id), "version": latest.version, "subject": latest.subject, "body_html": latest.body_html, "body_text": latest.body_text, } if latest else None, }) return result @router.get("/templates/{template_id}/versions") async def get_template_versions( template_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Gibt alle Versionen einer Vorlage zurueck.""" try: tid = uuid.UUID(template_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid template ID") template = db.query(DSRTemplateDB).filter( DSRTemplateDB.id == tid, DSRTemplateDB.tenant_id == uuid.UUID(tenant_id), ).first() if not template: raise HTTPException(status_code=404, detail="Template not found") versions = db.query(DSRTemplateVersionDB).filter( DSRTemplateVersionDB.template_id == tid, ).order_by(DSRTemplateVersionDB.created_at.desc()).all() return [ { "id": str(v.id), "template_id": str(v.template_id), "version": v.version, "subject": v.subject, "body_html": v.body_html, "body_text": v.body_text, "status": v.status, "published_at": v.published_at.isoformat() if v.published_at else None, "published_by": v.published_by, "created_at": v.created_at.isoformat() if v.created_at else None, "created_by": v.created_by, } for v in versions ] @router.post("/templates/{template_id}/versions") async def create_template_version( template_id: str, body: CreateTemplateVersion, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Erstellt eine neue Version einer Vorlage.""" try: tid = uuid.UUID(template_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid template ID") template = db.query(DSRTemplateDB).filter( DSRTemplateDB.id == tid, DSRTemplateDB.tenant_id == uuid.UUID(tenant_id), ).first() if not template: raise HTTPException(status_code=404, detail="Template not found") version = DSRTemplateVersionDB( template_id=tid, version=body.version, subject=body.subject, body_html=body.body_html, body_text=body.body_text, status="draft", ) db.add(version) db.commit() db.refresh(version) return { "id": str(version.id), "template_id": str(version.template_id), "version": version.version, "subject": version.subject, "body_html": version.body_html, "body_text": version.body_text, "status": version.status, "created_at": version.created_at.isoformat() if version.created_at else None, } @router.put("/template-versions/{version_id}/publish") async def publish_template_version( version_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Veroeffentlicht eine Vorlagen-Version.""" try: vid = uuid.UUID(version_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid version ID") version = db.query(DSRTemplateVersionDB).filter( DSRTemplateVersionDB.id == vid, ).first() if not version: raise HTTPException(status_code=404, detail="Version not found") now = datetime.utcnow() version.status = "published" version.published_at = now version.published_by = "admin" db.commit() db.refresh(version) return { "id": str(version.id), "template_id": str(version.template_id), "version": version.version, "status": version.status, "published_at": version.published_at.isoformat(), "published_by": version.published_by, } # ============================================================================= # Single DSR Endpoints (parameterized — MUST come after static paths) # ============================================================================= @router.get("/{dsr_id}") async def get_dsr( dsr_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Detail einer Betroffenenanfrage.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) return _dsr_to_dict(dsr) @router.put("/{dsr_id}") async def update_dsr( dsr_id: str, body: DSRUpdate, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Aktualisiert eine Betroffenenanfrage.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) if body.priority is not None: if body.priority not in VALID_PRIORITIES: raise HTTPException(status_code=400, detail=f"Invalid priority: {body.priority}") dsr.priority = body.priority if body.notes is not None: dsr.notes = body.notes if body.internal_notes is not None: dsr.internal_notes = body.internal_notes if body.assigned_to is not None: dsr.assigned_to = body.assigned_to dsr.assigned_at = datetime.utcnow() if body.request_text is not None: dsr.request_text = body.request_text if body.affected_systems is not None: dsr.affected_systems = body.affected_systems if body.erasure_checklist is not None: dsr.erasure_checklist = body.erasure_checklist if body.rectification_details is not None: dsr.rectification_details = body.rectification_details if body.objection_details is not None: dsr.objection_details = body.objection_details dsr.updated_at = datetime.utcnow() db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) @router.delete("/{dsr_id}") async def delete_dsr( dsr_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Storniert eine DSR (Soft Delete → Status cancelled).""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) if dsr.status in ("completed", "cancelled"): raise HTTPException(status_code=400, detail="DSR already completed or cancelled") _record_history(db, dsr, "cancelled", comment="DSR storniert") dsr.status = "cancelled" dsr.updated_at = datetime.utcnow() db.commit() return {"success": True, "message": "DSR cancelled"} # ============================================================================= # Workflow Actions # ============================================================================= @router.post("/{dsr_id}/status") async def change_status( dsr_id: str, body: StatusChange, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Aendert den Status einer DSR.""" if body.status not in VALID_STATUSES: raise HTTPException(status_code=400, detail=f"Invalid status: {body.status}") dsr = _get_dsr_or_404(db, dsr_id, tenant_id) _record_history(db, dsr, body.status, comment=body.comment) dsr.status = body.status dsr.updated_at = datetime.utcnow() db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) @router.post("/{dsr_id}/verify-identity") async def verify_identity( dsr_id: str, body: VerifyIdentity, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Verifiziert die Identitaet des Antragstellers.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) now = datetime.utcnow() dsr.identity_verified = True dsr.verification_method = body.method dsr.verified_at = now dsr.verified_by = "admin" dsr.verification_notes = body.notes dsr.verification_document_ref = body.document_ref # Auto-advance to processing if in identity_verification if dsr.status == "identity_verification": _record_history(db, dsr, "processing", comment="Identitaet verifiziert") dsr.status = "processing" elif dsr.status == "intake": _record_history(db, dsr, "identity_verification", comment="Identitaet verifiziert") dsr.status = "identity_verification" dsr.updated_at = now db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) @router.post("/{dsr_id}/assign") async def assign_dsr( dsr_id: str, body: AssignRequest, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Weist eine DSR einem Bearbeiter zu.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) dsr.assigned_to = body.assignee_id dsr.assigned_at = datetime.utcnow() dsr.assigned_by = "admin" dsr.updated_at = datetime.utcnow() db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) @router.post("/{dsr_id}/extend") async def extend_deadline( dsr_id: str, body: ExtendDeadline, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Verlaengert die Bearbeitungsfrist (Art. 12 Abs. 3 DSGVO).""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) if dsr.status in ("completed", "rejected", "cancelled"): raise HTTPException(status_code=400, detail="Cannot extend deadline for closed DSR") now = datetime.utcnow() current_deadline = dsr.extended_deadline_at or dsr.deadline_at new_deadline = current_deadline + timedelta(days=body.days or 60) dsr.extended_deadline_at = new_deadline dsr.extension_reason = body.reason dsr.extension_approved_by = "admin" dsr.extension_approved_at = now dsr.updated_at = now _record_history(db, dsr, dsr.status, comment=f"Frist verlaengert: {body.reason}") db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) @router.post("/{dsr_id}/complete") async def complete_dsr( dsr_id: str, body: CompleteDSR, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Schliesst eine DSR erfolgreich ab.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) if dsr.status in ("completed", "cancelled"): raise HTTPException(status_code=400, detail="DSR already completed or cancelled") now = datetime.utcnow() _record_history(db, dsr, "completed", comment=body.summary) dsr.status = "completed" dsr.completed_at = now dsr.completion_notes = body.summary if body.result_data: dsr.data_export = body.result_data dsr.updated_at = now db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) @router.post("/{dsr_id}/reject") async def reject_dsr( dsr_id: str, body: RejectDSR, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Lehnt eine DSR mit Rechtsgrundlage ab.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) if dsr.status in ("completed", "rejected", "cancelled"): raise HTTPException(status_code=400, detail="DSR already closed") now = datetime.utcnow() _record_history(db, dsr, "rejected", comment=f"{body.reason} ({body.legal_basis})") dsr.status = "rejected" dsr.rejection_reason = body.reason dsr.rejection_legal_basis = body.legal_basis dsr.completed_at = now dsr.updated_at = now db.commit() db.refresh(dsr) return _dsr_to_dict(dsr) # ============================================================================= # History & Communications # ============================================================================= @router.get("/{dsr_id}/history") async def get_history( dsr_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Gibt die Status-Historie zurueck.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) entries = db.query(DSRStatusHistoryDB).filter( DSRStatusHistoryDB.dsr_id == dsr.id, ).order_by(DSRStatusHistoryDB.created_at.desc()).all() return [ { "id": str(e.id), "dsr_id": str(e.dsr_id), "previous_status": e.previous_status, "new_status": e.new_status, "changed_by": e.changed_by, "comment": e.comment, "created_at": e.created_at.isoformat() if e.created_at else None, } for e in entries ] @router.get("/{dsr_id}/communications") async def get_communications( dsr_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Gibt die Kommunikationshistorie zurueck.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) comms = db.query(DSRCommunicationDB).filter( DSRCommunicationDB.dsr_id == dsr.id, ).order_by(DSRCommunicationDB.created_at.desc()).all() return [ { "id": str(c.id), "dsr_id": str(c.dsr_id), "communication_type": c.communication_type, "channel": c.channel, "subject": c.subject, "content": c.content, "template_used": c.template_used, "attachments": c.attachments or [], "sent_at": c.sent_at.isoformat() if c.sent_at else None, "sent_by": c.sent_by, "received_at": c.received_at.isoformat() if c.received_at else None, "created_at": c.created_at.isoformat() if c.created_at else None, "created_by": c.created_by, } for c in comms ] @router.post("/{dsr_id}/communicate") async def send_communication( dsr_id: str, body: SendCommunication, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Sendet eine Kommunikation.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) now = datetime.utcnow() comm = DSRCommunicationDB( tenant_id=uuid.UUID(tenant_id), dsr_id=dsr.id, communication_type=body.communication_type, channel=body.channel, subject=body.subject, content=body.content, template_used=body.template_used, sent_at=now if body.communication_type == "outgoing" else None, sent_by="admin" if body.communication_type == "outgoing" else None, received_at=now if body.communication_type == "incoming" else None, created_at=now, ) db.add(comm) db.commit() db.refresh(comm) return { "id": str(comm.id), "dsr_id": str(comm.dsr_id), "communication_type": comm.communication_type, "channel": comm.channel, "subject": comm.subject, "content": comm.content, "sent_at": comm.sent_at.isoformat() if comm.sent_at else None, "created_at": comm.created_at.isoformat() if comm.created_at else None, } # ============================================================================= # Exception Checks (Art. 17) # ============================================================================= @router.get("/{dsr_id}/exception-checks") async def get_exception_checks( dsr_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Gibt die Art. 17(3) Ausnahmepruefungen zurueck.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) checks = db.query(DSRExceptionCheckDB).filter( DSRExceptionCheckDB.dsr_id == dsr.id, ).order_by(DSRExceptionCheckDB.check_code).all() return [ { "id": str(c.id), "dsr_id": str(c.dsr_id), "check_code": c.check_code, "article": c.article, "label": c.label, "description": c.description, "applies": c.applies, "notes": c.notes, "checked_by": c.checked_by, "checked_at": c.checked_at.isoformat() if c.checked_at else None, } for c in checks ] @router.post("/{dsr_id}/exception-checks/init") async def init_exception_checks( dsr_id: str, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Initialisiert die Art. 17(3) Ausnahmepruefungen fuer eine Loeschanfrage.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) if dsr.request_type != "erasure": raise HTTPException(status_code=400, detail="Exception checks only for erasure requests") # Check if already initialized existing = db.query(DSRExceptionCheckDB).filter(DSRExceptionCheckDB.dsr_id == dsr.id).count() if existing > 0: raise HTTPException(status_code=400, detail="Exception checks already initialized") checks = [] for exc in ART17_EXCEPTIONS: check = DSRExceptionCheckDB( tenant_id=uuid.UUID(tenant_id), dsr_id=dsr.id, check_code=exc["check_code"], article=exc["article"], label=exc["label"], description=exc["description"], ) db.add(check) checks.append(check) db.commit() return [ { "id": str(c.id), "dsr_id": str(c.dsr_id), "check_code": c.check_code, "article": c.article, "label": c.label, "description": c.description, "applies": c.applies, "notes": c.notes, } for c in checks ] @router.put("/{dsr_id}/exception-checks/{check_id}") async def update_exception_check( dsr_id: str, check_id: str, body: UpdateExceptionCheck, tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ): """Aktualisiert eine einzelne Ausnahmepruefung.""" dsr = _get_dsr_or_404(db, dsr_id, tenant_id) try: cid = uuid.UUID(check_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid check ID") check = db.query(DSRExceptionCheckDB).filter( DSRExceptionCheckDB.id == cid, DSRExceptionCheckDB.dsr_id == dsr.id, ).first() if not check: raise HTTPException(status_code=404, detail="Exception check not found") check.applies = body.applies check.notes = body.notes check.checked_by = "admin" check.checked_at = datetime.utcnow() db.commit() db.refresh(check) return { "id": str(check.id), "dsr_id": str(check.dsr_id), "check_code": check.check_code, "article": check.article, "label": check.label, "description": check.description, "applies": check.applies, "notes": check.notes, "checked_by": check.checked_by, "checked_at": check.checked_at.isoformat() if check.checked_at else None, }