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

@ -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']}, "