"""Push-Benachrichtigungen mit Zwei-Stunden-Drosselung. Regel: Wer eine Liste ändert, löst bei allen anderen Mitgliedern eine Benachrichtigung aus - aber höchstens eine je Liste und Person innerhalb des eingestellten Zeitraums. Der Grund: Wer im Laden steht und abhakt, erzeugt in wenigen Minuten Dutzende Änderungen. Ohne Drosselung bekämen die anderen ebenso viele Meldungen und würden die Funktion nach dem ersten Einkauf abschalten. Was in der Nachricht steht, ist bewusst knapp: Listenname und wer sie geändert hat. Keine Artikelnamen. Eine Benachrichtigung erscheint auf dem gesperrten Bildschirm, und was dort steht, sieht jeder, der das Gerät gerade in der Hand hält. """ import json import logging from datetime import timedelta from sqlalchemy import select from sqlalchemy.orm import Session from app.config import settings from app.db import SessionLocal from app.models import ListMember, NotifyState, PushSubscription, ShoppingList, User from app.security import utcnow log = logging.getLogger(__name__) # Fehlercodes, bei denen die Anmeldung endgültig ungültig ist. _GONE = (404, 410) def _send_one(subscription: PushSubscription, payload: dict) -> str: """Zustellversuch an einen Endpunkt. @returns "ok" | "gone" | "error" """ try: from pywebpush import WebPushException, webpush except ImportError: log.error("pywebpush ist nicht installiert - Push nicht möglich.") return "error" try: webpush( subscription_info={ "endpoint": subscription.endpoint, "keys": {"p256dh": subscription.p256dh, "auth": subscription.auth}, }, data=json.dumps(payload), vapid_private_key=settings.vapid_private_key, vapid_claims={"sub": settings.vapid_contact}, timeout=settings.push_timeout, ttl=3600, ) return "ok" except WebPushException as exc: status = getattr(exc.response, "status_code", None) if status in _GONE: # Der Browser hat die Anmeldung verworfen - typisch nach # Deinstallation oder Löschen der Websitedaten. return "gone" log.warning("Push an %s… fehlgeschlagen (%s): %s", subscription.endpoint[:40], status, exc) return "error" except Exception: log.exception("Push an %s… fehlgeschlagen", subscription.endpoint[:40]) return "error" def send_to_user(db: Session, user_id: str, payload: dict) -> int: """Zustellung an alle Geräte einer Person. Gibt die Zahl der erfolgreichen Zustellungen zurück.""" if not settings.push_enabled: return 0 subscriptions = db.scalars( select(PushSubscription).where(PushSubscription.user_id == user_id) ).all() delivered = 0 for subscription in subscriptions: result = _send_one(subscription, payload) if result == "ok": subscription.last_success_at = utcnow() subscription.failure_count = 0 delivered += 1 elif result == "gone": db.delete(subscription) else: subscription.failure_count += 1 # Nach genug Fehlschlägen aufräumen: Ein Endpunkt, der # dauerhaft nicht antwortet, wird nicht wieder gut. if subscription.failure_count >= 10: db.delete(subscription) db.commit() return delivered def notify_list_changed(list_id: str, actor_id: str) -> None: """Benachrichtigt die übrigen Mitglieder über eine Änderung. Läuft im Hintergrund (BackgroundTasks) und öffnet dafür eine eigene Sitzung: Die Sitzung des Requests ist zu dem Zeitpunkt bereits geschlossen. """ if not settings.push_enabled: return with SessionLocal() as db: lst = db.get(ShoppingList, list_id) if lst is None or lst.deleted_at is not None: return actor = db.get(User, actor_id) actor_name = ( (actor.display_name or actor.email.split("@")[0]) if actor else "Jemand" ) members = db.scalars( select(ListMember).where( ListMember.list_id == list_id, ListMember.user_id != actor_id ) ).all() threshold = utcnow() - timedelta(hours=settings.push_throttle_hours) for member in members: state = db.get(NotifyState, (list_id, member.user_id)) if state is not None and state.last_notified_at > threshold: continue # innerhalb der Sperrfrist payload = { "title": lst.name, "body": f"{actor_name} hat die Liste geändert.", "list_id": list_id, "tag": f"list-{list_id}", } delivered = send_to_user(db, member.user_id, payload) if delivered == 0: # Nichts zugestellt - dann auch nicht die Sperrfrist # starten, sonst verpasst die Person die nächste Änderung. continue if state is None: db.add(NotifyState(list_id=list_id, user_id=member.user_id, last_notified_at=utcnow())) else: state.last_notified_at = utcnow() db.commit()