Ajoute une page d'aide en ligne pour configurer ntfy et Tasker

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
This commit is contained in:
Jacquin Antoine
2026-07-19 23:51:07 +02:00
parent 5fdef77e2b
commit f82f443ec7
5 changed files with 393 additions and 4 deletions

View File

@ -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,
})

211
app/templates/aide.html Normal file
View File

@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block content %}
<div class="stats-head">
<div>
<span class="stats-title">Aide</span>
<div class="stats-meta">Configurer les notifications ntfy et la détection de présence Tasker</div>
</div>
</div>
<div class="settings-card">
<span class="hist-label">Principe</span>
<p class="settings-p">
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 :
</p>
<ul class="aide-list">
<li>
<b>Réception des notifications</b> : <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>,
un service gratuit de notifications push par topic.
</li>
<li>
<b>Détection de présence</b> : 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 <a href="/settings">/settings</a>, en semaine, si un pointage manque).
</li>
</ul>
<p class="settings-p">
Avant de commencer : va sur <a href="/settings">/settings</a>, choisis un nom de topic ntfy
secret (ex: <code>prenom-pointeuse-a1b2</code>, pas devinable), enregistre-le. La page affiche
ensuite tes deux URLs personnelles <b>Arrivée</b> et <b>Départ</b> — elles contiennent un token
secret, ne les partage pas.
</p>
</div>
<div class="settings-card">
<span class="hist-label">1. Recevoir les notifications (ntfy)</span>
<p class="settings-p">
Installer l'app <b>ntfy</b> sur le téléphone, puis s'abonner au topic choisi dans /settings.
</p>
<ol class="aide-steps">
<li>
Installe l'app <b>ntfy</b> depuis le
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy" target="_blank" rel="noopener">Play Store</a>
ou <a href="https://f-droid.org/en/packages/io.heckel.ntfy/" target="_blank" rel="noopener">F-Droid</a>.
</li>
<li>
Ouvre l'app, bouton <b>+</b> → entre le nom de topic choisi sur
<a href="/settings">/settings</a> → valide. Laisse le serveur par défaut (<code>ntfy.sh</code>).
</li>
<li>
Teste l'envoi depuis un navigateur : ouvre
<code>https://ntfy.sh/&lt;ton-topic&gt;</code> et publie un message. La notif doit
arriver sur le téléphone en quelques secondes.
</li>
<li>
<b>Important</b> : 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.
</li>
</ol>
<p class="settings-hint">
Astuce : si tu veux ton propre serveur ntfy (auto-hébergé, pour éviter le topic public),
installe-le (<a href="https://docs.ntfy.sh/install/" target="_blank" rel="noopener">doc officielle</a>)
et renseigne son URL dans le champ <b>Serveur ntfy</b> sur /settings.
</p>
</div>
<div class="settings-card">
<span class="hist-label">2. Détecter la présence (Tasker)</span>
<p class="settings-p">
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".
</p>
<ol class="aide-steps">
<li>
Installe <a href="https://tasker.joaoapps.com/" target="_blank" rel="noopener">Tasker</a>
(payant, Play Store).
</li>
<li>
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.
</li>
<li>
Crée un profil <b>Entrée bureau</b> :
<ul class="aide-substeps">
<li>Onglet <b>Profiles</b> → bouton <b>+</b><b>Location</b>.</li>
<li>Renseigne l'adresse ou les coordonnées du bureau, rayon ~100150 m.</li>
<li>Tâche associée (nouvelle tâche, ex: <code>PointeuseArrivee</code>) :</li>
<li>Action <b>Net → HTTP Request</b>, méthode <code>GET</code>,
URL = ton lien <b>Arrivée</b> copié depuis <a href="/settings">/settings</a>.</li>
</ul>
</li>
<li>
Crée un profil symétrique <b>Sortie bureau</b> : même géofence, événement <b>Exit</b>,
tâche <code>PointeuseDepart</code> qui appelle ton lien <b>Départ</b>.
</li>
</ol>
<p class="settings-hint">
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.
</p>
</div>
<div class="settings-card">
<span class="hist-label">3. Vérifier</span>
<ol class="aide-steps">
<li>
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 <code>{"ok":true}</code>.
</li>
<li>
Va sur <a href="/logs">/logs</a> : tu dois voir une entrée "Présence / arrivée" ou
"Présence / départ" correspondante.
</li>
<li>
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
<a href="/logs">/logs</a> sous "Notification / envoyée".
</li>
</ol>
</div>
<div class="settings-card">
<span class="hist-label">Dépannage</span>
<ul class="aide-list">
<li>
<b>Pas de notif ntfy</b> : 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).
</li>
<li>
<b>Pas de déclenchement Tasker</b> : 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).
</li>
<li>
<b>Logs vides sur /logs</b> : 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).
</li>
</ul>
</div>
<style>
.aide-list {
margin: .4rem 0 .4rem 1.2rem;
padding: 0;
font-size: 13px;
line-height: 1.65;
color: var(--text);
display: flex;
flex-direction: column;
gap: .35rem;
list-style: disc;
}
.aide-list li::marker { color: var(--muted); }
.aide-list a, .aide-steps a { color: var(--accent); }
.aide-list code, .aide-steps code, .aide-substeps code {
font-family: var(--mono);
font-size: 12px;
background: var(--ground);
border: 1px solid var(--rule);
padding: .05rem .3rem;
}
.aide-steps {
counter-reset: step;
margin: .5rem 0 .4rem;
padding: 0;
font-size: 13px;
line-height: 1.65;
color: var(--text);
display: flex;
flex-direction: column;
gap: .55rem;
list-style: none;
}
.aide-steps > li {
position: relative;
padding-left: 2rem;
}
.aide-steps > li::before {
counter-increment: step;
content: counter(step);
position: absolute;
left: 0;
top: .05rem;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.aide-substeps {
margin: .35rem 0 .1rem 1.2rem;
padding: 0;
font-size: 12px;
color: var(--muted);
display: flex;
flex-direction: column;
gap: .2rem;
list-style: disc;
}
.aide-substeps li::marker { color: var(--rule); }
.settings-card .settings-p { margin: 0; }
.settings-card .settings-hint { margin-top: .5rem; }
</style>
{% endblock %}

View File

@ -1034,6 +1034,7 @@
<a href="/stats" class="nav-link">Statistiques</a> <a href="/stats" class="nav-link">Statistiques</a>
<a href="/logs" class="nav-link">Journaux</a> <a href="/logs" class="nav-link">Journaux</a>
<a href="/settings" class="nav-link">Réglages</a> <a href="/settings" class="nav-link">Réglages</a>
<a href="/aide" class="nav-link">Aide</a>
<span class="nav-user">{{ current_user }}</span> <span class="nav-user">{{ current_user }}</span>
<a href="/logout" class="nav-link nav-logout">Déconnexion</a> <a href="/logout" class="nav-link nav-logout">Déconnexion</a>
{% endif %} {% endif %}

View File

@ -51,11 +51,12 @@
</div> </div>
<div class="settings-card"> <div class="settings-card">
<span class="hist-label">Notifications (ntfy)</span> <span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span>
<p class="settings-p"> <p class="settings-p">
Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a> Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>
(public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec (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 <a href="/aide">Aide</a>.
</p> </p>
{% if saved %} {% if saved %}
@ -105,12 +106,13 @@
</div> </div>
<div class="settings-card"> <div class="settings-card">
<span class="hist-label">Détection de présence (Tasker)</span> <span class="hist-label">Détection de présence (Tasker) <a href="/aide" class="settings-help-link">Aide</a></span>
<p class="settings-p"> <p class="settings-p">
Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la
zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, l'URL "départ". Tant que tu es détecté présent sans zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, 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 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
<a href="/aide">Aide</a>.
</p> </p>
<div class="settings-urlrow"> <div class="settings-urlrow">
<span class="settings-urllabel">Arrivée</span> <span class="settings-urllabel">Arrivée</span>
@ -184,5 +186,21 @@
.time-range-input::-webkit-calendar-picker-indicator { .time-range-input::-webkit-calendar-picker-indicator {
margin-left: .5rem; 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);
}
</style> </style>
{% endblock %} {% endblock %}

148
app/tests/test_logs.py Normal file
View File

@ -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