feat(5.22): Saved Searches / Smart Lists — reusable filters for list views

- SavedFilter model with TenantMixin (name, entity_type, filter_criteria JSONB, user_id)
- Routes: GET/POST /saved-filters, DELETE /saved-filters/{id} with RBAC
- Alembic migration 0029_saved_filters creates saved_filters table
- Frontend: SavedFilters.tsx component with save/load/delete UI
- Frontend: api/savedFilters.ts with React Query hooks
- Integrated into ContactsListPage as example
- i18n keys for savedFilters.* in de.json and en.json
- Tests: test_saved_filters.py (9 tests) + SavedFilters.test.tsx (3 tests)
- Registered SavedFilter in conftest.py
This commit is contained in:
Agent Zero
2026-07-23 23:44:50 +02:00
parent 2c9e74776e
commit 182af355d1
12 changed files with 663 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
"""Saved filters routes — CRUD for reusable filter criteria."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.models.saved_filter import SavedFilter
router = APIRouter(prefix="/api/v1/saved-filters", tags=["saved-filters"])
VALID_ENTITY_TYPES = {"contacts", "mail", "calendar", "dms"}
class SavedFilterCreate(BaseModel):
"""Schema for creating a saved filter."""
name: str = Field(..., min_length=1, max_length=100)
entity_type: str = Field(..., pattern="^(contacts|mail|calendar|dms)$")
filter_criteria: dict[str, Any] = Field(default_factory=dict)
class SavedFilterUpdate(BaseModel):
"""Schema for updating a saved filter."""
name: str | None = Field(default=None, min_length=1, max_length=100)
filter_criteria: dict[str, Any] | None = None
def _filter_to_dict(f: SavedFilter) -> dict[str, Any]:
return {
"id": str(f.id),
"name": f.name,
"entity_type": f.entity_type,
"filter_criteria": f.filter_criteria,
"user_id": str(f.user_id),
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
@router.get("", dependencies=[Depends(require_permission("contacts:read"))])
async def list_saved_filters(
entity_type: str | None = Query(None, pattern="^(contacts|mail|calendar|dms)$"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List saved filters for the current user, optionally filtered by entity_type."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
query = select(SavedFilter).where(
SavedFilter.tenant_id == tenant_id,
SavedFilter.user_id == user_id,
SavedFilter.deleted_at.is_(None),
)
if entity_type:
query = query.where(SavedFilter.entity_type == entity_type)
query = query.order_by(SavedFilter.name)
result = await db.execute(query)
filters = result.scalars().all()
return [_filter_to_dict(f) for f in filters]
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("contacts:read"))])
async def create_saved_filter(
body: SavedFilterCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Create a new saved filter for the current user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Check uniqueness within user+entity
existing = await db.execute(
select(SavedFilter).where(
SavedFilter.tenant_id == tenant_id,
SavedFilter.user_id == user_id,
SavedFilter.entity_type == body.entity_type,
SavedFilter.name == body.name,
SavedFilter.deleted_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Filter name already exists", "code": "duplicate"})
saved = SavedFilter(
tenant_id=tenant_id,
user_id=user_id,
name=body.name,
entity_type=body.entity_type,
filter_criteria=body.filter_criteria,
)
db.add(saved)
await db.flush()
return _filter_to_dict(saved)
@router.delete("/{filter_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_permission("contacts:read"))])
async def delete_saved_filter(
filter_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Delete a saved filter (soft-delete)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
try:
fid = uuid.UUID(filter_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid filter_id", "code": "invalid_id"}) from None
result = await db.execute(
select(SavedFilter).where(
SavedFilter.id == fid,
SavedFilter.tenant_id == tenant_id,
SavedFilter.user_id == user_id,
SavedFilter.deleted_at.is_(None),
)
)
saved = result.scalar_one_or_none()
if saved is None:
raise HTTPException(404, detail={"detail": "Saved filter not found", "code": "not_found"})
from datetime import datetime, timezone
saved.deleted_at = datetime.now(timezone.utc)
await db.flush()
return Response(status_code=status.HTTP_204_NO_CONTENT)