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:
21
README.md
21
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
48
app/main.py
48
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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 < matin_fin < aprem_debut < 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);
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user