- W1: _parse_ids_param now returns 400 on non-integer input instead of 500 - W2: bulk_delete_scholars wraps commit in IntegrityError guard; router catches ScholarServiceError → 409 - W3: Move `from typing import Any` to top-level import in application.py - W4: Redact requested_by_email in PDF queue response for non-admin users
496 lines
14 KiB
Python
496 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_api_current_user
|
|
from app.api.errors import ApiException
|
|
from app.api.responses import success_payload
|
|
from app.api.routers.scholar_helpers import (
|
|
enqueue_initial_scrape_job_for_scholar,
|
|
hydrate_scholar_metadata_if_needed,
|
|
read_uploaded_image,
|
|
require_user_profile,
|
|
search_kwargs,
|
|
search_response_data,
|
|
serialize_scholar,
|
|
)
|
|
from app.api.runtime_deps import get_scholar_source
|
|
from app.api.schemas import (
|
|
DataExportEnvelope,
|
|
DataImportEnvelope,
|
|
DataImportRequest,
|
|
MessageEnvelope,
|
|
ScholarBulkCountEnvelope,
|
|
ScholarBulkIdsRequest,
|
|
ScholarBulkToggleRequest,
|
|
ScholarCreateRequest,
|
|
ScholarEnvelope,
|
|
ScholarImageUrlUpdateRequest,
|
|
ScholarSearchEnvelope,
|
|
ScholarsListEnvelope,
|
|
)
|
|
from app.db.models import User
|
|
from app.db.session import get_db_session
|
|
from app.logging_utils import structured_log
|
|
from app.services.portability import application as import_export_service
|
|
from app.services.scholar.source import ScholarSource
|
|
from app.services.scholars import application as scholar_service
|
|
from app.settings import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/scholars", tags=["api-scholars"])
|
|
|
|
|
|
def _parse_ids_param(ids: str | None) -> list[int] | None:
|
|
if not ids:
|
|
return None
|
|
parts = [p.strip() for p in ids.split(",") if p.strip()]
|
|
if not parts:
|
|
return None
|
|
try:
|
|
return [int(p) for p in parts]
|
|
except ValueError:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_ids_param",
|
|
message="The 'ids' parameter must be a comma-separated list of integers.",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=ScholarsListEnvelope,
|
|
)
|
|
async def list_scholars(
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
scholars = await scholar_service.list_scholars_for_user(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
)
|
|
return success_payload(
|
|
request,
|
|
data={
|
|
"scholars": [serialize_scholar(profile) for profile in scholars],
|
|
},
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/export",
|
|
response_model=DataExportEnvelope,
|
|
)
|
|
async def export_scholars_and_publications(
|
|
request: Request,
|
|
ids: str | None = Query(None, description="Comma-separated scholar profile IDs to export"),
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
scholar_profile_ids = _parse_ids_param(ids)
|
|
data = await import_export_service.export_user_data(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholar_profile_ids=scholar_profile_ids,
|
|
)
|
|
return success_payload(request, data=data)
|
|
|
|
|
|
@router.post(
|
|
"/bulk-delete",
|
|
response_model=ScholarBulkCountEnvelope,
|
|
)
|
|
async def bulk_delete_scholars(
|
|
payload: ScholarBulkIdsRequest,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
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(
|
|
"/import",
|
|
response_model=DataImportEnvelope,
|
|
)
|
|
async def import_scholars_and_publications(
|
|
payload: DataImportRequest,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
if (
|
|
payload.schema_version is not None
|
|
and int(payload.schema_version) != import_export_service.EXPORT_SCHEMA_VERSION
|
|
):
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_import_schema_version",
|
|
message=(
|
|
f"Import schema version is not supported. Expected {import_export_service.EXPORT_SCHEMA_VERSION}."
|
|
),
|
|
)
|
|
try:
|
|
result = await import_export_service.import_user_data(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholars=[item.model_dump() for item in payload.scholars],
|
|
publications=[item.model_dump() for item in payload.publications],
|
|
)
|
|
except import_export_service.ImportExportError as exc:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_import_payload",
|
|
message=str(exc),
|
|
) from exc
|
|
structured_log(logger, "info", "api.scholars.imported", user_id=current_user.id, **result)
|
|
return success_payload(request, data=result)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=ScholarEnvelope,
|
|
status_code=201,
|
|
)
|
|
async def create_scholar(
|
|
payload: ScholarCreateRequest,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
source: ScholarSource = Depends(get_scholar_source),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
try:
|
|
created = await scholar_service.create_scholar_for_user(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholar_id=payload.scholar_id,
|
|
display_name="",
|
|
profile_image_url=payload.profile_image_url,
|
|
)
|
|
except scholar_service.ScholarServiceError as exc:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_scholar",
|
|
message=str(exc),
|
|
) from exc
|
|
structured_log(logger, "info", "api.scholars.created", user_id=current_user.id, scholar_profile_id=created.id)
|
|
await enqueue_initial_scrape_job_for_scholar(
|
|
db_session,
|
|
profile=created,
|
|
user_id=current_user.id,
|
|
)
|
|
created = await hydrate_scholar_metadata_if_needed(
|
|
db_session,
|
|
profile=created,
|
|
source=source,
|
|
user_id=current_user.id,
|
|
)
|
|
|
|
return success_payload(
|
|
request,
|
|
data=serialize_scholar(created),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/search",
|
|
response_model=ScholarSearchEnvelope,
|
|
)
|
|
async def search_scholars(
|
|
request: Request,
|
|
query: str = Query(..., min_length=2, max_length=120),
|
|
limit: int = Query(10, ge=1, le=25),
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
source: ScholarSource = Depends(get_scholar_source),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
try:
|
|
parsed = await scholar_service.search_author_candidates(
|
|
source=source,
|
|
db_session=db_session,
|
|
query=query,
|
|
limit=limit,
|
|
**search_kwargs(),
|
|
)
|
|
except scholar_service.ScholarServiceError as exc:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_scholar_search",
|
|
message=str(exc),
|
|
) from exc
|
|
|
|
structured_log(
|
|
logger,
|
|
"info",
|
|
"api.scholars.search.completed",
|
|
user_id=current_user.id,
|
|
query=query.strip(),
|
|
candidate_count=len(parsed.candidates),
|
|
state=parsed.state.value,
|
|
)
|
|
return success_payload(
|
|
request,
|
|
data=search_response_data(query, parsed),
|
|
)
|
|
|
|
|
|
@router.patch(
|
|
"/{scholar_profile_id}/toggle",
|
|
response_model=ScholarEnvelope,
|
|
)
|
|
async def toggle_scholar(
|
|
scholar_profile_id: int,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
profile = await scholar_service.get_user_scholar_by_id(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholar_profile_id=scholar_profile_id,
|
|
)
|
|
if profile is None:
|
|
raise ApiException(
|
|
status_code=404,
|
|
code="scholar_not_found",
|
|
message="Scholar not found.",
|
|
)
|
|
updated = await scholar_service.toggle_scholar_enabled(db_session, profile=profile)
|
|
structured_log(
|
|
logger,
|
|
"info",
|
|
"api.scholars.toggled",
|
|
user_id=current_user.id,
|
|
scholar_profile_id=updated.id,
|
|
is_enabled=updated.is_enabled,
|
|
)
|
|
return success_payload(
|
|
request,
|
|
data=serialize_scholar(updated),
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{scholar_profile_id}",
|
|
response_model=MessageEnvelope,
|
|
)
|
|
async def delete_scholar(
|
|
scholar_profile_id: int,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
profile = await scholar_service.get_user_scholar_by_id(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholar_profile_id=scholar_profile_id,
|
|
)
|
|
if profile is None:
|
|
raise ApiException(
|
|
status_code=404,
|
|
code="scholar_not_found",
|
|
message="Scholar not found.",
|
|
)
|
|
try:
|
|
await scholar_service.delete_scholar(
|
|
db_session,
|
|
profile=profile,
|
|
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(
|
|
logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id
|
|
)
|
|
return success_payload(
|
|
request,
|
|
data={"message": "Scholar deleted."},
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/{scholar_profile_id}/image/url",
|
|
response_model=ScholarEnvelope,
|
|
)
|
|
async def update_scholar_image_url(
|
|
scholar_profile_id: int,
|
|
payload: ScholarImageUrlUpdateRequest,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
profile = await scholar_service.get_user_scholar_by_id(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholar_profile_id=scholar_profile_id,
|
|
)
|
|
if profile is None:
|
|
raise ApiException(
|
|
status_code=404,
|
|
code="scholar_not_found",
|
|
message="Scholar not found.",
|
|
)
|
|
|
|
try:
|
|
updated = await scholar_service.set_profile_image_override_url(
|
|
db_session,
|
|
profile=profile,
|
|
image_url=payload.image_url,
|
|
upload_dir=settings.scholar_image_upload_dir,
|
|
)
|
|
except scholar_service.ScholarServiceError as exc:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_scholar_image",
|
|
message=str(exc),
|
|
) from exc
|
|
|
|
structured_log(
|
|
logger, "info", "api.scholars.image_url_updated", user_id=current_user.id, scholar_profile_id=updated.id
|
|
)
|
|
return success_payload(
|
|
request,
|
|
data=serialize_scholar(updated),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{scholar_profile_id}/image/upload",
|
|
response_model=ScholarEnvelope,
|
|
)
|
|
async def upload_scholar_image(
|
|
scholar_profile_id: int,
|
|
request: Request,
|
|
image: UploadFile = File(...),
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
profile = await require_user_profile(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholar_profile_id=scholar_profile_id,
|
|
)
|
|
|
|
image_bytes = await read_uploaded_image(image)
|
|
try:
|
|
updated = await scholar_service.set_profile_image_upload(
|
|
db_session,
|
|
profile=profile,
|
|
content_type=image.content_type,
|
|
image_bytes=image_bytes,
|
|
upload_dir=settings.scholar_image_upload_dir,
|
|
max_upload_bytes=settings.scholar_image_upload_max_bytes,
|
|
)
|
|
except scholar_service.ScholarServiceError as exc:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_scholar_image",
|
|
message=str(exc),
|
|
) from exc
|
|
|
|
image_size = len(image_bytes)
|
|
structured_log(
|
|
logger,
|
|
"info",
|
|
"api.scholars.image_uploaded",
|
|
user_id=current_user.id,
|
|
scholar_profile_id=updated.id,
|
|
content_type=image.content_type,
|
|
size_bytes=image_size,
|
|
)
|
|
return success_payload(
|
|
request,
|
|
data=serialize_scholar(updated),
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{scholar_profile_id}/image",
|
|
response_model=ScholarEnvelope,
|
|
)
|
|
async def clear_scholar_image_customization(
|
|
scholar_profile_id: int,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
profile = await scholar_service.get_user_scholar_by_id(
|
|
db_session,
|
|
user_id=current_user.id,
|
|
scholar_profile_id=scholar_profile_id,
|
|
)
|
|
if profile is None:
|
|
raise ApiException(
|
|
status_code=404,
|
|
code="scholar_not_found",
|
|
message="Scholar not found.",
|
|
)
|
|
|
|
updated = await scholar_service.clear_profile_image_customization(
|
|
db_session,
|
|
profile=profile,
|
|
upload_dir=settings.scholar_image_upload_dir,
|
|
)
|
|
structured_log(logger, "info", "api.scholars.image_cleared", user_id=current_user.id, scholar_profile_id=updated.id)
|
|
return success_payload(
|
|
request,
|
|
data=serialize_scholar(updated),
|
|
)
|