bookmarks: account-synced saved locations API #117
1 changed files with 178 additions and 3 deletions
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue