Rend les plages horaires et les rappels ntfy configurables, supprime le
classement, ajoute une suite de tests pytest - calcul.py: matin_debut/fin, aprem_debut/fin et pause_dejeuner_fin sont paramétrables via config.json > plages (moteur de calcul de l'heure de sortie optimale inclus, pas seulement l'affichage). Défauts identiques à l'ancien comportement, validé par la suite de tests. - stats.py: les seuils de conformité utilisent la même config. - main.py/templates: warn de saisie et suffixes de cible dynamiques. - Rappels ntfy: serveur, topic et plage horaire (rappel_debut_h/fin_h) configurables par utilisateur depuis /settings, plus en dur dans le code. - Page classement supprimée (pas de compétition entre utilisateurs). - Nouvelle suite pytest (app/tests/), à lancer via `docker compose run --rm pointeuse pytest -v`.
This commit is contained in:
24
README.md
24
README.md
@ -30,6 +30,8 @@ La semaine cible est **39h** (7h48 / jour). Les directives imposent des plages d
|
|||||||
| Matin | 09:00 → 12:00 |
|
| Matin | 09:00 → 12:00 |
|
||||||
| Après-midi | 14:00 → 17:00 |
|
| Après-midi | 14:00 → 17:00 |
|
||||||
|
|
||||||
|
Ces horaires sont configurables (voir [Configuration](#configuration)).
|
||||||
|
|
||||||
L'outil calcule — sans arrondi — **l'heure précise à laquelle partir** chaque jour pour que le total de la semaine tombe pile sur 39h00.
|
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",
|
"user": "no-reply@exemple.fr",
|
||||||
"password": "change-moi",
|
"password": "change-moi",
|
||||||
"use_tls": true
|
"use_tls": true
|
||||||
|
},
|
||||||
|
"plages": {
|
||||||
|
"matin_debut": "09:00",
|
||||||
|
"matin_fin": "12:00",
|
||||||
|
"aprem_debut": "14:00",
|
||||||
|
"aprem_fin": "17:00",
|
||||||
|
"pause_dejeuner_fin": "13:30"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -85,6 +94,21 @@ Seul `data/config.example.json` est versionné.
|
|||||||
- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
|
- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
|
||||||
- `mail_from` — adresse expéditrice des emails de connexion.
|
- `mail_from` — adresse expéditrice des emails de connexion.
|
||||||
- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS).
|
- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS).
|
||||||
|
- `plages` — bornes de présence obligatoire matin/après-midi (voir [Principe](#principe)) et heure de reprise
|
||||||
|
par défaut de l'après-midi (`pause_dejeuner_fin`) tant que l'entrée aprem n'est pas encore saisie. Modifier
|
||||||
|
ces valeurs change à la fois les avertissements de saisie, les stats de conformité et le calcul de l'heure
|
||||||
|
de sortie optimale.
|
||||||
|
|
||||||
|
Les rappels ntfy (topic, serveur, plage horaire) se règlent par utilisateur depuis la page **Réglages** de l'app,
|
||||||
|
pas dans `config.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm pointeuse pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
"""Business logic: compute worked hours and expected hours per day/week."""
|
"""Business logic: compute worked hours and expected hours per day/week."""
|
||||||
from datetime import datetime, timedelta
|
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:
|
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
|
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)."""
|
"""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_travaille = 0
|
||||||
total_du = 0
|
total_du = 0
|
||||||
jours = []
|
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:])
|
du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:])
|
||||||
if du_remaining_mat == 0 and not jour.get("matin_entree"):
|
if du_remaining_mat == 0 and not jour.get("matin_entree"):
|
||||||
needed_today_mat = max(0, total_du - worked_before - du_remaining_mat)
|
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
|
if needed_today_mat > matin_window: # plus que le créneau matin obligatoire
|
||||||
entree_min = 12 * 60 - needed_today_mat
|
entree_min = matin_fin_min - needed_today_mat
|
||||||
if entree_min >= 6 * 60: # avant 9h obligatoire, pas avant 6h
|
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}"
|
jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
|
||||||
worked_before += jour["travaille_min"]
|
worked_before += jour["travaille_min"]
|
||||||
continue
|
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"):
|
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"]))
|
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:
|
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).
|
# (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"])
|
entree_min = hhmm_to_minutes(jour["matin_entree"])
|
||||||
elapsed = max(0, now_min - entree_min)
|
elapsed = max(0, now_min - entree_min)
|
||||||
projected_to_noon = max(0, 12 * 60 - entree_min)
|
projected_to_fin_matin = max(0, matin_fin_min - entree_min)
|
||||||
worked_morning = max(elapsed, projected_to_noon)
|
worked_morning = max(elapsed, projected_to_fin_matin)
|
||||||
|
|
||||||
needed_aprem = max(0, needed_today - worked_morning)
|
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
|
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, 17 * 60)
|
sortie_min = max(aprem_start + needed_aprem, aprem_fin_min)
|
||||||
|
|
||||||
if sortie_min > 23 * 60 + 59:
|
if sortie_min > 23 * 60 + 59:
|
||||||
jour["sortie_cible"] = None # deficit too large to recover in one afternoon
|
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}"
|
jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
|
||||||
|
|
||||||
entree_cible = None
|
entree_cible = None
|
||||||
if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (17 * 60 - APREM_DEBUT_MIN):
|
if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (aprem_fin_min - pause_dejeuner_fin_min):
|
||||||
entree_min = 17 * 60 - needed_aprem
|
entree_min = aprem_fin_min - needed_aprem
|
||||||
if 12 * 60 < entree_min < APREM_DEBUT_MIN: # avant l'heure de reprise par défaut, après le déjeuner
|
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}"
|
entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
|
||||||
jour["entree_cible"] = entree_cible
|
jour["entree_cible"] = entree_cible
|
||||||
worked_before += jour["travaille_min"]
|
worked_before += jour["travaille_min"]
|
||||||
|
|||||||
@ -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
|
|
||||||
67
app/main.py
67
app/main.py
@ -19,9 +19,8 @@ from models import (
|
|||||||
load_notif_config, save_notif_config, find_user_by_token, save_presence,
|
load_notif_config, save_notif_config, find_user_by_token, save_presence,
|
||||||
load_auth, save_auth, find_user_by_reset_token,
|
load_auth, save_auth, find_user_by_reset_token,
|
||||||
)
|
)
|
||||||
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 stats import compute_all_stats
|
||||||
from classement import compute_classement
|
|
||||||
from notifications import reminder_loop
|
from notifications import reminder_loop
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -97,6 +96,10 @@ def heures_jour_min() -> int:
|
|||||||
return int(float(load_config().get("heures_jour", 7.8)) * 60)
|
return int(float(load_config().get("heures_jour", 7.8)) * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def plages_config() -> dict:
|
||||||
|
return {**DEFAULT_PLAGES, **load_config().get("plages", {})}
|
||||||
|
|
||||||
|
|
||||||
def _weeks_by_year(all_weeks: list) -> dict:
|
def _weeks_by_year(all_weeks: list) -> dict:
|
||||||
result = {}
|
result = {}
|
||||||
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
|
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
|
||||||
@ -113,7 +116,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
|
|||||||
"aprem_entree": None, "aprem_sortie": None})
|
"aprem_entree": None, "aprem_sortie": None})
|
||||||
for d in dates
|
for d in dates
|
||||||
]
|
]
|
||||||
return {"result": compute_week(pointages, conges, h_jour)}
|
return {"result": compute_week(pointages, conges, h_jour, plages_config())}
|
||||||
|
|
||||||
|
|
||||||
def _build_jour_ctx(date, conges, h_jour, user_id):
|
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 = ""
|
html = ""
|
||||||
for jour in result["jours"]:
|
for jour in result["jours"]:
|
||||||
date = jour["date"]
|
date = jour["date"]
|
||||||
@ -169,7 +172,7 @@ def _oob_week_extras(result):
|
|||||||
cible = None
|
cible = None
|
||||||
if not jour.get("is_complete"):
|
if not jour.get("is_complete"):
|
||||||
if jour.get("entree_cible"):
|
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}'
|
cible = f'{jour["entree_cible"]}{suffix}'
|
||||||
elif jour.get("sortie_cible"):
|
elif jour.get("sortie_cible"):
|
||||||
cible = jour.get("sortie_cible")
|
cible = jour.get("sortie_cible")
|
||||||
@ -178,17 +181,17 @@ def _oob_week_extras(result):
|
|||||||
return html
|
return html
|
||||||
|
|
||||||
|
|
||||||
def _oob_time_cells(date, jour):
|
def _oob_time_cells(date, jour, plages):
|
||||||
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
|
||||||
matin_warn = not matin_cg and jour["du_min"] > 0 and (
|
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_entree") and jour["matin_entree"] > plages["matin_debut"]) or
|
||||||
(jour.get("matin_sortie") and jour["matin_sortie"] < "12:00")
|
(jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"])
|
||||||
)
|
)
|
||||||
aprem_warn = not aprem_cg and jour["du_min"] > 0 and (
|
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_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or
|
||||||
(jour.get("aprem_sortie") and jour["aprem_sortie"] < "17:00")
|
(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")
|
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")
|
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):
|
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
|
plages = plages_config()
|
||||||
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
||||||
cg = jour["conge"]
|
cg = jour["conge"]
|
||||||
btn_ma_on = " on" if "matin" in cg else ""
|
btn_ma_on = " on" if "matin" in cg else ""
|
||||||
@ -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_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_time_cells(date, jour)
|
_oob_time_cells(date, jour, plages)
|
||||||
+ btns_html
|
+ btns_html
|
||||||
+ _oob_calc(date, jour)
|
+ _oob_calc(date, jour)
|
||||||
+ _oob_week_extras(soldes_ctx["result"])
|
+ _oob_week_extras(soldes_ctx["result"], plages)
|
||||||
+ soldes_html
|
+ soldes_html
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
|
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
|
||||||
iso = d.isocalendar()
|
iso = d.isocalendar()
|
||||||
|
plages = plages_config()
|
||||||
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
||||||
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
|
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
|
||||||
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
_oob_calc(date, jour)
|
_oob_calc(date, jour)
|
||||||
+ _oob_time_cells(date, jour)
|
+ _oob_time_cells(date, jour, plages)
|
||||||
+ _oob_week_extras(soldes_ctx["result"])
|
+ _oob_week_extras(soldes_ctx["result"], plages)
|
||||||
+ soldes_html
|
+ soldes_html
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -403,6 +408,7 @@ def semaine(request: Request, year: int, week: int):
|
|||||||
return RedirectResponse("/login", status_code=302)
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
h_jour = heures_jour_min()
|
h_jour = heures_jour_min()
|
||||||
|
plages = plages_config()
|
||||||
conges = load_conges(user_id)
|
conges = load_conges(user_id)
|
||||||
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
|
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
|
||||||
|
|
||||||
@ -414,7 +420,7 @@ def semaine(request: Request, year: int, week: int):
|
|||||||
"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)
|
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)
|
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)
|
||||||
@ -425,6 +431,7 @@ def semaine(request: Request, year: int, week: int):
|
|||||||
"year": year, "week": week,
|
"year": year, "week": week,
|
||||||
"result": result,
|
"result": result,
|
||||||
**soldes_ctx,
|
**soldes_ctx,
|
||||||
|
"plages": plages,
|
||||||
"prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().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,
|
"next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week,
|
||||||
"jours_fr": JOURS_FR,
|
"jours_fr": JOURS_FR,
|
||||||
@ -527,6 +534,9 @@ def settings_page(request: Request):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"current_user": user_id,
|
"current_user": user_id,
|
||||||
"ntfy_topic": notif.get("ntfy_topic") or "",
|
"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",
|
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
|
||||||
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
|
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
|
||||||
"saved": request.query_params.get("saved") == "1",
|
"saved": request.query_params.get("saved") == "1",
|
||||||
@ -534,13 +544,22 @@ def settings_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/settings", response_class=HTMLResponse)
|
@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)
|
user_id = _get_user(request)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return RedirectResponse("/login", status_code=302)
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
logger.info("réglages notif mis à jour: user_id=%r ntfy_topic=%r", user_id, ntfy_topic)
|
rappel_debut_h = max(0, min(23, rappel_debut_h))
|
||||||
save_notif_config(user_id, ntfy_topic)
|
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)
|
return RedirectResponse("/settings?saved=1", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@ -566,15 +585,3 @@ def presence_depart(token: str):
|
|||||||
return {"ok": True}
|
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,
|
|
||||||
})
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import json
|
|||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from calcul import DEFAULT_PLAGES
|
||||||
|
|
||||||
DATA_DIR = Path("/data")
|
DATA_DIR = Path("/data")
|
||||||
CONFIG_FILE = DATA_DIR / "config.json"
|
CONFIG_FILE = DATA_DIR / "config.json"
|
||||||
|
|
||||||
@ -18,6 +20,7 @@ DEFAULT_CONFIG = {
|
|||||||
"password": "",
|
"password": "",
|
||||||
"use_tls": True,
|
"use_tls": True,
|
||||||
},
|
},
|
||||||
|
"plages": dict(DEFAULT_PLAGES),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── User path helpers ──────────────────────────────────────────────────────────
|
# ── User path helpers ──────────────────────────────────────────────────────────
|
||||||
@ -142,24 +145,41 @@ def _notif_file(user_id: str) -> Path:
|
|||||||
return _user_root(user_id) / "notif.json"
|
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:
|
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)
|
f = _notif_file(user_id)
|
||||||
if f.exists():
|
if f.exists():
|
||||||
cfg = json.loads(f.read_text())
|
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"):
|
if cfg.get("token"):
|
||||||
return cfg
|
return cfg
|
||||||
else:
|
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)
|
cfg["token"] = secrets.token_hex(16)
|
||||||
_ensure_user_dirs(user_id)
|
_ensure_user_dirs(user_id)
|
||||||
f.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
f.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
||||||
return cfg
|
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 = load_notif_config(user_id)
|
||||||
cfg["ntfy_topic"] = ntfy_topic.strip() or None
|
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)
|
_ensure_user_dirs(user_id)
|
||||||
_notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
_notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
|||||||
@ -8,24 +8,21 @@ from models import list_users, load_notif_config, load_presence, load_week_point
|
|||||||
|
|
||||||
logger = logging.getLogger("pointeuse.notifications")
|
logger = logging.getLogger("pointeuse.notifications")
|
||||||
|
|
||||||
NTFY_BASE = "https://ntfy.sh"
|
|
||||||
CHECK_INTERVAL_SECONDS = 300
|
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(
|
req = urllib.request.Request(
|
||||||
f"{NTFY_BASE}/{topic}",
|
f"{server}/{topic}",
|
||||||
data=message.encode("utf-8"),
|
data=message.encode("utf-8"),
|
||||||
headers={"Title": title.encode("utf-8"), "Priority": "high"},
|
headers={"Title": title.encode("utf-8"), "Priority": "high"},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
urllib.request.urlopen(req, timeout=10).close()
|
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:
|
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
|
# 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):
|
async def check_user(user_id: str, now: datetime):
|
||||||
notif = load_notif_config(user_id)
|
notif = load_notif_config(user_id)
|
||||||
topic = notif.get("ntfy_topic")
|
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:
|
if not topic:
|
||||||
logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id)
|
logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id)
|
||||||
return
|
return
|
||||||
if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H):
|
if now.weekday() >= 5 or not (debut_h <= now.hour < fin_h):
|
||||||
logger.debug("check_user(%r): hors plage de rappel (%s)", user_id, now)
|
logger.debug("check_user(%r): hors plage de rappel (%s, plage %sh-%sh)", user_id, now, debut_h, fin_h)
|
||||||
return
|
return
|
||||||
|
|
||||||
presence = load_presence(user_id)
|
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"):
|
if presence.get("present") and not jour.get("matin_entree"):
|
||||||
logger.info("rappel 'pense à pointer' pour %r", user_id)
|
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"):
|
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)
|
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():
|
async def reminder_loop():
|
||||||
|
|||||||
3
app/pytest.ini
Normal file
3
app/pytest.ini
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
35
app/stats.py
35
app/stats.py
@ -3,7 +3,7 @@ import statistics as _st
|
|||||||
from datetime import datetime, timedelta, date as _date
|
from datetime import datetime, timedelta, date as _date
|
||||||
|
|
||||||
from models import load_week_pointages, load_conges, list_all_weeks, load_config
|
from models import load_week_pointages, load_conges, list_all_weeks, load_config
|
||||||
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 = [
|
MOIS_FR = [
|
||||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
"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}"
|
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:
|
if mc and ac:
|
||||||
return None
|
return None
|
||||||
matin_ok = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie")))
|
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):
|
if not (matin_ok and aprem_ok):
|
||||||
return None
|
return None
|
||||||
if not mc:
|
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
|
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
|
return False
|
||||||
if not ac:
|
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
|
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 False
|
||||||
return True
|
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
|
n = n_ok = 0
|
||||||
for date, p in all_days:
|
for date, p in all_days:
|
||||||
mc = (date, "matin") in cg_set or (date, "jour") in cg_set
|
mc = (date, "matin") in cg_set or (date, "jour") in cg_set
|
||||||
ac = (date, "aprem") 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:
|
if result is None:
|
||||||
continue
|
continue
|
||||||
n += 1
|
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:
|
def compute_all_stats(user_id: str = "") -> dict:
|
||||||
all_weeks = sorted(list_all_weeks(user_id))
|
all_weeks = sorted(list_all_weeks(user_id))
|
||||||
conges = load_conges(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}
|
cg_set = {(c["date"], c["type"]) for c in conges}
|
||||||
|
|
||||||
today = _date.today()
|
today = _date.today()
|
||||||
@ -73,10 +75,10 @@ def compute_all_stats(user_id: str = "") -> dict:
|
|||||||
comp_ok = {s: 0 for s in SLOTS}
|
comp_ok = {s: 0 for s in SLOTS}
|
||||||
|
|
||||||
TARGETS = {
|
TARGETS = {
|
||||||
"matin_entree": ("le", 9 * 60),
|
"matin_entree": ("le", hhmm_to_minutes(plages["matin_debut"])),
|
||||||
"matin_sortie": ("ge", 12 * 60),
|
"matin_sortie": ("ge", hhmm_to_minutes(plages["matin_fin"])),
|
||||||
"aprem_entree": ("le", 14 * 60),
|
"aprem_entree": ("le", hhmm_to_minutes(plages["aprem_debut"])),
|
||||||
"aprem_sortie": ("ge", 17 * 60),
|
"aprem_sortie": ("ge", hhmm_to_minutes(plages["aprem_fin"])),
|
||||||
}
|
}
|
||||||
|
|
||||||
weekly_balances = []
|
weekly_balances = []
|
||||||
@ -177,13 +179,14 @@ def compute_all_stats(user_id: str = "") -> dict:
|
|||||||
deltas = [b["delta_min"] for b in weekly_balances]
|
deltas = [b["delta_min"] for b in weekly_balances]
|
||||||
avg_delta_min = round(_st.mean(deltas)) if deltas else 0
|
avg_delta_min = round(_st.mean(deltas)) if deltas else 0
|
||||||
|
|
||||||
comp_globale = _compliance_over(all_days, cg_set)
|
comp_globale = _compliance_over(all_days, cg_set, plages)
|
||||||
comp_mois = _compliance_over(month_days, cg_set)
|
comp_mois = _compliance_over(month_days, cg_set, plages)
|
||||||
comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set)
|
comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set, plages)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"time_stats": time_stats,
|
"time_stats": time_stats,
|
||||||
"compliance": compliance,
|
"compliance": compliance,
|
||||||
|
"plages": plages,
|
||||||
"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,
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
{% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %}
|
{% 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 aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %}
|
||||||
{% set matin_warn = not matin_cg and j.du_min > 0 and (
|
{% set matin_warn = not matin_cg and j.du_min > 0 and (
|
||||||
(j.matin_entree and j.matin_entree > '09:00') or
|
(j.matin_entree and j.matin_entree > plages.matin_debut) or
|
||||||
(j.matin_sortie and j.matin_sortie < '12:00')
|
(j.matin_sortie and j.matin_sortie < plages.matin_fin)
|
||||||
) %}
|
) %}
|
||||||
{% set aprem_warn = not aprem_cg and j.du_min > 0 and (
|
{% set aprem_warn = not aprem_cg and j.du_min > 0 and (
|
||||||
(j.aprem_entree and j.aprem_entree > '14:00') or
|
(j.aprem_entree and j.aprem_entree > plages.aprem_debut) or
|
||||||
(j.aprem_sortie and j.aprem_sortie < '17:00')
|
(j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
|
||||||
) %}
|
) %}
|
||||||
<tr id="row-{{ j.date }}">
|
<tr id="row-{{ j.date }}">
|
||||||
|
|
||||||
@ -72,9 +72,9 @@
|
|||||||
{%- if not j.is_complete -%}
|
{%- if not j.is_complete -%}
|
||||||
{%- if j.entree_cible -%}
|
{%- if j.entree_cible -%}
|
||||||
{%- if 'aprem' in j.conge -%}
|
{%- if 'aprem' in j.conge -%}
|
||||||
<span class="cible-num">{{ j.entree_cible }}→12:00</span>
|
<span class="cible-num">{{ j.entree_cible }}→{{ plages.matin_fin }}</span>
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
<span class="cible-num">{{ j.entree_cible }}→17:00</span>
|
<span class="cible-num">{{ j.entree_cible }}→{{ plages.aprem_fin }}</span>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- elif j.sortie_cible -%}
|
{%- elif j.sortie_cible -%}
|
||||||
<span class="cible-num">{{ j.sortie_cible }}</span>
|
<span class="cible-num">{{ j.sortie_cible }}</span>
|
||||||
|
|||||||
@ -1007,7 +1007,6 @@
|
|||||||
{% if current_user is defined and current_user %}
|
{% if current_user is defined and current_user %}
|
||||||
<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>
|
|
||||||
<a href="/settings" class="nav-link">Réglages</a>
|
<a href="/settings" class="nav-link">Réglages</a>
|
||||||
<span class="nav-user">{{ current_user }}</span>
|
<span class="nav-user">{{ current_user }}</span>
|
||||||
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
|
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
|
||||||
|
|||||||
@ -1,278 +0,0 @@
|
|||||||
{% 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é (0–100)">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 %}
|
|
||||||
@ -327,11 +327,11 @@ document.addEventListener('input', function(e) {
|
|||||||
var maDiv = document.getElementById('cg-matin-' + date);
|
var maDiv = document.getElementById('cg-matin-' + date);
|
||||||
var apDiv = document.getElementById('cg-aprem-' + date);
|
var apDiv = document.getElementById('cg-aprem-' + date);
|
||||||
if (maDiv && !maDiv.classList.contains('cg')) {
|
if (maDiv && !maDiv.classList.contains('cg')) {
|
||||||
var warn = (me && me.value && me.value > '09:00') || (ms && ms.value && ms.value < '12:00');
|
var warn = (me && me.value && me.value > '{{ plages.matin_debut }}') || (ms && ms.value && ms.value < '{{ plages.matin_fin }}');
|
||||||
maDiv.classList.toggle('warn', !!warn);
|
maDiv.classList.toggle('warn', !!warn);
|
||||||
}
|
}
|
||||||
if (apDiv && !apDiv.classList.contains('cg')) {
|
if (apDiv && !apDiv.classList.contains('cg')) {
|
||||||
var warnA = (ae && ae.value && ae.value > '14:00') || (as && as.value && as.value < '17:00');
|
var warnA = (ae && ae.value && ae.value > '{{ plages.aprem_debut }}') || (as && as.value && as.value < '{{ plages.aprem_fin }}');
|
||||||
apDiv.classList.toggle('warn', !!warnA);
|
apDiv.classList.toggle('warn', !!warnA);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,8 +10,9 @@
|
|||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<span class="hist-label">Notifications (ntfy)</span>
|
<span class="hist-label">Notifications (ntfy)</span>
|
||||||
<p class="settings-p">
|
<p class="settings-p">
|
||||||
Crée un topic sur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy.sh</a> (un identifiant
|
Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>
|
||||||
secret que toi seul connais), abonne-toi dessus avec l'app ntfy sur ton téléphone, puis renseigne-le ici.
|
(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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if saved %}
|
{% if saved %}
|
||||||
@ -19,6 +20,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/settings" class="settings-form">
|
<form method="post" action="/settings" class="settings-form">
|
||||||
|
<label class="login-label" for="ntfy_server">Serveur ntfy</label>
|
||||||
|
<input
|
||||||
|
id="ntfy_server"
|
||||||
|
class="login-input"
|
||||||
|
type="text"
|
||||||
|
name="ntfy_server"
|
||||||
|
value="{{ ntfy_server }}"
|
||||||
|
placeholder="https://ntfy.sh"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
<label class="login-label" for="ntfy_topic">Topic ntfy</label>
|
<label class="login-label" for="ntfy_topic">Topic ntfy</label>
|
||||||
<input
|
<input
|
||||||
id="ntfy_topic"
|
id="ntfy_topic"
|
||||||
@ -29,6 +40,23 @@
|
|||||||
placeholder="ex: antoine-pointeuse-x7f2"
|
placeholder="ex: antoine-pointeuse-x7f2"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
>
|
>
|
||||||
|
<label class="login-label" for="rappel_debut_h">Plage horaire des rappels</label>
|
||||||
|
<div class="settings-range">
|
||||||
|
<input
|
||||||
|
id="rappel_debut_h"
|
||||||
|
class="login-input settings-range-input"
|
||||||
|
type="number" min="0" max="23" name="rappel_debut_h"
|
||||||
|
value="{{ rappel_debut_h }}"
|
||||||
|
>
|
||||||
|
<span class="settings-range-sep">h → </span>
|
||||||
|
<input
|
||||||
|
id="rappel_fin_h"
|
||||||
|
class="login-input settings-range-input"
|
||||||
|
type="number" min="1" max="24" name="rappel_fin_h"
|
||||||
|
value="{{ rappel_fin_h }}"
|
||||||
|
>
|
||||||
|
<span class="settings-range-sep">h, jours ouvrés</span>
|
||||||
|
</div>
|
||||||
<button class="login-btn" type="submit">Enregistrer</button>
|
<button class="login-btn" type="submit">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -39,7 +67,7 @@
|
|||||||
Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la
|
Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la
|
||||||
zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, l'URL "départ". Tant que tu es détecté présent sans
|
zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, l'URL "départ". Tant que tu es détecté présent sans
|
||||||
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel toutes les
|
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel toutes les
|
||||||
5 minutes (7h–20h, jours ouvrés).
|
5 minutes, dans la plage horaire réglée ci-dessus (jours ouvrés).
|
||||||
</p>
|
</p>
|
||||||
<div class="settings-urlrow">
|
<div class="settings-urlrow">
|
||||||
<span class="settings-urllabel">Arrivée</span>
|
<span class="settings-urllabel">Arrivée</span>
|
||||||
@ -94,5 +122,8 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
.settings-hint { font-size: 11px; color: var(--muted); }
|
||||||
|
.settings-range { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
|
||||||
|
.settings-range-input { width: 70px; text-align: center; }
|
||||||
|
.settings-range-sep { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -58,10 +58,10 @@
|
|||||||
{# ── 4 time-slot cards ── #}
|
{# ── 4 time-slot cards ── #}
|
||||||
<div class="ts-grid">
|
<div class="ts-grid">
|
||||||
{% set slot_meta = [
|
{% set slot_meta = [
|
||||||
("matin_entree", "Entrée matin", "≤ 09:00", "le", "matin"),
|
("matin_entree", "Entrée matin", "≤ " ~ stats.plages.matin_debut, "le", "matin"),
|
||||||
("matin_sortie", "Sortie matin", "≥ 12:00", "ge", "matin"),
|
("matin_sortie", "Sortie matin", "≥ " ~ stats.plages.matin_fin, "ge", "matin"),
|
||||||
("aprem_entree", "Entrée après-midi","≤ 14:00", "le", "aprem"),
|
("aprem_entree", "Entrée après-midi","≤ " ~ stats.plages.aprem_debut,"le", "aprem"),
|
||||||
("aprem_sortie", "Sortie après-midi","≥ 17:00", "ge", "aprem"),
|
("aprem_sortie", "Sortie après-midi","≥ " ~ stats.plages.aprem_fin, "ge", "aprem"),
|
||||||
] %}
|
] %}
|
||||||
{% for slot, label, constraint, op, half in slot_meta %}
|
{% for slot, label, constraint, op, half in slot_meta %}
|
||||||
{% set ts = stats.time_stats[slot] %}
|
{% set ts = stats.time_stats[slot] %}
|
||||||
|
|||||||
24
app/tests/conftest.py
Normal file
24
app/tests/conftest.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Freezes calcul.py's notion of "today" for deterministic tests."""
|
||||||
|
import datetime as dt_module
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import calcul
|
||||||
|
|
||||||
|
|
||||||
|
class FrozenDatetime(dt_module.datetime):
|
||||||
|
_frozen = dt_module.datetime(2026, 7, 17, 10, 30) # vendredi
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def today(cls):
|
||||||
|
return cls._frozen
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return cls._frozen
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def freeze_today(monkeypatch):
|
||||||
|
monkeypatch.setattr(calcul, "datetime", FrozenDatetime)
|
||||||
|
return FrozenDatetime._frozen
|
||||||
155
app/tests/test_calcul.py
Normal file
155
app/tests/test_calcul.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
from calcul import (
|
||||||
|
compute_week, heures_dues, heures_travaillees, hhmm_to_minutes, minutes_to_hhmm,
|
||||||
|
)
|
||||||
|
|
||||||
|
HEURES_JOUR_MIN = 468 # 7h48 = 7.8 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None):
|
||||||
|
return {
|
||||||
|
"date": date,
|
||||||
|
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
|
||||||
|
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_hhmm_to_minutes():
|
||||||
|
assert hhmm_to_minutes("09:00") == 540
|
||||||
|
assert hhmm_to_minutes("00:00") == 0
|
||||||
|
assert hhmm_to_minutes(None) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_minutes_to_hhmm():
|
||||||
|
assert minutes_to_hhmm(468) == "7h48"
|
||||||
|
assert minutes_to_hhmm(0) == "0h00"
|
||||||
|
assert minutes_to_hhmm(-90) == "-1h30"
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_dues_weekend():
|
||||||
|
assert heures_dues("2026-07-18", [], HEURES_JOUR_MIN) == 0 # samedi
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_dues_conge_jour():
|
||||||
|
conges = [{"date": "2026-07-13", "type": "jour"}]
|
||||||
|
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_dues_conge_demi_journee():
|
||||||
|
conges = [{"date": "2026-07-13", "type": "matin"}]
|
||||||
|
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == HEURES_JOUR_MIN // 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_dues_matin_et_aprem_congés_egale_jour_complet():
|
||||||
|
conges = [{"date": "2026-07-13", "type": "matin"}, {"date": "2026-07-13", "type": "aprem"}]
|
||||||
|
assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_dues_jour_normal():
|
||||||
|
assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN) == HEURES_JOUR_MIN
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_travaillees_journee_complete():
|
||||||
|
p = _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54")
|
||||||
|
assert heures_travaillees(p) == 204 + 264 # 468
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_travaillees_pas_pointe():
|
||||||
|
assert heures_travaillees(_pointage("2026-07-13")) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_heures_travaillees_en_cours_aujourdhui(freeze_today):
|
||||||
|
# freeze_today = 2026-07-17 10:30 ; entrée à 09:00 -> 90 min écoulées
|
||||||
|
p = _pointage("2026-07-17", matin_entree="09:00")
|
||||||
|
assert heures_travaillees(p) == 90
|
||||||
|
|
||||||
|
|
||||||
|
def _semaine_complete(dates_travaillees):
|
||||||
|
"""4 jours complets à exactement HEURES_JOUR_MIN, pas de congé."""
|
||||||
|
return [
|
||||||
|
_pointage(d, "09:00", "12:24", "13:30", "17:54")
|
||||||
|
for d in dates_travaillees
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_semaine_entierement_complete_solde_zero():
|
||||||
|
dates = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
||||||
|
pointages = _semaine_complete(dates)
|
||||||
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
|
assert result["total_travaille"] == "39h00"
|
||||||
|
assert result["total_du"] == "39h00"
|
||||||
|
assert result["solde_min"] == 0
|
||||||
|
assert all(j["is_complete"] for j in result["jours"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_cumul_visible_seulement_si_chaine_ininterrompue():
|
||||||
|
dates = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
||||||
|
pointages = _semaine_complete(dates)
|
||||||
|
# mercredi incomplet (pas d'aprem)
|
||||||
|
pointages[2] = _pointage("2026-07-15", "09:00", "12:24")
|
||||||
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
|
jours = result["jours"]
|
||||||
|
assert jours[0]["cumul_visible"] is True
|
||||||
|
assert jours[1]["cumul_visible"] is True
|
||||||
|
assert jours[2]["cumul_visible"] is False # jour incomplet
|
||||||
|
assert jours[3]["cumul_visible"] is False # chaîne cassée après
|
||||||
|
assert jours[4]["cumul_visible"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_sortie_cible_journee_en_cours(freeze_today):
|
||||||
|
"""Régression: capture exactement les seuils 9h/12h/13h30/17h codés en dur.
|
||||||
|
|
||||||
|
Lundi-jeudi complets à 468 min. Vendredi (aujourd'hui, frozen) pointé
|
||||||
|
seulement le matin 09:00-12:00 (180 min). Le calcul doit produire une
|
||||||
|
cible de sortie et d'entrée dérivées des seuils métier actuels.
|
||||||
|
"""
|
||||||
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
||||||
|
pointages = _semaine_complete(dates_completes)
|
||||||
|
pointages.append(_pointage("2026-07-17", matin_entree="09:00", matin_sortie="12:00"))
|
||||||
|
|
||||||
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
|
||||||
|
assert vendredi["sortie_cible"] == "18:18"
|
||||||
|
assert vendredi["entree_cible"] == "12:12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_jour_futur_non_entame_pas_de_cible(freeze_today):
|
||||||
|
"""Un jour strictement après "aujourd'hui" et pas encore entamé n'a pas de cible."""
|
||||||
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"]
|
||||||
|
pointages = _semaine_complete(dates_completes)
|
||||||
|
pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé
|
||||||
|
|
||||||
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
|
lundi_suivant = result["jours"][5]
|
||||||
|
assert lundi_suivant["entree_cible"] is None
|
||||||
|
assert lundi_suivant["sortie_cible"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_jour_courant_non_entame_calcule_quand_meme_une_cible(freeze_today):
|
||||||
|
""""Aujourd'hui" sans rien pointé n'est PAS traité comme futur (date > today est faux à égalité)."""
|
||||||
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
||||||
|
pointages = _semaine_complete(dates_completes)
|
||||||
|
pointages.append(_pointage("2026-07-17"))
|
||||||
|
|
||||||
|
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
assert vendredi["sortie_cible"] == "21:18"
|
||||||
|
assert vendredi["entree_cible"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_week_plages_personnalisees(freeze_today):
|
||||||
|
"""Les seuils de plage sont bien paramétrables, pas juste figés sur les défauts."""
|
||||||
|
plages = {
|
||||||
|
"matin_debut": "08:00", "matin_fin": "13:00",
|
||||||
|
"aprem_debut": "14:00", "aprem_fin": "18:00",
|
||||||
|
"pause_dejeuner_fin": "14:00",
|
||||||
|
}
|
||||||
|
heures_jour = (13 * 60 - 8 * 60) + (18 * 60 - 14 * 60) # 540 = journée complète sous ce schéma
|
||||||
|
dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
|
||||||
|
pointages = [_pointage(d, "08:00", "13:00", "14:00", "18:00") for d in dates_completes]
|
||||||
|
pointages.append(_pointage("2026-07-17", "08:00", "13:00")) # matin complet, aprem pas commencé
|
||||||
|
|
||||||
|
result = compute_week(pointages, [], heures_jour, plages)
|
||||||
|
vendredi = result["jours"][4]
|
||||||
|
assert vendredi["sortie_cible"] == "18:00"
|
||||||
|
assert vendredi["entree_cible"] is None
|
||||||
42
app/tests/test_stats.py
Normal file
42
app/tests/test_stats.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from calcul import DEFAULT_PLAGES
|
||||||
|
from stats import _day_compliant
|
||||||
|
|
||||||
|
|
||||||
|
def _p(matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None):
|
||||||
|
return {
|
||||||
|
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
|
||||||
|
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_compliant_journee_dans_les_clous():
|
||||||
|
p = _p("08:50", "12:05", "13:55", "17:10")
|
||||||
|
assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_compliant_entree_matin_en_retard():
|
||||||
|
p = _p("09:10", "12:05", "13:55", "17:10")
|
||||||
|
assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_compliant_sortie_aprem_trop_tot():
|
||||||
|
p = _p("08:50", "12:05", "13:55", "16:45")
|
||||||
|
assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_compliant_journee_incomplete_renvoie_none():
|
||||||
|
p = _p("08:50", "12:05") # pas d'aprem
|
||||||
|
assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_compliant_conge_journee_entiere_renvoie_none():
|
||||||
|
p = _p()
|
||||||
|
assert _day_compliant(p, mc=True, ac=True, plages=DEFAULT_PLAGES) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_day_compliant_plages_personnalisees():
|
||||||
|
plages = {**DEFAULT_PLAGES, "matin_debut": "08:00"}
|
||||||
|
p = _p("08:30", "12:05", "13:55", "17:10")
|
||||||
|
# 08:30 est après 08:00 (plage custom) -> non conforme, alors qu'avec le défaut (09:00) ça le serait
|
||||||
|
assert _day_compliant(p, mc=False, ac=False, plages=plages) is False
|
||||||
|
assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is True
|
||||||
@ -9,5 +9,12 @@
|
|||||||
"user": "no-reply@exemple.fr",
|
"user": "no-reply@exemple.fr",
|
||||||
"password": "change-moi",
|
"password": "change-moi",
|
||||||
"use_tls": true
|
"use_tls": true
|
||||||
|
},
|
||||||
|
"plages": {
|
||||||
|
"matin_debut": "09:00",
|
||||||
|
"matin_fin": "12:00",
|
||||||
|
"aprem_debut": "14:00",
|
||||||
|
"aprem_fin": "17:00",
|
||||||
|
"pause_dejeuner_fin": "13:30"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,3 +4,4 @@ jinja2==3.1.4
|
|||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
odfpy==1.4.1
|
odfpy==1.4.1
|
||||||
bcrypt==4.2.0
|
bcrypt==4.2.0
|
||||||
|
pytest==8.3.3
|
||||||
|
|||||||
Reference in New Issue
Block a user