Erste Produktivversion
This commit is contained in:
160
backend/app/routers/push.py
Normal file
160
backend/app/routers/push.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""An- und Abmeldung von Geräten für Push-Benachrichtigungen."""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.deps import DbSession, VerifiedUser
|
||||
from app.models import PushSubscription
|
||||
from app.push import send_to_user
|
||||
from app.schemas import MessageOut
|
||||
from app.schemas_push import (
|
||||
PushConfigOut,
|
||||
PushSubscribeIn,
|
||||
PushSubscriptionOut,
|
||||
PushUnsubscribeIn,
|
||||
)
|
||||
from app.security import utcnow
|
||||
|
||||
router = APIRouter(prefix="/api/push", tags=["push"])
|
||||
|
||||
MAX_DEVICES_PER_USER = 20
|
||||
|
||||
|
||||
@router.get("/config", response_model=PushConfigOut)
|
||||
def push_config():
|
||||
"""Ohne Anmeldung erreichbar: Der öffentliche Schlüssel ist dafür da,
|
||||
veröffentlicht zu werden."""
|
||||
return PushConfigOut(
|
||||
enabled=settings.push_enabled,
|
||||
public_key=settings.vapid_public_key or None,
|
||||
throttle_hours=settings.push_throttle_hours,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subscriptions", response_model=list[PushSubscriptionOut])
|
||||
def my_subscriptions(db: DbSession, user: VerifiedUser):
|
||||
rows = db.scalars(
|
||||
select(PushSubscription)
|
||||
.where(PushSubscription.user_id == user.id)
|
||||
.order_by(PushSubscription.created_at)
|
||||
).all()
|
||||
return [
|
||||
PushSubscriptionOut(
|
||||
id=s.id, label=s.label, created_at=s.created_at,
|
||||
last_success_at=s.last_success_at,
|
||||
)
|
||||
for s in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/subscribe", response_model=PushSubscriptionOut,
|
||||
status_code=status.HTTP_201_CREATED)
|
||||
def subscribe(payload: PushSubscribeIn, db: DbSession, user: VerifiedUser):
|
||||
if not settings.push_enabled:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
|
||||
)
|
||||
|
||||
existing = db.scalar(
|
||||
select(PushSubscription).where(PushSubscription.endpoint == payload.endpoint)
|
||||
)
|
||||
if existing is not None:
|
||||
# Derselbe Endpunkt kann nach einem Kontowechsel auf demselben
|
||||
# Geraet auftauchen - dann uebernehmen statt abweisen.
|
||||
existing.user_id = user.id
|
||||
existing.p256dh = payload.keys.p256dh
|
||||
existing.auth = payload.keys.auth
|
||||
existing.label = payload.label or existing.label
|
||||
existing.failure_count = 0
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return PushSubscriptionOut(
|
||||
id=existing.id, label=existing.label, created_at=existing.created_at,
|
||||
last_success_at=existing.last_success_at,
|
||||
)
|
||||
|
||||
count = len(db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.user_id == user.id)
|
||||
).all())
|
||||
if count >= MAX_DEVICES_PER_USER:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"Höchstens {MAX_DEVICES_PER_USER} Geräte je Konto.",
|
||||
)
|
||||
|
||||
subscription = PushSubscription(
|
||||
user_id=user.id,
|
||||
endpoint=payload.endpoint,
|
||||
p256dh=payload.keys.p256dh,
|
||||
auth=payload.keys.auth,
|
||||
label=(payload.label or None),
|
||||
)
|
||||
db.add(subscription)
|
||||
db.commit()
|
||||
db.refresh(subscription)
|
||||
return PushSubscriptionOut(
|
||||
id=subscription.id, label=subscription.label,
|
||||
created_at=subscription.created_at, last_success_at=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/unsubscribe", response_model=MessageOut)
|
||||
def unsubscribe(payload: PushUnsubscribeIn, db: DbSession, user: VerifiedUser):
|
||||
subscription = db.scalar(
|
||||
select(PushSubscription).where(
|
||||
PushSubscription.endpoint == payload.endpoint,
|
||||
PushSubscription.user_id == user.id,
|
||||
)
|
||||
)
|
||||
if subscription is not None:
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
# Auch wenn nichts gefunden wurde: Fuer den Aufrufer ist das Ziel
|
||||
# erreicht - dieses Geraet bekommt keine Benachrichtigungen mehr.
|
||||
return MessageOut(detail="Benachrichtigungen für dieses Gerät abgeschaltet.")
|
||||
|
||||
|
||||
@router.delete("/subscriptions/{subscription_id}", response_model=MessageOut)
|
||||
def remove_subscription(subscription_id: str, db: DbSession, user: VerifiedUser):
|
||||
subscription = db.get(PushSubscription, subscription_id)
|
||||
if subscription is None or subscription.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gerät nicht gefunden")
|
||||
db.delete(subscription)
|
||||
db.commit()
|
||||
return MessageOut(detail="Gerät entfernt.")
|
||||
|
||||
|
||||
@router.post("/test", response_model=MessageOut, status_code=status.HTTP_202_ACCEPTED)
|
||||
def send_test(background: BackgroundTasks, db: DbSession, user: VerifiedUser):
|
||||
"""Testnachricht an die eigenen Geräte - umgeht die Drosselung."""
|
||||
if not settings.push_enabled:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"Push-Benachrichtigungen sind auf diesem Server nicht eingerichtet.",
|
||||
)
|
||||
|
||||
count = len(db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.user_id == user.id)
|
||||
).all())
|
||||
if count == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Für dieses Konto ist kein Gerät angemeldet.",
|
||||
)
|
||||
|
||||
def run() -> None:
|
||||
from app.db import SessionLocal
|
||||
with SessionLocal() as session:
|
||||
send_to_user(session, user.id, {
|
||||
"title": settings.app_name,
|
||||
"body": "Testbenachrichtigung – die Zustellung funktioniert.",
|
||||
"tag": "test",
|
||||
})
|
||||
|
||||
background.add_task(run)
|
||||
return MessageOut(
|
||||
detail=f"Testnachricht an {count} Gerät(e) in Auftrag gegeben. "
|
||||
"Ergebnis steht im Log des api-Containers."
|
||||
)
|
||||
Reference in New Issue
Block a user