diff --git a/app/main.py b/app/main.py index c1beaa6..ff5eb72 100644 --- a/app/main.py +++ b/app/main.py @@ -24,7 +24,7 @@ from models import ( from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from stats import compute_all_stats from export import export_semaine_ods, export_annee_ods -from notifications import reminder_loop +from notifications import reminder_loop, send_ntfy logging.basicConfig( 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) +@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( + '
' + 'Renseigne et enregistre un topic ntfy avant de tester.' + '
' + ) + + 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'
{msg}
' + ) + + @app.post("/settings/plages", response_class=HTMLResponse) def settings_plages_save( request: Request, diff --git a/app/notifications.py b/app/notifications.py index e301d80..4957081 100644 --- a/app/notifications.py +++ b/app/notifications.py @@ -11,7 +11,7 @@ logger = logging.getLogger("pointeuse.notifications") 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( f"{server}/{topic}", 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) if user_id: append_log(user_id, "notif", "envoyée", f"{title} — {message}") + return True except Exception: logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title) if user_id: append_log(user_id, "notif", "échec", f"{title} — {message}") # best-effort: a missed reminder isn't worth crashing the loop + return False def _todays_pointage(user_id: str, today: datetime) -> dict: diff --git a/app/templates/settings.html b/app/templates/settings.html index a729cbb..6dd1e47 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -103,6 +103,16 @@ +
+ + +
@@ -202,5 +212,28 @@ border-color: var(--accent); 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; +} {% endblock %} diff --git a/app/tests/test_logs.py b/app/tests/test_logs.py index ec4a930..64a32a3 100644 --- a/app/tests/test_logs.py +++ b/app/tests/test_logs.py @@ -146,3 +146,42 @@ def test_invalid_log_type_rejected(): assert False, "doit lever une AssertionError" except AssertionError: 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)