Files
timemaster/backend/app/models/user.py
T
patrickandClaude Opus 4.8 c122ab6fd7 feat(dsgvo): Art. 17 Anonymisierung von Mitarbeiterkonten
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>
2026-06-23 14:31:14 +02:00

111 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import uuid
import enum
from datetime import datetime, date
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.company import Company
from app.models.department import Department
from app.models.session import Session
class UserRole(str, enum.Enum):
SUPER_ADMIN = "SUPER_ADMIN"
RESELLER = "RESELLER"
COMPANY_ADMIN = "COMPANY_ADMIN"
HR = "HR"
MANAGER = "MANAGER"
EMPLOYEE = "EMPLOYEE"
class AuthProvider(str, enum.Enum):
LOCAL = "local"
LDAP = "ldap"
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
company_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"))
department_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="SET NULL"))
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.EMPLOYEE)
auth_provider: Mapped[AuthProvider] = mapped_column(
Enum(AuthProvider, name="authprovider", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AuthProvider.LOCAL,
)
ldap_dn: Mapped[str | None] = mapped_column(Text)
work_schedule_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("work_schedules.id", ondelete="SET NULL")
)
# Ein-/Austrittsdatum Basis für anteilige Urlaubsberechnung (Zwölftel-Regel)
entry_date: Mapped[date | None] = mapped_column(Date)
exit_date: Mapped[date | None] = mapped_column(Date)
# Pro-User-Benachrichtigungseinstellungen (agent-11 PR3). Leeres dict = alle an.
notification_prefs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
# DSGVO Art. 17: Zeitpunkt der Anonymisierung (Personenbezug entfernt). NULL = aktiv.
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Kiosk auth
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
# Kalender-Kürzel (vom Manager setzbar, für CalDAV-Template $kuerzel)
kuerzel: Mapped[str | None] = mapped_column(String(20))
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
personnel_number: Mapped[str | None] = mapped_column(String(50))
# NFC-UID für Kiosk-Login (optional, eindeutig pro Firma)
kiosk_nfc_uid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# TOTP / 2FA
totp_secret: Mapped[str | None] = mapped_column(String(500)) # Fernet-verschlüsselt
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Permissions
can_manual_time_entry: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Account state
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
invite_token_hash: Mapped[str | None] = mapped_column(Text)
invite_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Relationships
company: Mapped["Company"] = relationship(
"Company", back_populates="users", foreign_keys="User.company_id",
)
department: Mapped["Department | None"] = relationship(
"Department",
primaryjoin="User.department_id == Department.id",
foreign_keys="[User.department_id]",
back_populates="members",
)
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user", cascade="all, delete-orphan", lazy="noload")
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
def is_admin_or_above(self) -> bool:
return self.role in (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
def __repr__(self) -> str:
return f"<User {self.email} [{self.role}]>"