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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user