""" Saved searches API endpoints. Provides CRUD operations for user-defined saved search filters. Each user can save, list, update, and delete named filter combinations for quick access on the files page. """ import json import logging from typing import Annotated, Any from fastapi import APIRouter, Body, Depends, HTTPException, Request, status from sqlalchemy.orm import Session from app.auth import get_current_user, require_login from app.database import get_db from app.models import SavedSearch logger = logging.getLogger(__name__) router = APIRouter(prefix="/saved-searches", tags=["saved-searches"]) DbSession = Annotated[Session, Depends(get_db)] # Allowed filter keys that can be saved. # Files-view keys: search, mime_type, status, storage_provider, sort_by, sort_order # Search-view keys: q, document_type, language, sender, text_quality # Shared keys: tags, date_from, date_to ALLOWED_FILTER_KEYS = frozenset( { "search", "q", "mime_type", "status", "date_from", "date_to", "storage_provider", "tags", "sort_by", "sort_order", "document_type", "language", "sender", "text_quality", } ) # Maximum number of saved searches per user MAX_SAVED_SEARCHES_PER_USER = 50 # Maximum length for saved search name MAX_NAME_LENGTH = 100 def _get_user_id(request: Request) -> str: """Extract user identifier from the session. Returns the preferred_username, email, or 'anonymous' if auth is disabled. Args: request: The incoming HTTP request. Returns: A string identifying the current user. """ user = get_current_user(request) if user: return user.get("preferred_username") or user.get("email") or user.get("name", "anonymous") return "anonymous" def _validate_filters(filters: Any) -> dict: """Validate and sanitize filter parameters. Ensures only allowed filter keys are present and values are strings. Args: filters: The raw filter value from the client. Returns: A sanitized filter dictionary with only allowed keys. Raises: HTTPException: If filters is not a dict or contains invalid values. """ if not isinstance(filters, dict): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="filters must be a JSON object", ) sanitized = {} for key, value in filters.items(): if key in ALLOWED_FILTER_KEYS and isinstance(value, str) and value.strip(): sanitized[key] = value.strip() return sanitized def _serialize_saved_search(s: SavedSearch) -> dict: """Serialize a SavedSearch model instance to a JSON-compatible dict. Args: s: The SavedSearch model instance. Returns: A dictionary with id, name, filters, created_at, and updated_at. """ return { "id": s.id, "name": s.name, "filters": json.loads(s.filters), "created_at": s.created_at.isoformat() if s.created_at else None, "updated_at": s.updated_at.isoformat() if s.updated_at else None, } @router.get("") @require_login def list_saved_searches(request: Request, db: DbSession): """List all saved searches for the current user. Returns: A list of saved search objects with id, name, filters, and timestamps. """ user_id = _get_user_id(request) searches = db.query(SavedSearch).filter(SavedSearch.user_id == user_id).order_by(SavedSearch.name).all() return [_serialize_saved_search(s) for s in searches] @router.post("", status_code=status.HTTP_201_CREATED) @require_login def create_saved_search( request: Request, db: DbSession, name: str = Body(..., embed=True), filters: dict = Body(..., embed=True), ): """Create a new saved search for the current user. Request body (JSON): name: Display name for the saved search (required, max 100 chars) filters: Dictionary of filter parameters (required) Returns: The created saved search object. Raises: HTTPException 422: If name or filters are invalid. HTTPException 409: If a saved search with the same name already exists. """ user_id = _get_user_id(request) name = name.strip() if isinstance(name, str) else "" if not name or len(name) > MAX_NAME_LENGTH: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"name is required and must be at most {MAX_NAME_LENGTH} characters", ) sanitized_filters = _validate_filters(filters) if not sanitized_filters: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="At least one filter parameter is required", ) # Check user limit count = db.query(SavedSearch).filter(SavedSearch.user_id == user_id).count() if count >= MAX_SAVED_SEARCHES_PER_USER: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Maximum of {MAX_SAVED_SEARCHES_PER_USER} saved searches reached", ) # Check for duplicate name existing = db.query(SavedSearch).filter(SavedSearch.user_id == user_id, SavedSearch.name == name).first() if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"A saved search named '{name}' already exists", ) saved_search = SavedSearch( user_id=user_id, name=name, filters=json.dumps(sanitized_filters), ) try: db.add(saved_search) db.commit() db.refresh(saved_search) except Exception: db.rollback() logger.exception("Failed to create saved search for user=%s", user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save search", ) logger.info("Saved search created: user=%s, name=%r", user_id, name) return _serialize_saved_search(saved_search) @router.put("/{search_id}") @require_login def update_saved_search( search_id: int, request: Request, db: DbSession, name: str | None = Body(None, embed=True), filters: dict | None = Body(None, embed=True), ): """Update an existing saved search. Path Parameters: search_id: The ID of the saved search to update. Request body (JSON): name: New display name (optional) filters: New filter parameters (optional) Returns: The updated saved search object. Raises: HTTPException 404: If the saved search is not found. HTTPException 409: If the new name conflicts with an existing saved search. """ user_id = _get_user_id(request) saved_search = db.query(SavedSearch).filter(SavedSearch.id == search_id, SavedSearch.user_id == user_id).first() if not saved_search: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Saved search not found") if name is not None: new_name = name.strip() if isinstance(name, str) else "" if not new_name or len(new_name) > MAX_NAME_LENGTH: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"name must be non-empty and at most {MAX_NAME_LENGTH} characters", ) # Check for name conflict if new_name != saved_search.name: existing = ( db.query(SavedSearch).filter(SavedSearch.user_id == user_id, SavedSearch.name == new_name).first() ) if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"A saved search named '{new_name}' already exists", ) saved_search.name = new_name if filters is not None: sanitized_filters = _validate_filters(filters) if not sanitized_filters: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="At least one filter parameter is required", ) saved_search.filters = json.dumps(sanitized_filters) try: db.commit() db.refresh(saved_search) except Exception: db.rollback() logger.exception("Failed to update saved search id=%s, user=%s", search_id, user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update saved search", ) logger.info("Saved search updated: id=%s, user=%s", search_id, user_id) return _serialize_saved_search(saved_search) @router.delete("/{search_id}", status_code=status.HTTP_204_NO_CONTENT) @require_login def delete_saved_search(search_id: int, request: Request, db: DbSession): """Delete a saved search. Path Parameters: search_id: The ID of the saved search to delete. Raises: HTTPException 404: If the saved search is not found. """ user_id = _get_user_id(request) saved_search = db.query(SavedSearch).filter(SavedSearch.id == search_id, SavedSearch.user_id == user_id).first() if not saved_search: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Saved search not found") try: db.delete(saved_search) db.commit() except Exception: db.rollback() logger.exception("Failed to delete saved search id=%s, user=%s", search_id, user_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to delete saved search", ) logger.info("Saved search deleted: id=%s, user=%s", search_id, user_id)