feat(notifications): täglicher Lauf über SMTP und Apprise
- Kanäle SMTP (HTML-Mail mit Logos als CID-Anhang) und Apprise, beide blockierenden Bibliotheken laufen in einem Thread - Vier Anlässe: Fälligkeiten im Vorlauf, Kündigungsfristen in drei Stufen, überschrittene Budgets je Monat, Vertragsverlängerungen im Folgemonat - APScheduler im Anwendungsprozess, täglich 07:00 Europe/Berlin, räumt zugleich abgelaufene Sitzungen auf - Duplikatsschutz über den Zieltag statt den Versandtag; fehlgeschlagener Versand wird beim nächsten Lauf erneut versucht - Endpunkte für Regeln, Protokoll, Testversand und sofortigen Lauf - Einstellungsseite mit Einrichtungsstand, Regelpflege und Protokoll - 25 neue Backend-Tests (260 gesamt), 9 neue Frontend-Tests (74 gesamt) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""Zeitgesteuerte Aufgaben.
|
||||
|
||||
APScheduler läuft im selben Prozess wie die Anwendung – kein Redis, kein Celery.
|
||||
Der tägliche Lauf prüft die Benachrichtigungsregeln und räumt abgelaufene
|
||||
Sitzungen auf.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.clock import tz
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.auth import purge_expired_refresh_tokens
|
||||
from app.services.notifications import run_all
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAILY_JOB_ID = "moneyfy-daily"
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
async def daily_job() -> None:
|
||||
"""Täglicher Lauf. Fehler werden protokolliert, beenden den Scheduler aber nicht."""
|
||||
try:
|
||||
async with SessionLocal() as session:
|
||||
await run_all(session)
|
||||
|
||||
entfernt = await purge_expired_refresh_tokens(session)
|
||||
await session.commit()
|
||||
if entfernt:
|
||||
logger.info("%d abgelaufene Sitzungen entfernt.", entfernt)
|
||||
except Exception:
|
||||
logger.exception("Der tägliche Lauf ist fehlgeschlagen.")
|
||||
|
||||
|
||||
def start_scheduler() -> AsyncIOScheduler | None:
|
||||
"""Startet den Scheduler, sofern er eingeschaltet ist."""
|
||||
global _scheduler
|
||||
|
||||
if not settings.scheduler_enabled:
|
||||
logger.info("Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false).")
|
||||
return None
|
||||
if _scheduler is not None:
|
||||
return _scheduler
|
||||
|
||||
_scheduler = AsyncIOScheduler(timezone=tz())
|
||||
_scheduler.add_job(
|
||||
daily_job,
|
||||
CronTrigger(
|
||||
hour=settings.notification_hour,
|
||||
minute=settings.notification_minute,
|
||||
timezone=tz(),
|
||||
),
|
||||
id=DAILY_JOB_ID,
|
||||
name="Benachrichtigungen und Aufräumen",
|
||||
# Verpasste Läufe (etwa nach einem Neustart) einmal nachholen.
|
||||
misfire_grace_time=3600,
|
||||
coalesce=True,
|
||||
max_instances=1,
|
||||
replace_existing=True,
|
||||
)
|
||||
_scheduler.start()
|
||||
|
||||
logger.info(
|
||||
"Scheduler gestartet – täglicher Lauf um %02d:%02d %s.",
|
||||
settings.notification_hour,
|
||||
settings.notification_minute,
|
||||
settings.timezone,
|
||||
)
|
||||
return _scheduler
|
||||
|
||||
|
||||
def shutdown_scheduler() -> None:
|
||||
"""Hält den Scheduler beim Beenden der Anwendung an."""
|
||||
global _scheduler
|
||||
|
||||
if _scheduler is None:
|
||||
return
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
logger.info("Scheduler beendet.")
|
||||
|
||||
|
||||
def next_run_time():
|
||||
"""Nächster geplanter Lauf, für die Anzeige in den Einstellungen."""
|
||||
if _scheduler is None:
|
||||
return None
|
||||
job = _scheduler.get_job(DAILY_JOB_ID)
|
||||
return job.next_run_time if job else None
|
||||
Reference in New Issue
Block a user