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:
parent
61ac6f206f
commit
6742eff773
9 changed files with 730 additions and 79 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
export function useScholarBulkActions(
|
||||
visibleScholars: Ref<ScholarProfile[]>,
|
||||
callbacks: BulkActionCallbacks,
|
||||
) {
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const bulkAction = ref<ScholarBulkAction>("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<ScholarBulkActionOption[]>(() => {
|
||||
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<number>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -160,8 +160,30 @@ export async function clearScholarImage(
|
|||
return response.data;
|
||||
}
|
||||
|
||||
export async function exportScholarData(): Promise<DataExportPayload> {
|
||||
const response = await apiRequest<DataExportPayload>("/scholars/export", {
|
||||
export interface BulkCountResult {
|
||||
deleted_count: number;
|
||||
updated_count: number;
|
||||
}
|
||||
|
||||
export async function bulkDeleteScholars(scholarProfileIds: number[]): Promise<BulkCountResult> {
|
||||
const response = await apiRequest<BulkCountResult>("/scholars/bulk-delete", {
|
||||
method: "POST",
|
||||
body: { scholar_profile_ids: scholarProfileIds },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function bulkToggleScholars(scholarProfileIds: number[], isEnabled: boolean): Promise<BulkCountResult> {
|
||||
const response = await apiRequest<BulkCountResult>("/scholars/bulk-toggle", {
|
||||
method: "POST",
|
||||
body: { scholar_profile_ids: scholarProfileIds, is_enabled: isEnabled },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function exportScholarData(ids?: number[]): Promise<DataExportPayload> {
|
||||
const params = ids && ids.length > 0 ? `?ids=${ids.join(",")}` : "";
|
||||
const response = await apiRequest<DataExportPayload>(`/scholars/export${params}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return response.data;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -291,82 +292,44 @@ async function onDeleteScholar(): Promise<void> {
|
|||
}
|
||||
|
||||
async function onSaveImageUrl(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
|
@ -374,7 +337,13 @@ async function onExportData(): Promise<void> {
|
|||
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.");
|
||||
|
|
@ -383,10 +352,6 @@ async function onExportData(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function onOpenImportPicker(): void {
|
||||
importFileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onImportFileSelected(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement | null;
|
||||
const file = input?.files?.[0] ?? null;
|
||||
|
|
@ -396,16 +361,12 @@ async function onImportFileSelected(event: Event): Promise<void> {
|
|||
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.");
|
||||
|
|
@ -415,6 +376,15 @@ async function onImportFileSelected(event: Event): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Bulk actions ---
|
||||
|
||||
const bulk = useScholarBulkActions(visibleScholars, {
|
||||
clearMessages,
|
||||
assignError,
|
||||
setSuccess: (msg: string) => { successMessage.value = msg; },
|
||||
reloadScholars: loadScholars,
|
||||
});
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
onMounted(() => { void loadScholars(); });
|
||||
|
|
@ -472,7 +442,7 @@ watch(
|
|||
<AppButton variant="secondary" :disabled="loading || exportingData" @click="onExportData">
|
||||
{{ exportingData ? "Exporting..." : "Export" }}
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" :disabled="loading || importingData" @click="onOpenImportPicker">
|
||||
<AppButton variant="secondary" :disabled="loading || importingData" @click="importFileInput?.click()">
|
||||
{{ importingData ? "Importing..." : "Import" }}
|
||||
</AppButton>
|
||||
<AppRefreshButton variant="secondary" :disabled="saving" :loading="loading" title="Refresh scholars" loading-title="Refreshing scholars" @click="loadScholars" />
|
||||
|
|
@ -497,6 +467,34 @@ watch(
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-2">
|
||||
<span class="text-xs text-secondary">
|
||||
{{ trackedCountLabel }}
|
||||
<template v-if="bulk.hasSelection.value"> · {{ bulk.selectedCount.value }} selected</template>
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<label for="scholars-bulk-action" class="sr-only">Bulk action</label>
|
||||
<AppSelect
|
||||
id="scholars-bulk-action"
|
||||
v-model="bulk.bulkAction.value"
|
||||
:disabled="bulk.bulkBusy.value || loading"
|
||||
class="max-w-[14rem] !py-1.5 !text-xs"
|
||||
>
|
||||
<option v-for="option in bulk.bulkActionOptions.value" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</AppSelect>
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
class="h-8 min-h-8 shrink-0 px-2 text-xs"
|
||||
:disabled="bulk.bulkApplyDisabled.value"
|
||||
@click="bulk.onApplyBulkAction"
|
||||
>
|
||||
{{ bulk.bulkApplyLabel.value }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 xl:overflow-hidden">
|
||||
<AsyncStateGate :loading="loading" :loading-lines="6" :empty="!hasTrackedScholars" :show-empty="!errorMessage" empty-title="No scholars tracked" empty-body="Add a Scholar ID or URL to start ingestion tracking.">
|
||||
<AppEmptyState v-if="!hasVisibleScholars" title="No scholars match this filter" body="Clear or adjust the filter to see tracked scholars." />
|
||||
|
|
@ -504,6 +502,13 @@ watch(
|
|||
<ul class="flex gap-3 overflow-x-auto p-1 lg:hidden">
|
||||
<li v-for="item in visibleScholars" :key="item.id" class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="bulk-check mt-1 shrink-0"
|
||||
:checked="bulk.selectedIds.value.has(item.id)"
|
||||
:aria-label="`Select ${scholarLabel(item)}`"
|
||||
@change="bulk.onToggleRow(item.id, $event)"
|
||||
/>
|
||||
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<p class="truncate text-sm font-semibold text-ink-primary">
|
||||
|
|
@ -523,12 +528,31 @@ watch(
|
|||
<AppTable class="h-full overflow-y-scroll overscroll-contain" label="Tracked scholars table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="bulk-check"
|
||||
:checked="bulk.allVisibleSelected.value"
|
||||
:disabled="visibleScholars.length === 0"
|
||||
aria-label="Select all visible scholars"
|
||||
@change="bulk.onToggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th scope="col">Scholar</th>
|
||||
<th scope="col" class="w-[11rem]">Manage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in visibleScholars" :key="item.id">
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="bulk-check"
|
||||
:checked="bulk.selectedIds.value.has(item.id)"
|
||||
:aria-label="`Select ${scholarLabel(item)}`"
|
||||
@change="bulk.onToggleRow(item.id, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-start gap-3">
|
||||
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
||||
|
|
@ -575,3 +599,9 @@ watch(
|
|||
/>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulk-check {
|
||||
@apply h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
140
tests/integration/test_api_scholars_bulk.py
Normal file
140
tests/integration/test_api_scholars_bulk.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue