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:
@@ -163,6 +163,65 @@ class EmailService:
|
||||
cfg,
|
||||
)
|
||||
|
||||
async def send_pending_approvals_digest(
|
||||
self, approver: "User", items: list[dict], db: AsyncSession
|
||||
) -> None:
|
||||
"""Tägliche Zusammenfassung offener Anträge an eine genehmigende Person."""
|
||||
cfg = await self._load_smtp(approver.company_id, db)
|
||||
rows = "".join(
|
||||
f"<li>{i['user_name']} – {i['type_name']} "
|
||||
f"({i['start']}{' – ' + i['end'] if i['end'] != i['start'] else ''}, "
|
||||
f"{i['working_days']} Tag(e))</li>"
|
||||
for i in items
|
||||
)
|
||||
body = f"""
|
||||
<h1>Offene Abwesenheitsanträge</h1>
|
||||
<p>Hallo {approver.first_name}, es warten <strong>{len(items)}</strong> Anträge auf deine Genehmigung:</p>
|
||||
<ul style="font-size:14px;color:#555;line-height:1.7">{rows}</ul>
|
||||
<a href="{settings.frontend_url}/absences?status=pending" class="btn">Anträge prüfen</a>
|
||||
"""
|
||||
await self._send(
|
||||
approver.email, f"{len(items)} offene Abwesenheitsanträge",
|
||||
_html_wrapper("Offene Anträge", body), cfg,
|
||||
)
|
||||
|
||||
async def send_carryover_expiry_reminder(
|
||||
self, user: "User", remaining: int, expires_at, db: AsyncSession
|
||||
) -> None:
|
||||
cfg = await self._load_smtp(user.company_id, db)
|
||||
datum = expires_at.strftime("%d.%m.%Y")
|
||||
body = f"""
|
||||
<h1>Resturlaub verfällt bald</h1>
|
||||
<p>Hallo {user.first_name}, du hast noch <strong>{remaining} Urlaubstage</strong>,
|
||||
die am <strong>{datum}</strong> verfallen.</p>
|
||||
<p>Plane deinen Urlaub rechtzeitig ein, damit nichts verloren geht.</p>
|
||||
<a href="{settings.frontend_url}/absences" class="btn">Urlaub planen</a>
|
||||
"""
|
||||
await self._send(
|
||||
user.email, f"Dein Resturlaub verfällt am {datum}",
|
||||
_html_wrapper("Resturlaub", body), cfg,
|
||||
)
|
||||
|
||||
async def send_certificate_overdue_digest(
|
||||
self, hr_user: "User", items: list[dict], db: AsyncSession
|
||||
) -> None:
|
||||
cfg = await self._load_smtp(hr_user.company_id, db)
|
||||
rows = "".join(
|
||||
f"<li>{i['user_name']} – krank seit {i['start']}, AU fällig seit {i['due']}</li>"
|
||||
for i in items
|
||||
)
|
||||
body = f"""
|
||||
<h1>Fehlende AU-Bescheinigungen</h1>
|
||||
<p>Hallo {hr_user.first_name}, für <strong>{len(items)}</strong> Krankmeldung(en)
|
||||
fehlt die Arbeitsunfähigkeitsbescheinigung:</p>
|
||||
<ul style="font-size:14px;color:#555;line-height:1.7">{rows}</ul>
|
||||
<a href="{settings.frontend_url}/absences" class="btn">Abwesenheiten ansehen</a>
|
||||
"""
|
||||
await self._send(
|
||||
hr_user.email, f"{len(items)} fehlende AU-Bescheinigung(en)",
|
||||
_html_wrapper("AU fehlt", body), cfg,
|
||||
)
|
||||
|
||||
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
|
||||
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
|
||||
body = f"""
|
||||
|
||||
Reference in New Issue
Block a user