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:
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
|
|||||||
# Redis
|
# Redis
|
||||||
redis_url: str = "redis://localhost:6379/0"
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# Scheduler (Erinnerungs-Mails, agent-11 PR3)
|
||||||
|
scheduler_enabled: bool = True
|
||||||
|
reminder_hour: int = 7 # Server-Stunde für tägliche Erinnerungen
|
||||||
|
carryover_reminder_days: int = 14 # Vorlauf für Resturlaub-Verfall-Erinnerung
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
access_token_expire_minutes: int = 30
|
access_token_expire_minutes: int = 30
|
||||||
refresh_token_expire_days: int = 30
|
refresh_token_expire_days: int = 30
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Pro-User-Benachrichtigungseinstellungen (agent-11 PR3).
|
||||||
|
|
||||||
|
Bekannte Keys + Defaults. Ein leeres `notification_prefs`-dict bedeutet „alle an"
|
||||||
|
(opt-out-Modell). Neue Keys defaulten daher automatisch auf aktiv.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# key -> (Label, Default-aktiv)
|
||||||
|
NOTIFICATION_TYPES: dict[str, tuple[str, bool]] = {
|
||||||
|
"substitute_assigned": ("Als Vertretung eingetragen", True),
|
||||||
|
"pending_approvals": ("Offene Anträge zur Genehmigung (Zusammenfassung)", True),
|
||||||
|
"carryover_expiry": ("Resturlaub verfällt bald", True),
|
||||||
|
"certificate_overdue": ("Fehlende AU-Bescheinigung (HR)", True),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pref_enabled(user, key: str) -> bool:
|
||||||
|
"""Ob der User Benachrichtigungen vom Typ `key` erhalten möchte."""
|
||||||
|
prefs = getattr(user, "notification_prefs", None) or {}
|
||||||
|
default = NOTIFICATION_TYPES.get(key, ("", True))[1]
|
||||||
|
return bool(prefs.get(key, default))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_prefs(prefs: dict | None) -> dict:
|
||||||
|
"""Nur bekannte Keys übernehmen, auf bool casten."""
|
||||||
|
prefs = prefs or {}
|
||||||
|
return {k: bool(prefs[k]) for k in NOTIFICATION_TYPES if k in prefs}
|
||||||
@@ -41,8 +41,17 @@ async def lifespan(app: FastAPI):
|
|||||||
"In Production sollte ALLOWED_HOSTS in .env konfiguriert sein "
|
"In Production sollte ALLOWED_HOSTS in .env konfiguriert sein "
|
||||||
"um Host-Header-Injection zu verhindern."
|
"um Host-Header-Injection zu verhindern."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Erinnerungs-Scheduler (agent-11 PR3) – nicht im Test-Kontext starten
|
||||||
|
import sys
|
||||||
|
if settings.scheduler_enabled and "pytest" not in sys.modules:
|
||||||
|
from app.services.scheduler_service import start as start_scheduler
|
||||||
|
start_scheduler()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# Shutdown
|
# Shutdown
|
||||||
|
from app.services.scheduler_service import shutdown as shutdown_scheduler
|
||||||
|
shutdown_scheduler()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime, date
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, String, Text, func
|
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, String, Text, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -54,6 +54,9 @@ class User(Base):
|
|||||||
entry_date: Mapped[date | None] = mapped_column(Date)
|
entry_date: Mapped[date | None] = mapped_column(Date)
|
||||||
exit_date: Mapped[date | None] = mapped_column(Date)
|
exit_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
|
||||||
|
# Pro-User-Benachrichtigungseinstellungen (agent-11 PR3). Leeres dict = alle an.
|
||||||
|
notification_prefs: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||||
|
|
||||||
# Kiosk auth
|
# Kiosk auth
|
||||||
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
|
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
|
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||||
|
|||||||
@@ -426,28 +426,31 @@ async def update_balance(
|
|||||||
balance = await absence_service.get_balance(user_id, year, db)
|
balance = await absence_service.get_balance(user_id, year, db)
|
||||||
|
|
||||||
# Alte Werte für AuditLog sichern
|
# Alte Werte für AuditLog sichern
|
||||||
old_base = balance.base_days
|
old_value = {
|
||||||
old_special = balance.special_days
|
"entitled_days": balance.entitled_days,
|
||||||
old_carried = balance.carried_over_days
|
"special_days": balance.special_days,
|
||||||
|
"carried_over": balance.carried_over,
|
||||||
|
}
|
||||||
|
|
||||||
for field, value in data.model_dump(exclude_unset=True).items():
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
setattr(balance, field, value)
|
setattr(balance, field, value)
|
||||||
|
|
||||||
# AuditLog schreiben
|
# AuditLog schreiben
|
||||||
db.add(AuditLog(
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
action="update_vacation_balance",
|
action="update_vacation_balance",
|
||||||
entity_type="vacation_balance",
|
entity_type="vacation_balance",
|
||||||
entity_id=balance.id,
|
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={
|
new_value={
|
||||||
"base_days": balance.base_days,
|
"entitled_days": balance.entitled_days,
|
||||||
"special_days": balance.special_days,
|
"special_days": balance.special_days,
|
||||||
"carried_over_days": balance.carried_over_days,
|
"carried_over": balance.carried_over,
|
||||||
"target_user_id": str(user_id),
|
"target_user_id": str(user_id),
|
||||||
"year": year,
|
"year": year,
|
||||||
},
|
},
|
||||||
ip_address=get_client_ip(request),
|
ip=get_client_ip(request),
|
||||||
))
|
))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -456,6 +459,7 @@ async def update_balance(
|
|||||||
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
||||||
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
||||||
"pending_days": pending,
|
"pending_days": pending,
|
||||||
|
"available_days": absence_service.effective_available(balance, expired),
|
||||||
"carried_over_expires_at": expires_at,
|
"carried_over_expires_at": expires_at,
|
||||||
"carried_over_expired": expired,
|
"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)
|
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)
|
@router.patch("/me", response_model=CompanyOut)
|
||||||
async def update_my_company(
|
async def update_my_company(
|
||||||
data: CompanyUpdate,
|
data: CompanyUpdate,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from app.schemas.auth import MessageResponse
|
|||||||
from app.schemas.user import (
|
from app.schemas.user import (
|
||||||
InviteRequest,
|
InviteRequest,
|
||||||
NextPersonnelNumberResponse,
|
NextPersonnelNumberResponse,
|
||||||
|
NotificationPrefsUpdate,
|
||||||
|
NotificationTypeOut,
|
||||||
SetKioskPinRequest,
|
SetKioskPinRequest,
|
||||||
UserImportResult,
|
UserImportResult,
|
||||||
UserImportRowResult,
|
UserImportRowResult,
|
||||||
@@ -79,6 +81,32 @@ async def get_me(current_user: CurrentUser):
|
|||||||
return UserOut.model_validate(current_user)
|
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)
|
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
|
||||||
async def next_personnel_number(
|
async def next_personnel_number(
|
||||||
current_user: User = require_role(*_hr_roles),
|
current_user: User = require_role(*_hr_roles),
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ class UserOut(BaseModel):
|
|||||||
exit_date: date | None = None
|
exit_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTypeOut(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPrefsUpdate(BaseModel):
|
||||||
|
prefs: dict[str, bool]
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
first_name: str | None = Field(None, min_length=1, max_length=100)
|
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
last_name: str | None = Field(None, min_length=1, max_length=100)
|
last_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
|||||||
@@ -1073,6 +1073,9 @@ class AbsenceService:
|
|||||||
requester = await db.get(User, absence.user_id)
|
requester = await db.get(User, absence.user_id)
|
||||||
if substitute is None or requester is None or not substitute.email:
|
if substitute is None or requester is None or not substitute.email:
|
||||||
return
|
return
|
||||||
|
from app.core.notifications import pref_enabled
|
||||||
|
if not pref_enabled(substitute, "substitute_assigned"):
|
||||||
|
return
|
||||||
from app.services.email_service import email_service
|
from app.services.email_service import email_service
|
||||||
try:
|
try:
|
||||||
await email_service.send_substitute_notification(substitute, requester, absence, db)
|
await email_service.send_substitute_notification(substitute, requester, absence, db)
|
||||||
|
|||||||
@@ -163,6 +163,65 @@ class EmailService:
|
|||||||
cfg,
|
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:
|
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
|
||||||
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
|
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
|
||||||
body = f"""
|
body = f"""
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Geplante Erinnerungs-Jobs (agent-11 PR3).
|
||||||
|
|
||||||
|
AsyncIOScheduler läuft im FastAPI-Event-Loop. Jeder Job öffnet eine eigene
|
||||||
|
DB-Session mit RLS-Bypass (interner Job ohne Tenant-Kontext) und holt vor dem
|
||||||
|
Versand einen Redis-Tageslock, damit bei mehreren Prozessen nicht doppelt
|
||||||
|
gemailt wird. Die run_*-Funktionen sind ohne Scheduler aufrufbar (Tests +
|
||||||
|
manueller Trigger über die API) und können auf eine Firma eingegrenzt werden.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.notifications import pref_enabled
|
||||||
|
from app.models.absence import Absence, AbsenceStatus
|
||||||
|
from app.models.absence_type import AbsenceCategory, AbsenceType
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.vacation_balance import VacationBalance
|
||||||
|
from app.services.absence_service import absence_service
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_APPROVER_ROLES = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN)
|
||||||
|
_HR_ROLES = (UserRole.HR, UserRole.COMPANY_ADMIN)
|
||||||
|
|
||||||
|
_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(d: date) -> str:
|
||||||
|
return d.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
|
async def _active_companies(db: AsyncSession, company_id) -> list[Company]:
|
||||||
|
q = select(Company).where(Company.is_active.is_(True))
|
||||||
|
if company_id:
|
||||||
|
q = q.where(Company.id == company_id)
|
||||||
|
return list((await db.scalars(q)).all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _recipients(db: AsyncSession, company_id, roles) -> list[User]:
|
||||||
|
return list((await db.scalars(
|
||||||
|
select(User).where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.role.in_(roles),
|
||||||
|
)
|
||||||
|
)).all())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Job-Logik ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_pending_approvals(db: AsyncSession, company_id=None) -> int:
|
||||||
|
"""Zusammenfassung offener Anträge an alle Genehmiger je Firma. Liefert # Mails."""
|
||||||
|
sent = 0
|
||||||
|
for company in await _active_companies(db, company_id):
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Absence, User, AbsenceType)
|
||||||
|
.join(User, Absence.user_id == User.id)
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(User.company_id == company.id, Absence.status == AbsenceStatus.PENDING)
|
||||||
|
.order_by(Absence.start_date)
|
||||||
|
)).all()
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
items = [{
|
||||||
|
"user_name": u.full_name, "type_name": t.name,
|
||||||
|
"start": _fmt(a.start_date), "end": _fmt(a.end_date),
|
||||||
|
"working_days": float(a.working_days),
|
||||||
|
} for a, u, t in rows]
|
||||||
|
|
||||||
|
for approver in await _recipients(db, company.id, _APPROVER_ROLES):
|
||||||
|
if not approver.email or not pref_enabled(approver, "pending_approvals"):
|
||||||
|
continue
|
||||||
|
await email_service.send_pending_approvals_digest(approver, items, db)
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_carryover_expiry(db: AsyncSession, company_id=None) -> int:
|
||||||
|
"""Erinnert Mitarbeiter an bald verfallenden Resturlaub (Vorlauf konfigurierbar)."""
|
||||||
|
today = date.today()
|
||||||
|
year = today.year
|
||||||
|
sent = 0
|
||||||
|
for company in await _active_companies(db, company_id):
|
||||||
|
expires_at, expired = absence_service._carryover_expired(company, year)
|
||||||
|
if expires_at is None or expired:
|
||||||
|
continue
|
||||||
|
if (expires_at - today).days > settings.carryover_reminder_days:
|
||||||
|
continue
|
||||||
|
balances = (await db.execute(
|
||||||
|
select(VacationBalance, User)
|
||||||
|
.join(User, VacationBalance.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
User.company_id == company.id, User.is_active.is_(True),
|
||||||
|
VacationBalance.year == year, VacationBalance.carried_over > 0,
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
for bal, user in balances:
|
||||||
|
expiring = max(0, bal.carried_over - bal.used_days) # Übertrag zuerst verbraucht
|
||||||
|
if expiring <= 0 or not user.email or not pref_enabled(user, "carryover_expiry"):
|
||||||
|
continue
|
||||||
|
await email_service.send_carryover_expiry_reminder(user, expiring, expires_at, db)
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_certificate_overdue(db: AsyncSession, company_id=None) -> int:
|
||||||
|
"""Meldet HR fehlende AU-Bescheinigungen (knüpft an agent-05)."""
|
||||||
|
today = date.today()
|
||||||
|
sent = 0
|
||||||
|
for company in await _active_companies(db, company_id):
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Absence, User)
|
||||||
|
.join(User, Absence.user_id == User.id)
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(
|
||||||
|
User.company_id == company.id,
|
||||||
|
AbsenceType.category == AbsenceCategory.SICK,
|
||||||
|
Absence.certificate_required_by.isnot(None),
|
||||||
|
Absence.certificate_required_by < today,
|
||||||
|
Absence.certificate_received_at.is_(None),
|
||||||
|
Absence.status == AbsenceStatus.APPROVED,
|
||||||
|
)
|
||||||
|
.order_by(Absence.start_date)
|
||||||
|
)).all()
|
||||||
|
if not rows:
|
||||||
|
continue
|
||||||
|
items = [{
|
||||||
|
"user_name": u.full_name, "start": _fmt(a.start_date),
|
||||||
|
"due": _fmt(a.certificate_required_by),
|
||||||
|
} for a, u in rows]
|
||||||
|
|
||||||
|
for hr in await _recipients(db, company.id, _HR_ROLES):
|
||||||
|
if not hr.email or not pref_enabled(hr, "certificate_overdue"):
|
||||||
|
continue
|
||||||
|
await email_service.send_certificate_overdue_digest(hr, items, db)
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_all_reminders(db: AsyncSession, company_id=None) -> dict:
|
||||||
|
return {
|
||||||
|
"pending_approvals": await run_pending_approvals(db, company_id),
|
||||||
|
"carryover_expiry": await run_carryover_expiry(db, company_id),
|
||||||
|
"certificate_overdue": await run_certificate_overdue(db, company_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scheduler-Verdrahtung ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _acquire_daily_lock(name: str) -> bool:
|
||||||
|
"""True, wenn dieser Prozess den heutigen Job ausführen darf (Redis SET NX).
|
||||||
|
Ohne Redis: immer True (Single-Prozess-Annahme)."""
|
||||||
|
from app.core.redis import get_redis_client
|
||||||
|
redis = get_redis_client()
|
||||||
|
if redis is None:
|
||||||
|
return True
|
||||||
|
key = f"reminder_lock:{name}:{date.today().isoformat()}"
|
||||||
|
try:
|
||||||
|
return bool(redis.set(key, "1", nx=True, ex=23 * 3600))
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Reminder-Lock fehlgeschlagen (%s) – führe trotzdem aus: %s", name, exc)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_job(name: str, fn) -> None:
|
||||||
|
if not _acquire_daily_lock(name):
|
||||||
|
log.info("Reminder-Job %s bereits von anderem Prozess übernommen.", name)
|
||||||
|
return
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.core.database import AsyncSessionLocal
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
|
||||||
|
count = await fn(db)
|
||||||
|
await db.commit()
|
||||||
|
log.info("Reminder-Job %s: %s Mail(s) versendet.", name, count)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Reminder-Job %s fehlgeschlagen: %s", name, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def start() -> None:
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("APScheduler nicht verfügbar – Erinnerungen deaktiviert: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
|
_scheduler = AsyncIOScheduler()
|
||||||
|
hour = settings.reminder_hour
|
||||||
|
_scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=0),
|
||||||
|
args=["pending_approvals", run_pending_approvals], id="pending_approvals")
|
||||||
|
_scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=10),
|
||||||
|
args=["carryover_expiry", run_carryover_expiry], id="carryover_expiry")
|
||||||
|
_scheduler.add_job(_run_job, CronTrigger(hour=hour, minute=20),
|
||||||
|
args=["certificate_overdue", run_certificate_overdue], id="certificate_overdue")
|
||||||
|
_scheduler.start()
|
||||||
|
log.info("Reminder-Scheduler gestartet (täglich ab %02d:00 Uhr).", hour)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown() -> None:
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
_scheduler = None
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""User notification preferences (agent-11 PR3)
|
||||||
|
|
||||||
|
Revision ID: 0037
|
||||||
|
Revises: 0036
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
|
||||||
|
users.notification_prefs JSONB (opt-out je Benachrichtigungstyp; '{}' = alle an).
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0037"
|
||||||
|
down_revision = "0036"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS notification_prefs JSONB NOT NULL DEFAULT '{}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS notification_prefs")
|
||||||
@@ -26,3 +26,4 @@ aiosqlite>=0.20.0
|
|||||||
weasyprint>=61.0
|
weasyprint>=61.0
|
||||||
typer>=0.12.0
|
typer>=0.12.0
|
||||||
rich>=13.7.0
|
rich>=13.7.0
|
||||||
|
apscheduler>=3.10.0
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Tests für agent-11 PR3: geplante Erinnerungen + Notification-Prefs."""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session", loop_scope="session")
|
||||||
|
async def rem_company(client: AsyncClient):
|
||||||
|
resp = await client.post("/api/v1/auth/register", json={
|
||||||
|
"company_name": "Reminder AG", "first_name": "Rita", "last_name": "Admin",
|
||||||
|
"email": "admin@reminderag.de", "password": "Secret123",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
admin_h = {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||||
|
|
||||||
|
inv = await client.post("/api/v1/users/invite", json={
|
||||||
|
"first_name": "Emil", "last_name": "Employee", "email": "emil@reminderag.de",
|
||||||
|
"role": "EMPLOYEE", "initial_password": "Secret123",
|
||||||
|
}, headers=admin_h)
|
||||||
|
assert inv.status_code == 201, inv.text
|
||||||
|
login = await client.post("/api/v1/auth/login", json={
|
||||||
|
"email": "emil@reminderag.de", "password": "Secret123",
|
||||||
|
})
|
||||||
|
emp_h = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||||
|
return {"admin": admin_h, "emp": emp_h, "emp_id": inv.json()["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _vacation_type(client, headers):
|
||||||
|
types = (await client.get("/api/v1/absence-types/", headers=headers)).json()
|
||||||
|
return next(t for t in types if t["name"] == "Urlaub")["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_notification_prefs_list(client: AsyncClient, rem_company):
|
||||||
|
r = await client.get("/api/v1/users/me/notification-prefs", headers=rem_company["admin"])
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
keys = {p["key"] for p in r.json()}
|
||||||
|
assert "pending_approvals" in keys and "carryover_expiry" in keys
|
||||||
|
assert all(p["enabled"] for p in r.json()) # Default: alle an
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_run_reminders_pending_approvals(client: AsyncClient, rem_company):
|
||||||
|
vt = await _vacation_type(client, rem_company["emp"])
|
||||||
|
start = date.today() + timedelta(days=(7 - date.today().weekday()) + 70)
|
||||||
|
await client.post("/api/v1/absences/", json={
|
||||||
|
"type_id": vt, "start_date": str(start), "end_date": str(start + timedelta(days=2)),
|
||||||
|
}, headers=rem_company["emp"])
|
||||||
|
|
||||||
|
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||||
|
assert run.status_code == 200, run.text
|
||||||
|
assert run.json()["sent"]["pending_approvals"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_run_reminders_certificate_overdue(client: AsyncClient, rem_company):
|
||||||
|
# Krankmeldung in der Vergangenheit → AU längst fällig, nicht eingegangen
|
||||||
|
past = date.today() - timedelta(days=10)
|
||||||
|
sick = await client.post("/api/v1/absences/quick-sick", json={
|
||||||
|
"start_date": str(past), "end_date": str(past),
|
||||||
|
}, headers=rem_company["emp"])
|
||||||
|
assert sick.status_code == 201, sick.text
|
||||||
|
|
||||||
|
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||||
|
assert run.json()["sent"]["certificate_overdue"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_notification_prefs_disable_suppresses_mail(client: AsyncClient, rem_company):
|
||||||
|
# pending_approvals für den Admin abschalten → keine Digest-Mail mehr
|
||||||
|
patch = await client.patch("/api/v1/users/me/notification-prefs",
|
||||||
|
json={"prefs": {"pending_approvals": False}}, headers=rem_company["admin"])
|
||||||
|
assert patch.status_code == 200
|
||||||
|
assert any(p["key"] == "pending_approvals" and p["enabled"] is False for p in patch.json())
|
||||||
|
|
||||||
|
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||||
|
assert run.json()["sent"]["pending_approvals"] == 0
|
||||||
|
|
||||||
|
# wieder aktivieren (sauberer Zustand)
|
||||||
|
await client.patch("/api/v1/users/me/notification-prefs",
|
||||||
|
json={"prefs": {"pending_approvals": True}}, headers=rem_company["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="session")
|
||||||
|
async def test_run_reminders_carryover_expiry(client: AsyncClient, rem_company):
|
||||||
|
# Verfall auf ~7 Tage in der Zukunft konfigurieren
|
||||||
|
target = date.today() + timedelta(days=7)
|
||||||
|
await client.patch("/api/v1/companies/me", json={
|
||||||
|
"settings": {"carryover_expires_month": target.month, "carryover_expires_day": target.day},
|
||||||
|
}, headers=rem_company["admin"])
|
||||||
|
# Resturlaub fürs aktuelle Jahr beim Mitarbeiter setzen
|
||||||
|
yr = date.today().year
|
||||||
|
await client.patch(f"/api/v1/absences/balance/{rem_company['emp_id']}?year={yr}",
|
||||||
|
json={"carried_over": 5}, headers=rem_company["admin"])
|
||||||
|
|
||||||
|
run = await client.post("/api/v1/companies/me/run-reminders", headers=rem_company["admin"])
|
||||||
|
assert run.json()["sent"]["carryover_expiry"] >= 1
|
||||||
@@ -185,6 +185,8 @@ export function ProfilePage() {
|
|||||||
const [pinSuccess, setPinSuccess] = useState(false)
|
const [pinSuccess, setPinSuccess] = useState(false)
|
||||||
const [pinError, setPinError] = useState<string | null>(null)
|
const [pinError, setPinError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [notifPrefs, setNotifPrefs] = useState<{ key: string; label: string; enabled: boolean }[]>([])
|
||||||
|
|
||||||
const loadMe = () => {
|
const loadMe = () => {
|
||||||
api.get<UserOut>('/auth/me').then(u => {
|
api.get<UserOut>('/auth/me').then(u => {
|
||||||
setMe(u)
|
setMe(u)
|
||||||
@@ -192,7 +194,21 @@ export function ProfilePage() {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { loadMe() }, [])
|
const loadNotifPrefs = () => {
|
||||||
|
api.get<{ key: string; label: string; enabled: boolean }[]>('/users/me/notification-prefs')
|
||||||
|
.then(setNotifPrefs).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleNotif(key: string, enabled: boolean) {
|
||||||
|
setNotifPrefs(prev => prev.map(p => p.key === key ? { ...p, enabled } : p))
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<{ key: string; label: string; enabled: boolean }[]>(
|
||||||
|
'/users/me/notification-prefs', { prefs: { [key]: enabled } })
|
||||||
|
setNotifPrefs(updated)
|
||||||
|
} catch { loadNotifPrefs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadMe(); loadNotifPrefs() }, [])
|
||||||
|
|
||||||
async function changePassword(e: React.FormEvent) {
|
async function changePassword(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -328,6 +344,24 @@ export function ProfilePage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Benachrichtigungen */}
|
||||||
|
{notifPrefs.length > 0 && (
|
||||||
|
<div className='bg-white rounded-xl shadow-sm border border-gray-200 p-6'>
|
||||||
|
<h2 className='font-semibold text-gray-700 mb-1'>E-Mail-Benachrichtigungen</h2>
|
||||||
|
<p className='text-xs text-gray-400 mb-4'>Lege fest, worüber du per E-Mail informiert werden möchtest.</p>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{notifPrefs.map(p => (
|
||||||
|
<label key={p.key} className='flex items-center justify-between gap-3 cursor-pointer'>
|
||||||
|
<span className='text-sm text-gray-700'>{p.label}</span>
|
||||||
|
<input type='checkbox' checked={p.enabled}
|
||||||
|
onChange={e => toggleNotif(p.key, e.target.checked)}
|
||||||
|
className='h-4 w-4 accent-blue-600' />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user