Files
2026-08-08 20:31:58 +02:00

161 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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."
)