# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return,attr-defined,index,call-overload,type-arg,var-annotated,misc,call-arg,return-value" """ DSR service — CRUD, stats, export, deadline processing. Phase 1 Step 4: extracted from ``compliance.api.dsr_routes``. Workflow actions (status changes, identity verification, assignment, completion, rejection, communications, exception checks, templates) live in ``compliance.services.dsr_workflow_service``. Helpers ``_dsr_to_dict``, ``_get_dsr_or_404``, ``_record_history``, ``_generate_request_number`` and constants are defined here and re-exported from ``compliance.api.dsr_routes`` for legacy test imports. """ import csv import io import uuid from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy import func, or_, text from sqlalchemy.orm import Session from compliance.db.dsr_models import DSRRequestDB, DSRStatusHistoryDB from compliance.domain import NotFoundError, ValidationError from compliance.schemas.dsr import DSRCreate, DSRUpdate # ============================================================================ # Constants # ============================================================================ DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" ART17_EXCEPTIONS: List[Dict[str, str]] = [ { "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_DAYS: Dict[str, int] = { "access": 30, "rectification": 14, "erasure": 14, "restriction": 14, "portability": 30, "objection": 30, } # ============================================================================ # Module-level helpers (re-exported by compliance.api.dsr_routes) # ============================================================================ def _generate_request_number(db: Session, tenant_id: str) -> str: """Generate next request number: DSR-YYYY-NNNNNN.""" year = datetime.now(timezone.utc).year try: result = db.execute(text("SELECT nextval('compliance_dsr_request_number_seq')")) seq = result.scalar() except Exception: 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: Optional[str] = None, ) -> 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[str, Any]: """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 NotFoundError.""" try: uid = uuid.UUID(dsr_id) except ValueError: raise ValidationError("Invalid DSR ID format") dsr = db.query(DSRRequestDB).filter( DSRRequestDB.id == uid, DSRRequestDB.tenant_id == uuid.UUID(tenant_id), ).first() if not dsr: raise NotFoundError("DSR not found") return dsr # ============================================================================ # Service class # ============================================================================ class DSRService: """CRUD, stats, export, and deadline processing for DSRs.""" def __init__(self, db: Session) -> None: self._db = db # -- Create -------------------------------------------------------------- def create(self, body: DSRCreate, tenant_id: str) -> Dict[str, Any]: if body.request_type not in VALID_REQUEST_TYPES: raise ValidationError(f"Invalid request_type. Must be one of: {VALID_REQUEST_TYPES}") if body.source not in VALID_SOURCES: raise ValidationError(f"Invalid source. Must be one of: {VALID_SOURCES}") if body.priority and body.priority not in VALID_PRIORITIES: raise ValidationError(f"Invalid priority. Must be one of: {VALID_PRIORITIES}") now = datetime.now(timezone.utc) deadline_days = DEADLINE_DAYS.get(body.request_type, 30) request_number = _generate_request_number(self._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, ) self._db.add(dsr) self._db.flush() history = DSRStatusHistoryDB( tenant_id=uuid.UUID(tenant_id), dsr_id=dsr.id, previous_status=None, new_status="intake", changed_by="system", comment="DSR erstellt", ) self._db.add(history) self._db.commit() self._db.refresh(dsr) return _dsr_to_dict(dsr) # -- List ---------------------------------------------------------------- def list( # noqa: C901 — many filter params, straightforward self, tenant_id: str, *, status: Optional[str] = None, request_type: Optional[str] = None, assigned_to: Optional[str] = None, priority: Optional[str] = None, overdue_only: bool = False, search: Optional[str] = None, from_date: Optional[str] = None, to_date: Optional[str] = None, limit: int = 20, offset: int = 0, ) -> Dict[str, Any]: query = self._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.now(timezone.utc), 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, } # -- Get ----------------------------------------------------------------- def get(self, dsr_id: str, tenant_id: str) -> Dict[str, Any]: dsr = _get_dsr_or_404(self._db, dsr_id, tenant_id) return _dsr_to_dict(dsr) # -- Update -------------------------------------------------------------- def update(self, dsr_id: str, body: DSRUpdate, tenant_id: str) -> Dict[str, Any]: dsr = _get_dsr_or_404(self._db, dsr_id, tenant_id) if body.priority is not None: if body.priority not in VALID_PRIORITIES: raise ValidationError(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.now(timezone.utc) 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.now(timezone.utc) self._db.commit() self._db.refresh(dsr) return _dsr_to_dict(dsr) # -- Delete (soft) ------------------------------------------------------- def delete(self, dsr_id: str, tenant_id: str) -> Dict[str, Any]: dsr = _get_dsr_or_404(self._db, dsr_id, tenant_id) if dsr.status in ("completed", "cancelled"): raise ValidationError("DSR already completed or cancelled") _record_history(self._db, dsr, "cancelled", comment="DSR storniert") dsr.status = "cancelled" dsr.updated_at = datetime.now(timezone.utc) self._db.commit() return {"success": True, "message": "DSR cancelled"} # -- Stats --------------------------------------------------------------- def stats(self, tenant_id: str) -> Dict[str, Any]: tid = uuid.UUID(tenant_id) base = self._db.query(DSRRequestDB).filter(DSRRequestDB.tenant_id == tid) total = base.count() by_status = {s: base.filter(DSRRequestDB.status == s).count() for s in VALID_STATUSES} by_type = {t: base.filter(DSRRequestDB.request_type == t).count() for t in VALID_REQUEST_TYPES} now = datetime.now(timezone.utc) overdue = base.filter( DSRRequestDB.deadline_at < now, DSRRequestDB.status.notin_(["completed", "rejected", "cancelled"]), ).count() 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() 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() 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 -------------------------------------------------------------- def export(self, tenant_id: str, fmt: str = "csv") -> Any: tid = uuid.UUID(tenant_id) dsrs = self._db.query(DSRRequestDB).filter( DSRRequestDB.tenant_id == tid, ).order_by(DSRRequestDB.created_at.desc()).all() if fmt == "json": return { "exported_at": datetime.now(timezone.utc).isoformat(), "total": len(dsrs), "requests": [_dsr_to_dict(d) for d in dsrs], } 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 output # -- Deadline processing ------------------------------------------------- def process_deadlines(self, tenant_id: str) -> Dict[str, Any]: now = datetime.now(timezone.utc) tid = uuid.UUID(tenant_id) from sqlalchemy import and_ overdue = self._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}