Big changes
This commit is contained in:
parent
4240ad38e2
commit
e3f0d63fec
99 changed files with 8804 additions and 1731 deletions
|
|
@ -2,11 +2,12 @@ from __future__ import annotations
|
|||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routers import admin, auth, publications, runs, scholars, settings
|
||||
from app.api.routers import admin, admin_dbops, auth, publications, runs, scholars, settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(auth.router)
|
||||
router.include_router(admin.router)
|
||||
router.include_router(admin_dbops.router)
|
||||
router.include_router(scholars.router)
|
||||
router.include_router(settings.router)
|
||||
router.include_router(runs.router)
|
||||
|
|
|
|||
337
app/api/routers/admin_dbops.py
Normal file
337
app/api/routers/admin_dbops.py
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
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,
|
||||
"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 _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)
|
||||
|
|
@ -16,6 +16,8 @@ from app.api.schemas import (
|
|||
PublicationsListEnvelope,
|
||||
RetryPublicationPdfEnvelope,
|
||||
RetryPublicationPdfRequest,
|
||||
TogglePublicationFavoriteEnvelope,
|
||||
TogglePublicationFavoriteRequest,
|
||||
)
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
|
|
@ -61,7 +63,12 @@ def _serialize_publication_item(item) -> dict[str, object]:
|
|||
"pub_url": item.pub_url,
|
||||
"doi": item.doi,
|
||||
"pdf_url": item.pdf_url,
|
||||
"pdf_status": item.pdf_status,
|
||||
"pdf_attempt_count": item.pdf_attempt_count,
|
||||
"pdf_failure_reason": item.pdf_failure_reason,
|
||||
"pdf_failure_detail": item.pdf_failure_detail,
|
||||
"is_read": item.is_read,
|
||||
"is_favorite": item.is_favorite,
|
||||
"first_seen_at": item.first_seen_at,
|
||||
"is_new_in_latest_run": item.is_new_in_latest_run,
|
||||
}
|
||||
|
|
@ -72,46 +79,235 @@ async def _publication_counts(
|
|||
*,
|
||||
user_id: int,
|
||||
selected_scholar_id: int | None,
|
||||
) -> tuple[int, int, int]:
|
||||
favorite_only: bool,
|
||||
) -> tuple[int, int, int, int]:
|
||||
unread_count = await publication_service.count_unread_for_user(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
scholar_profile_id=selected_scholar_id,
|
||||
favorite_only=favorite_only,
|
||||
)
|
||||
favorites_count = await publication_service.count_favorite_for_user(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
scholar_profile_id=selected_scholar_id,
|
||||
)
|
||||
latest_count = await publication_service.count_latest_for_user(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
scholar_profile_id=selected_scholar_id,
|
||||
favorite_only=favorite_only,
|
||||
)
|
||||
total_count = await publication_service.count_for_user(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
mode=publication_service.MODE_ALL,
|
||||
scholar_profile_id=selected_scholar_id,
|
||||
favorite_only=favorite_only,
|
||||
)
|
||||
return unread_count, latest_count, total_count
|
||||
return unread_count, favorites_count, latest_count, total_count
|
||||
|
||||
|
||||
async def _list_publications_for_request(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
mode: Literal["all", "unread", "latest", "new"] | None,
|
||||
favorite_only: bool,
|
||||
scholar_profile_id: int | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> tuple[str, int | None, list]:
|
||||
resolved_mode = publication_service.resolve_publication_view_mode(mode)
|
||||
selected_scholar_id = scholar_profile_id
|
||||
await _require_selected_profile(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
selected_scholar_id=selected_scholar_id,
|
||||
)
|
||||
publications = await publication_service.list_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
mode=resolved_mode,
|
||||
scholar_profile_id=selected_scholar_id,
|
||||
favorite_only=favorite_only,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
await publication_service.schedule_missing_pdf_enrichment_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
request_email=current_user.email,
|
||||
items=publications,
|
||||
max_items=settings.unpaywall_max_items_per_request,
|
||||
)
|
||||
hydrated = await publication_service.hydrate_pdf_enrichment_state(
|
||||
db_session,
|
||||
items=publications,
|
||||
)
|
||||
return resolved_mode, selected_scholar_id, hydrated
|
||||
|
||||
|
||||
def _resolve_publications_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 _publications_list_data(
|
||||
*,
|
||||
mode: str,
|
||||
favorite_only: bool,
|
||||
selected_scholar_id: int | None,
|
||||
unread_count: int,
|
||||
favorites_count: int,
|
||||
latest_count: int,
|
||||
total_count: int,
|
||||
publications: list,
|
||||
page: int,
|
||||
page_size: int,
|
||||
offset: int,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"mode": mode,
|
||||
"favorite_only": favorite_only,
|
||||
"selected_scholar_profile_id": selected_scholar_id,
|
||||
"unread_count": unread_count,
|
||||
"favorites_count": favorites_count,
|
||||
"latest_count": latest_count,
|
||||
"new_count": latest_count,
|
||||
"total_count": 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),
|
||||
"publications": [_serialize_publication_item(item) for item in publications],
|
||||
}
|
||||
|
||||
|
||||
def _retry_pdf_message(*, queued: bool, resolved_pdf: bool, pdf_status: str) -> str:
|
||||
if resolved_pdf:
|
||||
return "Open-access PDF link already resolved."
|
||||
if queued:
|
||||
return "PDF lookup queued."
|
||||
if pdf_status in {"queued", "running"}:
|
||||
return "PDF lookup is already queued."
|
||||
return "No open-access PDF link found."
|
||||
|
||||
|
||||
def _favorite_message(*, is_favorite: bool) -> str:
|
||||
if is_favorite:
|
||||
return "Publication marked as favorite."
|
||||
return "Publication removed from favorites."
|
||||
|
||||
|
||||
async def _retry_publication_state(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
scholar_profile_id: int,
|
||||
publication_id: int,
|
||||
):
|
||||
publication = await publication_service.retry_pdf_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
scholar_profile_id=scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
)
|
||||
if publication is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="publication_not_found",
|
||||
message="Publication not found.",
|
||||
)
|
||||
queued = False
|
||||
if not publication.pdf_url:
|
||||
queued = await publication_service.schedule_retry_pdf_enrichment_for_row(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
request_email=current_user.email,
|
||||
item=publication,
|
||||
)
|
||||
hydrated = await publication_service.hydrate_pdf_enrichment_state(
|
||||
db_session,
|
||||
items=[publication],
|
||||
)
|
||||
current = hydrated[0] if hydrated else publication
|
||||
return current, queued
|
||||
|
||||
|
||||
async def _favorite_publication_state(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
scholar_profile_id: int,
|
||||
publication_id: int,
|
||||
is_favorite: bool,
|
||||
):
|
||||
updated_count = await publication_service.set_publication_favorite_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
scholar_profile_id=scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
is_favorite=is_favorite,
|
||||
)
|
||||
if updated_count <= 0:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="publication_not_found",
|
||||
message="Publication not found.",
|
||||
)
|
||||
publication = await publication_service.get_publication_item_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
scholar_profile_id=scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
)
|
||||
if publication is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="publication_not_found",
|
||||
message="Publication not found.",
|
||||
)
|
||||
hydrated = await publication_service.hydrate_pdf_enrichment_state(
|
||||
db_session,
|
||||
items=[publication],
|
||||
)
|
||||
return hydrated[0] if hydrated else publication
|
||||
|
||||
|
||||
def _log_retry_pdf_result(
|
||||
*,
|
||||
current_user: User,
|
||||
scholar_profile_id: int,
|
||||
publication_id: int,
|
||||
queued: bool,
|
||||
resolved_pdf: bool,
|
||||
pdf_status: str,
|
||||
doi: str | None,
|
||||
) -> None:
|
||||
logger.info(
|
||||
"api.publications.retry_pdf",
|
||||
extra={
|
||||
"event": "api.publications.retry_pdf",
|
||||
"user_id": current_user.id,
|
||||
"scholar_profile_id": scholar_profile_id,
|
||||
"publication_id": publication_id,
|
||||
"queued": queued,
|
||||
"resolved_pdf": resolved_pdf,
|
||||
"pdf_status": pdf_status,
|
||||
"has_doi": bool(doi),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PublicationsListEnvelope,
|
||||
|
|
@ -119,44 +315,48 @@ def _publications_list_data(
|
|||
async def list_publications(
|
||||
request: Request,
|
||||
mode: Literal["all", "unread", "latest", "new"] | None = Query(default=None),
|
||||
favorite_only: bool = Query(default=False),
|
||||
scholar_profile_id: int | None = Query(default=None, ge=1),
|
||||
limit: int = Query(default=300, ge=1, le=1000),
|
||||
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=1000),
|
||||
offset: int | None = Query(default=None, ge=0),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
resolved_mode = publication_service.resolve_publication_view_mode(mode)
|
||||
selected_scholar_id = scholar_profile_id
|
||||
await _require_selected_profile(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
selected_scholar_id=selected_scholar_id,
|
||||
)
|
||||
|
||||
publications = await publication_service.list_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
mode=resolved_mode,
|
||||
scholar_profile_id=selected_scholar_id,
|
||||
resolved_page, resolved_limit, resolved_offset = _resolve_publications_paging(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
await publication_service.schedule_missing_pdf_enrichment_for_user(
|
||||
user_id=current_user.id,
|
||||
request_email=current_user.email,
|
||||
items=publications,
|
||||
max_items=settings.unpaywall_max_items_per_request,
|
||||
resolved_mode, selected_scholar_id, publications = await _list_publications_for_request(
|
||||
db_session,
|
||||
current_user=current_user,
|
||||
mode=mode,
|
||||
favorite_only=favorite_only,
|
||||
scholar_profile_id=scholar_profile_id,
|
||||
limit=resolved_limit,
|
||||
offset=resolved_offset,
|
||||
)
|
||||
unread_count, latest_count, total_count = await _publication_counts(
|
||||
unread_count, favorites_count, latest_count, total_count = await _publication_counts(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
selected_scholar_id=selected_scholar_id,
|
||||
favorite_only=favorite_only,
|
||||
)
|
||||
data = _publications_list_data(
|
||||
mode=resolved_mode,
|
||||
favorite_only=favorite_only,
|
||||
selected_scholar_id=selected_scholar_id,
|
||||
unread_count=unread_count,
|
||||
favorites_count=favorites_count,
|
||||
latest_count=latest_count,
|
||||
total_count=total_count,
|
||||
publications=publications,
|
||||
page=resolved_page,
|
||||
page_size=resolved_limit,
|
||||
offset=resolved_offset,
|
||||
)
|
||||
return success_payload(request, data=data)
|
||||
|
||||
|
|
@ -242,37 +442,70 @@ async def retry_publication_pdf(
|
|||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
publication = await publication_service.retry_pdf_for_user(
|
||||
current, queued = await _retry_publication_state(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
current_user=current_user,
|
||||
scholar_profile_id=payload.scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
unpaywall_email=current_user.email,
|
||||
)
|
||||
if publication is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="publication_not_found",
|
||||
message="Publication not found.",
|
||||
)
|
||||
resolved_pdf = bool(publication.pdf_url)
|
||||
logger.info(
|
||||
"api.publications.retry_pdf",
|
||||
extra={
|
||||
"event": "api.publications.retry_pdf",
|
||||
"user_id": current_user.id,
|
||||
"scholar_profile_id": payload.scholar_profile_id,
|
||||
"publication_id": publication_id,
|
||||
"resolved_pdf": resolved_pdf,
|
||||
"has_doi": bool(publication.doi),
|
||||
},
|
||||
resolved_pdf = bool(current.pdf_url)
|
||||
message = _retry_pdf_message(
|
||||
queued=queued,
|
||||
resolved_pdf=resolved_pdf,
|
||||
pdf_status=current.pdf_status,
|
||||
)
|
||||
_log_retry_pdf_result(
|
||||
current_user=current_user,
|
||||
scholar_profile_id=payload.scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
queued=queued,
|
||||
resolved_pdf=resolved_pdf,
|
||||
pdf_status=current.pdf_status,
|
||||
doi=current.doi,
|
||||
)
|
||||
message = "Open-access PDF link resolved." if resolved_pdf else "No open-access PDF link found."
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"message": message,
|
||||
"queued": queued,
|
||||
"resolved_pdf": resolved_pdf,
|
||||
"publication": _serialize_publication_item(publication),
|
||||
"publication": _serialize_publication_item(current),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{publication_id}/favorite",
|
||||
response_model=TogglePublicationFavoriteEnvelope,
|
||||
)
|
||||
async def toggle_publication_favorite(
|
||||
payload: TogglePublicationFavoriteRequest,
|
||||
request: Request,
|
||||
publication_id: int = Path(ge=1),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
current = await _favorite_publication_state(
|
||||
db_session,
|
||||
current_user=current_user,
|
||||
scholar_profile_id=payload.scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
is_favorite=payload.is_favorite,
|
||||
)
|
||||
logger.info(
|
||||
"api.publications.favorite",
|
||||
extra={
|
||||
"event": "api.publications.favorite",
|
||||
"user_id": current_user.id,
|
||||
"scholar_profile_id": payload.scholar_profile_id,
|
||||
"publication_id": publication_id,
|
||||
"is_favorite": bool(payload.is_favorite),
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"message": _favorite_message(is_favorite=bool(payload.is_favorite)),
|
||||
"publication": _serialize_publication_item(current),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class ApiMeta(BaseModel):
|
||||
|
|
@ -525,6 +525,175 @@ class AdminResetPasswordRequest(BaseModel):
|
|||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminDbIntegrityCheckData(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
severity: str
|
||||
message: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminDbIntegrityData(BaseModel):
|
||||
status: str
|
||||
checked_at: datetime
|
||||
failures: list[str] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
checks: list[AdminDbIntegrityCheckData] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminDbIntegrityEnvelope(BaseModel):
|
||||
data: AdminDbIntegrityData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminDbRepairJobData(BaseModel):
|
||||
id: int
|
||||
job_name: str
|
||||
requested_by: str | None
|
||||
dry_run: bool
|
||||
status: str
|
||||
scope: dict[str, Any] = Field(default_factory=dict)
|
||||
summary: dict[str, Any] = Field(default_factory=dict)
|
||||
error_text: str | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminDbRepairJobsData(BaseModel):
|
||||
jobs: list[AdminDbRepairJobData] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminDbRepairJobsEnvelope(BaseModel):
|
||||
data: AdminDbRepairJobsData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueItemData(BaseModel):
|
||||
publication_id: int
|
||||
title: str
|
||||
doi: str | None
|
||||
pdf_url: str | None
|
||||
status: str
|
||||
attempt_count: int
|
||||
last_failure_reason: str | None
|
||||
last_failure_detail: str | None
|
||||
last_source: str | None
|
||||
requested_by_user_id: int | None
|
||||
requested_by_email: str | None
|
||||
queued_at: datetime | None
|
||||
last_attempt_at: datetime | None
|
||||
resolved_at: datetime | None
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueData(BaseModel):
|
||||
items: list[AdminPdfQueueItemData] = Field(default_factory=list)
|
||||
total_count: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
has_next: bool = False
|
||||
has_prev: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueEnvelope(BaseModel):
|
||||
data: AdminPdfQueueData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueRequeueData(BaseModel):
|
||||
publication_id: int
|
||||
queued: bool
|
||||
status: str
|
||||
message: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueRequeueEnvelope(BaseModel):
|
||||
data: AdminPdfQueueRequeueData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueBulkEnqueueData(BaseModel):
|
||||
requested_count: int
|
||||
queued_count: int
|
||||
message: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueBulkEnqueueEnvelope(BaseModel):
|
||||
data: AdminPdfQueueBulkEnqueueData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminRepairPublicationLinksRequest(BaseModel):
|
||||
scope_mode: Literal["single_user", "all_users"] = "single_user"
|
||||
user_id: int | None = Field(default=None, ge=1)
|
||||
scholar_profile_ids: list[int] = Field(default_factory=list, max_length=200)
|
||||
dry_run: bool = True
|
||||
gc_orphan_publications: bool = False
|
||||
requested_by: str | None = None
|
||||
confirmation_text: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_scope(self) -> "AdminRepairPublicationLinksRequest":
|
||||
if self.scope_mode == "single_user" and self.user_id is None:
|
||||
raise ValueError("user_id is required when scope_mode=single_user.")
|
||||
if self.scope_mode == "all_users" and self.user_id is not None:
|
||||
raise ValueError("user_id must be omitted when scope_mode=all_users.")
|
||||
if not self.dry_run and self.scope_mode == "all_users":
|
||||
expected = "REPAIR ALL USERS"
|
||||
provided = (self.confirmation_text or "").strip()
|
||||
if provided != expected:
|
||||
raise ValueError(
|
||||
"confirmation_text must equal 'REPAIR ALL USERS' "
|
||||
"when applying a repair to all users."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class AdminRepairPublicationLinksResultData(BaseModel):
|
||||
job_id: int
|
||||
status: str
|
||||
scope: dict[str, Any] = Field(default_factory=dict)
|
||||
summary: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminRepairPublicationLinksEnvelope(BaseModel):
|
||||
data: AdminRepairPublicationLinksResultData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingsPolicyData(BaseModel):
|
||||
min_run_interval_minutes: int
|
||||
min_request_delay_seconds: int
|
||||
|
|
@ -576,7 +745,12 @@ class PublicationItemData(BaseModel):
|
|||
pub_url: str | None
|
||||
doi: str | None
|
||||
pdf_url: str | None
|
||||
pdf_status: str = "untracked"
|
||||
pdf_attempt_count: int = 0
|
||||
pdf_failure_reason: str | None = None
|
||||
pdf_failure_detail: str | None = None
|
||||
is_read: bool
|
||||
is_favorite: bool = False
|
||||
first_seen_at: datetime
|
||||
is_new_in_latest_run: bool
|
||||
|
||||
|
|
@ -585,11 +759,17 @@ class PublicationItemData(BaseModel):
|
|||
|
||||
class PublicationsListData(BaseModel):
|
||||
mode: str
|
||||
favorite_only: bool = False
|
||||
selected_scholar_profile_id: int | None
|
||||
unread_count: int
|
||||
favorites_count: int
|
||||
latest_count: int
|
||||
new_count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_next: bool = False
|
||||
has_prev: bool = False
|
||||
publications: list[PublicationItemData]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
|
@ -652,6 +832,7 @@ class RetryPublicationPdfRequest(BaseModel):
|
|||
|
||||
class RetryPublicationPdfData(BaseModel):
|
||||
message: str
|
||||
queued: bool
|
||||
resolved_pdf: bool
|
||||
publication: PublicationItemData
|
||||
|
||||
|
|
@ -663,3 +844,24 @@ class RetryPublicationPdfEnvelope(BaseModel):
|
|||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class TogglePublicationFavoriteRequest(BaseModel):
|
||||
scholar_profile_id: int = Field(ge=1)
|
||||
is_favorite: bool
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class TogglePublicationFavoriteData(BaseModel):
|
||||
message: str
|
||||
publication: PublicationItemData
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class TogglePublicationFavoriteEnvelope(BaseModel):
|
||||
data: TogglePublicationFavoriteData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue