from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import PlainTextResponse, Response 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, SetKioskNfcUidRequest, SetKioskPinRequest, UserImportResult, UserImportRowResult, UserListResponse, UserOut, 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"]) _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.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, 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") @router.post("/{user_id}/kiosk-nfc", response_model=MessageResponse) async def set_kiosk_nfc( user_id: UUID, data: SetKioskNfcUidRequest, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """NFC-Karte einem Mitarbeiter zuordnen. Nur Admin/HR - die Karte allein ist kein Geheimnis (klonbare UID), daher darf sie nicht selbst zugewiesen werden.""" if not current_user.is_admin_or_above(): 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_nfc_uid(user, data.nfc_uid, db) return MessageResponse(message="Kiosk NFC-UID updated")