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.
+
+
+ -
+ Installe l'app ntfy depuis le
+ Play Store
+ ou F-Droid.
+
+ -
+ Ouvre l'app, bouton + → entre le nom de topic choisi sur
+ /settings → valide. Laisse le serveur par défaut (
ntfy.sh).
+
+ -
+ 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.
+
+ -
+ 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.
+
+
+
+ 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".
+
+
+ -
+ Installe Tasker
+ (payant, Play Store).
+
+ -
+ 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.
+
+ -
+ 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.
+
+
+ -
+ Crée un profil symétrique Sortie bureau : même géofence, événement Exit,
+ tâche
PointeuseDepart qui appelle ton lien Départ.
+
+
+
+ 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
+
+ -
+ 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}.
+
+ -
+ Va sur /logs : tu dois voir une entrée "Présence / arrivée" ou
+ "Présence / départ" correspondante.
+
+ -
+ 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".
+
+
+
+
+
+
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