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
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 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 sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -17,8 +17,15 @@ from data import climate
from data import grid
from notifications import push
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 (
BOOKMARK_MAX_PER_USER,
BookmarkImportIn,
BookmarkImportOut,
BookmarkIn,
BookmarkList,
BookmarkOut,
BookmarkPatch,
NotificationList,
NotificationOut,
PushSubscriptionIn,
@ -323,3 +330,171 @@ async def push_test(
# `failed` (delivery rejected — e.g. VAPID key mismatch) is surfaced so the client
# can tell "request accepted" apart from "actually delivered".
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],
)