thermograph/api_accounts.py
Emi Griffith 2f289f1cb6 Add PWA + Web Push delivery for weather alerts (#95)
Make the app installable and deliver existing alert notifications as OS
push, alongside the in-app bell.

Backend:
- PushSubscription model (per-device endpoint + keys, owned by a user) and
  register/unregister/test endpoints under /api/v2/push, cookie-auth scoped
  to the user like the subscription routes.
- push.py: VAPID key management (env -> data/vapid.json -> generated) and a
  pywebpush send helper that reports gone endpoints for pruning. No DB coupling.
- notify.py: after creating an in-app Notification, dispatch Web Push to the
  user's devices (guarded — a push failure never affects the in-app write;
  endpoints reported gone are pruned).
- Serve the .webmanifest with the correct media type.

Frontend:
- manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages.
- sw.js: push + notificationclick handlers (push-only; no fetch caching, so it
  doesn't fight the existing IndexedDB cache). Registered globally in nav.js in
  secure contexts.
- push-client.js + a "Notifications on this device" toggle and test-send on the
  /alerts page, subscribing through the existing cookie-aware apiFetch.

Push and service workers require a secure context, so this is active over HTTPS
(or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
2026-07-15 23:21:06 +00:00

307 lines
9.7 KiB
Python

"""Authenticated API for subscriptions and notifications.
All routes require a logged-in user (the fastapi-users cookie session) and are
scoped to that user — a row that isn't theirs reads as 404, never 403, so the API
doesn't leak which ids exist. Mounted under {BASE}/api/v2 alongside the rest of v2.
"""
import os
import time
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
import climate
import grid
import push
from db import get_async_session
from models import Notification, PushSubscription, Subscription
from schemas import (
NotificationList,
NotificationOut,
PushSubscriptionIn,
PushUnsubscribeIn,
SubscriptionIn,
SubscriptionOut,
SubscriptionPatch,
)
from users import current_active_user
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
BASE = f"/{_BASE}" if _BASE else ""
router = APIRouter(tags=["accounts"])
async def _owned_subscription(session: AsyncSession, sub_id: int, user) -> Subscription:
sub = (
await session.execute(
select(Subscription).where(
Subscription.id == sub_id, Subscription.user_id == user.id
)
)
).scalar_one_or_none()
if sub is None:
raise HTTPException(status_code=404, detail="Subscription not found.")
return sub
# --- subscriptions -----------------------------------------------------------
@router.get("/subscriptions", response_model=list[SubscriptionOut])
async def list_subscriptions(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
rows = (
await session.execute(
select(Subscription)
.where(Subscription.user_id == user.id)
.order_by(Subscription.created_at)
)
).scalars().all()
return rows
@router.post("/subscriptions", response_model=SubscriptionOut, status_code=201)
async def create_subscription(
body: SubscriptionIn,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
cell = grid.snap(body.lat, body.lon)
cell_id = cell["id"]
dup = (
await session.execute(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.cell_id == cell_id,
Subscription.kind == body.kind,
)
)
).scalar_one_or_none()
if dup is not None:
raise HTTPException(
status_code=409,
detail=f"You already have a {body.kind} alert for this location.",
)
label = body.label
if not label:
# Cache-only lookup — never block the event loop on Nominatim. The frontend
# normally supplies the label from /api/v2/place; this is just a fallback.
_, label = climate.reverse_geocode_cached(cell["center_lat"], cell["center_lon"])
sub = Subscription(
user_id=user.id,
cell_id=cell_id,
label=label,
lat=body.lat,
lon=body.lon,
threshold=body.threshold,
metrics=body.metrics,
kind=body.kind,
two_sided=body.two_sided,
)
session.add(sub)
await session.commit()
await session.refresh(sub)
return sub
@router.patch("/subscriptions/{sub_id}", response_model=SubscriptionOut)
async def update_subscription(
sub_id: int,
body: SubscriptionPatch,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
sub = await _owned_subscription(session, sub_id, user)
for key, value in body.model_dump(exclude_unset=True).items():
setattr(sub, key, value)
await session.commit()
await session.refresh(sub)
return sub
@router.delete("/subscriptions/{sub_id}", status_code=204)
async def delete_subscription(
sub_id: int,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
sub = await _owned_subscription(session, sub_id, user)
await session.delete(sub)
await session.commit()
# --- notifications -----------------------------------------------------------
@router.get("/notifications", response_model=NotificationList)
async def list_notifications(
unread: bool = Query(False, description="only return unread notifications"),
limit: int = Query(50, ge=1, le=200),
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
q = select(Notification).where(Notification.user_id == user.id)
if unread:
q = q.where(Notification.read_at.is_(None))
q = q.order_by(Notification.created_at.desc()).limit(limit)
rows = (await session.execute(q)).scalars().all()
unread_count = (
await session.execute(
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user.id, Notification.read_at.is_(None))
)
).scalar_one()
return NotificationList(
notifications=[NotificationOut.model_validate(r) for r in rows],
unread_count=unread_count,
)
@router.post("/notifications/{notif_id}/read", status_code=204)
async def mark_notification_read(
notif_id: int,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
import time
notif = (
await session.execute(
select(Notification).where(
Notification.id == notif_id, Notification.user_id == user.id
)
)
).scalar_one_or_none()
if notif is None:
raise HTTPException(status_code=404, detail="Notification not found.")
if notif.read_at is None:
notif.read_at = time.time()
await session.commit()
@router.post("/notifications/read-all", status_code=204)
async def mark_all_read(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
rows = (
await session.execute(
select(Notification).where(
Notification.user_id == user.id, Notification.read_at.is_(None)
)
)
).scalars().all()
now = time.time()
for n in rows:
n.read_at = now
if rows:
await session.commit()
# --- web push -----------------------------------------------------------------
# The VAPID public key is not a secret and the browser needs it before it can
# subscribe, so this one route is open (still under BASE). Everything else is
# user-scoped like the subscription routes above.
@router.get("/push/vapid-key")
async def push_vapid_key():
return {"key": push.public_key()}
@router.post("/push/subscribe", status_code=201)
async def push_subscribe(
body: PushSubscriptionIn,
request: Request,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Register (or refresh) this device's Web Push endpoint for the user.
Keyed on `endpoint` — re-subscribing from the same device updates the keys in
place rather than creating a duplicate, and a device that was registered to a
different account is re-homed to the current user.
"""
now = time.time()
existing = (
await session.execute(
select(PushSubscription).where(PushSubscription.endpoint == body.endpoint)
)
).scalar_one_or_none()
if existing is not None:
existing.user_id = user.id
existing.p256dh = body.keys.p256dh
existing.auth = body.keys.auth
existing.user_agent = request.headers.get("user-agent")
existing.last_used_at = now
else:
session.add(
PushSubscription(
user_id=user.id,
endpoint=body.endpoint,
p256dh=body.keys.p256dh,
auth=body.keys.auth,
user_agent=request.headers.get("user-agent"),
created_at=now,
last_used_at=now,
)
)
await session.commit()
return {"ok": True}
@router.delete("/push/subscribe", status_code=204)
async def push_unsubscribe(
body: PushUnsubscribeIn,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Drop this device's endpoint (called on toggle-off / permission revoked)."""
row = (
await session.execute(
select(PushSubscription).where(
PushSubscription.endpoint == body.endpoint,
PushSubscription.user_id == user.id,
)
)
).scalar_one_or_none()
if row is not None:
await session.delete(row)
await session.commit()
@router.post("/push/test", status_code=202)
async def push_test(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Send a canned push to every device registered to the user — the on-device
delivery check. Prunes any endpoint the push service reports as gone."""
rows = (
await session.execute(
select(PushSubscription).where(PushSubscription.user_id == user.id)
)
).scalars().all()
payload = {
"title": "Thermograph",
"body": "Push notifications are working on this device.",
"url": f"{BASE}/alerts",
"tag": "thermograph-test",
}
sent, pruned = 0, 0
for row in rows:
info = {"endpoint": row.endpoint, "keys": {"p256dh": row.p256dh, "auth": row.auth}}
# pywebpush blocks on network I/O — keep it off the event loop.
result = await run_in_threadpool(push.send, info, payload)
if result == "ok":
sent += 1
elif result == "gone":
await session.delete(row)
pruned += 1
if pruned:
await session.commit()
return {"devices": len(rows), "sent": sent, "pruned": pruned}