From 3d38abd85395ca8f0efab989f1cf0b3a9a33cbb6 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Wed, 9 Sep 2026 20:48:59 +0200 Subject: [PATCH] =?UTF-8?q?Astreinte=20:=20pr=C3=A9f=C3=A9rence=20personne?= =?UTF-8?q?lle=20et=20retour=20align=C3=A9=20sur=20les=20quarts=20d'horlog?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - La gestion des astreintes devient un réglage par compte (Réglages → « Gestion des astreintes », actif par défaut) : désactivée, le bouton AS, les interventions, forfaits, repos de 11h, récapitulatif, ratios, stats et exports n'en parlent plus — sans rien supprimer, réactiver retrouve tout - L'heure de retour au bureau tombe désormais toujours sur un quart d'heure d'horloge fixe (:00, :15, :30, :45), arrondie au supérieur après les 11h de repos (ex. fin comptée 23:54 → retour 11:00) - Corrigé : enregistrer ses plages horaires écrasait le forfait d'astreinte 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- README.md | 14 ++++- app/calcul.py | 13 ++-- app/export.py | 6 +- app/main.py | 66 +++++++++++++++----- app/models.py | 16 +++++ app/stats.py | 4 +- app/templates/_ligne.html | 8 ++- app/templates/settings.html | 34 +++++++++++ app/tests/test_astreinte.py | 17 ++++++ app/tests/test_astreinte_pref.py | 102 +++++++++++++++++++++++++++++++ 10 files changed, 250 insertions(+), 30 deletions(-) create mode 100644 app/tests/test_astreinte_pref.py diff --git a/README.md b/README.md index b4ae478..e675693 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,12 @@ d'astreinte (×2 en journée par défaut) et pour la fenêtre d'astreinte ci-des ## Astreinte & repos de 11h +La gestion des astreintes est une **préférence personnelle** (Réglages → +« Gestion des astreintes », active par défaut). Désactivée, tout ce qui suit +disparaît pour ce compte : bouton **AS**, saisie d'interventions, forfaits, +repos de 11h, récapitulatif et ratios — sans rien supprimer, une réactivation +retrouve les données telles quelles. + Le bouton **AS** du menu d'état 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) — sauf **samedi, dimanche et jour férié**, qui comptent de **0h00 à 0h00** @@ -216,9 +222,11 @@ L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11 repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour ≥ 13:00 »). Comme pour le décompte, la durée réelle de l'intervention est arrondie au quart d'heure supérieur (début 21:00, fin 23:37 → durée comptée 2h45) ; les -interventions hors fenêtre 18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 → -retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même. Un repos qui se -termine avant l'ouverture ne contraint rien. +interventions hors fenêtre 18:00→18:00 ne sont pas de l'astreinte. **L'heure de +retour tombe toujours sur un quart d'heure d'horloge fixe** (:00, :15, :30 ou :45), +arrondie au quart supérieur : fin 23:30 → retour 10:30 le lendemain ; 22:39 → +23:49 (fin comptée 23:54) → retour 11:00 ; fin 02:00 → retour 13:00 le jour même. +Un repos qui se termine avant l'ouverture ne contraint rien. À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le décompte de la semaine : diff --git a/app/calcul.py b/app/calcul.py index ee71c42..9a5a31e 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -10,6 +10,7 @@ DEFAULT_PLAGES = { "arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut) "depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun) "astreinte_forfait": "", # durée ajoutée au temps travaillé de chaque jour d'astreinte déclaré (HH:MM, vide = aucune) + "astreinte_active": True, # préférence personnelle : ce compte gère-t-il les astreintes ? } REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau @@ -312,10 +313,11 @@ dernière activité d'astreinte. 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, la durée réelle de l'intervention est arrondie au quart d'heure supérieur : la fin - comptée est le début + la durée arrondie, avant d'ajouter le repos. Le retour - s'applique au jour où se termine le repos : fin 23:30 → retour 10:30 le - lendemain ; 21:00 → 23:37 (durée comptée 2h45, fin 23:45) → retour 10:45 ; - fin 02:00 → retour 13:00 le jour même. + comptée est le début + la durée arrondie. Le retour retombe ensuite sur un + quart d'heure d'horloge fixe (:00, :15, :30, :45), arrondi au supérieur : + fin 23:30 → retour 10:30 le lendemain ; 21:00 → 23:37 (durée comptée 2h45, + fin 23:45) → retour 10:45 ; 22:39 → 23:49 (durée comptée 1h15, fin comptée + 23:54) → retour 11:00 ; fin 02:00 → retour 13:00 le jour même. Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour au-delà de l'ouverture.""" astreintes = set(astreintes or []) @@ -358,6 +360,9 @@ dernière activité d'astreinte. if not cands: return None best = max(cands) + # Le retour tombe sur un quart d'heure d'horloge fixe (:00, :15, :30, :45) : + # arrondi au quart supérieur — ex. fin comptée 23:54 + 11h = 10:54 → 11:00. + best = -(-best // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN # Un repos qui se termine avant l'ouverture ne contraint rien return best if best > hhmm_to_minutes(plages["matin_debut"]) else None diff --git a/app/export.py b/app/export.py index 21f611d..0a7cc11 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, load_feries, list_all_weeks, + load_week_pointages, load_conges, load_astreintes_effectives, load_feries, list_all_weeks, load_plages, heures_jour_min, ) @@ -105,7 +105,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes: h_jour = heures_jour_min() plages = load_plages(user_id) conges = load_conges(user_id) - astreintes = load_astreintes(user_id) + astreintes = load_astreintes_effectives(user_id) feries = load_feries(user_id) dates = _week_dates(year, week) @@ -140,7 +140,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes: h_jour = heures_jour_min() plages = load_plages(user_id) conges = load_conges(user_id) - astreintes = load_astreintes(user_id) + astreintes = load_astreintes_effectives(user_id) feries = load_feries(user_id) weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year) diff --git a/app/main.py b/app/main.py index 974c921..aa2a685 100644 --- a/app/main.py +++ b/app/main.py @@ -21,7 +21,8 @@ from fastapi.staticfiles import StaticFiles 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, + toggle_conge, list_all_weeks, toggle_astreinte, + astreinte_active, load_astreintes_effectives, load_feries, toggle_ferie, load_notif_config, save_notif_config, find_user_by_token, save_presence, regenerate_presence_token, @@ -312,7 +313,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(), load_feries(user_id)) + result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id)) result["heures_jour"] = minutes_to_hhmm(h_jour) # Panneau « aujourd'hui » : uniquement si le jour courant appartient à la # semaine affichée (re-rendu à chaque enregistrement via _soldes.html). @@ -337,7 +338,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id): iso = d.isocalendar() raw = load_week_pointages(iso.year, iso.week, user_id) plages = load_plages(user_id) - astreintes = set(load_astreintes(user_id)) + astreintes = set(load_astreintes_effectives(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, @@ -459,10 +460,11 @@ def _oob_time_cells(date, jour, plages): return matin_html + aprem_html -def _btns_html(date, jour): +def _btns_html(date, jour, as_active=True): """Bouton d'état d'une ligne (cible du swap HTMX) : pastilles résumant - congés / férié / astreinte, menu déroulant des cinq bascules. Doit rester - synchronisé avec le rendu Jinja de _ligne.html.""" + congés / férié / astreinte, menu déroulant des bascules. Doit rester + synchronisé avec le rendu Jinja de _ligne.html. as_active masque la + bascule d'astreinte des comptes qui ne la gèrent pas.""" cg = jour["conge"] chips = [] if "jour" in cg: @@ -473,7 +475,7 @@ def _btns_html(date, jour): chips.append('AM') if jour.get("ferie"): chips.append('F') - if jour.get("astreinte"): + if as_active and jour.get("astreinte"): chips.append('AS') state = "".join(chips) or '—' d = _e(date) @@ -483,6 +485,12 @@ def _btns_html(date, jour): return (f'') + # Bascule d'astreinte absente du menu pour les comptes qui ne la gèrent pas + as_opt = "" + if as_active: + as_opt = opt(f"/astreinte/{d}/toggle", "as", "AS", "Jour d'astreinte", jour.get("astreinte"), + "Jour d'astreinte (18h \u2192 18h, ou 0h \u2192 0h sam/dim/f\u00e9ri\u00e9)") + return ( f'
' f'
' @@ -497,8 +505,7 @@ def _btns_html(date, jour): + '
' + opt(f"/ferie/{d}/toggle", "ferie", "F", "Jour férié", jour.get("ferie"), "Jour férié : dû 0, astreinte comptée 0h00 → 0h00") - + opt(f"/astreinte/{d}/toggle", "as", "AS", "Jour d'astreinte", jour.get("astreinte"), - "Jour d'astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)") + + as_opt + '
' f'' f'' @@ -530,7 +537,7 @@ def _as_block_html(date, jour): rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {}) hint = "" if jour.get("retour_min") is not None: - hint = (f'' + hint = (f'' f'retour ≥ {_e(jour["retour"])}') if jour.get("forfait_min"): hint += (f'' @@ -563,7 +570,7 @@ def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() plages = load_plages(user_id) jour = _build_jour_ctx(date, conges, h_jour, user_id) - btns_html = _btns_html(date, jour) + btns_html = _btns_html(date, jour, as_active=plages.get("astreinte_active", True)) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) return HTMLResponse( @@ -788,7 +795,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(), load_feries(user_id)) + result = compute_week(pointages, conges, h_jour, plages, load_astreintes_effectives(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) @@ -834,7 +841,7 @@ def save_pointage( # Les événements d'astreinte ne sont affichés (donc postés) que les jours # qualifiés (astreinte ou lendemain) ; ailleurs on conserve ceux déjà saisis # au lieu de les écraser à vide. Les paires début/fin invalides sont ignorées. - if jour_astreinte_qualifie(date, set(load_astreintes(user_id))): + if jour_astreinte_qualifie(date, set(load_astreintes_effectives(user_id))): events = [] for deb, fin, desc in zip(astreinte_debut, astreinte_fin, astreinte_desc): deb, fin = clean(deb), clean(fin) @@ -886,9 +893,15 @@ def toggle_astreinte_route(date: str, request: Request): if not user_id: return RedirectResponse("/login", status_code=302) - toggle_astreinte(date, user_id) d = datetime.strptime(date, "%Y-%m-%d").date() iso = d.isocalendar() + # Préférence personnelle : sans gestion des astreintes, la bascule n'existe + # pas dans l'interface — on refuse aussi les POST directs. + if not astreinte_active(user_id): + logger.warning("bascule astreinte refusée (gestion désactivée): user_id=%r date=%r", user_id, date) + return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) + + toggle_astreinte(date, user_id) if not request.headers.get("HX-Request"): return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) @@ -1021,6 +1034,7 @@ 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", + "astreinte_saved": request.query_params.get("astreinte_saved") == "1", "facteurs_saved": request.query_params.get("facteurs_saved") == "1", "facteurs_error": request.query_params.get("facteurs_error") == "1", }) @@ -1121,6 +1135,26 @@ def settings_regenerate_presence_token(request: Request): ) +@app.post("/settings/astreinte-active", response_class=HTMLResponse) +def settings_astreinte_active(request: Request, astreinte_active: str = Form("")): + """Préférence personnelle : ce compte gère-t-il les astreintes ? Désactivée, + tout ce qui touche aux astreintes disparaît pour ce compte (bouton AS, + interventions, forfait, repos de 11h, récapitulatif, ratios) ; rien n'est + supprimé, une réactivation retrouve les données telles quelles.""" + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + actif = astreinte_active == "1" + plages = load_plages(user_id) + plages["astreinte_active"] = actif + save_plages(user_id, plages) + logger.info("gestion des astreintes %s: user_id=%r", "activée" if actif else "désactivée", user_id) + append_log(user_id, "settings", "Gestion des astreintes", + "activée" if actif else "désactivée (marquages et interventions conservés, interface masquée)") + return RedirectResponse("/settings?astreinte_saved=1", status_code=303) + + @app.post("/settings/plages", response_class=HTMLResponse) def settings_plages_save( request: Request, @@ -1153,7 +1187,9 @@ def settings_plages_save( return RedirectResponse("/settings?plages_error=1", status_code=303) logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields) - save_plages(user_id, fields) + # Fusionne sur les réglages existants : plages.json porte aussi le forfait + # d'astreinte et la préférence de gestion des astreintes, à conserver. + save_plages(user_id, {**load_plages(user_id), **fields}) append_log( user_id, "settings", "Plages horaires", f"matin {fields['matin_debut']}→{fields['matin_fin']}, " diff --git a/app/models.py b/app/models.py index 4130cf7..95a93cc 100644 --- a/app/models.py +++ b/app/models.py @@ -181,6 +181,22 @@ def toggle_astreinte(date: str, user_id: str = ""): save_astreintes(dates, user_id) +def astreinte_active(user_id: str) -> bool: + """Préférence personnelle : ce compte gère-t-il les astreintes ? + (stockée dans plages.json, active par défaut).""" + return bool(load_plages(user_id).get("astreinte_active", True)) + + +def load_astreintes_effectives(user_id: str = "") -> list[str]: + """Jours d'astreinte vus par les calculs et l'interface : vides si le compte + a désactivé la gestion des astreintes. Rien n'est supprimé — marquages et + interventions deviennent simplement inertes (plus de bouton AS, de décompte, + de repos de 11h ni d'export) jusqu'à réactivation.""" + if not astreinte_active(user_id): + return [] + return load_astreintes(user_id) + + # ── Jours fériés (marqués à la main) ────────────────────────────────────────── def _feries_file(user_id: str) -> Path: diff --git a/app/stats.py b/app/stats.py index e34a1e4..841b040 100644 --- a/app/stats.py +++ b/app/stats.py @@ -3,7 +3,7 @@ import statistics as _st from datetime import datetime, timedelta, date as _date from models import ( - load_week_pointages, load_conges, load_astreintes, list_all_weeks, + load_week_pointages, load_conges, load_astreintes_effectives, list_all_weeks, load_plages, heures_jour_min, ) from calcul import ( @@ -64,7 +64,7 @@ def _compliance_over(all_days: list[tuple], cg_set: set, plages: dict) -> dict: def compute_all_stats(user_id: str = "") -> dict: all_weeks = sorted(list_all_weeks(user_id)) conges = load_conges(user_id) - astreintes = set(load_astreintes(user_id)) + astreintes = set(load_astreintes_effectives(user_id)) h_jour = heures_jour_min() plages = load_plages(user_id) cg_set = {(c["date"], c["type"]) for c in conges} diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index e99bccc..fe5e011 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -98,8 +98,8 @@ {%- if 'matin' in j.conge %}MA{% endif -%} {%- if 'aprem' in j.conge %}AM{% endif -%} {%- if j.ferie %}F{% endif -%} - {%- if j.astreinte %}AS{% endif -%} - {%- if not (j.conge or j.ferie or j.astreinte) %}—{% endif -%} + {%- if plages.astreinte_active and j.astreinte %}AS{% endif -%} + {%- if not (j.conge or j.ferie or (plages.astreinte_active and j.astreinte)) %}—{% endif -%} ▾ @@ -122,11 +122,13 @@ title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00"> F Jour férié + {% if plages.astreinte_active %} + {% endif %} @@ -143,7 +145,7 @@ onclick="asToggle(this)"> ▸ {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} - {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} + {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} {% if j.forfait_min %}forfait {{ j.forfait_h }}{% endif %} {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %} diff --git a/app/templates/settings.html b/app/templates/settings.html index c840c77..d206b8b 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -50,6 +50,29 @@ +
+ Gestion des astreintes +

+ Préférence personnelle : si tu ne fais jamais d'astreinte, décoche pour + retirer de tes pages tout ce qui les concerne (bouton AS, saisie + d'interventions, forfaits, repos de 11h, récapitulatif et ratios). Rien + n'est supprimé — réactiver retrouve les données telles quelles. +

+ + {% if astreinte_saved %} +
Enregistré.
+ {% endif %} + +
+ + +
+
+ +{% if plages.astreinte_active %}
Ratios des heures d'astreinte

@@ -122,7 +145,9 @@ function asAddRatio() { } function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); } +{% endif %} +{% if plages.astreinte_active %}

Forfait par jour d'astreinte

@@ -147,6 +172,7 @@ function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }

+{% endif %}
Notifications (ntfy) Aide @@ -321,6 +347,14 @@ function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); } .as-f-jours { order: 3; width: 100%; } } .settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; } +.settings-check { + display: flex; + align-items: flex-start; + gap: .55rem; + font-size: 13px; + cursor: pointer; +} +.settings-check input { margin-top: .2rem; accent-color: var(--accent); } .settings-saved { background: var(--pos-bg); border-left: 3px solid var(--pos); diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py index 447de62..ad8af7e 100644 --- a/app/tests/test_astreinte.py +++ b/app/tests/test_astreinte.py @@ -132,6 +132,23 @@ def test_retour_fin_nocturne_hors_quart_arrondie(): assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + 15 +def test_retour_toujours_sur_un_quart_dhorloge_fixe(): + """Event 22:39→23:49 : durée 70 min arrondie à 75, fin comptée 23:54, + retour brut 10:54 → arrondi au quart d'horloge fixe supérieur 11:00. + Le retour tombe toujours sur :00, :15, :30 ou :45.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "22:39", "fin": "23:49"}])} + retour = retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) + assert retour == 11 * 60 + assert retour % PAS_ASTREINTE_MIN == 0 + + # Début déjà hors quart : 22:41→23:52 (durée 71 → 75, fin comptée 23:56, + # retour brut 10:56) → 11:00 également. + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "22:41", "fin": "23:52"}])} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 11 * 60 + + def test_retour_event_a_cheval_sur_minuit_saisi_la_veille(): """Event 23:00→01:00 (fin < début) saisi la veille : fin réelle 01:00 le jour même → 01:00 + 11h = 12:00.""" diff --git a/app/tests/test_astreinte_pref.py b/app/tests/test_astreinte_pref.py new file mode 100644 index 0000000..71f2c22 --- /dev/null +++ b/app/tests/test_astreinte_pref.py @@ -0,0 +1,102 @@ +"""La gestion des astreintes est une préférence personnelle (plages.json, +active par défaut) : désactivée, le compte ne voit plus rien qui les concerne +(bouton AS, sous-lignes d'intervention, forfait, repos de 11h, récapitulatif, +ratios) et les POST directs de bascule sont refusés — sans rien supprimer.""" +import sys + +import models # noqa: F401 (DATA_DIR temporaire posé par conftest) + +sys.path.insert(0, ".") +import main +from fastapi.testclient import TestClient + +client = TestClient(main.app) + +PLAGES_OK = { + "matin_debut": "09:00", "matin_fin": "12:00", + "aprem_debut": "14:00", "aprem_fin": "17:00", + "pause_dejeuner_fin": "13:30", + "arrivee_visee": "", "depart_vise": "", +} + + +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 _desactive(uid): + plages = models.load_plages(uid) + plages["astreinte_active"] = False + models.save_plages(uid, plages) + + +def test_bascule_de_la_preference(): + uid = "u-as-pref" + cookies = _login(uid) + assert models.astreinte_active(uid) is True # active par défaut + r = client.post("/settings/astreinte-active", cookies=cookies, follow_redirects=False) + assert r.status_code == 303 + assert models.astreinte_active(uid) is False + r = client.post("/settings/astreinte-active", cookies=cookies, + data={"astreinte_active": "1"}, follow_redirects=False) + assert r.status_code == 303 + assert models.astreinte_active(uid) is True + + +def test_desactivee_masque_interface_et_refuse_les_post(): + uid = "u-as-off" + cookies = _login(uid) + models.toggle_astreinte("2026-07-15", uid) # des données existent déjà + _desactive(uid) + + r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) + assert r.status_code == 200 + assert 'cg-opt-chip as">AS<' not in r.text # pas de bascule AS + assert "/astreinte/2026-07-15/toggle" not in r.text + assert 'class="as-decompte"' not in r.text # pas de récapitulatif + assert "retour ≥" not in r.text # pas de repos de 11h + + # POST direct refusé : données conservées mais inertes + r = client.post("/astreinte/2026-07-15/toggle", cookies=cookies, follow_redirects=False) + assert r.status_code == 303 + assert models.load_astreintes(uid) == ["2026-07-15"] + assert models.load_astreintes_effectives(uid) == [] + + # Réglages : cartes astreinte masquées, case décochée + r = client.get("/settings", cookies=cookies, follow_redirects=False) + assert "Gestion des astreintes" in r.text + assert "Forfait par jour d'astreinte" not in r.text + assert "Ratios des heures d'astreinte" not in r.text + assert 'name="astreinte_active" value="1" checked' not in r.text + + +def test_reactivation_retrouve_l_interface(): + uid = "u-as-on" + cookies = _login(uid) + models.toggle_astreinte("2026-07-15", uid) + r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False) + assert 'cg-opt-chip as">AS<' in r.text # bascule présente + assert 'cg-chip st-as">AS<' in r.text # pastille du jour marqué + + +def test_sauvegarde_plages_conserve_preference_et_forfait(): + uid = "u-as-merge" + cookies = _login(uid) + plages = models.load_plages(uid) + plages["astreinte_forfait"] = "01:30" + plages["astreinte_active"] = False + models.save_plages(uid, plages) + + r = client.post("/settings/plages", cookies=cookies, data=PLAGES_OK, + follow_redirects=False) + assert r.status_code == 303, r.text + plages = models.load_plages(uid) + assert plages["astreinte_forfait"] == "01:30" + assert plages["astreinte_active"] is False