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:
56
app/main.py
56
app/main.py
@ -1,22 +1,30 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, Request, Response
|
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.templating import Jinja2Templates
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from models import (
|
from models import (
|
||||||
load_config, load_week_pointages, save_week_pointages, load_conges,
|
load_config, load_week_pointages, save_week_pointages, load_conges,
|
||||||
toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user,
|
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 calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
|
||||||
from stats import compute_all_stats
|
from stats import compute_all_stats
|
||||||
from classement import compute_classement
|
from classement import compute_classement
|
||||||
|
from notifications import reminder_loop
|
||||||
|
|
||||||
app = FastAPI(title="Décompte Horaire")
|
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 = Jinja2Templates(directory="templates")
|
||||||
templates.env.filters["zip"] = zip
|
templates.env.filters["zip"] = zip
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
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)
|
@app.get("/classement", response_class=HTMLResponse)
|
||||||
def classement_page(request: Request):
|
def classement_page(request: Request):
|
||||||
user_id = _get_user(request)
|
user_id = _get_user(request)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""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 shutil
|
import shutil
|
||||||
from pathlib import Path
|
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 = ""):
|
def toggle_conge(date: str, type_conge: str, user_id: str = ""):
|
||||||
assert type_conge in ("jour", "matin", "aprem")
|
assert type_conge in ("jour", "matin", "aprem")
|
||||||
conges = load_conges(user_id)
|
conges = load_conges(user_id)
|
||||||
|
|||||||
56
app/notifications.py
Normal file
56
app/notifications.py
Normal 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)
|
||||||
@ -931,6 +931,7 @@
|
|||||||
<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="/classement" class="nav-link">Classement</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 %}
|
{% if current_user is defined and current_user %}
|
||||||
<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>
|
||||||
|
|||||||
98
app/templates/settings.html
Normal file
98
app/templates/settings.html
Normal 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 (7h–20h, 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 %}
|
||||||
Reference in New Issue
Block a user