Ajoute les jours fériés, l'astreinte week-end 0h-0h et les ratios éditables

- Bouton F : marque un jour férié (dû 0, sans consommer de congé) ; il
  qualifie les règles « ferie » et étend la fenêtre d'astreinte du jour.
- Samedi, dimanche et fériés : l'astreinte compte de 0h00 à 0h00 ; un jour
  AS déclaré garde sa borne du lendemain 18h pour ne pas tronquer les
  interventions nocturnes.
- Ratios d'astreinte éditables à la main dans Réglages (nom, jours, fenêtre,
  ratio), avec ajout/suppression de règles et validation.
- Un quart d'heure entamé par deux interventions qui se suivent n'est compté
  qu'une fois : les lignes d'événements somment au total du jour.
- Décompte mieux expliqué dans l'interface : quarts d'horloge (:00/:15/:30/:45),
  fenêtre couverte par événement, exemple chiffré.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 23:30:15 +02:00
parent 23271d736d
commit dc80e81db2
11 changed files with 599 additions and 111 deletions

View File

@ -22,12 +22,13 @@ 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,
load_feries, toggle_ferie,
load_notif_config, save_notif_config, find_user_by_token, save_presence,
regenerate_presence_token,
load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages, heures_jour_min,
append_log, load_logs, load_or_create_secret,
load_facteurs_astreinte,
load_facteurs_astreinte, save_facteurs_astreinte,
)
from calcul import (
compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues,
@ -129,6 +130,7 @@ JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanc
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$")
_EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$")
_HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
_JOURS_AS_VALIDES = ("semaine", "sam", "dim", "ferie")
# Échappement HTML pour les fragments construits en f-string hors templates Jinja
# (autoescape ne s'applique qu'aux templates). Defense-in-depth contre le XSS stocké.
@ -310,7 +312,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())
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id))
result["heures_jour"] = minutes_to_hhmm(h_jour)
return {"result": result}
@ -321,25 +323,27 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
raw = load_week_pointages(iso.year, iso.week, user_id)
plages = load_plages(user_id)
astreintes = set(load_astreintes(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,
"aprem_entree": None, "aprem_sortie": None})
conges_date = {c["type"] for c in conges if c["date"] == date}
jour_ferie = date in feries
# Retour au bureau après 11h de repos, calculé depuis la fin de la dernière
# activité d'astreinte (saisie sur le jour ou la veille qualifiés)
retour_min = retour_apres_repos(date, p_by_date, plages, astreintes)
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie)
forfait = (hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0) \
if date in astreintes else 0
trav = heures_travaillees(p, retour_min) + forfait
du = heures_dues(date, conges, h_jour, plages, retour_min)
du = heures_dues(date, conges, h_jour, plages, retour_min, jour_ferie)
du_full = du + matin_ex + aprem_ex
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = trav - du
# Décompte astreinte du jour (compteur séparé, hors heures de semaine) :
# seulement si des événements sont stockés et que le jour est qualifié.
events = p.get("astreinte_events") or []
dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes))
dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes, feries), feries)
if events and jour_astreinte_qualifie(date, astreintes) else None)
return {
**p,
@ -348,6 +352,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta),
"conge": conges_date,
"ferie": jour_ferie,
"forfait_min": forfait,
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
"du_full_min": du_full,
@ -440,18 +445,20 @@ def _oob_time_cells(date, jour, plages):
def _btns_html(date, jour):
"""Boutons congé + astreinte d'une ligne (cible du swap HTMX)."""
"""Boutons congé + astreinte + férié d'une ligne (cible du swap HTMX)."""
cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else ""
btn_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else ""
btn_as_on = " as-on" if jour.get("astreinte") else ""
btn_f_on = " ferie-on" if jour.get("ferie") else ""
return (
f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">'
f'<div class="conge-btns">'
f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'<button hx-post="/ferie/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-ferie{btn_f_on}" title="Jour férié (dû 0, astreinte comptée 0h00 → 0h00)">F</button>'
f'<button hx-post="/astreinte/{_e(date)}/toggle" hx-swap="none" class="btn-cg btn-as{btn_as_on}" title="Jour d&#39;astreinte">AS</button>'
f'</div></div>'
)
@ -489,7 +496,7 @@ def _as_block_html(date, jour):
f'forfait + {_e(jour["forfait_h"])}</span>')
total = ""
if jour.get("astreinte_min"):
total = (f'<span class="as-total" title="Compteur séparé, hors heures de semaine">'
total = (f'<span class="as-total" title="Décompte par quarts d&#39;heure de l&#39;horloge entamés (:00, :15, :30, :45), chacun compté 15 min entières — compteur séparé, hors heures de semaine">'
f'astreinte : {_e(jour["astreinte_h"])}</span>')
nb = len(events)
label = f'{nb} intervention' + ('s' if nb != 1 else '')
@ -740,7 +747,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())
result = compute_week(pointages, conges, h_jour, plages, load_astreintes(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)
@ -866,6 +873,23 @@ def toggle_astreinte_route(date: str, request: Request):
return HTMLResponse(html + _oob_week_extras(soldes_ctx["result"]) + soldes_html)
@app.post("/ferie/{date}/toggle", response_class=HTMLResponse)
def toggle_ferie_route(date: str, request: Request):
"""Marque/démarque un jour férié : dû à 0, aucune présence obligatoire, et
fenêtre d'astreinte étendue à la journée entière (0h00 → 0h00)."""
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
toggle_ferie(date, user_id)
d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar()
if request.headers.get("HX-Request"):
conges = load_conges(user_id)
return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
@app.get("/stats", response_class=HTMLResponse)
def stats_page(request: Request):
user_id = _get_user(request)
@ -956,6 +980,8 @@ 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",
"facteurs_saved": request.query_params.get("facteurs_saved") == "1",
"facteurs_error": request.query_params.get("facteurs_error") == "1",
})
@ -1118,6 +1144,46 @@ def settings_astreinte_forfait(request: Request, astreinte_forfait: str = Form("
return RedirectResponse("/settings?forfait_saved=1", status_code=303)
@app.post("/settings/astreinte-facteurs", response_class=HTMLResponse)
async def settings_astreinte_facteurs(request: Request):
"""Règles de pondération des heures d'astreinte, éditables à la main :
nom, types de jours, fenêtre horaire et ratio de chaque règle. Une règle au
nom vide est supprimée ; une règle invalide rejette l'enregistrement."""
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
form = await request.form()
indices = sorted({int(m.group(1)) for k in form.keys()
if (m := re.fullmatch(r"f_nom_(\d+)", k))})
regles = []
for i in indices:
nom = str(form.get(f"f_nom_{i}") or "").strip()
if not nom:
continue
debut = str(form.get(f"f_debut_{i}") or "")
fin = str(form.get(f"f_fin_{i}") or "")
facteur_txt = str(form.get(f"f_facteur_{i}") or "").strip().replace(",", ".")
jours = [j for j in form.getlist(f"f_jours_{i}") if j in _JOURS_AS_VALIDES]
try:
facteur = float(facteur_txt)
except ValueError:
facteur = 0.0
if not _HHMM_RE.match(debut) or not _HHMM_RE.match(fin) or facteur <= 0 or not jours:
logger.warning("règle astreinte rejetée (invalide): user_id=%r nom=%r debut=%r fin=%r facteur=%r jours=%r",
user_id, nom, debut, fin, facteur_txt, jours)
return RedirectResponse("/settings?facteurs_error=1", status_code=303)
regles.append({"nom": nom, "jours": jours, "debut": debut, "fin": fin, "facteur": facteur})
if not regles:
logger.warning("règles astreinte vides, rien n'est enregistré: user_id=%r", user_id)
return RedirectResponse("/settings?facteurs_error=1", status_code=303)
save_facteurs_astreinte(regles)
logger.info("règles astreinte mises à jour: user_id=%r n=%d", user_id, len(regles))
append_log(user_id, "settings", "Ratios astreinte", f"{len(regles)} règle(s)")
return RedirectResponse("/settings?facteurs_saved=1", status_code=303)
@app.post("/presence/{token}/arrivee")
def presence_arrivee(request: Request, token: str):
user_id = find_user_by_token(token)