Files
timemaster/backend/app/models/absence.py
T
patrickandClaude Opus 4.8 eab41ede69 feat: agent-12 – Zwei-Stufen-Genehmigung für Abwesenheiten
Optionale zweistufige Freigabe (Feature-Parität mit Urlaubsverwaltung,
Second-Stage-Authority), ohne SSO:

- Firmen-Opt-in companies.two_stage_approval_enabled + two_stage_min_days
  (nur Anträge ab X Arbeitstagen brauchen Stufe 2; 0 = alle).
- Ablauf PENDING → FIRST_APPROVED → APPROVED: erste Stufe durch Manager-Rollen,
  finale Stufe nur HR/Admin und zwingend eine ANDERE Person als Stufe 1.
- Urlaubs-/FZA-Abzug, CalDAV-Sync und Vertreter-Mail erst bei finaler Genehmigung.
  Ablehnen in beiden Stufen möglich; Eigentümer darf FIRST_APPROVED noch stornieren.
- pending_days, Kalender und Reminder-Digest berücksichtigen FIRST_APPROVED.
- Neuer Status-Wert + absences.first_approved_by; System-Kommentar bei Stufe 1.

Frontend: CompanySettingsPage (Toggle + Schwellwert), AbsencesPage
("Endgültig genehmigen"/Ablehnen für HR/Admin ≠ Erstgenehmiger, Status-Badge).

Migration 0038. 190/190 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:17:32 +02:00

101 lines
4.5 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 date, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, 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.user import User
from app.models.absence_type import AbsenceType
class AbsenceStatus(str, enum.Enum):
PENDING = "pending"
# Zwei-Stufen-Genehmigung: erste Stufe erteilt, wartet auf finale Genehmigung.
FIRST_APPROVED = "first_approved"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
# Mitarbeiter hat für einen bereits genehmigten Antrag eine Stornierung
# beantragt Manager muss zustimmen (→ cancelled) oder ablehnen (→ approved).
CANCELLATION_REQUESTED = "cancellation_requested"
class Absence(Base):
__tablename__ = "absences"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
type_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("absence_types.id", ondelete="RESTRICT"), nullable=False
)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
half_day_start: Mapped[bool] = mapped_column(Boolean, default=False)
half_day_end: Mapped[bool] = mapped_column(Boolean, default=False)
working_days: Mapped[float] = mapped_column(Numeric(5, 1), default=0)
status: Mapped[AbsenceStatus] = mapped_column(
Enum(AbsenceStatus, name="absencestatus", values_callable=lambda x: [e.value for e in x]),
nullable=False, default=AbsenceStatus.PENDING,
)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
# Zwei-Stufen-Genehmigung: Genehmiger der ersten Stufe (muss != finalem Genehmiger sein).
first_approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
substitute_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
note: Mapped[str | None] = mapped_column(Text)
rejection_reason: Mapped[str | None] = mapped_column(Text)
correction_note: Mapped[str | None] = mapped_column(Text)
# Zusatzinformationen (Weiterbildung, Dienstreise, etc.)
# Struktur je Kategorie:
# training: {"course_name": str, "provider": str, "location": str}
# business_trip: {"destination": str, "purpose": str}
meta: Mapped[dict | None] = mapped_column(JSONB)
# FZA in Stunden statt Tagen (bei Stunden-FZA ist start_date == end_date)
fza_hours: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
# Krankheit: Arbeitsunfähigkeitsbescheinigung
certificate_required_by: Mapped[date | None] = mapped_column(Date)
certificate_received_at: Mapped[date | None] = mapped_column(Date)
# CalDAV-Sync
caldav_uid: Mapped[str | None] = mapped_column(String(255))
caldav_user_etag: Mapped[str | None] = mapped_column(Text)
caldav_company_etag: Mapped[str | None] = mapped_column(Text)
caldav_last_error: Mapped[str | None] = mapped_column(Text)
caldav_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(
"User", primaryjoin="Absence.user_id == User.id",
foreign_keys="[Absence.user_id]", lazy="noload",
)
absence_type: Mapped["AbsenceType"] = relationship("AbsenceType", lazy="noload")
approver: Mapped["User | None"] = relationship(
"User", primaryjoin="Absence.approved_by == User.id",
foreign_keys="[Absence.approved_by]", lazy="noload",
)
substitute: Mapped["User | None"] = relationship(
"User", primaryjoin="Absence.substitute_id == User.id",
foreign_keys="[Absence.substitute_id]", lazy="noload",
)
def __repr__(self) -> str:
return f"<Absence {self.user_id} {self.start_date}{self.end_date} [{self.status}]>"