83 lines
2.5 KiB
Python
83 lines
2.5 KiB
Python
"""Saved searches — stored queries shown as virtual mailboxes (SPEC §12 M2)."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from typing import Annotated
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends
|
||
|
|
from pydantic import BaseModel, Field
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.api.errors import raise_api_error
|
||
|
|
from app.db import get_session
|
||
|
|
from app.identity.deps import CurrentUser, require_csrf
|
||
|
|
from app.models import SavedSearch
|
||
|
|
|
||
|
|
router = APIRouter(prefix="/saved-searches", tags=["search"], dependencies=[Depends(require_csrf)])
|
||
|
|
|
||
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||
|
|
|
||
|
|
|
||
|
|
class SavedSearchOut(BaseModel):
|
||
|
|
id: uuid.UUID
|
||
|
|
name: str
|
||
|
|
query: str
|
||
|
|
pinned: bool
|
||
|
|
|
||
|
|
|
||
|
|
class SavedSearchIn(BaseModel):
|
||
|
|
name: str = Field(min_length=1, max_length=80)
|
||
|
|
query: str = Field(min_length=1, max_length=500)
|
||
|
|
pinned: bool = True
|
||
|
|
|
||
|
|
|
||
|
|
def _out(s: SavedSearch) -> SavedSearchOut:
|
||
|
|
return SavedSearchOut(id=s.id, name=s.name, query=s.query, pinned=s.pinned)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("", response_model=list[SavedSearchOut])
|
||
|
|
async def list_saved_searches(user: CurrentUser, db: DbSession) -> list[SavedSearchOut]:
|
||
|
|
rows = await db.scalars(
|
||
|
|
select(SavedSearch).where(SavedSearch.user_id == user.id).order_by(SavedSearch.name)
|
||
|
|
)
|
||
|
|
return [_out(s) for s in rows]
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("", response_model=SavedSearchOut)
|
||
|
|
async def create_saved_search(
|
||
|
|
body: SavedSearchIn, user: CurrentUser, db: DbSession
|
||
|
|
) -> SavedSearchOut:
|
||
|
|
name = body.name.strip()
|
||
|
|
query = body.query.strip()
|
||
|
|
if not name or not query:
|
||
|
|
raise_api_error(400, "invalid", "name and query are required")
|
||
|
|
# Upsert by (user, name): re-saving under an existing name updates its query.
|
||
|
|
existing = await db.scalar(
|
||
|
|
select(SavedSearch).where(SavedSearch.user_id == user.id, SavedSearch.name == name)
|
||
|
|
)
|
||
|
|
if existing is not None:
|
||
|
|
existing.query = query
|
||
|
|
existing.pinned = body.pinned
|
||
|
|
saved = existing
|
||
|
|
else:
|
||
|
|
saved = SavedSearch(user_id=user.id, name=name, query=query, pinned=body.pinned)
|
||
|
|
db.add(saved)
|
||
|
|
await db.commit()
|
||
|
|
return _out(saved)
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete("/{search_id}")
|
||
|
|
async def delete_saved_search(
|
||
|
|
search_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||
|
|
) -> dict[str, str]:
|
||
|
|
saved = await db.scalar(
|
||
|
|
select(SavedSearch).where(SavedSearch.id == search_id, SavedSearch.user_id == user.id)
|
||
|
|
)
|
||
|
|
if saved is None:
|
||
|
|
raise_api_error(404, "not_found", "saved search not found")
|
||
|
|
await db.delete(saved)
|
||
|
|
await db.commit()
|
||
|
|
return {"status": "deleted"}
|