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

+ +

+ 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 + +
+ + +{% 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