diff --git a/README.md b/README.md index bd73e84..ef5b971 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,15 @@ docker compose run --rm pointeuse pytest -v --- -## Congés +## Congés & jours fériés -Trois boutons par ligne : **MA** (matin), **AM** (après-midi), **J** (journée entière).\ -Ils réduisent les heures dues et recalculent les préconisations **Fin cible** pour les -demi-journées restantes. +Quatre boutons par ligne : **MA** (congé matin), **AM** (congé après-midi), **J** +(journée entière) et **F** (jour férié). Les trois premiers réduisent les heures +dues et recalculent les préconisations **Fin cible** pour les demi-journées +restantes. **F** marque un jour férié (fête locale non calculée, pont...) : heures +dues nulles, aucune présence obligatoire, ligne grisée avec badge « férié » — sans +consommer de congé. Un jour marqué **F** compte aussi comme férié pour les ratios +d'astreinte (×2 en journée par défaut) et pour la fenêtre d'astreinte ci-dessous. --- @@ -183,7 +187,9 @@ demi-journées restantes. Le bouton **AS** marque un jour d'astreinte : la période couverte va de **18:00 ce jour-là à 18:00 le lendemain** (deux astreintes consécutives se recollent sans -trou). Ce jour et son lendemain, une sous-ligne dédiée apparaît sous la ligne du +trou) — sauf **samedi, dimanche et jour férié**, qui comptent de **0h00 à 0h00** +(journée entière ; un jour AS déclaré sam/dim/férié garde sa borne du lendemain +18:00 pour ne pas tronquer les interventions nocturnes). Ce jour et son lendemain, une sous-ligne dédiée apparaît sous la ligne du jour, pleine largeur : un menu dépliant fermé par défaut (seuls l'heure de retour et le total restent visibles). Déplié, il offre autant de lignes que nécessaire (**+ intervention**) sur trois colonnes : **début**, **fin** et description @@ -215,21 +221,26 @@ décompte de la semaine : - une demi-journée entièrement couverte par le repos est considérée comme remplie. Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles -sont décomptées à part, par pas de 15 minutes (toute tranche entamée compte en -totalité), dans des compteurs pondérés selon le jour et l'heure. Sous le bandeau -de la semaine s'affiche le détail par événement, les totaux par compteur et le -total pondéré. +sont décomptées à part, par quarts d'heure de l'horloge (:00, :15, :30, :45) — +toute tranche entamée compte en totalité, et un quart partagé par deux +interventions qui se suivent ou se chevauchent n'est compté **qu'une fois** +(attribué à la première, les lignes d'événements somment au total du jour) — dans +des compteurs pondérés selon le jour et l'heure. Sous le bandeau de la semaine +s'affiche le détail par événement, les totaux par compteur et le total pondéré. Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié** -(08:00→20:00) ×2. Ces ratios sont affichés sur la page **Réglages**. Les jours -fériés français (fixes + Pâques, Ascension, Pentecôte) -sont calculés automatiquement. +(08:00→20:00) ×2. Les jours fériés français (fixes + Pâques, Ascension, +Pentecôte) sont calculés automatiquement, et s'ajoutent les jours marqués **F**. -Ces règles sont ajustables dans `data/config.json`, clé `astreinte_facteurs` : -chaque règle porte un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, -`ferie`), un `debut`, une `fin` et un `facteur`. Une tranche qui ne correspond à -aucune règle est rangée dans « Autre » avec un facteur ×1. +Ces règles sont **éditables à la main** sur la page **Réglages** (section « Ratios +des heures d'astreinte ») : nom, types de jours, fenêtre horaire et ratio de chaque +règle, ajout et suppression de lignes. La première règle qui couvre un quart +gagne — place les fenêtres spécifiques avant les générales. Elles restent aussi +ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte +un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, `ferie`), un `debut`, +une `fin` et un `facteur`. Une tranche qui ne correspond à aucune règle est rangée +dans « Autre » avec un facteur ×1. Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) : partout ailleurs ils sont ignorés. Les colonnes **Astreinte** (brut), **Astreinte diff --git a/app/calcul.py b/app/calcul.py index 3e62bec..d30ced0 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -58,29 +58,44 @@ def jour_astreinte_qualifie(date_str: str, astreintes) -> bool: return date_str in astreintes or _veille(date_str) in astreintes -def fenetre_astreinte_jour(date_str: str, astreintes) -> tuple[int, int] | None: +def fenetre_astreinte_jour(date_str: str, astreintes, feries=None) -> tuple[int, int] | None: """Bornes (minutes sur la timeline du jour, 0 = minuit du jour, > 1440 = lendemain) de la période d'astreinte couvrant ce jour. Un jour déclaré AS couvre 18:00 → 18:00 le lendemain : son propre soir à partir de 18:00, et le lendemain jusqu'à 18:00 (le matin du jour AS lui-même n'est pas de l'astreinte). Deux astreintes consécutives se recollent sans trou ni - chevauchement. None si le jour n'est pas qualifié.""" + chevauchement. Samedi, dimanche et jour férié (légal ou marqué à la main via + `feries`, dates « YYYY-MM-DD ») : la journée compte dès 0h00 — un jour AS + déclaré garde sa borne du lendemain 18:00 (interventions nocturnes), un + sam/dim/férié lendemain d'astreinte compte jusqu'à minuit. + None si le jour n'est pas qualifié.""" astreintes = set(astreintes or []) jour_as = date_str in astreintes veille_as = _veille(date_str) in astreintes if not (jour_as or veille_as): return None - debut = 0 if veille_as else ASTREINTE_DEBUT_MIN - fin = 24 * 60 + ASTREINTE_DEBUT_MIN if jour_as else ASTREINTE_DEBUT_MIN + d = datetime.strptime(date_str, "%Y-%m-%d").date() + feries_eff = _jours_feries(d.year) | _jours_feries(d.year + 1) + for s in (feries or []): + try: + feries_eff.add(datetime.strptime(s, "%Y-%m-%d").date()) + except (TypeError, ValueError): + continue + plein = d.weekday() >= 5 or d in feries_eff + debut = 0 if (veille_as or plein) else ASTREINTE_DEBUT_MIN + if jour_as: + fin = 24 * 60 + ASTREINTE_DEBUT_MIN + else: + fin = 24 * 60 if plein else ASTREINTE_DEBUT_MIN return debut, fin -def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]: +def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict, ferie: bool = False) -> tuple[int, int]: """Présence obligatoire (matin, aprem) tombant avant l'heure de retour au bureau, en minutes : c'est le temps de présence retiré du décompte de la semaine quand le repos de 11h décale l'arrivée. Les demi-journées en congé ne sont pas décomptées - deux fois.""" - if retour_min is None: + deux fois. Un jour férié n'a aucune présence obligatoire.""" + if retour_min is None or ferie: return 0, 0 matin_debut = hhmm_to_minutes(plages["matin_debut"]) matin_fin = hhmm_to_minutes(plages["matin_fin"]) @@ -91,10 +106,11 @@ def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> return matin_ex, aprem_ex -def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int) -> tuple[int, int]: - """Nominaux (attendus) matin et aprem en minutes, après congés et exclusion liée - à l'heure de retour.""" - if "jour" in conges_date: +def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int, + ferie: bool = False) -> tuple[int, int]: + """Nominaux (attendus) matin et aprem en minutes, après congés, jour férié et + exclusion liée à l'heure de retour.""" + if ferie or "jour" in conges_date: m = a = 0 elif "matin" in conges_date: m, a = 0, du_full_min @@ -164,25 +180,36 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]: def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None, - fenetre: tuple[int, int] | None = None) -> dict: + fenetre: tuple[int, int] | None = None, + feries_declares: list[str] | set[str] | None = None) -> dict: """Décompte des heures réalisées en astreinte pour un jour, par pas de 15 min : toute tranche entamée est comptée en totalité. Chaque tranche est attribuée à la première règle (type de jour × fenêtre horaire) qui la couvre, puis pondérée par son facteur. Les événements vivent sur la timeline réelle : fin < début = intervention à cheval sur minuit, dont la partie après minuit compte sur le jour - suivant. Les tranches couvertes par plusieurs événements ne sont comptées qu'une - fois dans les totaux du jour. + suivant. Les tranches couvertes par plusieurs événements ne sont comptées + qu'une fois, attribuées au premier événement qui les entame (ordre + chronologique) : les compteurs des événements somment au total du jour. `fenetre` (bornes en minutes sur la timeline du jour, cf. fenetre_astreinte_jour) borne la période d'astreinte : seules les tranches à l'intérieur comptent, les événements ou fragments hors fenêtre sont ignorés. None = tout compter. + `feries_declares` (dates « YYYY-MM-DD » marquées à la main) complète les jours + fériés légaux calculés pour l'attribution des règles de type « ferie ». Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h, events: [détail par événement], categories: [compteurs par règle]}. """ regles = _regles_astreinte(facteurs) jour0 = datetime.strptime(date_str, "%Y-%m-%d").date() + # Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient les + # quarts pour les règles de type « ferie ». feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1) + for s in (feries_declares or []): + try: + feries.add(datetime.strptime(s, "%Y-%m-%d").date()) + except (TypeError, ValueError): + continue def _regle_du_quart(qm: int) -> dict | None: cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries) @@ -193,6 +220,9 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | # la première affectation gagne si des événements se chevauchent. quart_regles: dict[int, dict | None] = {} events_out = [] + # Événements triés par début : un quart entamé par plusieurs interventions qui + # se suivent ou se chevauchent n'est compté qu'une fois, attribué à la première. + parses = [] for e in events: deb, fin = e.get("debut"), e.get("fin") if not deb or not fin: @@ -205,9 +235,15 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | if fin_c <= debut_c: continue deb_min, duree = debut_c, fin_c - debut_c + parses.append((e, deb, fin, deb_min, duree)) + parses.sort(key=lambda t: (t[3], t[3] + t[4])) + vus: set[int] = set() + for e, deb, fin, deb_min, duree in parses: a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min - quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN, - a0 + duree, PAS_ASTREINTE_MIN)) + quarts = [qm for qm in range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN, + a0 + duree, PAS_ASTREINTE_MIN) + if qm not in vus] + vus.update(quarts) for qm in quarts: quart_regles.setdefault(qm, _regle_du_quart(qm)) ev_cats: dict[str, dict] = {} @@ -227,6 +263,8 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | ev_pondere = sum(d["pondere_min"] for d in ev_details) events_out.append({ "debut": deb, "fin": fin, "desc": e.get("desc") or "", + "tranches_debut": minutes_to_time(quarts[0] % 1440) if quarts else "", + "tranches_fin": minutes_to_time((quarts[-1] + PAS_ASTREINTE_MIN) % 1440) if quarts else "", "tranches": len(quarts), "min": ev_min, "min_h": minutes_to_hhmm(ev_min), "pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere), "details": ev_details, @@ -267,9 +305,10 @@ dernière activité d'astreinte. Chaque activité d'astreinte est un événement {debut, fin} saisi sur le jour de son début (les événements remplacent l'ancienne fin unique). fin < debut désigne une intervention à cheval sur minuit, dont la fin (heure du lendemain) - termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain : une - intervention terminée avant 18:00 sur le jour déclaré n'est pas de l'astreinte - et ne déclenche pas de repos. Comme pour le décompte, tout quart d'heure + termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain — sauf + samedi, dimanche et jour férié, où la journée entière compte (0h00 → 0h00) : + hors ces jours, une intervention terminée avant 18:00 sur le jour déclaré + n'est pas de l'astreinte et ne déclenche pas de repos. Comme pour le décompte, tout quart d'heure entamé est travaillé : la fin est arrondie au quart d'heure supérieur avant d'ajouter le repos. Le retour s'applique au jour où se termine le repos : fin 23:30 → retour 10:30 le lendemain ; fin 23:37 (comptée 23:45) → retour @@ -350,13 +389,14 @@ def heures_travaillees(p: dict, retour_min: int | None = None) -> int: def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int, - plages: dict | None = None, retour_min: int | None = None) -> int: - """Return expected minutes for a date, accounting for vacations. Si une heure de - retour est définie (repos de 11h après astreinte), la présence obligatoire avant - cette heure est retirée du décompte de la semaine.""" + plages: dict | None = None, retour_min: int | None = None, + ferie: bool = False) -> int: + """Return expected minutes for a date, accounting for vacations and public + holidays. Si une heure de retour est définie (repos de 11h après astreinte), la + présence obligatoire avant cette heure est retirée du décompte de la semaine.""" d = datetime.strptime(date_str, "%Y-%m-%d").date() - # Weekend = no hours due - if d.weekday() >= 5: + # Weekend ou jour férié = no hours due + if d.weekday() >= 5 or ferie: return 0 conges_date = {c["type"] for c in conges if c["date"] == date_str} @@ -379,10 +419,11 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int, return max(0, du) -def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None) -> dict: +def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None, feries: list[str] | set[str] | None = None) -> dict: """Compute totals for a list of pointages (one week).""" plages = {**DEFAULT_PLAGES, **(plages or {})} astreintes = set(astreintes or []) + feries = set(feries or []) forfait_as_min = hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0 matin_debut_min = hhmm_to_minutes(plages["matin_debut"]) matin_fin_min = hhmm_to_minutes(plages["matin_fin"]) @@ -404,19 +445,21 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int for p in pointages: conges_date = {c["type"] for c in conges if c["date"] == p["date"]} + jour_ferie = p["date"] in feries # Retour au bureau après 11h de repos, calculé depuis l'heure de fin de la # dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés) retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes) - # Décompte astreinte du jour : événements multiples, pas de 15 min entamées + # Décompte astreinte du jour : événements multiples, pas de 15 min entamées ; + # un férié marqué à la main qualifie les quarts pour les règles « ferie » events = p.get("astreinte_events") or [] - dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes)) + dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes, feries), feries) if jour_astreinte_qualifie(p["date"], astreintes) and events else None) - matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) + matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie) forfait = forfait_as_min if p["date"] in astreintes else 0 travaille = heures_travaillees(p, retour_min) + forfait - du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min) + du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min, jour_ferie) du_full = du + matin_ex + aprem_ex - matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex) + matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie) delta = travaille - du total_travaille += travaille total_du += du @@ -429,6 +472,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "du": minutes_to_hhmm(du), "delta": minutes_to_hhmm(delta), "conge": conges_date, + "ferie": jour_ferie, "forfait_min": forfait, "forfait_h": minutes_to_hhmm(forfait) if forfait else "", "du_full_min": du_full, diff --git a/app/export.py b/app/export.py index cdf434c..21f611d 100644 --- a/app/export.py +++ b/app/export.py @@ -9,7 +9,7 @@ from odf.text import P from calcul import compute_week, minutes_to_hhmm from models import ( - load_week_pointages, load_conges, load_astreintes, list_all_weeks, + load_week_pointages, load_conges, load_astreintes, load_feries, list_all_weeks, load_plages, heures_jour_min, ) @@ -106,6 +106,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes: plages = load_plages(user_id) conges = load_conges(user_id) astreintes = load_astreintes(user_id) + feries = load_feries(user_id) dates = _week_dates(year, week) raw = load_week_pointages(year, week, user_id) @@ -115,7 +116,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes: "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, plages, astreintes) + result = compute_week(pointages, conges, h_jour, plages, astreintes, None, feries) table = Table(name=f"Semaine {week:02d}-{year}") _size_columns(table, wide_col) @@ -140,6 +141,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes: plages = load_plages(user_id) conges = load_conges(user_id) astreintes = load_astreintes(user_id) + feries = load_feries(user_id) weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year) @@ -153,7 +155,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes: "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, plages, astreintes) + result = compute_week(pointages, conges, h_jour, plages, astreintes, None, feries) travaille_min = sum(j["travaille_min"] for j in result["jours"]) du_min = sum(j["du_min"] for j in result["jours"]) par_semaine.append((week, result, travaille_min, du_min)) diff --git a/app/main.py b/app/main.py index 4a3b163..8b97b03 100644 --- a/app/main.py +++ b/app/main.py @@ -22,12 +22,13 @@ from mail import send_mail from models import ( load_config, load_week_pointages, save_week_pointages, load_conges, toggle_conge, list_all_weeks, load_astreintes, toggle_astreinte, + load_feries, toggle_ferie, load_notif_config, save_notif_config, find_user_by_token, save_presence, regenerate_presence_token, load_auth, save_auth, find_user_by_reset_token, load_plages, save_plages, heures_jour_min, append_log, load_logs, load_or_create_secret, - load_facteurs_astreinte, + load_facteurs_astreinte, save_facteurs_astreinte, ) from calcul import ( compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues, @@ -129,6 +130,7 @@ JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanc _USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$") _EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$") _HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") +_JOURS_AS_VALIDES = ("semaine", "sam", "dim", "ferie") # Échappement HTML pour les fragments construits en f-string hors templates Jinja # (autoescape ne s'applique qu'aux templates). Defense-in-depth contre le XSS stocké. @@ -310,7 +312,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id): "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte()) + result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id)) result["heures_jour"] = minutes_to_hhmm(h_jour) return {"result": result} @@ -321,25 +323,27 @@ def _build_jour_ctx(date, conges, h_jour, user_id): raw = load_week_pointages(iso.year, iso.week, user_id) plages = load_plages(user_id) astreintes = set(load_astreintes(user_id)) + feries = set(load_feries(user_id)) p_by_date = {p["date"]: p for p in raw} p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None, "aprem_entree": None, "aprem_sortie": None}) conges_date = {c["type"] for c in conges if c["date"] == date} + jour_ferie = date in feries # Retour au bureau après 11h de repos, calculé depuis la fin de la dernière # activité d'astreinte (saisie sur le jour ou la veille qualifiés) retour_min = retour_apres_repos(date, p_by_date, plages, astreintes) - matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) + matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie) forfait = (hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0) \ if date in astreintes else 0 trav = heures_travaillees(p, retour_min) + forfait - du = heures_dues(date, conges, h_jour, plages, retour_min) + du = heures_dues(date, conges, h_jour, plages, retour_min, jour_ferie) du_full = du + matin_ex + aprem_ex - matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex) + matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie) delta = trav - du # Décompte astreinte du jour (compteur séparé, hors heures de semaine) : # seulement si des événements sont stockés et que le jour est qualifié. events = p.get("astreinte_events") or [] - dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes)) + dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes, feries), feries) if events and jour_astreinte_qualifie(date, astreintes) else None) return { **p, @@ -348,6 +352,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id): "du": minutes_to_hhmm(du), "delta": minutes_to_hhmm(delta), "conge": conges_date, + "ferie": jour_ferie, "forfait_min": forfait, "forfait_h": minutes_to_hhmm(forfait) if forfait else "", "du_full_min": du_full, @@ -440,18 +445,20 @@ def _oob_time_cells(date, jour, plages): def _btns_html(date, jour): - """Boutons congé + astreinte d'une ligne (cible du swap HTMX).""" + """Boutons congé + astreinte + férié d'une ligne (cible du swap HTMX).""" cg = jour["conge"] btn_ma_on = " on" if "matin" in cg else "" btn_am_on = " on" if "aprem" in cg else "" btn_j_on = " jour-on" if "jour" in cg else "" btn_as_on = " as-on" if jour.get("astreinte") else "" + btn_f_on = " ferie-on" if jour.get("ferie") else "" return ( f'
' f'
' f'' f'' f'' + f'' f'' f'
' ) @@ -489,7 +496,7 @@ def _as_block_html(date, jour): f'forfait + {_e(jour["forfait_h"])}') total = "" if jour.get("astreinte_min"): - total = (f'' + total = (f'' f'astreinte : {_e(jour["astreinte_h"])}') nb = len(events) label = f'{nb} intervention' + ('s' if nb != 1 else '') @@ -740,7 +747,7 @@ def semaine(request: Request, year: int, week: int): "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id), load_facteurs_astreinte()) + result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id)) prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1) next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1) @@ -866,6 +873,23 @@ def toggle_astreinte_route(date: str, request: Request): return HTMLResponse(html + _oob_week_extras(soldes_ctx["result"]) + soldes_html) +@app.post("/ferie/{date}/toggle", response_class=HTMLResponse) +def toggle_ferie_route(date: str, request: Request): + """Marque/démarque un jour férié : dû à 0, aucune présence obligatoire, et + fenêtre d'astreinte étendue à la journée entière (0h00 → 0h00).""" + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + toggle_ferie(date, user_id) + d = datetime.strptime(date, "%Y-%m-%d").date() + iso = d.isocalendar() + if request.headers.get("HX-Request"): + conges = load_conges(user_id) + return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id) + return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) + + @app.get("/stats", response_class=HTMLResponse) def stats_page(request: Request): user_id = _get_user(request) @@ -956,6 +980,8 @@ def settings_page(request: Request): "plages_error": request.query_params.get("plages_error") == "1", "forfait_saved": request.query_params.get("forfait_saved") == "1", "forfait_error": request.query_params.get("forfait_error") == "1", + "facteurs_saved": request.query_params.get("facteurs_saved") == "1", + "facteurs_error": request.query_params.get("facteurs_error") == "1", }) @@ -1118,6 +1144,46 @@ def settings_astreinte_forfait(request: Request, astreinte_forfait: str = Form(" return RedirectResponse("/settings?forfait_saved=1", status_code=303) +@app.post("/settings/astreinte-facteurs", response_class=HTMLResponse) +async def settings_astreinte_facteurs(request: Request): + """Règles de pondération des heures d'astreinte, éditables à la main : + nom, types de jours, fenêtre horaire et ratio de chaque règle. Une règle au + nom vide est supprimée ; une règle invalide rejette l'enregistrement.""" + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + form = await request.form() + indices = sorted({int(m.group(1)) for k in form.keys() + if (m := re.fullmatch(r"f_nom_(\d+)", k))}) + regles = [] + for i in indices: + nom = str(form.get(f"f_nom_{i}") or "").strip() + if not nom: + continue + debut = str(form.get(f"f_debut_{i}") or "") + fin = str(form.get(f"f_fin_{i}") or "") + facteur_txt = str(form.get(f"f_facteur_{i}") or "").strip().replace(",", ".") + jours = [j for j in form.getlist(f"f_jours_{i}") if j in _JOURS_AS_VALIDES] + try: + facteur = float(facteur_txt) + except ValueError: + facteur = 0.0 + if not _HHMM_RE.match(debut) or not _HHMM_RE.match(fin) or facteur <= 0 or not jours: + logger.warning("règle astreinte rejetée (invalide): user_id=%r nom=%r debut=%r fin=%r facteur=%r jours=%r", + user_id, nom, debut, fin, facteur_txt, jours) + return RedirectResponse("/settings?facteurs_error=1", status_code=303) + regles.append({"nom": nom, "jours": jours, "debut": debut, "fin": fin, "facteur": facteur}) + + if not regles: + logger.warning("règles astreinte vides, rien n'est enregistré: user_id=%r", user_id) + return RedirectResponse("/settings?facteurs_error=1", status_code=303) + save_facteurs_astreinte(regles) + logger.info("règles astreinte mises à jour: user_id=%r n=%d", user_id, len(regles)) + append_log(user_id, "settings", "Ratios astreinte", f"{len(regles)} règle(s)") + return RedirectResponse("/settings?facteurs_saved=1", status_code=303) + + @app.post("/presence/{token}/arrivee") def presence_arrivee(request: Request, token: str): user_id = find_user_by_token(token) diff --git a/app/models.py b/app/models.py index 7264814..4130cf7 100644 --- a/app/models.py +++ b/app/models.py @@ -181,6 +181,40 @@ def toggle_astreinte(date: str, user_id: str = ""): save_astreintes(dates, user_id) +# ── Jours fériés (marqués à la main) ────────────────────────────────────────── + +def _feries_file(user_id: str) -> Path: + return _user_root(user_id) / "feries.json" + + +def load_feries(user_id: str = "") -> list[str]: + """Dates marquées comme jours fériés (triées, dédupliquées).""" + f = _feries_file(user_id) + if not f.exists(): + return [] + try: + dates = json.loads(f.read_text()) + except json.JSONDecodeError: + return [] + return sorted(set(d for d in dates if isinstance(d, str))) + + +def save_feries(dates: list[str], user_id: str = ""): + _ensure_user_dirs(user_id) + _feries_file(user_id).write_text( + json.dumps(sorted(set(dates)), indent=2, ensure_ascii=False) + ) + + +def toggle_ferie(date: str, user_id: str = ""): + dates = load_feries(user_id) + if date in dates: + dates.remove(date) + else: + dates.append(date) + save_feries(dates, user_id) + + def load_facteurs_astreinte() -> list[dict]: """Règles globales de comptage des heures d'astreinte (type de jour × fenêtre horaire × facteur) ; défauts cohérents si absentes ou invalides dans la config.""" @@ -190,6 +224,13 @@ def load_facteurs_astreinte() -> list[dict]: return DEFAULT_FACTEURS_ASTREINTE +def save_facteurs_astreinte(facteurs: list[dict]): + """Règles globales de comptage des heures d'astreinte (config partagée).""" + cfg = load_config() + cfg["astreinte_facteurs"] = facteurs + save_config(cfg) + + # ── Auth (email + password) ──────────────────────────────────────────────────── def _auth_file(user_id: str) -> Path: diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index f3d78f4..0dbc0ec 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -12,11 +12,12 @@ (j.aprem_entree and j.aprem_entree > borne_aprem) or (j.aprem_sortie and j.aprem_sortie < plages.aprem_fin) ) %} - + {{ nom }} {{ j.date[8:] }}/{{ j.date[5:7] }} + {% if j.ferie %}férié{% endif %} @@ -106,6 +107,10 @@ hx-swap="none" class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}" title="Journée entière">J + + + {% endfor %} - {% endfor %} - +
+ + +
+ + +
Forfait par jour d'astreinte

@@ -226,22 +276,47 @@ border: 1px solid var(--rule-l); padding: 0 .25rem; } -.as-ratios { border: 1px solid var(--rule-l); display: flex; flex-direction: column; } -.as-ratio-row { - display: grid; - grid-template-columns: minmax(110px, auto) 1fr auto auto; - gap: .5rem 1rem; - align-items: baseline; - padding: .45rem .8rem; +.as-facteurs-form { display: flex; flex-direction: column; gap: .5rem; } +.as-ratio-edit { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem .75rem; + padding: .5rem .8rem; + border: 1px solid var(--rule-l); } -.as-ratio-row + .as-ratio-row { border-top: 1px solid var(--rule-l); } -.as-ratio-nom { font-size: 13px; font-weight: 500; } -.as-ratio-jours { font-size: 12px; color: var(--muted); } -.as-ratio-fenetre { font-family: var(--mono); font-size: 12px; color: var(--muted); } -.as-ratio-facteur { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--accent-2); } +.as-ratio-edit + .as-ratio-edit { margin-top: .4rem; } +.as-ratio-edit .as-f-nom { width: 140px; flex-shrink: 0; } +.as-f-jours { display: flex; gap: .75rem; flex-wrap: wrap; flex: 1; } +.as-f-jour { font-size: 12px; color: var(--text); display: inline-flex; align-items: center; gap: .3rem; white-space: nowrap; } +.as-ratio-edit .as-f-h { width: 90px; flex-shrink: 0; } +.as-f-x { font-family: var(--mono); color: var(--muted); display: inline-flex; align-items: center; gap: .3rem; } +.as-ratio-edit .as-f-facteur { width: 70px; } +.as-f-del { + border: 1px solid var(--rule); + background: var(--surface); + color: var(--muted); + border-radius: 4px; + width: 22px; + height: 22px; + cursor: pointer; + line-height: 1; + flex-shrink: 0; +} +.as-f-del:hover { color: var(--neg); border-color: var(--neg); } +.as-ratio-actions { display: flex; gap: .75rem; align-items: center; margin-top: .3rem; } +.as-f-add { + border: 1px dashed var(--rule); + background: none; + color: var(--muted); + border-radius: 4px; + padding: .35rem .7rem; + cursor: pointer; + font-size: 12px; +} +.as-f-add:hover { color: var(--accent); border-color: var(--accent); } @media (max-width: 600px) { - .as-ratio-row { grid-template-columns: 1fr auto; row-gap: .1rem; } - .as-ratio-jours, .as-ratio-fenetre { grid-column: 1 / -1; } + .as-f-jours { order: 3; width: 100%; } } .settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; } .settings-saved { diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py index 29d383b..b234f42 100644 --- a/app/tests/test_astreinte.py +++ b/app/tests/test_astreinte.py @@ -219,7 +219,7 @@ def test_decompte_vendredi_vers_samedi_reste_en_nuit(): def test_decompte_chevauchement_dedouble(): """10:00→10:20 et 10:10→10:30 se recouvrent : les tranches communes ne comptent - qu'une fois dans le total du jour, chaque event garde son propre détail.""" + qu'une fois, attribuées au premier événement ; les lignes somment au total.""" d = decompte_astreinte("2026-07-13", [ {"debut": "10:00", "fin": "10:20"}, {"debut": "10:10", "fin": "10:30"}, @@ -227,13 +227,29 @@ def test_decompte_chevauchement_dedouble(): assert len(d["events"]) == 2 assert d["events"][0]["tranches"] == 2 # tranches 10:00 + 10:15 assert d["events"][0]["min"] == 30 - assert d["events"][1]["tranches"] == 2 # 10:10 aligné sur la tranche 10:00 - assert d["events"][1]["min"] == 30 + assert d["events"][1]["tranches"] == 0 # les deux tranches déjà comptées + assert d["events"][1]["min"] == 0 assert d["tranches"] == 2 # union dédoublonnée assert d["total_min"] == 30 + assert sum(e["min"] for e in d["events"]) == d["total_min"] assert d["categories"][0]["nom"] == "Journée" +def test_decompte_quart_partage_entre_interventions(): + """Deux interventions qui se suivent et finissent/débutent dans le même quart : + le quart n'est compté qu'une fois (attribué à la première), les lignes + d'événements somment au total du jour.""" + d = decompte_astreinte("2026-07-13", [ + {"debut": "22:39", "fin": "23:05"}, + {"debut": "23:02", "fin": "23:49"}, + ]) + assert d["events"][0]["tranches"] == 3 # 22:30 + 22:45 + 23:00 + assert d["events"][1]["tranches"] == 3 # 23:15 + 23:30 + 23:45 (23:00 déjà compté) + assert sum(e["tranches"] for e in d["events"]) == d["tranches"] == 6 + assert d["total_min"] == 90 + assert d["total_pondere_min"] == 135 # 6 × 15 × 1,5 (Nuit) + + def test_decompte_event_incomplet_ignore(): """Un événement sans debut ou sans fin est ignoré.""" d = decompte_astreinte("2026-07-13", [{"debut": "22:00"}, {"fin": "23:00"}, {}]) @@ -426,11 +442,21 @@ def test_load_facteurs_astreinte_defaut_si_absent(): def test_fenetre_astreinte_jour(): """Le jour déclaré couvre 18:00 → 18:00 le lendemain ; le lendemain n'est - couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent.""" - assert fenetre_astreinte_jour("2026-07-14", ["2026-07-14"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN) - assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"]) == (0, ASTREINTE_DEBUT_MIN) - assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14", "2026-07-15"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN) - assert fenetre_astreinte_jour("2026-07-16", ["2026-07-14"]) is None + couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent. + Samedi, dimanche et férié : journée entière 0h00 → 0h00.""" + # jours ouvrés non fériés (mercredi 15 → vendredi 17 juillet 2026) + assert fenetre_astreinte_jour("2026-07-15", ["2026-07-15"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN) + assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15"]) == (0, ASTREINTE_DEBUT_MIN) + assert fenetre_astreinte_jour("2026-07-16", ["2026-07-15", "2026-07-16"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN) + assert fenetre_astreinte_jour("2026-07-17", ["2026-07-15"]) is None + # samedi et dimanche : journée entière dès 0h00, déclarés ou lendemain + # d'astreinte (le jour AS déclaré garde sa borne du lendemain 18:00) + assert fenetre_astreinte_jour("2026-07-18", ["2026-07-18"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN) + assert fenetre_astreinte_jour("2026-07-19", ["2026-07-18"]) == (0, 24 * 60) + # férié légal (14 juillet) : journée entière même en milieu de semaine + assert fenetre_astreinte_jour("2026-07-14", ["2026-07-13"]) == (0, 24 * 60) + # férié marqué à la main : idem + assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"], {"2026-07-15"}) == (0, 24 * 60) def test_decompte_hors_fenetre_18h_18h_ignore(): @@ -454,21 +480,58 @@ def test_decompte_hors_fenetre_18h_18h_ignore(): def test_compute_week_intervention_avant_18h_hors_astreinte(freeze_today): - """Mardi AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le même - créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1).""" + """Mercredi ouvré AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le + même créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1).""" pointages = [ - _pointage("2026-07-13"), _pointage("2026-07-16"), _pointage("2026-07-17"), - _pointage("2026-07-14", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]), - _pointage("2026-07-15"), + _pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-16"), + _pointage("2026-07-17"), + _pointage("2026-07-15", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]), ] - result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"]) assert result["astreinte"]["total_min"] == 0 - assert result["jours"][1]["astreinte_min"] == 0 - - pointages[3] = _pointage("2026-07-14", astreinte_events=[{"debut": "19:00", "fin": "20:00"}]) - result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + assert result["jours"][2]["astreinte_min"] == 0 + # le même créneau le soir, dans la fenêtre 18:00 → 18:00, compte (Journée ×1) + pointages[4] = _pointage("2026-07-15", astreinte_events=[{"debut": "19:00", "fin": "20:00"}]) + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-15"]) assert result["astreinte"]["total_min"] == 60 - assert result["astreinte"]["total_pondere_min"] == 120 # 14 juillet : férié ×2 + assert result["astreinte"]["total_pondere_min"] == 60 + + +def test_compute_week_astreinte_weekend_journee_entiere(freeze_today): + """Vendredi AS : le samedi compte de 0h00 à 0h00 — l'intervention 20:00→21:00 + du samedi (après la borne 18:00 des jours ouvrés) est de l'astreinte (Nuit ×1,5).""" + pointages = [ + _pointage("2026-07-13"), _pointage("2026-07-14"), _pointage("2026-07-15"), + _pointage("2026-07-16"), + _pointage("2026-07-17", astreinte_events=[{"debut": "20:00", "fin": "21:00"}]), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-17"]) + # l'événement saisi sur le vendredi est dans sa fenêtre 18:00 → 18:00 + vendredi = result["jours"][4] + assert vendredi["astreinte_min"] == 60 + + +def test_decompte_fenetre_samedi_soir_apres_18h(): + """Samedi lendemain d'astreinte : 20:00→21:00 compte (journée entière), + 4 tranches en Nuit ×1,5 = 1h30 pondérées.""" + fen = fenetre_astreinte_jour("2026-07-18", ["2026-07-17"]) + d = decompte_astreinte("2026-07-18", [{"debut": "20:00", "fin": "21:00"}], None, fen) + assert d["total_min"] == 60 + assert d["categories"][0]["nom"] == "Nuit" + assert d["total_pondere_min"] == 90 + + +def test_compute_week_ferie_journee_entiere_ratio_x2(freeze_today): + """Lundi AS, intervention le mardi 14 juillet (férié légal) : journée entière + 0h00 → 0h00, ratio Dimanche / férié ×2 (10:00→12:00 = 2h → 4h pondérées).""" + pointages = [ + _pointage("2026-07-13"), + _pointage("2026-07-14", astreinte_events=[{"debut": "10:00", "fin": "12:00"}]), + _pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-13"]) + assert result["astreinte"]["total_min"] == 120 + assert result["astreinte"]["total_pondere_min"] == 240 # férié ×2 def test_compute_week_astreinte_samedi_dimanche(freeze_today): diff --git a/app/tests/test_ferie.py b/app/tests/test_ferie.py new file mode 100644 index 0000000..10395ce --- /dev/null +++ b/app/tests/test_ferie.py @@ -0,0 +1,145 @@ +"""Jours fériés marqués à la main : stockage, décompte (dû 0, fenêtre d'astreinte +0h00 → 0h00) et ratios d'astreinte éditables depuis la page Réglages.""" +import sys + +# DATA_DIR temporaire est positionné par conftest.py avant l'import de models. +import models # noqa: F401 + +sys.path.insert(0, ".") +import main +from fastapi.testclient import TestClient + +from calcul import compute_week, heures_dues + +client = TestClient(main.app) + +HEURES_JOUR_MIN = 468 # 39h / 5j + + +def _login(uid: str) -> dict: + import bcrypt + models.save_auth(uid, email=f"{uid}@x.fr", + password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode()) + r = client.post("/login/password", + data={"email": f"{uid}@x.fr", "password": "x" * 12}, + follow_redirects=False) + assert r.status_code == 302, r.text + return {"user_id": r.cookies.get("user_id")} + + +def _pointage(date, astreinte_events=None): + return {"date": date, "matin_entree": None, "matin_sortie": None, + "aprem_entree": None, "aprem_sortie": None, + "astreinte_events": astreinte_events or []} + + +# ── Stockage ────────────────────────────────────────────────────────────────── + +def test_toggle_ferie_persiste(): + models.toggle_ferie("2026-07-15", "u-ferie") + assert models.load_feries("u-ferie") == ["2026-07-15"] + models.toggle_ferie("2026-07-15", "u-ferie") + assert models.load_feries("u-ferie") == [] + + +# ── Décompte ────────────────────────────────────────────────────────────────── + +def test_heures_dues_ferie_zero(): + """Un mercredi marqué férié ne doit aucune heure, congés ou pas.""" + assert heures_dues("2026-07-15", [], HEURES_JOUR_MIN, ferie=True) == 0 + assert heures_dues("2026-07-15", [], HEURES_JOUR_MIN, ferie=True, + retour_min=10 * 60) == 0 + + +def test_compute_week_ferie_du_zero_jour_complet(freeze_today): + """Mercredi marqué F : dû 0, jour complet d'office, badge férié, heures + pointées toujours comptées (delta positif).""" + pointages = [ + _pointage("2026-07-13", None), _pointage("2026-07-14", None), + _pointage("2026-07-15", None), _pointage("2026-07-16", None), + _pointage("2026-07-17", None), + ] + pointages[2].update(matin_entree="09:00", matin_sortie="12:24", + aprem_entree="13:30", aprem_sortie="17:54") + result = compute_week(pointages, [], HEURES_JOUR_MIN, {}, [], None, {"2026-07-15"}) + mercredi = result["jours"][2] + assert mercredi["ferie"] is True + assert mercredi["du_min"] == 0 + assert mercredi["is_complete"] is True + assert mercredi["travaille_min"] == HEURES_JOUR_MIN # pointé : compte en delta + assert result["jours"][1]["ferie"] is False + assert result["jours"][3]["ferie"] is False + + +def test_compute_week_ferie_etend_fenetre_astreinte(freeze_today): + """AS mardi + F mercredi : l'intervention de mercredi 20:00→21:00 (après la + borne 18:00 des jours ouvrés) compte — Nuit ×1,5 = 1h30 pondérées.""" + pointages = [ + _pointage("2026-07-13", None), + _pointage("2026-07-14", None), + _pointage("2026-07-15", [{"debut": "20:00", "fin": "21:00"}]), + _pointage("2026-07-16", None), _pointage("2026-07-17", None), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, {}, ["2026-07-14"], None, + {"2026-07-15"}) + assert result["jours"][2]["astreinte_min"] == 60 + assert result["astreinte"]["total_pondere_min"] == 90 + + +# ── Routes ──────────────────────────────────────────────────────────────────── + +def test_route_toggle_ferie(): + cookies = _login("u-ferie-route") + r = client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False) + assert r.status_code == 303 + assert models.load_feries("u-ferie-route") == ["2026-07-15"] + r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) + assert r.status_code == 200 + assert "ferie-on" in r.text # bouton F actif + assert "ferie-badge" in r.text # badge « férié » sur la ligne du jour + client.post("/ferie/2026-07-15/toggle", cookies=cookies, follow_redirects=False) + assert models.load_feries("u-ferie-route") == [] + + +def test_settings_astreinte_facteurs_editables(): + """POST de règles valides : enregistrées et rechargées ; règle au nom vide + supprimée ; ratio invalide rejette tout sans rien changer.""" + cookies = _login("u-facteurs") + avant = models.load_facteurs_astreinte() + + data = { + "f_nom_0": "Nuit", "f_debut_0": "20:00", "f_fin_0": "08:00", "f_facteur_0": "1.5", + "f_jours_0": ["semaine", "dim"], + "f_nom_1": "Garde", "f_debut_1": "18:00", "f_fin_1": "09:00", "f_facteur_1": "2,5", + "f_jours_1": ["sam"], + } + r = client.post("/settings/astreinte-facteurs", cookies=cookies, data=data, + follow_redirects=False) + assert r.status_code == 303 + assert "facteurs_saved=1" in r.headers["location"] + + facteurs = models.load_facteurs_astreinte() + assert [f["nom"] for f in facteurs] == ["Nuit", "Garde"] + assert facteurs[1]["facteur"] == 2.5 # virgule acceptée + assert facteurs[1]["jours"] == ["sam"] + + # suppression par nom vidé : la règle disparaît + r = client.post("/settings/astreinte-facteurs", cookies=cookies, data={ + "f_nom_0": "Nuit", "f_debut_0": "20:00", "f_fin_0": "08:00", "f_facteur_0": "1.5", + "f_jours_0": ["semaine"], + "f_nom_1": "", "f_debut_1": "18:00", "f_fin_1": "09:00", "f_facteur_1": "2", + }, follow_redirects=False) + assert r.status_code == 303 + assert [f["nom"] for f in models.load_facteurs_astreinte()] == ["Nuit"] + + # ratio invalide : rejet, rien n'est modifié + r = client.post("/settings/astreinte-facteurs", cookies=cookies, data={ + "f_nom_0": "Bug", "f_debut_0": "20:00", "f_fin_0": "08:00", + "f_facteur_0": "0", "f_jours_0": ["semaine"], + }, follow_redirects=False) + assert r.status_code == 303 + assert "facteurs_error=1" in r.headers["location"] + assert [f["nom"] for f in models.load_facteurs_astreinte()] == ["Nuit"] + + # remise aux règles par défaut pour les autres tests + models.save_facteurs_astreinte(avant)