Files
sapl/backend/routers/push.py
T
shridhar.sabat fec6167037 feat(capture): web-push subscription store + API + delivery on job completion (PR-3, refs #524)
- 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.
2026-09-10 14:41:58 +05:30

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}