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>
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user