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:
Justin Visser 2026-03-07 14:03:06 +01:00
parent 61ac6f206f
commit 6742eff773
9 changed files with 730 additions and 79 deletions

View file

@ -23,6 +23,9 @@ from app.api.schemas import (
DataImportEnvelope,
DataImportRequest,
MessageEnvelope,
ScholarBulkCountEnvelope,
ScholarBulkIdsRequest,
ScholarBulkToggleRequest,
ScholarCreateRequest,
ScholarEnvelope,
ScholarImageUrlUpdateRequest,
@ -42,6 +45,15 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scholars", tags=["api-scholars"])
def _parse_ids_param(ids: str | None) -> list[int] | None:
if not ids:
return None
parts = [p.strip() for p in ids.split(",") if p.strip()]
if not parts:
return None
return [int(p) for p in parts]
@router.get(
"",
response_model=ScholarsListEnvelope,
@ -69,16 +81,70 @@ async def list_scholars(
)
async def export_scholars_and_publications(
request: Request,
ids: str | None = Query(None, description="Comma-separated scholar profile IDs to export"),
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
scholar_profile_ids = _parse_ids_param(ids)
data = await import_export_service.export_user_data(
db_session,
user_id=current_user.id,
scholar_profile_ids=scholar_profile_ids,
)
return success_payload(request, data=data)
@router.post(
"/bulk-delete",
response_model=ScholarBulkCountEnvelope,
)
async def bulk_delete_scholars(
payload: ScholarBulkIdsRequest,
request: Request,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
deleted_count = await scholar_service.bulk_delete_scholars(
db_session,
user_id=current_user.id,
scholar_profile_ids=payload.scholar_profile_ids,
upload_dir=settings.scholar_image_upload_dir,
)
structured_log(
logger, "info", "scholars.bulk_delete",
user_id=current_user.id,
requested_ids=payload.scholar_profile_ids,
deleted_count=deleted_count,
)
return success_payload(request, data={"deleted_count": deleted_count, "updated_count": 0})
@router.post(
"/bulk-toggle",
response_model=ScholarBulkCountEnvelope,
)
async def bulk_toggle_scholars(
payload: ScholarBulkToggleRequest,
request: Request,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user),
):
updated_count = await scholar_service.bulk_toggle_scholars(
db_session,
user_id=current_user.id,
scholar_profile_ids=payload.scholar_profile_ids,
is_enabled=payload.is_enabled,
)
structured_log(
logger, "info", "scholars.bulk_toggle",
user_id=current_user.id,
requested_ids=payload.scholar_profile_ids,
is_enabled=payload.is_enabled,
updated_count=updated_count,
)
return success_payload(request, data={"deleted_count": 0, "updated_count": updated_count})
@router.post(
"/import",
response_model=DataImportEnvelope,

View file

@ -126,6 +126,33 @@ class DataExportEnvelope(BaseModel):
model_config = ConfigDict(extra="forbid")
class ScholarBulkIdsRequest(BaseModel):
scholar_profile_ids: list[int] = Field(..., min_length=1, max_length=500)
model_config = ConfigDict(extra="forbid")
class ScholarBulkToggleRequest(BaseModel):
scholar_profile_ids: list[int] = Field(..., min_length=1, max_length=500)
is_enabled: bool
model_config = ConfigDict(extra="forbid")
class ScholarBulkCountData(BaseModel):
deleted_count: int = 0
updated_count: int = 0
model_config = ConfigDict(extra="forbid")
class ScholarBulkCountEnvelope(BaseModel):
data: ScholarBulkCountData
meta: ApiMeta
model_config = ConfigDict(extra="forbid")
class DataImportRequest(BaseModel):
schema_version: int | None = None
exported_at: str | None = None