feat(scholars): add multiselect and bulk actions (delete, toggle, export)
Add Set<number>-based selection state to ScholarsPage with checkboxes in both table and card views, select-all toggle, and stale-key pruning. Backend: POST /scholars/bulk-delete, POST /scholars/bulk-toggle, and GET /scholars/export?ids= filter. Service functions use user-scoped queries. Structured logging for bulk operations. Frontend: useScholarBulkActions composable extracts selection and bulk action logic. AppSelect-based action dropdown with dynamic options. Delete prompts window.confirm; enable/disable applies immediately. Export downloads filtered JSON. Includes integration tests for all bulk endpoints and frontend unit tests for selection toggle, select-all, and stale pruning.
This commit is contained in:
parent
61ac6f206f
commit
6742eff773
9 changed files with 730 additions and 79 deletions
140
tests/integration/test_api_scholars_bulk.py
Normal file
140
tests/integration/test_api_scholars_bulk.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.main import app
|
||||
from tests.integration.helpers import (
|
||||
api_csrf_headers,
|
||||
insert_user,
|
||||
login_user,
|
||||
)
|
||||
|
||||
|
||||
def _create_scholar(client: TestClient, headers: dict, scholar_id: str) -> int:
|
||||
resp = client.post("/api/v1/scholars", json={"scholar_id": scholar_id}, headers=headers)
|
||||
assert resp.status_code == 201
|
||||
return int(resp.json()["data"]["id"])
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_with_valid_ids(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="bulk@example.com", password="pw123456")
|
||||
client = TestClient(app)
|
||||
login_user(client, email="bulk@example.com", password="pw123456")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
id1 = _create_scholar(client, headers, "aaaBBB111222")
|
||||
id2 = _create_scholar(client, headers, "cccDDD333444")
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/scholars/bulk-delete",
|
||||
json={"scholar_profile_ids": [id1, id2]},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["deleted_count"] == 2
|
||||
|
||||
list_resp = client.get("/api/v1/scholars")
|
||||
assert len(list_resp.json()["data"]["scholars"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_only_deletes_own_scholars(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="user1@example.com", password="pw123456")
|
||||
await insert_user(db_session, email="user2@example.com", password="pw123456")
|
||||
|
||||
client1 = TestClient(app)
|
||||
login_user(client1, email="user1@example.com", password="pw123456")
|
||||
headers1 = api_csrf_headers(client1)
|
||||
|
||||
client2 = TestClient(app)
|
||||
login_user(client2, email="user2@example.com", password="pw123456")
|
||||
headers2 = api_csrf_headers(client2)
|
||||
|
||||
id_user1 = _create_scholar(client1, headers1, "aaaBBB111222")
|
||||
id_user2 = _create_scholar(client2, headers2, "cccDDD333444")
|
||||
|
||||
# User1 tries to delete both — should only delete own
|
||||
resp = client1.post(
|
||||
"/api/v1/scholars/bulk-delete",
|
||||
json={"scholar_profile_ids": [id_user1, id_user2]},
|
||||
headers=headers1,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["deleted_count"] == 1
|
||||
|
||||
# User2's scholar still exists
|
||||
list_resp = client2.get("/api/v1/scholars")
|
||||
scholars = list_resp.json()["data"]["scholars"]
|
||||
assert len(scholars) == 1
|
||||
assert int(scholars[0]["id"]) == id_user2
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_toggle_enables_and_disables(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="toggle@example.com", password="pw123456")
|
||||
client = TestClient(app)
|
||||
login_user(client, email="toggle@example.com", password="pw123456")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
id1 = _create_scholar(client, headers, "aaaBBB111222")
|
||||
id2 = _create_scholar(client, headers, "cccDDD333444")
|
||||
|
||||
# Disable both
|
||||
resp = client.post(
|
||||
"/api/v1/scholars/bulk-toggle",
|
||||
json={"scholar_profile_ids": [id1, id2], "is_enabled": False},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["updated_count"] == 2
|
||||
|
||||
scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
|
||||
for s in scholars:
|
||||
assert s["is_enabled"] is False
|
||||
|
||||
# Re-enable both
|
||||
resp = client.post(
|
||||
"/api/v1/scholars/bulk-toggle",
|
||||
json={"scholar_profile_ids": [id1, id2], "is_enabled": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"]["updated_count"] == 2
|
||||
|
||||
scholars = client.get("/api/v1/scholars").json()["data"]["scholars"]
|
||||
for s in scholars:
|
||||
assert s["is_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.db
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_with_ids_filter(db_session: AsyncSession) -> None:
|
||||
await insert_user(db_session, email="export@example.com", password="pw123456")
|
||||
client = TestClient(app)
|
||||
login_user(client, email="export@example.com", password="pw123456")
|
||||
headers = api_csrf_headers(client)
|
||||
|
||||
id1 = _create_scholar(client, headers, "aaaBBB111222")
|
||||
_create_scholar(client, headers, "cccDDD333444")
|
||||
|
||||
# Export only id1
|
||||
resp = client.get(f"/api/v1/scholars/export?ids={id1}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert len(data["scholars"]) == 1
|
||||
assert data["scholars"][0]["scholar_id"] == "aaaBBB111222"
|
||||
|
||||
# Export all (no filter)
|
||||
resp_all = client.get("/api/v1/scholars/export")
|
||||
assert resp_all.status_code == 200
|
||||
assert len(resp_all.json()["data"]["scholars"]) == 2
|
||||
Loading…
Add table
Add a link
Reference in a new issue