diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py index 0f4d1f6..ce63ea2 100644 --- a/app/api/routers/admin_dbops.py +++ b/app/api/routers/admin_dbops.py @@ -5,7 +5,7 @@ import logging from fastapi import APIRouter, Depends, Path, Query, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_api_admin_user +from app.api.deps import get_api_admin_user, get_api_current_user from app.api.errors import ApiException from app.api.responses import success_payload from app.api.schemas import ( @@ -185,7 +185,7 @@ async def get_pdf_queue( offset: int | None = Query(default=None, ge=0), status: str | None = Query(default=None), db_session: AsyncSession = Depends(get_db_session), - admin_user: User = Depends(get_api_admin_user), + current_user: User = Depends(get_api_current_user), ): normalized_status = (status or "").strip().lower() or None resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging( @@ -204,7 +204,7 @@ async def get_pdf_queue( logger, "info", "api.admin.db.pdf_queue_listed", - admin_user_id=int(admin_user.id), + user_id=int(current_user.id), page=int(resolved_page), page_size=int(resolved_limit), offset=int(resolved_offset), diff --git a/app/api/routers/scholars.py b/app/api/routers/scholars.py index 7bbc642..92782b1 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,74 @@ 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..a19d9de 100644 --- a/app/services/scholars/application.py +++ b/app/services/scholars/application.py @@ -27,10 +27,62 @@ 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 + + cursor = 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 int(cursor.rowcount) + + __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..f6ad9d3 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 path = ids && ids.length > 0 ? `/scholars/export?ids=${ids.join(",")}` : "/scholars/export"; + const response = await apiRequest(path, { method: "GET", }); return response.data; diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue index b306d1e..6e7e326 100644 --- a/frontend/src/features/settings/SettingsAdminPanel.vue +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -1,7 +1,7 @@