Files
timemaster/backend/app/models/user.py
T
patrickandClaude Opus 4.8 2f110df619 feat: agent-11 PR3 – Scheduler + Erinnerungs-Mails + Notification-Prefs
Geplante Erinnerungen (Feature-Parität mit Urlaubsverwaltung):

- APScheduler (AsyncIOScheduler) in der FastAPI-Lifespan; tägliche Jobs ab
  settings.reminder_hour. Redis-Tageslock gegen Doppelversand bei mehreren
  Prozessen; jeder Job mit eigener Session + RLS-Bypass.
- Drei Jobs (auch einzeln aufrufbar): offene Anträge an Genehmiger,
  Resturlaub-Verfall-Vorwarnung an Mitarbeiter, fehlende AU an HR.
- Pro-User notification_prefs (JSONB, opt-out); GET/PATCH /users/me/notification-prefs
  + ProfilePage-UI; Vertreter-Mail respektiert die Prefs.
- Manueller Trigger POST /companies/me/run-reminders (Admin) – gleiche Logik,
  firmen-scoped (testbar ohne Warten).
- Bugfix: GET-/PATCH-Urlaubskonto (update_balance) nutzte nicht existente
  Felder (base_days/carried_over_days/ip_address) → korrigiert auf
  entitled_days/carried_over/ip + company_id; available_days ergänzt.

Migration 0037 (users.notification_prefs). 188/188 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:21:14 +02:00

108 lines
4.4 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="{}")
# 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}]>"