ci: add ruff linting and mypy type checking
Add ruff and mypy to dev dependencies with configuration in pyproject.toml. Add a lint CI job that runs ruff check, ruff format --check, and mypy. Auto-fix import sorting and formatting across the codebase. Exclude alembic/versions from linting (auto-generated migrations). Ignore B008 (FastAPI Depends pattern) and RUF001 (unicode in user-facing strings). 21 ruff lint errors and 50 mypy errors remain for manual review. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
399276ea69
commit
bf04c77aa9
137 changed files with 3066 additions and 1900 deletions
|
|
@ -1,2 +1 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -82,4 +82,3 @@ def register_api_exception_handlers(app: FastAPI) -> None:
|
|||
message="Request validation failed.",
|
||||
details=exc.errors(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,14 @@ async def create_user(
|
|||
message=str(exc),
|
||||
) from exc
|
||||
|
||||
structured_log(logger, "info", "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))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"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),
|
||||
|
|
@ -111,11 +118,7 @@ async def set_user_active(
|
|||
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)
|
||||
):
|
||||
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",
|
||||
|
|
@ -126,7 +129,14 @@ async def set_user_active(
|
|||
user=target_user,
|
||||
is_active=bool(payload.is_active),
|
||||
)
|
||||
structured_log(logger, "info", "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))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"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),
|
||||
|
|
@ -166,7 +176,13 @@ async def reset_user_password(
|
|||
user=target_user,
|
||||
password_hash=auth_service.hash_password(validated_password),
|
||||
)
|
||||
structured_log(logger, "info", "api.admin.user_password_reset", admin_user_id=int(admin_user.id), target_user_id=int(target_user.id))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"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}"},
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@ from app.api.errors import ApiException
|
|||
from app.api.responses import success_payload
|
||||
from app.api.schemas import (
|
||||
AdminDbIntegrityEnvelope,
|
||||
AdminPdfQueueBulkEnqueueEnvelope,
|
||||
AdminPdfQueueRequeueEnvelope,
|
||||
AdminPdfQueueEnvelope,
|
||||
AdminDbRepairJobsEnvelope,
|
||||
AdminRepairPublicationNearDuplicatesEnvelope,
|
||||
AdminRepairPublicationNearDuplicatesRequest,
|
||||
AdminPdfQueueBulkEnqueueEnvelope,
|
||||
AdminPdfQueueEnvelope,
|
||||
AdminPdfQueueRequeueEnvelope,
|
||||
AdminRepairPublicationLinksEnvelope,
|
||||
AdminRepairPublicationLinksRequest,
|
||||
AdminRepairPublicationNearDuplicatesEnvelope,
|
||||
AdminRepairPublicationNearDuplicatesRequest,
|
||||
)
|
||||
from app.db.models import DataRepairJob, User
|
||||
from app.db.session import get_db_session
|
||||
|
|
@ -25,8 +25,8 @@ from app.logging_utils import structured_log
|
|||
from app.services.domains.dbops import (
|
||||
collect_integrity_report,
|
||||
list_repair_jobs,
|
||||
run_publication_near_duplicate_repair,
|
||||
run_publication_link_repair,
|
||||
run_publication_near_duplicate_repair,
|
||||
)
|
||||
from app.services.domains.publications import application as publication_service
|
||||
|
||||
|
|
@ -136,7 +136,15 @@ async def get_integrity_report(
|
|||
admin_user: User = Depends(get_api_admin_user),
|
||||
):
|
||||
report = await collect_integrity_report(db_session)
|
||||
structured_log(logger, "info", "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", [])))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"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)
|
||||
|
||||
|
||||
|
|
@ -151,7 +159,14 @@ async def get_repair_jobs(
|
|||
admin_user: User = Depends(get_api_admin_user),
|
||||
):
|
||||
jobs = await list_repair_jobs(db_session, limit=limit)
|
||||
structured_log(logger, "info", "api.admin.db.repair_jobs_listed", admin_user_id=int(admin_user.id), limit=int(limit), job_count=len(jobs))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"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]},
|
||||
|
|
@ -186,7 +201,9 @@ async def get_pdf_queue(
|
|||
status=normalized_status,
|
||||
)
|
||||
structured_log(
|
||||
logger, "info", "api.admin.db.pdf_queue_listed",
|
||||
logger,
|
||||
"info",
|
||||
"api.admin.db.pdf_queue_listed",
|
||||
admin_user_id=int(admin_user.id),
|
||||
page=int(resolved_page),
|
||||
page_size=int(resolved_limit),
|
||||
|
|
@ -232,7 +249,14 @@ async def requeue_pdf_lookup(
|
|||
message="Publication not found.",
|
||||
)
|
||||
status, message = _requeue_response_state(queued=result.queued)
|
||||
structured_log(logger, "info", "api.admin.db.pdf_requeued", admin_user_id=int(admin_user.id), publication_id=int(publication_id), queued=bool(result.queued))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"api.admin.db.pdf_requeued",
|
||||
admin_user_id=int(admin_user.id),
|
||||
publication_id=int(publication_id),
|
||||
queued=bool(result.queued),
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
|
|
@ -260,7 +284,15 @@ async def requeue_all_missing_pdfs(
|
|||
request_email=admin_user.email,
|
||||
limit=limit,
|
||||
)
|
||||
structured_log(logger, "info", "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))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"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={
|
||||
|
|
@ -301,7 +333,9 @@ async def trigger_publication_link_repair(
|
|||
message=str(exc),
|
||||
) from exc
|
||||
structured_log(
|
||||
logger, "info", "api.admin.db.publication_link_repair_triggered",
|
||||
logger,
|
||||
"info",
|
||||
"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,
|
||||
|
|
@ -340,7 +374,16 @@ async def trigger_publication_near_duplicate_repair(
|
|||
code="invalid_near_duplicate_repair_request",
|
||||
message=str(exc),
|
||||
) from exc
|
||||
structured_log(logger, "info", "api.admin.db.dedup_repair_triggered", admin_user_id=int(admin_user.id), dry_run=bool(payload.dry_run), selected_cluster_count=len(payload.selected_cluster_keys), job_id=int(result["job_id"]), status=result["status"])
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"api.admin.db.dedup_repair_triggered",
|
||||
admin_user_id=int(admin_user.id),
|
||||
dry_run=bool(payload.dry_run),
|
||||
selected_cluster_count=len(payload.selected_cluster_keys),
|
||||
job_id=int(result["job_id"]),
|
||||
status=result["status"],
|
||||
)
|
||||
return success_payload(request, data=result)
|
||||
|
||||
|
||||
|
|
@ -381,12 +424,17 @@ async def drop_all_publications(
|
|||
await db_session.execute(delete(PublicationPdfJobEvent))
|
||||
await db_session.execute(delete(PublicationPdfJob))
|
||||
await db_session.execute(delete(Publication))
|
||||
await db_session.execute(
|
||||
update(ScholarProfile).values(baseline_completed=False)
|
||||
)
|
||||
await db_session.execute(update(ScholarProfile).values(baseline_completed=False))
|
||||
await db_session.commit()
|
||||
|
||||
structured_log(logger, "warning", "api.admin.db.all_publications_dropped", admin_user_id=int(admin_user.id), admin_email=admin_user.email, deleted_count=int(total_publications))
|
||||
structured_log(
|
||||
logger,
|
||||
"warning",
|
||||
"api.admin.db.all_publications_dropped",
|
||||
admin_user_id=int(admin_user.id),
|
||||
admin_email=admin_user.email,
|
||||
deleted_count=int(total_publications),
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ 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.errors import ApiException
|
||||
from app.api.responses import success_payload
|
||||
from app.api.schemas import (
|
||||
AuthMeEnvelope,
|
||||
|
|
@ -16,14 +16,14 @@ from app.api.schemas import (
|
|||
LoginRequest,
|
||||
MessageEnvelope,
|
||||
)
|
||||
from app.auth.deps import get_auth_service, get_login_rate_limiter
|
||||
from app.logging_utils import structured_log
|
||||
from app.auth.rate_limit import SlidingWindowRateLimiter
|
||||
from app.auth import runtime as auth_runtime
|
||||
from app.auth.deps import get_auth_service, get_login_rate_limiter
|
||||
from app.auth.rate_limit import SlidingWindowRateLimiter
|
||||
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.logging_utils import structured_log
|
||||
from app.security.csrf import ensure_csrf_token
|
||||
from app.services.domains.users import application as user_service
|
||||
|
||||
|
|
@ -37,7 +37,13 @@ def _login_limiter_key_and_email(request: Request, payload: LoginRequest) -> tup
|
|||
|
||||
|
||||
def _raise_rate_limited(normalized_email: str, retry_after_seconds: int) -> None:
|
||||
structured_log(logger, "warning", "api.auth.login_rate_limited", email=normalized_email, retry_after_seconds=retry_after_seconds)
|
||||
structured_log(
|
||||
logger,
|
||||
"warning",
|
||||
"api.auth.login_rate_limited",
|
||||
email=normalized_email,
|
||||
retry_after_seconds=retry_after_seconds,
|
||||
)
|
||||
raise ApiException(
|
||||
status_code=429,
|
||||
code="rate_limited",
|
||||
|
|
@ -200,7 +206,9 @@ async def logout(
|
|||
):
|
||||
current_user = await auth_runtime.get_authenticated_user(request, db_session)
|
||||
auth_runtime.invalidate_session(request)
|
||||
structured_log(logger, "info", "api.auth.logout", user_id=int(current_user.id) if current_user is not None else None)
|
||||
structured_log(
|
||||
logger, "info", "api.auth.logout", user_id=int(current_user.id) if current_user is not None else None
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query, Request
|
||||
|
|
@ -183,7 +183,7 @@ def _resolve_publications_snapshot(
|
|||
snapshot: str | None,
|
||||
) -> tuple[datetime, str]:
|
||||
if snapshot is None:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
now_utc = datetime.now(UTC)
|
||||
return now_utc, now_utc.isoformat()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(snapshot)
|
||||
|
|
@ -194,8 +194,8 @@ def _resolve_publications_snapshot(
|
|||
message="Invalid publications snapshot cursor.",
|
||||
) from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
normalized = parsed.astimezone(timezone.utc)
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
normalized = parsed.astimezone(UTC)
|
||||
return normalized, normalized.isoformat()
|
||||
|
||||
|
||||
|
|
@ -420,7 +420,9 @@ async def mark_all_publications_read(
|
|||
db_session,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
structured_log(logger, "info", "api.publications.mark_all_read", user_id=current_user.id, updated_count=updated_count)
|
||||
structured_log(
|
||||
logger, "info", "api.publications.mark_all_read", user_id=current_user.id, updated_count=updated_count
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
|
|
@ -440,18 +442,20 @@ async def mark_selected_publications_read(
|
|||
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
|
||||
}
|
||||
)
|
||||
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,
|
||||
)
|
||||
structured_log(logger, "info", "api.publications.mark_selected_read", user_id=current_user.id, requested_count=len(selection_pairs), updated_count=updated_count)
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"api.publications.mark_selected_read",
|
||||
user_id=current_user.id,
|
||||
requested_count=len(selection_pairs),
|
||||
updated_count=updated_count,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
|
|
@ -486,7 +490,9 @@ async def retry_publication_pdf(
|
|||
pdf_status=current.pdf_status,
|
||||
)
|
||||
structured_log(
|
||||
logger, "info", "api.publications.retry_pdf",
|
||||
logger,
|
||||
"info",
|
||||
"api.publications.retry_pdf",
|
||||
user_id=current_user.id,
|
||||
scholar_profile_id=payload.scholar_profile_id,
|
||||
publication_id=publication_id,
|
||||
|
|
@ -523,7 +529,15 @@ async def toggle_publication_favorite(
|
|||
publication_id=publication_id,
|
||||
is_favorite=payload.is_favorite,
|
||||
)
|
||||
structured_log(logger, "info", "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))
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"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={
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import re
|
|||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
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.runtime_deps import get_ingestion_service
|
||||
from app.api.schemas import (
|
||||
ManualRunEnvelope,
|
||||
QueueClearEnvelope,
|
||||
|
|
@ -29,7 +30,6 @@ 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__)
|
||||
|
||||
|
|
@ -77,10 +77,7 @@ def _normalize_idempotency_key(raw_value: str | None) -> str | None:
|
|||
raise ApiException(
|
||||
status_code=400,
|
||||
code="invalid_idempotency_key",
|
||||
message=(
|
||||
"Invalid Idempotency-Key. Use 8-128 characters from: "
|
||||
"A-Z a-z 0-9 . _ : -"
|
||||
),
|
||||
message=("Invalid Idempotency-Key. Use 8-128 characters from: A-Z a-z 0-9 . _ : -"),
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
|
@ -130,11 +127,7 @@ def _normalize_attempt_log(value: Any) -> list[dict[str, Any]]:
|
|||
"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
|
||||
),
|
||||
"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")),
|
||||
}
|
||||
)
|
||||
|
|
@ -155,19 +148,12 @@ def _normalize_page_logs(value: Any) -> list[dict[str, Any]]:
|
|||
"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
|
||||
),
|
||||
"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 [])
|
||||
],
|
||||
"warning_codes": [str(code) for code in (warning_codes if isinstance(warning_codes, list) else [])],
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
|
@ -179,19 +165,11 @@ def _normalize_debug(value: Any) -> dict[str, Any] | 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
|
||||
),
|
||||
"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
|
||||
),
|
||||
"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
|
||||
|
|
@ -199,10 +177,7 @@ def _normalize_debug(value: Any) -> dict[str, Any] | 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 [])
|
||||
],
|
||||
"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 [])
|
||||
|
|
@ -241,9 +216,7 @@ def _normalize_scholar_result(value: Any) -> dict[str, Any]:
|
|||
"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
|
||||
_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),
|
||||
|
|
@ -289,7 +262,9 @@ async def _load_safety_state(
|
|||
await db_session.commit()
|
||||
await db_session.refresh(user_settings)
|
||||
structured_log(
|
||||
logger, "info", "api.runs.safety_cooldown_cleared",
|
||||
logger,
|
||||
"info",
|
||||
"api.runs.safety_cooldown_cleared",
|
||||
user_id=user_id,
|
||||
reason=previous_safety_state.get("cooldown_reason"),
|
||||
cooldown_until=previous_safety_state.get("cooldown_until"),
|
||||
|
|
@ -299,7 +274,9 @@ async def _load_safety_state(
|
|||
|
||||
def _raise_manual_runs_disabled(*, user_id: int, safety_state: dict[str, Any]) -> None:
|
||||
structured_log(
|
||||
logger, "warning", "api.runs.manual_blocked_policy",
|
||||
logger,
|
||||
"warning",
|
||||
"api.runs.manual_blocked_policy",
|
||||
user_id=user_id,
|
||||
policy={"manual_run_allowed": False},
|
||||
safety_state=safety_state,
|
||||
|
|
@ -463,7 +440,9 @@ async def _execute_manual_run(
|
|||
|
||||
def _raise_manual_blocked_safety(*, exc, user_id: int) -> None:
|
||||
structured_log(
|
||||
logger, "info", "api.runs.manual_blocked_safety",
|
||||
logger,
|
||||
"info",
|
||||
"api.runs.manual_blocked_safety",
|
||||
user_id=user_id,
|
||||
reason=exc.safety_state.get("cooldown_reason"),
|
||||
cooldown_until=exc.safety_state.get("cooldown_until"),
|
||||
|
|
@ -613,12 +592,12 @@ async def cancel_run(
|
|||
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={
|
||||
|
|
@ -657,7 +636,7 @@ async def run_manual(
|
|||
|
||||
try:
|
||||
user_settings = await user_settings_service.get_or_create_settings(db_session, user_id=current_user.id)
|
||||
|
||||
|
||||
# Initialize run (creates the record and performs safety checks)
|
||||
run, scholars, start_cstart_map = await ingest_service.initialize_run(
|
||||
db_session,
|
||||
|
|
@ -673,13 +652,14 @@ async def run_manual(
|
|||
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
|
||||
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
|
||||
)
|
||||
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
# Kick off background execution
|
||||
from app.db.session import get_session_factory
|
||||
import asyncio
|
||||
|
||||
|
||||
from app.db.session import get_session_factory
|
||||
|
||||
asyncio.create_task(
|
||||
ingest_service.execute_run(
|
||||
session_factory=get_session_factory(),
|
||||
|
|
@ -700,7 +680,7 @@ async def run_manual(
|
|||
idempotency_key=idempotency_key,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
return success_payload(
|
||||
request,
|
||||
data={
|
||||
|
|
@ -714,7 +694,7 @@ async def run_manual(
|
|||
"reused_existing_run": False,
|
||||
"idempotency_key": idempotency_key,
|
||||
"safety_state": await _load_safety_state(db_session, user_id=current_user.id),
|
||||
}
|
||||
},
|
||||
)
|
||||
except ingestion_service.RunBlockedBySafetyPolicyError as exc:
|
||||
await db_session.rollback()
|
||||
|
|
@ -740,7 +720,6 @@ async def run_manual(
|
|||
_raise_manual_failed(exc=exc, user_id=current_user.id)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
"/queue/items",
|
||||
response_model=QueueListEnvelope,
|
||||
|
|
@ -891,7 +870,4 @@ async def stream_run_events(
|
|||
code="run_not_found",
|
||||
message="Run not found.",
|
||||
)
|
||||
return StreamingResponse(
|
||||
event_generator(run_id),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
return StreamingResponse(event_generator(run_id), media_type="text/event-stream")
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ from app.logging_utils import structured_log
|
|||
from app.services.domains.ingestion import queue as ingestion_queue_service
|
||||
from app.services.domains.portability import application as import_export_service
|
||||
from app.services.domains.scholar import rate_limit as scholar_rate_limit
|
||||
from app.services.domains.scholars import application as scholar_service
|
||||
from app.services.domains.scholar.source import ScholarSource
|
||||
from app.services.domains.scholars import application as scholar_service
|
||||
from app.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -82,14 +82,18 @@ async def _enqueue_initial_scrape_job_for_scholar(
|
|||
except Exception:
|
||||
await db_session.rollback()
|
||||
structured_log(
|
||||
logger, "warning", "api.scholars.initial_scrape_enqueue_failed",
|
||||
logger,
|
||||
"warning",
|
||||
"api.scholars.initial_scrape_enqueue_failed",
|
||||
user_id=user_id,
|
||||
scholar_profile_id=profile.id,
|
||||
)
|
||||
return False
|
||||
|
||||
structured_log(
|
||||
logger, "info", "api.scholars.initial_scrape_enqueued",
|
||||
logger,
|
||||
"info",
|
||||
"api.scholars.initial_scrape_enqueued",
|
||||
user_id=user_id,
|
||||
scholar_profile_id=profile.id,
|
||||
reason=INITIAL_SCHOLAR_SCRAPE_QUEUE_REASON,
|
||||
|
|
@ -120,9 +124,7 @@ def _serialize_scholar(profile) -> dict[str, object]:
|
|||
"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
|
||||
),
|
||||
"last_run_status": (profile.last_run_status.value if profile.last_run_status is not None else None),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -139,7 +141,9 @@ async def _hydrate_scholar_metadata_if_needed(
|
|||
should_skip, remaining_seconds = _is_create_hydration_rate_limited()
|
||||
if should_skip:
|
||||
structured_log(
|
||||
logger, "info", "api.scholars.create_metadata_hydration_skipped",
|
||||
logger,
|
||||
"info",
|
||||
"api.scholars.create_metadata_hydration_skipped",
|
||||
reason="scholar_request_throttle_active",
|
||||
user_id=user_id,
|
||||
scholar_profile_id=profile.id,
|
||||
|
|
@ -158,14 +162,18 @@ async def _hydrate_scholar_metadata_if_needed(
|
|||
)
|
||||
except TimeoutError:
|
||||
structured_log(
|
||||
logger, "info", "api.scholars.create_metadata_hydration_skipped",
|
||||
logger,
|
||||
"info",
|
||||
"api.scholars.create_metadata_hydration_skipped",
|
||||
reason="create_timeout",
|
||||
user_id=user_id,
|
||||
scholar_profile_id=profile.id,
|
||||
)
|
||||
except Exception:
|
||||
structured_log(
|
||||
logger, "warning", "api.scholars.create_metadata_hydration_failed",
|
||||
logger,
|
||||
"warning",
|
||||
"api.scholars.create_metadata_hydration_failed",
|
||||
user_id=user_id,
|
||||
scholar_profile_id=profile.id,
|
||||
)
|
||||
|
|
@ -185,9 +193,7 @@ def _search_kwargs() -> dict[str, object]:
|
|||
"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
|
||||
),
|
||||
"cooldown_rejection_alert_threshold": (settings.scholar_name_search_alert_cooldown_rejections_threshold),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -299,8 +305,7 @@ async def import_scholars_and_publications(
|
|||
status_code=400,
|
||||
code="invalid_import_schema_version",
|
||||
message=(
|
||||
"Import schema version is not supported. "
|
||||
f"Expected {import_export_service.EXPORT_SCHEMA_VERSION}."
|
||||
f"Import schema version is not supported. Expected {import_export_service.EXPORT_SCHEMA_VERSION}."
|
||||
),
|
||||
)
|
||||
try:
|
||||
|
|
@ -393,7 +398,15 @@ async def search_scholars(
|
|||
message=str(exc),
|
||||
) from exc
|
||||
|
||||
structured_log(logger, "info", "api.scholars.search.completed", user_id=current_user.id, query=query.strip(), candidate_count=len(parsed.candidates), state=parsed.state.value)
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"api.scholars.search.completed",
|
||||
user_id=current_user.id,
|
||||
query=query.strip(),
|
||||
candidate_count=len(parsed.candidates),
|
||||
state=parsed.state.value,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_search_response_data(query, parsed),
|
||||
|
|
@ -422,7 +435,14 @@ async def toggle_scholar(
|
|||
message="Scholar not found.",
|
||||
)
|
||||
updated = await scholar_service.toggle_scholar_enabled(db_session, profile=profile)
|
||||
structured_log(logger, "info", "api.scholars.toggled", user_id=current_user.id, scholar_profile_id=updated.id, is_enabled=updated.is_enabled)
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"api.scholars.toggled",
|
||||
user_id=current_user.id,
|
||||
scholar_profile_id=updated.id,
|
||||
is_enabled=updated.is_enabled,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_scholar(updated),
|
||||
|
|
@ -455,7 +475,9 @@ async def delete_scholar(
|
|||
profile=profile,
|
||||
upload_dir=settings.scholar_image_upload_dir,
|
||||
)
|
||||
structured_log(logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id)
|
||||
structured_log(
|
||||
logger, "info", "api.scholars.deleted", user_id=current_user.id, scholar_profile_id=scholar_profile_id
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data={"message": "Scholar deleted."},
|
||||
|
|
@ -499,7 +521,9 @@ async def update_scholar_image_url(
|
|||
message=str(exc),
|
||||
) from exc
|
||||
|
||||
structured_log(logger, "info", "api.scholars.image_url_updated", user_id=current_user.id, scholar_profile_id=updated.id)
|
||||
structured_log(
|
||||
logger, "info", "api.scholars.image_url_updated", user_id=current_user.id, scholar_profile_id=updated.id
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_scholar(updated),
|
||||
|
|
@ -541,7 +565,15 @@ async def upload_scholar_image(
|
|||
) from exc
|
||||
|
||||
image_size = len(image_bytes)
|
||||
structured_log(logger, "info", "api.scholars.image_uploaded", user_id=current_user.id, scholar_profile_id=updated.id, content_type=image.content_type, size_bytes=image_size)
|
||||
structured_log(
|
||||
logger,
|
||||
"info",
|
||||
"api.scholars.image_uploaded",
|
||||
user_id=current_user.id,
|
||||
scholar_profile_id=updated.id,
|
||||
content_type=image.content_type,
|
||||
size_bytes=image_size,
|
||||
)
|
||||
return success_payload(
|
||||
request,
|
||||
data=_serialize_scholar(updated),
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ async def _clear_expired_cooldown_with_log(
|
|||
await db_session.commit()
|
||||
await db_session.refresh(user_settings)
|
||||
structured_log(
|
||||
logger, "info", "api.settings.safety_cooldown_cleared",
|
||||
logger,
|
||||
"info",
|
||||
"api.settings.safety_cooldown_cleared",
|
||||
user_id=user_id,
|
||||
reason=previous_safety_state.get("cooldown_reason"),
|
||||
cooldown_until=previous_safety_state.get("cooldown_until"),
|
||||
|
|
@ -164,7 +166,9 @@ async def update_settings(
|
|||
user_settings=updated,
|
||||
)
|
||||
structured_log(
|
||||
logger, "info", "api.settings.updated",
|
||||
logger,
|
||||
"info",
|
||||
"api.settings.updated",
|
||||
user_id=current_user.id,
|
||||
auto_run_enabled=updated.auto_run_enabled,
|
||||
run_interval_minutes=updated.run_interval_minutes,
|
||||
|
|
|
|||
|
|
@ -671,7 +671,7 @@ class AdminRepairPublicationLinksRequest(BaseModel):
|
|||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_scope(self) -> "AdminRepairPublicationLinksRequest":
|
||||
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:
|
||||
|
|
@ -680,10 +680,7 @@ class AdminRepairPublicationLinksRequest(BaseModel):
|
|||
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."
|
||||
)
|
||||
raise ValueError("confirmation_text must equal 'REPAIR ALL USERS' when applying a repair to all users.")
|
||||
return self
|
||||
|
||||
|
||||
|
|
@ -735,7 +732,7 @@ class AdminRepairPublicationNearDuplicatesRequest(BaseModel):
|
|||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_apply_mode(self) -> "AdminRepairPublicationNearDuplicatesRequest":
|
||||
def validate_apply_mode(self) -> AdminRepairPublicationNearDuplicatesRequest:
|
||||
if self.dry_run:
|
||||
return self
|
||||
if not self.selected_cluster_keys:
|
||||
|
|
@ -744,8 +741,7 @@ class AdminRepairPublicationNearDuplicatesRequest(BaseModel):
|
|||
provided = (self.confirmation_text or "").strip()
|
||||
if provided != expected:
|
||||
raise ValueError(
|
||||
"confirmation_text must equal 'MERGE SELECTED DUPLICATES' "
|
||||
"when applying near-duplicate merges."
|
||||
"confirmation_text must equal 'MERGE SELECTED DUPLICATES' when applying near-duplicate merges."
|
||||
)
|
||||
return self
|
||||
|
||||
|
|
@ -787,7 +783,7 @@ class SettingsData(BaseModel):
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue