IT-Stock/app/scheduler.py

129 lines
5 KiB
Python
Raw Normal View History

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