Ajoute un journal par utilisateur des notifications ntfy et de la présence Tasker

Chaque utilisateur a désormais son propre fichier logs.json dans son
dossier users/<id>/, alimenté automatiquement quand un rappel ntfy est
envoyé (ou échoue) et quand Tasker signale une arrivée ou un départ.
Une nouvelle page /logs, accessible depuis la nav, affiche ces entrées
avec filtres (Tout / Notifications / Présence), et le journal tourne à
500 entrées pour rester compact.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-07-19 23:46:08 +02:00
parent ef30820c39
commit 5fdef77e2b
5 changed files with 221 additions and 4 deletions

View File

@ -19,6 +19,7 @@ from models import (
load_notif_config, save_notif_config, find_user_by_token, save_presence, load_notif_config, save_notif_config, find_user_by_token, save_presence,
load_auth, save_auth, find_user_by_reset_token, load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages, heures_jour_min, load_plages, save_plages, heures_jour_min,
append_log, load_logs,
) )
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
@ -637,6 +638,7 @@ def presence_arrivee(token: str):
return JSONResponse({"error": "invalid token"}, status_code=404) return JSONResponse({"error": "invalid token"}, status_code=404)
logger.info("présence: arrivée détectée pour %r", user_id) logger.info("présence: arrivée détectée pour %r", user_id)
save_presence(user_id, present=True, since=datetime.now().isoformat()) save_presence(user_id, present=True, since=datetime.now().isoformat())
append_log(user_id, "presence", "arrivée", "Présence détectée par Tasker")
return {"ok": True} return {"ok": True}
@ -648,6 +650,22 @@ def presence_depart(token: str):
return JSONResponse({"error": "invalid token"}, status_code=404) return JSONResponse({"error": "invalid token"}, status_code=404)
logger.info("présence: départ détecté pour %r", user_id) logger.info("présence: départ détecté pour %r", user_id)
save_presence(user_id, present=False, since=datetime.now().isoformat()) save_presence(user_id, present=False, since=datetime.now().isoformat())
append_log(user_id, "presence", "départ", "Départ détecté par Tasker")
return {"ok": True} return {"ok": True}
@app.get("/logs", response_class=HTMLResponse)
def logs_page(request: Request):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
logs = load_logs(user_id)
return templates.TemplateResponse("logs.html", {
"request": request,
"current_user": user_id,
"logs": logs,
"filter": request.query_params.get("filter", "all"),
})

View File

@ -1,6 +1,7 @@
"""Flat-file storage: JSON files per week for pointages, one file for congés.""" """Flat-file storage: JSON files per week for pointages, one file for congés."""
import json import json
import secrets import secrets
from datetime import datetime
from pathlib import Path from pathlib import Path
from calcul import DEFAULT_PLAGES from calcul import DEFAULT_PLAGES
@ -254,3 +255,47 @@ def toggle_conge(date: str, type_conge: str, user_id: str = ""):
conges.sort(key=lambda c: (c["date"], c["type"])) conges.sort(key=lambda c: (c["date"], c["type"]))
save_conges(conges, user_id) save_conges(conges, user_id)
# ── Journaux (notifications + présence) ───────────────────────────────────────
LOG_TYPES = ("notif", "presence")
MAX_LOGS = 500
def _logs_file(user_id: str) -> Path:
return _user_root(user_id) / "logs.json"
def append_log(user_id: str, entry_type: str, event: str, message: str = "", ts: str | None = None):
"""Ajoute une entrée au journal de l'utilisateur (rotation sur MAX_LOGS)."""
assert entry_type in LOG_TYPES, f"type de journal invalide: {entry_type!r}"
f = _logs_file(user_id)
logs = []
if f.exists():
try:
logs = json.loads(f.read_text())
except json.JSONDecodeError:
logs = []
logs.append({
"ts": ts or datetime.now().isoformat(timespec="seconds"),
"type": entry_type,
"event": event,
"message": message,
})
if len(logs) > MAX_LOGS:
logs = logs[-MAX_LOGS:]
_ensure_user_dirs(user_id)
f.write_text(json.dumps(logs, indent=2, ensure_ascii=False))
def load_logs(user_id: str) -> list[dict]:
"""Retourne les entrées du journal, des plus récentes aux plus anciennes."""
f = _logs_file(user_id)
if not f.exists():
return []
try:
logs = json.loads(f.read_text())
except json.JSONDecodeError:
return []
return list(reversed(logs))

View File

@ -4,14 +4,14 @@ import logging
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from models import list_users, load_notif_config, load_presence, load_week_pointages from models import list_users, load_notif_config, load_presence, load_week_pointages, append_log
logger = logging.getLogger("pointeuse.notifications") logger = logging.getLogger("pointeuse.notifications")
CHECK_INTERVAL_SECONDS = 300 CHECK_INTERVAL_SECONDS = 300
def send_ntfy(server: str, topic: str, message: str, title: str): def send_ntfy(server: str, topic: str, message: str, title: str, user_id: str | None = None):
req = urllib.request.Request( req = urllib.request.Request(
f"{server}/{topic}", f"{server}/{topic}",
data=message.encode("utf-8"), data=message.encode("utf-8"),
@ -21,8 +21,12 @@ def send_ntfy(server: str, topic: str, message: str, title: str):
try: try:
urllib.request.urlopen(req, timeout=10).close() urllib.request.urlopen(req, timeout=10).close()
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:
append_log(user_id, "notif", "envoyée", f"{title}{message}")
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:
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
@ -55,10 +59,18 @@ async def check_user(user_id: str, now: datetime):
if presence.get("present") and not jour.get("matin_entree"): if presence.get("present") and not jour.get("matin_entree"):
logger.info("rappel 'pense à pointer' pour %r", user_id) logger.info("rappel 'pense à pointer' pour %r", user_id)
await asyncio.to_thread(send_ntfy, server, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer") await asyncio.to_thread(
send_ntfy, server, topic,
"Tu es dans les locaux mais pas encore pointé ce matin.",
"⏰ Pense à pointer", user_id,
)
elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"): elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"):
logger.info("rappel 'pense à dépointer' pour %r", user_id) logger.info("rappel 'pense à dépointer' pour %r", user_id)
await asyncio.to_thread(send_ntfy, server, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer") await asyncio.to_thread(
send_ntfy, server, topic,
"Tu as quitté les locaux sans dépointer la sortie.",
"⏰ Pense à dépointer", user_id,
)
async def reminder_loop(): async def reminder_loop():

View File

@ -1032,6 +1032,7 @@
{% if current_user is defined and current_user %} {% if current_user is defined and current_user %}
<a href="/" class="nav-link">Semaine courante</a> <a href="/" class="nav-link">Semaine courante</a>
<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="/settings" class="nav-link">Réglages</a> <a href="/settings" class="nav-link">Réglages</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>

141
app/templates/logs.html Normal file
View File

@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block content %}
<div class="stats-head">
<div>
<span class="stats-title">Journaux</span>
<div class="stats-meta">
Notifications ntfy et détections de présence (Tasker) — {{ current_user }}
</div>
</div>
</div>
<div class="logs-tabs" role="tablist">
{% set counts = {'all': logs|length, 'notif': logs | selectattr('type', 'equalto', 'notif') | list | length, 'presence': logs | selectattr('type', 'equalto', 'presence') | list | length} %}
<a class="ltab{% if filter == 'all' %} active{% endif %}" href="/logs" role="tab">
Tout <span class="ltab-count">{{ counts.all }}</span>
</a>
<a class="ltab{% if filter == 'notif' %} active{% endif %}" href="/logs?filter=notif" role="tab">
Notifications <span class="ltab-count">{{ counts.notif }}</span>
</a>
<a class="ltab{% if filter == 'presence' %} active{% endif %}" href="/logs?filter=presence" role="tab">
Présence <span class="ltab-count">{{ counts.presence }}</span>
</a>
</div>
{% set visible = logs if filter == 'all' else logs | selectattr('type', 'equalto', filter) | list %}
{% if visible %}
<div class="logs-list">
{% for entry in visible %}
<div class="log-row log-{{ entry.type }}{% if entry.event == 'échec' %} log-failed{% endif %}">
<div class="log-ts">{{ entry.ts }}</div>
<div class="log-type">{{ 'Notification' if entry.type == 'notif' else 'Présence' }}</div>
<div class="log-body">
<span class="log-event">{{ entry.event }}</span>
{% if entry.message %}<span class="log-msg">{{ entry.message }}</span>{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="logs-empty">Aucun événement enregistré pour le moment.</div>
{% endif %}
<style>
.logs-tabs {
display: flex;
gap: .4rem;
border-bottom: 1.5px solid var(--rule);
padding-bottom: .6rem;
flex-wrap: wrap;
}
.ltab {
font-family: var(--mono);
font-size: 11px;
letter-spacing: .04em;
color: var(--muted);
text-decoration: none;
border: 1.5px solid var(--rule);
padding: .35rem .7rem;
display: inline-flex;
align-items: center;
gap: .45rem;
transition: border-color .15s, color .15s, background .15s;
}
.ltab:hover { border-color: var(--text); color: var(--text); }
.ltab.active { border-color: var(--text); color: var(--text); background: var(--surface); }
.ltab-count {
font-size: 10px;
background: var(--ground);
border: 1px solid var(--rule);
padding: .05rem .35rem;
color: var(--muted);
}
.logs-list {
border: 1.5px solid var(--rule);
background: var(--surface);
display: flex;
flex-direction: column;
}
.log-row {
display: grid;
grid-template-columns: 180px 130px 1fr;
gap: 1rem;
padding: .55rem 1rem;
border-bottom: 1px solid var(--rule-l);
font-size: 13px;
align-items: baseline;
}
.log-row:last-child { border-bottom: none; }
.log-row:hover { background: var(--ground); }
.log-ts {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
.log-type {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
white-space: nowrap;
}
.log-notif .log-type { color: var(--accent); }
.log-presence .log-type { color: var(--accent-2); }
.log-body { display: flex; flex-direction: column; gap: .15rem; }
.log-event {
font-weight: 500;
color: var(--text);
}
.log-msg {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.log-failed {
background: var(--neg-bg);
}
.log-failed .log-event { color: var(--neg); }
.log-failed:hover { background: var(--neg-bg); }
.logs-empty {
border: 1.5px solid var(--rule);
background: var(--surface);
padding: 2rem;
text-align: center;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 640px) {
.log-row {
grid-template-columns: 1fr;
gap: .2rem;
}
.log-ts { font-size: 10px; }
}
</style>
{% endblock %}