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 |
|
| Matin | 09:00 → 12:00 |
|
||||||
| Après-midi | 14:00 → 17: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
|
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**
|
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",
|
"user": "no-reply@exemple.fr",
|
||||||
"password": "change-moi",
|
"password": "change-moi",
|
||||||
"use_tls": true
|
"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.
|
- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
|
||||||
- `mail_from` — adresse expéditrice des emails de connexion.
|
- `mail_from` — adresse expéditrice des emails de connexion.
|
||||||
- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS).
|
- `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,
|
Les rappels ntfy (topic, serveur, plage horaire) et les **plages horaires** (bornes de
|
||||||
pas dans `config.json`.
|
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,
|
toggle_conge, list_all_weeks,
|
||||||
load_notif_config, save_notif_config, find_user_by_token, save_presence,
|
load_notif_config, save_notif_config, find_user_by_token, save_presence,
|
||||||
load_auth, save_auth, find_user_by_reset_token,
|
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 stats import compute_all_stats
|
||||||
from notifications import reminder_loop
|
from notifications import reminder_loop
|
||||||
|
|
||||||
@ -55,6 +56,7 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
|||||||
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
|
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
|
||||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$")
|
_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})$")
|
_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 ──────────────────────────────────────────────────────────────
|
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
||||||
@ -96,10 +98,6 @@ def heures_jour_min() -> int:
|
|||||||
return int(float(load_config().get("heures_jour", 7.8)) * 60)
|
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:
|
def _weeks_by_year(all_weeks: list) -> dict:
|
||||||
result = {}
|
result = {}
|
||||||
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
|
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})
|
"aprem_entree": None, "aprem_sortie": None})
|
||||||
for d in dates
|
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):
|
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):
|
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
plages = plages_config()
|
plages = load_plages(user_id)
|
||||||
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
||||||
cg = jour["conge"]
|
cg = jour["conge"]
|
||||||
btn_ma_on = " on" if "matin" in cg else ""
|
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):
|
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
plages = plages_config()
|
plages = load_plages(user_id)
|
||||||
jour = _build_jour_ctx(date, conges, h_jour, 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_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})
|
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)
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
h_jour = heures_jour_min()
|
h_jour = heures_jour_min()
|
||||||
plages = plages_config()
|
plages = load_plages(user_id)
|
||||||
conges = load_conges(user_id)
|
conges = load_conges(user_id)
|
||||||
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, 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)
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
notif = load_notif_config(user_id)
|
notif = load_notif_config(user_id)
|
||||||
|
plages = load_plages(user_id)
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
return templates.TemplateResponse("settings.html", {
|
return templates.TemplateResponse("settings.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
@ -537,7 +536,10 @@ def settings_page(request: Request):
|
|||||||
"rappel_fin_h": notif.get("rappel_fin_h", 20),
|
"rappel_fin_h": notif.get("rappel_fin_h", 20),
|
||||||
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
|
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
|
||||||
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
|
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
|
||||||
|
"plages": plages,
|
||||||
"saved": request.query_params.get("saved") == "1",
|
"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)
|
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")
|
@app.get("/presence/{token}/arrivee")
|
||||||
def presence_arrivee(token: str):
|
def presence_arrivee(token: str):
|
||||||
user_id = find_user_by_token(token)
|
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 = ""):
|
def toggle_conge(date: str, type_conge: str, user_id: str = ""):
|
||||||
assert type_conge in ("jour", "matin", "aprem")
|
assert type_conge in ("jour", "matin", "aprem")
|
||||||
conges = load_conges(user_id)
|
conges = load_conges(user_id)
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
import statistics as _st
|
import statistics as _st
|
||||||
from datetime import datetime, timedelta, date as _date
|
from datetime import datetime, timedelta, date as _date
|
||||||
|
|
||||||
from models import load_week_pointages, load_conges, list_all_weeks, load_config
|
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, DEFAULT_PLAGES
|
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues
|
||||||
|
|
||||||
MOIS_FR = [
|
MOIS_FR = [
|
||||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
@ -60,7 +60,7 @@ def compute_all_stats(user_id: str = "") -> dict:
|
|||||||
conges = load_conges(user_id)
|
conges = load_conges(user_id)
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
h_jour = int(float(cfg.get("heures_jour", 7.8)) * 60)
|
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}
|
cg_set = {(c["date"], c["type"]) for c in conges}
|
||||||
|
|
||||||
today = _date.today()
|
today = _date.today()
|
||||||
|
|||||||
@ -7,6 +7,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="settings-card">
|
||||||
<span class="hist-label">Notifications (ntfy)</span>
|
<span class="hist-label">Notifications (ntfy)</span>
|
||||||
<p class="settings-p">
|
<p class="settings-p">
|
||||||
@ -102,6 +136,14 @@
|
|||||||
padding: .5rem .8rem;
|
padding: .5rem .8rem;
|
||||||
font-family: var(--mono);
|
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-urlrow { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||||
.settings-urllabel {
|
.settings-urllabel {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
|
|||||||
@ -9,12 +9,5 @@
|
|||||||
"user": "no-reply@exemple.fr",
|
"user": "no-reply@exemple.fr",
|
||||||
"password": "change-moi",
|
"password": "change-moi",
|
||||||
"use_tls": true
|
"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