# Copyright (C) 2025 Raccoon Survey org
# This file is part of Raccoon Survey.
# Raccoon Survey is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License v3 as published by
# the Free Software Foundation.
# See the LICENSE file distributed with this program for details.
from __future__ import annotations
import atexit
import os
from apscheduler.schedulers.background import BackgroundScheduler
from flask import Flask
from src.core.services import surveys_service, tokens_service
[docs]
def create_scheduler(app: Flask) -> BackgroundScheduler:
"""Create BackgroundScheduler with registered jobs.
Args:
app (Flask): Flask application instance
Returns:
BackgroundScheduler: Configured scheduler instance
"""
scheduler = BackgroundScheduler(timezone="UTC")
def _run_cleanup():
# Ensure Flask application context for DB operations
with app.app_context():
try:
result = tokens_service.cleanup_expired_tokens(dry_run=False)
app.logger.info(f"cleanup_expired_tokens ran: {result}")
except Exception as e:
app.logger.exception("cleanup_expired_tokens failed", exc_info=e)
def _run_expire_surveys():
with app.app_context():
try:
result = surveys_service.deactivate_expired_surveys()
app.logger.info(f"deactivate_expired_surveys ran: {result}")
except Exception as e:
app.logger.exception("deactivate_expired_surveys failed", exc_info=e)
# Read schedule from config
try:
raw_hour = app.config.get("CLEANUP_CRON_HOUR", 3)
raw_minute = app.config.get("CLEANUP_CRON_MINUTE", 0)
hour = int(raw_hour)
minute = int(raw_minute)
except Exception:
app.logger.warning(
"Invalid CLEANUP_CRON_* values: hour=%r minute=%r; using defaults 3:00",
raw_hour, # pyright: ignore[reportPossiblyUnboundVariable]
raw_minute, # pyright: ignore[reportPossiblyUnboundVariable]
)
hour, minute = 3, 0
if hour < 0 or hour > 23:
app.logger.warning("CLEANUP_CRON_HOUR out of range (%s). Clamping to 3.", hour)
hour = 3
if minute < 0 or minute > 59:
app.logger.warning(
"CLEANUP_CRON_MINUTE out of range (%s). Clamping to 0.", minute
)
minute = 0
# Run daily at configured time
scheduler.add_job(
_run_cleanup,
trigger="cron",
hour=hour,
minute=minute,
id="cleanup_expired_tokens_daily",
replace_existing=True,
misfire_grace_time=3600,
)
scheduler.add_job(
_run_expire_surveys,
trigger="cron",
hour=hour,
minute=minute,
id="deactivate_expired_surveys_daily",
replace_existing=True,
misfire_grace_time=3600,
)
return scheduler
[docs]
def start_scheduler(app: Flask, debug: bool | None = None) -> None:
"""Start BackgroundScheduler safely, avoiding double-start under Flask reloader.
Args:
app (Flask): Flask application instance
debug (Optional[bool]): Debug flag to infer reloader behavior
"""
should_start = True
if debug:
# When reloader is enabled, only start in the subprocess where WERKZEUG_RUN_MAIN == "true"
should_start = os.environ.get("WERKZEUG_RUN_MAIN") == "true"
if not should_start:
return
scheduler = create_scheduler(app)
scheduler.start()
# Run cleanup immediately on startup (controlled by config)
run_on_start = bool(app.config.get("CLEANUP_RUN_ON_START", True))
if run_on_start:
with app.app_context():
try:
result = tokens_service.cleanup_expired_tokens(dry_run=False)
app.logger.info(f"cleanup_expired_tokens ran (startup): {result}")
except Exception as e:
app.logger.exception(
"cleanup_expired_tokens failed on startup", exc_info=e
)
try:
result2 = surveys_service.deactivate_expired_surveys()
app.logger.info(f"deactivate_expired_surveys ran (startup): {result2}")
except Exception as e:
app.logger.exception(
"deactivate_expired_surveys failed on startup", exc_info=e
)
# Gracefully shutdown scheduler on app exit
atexit.register(lambda: scheduler.shutdown(wait=False))