* Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
196 lines
6 KiB
Python
196 lines
6 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.
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
import climate
|
|
import grid
|
|
from db import get_async_session
|
|
from models import Notification, Subscription
|
|
from schemas import (
|
|
NotificationList,
|
|
NotificationOut,
|
|
SubscriptionIn,
|
|
SubscriptionOut,
|
|
SubscriptionPatch,
|
|
)
|
|
from users import current_active_user
|
|
|
|
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),
|
|
):
|
|
import time
|
|
|
|
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()
|