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

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

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);

View File

@ -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"
}
}