Erste Produktivversion
This commit is contained in:
153
backend/app/push.py
Normal file
153
backend/app/push.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user