Ajoute un bouton de test de la notification ntfy depuis les réglages

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
This commit is contained in:
Jacquin Antoine
2026-07-19 23:57:48 +02:00
parent f82f443ec7
commit 2ee317a46e
4 changed files with 109 additions and 2 deletions

View File

@ -24,7 +24,7 @@ from models import (
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats from stats import compute_all_stats
from export import export_semaine_ods, export_annee_ods from export import export_semaine_ods, export_annee_ods
from notifications import reminder_loop from notifications import reminder_loop, send_ntfy
logging.basicConfig( logging.basicConfig(
level=getattr(logging, load_config().get("log_level", "DEBUG").upper(), logging.DEBUG), 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) 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(
'<div id="ntfy-test-result" class="settings-error">'
'Renseigne et enregistre un topic ntfy avant de tester.'
'</div>'
)
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'<div id="ntfy-test-result" class="{cls}">{msg}</div>'
)
@app.post("/settings/plages", response_class=HTMLResponse) @app.post("/settings/plages", response_class=HTMLResponse)
def settings_plages_save( def settings_plages_save(
request: Request, request: Request,

View File

@ -11,7 +11,7 @@ logger = logging.getLogger("pointeuse.notifications")
CHECK_INTERVAL_SECONDS = 300 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( req = urllib.request.Request(
f"{server}/{topic}", f"{server}/{topic}",
data=message.encode("utf-8"), 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) logger.info("notif ntfy envoyée: server=%r topic=%r title=%r", server, topic, title)
if user_id: if user_id:
append_log(user_id, "notif", "envoyée", f"{title}{message}") append_log(user_id, "notif", "envoyée", f"{title}{message}")
return True
except Exception: except Exception:
logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title) logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title)
if user_id: if user_id:
append_log(user_id, "notif", "échec", f"{title}{message}") append_log(user_id, "notif", "échec", f"{title}{message}")
# best-effort: a missed reminder isn't worth crashing the loop # best-effort: a missed reminder isn't worth crashing the loop
return False
def _todays_pointage(user_id: str, today: datetime) -> dict: def _todays_pointage(user_id: str, today: datetime) -> dict:

View File

@ -103,6 +103,16 @@
</div> </div>
<button class="login-btn" type="submit">Enregistrer</button> <button class="login-btn" type="submit">Enregistrer</button>
</form> </form>
<div class="settings-test-row">
<button
class="login-btn settings-test-btn"
hx-post="/settings/test-ntfy"
hx-target="#ntfy-test-result"
hx-swap="innerHTML"
hx-disabled-elt="this"
>Tester la notification</button>
<span id="ntfy-test-result"></span>
</div>
</div> </div>
<div class="settings-card"> <div class="settings-card">
@ -202,5 +212,28 @@
border-color: var(--accent); border-color: var(--accent);
background: rgba(47,95,160,.06); 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;
}
</style> </style>
{% endblock %} {% endblock %}

View File

@ -146,3 +146,42 @@ def test_invalid_log_type_rejected():
assert False, "doit lever une AssertionError" assert False, "doit lever une AssertionError"
except AssertionError: except AssertionError:
pass 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)