Rend les plages horaires configurables par utilisateur plutôt que globalement

Chaque compte a désormais son propre data/users/<user>/plages.json, réglable
depuis une nouvelle carte "Plages horaires" sur /settings (avec validation
HH:MM et ordre matin < aprem). Le calcul, les stats de conformité et les
avertissements de saisie lisent la config de l'utilisateur courant au lieu
du plages global. La valeur globale de data/config.json reste lue comme
valeur de repli tant qu'un utilisateur n'a rien personnalisé.
This commit is contained in:
Antoine
2026-07-17 23:15:06 +02:00
parent dd8848a115
commit d87bf6a212
6 changed files with 115 additions and 33 deletions

View File

@ -18,8 +18,9 @@ from models import (
toggle_conge, list_all_weeks,
load_notif_config, save_notif_config, find_user_by_token, save_presence,
load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages,
)
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues, DEFAULT_PLAGES
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats
from notifications import reminder_loop
@ -55,6 +56,7 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
_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$")
# ── Auth helpers ──────────────────────────────────────────────────────────────
@ -96,10 +98,6 @@ def heures_jour_min() -> int:
return int(float(load_config().get("heures_jour", 7.8)) * 60)
def plages_config() -> dict:
return {**DEFAULT_PLAGES, **load_config().get("plages", {})}
def _weeks_by_year(all_weeks: list) -> dict:
result = {}
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
@ -116,7 +114,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
return {"result": compute_week(pointages, conges, h_jour, plages_config())}
return {"result": compute_week(pointages, conges, h_jour, load_plages(user_id))}
def _build_jour_ctx(date, conges, h_jour, user_id):
@ -214,7 +212,7 @@ def _oob_time_cells(date, jour, plages):
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar()
plages = plages_config()
plages = load_plages(user_id)
jour = _build_jour_ctx(date, conges, h_jour, user_id)
cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else ""
@ -241,7 +239,7 @@ def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar()
plages = plages_config()
plages = load_plages(user_id)
jour = _build_jour_ctx(date, 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})
@ -406,7 +404,7 @@ def semaine(request: Request, year: int, week: int):
return RedirectResponse("/login", status_code=302)
h_jour = heures_jour_min()
plages = plages_config()
plages = load_plages(user_id)
conges = load_conges(user_id)
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
@ -527,6 +525,7 @@ def settings_page(request: Request):
return RedirectResponse("/login", status_code=302)
notif = load_notif_config(user_id)
plages = load_plages(user_id)
base_url = str(request.base_url).rstrip("/")
return templates.TemplateResponse("settings.html", {
"request": request,
@ -537,7 +536,10 @@ def settings_page(request: Request):
"rappel_fin_h": notif.get("rappel_fin_h", 20),
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
"plages": plages,
"saved": request.query_params.get("saved") == "1",
"plages_saved": request.query_params.get("plages_saved") == "1",
"plages_error": request.query_params.get("plages_error") == "1",
})
@ -561,6 +563,34 @@ def settings_save(
return RedirectResponse("/settings?saved=1", status_code=303)
@app.post("/settings/plages", response_class=HTMLResponse)
def settings_plages_save(
request: Request,
matin_debut: str = Form(""), matin_fin: str = Form(""),
aprem_debut: str = Form(""), aprem_fin: str = Form(""),
pause_dejeuner_fin: str = Form(""),
):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
fields = {
"matin_debut": matin_debut, "matin_fin": matin_fin,
"aprem_debut": aprem_debut, "aprem_fin": aprem_fin,
"pause_dejeuner_fin": pause_dejeuner_fin,
}
if not all(_HHMM_RE.match(v) for v in fields.values()):
logger.warning("réglages plages rejetés (format invalide): user_id=%r valeurs=%r", user_id, fields)
return RedirectResponse("/settings?plages_error=1", status_code=303)
if not (fields["matin_debut"] < fields["matin_fin"] < fields["aprem_debut"] < fields["aprem_fin"]):
logger.warning("réglages plages rejetés (ordre invalide): user_id=%r valeurs=%r", user_id, fields)
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)
return RedirectResponse("/settings?plages_saved=1", status_code=303)
@app.get("/presence/{token}/arrivee")
def presence_arrivee(token: str):
user_id = find_user_by_token(token)

View File

@ -210,6 +210,30 @@ def save_presence(user_id: str, present: bool, since: str | None):
)
# ── Plages horaires (par utilisateur) ─────────────────────────────────────────
def _plages_file(user_id: str) -> Path:
return _user_root(user_id) / "plages.json"
def load_plages(user_id: str) -> dict:
"""Return this user's plage config, falling back to the (legacy) global config
default, then to DEFAULT_PLAGES, for any key not yet customized."""
base = {**DEFAULT_PLAGES, **load_config().get("plages", {})}
f = _plages_file(user_id)
if f.exists():
try:
return {**base, **json.loads(f.read_text())}
except json.JSONDecodeError:
pass
return base
def save_plages(user_id: str, plages: dict):
_ensure_user_dirs(user_id)
_plages_file(user_id).write_text(json.dumps(plages, indent=2, ensure_ascii=False))
def toggle_conge(date: str, type_conge: str, user_id: str = ""):
assert type_conge in ("jour", "matin", "aprem")
conges = load_conges(user_id)

View File

@ -2,8 +2,8 @@
import statistics as _st
from datetime import datetime, timedelta, date as _date
from models import load_week_pointages, load_conges, list_all_weeks, load_config
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues, DEFAULT_PLAGES
from models import load_week_pointages, load_conges, list_all_weeks, load_config, load_plages
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues
MOIS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
@ -60,7 +60,7 @@ def compute_all_stats(user_id: str = "") -> dict:
conges = load_conges(user_id)
cfg = load_config()
h_jour = int(float(cfg.get("heures_jour", 7.8)) * 60)
plages = {**DEFAULT_PLAGES, **cfg.get("plages", {})}
plages = load_plages(user_id)
cg_set = {(c["date"], c["type"]) for c in conges}
today = _date.today()

View File

@ -7,6 +7,40 @@
</div>
</div>
<div class="settings-card">
<span class="hist-label">Plages horaires</span>
<p class="settings-p">
Bornes de présence obligatoire matin/après-midi et heure de reprise par défaut de
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
fin de demi-journée. Propres à ton compte — ne change rien pour les autres.
</p>
{% if plages_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
{% if plages_error %}
<div class="settings-error">Horaires invalides (format HH:MM, matin_debut &lt; matin_fin &lt; aprem_debut &lt; aprem_fin).</div>
{% endif %}
<form method="post" action="/settings/plages" class="settings-form">
<label class="login-label" for="matin_debut">Présence matin</label>
<div class="settings-range">
<input id="matin_debut" class="login-input settings-range-input" type="time" name="matin_debut" value="{{ plages.matin_debut }}">
<span class="settings-range-sep"></span>
<input id="matin_fin" class="login-input settings-range-input" type="time" name="matin_fin" value="{{ plages.matin_fin }}">
</div>
<label class="login-label" for="aprem_debut">Présence après-midi</label>
<div class="settings-range">
<input id="aprem_debut" class="login-input settings-range-input" type="time" name="aprem_debut" value="{{ plages.aprem_debut }}">
<span class="settings-range-sep"></span>
<input id="aprem_fin" class="login-input settings-range-input" type="time" name="aprem_fin" value="{{ plages.aprem_fin }}">
</div>
<label class="login-label" for="pause_dejeuner_fin">Reprise par défaut de l'après-midi</label>
<input id="pause_dejeuner_fin" class="login-input settings-range-input" type="time" name="pause_dejeuner_fin" value="{{ plages.pause_dejeuner_fin }}">
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
<div class="settings-card">
<span class="hist-label">Notifications (ntfy)</span>
<p class="settings-p">
@ -102,6 +136,14 @@
padding: .5rem .8rem;
font-family: var(--mono);
}
.settings-error {
background: var(--neg-bg);
border-left: 3px solid var(--neg);
color: var(--neg);
font-size: 12px;
padding: .5rem .8rem;
font-family: var(--mono);
}
.settings-urlrow { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.settings-urllabel {
font-family: var(--mono);