bookmarks: account-synced saved locations API

This commit is contained in:
emi 2026-07-26 18:27:40 +00:00
parent 8572c4aa6d
commit 67284dfe2e

View file

@ -1,4 +1,4 @@
"""Authenticated API for subscriptions and notifications. """Authenticated API for subscriptions, notifications, and bookmarks.
All routes require a logged-in user (the fastapi-users cookie session) and are 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 scoped to that user a row that isn't theirs reads as 404, never 403, so the API
@ -7,7 +7,7 @@ doesn't leak which ids exist. Mounted under {BASE}/api/v2 alongside the rest of
import os import os
import time import time
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -17,8 +17,15 @@ from data import climate
from data import grid from data import grid
from notifications import push from notifications import push
from accounts.db import get_async_session, get_read_session from accounts.db import get_async_session, get_read_session
from accounts.models import Notification, PushSubscription, Subscription from accounts.models import Bookmark, Notification, PushSubscription, Subscription
from accounts.schemas import ( from accounts.schemas import (
BOOKMARK_MAX_PER_USER,
BookmarkImportIn,
BookmarkImportOut,
BookmarkIn,
BookmarkList,
BookmarkOut,
BookmarkPatch,
NotificationList, NotificationList,
NotificationOut, NotificationOut,
PushSubscriptionIn, PushSubscriptionIn,
@ -323,3 +330,171 @@ async def push_test(
# `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client # `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client
# can tell "request accepted" apart from "actually delivered". # can tell "request accepted" apart from "actually delivered".
return {"devices": len(rows), "sent": sent, "pruned": pruned, "failed": failed} return {"devices": len(rows), "sent": sent, "pruned": pruned, "failed": failed}
# --- bookmarks -----------------------------------------------------------------
async def _owned_bookmark(session: AsyncSession, bookmark_id: int, user) -> Bookmark:
bookmark = (
await session.execute(
select(Bookmark).where(
Bookmark.id == bookmark_id, Bookmark.user_id == user.id
)
)
).scalar_one_or_none()
if bookmark is None:
raise HTTPException(status_code=404, detail="Bookmark not found.")
return bookmark
@router.get("/bookmarks", response_model=BookmarkList)
async def list_bookmarks(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_read_session), # pure read → read-only engine
):
rows = (
await session.execute(
select(Bookmark)
.where(Bookmark.user_id == user.id)
.order_by(Bookmark.created_at)
)
).scalars().all()
return BookmarkList(bookmarks=[BookmarkOut.model_validate(r) for r in rows])
@router.post("/bookmarks", response_model=BookmarkOut, status_code=201)
async def create_bookmark(
body: BookmarkIn,
response: Response,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Create a bookmark, or — if one already exists for this (user, cell) —
rename it in place and return 200. Idempotent upsert rather than a 409, so
re-bookmarking a place you already saved is never an error."""
cell = grid.snap(body.lat, body.lon)
cell_id = cell["id"]
existing = (
await session.execute(
select(Bookmark).where(
Bookmark.user_id == user.id, Bookmark.cell_id == cell_id
)
)
).scalar_one_or_none()
if existing is not None:
existing.label = body.label
await session.commit()
await session.refresh(existing)
audit.log_activity("bookmark.update", {"user_id": str(user.id),
"bookmark_id": existing.id})
response.status_code = 200
return existing
count = (
await session.execute(
select(func.count()).select_from(Bookmark).where(Bookmark.user_id == user.id)
)
).scalar_one()
if count >= BOOKMARK_MAX_PER_USER:
raise HTTPException(
status_code=409,
detail=f"You can have at most {BOOKMARK_MAX_PER_USER} bookmarks.",
)
bookmark = Bookmark(
user_id=user.id,
cell_id=cell_id,
label=body.label,
lat=body.lat,
lon=body.lon,
)
session.add(bookmark)
await session.commit()
await session.refresh(bookmark)
audit.log_activity("bookmark.create", {"user_id": str(user.id), "bookmark_id": bookmark.id,
"cell_id": cell_id})
return bookmark
@router.patch("/bookmarks/{bookmark_id}", response_model=BookmarkOut)
async def update_bookmark(
bookmark_id: int,
body: BookmarkPatch,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
bookmark = await _owned_bookmark(session, bookmark_id, user)
bookmark.label = body.label
await session.commit()
await session.refresh(bookmark)
audit.log_activity("bookmark.rename", {"user_id": str(user.id), "bookmark_id": bookmark.id})
return bookmark
@router.delete("/bookmarks/{bookmark_id}", status_code=204)
async def delete_bookmark(
bookmark_id: int,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
bookmark = await _owned_bookmark(session, bookmark_id, user)
await session.delete(bookmark)
await session.commit()
audit.log_activity("bookmark.delete", {"user_id": str(user.id), "bookmark_id": bookmark_id})
@router.post("/bookmarks/import", response_model=BookmarkImportOut)
async def import_bookmarks(
body: BookmarkImportIn,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Bulk-import a visitor's localStorage bookmarks at login. Existing rows keep
their label (never overwritten); duplicates within the payload itself are also
skipped. Stops importing once the user's total hits BOOKMARK_MAX_PER_USER —
any remaining items count as skipped rather than erroring."""
existing_rows = (
await session.execute(select(Bookmark).where(Bookmark.user_id == user.id))
).scalars().all()
existing_cells = {r.cell_id for r in existing_rows}
count = len(existing_rows)
imported = 0
skipped = 0
seen_cells: set[str] = set()
for item in body.items:
cell_id = grid.snap(item.lat, item.lon)["id"]
if cell_id in existing_cells or cell_id in seen_cells:
skipped += 1
continue
if count >= BOOKMARK_MAX_PER_USER:
skipped += 1
continue
session.add(Bookmark(
user_id=user.id,
cell_id=cell_id,
label=item.label,
lat=item.lat,
lon=item.lon,
))
seen_cells.add(cell_id)
count += 1
imported += 1
if imported:
await session.commit()
audit.log_activity("bookmark.import", {"user_id": str(user.id),
"imported": imported, "skipped": skipped})
rows = (
await session.execute(
select(Bookmark)
.where(Bookmark.user_id == user.id)
.order_by(Bookmark.created_at)
)
).scalars().all()
return BookmarkImportOut(
imported=imported,
skipped=skipped,
bookmarks=[BookmarkOut.model_validate(r) for r in rows],
)