"""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()