Files
pointeuse-optimisator/app/models.py
Antoine c2d7d0e3ac 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`.
2026-07-17 22:40:55 +02:00

229 lines
7.9 KiB
Python

"""Flat-file storage: JSON files per week for pointages, one file for congés."""
import json
import secrets
from pathlib import Path
from calcul import DEFAULT_PLAGES
DATA_DIR = Path("/data")
CONFIG_FILE = DATA_DIR / "config.json"
DEFAULT_CONFIG = {
"heures_jour": 7.8, # 7h48 = 39h/semaine
"log_level": "DEBUG", # DEBUG, INFO, WARNING ou ERROR
"allowed_email_domain": "", # ex: "sdv.fr" — seules ces adresses peuvent se connecter
"mail_from": "", # ex: "no-reply@sdv.fr" — expéditeur des emails de connexion
"smtp": {
"host": "", # ex: "smtp.sdv.fr"
"port": 587,
"user": "",
"password": "",
"use_tls": True,
},
"plages": dict(DEFAULT_PLAGES),
}
# ── User path helpers ──────────────────────────────────────────────────────────
def _user_root(user_id: str) -> Path:
return DATA_DIR / "users" / user_id
def _pointages_dir(user_id: str) -> Path:
return _user_root(user_id) / "pointages"
def _conges_file(user_id: str) -> Path:
return _user_root(user_id) / "conges.json"
def _ensure_user_dirs(user_id: str):
_pointages_dir(user_id).mkdir(parents=True, exist_ok=True)
# ── User list ─────────────────────────────────────────────────────────────────
def list_users() -> list[str]:
users_dir = DATA_DIR / "users"
if not users_dir.exists():
return []
return sorted(p.name for p in users_dir.iterdir() if p.is_dir())
# ── Config (global) ───────────────────────────────────────────────────────────
def load_config() -> dict:
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
if CONFIG_FILE.exists():
try:
return json.loads(CONFIG_FILE.read_text())
except json.JSONDecodeError as e:
raise ValueError(f"{CONFIG_FILE} invalide : {e}") from e
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
return DEFAULT_CONFIG.copy()
def save_config(cfg: dict):
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
# ── Pointages ─────────────────────────────────────────────────────────────────
def _week_file(user_id: str, year: int, week: int) -> Path:
return _pointages_dir(user_id) / f"{year}-W{week:02d}.json"
def load_week_pointages(year: int, week: int, user_id: str = "") -> list[dict]:
f = _week_file(user_id, year, week)
return json.loads(f.read_text()) if f.exists() else []
def save_week_pointages(year: int, week: int, pointages: list[dict], user_id: str = ""):
_ensure_user_dirs(user_id)
_week_file(user_id, year, week).write_text(
json.dumps(pointages, indent=2, ensure_ascii=False)
)
def list_all_weeks(user_id: str = "") -> list[tuple[int, int]]:
pt_dir = _pointages_dir(user_id)
if not pt_dir.exists():
return []
weeks = []
for f in pt_dir.glob("????-W??.json"):
parts = f.stem.split("-W")
if len(parts) == 2:
weeks.append((int(parts[0]), int(parts[1])))
return sorted(weeks)
# ── Congés ────────────────────────────────────────────────────────────────────
def load_conges(user_id: str = "") -> list[dict]:
f = _conges_file(user_id)
return json.loads(f.read_text()) if f.exists() else []
def save_conges(conges: list[dict], user_id: str = ""):
_ensure_user_dirs(user_id)
_conges_file(user_id).write_text(
json.dumps(conges, indent=2, ensure_ascii=False)
)
# ── Auth (email + password) ────────────────────────────────────────────────────
def _auth_file(user_id: str) -> Path:
return _user_root(user_id) / "auth.json"
def load_auth(user_id: str) -> dict:
f = _auth_file(user_id)
if f.exists():
return json.loads(f.read_text())
return {"email": None, "password_hash": None, "token": None, "token_expires": None}
def save_auth(user_id: str, **fields):
auth = load_auth(user_id)
auth.update(fields)
_ensure_user_dirs(user_id)
_auth_file(user_id).write_text(json.dumps(auth, indent=2, ensure_ascii=False))
def find_user_by_reset_token(token: str) -> str | None:
for user_id in list_users():
if load_auth(user_id).get("token") == token:
return user_id
return None
# ── Notifications (ntfy topic + presence tracking) ────────────────────────────
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", "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, "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, 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))
def find_user_by_token(token: str) -> str | None:
for user_id in list_users():
f = _notif_file(user_id)
if f.exists() and json.loads(f.read_text()).get("token") == token:
return user_id
return None
def _presence_file(user_id: str) -> Path:
return _user_root(user_id) / "presence.json"
def load_presence(user_id: str) -> dict:
f = _presence_file(user_id)
if f.exists():
return json.loads(f.read_text())
return {"present": False, "since": None}
def save_presence(user_id: str, present: bool, since: str | None):
_ensure_user_dirs(user_id)
_presence_file(user_id).write_text(
json.dumps({"present": present, "since": since}, indent=2, ensure_ascii=False)
)
def toggle_conge(date: str, type_conge: str, user_id: str = ""):
assert type_conge in ("jour", "matin", "aprem")
conges = load_conges(user_id)
already = any(c["date"] == date and c["type"] == type_conge for c in conges)
if already:
conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)]
else:
if type_conge == "jour":
conges = [c for c in conges if c["date"] != date]
else:
conges = [c for c in conges if not (c["date"] == date and c["type"] == "jour")]
conges.append({"date": date, "type": type_conge})
conges.sort(key=lambda c: (c["date"], c["type"]))
save_conges(conges, user_id)