"""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