Statt Hard-Delete (kollidiert mit Lohn-/Steuer-Aufbewahrung) werden bei
DSGVO-Löschanfrage die personenbezogenen Felder der bestehenden User-Zeile
überschrieben; referenzierte Daten (Zeit/Abwesenheit/Auszahlung/AuditLog)
bleiben entkoppelt erhalten.
- users.anonymized_at (Migration 0040); UserOut.anonymized_at.
- user_service.anonymize_user: überschreibt Name/E-Mail (eindeutig anon-<id>@deleted.local),
entfernt Auth-Material (Passwort/PIN/QR/NFC/TOTP/LDAP-DN), is_active=False, löscht Sessions,
AuditLog 'user_anonymized'. Personalnummer bleibt reserviert (kein Personenbezug mehr).
Selbst-Anonymisierung (400), Doppelanonymisierung/Reaktivierung anonymisierter Konten (409).
- POST /users/{id}/anonymize (COMPANY_ADMIN/SUPER_ADMIN, mandantengeprüft).
- UsersPage: "Anonymisieren"-Button mit Sicherheitsabfrage + "Anonymisiert"-Badge.
193/193 Tests grün. Deployed 137 + 164.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
261 lines
8.9 KiB
Python
261 lines
8.9 KiB
Python
from typing import Annotated
|
||
from uuid import UUID
|
||
|
||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||
from fastapi.responses import PlainTextResponse
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.database import get_db
|
||
from app.core.dependencies import CurrentUser, require_role
|
||
from app.models.user import User, UserRole
|
||
from app.schemas.auth import MessageResponse
|
||
from app.schemas.user import (
|
||
InviteRequest,
|
||
NextPersonnelNumberResponse,
|
||
NotificationPrefsUpdate,
|
||
NotificationTypeOut,
|
||
SetKioskPinRequest,
|
||
UserImportResult,
|
||
UserImportRowResult,
|
||
UserListResponse,
|
||
UserOut,
|
||
UserUpdate,
|
||
)
|
||
from app.services import user_import_service
|
||
from app.services.user_service import user_service
|
||
|
||
router = APIRouter(prefix="/users", tags=["Users"])
|
||
|
||
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||
_hr_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN, UserRole.HR, UserRole.MANAGER)
|
||
|
||
|
||
@router.get("/", response_model=UserListResponse)
|
||
async def list_users(
|
||
current_user: User = require_role(*_hr_roles),
|
||
skip: int = Query(0, ge=0),
|
||
limit: int = Query(50, ge=1, le=500),
|
||
active_only: bool = Query(True),
|
||
search: str | None = Query(None, max_length=100),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
total, users = await user_service.list_users(
|
||
current_user.company_id, db, skip, limit, active_only, search,
|
||
)
|
||
return UserListResponse(total=total, items=[UserOut.model_validate(u) for u in users])
|
||
|
||
|
||
@router.post("/invite", response_model=UserOut, status_code=201)
|
||
async def invite_user(
|
||
data: InviteRequest,
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await user_service.invite(data, current_user.company_id, current_user, db)
|
||
return UserOut.model_validate(user)
|
||
|
||
|
||
@router.get("/colleagues")
|
||
async def list_colleagues(
|
||
current_user: CurrentUser,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Schlanke Kollegenliste (id + Name) für alle Mitarbeiter – z.B. zur
|
||
Vertreter-Auswahl. Nur aktive User der eigenen Firma (RLS-gefenced),
|
||
ohne sensible Felder."""
|
||
from sqlalchemy import select
|
||
rows = await db.scalars(
|
||
select(User)
|
||
.where(
|
||
User.company_id == current_user.company_id,
|
||
User.is_active.is_(True),
|
||
User.id != current_user.id,
|
||
)
|
||
.order_by(User.last_name, User.first_name)
|
||
)
|
||
return [{"id": str(u.id), "full_name": u.full_name} for u in rows.all()]
|
||
|
||
|
||
@router.get("/me", response_model=UserOut)
|
||
async def get_me(current_user: CurrentUser):
|
||
return UserOut.model_validate(current_user)
|
||
|
||
|
||
@router.get("/me/notification-prefs", response_model=list[NotificationTypeOut])
|
||
async def get_notification_prefs(current_user: CurrentUser):
|
||
from app.core.notifications import NOTIFICATION_TYPES, pref_enabled
|
||
return [
|
||
NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k))
|
||
for k, (label, _default) in NOTIFICATION_TYPES.items()
|
||
]
|
||
|
||
|
||
@router.patch("/me/notification-prefs", response_model=list[NotificationTypeOut])
|
||
async def update_notification_prefs(
|
||
data: NotificationPrefsUpdate,
|
||
current_user: CurrentUser,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
from app.core.notifications import NOTIFICATION_TYPES, normalize_prefs, pref_enabled
|
||
merged = dict(current_user.notification_prefs or {})
|
||
merged.update(normalize_prefs(data.prefs))
|
||
current_user.notification_prefs = merged # neues dict → JSONB-Änderung erkannt
|
||
await db.commit()
|
||
return [
|
||
NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k))
|
||
for k, (label, _default) in NOTIFICATION_TYPES.items()
|
||
]
|
||
|
||
|
||
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
|
||
async def next_personnel_number(
|
||
current_user: User = require_role(*_hr_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Schlägt die nächste freie Personalnummer vor (ohne den Counter zu erhöhen)."""
|
||
suggestion = await user_service.next_personnel_suggestion(current_user.company_id, db)
|
||
return NextPersonnelNumberResponse(next=suggestion)
|
||
|
||
|
||
@router.get("/by-personnel/{number}", response_model=UserOut)
|
||
async def get_user_by_personnel(
|
||
number: str,
|
||
current_user: User = require_role(*_hr_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await user_service.get_by_personnel_number(number, current_user.company_id, db)
|
||
return UserOut.model_validate(user)
|
||
|
||
|
||
@router.get("/import-template.csv", response_class=PlainTextResponse)
|
||
async def import_template(
|
||
current_user: User = require_role(*_admin_roles),
|
||
):
|
||
csv_text = user_import_service.build_template_csv()
|
||
return PlainTextResponse(
|
||
content=csv_text,
|
||
media_type="text/csv",
|
||
headers={"Content-Disposition": 'attachment; filename="user-import-template.csv"'},
|
||
)
|
||
|
||
|
||
_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
|
||
|
||
|
||
async def _read_upload(file: UploadFile) -> bytes:
|
||
"""Liest eine UploadFile mit Größenbegrenzung (max 10 MB)."""
|
||
content = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||
if len(content) > _MAX_UPLOAD_BYTES:
|
||
raise HTTPException(
|
||
status_code=413,
|
||
detail=f"Datei zu groß. Maximale Upload-Größe: {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB.",
|
||
)
|
||
return content
|
||
|
||
|
||
@router.post("/import/preview", response_model=UserImportResult)
|
||
async def user_import_preview(
|
||
file: Annotated[UploadFile, File()],
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
content = await _read_upload(file)
|
||
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
|
||
return _to_import_result_schema(result)
|
||
|
||
|
||
@router.post("/import/apply", response_model=UserImportResult)
|
||
async def user_import_apply(
|
||
file: Annotated[UploadFile, File()],
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
content = await _read_upload(file)
|
||
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
|
||
return _to_import_result_schema(result)
|
||
|
||
|
||
def _to_import_result_schema(result) -> UserImportResult:
|
||
return UserImportResult(
|
||
total_rows=result.total_rows,
|
||
created=result.created,
|
||
reactivated=result.reactivated,
|
||
errors=result.errors,
|
||
items=[
|
||
UserImportRowResult(
|
||
row=i.row, email=i.email, personnel_number=i.personnel_number,
|
||
action=i.action, message=i.message,
|
||
)
|
||
for i in result.items
|
||
],
|
||
)
|
||
|
||
|
||
@router.get("/{user_id}", response_model=UserOut)
|
||
async def get_user(
|
||
user_id: UUID,
|
||
current_user: User = require_role(*_hr_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await user_service.get_by_id(user_id, current_user.company_id, db)
|
||
return UserOut.model_validate(user)
|
||
|
||
|
||
@router.patch("/{user_id}", response_model=UserOut)
|
||
async def update_user(
|
||
user_id: UUID,
|
||
data: UserUpdate,
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await user_service.update(user_id, data, current_user, db)
|
||
return UserOut.model_validate(user)
|
||
|
||
|
||
@router.post("/{user_id}/deactivate", response_model=UserOut)
|
||
async def deactivate_user(
|
||
user_id: UUID,
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await user_service.deactivate(user_id, current_user, db)
|
||
return UserOut.model_validate(user)
|
||
|
||
|
||
@router.post("/{user_id}/reactivate", response_model=UserOut)
|
||
async def reactivate_user(
|
||
user_id: UUID,
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
user = await user_service.reactivate(user_id, current_user, db)
|
||
return UserOut.model_validate(user)
|
||
|
||
|
||
@router.post("/{user_id}/anonymize", response_model=UserOut)
|
||
async def anonymize_user(
|
||
user_id: UUID,
|
||
current_user: User = require_role(*_admin_roles),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""DSGVO Art. 17: Personenbezug des Kontos entfernen (nicht umkehrbar).
|
||
Referenzierte Daten (Zeit/Abwesenheit/Auszahlung) bleiben entkoppelt erhalten."""
|
||
user = await user_service.anonymize_user(user_id, current_user, db)
|
||
return UserOut.model_validate(user)
|
||
|
||
|
||
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
|
||
async def set_kiosk_pin(
|
||
user_id: UUID,
|
||
data: SetKioskPinRequest,
|
||
current_user: CurrentUser,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
# Users can set their own PIN; admins can set for any user in company
|
||
if user_id != current_user.id and not current_user.is_admin_or_above():
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=403, detail="Not allowed")
|
||
user = await user_service.get_by_id(user_id, current_user.company_id, db)
|
||
await user_service.set_kiosk_pin(user, data.pin, db)
|
||
return MessageResponse(message="Kiosk PIN updated")
|