From 7f78dd6353ab430e34e199dfa47bd7947fb5d2ae Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Sat, 7 Mar 2026 14:03:06 +0100 Subject: [PATCH] feat(scholars): add multiselect and bulk actions (delete, toggle, export) Add Set-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. --- app/api/routers/scholars.py | 66 ++++++ app/api/schemas/scholars.py | 27 +++ app/services/portability/exporting.py | 18 +- app/services/scholars/application.py | 51 +++++ .../composables/useScholarBulkActions.test.ts | 112 ++++++++++ .../composables/useScholarBulkActions.ts | 195 ++++++++++++++++++ frontend/src/features/scholars/index.ts | 26 ++- frontend/src/pages/ScholarsPage.vue | 174 +++++++++------- tests/integration/test_api_scholars_bulk.py | 140 +++++++++++++ 9 files changed, 730 insertions(+), 79 deletions(-) create mode 100644 frontend/src/features/scholars/composables/useScholarBulkActions.test.ts create mode 100644 frontend/src/features/scholars/composables/useScholarBulkActions.ts create mode 100644 tests/integration/test_api_scholars_bulk.py diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 7bbc642..24078b3 100644 --- a/app/api/routers/scholars.py +++ b/app/api/routers/scholars.py @@ -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, diff --git a/app/api/schemas/scholars.py b/app/api/schemas/scholars.py index 91cc459..bc1035d 100644 --- a/app/api/schemas/scholars.py +++ b/app/api/schemas/scholars.py @@ -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 diff --git a/app/services/portability/exporting.py b/app/services/portability/exporting.py index 0589498..a11eaef 100644 --- a/app/services/portability/exporting.py +++ b/app/services/portability/exporting.py @@ -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 { diff --git a/app/services/scholars/application.py b/app/services/scholars/application.py index 028e3fc..cc77613 100644 --- a/app/services/scholars/application.py +++ b/app/services/scholars/application.py @@ -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", diff --git a/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts b/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts new file mode 100644 index 0000000..868aad6 --- /dev/null +++ b/frontend/src/features/scholars/composables/useScholarBulkActions.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from "vitest"; +import { nextTick, ref } from "vue"; +import { useScholarBulkActions, type ScholarBulkAction } from "./useScholarBulkActions"; +import type { ScholarProfile } from "@/features/scholars"; + +vi.mock("@/features/scholars", () => ({ + bulkDeleteScholars: vi.fn(), + bulkToggleScholars: vi.fn(), + exportScholarData: vi.fn(), +})); + +function makeProfile(id: number, name: string): ScholarProfile { + return { + id, + scholar_id: `scholar_${id}`, + display_name: name, + profile_image_url: null, + profile_image_source: "none", + is_enabled: true, + baseline_completed: false, + last_run_dt: null, + last_run_status: null, + }; +} + +function setup(profiles: ScholarProfile[] = []) { + const visibleScholars = ref(profiles); + const callbacks = { + clearMessages: vi.fn(), + assignError: vi.fn(), + setSuccess: vi.fn(), + reloadScholars: vi.fn(async () => {}), + }; + const bulk = useScholarBulkActions(visibleScholars, callbacks); + return { visibleScholars, callbacks, bulk }; +} + +describe("useScholarBulkActions", () => { + it("starts with empty selection", () => { + const { bulk } = setup([makeProfile(1, "Alice")]); + expect(bulk.selectedIds.value.size).toBe(0); + expect(bulk.hasSelection.value).toBe(false); + }); + + it("toggles individual row selection", () => { + const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]); + bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event); + expect(bulk.selectedIds.value.has(1)).toBe(true); + expect(bulk.selectedCount.value).toBe(1); + + bulk.onToggleRow(1, { target: { checked: false } } as unknown as Event); + expect(bulk.selectedIds.value.has(1)).toBe(false); + expect(bulk.selectedCount.value).toBe(0); + }); + + it("toggles all visible scholars", () => { + const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]); + bulk.onToggleAll({ target: { checked: true } } as unknown as Event); + expect(bulk.selectedIds.value.size).toBe(2); + expect(bulk.allVisibleSelected.value).toBe(true); + + bulk.onToggleAll({ target: { checked: false } } as unknown as Event); + expect(bulk.selectedIds.value.size).toBe(0); + }); + + it("prunes stale selections when list changes", async () => { + const { visibleScholars, bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]); + bulk.onToggleAll({ target: { checked: true } } as unknown as Event); + expect(bulk.selectedIds.value.size).toBe(2); + + // Remove Bob from the visible list + visibleScholars.value = [makeProfile(1, "Alice")]; + await nextTick(); + + expect(bulk.selectedIds.value.size).toBe(1); + expect(bulk.selectedIds.value.has(1)).toBe(true); + expect(bulk.selectedIds.value.has(2)).toBe(false); + }); + + it("shows correct bulk action options with and without selection", () => { + const { bulk } = setup([makeProfile(1, "Alice")]); + // Without selection + expect(bulk.bulkActionOptions.value.length).toBe(1); + expect(bulk.bulkActionOptions.value[0].value).toBe("select_all"); + + // With selection + bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event); + expect(bulk.bulkActionOptions.value.length).toBe(5); + const values = bulk.bulkActionOptions.value.map((o) => o.value); + expect(values).toContain("delete_selected"); + expect(values).toContain("enable_selected"); + expect(values).toContain("disable_selected"); + expect(values).toContain("export_selected"); + expect(values).toContain("clear_selection"); + }); + + it("select all action selects all visible", async () => { + const { bulk } = setup([makeProfile(1, "Alice"), makeProfile(2, "Bob")]); + bulk.bulkAction.value = "select_all" as ScholarBulkAction; + await bulk.onApplyBulkAction(); + expect(bulk.selectedIds.value.size).toBe(2); + }); + + it("clear selection action clears all", async () => { + const { bulk } = setup([makeProfile(1, "Alice")]); + bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event); + bulk.bulkAction.value = "clear_selection" as ScholarBulkAction; + await bulk.onApplyBulkAction(); + expect(bulk.selectedIds.value.size).toBe(0); + }); +}); diff --git a/frontend/src/features/scholars/composables/useScholarBulkActions.ts b/frontend/src/features/scholars/composables/useScholarBulkActions.ts new file mode 100644 index 0000000..f4910a7 --- /dev/null +++ b/frontend/src/features/scholars/composables/useScholarBulkActions.ts @@ -0,0 +1,195 @@ +import { computed, ref, watch, type Ref } from "vue"; + +import { + bulkDeleteScholars, + bulkToggleScholars, + exportScholarData, + type ScholarProfile, +} from "@/features/scholars"; +import { ApiRequestError } from "@/lib/api/errors"; + +export type ScholarBulkAction = + | "delete_selected" + | "enable_selected" + | "disable_selected" + | "export_selected" + | "clear_selection" + | "select_all"; + +export interface ScholarBulkActionOption { + value: ScholarBulkAction; + label: string; +} + +export interface BulkActionCallbacks { + clearMessages: () => void; + assignError: (error: unknown, fallback: string) => void; + setSuccess: (msg: string) => void; + reloadScholars: () => Promise; +} + +export function useScholarBulkActions( + visibleScholars: Ref, + callbacks: BulkActionCallbacks, +) { + const selectedIds = ref>(new Set()); + const bulkAction = ref("select_all"); + const bulkBusy = ref(false); + + const selectedCount = computed(() => selectedIds.value.size); + const hasSelection = computed(() => selectedCount.value > 0); + + const allVisibleSelected = computed(() => { + if (visibleScholars.value.length === 0) return false; + for (const item of visibleScholars.value) { + if (!selectedIds.value.has(item.id)) return false; + } + return true; + }); + + const bulkActionOptions = computed(() => { + if (!hasSelection.value) return [{ value: "select_all", label: "Select all" }]; + const n = selectedCount.value; + return [ + { value: "delete_selected", label: `Delete selected (${n})` }, + { value: "enable_selected", label: `Enable selected (${n})` }, + { value: "disable_selected", label: `Disable selected (${n})` }, + { value: "export_selected", label: `Export selected (${n})` }, + { value: "clear_selection", label: "Clear selection" }, + ]; + }); + + const bulkApplyLabel = computed(() => { + if (bulkBusy.value) return "Applying..."; + if (bulkAction.value === "select_all") return "Select"; + if (bulkAction.value === "clear_selection") return "Clear"; + return "Apply"; + }); + + const bulkApplyDisabled = computed(() => { + if (bulkBusy.value) return true; + if (bulkAction.value === "select_all") return visibleScholars.value.length === 0; + return selectedCount.value === 0; + }); + + // Prune stale selections when visible list changes + watch(visibleScholars, (items) => { + const validIds = new Set(items.map((item) => item.id)); + const next = new Set(); + for (const id of selectedIds.value) { + if (validIds.has(id)) next.add(id); + } + if (next.size !== selectedIds.value.size) selectedIds.value = next; + }); + + // Reset bulk action dropdown when selection state changes + watch(hasSelection, (has) => { + const validValues = new Set(bulkActionOptions.value.map((o) => o.value)); + if (validValues.has(bulkAction.value)) return; + bulkAction.value = has ? "delete_selected" : "select_all"; + }); + + function onToggleAll(event: Event): void { + const checked = (event.target as HTMLInputElement).checked; + const next = new Set(selectedIds.value); + for (const item of visibleScholars.value) { + if (checked) { next.add(item.id); } else { next.delete(item.id); } + } + selectedIds.value = next; + } + + function onToggleRow(id: number, event: Event): void { + const checked = (event.target as HTMLInputElement).checked; + const next = new Set(selectedIds.value); + if (checked) { next.add(id); } else { next.delete(id); } + selectedIds.value = next; + } + + function downloadJsonFile(filename: string, payload: unknown): void { + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } + + async function onApplyBulkAction(): Promise { + if (bulkApplyDisabled.value) return; + if (bulkAction.value === "select_all") { + selectedIds.value = new Set(visibleScholars.value.map((item) => item.id)); + return; + } + if (bulkAction.value === "clear_selection") { selectedIds.value = new Set(); return; } + if (bulkAction.value === "delete_selected") { await onBulkDelete(); return; } + if (bulkAction.value === "enable_selected") { await onBulkToggle(true); return; } + if (bulkAction.value === "disable_selected") { await onBulkToggle(false); return; } + if (bulkAction.value === "export_selected") { await onBulkExport(); return; } + } + + async function onBulkDelete(): Promise { + const ids = [...selectedIds.value]; + if (!window.confirm(`Delete ${ids.length} scholar(s)? This removes all linked publications and queue data.`)) return; + bulkBusy.value = true; + callbacks.clearMessages(); + try { + const result = await bulkDeleteScholars(ids); + callbacks.setSuccess(`${result.deleted_count} scholar(s) deleted.`); + selectedIds.value = new Set(); + await callbacks.reloadScholars(); + } catch (error) { + callbacks.assignError(error, "Unable to bulk delete scholars."); + } finally { + bulkBusy.value = false; + } + } + + async function onBulkToggle(isEnabled: boolean): Promise { + bulkBusy.value = true; + callbacks.clearMessages(); + try { + const ids = [...selectedIds.value]; + const result = await bulkToggleScholars(ids, isEnabled); + const verb = isEnabled ? "enabled" : "disabled"; + callbacks.setSuccess(`${result.updated_count} scholar(s) ${verb}.`); + selectedIds.value = new Set(); + await callbacks.reloadScholars(); + } catch (error) { + callbacks.assignError(error, `Unable to bulk ${isEnabled ? "enable" : "disable"} scholars.`); + } finally { + bulkBusy.value = false; + } + } + + async function onBulkExport(): Promise { + bulkBusy.value = true; + callbacks.clearMessages(); + try { + const ids = [...selectedIds.value]; + const payload = await exportScholarData(ids); + const dateSlug = payload.exported_at.slice(0, 10) || "unknown-date"; + downloadJsonFile(`scholarr-export-${dateSlug}.json`, payload); + callbacks.setSuccess("Export complete."); + } catch (error) { + callbacks.assignError(error, "Unable to export selected scholars."); + } finally { + bulkBusy.value = false; + } + } + + return { + selectedIds, + selectedCount, + hasSelection, + allVisibleSelected, + bulkAction, + bulkBusy, + bulkActionOptions, + bulkApplyLabel, + bulkApplyDisabled, + onToggleAll, + onToggleRow, + onApplyBulkAction, + }; +} diff --git a/frontend/src/features/scholars/index.ts b/frontend/src/features/scholars/index.ts index ef3c8c4..fedc71b 100644 --- a/frontend/src/features/scholars/index.ts +++ b/frontend/src/features/scholars/index.ts @@ -160,8 +160,30 @@ export async function clearScholarImage( return response.data; } -export async function exportScholarData(): Promise { - const response = await apiRequest("/scholars/export", { +export interface BulkCountResult { + deleted_count: number; + updated_count: number; +} + +export async function bulkDeleteScholars(scholarProfileIds: number[]): Promise { + const response = await apiRequest("/scholars/bulk-delete", { + method: "POST", + body: { scholar_profile_ids: scholarProfileIds }, + }); + return response.data; +} + +export async function bulkToggleScholars(scholarProfileIds: number[], isEnabled: boolean): Promise { + const response = await apiRequest("/scholars/bulk-toggle", { + method: "POST", + body: { scholar_profile_ids: scholarProfileIds, is_enabled: isEnabled }, + }); + return response.data; +} + +export async function exportScholarData(ids?: number[]): Promise { + const params = ids && ids.length > 0 ? `?ids=${ids.join(",")}` : ""; + const response = await apiRequest(`/scholars/export${params}`, { method: "GET", }); return response.data; diff --git a/frontend/src/pages/ScholarsPage.vue b/frontend/src/pages/ScholarsPage.vue index 16b2a96..6b9f59a 100644 --- a/frontend/src/pages/ScholarsPage.vue +++ b/frontend/src/pages/ScholarsPage.vue @@ -27,6 +27,7 @@ import { type ScholarProfile, type ScholarSearchCandidate, } from "@/features/scholars"; +import { useScholarBulkActions } from "@/features/scholars/composables/useScholarBulkActions"; import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue"; import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue"; import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue"; @@ -290,82 +291,44 @@ async function onDeleteScholar(): Promise { } async function onSaveImageUrl(): Promise { - const profile = activeScholarSettings.value; - if (!profile) return; - const candidate = (imageUrlDraftByScholarId.value[profile.id] || "").trim(); - if (!candidate) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; } - imageSavingScholarId.value = profile.id; + const p = activeScholarSettings.value; + if (!p) return; + const url = (imageUrlDraftByScholarId.value[p.id] || "").trim(); + if (!url) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; } + imageSavingScholarId.value = p.id; clearMessages(); - try { - await setScholarImageUrl(profile.id, candidate); - successMessage.value = `Image URL updated for ${scholarLabel(profile)}.`; - await loadScholars(); - } catch (error) { - assignError(error, "Unable to update scholar image URL."); - } finally { - imageSavingScholarId.value = null; - } + try { await setScholarImageUrl(p.id, url); successMessage.value = `Image URL updated for ${scholarLabel(p)}.`; await loadScholars(); } + catch (e) { assignError(e, "Unable to update scholar image URL."); } + finally { imageSavingScholarId.value = null; } } async function onUploadImage(event: Event): Promise { - const profile = activeScholarSettings.value; - if (!profile) return; + const p = activeScholarSettings.value; + if (!p) return; const input = event.target as HTMLInputElement | null; const file = input?.files?.[0] ?? null; if (!file) return; - imageUploadingScholarId.value = profile.id; + imageUploadingScholarId.value = p.id; clearMessages(); - try { - await uploadScholarImage(profile.id, file); - successMessage.value = `Uploaded image for ${scholarLabel(profile)}.`; - await loadScholars(); - } catch (error) { - assignError(error, "Unable to upload scholar image."); - } finally { - imageUploadingScholarId.value = null; - if (input) input.value = ""; - } + try { await uploadScholarImage(p.id, file); successMessage.value = `Uploaded image for ${scholarLabel(p)}.`; await loadScholars(); } + catch (e) { assignError(e, "Unable to upload scholar image."); } + finally { imageUploadingScholarId.value = null; if (input) input.value = ""; } } async function onResetImage(): Promise { - const profile = activeScholarSettings.value; - if (!profile) return; - imageSavingScholarId.value = profile.id; + const p = activeScholarSettings.value; + if (!p) return; + imageSavingScholarId.value = p.id; clearMessages(); - try { - await clearScholarImage(profile.id); - successMessage.value = `Image reset for ${scholarLabel(profile)}.`; - await loadScholars(); - } catch (error) { - assignError(error, "Unable to reset scholar image."); - } finally { - imageSavingScholarId.value = null; - } + try { await clearScholarImage(p.id); successMessage.value = `Image reset for ${scholarLabel(p)}.`; await loadScholars(); } + catch (e) { assignError(e, "Unable to reset scholar image."); } + finally { imageSavingScholarId.value = null; } } // --- Import/export --- -function suggestExportFilename(exportedAt: string): string { - return `scholarr-export-${exportedAt.slice(0, 10) || "unknown-date"}.json`; -} - -function downloadJsonFile(filename: string, payload: unknown): void { - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = filename; - anchor.click(); - URL.revokeObjectURL(url); -} - -function importSummary(result: DataImportResult): string { - return ( - `Import complete. Scholars +${result.scholars_created}` + - ` / updated ${result.scholars_updated}; publications +${result.publications_created}` + - ` / updated ${result.publications_updated}; links +${result.links_created}` + - ` / updated ${result.links_updated}; skipped ${result.skipped_records}.` - ); +function importSummary(r: DataImportResult): string { + return `Import complete. Scholars +${r.scholars_created} / updated ${r.scholars_updated}; publications +${r.publications_created} / updated ${r.publications_updated}; links +${r.links_created} / updated ${r.links_updated}; skipped ${r.skipped_records}.`; } async function onExportData(): Promise { @@ -373,7 +336,13 @@ async function onExportData(): Promise { clearMessages(); try { const payload = await exportScholarData(); - downloadJsonFile(suggestExportFilename(payload.exported_at), payload); + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `scholarr-export-${payload.exported_at.slice(0, 10) || "unknown-date"}.json`; + a.click(); + URL.revokeObjectURL(url); successMessage.value = "Export complete."; } catch (error) { assignError(error, "Unable to export scholars and publications."); @@ -382,10 +351,6 @@ async function onExportData(): Promise { } } -function onOpenImportPicker(): void { - importFileInput.value?.click(); -} - async function onImportFileSelected(event: Event): Promise { const input = event.target as HTMLInputElement | null; const file = input?.files?.[0] ?? null; @@ -395,16 +360,12 @@ async function onImportFileSelected(event: Event): Promise { try { const raw = await file.text(); let parsed = JSON.parse(raw); - // Accept the full export envelope (with data/meta wrapper) - if (parsed?.data && Array.isArray(parsed.data.scholars)) { - parsed = parsed.data; - } + if (parsed?.data && Array.isArray(parsed.data.scholars)) parsed = parsed.data; const payload = parsed as DataImportPayload; if (!payload || !Array.isArray(payload.scholars) || !Array.isArray(payload.publications)) { throw new Error("Invalid import file: expected scholars[] and publications[] arrays."); } - const result = await importScholarData(payload); - successMessage.value = importSummary(result); + successMessage.value = importSummary(await importScholarData(payload)); await loadScholars(); } catch (error) { assignError(error, "Unable to import scholars and publications."); @@ -414,6 +375,15 @@ async function onImportFileSelected(event: Event): Promise { } } +// --- Bulk actions --- + +const bulk = useScholarBulkActions(visibleScholars, { + clearMessages, + assignError, + setSuccess: (msg: string) => { successMessage.value = msg; }, + reloadScholars: loadScholars, +}); + // --- Lifecycle --- onMounted(() => { void loadScholars(); }); @@ -471,7 +441,7 @@ watch( {{ exportingData ? "Exporting..." : "Export" }} - + {{ importingData ? "Importing..." : "Import" }} @@ -496,6 +466,34 @@ watch( +
+ + {{ trackedCountLabel }} + + +
+ + + + + + {{ bulk.bulkApplyLabel.value }} + +
+
+
@@ -503,6 +501,13 @@ watch(
  • +

    {{ scholarLabel(item) }}

    @@ -519,12 +524,31 @@ watch( + + + Scholar Manage + + +
    @@ -568,3 +592,9 @@ watch( /> + + diff --git a/tests/integration/test_api_scholars_bulk.py b/tests/integration/test_api_scholars_bulk.py new file mode 100644 index 0000000..a481c32 --- /dev/null +++ b/tests/integration/test_api_scholars_bulk.py @@ -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