feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

Abwesenheits-Modul abgerundet (Feature-Parität mit Urlaubsverwaltung):

- Vertretung: Overlap-Warnung beim Anlegen, E-Mail an Vertretung bei
  Genehmigung, GET /absences/?as_substitute=true, neuer schlanker
  GET /users/colleagues (alle Rollen, RLS-gefenced) für die Auswahl;
  Vertreter-Dropdown + Anzeige in der Liste.
- Stornierung mit Re-Genehmigung: neuer Status CANCELLATION_REQUESTED,
  POST /absences/{id}/request-cancellation; Manager genehmigt/lehnt über
  bestehende approve/reject ab (Urlaub + FZA-Rückbuchung via _apply_cancellation).
- Kommentare: Model AbsenceComment (company_id-RLS), GET/POST comments,
  System-Kommentare bei Statuswechsel, AbsenceCommentsModal.
- Fix: CalDAV fire-and-forget nutzte die Request-Session weiter (in Tests
  geteilt -> "another operation in progress"); jetzt sync_*_bg mit eigener
  Session + RLS-Bypass.

Migration 0035. 178/178 Tests grün. Deployed auf 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:47:47 +02:00
co-authored by Claude Opus 4.8
parent c3cb9ce073
commit 3b2df1c978
18 changed files with 793 additions and 27 deletions
+2
View File
@@ -8,6 +8,7 @@ from app.models.work_schedule import WorkSchedule
from app.models.time_entry import TimeEntry, EntryStatus, EntrySource
from app.models.absence_type import AbsenceType
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_comment import AbsenceComment
from app.models.vacation_balance import VacationBalance
from app.models.overtime_balance import OvertimeBalance
from app.models.public_holiday import PublicHoliday
@@ -32,6 +33,7 @@ __all__ = [
"AbsenceType",
"Absence",
"AbsenceStatus",
"AbsenceComment",
"VacationBalance",
"OvertimeBalance",
"PublicHoliday",
+7 -4
View File
@@ -16,10 +16,13 @@ if TYPE_CHECKING:
class AbsenceStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
PENDING = "pending"
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):
+44
View File
@@ -0,0 +1,44 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class AbsenceComment(Base):
"""Kommentar-Thread an einem Abwesenheitsantrag.
`company_id` wird redundant gespeichert, damit die Tabelle über die normale
company_id-RLS-Policy gefenced werden kann (kein Join nötig). System-Kommentare
(`is_system=True`) werden bei Statuswechseln automatisch erzeugt.
"""
__tablename__ = "absence_comments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
absence_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("absences.id", ondelete="CASCADE"), nullable=False, index=True
)
company_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
)
author_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
body: Mapped[str] = mapped_column(Text, nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
author: Mapped["User | None"] = relationship(
"User", primaryjoin="AbsenceComment.author_id == User.id",
foreign_keys="[AbsenceComment.author_id]", lazy="noload",
)
def __repr__(self) -> str:
return f"<AbsenceComment {self.absence_id} by {self.author_id}>"
+43
View File
@@ -12,12 +12,15 @@ from app.schemas.absence import (
AbsenceCreate,
AbsenceListResponse,
AbsenceOut,
AbsenceCommentCreate,
AbsenceCommentOut,
AbsenceReject,
AbsenceUpdate,
AbsenceTypeCreate,
AbsenceTypeOut,
AbsenceTypeUpdate,
CalendarEntry,
CancellationRequest,
CertificateMarkIn,
OvertimeBalanceOut,
PublicHolidayCreate,
@@ -256,11 +259,13 @@ async def list_absences(
type_id: UUID | None = Query(None),
status: AbsenceStatus | None = Query(None),
year: int | None = Query(None),
as_substitute: bool = Query(False),
db: AsyncSession = Depends(get_db),
):
total, absences = await absence_service.list_absences(
current_user.company_id, current_user, db,
user_id=user_id, type_id=type_id, status=status, year=year,
as_substitute=as_substitute,
)
return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences])
@@ -325,6 +330,44 @@ async def cancel_absence(
return AbsenceOut.model_validate(absence)
@router.post("/absences/{absence_id}/request-cancellation", response_model=AbsenceOut)
async def request_cancellation(
absence_id: UUID,
data: CancellationRequest,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Mitarbeiter beantragt Stornierung eines genehmigten Antrags (Manager genehmigt/lehnt ab).
HR/Admin storniert weiterhin direkt über DELETE."""
absence = await absence_service.request_cancellation(absence_id, data.reason, current_user, db)
await db.commit()
return AbsenceOut.model_validate(absence)
# ── Kommentare ────────────────────────────────────────────────────────────────
@router.get("/absences/{absence_id}/comments", response_model=list[AbsenceCommentOut])
async def list_absence_comments(
absence_id: UUID,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
return await absence_service.list_comments(absence_id, current_user, db)
@router.post("/absences/{absence_id}/comments", response_model=AbsenceCommentOut, status_code=201)
async def add_absence_comment(
absence_id: UUID,
data: AbsenceCommentCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
comment = await absence_service.add_comment(absence_id, data.body, current_user, db)
await db.commit()
return comment
class AbsenceApproveOut(AbsenceOut):
warnings: list[str] = []
+21
View File
@@ -53,6 +53,27 @@ async def invite_user(
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)
+22
View File
@@ -122,6 +122,28 @@ class AbsenceReject(BaseModel):
rejection_reason: str = Field(min_length=1)
class CancellationRequest(BaseModel):
reason: str | None = Field(None, max_length=1000)
# ── Kommentare ────────────────────────────────────────────────────────────────
class AbsenceCommentOut(BaseModel):
model_config = {"from_attributes": True}
id: uuid.UUID
absence_id: uuid.UUID
author_id: uuid.UUID | None
author_name: str | None = None
body: str
is_system: bool
created_at: datetime
class AbsenceCommentCreate(BaseModel):
body: str = Field(min_length=1, max_length=2000)
class AbsenceListResponse(BaseModel):
total: int
items: list[AbsenceOut]
+237 -7
View File
@@ -108,13 +108,17 @@ class AbsenceService:
type_id: UUID | None = None,
status: AbsenceStatus | None = None,
year: int | None = None,
as_substitute: bool = False,
) -> tuple[int, list[Absence]]:
q = (
select(Absence)
.join(User, Absence.user_id == User.id)
.where(User.company_id == company_id)
)
if current_user.role == UserRole.EMPLOYEE:
if as_substitute:
# Anträge, in denen der aktuelle User als Vertretung eingetragen ist
q = q.where(Absence.substitute_id == current_user.id)
elif current_user.role == UserRole.EMPLOYEE:
q = q.where(Absence.user_id == current_user.id)
elif user_id:
q = q.where(Absence.user_id == user_id)
@@ -185,6 +189,28 @@ class AbsenceService:
if overlap:
warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.")
# Vertreter prüfen: gleiche Firma + im Zeitraum selbst nicht abwesend (nur Warnung)
if data.substitute_id:
substitute = await db.get(User, data.substitute_id)
if substitute is None or substitute.company_id != current_user.company_id:
raise HTTPException(status_code=404, detail="Vertretung nicht gefunden.")
if substitute.id == current_user.id:
raise HTTPException(status_code=400, detail="Man kann sich nicht selbst vertreten.")
sub_overlap = await db.scalar(
select(Absence).where(
and_(
Absence.user_id == data.substitute_id,
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
Absence.start_date <= data.end_date,
Absence.end_date >= data.start_date,
)
)
)
if sub_overlap:
warnings.append(
f"Gewählte Vertretung ({substitute.full_name}) ist im Zeitraum selbst abwesend."
)
status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED
approved_by = None if absence_type.requires_approval else current_user.id
@@ -215,9 +241,12 @@ class AbsenceService:
db.add(absence)
await db.flush()
# Bei automatischer Genehmigung Konto abziehen
if not absence_type.requires_approval and absence_type.deducts_vacation:
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
# Bei automatischer Genehmigung Konto abziehen + Vertretung benachrichtigen
if not absence_type.requires_approval:
if absence_type.deducts_vacation:
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
if absence.substitute_id:
await self._notify_substitute(absence, db)
return absence, warnings
@@ -347,7 +376,7 @@ class AbsenceService:
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
return absence
@@ -368,6 +397,12 @@ class AbsenceService:
status_code=409,
detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden."
)
# Storno-Anfrage genehmigen → Antrag tatsächlich stornieren + Rückbuchung
if absence.status == AbsenceStatus.CANCELLATION_REQUESTED:
await self._apply_cancellation(absence, current_user, db, from_request=True)
return absence, []
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.")
@@ -408,7 +443,11 @@ class AbsenceService:
# CalDAV-Sync (fire & forget Fehler blockieren nicht die Genehmigung)
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_approved(absence, db))
asyncio.create_task(caldav_service.sync_approved_bg(absence.id))
# Vertretung benachrichtigen
if absence.substitute_id:
await self._notify_substitute(absence, db)
return absence, fza_warnings
@@ -424,6 +463,26 @@ class AbsenceService:
requester = await db.get(User, absence.user_id)
if requester is None or requester.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
# Storno-Anfrage ablehnen → Antrag bleibt genehmigt
if absence.status == AbsenceStatus.CANCELLATION_REQUESTED:
absence.status = AbsenceStatus.APPROVED
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_cancellation_rejected",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "cancellation_requested"},
new_value={"status": "approved", "rejection_reason": data.rejection_reason,
"absence_user_id": str(absence.user_id)},
))
await self._add_system_comment(
absence, current_user.company_id, current_user.id,
f"Stornierung abgelehnt von {current_user.full_name}: {data.rejection_reason}", db,
)
return absence
if absence.status != AbsenceStatus.PENDING:
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.")
@@ -452,7 +511,7 @@ class AbsenceService:
))
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed(absence, db))
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
return absence
@@ -845,5 +904,176 @@ class AbsenceService:
return list(by_user.values())
# ── Stornierung mit Re-Genehmigung ──────────────────────────────────────────
async def request_cancellation(
self, absence_id: UUID, reason: str | None, current_user: User, db: AsyncSession
) -> Absence:
"""Mitarbeiter beantragt die Stornierung eines bereits GENEHMIGTEN Antrags.
HR/Admin storniert weiterhin direkt (über cancel_absence). Diese Anfrage
setzt den Status auf CANCELLATION_REQUESTED → Manager muss zustimmen.
"""
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
if absence.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
if absence.status != AbsenceStatus.APPROVED:
raise HTTPException(
status_code=409,
detail="Nur genehmigte Anträge können zur Stornierung eingereicht werden.",
)
absence.status = AbsenceStatus.CANCELLATION_REQUESTED
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="absence_cancellation_requested",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "approved"},
new_value={"status": "cancellation_requested", "reason": reason,
"absence_user_id": str(absence.user_id)},
))
body = "Stornierung beantragt" + (f": {reason}" if reason else ".")
await self._add_system_comment(absence, current_user.company_id, current_user.id, body, db)
return absence
async def _apply_cancellation(
self, absence: Absence, actor: User, db: AsyncSession, from_request: bool,
) -> None:
"""Genehmigten Antrag tatsächlich stornieren inkl. Rückbuchung (Urlaub + FZA)."""
absence_type = await db.get(AbsenceType, absence.type_id)
if absence_type and absence_type.deducts_vacation:
await self._refund_vacation(
absence.user_id, absence.start_date.year, int(absence.working_days), db
)
if absence_type and absence_type.affects_overtime_balance:
await self._refund_overtime(
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
)
absence.status = AbsenceStatus.CANCELLED
db.add(AuditLog(
company_id=actor.company_id,
user_id=actor.id,
action="absence_cancellation_approved" if from_request else "absence_cancelled",
entity_type="absence",
entity_id=absence.id,
old_value={"status": "cancellation_requested" if from_request else "approved"},
new_value={
"status": "cancelled",
"cancelled_by": str(actor.id),
"cancelled_by_name": actor.full_name,
"absence_user_id": str(absence.user_id),
"working_days": float(absence.working_days),
},
))
await self._add_system_comment(
absence, actor.company_id, actor.id,
f"Stornierung genehmigt von {actor.full_name}.", db,
)
from app.services.caldav_service import caldav_service
asyncio.create_task(caldav_service.sync_removed_bg(absence.id))
async def _refund_vacation(
self, user_id: UUID, year: int, days: int, db: AsyncSession
) -> None:
balance = await db.scalar(
select(VacationBalance).where(
VacationBalance.user_id == user_id, VacationBalance.year == year
)
)
if balance is not None:
balance.used_days = max(0, balance.used_days - days)
async def _notify_substitute(self, absence: Absence, db: AsyncSession) -> None:
"""Eingetragene Vertretung über die genehmigte Abwesenheit informieren."""
if not absence.substitute_id:
return
substitute = await db.get(User, absence.substitute_id)
requester = await db.get(User, absence.user_id)
if substitute is None or requester is None or not substitute.email:
return
from app.services.email_service import email_service
try:
await email_service.send_substitute_notification(substitute, requester, absence, db)
except Exception as exc: # Mailfehler dürfen die Genehmigung nicht blockieren
print(f"Vertreter-Benachrichtigung fehlgeschlagen: {exc}")
# ── Kommentare ──────────────────────────────────────────────────────────────
async def _add_system_comment(
self, absence: Absence, company_id: UUID, author_id: UUID | None, body: str, db: AsyncSession
) -> None:
from app.models.absence_comment import AbsenceComment
db.add(AbsenceComment(
absence_id=absence.id, company_id=company_id,
author_id=author_id, body=body, is_system=True,
))
async def _assert_comment_access(
self, absence: Absence, current_user: User, db: AsyncSession
) -> None:
"""Sichtbar für: Antragsteller, eingetragene Vertretung, Manager-Rollen der Firma."""
if current_user.role in _manager_roles:
owner = await db.get(User, absence.user_id)
if owner is None or owner.company_id != current_user.company_id:
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
return
if current_user.id in (absence.user_id, absence.substitute_id):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
async def list_comments(
self, absence_id: UUID, current_user: User, db: AsyncSession
) -> list:
from app.models.absence_comment import AbsenceComment
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
await self._assert_comment_access(absence, current_user, db)
rows = (await db.execute(
select(AbsenceComment, User)
.outerjoin(User, AbsenceComment.author_id == User.id)
.where(AbsenceComment.absence_id == absence_id)
.order_by(AbsenceComment.created_at)
)).all()
result = []
for comment, author in rows:
result.append({
"id": comment.id, "absence_id": comment.absence_id,
"author_id": comment.author_id,
"author_name": author.full_name if author else None,
"body": comment.body, "is_system": comment.is_system,
"created_at": comment.created_at,
})
return result
async def add_comment(
self, absence_id: UUID, body: str, current_user: User, db: AsyncSession
) -> dict:
from app.models.absence_comment import AbsenceComment
absence = await db.get(Absence, absence_id)
if absence is None:
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
await self._assert_comment_access(absence, current_user, db)
comment = AbsenceComment(
absence_id=absence_id, company_id=current_user.company_id,
author_id=current_user.id, body=body.strip(), is_system=False,
)
db.add(comment)
await db.flush()
return {
"id": comment.id, "absence_id": comment.absence_id,
"author_id": comment.author_id, "author_name": current_user.full_name,
"body": comment.body, "is_system": comment.is_system,
"created_at": comment.created_at,
}
absence_service = AbsenceService()
+28
View File
@@ -346,6 +346,34 @@ class CalDavService:
select(CaldavUserConfig).where(CaldavUserConfig.user_id == user_id)
)
# ── Hintergrund-Sync (eigene Session) ─────────────────────────────────────
# Fire-and-forget aus Request-Handlern darf NICHT die Request-Session
# weiterverwenden (wird nach der Response geschlossen; in Tests sogar
# sessionweit geteilt → "another operation in progress"). Diese Wrapper
# öffnen eine eigene Session, laden die Abwesenheit frisch und committen.
async def sync_approved_bg(self, absence_id: uuid.UUID) -> None:
await self._run_bg(absence_id, self.sync_approved)
async def sync_removed_bg(self, absence_id: uuid.UUID) -> None:
await self._run_bg(absence_id, self.sync_removed)
async def _run_bg(self, absence_id: uuid.UUID, fn) -> None:
from sqlalchemy import text
from app.core.database import AsyncSessionLocal
try:
async with AsyncSessionLocal() as db:
# Interner Job ohne Tenant-Kontext → RLS-Bypass nötig
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
absence = await db.get(Absence, absence_id)
if absence is None:
return
await fn(absence, db)
await db.commit()
except Exception as exc: # darf den Request niemals beeinflussen
log.warning("CalDAV background sync failed for absence %s: %s", absence_id, exc)
# ── Sync-Operationen ──────────────────────────────────────────────────────
async def sync_approved(self, absence: Absence, db: AsyncSession) -> None:
+22
View File
@@ -141,6 +141,28 @@ class EmailService:
"""
await self._send(user.email, "Passwort zurücksetzen", _html_wrapper("Passwort zurücksetzen", body), cfg)
async def send_substitute_notification(
self, substitute: "User", requester: "User", absence, db: AsyncSession
) -> None:
"""Informiert die eingetragene Vertretung über eine genehmigte Abwesenheit."""
cfg = await self._load_smtp(substitute.company_id, db)
start = absence.start_date.strftime("%d.%m.%Y")
end = absence.end_date.strftime("%d.%m.%Y")
zeitraum = start if start == end else f"{start} {end}"
body = f"""
<h1>Du wurdest als Vertretung eingetragen</h1>
<p>Hallo {substitute.first_name},</p>
<p><strong>{requester.full_name}</strong> ist im Zeitraum <strong>{zeitraum}</strong>
abwesend und hat dich als Vertretung benannt.</p>
<a href="{settings.frontend_url}/absences" class="btn">Abwesenheiten ansehen</a>
"""
await self._send(
substitute.email,
f"Vertretung für {requester.full_name} ({zeitraum})",
_html_wrapper("Vertretung", body),
cfg,
)
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
body = f"""