From 84af5a874a718bb55410f82ee097c8244772a137 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 7 Aug 2026 08:19:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(dsgvo):=20Art.=2015=20Auskunftsrecht=20?= =?UTF-8?q?=E2=80=93=20strukturierter=20Datenexport=20pro=20User?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /users/{id}/export-data liefert alle personenbezogenen Daten (Profil, Zeiterfassung, Abwesenheiten, Kommentare, Urlaubs-/Überstunden- konto, Auszahlungen, AuditLog-Einträge über den User) als JSON-Download. Eigenes Konto immer erlaubt, fremde Konten nur HR/Admin/Manager der eigenen Firma. Ergänzt die bestehende Art.-17-Anonymisierung. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj --- backend/app/routers/users.py | 27 +++++- backend/app/services/gdpr_service.py | 127 +++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/gdpr_service.py diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 14db172..b493914 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -2,7 +2,7 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile -from fastapi.responses import PlainTextResponse +from fastapi.responses import PlainTextResponse, Response from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db @@ -22,6 +22,7 @@ from app.schemas.user import ( UserUpdate, ) from app.services import user_import_service +from app.services.gdpr_service import gdpr_service from app.services.user_service import user_service router = APIRouter(prefix="/users", tags=["Users"]) @@ -244,6 +245,30 @@ async def anonymize_user( return UserOut.model_validate(user) +@router.get("/{user_id}/export-data") +async def export_user_data( + user_id: UUID, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """DSGVO Art. 15 (Auskunftsrecht): strukturierter Export aller + personenbezogenen Daten des Users als JSON. Eigenes Konto immer erlaubt, + fremde Konten nur für HR/Admin/Manager der eigenen Firma.""" + if user_id != current_user.id and current_user.role not in _hr_roles: + raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Konto") + + try: + data = await gdpr_service.export_user_data(user_id, current_user.company_id, db) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + content = gdpr_service.to_json(data) + return Response( + content=content, media_type="application/json", + headers={"Content-Disposition": f"attachment; filename=datenauskunft_{user_id}.json"}, + ) + + @router.post("/{user_id}/kiosk-pin", response_model=MessageResponse) async def set_kiosk_pin( user_id: UUID, diff --git a/backend/app/services/gdpr_service.py b/backend/app/services/gdpr_service.py new file mode 100644 index 0000000..078786d --- /dev/null +++ b/backend/app/services/gdpr_service.py @@ -0,0 +1,127 @@ +"""DSGVO Art. 15 (Auskunftsrecht): strukturierter Export aller personenbezogenen +Daten eines Users. Rein lesend, keine Schreiboperation. Ergänzt die bereits +vorhandene Art.-17-Anonymisierung (user_service.anonymize_user).""" +import json +from datetime import date, datetime, time +from decimal import Decimal +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.absence import Absence +from app.models.absence_comment import AbsenceComment +from app.models.audit_log import AuditLog +from app.models.hours_payout import HoursPayout +from app.models.overtime_balance import OvertimeBalance +from app.models.time_entry import TimeEntry +from app.models.user import User +from app.models.vacation_balance import VacationBalance + + +def _json_default(value): + if isinstance(value, (datetime, date, time)): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + if isinstance(value, UUID): + return str(value) + raise TypeError(f"Nicht serialisierbar: {type(value)}") + + +def _row(obj, fields: list[str]) -> dict: + return {f: getattr(obj, f) for f in fields} + + +class GdprService: + + async def export_user_data(self, user_id: UUID, company_id: UUID, db: AsyncSession) -> dict: + """Alle personenbezogenen Daten eines Users, company-isoliert. Wirft + ValueError wenn der User nicht in der Firma existiert (404 im Router).""" + user = await db.get(User, user_id) + if user is None or user.company_id != company_id: + raise ValueError("Mitarbeiter nicht gefunden") + + profile = _row(user, [ + "id", "first_name", "last_name", "email", "role", "personnel_number", + "is_active", "entry_date", "exit_date", "created_at", + ]) + + time_entries = [ + _row(e, [ + "id", "date", "start_time", "end_time", "break_minutes", + "worked_hours", "status", "source", "note", "created_at", + ]) + for e in (await db.scalars( + select(TimeEntry).where(TimeEntry.user_id == user_id).order_by(TimeEntry.date) + )).all() + ] + + absences = [ + _row(a, [ + "id", "type_id", "start_date", "end_date", "half_day_start", + "half_day_end", "working_days", "status", "note", + "rejection_reason", "substitute_id", "created_at", + ]) + for a in (await db.scalars( + select(Absence).where(Absence.user_id == user_id).order_by(Absence.start_date) + )).all() + ] + + absence_comments = [ + _row(c, ["id", "absence_id", "body", "is_system", "created_at"]) + for c in (await db.scalars( + select(AbsenceComment).where(AbsenceComment.author_id == user_id) + )).all() + ] + + vacation_balances = [ + _row(v, ["id", "year", "entitled_days", "special_days", "carried_over", "used_days"]) + for v in (await db.scalars( + select(VacationBalance).where(VacationBalance.user_id == user_id).order_by(VacationBalance.year) + )).all() + ] + + overtime_balance = await db.scalar( + select(OvertimeBalance).where(OvertimeBalance.user_id == user_id) + ) + overtime = _row(overtime_balance, ["total_hours", "taken_hours"]) if overtime_balance else None + + hours_payouts = [ + _row(p, [ + "id", "hours", "period_year", "period_month", "note", "status", + "rejection_reason", "decided_at", "created_at", + ]) + for p in (await db.scalars( + select(HoursPayout).where(HoursPayout.user_id == user_id).order_by(HoursPayout.created_at) + )).all() + ] + + # AuditLog: nur Einträge über die Person, keine, bei denen sie nur Akteur war + # (letzteres wäre kein Datum ÜBER die Person, sondern über deren Handlung an Dritten) + audit_entries = [ + _row(a, ["id", "action", "entity_type", "entity_id", "created_at"]) + for a in (await db.scalars( + select(AuditLog).where( + AuditLog.entity_type == "user", AuditLog.entity_id == user_id + ).order_by(AuditLog.created_at) + )).all() + ] + + return { + "export_created_at": datetime.utcnow(), + "profile": profile, + "time_entries": time_entries, + "absences": absences, + "absence_comments": absence_comments, + "vacation_balances": vacation_balances, + "overtime_balance": overtime, + "hours_payouts": hours_payouts, + "audit_log_about_user": audit_entries, + } + + def to_json(self, data: dict) -> str: + return json.dumps(data, default=_json_default, indent=2, ensure_ascii=False) + + +gdpr_service = GdprService()