Files
timemaster/backend/app/services/scheduler_service.py
T
patrickandClaude Sonnet 5 5a9e432a80 feat(dsgvo): Löschkonzept/Aufbewahrungsfristen (Auto-Purge)
Neuer retention_service.py: Lohn-/zeitrelevante Daten (time_entries,
hours_payouts) werden nach konfigurierbarer Frist gelöscht
(company.settings.retention_lohn_years, Default 10 Jahre). Technische
Tabellen mit fester Frist: audit_logs (3 Jahre), abgelaufene
sessions/password_resets (sofort).

Täglicher Scheduler-Job (03:00 Uhr, Redis-Tageslock analog Reminder-Jobs)
plus manuelle Trigger: POST /companies/me/run-retention-purge
(COMPANY_ADMIN/HR, nur eigene Firma) und POST /admin/run-retention-purge
(SUPER_ADMIN, global inkl. technischer Tabellen).

Letzter offener Punkt aus dem DSGVO-Löschkonzept (Art. 15/17 bereits erledigt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gis16MnuwkYcivLrSxK1pD
2026-08-27 09:04:27 +02:00

238 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from app.services.retention_service import run_retention_purge
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.in_([AbsenceStatus.PENDING, AbsenceStatus.FIRST_APPROVED]),
)
.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)
async def _run_purge_job(name: str, fn) -> None:
if not _acquire_daily_lock(name):
log.info("Purge-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'"))
result = await fn(db)
await db.commit()
log.info("Purge-Job %s: %s", name, result)
except Exception as exc:
log.exception("Purge-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.add_job(_run_purge_job, CronTrigger(hour=3, minute=0),
args=["retention_purge", run_retention_purge], id="retention_purge")
_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