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:
208
app/main.py
208
app/main.py
@ -1,18 +1,20 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
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.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from models import (
|
||||
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 stats import compute_all_stats
|
||||
from classement import compute_classement
|
||||
|
||||
app = FastAPI(title="Décompte Horaire")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@ -20,46 +22,62 @@ templates.env.filters["zip"] = zip
|
||||
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,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]:
|
||||
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)]
|
||||
|
||||
|
||||
def heures_jour_min() -> int:
|
||||
cfg = load_config()
|
||||
return int(float(cfg.get("heures_jour", 7.8)) * 60)
|
||||
return int(float(load_config().get("heures_jour", 7.8)) * 60)
|
||||
|
||||
|
||||
def _weeks_by_year(all_weeks: list) -> dict:
|
||||
"""Group sorted weeks by year, most recent first."""
|
||||
result = {}
|
||||
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)
|
||||
return result
|
||||
|
||||
|
||||
def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> dict:
|
||||
"""Compute week result for the soldes partial."""
|
||||
def _build_soldes_ctx(year, week, conges, h_jour, user_id):
|
||||
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}
|
||||
pointages = [
|
||||
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
|
||||
"aprem_entree": None, "aprem_sortie": None})
|
||||
for d in dates
|
||||
]
|
||||
result = compute_week(pointages, conges, h_jour)
|
||||
return {"result": result}
|
||||
return {"result": compute_week(pointages, conges, h_jour)}
|
||||
|
||||
|
||||
def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict:
|
||||
"""Return template context for a single day row."""
|
||||
def _build_jour_ctx(date, conges, h_jour, user_id):
|
||||
from calcul import heures_travaillees
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
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 = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_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:
|
||||
"""Build OOB <span> fragments for the two calc cells (safe non-table elements)."""
|
||||
def _oob_calc(date, jour):
|
||||
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else jour["travaille"]}</span>'
|
||||
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 ""
|
||||
delta_inner = f'<span class="{delta_cls}">{sign}{jour["delta"]}</span>'
|
||||
delta_inner = f'<span class="{cls}">{sign}{jour["delta"]}</span>'
|
||||
else:
|
||||
delta_inner = '<span class="delta-zero">—</span>'
|
||||
return (
|
||||
@ -91,12 +108,10 @@ def _oob_calc(date: str, jour: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _oob_week_extras(result: dict) -> str:
|
||||
"""OOB <span> for all 5 days' cumulative delta and sortie cible."""
|
||||
def _oob_week_extras(result):
|
||||
html = ""
|
||||
for jour in result["jours"]:
|
||||
date = jour["date"]
|
||||
# Cumulative delta — only when chain of complete days is unbroken
|
||||
cm = jour["delta_cumul_min"]
|
||||
if jour.get("cumul_visible"):
|
||||
if cm == 0:
|
||||
@ -108,15 +123,13 @@ def _oob_week_extras(result: dict) -> str:
|
||||
else:
|
||||
cumul_inner = '<span class="delta-zero">—</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_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>'
|
||||
return html
|
||||
|
||||
|
||||
def _oob_time_cells(date: str, jour: dict) -> str:
|
||||
"""OOB <div> for matin/aprem cells with congé state and warn class."""
|
||||
def _oob_time_cells(date, jour):
|
||||
cg = jour["conge"]
|
||||
matin_cg = "matin" 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
|
||||
|
||||
|
||||
def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse:
|
||||
"""Return OOB <div>/<span> fragments for congé state + calc + soldes. No table elements."""
|
||||
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||
iso = d.isocalendar()
|
||||
jour = _build_jour_ctx(date, conges, h_jour)
|
||||
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
||||
cg = jour["conge"]
|
||||
|
||||
btn_ma_on = " on" if "matin" in cg else ""
|
||||
btn_am_on = " on" if "aprem" 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'</div></div>'
|
||||
)
|
||||
|
||||
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})
|
||||
return HTMLResponse(
|
||||
_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:
|
||||
"""Return OOB <span>/<div> for calc cells, time cells, week extras, and soldes."""
|
||||
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||
iso = d.isocalendar()
|
||||
jour = _build_jour_ctx(date, conges, h_jour)
|
||||
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, 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, user_id)
|
||||
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
||||
return HTMLResponse(
|
||||
_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, 1–32 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)
|
||||
def index(request: Request):
|
||||
if not _get_user(request):
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
today = datetime.today()
|
||||
iso = today.isocalendar()
|
||||
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
|
||||
@ -201,12 +247,16 @@ def index(request: Request):
|
||||
|
||||
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
|
||||
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()
|
||||
conges = load_conges()
|
||||
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour)
|
||||
conges = load_conges(user_id)
|
||||
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
|
||||
|
||||
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}
|
||||
pointages = [
|
||||
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)
|
||||
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
|
||||
prev_iso = prev_d.isocalendar()
|
||||
next_iso = next_d.isocalendar()
|
||||
|
||||
all_weeks = list_all_weeks()
|
||||
all_weeks = list_all_weeks(user_id)
|
||||
|
||||
return templates.TemplateResponse("semaine.html", {
|
||||
"request": request,
|
||||
"year": year, "week": week,
|
||||
"result": result,
|
||||
**soldes_ctx,
|
||||
"prev_year": prev_iso.year, "prev_week": prev_iso.week,
|
||||
"next_year": next_iso.year, "next_week": next_iso.week,
|
||||
"prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week,
|
||||
"next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week,
|
||||
"jours_fr": JOURS_FR,
|
||||
"all_weeks": all_weeks,
|
||||
"weeks_by_year": _weeks_by_year(all_weeks),
|
||||
"current_user": user_id,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/pointage/{date}", response_class=HTMLResponse)
|
||||
def save_pointage(
|
||||
request: Request,
|
||||
date: str,
|
||||
matin_entree: str = Form(""),
|
||||
matin_sortie: str = Form(""),
|
||||
aprem_entree: str = Form(""),
|
||||
aprem_sortie: str = Form(""),
|
||||
request: Request, date: str,
|
||||
matin_entree: str = Form(""), matin_sortie: str = Form(""),
|
||||
aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
|
||||
):
|
||||
def clean(v: str):
|
||||
v = v.strip()
|
||||
return v if v else None
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
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()
|
||||
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[date] = {
|
||||
"date": date,
|
||||
"matin_entree": clean(matin_entree),
|
||||
"matin_sortie": clean(matin_sortie),
|
||||
"aprem_entree": clean(aprem_entree),
|
||||
"aprem_sortie": clean(aprem_sortie),
|
||||
"matin_entree": clean(matin_entree), "matin_sortie": clean(matin_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()
|
||||
return _htmx_calc_and_soldes(request, date, d, conges, h_jour)
|
||||
|
||||
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
|
||||
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)
|
||||
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({
|
||||
"time_stats": {
|
||||
slot: {
|
||||
@ -286,18 +349,19 @@ def stats_page(request: Request):
|
||||
"request": request,
|
||||
"stats": stats,
|
||||
"data_json": data_json,
|
||||
"current_user": user_id,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
|
||||
def toggle_conge_route(date: str, type_conge: str, request: Request):
|
||||
toggle_conge(date, type_conge)
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
if request.headers.get("HX-Request"):
|
||||
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)
|
||||
|
||||
@app.get("/classement", response_class=HTMLResponse)
|
||||
def classement_page(request: Request):
|
||||
user_id = _get_user(request)
|
||||
if not user_id:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
ranking = compute_classement()
|
||||
return templates.TemplateResponse("classement.html", {
|
||||
"request": request,
|
||||
"ranking": ranking,
|
||||
"current_user": user_id,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user