Compare commits
30 commits
bugfix/bro
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f501ea4f94 | |||
| f8e3098fc3 | |||
| c85fa08b7b | |||
| 39c7eb686c | |||
| 3e933998a0 | |||
| 6f3e0eec39 | |||
| 76c4811405 | |||
| 6742eff773 | |||
| 61ac6f206f | |||
| afbb25d217 | |||
| 813da91608 | |||
| 0d955c31dc | |||
| a72151cfdc | |||
|
|
c6c89879c9 | ||
| 2c591a0ca3 | |||
|
|
5754bffaa0 | ||
| 4620978dce | |||
|
|
cfb1033110 | ||
|
|
8ba3b3d41a | ||
|
|
92ad054b97 | ||
|
|
acf23a7d6c | ||
|
|
4d949e8657 | ||
|
|
e2fe34946c | ||
|
|
76469e2d1e | ||
|
|
9401aa9b69 | ||
|
|
6e17a65b2f | ||
|
|
84faaad5eb | ||
|
|
33f9579169 | ||
|
|
4a66631a57 | ||
|
|
4bdef0cc2a |
30 changed files with 1679 additions and 197 deletions
|
|
@ -5,7 +5,7 @@ import logging
|
||||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_api_admin_user
|
from app.api.deps import get_api_admin_user, get_api_current_user
|
||||||
from app.api.errors import ApiException
|
from app.api.errors import ApiException
|
||||||
from app.api.responses import success_payload
|
from app.api.responses import success_payload
|
||||||
from app.api.schemas import (
|
from app.api.schemas import (
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -185,7 +185,7 @@ async def get_pdf_queue(
|
||||||
offset: int | None = Query(default=None, ge=0),
|
offset: int | None = Query(default=None, ge=0),
|
||||||
status: str | None = Query(default=None),
|
status: str | None = Query(default=None),
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
admin_user: User = Depends(get_api_admin_user),
|
current_user: User = Depends(get_api_current_user),
|
||||||
):
|
):
|
||||||
normalized_status = (status or "").strip().lower() or None
|
normalized_status = (status or "").strip().lower() or None
|
||||||
resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging(
|
resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging(
|
||||||
|
|
@ -204,7 +204,7 @@ async def get_pdf_queue(
|
||||||
logger,
|
logger,
|
||||||
"info",
|
"info",
|
||||||
"api.admin.db.pdf_queue_listed",
|
"api.admin.db.pdf_queue_listed",
|
||||||
admin_user_id=int(admin_user.id),
|
user_id=int(current_user.id),
|
||||||
page=int(resolved_page),
|
page=int(resolved_page),
|
||||||
page_size=int(resolved_limit),
|
page_size=int(resolved_limit),
|
||||||
offset=int(resolved_offset),
|
offset=int(resolved_offset),
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -261,11 +345,18 @@ async def delete_scholar(
|
||||||
code="scholar_not_found",
|
code="scholar_not_found",
|
||||||
message="Scholar not found.",
|
message="Scholar not found.",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
await scholar_service.delete_scholar(
|
await scholar_service.delete_scholar(
|
||||||
db_session,
|
db_session,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
upload_dir=settings.scholar_image_upload_dir,
|
upload_dir=settings.scholar_image_upload_dir,
|
||||||
)
|
)
|
||||||
|
except scholar_service.ScholarServiceError as exc:
|
||||||
|
raise ApiException(
|
||||||
|
status_code=409,
|
||||||
|
code="scholar_delete_failed",
|
||||||
|
message=str(exc),
|
||||||
|
) from exc
|
||||||
structured_log(
|
structured_log(
|
||||||
logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id
|
logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -274,6 +275,7 @@ async def _run_first_pass(
|
||||||
request_delay_seconds: int,
|
request_delay_seconds: int,
|
||||||
queue_delay_seconds: int,
|
queue_delay_seconds: int,
|
||||||
progress: RunProgress,
|
progress: RunProgress,
|
||||||
|
on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None,
|
||||||
) -> dict[int, int]:
|
) -> dict[int, int]:
|
||||||
first_pass_cstarts: dict[int, int] = {}
|
first_pass_cstarts: dict[int, int] = {}
|
||||||
for index, scholar in enumerate(scholars):
|
for index, scholar in enumerate(scholars):
|
||||||
|
|
@ -310,6 +312,8 @@ async def _run_first_pass(
|
||||||
scholars_remaining=len(scholars) - index - 1,
|
scholars_remaining=len(scholars) - index - 1,
|
||||||
)
|
)
|
||||||
return first_pass_cstarts
|
return first_pass_cstarts
|
||||||
|
if on_progress is not None:
|
||||||
|
await on_progress(1, 0)
|
||||||
resume_cstart = outcome.result_entry.get("continuation_cstart")
|
resume_cstart = outcome.result_entry.get("continuation_cstart")
|
||||||
if resume_cstart is not None and int(resume_cstart) > start_cstart:
|
if resume_cstart is not None and int(resume_cstart) > start_cstart:
|
||||||
first_pass_cstarts[int(scholar.id)] = int(resume_cstart)
|
first_pass_cstarts[int(scholar.id)] = int(resume_cstart)
|
||||||
|
|
@ -330,6 +334,7 @@ async def _run_depth_pass(
|
||||||
auto_queue_continuations: bool,
|
auto_queue_continuations: bool,
|
||||||
queue_delay_seconds: int,
|
queue_delay_seconds: int,
|
||||||
progress: RunProgress,
|
progress: RunProgress,
|
||||||
|
on_progress: Callable[[int, int], Coroutine[Any, Any, None]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
for index, scholar in enumerate(scholars):
|
for index, scholar in enumerate(scholars):
|
||||||
resume_cstart = first_pass_cstarts.get(int(scholar.id))
|
resume_cstart = first_pass_cstarts.get(int(scholar.id))
|
||||||
|
|
@ -367,6 +372,8 @@ async def _run_depth_pass(
|
||||||
scholars_remaining=len(scholars) - index - 1,
|
scholars_remaining=len(scholars) - index - 1,
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
if on_progress is not None:
|
||||||
|
await on_progress(0, 1)
|
||||||
|
|
||||||
|
|
||||||
async def run_scholar_iteration(
|
async def run_scholar_iteration(
|
||||||
|
|
@ -387,6 +394,8 @@ async def run_scholar_iteration(
|
||||||
auto_queue_continuations: bool,
|
auto_queue_continuations: bool,
|
||||||
queue_delay_seconds: int,
|
queue_delay_seconds: int,
|
||||||
) -> RunProgress:
|
) -> RunProgress:
|
||||||
|
from app.services.runs.events import run_events
|
||||||
|
|
||||||
progress = RunProgress()
|
progress = RunProgress()
|
||||||
scholar_kwargs: dict[str, Any] = {
|
scholar_kwargs: dict[str, Any] = {
|
||||||
"request_delay_seconds": request_delay_seconds,
|
"request_delay_seconds": request_delay_seconds,
|
||||||
|
|
@ -396,6 +405,22 @@ async def run_scholar_iteration(
|
||||||
"rate_limit_backoff_seconds": rate_limit_backoff_seconds,
|
"rate_limit_backoff_seconds": rate_limit_backoff_seconds,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visited = 0
|
||||||
|
finished = 0
|
||||||
|
total = len(scholars)
|
||||||
|
|
||||||
|
async def _emit(v: int = 0, f: int = 0) -> None:
|
||||||
|
nonlocal visited, finished
|
||||||
|
visited += v
|
||||||
|
finished += f
|
||||||
|
await run_events.publish(
|
||||||
|
run.id,
|
||||||
|
"scholar_progress",
|
||||||
|
{"visited": visited, "finished": finished, "total": total},
|
||||||
|
)
|
||||||
|
|
||||||
|
await _emit()
|
||||||
first_pass_cstarts = await _run_first_pass(
|
first_pass_cstarts = await _run_first_pass(
|
||||||
db_session,
|
db_session,
|
||||||
scholars=scholars,
|
scholars=scholars,
|
||||||
|
|
@ -407,8 +432,12 @@ async def run_scholar_iteration(
|
||||||
request_delay_seconds=request_delay_seconds,
|
request_delay_seconds=request_delay_seconds,
|
||||||
queue_delay_seconds=queue_delay_seconds,
|
queue_delay_seconds=queue_delay_seconds,
|
||||||
progress=progress,
|
progress=progress,
|
||||||
|
on_progress=_emit,
|
||||||
)
|
)
|
||||||
remaining_max = max(max_pages_per_scholar - 1, 0)
|
remaining_max = max(max_pages_per_scholar - 1, 0)
|
||||||
|
scholars_finished_in_first_pass = len(scholars) - len(first_pass_cstarts)
|
||||||
|
if scholars_finished_in_first_pass > 0:
|
||||||
|
await _emit(f=scholars_finished_in_first_pass)
|
||||||
if remaining_max <= 0:
|
if remaining_max <= 0:
|
||||||
return progress
|
return progress
|
||||||
await _run_depth_pass(
|
await _run_depth_pass(
|
||||||
|
|
@ -424,5 +453,6 @@ async def run_scholar_iteration(
|
||||||
auto_queue_continuations=auto_queue_continuations,
|
auto_queue_continuations=auto_queue_continuations,
|
||||||
queue_delay_seconds=queue_delay_seconds,
|
queue_delay_seconds=queue_delay_seconds,
|
||||||
progress=progress,
|
progress=progress,
|
||||||
|
on_progress=_emit,
|
||||||
)
|
)
|
||||||
return progress
|
return progress
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -125,7 +182,11 @@ async def delete_scholar(
|
||||||
_safe_remove_upload(upload_root, profile.profile_image_upload_path)
|
_safe_remove_upload(upload_root, profile.profile_image_upload_path)
|
||||||
|
|
||||||
await db_session.delete(profile)
|
await db_session.delete(profile)
|
||||||
|
try:
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
await db_session.rollback()
|
||||||
|
raise ScholarServiceError("Unable to delete scholar due to a database constraint.") from exc
|
||||||
|
|
||||||
|
|
||||||
async def hydrate_profile_metadata(
|
async def hydrate_profile_metadata(
|
||||||
|
|
|
||||||
61
frontend/src/components/patterns/ScrapeSafetyBadge.test.ts
Normal file
61
frontend/src/components/patterns/ScrapeSafetyBadge.test.ts
Normal 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:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 gap-1">
|
||||||
<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 rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
|
<AppHelpHint :text="tooltipText" />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
28
frontend/src/components/ui/AppConfirmModal.vue
Normal file
28
frontend/src/components/ui/AppConfirmModal.vue
Normal 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>
|
||||||
|
|
@ -2,10 +2,144 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { mount } from "@vue/test-utils";
|
import { mount } from "@vue/test-utils";
|
||||||
import ScholarBatchAdd from "./ScholarBatchAdd.vue";
|
import ScholarBatchAdd from "./ScholarBatchAdd.vue";
|
||||||
|
import {
|
||||||
|
parseScholarIds,
|
||||||
|
parseScholarTokens,
|
||||||
|
extractScholarIdFromUrl,
|
||||||
|
validateTokenAsId,
|
||||||
|
} from "./scholar-batch-parsing";
|
||||||
|
|
||||||
const defaultProps = { saving: false, loading: false };
|
const defaultProps = { saving: false, loading: false };
|
||||||
|
|
||||||
describe("ScholarBatchAdd", () => {
|
describe("parseScholarIds (unit)", () => {
|
||||||
|
it("parses a single bare ID", () => {
|
||||||
|
expect(parseScholarIds("A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses multiple IDs separated by commas", () => {
|
||||||
|
expect(parseScholarIds("A-UbBTPM15wL, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates IDs", () => {
|
||||||
|
expect(parseScholarIds("A-UbBTPM15wL\nA-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts ID from standard Google Scholar URL", () => {
|
||||||
|
expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL")).toEqual(["A-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts ID from URL with trailing slash", () => {
|
||||||
|
expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL/")).toEqual(["A-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts ID from URL with fragment", () => {
|
||||||
|
expect(parseScholarIds("https://scholar.google.com/citations?user=A-UbBTPM15wL#section")).toEqual(["A-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts ID from URL with extra query params", () => {
|
||||||
|
expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&view_op=list_works&sortby=pubdate")).toEqual(["A-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles URL-encoded characters in non-ID parts", () => {
|
||||||
|
expect(parseScholarIds("https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL&label=%E4%B8%AD%E6%96%87")).toEqual(["A-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed IDs (too short)", () => {
|
||||||
|
expect(parseScholarIds("ABC123")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed IDs (too long)", () => {
|
||||||
|
expect(parseScholarIds("ABCDEF1234567")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects IDs with special characters", () => {
|
||||||
|
expect(parseScholarIds("ABCDEF12345!")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects IDs with embedded whitespace", () => {
|
||||||
|
expect(parseScholarIds("ABC DEF12345")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed valid and invalid tokens", () => {
|
||||||
|
expect(parseScholarIds("A-UbBTPM15wL, invalid, B-UbBTPM15wL")).toEqual(["A-UbBTPM15wL", "B-UbBTPM15wL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for empty string", () => {
|
||||||
|
expect(parseScholarIds("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for whitespace only", () => {
|
||||||
|
expect(parseScholarIds(" ")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractScholarIdFromUrl", () => {
|
||||||
|
it("returns null for non-URL strings", () => {
|
||||||
|
expect(extractScholarIdFromUrl("not-a-url")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for URL without user param", () => {
|
||||||
|
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?hl=en")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts from URL with trailing slashes", () => {
|
||||||
|
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL///")).toBe("A-UbBTPM15wL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips fragment before parsing", () => {
|
||||||
|
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=A-UbBTPM15wL#foo")).toBe("A-UbBTPM15wL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when user param is invalid length", () => {
|
||||||
|
expect(extractScholarIdFromUrl("https://scholar.google.com/citations?user=short")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateTokenAsId", () => {
|
||||||
|
it("returns null for valid 12-char ID", () => {
|
||||||
|
expect(validateTokenAsId("ABCDEF123456")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects wrong length", () => {
|
||||||
|
expect(validateTokenAsId("ABC")).toContain("12 characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects invalid characters", () => {
|
||||||
|
expect(validateTokenAsId("ABCDEF12345!")).toContain("invalid characters");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects whitespace", () => {
|
||||||
|
expect(validateTokenAsId("ABC DEF12345")).toContain("whitespace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects empty input", () => {
|
||||||
|
expect(validateTokenAsId("")).toContain("empty");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseScholarTokens (per-token errors)", () => {
|
||||||
|
it("marks invalid tokens with error messages", () => {
|
||||||
|
const tokens = parseScholarTokens("A-UbBTPM15wL, short, B-UbBTPM15wL");
|
||||||
|
expect(tokens).toHaveLength(3);
|
||||||
|
expect(tokens[0].id).toBe("A-UbBTPM15wL");
|
||||||
|
expect(tokens[0].error).toBeNull();
|
||||||
|
expect(tokens[1].id).toBeNull();
|
||||||
|
expect(tokens[1].error).toContain("12 characters");
|
||||||
|
expect(tokens[2].id).toBe("B-UbBTPM15wL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks duplicate tokens", () => {
|
||||||
|
const tokens = parseScholarTokens("A-UbBTPM15wL, A-UbBTPM15wL");
|
||||||
|
expect(tokens[1].error).toBe("duplicate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks bad URL tokens", () => {
|
||||||
|
const tokens = parseScholarTokens("https://scholar.google.com/citations?hl=en");
|
||||||
|
expect(tokens[0].error).toContain("could not extract");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ScholarBatchAdd (component)", () => {
|
||||||
it("renders the heading and input", () => {
|
it("renders the heading and input", () => {
|
||||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||||
expect(wrapper.text()).toContain("Add Scholar Profiles");
|
expect(wrapper.text()).toContain("Add Scholar Profiles");
|
||||||
|
|
@ -73,4 +207,18 @@ describe("ScholarBatchAdd", () => {
|
||||||
const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
|
const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
|
||||||
expect(wrapper.text()).toContain("Adding...");
|
expect(wrapper.text()).toContain("Adding...");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows validation errors for invalid tokens", async () => {
|
||||||
|
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||||
|
await wrapper.find("textarea").setValue("A-UbBTPM15wL, bad!");
|
||||||
|
const errors = wrapper.find("[data-testid='validation-errors']");
|
||||||
|
expect(errors.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows skipped count alongside valid count", async () => {
|
||||||
|
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||||
|
await wrapper.find("textarea").setValue("A-UbBTPM15wL, tooshort");
|
||||||
|
expect(wrapper.text()).toContain("1 valid");
|
||||||
|
expect(wrapper.text()).toContain("1 skipped");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { computed, ref } from "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 AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
|
import { parseScholarTokens } from "./scholar-batch-parsing";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
|
@ -15,38 +16,13 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const scholarBatchInput = ref("");
|
const scholarBatchInput = ref("");
|
||||||
|
|
||||||
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
|
const parsedTokens = computed(() => parseScholarTokens(scholarBatchInput.value));
|
||||||
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
|
const validIds = computed(() => parsedTokens.value.filter((t) => t.id !== null));
|
||||||
|
const invalidTokens = computed(() => parsedTokens.value.filter((t) => t.id === null));
|
||||||
function parseScholarIds(raw: string): string[] {
|
const parsedBatchCount = computed(() => validIds.value.length);
|
||||||
const ordered: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
|
|
||||||
for (const token of tokens) {
|
|
||||||
let candidate: string | null = null;
|
|
||||||
if (SCHOLAR_ID_PATTERN.test(token)) candidate = token;
|
|
||||||
if (!candidate) {
|
|
||||||
const directParamMatch = token.match(URL_USER_PARAM_PATTERN);
|
|
||||||
if (directParamMatch) candidate = directParamMatch[1];
|
|
||||||
}
|
|
||||||
if (!candidate && token.includes("scholar.google")) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(token);
|
|
||||||
const userParam = parsed.searchParams.get("user");
|
|
||||||
if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) candidate = userParam;
|
|
||||||
} catch (_error) { /* Ignore non-URL tokens. */ }
|
|
||||||
}
|
|
||||||
if (!candidate || seen.has(candidate)) continue;
|
|
||||||
seen.add(candidate);
|
|
||||||
ordered.push(candidate);
|
|
||||||
}
|
|
||||||
return ordered;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
|
|
||||||
|
|
||||||
function onSubmit(): void {
|
function onSubmit(): void {
|
||||||
const ids = parseScholarIds(scholarBatchInput.value);
|
const ids = validIds.value.map((t) => t.id!);
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
emit("add-scholars", ids);
|
emit("add-scholars", ids);
|
||||||
scholarBatchInput.value = "";
|
scholarBatchInput.value = "";
|
||||||
|
|
@ -76,11 +52,33 @@ function onSubmit(): void {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div v-if="parsedTokens.length > 0" class="space-y-1">
|
||||||
<p class="text-xs text-secondary">
|
<p class="text-xs text-secondary">
|
||||||
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
|
<strong class="text-ink-primary">{{ parsedBatchCount }}</strong> valid ID{{ parsedBatchCount === 1 ? "" : "s" }}
|
||||||
|
<template v-if="invalidTokens.length > 0">
|
||||||
|
· <span class="text-state-warning-text">{{ invalidTokens.length }} skipped</span>
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<AppButton type="submit" :disabled="saving || loading">
|
<ul v-if="invalidTokens.length > 0" class="space-y-0.5" data-testid="validation-errors">
|
||||||
|
<li
|
||||||
|
v-for="item in invalidTokens.slice(0, 5)"
|
||||||
|
:key="item.index"
|
||||||
|
class="text-xs text-state-warning-text"
|
||||||
|
>
|
||||||
|
#{{ item.index }}: {{ item.error }}
|
||||||
|
<code class="ml-1 break-all text-ink-muted">{{ item.raw.length > 60 ? item.raw.slice(0, 57) + "..." : item.raw }}</code>
|
||||||
|
</li>
|
||||||
|
<li v-if="invalidTokens.length > 5" class="text-xs text-state-warning-text">
|
||||||
|
+{{ invalidTokens.length - 5 }} more skipped
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-xs text-secondary">
|
||||||
|
Parsed IDs: <strong class="text-ink-primary">0</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<AppButton type="submit" :disabled="saving || loading || parsedBatchCount === 0">
|
||||||
{{ saving ? "Adding..." : "Add scholars" }}
|
{{ saving ? "Adding..." : "Add scholars" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,14 @@ function scholarPublicationsRoute(profile: ScholarProfile): { name: string; quer
|
||||||
:image-url="scholar.profile_image_url"
|
:image-url="scholar.profile_image_url"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 space-y-1">
|
<div class="min-w-0 space-y-1">
|
||||||
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(scholar) }}</p>
|
<p class="truncate text-sm font-semibold text-ink-primary">
|
||||||
|
{{ scholarLabel(scholar) }}
|
||||||
|
<span
|
||||||
|
v-if="!scholar.baseline_completed"
|
||||||
|
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
|
||||||
|
title="This scholar hasn't been scraped yet. Publications will appear after the next run."
|
||||||
|
>Pending</span>
|
||||||
|
</p>
|
||||||
<p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p>
|
<p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs">
|
<RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
baselineCompleted: boolean;
|
||||||
|
lastRunStatus: string | null;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-if="!baselineCompleted"
|
||||||
|
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
|
||||||
|
title="This scholar hasn't been scraped yet. Publications will appear after the next run."
|
||||||
|
>Pending</span>
|
||||||
|
<span
|
||||||
|
v-if="lastRunStatus === 'failed'"
|
||||||
|
class="ml-1.5 inline-flex items-center rounded-full border border-state-danger-border bg-state-danger-bg px-1.5 py-0.5 text-[10px] font-medium text-state-danger-text"
|
||||||
|
title="The last scrape run for this scholar failed."
|
||||||
|
>Failed</span>
|
||||||
|
<span
|
||||||
|
v-else-if="lastRunStatus === 'partial'"
|
||||||
|
class="ml-1.5 inline-flex items-center rounded-full border border-state-warning-border bg-state-warning-bg px-1.5 py-0.5 text-[10px] font-medium text-state-warning-text"
|
||||||
|
title="The last scrape run for this scholar completed with partial results."
|
||||||
|
>Partial</span>
|
||||||
|
<span
|
||||||
|
v-else-if="lastRunStatus === 'success'"
|
||||||
|
class="ml-1.5 inline-flex items-center rounded-full border border-state-success-border bg-state-success-bg px-1.5 py-0.5 text-[10px] font-medium text-state-success-text"
|
||||||
|
title="The last scrape run for this scholar completed successfully."
|
||||||
|
>OK</span>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
|
||||||
|
|
||||||
|
export interface ParsedToken {
|
||||||
|
index: number;
|
||||||
|
raw: string;
|
||||||
|
id: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractScholarIdFromUrl(token: string): string | null {
|
||||||
|
const cleaned = token.replace(/\/+$/, "").replace(/#.*$/, "");
|
||||||
|
try {
|
||||||
|
const parsed = new URL(cleaned);
|
||||||
|
const userParam = parsed.searchParams.get("user");
|
||||||
|
if (!userParam) return null;
|
||||||
|
const decoded = decodeURIComponent(userParam).trim();
|
||||||
|
if (SCHOLAR_ID_PATTERN.test(decoded)) return decoded;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTokenAsId(token: string): string | null {
|
||||||
|
const trimmed = token.trim();
|
||||||
|
if (!trimmed) return "empty input";
|
||||||
|
if (/\s/.test(trimmed)) return "contains whitespace";
|
||||||
|
if (trimmed.length !== 12) return `must be 12 characters (got ${trimmed.length})`;
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
return "contains invalid characters (only a-z, A-Z, 0-9, _ and - allowed)";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScholarTokens(raw: string): ParsedToken[] {
|
||||||
|
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
|
||||||
|
const results: ParsedToken[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
const token = tokens[i];
|
||||||
|
|
||||||
|
if (SCHOLAR_ID_PATTERN.test(token)) {
|
||||||
|
if (seen.has(token)) {
|
||||||
|
results.push({ index: i + 1, raw: token, id: null, error: "duplicate" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(token);
|
||||||
|
results.push({ index: i + 1, raw: token, id: token, error: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.includes("scholar.google") || token.startsWith("http")) {
|
||||||
|
const extracted = extractScholarIdFromUrl(token);
|
||||||
|
if (extracted) {
|
||||||
|
if (seen.has(extracted)) {
|
||||||
|
results.push({ index: i + 1, raw: token, id: null, error: "duplicate" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(extracted);
|
||||||
|
results.push({ index: i + 1, raw: token, id: extracted, error: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push({ index: i + 1, raw: token, id: null, error: "could not extract scholar ID from URL" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = validateTokenAsId(token);
|
||||||
|
results.push({ index: i + 1, raw: token, id: null, error: reason ?? "invalid scholar ID" });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScholarIds(raw: string): string[] {
|
||||||
|
return parseScholarTokens(raw).filter((t) => t.id !== null).map((t) => t.id!);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
94
frontend/src/features/settings/SettingsAdminPanel.test.ts
Normal file
94
frontend/src/features/settings/SettingsAdminPanel.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from "vue";
|
import { nextTick, onMounted, watch } from "vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
|
||||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import AdminIntegritySection from "@/features/settings/components/AdminIntegritySection.vue";
|
import AdminIntegritySection from "@/features/settings/components/AdminIntegritySection.vue";
|
||||||
import AdminPdfQueueSection from "@/features/settings/components/AdminPdfQueueSection.vue";
|
import AdminPdfQueueSection from "@/features/settings/components/AdminPdfQueueSection.vue";
|
||||||
|
|
@ -18,7 +18,6 @@ const props = defineProps<{
|
||||||
section: "users" | "integrity" | "repairs" | "pdf";
|
section: "users" | "integrity" | "repairs" | "pdf";
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const loading = ref(true);
|
|
||||||
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError } = useRequestState();
|
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError } = useRequestState();
|
||||||
|
|
||||||
const usersRef = ref<InstanceType<typeof AdminUsersSection> | null>(null);
|
const usersRef = ref<InstanceType<typeof AdminUsersSection> | null>(null);
|
||||||
|
|
@ -26,39 +25,33 @@ 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);
|
||||||
|
|
||||||
async function refreshForSection(): Promise<void> {
|
let loadGeneration = 0;
|
||||||
if (props.section === SECTION_USERS && usersRef.value) {
|
|
||||||
await usersRef.value.load();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.section === SECTION_INTEGRITY && integrityRef.value) {
|
|
||||||
await integrityRef.value.load();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.section === SECTION_REPAIRS) {
|
|
||||||
await usersRef.value?.load();
|
|
||||||
await repairsRef.value?.load();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
|
||||||
await pdfQueueRef.value.load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSection(): Promise<void> {
|
async function loadSection(): Promise<void> {
|
||||||
loading.value = true;
|
const gen = ++loadGeneration;
|
||||||
clearAlerts();
|
clearAlerts();
|
||||||
try {
|
try {
|
||||||
await refreshForSection();
|
if (props.section === SECTION_USERS && usersRef.value) {
|
||||||
|
await usersRef.value.load();
|
||||||
|
} else if (props.section === SECTION_INTEGRITY && integrityRef.value) {
|
||||||
|
await integrityRef.value.load();
|
||||||
|
} else if (props.section === SECTION_REPAIRS) {
|
||||||
|
await usersRef.value?.load();
|
||||||
|
if (gen !== loadGeneration) return;
|
||||||
|
await repairsRef.value?.load();
|
||||||
|
} else if (props.section === SECTION_PDF && pdfQueueRef.value) {
|
||||||
|
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.");
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadSection);
|
onMounted(() => {
|
||||||
watch(() => props.section, loadSection);
|
nextTick(loadSection);
|
||||||
|
});
|
||||||
|
watch(() => props.section, loadSection, { flush: "post" });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -72,11 +65,9 @@ watch(() => props.section, loadSection);
|
||||||
@dismiss-success="successMessage = null"
|
@dismiss-success="successMessage = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AsyncStateGate :loading="loading" :loading-lines="10" :show-empty="false">
|
|
||||||
<AdminUsersSection v-if="props.section === SECTION_USERS || props.section === SECTION_REPAIRS" v-show="props.section === SECTION_USERS" ref="usersRef" />
|
<AdminUsersSection v-if="props.section === SECTION_USERS || props.section === SECTION_REPAIRS" v-show="props.section === SECTION_USERS" ref="usersRef" />
|
||||||
<AdminIntegritySection v-if="props.section === SECTION_INTEGRITY" ref="integrityRef" />
|
<AdminIntegritySection v-if="props.section === SECTION_INTEGRITY" ref="integrityRef" />
|
||||||
<AdminRepairsSection v-if="props.section === SECTION_REPAIRS" ref="repairsRef" :users="usersRef?.users ?? []" />
|
<AdminRepairsSection v-if="props.section === SECTION_REPAIRS" ref="repairsRef" :users="usersRef?.users ?? []" />
|
||||||
<AdminPdfQueueSection v-if="props.section === SECTION_PDF" ref="pdfQueueRef" />
|
<AdminPdfQueueSection v-if="props.section === SECTION_PDF" ref="pdfQueueRef" />
|
||||||
</AsyncStateGate>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
import { mount, flushPromises } from "@vue/test-utils";
|
import { mount, flushPromises } from "@vue/test-utils";
|
||||||
|
import { setActivePinia, createPinia } from "pinia";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
|
import AdminPdfQueueSection from "./AdminPdfQueueSection.vue";
|
||||||
|
|
||||||
vi.mock("@/features/admin_dbops", () => ({
|
vi.mock("@/features/admin_dbops", () => ({
|
||||||
|
|
@ -36,6 +38,9 @@ function buildQueueItem(overrides: Record<string, unknown> = {}) {
|
||||||
|
|
||||||
describe("AdminPdfQueueSection", () => {
|
describe("AdminPdfQueueSection", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
const auth = useAuthStore();
|
||||||
|
auth.$patch({ state: "authenticated", user: { id: 1, email: "admin@test.com", is_admin: true, is_active: true } });
|
||||||
mockedListQueue.mockReset();
|
mockedListQueue.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
import AppBadge from "@/components/ui/AppBadge.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";
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
import { useRequestState } from "@/composables/useRequestState";
|
import { useRequestState } from "@/composables/useRequestState";
|
||||||
|
|
||||||
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
const { errorMessage, errorRequestId, successMessage, clearAlerts, assignError, setSuccess } = useRequestState();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const refreshingPdfQueue = ref(false);
|
const refreshingPdfQueue = ref(false);
|
||||||
const requeueingPublicationId = ref<number | null>(null);
|
const requeueingPublicationId = ref<number | null>(null);
|
||||||
|
|
@ -162,7 +164,7 @@ defineExpose({ load });
|
||||||
<option value="50">50 / page</option>
|
<option value="50">50 / page</option>
|
||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</AppSelect>
|
</AppSelect>
|
||||||
<AppButton variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
|
<AppButton v-if="auth.isAdmin" variant="secondary" class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs" :disabled="requeueingAllPdfs" title="Queue all missing PDFs" @click="onRequeueAllPdfs">
|
||||||
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
|
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
|
<AppRefreshButton variant="secondary" size="sm" :loading="refreshingPdfQueue" title="Refresh PDF queue" loading-title="Refreshing PDF queue" @click="refreshPdfQueue" />
|
||||||
|
|
@ -194,7 +196,7 @@ defineExpose({ load });
|
||||||
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
|
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
|
||||||
<td>{{ formatTimestamp(item.resolved_at) }}</td>
|
<td>{{ formatTimestamp(item.resolved_at) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<AppButton variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
|
<AppButton v-if="auth.isAdmin" variant="ghost" :disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)" @click="onRequeuePdf(item)">
|
||||||
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
|
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,7 @@ watch(
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="auth.isAdmin && runStatus.isLikelyRunning"
|
v-if="runStatus.isLikelyRunning"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
:disabled="!activeRunId || isCancelAnimating"
|
:disabled="!activeRunId || isCancelAnimating"
|
||||||
@click="onCancelRun"
|
@click="onCancelRun"
|
||||||
|
|
@ -420,7 +420,6 @@ watch(
|
||||||
</span>
|
</span>
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="auth.isAdmin"
|
|
||||||
:disabled="isStartBlocked"
|
:disabled="isStartBlocked"
|
||||||
:title="startCheckDisabledReason || undefined"
|
:title="startCheckDisabledReason || undefined"
|
||||||
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
|
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
|
||||||
|
|
@ -486,9 +485,21 @@ watch(
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-secondary">
|
<p class="mt-2 text-sm text-secondary">
|
||||||
Started {{ formatDate(displayedLatestRun.start_dt) }}. Processed
|
Started {{ formatDate(displayedLatestRun.start_dt) }}.
|
||||||
{{ displayedLatestRun.scholar_count }} scholars and discovered
|
<template v-if="runStatus.isLikelyRunning">
|
||||||
|
{{ runStatus.scholarProgress?.visited ?? 0 }} / {{ runStatus.scholarProgress?.total ?? '…' }} visited
|
||||||
|
· {{ runStatus.scholarProgress?.finished ?? 0 }} finished
|
||||||
|
· {{ displayedLatestRun.new_publication_count }} new publications.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Processed {{ displayedLatestRun.scholar_count }} scholars and discovered
|
||||||
{{ displayedLatestRun.new_publication_count }} new publications.
|
{{ displayedLatestRun.new_publication_count }} new publications.
|
||||||
|
<template v-if="displayedLatestRun.failed_count > 0 || displayedLatestRun.partial_count > 0">
|
||||||
|
<span class="text-state-warning-text">
|
||||||
|
{{ displayedLatestRun.failed_count > 0 ? `${displayedLatestRun.failed_count} failed` : "" }}{{ displayedLatestRun.failed_count > 0 && displayedLatestRun.partial_count > 0 ? ", " : "" }}{{ displayedLatestRun.partial_count > 0 ? `${displayedLatestRun.partial_count} partial` : "" }}.
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AppEmptyState
|
<AppEmptyState
|
||||||
|
|
@ -521,6 +532,9 @@ watch(
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-secondary">
|
<span class="text-xs text-secondary">
|
||||||
{{ formatDate(run.start_dt) }} • {{ run.new_publication_count }} new
|
{{ formatDate(run.start_dt) }} • {{ run.new_publication_count }} new
|
||||||
|
<template v-if="run.failed_count > 0">
|
||||||
|
• <span class="text-state-warning-text">{{ run.failed_count }} failed</span>
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -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,10 +28,12 @@ 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";
|
||||||
import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue";
|
import ScholarSettingsModal from "@/features/scholars/components/ScholarSettingsModal.vue";
|
||||||
|
import ScholarStatusBadges from "@/features/scholars/components/ScholarStatusBadges.vue";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
import { useRunStatusStore } from "@/stores/run_status";
|
import { useRunStatusStore } from "@/stores/run_status";
|
||||||
|
|
||||||
|
|
@ -270,11 +273,16 @@ 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(
|
||||||
|
`Delete ${label}?`,
|
||||||
|
"This removes all linked publications and queue data. This action cannot be undone.",
|
||||||
|
"danger",
|
||||||
|
async () => {
|
||||||
|
bulk.dismissConfirm();
|
||||||
activeScholarId.value = profile.id;
|
activeScholarId.value = profile.id;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
|
|
@ -287,85 +295,49 @@ async function onDeleteScholar(): Promise<void> {
|
||||||
} finally {
|
} finally {
|
||||||
activeScholarId.value = null;
|
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> {
|
||||||
|
|
@ -373,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.");
|
||||||
|
|
@ -382,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;
|
||||||
|
|
@ -395,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.");
|
||||||
|
|
@ -414,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(); });
|
||||||
|
|
@ -471,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" />
|
||||||
|
|
@ -496,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." />
|
||||||
|
|
@ -503,9 +510,19 @@ 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">{{ scholarLabel(item) }}</p>
|
<p class="truncate text-sm font-semibold text-ink-primary">
|
||||||
|
{{ scholarLabel(item) }}
|
||||||
|
<ScholarStatusBadges :baseline-completed="item.baseline_completed" :last-run-status="item.last_run_status" />
|
||||||
|
</p>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
|
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
|
||||||
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
|
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
|
||||||
|
|
@ -519,17 +536,39 @@ 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" />
|
||||||
<div class="grid min-w-0 gap-1">
|
<div class="grid min-w-0 gap-1">
|
||||||
<strong class="truncate text-ink-primary">{{ scholarLabel(item) }}</strong>
|
<strong class="truncate text-ink-primary">
|
||||||
|
{{ scholarLabel(item) }}
|
||||||
|
<ScholarStatusBadges :baseline-completed="item.baseline_completed" :last-run-status="item.last_run_status" />
|
||||||
|
</strong>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
|
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">Publications</RouterLink>
|
||||||
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
|
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">Open profile</a>
|
||||||
|
|
@ -566,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>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ const savingScholarHttp = ref(false);
|
||||||
const updatingPassword = ref(false);
|
const updatingPassword = ref(false);
|
||||||
|
|
||||||
const autoRunEnabled = ref(false);
|
const autoRunEnabled = ref(false);
|
||||||
const runIntervalMinutes = ref("60");
|
const runIntervalHours = ref("1");
|
||||||
const requestDelaySeconds = ref("2");
|
const requestDelaySeconds = ref("2");
|
||||||
const navVisiblePages = ref<string[]>([]);
|
const navVisiblePages = ref<string[]>([]);
|
||||||
const openalexApiKey = ref("");
|
const openalexApiKey = ref("");
|
||||||
|
|
@ -77,13 +77,13 @@ const tabItems = computed<AppTabItem[]>(() => {
|
||||||
const tabs: AppTabItem[] = [
|
const tabs: AppTabItem[] = [
|
||||||
{ id: TAB_CHECKING, label: "Checking" },
|
{ id: TAB_CHECKING, label: "Checking" },
|
||||||
{ id: TAB_ACCOUNT, label: "Account" },
|
{ id: TAB_ACCOUNT, label: "Account" },
|
||||||
|
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
|
||||||
];
|
];
|
||||||
if (auth.isAdmin) {
|
if (auth.isAdmin) {
|
||||||
tabs.push(
|
tabs.push(
|
||||||
{ id: TAB_ADMIN_USERS, label: "Users" },
|
{ id: TAB_ADMIN_USERS, label: "Users" },
|
||||||
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
|
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
|
||||||
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
|
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
|
||||||
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
|
|
||||||
{ id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
|
{ id: TAB_ADMIN_INTEGRATIONS, label: "Integrations" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +138,7 @@ function hydrateSettings(settings: UserSettings): void {
|
||||||
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
|
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
|
||||||
|
|
||||||
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
|
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
|
||||||
runIntervalMinutes.value = String(settings.run_interval_minutes);
|
runIntervalHours.value = String(Number(settings.run_interval_minutes) / 60);
|
||||||
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
||||||
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
||||||
|
|
||||||
|
|
@ -168,6 +168,23 @@ 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 {
|
||||||
|
const hours = Number(value);
|
||||||
|
if (!Number.isFinite(hours) || hours <= 0) {
|
||||||
|
throw new Error("Check interval must be a positive number of hours.");
|
||||||
|
}
|
||||||
|
const minutes = Math.round(hours * 60);
|
||||||
|
if (minutes < minMinutes) {
|
||||||
|
throw new Error(`Check interval must be at least ${formatHours(minMinutes)} hours.`);
|
||||||
|
}
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettings(): Promise<void> {
|
async function loadSettings(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
|
|
@ -225,9 +242,8 @@ async function onSaveSettings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const payload: UserSettingsUpdate = {
|
const payload: UserSettingsUpdate = {
|
||||||
auto_run_enabled: autoRunEnabled.value,
|
auto_run_enabled: autoRunEnabled.value,
|
||||||
run_interval_minutes: parseBoundedInteger(
|
run_interval_minutes: parseHoursToMinutes(
|
||||||
runIntervalMinutes.value,
|
runIntervalHours.value,
|
||||||
"Check interval (minutes)",
|
|
||||||
minCheckIntervalMinutes.value,
|
minCheckIntervalMinutes.value,
|
||||||
),
|
),
|
||||||
request_delay_seconds: parseBoundedInteger(
|
request_delay_seconds: parseBoundedInteger(
|
||||||
|
|
@ -357,11 +373,11 @@ onMounted(async () => {
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
Check interval (minutes)
|
Check interval (hours)
|
||||||
<AppHelpHint text="Minimum is controlled by server policy." />
|
<AppHelpHint text="Minimum is controlled by server policy." />
|
||||||
</span>
|
</span>
|
||||||
<AppInput v-model="runIntervalMinutes" inputmode="numeric" />
|
<AppInput v-model="runIntervalHours" inputmode="decimal" />
|
||||||
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }}</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">
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
||||||
lastErrorRequestId: null as string | null,
|
lastErrorRequestId: null as string | null,
|
||||||
lastSyncAt: null as number | null,
|
lastSyncAt: null as number | null,
|
||||||
livePublications: [] as Array<PublicationItem>,
|
livePublications: [] as Array<PublicationItem>,
|
||||||
|
scholarProgress: null as { visited: number; finished: number; total: number } | null,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
isRunActive(state): boolean {
|
isRunActive(state): boolean {
|
||||||
|
|
@ -257,6 +258,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
||||||
}
|
}
|
||||||
activeStreamRunId = targetRunId;
|
activeStreamRunId = targetRunId;
|
||||||
this.livePublications = [];
|
this.livePublications = [];
|
||||||
|
this.scholarProgress = null;
|
||||||
eventSource = new EventSource(`/api/v1/runs/${targetRunId}/stream`);
|
eventSource = new EventSource(`/api/v1/runs/${targetRunId}/stream`);
|
||||||
eventSource.addEventListener("publication_discovered", (e) => {
|
eventSource.addEventListener("publication_discovered", (e) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -312,6 +314,19 @@ export const useRunStatusStore = defineStore("runStatus", {
|
||||||
console.error("Failed to parse SSE event", err);
|
console.error("Failed to parse SSE event", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
eventSource.addEventListener("scholar_progress", (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const visited = typeof data.visited === "number" ? Math.max(0, Math.trunc(data.visited)) : null;
|
||||||
|
const finished = typeof data.finished === "number" ? Math.max(0, Math.trunc(data.finished)) : null;
|
||||||
|
const total = typeof data.total === "number" ? Math.max(1, Math.trunc(data.total)) : null;
|
||||||
|
if (visited !== null && finished !== null && total !== null) {
|
||||||
|
this.scholarProgress = { visited, finished, total };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse SSE event", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
// Reconnecting is handled automatically by EventSource,
|
// Reconnecting is handled automatically by EventSource,
|
||||||
// but if it's permanently closed, we could do something here.
|
// but if it's permanently closed, we could do something here.
|
||||||
|
|
@ -491,6 +506,7 @@ export const useRunStatusStore = defineStore("runStatus", {
|
||||||
this.lastSyncAt = null;
|
this.lastSyncAt = null;
|
||||||
this.safetyState = createDefaultSafetyState();
|
this.safetyState = createDefaultSafetyState();
|
||||||
this.livePublications = [];
|
this.livePublications = [];
|
||||||
|
this.scholarProgress = null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -340,10 +340,11 @@ async def test_api_admin_dbops_forbidden_for_non_admin_and_validates_scope(db_se
|
||||||
assert forbidden_integrity.status_code == 403
|
assert forbidden_integrity.status_code == 403
|
||||||
assert forbidden_integrity.json()["error"]["code"] == "forbidden"
|
assert forbidden_integrity.json()["error"]["code"] == "forbidden"
|
||||||
|
|
||||||
forbidden_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
|
# PDF queue listing is accessible to all authenticated users (not admin-only).
|
||||||
assert forbidden_pdf_queue.status_code == 403
|
allowed_pdf_queue = client.get("/api/v1/admin/db/pdf-queue")
|
||||||
assert forbidden_pdf_queue.json()["error"]["code"] == "forbidden"
|
assert allowed_pdf_queue.status_code == 200
|
||||||
|
|
||||||
|
# But requeue actions still require admin.
|
||||||
forbidden_requeue = client.post(
|
forbidden_requeue = client.post(
|
||||||
"/api/v1/admin/db/pdf-queue/1/requeue",
|
"/api/v1/admin/db/pdf-queue/1/requeue",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,110 @@ async def test_api_scholars_crud_flow(db_session: AsyncSession) -> None:
|
||||||
assert delete_response.json()["data"]["message"] == "Scholar deleted."
|
assert delete_response.json()["data"]["message"] == "Scholar deleted."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_delete_scholar_returns_404_for_nonexistent(db_session: AsyncSession) -> None:
|
||||||
|
await insert_user(
|
||||||
|
db_session,
|
||||||
|
email="api-delete-404@example.com",
|
||||||
|
password="api-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
login_user(client, email="api-delete-404@example.com", password="api-password")
|
||||||
|
headers = api_csrf_headers(client)
|
||||||
|
|
||||||
|
response = client.delete("/api/v1/scholars/999999", headers=headers)
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["error"]["code"] == "scholar_not_found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_delete_scholar_cascades_linked_publications(db_session: AsyncSession) -> None:
|
||||||
|
user_id = await insert_user(
|
||||||
|
db_session,
|
||||||
|
email="api-delete-cascade@example.com",
|
||||||
|
password="api-password",
|
||||||
|
)
|
||||||
|
scholar_result = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||||
|
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"user_id": user_id, "scholar_id": "delCASCADE01", "display_name": "Cascade Test"},
|
||||||
|
)
|
||||||
|
scholar_profile_id = int(scholar_result.scalar_one())
|
||||||
|
pub_result = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO publications (fingerprint_sha256, title_raw, title_normalized, citation_count)
|
||||||
|
VALUES (:fingerprint, :title_raw, :title_normalized, 0)
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"fingerprint": f"{(user_id + 7000):064x}",
|
||||||
|
"title_raw": "Cascade Publication",
|
||||||
|
"title_normalized": "cascadepublication",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
publication_id = int(pub_result.scalar_one())
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scholar_publications (scholar_profile_id, publication_id, is_read)
|
||||||
|
VALUES (:scholar_profile_id, :publication_id, false)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"scholar_profile_id": scholar_profile_id, "publication_id": publication_id},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
login_user(client, email="api-delete-cascade@example.com", password="api-password")
|
||||||
|
headers = api_csrf_headers(client)
|
||||||
|
|
||||||
|
delete_response = client.delete(f"/api/v1/scholars/{scholar_profile_id}", headers=headers)
|
||||||
|
assert delete_response.status_code == 200
|
||||||
|
assert delete_response.json()["data"]["message"] == "Scholar deleted."
|
||||||
|
|
||||||
|
link_result = await db_session.execute(
|
||||||
|
text("SELECT count(*) FROM scholar_publications WHERE scholar_profile_id = :id"),
|
||||||
|
{"id": scholar_profile_id},
|
||||||
|
)
|
||||||
|
assert int(link_result.scalar_one()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_create_scholar_rejects_corrupted_id(db_session: AsyncSession) -> None:
|
||||||
|
await insert_user(
|
||||||
|
db_session,
|
||||||
|
email="api-bad-id@example.com",
|
||||||
|
password="api-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
login_user(client, email="api-bad-id@example.com", password="api-password")
|
||||||
|
headers = api_csrf_headers(client)
|
||||||
|
|
||||||
|
for bad_id in ["short", "ABCDEF12345!", "", " ", "AB CD EF1234"]:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/scholars",
|
||||||
|
json={"scholar_id": bad_id},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400, f"Expected 400 for scholar_id={bad_id!r}"
|
||||||
|
assert response.json()["error"]["code"] == "invalid_scholar"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.db
|
@pytest.mark.db
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
170
tests/integration/test_api_scholars_bulk.py
Normal file
170
tests/integration/test_api_scholars_bulk.py
Normal 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"
|
||||||
|
|
@ -42,6 +42,34 @@ class TestValidateScholarId:
|
||||||
with pytest.raises(ScholarServiceError, match="12"):
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
validate_scholar_id("ABCDEF12345!")
|
validate_scholar_id("ABCDEF12345!")
|
||||||
|
|
||||||
|
def test_rejects_empty_string(self) -> None:
|
||||||
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
|
validate_scholar_id("")
|
||||||
|
|
||||||
|
def test_rejects_whitespace_only(self) -> None:
|
||||||
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
|
validate_scholar_id(" ")
|
||||||
|
|
||||||
|
def test_rejects_embedded_spaces(self) -> None:
|
||||||
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
|
validate_scholar_id("ABCDEF 12345")
|
||||||
|
|
||||||
|
def test_rejects_tab_characters(self) -> None:
|
||||||
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
|
validate_scholar_id("ABCDEF\t12345")
|
||||||
|
|
||||||
|
def test_rejects_url_as_id(self) -> None:
|
||||||
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
|
validate_scholar_id("https://scholar.google.com/citations?user=ABCDEF123456")
|
||||||
|
|
||||||
|
def test_rejects_newline_in_id(self) -> None:
|
||||||
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
|
validate_scholar_id("ABCDEF\n12345")
|
||||||
|
|
||||||
|
def test_rejects_unicode_characters(self) -> None:
|
||||||
|
with pytest.raises(ScholarServiceError, match="12"):
|
||||||
|
validate_scholar_id("ABCDEF12345\u00e9")
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeDisplayName:
|
class TestNormalizeDisplayName:
|
||||||
def test_returns_stripped_name(self) -> None:
|
def test_returns_stripped_name(self) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue