from datetime import datetime, timedelta from itertools import groupby from fastapi import FastAPI, Form, Request 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, ) from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues app = FastAPI(title="Décompte Horaire") templates = Jinja2Templates(directory="templates") templates.env.filters["zip"] = zip app.mount("/static", StaticFiles(directory="static"), name="static") JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] 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) 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.""" dates = week_dates(year, week) raw = load_week_pointages(year, week) 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} def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict: """Return template context for a single day row.""" from calcul import heures_travaillees d = datetime.strptime(date, "%Y-%m-%d").date() iso = d.isocalendar() raw = load_week_pointages(iso.year, iso.week) 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}) trav = heures_travaillees(p) du = heures_dues(date, conges, h_jour) delta = trav - du return { **p, "travaille_min": trav, "du_min": du, "delta_min": delta, "travaille": minutes_to_hhmm(trav), "du": minutes_to_hhmm(du), "delta": minutes_to_hhmm(delta), "conge": {c["type"] for c in conges if c["date"] == date}, } def _oob_calc(date: str, jour: dict) -> str: """Build OOB fragments for the two calc cells (safe non-table elements).""" trav_inner = f'{"—" if not jour["travaille_min"] else jour["travaille"]}' if jour["travaille_min"]: delta_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'{sign}{jour["delta"]}' else: delta_inner = '' return ( f'{trav_inner}' f'{delta_inner}' ) def _oob_week_extras(result: dict) -> str: """OOB for all 5 days' cumulative delta and sortie cible.""" 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: cumul_inner = '0h00' else: cls = "delta-pos" if cm > 0 else "delta-neg" sign = "+" if cm > 0 else "-" cumul_inner = f'{sign}{jour["delta_cumul"]}' else: cumul_inner = '' html += f'{cumul_inner}' # Sortie cible — only for incomplete days cible = jour.get("sortie_cible") if not jour.get("is_complete") else None cible_inner = f'{cible}' if cible else '' html += f'{cible_inner}' return html def _oob_time_cells(date: str, jour: dict) -> str: """OOB
for matin/aprem cells with congé state and warn class.""" cg = jour["conge"] matin_cg = "matin" in cg or "jour" in cg aprem_cg = "aprem" in cg or "jour" in cg matin_warn = not matin_cg and jour["du_min"] > 0 and ( (jour.get("matin_entree") and jour["matin_entree"] > "09:00") or (jour.get("matin_sortie") and jour["matin_sortie"] < "12:00") ) aprem_warn = not aprem_cg and jour["du_min"] > 0 and ( (jour.get("aprem_entree") and jour["aprem_entree"] > "14:00") or (jour.get("aprem_sortie") and jour["aprem_sortie"] < "17:00") ) matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill") aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill") matin_html = ( f'
' f'
' f'' f'' f'' f'
' ) aprem_html = ( f'
' f'
' f'' f'' f'' f'
' ) return matin_html + aprem_html def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: """Return OOB
/ fragments for congé state + calc + soldes. No table elements.""" iso = d.isocalendar() jour = _build_jour_ctx(date, conges, h_jour) 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 "" btns_html = ( f'
' f'
' f'' f'' f'' f'
' ) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) return HTMLResponse( _oob_time_cells(date, jour) + btns_html + _oob_calc(date, jour) + _oob_week_extras(soldes_ctx["result"]) + soldes_html ) def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: """Return OOB /
for calc cells, time cells, week extras, and soldes.""" iso = d.isocalendar() jour = _build_jour_ctx(date, conges, h_jour) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) return HTMLResponse( _oob_calc(date, jour) + _oob_time_cells(date, jour) + _oob_week_extras(soldes_ctx["result"]) + soldes_html ) @app.get("/", response_class=HTMLResponse) def index(request: Request): today = datetime.today() iso = today.isocalendar() return RedirectResponse(f"/semaine/{iso.year}/{iso.week}") @app.get("/semaine/{year}/{week}", response_class=HTMLResponse) def semaine(request: Request, year: int, week: int): h_jour = heures_jour_min() conges = load_conges() soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour) dates = week_dates(year, week) raw = load_week_pointages(year, week) 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) 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() 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, "jours_fr": JOURS_FR, "all_weeks": all_weeks, "weeks_by_year": _weeks_by_year(all_weeks), }) @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(""), ): def clean(v: str): 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) 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), } save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"])) 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): 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)