feat(dsgvo): Art. 15 Auskunftsrecht – strukturierter Datenexport pro User
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj
This commit is contained in:
@@ -2,7 +2,7 @@ from typing import Annotated
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -22,6 +22,7 @@ from app.schemas.user import (
|
|||||||
UserUpdate,
|
UserUpdate,
|
||||||
)
|
)
|
||||||
from app.services import user_import_service
|
from app.services import user_import_service
|
||||||
|
from app.services.gdpr_service import gdpr_service
|
||||||
from app.services.user_service import user_service
|
from app.services.user_service import user_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["Users"])
|
router = APIRouter(prefix="/users", tags=["Users"])
|
||||||
@@ -244,6 +245,30 @@ async def anonymize_user(
|
|||||||
return UserOut.model_validate(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)
|
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
|
||||||
async def set_kiosk_pin(
|
async def set_kiosk_pin(
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user