diff --git a/README.md b/README.md index 4cdac94..18f2fb9 100644 --- a/README.md +++ b/README.md @@ -30,6 +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)). + L'outil calcule — sans arrondi — **l'heure précise à laquelle partir** chaque jour pour que le total de la semaine tombe pile sur 39h00. --- @@ -76,6 +78,13 @@ 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" } } ``` @@ -85,6 +94,21 @@ 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`. + +--- + +## Tests + +```bash +docker compose run --rm pointeuse pytest -v +``` --- diff --git a/app/calcul.py b/app/calcul.py index ebd6cd3..8fcc5a5 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -1,7 +1,13 @@ """Business logic: compute worked hours and expected hours per day/week.""" from datetime import datetime, timedelta -APREM_DEBUT_MIN = 13 * 60 + 30 # 13:30 — pause déjeuner de base 12h00-13h30 +DEFAULT_PLAGES = { + "matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin + "matin_fin": "12:00", # présence obligatoire jusqu'à cette heure le matin + "aprem_debut": "14:00", # présence obligatoire à partir de cette heure l'après-midi + "aprem_fin": "17:00", # présence obligatoire jusqu'à cette heure l'après-midi (plancher de sortie) + "pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie +} def hhmm_to_minutes(val: str | None) -> int: @@ -62,8 +68,15 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int: return heures_jour_min -def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int) -> dict: +def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None) -> dict: """Compute totals for a list of pointages (one week).""" + plages = {**DEFAULT_PLAGES, **(plages or {})} + matin_debut_min = hhmm_to_minutes(plages["matin_debut"]) + matin_fin_min = hhmm_to_minutes(plages["matin_fin"]) + aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) + pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"]) + matin_window = matin_fin_min - matin_debut_min + total_travaille = 0 total_du = 0 jours = [] @@ -124,9 +137,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:]) if du_remaining_mat == 0 and not jour.get("matin_entree"): needed_today_mat = max(0, total_du - worked_before - du_remaining_mat) - if needed_today_mat > 3 * 60: # plus que le créneau 9h-12h obligatoire - entree_min = 12 * 60 - needed_today_mat - if entree_min >= 6 * 60: # avant 9h obligatoire, pas avant 6h + if needed_today_mat > matin_window: # plus que le créneau matin obligatoire + entree_min = matin_fin_min - needed_today_mat + floor_min = matin_debut_min - matin_window # pas avant ce plancher symétrique + if entree_min >= floor_min: jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}" worked_before += jour["travaille_min"] continue @@ -138,16 +152,16 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int if jour.get("matin_entree") and jour.get("matin_sortie"): worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"])) elif jour.get("matin_entree") and jour["date"] == today: - # Pas de sortie pointée le matin : projeter une matinée normale jusqu'à midi + # Pas de sortie pointée le matin : projeter une matinée normale jusqu'à la fin de la plage # (sinon la cible dumperait tout le quota du jour sur l'après-midi dès l'arrivée). entree_min = hhmm_to_minutes(jour["matin_entree"]) elapsed = max(0, now_min - entree_min) - projected_to_noon = max(0, 12 * 60 - entree_min) - worked_morning = max(elapsed, projected_to_noon) + projected_to_fin_matin = max(0, matin_fin_min - entree_min) + worked_morning = max(elapsed, projected_to_fin_matin) needed_aprem = max(0, needed_today - worked_morning) - aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else APREM_DEBUT_MIN - sortie_min = max(aprem_start + needed_aprem, 17 * 60) + aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min + sortie_min = max(aprem_start + needed_aprem, aprem_fin_min) if sortie_min > 23 * 60 + 59: jour["sortie_cible"] = None # deficit too large to recover in one afternoon @@ -155,9 +169,9 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" entree_cible = None - if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (17 * 60 - APREM_DEBUT_MIN): - entree_min = 17 * 60 - needed_aprem - if 12 * 60 < entree_min < APREM_DEBUT_MIN: # avant l'heure de reprise par défaut, après le déjeuner + if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (aprem_fin_min - pause_dejeuner_fin_min): + entree_min = aprem_fin_min - needed_aprem + if matin_fin_min < entree_min < pause_dejeuner_fin_min: # après le déjeuner, avant la reprise par défaut entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}" jour["entree_cible"] = entree_cible worked_before += jour["travaille_min"] diff --git a/app/classement.py b/app/classement.py deleted file mode 100644 index b42c8fe..0000000 --- a/app/classement.py +++ /dev/null @@ -1,48 +0,0 @@ -"""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 diff --git a/app/main.py b/app/main.py index d0facdd..c458948 100644 --- a/app/main.py +++ b/app/main.py @@ -19,9 +19,8 @@ from models import ( load_notif_config, save_notif_config, find_user_by_token, save_presence, load_auth, save_auth, find_user_by_reset_token, ) -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, DEFAULT_PLAGES from stats import compute_all_stats -from classement import compute_classement from notifications import reminder_loop logging.basicConfig( @@ -97,6 +96,10 @@ 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]): @@ -113,7 +116,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)} + return {"result": compute_week(pointages, conges, h_jour, plages_config())} def _build_jour_ctx(date, conges, h_jour, user_id): @@ -151,7 +154,7 @@ def _oob_calc(date, jour): ) -def _oob_week_extras(result): +def _oob_week_extras(result, plages): html = "" for jour in result["jours"]: date = jour["date"] @@ -169,7 +172,7 @@ def _oob_week_extras(result): cible = None if not jour.get("is_complete"): if jour.get("entree_cible"): - suffix = "→12:00" if "aprem" in jour.get("conge", set()) else "→17:00" + suffix = f'→{plages["matin_fin"]}' if "aprem" in jour.get("conge", set()) else f'→{plages["aprem_fin"]}' cible = f'{jour["entree_cible"]}{suffix}' elif jour.get("sortie_cible"): cible = jour.get("sortie_cible") @@ -178,17 +181,17 @@ def _oob_week_extras(result): return html -def _oob_time_cells(date, jour): +def _oob_time_cells(date, jour, plages): 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") + (jour.get("matin_entree") and jour["matin_entree"] > plages["matin_debut"]) or + (jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"]) ) 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") + (jour.get("aprem_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or + (jour.get("aprem_sortie") and jour["aprem_sortie"] < plages["aprem_fin"]) ) 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") @@ -213,6 +216,7 @@ def _oob_time_cells(date, jour): def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() + plages = plages_config() jour = _build_jour_ctx(date, conges, h_jour, user_id) cg = jour["conge"] btn_ma_on = " on" if "matin" in cg else "" @@ -229,23 +233,24 @@ def _htmx_conge_and_soldes(request, date, d, 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_time_cells(date, jour) + _oob_time_cells(date, jour, plages) + btns_html + _oob_calc(date, jour) - + _oob_week_extras(soldes_ctx["result"]) + + _oob_week_extras(soldes_ctx["result"], plages) + soldes_html ) def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() + plages = plages_config() 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) - + _oob_time_cells(date, jour) - + _oob_week_extras(soldes_ctx["result"]) + + _oob_time_cells(date, jour, plages) + + _oob_week_extras(soldes_ctx["result"], plages) + soldes_html ) @@ -403,6 +408,7 @@ def semaine(request: Request, year: int, week: int): return RedirectResponse("/login", status_code=302) h_jour = heures_jour_min() + plages = plages_config() conges = load_conges(user_id) soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id) @@ -414,7 +420,7 @@ def semaine(request: Request, year: int, week: int): "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour) + result = compute_week(pointages, conges, h_jour, plages) 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) @@ -425,6 +431,7 @@ def semaine(request: Request, year: int, week: int): "year": year, "week": week, "result": result, **soldes_ctx, + "plages": plages, "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, @@ -527,6 +534,9 @@ def settings_page(request: Request): "request": request, "current_user": user_id, "ntfy_topic": notif.get("ntfy_topic") or "", + "ntfy_server": notif.get("ntfy_server") or "https://ntfy.sh", + "rappel_debut_h": notif.get("rappel_debut_h", 7), + "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", "saved": request.query_params.get("saved") == "1", @@ -534,13 +544,22 @@ def settings_page(request: Request): @app.post("/settings", response_class=HTMLResponse) -def settings_save(request: Request, ntfy_topic: str = Form("")): +def settings_save( + request: Request, ntfy_topic: str = Form(""), ntfy_server: str = Form(""), + rappel_debut_h: int = Form(7), rappel_fin_h: int = Form(20), +): user_id = _get_user(request) if not user_id: return RedirectResponse("/login", status_code=302) - logger.info("réglages notif mis à jour: user_id=%r ntfy_topic=%r", user_id, ntfy_topic) - save_notif_config(user_id, ntfy_topic) + rappel_debut_h = max(0, min(23, rappel_debut_h)) + rappel_fin_h = max(rappel_debut_h + 1, min(24, rappel_fin_h)) + + logger.info( + "réglages notif mis à jour: user_id=%r ntfy_topic=%r ntfy_server=%r plage=%sh-%sh", + user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h, + ) + save_notif_config(user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h) return RedirectResponse("/settings?saved=1", status_code=303) @@ -566,15 +585,3 @@ def presence_depart(token: str): return {"ok": True} -@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, - }) diff --git a/app/models.py b/app/models.py index 9bdc674..4812566 100644 --- a/app/models.py +++ b/app/models.py @@ -3,6 +3,8 @@ import json import secrets from pathlib import Path +from calcul import DEFAULT_PLAGES + DATA_DIR = Path("/data") CONFIG_FILE = DATA_DIR / "config.json" @@ -18,6 +20,7 @@ DEFAULT_CONFIG = { "password": "", "use_tls": True, }, + "plages": dict(DEFAULT_PLAGES), } # ── User path helpers ────────────────────────────────────────────────────────── @@ -142,24 +145,41 @@ def _notif_file(user_id: str) -> Path: return _user_root(user_id) / "notif.json" +DEFAULT_NTFY_SERVER = "https://ntfy.sh" +DEFAULT_RAPPEL_DEBUT_H = 7 +DEFAULT_RAPPEL_FIN_H = 20 + + def load_notif_config(user_id: str) -> dict: - """Return {"ntfy_topic": str|None, "token": str}, generating a token on first use.""" + """Return {"ntfy_topic", "ntfy_server", "rappel_debut_h", "rappel_fin_h", "token"}.""" f = _notif_file(user_id) if f.exists(): cfg = json.loads(f.read_text()) + cfg.setdefault("ntfy_server", DEFAULT_NTFY_SERVER) + cfg.setdefault("rappel_debut_h", DEFAULT_RAPPEL_DEBUT_H) + cfg.setdefault("rappel_fin_h", DEFAULT_RAPPEL_FIN_H) if cfg.get("token"): return cfg else: - cfg = {"ntfy_topic": None} + cfg = { + "ntfy_topic": None, "ntfy_server": DEFAULT_NTFY_SERVER, + "rappel_debut_h": DEFAULT_RAPPEL_DEBUT_H, "rappel_fin_h": DEFAULT_RAPPEL_FIN_H, + } cfg["token"] = secrets.token_hex(16) _ensure_user_dirs(user_id) f.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) return cfg -def save_notif_config(user_id: str, ntfy_topic: str): +def save_notif_config( + user_id: str, ntfy_topic: str, ntfy_server: str = "", + rappel_debut_h: int = DEFAULT_RAPPEL_DEBUT_H, rappel_fin_h: int = DEFAULT_RAPPEL_FIN_H, +): cfg = load_notif_config(user_id) cfg["ntfy_topic"] = ntfy_topic.strip() or None + cfg["ntfy_server"] = ntfy_server.strip().rstrip("/") or DEFAULT_NTFY_SERVER + cfg["rappel_debut_h"] = rappel_debut_h + cfg["rappel_fin_h"] = rappel_fin_h _ensure_user_dirs(user_id) _notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) diff --git a/app/notifications.py b/app/notifications.py index 89fb353..b2c2ac1 100644 --- a/app/notifications.py +++ b/app/notifications.py @@ -8,24 +8,21 @@ from models import list_users, load_notif_config, load_presence, load_week_point logger = logging.getLogger("pointeuse.notifications") -NTFY_BASE = "https://ntfy.sh" CHECK_INTERVAL_SECONDS = 300 -RAPPEL_DEBUT_H = 7 -RAPPEL_FIN_H = 20 -def send_ntfy(topic: str, message: str, title: str): +def send_ntfy(server: str, topic: str, message: str, title: str): req = urllib.request.Request( - f"{NTFY_BASE}/{topic}", + f"{server}/{topic}", data=message.encode("utf-8"), headers={"Title": title.encode("utf-8"), "Priority": "high"}, method="POST", ) try: urllib.request.urlopen(req, timeout=10).close() - logger.info("notif ntfy envoyée: topic=%r title=%r", topic, title) + logger.info("notif ntfy envoyée: server=%r topic=%r title=%r", server, topic, title) except Exception: - logger.exception("échec de l'envoi ntfy: topic=%r title=%r", topic, title) + logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title) # best-effort: a missed reminder isn't worth crashing the loop @@ -39,11 +36,14 @@ def _todays_pointage(user_id: str, today: datetime) -> dict: async def check_user(user_id: str, now: datetime): notif = load_notif_config(user_id) topic = notif.get("ntfy_topic") + server = notif.get("ntfy_server", "https://ntfy.sh") + debut_h = notif.get("rappel_debut_h", 7) + fin_h = notif.get("rappel_fin_h", 20) if not topic: logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id) return - if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H): - logger.debug("check_user(%r): hors plage de rappel (%s)", user_id, now) + if now.weekday() >= 5 or not (debut_h <= now.hour < fin_h): + logger.debug("check_user(%r): hors plage de rappel (%s, plage %sh-%sh)", user_id, now, debut_h, fin_h) return presence = load_presence(user_id) @@ -55,10 +55,10 @@ async def check_user(user_id: str, now: datetime): if presence.get("present") and not jour.get("matin_entree"): logger.info("rappel 'pense à pointer' pour %r", user_id) - await asyncio.to_thread(send_ntfy, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer") + await asyncio.to_thread(send_ntfy, server, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer") elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"): logger.info("rappel 'pense à dépointer' pour %r", user_id) - await asyncio.to_thread(send_ntfy, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer") + await asyncio.to_thread(send_ntfy, server, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer") async def reminder_loop(): diff --git a/app/pytest.ini b/app/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/app/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/app/stats.py b/app/stats.py index c56038e..c4cf223 100644 --- a/app/stats.py +++ b/app/stats.py @@ -3,7 +3,7 @@ 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 +from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues, DEFAULT_PLAGES MOIS_FR = [ "janvier", "février", "mars", "avril", "mai", "juin", @@ -21,7 +21,7 @@ def _to_hhmm(minutes_float: float) -> str: return f"{m // 60:02d}:{m % 60:02d}" -def _day_compliant(p: dict, mc: bool, ac: bool) -> bool | None: +def _day_compliant(p: dict, mc: bool, ac: bool, plages: dict) -> bool | None: if mc and ac: return None matin_ok = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie"))) @@ -29,24 +29,24 @@ def _day_compliant(p: dict, mc: bool, ac: bool) -> bool | None: if not (matin_ok and aprem_ok): return None if not mc: - if p.get("matin_entree") and hhmm_to_minutes(p["matin_entree"]) > 9 * 60: + if p.get("matin_entree") and hhmm_to_minutes(p["matin_entree"]) > hhmm_to_minutes(plages["matin_debut"]): return False - if p.get("matin_sortie") and hhmm_to_minutes(p["matin_sortie"]) < 12 * 60: + if p.get("matin_sortie") and hhmm_to_minutes(p["matin_sortie"]) < hhmm_to_minutes(plages["matin_fin"]): return False if not ac: - if p.get("aprem_entree") and hhmm_to_minutes(p["aprem_entree"]) > 14 * 60: + if p.get("aprem_entree") and hhmm_to_minutes(p["aprem_entree"]) > hhmm_to_minutes(plages["aprem_debut"]): return False - if p.get("aprem_sortie") and hhmm_to_minutes(p["aprem_sortie"]) < 17 * 60: + if p.get("aprem_sortie") and hhmm_to_minutes(p["aprem_sortie"]) < hhmm_to_minutes(plages["aprem_fin"]): return False return True -def _compliance_over(all_days: list[tuple], cg_set: set) -> dict: +def _compliance_over(all_days: list[tuple], cg_set: set, plages: dict) -> dict: n = n_ok = 0 for date, p in all_days: mc = (date, "matin") in cg_set or (date, "jour") in cg_set ac = (date, "aprem") in cg_set or (date, "jour") in cg_set - result = _day_compliant(p, mc, ac) + result = _day_compliant(p, mc, ac, plages) if result is None: continue n += 1 @@ -58,7 +58,9 @@ def _compliance_over(all_days: list[tuple], cg_set: set) -> dict: def compute_all_stats(user_id: str = "") -> dict: all_weeks = sorted(list_all_weeks(user_id)) conges = load_conges(user_id) - h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60) + cfg = load_config() + h_jour = int(float(cfg.get("heures_jour", 7.8)) * 60) + plages = {**DEFAULT_PLAGES, **cfg.get("plages", {})} cg_set = {(c["date"], c["type"]) for c in conges} today = _date.today() @@ -73,10 +75,10 @@ def compute_all_stats(user_id: str = "") -> dict: comp_ok = {s: 0 for s in SLOTS} TARGETS = { - "matin_entree": ("le", 9 * 60), - "matin_sortie": ("ge", 12 * 60), - "aprem_entree": ("le", 14 * 60), - "aprem_sortie": ("ge", 17 * 60), + "matin_entree": ("le", hhmm_to_minutes(plages["matin_debut"])), + "matin_sortie": ("ge", hhmm_to_minutes(plages["matin_fin"])), + "aprem_entree": ("le", hhmm_to_minutes(plages["aprem_debut"])), + "aprem_sortie": ("ge", hhmm_to_minutes(plages["aprem_fin"])), } weekly_balances = [] @@ -177,13 +179,14 @@ def compute_all_stats(user_id: str = "") -> dict: deltas = [b["delta_min"] for b in weekly_balances] avg_delta_min = round(_st.mean(deltas)) if deltas else 0 - comp_globale = _compliance_over(all_days, cg_set) - comp_mois = _compliance_over(month_days, cg_set) - comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set) + comp_globale = _compliance_over(all_days, cg_set, plages) + comp_mois = _compliance_over(month_days, cg_set, plages) + comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set, plages) return { "time_stats": time_stats, "compliance": compliance, + "plages": plages, "weekly_balances": weekly_balances, "total_weeks": len(weekly_balances), "n_jours": n_jours, diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index cbca643..b6bc298 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -1,12 +1,12 @@ {% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %} {% set aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %} {% set matin_warn = not matin_cg and j.du_min > 0 and ( - (j.matin_entree and j.matin_entree > '09:00') or - (j.matin_sortie and j.matin_sortie < '12:00') + (j.matin_entree and j.matin_entree > plages.matin_debut) or + (j.matin_sortie and j.matin_sortie < plages.matin_fin) ) %} {% set aprem_warn = not aprem_cg and j.du_min > 0 and ( - (j.aprem_entree and j.aprem_entree > '14:00') or - (j.aprem_sortie and j.aprem_sortie < '17:00') + (j.aprem_entree and j.aprem_entree > plages.aprem_debut) or + (j.aprem_sortie and j.aprem_sortie < plages.aprem_fin) ) %}
Précision sur le zéro heure supp · Conformité aux directives
-| # | -Utilisateur | -Score | -Préc. zéro | -Conformité | -Écart moy. | -Semaines | -Jours | -
|---|---|---|---|---|---|---|---|
| - {% if loop.index == 1 %}1 - {% elif loop.index == 2 %}2 - {% elif loop.index == 3 %}3 - {% else %}{{ loop.index }} - {% endif %} - | -- {{ u.display_name }} - {% if u.user_id == current_user %}moi{% endif %} - | -- - | -{{ u.pct_zero if u.pct_zero is not none else '—' }}{% if u.pct_zero is not none %}%{% endif %} | -{{ u.conf_pct if u.conf_pct is not none else '—' }}{% if u.conf_pct is not none %}%{% endif %} | -{{ u.avg_delta }} | -{{ u.n_semaines }} | -{{ u.n_jours }} | -
- Crée un topic sur ntfy.sh (un identifiant - secret que toi seul connais), abonne-toi dessus avec l'app ntfy sur ton téléphone, puis renseigne-le ici. + Crée un topic sur ton serveur ntfy + (public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec + l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous.
{% if saved %} @@ -19,6 +20,16 @@ {% endif %}