154 lines
5.2 KiB
Python
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()
|