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 ## 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 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 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** 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 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 ≥ 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 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 → interventions hors fenêtre 18:00→18:00 ne sont pas de l'astreinte. **L'heure de
retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même. Un repos qui se retour tombe toujours sur un quart d'heure d'horloge fixe** (:00, :15, :30 ou :45),
termine avant l'ouverture ne contraint rien. 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 À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le
décompte de la semaine : 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) "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) "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_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 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é 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 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 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 comptée est le début + la durée arrondie. Le retour retombe ensuite sur un
s'applique au jour où se termine le repos : fin 23:30 → retour 10:30 le quart d'heure d'horloge fixe (:00, :15, :30, :45), arrondi au supérieur :
lendemain ; 21:00 → 23:37 (durée comptée 2h45, fin 23:45) → retour 10:45 ; fin 23:30 → retour 10:30 le lendemain ; 21:00 → 23:37 (durée comptée 2h45,
fin 02:00 → retour 13:00 le jour même. 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 Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour
au-delà de l'ouverture.""" au-delà de l'ouverture."""
astreintes = set(astreintes or []) astreintes = set(astreintes or [])
@ -358,6 +360,9 @@ dernière activité d'astreinte.
if not cands: if not cands:
return None return None
best = max(cands) 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 # Un repos qui se termine avant l'ouverture ne contraint rien
return best if best > hhmm_to_minutes(plages["matin_debut"]) else None 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 calcul import compute_week, minutes_to_hhmm
from models import ( 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, 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() h_jour = heures_jour_min()
plages = load_plages(user_id) plages = load_plages(user_id)
conges = load_conges(user_id) conges = load_conges(user_id)
astreintes = load_astreintes(user_id) astreintes = load_astreintes_effectives(user_id)
feries = load_feries(user_id) feries = load_feries(user_id)
dates = _week_dates(year, week) dates = _week_dates(year, week)
@ -140,7 +140,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
h_jour = heures_jour_min() h_jour = heures_jour_min()
plages = load_plages(user_id) plages = load_plages(user_id)
conges = load_conges(user_id) conges = load_conges(user_id)
astreintes = load_astreintes(user_id) astreintes = load_astreintes_effectives(user_id)
feries = load_feries(user_id) feries = load_feries(user_id)
weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year) 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 mail import send_mail
from models import ( from models import (
load_config, load_week_pointages, save_week_pointages, load_conges, 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_feries, toggle_ferie,
load_notif_config, save_notif_config, find_user_by_token, save_presence, load_notif_config, save_notif_config, find_user_by_token, save_presence,
regenerate_presence_token, regenerate_presence_token,
@ -312,7 +313,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
for d in dates 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) result["heures_jour"] = minutes_to_hhmm(h_jour)
# Panneau « aujourd'hui » : uniquement si le jour courant appartient à la # Panneau « aujourd'hui » : uniquement si le jour courant appartient à la
# semaine affichée (re-rendu à chaque enregistrement via _soldes.html). # 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() iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week, user_id) raw = load_week_pointages(iso.year, iso.week, user_id)
plages = load_plages(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)) feries = set(load_feries(user_id))
p_by_date = {p["date"]: p for p in raw} p_by_date = {p["date"]: p for p in raw}
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None, 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 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 """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 congés / férié / astreinte, menu déroulant des bascules. Doit rester
synchronisé avec le rendu Jinja de _ligne.html.""" 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"] cg = jour["conge"]
chips = [] chips = []
if "jour" in cg: if "jour" in cg:
@ -473,7 +475,7 @@ def _btns_html(date, jour):
chips.append('<span class="cg-chip st-am">AM</span>') chips.append('<span class="cg-chip st-am">AM</span>')
if jour.get("ferie"): if jour.get("ferie"):
chips.append('<span class="cg-chip st-ferie">F</span>') 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>') chips.append('<span class="cg-chip st-as">AS</span>')
state = "".join(chips) or '<span class="cg-none">—</span>' state = "".join(chips) or '<span class="cg-none">—</span>'
d = _e(date) 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}>' 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>') 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 ( return (
f'<div id="cg-btn-{d}" class="cg-cell" hx-swap-oob="true">' f'<div id="cg-btn-{d}" class="cg-cell" hx-swap-oob="true">'
f'<details class="cg-menu">' f'<details class="cg-menu">'
@ -497,8 +505,7 @@ def _btns_html(date, jour):
+ '<div class="cg-opt-sep"></div>' + '<div class="cg-opt-sep"></div>'
+ opt(f"/ferie/{d}/toggle", "ferie", "F", "Jour férié", jour.get("ferie"), + opt(f"/ferie/{d}/toggle", "ferie", "F", "Jour férié", jour.get("ferie"),
"Jour férié : dû 0, astreinte comptée 0h00 → 0h00") "Jour férié : dû 0, astreinte comptée 0h00 → 0h00")
+ opt(f"/astreinte/{d}/toggle", "as", "AS", "Jour d&#39;astreinte", jour.get("astreinte"), + as_opt
"Jour d&#39;astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)")
+ '</div>' + '</div>'
f'</details>' f'</details>'
f'</div>' 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, {}) rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {})
hint = "" hint = ""
if jour.get("retour_min") is not None: 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>') f'retour ≥ {_e(jour["retour"])}</span>')
if jour.get("forfait_min"): 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">' 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() iso = d.isocalendar()
plages = load_plages(user_id) plages = load_plages(user_id)
jour = _build_jour_ctx(date, conges, h_jour, 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_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}) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse( return HTMLResponse(
@ -788,7 +795,7 @@ def semaine(request: Request, year: int, week: int):
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
for d in dates 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) 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) 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 # 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 # 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. # 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 = [] events = []
for deb, fin, desc in zip(astreinte_debut, astreinte_fin, astreinte_desc): for deb, fin, desc in zip(astreinte_debut, astreinte_fin, astreinte_desc):
deb, fin = clean(deb), clean(fin) deb, fin = clean(deb), clean(fin)
@ -886,9 +893,15 @@ def toggle_astreinte_route(date: str, request: Request):
if not user_id: if not user_id:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
toggle_astreinte(date, user_id)
d = datetime.strptime(date, "%Y-%m-%d").date() d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar() 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"): if not request.headers.get("HX-Request"):
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) 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", "plages_error": request.query_params.get("plages_error") == "1",
"forfait_saved": request.query_params.get("forfait_saved") == "1", "forfait_saved": request.query_params.get("forfait_saved") == "1",
"forfait_error": request.query_params.get("forfait_error") == "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_saved": request.query_params.get("facteurs_saved") == "1",
"facteurs_error": request.query_params.get("facteurs_error") == "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) @app.post("/settings/plages", response_class=HTMLResponse)
def settings_plages_save( def settings_plages_save(
request: Request, request: Request,
@ -1153,7 +1187,9 @@ def settings_plages_save(
return RedirectResponse("/settings?plages_error=1", status_code=303) return RedirectResponse("/settings?plages_error=1", status_code=303)
logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields) 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( append_log(
user_id, "settings", "Plages horaires", user_id, "settings", "Plages horaires",
f"matin {fields['matin_debut']}→{fields['matin_fin']}, " 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) 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) ────────────────────────────────────────── # ── Jours fériés (marqués à la main) ──────────────────────────────────────────
def _feries_file(user_id: str) -> Path: 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 datetime import datetime, timedelta, date as _date
from models import ( 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, load_plages, heures_jour_min,
) )
from calcul import ( 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: def compute_all_stats(user_id: str = "") -> dict:
all_weeks = sorted(list_all_weeks(user_id)) all_weeks = sorted(list_all_weeks(user_id))
conges = load_conges(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() h_jour = heures_jour_min()
plages = load_plages(user_id) plages = load_plages(user_id)
cg_set = {(c["date"], c["type"]) for c in conges} 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 '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 '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.ferie %}<span class="cg-chip st-ferie">F</span>{% endif -%}
{%- if j.astreinte %}<span class="cg-chip st-as">AS</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 j.astreinte) %}<span class="cg-none">—</span>{% endif -%} {%- if not (j.conge or j.ferie or (plages.astreinte_active and j.astreinte)) %}<span class="cg-none">—</span>{% endif -%}
</span> </span>
<span class="cg-caret">▾</span> <span class="cg-caret">▾</span>
</summary> </summary>
@ -122,11 +122,13 @@
title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00"> title="Jour férié : dû 0, astreinte comptée 0h00 → 0h00">
<span class="cg-opt-chip ferie">F</span> Jour férié <span class="cg-opt-chip ferie">F</span> Jour férié
</button> </button>
{% if plages.astreinte_active %}
<button hx-post="/astreinte/{{ j.date }}/toggle" hx-swap="none" <button hx-post="/astreinte/{{ j.date }}/toggle" hx-swap="none"
class="cg-opt{% if j.astreinte %} on{% endif %}" class="cg-opt{% if j.astreinte %} on{% endif %}"
title="Jour d'astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)"> title="Jour d'astreinte (18h → 18h, ou 0h → 0h sam/dim/férié)">
<span class="cg-opt-chip as">AS</span> Jour d'astreinte <span class="cg-opt-chip as">AS</span> Jour d'astreinte
</button> </button>
{% endif %}
</div> </div>
</details> </details>
</div> </div>
@ -143,7 +145,7 @@
onclick="asToggle(this)"> onclick="asToggle(this)">
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} <span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
</button> </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.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 %} {% 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> </div>

View File

@ -50,6 +50,29 @@
</form> </form>
</div> </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"> <div class="settings-card">
<span class="hist-label">Ratios des heures d'astreinte</span> <span class="hist-label">Ratios des heures d'astreinte</span>
<p class="settings-p"> <p class="settings-p">
@ -122,7 +145,9 @@ function asAddRatio() {
} }
function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); } function asDelRatio(btn) { btn.closest('.as-ratio-edit').remove(); }
</script> </script>
{% endif %}
{% if plages.astreinte_active %}
<div class="settings-card"> <div class="settings-card">
<span class="hist-label">Forfait par jour d'astreinte</span> <span class="hist-label">Forfait par jour d'astreinte</span>
<p class="settings-p"> <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> <button class="login-btn" type="submit">Enregistrer</button>
</form> </form>
</div> </div>
{% endif %}
<div class="settings-card"> <div class="settings-card">
<span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span> <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%; } .as-f-jours { order: 3; width: 100%; }
} }
.settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; } .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 { .settings-saved {
background: var(--pos-bg); background: var(--pos-bg);
border-left: 3px solid var(--pos); 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 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(): 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 """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.""" 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