2026-07-14 17:28:39 +00:00
|
|
|
"""
|
|
|
|
|
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
|
2026-07-14 17:37:36 +00:00
|
|
|
(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.
|
2026-07-14 17:28:39 +00:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-07-14 17:37:36 +00:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
2026-07-14 17:28:39 +00:00
|
|
|
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
|
2026-07-14 17:37:36 +00:00
|
|
|
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"),
|
|
|
|
|
]
|
2026-07-14 17:28:39 +00:00
|
|
|
|
|
|
|
|
_scheduler = BackgroundScheduler()
|
2026-07-14 17:37:36 +00:00
|
|
|
_JOB_ID = "resume_stock"
|
2026-07-14 17:28:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 17:37:36 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 17:28:39 +00:00
|
|
|
def demarrer_planification() -> None:
|
2026-07-14 17:37:36 +00:00
|
|
|
"""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)
|
2026-07-14 17:28:39 +00:00
|
|
|
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)
|
2026-07-14 17:37:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|