Astreinte : préférence personnelle et retour aligné sur les quarts d'horloge

- 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
This commit is contained in:
Jacquin Antoine
2026-09-09 20:48:59 +02:00
parent e9983a28c7
commit 3d38abd853
10 changed files with 250 additions and 30 deletions

View File

@ -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 :

View File

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

View File

@ -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)

View File

@ -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('<span class="cg-chip st-am">AM</span>')
if jour.get("ferie"):
chips.append('<span class="cg-chip st-ferie">F</span>')
if jour.get("astreinte"):
if as_active and jour.get("astreinte"):
chips.append('<span class="cg-chip st-as">AS</span>')
state = "".join(chips) or '<span class="cg-none">—</span>'
d = _e(date)
@ -483,6 +485,12 @@ def _btns_html(date, jour):
return (f'<button hx-post="{url}" hx-swap="none" class="cg-opt{" on" if on else ""}"{t}>'
f'<span class="cg-opt-chip {chip_cls}">{chip_txt}</span> {label}</button>')
# 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&#39;astreinte", jour.get("astreinte"),
"Jour d&#39;astreinte (18h \u2192 18h, ou 0h \u2192 0h sam/dim/f\u00e9ri\u00e9)")
return (
f'<div id="cg-btn-{d}" class="cg-cell" hx-swap-oob="true">'
f'<details class="cg-menu">'
@ -497,8 +505,7 @@ def _btns_html(date, jour):
+ '<div class="cg-opt-sep"></div>'
+ 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&#39;astreinte", jour.get("astreinte"),
"Jour d&#39;astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)")
+ as_opt
+ '</div>'
f'</details>'
f'</div>'
@ -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'<span class="as-hint" title="11h de repos consécutif après la dernière activité, durée arrondie au quart d\'heure supérieur">'
hint = (f'<span class="as-hint" title="11h de repos consécutif après la dernière activité (durée arrondie au quart d\'heure supérieur), retour arrondi au quart d\'horloge supérieur : :00, :15, :30 ou :45">'
f'retour ≥ {_e(jour["retour"])}</span>')
if jour.get("forfait_min"):
hint += (f'<span class="as-hint" title="Forfait du jour AS déclaré (réglé dans Réglages) : suivi dans le récapitulatif d&#39;astreinte, hors heures travaillées">'
@ -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']}, "

View File

@ -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:

View File

@ -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}

View File

@ -98,8 +98,8 @@
{%- if 'matin' in j.conge %}<span class="cg-chip st-ma">MA</span>{% endif -%}
{%- if 'aprem' in j.conge %}<span class="cg-chip st-am">AM</span>{% endif -%}
{%- if j.ferie %}<span class="cg-chip st-ferie">F</span>{% endif -%}
{%- if j.astreinte %}<span class="cg-chip st-as">AS</span>{% endif -%}
{%- if not (j.conge or j.ferie or j.astreinte) %}<span class="cg-none">—</span>{% endif -%}
{%- if plages.astreinte_active and j.astreinte %}<span class="cg-chip st-as">AS</span>{% endif -%}
{%- if not (j.conge or j.ferie or (plages.astreinte_active and j.astreinte)) %}<span class="cg-none">—</span>{% endif -%}
</span>
<span class="cg-caret">▾</span>
</summary>
@ -122,11 +122,13 @@
title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">
<span class="cg-opt-chip ferie">F</span> Jour férié
</button>
{% if plages.astreinte_active %}
<button hx-post="/astreinte/{{ j.date }}/toggle" hx-swap="none"
class="cg-opt{% if j.astreinte %} on{% endif %}"
title="Jour d'astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)">
<span class="cg-opt-chip as">AS</span> Jour d'astreinte
</button>
{% endif %}
</div>
</details>
</div>
@ -143,7 +145,7 @@
onclick="asToggle(this)">
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
</button>
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité, durée arrondie au quart d'heure supérieur">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité (durée arrondie au quart d'heure supérieur), retour arrondi au quart d'horloge supérieur : :00, :15, :30 ou :45">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.forfait_min %}<span class="as-hint" title="Forfait du jour AS déclaré (Réglages) : suivi dans le récapitulatif d'astreinte, hors heures travaillées">forfait {{ j.forfait_h }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Durée réelle des interventions arrondie au quart d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même sans tomber sur un quart d'heure de l'horloge — compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
</div>

View File

@ -50,6 +50,29 @@
</form>
</div>
<div class="settings-card">
<span class="hist-label">Gestion des astreintes</span>
<p class="settings-p">
Préférence personnelle : si tu ne fais jamais d'astreinte, décoche pour
retirer de tes pages tout ce qui les concerne (bouton <b>AS</b>, saisie
d'interventions, forfaits, repos de 11h, récapitulatif et ratios). Rien
n'est supprimé — réactiver retrouve les données telles quelles.
</p>
{% if astreinte_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
<form method="post" action="/settings/astreinte-active" class="settings-form">
<label class="settings-check">
<input type="checkbox" name="astreinte_active" value="1" {% if plages.astreinte_active %}checked{% endif %}>
<span>Je gère des astreintes sur ce compte</span>
</label>
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
{% if plages.astreinte_active %}
<div class="settings-card">
<span class="hist-label">Ratios des heures d'astreinte</span>
<p class="settings-p">
@ -122,7 +145,9 @@ function asAddRatio() {
}
function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }
</script>
{% endif %}
{% if plages.astreinte_active %}
<div class="settings-card">
<span class="hist-label">Forfait par jour d'astreinte</span>
<p class="settings-p">
@ -147,6 +172,7 @@ function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
{% endif %}
<div class="settings-card">
<span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span>
@ -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);

View File

@ -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."""

View File

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