- models/push_subscription.py: push_subscriptions table (id uuid pk, user_email indexed, endpoint Text, p256dh/auth Text, user_agent nullable, created_at). Registered in main.py AND sync_worker.py so both containers create_all the table. - routers/push.py (wired at /api/push): GET /vapid-public, GET /status, POST /subscribe (upsert by user_email+endpoint), DELETE /subscribe (query-param or body endpoint; TestClient-safe). - services/push_service.py: send_push_to_user() — VAPID-signed pywebpush delivery, deletes 404/410-gone subs, never raises; logs once and skips when VAPID keys are missing. - capture_job_processor: fires a 'Capture complete' push next to the bell Notification at terminal status (target /library). - config: vapid_private_key/public_key/subject (subject defaults to mailto: from Mail_From). requirements.txt + .env.dev/uat.example. - tests/test_push.py: subscribe upsert-not-duplicate, status, unsubscribe idempotent, vapid-public, silent no-op without keys, mocked delivery asserting title/body/target_path + gone-sub cleanup.
121 lines
4.2 KiB
Python
121 lines
4.2 KiB
Python
"""Web-Push subscription API (issue #524, PR-3).
|
|
|
|
The browser registers its push endpoint here once the user opts in. The backend
|
|
stores it keyed by (user_email, endpoint) so the capture worker can push at job
|
|
completion. Also exposes the VAPID public key the browser needs to subscribe,
|
|
and a status flag for the UI toggle.
|
|
|
|
Routes follow the app convention (static before parameterized).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from config import settings
|
|
from models.push_subscription import PushSubscription
|
|
from models.user import User
|
|
from routers.auth import current_user, get_db
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class PushKeys(BaseModel):
|
|
p256dh: str = Field(..., min_length=1)
|
|
auth: str = Field(..., min_length=1)
|
|
|
|
|
|
class PushSubscribeBody(BaseModel):
|
|
endpoint: str = Field(..., min_length=1)
|
|
keys: PushKeys
|
|
user_agent: str | None = None
|
|
|
|
|
|
def _domain() -> str:
|
|
"""mail-from domain for the VAPID subject default (mirror push_service)."""
|
|
if settings.vapid_subject:
|
|
return settings.vapid_subject
|
|
mail_from = (settings.mail_from or "").strip()
|
|
if mail_from:
|
|
return f"mailto:{mail_from}"
|
|
return "mailto:sapl@saplind.com"
|
|
|
|
|
|
def _vapid_public() -> str | None:
|
|
"""The configured VAPID public key, or None when push is disabled."""
|
|
return settings.vapid_public_key or None
|
|
|
|
|
|
@router.get("/vapid-public")
|
|
def get_vapid_public(user: User = Depends(current_user)):
|
|
"""Return the VAPID public key the browser must pass to pushManager.subscribe."""
|
|
return {"public_key": _vapid_public()}
|
|
|
|
|
|
@router.get("/status")
|
|
def push_status(user: User = Depends(current_user), db: Session = Depends(get_db)):
|
|
"""True when the current user has at least one active subscription."""
|
|
has = (db.query(PushSubscription)
|
|
.filter(PushSubscription.user_email == user.email)
|
|
.first())
|
|
return {"subscribed": has is not None,
|
|
"supported": _vapid_public() is not None}
|
|
|
|
|
|
@router.post("/subscribe")
|
|
def subscribe(body: PushSubscribeBody,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
"""Upsert a push subscription by (user_email, endpoint)."""
|
|
endpoint = (body.endpoint or "").strip()
|
|
if not endpoint:
|
|
raise HTTPException(status_code=400, detail={"error": "endpoint required", "code": "EMPTY"})
|
|
if _vapid_public() is None:
|
|
raise HTTPException(status_code=503,
|
|
detail={"error": "Push notifications are not configured on this server",
|
|
"code": "VAPID_NOT_CONFIGURED"})
|
|
|
|
sub = (db.query(PushSubscription)
|
|
.filter(PushSubscription.user_email == user.email,
|
|
PushSubscription.endpoint == endpoint)
|
|
.first())
|
|
if sub is None:
|
|
sub = PushSubscription(
|
|
id=str(uuid.uuid4()),
|
|
user_email=user.email,
|
|
endpoint=endpoint,
|
|
p256dh=body.keys.p256dh,
|
|
auth=body.keys.auth,
|
|
user_agent=body.user_agent,
|
|
created_at=datetime.now(timezone.utc),
|
|
)
|
|
db.add(sub)
|
|
else:
|
|
sub.p256dh = body.keys.p256dh
|
|
sub.auth = body.keys.auth
|
|
if body.user_agent is not None:
|
|
sub.user_agent = body.user_agent
|
|
db.commit()
|
|
return {"ok": True, "id": sub.id}
|
|
|
|
|
|
@router.delete("/subscribe")
|
|
def unsubscribe(endpoint: str | None = Query(None),
|
|
body: PushSubscribeBody | None = Body(None),
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db)):
|
|
"""Remove a push subscription by endpoint (query param or body). Idempotent."""
|
|
endpoint = (endpoint or (body.endpoint if body else None) or "").strip()
|
|
if not endpoint:
|
|
raise HTTPException(status_code=400, detail={"error": "endpoint required", "code": "EMPTY"})
|
|
removed = (db.query(PushSubscription)
|
|
.filter(PushSubscription.user_email == user.email,
|
|
PushSubscription.endpoint == endpoint)
|
|
.delete(synchronize_session=False))
|
|
db.commit()
|
|
return {"ok": True, "removed": removed}
|