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:
14
README.md
14
README.md
@ -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 :
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
66
app/main.py
66
app/main.py
@ -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'astreinte", jour.get("astreinte"),
|
||||
"Jour d'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'astreinte", jour.get("astreinte"),
|
||||
"Jour d'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'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']}, "
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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."""
|
||||
|
||||
102
app/tests/test_astreinte_pref.py
Normal file
102
app/tests/test_astreinte_pref.py
Normal 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
|
||||
Reference in New Issue
Block a user