Implémente système multi-utilisateurs avec classement

- Auth cookie simple (SSO-ready) : login/logout, redirection si non authentifié
- Données isolées par utilisateur dans /data/users/{user_id}/
- Migration automatique des données legacy au premier login
- Page classement avec podium et tableau comparatif (score, précision zéro, conformité)
- Nav mise à jour : Classement, affichage utilisateur courant, déconnexion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
toto
2026-06-24 17:29:07 +02:00
parent 1a34bff1c6
commit b6e0fcd925
7 changed files with 673 additions and 131 deletions

48
app/classement.py Normal file
View File

@ -0,0 +1,48 @@
"""Leaderboard stats: compare all users on overtime precision and directive compliance."""
from models import list_users
from stats import compute_all_stats, _compliance_over, _day_compliant
from calcul import minutes_to_hhmm
def compute_classement() -> list[dict]:
"""Return ranked list of users with their key metrics."""
results = []
for user_id in list_users():
s = compute_all_stats(user_id)
if not s["weekly_balances"] and s["n_jours"] == 0:
continue # skip empty accounts
balances = [b["delta_min"] for b in s["weekly_balances"]]
# Precision: % of weeks where |solde| ≤ 30 min (≈ at zero)
if balances:
pct_zero = round(100 * sum(1 for b in balances if abs(b) <= 30) / len(balances))
mean_abs = round(sum(abs(b) for b in balances) / len(balances))
else:
pct_zero = None
mean_abs = None
conf_pct = s["comp_globale"]["pct"]
# Combined score (SSO era: will weight per role/contract)
score = round(
(pct_zero or 0) * 0.6 + (conf_pct or 0) * 0.4
)
results.append({
"user_id": user_id,
"display_name": user_id.replace("_", " ").replace("-", " ").title(),
"n_semaines": len(balances),
"n_jours": s["n_jours"],
"pct_zero": pct_zero, # % weeks ≈ 0 HS — higher = better
"mean_abs_min": mean_abs, # mean abs(solde) in minutes — lower = better
"mean_abs_str": minutes_to_hhmm(mean_abs) if mean_abs is not None else "",
"conf_pct": conf_pct, # directive compliance % — higher = better
"score": score, # combined 0-100 — higher = better
"avg_delta": s["avg_delta"],
"avg_delta_min": s["avg_delta_min"],
})
results.sort(key=lambda r: -r["score"])
return results

View File

@ -1,18 +1,20 @@
import json import json
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from itertools import groupby from itertools import groupby
from fastapi import FastAPI, Form, Request from fastapi import FastAPI, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from models import ( from models import (
load_config, load_week_pointages, save_week_pointages, load_conges, load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, list_all_weeks, toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user,
) )
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues 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 classement import compute_classement
app = FastAPI(title="Décompte Horaire") app = FastAPI(title="Décompte Horaire")
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
@ -20,46 +22,62 @@ templates.env.filters["zip"] = zip
app.mount("/static", StaticFiles(directory="static"), name="static") 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,32}$")
# ── Auth helpers ──────────────────────────────────────────────────────────────
def _get_user(request: Request) -> str | None:
"""Extract current user from cookie.
TODO: replace with SSO token validation when integrating corporate SSO.
"""
uid = request.cookies.get("user_id", "").strip()
return uid if _USERNAME_RE.match(uid) else None
def _require_user(request: Request):
"""Return user_id or redirect to login."""
uid = _get_user(request)
if not uid:
return None
return uid
# ── Shared helpers ────────────────────────────────────────────────────────────
def week_dates(year: int, week: int) -> list[str]: def week_dates(year: int, week: int) -> list[str]:
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)] return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
def heures_jour_min() -> int: def heures_jour_min() -> int:
cfg = load_config() return int(float(load_config().get("heures_jour", 7.8)) * 60)
return int(float(cfg.get("heures_jour", 7.8)) * 60)
def _weeks_by_year(all_weeks: list) -> dict: def _weeks_by_year(all_weeks: list) -> dict:
"""Group sorted weeks by year, most recent first."""
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]):
result[yr] = sorted(wks, key=lambda x: x[1], reverse=True) result[yr] = sorted(wks, key=lambda x: x[1], reverse=True)
return result return result
def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> dict: def _build_soldes_ctx(year, week, conges, h_jour, user_id):
"""Compute week result for the soldes partial."""
dates = week_dates(year, week) dates = week_dates(year, week)
raw = load_week_pointages(year, week) raw = load_week_pointages(year, week, user_id)
p_by_date = {p["date"]: p for p in raw} p_by_date = {p["date"]: p for p in raw}
pointages = [ pointages = [
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None, p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
for d in dates for d in dates
] ]
result = compute_week(pointages, conges, h_jour) return {"result": compute_week(pointages, conges, h_jour)}
return {"result": result}
def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict: def _build_jour_ctx(date, conges, h_jour, user_id):
"""Return template context for a single day row."""
from calcul import heures_travaillees from calcul import heures_travaillees
d = datetime.strptime(date, "%Y-%m-%d").date() d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar() iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week) raw = load_week_pointages(iso.year, iso.week, user_id)
p_by_date = {p["date"]: p for p in raw} p_by_date = {p["date"]: p for p in raw}
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None, p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None}) "aprem_entree": None, "aprem_sortie": None})
@ -76,13 +94,12 @@ def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict:
} }
def _oob_calc(date: str, jour: dict) -> str: def _oob_calc(date, jour):
"""Build OOB <span> fragments for the two calc cells (safe non-table elements)."""
trav_inner = f'<span class="calc-num">{"" if not jour["travaille_min"] else jour["travaille"]}</span>' trav_inner = f'<span class="calc-num">{"" if not jour["travaille_min"] else jour["travaille"]}</span>'
if jour["travaille_min"]: if jour["travaille_min"]:
delta_cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
sign = "+" if jour["delta_min"] > 0 else "" sign = "+" if jour["delta_min"] > 0 else ""
delta_inner = f'<span class="{delta_cls}">{sign}{jour["delta"]}</span>' delta_inner = f'<span class="{cls}">{sign}{jour["delta"]}</span>'
else: else:
delta_inner = '<span class="delta-zero">—</span>' delta_inner = '<span class="delta-zero">—</span>'
return ( return (
@ -91,12 +108,10 @@ def _oob_calc(date: str, jour: dict) -> str:
) )
def _oob_week_extras(result: dict) -> str: def _oob_week_extras(result):
"""OOB <span> for all 5 days' cumulative delta and sortie cible."""
html = "" html = ""
for jour in result["jours"]: for jour in result["jours"]:
date = jour["date"] date = jour["date"]
# Cumulative delta — only when chain of complete days is unbroken
cm = jour["delta_cumul_min"] cm = jour["delta_cumul_min"]
if jour.get("cumul_visible"): if jour.get("cumul_visible"):
if cm == 0: if cm == 0:
@ -108,15 +123,13 @@ def _oob_week_extras(result: dict) -> str:
else: else:
cumul_inner = '<span class="delta-zero">—</span>' cumul_inner = '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>' html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
# Sortie cible — only for incomplete days
cible = jour.get("sortie_cible") if not jour.get("is_complete") else None cible = jour.get("sortie_cible") if not jour.get("is_complete") else None
cible_inner = f'<span class="cible-num">{cible}</span>' if cible else '<span class="delta-zero">—</span>' cible_inner = f'<span class="cible-num">{cible}</span>' if cible else '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>' html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>'
return html return html
def _oob_time_cells(date: str, jour: dict) -> str: def _oob_time_cells(date, jour):
"""OOB <div> for matin/aprem cells with congé state and warn class."""
cg = jour["conge"] cg = jour["conge"]
matin_cg = "matin" in cg or "jour" in cg matin_cg = "matin" in cg or "jour" in cg
aprem_cg = "aprem" in cg or "jour" in cg aprem_cg = "aprem" in cg or "jour" in cg
@ -149,12 +162,10 @@ def _oob_time_cells(date: str, jour: dict) -> str:
return matin_html + aprem_html return matin_html + aprem_html
def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
"""Return OOB <div>/<span> fragments for congé state + calc + soldes. No table elements."""
iso = d.isocalendar() iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour) 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 ""
btn_am_on = " on" if "aprem" in cg else "" btn_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else "" btn_j_on = " jour-on" if "jour" in cg else ""
@ -166,8 +177,7 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h
f'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>' f'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'</div></div>' f'</div></div>'
) )
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)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse( return HTMLResponse(
_oob_time_cells(date, jour) _oob_time_cells(date, jour)
@ -178,11 +188,10 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h
) )
def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
"""Return OOB <span>/<div> for calc cells, time cells, week extras, and soldes."""
iso = d.isocalendar() iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour) jour = _build_jour_ctx(date, conges, h_jour, user_id)
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour) 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})
return HTMLResponse( return HTMLResponse(
_oob_calc(date, jour) _oob_calc(date, jour)
@ -192,8 +201,45 @@ def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_
) )
# ── Login / logout ────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
if _get_user(request):
return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("login.html", {"request": request, "error": None})
@app.post("/login", response_class=HTMLResponse)
def login_submit(request: Request, response: Response, username: str = Form("")):
username = username.strip().lower()
if not _USERNAME_RE.match(username):
return templates.TemplateResponse("login.html", {
"request": request,
"error": "Nom d'utilisateur invalide (lettres, chiffres, - et _ uniquement, 132 caractères).",
})
# First-ever login: migrate legacy flat data to this user's directory
if needs_migration():
migrate_legacy_to_user(username)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie("user_id", username, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
return resp
@app.get("/logout")
def logout():
resp = RedirectResponse("/login", status_code=302)
resp.delete_cookie("user_id")
return resp
# ── Main routes ───────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request): def index(request: Request):
if not _get_user(request):
return RedirectResponse("/login", status_code=302)
today = datetime.today() today = datetime.today()
iso = today.isocalendar() iso = today.isocalendar()
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}") return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
@ -201,12 +247,16 @@ def index(request: Request):
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse) @app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
def semaine(request: Request, year: int, week: int): def semaine(request: Request, year: int, week: int):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
h_jour = heures_jour_min() h_jour = heures_jour_min()
conges = load_conges() conges = load_conges(user_id)
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour) soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
dates = week_dates(year, week) dates = week_dates(year, week)
raw = load_week_pointages(year, week) raw = load_week_pointages(year, week, user_id)
p_by_date = {p["date"]: p for p in raw} p_by_date = {p["date"]: p for p in raw}
pointages = [ pointages = [
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None, p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
@ -217,58 +267,71 @@ def semaine(request: Request, year: int, week: int):
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1) prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1) next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
prev_iso = prev_d.isocalendar() all_weeks = list_all_weeks(user_id)
next_iso = next_d.isocalendar()
all_weeks = list_all_weeks()
return templates.TemplateResponse("semaine.html", { return templates.TemplateResponse("semaine.html", {
"request": request, "request": request,
"year": year, "week": week, "year": year, "week": week,
"result": result, "result": result,
**soldes_ctx, **soldes_ctx,
"prev_year": prev_iso.year, "prev_week": prev_iso.week, "prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week,
"next_year": next_iso.year, "next_week": next_iso.week, "next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week,
"jours_fr": JOURS_FR, "jours_fr": JOURS_FR,
"all_weeks": all_weeks, "all_weeks": all_weeks,
"weeks_by_year": _weeks_by_year(all_weeks), "weeks_by_year": _weeks_by_year(all_weeks),
"current_user": user_id,
}) })
@app.post("/pointage/{date}", response_class=HTMLResponse) @app.post("/pointage/{date}", response_class=HTMLResponse)
def save_pointage( def save_pointage(
request: Request, request: Request, date: str,
date: str, matin_entree: str = Form(""), matin_sortie: str = Form(""),
matin_entree: str = Form(""), aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
matin_sortie: str = Form(""),
aprem_entree: str = Form(""),
aprem_sortie: str = Form(""),
): ):
def clean(v: str): user_id = _get_user(request)
v = v.strip() if not user_id:
return v if v else None return RedirectResponse("/login", status_code=302)
def clean(v):
v = v.strip(); return v if v else None
d = datetime.strptime(date, "%Y-%m-%d").date() d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar() iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week) raw = load_week_pointages(iso.year, iso.week, user_id)
p_by_date = {p["date"]: p for p in raw} p_by_date = {p["date"]: p for p in raw}
p_by_date[date] = { p_by_date[date] = {
"date": date, "date": date,
"matin_entree": clean(matin_entree), "matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie),
"matin_sortie": clean(matin_sortie), "aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie),
"aprem_entree": clean(aprem_entree),
"aprem_sortie": clean(aprem_sortie),
} }
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"])) save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id)
conges = load_conges(user_id)
return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
h_jour = heures_jour_min()
conges = load_conges() @app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
return _htmx_calc_and_soldes(request, date, d, conges, h_jour) def toggle_conge_route(date: str, type_conge: str, request: Request):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
toggle_conge(date, type_conge, user_id)
d = datetime.strptime(date, "%Y-%m-%d").date()
if request.headers.get("HX-Request"):
conges = load_conges(user_id)
return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
iso = d.isocalendar()
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
@app.get("/stats", response_class=HTMLResponse) @app.get("/stats", response_class=HTMLResponse)
def stats_page(request: Request): def stats_page(request: Request):
stats = compute_all_stats() user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
stats = compute_all_stats(user_id)
data_json = json.dumps({ data_json = json.dumps({
"time_stats": { "time_stats": {
slot: { slot: {
@ -286,18 +349,19 @@ def stats_page(request: Request):
"request": request, "request": request,
"stats": stats, "stats": stats,
"data_json": data_json, "data_json": data_json,
"current_user": user_id,
}) })
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse) @app.get("/classement", response_class=HTMLResponse)
def toggle_conge_route(date: str, type_conge: str, request: Request): def classement_page(request: Request):
toggle_conge(date, type_conge) user_id = _get_user(request)
d = datetime.strptime(date, "%Y-%m-%d").date() if not user_id:
if request.headers.get("HX-Request"): return RedirectResponse("/login", status_code=302)
h_jour = heures_jour_min()
conges = load_conges()
return _htmx_conge_and_soldes(request, date, d, conges, h_jour)
iso = d.isocalendar()
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
ranking = compute_classement()
return templates.TemplateResponse("classement.html", {
"request": request,
"ranking": ranking,
"current_user": user_id,
})

View File

@ -1,25 +1,75 @@
"""Flat-file storage: JSON files per week for pointages, one file for congés.""" """Flat-file storage: JSON files per week for pointages, one file for congés."""
import json import json
import shutil
from pathlib import Path from pathlib import Path
DATA_DIR = Path("/data") DATA_DIR = Path("/data")
POINTAGES_DIR = DATA_DIR / "pointages"
CONGES_FILE = DATA_DIR / "conges.json"
CONFIG_FILE = DATA_DIR / "config.json" CONFIG_FILE = DATA_DIR / "config.json"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"heures_jour": 7.8, # 7h48 = 39h/semaine "heures_jour": 7.8, # 7h48 = 39h/semaine
} }
# ── User path helpers ──────────────────────────────────────────────────────────
def _ensure_dirs(): def _user_root(user_id: str) -> Path:
POINTAGES_DIR.mkdir(parents=True, exist_ok=True) """Return data directory for a user.
Legacy fallback: if no users/ directory exists yet but /data/pointages/ does,
the first login migrates the data automatically (see migrate_legacy_to_user).
After migration, each user gets /data/users/{user_id}/.
"""
return DATA_DIR / "users" / user_id
# ---------- config ---------- def _pointages_dir(user_id: str) -> Path:
return _user_root(user_id) / "pointages"
def _conges_file(user_id: str) -> Path:
return _user_root(user_id) / "conges.json"
def _ensure_user_dirs(user_id: str):
_pointages_dir(user_id).mkdir(parents=True, exist_ok=True)
# ── Legacy migration (called once on first login) ─────────────────────────────
def needs_migration() -> bool:
"""True when legacy flat data exists and no users/ directory has been created yet."""
return (DATA_DIR / "pointages").exists() and not (DATA_DIR / "users").exists()
def migrate_legacy_to_user(user_id: str):
"""Move /data/pointages/ and /data/conges.json into /data/users/{user_id}/."""
user_root = _user_root(user_id)
user_root.mkdir(parents=True, exist_ok=True)
legacy_pt = DATA_DIR / "pointages"
dest_pt = user_root / "pointages"
if legacy_pt.exists() and not dest_pt.exists():
shutil.copytree(legacy_pt, dest_pt)
legacy_cg = DATA_DIR / "conges.json"
dest_cg = user_root / "conges.json"
if legacy_cg.exists() and not dest_cg.exists():
shutil.copy2(legacy_cg, dest_cg)
# ── User list ─────────────────────────────────────────────────────────────────
def list_users() -> list[str]:
users_dir = DATA_DIR / "users"
if not users_dir.exists():
return []
return sorted(p.name for p in users_dir.iterdir() if p.is_dir())
# ── Config (global) ───────────────────────────────────────────────────────────
def load_config() -> dict: def load_config() -> dict:
_ensure_dirs() CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
if CONFIG_FILE.exists(): if CONFIG_FILE.exists():
return json.loads(CONFIG_FILE.read_text()) return json.loads(CONFIG_FILE.read_text())
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2)) CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
@ -27,78 +77,62 @@ def load_config() -> dict:
def save_config(cfg: dict): def save_config(cfg: dict):
_ensure_dirs() CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
# ---------- pointages ---------- # ── Pointages ─────────────────────────────────────────────────────────────────
def _week_file(year: int, week: int) -> Path: def _week_file(user_id: str, year: int, week: int) -> Path:
return POINTAGES_DIR / f"{year}-W{week:02d}.json" return _pointages_dir(user_id) / f"{year}-W{week:02d}.json"
def load_week_pointages(year: int, week: int) -> list[dict]: def load_week_pointages(year: int, week: int, user_id: str = "") -> list[dict]:
f = _week_file(year, week) f = _week_file(user_id, year, week)
if f.exists(): return json.loads(f.read_text()) if f.exists() else []
return json.loads(f.read_text())
return []
def save_week_pointages(year: int, week: int, pointages: list[dict]): def save_week_pointages(year: int, week: int, pointages: list[dict], user_id: str = ""):
_ensure_dirs() _ensure_user_dirs(user_id)
_week_file(year, week).write_text(json.dumps(pointages, indent=2, ensure_ascii=False)) _week_file(user_id, year, week).write_text(
json.dumps(pointages, indent=2, ensure_ascii=False)
)
def upsert_pointages(records: list[dict]): def list_all_weeks(user_id: str = "") -> list[tuple[int, int]]:
"""Insert or update pointage records, grouped by ISO week.""" pt_dir = _pointages_dir(user_id)
from datetime import datetime if not pt_dir.exists():
by_week: dict[tuple, dict[str, dict]] = {} return []
for r in records:
d = datetime.strptime(r["date"], "%Y-%m-%d").date()
iso = d.isocalendar()
key = (iso.year, iso.week)
by_week.setdefault(key, {})
by_week[key][r["date"]] = r
for (year, week), new_by_date in by_week.items():
existing = {p["date"]: p for p in load_week_pointages(year, week)}
existing.update(new_by_date)
save_week_pointages(year, week, sorted(existing.values(), key=lambda p: p["date"]))
def list_all_weeks() -> list[tuple[int, int]]:
"""Return sorted list of (year, week) for which we have data."""
_ensure_dirs()
weeks = [] weeks = []
for f in POINTAGES_DIR.glob("????-W??.json"): for f in pt_dir.glob("????-W??.json"):
parts = f.stem.split("-W") parts = f.stem.split("-W")
if len(parts) == 2: if len(parts) == 2:
weeks.append((int(parts[0]), int(parts[1]))) weeks.append((int(parts[0]), int(parts[1])))
return sorted(weeks) return sorted(weeks)
# ---------- congés ---------- # ── Congés ────────────────────────────────────────────────────────────────────
def load_conges() -> list[dict]: def load_conges(user_id: str = "") -> list[dict]:
if CONGES_FILE.exists(): f = _conges_file(user_id)
return json.loads(CONGES_FILE.read_text()) return json.loads(f.read_text()) if f.exists() else []
return []
def save_conges(conges: list[dict]): def save_conges(conges: list[dict], user_id: str = ""):
_ensure_dirs() _ensure_user_dirs(user_id)
CONGES_FILE.write_text(json.dumps(conges, indent=2, ensure_ascii=False)) _conges_file(user_id).write_text(
json.dumps(conges, indent=2, ensure_ascii=False)
)
def toggle_conge(date: str, type_conge: 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() conges = load_conges(user_id)
already = any(c["date"] == date and c["type"] == type_conge for c in conges) already = any(c["date"] == date and c["type"] == type_conge for c in conges)
if already: if already:
conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)] conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)]
else: else:
# Adding: remove conflicting entries first
if type_conge == "jour": if type_conge == "jour":
conges = [c for c in conges if c["date"] != date] conges = [c for c in conges if c["date"] != date]
else: else:
@ -106,4 +140,4 @@ def toggle_conge(date: str, type_conge: str):
conges.append({"date": date, "type": type_conge}) conges.append({"date": date, "type": type_conge})
conges.sort(key=lambda c: (c["date"], c["type"])) conges.sort(key=lambda c: (c["date"], c["type"]))
save_conges(conges) save_conges(conges, user_id)

View File

@ -50,9 +50,9 @@ def _compliance_over(all_days: list[tuple], cg_set: set) -> dict:
return {"pct": round(100 * n_ok / n) if n else None, "n": n, "n_ok": n_ok} return {"pct": round(100 * n_ok / n) if n else None, "n": n, "n_ok": n_ok}
def compute_all_stats() -> dict: def compute_all_stats(user_id: str = "") -> dict:
all_weeks = sorted(list_all_weeks()) all_weeks = sorted(list_all_weeks(user_id))
conges = load_conges() conges = load_conges(user_id)
h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60) h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60)
cg_set = {(c["date"], c["type"]) for c in conges} cg_set = {(c["date"], c["type"]) for c in conges}
@ -63,7 +63,6 @@ def compute_all_stats() -> dict:
current_ym = (today.year, today.month) current_ym = (today.year, today.month)
SLOTS = ["matin_entree", "matin_sortie", "aprem_entree", "aprem_sortie"] SLOTS = ["matin_entree", "matin_sortie", "aprem_entree", "aprem_sortie"]
# Store (value, date) pairs per slot
slot_pairs: dict[str, list[tuple[int, str]]] = {s: [] for s in SLOTS} slot_pairs: dict[str, list[tuple[int, str]]] = {s: [] for s in SLOTS}
comp_n = {s: 0 for s in SLOTS} comp_n = {s: 0 for s in SLOTS}
comp_ok = {s: 0 for s in SLOTS} comp_ok = {s: 0 for s in SLOTS}
@ -81,7 +80,7 @@ def compute_all_stats() -> dict:
prev_week_day_list: list[tuple] = [] prev_week_day_list: list[tuple] = []
for year, week in all_weeks: for year, week in all_weeks:
raw = load_week_pointages(year, week) raw = load_week_pointages(year, week, user_id)
if not raw: if not raw:
continue continue
@ -136,7 +135,7 @@ def compute_all_stats() -> dict:
weekly_balances.append({ weekly_balances.append({
"year": year, "week": week, "year": year, "week": week,
"label": f"S{week:02d}", "label": f"S{week:02d}",
"date": dates[0], # Monday — used for period filtering "date": dates[0],
"delta_min": week_delta, "delta_min": week_delta,
"delta": minutes_to_hhmm(week_delta), "delta": minutes_to_hhmm(week_delta),
"partial": week_has_incomplete, "partial": week_has_incomplete,
@ -163,7 +162,6 @@ def compute_all_stats() -> dict:
for slot in comp_n for slot in comp_n
} }
rates = [v for v in compliance.values() if v is not None] rates = [v for v in compliance.values() if v is not None]
overall_slot = round(sum(rates) / len(rates)) if rates else None
n_jours = max(len(slot_pairs["matin_entree"]), len(slot_pairs["aprem_entree"])) n_jours = max(len(slot_pairs["matin_entree"]), len(slot_pairs["aprem_entree"]))
deltas = [b["delta_min"] for b in weekly_balances] deltas = [b["delta_min"] for b in weekly_balances]
@ -176,7 +174,6 @@ def compute_all_stats() -> dict:
return { return {
"time_stats": time_stats, "time_stats": time_stats,
"compliance": compliance, "compliance": compliance,
"overall_compliance": overall_slot,
"weekly_balances": weekly_balances, "weekly_balances": weekly_balances,
"total_weeks": len(weekly_balances), "total_weeks": len(weekly_balances),
"n_jours": n_jours, "n_jours": n_jours,

View File

@ -79,6 +79,13 @@
transition: color .15s; transition: color .15s;
} }
nav a.nav-link:hover { color: var(--ground); } nav a.nav-link:hover { color: var(--ground); }
.nav-user {
font-family: var(--mono);
font-size: 11px;
color: var(--accent-2);
letter-spacing: .05em;
}
.nav-logout { opacity: .6; font-size: 11px; }
/* ── Layout ── */ /* ── Layout ── */
.container { .container {
@ -836,6 +843,11 @@
<span class="spacer"></span> <span class="spacer"></span>
<a href="/" class="nav-link">Semaine courante</a> <a href="/" class="nav-link">Semaine courante</a>
<a href="/stats" class="nav-link">Statistiques</a> <a href="/stats" class="nav-link">Statistiques</a>
<a href="/classement" class="nav-link">Classement</a>
{% if current_user is defined and current_user %}
<span class="nav-user">{{ current_user }}</span>
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
{% endif %}
</nav> </nav>
<div class="container"> <div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}

View File

@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block content %}
<div class="clmt-head">
<span class="clmt-eyebrow">Compétition interne</span>
<h1 class="clmt-title">Classement</h1>
<p class="clmt-sub">Précision sur le zéro heure supp · Conformité aux directives</p>
</div>
{% if ranking %}
{# ── Podium top 3 ── #}
{% if ranking|length >= 1 %}
<div class="podium">
{% set top3 = ranking[:3] %}
{# réordonner : 2e à gauche, 1er au centre, 3e à droite #}
{% if top3|length == 1 %}
{% set order = [top3[0]] %}
{% elif top3|length == 2 %}
{% set order = [top3[1], top3[0]] %}
{% else %}
{% set order = [top3[1], top3[0], top3[2]] %}
{% endif %}
{% for u in order %}
{% set rank = ranking.index(u) + 1 %}
<div class="podium-slot podium-rank-{{ rank }}{% if u.user_id == current_user %} podium-me{% endif %}">
<div class="podium-score">{{ u.score }}</div>
<div class="podium-name">{{ u.display_name }}</div>
<div class="podium-medal">{% if rank == 1 %}🥇{% elif rank == 2 %}🥈{% else %}🥉{% endif %}</div>
<div class="podium-bar-wrap">
<div class="podium-bar podium-bar-{{ rank }}"></div>
</div>
<div class="podium-rank-label">#{{ rank }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{# ── Tableau complet ── #}
<div class="clmt-table-wrap">
<table class="clmt-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-name">Utilisateur</th>
<th class="col-num" title="Score combiné (0100)">Score</th>
<th class="col-num" title="% semaines avec |solde| ≤ 30 min">Préc. zéro</th>
<th class="col-num" title="Conformité aux horaires direction">Conformité</th>
<th class="col-num" title="Écart moyen absolu en heures">Écart moy.</th>
<th class="col-num" title="Nombre de semaines">Semaines</th>
<th class="col-num" title="Nombre de jours complets">Jours</th>
</tr>
</thead>
<tbody>
{% for u in ranking %}
<tr class="clmt-row{% if u.user_id == current_user %} clmt-row-me{% endif %}">
<td class="col-rank">
{% if loop.index == 1 %}<span class="rank-badge rank-1">1</span>
{% elif loop.index == 2 %}<span class="rank-badge rank-2">2</span>
{% elif loop.index == 3 %}<span class="rank-badge rank-3">3</span>
{% else %}<span class="rank-num">{{ loop.index }}</span>
{% endif %}
</td>
<td class="col-name">
{{ u.display_name }}
{% if u.user_id == current_user %}<span class="me-tag">moi</span>{% endif %}
</td>
<td class="col-num">
<div class="score-bar-wrap">
<div class="score-bar" style="width:{{ u.score }}%"></div>
<span class="score-val">{{ u.score }}</span>
</div>
</td>
<td class="col-num">{{ u.pct_zero if u.pct_zero is not none else '—' }}{% if u.pct_zero is not none %}<span class="pct-sym">%</span>{% endif %}</td>
<td class="col-num">{{ u.conf_pct if u.conf_pct is not none else '—' }}{% if u.conf_pct is not none %}<span class="pct-sym">%</span>{% endif %}</td>
<td class="col-num {% if u.avg_delta_min > 0 %}num-pos{% elif u.avg_delta_min < 0 %}num-neg{% endif %}">{{ u.avg_delta }}</td>
<td class="col-num col-minor">{{ u.n_semaines }}</td>
<td class="col-num col-minor">{{ u.n_jours }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="clmt-legend">
<div class="clmt-legend-item"><strong>Score</strong> = préc. zéro × 0,6 + conformité × 0,4</div>
<div class="clmt-legend-item"><strong>Préc. zéro</strong> = % semaines avec |solde| ≤ 30 min</div>
<div class="clmt-legend-item"><strong>Conformité</strong> = % jours respectant les 4 créneaux (≤9h, ≥12h, ≤14h, ≥17h)</div>
</div>
{% else %}
<div class="clmt-empty">Aucun utilisateur enregistré pour le moment.</div>
{% endif %}
<style>
.clmt-head { border-bottom: 2px solid var(--text); padding-bottom: .75rem; }
.clmt-eyebrow {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
display: block;
margin-bottom: .3rem;
}
.clmt-title {
font-family: var(--mono);
font-size: 26px;
font-weight: 500;
letter-spacing: -.02em;
}
.clmt-sub { font-size: 12px; color: var(--muted); margin-top: .3rem; }
/* ── Podium ── */
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 1rem;
background: var(--surface);
border: 1.5px solid var(--rule);
padding: 2rem 2rem 0;
min-height: 200px;
}
.podium-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
flex: 1;
max-width: 160px;
}
.podium-slot.podium-me .podium-name { color: var(--accent); }
.podium-score {
font-family: var(--mono);
font-size: 28px;
font-weight: 500;
color: var(--text);
}
.podium-name {
font-size: 13px;
font-weight: 500;
text-align: center;
}
.podium-medal { font-size: 22px; }
.podium-bar-wrap { width: 100%; }
.podium-bar {
width: 100%;
background: var(--accent);
opacity: .25;
}
.podium-bar-1 { height: 80px; }
.podium-bar-2 { height: 55px; }
.podium-bar-3 { height: 35px; }
.podium-rank-1 .podium-bar { opacity: .4; }
.podium-rank-label {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
background: var(--surface);
width: 100%;
text-align: center;
padding: .3rem 0;
border-top: 1px solid var(--rule);
}
/* ── Table ── */
.clmt-table-wrap { overflow-x: auto; }
.clmt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: var(--surface);
border: 1.5px solid var(--rule);
}
.clmt-table th {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
text-align: right;
padding: .6rem .9rem;
border-bottom: 1.5px solid var(--rule);
background: var(--ground);
white-space: nowrap;
}
.clmt-table th.col-name { text-align: left; }
.clmt-table th.col-rank { text-align: center; }
.clmt-row td {
padding: .6rem .9rem;
border-bottom: 1px solid var(--rule-l);
vertical-align: middle;
}
.clmt-row:last-child td { border-bottom: none; }
.clmt-row-me td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.clmt-row-me:hover td { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.clmt-row:hover td { background: var(--ground); }
.col-rank { text-align: center; }
.col-num {
font-family: var(--mono);
font-size: 13px;
text-align: right;
white-space: nowrap;
}
.col-minor { opacity: .65; }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
}
.rank-1 { background: #F0C040; color: #3A2800; }
.rank-2 { background: #C8C8C8; color: #2A2A2A; }
.rank-3 { background: #D4956A; color: #3A1A00; }
.rank-num { font-family: var(--mono); font-size: 12px; color: var(--muted); }
.me-tag {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .06em;
text-transform: uppercase;
background: var(--accent);
color: #fff;
padding: .1rem .35rem;
margin-left: .4rem;
vertical-align: middle;
}
.pct-sym { font-size: 10px; color: var(--muted); margin-left: 1px; }
.num-pos { color: var(--pos); }
.num-neg { color: var(--neg); }
.score-bar-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: .4rem;
}
.score-bar {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 16px;
background: var(--accent);
opacity: .12;
transition: width .4s ease;
min-width: 0;
}
.score-val {
position: relative;
font-family: var(--mono);
font-size: 13px;
z-index: 1;
}
.clmt-legend {
display: flex;
flex-wrap: wrap;
gap: .5rem 2rem;
font-size: 11px;
color: var(--muted);
padding: .5rem 0;
border-top: 1px solid var(--rule-l);
}
.clmt-empty {
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 3rem;
}
</style>
{% endblock %}

109
app/templates/login.html Normal file
View File

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block content %}
<div class="login-wrap">
<div class="login-card">
<div class="login-header">
<span class="login-eyebrow">Décompte Horaire</span>
<h1 class="login-title">Connexion</h1>
</div>
{% if error %}
<div class="login-error">{{ error }}</div>
{% endif %}
<form method="post" action="/login" class="login-form">
<label class="login-label" for="username">Identifiant</label>
<input
id="username"
class="login-input"
type="text"
name="username"
autocomplete="username"
autofocus
placeholder="prenom.nom"
pattern="[a-zA-Z0-9_\-]{1,32}"
required
>
<p class="login-hint">Lettres, chiffres, tirets — 1 à 32 caractères.</p>
<button class="login-btn" type="submit">Entrer</button>
</form>
</div>
</div>
<style>
.login-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 4rem;
}
.login-card {
background: var(--surface);
border: 1.5px solid var(--rule);
padding: 2.5rem 3rem;
width: 380px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.login-header { display: flex; flex-direction: column; gap: .35rem; }
.login-eyebrow {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.login-title {
font-family: var(--mono);
font-size: 22px;
font-weight: 500;
color: var(--text);
letter-spacing: -.01em;
}
.login-error {
background: var(--neg-bg);
border-left: 3px solid var(--neg);
color: var(--neg);
font-size: 12px;
padding: .6rem .9rem;
font-family: var(--mono);
}
.login-form { display: flex; flex-direction: column; gap: .75rem; }
.login-label {
font-size: 11px;
font-weight: 500;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.login-input {
font-family: var(--mono);
font-size: 15px;
padding: .6rem .75rem;
border: 1.5px solid var(--rule);
background: var(--ground);
color: var(--text);
outline: none;
transition: border-color .15s;
}
.login-input:focus { border-color: var(--accent); }
.login-hint { font-size: 11px; color: var(--muted); }
.login-btn {
margin-top: .5rem;
font-family: var(--mono);
font-size: 13px;
font-weight: 500;
letter-spacing: .08em;
text-transform: uppercase;
padding: .7rem 1.5rem;
background: var(--text);
color: var(--surface);
border: none;
cursor: pointer;
transition: background .15s;
}
.login-btn:hover { background: var(--accent); }
</style>
{% endblock %}