diff --git a/app/main.py b/app/main.py index ef2425e..2b2a83f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,30 @@ +import asyncio import json import re from datetime import datetime, timedelta from itertools import groupby from fastapi import FastAPI, Form, Request, Response -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from models import ( load_config, load_week_pointages, save_week_pointages, load_conges, toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user, + load_notif_config, save_notif_config, find_user_by_token, save_presence, ) from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from stats import compute_all_stats from classement import compute_classement +from notifications import reminder_loop app = FastAPI(title="Décompte Horaire") + + +@app.on_event("startup") +async def _start_reminder_loop(): + asyncio.create_task(reminder_loop()) templates = Jinja2Templates(directory="templates") templates.env.filters["zip"] = zip app.mount("/static", StaticFiles(directory="static"), name="static") @@ -370,6 +378,52 @@ def stats_page(request: Request): }) +@app.get("/settings", response_class=HTMLResponse) +def settings_page(request: Request): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + notif = load_notif_config(user_id) + base_url = str(request.base_url).rstrip("/") + return templates.TemplateResponse("settings.html", { + "request": request, + "current_user": user_id, + "ntfy_topic": notif.get("ntfy_topic") or "", + "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", + }) + + +@app.post("/settings", response_class=HTMLResponse) +def settings_save(request: Request, ntfy_topic: str = Form("")): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + save_notif_config(user_id, ntfy_topic) + return RedirectResponse("/settings?saved=1", status_code=303) + + +@app.get("/presence/{token}/arrivee") +def presence_arrivee(token: str): + user_id = find_user_by_token(token) + if not user_id: + return JSONResponse({"error": "invalid token"}, status_code=404) + save_presence(user_id, present=True, since=datetime.now().isoformat()) + return {"ok": True} + + +@app.get("/presence/{token}/depart") +def presence_depart(token: str): + user_id = find_user_by_token(token) + if not user_id: + return JSONResponse({"error": "invalid token"}, status_code=404) + save_presence(user_id, present=False, since=datetime.now().isoformat()) + return {"ok": True} + + @app.get("/classement", response_class=HTMLResponse) def classement_page(request: Request): user_id = _get_user(request) diff --git a/app/models.py b/app/models.py index 997450a..1bd719c 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ """Flat-file storage: JSON files per week for pointages, one file for congés.""" import json +import secrets import shutil from pathlib import Path @@ -125,6 +126,60 @@ def save_conges(conges: list[dict], user_id: str = ""): ) +# ── Notifications (ntfy topic + presence tracking) ──────────────────────────── + +def _notif_file(user_id: str) -> Path: + return _user_root(user_id) / "notif.json" + + +def load_notif_config(user_id: str) -> dict: + """Return {"ntfy_topic": str|None, "token": str}, generating a token on first use.""" + f = _notif_file(user_id) + if f.exists(): + cfg = json.loads(f.read_text()) + if cfg.get("token"): + return cfg + else: + cfg = {"ntfy_topic": None} + 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): + cfg = load_notif_config(user_id) + cfg["ntfy_topic"] = ntfy_topic.strip() or None + _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) diff --git a/app/notifications.py b/app/notifications.py new file mode 100644 index 0000000..47cdcf3 --- /dev/null +++ b/app/notifications.py @@ -0,0 +1,56 @@ +"""Reminders: nag via ntfy when present-but-unpointed or departed-but-not-depointed.""" +import asyncio +import urllib.request +from datetime import datetime + +from models import list_users, load_notif_config, load_presence, load_week_pointages + +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): + req = urllib.request.Request( + f"{NTFY_BASE}/{topic}", + data=message.encode("utf-8"), + headers={"Title": title.encode("utf-8"), "Priority": "high"}, + method="POST", + ) + try: + urllib.request.urlopen(req, timeout=10).close() + except Exception: + pass # best-effort: a missed reminder isn't worth crashing the loop + + +def _todays_pointage(user_id: str, today: datetime) -> dict: + iso = today.isocalendar() + pointages = load_week_pointages(iso.year, iso.week, user_id) + date_str = today.strftime("%Y-%m-%d") + return next((p for p in pointages if p["date"] == date_str), {}) + + +async def check_user(user_id: str, now: datetime): + notif = load_notif_config(user_id) + topic = notif.get("ntfy_topic") + if not topic: + return + if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H): + return + + presence = load_presence(user_id) + jour = _todays_pointage(user_id, now) + + if presence.get("present") and not jour.get("matin_entree"): + await asyncio.to_thread(send_ntfy, 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"): + await asyncio.to_thread(send_ntfy, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer") + + +async def reminder_loop(): + while True: + now = datetime.now() + for user_id in list_users(): + await check_user(user_id, now) + await asyncio.sleep(CHECK_INTERVAL_SECONDS) diff --git a/app/templates/base.html b/app/templates/base.html index 5af9d3b..acc83ab 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -931,6 +931,7 @@ Semaine courante Statistiques Classement + Réglages {% if current_user is defined and current_user %} {{ current_user }} Déconnexion diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..7be2f1b --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block content %} +
+
+ Réglages +
Rappels de pointage sur ton téléphone
+
+
+ +
+ Notifications (ntfy) +

+ 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. +

+ + {% if saved %} +
Enregistré.
+ {% endif %} + +
+ + + +
+
+ +
+ Détection de présence (Tasker) +

+ Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'entrée de la + zone, appelle l'URL "arrivée" ; à la sortie, 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 + 5 minutes (7h–20h, jours ouvrés). +

+
+ Arrivée + {{ arrivee_url }} +
+
+ Départ + {{ depart_url }} +
+

+ Ces URLs sont secrètes (elles contiennent ton token) — ne les partage pas. +

+
+ + +{% endblock %}