"""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, })