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