From 27bcd7a0b4b0fe1946cff9d5771d59c020831eff Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Sun, 19 Jul 2026 23:25:16 +0200 Subject: [PATCH 01/23] =?UTF-8?q?Affiche=20l'heure=20d'arriv=C3=A9e=20en?= =?UTF-8?q?=20plus=20du=20d=C3=A9part=20dans=20les=20pr=C3=A9conisations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La colonne "Cible" (anciennement "Fin cible") montre désormais une plage complète entrée → sortie pour chaque demi-journée à rattraper, au lieu d'une heure de départ seule. Moins ambigu pour l'utilisateur qui sait aussi quand arriver. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/calcul.py | 12 ++++++++++-- app/main.py | 4 ++-- app/templates/_ligne.html | 4 ++-- app/templates/semaine.html | 2 +- app/tests/test_calcul.py | 2 ++ 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 66d2476..5df373d 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -131,6 +131,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int for jour in jours: jour["cible_matin"] = None jour["cible_aprem"] = None + jour["cible_matin_entree"] = None + jour["cible_aprem_entree"] = None restant = total_du - total_travaille @@ -204,8 +206,14 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int continue # rythme normal suffit pour cette demi-journée, rien à signaler sortie_min = s["debut"] + s["nominal"] + adjs[i] sortie_min = max(sortie_min, matin_fin_min if s["half"] == "matin" else aprem_fin_min) - cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" - s["jour"]["cible_matin" if s["half"] == "matin" else "cible_aprem"] = cible + entree = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}" + sortie = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" + if s["half"] == "matin": + s["jour"]["cible_matin_entree"] = entree + s["jour"]["cible_matin"] = sortie + else: + s["jour"]["cible_aprem_entree"] = entree + s["jour"]["cible_aprem"] = sortie reste_min = max(0, restant) diff --git a/app/main.py b/app/main.py index ae4757f..7e7b2d1 100644 --- a/app/main.py +++ b/app/main.py @@ -166,9 +166,9 @@ def _oob_week_extras(result): html += f'{cumul_inner}' parts = [] if jour.get("cible_matin"): - parts.append(f'M {jour["cible_matin"]}') + parts.append(f'M {jour["cible_matin_entree"]} → {jour["cible_matin"]}') if jour.get("cible_aprem"): - parts.append(f'A {jour["cible_aprem"]}') + parts.append(f'A {jour["cible_aprem_entree"]} → {jour["cible_aprem"]}') cible_inner = " ".join(parts) if parts else '—' html += f'{cible_inner}' return html diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index f444f52..73b399d 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -70,9 +70,9 @@ {%- if j.cible_matin or j.cible_aprem -%} - {%- if j.cible_matin -%}M {{ j.cible_matin }}{%- endif -%} + {%- if j.cible_matin -%}M {{ j.cible_matin_entree }} → {{ j.cible_matin }}{%- endif -%} {%- if j.cible_matin and j.cible_aprem %} {% endif -%} - {%- if j.cible_aprem -%}A {{ j.cible_aprem }}{%- endif -%} + {%- if j.cible_aprem -%}A {{ j.cible_aprem_entree }} → {{ j.cible_aprem }}{%- endif -%} {%- else -%} — {%- endif %} diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 478e7b3..a498306 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -53,7 +53,7 @@ Travaillé Δ jour Δ sem. - Fin cible + Cible Congé diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py index 726dab9..19909d9 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -113,6 +113,8 @@ def test_compute_week_cible_repartie_sur_seule_demi_journee_restante(freeze_toda assert vendredi["cible_matin"] is None # matin déjà pointé (entrée+sortie), plus rien à y faire assert vendredi["cible_aprem"] == "18:18" + # La préconisation aprem inclut aussi l'heure d'arrivée (reprise par défaut 13:30) + assert vendredi["cible_aprem_entree"] == "13:30" def test_compute_week_semaine_totalement_vide_compte_les_10_demi_journees(freeze_today): From ef30820c39dc5c41a2b67dc5ea36bbbc0749da37 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Sun, 19 Jul 2026 23:37:17 +0200 Subject: [PATCH 02/23] =?UTF-8?q?Propose=20syst=C3=A9matiquement=20les=20h?= =?UTF-8?q?oraires=20du=20matin=20et=20de=20l'apr=C3=A8s-midi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La colonne Cible affichait un horaire seulement pour les demi-journées avec du rattrapage à faire ; les demi-journées au rythme normal n'affichaient rien. Désormais chaque demi-journée restante (matin et après-midi) propose sa plage entrée → sortie, qu'il y ait du retard ou non, pour donner un planning complet à l'utilisateur. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/calcul.py | 2 -- app/tests/test_calcul.py | 42 ++++++++++++++++++++++++---------------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 5df373d..2735513 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -202,8 +202,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int left -= give for i, s in enumerate(slots): - if adjs[i] <= 0: - continue # rythme normal suffit pour cette demi-journée, rien à signaler sortie_min = s["debut"] + s["nominal"] + adjs[i] sortie_min = max(sortie_min, matin_fin_min if s["half"] == "matin" else aprem_fin_min) entree = f"{s['debut'] // 60:02d}:{s['debut'] % 60:02d}" diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py index 19909d9..207ee29 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -128,10 +128,11 @@ def test_compute_week_semaine_totalement_vide_compte_les_10_demi_journees(freeze result = compute_week(pointages, [], HEURES_JOUR_MIN) assert result["slots_restants"] == 10 assert result["reste_min"] == HEURES_JOUR_MIN * 5 - # Rien n'est encore en retard puisque rien n'a encore été renseigné : le rythme - # normal (à répartir librement sur les 10 demi-journées) suffit, pas de cible imposée. + # Pas de retard (rien n'a encore été renseigné) : le rythme normal suffit, extra = 0. + # Mais chaque demi-journée reste propose son horaire visé (matin 09:00→12:00, + # aprem 13:30→18:18 avec le surplus nominal basculé depuis le matin saturé). assert result["extra_min"] == 0 - assert all(j["cible_matin"] is None and j["cible_aprem"] is None for j in result["jours"]) + assert all(j["cible_matin"] == "12:00" and j["cible_aprem"] == "18:18" for j in result["jours"]) def test_compute_week_jour_futur_non_entame_recoit_une_cible_repartie(freeze_today): @@ -150,30 +151,34 @@ def test_compute_week_jour_futur_non_entame_recoit_une_cible_repartie(freeze_tod assert lundi_suivant["cible_matin"] is not None or lundi_suivant["cible_aprem"] is not None -def test_compute_week_jour_futur_a_egalite_pas_de_cible(freeze_today): - """Quand le rythme normal des demi-journées restantes suffit déjà, aucune cible n'est affichée.""" +def test_compute_week_jour_futur_a_egalite_propose_le_rythme_normal(freeze_today): + """Quand le rythme normal des demi-journées restantes suffit (pas de rattrapage), + les horaires visés du matin et de l'après-midi sont quand même proposés.""" 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é — mais la semaine est déjà à l'équilibre result = compute_week(pointages, [], HEURES_JOUR_MIN) lundi_suivant = result["jours"][5] - assert lundi_suivant["cible_matin"] is None - assert lundi_suivant["cible_aprem"] is None + assert result["extra_min"] == 0 + assert lundi_suivant["cible_matin"] == "12:00" + assert lundi_suivant["cible_aprem"] == "18:18" -def test_compute_week_jour_courant_non_entame_rythme_normal_suffit(freeze_today): +def test_compute_week_jour_courant_non_entame_propose_le_rythme_normal(freeze_today): """"Aujourd'hui" sans rien pointé n'est pas traité comme un jour futur figé : ses deux demi-journées entrent dans la répartition. Ici le manque (468 min) correspond exactement - au nominal des deux demi-journées restantes : le rythme normal suffit, pas de cible.""" + au nominal des deux demi-journées restantes : le rythme normal suffit, mais les horaires + visés matin et aprem restent proposés.""" 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["cible_matin"] is None - assert vendredi["cible_aprem"] is None + assert result["extra_min"] == 0 + assert vendredi["cible_matin"] == "12:00" + assert vendredi["cible_aprem"] == "18:18" def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today): @@ -196,8 +201,9 @@ def test_compute_week_demi_journee_en_cours_pas_de_double_comptage(freeze_today) result = compute_week(pointages, [], HEURES_JOUR_MIN) vendredi = result["jours"][4] assert result["extra_min"] == 60 - # matin saturé au nominal (09:00→12:00), pas de cible ; aprem : 13:30 + 288 + 60 = 19:18 - assert vendredi["cible_matin"] is None + # matin en cours : sa cible correspond au planning nominal (09:00→12:00), pas de rattrapage + # possible (saturé au plafond pause) ; aprem : 13:30 + 288 + 60 = 19:18 + assert vendredi["cible_matin"] == "12:00" assert vendredi["cible_aprem"] == "19:18" @@ -223,7 +229,9 @@ def test_compute_week_cible_matin_ne_mange_jamais_la_pause_de_midi(freeze_today) # 120 min de retard + 54 min de nominal matin qui ne tiennent pas avant la pause # (et ne peuvent pas basculer sur l'après-midi, en congé) assert result["extra_min"] == 174 - assert vendredi["cible_matin"] is None + # Le matin (seul créneau dispo, aprem en congé) est saturé à son plafond 09:00→12:00 : + # cible proposée au rythme normal, mais le rattrapage (174 min) ne tient pas. + assert vendredi["cible_matin"] == "12:00" assert vendredi["cible_aprem"] is None # congé @@ -245,8 +253,8 @@ def test_compute_week_surplus_creneau_sature_reporte_sur_les_autres(freeze_today result = compute_week(pointages, [], HEURES_JOUR_MIN) vendredi = result["jours"][4] assert result["extra_min"] == 80 - # matin saturé au nominal, pas de cible ; aprem : 13:30 + 288 + 80 = 19:38 - assert vendredi["cible_matin"] is None + # matin saturé au nominal (cible au rythme normal 09:00→12:00) ; aprem : 13:30 + 288 + 80 = 19:38 + assert vendredi["cible_matin"] == "12:00" assert vendredi["cible_aprem"] == "19:38" @@ -327,7 +335,7 @@ def test_compute_week_depart_vise_plafond_souple(freeze_today): # vendredi aprem : 13:30 + 234 + (36 + 9) = 18:09 ; lundi aprem : 13:30 + 288 + 9 = 18:27 # (sans départ visé, lundi finirait à 18:45) assert vendredi["cible_aprem"] == "18:09" - assert lundi["cible_matin"] is None + assert lundi["cible_matin"] == "12:00" # matin au rythme normal (pas de rattrapage pour ce créneau) assert lundi["cible_aprem"] == "18:27" From 5fdef77e2b554e7611ef5b64101a4e1cd722a38b Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Sun, 19 Jul 2026 23:46:08 +0200 Subject: [PATCH 03/23] =?UTF-8?q?Ajoute=20un=20journal=20par=20utilisateur?= =?UTF-8?q?=20des=20notifications=20ntfy=20et=20de=20la=20pr=C3=A9sence=20?= =?UTF-8?q?Tasker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque utilisateur a désormais son propre fichier logs.json dans son dossier users//, alimenté automatiquement quand un rappel ntfy est envoyé (ou échoue) et quand Tasker signale une arrivée ou un départ. Une nouvelle page /logs, accessible depuis la nav, affiche ces entrées avec filtres (Tout / Notifications / Présence), et le journal tourne à 500 entrées pour rester compact. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/main.py | 18 +++++ app/models.py | 45 +++++++++++++ app/notifications.py | 20 ++++-- app/templates/base.html | 1 + app/templates/logs.html | 141 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 app/templates/logs.html diff --git a/app/main.py b/app/main.py index 7e7b2d1..b80e43a 100644 --- a/app/main.py +++ b/app/main.py @@ -19,6 +19,7 @@ from models import ( load_notif_config, save_notif_config, find_user_by_token, save_presence, load_auth, save_auth, find_user_by_reset_token, load_plages, save_plages, heures_jour_min, + append_log, load_logs, ) from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from stats import compute_all_stats @@ -637,6 +638,7 @@ def presence_arrivee(token: str): return JSONResponse({"error": "invalid token"}, status_code=404) logger.info("présence: arrivée détectée pour %r", user_id) save_presence(user_id, present=True, since=datetime.now().isoformat()) + append_log(user_id, "presence", "arrivée", "Présence détectée par Tasker") return {"ok": True} @@ -648,6 +650,22 @@ def presence_depart(token: str): return JSONResponse({"error": "invalid token"}, status_code=404) logger.info("présence: départ détecté pour %r", user_id) save_presence(user_id, present=False, since=datetime.now().isoformat()) + append_log(user_id, "presence", "départ", "Départ détecté par Tasker") return {"ok": True} +@app.get("/logs", response_class=HTMLResponse) +def logs_page(request: Request): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + logs = load_logs(user_id) + return templates.TemplateResponse("logs.html", { + "request": request, + "current_user": user_id, + "logs": logs, + "filter": request.query_params.get("filter", "all"), + }) + + diff --git a/app/models.py b/app/models.py index b9abf74..3e024db 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,7 @@ """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 @@ -254,3 +255,47 @@ def toggle_conge(date: str, type_conge: str, user_id: str = ""): 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)) diff --git a/app/notifications.py b/app/notifications.py index b2c2ac1..e301d80 100644 --- a/app/notifications.py +++ b/app/notifications.py @@ -4,14 +4,14 @@ import logging import urllib.request from datetime import datetime -from models import list_users, load_notif_config, load_presence, load_week_pointages +from models import list_users, load_notif_config, load_presence, load_week_pointages, append_log logger = logging.getLogger("pointeuse.notifications") CHECK_INTERVAL_SECONDS = 300 -def send_ntfy(server: str, topic: str, message: str, title: str): +def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None): req = urllib.request.Request( f"{server}/{topic}", data=message.encode("utf-8"), @@ -21,8 +21,12 @@ def send_ntfy(server: str, topic: str, message: str, title: str): try: urllib.request.urlopen(req, timeout=10).close() logger.info("notif ntfy envoyée: server=%r topic=%r title=%r", server, topic, title) + if user_id: + append_log(user_id, "notif", "envoyée", f"{title} — {message}") except Exception: logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title) + if user_id: + append_log(user_id, "notif", "échec", f"{title} — {message}") # best-effort: a missed reminder isn't worth crashing the loop @@ -55,10 +59,18 @@ async def check_user(user_id: str, now: datetime): if presence.get("present") and not jour.get("matin_entree"): logger.info("rappel 'pense à pointer' pour %r", user_id) - await asyncio.to_thread(send_ntfy, server, 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", user_id, + ) elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"): logger.info("rappel 'pense à dépointer' pour %r", user_id) - await asyncio.to_thread(send_ntfy, server, 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", user_id, + ) async def reminder_loop(): diff --git a/app/templates/base.html b/app/templates/base.html index b00946c..258c5d1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1032,6 +1032,7 @@ {% if current_user is defined and current_user %} Semaine courante Statistiques + Journaux Réglages {{ current_user }} Déconnexion diff --git a/app/templates/logs.html b/app/templates/logs.html new file mode 100644 index 0000000..1e68289 --- /dev/null +++ b/app/templates/logs.html @@ -0,0 +1,141 @@ +{% extends "base.html" %} +{% block content %} +
+
+ Journaux +
+ Notifications ntfy et détections de présence (Tasker) — {{ current_user }} +
+
+
+ +
+ {% set counts = {'all': logs|length, 'notif': logs | selectattr('type', 'equalto', 'notif') | list | length, 'presence': logs | selectattr('type', 'equalto', 'presence') | list | length} %} + + Tout {{ counts.all }} + + + Notifications {{ counts.notif }} + + + Présence {{ counts.presence }} + +
+ +{% set visible = logs if filter == 'all' else logs | selectattr('type', 'equalto', filter) | list %} + +{% if visible %} +
+ {% for entry in visible %} +
+
{{ entry.ts }}
+
{{ 'Notification' if entry.type == 'notif' else 'Présence' }}
+
+ {{ entry.event }} + {% if entry.message %}{{ entry.message }}{% endif %} +
+
+ {% endfor %} +
+{% else %} +
Aucun événement enregistré pour le moment.
+{% endif %} + + +{% endblock %} From f82f443ec744b00f29cfb9dbad431bfbbf276a3c Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Sun, 19 Jul 2026 23:51:07 +0200 Subject: [PATCH 04/23] Ajoute une page d'aide en ligne pour configurer ntfy et Tasker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La nouvelle route /aide regroupe dans l'app les étapes d'installation de l'app ntfy (Play Store/F-Droid, abonnement au topic, piège optimisation batterie, option auto-hébergé) et de Tasker (permissions localisation, profils Enter/Exit avec HTTP Request), plus une section vérification via /logs et un dépannage. Elle est accessible depuis la nav et depuis les deux cartes ntfy/Tasker de /settings. Les 11 tests de test_logs.py couvrent en plus les routes /logs et /aide, le logging présence, l'isolation par user et la rotation. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/main.py | 11 ++ app/templates/aide.html | 211 ++++++++++++++++++++++++++++++++++++ app/templates/base.html | 1 + app/templates/settings.html | 26 ++++- app/tests/test_logs.py | 148 +++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 app/templates/aide.html create mode 100644 app/tests/test_logs.py diff --git a/app/main.py b/app/main.py index b80e43a..c1beaa6 100644 --- a/app/main.py +++ b/app/main.py @@ -669,3 +669,14 @@ def logs_page(request: Request): }) +@app.get("/aide", response_class=HTMLResponse) +def aide_page(request: Request): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + return templates.TemplateResponse("aide.html", { + "request": request, + "current_user": user_id, + }) + + diff --git a/app/templates/aide.html b/app/templates/aide.html new file mode 100644 index 0000000..cbe55e8 --- /dev/null +++ b/app/templates/aide.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} +{% block content %} +
+
+ Aide +
Configurer les notifications ntfy et la détection de présence Tasker
+
+
+ +
+ Principe +

+ L'app peut te relancer automatiquement si tu es détecté présent au bureau sans avoir pointé + le matin, ou parti sans avoir pointé la sortie. Ça repose sur deux briques : +

+
    +
  • + Réception des notifications : ntfy, + un service gratuit de notifications push par topic. +
  • +
  • + Détection de présence : ton téléphone appelle une URL de l'app quand il entre/sort + du bureau. L'app fait le reste (vérifie toutes les 5 min, dans la plage horaire réglée + sur /settings, en semaine, si un pointage manque). +
  • +
+

+ Avant de commencer : va sur /settings, choisis un nom de topic ntfy + secret (ex: prenom-pointeuse-a1b2, pas devinable), enregistre-le. La page affiche + ensuite tes deux URLs personnelles Arrivée et Départ — elles contiennent un token + secret, ne les partage pas. +

+
+ +
+ 1. Recevoir les notifications (ntfy) +

+ Installer l'app ntfy sur le téléphone, puis s'abonner au topic choisi dans /settings. +

+
    +
  1. + Installe l'app ntfy depuis le + Play Store + ou F-Droid. +
  2. +
  3. + Ouvre l'app, bouton + → entre le nom de topic choisi sur + /settings → valide. Laisse le serveur par défaut (ntfy.sh). +
  4. +
  5. + Teste l'envoi depuis un navigateur : ouvre + https://ntfy.sh/<ton-topic> et publie un message. La notif doit + arriver sur le téléphone en quelques secondes. +
  6. +
  7. + Important : sur Android, désactive l'optimisation de la batterie pour ntfy + (Réglages → Applications → ntfy → Batterie → "Aucune restriction"). Sinon Android + peut couper les notifications en arrière-plan. +
  8. +
+

+ Astuce : si tu veux ton propre serveur ntfy (auto-hébergé, pour éviter le topic public), + installe-le (doc officielle) + et renseigne son URL dans le champ Serveur ntfy sur /settings. +

+
+ +
+ 2. Détecter la présence (Tasker) +

+ Créer une automatisation Tasker déclenchée par une géofence autour du bureau : à l'entrée + de la zone, appeler l'URL "Arrivée" ; à la sortie, l'URL "Départ". +

+
    +
  1. + Installe Tasker + (payant, Play Store). +
  2. +
  3. + Autorise Tasker à utiliser la localisation en arrière-plan + (Réglages → Applications → Tasker → Autorisations → Localisation → "Toujours"). + Sans ça, Android coupe la géolocalisation quand l'app n'est pas au premier plan. +
  4. +
  5. + Crée un profil Entrée bureau : +
      +
    • Onglet Profiles → bouton + → Location.
    • +
    • Renseigne l'adresse ou les coordonnées du bureau, rayon ~100–150 m.
    • +
    • Tâche associée (nouvelle tâche, ex: PointeuseArrivee) :
    • +
    • Action Net → HTTP Request, méthode GET, + URL = ton lien Arrivée copié depuis /settings.
    • +
    +
  6. +
  7. + Crée un profil symétrique Sortie bureau : même géofence, événement Exit, + tâche PointeuseDepart qui appelle ton lien Départ. +
  8. +
+

+ Si tu changes de téléphone ou réinstalles, les tokens restent valides — il suffit de + recharger les profils Tasker et l'abonnement ntfy. +

+
+ +
+ 3. Vérifier +
    +
  1. + Force une entrée/sortie de zone (ou lance la tâche HTTP manuellement dans Tasker via + le bouton "play"). La réponse doit être {"ok":true}. +
  2. +
  3. + Va sur /logs : tu dois voir une entrée "Présence / arrivée" ou + "Présence / départ" correspondante. +
  4. +
  5. + Simule une journée sans pointage matin pendant que tu es marqué "présent" : une notif + ntfy doit arriver dans les 5 minutes. Elle apparaîtra aussi dans + /logs sous "Notification / envoyée". +
  6. +
+
+ +
+ Dépannage +
    +
  • + Pas de notif ntfy : vérifie l'optimisation batterie sur l'app ntfy, et que le topic + est bien le même côté serveur et côté téléphone (sensible à la casse). +
  • +
  • + Pas de déclenchement Tasker : dans Tasker, vérifie que la géofence est bien activée + (icône rouge) et lance la tâche à la main pour isoler le problème (réseau vs géoloc). +
  • +
  • + Logs vides sur /logs : les entrées ne s'écrivent que quand un événement réel se + produit (notif envoyée/échouée, arrivée/départ détecté). Force un test (étape 3). +
  • +
+
+ + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 258c5d1..2bcd309 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1034,6 +1034,7 @@ Statistiques Journaux Réglages + Aide {{ current_user }} Déconnexion {% endif %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 91fb8bc..a729cbb 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -51,11 +51,12 @@
- Notifications (ntfy) + Notifications (ntfy) Aide

Crée un topic sur ton serveur ntfy (public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec - l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous. + l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous. Guide complet sur + la page Aide.

{% if saved %} @@ -105,12 +106,13 @@
- Détection de présence (Tasker) + Détection de présence (Tasker) Aide

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, dans la plage horaire réglée ci-dessus (jours ouvrés). + 5 minutes, dans la plage horaire réglée ci-dessus (jours ouvrés). Étapes détaillées sur la page + Aide.

Arrivée @@ -184,5 +186,21 @@ .time-range-input::-webkit-calendar-picker-indicator { margin-left: .5rem; } +.settings-help-link { + font-family: var(--mono); + font-size: 10px; + letter-spacing: .04em; + color: var(--accent); + text-decoration: none; + border: 1px solid var(--rule); + padding: .1rem .4rem; + margin-left: .5rem; + vertical-align: middle; + transition: border-color .15s, background .15s; +} +.settings-help-link:hover { + border-color: var(--accent); + background: rgba(47,95,160,.06); +} {% endblock %} diff --git a/app/tests/test_logs.py b/app/tests/test_logs.py new file mode 100644 index 0000000..ec4a930 --- /dev/null +++ b/app/tests/test_logs.py @@ -0,0 +1,148 @@ +"""Smoke tests pour les nouvelles routes /logs et /aide + journalisation présence.""" +import sys +import tempfile +from pathlib import Path + +# Prépare un DATA_DIR temporaire AVANT l'import de models +_TMP = Path(tempfile.mkdtemp()) +import models +models.DATA_DIR = _TMP +models.CONFIG_FILE = models.DATA_DIR / "config.json" +models.CONFIG_FILE.write_text('{"heures_jour": 7.8, "allowed_email_domain": "x.fr"}') + +# Recharge main dans un état propre +sys.path.insert(0, ".") +import main +from fastapi.testclient import TestClient + +client = TestClient(main.app) + + +def _login_as(uid: str): + """Crée un user avec mdp puis se connecte (renvoie les cookies).""" + import bcrypt + models.save_auth(uid, email=f"{uid}@x.fr", + password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode()) + r = client.post("/login/password", data={"email": f"{uid}@x.fr", "password": "x" * 12}, + follow_redirects=False) + assert r.status_code == 302, r.text + return {"user_id": uid} + + +COOKIES = _login_as("alice") + + +# ── Route /logs ────────────────────────────────────────────────────────────── + +def test_logs_page_requires_auth(): + # Client frais, sans cookie, doit être redirigé vers /login + unauth = TestClient(main.app) + r = unauth.get("/logs", follow_redirects=False) + assert r.status_code == 302 + assert "/login" in r.headers["location"] + + +def test_logs_page_empty_for_new_user(): + r = client.get("/logs", cookies=COOKIES) + assert r.status_code == 200 + assert "Journaux" in r.text + assert "Aucun événement" in r.text # état initial + + +def test_logs_page_shows_entries_after_presence(): + token = models.load_notif_config("alice")["token"] + # Simule une arrivée Tasker + client.get(f"/presence/{token}/arrivee") + # Simule un départ + client.get(f"/presence/{token}/depart") + + r = client.get("/logs", cookies=COOKIES) + assert r.status_code == 200 + assert "arrivée" in r.text + assert "départ" in r.text + assert "Présence" in r.text + assert "Aucun événement" not in r.text + + +def test_logs_filter_query_param(): + # Toutes les entrées sont de type "presence" à ce stade + r_all = client.get("/logs", cookies=COOKIES) + r_notif = client.get("/logs?filter=notif", cookies=COOKIES) + r_pres = client.get("/logs?filter=presence", cookies=COOKIES) + + # Le filtre "notif" ne doit PAS montrer les entrées presence + assert "arrivée" not in r_notif.text + # Le filtre "presence" DOIT les montrer + assert "arrivée" in r_pres.text + assert "départ" in r_pres.text + # Le total Tout inclut tout + assert "arrivée" in r_all.text + + +# ── Route /aide ────────────────────────────────────────────────────────────── + +def test_aide_page_requires_auth(): + unauth = TestClient(main.app) + r = unauth.get("/aide", follow_redirects=False) + assert r.status_code == 302 + assert "/login" in r.headers["location"] + + +def test_aide_page_content(): + r = client.get("/aide", cookies=COOKIES) + assert r.status_code == 200 + # Vérifie la présence des sections clés + for needle in ["ntfy", "Tasker", "Aide", "topic", "géofence", "/logs"]: + assert needle in r.text, f"missing {needle!r}" + + +# ── Logging via notifications.send_ntfy (échec) ───────────────────────────── + +def test_send_ntfy_failure_logs_entry(): + # Serveur injoignable → échec → entrée de log "échec" + import notifications + notifications.send_ntfy( + "http://127.0.0.1:1", "topic-injoignable", + "msg test", "titre test", user_id="alice", + ) + logs = models.load_logs("alice") + assert any(l["event"] == "échec" and l["type"] == "notif" for l in logs) + + +# ── Navigation ────────────────────────────────────────────────────────────── + +def test_nav_links_present(): + r = client.get("/aide", cookies=COOKIES) + assert 'href="/logs"' in r.text + assert 'href="/aide"' in r.text + assert 'href="/settings"' in r.text + + +# ── Isolation par user ────────────────────────────────────────────────────── + +def test_logs_isolated_per_user(): + # Bob ne doit pas voir les logs d'alice + bob_cookies = _login_as("bob") + r = client.get("/logs", cookies=bob_cookies) + assert "arrivée" not in r.text + assert "Aucun événement" in r.text + + +# ── Rotation des logs ─────────────────────────────────────────────────────── + +def test_log_rotation_caps_at_max(): + # Alice a déjà quelques entrées ; on en ajoute largement > MAX_LOGS + for i in range(models.MAX_LOGS + 50): + models.append_log("alice", "notif", "envoyée", f"msg {i}") + logs = models.load_logs("alice") + assert len(logs) == models.MAX_LOGS + # Le plus récent est bien le dernier inséré + assert logs[0]["message"] == f"msg {models.MAX_LOGS + 49}" + + +def test_invalid_log_type_rejected(): + try: + models.append_log("alice", "invalid_type", "x", "y") + assert False, "doit lever une AssertionError" + except AssertionError: + pass From 2ee317a46eeec046ac18d8c927a68e14670d3f4a Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Sun, 19 Jul 2026 23:57:48 +0200 Subject: [PATCH 05/23] =?UTF-8?q?Ajoute=20un=20bouton=20de=20test=20de=20l?= =?UTF-8?q?a=20notification=20ntfy=20depuis=20les=20r=C3=A9glages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet de vérifier en un clic que le serveur et le topic ntfy renseignés fonctionnent, avant d'attendre un vrai rappel. Le bouton envoie une notification de test via la même chaîne que les rappels réels (donc tracée dans /logs), et affiche le résultat inline. La fonction send_ntfy retourne désormais un booléen de succès pour permettre ce retour utilisateur. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/main.py | 35 ++++++++++++++++++++++++++++++++- app/notifications.py | 4 +++- app/templates/settings.html | 33 +++++++++++++++++++++++++++++++ app/tests/test_logs.py | 39 +++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index c1beaa6..ff5eb72 100644 --- a/app/main.py +++ b/app/main.py @@ -24,7 +24,7 @@ from models import ( from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from stats import compute_all_stats from export import export_semaine_ods, export_annee_ods -from notifications import reminder_loop +from notifications import reminder_loop, send_ntfy logging.basicConfig( level=getattr(logging, load_config().get("log_level", "DEBUG").upper(), logging.DEBUG), @@ -594,6 +594,39 @@ def settings_save( return RedirectResponse("/settings?saved=1", status_code=303) +@app.post("/settings/test-ntfy", response_class=HTMLResponse) +def settings_test_ntfy(request: Request): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + notif = load_notif_config(user_id) + topic = notif.get("ntfy_topic") + server = notif.get("ntfy_server", "https://ntfy.sh") + + if not topic: + logger.warning("test ntfy: pas de topic configuré pour %r", user_id) + return HTMLResponse( + '
' + 'Renseigne et enregistre un topic ntfy avant de tester.' + '
' + ) + + logger.info("test ntfy: envoi de test pour %r sur %r/%r", user_id, server, topic) + ok = send_ntfy( + server, topic, + "Ceci est une notification de test — ta configuration ntfy fonctionne.", + "Test pointeuse", user_id=user_id, + ) + cls = "settings-saved" if ok else "settings-error" + msg = ("Notification envoyée. Vérifie ton téléphone, elle doit arriver en quelques secondes." + if ok else + "Échec de l'envoi. Vérifie le serveur et le topic, et l'optimisation batterie de l'app ntfy.") + return HTMLResponse( + f'
{msg}
' + ) + + @app.post("/settings/plages", response_class=HTMLResponse) def settings_plages_save( request: Request, diff --git a/app/notifications.py b/app/notifications.py index e301d80..4957081 100644 --- a/app/notifications.py +++ b/app/notifications.py @@ -11,7 +11,7 @@ logger = logging.getLogger("pointeuse.notifications") CHECK_INTERVAL_SECONDS = 300 -def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None): +def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None) -> bool: req = urllib.request.Request( f"{server}/{topic}", data=message.encode("utf-8"), @@ -23,11 +23,13 @@ def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | logger.info("notif ntfy envoyée: server=%r topic=%r title=%r", server, topic, title) if user_id: append_log(user_id, "notif", "envoyée", f"{title} — {message}") + return True except Exception: logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title) if user_id: append_log(user_id, "notif", "échec", f"{title} — {message}") # best-effort: a missed reminder isn't worth crashing the loop + return False def _todays_pointage(user_id: str, today: datetime) -> dict: diff --git a/app/templates/settings.html b/app/templates/settings.html index a729cbb..6dd1e47 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -103,6 +103,16 @@
+
+ + +
@@ -202,5 +212,28 @@ border-color: var(--accent); background: rgba(47,95,160,.06); } +.settings-test-row { + display: flex; + align-items: center; + gap: .75rem; + flex-wrap: wrap; + max-width: 420px; +} +.settings-test-btn { + flex-shrink: 0; +} +#ntfy-test-result { + flex: 1; + min-width: 0; +} +#ntfy-test-result:empty { display: none; } +#ntfy-test-result:not(:empty) { + font-family: var(--mono); + font-size: 12px; + padding: .5rem .8rem; + border-left: 3px solid var(--muted); + background: var(--ground); + line-height: 1.45; +} {% endblock %} diff --git a/app/tests/test_logs.py b/app/tests/test_logs.py index ec4a930..64a32a3 100644 --- a/app/tests/test_logs.py +++ b/app/tests/test_logs.py @@ -146,3 +146,42 @@ def test_invalid_log_type_rejected(): assert False, "doit lever une AssertionError" except AssertionError: pass + + +# ── Bouton de test ntfy (/settings/test-ntfy) ──────────────────────────────── + +def test_test_ntfy_requires_auth(): + unauth = TestClient(main.app) + r = unauth.post("/settings/test-ntfy", follow_redirects=False) + assert r.status_code == 302 + assert "/login" in r.headers["location"] + + +def test_test_ntfy_without_topic_returns_error(): + # Alice n'a pas de topic configuré → message d'erreur + models.save_notif_config("alice", ntfy_topic="", ntfy_server="https://ntfy.sh") + r = client.post("/settings/test-ntfy", cookies=COOKIES) + assert r.status_code == 200 + assert "settings-error" in r.text + assert "topic" in r.text.lower() + + +def test_test_ntfy_with_bad_server_returns_failure(): + # Topic renseigné mais serveur injoignable → échec + models.save_notif_config("alice", ntfy_topic="topic-test", ntfy_server="http://127.0.0.1:1") + r = client.post("/settings/test-ntfy", cookies=COOKIES) + assert r.status_code == 200 + assert "settings-error" in r.text + assert "Échec" in r.text + # L'entrée de log d'échec doit être présente + logs = models.load_logs("alice") + assert any(l["event"] == "échec" and l["type"] == "notif" for l in logs) + + +def test_test_ntfy_logs_to_journal(): + """Le bouton de test doit écrire dans le journal (même en échec).""" + models.save_notif_config("alice", ntfy_topic="topic-test-2", ntfy_server="http://127.0.0.1:1") + client.post("/settings/test-ntfy", cookies=COOKIES) + logs = models.load_logs("alice") + # L'entrée la plus récente doit être l'échec du test (message contient "Test pointeuse") + assert any(l["event"] == "échec" and "Test pointeuse" in l["message"] for l in logs) From 076b5a4fa25708337e0d29e9df97ccc9fedba4b5 Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Mon, 20 Jul 2026 00:01:12 +0200 Subject: [PATCH 06/23] =?UTF-8?q?Ajoute=20le=20support=20d'un=20jeton=20d'?= =?UTF-8?q?acc=C3=A8s=20pour=20les=20serveurs=20ntfy=20prot=C3=A9g=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Certains serveurs ntfy auto-hébergés (ex: ntfy.arkel.fr) renvoient 403 Forbidden sans authentification. Un nouveau champ "Jeton d'accès" (optionnel, type password) sur /settings permet de renseigner un token qui sera envoyé comme "Authorization: Bearer ..." à chaque publication, pour les rappels réels comme pour le bouton de test. La page /aide documente le cas 403 et la marche à suivre. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- app/main.py | 9 +++-- app/models.py | 6 +++- app/notifications.py | 12 ++++--- app/templates/aide.html | 7 ++++ app/templates/settings.html | 10 ++++++ app/tests/test_logs.py | 65 +++++++++++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index ff5eb72..8bc386d 100644 --- a/app/main.py +++ b/app/main.py @@ -563,6 +563,7 @@ def settings_page(request: Request): "current_user": user_id, "ntfy_topic": notif.get("ntfy_topic") or "", "ntfy_server": notif.get("ntfy_server") or "https://ntfy.sh", + "ntfy_token": notif.get("ntfy_token") or "", "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", @@ -578,6 +579,7 @@ def settings_page(request: Request): 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), + ntfy_token: str = Form(""), ): user_id = _get_user(request) if not user_id: @@ -587,10 +589,11 @@ def settings_save( 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", + "réglages notif mis à jour: user_id=%r ntfy_topic=%r ntfy_server=%r plage=%sh-%sh token=%s", user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h, + "(défini)" if ntfy_token.strip() else "(vide)", ) - save_notif_config(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, ntfy_token) return RedirectResponse("/settings?saved=1", status_code=303) @@ -616,7 +619,7 @@ def settings_test_ntfy(request: Request): ok = send_ntfy( server, topic, "Ceci est une notification de test — ta configuration ntfy fonctionne.", - "Test pointeuse", user_id=user_id, + "Test pointeuse", user_id=user_id, token=notif.get("ntfy_token", ""), ) cls = "settings-saved" if ok else "settings-error" msg = ("Notification envoyée. Vérifie ton téléphone, elle doit arriver en quelques secondes." diff --git a/app/models.py b/app/models.py index 3e024db..a6d6012 100644 --- a/app/models.py +++ b/app/models.py @@ -156,11 +156,12 @@ 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"}.""" + """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"): @@ -168,6 +169,7 @@ def load_notif_config(user_id: str) -> dict: 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) @@ -179,10 +181,12 @@ def load_notif_config(user_id: str) -> dict: 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) diff --git a/app/notifications.py b/app/notifications.py index 4957081..7e1d0ad 100644 --- a/app/notifications.py +++ b/app/notifications.py @@ -11,11 +11,14 @@ logger = logging.getLogger("pointeuse.notifications") CHECK_INTERVAL_SECONDS = 300 -def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None) -> bool: +def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None, token: str = "") -> bool: + headers = {"Title": title.encode("utf-8"), "Priority": "high"} + if token: + headers["Authorization"] = f"Bearer {token}" req = urllib.request.Request( f"{server}/{topic}", data=message.encode("utf-8"), - headers={"Title": title.encode("utf-8"), "Priority": "high"}, + headers=headers, method="POST", ) try: @@ -43,6 +46,7 @@ async def check_user(user_id: str, now: datetime): notif = load_notif_config(user_id) topic = notif.get("ntfy_topic") server = notif.get("ntfy_server", "https://ntfy.sh") + token = notif.get("ntfy_token", "") debut_h = notif.get("rappel_debut_h", 7) fin_h = notif.get("rappel_fin_h", 20) if not topic: @@ -64,14 +68,14 @@ async def check_user(user_id: str, now: datetime): await asyncio.to_thread( send_ntfy, server, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", - "⏰ Pense à pointer", user_id, + "⏰ Pense à pointer", user_id, token, ) elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"): logger.info("rappel 'pense à dépointer' pour %r", user_id) await asyncio.to_thread( send_ntfy, server, topic, "Tu as quitté les locaux sans dépointer la sortie.", - "⏰ Pense à dépointer", user_id, + "⏰ Pense à dépointer", user_id, token, ) diff --git a/app/templates/aide.html b/app/templates/aide.html index cbe55e8..ad5860f 100644 --- a/app/templates/aide.html +++ b/app/templates/aide.html @@ -57,6 +57,13 @@ (Réglages → Applications → ntfy → Batterie → "Aucune restriction"). Sinon Android peut couper les notifications en arrière-plan. +
  • + Serveur protégé par jeton : si ton instance ntfy renvoie 403 Forbidden, + elle exige un jeton d'accès. Crée-le sur le serveur (panneau d'admin ntfy, onglet + "Access tokens"), puis colle-le dans le champ Jeton d'accès sur + /settings. Il est envoyé comme + Authorization: Bearer ... à chaque publication. +
  • Astuce : si tu veux ton propre serveur ntfy (auto-hébergé, pour éviter le topic public), diff --git a/app/templates/settings.html b/app/templates/settings.html index 6dd1e47..6ed47e0 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -84,6 +84,16 @@ placeholder="ex: antoine-pointeuse-x7f2" autocomplete="off" > + +

    Date: Mon, 20 Jul 2026 00:05:34 +0200 Subject: [PATCH 07/23] =?UTF-8?q?Ajoute=20la=20r=C3=A9g=C3=A9n=C3=A8se=20d?= =?UTF-8?q?u=20token=20de=20pr=C3=A9sence=20Tasker=20et=20=C3=A9largit=20l?= =?UTF-8?q?e=20bandeau=20de=20test=20ntfy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/main.py | 28 +++++++++++++++++++++ app/models.py | 9 +++++++ app/templates/settings.html | 35 ++++++++++++++++++++------ app/tests/test_logs.py | 49 +++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index 8bc386d..46617db 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from models import ( load_config, load_week_pointages, save_week_pointages, load_conges, toggle_conge, list_all_weeks, load_notif_config, save_notif_config, find_user_by_token, save_presence, + regenerate_presence_token, load_auth, save_auth, find_user_by_reset_token, load_plages, save_plages, heures_jour_min, append_log, load_logs, @@ -630,6 +631,33 @@ def settings_test_ntfy(request: Request): ) +@app.post("/settings/regenerate-presence-token", response_class=HTMLResponse) +def settings_regenerate_presence_token(request: Request): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + new_token = regenerate_presence_token(user_id) + logger.warning("token de présence régénéré pour %r", user_id) + + base_url = str(request.base_url).rstrip("/") + arrivee_url = f"{base_url}/presence/{new_token}/arrivee" + depart_url = f"{base_url}/presence/{new_token}/depart" + return HTMLResponse( + f'
    ' + f'
    ' + f'Arrivée' + f'{arrivee_url}' + f'
    ' + f'
    ' + f'Départ' + f'{depart_url}' + f'
    ' + f'
    Token régénéré. Mets à jour les URLs dans Tasker.
    ' + f'
    ' + ) + + @app.post("/settings/plages", response_class=HTMLResponse) def settings_plages_save( request: Request, diff --git a/app/models.py b/app/models.py index a6d6012..dd9286c 100644 --- a/app/models.py +++ b/app/models.py @@ -201,6 +201,15 @@ def find_user_by_token(token: str) -> str | None: 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" diff --git a/app/templates/settings.html b/app/templates/settings.html index 6ed47e0..d9839ed 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -134,17 +134,29 @@ 5 minutes, dans la plage horaire réglée ci-dessus (jours ouvrés). Étapes détaillées sur la page Aide.

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

    Ces URLs sont secrètes (elles contiennent ton token) — ne les partage pas. + En cas de compromission, régénère le token : les anciennes URLs cesseront de fonctionner + et tu devras mettre à jour Tasker avec les nouvelles.

    +