Files
einkaufsapp/backend/app/push.py
2026-08-08 20:31:58 +02:00

154 lines
5.2 KiB
Python

"""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()