Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

160
backend/app/routers/push.py Normal file
View 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."
)