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 6fa54db08f
commit 7f78dd6353
9 changed files with 730 additions and 79 deletions

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",