Ajoute les rappels de pointage via ntfy et détection de présence Tasker

Notifie l'utilisateur toutes les 5 min s'il est présent dans les locaux
sans avoir pointé le matin, ou parti sans avoir dépointé la sortie.
This commit is contained in:
Antoine
2026-07-17 20:45:27 +02:00
parent 47c05ee7f1
commit 1ab1cf27c1
5 changed files with 265 additions and 1 deletions

View File

@ -1,22 +1,30 @@
import asyncio
import json
import re
from datetime import datetime, timedelta
from itertools import groupby
from fastapi import FastAPI, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from models import (
load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user,
load_notif_config, save_notif_config, find_user_by_token, save_presence,
)
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats
from classement import compute_classement
from notifications import reminder_loop
app = FastAPI(title="Décompte Horaire")
@app.on_event("startup")
async def _start_reminder_loop():
asyncio.create_task(reminder_loop())
templates = Jinja2Templates(directory="templates")
templates.env.filters["zip"] = zip
app.mount("/static", StaticFiles(directory="static"), name="static")
@ -370,6 +378,52 @@ def stats_page(request: Request):
})
@app.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
notif = load_notif_config(user_id)
base_url = str(request.base_url).rstrip("/")
return templates.TemplateResponse("settings.html", {
"request": request,
"current_user": user_id,
"ntfy_topic": notif.get("ntfy_topic") or "",
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
"saved": request.query_params.get("saved") == "1",
})
@app.post("/settings", response_class=HTMLResponse)
def settings_save(request: Request, ntfy_topic: str = Form("")):
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
save_notif_config(user_id, ntfy_topic)
return RedirectResponse("/settings?saved=1", status_code=303)
@app.get("/presence/{token}/arrivee")
def presence_arrivee(token: str):
user_id = find_user_by_token(token)
if not user_id:
return JSONResponse({"error": "invalid token"}, status_code=404)
save_presence(user_id, present=True, since=datetime.now().isoformat())
return {"ok": True}
@app.get("/presence/{token}/depart")
def presence_depart(token: str):
user_id = find_user_by_token(token)
if not user_id:
return JSONResponse({"error": "invalid token"}, status_code=404)
save_presence(user_id, present=False, since=datetime.now().isoformat())
return {"ok": True}
@app.get("/classement", response_class=HTMLResponse)
def classement_page(request: Request):
user_id = _get_user(request)

View File

@ -1,5 +1,6 @@
"""Flat-file storage: JSON files per week for pointages, one file for congés."""
import json
import secrets
import shutil
from pathlib import Path
@ -125,6 +126,60 @@ def save_conges(conges: list[dict], user_id: str = ""):
)
# ── Notifications (ntfy topic + presence tracking) ────────────────────────────
def _notif_file(user_id: str) -> Path:
return _user_root(user_id) / "notif.json"
def load_notif_config(user_id: str) -> dict:
"""Return {"ntfy_topic": str|None, "token": str}, generating a token on first use."""
f = _notif_file(user_id)
if f.exists():
cfg = json.loads(f.read_text())
if cfg.get("token"):
return cfg
else:
cfg = {"ntfy_topic": None}
cfg["token"] = secrets.token_hex(16)
_ensure_user_dirs(user_id)
f.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
return cfg
def save_notif_config(user_id: str, ntfy_topic: str):
cfg = load_notif_config(user_id)
cfg["ntfy_topic"] = ntfy_topic.strip() or None
_ensure_user_dirs(user_id)
_notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
def find_user_by_token(token: str) -> str | None:
for user_id in list_users():
f = _notif_file(user_id)
if f.exists() and json.loads(f.read_text()).get("token") == token:
return user_id
return None
def _presence_file(user_id: str) -> Path:
return _user_root(user_id) / "presence.json"
def load_presence(user_id: str) -> dict:
f = _presence_file(user_id)
if f.exists():
return json.loads(f.read_text())
return {"present": False, "since": None}
def save_presence(user_id: str, present: bool, since: str | None):
_ensure_user_dirs(user_id)
_presence_file(user_id).write_text(
json.dumps({"present": present, "since": since}, indent=2, ensure_ascii=False)
)
def toggle_conge(date: str, type_conge: str, user_id: str = ""):
assert type_conge in ("jour", "matin", "aprem")
conges = load_conges(user_id)

56
app/notifications.py Normal file
View File

@ -0,0 +1,56 @@
"""Reminders: nag via ntfy when present-but-unpointed or departed-but-not-depointed."""
import asyncio
import urllib.request
from datetime import datetime
from models import list_users, load_notif_config, load_presence, load_week_pointages
NTFY_BASE = "https://ntfy.sh"
CHECK_INTERVAL_SECONDS = 300
RAPPEL_DEBUT_H = 7
RAPPEL_FIN_H = 20
def send_ntfy(topic: str, message: str, title: str):
req = urllib.request.Request(
f"{NTFY_BASE}/{topic}",
data=message.encode("utf-8"),
headers={"Title": title.encode("utf-8"), "Priority": "high"},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=10).close()
except Exception:
pass # best-effort: a missed reminder isn't worth crashing the loop
def _todays_pointage(user_id: str, today: datetime) -> dict:
iso = today.isocalendar()
pointages = load_week_pointages(iso.year, iso.week, user_id)
date_str = today.strftime("%Y-%m-%d")
return next((p for p in pointages if p["date"] == date_str), {})
async def check_user(user_id: str, now: datetime):
notif = load_notif_config(user_id)
topic = notif.get("ntfy_topic")
if not topic:
return
if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H):
return
presence = load_presence(user_id)
jour = _todays_pointage(user_id, now)
if presence.get("present") and not jour.get("matin_entree"):
await asyncio.to_thread(send_ntfy, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer")
elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"):
await asyncio.to_thread(send_ntfy, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer")
async def reminder_loop():
while True:
now = datetime.now()
for user_id in list_users():
await check_user(user_id, now)
await asyncio.sleep(CHECK_INTERVAL_SECONDS)

View File

@ -931,6 +931,7 @@
<a href="/" class="nav-link">Semaine courante</a>
<a href="/stats" class="nav-link">Statistiques</a>
<a href="/classement" class="nav-link">Classement</a>
<a href="/settings" class="nav-link">Réglages</a>
{% if current_user is defined and current_user %}
<span class="nav-user">{{ current_user }}</span>
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>

View File

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block content %}
<div class="stats-head">
<div>
<span class="stats-title">Réglages</span>
<div class="stats-meta">Rappels de pointage sur ton téléphone</div>
</div>
</div>
<div class="settings-card">
<span class="hist-label">Notifications (ntfy)</span>
<p class="settings-p">
Crée un topic sur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy.sh</a> (un identifiant
secret que toi seul connais), abonne-toi dessus avec l'app ntfy sur ton téléphone, puis renseigne-le ici.
</p>
{% if saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
<form method="post" action="/settings" class="settings-form">
<label class="login-label" for="ntfy_topic">Topic ntfy</label>
<input
id="ntfy_topic"
class="login-input"
type="text"
name="ntfy_topic"
value="{{ ntfy_topic }}"
placeholder="ex: antoine-pointeuse-x7f2"
autocomplete="off"
>
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
<div class="settings-card">
<span class="hist-label">Détection de présence (Tasker)</span>
<p class="settings-p">
Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la
zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, l'URL "départ". Tant que tu es détecté présent sans
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel toutes les
5 minutes (7h20h, jours ouvrés).
</p>
<div class="settings-urlrow">
<span class="settings-urllabel">Arrivée</span>
<code class="settings-url">{{ arrivee_url }}</code>
</div>
<div class="settings-urlrow">
<span class="settings-urllabel">Départ</span>
<code class="settings-url">{{ depart_url }}</code>
</div>
<p class="settings-hint">
Ces URLs sont secrètes (elles contiennent ton token) — ne les partage pas.
</p>
</div>
<style>
.settings-card {
background: var(--surface);
border: 1.5px solid var(--rule);
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: .75rem;
}
.settings-p { font-size: 13px; color: var(--text); line-height: 1.6; }
.settings-p a { color: var(--accent); }
.settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; }
.settings-saved {
background: var(--pos-bg);
border-left: 3px solid var(--pos);
color: var(--pos);
font-size: 12px;
padding: .5rem .8rem;
font-family: var(--mono);
}
.settings-urlrow { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.settings-urllabel {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
width: 70px;
flex-shrink: 0;
}
.settings-url {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
background: var(--ground);
border: 1px solid var(--rule);
padding: .4rem .6rem;
word-break: break-all;
}
.settings-hint { font-size: 11px; color: var(--muted); }
</style>
{% endblock %}