bookmarks: account-synced saved locations API
This commit is contained in:
parent
8572c4aa6d
commit
67284dfe2e
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
|
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],
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue