groupe-meditation/backend/app/services/notifications.py

112 lines
3.5 KiB
Python

"""Envoi des notifications Web Push."""
import asyncio
import base64
import json
from tempfile import NamedTemporaryFile
from typing import Iterable
from pywebpush import WebPushException, webpush
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.membre import Membre
from app.models.push_subscription import PushSubscription
NOTIFICATION_TYPES = [
{"code": "gouvernance", "label": "Gouvernance"},
{"code": "tresorerie", "label": "Trésorerie"},
{"code": "membres", "label": "Membres"},
{"code": "postes", "label": "Postes et mandats"},
{"code": "rencontres", "label": "Rencontres et présences"},
{"code": "litterature", "label": "Littérature"},
{"code": "evenements", "label": "Événements"},
{"code": "systeme", "label": "Système"},
]
DEFAULT_NOTIFICATION_PREFERENCES = {item["code"]: True for item in NOTIFICATION_TYPES}
def normalized_preferences(raw: dict | None) -> dict:
prefs = DEFAULT_NOTIFICATION_PREFERENCES.copy()
if isinstance(raw, dict):
for code in prefs:
if code in raw:
prefs[code] = bool(raw[code])
return prefs
def notifications_configured() -> bool:
return bool(settings.VAPID_PUBLIC_KEY and settings.VAPID_PRIVATE_KEY_B64)
def _private_key_pem() -> str:
if not settings.VAPID_PRIVATE_KEY_B64:
return ""
return base64.b64decode(settings.VAPID_PRIVATE_KEY_B64).decode()
def _send_sync(abonnement: dict, payload: dict) -> None:
private_key = _private_key_pem()
with NamedTemporaryFile("w", suffix=".pem") as key_file:
key_file.write(private_key)
key_file.flush()
webpush(
subscription_info=abonnement,
data=json.dumps(payload, ensure_ascii=False),
vapid_private_key=key_file.name,
vapid_claims={"sub": settings.VAPID_SUBJECT},
)
async def send_to_subscriptions(db: AsyncSession, subscriptions: Iterable[PushSubscription], payload: dict) -> int:
if not notifications_configured():
return 0
sent = 0
for sub in subscriptions:
try:
await asyncio.to_thread(_send_sync, sub.abonnement, payload)
sent += 1
except WebPushException as exc:
if getattr(exc.response, "status_code", None) in {404, 410}:
await db.delete(sub)
except Exception:
continue
return sent
async def notify_group_change(
db: AsyncSession,
*,
groupe_id,
actor_id,
title: str,
body: str,
url: str = "/",
category: str = "systeme",
) -> int:
if not notifications_configured() or not groupe_id:
return 0
subscriptions_result = await db.execute(
select(PushSubscription).where(
PushSubscription.groupe_id == groupe_id,
PushSubscription.membre_id != actor_id,
)
)
subscriptions = subscriptions_result.scalars().all()
member_ids = {sub.membre_id for sub in subscriptions}
if not member_ids:
return 0
membres_result = await db.execute(select(Membre).where(Membre.id.in_(member_ids)))
membres = {m.id: m for m in membres_result.scalars().all()}
filtered = [
sub for sub in subscriptions
if normalized_preferences(getattr(membres.get(sub.membre_id), "notification_preferences", None)).get(category, True)
]
return await send_to_subscriptions(db, filtered, {
"title": title,
"body": body,
"url": url,
"category": category,
})