scholarr/app/api/routers/admin_dbops.py
2026-02-22 16:23:35 +01:00

350 lines
11 KiB
Python

from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_api_admin_user
from app.api.errors import ApiException
from app.api.responses import success_payload
from app.api.schemas import (
AdminDbIntegrityEnvelope,
AdminPdfQueueBulkEnqueueEnvelope,
AdminPdfQueueRequeueEnvelope,
AdminPdfQueueEnvelope,
AdminDbRepairJobsEnvelope,
AdminRepairPublicationLinksEnvelope,
AdminRepairPublicationLinksRequest,
)
from app.db.models import DataRepairJob, User
from app.db.session import get_db_session
from app.services.domains.dbops import (
collect_integrity_report,
list_repair_jobs,
run_publication_link_repair,
)
from app.services.domains.publications import application as publication_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/db", tags=["api-admin-dbops"])
def _serialize_repair_job(job: DataRepairJob) -> dict[str, object]:
return {
"id": int(job.id),
"job_name": job.job_name,
"requested_by": job.requested_by,
"dry_run": bool(job.dry_run),
"status": job.status,
"scope": dict(job.scope or {}),
"summary": dict(job.summary or {}),
"error_text": job.error_text,
"started_at": job.started_at,
"finished_at": job.finished_at,
"created_at": job.created_at,
"updated_at": job.updated_at,
}
def _requested_by_value(*, payload: AdminRepairPublicationLinksRequest, admin_user: User) -> str:
from_payload = (payload.requested_by or "").strip()
return from_payload or admin_user.email
def _serialize_pdf_queue_item(item) -> dict[str, object]:
return {
"publication_id": item.publication_id,
"title": item.title,
"doi": item.doi,
"display_identifier": _serialize_display_identifier(item.display_identifier),
"pdf_url": item.pdf_url,
"status": item.status,
"attempt_count": item.attempt_count,
"last_failure_reason": item.last_failure_reason,
"last_failure_detail": item.last_failure_detail,
"last_source": item.last_source,
"requested_by_user_id": item.requested_by_user_id,
"requested_by_email": item.requested_by_email,
"queued_at": item.queued_at,
"last_attempt_at": item.last_attempt_at,
"resolved_at": item.resolved_at,
"updated_at": item.updated_at,
}
def _serialize_display_identifier(value) -> dict[str, object] | None:
if value is None:
return None
return {
"kind": value.kind,
"value": value.value,
"label": value.label,
"url": value.url,
"confidence_score": float(value.confidence_score),
}
def _requeue_response_state(*, queued: bool) -> tuple[str, str]:
if queued:
return "queued", "PDF lookup queued."
return "blocked", "PDF lookup is already queued or currently running."
def _bulk_enqueue_message(*, queued_count: int, requested_count: int) -> str:
if requested_count == 0:
return "No missing-PDF publications were found."
if queued_count == 0:
return "No publications were queued; all candidates were already in-flight."
return f"Queued {queued_count} publication(s) for PDF lookup."
def _resolve_pdf_queue_paging(
*,
page: int,
page_size: int,
limit: int | None,
offset: int | None,
) -> tuple[int, int, int]:
resolved_limit = int(limit) if limit is not None else int(page_size)
resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0)
resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1)
return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0)
def _pdf_queue_page_data(*, total_count: int, page: int, page_size: int, offset: int) -> dict[str, object]:
return {
"total_count": int(total_count),
"page": int(page),
"page_size": int(page_size),
"has_prev": int(offset) > 0,
"has_next": int(offset) + int(page_size) < int(total_count),
}
@router.get(
"/integrity",
response_model=AdminDbIntegrityEnvelope,
)
async def get_integrity_report(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
admin_user: User = Depends(get_api_admin_user),
):
report = await collect_integrity_report(db_session)
logger.info(
"api.admin.db.integrity_checked",
extra={
"event": "api.admin.db.integrity_checked",
"admin_user_id": int(admin_user.id),
"status": report.get("status"),
"failure_count": len(report.get("failures", [])),
"warning_count": len(report.get("warnings", [])),
},
)
return success_payload(request, data=report)
@router.get(
"/repair-jobs",
response_model=AdminDbRepairJobsEnvelope,
)
async def get_repair_jobs(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
db_session: AsyncSession = Depends(get_db_session),
admin_user: User = Depends(get_api_admin_user),
):
jobs = await list_repair_jobs(db_session, limit=limit)
logger.info(
"api.admin.db.repair_jobs_listed",
extra={
"event": "api.admin.db.repair_jobs_listed",
"admin_user_id": int(admin_user.id),
"limit": int(limit),
"job_count": len(jobs),
},
)
return success_payload(
request,
data={"jobs": [_serialize_repair_job(job) for job in jobs]},
)
@router.get(
"/pdf-queue",
response_model=AdminPdfQueueEnvelope,
)
async def get_pdf_queue(
request: Request,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=500),
limit: int | None = Query(default=None, ge=1, le=500),
offset: int | None = Query(default=None, ge=0),
status: str | None = Query(default=None),
db_session: AsyncSession = Depends(get_db_session),
admin_user: User = Depends(get_api_admin_user),
):
normalized_status = (status or "").strip().lower() or None
resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging(
page=page,
page_size=page_size,
limit=limit,
offset=offset,
)
queue_page = await publication_service.list_pdf_queue_page(
db_session,
limit=resolved_limit,
offset=resolved_offset,
status=normalized_status,
)
logger.info(
"api.admin.db.pdf_queue_listed",
extra={
"event": "api.admin.db.pdf_queue_listed",
"admin_user_id": int(admin_user.id),
"page": int(resolved_page),
"page_size": int(resolved_limit),
"offset": int(resolved_offset),
"status": normalized_status,
"item_count": len(queue_page.items),
"total_count": int(queue_page.total_count),
},
)
return success_payload(
request,
data={
"items": [_serialize_pdf_queue_item(item) for item in queue_page.items],
**_pdf_queue_page_data(
total_count=queue_page.total_count,
page=resolved_page,
page_size=resolved_limit,
offset=resolved_offset,
),
},
)
@router.post(
"/pdf-queue/{publication_id}/requeue",
response_model=AdminPdfQueueRequeueEnvelope,
)
async def requeue_pdf_lookup(
request: Request,
publication_id: int = Path(ge=1),
db_session: AsyncSession = Depends(get_db_session),
admin_user: User = Depends(get_api_admin_user),
):
result = await publication_service.enqueue_retry_pdf_job_for_publication_id(
db_session,
user_id=int(admin_user.id),
request_email=admin_user.email,
publication_id=publication_id,
)
if not result.publication_exists:
raise ApiException(
status_code=404,
code="publication_not_found",
message="Publication not found.",
)
status, message = _requeue_response_state(queued=result.queued)
logger.info(
"api.admin.db.pdf_queue_requeue_requested",
extra={
"event": "api.admin.db.pdf_queue_requeue_requested",
"admin_user_id": int(admin_user.id),
"publication_id": int(publication_id),
"queued": bool(result.queued),
},
)
return success_payload(
request,
data={
"publication_id": int(publication_id),
"queued": bool(result.queued),
"status": status,
"message": message,
},
)
@router.post(
"/pdf-queue/requeue-all",
response_model=AdminPdfQueueBulkEnqueueEnvelope,
)
async def requeue_all_missing_pdfs(
request: Request,
limit: int = Query(default=1000, ge=1, le=5000),
db_session: AsyncSession = Depends(get_db_session),
admin_user: User = Depends(get_api_admin_user),
):
result = await publication_service.enqueue_all_missing_pdf_jobs(
db_session,
user_id=int(admin_user.id),
request_email=admin_user.email,
limit=limit,
)
logger.info(
"api.admin.db.pdf_queue_requeue_all",
extra={
"event": "api.admin.db.pdf_queue_requeue_all",
"admin_user_id": int(admin_user.id),
"limit": int(limit),
"requested_count": int(result.requested_count),
"queued_count": int(result.queued_count),
},
)
return success_payload(
request,
data={
"requested_count": int(result.requested_count),
"queued_count": int(result.queued_count),
"message": _bulk_enqueue_message(
queued_count=int(result.queued_count),
requested_count=int(result.requested_count),
),
},
)
@router.post(
"/repairs/publication-links",
response_model=AdminRepairPublicationLinksEnvelope,
)
async def trigger_publication_link_repair(
payload: AdminRepairPublicationLinksRequest,
request: Request,
db_session: AsyncSession = Depends(get_db_session),
admin_user: User = Depends(get_api_admin_user),
):
try:
result = await run_publication_link_repair(
db_session,
scope_mode=payload.scope_mode,
user_id=payload.user_id,
scholar_profile_ids=payload.scholar_profile_ids,
dry_run=bool(payload.dry_run),
gc_orphan_publications=bool(payload.gc_orphan_publications),
requested_by=_requested_by_value(payload=payload, admin_user=admin_user),
)
except ValueError as exc:
raise ApiException(
status_code=400,
code="invalid_repair_scope",
message=str(exc),
) from exc
logger.info(
"api.admin.db.publication_link_repair_triggered",
extra={
"event": "api.admin.db.publication_link_repair_triggered",
"admin_user_id": int(admin_user.id),
"scope_mode": payload.scope_mode,
"target_user_id": int(payload.user_id) if payload.user_id is not None else None,
"dry_run": bool(payload.dry_run),
"gc_orphan_publications": bool(payload.gc_orphan_publications),
"job_id": int(result["job_id"]),
"status": result["status"],
},
)
return success_payload(request, data=result)