From 11da7065734743a1d327f4c7f4c72955e2b6d671 Mon Sep 17 00:00:00 2001 From: maxsoch Date: Tue, 14 Jul 2026 19:37:36 +0200 Subject: [PATCH] =?UTF-8?q?Planification=20du=20r=C3=A9sum=C3=A9=20de=20st?= =?UTF-8?q?ock=20modifiable=20depuis=20l'interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'horaire de l'email récapitulatif se configure maintenant sur /destinataires (raccourcis courants + expression cron personnalisée) au lieu de .env, avec reprogrammation immédiate sans redémarrage. Stocké en base (ParametrePlanification) plutôt que dans un fichier de config. Défaut changé pour tous les lundis à 9h. Les exemples et le code n'utilisent que des noms de jours (mon, tue, ...) dans les expressions cron : vérifié empiriquement qu'APScheduler ne suit pas la convention Unix pour les jours numériques (0=lundi chez lui, pas 0=dimanche), ce qui rendrait "0 9 * * 1" trompeur. --- .env.example | 8 +-- CLAUDE.md | 25 ++++++--- README.md | 17 +++--- app/config.py | 5 -- app/models.py | 13 +++++ app/routers/destinataires.py | 67 +++++++++++++++++++++- app/scheduler.py | 96 +++++++++++++++++++++++++++++--- app/static/style.css | 13 +++++ app/templates/destinataires.html | 40 +++++++++++++ tests/test_planification.py | 55 ++++++++++++++++++ 10 files changed, 302 insertions(+), 37 deletions(-) create mode 100644 tests/test_planification.py diff --git a/.env.example b/.env.example index 255e79e..80f45d8 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,6 @@ SMTP_USER= SMTP_PASSWORD= SMTP_FROM=stock-it@clinique.local -# Expression cron (minute heure jour mois jour_semaine) pour l'email -# récapitulatif périodique de l'état du stock. Par défaut : tous les -# jours à 8h. Le planificateur tourne dans le process de l'app (voir -# app/scheduler.py) — pas de cron système à configurer. -RESUME_STOCK_CRON=0 8 * * * +# La planification de l'email récapitulatif de stock (tous les lundis à 9h +# par défaut) se configure depuis l'interface, sur /destinataires — pas +# besoin de variable d'environnement pour ça. Voir app/scheduler.py. diff --git a/CLAUDE.md b/CLAUDE.md index 91f0945..d842ddf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,10 +126,21 @@ Deux emails distincts, tous les deux dans `app/email_alerts.py` : - **Résumé périodique** (`envoyer_resume_stock`) : stock bas en premier, puis le reste du stock groupé par catégorie. Planifié par `app/scheduler.py` (APScheduler, tourne dans le process de l'app, pas de - cron système à configurer) via l'expression cron `RESUME_STOCK_CRON` - (`.env`, défaut `0 8 * * *` = tous les jours à 8h). Limite : ne se - déclenche que si le serveur tourne au moment prévu, pas de rattrapage. - `scripts/envoyer_resume_stock.py` permet de le déclencher manuellement - (utile pour tester le contenu, ou pour brancher l'envoi sur un cron - système / Planificateur de tâches Windows à la place si cette limite - devient un problème). + cron système à configurer), avec l'expression cron stockée en base + (`ParametrePlanification`, une seule ligne) et modifiable depuis + l'interface sur `/destinataires` — pas de fichier à éditer ni de + redémarrage nécessaire. Défaut : `0 9 * * mon` (tous les lundis à 9h). + Limite : ne se déclenche que si le serveur tourne au moment prévu, pas + de rattrapage. `scripts/envoyer_resume_stock.py` permet de le déclencher + manuellement (utile pour tester le contenu, ou pour brancher l'envoi sur + un cron système / Planificateur de tâches Windows à la place si cette + limite devient un problème). + + **Piège APScheduler** : le champ "jour de la semaine" d'une expression + cron numérique NE SUIT PAS la convention Unix (0=dimanche). Chez + APScheduler, 0=lundi (convention `datetime.weekday()`), donc `0 9 * * 1` + tombe un **mardi**, pas un lundi. Pour éviter toute confusion, le code et + les exemples proposés à l'utilisateur (`EXEMPLES_CRON`) n'utilisent que + des noms de jours (`mon`, `tue`, ...), jamais de chiffres. Vérifié + empiriquement avec la version d'APScheduler installée (voir le + commentaire en tête de `app/scheduler.py`). diff --git a/README.md b/README.md index cbc33c8..92954cf 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,6 @@ SMTP_PORT=25 SMTP_USER=... SMTP_PASSWORD=... SMTP_FROM=stock-it@clinique.local -RESUME_STOCK_CRON=0 8 * * * ``` Si `SMTP_HOST` n'est pas renseigné (cas du dev local), rien n'est réellement @@ -70,14 +69,16 @@ Deux emails distincts : d'alerte (`Materiel.seuil_alerte`). Contient ce matériel, puis un résumé de tous les articles actuellement en stock bas. - **Résumé périodique** : stock bas en premier, puis le reste du stock - groupé par catégorie. Planifié via `RESUME_STOCK_CRON` (expression cron - classique, ex. `0 8 * * *` = tous les jours à 8h) — le planificateur - tourne dans le process de l'app (APScheduler), rien à configurer côté - système. Pour le déclencher manuellement (test, ou pour le brancher sur - un cron système / Planificateur de tâches Windows à la place) : - `python scripts/envoyer_resume_stock.py`. + groupé par catégorie. L'horaire se configure **depuis l'interface**, sur + `/destinataires` (raccourcis courants + expression cron personnalisée) — + pas de fichier à éditer, le changement s'applique immédiatement sans + redémarrer le serveur. Défaut : tous les lundis à 9h. Le planificateur + (APScheduler) tourne dans le process de l'app, rien à configurer côté + système d'exploitation. Pour déclencher l'envoi manuellement (test, ou + pour le brancher sur un cron système / Planificateur de tâches Windows + à la place) : `python scripts/envoyer_resume_stock.py`. -Les destinataires (plusieurs possibles) se gèrent sur `/destinataires`. +Les destinataires (plusieurs possibles) se gèrent aussi sur `/destinataires`. ## Douchette code-barres diff --git a/app/config.py b/app/config.py index b1d7a3f..8537eff 100644 --- a/app/config.py +++ b/app/config.py @@ -28,10 +28,5 @@ class Settings: # affichées dans la console au lieu d'être envoyées. Voir email_alerts.py. smtp_configured: bool = bool(smtp_host) - # Expression cron classique (minute heure jour mois jour_semaine) pour - # l'email récapitulatif périodique de l'état du stock. Par défaut : - # tous les jours à 8h. Voir app/scheduler.py. - resume_stock_cron: str = os.getenv("RESUME_STOCK_CRON", "0 8 * * *") - settings = Settings() diff --git a/app/models.py b/app/models.py index cddb20b..a27e72b 100644 --- a/app/models.py +++ b/app/models.py @@ -80,3 +80,16 @@ class DestinataireAlerte(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) email: str = Field(unique=True) + + +class ParametrePlanification(SQLModel, table=True): + """Réglage de la planification de l'email récapitulatif de stock. + + Une seule ligne (id=1, upsert), modifiable depuis l'interface + (/destinataires) sans éditer .env ni redémarrer le serveur — voir + app/scheduler.py qui lit/écrit cette table et reprogramme la tâche en + direct à chaque changement. + """ + + id: Optional[int] = Field(default=None, primary_key=True) + expression_cron: str = Field(default="0 9 * * mon") diff --git a/app/routers/destinataires.py b/app/routers/destinataires.py index 13b12b1..0396459 100644 --- a/app/routers/destinataires.py +++ b/app/routers/destinataires.py @@ -1,4 +1,7 @@ -"""Routes pour gérer la liste des emails qui reçoivent les alertes de stock bas.""" +""" +Routes pour gérer la liste des emails qui reçoivent les alertes de stock +bas, et la planification de l'email récapitulatif périodique. +""" from fastapi import APIRouter, Depends, Form from fastapi.requests import Request @@ -7,20 +10,41 @@ from sqlmodel import Session, select from app.database import get_session from app.models import DestinataireAlerte +from app.scheduler import ( + EXEMPLES_CRON, + definir_expression_cron, + obtenir_expression_cron, + prochaine_execution, + valider_expression_cron, +) from app.templates_engine import templates router = APIRouter() +def _contexte_planification(session: Session, **kwargs) -> dict: + contexte = { + "expression_cron": obtenir_expression_cron(session), + "exemples_cron": EXEMPLES_CRON, + "prochaine_execution": prochaine_execution(), + "erreur_cron": None, + } + contexte.update(kwargs) + return contexte + + @router.get("/destinataires") def lister_destinataires(request: Request, q: str = "", session: Session = Depends(get_session)): """Affiche la liste des emails configurés pour recevoir les alertes, filtrée par ?q=... si fourni (même comportement que /materiels, pour - rester cohérent si la liste grandit).""" + rester cohérent si la liste grandit), ainsi que la planification de + l'email récapitulatif périodique.""" tous = session.exec(select(DestinataireAlerte)).all() destinataires = [d for d in tous if q.lower() in d.email.lower()] if q else tous return templates.TemplateResponse( - request, "destinataires.html", {"destinataires": destinataires, "q": q} + request, + "destinataires.html", + {"destinataires": destinataires, "q": q, **_contexte_planification(session)}, ) @@ -40,3 +64,40 @@ def supprimer_destinataire(destinataire_id: int, session: Session = Depends(get_ session.delete(destinataire) session.commit() return RedirectResponse(url="/destinataires", status_code=303) + + +@router.post("/destinataires/planification") +def modifier_planification( + request: Request, expression_cron: str = Form(...), session: Session = Depends(get_session) +): + """Change l'horaire de l'email récapitulatif périodique et reprogramme + la tâche immédiatement (pas besoin de redémarrer le serveur). + + Pas de redirection en cas d'expression cron invalide : on réaffiche la + page avec un message d'erreur et la valeur saisie, pour ne pas la + perdre (contrairement au reste du formulaire, elle n'est pas + enregistrée tant qu'elle n'est pas valide). + """ + expression_cron = expression_cron.strip() + if not valider_expression_cron(expression_cron): + tous = session.exec(select(DestinataireAlerte)).all() + return templates.TemplateResponse( + request, + "destinataires.html", + { + "destinataires": tous, + "q": "", + **_contexte_planification( + session, + expression_cron=expression_cron, + erreur_cron=( + f"Expression cron invalide : « {expression_cron} ». " + "Format attendu : minute heure jour mois jour_semaine " + "(ex: 0 9 * * mon)." + ), + ), + }, + ) + + definir_expression_cron(session, expression_cron) + return RedirectResponse(url="/destinataires", status_code=303) diff --git a/app/scheduler.py b/app/scheduler.py index 6834a92..8545440 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -3,10 +3,20 @@ 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 (RESUME_STOCK_CRON -dans .env, ex: "0 8 * * *" = tous les jours à 8h). Rien à configurer côté -système d'exploitation du serveur — tout reste dans ce dossier, comme le -reste du projet. +(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 @@ -16,15 +26,30 @@ 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.config import settings 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: @@ -35,11 +60,57 @@ def _tache_resume_stock() -> None: 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 la tâche planifiée. Appelé une fois au démarrage de l'app - (voir lifespan dans main.py).""" - trigger = CronTrigger.from_crontab(settings.resume_stock_cron) - _scheduler.add_job(_tache_resume_stock, trigger, id="resume_stock", replace_existing=True) + """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() @@ -48,3 +119,10 @@ 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 diff --git a/app/static/style.css b/app/static/style.css index 4ba05ea..6f9a5f2 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -207,6 +207,19 @@ details[open] > summary .noeud-chevron { white-space: nowrap; } +/* Planification de l'email récapitulatif (/destinataires) : un select de + raccourcis courants et un champ libre pour une expression cron + personnalisée, tous deux visibles en même temps plutôt que de basculer + l'un/l'autre en JavaScript. */ +.formulaire-planification select { + min-width: 260px; +} + +.champ-cron { + font-family: var(--police-donnee); + min-width: 200px; +} + /* --------------------------------------------------------------------- Arborescence de catégories : profondeur illimitée, repliée par défaut. ------------------------------------------------------------------ */ diff --git a/app/templates/destinataires.html b/app/templates/destinataires.html index e64816e..7ecf4f5 100644 --- a/app/templates/destinataires.html +++ b/app/templates/destinataires.html @@ -27,4 +27,44 @@ + +

Résumé périodique du stock

+

+ Un email récapitulatif (stock bas en premier, puis le reste du stock) est + envoyé automatiquement selon l'horaire ci-dessous, aux destinataires listés + plus haut. + {% if prochaine_execution %} + Prochain envoi : {{ prochaine_execution.strftime("%d/%m/%Y à %H:%M") }}. + {% endif %} +

+ +{% if erreur_cron %} +

{{ erreur_cron }}

+{% endif %} + +{% set exemples_valeurs = exemples_cron | map(attribute=0) | list %} +
+ + +
+ +
+ + +
+

+ Format cron : minute (0-59) heure (0-23) jour (1-31) mois (1-12) jour de la + semaine. Utilisez les noms de jours (mon, tue, wed, thu, fri, sat, sun), + pas de chiffres — leur numérotation ne suit pas le cron Unix habituel et + prête à confusion. +

{% endblock %} diff --git a/tests/test_planification.py b/tests/test_planification.py new file mode 100644 index 0000000..a289c84 --- /dev/null +++ b/tests/test_planification.py @@ -0,0 +1,55 @@ +""" +Tests de la planification de l'email récapitulatif, modifiable depuis +/destinataires : lecture/écriture de l'expression cron en base, et +validation (APScheduler a sa propre convention de jour de la semaine, donc +la validation doit accepter les noms de jours comme "mon" sans ambiguïté). +""" + +from app.scheduler import EXPRESSION_PAR_DEFAUT, obtenir_expression_cron, valider_expression_cron + + +def test_expression_par_defaut_est_tous_les_lundis(session): + assert obtenir_expression_cron(session) == EXPRESSION_PAR_DEFAUT == "0 9 * * mon" + + +def test_valider_expression_cron_accepte_les_noms_de_jours(): + assert valider_expression_cron("0 9 * * mon") is True + assert valider_expression_cron("0 8 * * mon-fri") is True + assert valider_expression_cron("0 */6 * * *") is True + + +def test_valider_expression_cron_rejette_une_expression_invalide(): + assert valider_expression_cron("pas une expression cron") is False + assert valider_expression_cron("0 25 * * *") is False # heure hors plage + + +def test_modifier_planification_avec_une_expression_valide(client, session): + reponse = client.post( + "/destinataires/planification", + data={"expression_cron": "0 8 * * *"}, + follow_redirects=False, + ) + + assert reponse.status_code == 303 + assert obtenir_expression_cron(session) == "0 8 * * *" + + +def test_modifier_planification_avec_une_expression_invalide_ne_sauvegarde_pas(client, session): + expression_avant = obtenir_expression_cron(session) + + reponse = client.post( + "/destinataires/planification", + data={"expression_cron": "n'importe quoi"}, + ) + + assert reponse.status_code == 200 + assert "invalide" in reponse.text.lower() + assert obtenir_expression_cron(session) == expression_avant + + +def test_page_destinataires_affiche_les_exemples_cron(client, session): + reponse = client.get("/destinataires") + + assert reponse.status_code == 200 + assert "Tous les lundis à 9h" in reponse.text + assert "0 9 * * mon" in reponse.text