feat: agent-11 PR3 – Scheduler + Erinnerungs-Mails + Notification-Prefs
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled

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>
This commit is contained in:
2026-06-23 12:21:14 +02:00
co-authored by Claude Opus 4.8
parent c9c197ddae
commit c83fb31408
15 changed files with 542 additions and 9 deletions
+11 -7
View File
@@ -426,28 +426,31 @@ async def update_balance(
balance = await absence_service.get_balance(user_id, year, db)
# Alte Werte für AuditLog sichern
old_base = balance.base_days
old_special = balance.special_days
old_carried = balance.carried_over_days
old_value = {
"entitled_days": balance.entitled_days,
"special_days": balance.special_days,
"carried_over": balance.carried_over,
}
for field, value in data.model_dump(exclude_unset=True).items():
setattr(balance, field, value)
# AuditLog schreiben
db.add(AuditLog(
company_id=current_user.company_id,
user_id=current_user.id,
action="update_vacation_balance",
entity_type="vacation_balance",
entity_id=balance.id,
old_value={"base_days": old_base, "special_days": old_special, "carried_over_days": old_carried},
old_value=old_value,
new_value={
"base_days": balance.base_days,
"entitled_days": balance.entitled_days,
"special_days": balance.special_days,
"carried_over_days": balance.carried_over_days,
"carried_over": balance.carried_over,
"target_user_id": str(user_id),
"year": year,
},
ip_address=get_client_ip(request),
ip=get_client_ip(request),
))
await db.commit()
@@ -456,6 +459,7 @@ async def update_balance(
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
return VacationBalanceOut.model_validate(balance).model_copy(update={
"pending_days": pending,
"available_days": absence_service.effective_available(balance, expired),
"carried_over_expires_at": expires_at,
"carried_over_expired": expired,
})
+13
View File
@@ -39,6 +39,19 @@ async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(g
return CompanyOut.model_validate(company)
@router.post("/me/run-reminders")
async def run_reminders_now(
current_user: User = require_role(*_admin_roles),
db: AsyncSession = Depends(get_db),
):
"""Erinnerungs-Jobs (offene Anträge, Resturlaub-Verfall, AU fehlt) sofort für die
eigene Firma ausführen. Gleiche Logik wie der tägliche Scheduler."""
from app.services.scheduler_service import run_all_reminders
result = await run_all_reminders(db, company_id=current_user.company_id)
await db.commit()
return {"sent": result}
@router.patch("/me", response_model=CompanyOut)
async def update_my_company(
data: CompanyUpdate,
+28
View File
@@ -12,6 +12,8 @@ from app.schemas.auth import MessageResponse
from app.schemas.user import (
InviteRequest,
NextPersonnelNumberResponse,
NotificationPrefsUpdate,
NotificationTypeOut,
SetKioskPinRequest,
UserImportResult,
UserImportRowResult,
@@ -79,6 +81,32 @@ async def get_me(current_user: CurrentUser):
return UserOut.model_validate(current_user)
@router.get("/me/notification-prefs", response_model=list[NotificationTypeOut])
async def get_notification_prefs(current_user: CurrentUser):
from app.core.notifications import NOTIFICATION_TYPES, pref_enabled
return [
NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k))
for k, (label, _default) in NOTIFICATION_TYPES.items()
]
@router.patch("/me/notification-prefs", response_model=list[NotificationTypeOut])
async def update_notification_prefs(
data: NotificationPrefsUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
from app.core.notifications import NOTIFICATION_TYPES, normalize_prefs, pref_enabled
merged = dict(current_user.notification_prefs or {})
merged.update(normalize_prefs(data.prefs))
current_user.notification_prefs = merged # neues dict → JSONB-Änderung erkannt
await db.commit()
return [
NotificationTypeOut(key=k, label=label, enabled=pref_enabled(current_user, k))
for k, (label, _default) in NOTIFICATION_TYPES.items()
]
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
async def next_personnel_number(
current_user: User = require_role(*_hr_roles),