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''
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