Files
pointeuse-optimisator/app/models.py
Jacquin Antoine 1d8a273ffb Ajoute la régénèse du token de présence Tasker et élargit le bandeau de test ntfy
Le bouton "Régénérer le token" de la carte Tasker crée un nouveau token
secret et invalide immédiatement les anciennes URLs, au cas où elles
auraient fuité. La fonction regenerate_presence_token préserve les
autres réglages notif (topic, serveur, jeton d'accès, plage). Au passage
le message de retour du bouton de test ntfy occupe désormais toute la
largeur de la carte au lieu d'être limité à 420px.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-07-20 00:05:34 +02:00

315 lines
11 KiB
Python

"""Flat-file storage: JSON files per week for pointages, one file for congés."""
import json
import secrets
from datetime import datetime
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))
def heures_jour_min() -> int:
return int(float(load_config().get("heures_jour", 7.8)) * 60)
# ── 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, ntfy_token, 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("ntfy_token", "")
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,
"ntfy_token": "",
"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,
ntfy_token: str = "",
):
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["ntfy_token"] = ntfy_token.strip()
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 regenerate_presence_token(user_id: str) -> str:
"""Génère un nouveau token de présence (invalide les URLs Tasker précédentes)."""
cfg = load_notif_config(user_id)
cfg["token"] = secrets.token_hex(16)
_ensure_user_dirs(user_id)
_notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
return cfg["token"]
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)
)
# ── Plages horaires (par utilisateur) ─────────────────────────────────────────
def _plages_file(user_id: str) -> Path:
return _user_root(user_id) / "plages.json"
def load_plages(user_id: str) -> dict:
"""Return this user's plage config, falling back to the (legacy) global config
default, then to DEFAULT_PLAGES, for any key not yet customized."""
base = {**DEFAULT_PLAGES, **load_config().get("plages", {})}
f = _plages_file(user_id)
if f.exists():
try:
return {**base, **json.loads(f.read_text())}
except json.JSONDecodeError:
pass
return base
def save_plages(user_id: str, plages: dict):
_ensure_user_dirs(user_id)
_plages_file(user_id).write_text(json.dumps(plages, 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)
# ── Journaux (notifications + présence) ───────────────────────────────────────
LOG_TYPES = ("notif", "presence")
MAX_LOGS = 500
def _logs_file(user_id: str) -> Path:
return _user_root(user_id) / "logs.json"
def append_log(user_id: str, entry_type: str, event: str, message: str = "", ts: str | None = None):
"""Ajoute une entrée au journal de l'utilisateur (rotation sur MAX_LOGS)."""
assert entry_type in LOG_TYPES, f"type de journal invalide: {entry_type!r}"
f = _logs_file(user_id)
logs = []
if f.exists():
try:
logs = json.loads(f.read_text())
except json.JSONDecodeError:
logs = []
logs.append({
"ts": ts or datetime.now().isoformat(timespec="seconds"),
"type": entry_type,
"event": event,
"message": message,
})
if len(logs) > MAX_LOGS:
logs = logs[-MAX_LOGS:]
_ensure_user_dirs(user_id)
f.write_text(json.dumps(logs, indent=2, ensure_ascii=False))
def load_logs(user_id: str) -> list[dict]:
"""Retourne les entrées du journal, des plus récentes aux plus anciennes."""
f = _logs_file(user_id)
if not f.exists():
return []
try:
logs = json.loads(f.read_text())
except json.JSONDecodeError:
return []
return list(reversed(logs))