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="/logs" class="nav-link">Journaux</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>
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
{% endif %}

View File

@ -51,11 +51,12 @@
</div>
<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">
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
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>
{% if saved %}
@ -105,12 +106,13 @@
</div>
<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">
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
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>
<div class="settings-urlrow">
<span class="settings-urllabel">Arrivée</span>
@ -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);
}
</style>
{% 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