Token-gescoper iCal-Feed (/absences/ical/<token>.ics), abonnierbar in Outlook/Apple/Google. Anders als der CalDAV-Client (Push nach Nextcloud) pollt der Kalender die URL selbst. Feed zeigt nur die eigenen bestätigten Abwesenheiten des Token-Inhabers. - users.ical_token_hash (SHA-256, rotierbar) + Migration 0041 (nur Spalte, keine RLS-Aenderung; users-Policy deckt neue nullable Spalte ab) - Router ical.py: oeffentlicher Feed (kein JWT) + Token-Verwaltung POST/GET/DELETE /users/me/ical-token (authentifiziert) - ProfilePage: Sektion "Kalender-Abo (iCal)" mit Erzeugen/Rotieren/ Deaktivieren, URL-Anzeige einmalig + Kopieren - test_ical.py: Token-Lifecycle + oeffentlicher Feed (3 Tests) Deployed auf 137 (Migration 0041, 196/196 Tests gruen). 164 ausstehend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.8 KiB
Python
115 lines
4.8 KiB
Python
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))
|
||
|
||
# iCal-Abo: gehashtes rotierbares Token für den read-only Kalender-Feed
|
||
# (/absences/ical/<token>.ics). Nur eigene Abwesenheiten, kein JWT nötig.
|
||
ical_token_hash: Mapped[str | None] = mapped_column(Text, unique=True)
|
||
|
||
# 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}]>"
|