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
+1
View File
@@ -82,6 +82,7 @@ from app.plugins.builtins.report_generator.models import ( # noqa: F401
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
from app.plugins.builtins.tasks import TasksPlugin # noqa: F401
from app.plugins.builtins.tasks.models import Task # noqa: F401
from app.models.saved_filter import SavedFilter # noqa: F401
from app.plugins.registry import reset_registry_for_testing # noqa: F401
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
+128
View File
@@ -0,0 +1,128 @@
"""Saved filters tests — CRUD for reusable filter criteria."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
class TestSavedFilterList:
"""GET /api/v1/saved-filters"""
async def test_list_saved_filters_returns_200(self, client: AsyncClient, db_session):
"""GET /saved-filters returns 200 with list."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_list_saved_filters_with_entity_type(self, client: AsyncClient, db_session):
"""GET /saved-filters?entity_type=contacts filters by entity."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get("/api/v1/saved-filters?entity_type=contacts", headers=ORIGIN_HEADER)
assert resp.status_code == 200
for item in resp.json():
assert item["entity_type"] == "contacts"
async def test_list_saved_filters_requires_auth(self, client: AsyncClient, db_session):
"""GET /saved-filters without auth returns 401."""
resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert resp.status_code == 401
@pytest.mark.asyncio
class TestSavedFilterCreate:
"""POST /api/v1/saved-filters"""
async def test_create_saved_filter_returns_201(self, client: AsyncClient, db_session):
"""POST /saved-filters creates a filter and returns 201."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/saved-filters",
json={
"name": "Important Clients",
"entity_type": "contacts",
"filter_criteria": {"type": "company", "search": "important"},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Important Clients"
assert data["entity_type"] == "contacts"
assert data["filter_criteria"]["type"] == "company"
async def test_create_saved_filter_duplicate_returns_409(self, client: AsyncClient, db_session):
"""POST /saved-filters with duplicate name returns 409."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create first
await client.post(
"/api/v1/saved-filters",
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
# Create duplicate
resp = await client.post(
"/api/v1/saved-filters",
json={"name": "My Filter", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 409
async def test_create_saved_filter_invalid_entity_returns_422(self, client: AsyncClient, db_session):
"""POST /saved-filters with invalid entity_type returns 422."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/saved-filters",
json={"name": "Test", "entity_type": "invalid", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422
@pytest.mark.asyncio
class TestSavedFilterDelete:
"""DELETE /api/v1/saved-filters/{id}"""
async def test_delete_saved_filter_returns_204(self, client: AsyncClient, db_session):
"""DELETE /saved-filters/{id} soft-deletes the filter."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create
create_resp = await client.post(
"/api/v1/saved-filters",
json={"name": "To Delete", "entity_type": "contacts", "filter_criteria": {}},
headers=ORIGIN_HEADER,
)
filter_id = create_resp.json()["id"]
# Delete
resp = await client.delete(f"/api/v1/saved-filters/{filter_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Verify gone from list
list_resp = await client.get("/api/v1/saved-filters", headers=ORIGIN_HEADER)
assert not any(f["id"] == filter_id for f in list_resp.json())
async def test_delete_saved_filter_not_found_returns_404(self, client: AsyncClient, db_session):
"""DELETE non-existent filter returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.delete(
"/api/v1/saved-filters/00000000-0000-0000-0000-000000000000",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
async def test_delete_saved_filter_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
"""DELETE with invalid UUID returns 400."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.delete("/api/v1/saved-filters/not-a-uuid", headers=ORIGIN_HEADER)
assert resp.status_code == 400