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_auth, save_auth, find_user_by_reset_token,
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 stats import compute_all_stats
@ -637,6 +638,7 @@ def presence_arrivee(token: str):
return JSONResponse({"error": "invalid token"}, status_code=404)
logger.info("présence: arrivée détectée pour %r", user_id)
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}
@ -648,6 +650,22 @@ def presence_depart(token: str):
return JSONResponse({"error": "invalid token"}, status_code=404)
logger.info("présence: départ détecté pour %r", user_id)
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}
@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."""
import json
import secrets
from datetime import datetime
from pathlib import Path
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"]))
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
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")
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(
f"{server}/{topic}",
data=message.encode("utf-8"),
@ -21,8 +21,12 @@ def send_ntfy(server: str, topic: str, message: str, title: str):
try:
urllib.request.urlopen(req, timeout=10).close()
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:
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
@ -55,10 +59,18 @@ async def check_user(user_id: str, now: datetime):
if presence.get("present") and not jour.get("matin_entree"):
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"):
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():

View File

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