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:
84
app/main.py
84
app/main.py
@ -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'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'heure de l'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)
|
||||
|
||||
Reference in New Issue
Block a user