Spaces:
Sleeping
Sleeping
| """APScheduler — loads jobs from DB and fires notifications.""" | |
| import logging | |
| from datetime import date | |
| from apscheduler.schedulers.background import BackgroundScheduler | |
| from apscheduler.triggers.cron import CronTrigger | |
| from apscheduler.triggers.interval import IntervalTrigger | |
| from db import queries | |
| from vital_types.db import ScheduledJob | |
| from core.app_config import is_demo_mode | |
| from core.notifications import send_notification | |
| from core.tts import speak | |
| from core.weekly_startup import ensure_weekly_report | |
| logger = logging.getLogger(__name__) | |
| _scheduler: BackgroundScheduler | None = None | |
| _WEEKLY_REPORT_JOB_ID = "vital_weekly_report" | |
| _WEEKDAY_BY_NAME = { | |
| "monday": 0, | |
| "tuesday": 1, | |
| "wednesday": 2, | |
| "thursday": 3, | |
| "friday": 4, | |
| "saturday": 5, | |
| "sunday": 6, | |
| } | |
| def fire_notification(job_id: str) -> None: | |
| """Deliver one scheduled notification and optional TTS for a job id.""" | |
| if is_demo_mode(): | |
| logger.info("[scheduler] Demo mode — skipped job %s", job_id) | |
| return | |
| jobs = queries.get_combined_scheduler_jobs(date.today()) | |
| job = next((item for item in jobs if item.job_id == job_id), None) | |
| if job is None: | |
| logger.warning("[scheduler] Job not found: %s", job_id) | |
| return | |
| logger.info( | |
| "[scheduler] Firing %s (%s) at %s — %s", | |
| job.job_id, | |
| job.type, | |
| job.time or job.interval_minutes, | |
| job.message[:80], | |
| ) | |
| result = send_notification("Vitál", job.message, job_id=job.job_id) | |
| tts_spoken = False | |
| tts_skip_reason: str | None = "disabled_in_settings" | |
| if job.tts: | |
| speak_result = speak(job.message) | |
| tts_spoken = speak_result.spoken | |
| tts_skip_reason = speak_result.skipped_reason | |
| if result.delivered: | |
| if tts_spoken: | |
| logger.info("[scheduler] Delivered %s (tts spoken)", job.job_id) | |
| else: | |
| logger.info( | |
| "[scheduler] Delivered %s (tts_enabled=%s, not spoken: %s)", | |
| job.job_id, | |
| job.tts, | |
| tts_skip_reason, | |
| ) | |
| else: | |
| logger.info( | |
| "[scheduler] Notification skipped for %s: %s", | |
| job.job_id, | |
| result.skipped_reason, | |
| ) | |
| def _register_job(scheduler: BackgroundScheduler, job: ScheduledJob) -> None: | |
| """Register one scheduled job with APScheduler.""" | |
| if job.schedule_type == "interval_minutes": | |
| if job.interval_minutes is None or job.interval_minutes < 1: | |
| logger.warning("[scheduler] Skipping %s — invalid interval.", job.job_id) | |
| return | |
| scheduler.add_job( | |
| fire_notification, | |
| trigger=IntervalTrigger(minutes=job.interval_minutes), | |
| args=[job.job_id], | |
| id=job.job_id, | |
| replace_existing=True, | |
| ) | |
| logger.info( | |
| "[scheduler] Registered %s every %s min (%s)", | |
| job.job_id, | |
| job.interval_minutes, | |
| job.type, | |
| ) | |
| return | |
| if not job.time or ":" not in job.time: | |
| logger.warning("[scheduler] Skipping %s — missing time.", job.job_id) | |
| return | |
| hour_text, minute_text = job.time.split(":", 1) | |
| scheduler.add_job( | |
| fire_notification, | |
| trigger=CronTrigger(hour=int(hour_text), minute=int(minute_text)), | |
| args=[job.job_id], | |
| id=job.job_id, | |
| replace_existing=True, | |
| ) | |
| logger.info( | |
| "[scheduler] Registered %s at %s daily (%s)", | |
| job.job_id, | |
| job.time, | |
| job.type, | |
| ) | |
| def _register_weekly_report_job(scheduler: BackgroundScheduler) -> None: | |
| """Register the Sunday weekly-report generation cron from onboarding settings.""" | |
| structure = queries.get_weekly_check_structure() or {} | |
| report_day = str(structure.get("report_day", "Sunday")) | |
| report_time = str(structure.get("report_time", "20:00")) | |
| day_of_week = _WEEKDAY_BY_NAME.get(report_day.strip().lower(), 6) | |
| if ":" not in report_time: | |
| logger.warning("[scheduler] Invalid weekly report time %r — skipping.", report_time) | |
| return | |
| hour_text, minute_text = report_time.split(":", 1) | |
| scheduler.add_job( | |
| ensure_weekly_report, | |
| trigger=CronTrigger( | |
| day_of_week=day_of_week, | |
| hour=int(hour_text), | |
| minute=int(minute_text), | |
| ), | |
| id=_WEEKLY_REPORT_JOB_ID, | |
| replace_existing=True, | |
| ) | |
| logger.info( | |
| "[scheduler] Registered weekly report on %s at %s.", | |
| report_day, | |
| report_time, | |
| ) | |
| def load_jobs_from_db(scheduler: BackgroundScheduler) -> int: | |
| """Reload active scheduled jobs from the database.""" | |
| job_ids = {job.id for job in scheduler.get_jobs() if job.id is not None} | |
| jobs = queries.get_combined_scheduler_jobs(date.today()) | |
| active_ids = {job.job_id for job in jobs} | |
| active_ids.add(_WEEKLY_REPORT_JOB_ID) | |
| for stale_id in job_ids - active_ids: | |
| scheduler.remove_job(stale_id) | |
| logger.info("[scheduler] Removed stale job %s", stale_id) | |
| for job in jobs: | |
| _register_job(scheduler, job) | |
| _register_weekly_report_job(scheduler) | |
| return len(jobs) | |
| def start_scheduler() -> BackgroundScheduler | None: | |
| """Start the background scheduler and load jobs from the database.""" | |
| global _scheduler | |
| if is_demo_mode(): | |
| logger.info("[scheduler] Disabled in demo mode.") | |
| return None | |
| if _scheduler is not None and _scheduler.running: | |
| count = load_jobs_from_db(_scheduler) | |
| logger.info("[scheduler] Reloaded %s active jobs.", count) | |
| return _scheduler | |
| _scheduler = BackgroundScheduler() | |
| count = load_jobs_from_db(_scheduler) | |
| _scheduler.start() | |
| logger.info("[scheduler] Started with %s active jobs.", count) | |
| return _scheduler | |
| def stop_scheduler() -> None: | |
| """Shut down the background scheduler.""" | |
| global _scheduler | |
| if _scheduler is not None and _scheduler.running: | |
| _scheduler.shutdown(wait=False) | |
| _scheduler = None | |
| def reload_scheduler_jobs() -> int: | |
| """Reload jobs from DB into the running scheduler.""" | |
| if _scheduler is None or not _scheduler.running: | |
| return 0 | |
| return load_jobs_from_db(_scheduler) | |