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:
18
app/main.py
18
app/main.py
@ -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"),
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
141
app/templates/logs.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user