""" Planification de l'email récapitulatif de stock (voir email_alerts.envoyer_resume_stock). Choix : un planificateur qui tourne dans le process FastAPI lui-même (APScheduler), configuré par une expression cron classique stockée en base (ParametrePlanification, modifiable depuis /destinataires) plutôt que dans .env : ça permet à l'utilisateur de changer l'horaire depuis l'interface, sans éditer de fichier ni redémarrer le serveur. Rien à configurer côté système d'exploitation. Piège à connaître : le champ "jour de la semaine" d'APScheduler NE SUIT PAS la convention cron Unix (0=dimanche). Chez APScheduler, un jour numérique suit datetime.weekday() (0=lundi), donc "0 9 * * 1" avec le chiffre 1 tombe un MARDI, pas un lundi ! Pour éviter toute ambiguïté, ce module n'utilise jamais de jour numérique : uniquement les noms (mon/tue/wed/thu/fri/sat/sun), y compris dans les exemples proposés à l'utilisateur. Vérifié empiriquement avec la version d'APScheduler installée ici avant d'écrire ce commentaire. Limite à connaître : la tâche ne se déclenche que si le process est en cours d'exécution au moment prévu, sans rattrapage si le serveur était arrêté à ce moment-là. Pour un outil interne redémarré rarement, c'est un compromis raisonnable face à la complexité d'un vrai cron système. Si ça devient un problème, scripts/envoyer_resume_stock.py peut être branché sur un cron/Planificateur de tâches Windows à la place. """ from datetime import datetime from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from sqlmodel import Session from app.database import engine from app.email_alerts import envoyer_resume_stock from app.models import ParametrePlanification EXPRESSION_PAR_DEFAUT = "0 9 * * mon" # tous les lundis à 9h # Proposés dans le formulaire de /destinataires comme raccourcis courants ; # l'utilisateur peut aussi saisir n'importe quelle expression cron valide. EXEMPLES_CRON: list[tuple[str, str]] = [ ("0 9 * * mon", "Tous les lundis à 9h"), ("0 8 * * *", "Tous les jours à 8h"), ("0 8 * * mon-fri", "Les jours ouvrés à 8h"), ("0 */6 * * *", "Toutes les 6 heures"), ("0 8 1 * *", "Le 1er de chaque mois à 8h"), ] _scheduler = BackgroundScheduler() _JOB_ID = "resume_stock" def _tache_resume_stock() -> None: """Ouvre sa propre session DB : cette fonction est appelée par le thread du planificateur, en dehors du cycle requête/réponse FastAPI où la session habituelle (get_session) n'existe pas.""" with Session(engine) as session: envoyer_resume_stock(session) def obtenir_expression_cron(session: Session) -> str: """Lit l'expression cron actuelle depuis la base, en créant la ligne de réglage avec la valeur par défaut si c'est le tout premier appel.""" parametre = session.get(ParametrePlanification, 1) if parametre is None: parametre = ParametrePlanification(id=1, expression_cron=EXPRESSION_PAR_DEFAUT) session.add(parametre) session.commit() session.refresh(parametre) return parametre.expression_cron def valider_expression_cron(expression: str) -> bool: """Vérifie qu'une expression cron (minute heure jour mois jour_semaine) est syntaxiquement valide, sans l'appliquer.""" try: CronTrigger.from_crontab(expression) except ValueError: return False return True def definir_expression_cron(session: Session, expression: str) -> None: """Enregistre une nouvelle expression cron et reprogramme immédiatement la tâche planifiée, sans redémarrer le serveur. L'appelant doit avoir validé l'expression au préalable avec valider_expression_cron() : ici on suppose qu'elle est valide. """ parametre = session.get(ParametrePlanification, 1) if parametre is None: parametre = ParametrePlanification(id=1, expression_cron=expression) else: parametre.expression_cron = expression session.add(parametre) session.commit() _programmer(expression) def _programmer(expression: str) -> None: trigger = CronTrigger.from_crontab(expression) _scheduler.add_job(_tache_resume_stock, trigger, id=_JOB_ID, replace_existing=True) def demarrer_planification() -> None: """Démarre le planificateur au lancement de l'app, avec l'expression actuellement enregistrée en base (ou la valeur par défaut au tout premier démarrage).""" with Session(engine) as session: expression = obtenir_expression_cron(session) _programmer(expression) if not _scheduler.running: _scheduler.start() def arreter_planification() -> None: """Arrête proprement le planificateur (appelé à l'extinction de l'app).""" if _scheduler.running: _scheduler.shutdown(wait=False) def prochaine_execution() -> datetime | None: """Prochaine date d'exécution de la tâche planifiée, pour l'affichage dans l'interface. None si le planificateur n'est pas démarré.""" job = _scheduler.get_job(_JOB_ID) return job.next_run_time if job else None