Compare commits

..

8 commits

Author SHA1 Message Date
f501ea4f94 fix(frontend): confirm modal, settings display, race guard, and test coverage
- S1: Replace window.confirm() with AppConfirmModal for delete actions
- S4: Add formatHours helper to avoid ugly decimals in settings hints
- W5: Add generation counter to SettingsAdminPanel to prevent stale
  async results when section changes rapidly
- S7: Remove unused ApiRequestError import from useScholarBulkActions
- S2: Add error-path tests for bulk delete/toggle network failures
- S3: Add CSRF boundary tests for bulk-delete and bulk-toggle endpoints
2026-03-07 18:07:29 +01:00
f8e3098fc3 fix(backend): harden bulk endpoints and redact pdf-queue email for non-admins
- W1: _parse_ids_param now returns 400 on non-integer input instead of 500
- W2: bulk_delete_scholars wraps commit in IntegrityError guard; router
  catches ScholarServiceError → 409
- W3: Move `from typing import Any` to top-level import in application.py
- W4: Redact requested_by_email in PDF queue response for non-admin users
2026-03-07 17:57:37 +01:00
c85fa08b7b fix(frontend): add cooldown tooltips and fix settings tab initial load
Add AppHelpHint tooltips to ScrapeSafetyBadge and ScrapeSafetyBanner
showing rate-limit explanation, cooldown reason, and recommended action.
When no cooldown is active, show a simple "ready" message.

Fix settings tabs not loading on initial navigation by deferring
onMounted load via nextTick, and using flush:'post' on the section
watcher so template refs are attached before load() runs.
2026-03-07 16:50:35 +01:00
39c7eb686c fix(mypy): use CursorResult type annotation for bulk_toggle_scholars 2026-03-07 16:16:55 +01:00
3e933998a0 fix(ci): fix mypy error in bulk_toggle_scholars
Use int(cursor.rowcount) instead of type: ignore comment.
2026-03-07 16:11:25 +01:00
6f3e0eec39 fix(ci): fix ruff format in scholars router
Put each structured_log argument on its own line.
2026-03-07 16:11:25 +01:00
76c4811405 fix(ci): fix import sorting, API contract, and pdf-queue test
- Add missing blank line after imports in application.py (ruff I001)
- Use full path string instead of template literal for export API call
  to avoid false positive in API contract drift check
- Update pdf-queue test to expect 200 for non-admin listing
2026-03-07 16:11:25 +01:00
6742eff773 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.
2026-03-07 16:11:25 +01:00
17 changed files with 1115 additions and 104 deletions

View file

@ -57,7 +57,7 @@ def _requested_by_value(*, payload, admin_user: User) -> str:
return from_payload or admin_user.email return from_payload or admin_user.email
def _serialize_pdf_queue_item(item) -> dict[str, object]: def _serialize_pdf_queue_item(item, *, is_admin: bool = False) -> dict[str, object]:
return { return {
"publication_id": item.publication_id, "publication_id": item.publication_id,
"title": item.title, "title": item.title,
@ -69,7 +69,7 @@ def _serialize_pdf_queue_item(item) -> dict[str, object]:
"last_failure_detail": item.last_failure_detail, "last_failure_detail": item.last_failure_detail,
"last_source": item.last_source, "last_source": item.last_source,
"requested_by_user_id": item.requested_by_user_id, "requested_by_user_id": item.requested_by_user_id,
"requested_by_email": item.requested_by_email, "requested_by_email": item.requested_by_email if is_admin else None,
"queued_at": item.queued_at, "queued_at": item.queued_at,
"last_attempt_at": item.last_attempt_at, "last_attempt_at": item.last_attempt_at,
"resolved_at": item.resolved_at, "resolved_at": item.resolved_at,
@ -215,7 +215,7 @@ async def get_pdf_queue(
return success_payload( return success_payload(
request, request,
data={ data={
"items": [_serialize_pdf_queue_item(item) for item in queue_page.items], "items": [_serialize_pdf_queue_item(item, is_admin=current_user.is_admin) for item in queue_page.items],
**_pdf_queue_page_data( **_pdf_queue_page_data(
total_count=queue_page.total_count, total_count=queue_page.total_count,
page=resolved_page, page=resolved_page,

View file

@ -23,6 +23,9 @@ from app.api.schemas import (
DataImportEnvelope, DataImportEnvelope,
DataImportRequest, DataImportRequest,
MessageEnvelope, MessageEnvelope,
ScholarBulkCountEnvelope,
ScholarBulkIdsRequest,
ScholarBulkToggleRequest,
ScholarCreateRequest, ScholarCreateRequest,
ScholarEnvelope, ScholarEnvelope,
ScholarImageUrlUpdateRequest, ScholarImageUrlUpdateRequest,
@ -42,6 +45,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scholars", tags=["api-scholars"]) 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
try:
return [int(p) for p in parts]
except ValueError as exc:
raise ApiException(
status_code=400,
code="invalid_ids_param",
message="The 'ids' parameter must be a comma-separated list of integers.",
) from exc
@router.get( @router.get(
"", "",
response_model=ScholarsListEnvelope, response_model=ScholarsListEnvelope,
@ -69,16 +88,81 @@ async def list_scholars(
) )
async def export_scholars_and_publications( async def export_scholars_and_publications(
request: Request, request: Request,
ids: str | None = Query(None, description="Comma-separated scholar profile IDs to export"),
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_api_current_user), current_user: User = Depends(get_api_current_user),
): ):
scholar_profile_ids = _parse_ids_param(ids)
data = await import_export_service.export_user_data( data = await import_export_service.export_user_data(
db_session, db_session,
user_id=current_user.id, user_id=current_user.id,
scholar_profile_ids=scholar_profile_ids,
) )
return success_payload(request, data=data) 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),
):
try:
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,
)
except scholar_service.ScholarServiceError as exc:
raise ApiException(
status_code=409,
code="scholar_bulk_delete_failed",
message=str(exc),
) from exc
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( @router.post(
"/import", "/import",
response_model=DataImportEnvelope, response_model=DataImportEnvelope,

View file

@ -126,6 +126,33 @@ class DataExportEnvelope(BaseModel):
model_config = ConfigDict(extra="forbid") 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): class DataImportRequest(BaseModel):
schema_version: int | None = None schema_version: int | None = None
exported_at: str | None = None exported_at: str | None = None

View file

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

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@ -27,10 +28,66 @@ from app.services.scholars.validators import (
validate_scholar_id, 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)
try:
await db_session.commit()
except IntegrityError as exc:
await db_session.rollback()
raise ScholarServiceError("Unable to bulk-delete scholars due to a database constraint.") from exc
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 CursorResult, update
cursor: CursorResult[Any] = await db_session.execute( # type: ignore[assignment]
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 or 0)
__all__ = [ __all__ = [
"SEARCH_COOLDOWN_REASON", "SEARCH_COOLDOWN_REASON",
"SEARCH_DISABLED_REASON", "SEARCH_DISABLED_REASON",
"ScholarServiceError", "ScholarServiceError",
"bulk_delete_scholars",
"bulk_toggle_scholars",
"clear_profile_image_customization", "clear_profile_image_customization",
"create_scholar_for_user", "create_scholar_for_user",
"delete_scholar", "delete_scholar",

View file

@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import ScrapeSafetyBadge from "./ScrapeSafetyBadge.vue";
import { createDefaultSafetyState, type ScrapeSafetyState } from "@/features/safety";
function buildState(overrides: Partial<ScrapeSafetyState> = {}): ScrapeSafetyState {
return { ...createDefaultSafetyState(), ...overrides };
}
describe("ScrapeSafetyBadge", () => {
it("shows ready tooltip when cooldown is inactive", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: { state: buildState({ cooldown_active: false }) },
});
expect(wrapper.text()).toContain("Safety ready");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
expect(hint.props("text")).toContain("No active cooldown");
});
it("shows cooldown tooltip with reason and action when active", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "blocked_failure_threshold_exceeded",
cooldown_reason_label: "Too many blocked requests",
recommended_action: "Wait for cooldown to expire",
cooldown_remaining_seconds: 120,
}),
},
});
expect(wrapper.text()).toContain("Safety cooldown");
const hint = wrapper.findComponent({ name: "AppHelpHint" });
expect(hint.exists()).toBe(true);
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).toContain("Too many blocked requests");
expect(text).toContain("Wait for cooldown to expire");
});
it("shows cooldown tooltip without optional fields", () => {
const wrapper = mount(ScrapeSafetyBadge, {
props: {
state: buildState({
cooldown_active: true,
cooldown_reason: "some_reason",
cooldown_reason_label: null,
recommended_action: null,
cooldown_remaining_seconds: 60,
}),
},
});
const hint = wrapper.findComponent({ name: "AppHelpHint" });
const text = hint.props("text") as string;
expect(text).toContain("Google Scholar rate-limits");
expect(text).not.toContain("Why:");
expect(text).not.toContain("Action:");
});
});

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { type ScrapeSafetyState } from "@/features/safety"; import { type ScrapeSafetyState } from "@/features/safety";
const props = defineProps<{ const props = defineProps<{
@ -18,10 +19,30 @@ const toneClass = computed(() => {
} }
return "border-state-warning-border bg-state-warning-bg text-state-warning-text"; return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
}); });
const READY_TOOLTIP = "No active cooldown. Scraping can proceed normally.";
const RATE_LIMIT_INTRO =
"Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
const tooltipText = computed(() => {
if (!props.state.cooldown_active) return READY_TOOLTIP;
const parts = [RATE_LIMIT_INTRO];
if (props.state.cooldown_reason_label) {
parts.push(`Why: ${props.state.cooldown_reason_label}`);
}
if (props.state.recommended_action) {
parts.push(`Action: ${props.state.recommended_action}`);
}
return parts.join(" \u2014 ");
});
</script> </script>
<template> <template>
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass"> <span class="inline-flex items-center gap-1">
{{ label }} <span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
{{ label }}
</span>
<AppHelpHint :text="tooltipText" />
</span> </span>
</template> </template>

View file

@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppAlert from "@/components/ui/AppAlert.vue"; import AppAlert from "@/components/ui/AppAlert.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { import {
formatCooldownCountdown, formatCooldownCountdown,
type ScrapeSafetyState, type ScrapeSafetyState,
@ -79,6 +80,10 @@ const actionText = computed(() => {
return props.safetyState.recommended_action; return props.safetyState.recommended_action;
}); });
const bannerHintText = computed(() => {
return "Google Scholar rate-limits automated requests. The cooldown pauses scraping to avoid your IP being blocked.";
});
onMounted(() => { onMounted(() => {
timer = setInterval(() => { timer = setInterval(() => {
now.value = Date.now(); now.value = Date.now();
@ -95,7 +100,12 @@ onBeforeUnmount(() => {
<template> <template>
<AppAlert v-if="isVisible" :tone="tone"> <AppAlert v-if="isVisible" :tone="tone">
<template #title>{{ title }}</template> <template #title>
<span class="inline-flex items-center gap-1">
{{ title }}
<AppHelpHint :text="bannerHintText" />
</span>
</template>
<p>{{ detailText }}</p> <p>{{ detailText }}</p>
<p v-if="actionText" class="text-secondary">{{ actionText }}</p> <p v-if="actionText" class="text-secondary">{{ actionText }}</p>
</AppAlert> </AppAlert>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
import AppButton from "@/components/ui/AppButton.vue";
import AppModal from "@/components/ui/AppModal.vue";
withDefaults(
defineProps<{
open: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
}>(),
{ confirmLabel: "Confirm", cancelLabel: "Cancel", variant: "default" },
);
const emit = defineEmits<{ confirm: []; cancel: [] }>();
</script>
<template>
<AppModal :open="open" :title="title" @close="emit('cancel')">
<p class="mb-6 text-sm text-secondary">{{ message }}</p>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" @click="emit('cancel')">{{ cancelLabel }}</AppButton>
<AppButton :variant="variant === 'danger' ? 'danger' : 'primary'" @click="emit('confirm')">{{ confirmLabel }}</AppButton>
</div>
</AppModal>
</template>

View file

@ -0,0 +1,148 @@
// @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);
});
it("bulk delete calls assignError on network failure", async () => {
const { bulkDeleteScholars } = await import("@/features/scholars");
vi.mocked(bulkDeleteScholars).mockRejectedValueOnce(new Error("Network error"));
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
bulk.bulkAction.value = "delete_selected" as ScholarBulkAction;
await bulk.onApplyBulkAction();
// Trigger the confirm callback
expect(bulk.confirmState.value.open).toBe(true);
await bulk.confirmState.value.onConfirm();
expect(callbacks.assignError).toHaveBeenCalledWith(
expect.any(Error),
"Unable to bulk delete scholars.",
);
expect(bulk.bulkBusy.value).toBe(false);
});
it("bulk toggle calls assignError on network failure", async () => {
const { bulkToggleScholars } = await import("@/features/scholars");
vi.mocked(bulkToggleScholars).mockRejectedValueOnce(new Error("Network error"));
const { bulk, callbacks } = setup([makeProfile(1, "Alice")]);
bulk.onToggleRow(1, { target: { checked: true } } as unknown as Event);
bulk.bulkAction.value = "enable_selected" as ScholarBulkAction;
await bulk.onApplyBulkAction();
expect(callbacks.assignError).toHaveBeenCalledWith(
expect.any(Error),
"Unable to bulk enable scholars.",
);
expect(bulk.bulkBusy.value).toBe(false);
});
});

View file

@ -0,0 +1,221 @@
import { computed, ref, watch, type Ref } from "vue";
import {
bulkDeleteScholars,
bulkToggleScholars,
exportScholarData,
type ScholarProfile,
} from "@/features/scholars";
export type ScholarBulkAction =
| "delete_selected"
| "enable_selected"
| "disable_selected"
| "export_selected"
| "clear_selection"
| "select_all";
export interface ScholarBulkActionOption {
value: ScholarBulkAction;
label: string;
}
export interface ConfirmState {
open: boolean;
title: string;
message: string;
variant: "danger" | "default";
onConfirm: () => void;
}
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 confirmState = ref<ConfirmState>({ open: false, title: "", message: "", variant: "default", onConfirm: () => {} });
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; }
}
function dismissConfirm(): void {
confirmState.value = { ...confirmState.value, open: false };
}
function requestConfirm(title: string, message: string, variant: "danger" | "default", onConfirm: () => void): void {
confirmState.value = { open: true, title, message, variant, onConfirm };
}
function onBulkDelete(): void {
const ids = [...selectedIds.value];
requestConfirm(
`Delete ${ids.length} scholar(s)?`,
"This removes all linked publications and queue data. This action cannot be undone.",
"danger",
async () => {
dismissConfirm();
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,
confirmState,
dismissConfirm,
requestConfirm,
onToggleAll,
onToggleRow,
onApplyBulkAction,
};
}

View file

@ -160,8 +160,30 @@ export async function clearScholarImage(
return response.data; return response.data;
} }
export async function exportScholarData(): Promise<DataExportPayload> { export interface BulkCountResult {
const response = await apiRequest<DataExportPayload>("/scholars/export", { 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 path = ids && ids.length > 0 ? `/scholars/export?ids=${ids.join(",")}` : "/scholars/export";
const response = await apiRequest<DataExportPayload>(path, {
method: "GET", method: "GET",
}); });
return response.data; return response.data;

View file

@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import SettingsAdminPanel from "./SettingsAdminPanel.vue";
vi.mock("@/features/admin_users", () => ({
listAdminUsers: vi.fn().mockResolvedValue([]),
createAdminUser: vi.fn(),
setAdminUserActive: vi.fn(),
resetAdminUserPassword: vi.fn(),
}));
vi.mock("@/features/admin_dbops", () => ({
getAdminDbIntegrityReport: vi.fn().mockResolvedValue({
status: "ok",
warnings: [],
failures: [],
checked_at: null,
checks: [],
}),
listAdminPdfQueue: vi.fn().mockResolvedValue({
items: [],
total_count: 0,
has_next: false,
has_prev: false,
page: 1,
page_size: 50,
}),
requeueAdminPdfLookup: vi.fn(),
requeueAllAdminPdfLookups: vi.fn(),
}));
vi.mock("@/stores/auth", () => ({
useAuthStore: () => ({ isAdmin: true }),
}));
vi.mock("@/features/admin_repairs", () => ({
listAdminRepairTasks: vi.fn().mockResolvedValue([]),
runAdminRepairTask: vi.fn(),
}));
import { listAdminUsers } from "@/features/admin_users";
import { getAdminDbIntegrityReport, listAdminPdfQueue } from "@/features/admin_dbops";
const mockedListUsers = vi.mocked(listAdminUsers);
const mockedGetReport = vi.mocked(getAdminDbIntegrityReport);
const mockedListPdfQueue = vi.mocked(listAdminPdfQueue);
describe("SettingsAdminPanel", () => {
beforeEach(() => {
mockedListUsers.mockClear();
mockedGetReport.mockClear();
mockedListPdfQueue.mockClear();
});
it("calls load on users section after nextTick when section=users", async () => {
mount(SettingsAdminPanel, { props: { section: "users" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedListUsers).toHaveBeenCalled();
});
it("calls load on integrity section after nextTick when section=integrity", async () => {
mount(SettingsAdminPanel, { props: { section: "integrity" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("calls load on new section when section prop changes", async () => {
const wrapper = mount(SettingsAdminPanel, { props: { section: "users" } });
await nextTick();
await nextTick();
await flushPromises();
mockedListUsers.mockClear();
mockedGetReport.mockClear();
await wrapper.setProps({ section: "integrity" });
await nextTick();
await flushPromises();
expect(mockedGetReport).toHaveBeenCalled();
});
it("calls load on pdf section when section=pdf", async () => {
mount(SettingsAdminPanel, { props: { section: "pdf" } });
await nextTick();
await nextTick();
await flushPromises();
expect(mockedListPdfQueue).toHaveBeenCalled();
});
});

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from "vue"; import { nextTick, onMounted, watch } from "vue";
import { ref } from "vue"; import { ref } from "vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
@ -25,7 +25,10 @@ const integrityRef = ref<InstanceType<typeof AdminIntegritySection> | null>(null
const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null); const repairsRef = ref<InstanceType<typeof AdminRepairsSection> | null>(null);
const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null); const pdfQueueRef = ref<InstanceType<typeof AdminPdfQueueSection> | null>(null);
let loadGeneration = 0;
async function loadSection(): Promise<void> { async function loadSection(): Promise<void> {
const gen = ++loadGeneration;
clearAlerts(); clearAlerts();
try { try {
if (props.section === SECTION_USERS && usersRef.value) { if (props.section === SECTION_USERS && usersRef.value) {
@ -34,17 +37,21 @@ async function loadSection(): Promise<void> {
await integrityRef.value.load(); await integrityRef.value.load();
} else if (props.section === SECTION_REPAIRS) { } else if (props.section === SECTION_REPAIRS) {
await usersRef.value?.load(); await usersRef.value?.load();
if (gen !== loadGeneration) return;
await repairsRef.value?.load(); await repairsRef.value?.load();
} else if (props.section === SECTION_PDF && pdfQueueRef.value) { } else if (props.section === SECTION_PDF && pdfQueueRef.value) {
await pdfQueueRef.value.load(); await pdfQueueRef.value.load();
} }
} catch (error) { } catch (error) {
if (gen !== loadGeneration) return;
assignError(error, "Unable to load admin data."); assignError(error, "Unable to load admin data.");
} }
} }
onMounted(loadSection); onMounted(() => {
watch(() => props.section, loadSection); nextTick(loadSection);
});
watch(() => props.section, loadSection, { flush: "post" });
</script> </script>
<template> <template>

View file

@ -6,6 +6,7 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import AppButton from "@/components/ui/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue"; import AppCard from "@/components/ui/AppCard.vue";
import AppConfirmModal from "@/components/ui/AppConfirmModal.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue"; import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue"; import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import AppInput from "@/components/ui/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
@ -27,6 +28,7 @@ import {
type ScholarProfile, type ScholarProfile,
type ScholarSearchCandidate, type ScholarSearchCandidate,
} from "@/features/scholars"; } from "@/features/scholars";
import { useScholarBulkActions } from "@/features/scholars/composables/useScholarBulkActions";
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue"; import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue"; import ScholarBatchAdd from "@/features/scholars/components/ScholarBatchAdd.vue";
import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue"; import ScholarNameSearch from "@/features/scholars/components/ScholarNameSearch.vue";
@ -271,102 +273,71 @@ async function onToggleScholar(): Promise<void> {
} }
} }
async function onDeleteScholar(): Promise<void> { function onDeleteScholar(): void {
const profile = activeScholarSettings.value; const profile = activeScholarSettings.value;
if (!profile) return; if (!profile) return;
const label = scholarLabel(profile); const label = scholarLabel(profile);
if (!window.confirm(`Delete scholar ${label}? This removes all linked publications and queue data.`)) return; bulk.requestConfirm(
activeScholarId.value = profile.id; `Delete ${label}?`,
clearMessages(); "This removes all linked publications and queue data. This action cannot be undone.",
try { "danger",
await deleteScholar(profile.id); async () => {
successMessage.value = `${label} deleted.`; bulk.dismissConfirm();
activeScholarSettingsId.value = null; activeScholarId.value = profile.id;
await loadScholars(); clearMessages();
} catch (error) { try {
assignError(error, "Unable to delete scholar."); await deleteScholar(profile.id);
} finally { successMessage.value = `${label} deleted.`;
activeScholarId.value = null; activeScholarSettingsId.value = null;
} await loadScholars();
} catch (error) {
assignError(error, "Unable to delete scholar.");
} finally {
activeScholarId.value = null;
}
},
);
} }
async function onSaveImageUrl(): Promise<void> { async function onSaveImageUrl(): Promise<void> {
const profile = activeScholarSettings.value; const p = activeScholarSettings.value;
if (!profile) return; if (!p) return;
const candidate = (imageUrlDraftByScholarId.value[profile.id] || "").trim(); const url = (imageUrlDraftByScholarId.value[p.id] || "").trim();
if (!candidate) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; } if (!url) { errorMessage.value = "Enter an image URL before saving, or use Reset image."; return; }
imageSavingScholarId.value = profile.id; imageSavingScholarId.value = p.id;
clearMessages(); clearMessages();
try { try { await setScholarImageUrl(p.id, url); successMessage.value = `Image URL updated for ${scholarLabel(p)}.`; await loadScholars(); }
await setScholarImageUrl(profile.id, candidate); catch (e) { assignError(e, "Unable to update scholar image URL."); }
successMessage.value = `Image URL updated for ${scholarLabel(profile)}.`; finally { imageSavingScholarId.value = null; }
await loadScholars();
} catch (error) {
assignError(error, "Unable to update scholar image URL.");
} finally {
imageSavingScholarId.value = null;
}
} }
async function onUploadImage(event: Event): Promise<void> { async function onUploadImage(event: Event): Promise<void> {
const profile = activeScholarSettings.value; const p = activeScholarSettings.value;
if (!profile) return; if (!p) return;
const input = event.target as HTMLInputElement | null; const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null; const file = input?.files?.[0] ?? null;
if (!file) return; if (!file) return;
imageUploadingScholarId.value = profile.id; imageUploadingScholarId.value = p.id;
clearMessages(); clearMessages();
try { try { await uploadScholarImage(p.id, file); successMessage.value = `Uploaded image for ${scholarLabel(p)}.`; await loadScholars(); }
await uploadScholarImage(profile.id, file); catch (e) { assignError(e, "Unable to upload scholar image."); }
successMessage.value = `Uploaded image for ${scholarLabel(profile)}.`; finally { imageUploadingScholarId.value = null; if (input) input.value = ""; }
await loadScholars();
} catch (error) {
assignError(error, "Unable to upload scholar image.");
} finally {
imageUploadingScholarId.value = null;
if (input) input.value = "";
}
} }
async function onResetImage(): Promise<void> { async function onResetImage(): Promise<void> {
const profile = activeScholarSettings.value; const p = activeScholarSettings.value;
if (!profile) return; if (!p) return;
imageSavingScholarId.value = profile.id; imageSavingScholarId.value = p.id;
clearMessages(); clearMessages();
try { try { await clearScholarImage(p.id); successMessage.value = `Image reset for ${scholarLabel(p)}.`; await loadScholars(); }
await clearScholarImage(profile.id); catch (e) { assignError(e, "Unable to reset scholar image."); }
successMessage.value = `Image reset for ${scholarLabel(profile)}.`; finally { imageSavingScholarId.value = null; }
await loadScholars();
} catch (error) {
assignError(error, "Unable to reset scholar image.");
} finally {
imageSavingScholarId.value = null;
}
} }
// --- Import/export --- // --- Import/export ---
function suggestExportFilename(exportedAt: string): string { function importSummary(r: DataImportResult): string {
return `scholarr-export-${exportedAt.slice(0, 10) || "unknown-date"}.json`; 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}.`;
}
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}.`
);
} }
async function onExportData(): Promise<void> { async function onExportData(): Promise<void> {
@ -374,7 +345,13 @@ async function onExportData(): Promise<void> {
clearMessages(); clearMessages();
try { try {
const payload = await exportScholarData(); 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."; successMessage.value = "Export complete.";
} catch (error) { } catch (error) {
assignError(error, "Unable to export scholars and publications."); assignError(error, "Unable to export scholars and publications.");
@ -383,10 +360,6 @@ async function onExportData(): Promise<void> {
} }
} }
function onOpenImportPicker(): void {
importFileInput.value?.click();
}
async function onImportFileSelected(event: Event): Promise<void> { async function onImportFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement | null; const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null; const file = input?.files?.[0] ?? null;
@ -396,16 +369,12 @@ async function onImportFileSelected(event: Event): Promise<void> {
try { try {
const raw = await file.text(); const raw = await file.text();
let parsed = JSON.parse(raw); 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; const payload = parsed as DataImportPayload;
if (!payload || !Array.isArray(payload.scholars) || !Array.isArray(payload.publications)) { if (!payload || !Array.isArray(payload.scholars) || !Array.isArray(payload.publications)) {
throw new Error("Invalid import file: expected scholars[] and publications[] arrays."); throw new Error("Invalid import file: expected scholars[] and publications[] arrays.");
} }
const result = await importScholarData(payload); successMessage.value = importSummary(await importScholarData(payload));
successMessage.value = importSummary(result);
await loadScholars(); await loadScholars();
} catch (error) { } catch (error) {
assignError(error, "Unable to import scholars and publications."); assignError(error, "Unable to import scholars and publications.");
@ -415,6 +384,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 --- // --- Lifecycle ---
onMounted(() => { void loadScholars(); }); onMounted(() => { void loadScholars(); });
@ -472,7 +450,7 @@ watch(
<AppButton variant="secondary" :disabled="loading || exportingData" @click="onExportData"> <AppButton variant="secondary" :disabled="loading || exportingData" @click="onExportData">
{{ exportingData ? "Exporting..." : "Export" }} {{ exportingData ? "Exporting..." : "Export" }}
</AppButton> </AppButton>
<AppButton variant="secondary" :disabled="loading || importingData" @click="onOpenImportPicker"> <AppButton variant="secondary" :disabled="loading || importingData" @click="importFileInput?.click()">
{{ importingData ? "Importing..." : "Import" }} {{ importingData ? "Importing..." : "Import" }}
</AppButton> </AppButton>
<AppRefreshButton variant="secondary" :disabled="saving" :loading="loading" title="Refresh scholars" loading-title="Refreshing scholars" @click="loadScholars" /> <AppRefreshButton variant="secondary" :disabled="saving" :loading="loading" title="Refresh scholars" loading-title="Refreshing scholars" @click="loadScholars" />
@ -497,6 +475,34 @@ watch(
</div> </div>
</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"> <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."> <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." /> <AppEmptyState v-if="!hasVisibleScholars" title="No scholars match this filter" body="Clear or adjust the filter to see tracked scholars." />
@ -504,6 +510,13 @@ watch(
<ul class="flex gap-3 overflow-x-auto p-1 lg:hidden"> <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"> <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"> <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" /> <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"> <div class="min-w-0 flex-1 space-y-1">
<p class="truncate text-sm font-semibold text-ink-primary"> <p class="truncate text-sm font-semibold text-ink-primary">
@ -523,12 +536,31 @@ watch(
<AppTable class="h-full overflow-y-scroll overscroll-contain" label="Tracked scholars table"> <AppTable class="h-full overflow-y-scroll overscroll-contain" label="Tracked scholars table">
<thead> <thead>
<tr> <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">Scholar</th>
<th scope="col" class="w-[11rem]">Manage</th> <th scope="col" class="w-[11rem]">Manage</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in visibleScholars" :key="item.id"> <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> <td>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" /> <ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
@ -573,5 +605,21 @@ watch(
@toggle="onToggleScholar" @toggle="onToggleScholar"
@delete="onDeleteScholar" @delete="onDeleteScholar"
/> />
<AppConfirmModal
:open="bulk.confirmState.value.open"
:title="bulk.confirmState.value.title"
:message="bulk.confirmState.value.message"
:variant="bulk.confirmState.value.variant"
confirm-label="Delete"
@confirm="bulk.confirmState.value.onConfirm()"
@cancel="bulk.dismissConfirm()"
/>
</AppPage> </AppPage>
</template> </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>

View file

@ -168,6 +168,11 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num
return parsed; return parsed;
} }
function formatHours(minutes: number): string {
const h = minutes / 60;
return Number.isInteger(h) ? String(h) : h.toFixed(2).replace(/\.?0+$/, "");
}
function parseHoursToMinutes(value: string, minMinutes: number): number { function parseHoursToMinutes(value: string, minMinutes: number): number {
const hours = Number(value); const hours = Number(value);
if (!Number.isFinite(hours) || hours <= 0) { if (!Number.isFinite(hours) || hours <= 0) {
@ -175,7 +180,7 @@ function parseHoursToMinutes(value: string, minMinutes: number): number {
} }
const minutes = Math.round(hours * 60); const minutes = Math.round(hours * 60);
if (minutes < minMinutes) { if (minutes < minMinutes) {
throw new Error(`Check interval must be at least ${minMinutes / 60} hours.`); throw new Error(`Check interval must be at least ${formatHours(minMinutes)} hours.`);
} }
return minutes; return minutes;
} }
@ -372,7 +377,7 @@ onMounted(async () => {
<AppHelpHint text="Minimum is controlled by server policy." /> <AppHelpHint text="Minimum is controlled by server policy." />
</span> </span>
<AppInput v-model="runIntervalHours" inputmode="decimal" /> <AppInput v-model="runIntervalHours" inputmode="decimal" />
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes / 60 }} hours</span> <span class="text-xs text-secondary">Minimum: {{ formatHours(minCheckIntervalMinutes) }} hours</span>
</label> </label>
<label class="grid gap-2 text-sm font-medium text-ink-secondary"> <label class="grid gap-2 text-sm font-medium text-ink-secondary">

View file

@ -0,0 +1,170 @@
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
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_delete_rejects_without_csrf(db_session: AsyncSession) -> None:
await insert_user(db_session, email="csrf-bulk-del@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="csrf-bulk-del@example.com", password="pw123456")
response = client.post(
"/api/v1/scholars/bulk-delete",
json={"scholar_profile_ids": [1]},
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "csrf_invalid"
@pytest.mark.integration
@pytest.mark.db
@pytest.mark.asyncio
async def test_bulk_toggle_rejects_without_csrf(db_session: AsyncSession) -> None:
await insert_user(db_session, email="csrf-bulk-tog@example.com", password="pw123456")
client = TestClient(app)
login_user(client, email="csrf-bulk-tog@example.com", password="pw123456")
response = client.post(
"/api/v1/scholars/bulk-toggle",
json={"scholar_profile_ids": [1], "is_enabled": False},
)
assert response.status_code == 403
assert response.json()["error"]["code"] == "csrf_invalid"