temp commit
This commit is contained in:
parent
8760f27b51
commit
0e9e49df16
193 changed files with 23228 additions and 935 deletions
2
build/lib/app/api/__init__.py
Normal file
2
build/lib/app/api/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from __future__ import annotations
|
||||
|
||||
35
build/lib/app/api/deps.py
Normal file
35
build/lib/app/api/deps.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiException
|
||||
from app.auth import runtime as auth_runtime
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
|
||||
|
||||
async def get_api_current_user(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
current_user = await auth_runtime.get_authenticated_user(request, db_session)
|
||||
if current_user is None:
|
||||
raise ApiException(
|
||||
status_code=401,
|
||||
code="auth_required",
|
||||
message="Authentication required.",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_api_admin_user(
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
) -> User:
|
||||
if not current_user.is_admin:
|
||||
raise ApiException(
|
||||
status_code=403,
|
||||
code="forbidden",
|
||||
message="Admin access required.",
|
||||
)
|
||||
return current_user
|
||||
85
build/lib/app/api/errors.py
Normal file
85
build/lib/app/api/errors.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.exception_handlers import (
|
||||
http_exception_handler as fastapi_http_exception_handler,
|
||||
)
|
||||
from fastapi.exception_handlers import (
|
||||
request_validation_exception_handler as fastapi_validation_exception_handler,
|
||||
)
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
from app.api.responses import error_response
|
||||
|
||||
|
||||
def _is_api_path(path: str) -> bool:
|
||||
return path.startswith("/api/")
|
||||
|
||||
|
||||
def _status_code_to_error_code(status_code: int) -> str:
|
||||
mapping = {
|
||||
400: "bad_request",
|
||||
401: "auth_required",
|
||||
403: "forbidden",
|
||||
404: "not_found",
|
||||
409: "conflict",
|
||||
422: "validation_error",
|
||||
429: "rate_limited",
|
||||
500: "internal_error",
|
||||
}
|
||||
return mapping.get(status_code, "error")
|
||||
|
||||
|
||||
class ApiException(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
message: str,
|
||||
details: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.details = details
|
||||
|
||||
|
||||
def register_api_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(ApiException)
|
||||
async def _handle_api_exception(request: Request, exc: ApiException):
|
||||
return error_response(
|
||||
request,
|
||||
status_code=exc.status_code,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details=exc.details,
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _handle_http_exception(request: Request, exc: HTTPException):
|
||||
if not _is_api_path(request.url.path):
|
||||
return await fastapi_http_exception_handler(request, exc)
|
||||
return error_response(
|
||||
request,
|
||||
status_code=exc.status_code,
|
||||
code=_status_code_to_error_code(exc.status_code),
|
||||
message=str(exc.detail) if exc.detail is not None else "Request failed.",
|
||||
details=exc.detail if isinstance(exc.detail, (dict, list)) else None,
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _handle_validation_exception(request: Request, exc: RequestValidationError):
|
||||
if not _is_api_path(request.url.path):
|
||||
return await fastapi_validation_exception_handler(request, exc)
|
||||
return error_response(
|
||||
request,
|
||||
status_code=422,
|
||||
code="validation_error",
|
||||
message="Request validation failed.",
|
||||
details=exc.errors(),
|
||||
)
|
||||
|
||||
63
build/lib/app/api/media.py
Normal file
63
build/lib/app/api/media.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_api_current_user
|
||||
from app.api.errors import ApiException
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
from app.services.domains.scholars import application as scholar_service
|
||||
from app.settings import settings
|
||||
|
||||
router = APIRouter(tags=["media"])
|
||||
|
||||
|
||||
@router.get("/scholar-images/{scholar_profile_id}/upload")
|
||||
async def get_uploaded_scholar_image(
|
||||
scholar_profile_id: int,
|
||||
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.",
|
||||
)
|
||||
if not profile.profile_image_upload_path:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="scholar_image_not_found",
|
||||
message="Scholar image not found.",
|
||||
)
|
||||
|
||||
try:
|
||||
image_path = scholar_service.resolve_upload_file_path(
|
||||
upload_dir=settings.scholar_image_upload_dir,
|
||||
relative_path=profile.profile_image_upload_path,
|
||||
)
|
||||
except scholar_service.ScholarServiceError as exc:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="scholar_image_not_found",
|
||||
message="Scholar image not found.",
|
||||
) from exc
|
||||
|
||||
if not image_path.exists() or not image_path.is_file():
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="scholar_image_not_found",
|
||||
message="Scholar image not found.",
|
||||
)
|
||||
|
||||
media_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream"
|
||||
return FileResponse(path=image_path, media_type=media_type)
|
||||
64
build/lib/app/api/responses.py
Normal file
64
build/lib/app/api/responses.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str | None:
|
||||
request_state = getattr(request, "state", None)
|
||||
if request_state is None:
|
||||
return None
|
||||
return getattr(request_state, "request_id", None)
|
||||
|
||||
|
||||
def success_payload(
|
||||
request: Request,
|
||||
*,
|
||||
data: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"data": data,
|
||||
"meta": {
|
||||
"request_id": _request_id(request),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def success_response(
|
||||
request: Request,
|
||||
*,
|
||||
data: Any,
|
||||
status_code: int = 200,
|
||||
) -> JSONResponse:
|
||||
payload = success_payload(request, data=data)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=jsonable_encoder(payload),
|
||||
)
|
||||
|
||||
|
||||
def error_response(
|
||||
request: Request,
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
message: str,
|
||||
details: Any | None = None,
|
||||
) -> JSONResponse:
|
||||
payload = {
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"details": details,
|
||||
},
|
||||
"meta": {
|
||||
"request_id": _request_id(request),
|
||||
},
|
||||
}
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=jsonable_encoder(payload),
|
||||
)
|
||||
14
build/lib/app/api/router.py
Normal file
14
build/lib/app/api/router.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
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)
|
||||
router.include_router(publications.router)
|
||||
2
build/lib/app/api/routers/__init__.py
Normal file
2
build/lib/app/api/routers/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from __future__ import annotations
|
||||
|
||||
202
build/lib/app/api/routers/admin.py
Normal file
202
build/lib/app/api/routers/admin.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, 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 (
|
||||
AdminResetPasswordRequest,
|
||||
AdminUserActiveUpdateRequest,
|
||||
AdminUserCreateRequest,
|
||||
AdminUserEnvelope,
|
||||
AdminUsersListEnvelope,
|
||||
MessageEnvelope,
|
||||
)
|
||||
from app.auth.deps import get_auth_service
|
||||
from app.auth.service import AuthService
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
from app.services.domains.users import application as user_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/users", tags=["api-admin-users"])
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> dict[str, object]:
|
||||
return {
|
||||
"id": int(user.id),
|
||||
"email": user.email,
|
||||
"is_active": bool(user.is_active),
|
||||
"is_admin": bool(user.is_admin),
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=AdminUsersListEnvelope,
|
||||
)
|
||||
async def list_users(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
admin_user: User = Depends(get_api_admin_user),
|
||||
):
|
||||
users = await user_service.list_users(db_session)
|
||||
logger.info(
|
||||
"api.admin.users_listed",
|
||||
extra={
|
||||
"event": "api.admin.users_listed",
|
||||
"admin_user_id": int(admin_user.id),
|
||||
"user_count": len(users),
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"users": [_serialize_user(user) for user in users],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AdminUserEnvelope,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_user(
|
||||
payload: AdminUserCreateRequest,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
admin_user: User = Depends(get_api_admin_user),
|
||||
):
|
||||
try:
|
||||
validated_email = user_service.validate_email(payload.email)
|
||||
validated_password = user_service.validate_password(payload.password)
|
||||
created_user = await user_service.create_user(
|
||||
db_session,
|
||||
email=validated_email,
|
||||
password_hash=auth_service.hash_password(validated_password),
|
||||
is_admin=bool(payload.is_admin),
|
||||
)
|
||||
except user_service.UserServiceError as exc:
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_user_input",
|
||||
message=str(exc),
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
"api.admin.user_created",
|
||||
extra={
|
||||
"event": "api.admin.user_created",
|
||||
"admin_user_id": int(admin_user.id),
|
||||
"target_user_id": int(created_user.id),
|
||||
"target_is_admin": bool(created_user.is_admin),
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_user(created_user),
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}/active",
|
||||
response_model=AdminUserEnvelope,
|
||||
)
|
||||
async def set_user_active(
|
||||
user_id: int,
|
||||
payload: AdminUserActiveUpdateRequest,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
admin_user: User = Depends(get_api_admin_user),
|
||||
):
|
||||
target_user = await user_service.get_user_by_id(db_session, user_id)
|
||||
if target_user is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="user_not_found",
|
||||
message="User not found.",
|
||||
)
|
||||
if (
|
||||
int(target_user.id) == int(admin_user.id)
|
||||
and bool(target_user.is_active)
|
||||
and not bool(payload.is_active)
|
||||
):
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="cannot_deactivate_self",
|
||||
message="You cannot deactivate your own account.",
|
||||
)
|
||||
updated_user = await user_service.set_user_active(
|
||||
db_session,
|
||||
user=target_user,
|
||||
is_active=bool(payload.is_active),
|
||||
)
|
||||
logger.info(
|
||||
"api.admin.user_active_updated",
|
||||
extra={
|
||||
"event": "api.admin.user_active_updated",
|
||||
"admin_user_id": int(admin_user.id),
|
||||
"target_user_id": int(updated_user.id),
|
||||
"is_active": bool(updated_user.is_active),
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_user(updated_user),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/reset-password",
|
||||
response_model=MessageEnvelope,
|
||||
)
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
payload: AdminResetPasswordRequest,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
admin_user: User = Depends(get_api_admin_user),
|
||||
):
|
||||
target_user = await user_service.get_user_by_id(db_session, user_id)
|
||||
if target_user is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="user_not_found",
|
||||
message="User not found.",
|
||||
)
|
||||
try:
|
||||
validated_password = user_service.validate_password(payload.new_password)
|
||||
except user_service.UserServiceError as exc:
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_password",
|
||||
message=str(exc),
|
||||
) from exc
|
||||
|
||||
await user_service.set_user_password_hash(
|
||||
db_session,
|
||||
user=target_user,
|
||||
password_hash=auth_service.hash_password(validated_password),
|
||||
)
|
||||
logger.info(
|
||||
"api.admin.user_password_reset",
|
||||
extra={
|
||||
"event": "api.admin.user_password_reset",
|
||||
"admin_user_id": int(admin_user.id),
|
||||
"target_user_id": int(target_user.id),
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={"message": f"Password reset: {target_user.email}"},
|
||||
)
|
||||
349
build/lib/app/api/routers/admin_dbops.py
Normal file
349
build/lib/app/api/routers/admin_dbops.py
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
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,
|
||||
"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)
|
||||
237
build/lib/app/api/routers/auth.py
Normal file
237
build/lib/app/api/routers/auth.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import ApiException
|
||||
from app.api.deps import get_api_current_user
|
||||
from app.api.responses import success_payload
|
||||
from app.api.schemas import (
|
||||
AuthMeEnvelope,
|
||||
ChangePasswordRequest,
|
||||
CsrfBootstrapEnvelope,
|
||||
LoginEnvelope,
|
||||
LoginRequest,
|
||||
MessageEnvelope,
|
||||
)
|
||||
from app.auth.deps import get_auth_service, get_login_rate_limiter
|
||||
from app.auth.rate_limit import SlidingWindowRateLimiter
|
||||
from app.auth import runtime as auth_runtime
|
||||
from app.auth.service import AuthService
|
||||
from app.auth.session import set_session_user
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
from app.security.csrf import ensure_csrf_token
|
||||
from app.services.domains.users import application as user_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["api-auth"])
|
||||
|
||||
|
||||
def _login_limiter_key_and_email(request: Request, payload: LoginRequest) -> tuple[str, str]:
|
||||
return auth_runtime.login_rate_limit_key(request, payload.email), payload.email.strip().lower()
|
||||
|
||||
|
||||
def _raise_rate_limited(normalized_email: str, retry_after_seconds: int) -> None:
|
||||
logger.warning(
|
||||
"api.auth.login_rate_limited",
|
||||
extra={
|
||||
"event": "api.auth.login_rate_limited",
|
||||
"email": normalized_email,
|
||||
"retry_after_seconds": retry_after_seconds,
|
||||
},
|
||||
)
|
||||
raise ApiException(
|
||||
status_code=429,
|
||||
code="rate_limited",
|
||||
message="Too many login attempts. Please try again later.",
|
||||
details={"retry_after_seconds": retry_after_seconds},
|
||||
)
|
||||
|
||||
|
||||
def _serialize_user_payload(user: User) -> dict[str, object]:
|
||||
return {
|
||||
"id": int(user.id),
|
||||
"email": user.email,
|
||||
"is_admin": bool(user.is_admin),
|
||||
"is_active": bool(user.is_active),
|
||||
}
|
||||
|
||||
|
||||
def _raise_invalid_credentials(*, normalized_email: str) -> None:
|
||||
logger.info(
|
||||
"api.auth.login_failed",
|
||||
extra={"event": "api.auth.login_failed", "email": normalized_email},
|
||||
)
|
||||
raise ApiException(
|
||||
status_code=401,
|
||||
code="invalid_credentials",
|
||||
message="Invalid email or password.",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=LoginEnvelope,
|
||||
)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter),
|
||||
):
|
||||
limiter_key, normalized_email = _login_limiter_key_and_email(request, payload)
|
||||
decision = rate_limiter.check(limiter_key)
|
||||
if not decision.allowed:
|
||||
_raise_rate_limited(normalized_email, int(decision.retry_after_seconds))
|
||||
|
||||
user = await auth_service.authenticate_user(
|
||||
db_session,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
)
|
||||
if user is None:
|
||||
rate_limiter.record_failure(limiter_key)
|
||||
_raise_invalid_credentials(normalized_email=normalized_email)
|
||||
|
||||
rate_limiter.reset(limiter_key)
|
||||
set_session_user(
|
||||
request,
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
logger.info(
|
||||
"api.auth.login_succeeded",
|
||||
extra={
|
||||
"event": "api.auth.login_succeeded",
|
||||
"user_id": user.id,
|
||||
"is_admin": user.is_admin,
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"authenticated": True,
|
||||
"csrf_token": ensure_csrf_token(request),
|
||||
"user": _serialize_user_payload(user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=AuthMeEnvelope,
|
||||
)
|
||||
async def get_current_session(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"authenticated": True,
|
||||
"csrf_token": ensure_csrf_token(request),
|
||||
"user": _serialize_user_payload(current_user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/csrf",
|
||||
response_model=CsrfBootstrapEnvelope,
|
||||
)
|
||||
async def get_csrf_bootstrap(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
current_user = await auth_runtime.get_authenticated_user(request, db_session)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"csrf_token": ensure_csrf_token(request),
|
||||
"authenticated": current_user is not None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/change-password",
|
||||
response_model=MessageEnvelope,
|
||||
)
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
if not auth_service.verify_password(
|
||||
password_hash=current_user.password_hash,
|
||||
password=payload.current_password,
|
||||
):
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_current_password",
|
||||
message="Current password is incorrect.",
|
||||
)
|
||||
if payload.new_password != payload.confirm_password:
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="password_confirmation_mismatch",
|
||||
message="New password and confirmation do not match.",
|
||||
)
|
||||
try:
|
||||
validated_password = user_service.validate_password(payload.new_password)
|
||||
except user_service.UserServiceError as exc:
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_password",
|
||||
message=str(exc),
|
||||
) from exc
|
||||
|
||||
await user_service.set_user_password_hash(
|
||||
db_session,
|
||||
user=current_user,
|
||||
password_hash=auth_service.hash_password(validated_password),
|
||||
)
|
||||
logger.info(
|
||||
"api.auth.password_changed",
|
||||
extra={
|
||||
"event": "api.auth.password_changed",
|
||||
"user_id": int(current_user.id),
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={"message": "Password updated successfully."},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
response_model=MessageEnvelope,
|
||||
)
|
||||
async def logout(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
current_user = await auth_runtime.get_authenticated_user(request, db_session)
|
||||
auth_runtime.invalidate_session(request)
|
||||
logger.info(
|
||||
"api.auth.logout",
|
||||
extra={
|
||||
"event": "api.auth.logout",
|
||||
"user_id": int(current_user.id) if current_user is not None else None,
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"message": "Logged out.",
|
||||
},
|
||||
)
|
||||
528
build/lib/app/api/routers/publications.py
Normal file
528
build/lib/app/api/routers/publications.py
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
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.schemas import (
|
||||
MarkAllReadEnvelope,
|
||||
MarkSelectedReadEnvelope,
|
||||
MarkSelectedReadRequest,
|
||||
PublicationsListEnvelope,
|
||||
RetryPublicationPdfEnvelope,
|
||||
RetryPublicationPdfRequest,
|
||||
TogglePublicationFavoriteEnvelope,
|
||||
TogglePublicationFavoriteRequest,
|
||||
)
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
from app.services.domains.publication_identifiers import application as identifier_service
|
||||
from app.services.domains.publications import application as publication_service
|
||||
from app.services.domains.scholars import application as scholar_service
|
||||
from app.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/publications", tags=["api-publications"])
|
||||
|
||||
|
||||
async def _require_selected_profile(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
selected_scholar_id: int | None,
|
||||
) -> None:
|
||||
if selected_scholar_id is None:
|
||||
return
|
||||
selected_profile = await scholar_service.get_user_scholar_by_id(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
scholar_profile_id=selected_scholar_id,
|
||||
)
|
||||
if selected_profile is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="scholar_not_found",
|
||||
message="Scholar filter not found.",
|
||||
)
|
||||
|
||||
|
||||
def _serialize_publication_item(item) -> dict[str, object]:
|
||||
return {
|
||||
"publication_id": item.publication_id,
|
||||
"scholar_profile_id": item.scholar_profile_id,
|
||||
"scholar_label": item.scholar_label,
|
||||
"title": item.title,
|
||||
"year": item.year,
|
||||
"citation_count": item.citation_count,
|
||||
"venue_text": item.venue_text,
|
||||
"pub_url": item.pub_url,
|
||||
"display_identifier": _serialize_display_identifier(item.display_identifier),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
async def _publication_counts(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
selected_scholar_id: int | None,
|
||||
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, 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],
|
||||
)
|
||||
current = hydrated[0] if hydrated else publication
|
||||
identifiers = await identifier_service.overlay_publication_items_with_display_identifiers(
|
||||
db_session,
|
||||
items=[current],
|
||||
)
|
||||
return identifiers[0] if identifiers else current
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=PublicationsListEnvelope,
|
||||
)
|
||||
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),
|
||||
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_page, resolved_limit, resolved_offset = _resolve_publications_paging(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
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, 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)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mark-all-read",
|
||||
response_model=MarkAllReadEnvelope,
|
||||
)
|
||||
async def mark_all_publications_read(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
updated_count = await publication_service.mark_all_unread_as_read_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
logger.info(
|
||||
"api.publications.mark_all_read",
|
||||
extra={
|
||||
"event": "api.publications.mark_all_read",
|
||||
"user_id": current_user.id,
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"message": "Marked all unread publications as read.",
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/mark-read",
|
||||
response_model=MarkSelectedReadEnvelope,
|
||||
)
|
||||
async def mark_selected_publications_read(
|
||||
payload: MarkSelectedReadRequest,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
selection_pairs = sorted(
|
||||
{
|
||||
(int(item.scholar_profile_id), int(item.publication_id))
|
||||
for item in payload.selections
|
||||
}
|
||||
)
|
||||
updated_count = await publication_service.mark_selected_as_read_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
selections=selection_pairs,
|
||||
)
|
||||
logger.info(
|
||||
"api.publications.mark_selected_read",
|
||||
extra={
|
||||
"event": "api.publications.mark_selected_read",
|
||||
"user_id": current_user.id,
|
||||
"requested_count": len(selection_pairs),
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"message": "Marked selected publications as read.",
|
||||
"requested_count": len(selection_pairs),
|
||||
"updated_count": updated_count,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{publication_id}/retry-pdf",
|
||||
response_model=RetryPublicationPdfEnvelope,
|
||||
)
|
||||
async def retry_publication_pdf(
|
||||
payload: RetryPublicationPdfRequest,
|
||||
request: Request,
|
||||
publication_id: int = Path(ge=1),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
current, queued = await _retry_publication_state(
|
||||
db_session,
|
||||
current_user=current_user,
|
||||
scholar_profile_id=payload.scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
)
|
||||
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=None,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"message": message,
|
||||
"queued": queued,
|
||||
"resolved_pdf": resolved_pdf,
|
||||
"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),
|
||||
},
|
||||
)
|
||||
845
build/lib/app/api/routers/runs.py
Normal file
845
build/lib/app/api/routers/runs.py
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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 fastapi.responses import StreamingResponse
|
||||
from app.api.schemas import (
|
||||
ManualRunEnvelope,
|
||||
QueueClearEnvelope,
|
||||
QueueItemEnvelope,
|
||||
QueueListEnvelope,
|
||||
RunDetailEnvelope,
|
||||
RunsListEnvelope,
|
||||
)
|
||||
from app.db.models import RunStatus, RunTriggerType, User
|
||||
from app.db.session import get_db_session
|
||||
from app.services.domains.ingestion import application as ingestion_service
|
||||
from app.services.domains.ingestion import safety as run_safety_service
|
||||
from app.services.domains.runs import application as run_service
|
||||
from app.services.domains.runs.events import event_generator
|
||||
from app.services.domains.settings import application as user_settings_service
|
||||
from app.settings import settings
|
||||
from app.api.runtime_deps import get_ingestion_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/runs", tags=["api-runs"])
|
||||
|
||||
IDEMPOTENCY_HEADER = "Idempotency-Key"
|
||||
IDEMPOTENCY_MAX_LENGTH = 128
|
||||
IDEMPOTENCY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{8,128}$")
|
||||
|
||||
|
||||
def _int_value(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _str_value(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text if text else None
|
||||
|
||||
|
||||
def _bool_value(value: Any, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_idempotency_key(raw_value: str | None) -> str | None:
|
||||
if raw_value is None:
|
||||
return None
|
||||
candidate = raw_value.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
if len(candidate) > IDEMPOTENCY_MAX_LENGTH or not IDEMPOTENCY_PATTERN.match(candidate):
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_idempotency_key",
|
||||
message=(
|
||||
"Invalid Idempotency-Key. Use 8-128 characters from: "
|
||||
"A-Z a-z 0-9 . _ : -"
|
||||
),
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
def _serialize_run(run) -> dict[str, Any]:
|
||||
summary = run_service.extract_run_summary(run.error_log)
|
||||
return {
|
||||
"id": int(run.id),
|
||||
"trigger_type": run.trigger_type.value,
|
||||
"status": run.status.value,
|
||||
"start_dt": run.start_dt,
|
||||
"end_dt": run.end_dt,
|
||||
"scholar_count": int(run.scholar_count or 0),
|
||||
"new_publication_count": int(run.new_pub_count or 0),
|
||||
"failed_count": int(summary["failed_count"]),
|
||||
"partial_count": int(summary["partial_count"]),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_queue_item(item) -> dict[str, Any]:
|
||||
return {
|
||||
"id": int(item.id),
|
||||
"scholar_profile_id": int(item.scholar_profile_id),
|
||||
"scholar_label": item.scholar_label,
|
||||
"status": item.status,
|
||||
"reason": item.reason,
|
||||
"dropped_reason": item.dropped_reason,
|
||||
"attempt_count": int(item.attempt_count),
|
||||
"resume_cstart": int(item.resume_cstart),
|
||||
"next_attempt_dt": item.next_attempt_dt,
|
||||
"updated_at": item.updated_at,
|
||||
"last_error": item.last_error,
|
||||
"last_run_id": item.last_run_id,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_attempt_log(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"attempt": _int_value(item.get("attempt"), 0),
|
||||
"cstart": _int_value(item.get("cstart"), 0),
|
||||
"state": _str_value(item.get("state")),
|
||||
"state_reason": _str_value(item.get("state_reason")),
|
||||
"status_code": (
|
||||
_int_value(item.get("status_code"))
|
||||
if item.get("status_code") is not None
|
||||
else None
|
||||
),
|
||||
"fetch_error": _str_value(item.get("fetch_error")),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_page_logs(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
warning_codes = item.get("warning_codes")
|
||||
normalized.append(
|
||||
{
|
||||
"page": _int_value(item.get("page"), 0),
|
||||
"cstart": _int_value(item.get("cstart"), 0),
|
||||
"state": _str_value(item.get("state")) or "unknown",
|
||||
"state_reason": _str_value(item.get("state_reason")),
|
||||
"status_code": (
|
||||
_int_value(item.get("status_code"))
|
||||
if item.get("status_code") is not None
|
||||
else None
|
||||
),
|
||||
"publication_count": _int_value(item.get("publication_count"), 0),
|
||||
"attempt_count": _int_value(item.get("attempt_count"), 0),
|
||||
"has_show_more_button": _bool_value(item.get("has_show_more_button"), False),
|
||||
"articles_range": _str_value(item.get("articles_range")),
|
||||
"warning_codes": [
|
||||
str(code)
|
||||
for code in (warning_codes if isinstance(warning_codes, list) else [])
|
||||
],
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_debug(value: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
marker_counts = value.get("marker_counts_nonzero")
|
||||
warning_codes = value.get("warning_codes")
|
||||
return {
|
||||
"status_code": (
|
||||
_int_value(value.get("status_code"))
|
||||
if value.get("status_code") is not None
|
||||
else None
|
||||
),
|
||||
"final_url": _str_value(value.get("final_url")),
|
||||
"fetch_error": _str_value(value.get("fetch_error")),
|
||||
"body_sha256": _str_value(value.get("body_sha256")),
|
||||
"body_length": (
|
||||
_int_value(value.get("body_length"))
|
||||
if value.get("body_length") is not None
|
||||
else None
|
||||
),
|
||||
"has_show_more_button": (
|
||||
_bool_value(value.get("has_show_more_button"), False)
|
||||
if value.get("has_show_more_button") is not None
|
||||
else None
|
||||
),
|
||||
"articles_range": _str_value(value.get("articles_range")),
|
||||
"state_reason": _str_value(value.get("state_reason")),
|
||||
"warning_codes": [
|
||||
str(code)
|
||||
for code in (warning_codes if isinstance(warning_codes, list) else [])
|
||||
],
|
||||
"marker_counts_nonzero": {
|
||||
str(key): _int_value(count, 0)
|
||||
for key, count in (marker_counts.items() if isinstance(marker_counts, dict) else [])
|
||||
},
|
||||
"page_logs": _normalize_page_logs(value.get("page_logs")),
|
||||
"attempt_log": _normalize_attempt_log(value.get("attempt_log")),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_scholar_result(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {
|
||||
"scholar_profile_id": 0,
|
||||
"scholar_id": "unknown",
|
||||
"state": "unknown",
|
||||
"state_reason": None,
|
||||
"outcome": "failed",
|
||||
"attempt_count": 0,
|
||||
"publication_count": 0,
|
||||
"start_cstart": 0,
|
||||
"continuation_cstart": None,
|
||||
"continuation_enqueued": False,
|
||||
"continuation_cleared": False,
|
||||
"warnings": [],
|
||||
"error": None,
|
||||
"debug": None,
|
||||
}
|
||||
warnings = value.get("warnings")
|
||||
return {
|
||||
"scholar_profile_id": _int_value(value.get("scholar_profile_id"), 0),
|
||||
"scholar_id": _str_value(value.get("scholar_id")) or "unknown",
|
||||
"state": _str_value(value.get("state")) or "unknown",
|
||||
"state_reason": _str_value(value.get("state_reason")),
|
||||
"outcome": _str_value(value.get("outcome")) or "failed",
|
||||
"attempt_count": _int_value(value.get("attempt_count"), 0),
|
||||
"publication_count": _int_value(value.get("publication_count"), 0),
|
||||
"start_cstart": _int_value(value.get("start_cstart"), 0),
|
||||
"continuation_cstart": (
|
||||
_int_value(value.get("continuation_cstart"))
|
||||
if value.get("continuation_cstart") is not None
|
||||
else None
|
||||
),
|
||||
"continuation_enqueued": _bool_value(value.get("continuation_enqueued"), False),
|
||||
"continuation_cleared": _bool_value(value.get("continuation_cleared"), False),
|
||||
"warnings": [str(item) for item in (warnings if isinstance(warnings, list) else [])],
|
||||
"error": _str_value(value.get("error")),
|
||||
"debug": _normalize_debug(value.get("debug")),
|
||||
}
|
||||
|
||||
|
||||
def _manual_run_payload_from_run(
|
||||
run,
|
||||
*,
|
||||
idempotency_key: str | None,
|
||||
reused_existing_run: bool,
|
||||
safety_state: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
summary = run_service.extract_run_summary(run.error_log)
|
||||
return {
|
||||
"run_id": int(run.id),
|
||||
"status": run.status.value,
|
||||
"scholar_count": int(run.scholar_count or 0),
|
||||
"succeeded_count": int(summary["succeeded_count"]),
|
||||
"failed_count": int(summary["failed_count"]),
|
||||
"partial_count": int(summary["partial_count"]),
|
||||
"new_publication_count": int(run.new_pub_count or 0),
|
||||
"reused_existing_run": reused_existing_run,
|
||||
"idempotency_key": idempotency_key,
|
||||
"safety_state": safety_state,
|
||||
}
|
||||
|
||||
|
||||
async def _load_safety_state(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
) -> dict[str, Any]:
|
||||
user_settings = await user_settings_service.get_or_create_settings(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
)
|
||||
previous_safety_state = run_safety_service.get_safety_event_context(user_settings)
|
||||
if run_safety_service.clear_expired_cooldown(user_settings):
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user_settings)
|
||||
logger.info(
|
||||
"api.runs.safety_cooldown_cleared",
|
||||
extra={
|
||||
"event": "api.runs.safety_cooldown_cleared",
|
||||
"user_id": user_id,
|
||||
"reason": previous_safety_state.get("cooldown_reason"),
|
||||
"cooldown_until": previous_safety_state.get("cooldown_until"),
|
||||
"metric_name": "api_runs_safety_cooldown_cleared_total",
|
||||
"metric_value": 1,
|
||||
},
|
||||
)
|
||||
return run_safety_service.get_safety_state_payload(user_settings)
|
||||
|
||||
|
||||
def _raise_manual_runs_disabled(*, user_id: int, safety_state: dict[str, Any]) -> None:
|
||||
logger.warning(
|
||||
"api.runs.manual_blocked_policy",
|
||||
extra={
|
||||
"event": "api.runs.manual_blocked_policy",
|
||||
"user_id": user_id,
|
||||
"policy": {"manual_run_allowed": False},
|
||||
"safety_state": safety_state,
|
||||
"metric_name": "api_runs_manual_blocked_policy_total",
|
||||
"metric_value": 1,
|
||||
},
|
||||
)
|
||||
raise ApiException(
|
||||
status_code=403,
|
||||
code="manual_runs_disabled",
|
||||
message="Manual checks are disabled by server policy.",
|
||||
details={
|
||||
"policy": {"manual_run_allowed": False},
|
||||
"safety_state": safety_state,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _reused_manual_run_payload(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
request: Request,
|
||||
user_id: int,
|
||||
idempotency_key: str | None,
|
||||
safety_state: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
if idempotency_key is None:
|
||||
return None
|
||||
previous_run = await run_service.get_manual_run_by_idempotency_key(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if previous_run is None:
|
||||
return None
|
||||
if previous_run.status == RunStatus.RUNNING:
|
||||
raise ApiException(
|
||||
status_code=409,
|
||||
code="run_in_progress",
|
||||
message="A run with this idempotency key is still in progress.",
|
||||
details={"run_id": int(previous_run.id), "idempotency_key": idempotency_key},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_manual_run_payload_from_run(
|
||||
previous_run,
|
||||
idempotency_key=idempotency_key,
|
||||
reused_existing_run=True,
|
||||
safety_state=safety_state,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _run_ingestion_for_manual(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
ingest_service: ingestion_service.ScholarIngestionService,
|
||||
user_id: int,
|
||||
idempotency_key: str | None,
|
||||
):
|
||||
user_settings = await user_settings_service.get_or_create_settings(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
)
|
||||
return await ingest_service.run_for_user(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
trigger_type=RunTriggerType.MANUAL,
|
||||
request_delay_seconds=user_settings.request_delay_seconds,
|
||||
network_error_retries=settings.ingestion_network_error_retries,
|
||||
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
|
||||
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
|
||||
page_size=settings.ingestion_page_size,
|
||||
auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
|
||||
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
|
||||
idempotency_key=idempotency_key,
|
||||
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
|
||||
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
|
||||
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
|
||||
)
|
||||
|
||||
|
||||
async def _recover_integrity_error(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
request: Request,
|
||||
user_id: int,
|
||||
idempotency_key: str | None,
|
||||
original_exc: IntegrityError,
|
||||
) -> dict[str, Any]:
|
||||
if idempotency_key is None:
|
||||
logger.exception(
|
||||
"api.runs.manual_integrity_error",
|
||||
extra={"event": "api.runs.manual_integrity_error", "user_id": user_id},
|
||||
)
|
||||
raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc
|
||||
existing_run = await run_service.get_manual_run_by_idempotency_key(
|
||||
db_session,
|
||||
user_id=user_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if existing_run is None:
|
||||
logger.exception(
|
||||
"api.runs.manual_integrity_error",
|
||||
extra={"event": "api.runs.manual_integrity_error", "user_id": user_id},
|
||||
)
|
||||
raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from original_exc
|
||||
if existing_run.status == RunStatus.RUNNING:
|
||||
raise ApiException(
|
||||
status_code=409,
|
||||
code="run_in_progress",
|
||||
message="A run with this idempotency key is still in progress.",
|
||||
details={"run_id": int(existing_run.id), "idempotency_key": idempotency_key},
|
||||
) from original_exc
|
||||
return success_payload(
|
||||
request,
|
||||
data=_manual_run_payload_from_run(
|
||||
existing_run,
|
||||
idempotency_key=idempotency_key,
|
||||
reused_existing_run=True,
|
||||
safety_state=await _load_safety_state(db_session, user_id=user_id),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _execute_manual_run(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
request: Request,
|
||||
ingest_service: ingestion_service.ScholarIngestionService,
|
||||
user_id: int,
|
||||
idempotency_key: str | None,
|
||||
):
|
||||
try:
|
||||
return await _run_ingestion_for_manual(
|
||||
db_session,
|
||||
ingest_service=ingest_service,
|
||||
user_id=user_id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except ingestion_service.RunAlreadyInProgressError as exc:
|
||||
await db_session.rollback()
|
||||
raise ApiException(
|
||||
status_code=409,
|
||||
code="run_in_progress",
|
||||
message="A run is already in progress for this account.",
|
||||
) from exc
|
||||
except ingestion_service.RunBlockedBySafetyPolicyError as exc:
|
||||
await db_session.rollback()
|
||||
_raise_manual_blocked_safety(exc=exc, user_id=user_id)
|
||||
except IntegrityError as exc:
|
||||
await db_session.rollback()
|
||||
return await _recover_integrity_error(
|
||||
db_session,
|
||||
request=request,
|
||||
user_id=user_id,
|
||||
idempotency_key=idempotency_key,
|
||||
original_exc=exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
await db_session.rollback()
|
||||
_raise_manual_failed(exc=exc, user_id=user_id)
|
||||
|
||||
|
||||
def _raise_manual_blocked_safety(*, exc, user_id: int) -> None:
|
||||
logger.info(
|
||||
"api.runs.manual_blocked_safety",
|
||||
extra={
|
||||
"event": "api.runs.manual_blocked_safety",
|
||||
"user_id": user_id,
|
||||
"reason": exc.safety_state.get("cooldown_reason"),
|
||||
"cooldown_until": exc.safety_state.get("cooldown_until"),
|
||||
"cooldown_remaining_seconds": exc.safety_state.get("cooldown_remaining_seconds"),
|
||||
"metric_name": "api_runs_manual_blocked_safety_total",
|
||||
"metric_value": 1,
|
||||
},
|
||||
)
|
||||
raise ApiException(
|
||||
status_code=429,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details={"safety_state": exc.safety_state},
|
||||
) from exc
|
||||
|
||||
|
||||
def _raise_manual_failed(*, exc: Exception, user_id: int) -> None:
|
||||
logger.exception(
|
||||
"api.runs.manual_failed",
|
||||
extra={"event": "api.runs.manual_failed", "user_id": user_id},
|
||||
)
|
||||
raise ApiException(status_code=500, code="manual_run_failed", message="Manual run failed.") from exc
|
||||
|
||||
|
||||
def _manual_run_success_payload(
|
||||
*,
|
||||
run_summary,
|
||||
idempotency_key: str | None,
|
||||
safety_state: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": run_summary.crawl_run_id,
|
||||
"status": run_summary.status.value,
|
||||
"scholar_count": run_summary.scholar_count,
|
||||
"succeeded_count": run_summary.succeeded_count,
|
||||
"failed_count": run_summary.failed_count,
|
||||
"partial_count": run_summary.partial_count,
|
||||
"new_publication_count": run_summary.new_publication_count,
|
||||
"reused_existing_run": False,
|
||||
"idempotency_key": idempotency_key,
|
||||
"safety_state": safety_state,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=RunsListEnvelope,
|
||||
)
|
||||
async def list_runs(
|
||||
request: Request,
|
||||
failed_only: bool = Query(default=False),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
runs = await run_service.list_runs_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
limit=limit,
|
||||
failed_only=failed_only,
|
||||
)
|
||||
safety_state = await _load_safety_state(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"runs": [_serialize_run(run) for run in runs],
|
||||
"safety_state": safety_state,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{run_id}",
|
||||
response_model=RunDetailEnvelope,
|
||||
)
|
||||
async def get_run(
|
||||
run_id: int,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
run = await run_service.get_run_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if run is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="run_not_found",
|
||||
message="Run not found.",
|
||||
)
|
||||
error_log = run.error_log if isinstance(run.error_log, dict) else {}
|
||||
scholar_results = error_log.get("scholar_results")
|
||||
if not isinstance(scholar_results, list):
|
||||
scholar_results = []
|
||||
safety_state = await _load_safety_state(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"run": _serialize_run(run),
|
||||
"summary": run_service.extract_run_summary(error_log),
|
||||
"scholar_results": [_normalize_scholar_result(item) for item in scholar_results],
|
||||
"safety_state": safety_state,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{run_id}/cancel",
|
||||
response_model=RunDetailEnvelope,
|
||||
)
|
||||
async def cancel_run(
|
||||
run_id: int,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
run = await run_service.get_run_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if run is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="run_not_found",
|
||||
message="Run not found.",
|
||||
)
|
||||
|
||||
if run.status == RunStatus.RUNNING:
|
||||
run.status = RunStatus.CANCELED
|
||||
await db_session.commit()
|
||||
await db_session.refresh(run)
|
||||
|
||||
error_log = run.error_log if isinstance(run.error_log, dict) else {}
|
||||
scholar_results = error_log.get("scholar_results")
|
||||
if not isinstance(scholar_results, list):
|
||||
scholar_results = []
|
||||
|
||||
safety_state = await _load_safety_state(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"run": _serialize_run(run),
|
||||
"summary": run_service.extract_run_summary(error_log),
|
||||
"scholar_results": [_normalize_scholar_result(item) for item in scholar_results],
|
||||
"safety_state": safety_state,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/manual",
|
||||
response_model=ManualRunEnvelope,
|
||||
)
|
||||
async def run_manual(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service),
|
||||
):
|
||||
safety_state = await _load_safety_state(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
if not settings.ingestion_manual_run_allowed:
|
||||
_raise_manual_runs_disabled(user_id=current_user.id, safety_state=safety_state)
|
||||
|
||||
idempotency_key = _normalize_idempotency_key(request.headers.get(IDEMPOTENCY_HEADER))
|
||||
reused_payload = await _reused_manual_run_payload(
|
||||
db_session,
|
||||
request=request,
|
||||
user_id=current_user.id,
|
||||
idempotency_key=idempotency_key,
|
||||
safety_state=safety_state,
|
||||
)
|
||||
if reused_payload is not None:
|
||||
return reused_payload
|
||||
|
||||
run_summary = await _execute_manual_run(
|
||||
db_session,
|
||||
request=request,
|
||||
ingest_service=ingest_service,
|
||||
user_id=current_user.id,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if isinstance(run_summary, dict):
|
||||
return run_summary
|
||||
|
||||
current_safety_state = await _load_safety_state(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_manual_run_success_payload(
|
||||
run_summary=run_summary,
|
||||
idempotency_key=idempotency_key,
|
||||
safety_state=current_safety_state,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/queue/items",
|
||||
response_model=QueueListEnvelope,
|
||||
)
|
||||
async def list_queue_items(
|
||||
request: Request,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
items = await run_service.list_queue_items_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
limit=limit,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"queue_items": [_serialize_queue_item(item) for item in items],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/queue/{queue_item_id}/retry",
|
||||
response_model=QueueItemEnvelope,
|
||||
)
|
||||
async def retry_queue_item(
|
||||
queue_item_id: int,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
try:
|
||||
queue_item = await run_service.retry_queue_item_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
queue_item_id=queue_item_id,
|
||||
)
|
||||
except run_service.QueueTransitionError as exc:
|
||||
raise ApiException(
|
||||
status_code=409,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details={"current_status": exc.current_status},
|
||||
) from exc
|
||||
if queue_item is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="queue_item_not_found",
|
||||
message="Queue item not found.",
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_queue_item(queue_item),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/queue/{queue_item_id}/drop",
|
||||
response_model=QueueItemEnvelope,
|
||||
)
|
||||
async def drop_queue_item(
|
||||
queue_item_id: int,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
try:
|
||||
dropped = await run_service.drop_queue_item_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
queue_item_id=queue_item_id,
|
||||
)
|
||||
except run_service.QueueTransitionError as exc:
|
||||
raise ApiException(
|
||||
status_code=409,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details={"current_status": exc.current_status},
|
||||
) from exc
|
||||
if dropped is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="queue_item_not_found",
|
||||
message="Queue item not found.",
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_queue_item(dropped),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/queue/{queue_item_id}",
|
||||
response_model=QueueClearEnvelope,
|
||||
)
|
||||
async def clear_queue_item(
|
||||
queue_item_id: int,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
try:
|
||||
deleted = await run_service.clear_queue_item_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
queue_item_id=queue_item_id,
|
||||
)
|
||||
except run_service.QueueTransitionError as exc:
|
||||
raise ApiException(
|
||||
status_code=409,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
details={"current_status": exc.current_status},
|
||||
) from exc
|
||||
if deleted is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="queue_item_not_found",
|
||||
message="Queue item not found.",
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
"queue_item_id": deleted.queue_item_id,
|
||||
"previous_status": deleted.previous_status,
|
||||
"status": "cleared",
|
||||
"message": "Queue item cleared.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}/stream")
|
||||
async def stream_run_events(
|
||||
run_id: int,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
run = await run_service.get_run_for_user(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if run is None:
|
||||
raise ApiException(
|
||||
status_code=404,
|
||||
code="run_not_found",
|
||||
message="Run not found.",
|
||||
)
|
||||
return StreamingResponse(
|
||||
event_generator(run_id),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
557
build/lib/app/api/routers/scholars.py
Normal file
557
build/lib/app/api/routers/scholars.py
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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.runtime_deps import get_scholar_source
|
||||
from app.api.schemas import (
|
||||
DataExportEnvelope,
|
||||
DataImportEnvelope,
|
||||
DataImportRequest,
|
||||
MessageEnvelope,
|
||||
ScholarCreateRequest,
|
||||
ScholarEnvelope,
|
||||
ScholarImageUrlUpdateRequest,
|
||||
ScholarSearchEnvelope,
|
||||
ScholarsListEnvelope,
|
||||
)
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
from app.services.domains.portability import application as import_export_service
|
||||
from app.services.domains.scholars import application as scholar_service
|
||||
from app.services.domains.scholar.source import ScholarSource
|
||||
from app.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/scholars", tags=["api-scholars"])
|
||||
|
||||
|
||||
def _uploaded_image_media_path(scholar_profile_id: int) -> str:
|
||||
return f"/scholar-images/{scholar_profile_id}/upload"
|
||||
|
||||
|
||||
def _serialize_scholar(profile) -> dict[str, object]:
|
||||
uploaded_image_url = None
|
||||
if profile.profile_image_upload_path:
|
||||
uploaded_image_url = _uploaded_image_media_path(int(profile.id))
|
||||
|
||||
profile_image_url, profile_image_source = scholar_service.resolve_profile_image(
|
||||
profile,
|
||||
uploaded_image_url=uploaded_image_url,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": int(profile.id),
|
||||
"scholar_id": profile.scholar_id,
|
||||
"display_name": profile.display_name,
|
||||
"profile_image_url": profile_image_url,
|
||||
"profile_image_source": profile_image_source,
|
||||
"is_enabled": bool(profile.is_enabled),
|
||||
"baseline_completed": bool(profile.baseline_completed),
|
||||
"last_run_dt": profile.last_run_dt,
|
||||
"last_run_status": (
|
||||
profile.last_run_status.value if profile.last_run_status is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _hydrate_scholar_metadata_if_needed(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
profile,
|
||||
source: ScholarSource,
|
||||
user_id: int,
|
||||
):
|
||||
try:
|
||||
if not profile.profile_image_url or not (profile.display_name or "").strip():
|
||||
return await asyncio.wait_for(
|
||||
scholar_service.hydrate_profile_metadata(
|
||||
db_session,
|
||||
profile=profile,
|
||||
source=source,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"api.scholars.create_metadata_hydration_failed",
|
||||
extra={
|
||||
"event": "api.scholars.create_metadata_hydration_failed",
|
||||
"user_id": user_id,
|
||||
"scholar_profile_id": profile.id,
|
||||
},
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def _search_kwargs() -> dict[str, object]:
|
||||
return {
|
||||
"network_error_retries": settings.ingestion_network_error_retries,
|
||||
"retry_backoff_seconds": settings.ingestion_retry_backoff_seconds,
|
||||
"search_enabled": settings.scholar_name_search_enabled,
|
||||
"cache_ttl_seconds": settings.scholar_name_search_cache_ttl_seconds,
|
||||
"blocked_cache_ttl_seconds": settings.scholar_name_search_blocked_cache_ttl_seconds,
|
||||
"cache_max_entries": settings.scholar_name_search_cache_max_entries,
|
||||
"min_interval_seconds": settings.scholar_name_search_min_interval_seconds,
|
||||
"interval_jitter_seconds": settings.scholar_name_search_interval_jitter_seconds,
|
||||
"cooldown_block_threshold": settings.scholar_name_search_cooldown_block_threshold,
|
||||
"cooldown_seconds": settings.scholar_name_search_cooldown_seconds,
|
||||
"retry_alert_threshold": settings.scholar_name_search_alert_retry_count_threshold,
|
||||
"cooldown_rejection_alert_threshold": (
|
||||
settings.scholar_name_search_alert_cooldown_rejections_threshold
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _search_response_data(query: str, parsed) -> dict[str, object]:
|
||||
return {
|
||||
"query": query.strip(),
|
||||
"state": parsed.state.value,
|
||||
"state_reason": parsed.state_reason,
|
||||
"action_hint": scholar_service.scrape_state_hint(
|
||||
state=parsed.state,
|
||||
state_reason=parsed.state_reason,
|
||||
),
|
||||
"candidates": [
|
||||
{
|
||||
"scholar_id": item.scholar_id,
|
||||
"display_name": item.display_name,
|
||||
"affiliation": item.affiliation,
|
||||
"email_domain": item.email_domain,
|
||||
"cited_by_count": item.cited_by_count,
|
||||
"interests": item.interests,
|
||||
"profile_url": item.profile_url,
|
||||
"profile_image_url": item.profile_image_url,
|
||||
}
|
||||
for item in parsed.candidates
|
||||
],
|
||||
"warnings": parsed.warnings,
|
||||
}
|
||||
|
||||
|
||||
async def _read_uploaded_image(image: UploadFile) -> bytes:
|
||||
try:
|
||||
return await image.read()
|
||||
finally:
|
||||
await image.close()
|
||||
|
||||
|
||||
async def _require_user_profile(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
scholar_profile_id: int,
|
||||
):
|
||||
profile = await scholar_service.get_user_scholar_by_id(
|
||||
db_session,
|
||||
user_id=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.",
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
@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,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
data = await import_export_service.export_user_data(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return success_payload(request, data=data)
|
||||
|
||||
|
||||
@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=(
|
||||
"Import schema version is not supported. "
|
||||
f"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
|
||||
logger.info(
|
||||
"api.scholars.imported",
|
||||
extra={
|
||||
"event": "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
|
||||
logger.info(
|
||||
"api.scholars.created",
|
||||
extra={
|
||||
"event": "api.scholars.created",
|
||||
"user_id": current_user.id,
|
||||
"scholar_profile_id": created.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
|
||||
|
||||
logger.info(
|
||||
"api.scholars.search.completed",
|
||||
extra={
|
||||
"event": "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)
|
||||
logger.info(
|
||||
"api.scholars.toggled",
|
||||
extra={
|
||||
"event": "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.",
|
||||
)
|
||||
await scholar_service.delete_scholar(
|
||||
db_session,
|
||||
profile=profile,
|
||||
upload_dir=settings.scholar_image_upload_dir,
|
||||
)
|
||||
logger.info(
|
||||
"api.scholars.deleted",
|
||||
extra={
|
||||
"event": "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
|
||||
|
||||
logger.info(
|
||||
"api.scholars.image_url_updated",
|
||||
extra={
|
||||
"event": "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)
|
||||
logger.info(
|
||||
"api.scholars.image_uploaded",
|
||||
extra={
|
||||
"event": "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,
|
||||
)
|
||||
logger.info(
|
||||
"api.scholars.image_customization_cleared",
|
||||
extra={
|
||||
"event": "api.scholars.image_customization_cleared",
|
||||
"user_id": current_user.id,
|
||||
"scholar_profile_id": updated.id,
|
||||
},
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_scholar(updated),
|
||||
)
|
||||
|
||||
189
build/lib/app/api/routers/settings.py
Normal file
189
build/lib/app/api/routers/settings.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
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.schemas import SettingsEnvelope, SettingsUpdateRequest
|
||||
from app.db.models import User
|
||||
from app.db.session import get_db_session
|
||||
from app.services.domains.ingestion import safety as run_safety_service
|
||||
from app.services.domains.settings import application as user_settings_service
|
||||
from app.settings import settings as settings_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["api-settings"])
|
||||
|
||||
|
||||
def _serialize_settings(user_settings) -> dict[str, object]:
|
||||
min_run_interval_minutes, min_request_delay_seconds = _minimum_policy()
|
||||
return {
|
||||
"auto_run_enabled": bool(user_settings.auto_run_enabled) and settings_module.ingestion_automation_allowed,
|
||||
"run_interval_minutes": int(user_settings.run_interval_minutes),
|
||||
"request_delay_seconds": int(user_settings.request_delay_seconds),
|
||||
"nav_visible_pages": list(user_settings.nav_visible_pages or []),
|
||||
"policy": {
|
||||
"min_run_interval_minutes": min_run_interval_minutes,
|
||||
"min_request_delay_seconds": min_request_delay_seconds,
|
||||
"automation_allowed": bool(settings_module.ingestion_automation_allowed),
|
||||
"manual_run_allowed": bool(settings_module.ingestion_manual_run_allowed),
|
||||
"blocked_failure_threshold": max(1, int(settings_module.ingestion_alert_blocked_failure_threshold)),
|
||||
"network_failure_threshold": max(1, int(settings_module.ingestion_alert_network_failure_threshold)),
|
||||
"cooldown_blocked_seconds": max(60, int(settings_module.ingestion_safety_cooldown_blocked_seconds)),
|
||||
"cooldown_network_seconds": max(60, int(settings_module.ingestion_safety_cooldown_network_seconds)),
|
||||
},
|
||||
"safety_state": run_safety_service.get_safety_state_payload(user_settings),
|
||||
"openalex_api_key": user_settings.openalex_api_key,
|
||||
"crossref_api_token": user_settings.crossref_api_token,
|
||||
"crossref_api_mailto": user_settings.crossref_api_mailto,
|
||||
}
|
||||
|
||||
|
||||
def _minimum_policy() -> tuple[int, int]:
|
||||
min_run_interval_minutes = user_settings_service.resolve_run_interval_minimum(
|
||||
settings_module.ingestion_min_run_interval_minutes
|
||||
)
|
||||
min_request_delay_seconds = user_settings_service.resolve_request_delay_minimum(
|
||||
settings_module.ingestion_min_request_delay_seconds
|
||||
)
|
||||
return min_run_interval_minutes, min_request_delay_seconds
|
||||
|
||||
|
||||
def _parse_settings_payload(payload: SettingsUpdateRequest, user_settings) -> tuple[int, int, list[str]]:
|
||||
min_run_interval_minutes, min_request_delay_seconds = _minimum_policy()
|
||||
parsed_interval = user_settings_service.parse_run_interval_minutes(
|
||||
str(payload.run_interval_minutes),
|
||||
minimum=min_run_interval_minutes,
|
||||
)
|
||||
parsed_delay = user_settings_service.parse_request_delay_seconds(
|
||||
str(payload.request_delay_seconds),
|
||||
minimum=min_request_delay_seconds,
|
||||
)
|
||||
parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages(
|
||||
payload.nav_visible_pages
|
||||
if payload.nav_visible_pages is not None
|
||||
else list(user_settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES)
|
||||
)
|
||||
return parsed_interval, parsed_delay, parsed_nav_visible_pages
|
||||
|
||||
|
||||
async def _clear_expired_cooldown_with_log(
|
||||
db_session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
user_settings,
|
||||
) -> None:
|
||||
previous_safety_state = run_safety_service.get_safety_event_context(user_settings)
|
||||
if not run_safety_service.clear_expired_cooldown(user_settings):
|
||||
return
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user_settings)
|
||||
logger.info(
|
||||
"api.settings.safety_cooldown_cleared",
|
||||
extra={
|
||||
"event": "api.settings.safety_cooldown_cleared",
|
||||
"user_id": user_id,
|
||||
"reason": previous_safety_state.get("cooldown_reason"),
|
||||
"cooldown_until": previous_safety_state.get("cooldown_until"),
|
||||
"metric_name": "api_settings_safety_cooldown_cleared_total",
|
||||
"metric_value": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _effective_auto_run_enabled(payload: SettingsUpdateRequest) -> bool:
|
||||
return bool(payload.auto_run_enabled) and settings_module.ingestion_automation_allowed
|
||||
|
||||
|
||||
def _log_settings_update(*, user_id: int, updated) -> None:
|
||||
logger.info(
|
||||
"api.settings.updated",
|
||||
extra={
|
||||
"event": "api.settings.updated",
|
||||
"user_id": user_id,
|
||||
"auto_run_enabled": updated.auto_run_enabled,
|
||||
"run_interval_minutes": updated.run_interval_minutes,
|
||||
"request_delay_seconds": updated.request_delay_seconds,
|
||||
"nav_visible_pages": updated.nav_visible_pages,
|
||||
"openalex_api_key": "SET" if updated.openalex_api_key else "UNSET",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=SettingsEnvelope,
|
||||
)
|
||||
async def get_settings(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
user_settings = await user_settings_service.get_or_create_settings(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
await _clear_expired_cooldown_with_log(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
user_settings=user_settings,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_settings(user_settings),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"",
|
||||
response_model=SettingsEnvelope,
|
||||
)
|
||||
async def update_settings(
|
||||
payload: SettingsUpdateRequest,
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_api_current_user),
|
||||
):
|
||||
user_settings = await user_settings_service.get_or_create_settings(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed_interval, parsed_delay, parsed_nav_visible_pages = _parse_settings_payload(
|
||||
payload,
|
||||
user_settings,
|
||||
)
|
||||
except user_settings_service.UserSettingsServiceError as exc:
|
||||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_settings",
|
||||
message=str(exc),
|
||||
) from exc
|
||||
|
||||
updated = await user_settings_service.update_settings(
|
||||
db_session,
|
||||
settings=user_settings,
|
||||
auto_run_enabled=_effective_auto_run_enabled(payload),
|
||||
run_interval_minutes=parsed_interval,
|
||||
request_delay_seconds=parsed_delay,
|
||||
nav_visible_pages=parsed_nav_visible_pages,
|
||||
openalex_api_key=payload.openalex_api_key,
|
||||
crossref_api_token=payload.crossref_api_token,
|
||||
crossref_api_mailto=payload.crossref_api_mailto,
|
||||
)
|
||||
await _clear_expired_cooldown_with_log(
|
||||
db_session,
|
||||
user_id=current_user.id,
|
||||
user_settings=updated,
|
||||
)
|
||||
_log_settings_update(user_id=current_user.id, updated=updated)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_settings(updated),
|
||||
)
|
||||
16
build/lib/app/api/runtime_deps.py
Normal file
16
build/lib/app/api/runtime_deps.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.services.domains.ingestion import application as ingestion_service
|
||||
from app.services.domains.scholar import source as scholar_source_service
|
||||
|
||||
|
||||
def get_scholar_source() -> scholar_source_service.ScholarSource:
|
||||
return scholar_source_service.LiveScholarSource()
|
||||
|
||||
|
||||
def get_ingestion_service(
|
||||
source: scholar_source_service.ScholarSource = Depends(get_scholar_source),
|
||||
) -> ingestion_service.ScholarIngestionService:
|
||||
return ingestion_service.ScholarIngestionService(source=source)
|
||||
884
build/lib/app/api/schemas.py
Normal file
884
build/lib/app/api/schemas.py
Normal file
|
|
@ -0,0 +1,884 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class ApiMeta(BaseModel):
|
||||
request_id: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ApiErrorData(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
details: Any | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ApiErrorEnvelope(BaseModel):
|
||||
error: ApiErrorData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MessageData(BaseModel):
|
||||
message: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MessageEnvelope(BaseModel):
|
||||
data: MessageData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SessionUserData(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
is_admin: bool
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AuthMeData(BaseModel):
|
||||
authenticated: bool
|
||||
csrf_token: str
|
||||
user: SessionUserData
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AuthMeEnvelope(BaseModel):
|
||||
data: AuthMeData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CsrfBootstrapData(BaseModel):
|
||||
csrf_token: str
|
||||
authenticated: bool
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class CsrfBootstrapEnvelope(BaseModel):
|
||||
data: CsrfBootstrapData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class LoginData(BaseModel):
|
||||
authenticated: bool
|
||||
csrf_token: str
|
||||
user: SessionUserData
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class LoginEnvelope(BaseModel):
|
||||
data: LoginData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarItemData(BaseModel):
|
||||
id: int
|
||||
scholar_id: str
|
||||
display_name: str | None
|
||||
profile_image_url: str | None
|
||||
profile_image_source: str
|
||||
is_enabled: bool
|
||||
baseline_completed: bool
|
||||
last_run_dt: datetime | None
|
||||
last_run_status: str | None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarsListData(BaseModel):
|
||||
scholars: list[ScholarItemData]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarsListEnvelope(BaseModel):
|
||||
data: ScholarsListData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarCreateRequest(BaseModel):
|
||||
scholar_id: str
|
||||
profile_image_url: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarEnvelope(BaseModel):
|
||||
data: ScholarItemData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarSearchCandidateData(BaseModel):
|
||||
scholar_id: str
|
||||
display_name: str
|
||||
affiliation: str | None
|
||||
email_domain: str | None
|
||||
cited_by_count: int | None
|
||||
interests: list[str] = Field(default_factory=list)
|
||||
profile_url: str
|
||||
profile_image_url: str | None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarSearchData(BaseModel):
|
||||
query: str
|
||||
state: str
|
||||
state_reason: str
|
||||
action_hint: str | None = None
|
||||
candidates: list[ScholarSearchCandidateData]
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarSearchEnvelope(BaseModel):
|
||||
data: ScholarSearchData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarImageUrlUpdateRequest(BaseModel):
|
||||
image_url: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScholarExportItemData(BaseModel):
|
||||
scholar_id: str
|
||||
display_name: str | None = None
|
||||
is_enabled: bool = True
|
||||
profile_image_override_url: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PublicationExportItemData(BaseModel):
|
||||
scholar_id: str
|
||||
cluster_id: str | None = None
|
||||
fingerprint_sha256: str | None = None
|
||||
title: str
|
||||
year: int | None = None
|
||||
citation_count: int = 0
|
||||
author_text: str | None = None
|
||||
venue_text: str | None = None
|
||||
pub_url: str | None = None
|
||||
pdf_url: str | None = None
|
||||
is_read: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DataExportData(BaseModel):
|
||||
schema_version: int
|
||||
exported_at: str
|
||||
scholars: list[ScholarExportItemData]
|
||||
publications: list[PublicationExportItemData]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DataExportEnvelope(BaseModel):
|
||||
data: DataExportData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DataImportRequest(BaseModel):
|
||||
schema_version: int | None = None
|
||||
scholars: list[ScholarExportItemData] = Field(default_factory=list)
|
||||
publications: list[PublicationExportItemData] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DataImportResultData(BaseModel):
|
||||
scholars_created: int
|
||||
scholars_updated: int
|
||||
publications_created: int
|
||||
publications_updated: int
|
||||
links_created: int
|
||||
links_updated: int
|
||||
skipped_records: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DataImportEnvelope(BaseModel):
|
||||
data: DataImportResultData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunListItemData(BaseModel):
|
||||
id: int
|
||||
trigger_type: str
|
||||
status: str
|
||||
start_dt: datetime
|
||||
end_dt: datetime | None
|
||||
scholar_count: int
|
||||
new_publication_count: int
|
||||
failed_count: int
|
||||
partial_count: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunsListData(BaseModel):
|
||||
runs: list[RunListItemData]
|
||||
safety_state: ScrapeSafetyStateData
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunsListEnvelope(BaseModel):
|
||||
data: RunsListData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunSummaryData(BaseModel):
|
||||
succeeded_count: int
|
||||
failed_count: int
|
||||
partial_count: int
|
||||
failed_state_counts: dict[str, int] = Field(default_factory=dict)
|
||||
failed_reason_counts: dict[str, int] = Field(default_factory=dict)
|
||||
scrape_failure_counts: dict[str, int] = Field(default_factory=dict)
|
||||
retry_counts: dict[str, int] = Field(default_factory=dict)
|
||||
alert_thresholds: dict[str, int] = Field(default_factory=dict)
|
||||
alert_flags: dict[str, bool] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScrapeSafetyCountersData(BaseModel):
|
||||
consecutive_blocked_runs: int = 0
|
||||
consecutive_network_runs: int = 0
|
||||
cooldown_entry_count: int = 0
|
||||
blocked_start_count: int = 0
|
||||
last_blocked_failure_count: int = 0
|
||||
last_network_failure_count: int = 0
|
||||
last_evaluated_run_id: int | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ScrapeSafetyStateData(BaseModel):
|
||||
cooldown_active: bool
|
||||
cooldown_reason: str | None = None
|
||||
cooldown_reason_label: str | None = None
|
||||
cooldown_until: datetime | None = None
|
||||
cooldown_remaining_seconds: int = 0
|
||||
recommended_action: str | None = None
|
||||
counters: ScrapeSafetyCountersData = Field(default_factory=ScrapeSafetyCountersData)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunAttemptLogData(BaseModel):
|
||||
attempt: int
|
||||
cstart: int
|
||||
state: str | None = None
|
||||
state_reason: str | None = None
|
||||
status_code: int | None = None
|
||||
fetch_error: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunPageLogData(BaseModel):
|
||||
page: int
|
||||
cstart: int
|
||||
state: str
|
||||
state_reason: str | None = None
|
||||
status_code: int | None = None
|
||||
publication_count: int = 0
|
||||
attempt_count: int = 0
|
||||
has_show_more_button: bool = False
|
||||
articles_range: str | None = None
|
||||
warning_codes: list[str] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunDebugData(BaseModel):
|
||||
status_code: int | None = None
|
||||
final_url: str | None = None
|
||||
fetch_error: str | None = None
|
||||
body_sha256: str | None = None
|
||||
body_length: int | None = None
|
||||
has_show_more_button: bool | None = None
|
||||
articles_range: str | None = None
|
||||
state_reason: str | None = None
|
||||
warning_codes: list[str] = Field(default_factory=list)
|
||||
marker_counts_nonzero: dict[str, int] = Field(default_factory=dict)
|
||||
page_logs: list[RunPageLogData] = Field(default_factory=list)
|
||||
attempt_log: list[RunAttemptLogData] = Field(default_factory=list)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunScholarResultData(BaseModel):
|
||||
scholar_profile_id: int
|
||||
scholar_id: str
|
||||
state: str
|
||||
state_reason: str | None = None
|
||||
outcome: str
|
||||
attempt_count: int = 0
|
||||
publication_count: int = 0
|
||||
start_cstart: int = 0
|
||||
continuation_cstart: int | None = None
|
||||
continuation_enqueued: bool = False
|
||||
continuation_cleared: bool = False
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
debug: RunDebugData | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunDetailData(BaseModel):
|
||||
run: RunListItemData
|
||||
summary: RunSummaryData
|
||||
scholar_results: list[RunScholarResultData]
|
||||
safety_state: ScrapeSafetyStateData
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RunDetailEnvelope(BaseModel):
|
||||
data: RunDetailData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ManualRunData(BaseModel):
|
||||
run_id: int
|
||||
status: str
|
||||
scholar_count: int
|
||||
succeeded_count: int
|
||||
failed_count: int
|
||||
partial_count: int
|
||||
new_publication_count: int
|
||||
reused_existing_run: bool
|
||||
idempotency_key: str | None = None
|
||||
safety_state: ScrapeSafetyStateData
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ManualRunEnvelope(BaseModel):
|
||||
data: ManualRunData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class QueueItemData(BaseModel):
|
||||
id: int
|
||||
scholar_profile_id: int
|
||||
scholar_label: str
|
||||
status: str
|
||||
reason: str
|
||||
dropped_reason: str | None
|
||||
attempt_count: int
|
||||
resume_cstart: int
|
||||
next_attempt_dt: datetime | None
|
||||
updated_at: datetime
|
||||
last_error: str | None
|
||||
last_run_id: int | None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class QueueListData(BaseModel):
|
||||
queue_items: list[QueueItemData]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class QueueListEnvelope(BaseModel):
|
||||
data: QueueListData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class QueueItemEnvelope(BaseModel):
|
||||
data: QueueItemData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class QueueClearData(BaseModel):
|
||||
queue_item_id: int
|
||||
previous_status: str
|
||||
status: str
|
||||
message: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class QueueClearEnvelope(BaseModel):
|
||||
data: QueueClearData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminUserData(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminUsersListData(BaseModel):
|
||||
users: list[AdminUserData]
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminUsersListEnvelope(BaseModel):
|
||||
data: AdminUsersListData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminUserCreateRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
is_admin: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminUserActiveUpdateRequest(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminUserEnvelope(BaseModel):
|
||||
data: AdminUserData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminResetPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
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 DisplayIdentifierData(BaseModel):
|
||||
kind: str
|
||||
value: str
|
||||
label: str
|
||||
url: str | None
|
||||
confidence_score: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AdminPdfQueueItemData(BaseModel):
|
||||
publication_id: int
|
||||
title: str
|
||||
display_identifier: DisplayIdentifierData | None = 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
|
||||
automation_allowed: bool
|
||||
manual_run_allowed: bool
|
||||
blocked_failure_threshold: int
|
||||
network_failure_threshold: int
|
||||
cooldown_blocked_seconds: int
|
||||
cooldown_network_seconds: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingsData(BaseModel):
|
||||
auto_run_enabled: bool
|
||||
run_interval_minutes: int
|
||||
request_delay_seconds: int
|
||||
nav_visible_pages: list[str]
|
||||
policy: SettingsPolicyData
|
||||
safety_state: ScrapeSafetyStateData
|
||||
|
||||
openalex_api_key: str | None = None
|
||||
crossref_api_token: str | None = None
|
||||
crossref_api_mailto: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingsEnvelope(BaseModel):
|
||||
data: SettingsData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettingsUpdateRequest(BaseModel):
|
||||
auto_run_enabled: bool
|
||||
run_interval_minutes: int
|
||||
request_delay_seconds: int
|
||||
nav_visible_pages: list[str] | None = None
|
||||
|
||||
openalex_api_key: str | None = None
|
||||
crossref_api_token: str | None = None
|
||||
crossref_api_mailto: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PublicationItemData(BaseModel):
|
||||
publication_id: int
|
||||
scholar_profile_id: int
|
||||
scholar_label: str
|
||||
title: str
|
||||
year: int | None
|
||||
citation_count: int
|
||||
venue_text: str | None
|
||||
pub_url: str | None
|
||||
display_identifier: DisplayIdentifierData | None = 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
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class PublicationsListEnvelope(BaseModel):
|
||||
data: PublicationsListData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MarkAllReadData(BaseModel):
|
||||
message: str
|
||||
updated_count: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MarkAllReadEnvelope(BaseModel):
|
||||
data: MarkAllReadData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PublicationSelectionItem(BaseModel):
|
||||
scholar_profile_id: int
|
||||
publication_id: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MarkSelectedReadRequest(BaseModel):
|
||||
selections: list[PublicationSelectionItem] = Field(min_length=1, max_length=500)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MarkSelectedReadData(BaseModel):
|
||||
message: str
|
||||
requested_count: int
|
||||
updated_count: int
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class MarkSelectedReadEnvelope(BaseModel):
|
||||
data: MarkSelectedReadData
|
||||
meta: ApiMeta
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RetryPublicationPdfRequest(BaseModel):
|
||||
scholar_profile_id: int = Field(ge=1)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RetryPublicationPdfData(BaseModel):
|
||||
message: str
|
||||
queued: bool
|
||||
resolved_pdf: bool
|
||||
publication: PublicationItemData
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RetryPublicationPdfEnvelope(BaseModel):
|
||||
data: RetryPublicationPdfData
|
||||
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