Files
moneyfyandClaude Opus 5 54c59c9f71 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
2026-09-09 17:04:30 +02:00

94 lines
2.7 KiB
Python
Raw Permalink 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.
"""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