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

View file

@ -56,11 +56,14 @@ async def export_user_data(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_ids: list[int] | None = None,
) -> dict[str, Any]:
scholars_result = await db_session.execute(
select(ScholarProfile).where(ScholarProfile.user_id == user_id).order_by(ScholarProfile.id.asc())
)
publication_result = await db_session.execute(
scholar_query = select(ScholarProfile).where(ScholarProfile.user_id == user_id)
if scholar_profile_ids:
scholar_query = scholar_query.where(ScholarProfile.id.in_(scholar_profile_ids))
scholars_result = await db_session.execute(scholar_query.order_by(ScholarProfile.id.asc()))
pub_query = (
select(
ScholarProfile.scholar_id,
Publication.cluster_id,
@ -77,8 +80,13 @@ async def export_user_data(
.join(ScholarPublication, ScholarPublication.scholar_profile_id == ScholarProfile.id)
.join(Publication, Publication.id == ScholarPublication.publication_id)
.where(ScholarProfile.user_id == user_id)
.order_by(ScholarPublication.created_at.desc(), Publication.id.desc())
)
if scholar_profile_ids:
pub_query = pub_query.where(ScholarProfile.id.in_(scholar_profile_ids))
publication_result = await db_session.execute(
pub_query.order_by(ScholarPublication.created_at.desc(), Publication.id.desc())
)
scholars = [_serialize_export_scholar(profile) for profile in scholars_result.scalars().all()]
publications = [_serialize_export_publication(row) for row in publication_result.all()]
return {

View file

@ -27,10 +27,61 @@ from app.services.scholars.validators import (
validate_scholar_id,
)
async def bulk_delete_scholars(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_ids: list[int],
upload_dir: str | None = None,
) -> int:
from sqlalchemy import select
result = await db_session.execute(
select(ScholarProfile).where(
ScholarProfile.id.in_(scholar_profile_ids),
ScholarProfile.user_id == user_id,
)
)
profiles = list(result.scalars().all())
if not profiles:
return 0
if upload_dir:
upload_root = _ensure_upload_root(upload_dir, create=True)
for profile in profiles:
_safe_remove_upload(upload_root, profile.profile_image_upload_path)
for profile in profiles:
await db_session.delete(profile)
await db_session.commit()
return len(profiles)
async def bulk_toggle_scholars(
db_session: AsyncSession,
*,
user_id: int,
scholar_profile_ids: list[int],
is_enabled: bool,
) -> int:
from sqlalchemy import update
result = await db_session.execute(
update(ScholarProfile)
.where(
ScholarProfile.id.in_(scholar_profile_ids),
ScholarProfile.user_id == user_id,
)
.values(is_enabled=is_enabled)
)
await db_session.commit()
return result.rowcount # type: ignore[return-value]
__all__ = [
"SEARCH_COOLDOWN_REASON",
"SEARCH_DISABLED_REASON",
"ScholarServiceError",
"bulk_delete_scholars",
"bulk_toggle_scholars",
"clear_profile_image_customization",
"create_scholar_for_user",
"delete_scholar",