Ajoute une journalisation détaillée (login, mail, présence, rappels)
Niveau configurable via LOG_LEVEL (défaut DEBUG). Les erreurs de config JSON invalide remontent maintenant un message clair (fichier + position) au lieu d'un JSONDecodeError brut.
This commit is contained in:
28
app/mail.py
28
app/mail.py
@ -1,11 +1,15 @@
|
|||||||
"""Mail delivery via a configured SMTP relay (host/port/credentials in config.json)."""
|
"""Mail delivery via a configured SMTP relay (host/port/credentials in config.json)."""
|
||||||
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
logger = logging.getLogger("pointeuse.mail")
|
||||||
|
|
||||||
|
|
||||||
def send_mail(to_addr: str, subject: str, body: str, from_addr: str, smtp_cfg: dict):
|
def send_mail(to_addr: str, subject: str, body: str, from_addr: str, smtp_cfg: dict):
|
||||||
host = smtp_cfg.get("host")
|
host = smtp_cfg.get("host")
|
||||||
if not host:
|
if not host:
|
||||||
|
logger.error("envoi annulé: aucun serveur SMTP configuré (smtp.host manquant dans config.json)")
|
||||||
raise RuntimeError("Aucun serveur SMTP configuré (smtp.host manquant dans config.json).")
|
raise RuntimeError("Aucun serveur SMTP configuré (smtp.host manquant dans config.json).")
|
||||||
port = int(smtp_cfg.get("port", 587))
|
port = int(smtp_cfg.get("port", 587))
|
||||||
user = smtp_cfg.get("user")
|
user = smtp_cfg.get("user")
|
||||||
@ -17,9 +21,21 @@ def send_mail(to_addr: str, subject: str, body: str, from_addr: str, smtp_cfg: d
|
|||||||
msg["From"] = from_addr
|
msg["From"] = from_addr
|
||||||
msg["To"] = to_addr
|
msg["To"] = to_addr
|
||||||
|
|
||||||
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
logger.info(
|
||||||
if use_tls:
|
"envoi mail: to=%r from=%r subject=%r via smtp=%s:%s user=%r tls=%r",
|
||||||
smtp.starttls()
|
to_addr, from_addr, subject, host, port, user, use_tls,
|
||||||
if user:
|
)
|
||||||
smtp.login(user, password)
|
try:
|
||||||
smtp.send_message(msg)
|
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
||||||
|
smtp.set_debuglevel(1)
|
||||||
|
if use_tls:
|
||||||
|
logger.debug("STARTTLS vers %s:%s", host, port)
|
||||||
|
smtp.starttls()
|
||||||
|
if user:
|
||||||
|
logger.debug("login SMTP en tant que %r", user)
|
||||||
|
smtp.login(user, password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
logger.info("mail envoyé avec succès à %r", to_addr)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("échec de l'envoi du mail à %r via %s:%s", to_addr, host, port)
|
||||||
|
raise
|
||||||
|
|||||||
41
app/main.py
41
app/main.py
@ -1,5 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -23,9 +25,28 @@ from stats import compute_all_stats
|
|||||||
from classement import compute_classement
|
from classement import compute_classement
|
||||||
from notifications import reminder_loop
|
from notifications import reminder_loop
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, os.environ.get("LOG_LEVEL", "DEBUG").upper(), logging.DEBUG),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("pointeuse")
|
||||||
|
|
||||||
app = FastAPI(title="Décompte Horaire")
|
app = FastAPI(title="Décompte Horaire")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _log_config_at_startup():
|
||||||
|
cfg = load_config()
|
||||||
|
smtp = cfg.get("smtp", {})
|
||||||
|
logger.info(
|
||||||
|
"config chargée: heures_jour=%r allowed_email_domain=%r mail_from=%r "
|
||||||
|
"smtp.host=%r smtp.port=%r smtp.user=%r smtp.password=%s smtp.use_tls=%r",
|
||||||
|
cfg.get("heures_jour"), cfg.get("allowed_email_domain"), cfg.get("mail_from"),
|
||||||
|
smtp.get("host"), smtp.get("port"), smtp.get("user"),
|
||||||
|
"***" if smtp.get("password") else "(vide)", smtp.get("use_tls"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def _start_reminder_loop():
|
async def _start_reminder_loop():
|
||||||
asyncio.create_task(reminder_loop())
|
asyncio.create_task(reminder_loop())
|
||||||
@ -251,7 +272,9 @@ def login_submit(request: Request, email: str = Form("")):
|
|||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
m = _EMAIL_RE.match(email)
|
m = _EMAIL_RE.match(email)
|
||||||
domain = allowed_email_domain()
|
domain = allowed_email_domain()
|
||||||
|
logger.info("POST /login: email=%r domaine_attendu=%r", email, domain)
|
||||||
if not m or not domain or m.group(2) != domain:
|
if not m or not domain or m.group(2) != domain:
|
||||||
|
logger.warning("login refusé (domaine invalide): email=%r", email)
|
||||||
return templates.TemplateResponse("login.html", _login_ctx(
|
return templates.TemplateResponse("login.html", _login_ctx(
|
||||||
request, "email", email,
|
request, "email", email,
|
||||||
f"Seules les adresses @{domain or '?'} sont autorisées.",
|
f"Seules les adresses @{domain or '?'} sont autorisées.",
|
||||||
@ -259,6 +282,7 @@ def login_submit(request: Request, email: str = Form("")):
|
|||||||
|
|
||||||
user_id = m.group(1)
|
user_id = m.group(1)
|
||||||
auth = load_auth(user_id)
|
auth = load_auth(user_id)
|
||||||
|
logger.debug("login: user_id=%r compte_existant=%s", user_id, bool(auth.get("password_hash")))
|
||||||
|
|
||||||
if auth.get("password_hash"):
|
if auth.get("password_hash"):
|
||||||
return templates.TemplateResponse("login.html", _login_ctx(request, "password", email))
|
return templates.TemplateResponse("login.html", _login_ctx(request, "password", email))
|
||||||
@ -267,6 +291,7 @@ def login_submit(request: Request, email: str = Form("")):
|
|||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
|
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
|
||||||
save_auth(user_id, email=email, token=token, token_expires=expires)
|
save_auth(user_id, email=email, token=token, token_expires=expires)
|
||||||
|
logger.info("nouveau token de création de compte pour %r (expire %s)", user_id, expires)
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
link = f"{base_url}/set-password/{token}"
|
link = f"{base_url}/set-password/{token}"
|
||||||
@ -279,6 +304,7 @@ def login_submit(request: Request, email: str = Form("")):
|
|||||||
smtp_config(),
|
smtp_config(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception("échec envoi email d'invitation à %r", email)
|
||||||
return templates.TemplateResponse("login.html", _login_ctx(
|
return templates.TemplateResponse("login.html", _login_ctx(
|
||||||
request, "email", email, "Impossible d'envoyer l'email pour le moment, réessaie plus tard.",
|
request, "email", email, "Impossible d'envoyer l'email pour le moment, réessaie plus tard.",
|
||||||
))
|
))
|
||||||
@ -291,16 +317,20 @@ def login_password(request: Request, email: str = Form(""), password: str = Form
|
|||||||
email = email.strip().lower()
|
email = email.strip().lower()
|
||||||
m = _EMAIL_RE.match(email)
|
m = _EMAIL_RE.match(email)
|
||||||
domain = allowed_email_domain()
|
domain = allowed_email_domain()
|
||||||
|
logger.info("POST /login/password: email=%r", email)
|
||||||
if not m or not domain or m.group(2) != domain:
|
if not m or not domain or m.group(2) != domain:
|
||||||
|
logger.warning("login/password refusé (domaine invalide): email=%r", email)
|
||||||
return RedirectResponse("/login", status_code=302)
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
user_id = m.group(1)
|
user_id = m.group(1)
|
||||||
ph = load_auth(user_id).get("password_hash")
|
ph = load_auth(user_id).get("password_hash")
|
||||||
if not ph or not bcrypt.checkpw(password.encode(), ph.encode()):
|
if not ph or not bcrypt.checkpw(password.encode(), ph.encode()):
|
||||||
|
logger.warning("mot de passe incorrect pour %r", user_id)
|
||||||
return templates.TemplateResponse("login.html", _login_ctx(
|
return templates.TemplateResponse("login.html", _login_ctx(
|
||||||
request, "password", email, "Mot de passe incorrect.",
|
request, "password", email, "Mot de passe incorrect.",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
logger.info("connexion réussie: user_id=%r", user_id)
|
||||||
resp = RedirectResponse("/", status_code=302)
|
resp = RedirectResponse("/", status_code=302)
|
||||||
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
|
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
|
||||||
return resp
|
return resp
|
||||||
@ -328,10 +358,12 @@ def set_password_page(request: Request, token: str):
|
|||||||
def set_password_submit(request: Request, token: str, password: str = Form(""), password2: str = Form("")):
|
def set_password_submit(request: Request, token: str, password: str = Form(""), password2: str = Form("")):
|
||||||
user_id = _valid_reset_token(token)
|
user_id = _valid_reset_token(token)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
logger.warning("POST /set-password: token invalide ou expiré")
|
||||||
return templates.TemplateResponse("set_password.html", {
|
return templates.TemplateResponse("set_password.html", {
|
||||||
"request": request, "token": token, "valid": False, "error": None,
|
"request": request, "token": token, "valid": False, "error": None,
|
||||||
})
|
})
|
||||||
if len(password) < 8 or password != password2:
|
if len(password) < 8 or password != password2:
|
||||||
|
logger.info("POST /set-password: mot de passe rejeté (règles) pour %r", user_id)
|
||||||
return templates.TemplateResponse("set_password.html", {
|
return templates.TemplateResponse("set_password.html", {
|
||||||
"request": request, "token": token, "valid": True,
|
"request": request, "token": token, "valid": True,
|
||||||
"error": "Les mots de passe ne correspondent pas ou font moins de 8 caractères.",
|
"error": "Les mots de passe ne correspondent pas ou font moins de 8 caractères.",
|
||||||
@ -339,6 +371,7 @@ def set_password_submit(request: Request, token: str, password: str = Form(""),
|
|||||||
|
|
||||||
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
save_auth(user_id, password_hash=password_hash, token=None, token_expires=None)
|
save_auth(user_id, password_hash=password_hash, token=None, token_expires=None)
|
||||||
|
logger.info("mot de passe défini pour %r", user_id)
|
||||||
|
|
||||||
resp = RedirectResponse("/", status_code=302)
|
resp = RedirectResponse("/", status_code=302)
|
||||||
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
|
resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30)
|
||||||
@ -346,7 +379,8 @@ def set_password_submit(request: Request, token: str, password: str = Form(""),
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/logout")
|
@app.get("/logout")
|
||||||
def logout():
|
def logout(request: Request):
|
||||||
|
logger.info("déconnexion: user_id=%r", _get_user(request))
|
||||||
resp = RedirectResponse("/login", status_code=302)
|
resp = RedirectResponse("/login", status_code=302)
|
||||||
resp.delete_cookie("user_id")
|
resp.delete_cookie("user_id")
|
||||||
return resp
|
return resp
|
||||||
@ -506,6 +540,7 @@ def settings_save(request: Request, ntfy_topic: str = Form("")):
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
return RedirectResponse("/login", status_code=302)
|
return RedirectResponse("/login", status_code=302)
|
||||||
|
|
||||||
|
logger.info("réglages notif mis à jour: user_id=%r ntfy_topic=%r", user_id, ntfy_topic)
|
||||||
save_notif_config(user_id, ntfy_topic)
|
save_notif_config(user_id, ntfy_topic)
|
||||||
return RedirectResponse("/settings?saved=1", status_code=303)
|
return RedirectResponse("/settings?saved=1", status_code=303)
|
||||||
|
|
||||||
@ -514,7 +549,9 @@ def settings_save(request: Request, ntfy_topic: str = Form("")):
|
|||||||
def presence_arrivee(token: str):
|
def presence_arrivee(token: str):
|
||||||
user_id = find_user_by_token(token)
|
user_id = find_user_by_token(token)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
logger.warning("GET /presence/.../arrivee: token inconnu")
|
||||||
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)
|
||||||
save_presence(user_id, present=True, since=datetime.now().isoformat())
|
save_presence(user_id, present=True, since=datetime.now().isoformat())
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@ -523,7 +560,9 @@ def presence_arrivee(token: str):
|
|||||||
def presence_depart(token: str):
|
def presence_depart(token: str):
|
||||||
user_id = find_user_by_token(token)
|
user_id = find_user_by_token(token)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
logger.warning("GET /presence/.../depart: token inconnu")
|
||||||
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)
|
||||||
save_presence(user_id, present=False, since=datetime.now().isoformat())
|
save_presence(user_id, present=False, since=datetime.now().isoformat())
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,10 @@ def list_users() -> list[str]:
|
|||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
return json.loads(CONFIG_FILE.read_text())
|
try:
|
||||||
|
return json.loads(CONFIG_FILE.read_text())
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"{CONFIG_FILE} invalide : {e}") from e
|
||||||
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
|
CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2))
|
||||||
return DEFAULT_CONFIG.copy()
|
return DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
"""Reminders: nag via ntfy when present-but-unpointed or departed-but-not-depointed."""
|
"""Reminders: nag via ntfy when present-but-unpointed or departed-but-not-depointed."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger("pointeuse.notifications")
|
||||||
|
|
||||||
NTFY_BASE = "https://ntfy.sh"
|
NTFY_BASE = "https://ntfy.sh"
|
||||||
CHECK_INTERVAL_SECONDS = 300
|
CHECK_INTERVAL_SECONDS = 300
|
||||||
RAPPEL_DEBUT_H = 7
|
RAPPEL_DEBUT_H = 7
|
||||||
@ -20,8 +23,10 @@ def send_ntfy(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: topic=%r title=%r", topic, title)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # best-effort: a missed reminder isn't worth crashing the loop
|
logger.exception("échec de l'envoi ntfy: topic=%r title=%r", topic, title)
|
||||||
|
# best-effort: a missed reminder isn't worth crashing the loop
|
||||||
|
|
||||||
|
|
||||||
def _todays_pointage(user_id: str, today: datetime) -> dict:
|
def _todays_pointage(user_id: str, today: datetime) -> dict:
|
||||||
@ -35,22 +40,33 @@ async def check_user(user_id: str, now: datetime):
|
|||||||
notif = load_notif_config(user_id)
|
notif = load_notif_config(user_id)
|
||||||
topic = notif.get("ntfy_topic")
|
topic = notif.get("ntfy_topic")
|
||||||
if not topic:
|
if not topic:
|
||||||
|
logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id)
|
||||||
return
|
return
|
||||||
if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H):
|
if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H):
|
||||||
|
logger.debug("check_user(%r): hors plage de rappel (%s)", user_id, now)
|
||||||
return
|
return
|
||||||
|
|
||||||
presence = load_presence(user_id)
|
presence = load_presence(user_id)
|
||||||
jour = _todays_pointage(user_id, now)
|
jour = _todays_pointage(user_id, now)
|
||||||
|
logger.debug(
|
||||||
|
"check_user(%r): presence=%s matin_entree=%r aprem_sortie=%r",
|
||||||
|
user_id, presence.get("present"), jour.get("matin_entree"), jour.get("aprem_sortie"),
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
||||||
await asyncio.to_thread(send_ntfy, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer")
|
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"):
|
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, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer")
|
await asyncio.to_thread(send_ntfy, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer")
|
||||||
|
|
||||||
|
|
||||||
async def reminder_loop():
|
async def reminder_loop():
|
||||||
|
logger.info("boucle de rappels démarrée (intervalle=%ss)", CHECK_INTERVAL_SECONDS)
|
||||||
while True:
|
while True:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
for user_id in list_users():
|
users = list_users()
|
||||||
|
logger.debug("cycle de vérification pour %d utilisateur(s)", len(users))
|
||||||
|
for user_id in users:
|
||||||
await check_user(user_id, now)
|
await check_user(user_id, now)
|
||||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
|
|||||||
Reference in New Issue
Block a user