From d87bf6a212a670285cded78d3a0505bf17f6eaa4 Mon Sep 17 00:00:00 2001 From: Antoine Date: Fri, 17 Jul 2026 23:15:06 +0200 Subject: [PATCH] =?UTF-8?q?Rend=20les=20plages=20horaires=20configurables?= =?UTF-8?q?=20par=20utilisateur=20plut=C3=B4t=20que=20globalement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque compte a désormais son propre data/users//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é. --- README.md | 21 ++++++---------- app/main.py | 48 ++++++++++++++++++++++++++++++------- app/models.py | 24 +++++++++++++++++++ app/stats.py | 6 ++--- app/templates/settings.html | 42 ++++++++++++++++++++++++++++++++ data/config.example.json | 7 ------ 6 files changed, 115 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0ee980a..d068465 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ La semaine cible est **39h** (7h48 / jour). Les directives imposent des plages d | Matin | 09:00 → 12:00 | | Après-midi | 14:00 → 17:00 | -Ces horaires sont configurables (voir [Configuration](#configuration)). +Ces horaires sont réglables par utilisateur depuis la page **Réglages** de l'app (voir +[Configuration](#configuration)). Quand un écart apparaît, l'outil ne le reporte pas d'un coup sur la fin de la journée en cours : il **répartit le manque à parts égales sur toutes les demi-journées restantes** @@ -86,13 +87,6 @@ Seul `data/config.example.json` est versionné. "user": "no-reply@exemple.fr", "password": "change-moi", "use_tls": true - }, - "plages": { - "matin_debut": "09:00", - "matin_fin": "12:00", - "aprem_debut": "14:00", - "aprem_fin": "17:00", - "pause_dejeuner_fin": "13:30" } } ``` @@ -102,13 +96,12 @@ Seul `data/config.example.json` est versionné. - `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter. - `mail_from` — adresse expéditrice des emails de connexion. - `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS). -- `plages` — bornes de présence obligatoire matin/après-midi (voir [Principe](#principe)) et heure de reprise - par défaut de l'après-midi (`pause_dejeuner_fin`) tant que l'entrée aprem n'est pas encore saisie. Modifier - ces valeurs change à la fois les avertissements de saisie, les stats de conformité et le calcul de l'heure - de sortie optimale. -Les rappels ntfy (topic, serveur, plage horaire) se règlent par utilisateur depuis la page **Réglages** de l'app, -pas dans `config.json`. +Les rappels ntfy (topic, serveur, plage horaire) et les **plages horaires** (bornes de +présence obligatoire matin/après-midi, voir [Principe](#principe), et heure de reprise +par défaut de l'après-midi) se règlent **par utilisateur** depuis la page **Réglages** +de l'app, pas dans `config.json`. Modifier ses plages change, pour ce compte uniquement, +les avertissements de saisie, les stats de conformité et les préconisations de rattrapage. --- diff --git a/app/main.py b/app/main.py index 4d1c14f..04122bd 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py index 4812566..79e2b39 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/stats.py b/app/stats.py index c4cf223..36306f5 100644 --- a/app/stats.py +++ b/app/stats.py @@ -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() diff --git a/app/templates/settings.html b/app/templates/settings.html index 3a3b42b..7c02396 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -7,6 +7,40 @@ +
+ Plages horaires +

+ 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. +

+ + {% if plages_saved %} +
Enregistré.
+ {% endif %} + {% if plages_error %} +
Horaires invalides (format HH:MM, matin_debut < matin_fin < aprem_debut < aprem_fin).
+ {% endif %} + +
+ +
+ + + +
+ +
+ + + +
+ + + +
+
+
Notifications (ntfy)

@@ -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); diff --git a/data/config.example.json b/data/config.example.json index 76f48d5..a106702 100644 --- a/data/config.example.json +++ b/data/config.example.json @@ -9,12 +9,5 @@ "user": "no-reply@exemple.fr", "password": "change-moi", "use_tls": true - }, - "plages": { - "matin_debut": "09:00", - "matin_fin": "12:00", - "aprem_debut": "14:00", - "aprem_fin": "17:00", - "pause_dejeuner_fin": "13:30" } }