From e3f0d63fecc1f371c94d7e2a7d978cf4ed456773 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Sat, 21 Feb 2026 17:33:05 +0100 Subject: [PATCH] Big changes --- .env.example | 61 +- .github/workflows/ci.yml | 24 + .github/workflows/docs-pages.yml | 58 ++ .gitignore | 2 + README.md | 291 +----- agents.md | 5 + .../20260220_0012_add_data_repair_jobs.py | 106 +++ .../20260221_0013_add_publication_pdf_jobs.py | 179 ++++ ..._add_scholar_publication_favorite_state.py | 44 + ...260221_0015_enforce_request_delay_floor.py | 80 ++ app/api/router.py | 3 +- app/api/routers/admin_dbops.py | 337 +++++++ app/api/routers/publications.py | 321 ++++++- app/api/schemas.py | 206 +++- app/db/models.py | 122 ++- app/http/middleware.py | 4 +- app/services/domains/dbops/__init__.py | 5 + app/services/domains/dbops/application.py | 364 +++++++ app/services/domains/dbops/integrity.py | 175 ++++ app/services/domains/dbops/query.py | 22 + app/services/domains/ingestion/application.py | 38 +- app/services/domains/ingestion/scheduler.py | 22 +- .../domains/publications/application.py | 20 + app/services/domains/publications/counts.py | 22 + .../domains/publications/enrichment.py | 158 +--- app/services/domains/publications/listing.py | 105 +-- .../domains/publications/pdf_queue.py | 877 +++++++++++++++++ app/services/domains/publications/queries.py | 10 + .../domains/publications/read_state.py | 43 +- app/services/domains/publications/types.py | 5 + app/services/domains/unpaywall/__init__.py | 7 +- app/services/domains/unpaywall/application.py | 312 +++++- .../domains/unpaywall/pdf_discovery.py | 156 +++ app/services/domains/unpaywall/rate_limit.py | 18 + app/settings.py | 13 + docs/README.md | 30 + docs/architecture/domain_boundaries.md | 25 + CONTRIBUTING.md => docs/contributing.md | 0 docs/deploy/quickstart.md | 68 ++ .../frontend/theme_phase0_inventory.md | 0 docs/index.md | 12 + docs/ops/db_runbook.md | 100 ++ docs/ops/migration_checklist.md | 48 + docs/reference/api_contract.md | 29 + docs/reference/environment.md | 68 ++ frontend/index.html | 6 + frontend/package.json | 1 + frontend/public/android-chrome-192x192.png | Bin 0 -> 9669 bytes frontend/public/android-chrome-512x512.png | Bin 0 -> 40611 bytes frontend/public/apple-touch-icon.png | Bin 0 -> 8914 bytes frontend/public/favicon-16x16.png | Bin 0 -> 580 bytes frontend/public/favicon-32x32.png | Bin 0 -> 990 bytes frontend/public/favicon.ico | Bin 0 -> 15086 bytes frontend/public/site.webmanifest | 21 + frontend/scripts/generate_brand_assets.sh | 56 ++ frontend/src/app/router.ts | 7 - frontend/src/components/layout/AppHeader.vue | 79 +- frontend/src/components/layout/AppNav.vue | 20 +- frontend/src/components/ui/AppBrandMark.vue | 55 ++ .../src/components/ui/AppRefreshButton.vue | 61 ++ frontend/src/components/ui/AppTabs.vue | 51 + frontend/src/components/ui/AppThemePicker.vue | 95 ++ frontend/src/features/admin_dbops/index.ts | 153 +++ frontend/src/features/dashboard/index.ts | 2 +- frontend/src/features/publications/index.ts | 51 +- .../features/settings/SettingsAdminPanel.vue | 826 ++++++++++++++++ frontend/src/logo.png | Bin 0 -> 106296 bytes frontend/src/pages/AdminUsersPage.vue | 316 ------- frontend/src/pages/DashboardPage.vue | 34 +- frontend/src/pages/LoginPage.vue | 58 +- frontend/src/pages/PublicationsPage.vue | 891 +++++++++++++----- frontend/src/pages/RunDetailPage.vue | 12 +- frontend/src/pages/RunsPage.vue | 11 +- frontend/src/pages/ScholarsPage.vue | 12 +- frontend/src/pages/SettingsPage.vue | 459 ++++----- frontend/src/theme/presets/canopy.js | 313 ++++++ frontend/src/theme/presets/dune.js | 80 +- frontend/src/theme/presets/oatmeal.js | 80 +- frontend/src/theme/presets/parchment.js | 80 +- frontend/src/theme/presets/sunset.js | 313 ++++++ scripts/check_env_contract.py | 126 +++ scripts/db/backup_full.sh | 63 ++ scripts/db/check_integrity.py | 50 + scripts/db/repair_publication_links.py | 66 ++ scripts/db/restore_dump.sh | 79 ++ tests/conftest.py | 3 + tests/integration/test_api_v1.py | 592 +++++++++++- tests/integration/test_data_repair_jobs.py | 252 +++++ tests/integration/test_db_integrity.py | 70 ++ tests/integration/test_migrations.py | 46 +- .../unit/test_publication_pdf_queue_policy.py | 109 +++ tests/unit/test_request_delay_policy.py | 45 + tests/unit/test_unpaywall_pdf_discovery.py | 94 ++ tests/unit/test_unpaywall_resolution.py | 89 ++ website/docusaurus.config.js | 80 ++ website/package.json | 18 + website/sidebars.js | 6 + website/src/css/custom.css | 9 + website/static/img/favicon.ico | Bin 0 -> 15086 bytes 99 files changed, 8804 insertions(+), 1731 deletions(-) create mode 100644 .github/workflows/docs-pages.yml create mode 100644 alembic/versions/20260220_0012_add_data_repair_jobs.py create mode 100644 alembic/versions/20260221_0013_add_publication_pdf_jobs.py create mode 100644 alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py create mode 100644 alembic/versions/20260221_0015_enforce_request_delay_floor.py create mode 100644 app/api/routers/admin_dbops.py create mode 100644 app/services/domains/dbops/__init__.py create mode 100644 app/services/domains/dbops/application.py create mode 100644 app/services/domains/dbops/integrity.py create mode 100644 app/services/domains/dbops/query.py create mode 100644 app/services/domains/publications/pdf_queue.py create mode 100644 app/services/domains/unpaywall/pdf_discovery.py create mode 100644 app/services/domains/unpaywall/rate_limit.py create mode 100644 docs/README.md create mode 100644 docs/architecture/domain_boundaries.md rename CONTRIBUTING.md => docs/contributing.md (100%) create mode 100644 docs/deploy/quickstart.md rename frontend/src/theme/THEME_PHASE0_INVENTORY.md => docs/frontend/theme_phase0_inventory.md (100%) create mode 100644 docs/index.md create mode 100644 docs/ops/db_runbook.md create mode 100644 docs/ops/migration_checklist.md create mode 100644 docs/reference/api_contract.md create mode 100644 docs/reference/environment.md create mode 100644 frontend/public/android-chrome-192x192.png create mode 100644 frontend/public/android-chrome-512x512.png create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/favicon-16x16.png create mode 100644 frontend/public/favicon-32x32.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/site.webmanifest create mode 100755 frontend/scripts/generate_brand_assets.sh create mode 100644 frontend/src/components/ui/AppBrandMark.vue create mode 100644 frontend/src/components/ui/AppRefreshButton.vue create mode 100644 frontend/src/components/ui/AppTabs.vue create mode 100644 frontend/src/components/ui/AppThemePicker.vue create mode 100644 frontend/src/features/admin_dbops/index.ts create mode 100644 frontend/src/features/settings/SettingsAdminPanel.vue create mode 100644 frontend/src/logo.png delete mode 100644 frontend/src/pages/AdminUsersPage.vue create mode 100644 frontend/src/theme/presets/canopy.js create mode 100644 frontend/src/theme/presets/sunset.js create mode 100644 scripts/check_env_contract.py create mode 100755 scripts/db/backup_full.sh create mode 100755 scripts/db/check_integrity.py create mode 100755 scripts/db/repair_publication_links.py create mode 100755 scripts/db/restore_dump.sh create mode 100644 tests/integration/test_data_repair_jobs.py create mode 100644 tests/integration/test_db_integrity.py create mode 100644 tests/unit/test_publication_pdf_queue_policy.py create mode 100644 tests/unit/test_request_delay_policy.py create mode 100644 tests/unit/test_unpaywall_pdf_discovery.py create mode 100644 tests/unit/test_unpaywall_resolution.py create mode 100644 website/docusaurus.config.js create mode 100644 website/package.json create mode 100644 website/sidebars.js create mode 100644 website/src/css/custom.css create mode 100644 website/static/img/favicon.ico diff --git a/.env.example b/.env.example index dc38b6b..326e7d3 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,52 @@ +# ------------------------------ +# Compose + Database +# ------------------------------ POSTGRES_DB=scholar POSTGRES_USER=scholar POSTGRES_PASSWORD=change-me - DATABASE_URL=postgresql+asyncpg://scholar:scholar@db:5432/scholar # Optional override. If empty, tests derive "_test". TEST_DATABASE_URL= SCHOLARR_IMAGE=justinzeus/scholarr:latest +# ------------------------------ +# App Runtime + Networking +# ------------------------------ APP_NAME=scholarr APP_HOST=0.0.0.0 APP_PORT=8000 APP_HOST_PORT=8000 APP_RELOAD=0 +MIGRATE_ON_START=1 FRONTEND_ENABLED=1 FRONTEND_DIST_DIR=/app/frontend/dist + +# ------------------------------ +# Database Pool +# ------------------------------ +DATABASE_POOL_MODE=auto +DATABASE_POOL_SIZE=5 +DATABASE_POOL_MAX_OVERFLOW=10 +DATABASE_POOL_TIMEOUT_SECONDS=30 + +# ------------------------------ +# Frontend Dev Overrides +# ------------------------------ FRONTEND_HOST_PORT=5173 CHOKIDAR_USEPOLLING=1 VITE_DEV_API_PROXY_TARGET=http://app:8000 -MIGRATE_ON_START=1 +# ------------------------------ +# Auth + Session +# ------------------------------ SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters SESSION_COOKIE_SECURE=1 +LOGIN_RATE_LIMIT_ATTEMPTS=5 +LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 + +# ------------------------------ +# HTTP Security Headers + CSP +# ------------------------------ SECURITY_HEADERS_ENABLED=1 SECURITY_X_CONTENT_TYPE_OPTIONS=nosniff SECURITY_X_FRAME_OPTIONS=DENY @@ -36,16 +62,23 @@ SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED=0 SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE=31536000 SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS=1 SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD=0 -LOGIN_RATE_LIMIT_ATTEMPTS=5 -LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 + +# ------------------------------ +# Logging +# ------------------------------ LOG_LEVEL=INFO LOG_FORMAT=console LOG_REQUESTS=1 LOG_UVICORN_ACCESS=0 LOG_REQUEST_SKIP_PATHS=/healthz LOG_REDACT_FIELDS= + +# ------------------------------ +# Scheduler + Ingestion Safety +# ------------------------------ SCHEDULER_ENABLED=1 SCHEDULER_TICK_SECONDS=60 +SCHEDULER_QUEUE_BATCH_SIZE=10 INGESTION_AUTOMATION_ALLOWED=1 INGESTION_MANUAL_RUN_ALLOWED=1 INGESTION_MIN_RUN_INTERVAL_MINUTES=15 @@ -63,7 +96,10 @@ INGESTION_CONTINUATION_QUEUE_ENABLED=1 INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120 INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600 INGESTION_CONTINUATION_MAX_ATTEMPTS=6 -SCHEDULER_QUEUE_BATCH_SIZE=10 + +# ------------------------------ +# Scholar Images + Name Search Safety +# ------------------------------ SCHOLAR_IMAGE_UPLOAD_DIR=/var/lib/scholarr/uploads SCHOLAR_IMAGE_UPLOAD_MAX_BYTES=2000000 SCHOLAR_NAME_SEARCH_ENABLED=1 @@ -76,21 +112,34 @@ SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1 SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800 SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD=2 SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD=3 + +# ------------------------------ +# OA Enrichment + PDF Resolution +# ------------------------------ UNPAYWALL_ENABLED=1 UNPAYWALL_EMAIL= UNPAYWALL_TIMEOUT_SECONDS=4.0 +UNPAYWALL_MIN_INTERVAL_SECONDS=0.6 UNPAYWALL_MAX_ITEMS_PER_REQUEST=20 UNPAYWALL_RETRY_COOLDOWN_SECONDS=1800 +UNPAYWALL_PDF_DISCOVERY_ENABLED=1 +UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES=5 +UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES=500000 +PDF_AUTO_RETRY_INTERVAL_SECONDS=86400 +PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS=3600 +PDF_AUTO_RETRY_MAX_ATTEMPTS=3 CROSSREF_ENABLED=1 CROSSREF_MAX_ROWS=10 CROSSREF_TIMEOUT_SECONDS=8.0 CROSSREF_MIN_INTERVAL_SECONDS=0.6 CROSSREF_MAX_LOOKUPS_PER_REQUEST=8 +# ------------------------------ +# Startup Bootstrap + DB Wait +# ------------------------------ BOOTSTRAP_ADMIN_ON_START=0 BOOTSTRAP_ADMIN_EMAIL= BOOTSTRAP_ADMIN_PASSWORD= BOOTSTRAP_ADMIN_FORCE_PASSWORD=0 - DB_WAIT_TIMEOUT_SECONDS=60 DB_WAIT_INTERVAL_SECONDS=2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ff6965..1459666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: - name: Enforce no generated artifacts run: ./scripts/check_no_generated_artifacts.sh + - name: Enforce env contract parity + run: python3 scripts/check_env_contract.py + test: runs-on: ubuntu-latest needs: @@ -106,12 +109,33 @@ jobs: working-directory: frontend run: npm run build + docs-quality: + runs-on: ubuntu-latest + needs: + - repo-hygiene + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install docs dependencies + run: npm install --prefix website + + - name: Docs build + run: npm --prefix website run build + docker-publish: runs-on: ubuntu-latest needs: - repo-hygiene - test - frontend-quality + - docs-quality if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000..8bdb167 --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,58 @@ +name: Docs Pages + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - docs/** + - website/** + - .github/workflows/docs-pages.yml + +permissions: + contents: read + +concurrency: + group: docs-pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install docs dependencies + run: npm install --prefix website + + - name: Build docs site + run: npm --prefix website run build + + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: website/build + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index f539821..218252b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ planning/ frontend/node_modules/ frontend/dist/ frontend/.vite/ +website/node_modules/ +website/build/ AGENTS.MD diff --git a/README.md b/README.md index 3ec9573..b31ab8f 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,7 @@ cp .env.example .env - `POSTGRES_PASSWORD` - `SESSION_SECRET_KEY` -3. Choose a deploy method below. - -## Deploy Method A: Prebuilt Image (Recommended) - -Use this for normal self-hosted deployment. +3. Start stack: ```bash docker compose pull @@ -37,234 +33,36 @@ Open: - App/API: `http://localhost:8000` - Health: `http://localhost:8000/healthz` -Upgrade: +## Documentation + +Primary docs live under `docs/`. + +- Index: `docs/README.md` +- Deploy and dev workflow: `docs/deploy/quickstart.md` +- Environment reference: `docs/reference/environment.md` +- API contract and payload conventions: `docs/reference/api_contract.md` +- Architecture and domain boundaries: `docs/architecture/domain_boundaries.md` +- Scrape safety runbook: `docs/ops/scrape_safety_runbook.md` +- DB backup/restore/integrity runbook: `docs/ops/db_runbook.md` +- Migration rollout checklist: `docs/ops/migration_checklist.md` +- Frontend theme inventory: `docs/frontend/theme_phase0_inventory.md` +- Contributing policy: `docs/contributing.md` + +## Docs Site (Docusaurus) + +Docusaurus source lives in `website/` and consumes markdown from `docs/`. + +Local build: ```bash -docker compose pull -docker compose up -d +cd website +npm install +npm run build ``` -## Deploy Method B: Local Source Build + Dev Frontend +GitHub Pages deploy is automated via `.github/workflows/docs-pages.yml`. -Use this for development on this repository. - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build -``` - -Open: -- API (FastAPI): `http://localhost:8000` -- Frontend dev server (Vite): `http://localhost:5173` - -Stop: - -```bash -docker compose -f docker-compose.yml -f docker-compose.dev.yml down -``` - -## Essential Files - -- `docker-compose.yml`: deployment compose (prebuilt image). -- `docker-compose.dev.yml`: dev override (source mounts + Vite dev server). -- `.env.example`: full env variable template. -- `Dockerfile`: multi-stage build (frontend + backend runtime). -- `README.md`: deployment and operations reference. -- `CONTRIBUTING.md`: contribution policy and merge checklist. -- `docs/ops/scrape_safety_runbook.md`: scrape cooldown and blocked-IP operations guide. -- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI. - -## Data Model Notes - -- Scholar tracking is user-scoped: each account can track the same Scholar ID independently. -- Publications are shared/global records deduplicated by Scholar cluster ID and normalized fingerprint. -- Per-account visibility and read state is stored on scholar-publication links, not on the global publication row. -- Publication records include canonical Scholar detail URLs (`pub_url`), normalized DOI (`doi`), and resolved OA PDF links (`pdf_url`) when available. - -## Backend Architecture (Refactored) - -- Canonical service logic lives in `app/services/domains/*`; root-level `app/services/*.py` business-logic modules are removed. -- `app/services/domains/ingestion/*`: run orchestration (`application.py`), continuation queue (`queue.py`), scrape safety cooldown policy (`safety.py`), scheduler/queue draining (`scheduler.py`), plus shared constants/types/fingerprint helpers. -- `app/services/domains/scholar/*`: fail-fast Google Scholar parsing and source access. Parser flow is modularized across row extractors, state detection, constants, and parser types. -- `app/services/domains/scholars/*`: scholar CRUD, name-search safety/caching, profile-image validation/upload handling, and shared scholar validation helpers. -- `app/services/domains/publications/*`: publication listing/read-state query modules, lazy/background OA enrichment scheduling (non-blocking for list responses), and per-publication PDF retry support. -- `app/services/domains/doi/*`: DOI normalization helpers used by ingestion and enrichment. -- `app/services/domains/crossref/*`: DOI discovery fallback using Crossref metadata queries with bounded/paced request behavior. -- `app/services/domains/unpaywall/*`: OA resolution by DOI (best OA location + PDF URL extraction), using per-user email for API calls. -- `app/services/domains/runs/*`: run history summaries plus continuation queue status/retry/drop/clear operations. -- `app/services/domains/portability/*`: import/export of tracked scholars and publication-link state while preserving global publication deduplication. -- `app/services/domains/settings/*` and `app/services/domains/users/*`: user settings and user management services. - -## Frontend Navigation and Layout Notes - -- On mobile (`< lg`), primary navigation is behind a header hamburger and opens as a left-side drawer with backdrop. -- Mobile nav closes on route change, nav link click, and logout. -- Long lists are constrained to internal scroll containers in fill-layout pages (including dashboard recent publications and tracked scholars table view). - -## Name Search Status - -- Scholar name search is intentionally WIP in the UI. -- Current Google Scholar behavior often redirects name-search traffic to login/challenge flows, so production onboarding should use direct Scholar ID/profile URL adds. - -## Environment Variables (Complete Reference) - -Notes: -- Boolean envs accept: `1/0`, `true/false`, `yes/no`, `on/off`. -- Values shown are deployment defaults from `.env.example` and `docker-compose.yml`. -- Some internal app fallbacks may differ for local test/dev safety when env vars are omitted. -- `deploy` means used in regular deployment. -- `dev` means used in local dev workflow. - -### Core Compose and Database - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `POSTGRES_DB` | `scholar` | any valid DB name | deploy, dev | PostgreSQL database name. | -| `POSTGRES_USER` | `scholar` | any valid DB user | deploy, dev | PostgreSQL user. | -| `POSTGRES_PASSWORD` | `change-me` | strong password | deploy, dev | PostgreSQL password. Required. | -| `DATABASE_URL` | `postgresql+asyncpg://scholar:scholar@db:5432/scholar` | valid SQLAlchemy asyncpg URL | deploy, dev, test | App database connection URL. | -| `TEST_DATABASE_URL` | empty | valid SQLAlchemy asyncpg URL | test | Optional explicit integration test DB URL. | -| `SCHOLARR_IMAGE` | `justinzeus/scholarr:latest` | any image ref | deploy | App image tag used by deployment compose. | - -### App Runtime and Networking - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `APP_NAME` | `scholarr` | any string | deploy, dev | FastAPI app name/title. | -| `APP_HOST` | `0.0.0.0` | bind host | deploy, dev | Uvicorn bind host inside container. | -| `APP_PORT` | `8000` | integer | deploy, dev | Uvicorn bind port inside container. | -| `APP_HOST_PORT` | `8000` | integer | deploy, dev | Host port mapped to app container port 8000. | -| `APP_RELOAD` | `0` | boolean | deploy, dev | Enable uvicorn reload mode. Recommended `0` in deploy, `1` in dev. | -| `MIGRATE_ON_START` | `1` | boolean | deploy, dev | Run Alembic migrations during app startup. | -| `FRONTEND_ENABLED` | `1` | boolean | deploy, dev | Serve bundled frontend assets from FastAPI. | -| `FRONTEND_DIST_DIR` | `/app/frontend/dist` | absolute path | deploy, dev | Path to built frontend assets inside app container. | - -### Dev Frontend Overrides - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `FRONTEND_HOST_PORT` | `5173` | integer | dev | Host port mapped to Vite dev server. | -| `CHOKIDAR_USEPOLLING` | `1` | boolean | dev | File watching mode for containerized Vite. | -| `VITE_DEV_API_PROXY_TARGET` | `http://app:8000` | URL | dev | Vite proxy target for `/api` and `/healthz`. | - -### Auth and Session Security - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `SESSION_SECRET_KEY` | `replace-with-a-long-random-secret-at-least-32-characters` | long random string | deploy, dev | Session signing secret. Required in deploy. | -| `SESSION_COOKIE_SECURE` | `1` | boolean | deploy, dev | Mark session cookie as HTTPS-only. Set `1` behind HTTPS. | -| `LOGIN_RATE_LIMIT_ATTEMPTS` | `5` | integer >= 1 | deploy, dev | Login attempts allowed per window. | -| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | `60` | integer >= 1 | deploy, dev | Login rate limit window in seconds. | - -### HTTP Security Headers and CSP - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `SECURITY_HEADERS_ENABLED` | `1` | boolean | deploy, dev | Global switch for response security headers middleware. | -| `SECURITY_X_CONTENT_TYPE_OPTIONS` | `nosniff` | header value | deploy, dev | `X-Content-Type-Options` response header value. | -| `SECURITY_X_FRAME_OPTIONS` | `DENY` | header value | deploy, dev | `X-Frame-Options` response header value. | -| `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | header value | deploy, dev | `Referrer-Policy` response header value. | -| `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()` | permissions policy string | deploy, dev | `Permissions-Policy` response header value. | -| `SECURITY_CROSS_ORIGIN_OPENER_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Opener-Policy` response header value. | -| `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Resource-Policy` response header value. | -| `SECURITY_CSP_ENABLED` | `1` | boolean | deploy, dev | Enable Content Security Policy headers. | -| `SECURITY_CSP_POLICY` | strict SPA/API default | CSP policy string | deploy, dev | CSP applied to app/API routes (excluding docs paths). | -| `SECURITY_CSP_DOCS_POLICY` | relaxed docs default | CSP policy string | deploy, dev | CSP override for `/docs` and `/redoc` to keep Swagger/ReDoc usable. | -| `SECURITY_CSP_REPORT_ONLY` | `0` | boolean | deploy, dev | Emit CSP in report-only mode instead of enforcement mode. | -| `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED` | `0` | boolean | deploy, dev | Enable `Strict-Transport-Security` header. | -| `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE` | `31536000` | integer >= 0 | deploy, dev | `max-age` for HSTS header. | -| `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS` | `1` | boolean | deploy, dev | Add `includeSubDomains` directive to HSTS header. | -| `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` | `0` | boolean | deploy, dev | Add `preload` directive to HSTS header. | - -### Logging - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | deploy, dev | Application log level. | -| `LOG_FORMAT` | `console` | `console`, `json` | deploy, dev | Log output format. | -| `LOG_REQUESTS` | `1` | boolean | deploy, dev | Enable request start/completion logs. | -| `LOG_UVICORN_ACCESS` | `0` | boolean | deploy, dev | Enable uvicorn access logs. | -| `LOG_REQUEST_SKIP_PATHS` | `/healthz` | comma-separated path prefixes | deploy, dev | Request log skip list. | -| `LOG_REDACT_FIELDS` | empty | comma-separated keys | deploy, dev | Extra fields to redact in structured logs. | - -### Scheduler and Ingestion - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `SCHEDULER_ENABLED` | `1` | boolean | deploy, dev | Enable background scheduler loop. | -| `SCHEDULER_TICK_SECONDS` | `60` | integer >= 1 | deploy, dev | Scheduler interval in seconds. | -| `INGESTION_AUTOMATION_ALLOWED` | `1` | boolean | deploy, dev | Global safety switch for scheduled/automatic checks. When disabled, auto-run settings are forced off. | -| `INGESTION_MANUAL_RUN_ALLOWED` | `1` | boolean | deploy, dev | Global safety switch for manual checks from API/UI. | -| `INGESTION_MIN_RUN_INTERVAL_MINUTES` | `15` | integer >= 15 | deploy, dev | Server-enforced minimum for user-configured automatic check interval. | -| `INGESTION_MIN_REQUEST_DELAY_SECONDS` | `2` | integer >= 2 | deploy, dev | Server-enforced minimum delay between scholar requests. | -| `SCHEDULER_QUEUE_BATCH_SIZE` | `10` | integer >= 1 | deploy, dev | Queue items processed per scheduler tick. | -| `INGESTION_NETWORK_ERROR_RETRIES` | `1` | integer >= 0 | deploy, dev | Retries for transient ingestion network errors. | -| `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. | -| `INGESTION_MAX_PAGES_PER_SCHOLAR` | `30` | integer >= 1 | deploy, dev | Upper bound of pages fetched per scholar run. | -| `INGESTION_PAGE_SIZE` | `100` | integer >= 1 | deploy, dev | Requested Scholar page size. | -| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Trigger blocked/captcha scrape alert flag when this many blocked failures occur in a run. | -| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Trigger network scrape alert flag when this many network failures occur in a run. | -| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Trigger retry alert flag when scheduled retry count reaches this threshold in a run. | -| `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS` | `1800` | integer >= 60 | deploy, dev | Cooldown duration applied when blocked-failure threshold is exceeded. | -| `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS` | `900` | integer >= 60 | deploy, dev | Cooldown duration applied when network-failure threshold is exceeded. | -| `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. | -| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. | -| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. | -| `INGESTION_CONTINUATION_MAX_ATTEMPTS` | `6` | integer >= 1 | deploy, dev | Max failed continuation attempts before dropping item. | - -### Scholar Images and Name Search Safety - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images in compose deployments. App fallback without env is `/tmp/scholarr_uploads/scholar_images`. | -| `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | `2000000` | integer >= 1 | deploy, dev | Max uploaded image size in bytes. | -| `SCHOLAR_NAME_SEARCH_ENABLED` | `1` | boolean | deploy, dev | Enable name-search helper endpoint. | -| `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | `21600` | integer >= 1 | deploy, dev | Cache TTL for successful name-search responses. | -| `SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS` | `300` | integer >= 1 | deploy, dev | Cache TTL for blocked/captcha responses. | -| `SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES` | `512` | integer >= 1 | deploy, dev | Max cached search entries. | -| `SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS` | `8.0` | float >= 0 | deploy, dev | Minimum interval between live name-search requests. | -| `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | `2.0` | float >= 0 | deploy, dev | Added jitter to reduce request burst patterns. | -| `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Consecutive blocked responses before cooldown starts. | -| `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | `1800` | integer >= 1 | deploy, dev | Cooldown duration after repeated blocked responses. | -| `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Emit retry-threshold observability warning when name-search retry count reaches this value. | -| `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Emit cooldown-threshold observability alert after this many requests are rejected during active cooldown. | - -### Open-Access Enrichment (Crossref + Unpaywall) - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `UNPAYWALL_ENABLED` | `1` | boolean | deploy, dev | Enable Unpaywall OA resolution. | -| `UNPAYWALL_EMAIL` | empty | valid email | deploy, dev | Fallback email if per-user email is unavailable. | -| `UNPAYWALL_TIMEOUT_SECONDS` | `4.0` | float >= 0.5 | deploy, dev | Timeout for Unpaywall requests. | -| `UNPAYWALL_MAX_ITEMS_PER_REQUEST` | `20` | integer >= 0 | deploy, dev | Max publications queued per lazy enrichment pass. | -| `UNPAYWALL_RETRY_COOLDOWN_SECONDS` | `1800` | integer >= 1 | deploy, dev | Per-user/per-publication cooldown before re-scheduling unresolved OA lookups. | -| `CROSSREF_ENABLED` | `1` | boolean | deploy, dev | Enable Crossref DOI discovery fallback when DOI is missing/insufficient. | -| `CROSSREF_MAX_ROWS` | `10` | integer >= 1 | deploy, dev | Max Crossref rows evaluated per lookup. | -| `CROSSREF_TIMEOUT_SECONDS` | `8.0` | float >= 0.5 | deploy, dev | Timeout budget per Crossref lookup task. | -| `CROSSREF_MIN_INTERVAL_SECONDS` | `0.6` | float >= 0 | deploy, dev | Minimum pacing interval between Crossref requests. | -| `CROSSREF_MAX_LOOKUPS_PER_REQUEST` | `8` | integer >= 0 | deploy, dev | Max Crossref DOI lookups performed during one lazy pass or manual retry. | - -### Scrape Safety Operations - -- Structured safety events are emitted for: - - policy/manual run blocks, - - cooldown entered/cleared transitions, - - blocked/network/retry threshold trips, - - scheduler cooldown skips/deferments. -- Use `LOG_FORMAT=json` and ship logs to your preferred collector for alerting. -- Runbook: `docs/ops/scrape_safety_runbook.md`. - -### Startup Bootstrap and DB Wait - -| Variable | Default | Options | Scope | Description | -| --- | --- | --- | --- | --- | -| `BOOTSTRAP_ADMIN_ON_START` | `0` | boolean | deploy, dev | Auto-create admin at startup. | -| `BOOTSTRAP_ADMIN_EMAIL` | empty | valid email | deploy, dev | Bootstrap admin email. | -| `BOOTSTRAP_ADMIN_PASSWORD` | empty | strong password | deploy, dev | Bootstrap admin password. | -| `BOOTSTRAP_ADMIN_FORCE_PASSWORD` | `0` | boolean | deploy, dev | Force-reset bootstrap admin password if user already exists. | -| `DB_WAIT_TIMEOUT_SECONDS` | `60` | integer >= 1 | deploy, dev | Max seconds to wait for DB readiness at startup. | -| `DB_WAIT_INTERVAL_SECONDS` | `2` | integer >= 1 | deploy, dev | Interval between DB readiness checks. | - -## Quality and Test Commands +## Quality Gates Backend: @@ -283,43 +81,10 @@ npm run test:run npm run build ``` -Contract drift: +Contract and env checks: ```bash python3 scripts/check_frontend_api_contract.py -``` - -Repository hygiene: - -```bash +python3 scripts/check_env_contract.py ./scripts/check_no_generated_artifacts.sh ``` - -Scheduled fixture probes run in GitHub Actions via `.github/workflows/scheduled-probes.yml`. - -## API Contract - -- Base path: `/api/v1` -- Success envelope: `{"data": ..., "meta": {"request_id": "..."}}` -- Error envelope: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` - -### Publications semantics - -- `GET /api/v1/publications` supports `mode=all|unread|latest` (plus temporary alias `mode=new`). -- `unread` = actionable read-state (`is_read=false`). -- `latest` = discovery-state (`first seen in the latest completed check`). -- Publications include `pub_url`, `doi`, and `pdf_url` in API responses. -- OA enrichment flow is DOI-first: - - existing DOI (or explicit DOI in source metadata) -> Unpaywall lookup, - - Crossref DOI discovery fallback (paced + bounded), - - Unpaywall DOI lookup for OA status and `pdf_url`. -- `POST /api/v1/publications/{publication_id}/retry-pdf` retries enrichment for a single publication in current user scope. -- Response counters: - - `unread_count`: unread publications in current scope. - - `latest_count`: publications discovered in latest completed check. - - `new_count`: compatibility alias for `latest_count` (temporary). - -### Scholar Data Portability - -- `GET /api/v1/scholars/export`: exports tracked scholars plus scholar-publication link data as JSON. -- `POST /api/v1/scholars/import`: imports that JSON payload, upserting scholars, global publication records, and per-scholar read state. diff --git a/agents.md b/agents.md index 18abc7c..07b62d2 100644 --- a/agents.md +++ b/agents.md @@ -36,3 +36,8 @@ These limits prevent IP bans and are not to be optimized away. * **`app/services/domains/unpaywall/*`:** OA resolver by DOI only (best OA location + PDF URL extraction). Do not use Google Scholar for PDF resolution to avoid N+1 scrape amplification. * **`app/services/domains/portability/*`:** Handles JSON import/export for user-scoped scholars and scholar-publication link state while preserving global publication dedup rules. * **`app/services/domains/ingestion/scheduler.py`:** Owns automatic runs and continuation queue retries/drops; do not bypass safety gate or cooldown logic. + + +## 6. UI rules +Make sure to properly integrate tailwind in combination with the preset theming +Clarity through both styling and language are a priority. all UI elements need to have a proper reason for existing. \ No newline at end of file diff --git a/alembic/versions/20260220_0012_add_data_repair_jobs.py b/alembic/versions/20260220_0012_add_data_repair_jobs.py new file mode 100644 index 0000000..242876d --- /dev/null +++ b/alembic/versions/20260220_0012_add_data_repair_jobs.py @@ -0,0 +1,106 @@ +"""Add data repair job audit table. + +Revision ID: 20260220_0012 +Revises: 20260220_0011 +Create Date: 2026-02-20 23:35:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "20260220_0012" +down_revision: str | Sequence[str] | None = "20260220_0011" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _table_names() -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + return set(inspector.get_table_names()) + + +def _create_data_repair_jobs_table() -> None: + op.create_table( + "data_repair_jobs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("job_name", sa.String(length=64), nullable=False), + sa.Column("requested_by", sa.String(length=255), nullable=True), + sa.Column( + "scope", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("dry_run", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column( + "status", + sa.String(length=16), + nullable=False, + server_default=sa.text("'planned'"), + ), + sa.Column( + "summary", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("error_text", sa.Text(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "status IN ('planned', 'running', 'completed', 'failed')", + name="data_repair_jobs_status_valid", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_data_repair_jobs")), + ) + + +def _create_data_repair_jobs_indexes() -> None: + op.create_index( + "ix_data_repair_jobs_created_at", + "data_repair_jobs", + ["created_at"], + unique=False, + ) + op.create_index( + "ix_data_repair_jobs_job_name_created_at", + "data_repair_jobs", + ["job_name", "created_at"], + unique=False, + ) + + +def _drop_data_repair_jobs_indexes() -> None: + op.drop_index("ix_data_repair_jobs_job_name_created_at", table_name="data_repair_jobs") + op.drop_index("ix_data_repair_jobs_created_at", table_name="data_repair_jobs") + + +def upgrade() -> None: + if "data_repair_jobs" in _table_names(): + return + _create_data_repair_jobs_table() + _create_data_repair_jobs_indexes() + + +def downgrade() -> None: + if "data_repair_jobs" not in _table_names(): + return + _drop_data_repair_jobs_indexes() + op.drop_table("data_repair_jobs") diff --git a/alembic/versions/20260221_0013_add_publication_pdf_jobs.py b/alembic/versions/20260221_0013_add_publication_pdf_jobs.py new file mode 100644 index 0000000..63db6b2 --- /dev/null +++ b/alembic/versions/20260221_0013_add_publication_pdf_jobs.py @@ -0,0 +1,179 @@ +"""Add persistent publication PDF job tracking tables. + +Revision ID: 20260221_0013 +Revises: 20260220_0012 +Create Date: 2026-02-21 12:25:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260221_0013" +down_revision: str | Sequence[str] | None = "20260220_0012" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _table_names() -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + return set(inspector.get_table_names()) + + +def _create_publication_pdf_jobs_table() -> None: + op.create_table( + "publication_pdf_jobs", + sa.Column("publication_id", sa.Integer(), nullable=False), + sa.Column( + "status", + sa.String(length=16), + nullable=False, + server_default=sa.text("'queued'"), + ), + sa.Column("attempt_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("queued_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_failure_reason", sa.String(length=64), nullable=True), + sa.Column("last_failure_detail", sa.Text(), nullable=True), + sa.Column("last_source", sa.String(length=32), nullable=True), + sa.Column("last_requested_by_user_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.CheckConstraint( + "status IN ('queued', 'running', 'resolved', 'failed')", + name="publication_pdf_jobs_status_valid", + ), + sa.ForeignKeyConstraint( + ["last_requested_by_user_id"], + ["users.id"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["publication_id"], + ["publications.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("publication_id", name=op.f("pk_publication_pdf_jobs")), + ) + + +def _create_publication_pdf_jobs_indexes() -> None: + op.create_index( + "ix_publication_pdf_jobs_status", + "publication_pdf_jobs", + ["status"], + unique=False, + ) + op.create_index( + "ix_publication_pdf_jobs_updated_at", + "publication_pdf_jobs", + ["updated_at"], + unique=False, + ) + op.create_index( + "ix_publication_pdf_jobs_queued_at", + "publication_pdf_jobs", + ["queued_at"], + unique=False, + ) + + +def _create_publication_pdf_job_events_table() -> None: + op.create_table( + "publication_pdf_job_events", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("publication_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("event_type", sa.String(length=32), nullable=False), + sa.Column("status", sa.String(length=16), nullable=True), + sa.Column("source", sa.String(length=32), nullable=True), + sa.Column("failure_reason", sa.String(length=64), nullable=True), + sa.Column("message", sa.Text(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["publication_id"], + ["publications.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_publication_pdf_job_events")), + ) + + +def _create_publication_pdf_job_events_indexes() -> None: + op.create_index( + "ix_publication_pdf_job_events_publication_created", + "publication_pdf_job_events", + ["publication_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_publication_pdf_job_events_created_at", + "publication_pdf_job_events", + ["created_at"], + unique=False, + ) + op.create_index( + "ix_publication_pdf_job_events_event_type", + "publication_pdf_job_events", + ["event_type"], + unique=False, + ) + + +def _drop_publication_pdf_jobs_indexes() -> None: + op.drop_index("ix_publication_pdf_jobs_queued_at", table_name="publication_pdf_jobs") + op.drop_index("ix_publication_pdf_jobs_updated_at", table_name="publication_pdf_jobs") + op.drop_index("ix_publication_pdf_jobs_status", table_name="publication_pdf_jobs") + + +def _drop_publication_pdf_job_events_indexes() -> None: + op.drop_index("ix_publication_pdf_job_events_event_type", table_name="publication_pdf_job_events") + op.drop_index("ix_publication_pdf_job_events_created_at", table_name="publication_pdf_job_events") + op.drop_index( + "ix_publication_pdf_job_events_publication_created", + table_name="publication_pdf_job_events", + ) + + +def upgrade() -> None: + tables = _table_names() + if "publication_pdf_jobs" not in tables: + _create_publication_pdf_jobs_table() + _create_publication_pdf_jobs_indexes() + if "publication_pdf_job_events" not in tables: + _create_publication_pdf_job_events_table() + _create_publication_pdf_job_events_indexes() + + +def downgrade() -> None: + tables = _table_names() + if "publication_pdf_job_events" in tables: + _drop_publication_pdf_job_events_indexes() + op.drop_table("publication_pdf_job_events") + if "publication_pdf_jobs" in tables: + _drop_publication_pdf_jobs_indexes() + op.drop_table("publication_pdf_jobs") diff --git a/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py b/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py new file mode 100644 index 0000000..1fa8f29 --- /dev/null +++ b/alembic/versions/20260221_0014_add_scholar_publication_favorite_state.py @@ -0,0 +1,44 @@ +"""add scholar publication favorite state + +Revision ID: 20260221_0014 +Revises: 20260221_0013 +Create Date: 2026-02-21 14:35:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20260221_0014" +down_revision = "20260221_0013" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "scholar_publications", + sa.Column( + "is_favorite", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.create_index( + "ix_scholar_publications_is_favorite", + "scholar_publications", + ["is_favorite"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_scholar_publications_is_favorite", + table_name="scholar_publications", + ) + op.drop_column("scholar_publications", "is_favorite") diff --git a/alembic/versions/20260221_0015_enforce_request_delay_floor.py b/alembic/versions/20260221_0015_enforce_request_delay_floor.py new file mode 100644 index 0000000..688ce0a --- /dev/null +++ b/alembic/versions/20260221_0015_enforce_request_delay_floor.py @@ -0,0 +1,80 @@ +"""enforce request delay floor at two seconds + +Revision ID: 20260221_0015 +Revises: 20260221_0014 +Create Date: 2026-02-21 18:10:00 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260221_0015" +down_revision: str | Sequence[str] | None = "20260221_0014" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +TABLE_NAME = "user_settings" +CHECK_NAME_MIN_1 = "request_delay_seconds_min_1" +CHECK_NAME_MIN_2 = "request_delay_seconds_min_2" +LEGACY_CHECK_NAME_MIN_1 = "ck_user_settings_request_delay_seconds_min_1" +LEGACY_CHECK_NAME_MIN_2 = "ck_user_settings_request_delay_seconds_min_2" + + +def _check_constraints() -> set[str]: + bind = op.get_bind() + inspector = sa.inspect(bind) + return { + str(item.get("name")) + for item in inspector.get_check_constraints(TABLE_NAME) + if item.get("name") + } + + +def _drop_check_if_exists(name: str) -> None: + if name not in _check_constraints(): + return + op.drop_constraint( + name, + TABLE_NAME, + type_="check", + ) + + +def upgrade() -> None: + op.execute( + sa.text( + "UPDATE user_settings SET request_delay_seconds = 2 " + "WHERE request_delay_seconds < 2" + ) + ) + + _drop_check_if_exists(CHECK_NAME_MIN_1) + _drop_check_if_exists(LEGACY_CHECK_NAME_MIN_1) + + existing = _check_constraints() + if CHECK_NAME_MIN_2 not in existing and LEGACY_CHECK_NAME_MIN_2 not in existing: + op.create_check_constraint( + CHECK_NAME_MIN_2, + TABLE_NAME, + "request_delay_seconds >= 2", + ) + + +def downgrade() -> None: + _drop_check_if_exists(CHECK_NAME_MIN_2) + _drop_check_if_exists(LEGACY_CHECK_NAME_MIN_2) + + existing = _check_constraints() + if CHECK_NAME_MIN_1 not in existing and LEGACY_CHECK_NAME_MIN_1 not in existing: + op.create_check_constraint( + CHECK_NAME_MIN_1, + TABLE_NAME, + "request_delay_seconds >= 1", + ) diff --git a/app/api/router.py b/app/api/router.py index 09d49b2..a11d98a 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -2,11 +2,12 @@ from __future__ import annotations from fastapi import APIRouter -from app.api.routers import admin, auth, publications, runs, scholars, settings +from app.api.routers import admin, admin_dbops, auth, publications, runs, scholars, settings router = APIRouter(prefix="/api/v1") router.include_router(auth.router) router.include_router(admin.router) +router.include_router(admin_dbops.router) router.include_router(scholars.router) router.include_router(settings.router) router.include_router(runs.router) diff --git a/app/api/routers/admin_dbops.py b/app/api/routers/admin_dbops.py new file mode 100644 index 0000000..16271a0 --- /dev/null +++ b/app/api/routers/admin_dbops.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, Path, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_api_admin_user +from app.api.errors import ApiException +from app.api.responses import success_payload +from app.api.schemas import ( + AdminDbIntegrityEnvelope, + AdminPdfQueueBulkEnqueueEnvelope, + AdminPdfQueueRequeueEnvelope, + AdminPdfQueueEnvelope, + AdminDbRepairJobsEnvelope, + AdminRepairPublicationLinksEnvelope, + AdminRepairPublicationLinksRequest, +) +from app.db.models import DataRepairJob, User +from app.db.session import get_db_session +from app.services.domains.dbops import ( + collect_integrity_report, + list_repair_jobs, + run_publication_link_repair, +) +from app.services.domains.publications import application as publication_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/db", tags=["api-admin-dbops"]) + + +def _serialize_repair_job(job: DataRepairJob) -> dict[str, object]: + return { + "id": int(job.id), + "job_name": job.job_name, + "requested_by": job.requested_by, + "dry_run": bool(job.dry_run), + "status": job.status, + "scope": dict(job.scope or {}), + "summary": dict(job.summary or {}), + "error_text": job.error_text, + "started_at": job.started_at, + "finished_at": job.finished_at, + "created_at": job.created_at, + "updated_at": job.updated_at, + } + + +def _requested_by_value(*, payload: AdminRepairPublicationLinksRequest, admin_user: User) -> str: + from_payload = (payload.requested_by or "").strip() + return from_payload or admin_user.email + + +def _serialize_pdf_queue_item(item) -> dict[str, object]: + return { + "publication_id": item.publication_id, + "title": item.title, + "doi": item.doi, + "pdf_url": item.pdf_url, + "status": item.status, + "attempt_count": item.attempt_count, + "last_failure_reason": item.last_failure_reason, + "last_failure_detail": item.last_failure_detail, + "last_source": item.last_source, + "requested_by_user_id": item.requested_by_user_id, + "requested_by_email": item.requested_by_email, + "queued_at": item.queued_at, + "last_attempt_at": item.last_attempt_at, + "resolved_at": item.resolved_at, + "updated_at": item.updated_at, + } + + +def _requeue_response_state(*, queued: bool) -> tuple[str, str]: + if queued: + return "queued", "PDF lookup queued." + return "blocked", "PDF lookup is already queued or currently running." + + +def _bulk_enqueue_message(*, queued_count: int, requested_count: int) -> str: + if requested_count == 0: + return "No missing-PDF publications were found." + if queued_count == 0: + return "No publications were queued; all candidates were already in-flight." + return f"Queued {queued_count} publication(s) for PDF lookup." + + +def _resolve_pdf_queue_paging( + *, + page: int, + page_size: int, + limit: int | None, + offset: int | None, +) -> tuple[int, int, int]: + resolved_limit = int(limit) if limit is not None else int(page_size) + resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0) + resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1) + return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0) + + +def _pdf_queue_page_data(*, total_count: int, page: int, page_size: int, offset: int) -> dict[str, object]: + return { + "total_count": int(total_count), + "page": int(page), + "page_size": int(page_size), + "has_prev": int(offset) > 0, + "has_next": int(offset) + int(page_size) < int(total_count), + } + + +@router.get( + "/integrity", + response_model=AdminDbIntegrityEnvelope, +) +async def get_integrity_report( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + report = await collect_integrity_report(db_session) + logger.info( + "api.admin.db.integrity_checked", + extra={ + "event": "api.admin.db.integrity_checked", + "admin_user_id": int(admin_user.id), + "status": report.get("status"), + "failure_count": len(report.get("failures", [])), + "warning_count": len(report.get("warnings", [])), + }, + ) + return success_payload(request, data=report) + + +@router.get( + "/repair-jobs", + response_model=AdminDbRepairJobsEnvelope, +) +async def get_repair_jobs( + request: Request, + limit: int = Query(default=50, ge=1, le=200), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + jobs = await list_repair_jobs(db_session, limit=limit) + logger.info( + "api.admin.db.repair_jobs_listed", + extra={ + "event": "api.admin.db.repair_jobs_listed", + "admin_user_id": int(admin_user.id), + "limit": int(limit), + "job_count": len(jobs), + }, + ) + return success_payload( + request, + data={"jobs": [_serialize_repair_job(job) for job in jobs]}, + ) + + +@router.get( + "/pdf-queue", + response_model=AdminPdfQueueEnvelope, +) +async def get_pdf_queue( + request: Request, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=100, ge=1, le=500), + limit: int | None = Query(default=None, ge=1, le=500), + offset: int | None = Query(default=None, ge=0), + status: str | None = Query(default=None), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + normalized_status = (status or "").strip().lower() or None + resolved_page, resolved_limit, resolved_offset = _resolve_pdf_queue_paging( + page=page, + page_size=page_size, + limit=limit, + offset=offset, + ) + queue_page = await publication_service.list_pdf_queue_page( + db_session, + limit=resolved_limit, + offset=resolved_offset, + status=normalized_status, + ) + logger.info( + "api.admin.db.pdf_queue_listed", + extra={ + "event": "api.admin.db.pdf_queue_listed", + "admin_user_id": int(admin_user.id), + "page": int(resolved_page), + "page_size": int(resolved_limit), + "offset": int(resolved_offset), + "status": normalized_status, + "item_count": len(queue_page.items), + "total_count": int(queue_page.total_count), + }, + ) + return success_payload( + request, + data={ + "items": [_serialize_pdf_queue_item(item) for item in queue_page.items], + **_pdf_queue_page_data( + total_count=queue_page.total_count, + page=resolved_page, + page_size=resolved_limit, + offset=resolved_offset, + ), + }, + ) + + +@router.post( + "/pdf-queue/{publication_id}/requeue", + response_model=AdminPdfQueueRequeueEnvelope, +) +async def requeue_pdf_lookup( + request: Request, + publication_id: int = Path(ge=1), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + result = await publication_service.enqueue_retry_pdf_job_for_publication_id( + db_session, + user_id=int(admin_user.id), + request_email=admin_user.email, + publication_id=publication_id, + ) + if not result.publication_exists: + raise ApiException( + status_code=404, + code="publication_not_found", + message="Publication not found.", + ) + status, message = _requeue_response_state(queued=result.queued) + logger.info( + "api.admin.db.pdf_queue_requeue_requested", + extra={ + "event": "api.admin.db.pdf_queue_requeue_requested", + "admin_user_id": int(admin_user.id), + "publication_id": int(publication_id), + "queued": bool(result.queued), + }, + ) + return success_payload( + request, + data={ + "publication_id": int(publication_id), + "queued": bool(result.queued), + "status": status, + "message": message, + }, + ) + + +@router.post( + "/pdf-queue/requeue-all", + response_model=AdminPdfQueueBulkEnqueueEnvelope, +) +async def requeue_all_missing_pdfs( + request: Request, + limit: int = Query(default=1000, ge=1, le=5000), + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + result = await publication_service.enqueue_all_missing_pdf_jobs( + db_session, + user_id=int(admin_user.id), + request_email=admin_user.email, + limit=limit, + ) + logger.info( + "api.admin.db.pdf_queue_requeue_all", + extra={ + "event": "api.admin.db.pdf_queue_requeue_all", + "admin_user_id": int(admin_user.id), + "limit": int(limit), + "requested_count": int(result.requested_count), + "queued_count": int(result.queued_count), + }, + ) + return success_payload( + request, + data={ + "requested_count": int(result.requested_count), + "queued_count": int(result.queued_count), + "message": _bulk_enqueue_message( + queued_count=int(result.queued_count), + requested_count=int(result.requested_count), + ), + }, + ) + + +@router.post( + "/repairs/publication-links", + response_model=AdminRepairPublicationLinksEnvelope, +) +async def trigger_publication_link_repair( + payload: AdminRepairPublicationLinksRequest, + request: Request, + db_session: AsyncSession = Depends(get_db_session), + admin_user: User = Depends(get_api_admin_user), +): + try: + result = await run_publication_link_repair( + db_session, + scope_mode=payload.scope_mode, + user_id=payload.user_id, + scholar_profile_ids=payload.scholar_profile_ids, + dry_run=bool(payload.dry_run), + gc_orphan_publications=bool(payload.gc_orphan_publications), + requested_by=_requested_by_value(payload=payload, admin_user=admin_user), + ) + except ValueError as exc: + raise ApiException( + status_code=400, + code="invalid_repair_scope", + message=str(exc), + ) from exc + logger.info( + "api.admin.db.publication_link_repair_triggered", + extra={ + "event": "api.admin.db.publication_link_repair_triggered", + "admin_user_id": int(admin_user.id), + "scope_mode": payload.scope_mode, + "target_user_id": int(payload.user_id) if payload.user_id is not None else None, + "dry_run": bool(payload.dry_run), + "gc_orphan_publications": bool(payload.gc_orphan_publications), + "job_id": int(result["job_id"]), + "status": result["status"], + }, + ) + return success_payload(request, data=result) diff --git a/app/api/routers/publications.py b/app/api/routers/publications.py index 998cdea..5055c6c 100644 --- a/app/api/routers/publications.py +++ b/app/api/routers/publications.py @@ -16,6 +16,8 @@ from app.api.schemas import ( PublicationsListEnvelope, RetryPublicationPdfEnvelope, RetryPublicationPdfRequest, + TogglePublicationFavoriteEnvelope, + TogglePublicationFavoriteRequest, ) from app.db.models import User from app.db.session import get_db_session @@ -61,7 +63,12 @@ def _serialize_publication_item(item) -> dict[str, object]: "pub_url": item.pub_url, "doi": item.doi, "pdf_url": item.pdf_url, + "pdf_status": item.pdf_status, + "pdf_attempt_count": item.pdf_attempt_count, + "pdf_failure_reason": item.pdf_failure_reason, + "pdf_failure_detail": item.pdf_failure_detail, "is_read": item.is_read, + "is_favorite": item.is_favorite, "first_seen_at": item.first_seen_at, "is_new_in_latest_run": item.is_new_in_latest_run, } @@ -72,46 +79,235 @@ async def _publication_counts( *, user_id: int, selected_scholar_id: int | None, -) -> tuple[int, int, int]: + favorite_only: bool, +) -> tuple[int, int, int, int]: unread_count = await publication_service.count_unread_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, + favorite_only=favorite_only, + ) + favorites_count = await publication_service.count_favorite_for_user( + db_session, + user_id=user_id, + scholar_profile_id=selected_scholar_id, ) latest_count = await publication_service.count_latest_for_user( db_session, user_id=user_id, scholar_profile_id=selected_scholar_id, + favorite_only=favorite_only, ) total_count = await publication_service.count_for_user( db_session, user_id=user_id, mode=publication_service.MODE_ALL, scholar_profile_id=selected_scholar_id, + favorite_only=favorite_only, ) - return unread_count, latest_count, total_count + return unread_count, favorites_count, latest_count, total_count + + +async def _list_publications_for_request( + db_session: AsyncSession, + *, + current_user: User, + mode: Literal["all", "unread", "latest", "new"] | None, + favorite_only: bool, + scholar_profile_id: int | None, + limit: int, + offset: int, +) -> tuple[str, int | None, list]: + resolved_mode = publication_service.resolve_publication_view_mode(mode) + selected_scholar_id = scholar_profile_id + await _require_selected_profile( + db_session, + user_id=current_user.id, + selected_scholar_id=selected_scholar_id, + ) + publications = await publication_service.list_for_user( + db_session, + user_id=current_user.id, + mode=resolved_mode, + scholar_profile_id=selected_scholar_id, + favorite_only=favorite_only, + limit=limit, + offset=offset, + ) + await publication_service.schedule_missing_pdf_enrichment_for_user( + db_session, + user_id=current_user.id, + request_email=current_user.email, + items=publications, + max_items=settings.unpaywall_max_items_per_request, + ) + hydrated = await publication_service.hydrate_pdf_enrichment_state( + db_session, + items=publications, + ) + return resolved_mode, selected_scholar_id, hydrated + + +def _resolve_publications_paging( + *, + page: int, + page_size: int, + limit: int | None, + offset: int | None, +) -> tuple[int, int, int]: + resolved_limit = int(limit) if limit is not None else int(page_size) + resolved_offset = int(offset) if offset is not None else max((int(page) - 1) * resolved_limit, 0) + resolved_page = max((resolved_offset // max(resolved_limit, 1)) + 1, 1) + return resolved_page, max(resolved_limit, 1), max(resolved_offset, 0) def _publications_list_data( *, mode: str, + favorite_only: bool, selected_scholar_id: int | None, unread_count: int, + favorites_count: int, latest_count: int, total_count: int, publications: list, + page: int, + page_size: int, + offset: int, ) -> dict[str, object]: return { "mode": mode, + "favorite_only": favorite_only, "selected_scholar_profile_id": selected_scholar_id, "unread_count": unread_count, + "favorites_count": favorites_count, "latest_count": latest_count, "new_count": latest_count, "total_count": total_count, + "page": int(page), + "page_size": int(page_size), + "has_prev": int(offset) > 0, + "has_next": int(offset) + int(page_size) < int(total_count), "publications": [_serialize_publication_item(item) for item in publications], } +def _retry_pdf_message(*, queued: bool, resolved_pdf: bool, pdf_status: str) -> str: + if resolved_pdf: + return "Open-access PDF link already resolved." + if queued: + return "PDF lookup queued." + if pdf_status in {"queued", "running"}: + return "PDF lookup is already queued." + return "No open-access PDF link found." + + +def _favorite_message(*, is_favorite: bool) -> str: + if is_favorite: + return "Publication marked as favorite." + return "Publication removed from favorites." + + +async def _retry_publication_state( + db_session: AsyncSession, + *, + current_user: User, + scholar_profile_id: int, + publication_id: int, +): + publication = await publication_service.retry_pdf_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + ) + if publication is None: + raise ApiException( + status_code=404, + code="publication_not_found", + message="Publication not found.", + ) + queued = False + if not publication.pdf_url: + queued = await publication_service.schedule_retry_pdf_enrichment_for_row( + db_session, + user_id=current_user.id, + request_email=current_user.email, + item=publication, + ) + hydrated = await publication_service.hydrate_pdf_enrichment_state( + db_session, + items=[publication], + ) + current = hydrated[0] if hydrated else publication + return current, queued + + +async def _favorite_publication_state( + db_session: AsyncSession, + *, + current_user: User, + scholar_profile_id: int, + publication_id: int, + is_favorite: bool, +): + updated_count = await publication_service.set_publication_favorite_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + is_favorite=is_favorite, + ) + if updated_count <= 0: + raise ApiException( + status_code=404, + code="publication_not_found", + message="Publication not found.", + ) + publication = await publication_service.get_publication_item_for_user( + db_session, + user_id=current_user.id, + scholar_profile_id=scholar_profile_id, + publication_id=publication_id, + ) + if publication is None: + raise ApiException( + status_code=404, + code="publication_not_found", + message="Publication not found.", + ) + hydrated = await publication_service.hydrate_pdf_enrichment_state( + db_session, + items=[publication], + ) + return hydrated[0] if hydrated else publication + + +def _log_retry_pdf_result( + *, + current_user: User, + scholar_profile_id: int, + publication_id: int, + queued: bool, + resolved_pdf: bool, + pdf_status: str, + doi: str | None, +) -> None: + logger.info( + "api.publications.retry_pdf", + extra={ + "event": "api.publications.retry_pdf", + "user_id": current_user.id, + "scholar_profile_id": scholar_profile_id, + "publication_id": publication_id, + "queued": queued, + "resolved_pdf": resolved_pdf, + "pdf_status": pdf_status, + "has_doi": bool(doi), + }, + ) + + @router.get( "", response_model=PublicationsListEnvelope, @@ -119,44 +315,48 @@ def _publications_list_data( async def list_publications( request: Request, mode: Literal["all", "unread", "latest", "new"] | None = Query(default=None), + favorite_only: bool = Query(default=False), scholar_profile_id: int | None = Query(default=None, ge=1), - limit: int = Query(default=300, ge=1, le=1000), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=100, ge=1, le=500), + limit: int | None = Query(default=None, ge=1, le=1000), + offset: int | None = Query(default=None, ge=0), db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - resolved_mode = publication_service.resolve_publication_view_mode(mode) - selected_scholar_id = scholar_profile_id - await _require_selected_profile( - db_session, - user_id=current_user.id, - selected_scholar_id=selected_scholar_id, - ) - - publications = await publication_service.list_for_user( - db_session, - user_id=current_user.id, - mode=resolved_mode, - scholar_profile_id=selected_scholar_id, + resolved_page, resolved_limit, resolved_offset = _resolve_publications_paging( + page=page, + page_size=page_size, limit=limit, + offset=offset, ) - await publication_service.schedule_missing_pdf_enrichment_for_user( - user_id=current_user.id, - request_email=current_user.email, - items=publications, - max_items=settings.unpaywall_max_items_per_request, + resolved_mode, selected_scholar_id, publications = await _list_publications_for_request( + db_session, + current_user=current_user, + mode=mode, + favorite_only=favorite_only, + scholar_profile_id=scholar_profile_id, + limit=resolved_limit, + offset=resolved_offset, ) - unread_count, latest_count, total_count = await _publication_counts( + unread_count, favorites_count, latest_count, total_count = await _publication_counts( db_session, user_id=current_user.id, selected_scholar_id=selected_scholar_id, + favorite_only=favorite_only, ) data = _publications_list_data( mode=resolved_mode, + favorite_only=favorite_only, selected_scholar_id=selected_scholar_id, unread_count=unread_count, + favorites_count=favorites_count, latest_count=latest_count, total_count=total_count, publications=publications, + page=resolved_page, + page_size=resolved_limit, + offset=resolved_offset, ) return success_payload(request, data=data) @@ -242,37 +442,70 @@ async def retry_publication_pdf( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_api_current_user), ): - publication = await publication_service.retry_pdf_for_user( + current, queued = await _retry_publication_state( db_session, - user_id=current_user.id, + current_user=current_user, scholar_profile_id=payload.scholar_profile_id, publication_id=publication_id, - unpaywall_email=current_user.email, ) - if publication is None: - raise ApiException( - status_code=404, - code="publication_not_found", - message="Publication not found.", - ) - resolved_pdf = bool(publication.pdf_url) - logger.info( - "api.publications.retry_pdf", - extra={ - "event": "api.publications.retry_pdf", - "user_id": current_user.id, - "scholar_profile_id": payload.scholar_profile_id, - "publication_id": publication_id, - "resolved_pdf": resolved_pdf, - "has_doi": bool(publication.doi), - }, + resolved_pdf = bool(current.pdf_url) + message = _retry_pdf_message( + queued=queued, + resolved_pdf=resolved_pdf, + pdf_status=current.pdf_status, + ) + _log_retry_pdf_result( + current_user=current_user, + scholar_profile_id=payload.scholar_profile_id, + publication_id=publication_id, + queued=queued, + resolved_pdf=resolved_pdf, + pdf_status=current.pdf_status, + doi=current.doi, ) - message = "Open-access PDF link resolved." if resolved_pdf else "No open-access PDF link found." return success_payload( request, data={ "message": message, + "queued": queued, "resolved_pdf": resolved_pdf, - "publication": _serialize_publication_item(publication), + "publication": _serialize_publication_item(current), + }, + ) + + +@router.post( + "/{publication_id}/favorite", + response_model=TogglePublicationFavoriteEnvelope, +) +async def toggle_publication_favorite( + payload: TogglePublicationFavoriteRequest, + request: Request, + publication_id: int = Path(ge=1), + db_session: AsyncSession = Depends(get_db_session), + current_user: User = Depends(get_api_current_user), +): + current = await _favorite_publication_state( + db_session, + current_user=current_user, + scholar_profile_id=payload.scholar_profile_id, + publication_id=publication_id, + is_favorite=payload.is_favorite, + ) + logger.info( + "api.publications.favorite", + extra={ + "event": "api.publications.favorite", + "user_id": current_user.id, + "scholar_profile_id": payload.scholar_profile_id, + "publication_id": publication_id, + "is_favorite": bool(payload.is_favorite), + }, + ) + return success_payload( + request, + data={ + "message": _favorite_message(is_favorite=bool(payload.is_favorite)), + "publication": _serialize_publication_item(current), }, ) diff --git a/app/api/schemas.py b/app/api/schemas.py index 9deb89a..3ae8876 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -1,9 +1,9 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class ApiMeta(BaseModel): @@ -525,6 +525,175 @@ class AdminResetPasswordRequest(BaseModel): model_config = ConfigDict(extra="forbid") +class AdminDbIntegrityCheckData(BaseModel): + name: str + count: int + severity: str + message: str + + model_config = ConfigDict(extra="forbid") + + +class AdminDbIntegrityData(BaseModel): + status: str + checked_at: datetime + failures: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + checks: list[AdminDbIntegrityCheckData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class AdminDbIntegrityEnvelope(BaseModel): + data: AdminDbIntegrityData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminDbRepairJobData(BaseModel): + id: int + job_name: str + requested_by: str | None + dry_run: bool + status: str + scope: dict[str, Any] = Field(default_factory=dict) + summary: dict[str, Any] = Field(default_factory=dict) + error_text: str | None + started_at: datetime | None + finished_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(extra="forbid") + + +class AdminDbRepairJobsData(BaseModel): + jobs: list[AdminDbRepairJobData] = Field(default_factory=list) + + model_config = ConfigDict(extra="forbid") + + +class AdminDbRepairJobsEnvelope(BaseModel): + data: AdminDbRepairJobsData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueItemData(BaseModel): + publication_id: int + title: str + doi: str | None + pdf_url: str | None + status: str + attempt_count: int + last_failure_reason: str | None + last_failure_detail: str | None + last_source: str | None + requested_by_user_id: int | None + requested_by_email: str | None + queued_at: datetime | None + last_attempt_at: datetime | None + resolved_at: datetime | None + updated_at: datetime + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueData(BaseModel): + items: list[AdminPdfQueueItemData] = Field(default_factory=list) + total_count: int = 0 + page: int = 1 + page_size: int = 100 + has_next: bool = False + has_prev: bool = False + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueEnvelope(BaseModel): + data: AdminPdfQueueData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueRequeueData(BaseModel): + publication_id: int + queued: bool + status: str + message: str + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueRequeueEnvelope(BaseModel): + data: AdminPdfQueueRequeueData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueBulkEnqueueData(BaseModel): + requested_count: int + queued_count: int + message: str + + model_config = ConfigDict(extra="forbid") + + +class AdminPdfQueueBulkEnqueueEnvelope(BaseModel): + data: AdminPdfQueueBulkEnqueueData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + +class AdminRepairPublicationLinksRequest(BaseModel): + scope_mode: Literal["single_user", "all_users"] = "single_user" + user_id: int | None = Field(default=None, ge=1) + scholar_profile_ids: list[int] = Field(default_factory=list, max_length=200) + dry_run: bool = True + gc_orphan_publications: bool = False + requested_by: str | None = None + confirmation_text: str | None = None + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_scope(self) -> "AdminRepairPublicationLinksRequest": + if self.scope_mode == "single_user" and self.user_id is None: + raise ValueError("user_id is required when scope_mode=single_user.") + if self.scope_mode == "all_users" and self.user_id is not None: + raise ValueError("user_id must be omitted when scope_mode=all_users.") + if not self.dry_run and self.scope_mode == "all_users": + expected = "REPAIR ALL USERS" + provided = (self.confirmation_text or "").strip() + if provided != expected: + raise ValueError( + "confirmation_text must equal 'REPAIR ALL USERS' " + "when applying a repair to all users." + ) + return self + + +class AdminRepairPublicationLinksResultData(BaseModel): + job_id: int + status: str + scope: dict[str, Any] = Field(default_factory=dict) + summary: dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + +class AdminRepairPublicationLinksEnvelope(BaseModel): + data: AdminRepairPublicationLinksResultData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") + + class SettingsPolicyData(BaseModel): min_run_interval_minutes: int min_request_delay_seconds: int @@ -576,7 +745,12 @@ class PublicationItemData(BaseModel): pub_url: str | None doi: str | None pdf_url: str | None + pdf_status: str = "untracked" + pdf_attempt_count: int = 0 + pdf_failure_reason: str | None = None + pdf_failure_detail: str | None = None is_read: bool + is_favorite: bool = False first_seen_at: datetime is_new_in_latest_run: bool @@ -585,11 +759,17 @@ class PublicationItemData(BaseModel): class PublicationsListData(BaseModel): mode: str + favorite_only: bool = False selected_scholar_profile_id: int | None unread_count: int + favorites_count: int latest_count: int new_count: int total_count: int + page: int + page_size: int + has_next: bool = False + has_prev: bool = False publications: list[PublicationItemData] model_config = ConfigDict(extra="forbid") @@ -652,6 +832,7 @@ class RetryPublicationPdfRequest(BaseModel): class RetryPublicationPdfData(BaseModel): message: str + queued: bool resolved_pdf: bool publication: PublicationItemData @@ -663,3 +844,24 @@ class RetryPublicationPdfEnvelope(BaseModel): meta: ApiMeta model_config = ConfigDict(extra="forbid") + + +class TogglePublicationFavoriteRequest(BaseModel): + scholar_profile_id: int = Field(ge=1) + is_favorite: bool + + model_config = ConfigDict(extra="forbid") + + +class TogglePublicationFavoriteData(BaseModel): + message: str + publication: PublicationItemData + + model_config = ConfigDict(extra="forbid") + + +class TogglePublicationFavoriteEnvelope(BaseModel): + data: TogglePublicationFavoriteData + meta: ApiMeta + + model_config = ConfigDict(extra="forbid") diff --git a/app/db/models.py b/app/db/models.py index e9e4303..43b44c2 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -81,8 +81,8 @@ class UserSetting(Base): name="run_interval_minutes_min_15", ), CheckConstraint( - "request_delay_seconds >= 1", - name="request_delay_seconds_min_1", + "request_delay_seconds >= 2", + name="request_delay_seconds_min_2", ), ) @@ -237,10 +237,80 @@ class Publication(Base): ) +class PublicationPdfJob(Base): + __tablename__ = "publication_pdf_jobs" + __table_args__ = ( + CheckConstraint( + "status IN ('queued', 'running', 'resolved', 'failed')", + name="publication_pdf_jobs_status_valid", + ), + Index("ix_publication_pdf_jobs_status", "status"), + Index("ix_publication_pdf_jobs_updated_at", "updated_at"), + Index("ix_publication_pdf_jobs_queued_at", "queued_at"), + ) + + publication_id: Mapped[int] = mapped_column( + ForeignKey("publications.id", ondelete="CASCADE"), + primary_key=True, + ) + status: Mapped[str] = mapped_column( + String(16), + nullable=False, + server_default=text("'queued'"), + ) + attempt_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + server_default=text("0"), + ) + queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_failure_reason: Mapped[str | None] = mapped_column(String(64)) + last_failure_detail: Mapped[str | None] = mapped_column(Text) + last_source: Mapped[str | None] = mapped_column(String(32)) + last_requested_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + +class PublicationPdfJobEvent(Base): + __tablename__ = "publication_pdf_job_events" + __table_args__ = ( + Index("ix_publication_pdf_job_events_publication_created", "publication_id", "created_at"), + Index("ix_publication_pdf_job_events_created_at", "created_at"), + Index("ix_publication_pdf_job_events_event_type", "event_type"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + publication_id: Mapped[int] = mapped_column( + ForeignKey("publications.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + ) + event_type: Mapped[str] = mapped_column(String(32), nullable=False) + status: Mapped[str | None] = mapped_column(String(16)) + source: Mapped[str | None] = mapped_column(String(32)) + failure_reason: Mapped[str | None] = mapped_column(String(64)) + message: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + class ScholarPublication(Base): __tablename__ = "scholar_publications" __table_args__ = ( Index("ix_scholar_publications_is_read", "is_read"), + Index("ix_scholar_publications_is_favorite", "is_favorite"), ) scholar_profile_id: Mapped[int] = mapped_column( @@ -254,6 +324,9 @@ class ScholarPublication(Base): is_read: Mapped[bool] = mapped_column( Boolean, nullable=False, server_default=text("false") ) + is_favorite: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) first_seen_run_id: Mapped[int | None] = mapped_column( ForeignKey("crawl_runs.id", ondelete="SET NULL") ) @@ -322,6 +395,51 @@ class IngestionQueueItem(Base): ) +class DataRepairJob(Base): + __tablename__ = "data_repair_jobs" + __table_args__ = ( + CheckConstraint( + "status IN ('planned', 'running', 'completed', 'failed')", + name="data_repair_jobs_status_valid", + ), + Index("ix_data_repair_jobs_created_at", "created_at"), + Index("ix_data_repair_jobs_job_name_created_at", "job_name", "created_at"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + job_name: Mapped[str] = mapped_column(String(64), nullable=False) + requested_by: Mapped[str | None] = mapped_column(String(255)) + scope: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + server_default=text("'{}'::jsonb"), + ) + dry_run: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + server_default=text("true"), + ) + status: Mapped[str] = mapped_column( + String(16), + nullable=False, + server_default=text("'planned'"), + ) + summary: Mapped[dict] = mapped_column( + JSONB, + nullable=False, + server_default=text("'{}'::jsonb"), + ) + error_text: Mapped[str | None] = mapped_column(Text) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + class AuthorSearchRuntimeState(Base): __tablename__ = "author_search_runtime_state" diff --git a/app/http/middleware.py b/app/http/middleware.py index 49a40a4..ebe72f8 100644 --- a/app/http/middleware.py +++ b/app/http/middleware.py @@ -61,7 +61,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): start = time.perf_counter() should_log = self._log_requests and not self._is_skipped_path(request.url.path) if should_log: - logger.info( + logger.debug( "request.started", extra={ "event": "request.started", @@ -88,7 +88,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware): duration_ms = int((time.perf_counter() - start) * 1000) response.headers[REQUEST_ID_HEADER] = request_id if should_log: - logger.info( + logger.debug( "request.completed", extra={ "event": "request.completed", diff --git a/app/services/domains/dbops/__init__.py b/app/services/domains/dbops/__init__.py new file mode 100644 index 0000000..f5b02e4 --- /dev/null +++ b/app/services/domains/dbops/__init__.py @@ -0,0 +1,5 @@ +from app.services.domains.dbops.application import run_publication_link_repair +from app.services.domains.dbops.integrity import collect_integrity_report +from app.services.domains.dbops.query import list_repair_jobs + +__all__ = ["collect_integrity_report", "list_repair_jobs", "run_publication_link_repair"] diff --git a/app/services/domains/dbops/application.py b/app/services/domains/dbops/application.py new file mode 100644 index 0000000..e7ace77 --- /dev/null +++ b/app/services/domains/dbops/application.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import delete, exists, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import DataRepairJob, IngestionQueueItem, Publication, ScholarProfile, ScholarPublication + + +REPAIR_STATUS_PLANNED = "planned" +REPAIR_STATUS_RUNNING = "running" +REPAIR_STATUS_COMPLETED = "completed" +REPAIR_STATUS_FAILED = "failed" +SCOPE_MODE_SINGLE_USER = "single_user" +SCOPE_MODE_ALL_USERS = "all_users" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _normalize_scope_mode(scope_mode: str) -> str: + normalized = scope_mode.strip().lower() + if normalized in {SCOPE_MODE_SINGLE_USER, SCOPE_MODE_ALL_USERS}: + return normalized + raise ValueError("Unknown scope mode.") + + +def _scope_user_id(*, scope_mode: str, user_id: int | None) -> int | None: + if scope_mode == SCOPE_MODE_SINGLE_USER: + if user_id is None: + raise ValueError("user_id is required when scope_mode=single_user.") + return int(user_id) + if user_id is not None: + raise ValueError("user_id must be omitted when scope_mode=all_users.") + return None + + +def _scope_payload( + *, + scope_mode: str, + user_id: int | None, + target_scholar_profile_ids: list[int], + orphan_gc: bool, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "scope_mode": scope_mode, + "scholar_profile_ids": [int(value) for value in target_scholar_profile_ids], + "gc_orphan_publications": bool(orphan_gc), + } + if user_id is not None: + payload["user_id"] = int(user_id) + return payload + + +async def _target_scholar_profile_ids( + db_session: AsyncSession, + *, + scope_mode: str, + user_id: int | None, + scholar_profile_ids: list[int] | None, +) -> list[int]: + stmt = select(ScholarProfile.id) + if scope_mode == SCOPE_MODE_SINGLE_USER: + stmt = stmt.where(ScholarProfile.user_id == user_id) + if scholar_profile_ids: + normalized_ids = [int(value) for value in scholar_profile_ids] + stmt = stmt.where(ScholarProfile.id.in_(normalized_ids)) + result = await db_session.execute(stmt.order_by(ScholarProfile.id.asc())) + ids = [int(row[0]) for row in result.all()] + if not ids: + raise ValueError("No target scholar profiles found for the requested scope.") + return ids + + +async def _count_scope( + db_session: AsyncSession, + *, + user_id: int | None, + target_scholar_profile_ids: list[int], +) -> dict[str, int]: + links_result = await db_session.execute( + select(func.count()) + .select_from(ScholarPublication) + .where(ScholarPublication.scholar_profile_id.in_(target_scholar_profile_ids)) + ) + queue_stmt = ( + select(func.count()) + .select_from(IngestionQueueItem) + .where(IngestionQueueItem.scholar_profile_id.in_(target_scholar_profile_ids)) + ) + if user_id is not None: + queue_stmt = queue_stmt.where(IngestionQueueItem.user_id == user_id) + queue_result = await db_session.execute(queue_stmt) + return { + "target_scholar_count": len(target_scholar_profile_ids), + "links_in_scope": int(links_result.scalar_one() or 0), + "queue_items_in_scope": int(queue_result.scalar_one() or 0), + } + + +async def _count_orphan_publications(db_session: AsyncSession) -> int: + stmt = ( + select(func.count()) + .select_from(Publication) + .where( + ~exists( + select(1).where(ScholarPublication.publication_id == Publication.id) + ) + ) + ) + result = await db_session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def _create_job( + db_session: AsyncSession, + *, + requested_by: str | None, + scope: dict[str, Any], + dry_run: bool, +) -> DataRepairJob: + job = DataRepairJob( + job_name="repair_publication_links", + requested_by=(requested_by or "").strip() or None, + scope=scope, + dry_run=dry_run, + status=REPAIR_STATUS_PLANNED, + summary={}, + ) + db_session.add(job) + await db_session.flush() + return job + + +def _job_summary( + *, + counts: dict[str, int], + dry_run: bool, + links_deleted: int, + queue_items_deleted: int, + scholars_reset: int, + orphan_publications_before: int, + orphan_publications_deleted: int, +) -> dict[str, Any]: + return { + **counts, + "dry_run": bool(dry_run), + "links_deleted": int(links_deleted), + "queue_items_deleted": int(queue_items_deleted), + "scholars_reset": int(scholars_reset), + "orphan_publications_before": int(orphan_publications_before), + "orphan_publications_deleted": int(orphan_publications_deleted), + } + + +async def _delete_links_for_targets(db_session: AsyncSession, *, target_scholar_profile_ids: list[int]) -> int: + result = await db_session.execute( + delete(ScholarPublication).where( + ScholarPublication.scholar_profile_id.in_(target_scholar_profile_ids) + ) + ) + return int(result.rowcount or 0) + + +async def _delete_queue_for_targets( + db_session: AsyncSession, + *, + user_id: int | None, + target_scholar_profile_ids: list[int], +) -> int: + stmt = delete(IngestionQueueItem).where( + IngestionQueueItem.scholar_profile_id.in_(target_scholar_profile_ids) + ) + if user_id is not None: + stmt = stmt.where(IngestionQueueItem.user_id == user_id) + result = await db_session.execute(stmt) + return int(result.rowcount or 0) + + +async def _reset_scholar_tracking_state( + db_session: AsyncSession, + *, + user_id: int | None, + target_scholar_profile_ids: list[int], +) -> int: + stmt = update(ScholarProfile).where(ScholarProfile.id.in_(target_scholar_profile_ids)) + if user_id is not None: + stmt = stmt.where(ScholarProfile.user_id == user_id) + result = await db_session.execute( + stmt.values( + baseline_completed=False, + last_initial_page_fingerprint_sha256=None, + last_initial_page_checked_at=None, + last_run_dt=None, + last_run_status=None, + ) + ) + return int(result.rowcount or 0) + + +async def _delete_orphan_publications(db_session: AsyncSession) -> int: + result = await db_session.execute( + delete(Publication).where( + ~exists(select(1).where(ScholarPublication.publication_id == Publication.id)) + ) + ) + return int(result.rowcount or 0) + + +async def _mutation_counts( + db_session: AsyncSession, + *, + user_id: int | None, + target_ids: list[int], + dry_run: bool, + gc_orphan_publications: bool, +) -> tuple[int, int, int, int]: + if dry_run: + return 0, 0, 0, 0 + links_deleted = await _delete_links_for_targets( + db_session, + target_scholar_profile_ids=target_ids, + ) + queue_deleted = await _delete_queue_for_targets( + db_session, + user_id=user_id, + target_scholar_profile_ids=target_ids, + ) + scholars_reset = await _reset_scholar_tracking_state( + db_session, + user_id=user_id, + target_scholar_profile_ids=target_ids, + ) + orphan_deleted = 0 + if gc_orphan_publications: + orphan_deleted = await _delete_orphan_publications(db_session) + return links_deleted, queue_deleted, scholars_reset, orphan_deleted + + +def _result_payload(*, job: DataRepairJob, scope: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]: + return { + "job_id": int(job.id), + "status": job.status, + "scope": scope, + "summary": summary, + } + + +async def _complete_job( + db_session: AsyncSession, + *, + job: DataRepairJob, + summary: dict[str, Any], + scope: dict[str, Any], +) -> dict[str, Any]: + job.summary = summary + job.status = REPAIR_STATUS_COMPLETED + job.finished_at = _utcnow() + await db_session.commit() + return _result_payload(job=job, scope=scope, summary=summary) + + +async def _fail_job(db_session: AsyncSession, *, job: DataRepairJob, error: Exception) -> None: + await db_session.rollback() + job.status = REPAIR_STATUS_FAILED + job.error_text = str(error) + job.finished_at = _utcnow() + db_session.add(job) + await db_session.commit() + + +async def _prepare_repair_job( + db_session: AsyncSession, + *, + scope_mode: str, + user_id: int | None, + scholar_profile_ids: list[int] | None, + dry_run: bool, + gc_orphan_publications: bool, + requested_by: str | None, +) -> tuple[int | None, list[int], dict[str, Any], DataRepairJob]: + normalized_scope = _normalize_scope_mode(scope_mode) + scope_user_id = _scope_user_id(scope_mode=normalized_scope, user_id=user_id) + target_ids = await _target_scholar_profile_ids( + db_session, + scope_mode=normalized_scope, + user_id=scope_user_id, + scholar_profile_ids=scholar_profile_ids, + ) + scope = _scope_payload( + scope_mode=normalized_scope, + user_id=scope_user_id, + target_scholar_profile_ids=target_ids, + orphan_gc=gc_orphan_publications, + ) + job = await _create_job(db_session, requested_by=requested_by, scope=scope, dry_run=dry_run) + job.status = REPAIR_STATUS_RUNNING + job.started_at = _utcnow() + return scope_user_id, target_ids, scope, job + + +async def _build_repair_summary( + db_session: AsyncSession, + *, + scope_user_id: int | None, + target_ids: list[int], + dry_run: bool, + gc_orphan_publications: bool, +) -> dict[str, Any]: + counts = await _count_scope(db_session, user_id=scope_user_id, target_scholar_profile_ids=target_ids) + orphan_before = await _count_orphan_publications(db_session) + links_deleted, queue_deleted, scholars_reset, orphan_deleted = await _mutation_counts( + db_session, + user_id=scope_user_id, + target_ids=target_ids, + dry_run=dry_run, + gc_orphan_publications=gc_orphan_publications, + ) + return _job_summary( + counts=counts, + dry_run=dry_run, + links_deleted=links_deleted, + queue_items_deleted=queue_deleted, + scholars_reset=scholars_reset, + orphan_publications_before=orphan_before, + orphan_publications_deleted=orphan_deleted, + ) + + +async def run_publication_link_repair( + db_session: AsyncSession, + *, + scope_mode: str = SCOPE_MODE_SINGLE_USER, + user_id: int | None = None, + scholar_profile_ids: list[int] | None = None, + dry_run: bool = True, + gc_orphan_publications: bool = False, + requested_by: str | None = None, +) -> dict[str, Any]: + scope_user_id, target_ids, scope, job = await _prepare_repair_job( + db_session, + scope_mode=scope_mode, + user_id=user_id, + scholar_profile_ids=scholar_profile_ids, + dry_run=dry_run, + gc_orphan_publications=gc_orphan_publications, + requested_by=requested_by, + ) + + try: + summary = await _build_repair_summary( + db_session, + scope_user_id=scope_user_id, + target_ids=target_ids, + dry_run=dry_run, + gc_orphan_publications=gc_orphan_publications, + ) + return await _complete_job(db_session, job=job, summary=summary, scope=scope) + except Exception as exc: + await _fail_job(db_session, job=job, error=exc) + raise diff --git a/app/services/domains/dbops/integrity.py b/app/services/domains/dbops/integrity.py new file mode 100644 index 0000000..4d9c43c --- /dev/null +++ b/app/services/domains/dbops/integrity.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Publication + +INTEGRITY_CHECK_DEFS = ( + ( + "legacy_cluster_id_format", + "warning", + "Publications with non-namespaced cluster IDs.", + ), + ( + "negative_citation_count", + "failure", + "Publications with negative citation counts.", + ), + ( + "orphan_publications", + "warning", + "Publications with no scholar links.", + ), + ( + "orphan_scholar_publication_links", + "failure", + "Link rows missing parent scholar/publication.", + ), + ( + "duplicate_fingerprint_keys", + "failure", + "Duplicate publication fingerprint keys.", + ), + ( + "duplicate_cluster_ids", + "failure", + "Duplicate non-null publication cluster IDs.", + ), + ( + "missing_pdf_url", + "metric", + "Publications without a resolved PDF URL.", + ), +) + + +async def _legacy_cluster_id_count(db_session: AsyncSession) -> int: + stmt = ( + select(func.count()) + .select_from(Publication) + .where(Publication.cluster_id.is_not(None)) + .where(~Publication.cluster_id.like("cfv:%")) + .where(~Publication.cluster_id.like("cluster:%")) + ) + result = await db_session.execute(stmt) + return int(result.scalar_one() or 0) + + +async def _negative_citation_count(db_session: AsyncSession) -> int: + result = await db_session.execute( + select(func.count()).select_from(Publication).where(Publication.citation_count < 0) + ) + return int(result.scalar_one() or 0) + + +async def _missing_pdf_url_count(db_session: AsyncSession) -> int: + result = await db_session.execute( + select(func.count()).select_from(Publication).where(Publication.pdf_url.is_(None)) + ) + return int(result.scalar_one() or 0) + + +async def _count_from_sql(db_session: AsyncSession, *, sql: str) -> int: + result = await db_session.execute(text(sql)) + return int(result.scalar_one() or 0) + + +def _issues_for_severity(*, checks: list[dict[str, Any]], severity: str) -> list[str]: + return [row["name"] for row in checks if row["severity"] == severity and row["count"] > 0] + + +def _check_row(*, name: str, count: int, severity: str, message: str) -> dict[str, Any]: + return { + "name": name, + "count": int(count), + "severity": severity, + "message": message, + } + + +async def _collect_counts(db_session: AsyncSession) -> dict[str, int]: + orphan_publications = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM publications p " + "LEFT JOIN scholar_publications sp ON sp.publication_id = p.id " + "WHERE sp.publication_id IS NULL" + ), + ) + orphan_links = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM scholar_publications sp " + "LEFT JOIN publications p ON p.id = sp.publication_id " + "LEFT JOIN scholar_profiles s ON s.id = sp.scholar_profile_id " + "WHERE p.id IS NULL OR s.id IS NULL" + ), + ) + duplicate_fingerprints = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM (" + "SELECT fingerprint_sha256 FROM publications " + "GROUP BY fingerprint_sha256 HAVING count(*) > 1" + ") dup" + ), + ) + duplicate_cluster_ids = await _count_from_sql( + db_session, + sql=( + "SELECT count(*) FROM (" + "SELECT cluster_id FROM publications " + "WHERE cluster_id IS NOT NULL " + "GROUP BY cluster_id HAVING count(*) > 1" + ") dup" + ), + ) + return { + "legacy_cluster_id_format": await _legacy_cluster_id_count(db_session), + "negative_citation_count": await _negative_citation_count(db_session), + "orphan_publications": orphan_publications, + "orphan_scholar_publication_links": orphan_links, + "duplicate_fingerprint_keys": duplicate_fingerprints, + "duplicate_cluster_ids": duplicate_cluster_ids, + "missing_pdf_url": await _missing_pdf_url_count(db_session), + } + + +def _build_checks(*, counts: dict[str, int]) -> list[dict[str, Any]]: + return [ + _check_row( + name=name, + count=counts[name], + severity=severity, + message=message, + ) + for name, severity, message in INTEGRITY_CHECK_DEFS + ] + + +def _status_from_issues(*, failures: list[str], warnings: list[str]) -> str: + status = "ok" + if failures: + status = "failed" + elif warnings: + status = "warning" + return status + + +async def collect_integrity_report(db_session: AsyncSession) -> dict[str, Any]: + counts = await _collect_counts(db_session) + checks = _build_checks(counts=counts) + failures = _issues_for_severity(checks=checks, severity="failure") + warnings = _issues_for_severity(checks=checks, severity="warning") + + return { + "status": _status_from_issues(failures=failures, warnings=warnings), + "checked_at": datetime.now(timezone.utc).isoformat(), + "failures": failures, + "warnings": warnings, + "checks": checks, + } diff --git a/app/services/domains/dbops/query.py b/app/services/domains/dbops/query.py new file mode 100644 index 0000000..2e95e31 --- /dev/null +++ b/app/services/domains/dbops/query.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import DataRepairJob + + +def _bounded_limit(limit: int) -> int: + return max(1, min(int(limit), 200)) + + +async def list_repair_jobs( + db_session: AsyncSession, + *, + limit: int = 50, +) -> list[DataRepairJob]: + bounded = _bounded_limit(limit) + result = await db_session.execute( + select(DataRepairJob).order_by(DataRepairJob.created_at.desc()).limit(bounded) + ) + return list(result.scalars()) diff --git a/app/services/domains/ingestion/application.py b/app/services/domains/ingestion/application.py index aa2844d..54a7e25 100644 --- a/app/services/domains/ingestion/application.py +++ b/app/services/domains/ingestion/application.py @@ -91,6 +91,35 @@ class ScholarIngestionService: def __init__(self, *, source: ScholarSource) -> None: self._source = source + @staticmethod + def _effective_request_delay_seconds(value: int) -> int: + policy_minimum = user_settings_service.resolve_request_delay_minimum( + settings.ingestion_min_request_delay_seconds + ) + return max(policy_minimum, _int_or_default(value, policy_minimum)) + + @staticmethod + def _log_request_delay_coercion( + *, + user_id: int, + requested_request_delay_seconds: int, + effective_request_delay_seconds: int, + ) -> None: + logger.warning( + "ingestion.request_delay_coerced_to_policy_floor", + extra={ + "event": "ingestion.request_delay_coerced_to_policy_floor", + "user_id": user_id, + "requested_request_delay_seconds": requested_request_delay_seconds, + "effective_request_delay_seconds": effective_request_delay_seconds, + "policy_minimum_request_delay_seconds": user_settings_service.resolve_request_delay_minimum( + settings.ingestion_min_request_delay_seconds + ), + "metric_name": "ingestion_request_delay_coerced_total", + "metric_value": 1, + }, + ) + async def _load_user_settings_for_run( self, db_session: AsyncSession, @@ -1278,7 +1307,14 @@ class ScholarIngestionService: return failure_summary, alert_summary async def run_for_user(self, db_session: AsyncSession, *, user_id: int, trigger_type: RunTriggerType, request_delay_seconds: int, network_error_retries: int = 1, retry_backoff_seconds: float = 1.0, max_pages_per_scholar: int = 30, page_size: int = 100, scholar_profile_ids: set[int] | None = None, start_cstart_by_scholar_id: dict[int, int] | None = None, auto_queue_continuations: bool = True, queue_delay_seconds: int = 60, idempotency_key: str | None = None, alert_blocked_failure_threshold: int = 1, alert_network_failure_threshold: int = 2, alert_retry_scheduled_threshold: int = 3) -> RunExecutionSummary: - paging_kwargs = self._paging_kwargs(request_delay_seconds=request_delay_seconds, network_error_retries=network_error_retries, retry_backoff_seconds=retry_backoff_seconds, max_pages_per_scholar=max_pages_per_scholar, page_size=page_size) + effective_request_delay_seconds = self._effective_request_delay_seconds(request_delay_seconds) + if effective_request_delay_seconds != _int_or_default(request_delay_seconds, effective_request_delay_seconds): + self._log_request_delay_coercion( + user_id=user_id, + requested_request_delay_seconds=_int_or_default(request_delay_seconds, 0), + effective_request_delay_seconds=effective_request_delay_seconds, + ) + paging_kwargs = self._paging_kwargs(request_delay_seconds=effective_request_delay_seconds, network_error_retries=network_error_retries, retry_backoff_seconds=retry_backoff_seconds, max_pages_per_scholar=max_pages_per_scholar, page_size=page_size) threshold_kwargs = self._threshold_kwargs(alert_blocked_failure_threshold=alert_blocked_failure_threshold, alert_network_failure_threshold=alert_network_failure_threshold, alert_retry_scheduled_threshold=alert_retry_scheduled_threshold) user_settings, run, scholars, start_cstart_map = await self._initialize_run_for_user( db_session, diff --git a/app/services/domains/ingestion/scheduler.py b/app/services/domains/ingestion/scheduler.py index 2959b63..9db0204 100644 --- a/app/services/domains/ingestion/scheduler.py +++ b/app/services/domains/ingestion/scheduler.py @@ -23,12 +23,28 @@ from app.services.domains.ingestion.application import ( RunBlockedBySafetyPolicyError, ScholarIngestionService, ) +from app.services.domains.settings import application as user_settings_service from app.services.domains.scholar.source import LiveScholarSource from app.settings import settings logger = logging.getLogger(__name__) +def _request_delay_floor_seconds() -> int: + return user_settings_service.resolve_request_delay_minimum( + settings.ingestion_min_request_delay_seconds + ) + + +def _effective_request_delay_seconds(value: int | None) -> int: + floor = _request_delay_floor_seconds() + try: + parsed = int(value) if value is not None else floor + except (TypeError, ValueError): + parsed = floor + return max(floor, parsed) + + @dataclass(frozen=True) class _AutoRunCandidate: user_id: int @@ -178,7 +194,7 @@ class SchedulerService: return _AutoRunCandidate( user_id=int(user_id), run_interval_minutes=int(run_interval_minutes), - request_delay_seconds=int(request_delay_seconds), + request_delay_seconds=_effective_request_delay_seconds(request_delay_seconds), cooldown_until=cooldown_until, cooldown_reason=(str(cooldown_reason).strip() if cooldown_reason else None), ) @@ -577,6 +593,4 @@ class SchedulerService: select(UserSetting.request_delay_seconds).where(UserSetting.user_id == user_id) ) delay = result.scalar_one_or_none() - if delay is None: - return 10 - return max(1, int(delay)) + return _effective_request_delay_seconds(delay) diff --git a/app/services/domains/publications/application.py b/app/services/domains/publications/application.py index 8e9e32a..cad6828 100644 --- a/app/services/domains/publications/application.py +++ b/app/services/domains/publications/application.py @@ -2,6 +2,7 @@ from __future__ import annotations from app.services.domains.publications.counts import ( count_for_user, + count_favorite_for_user, count_latest_for_user, count_unread_for_user, ) @@ -12,7 +13,9 @@ from app.services.domains.publications.listing import ( list_unread_for_user, ) from app.services.domains.publications.enrichment import ( + hydrate_pdf_enrichment_state, schedule_missing_pdf_enrichment_for_user, + schedule_retry_pdf_enrichment_for_row, ) from app.services.domains.publications.modes import ( MODE_ALL, @@ -30,6 +33,14 @@ from app.services.domains.publications.queries import ( from app.services.domains.publications.read_state import ( mark_all_unread_as_read_for_user, mark_selected_as_read_for_user, + set_publication_favorite_for_user, +) +from app.services.domains.publications.pdf_queue import ( + count_pdf_queue_items, + enqueue_all_missing_pdf_jobs, + enqueue_retry_pdf_job_for_publication_id, + list_pdf_queue_page, + list_pdf_queue_items, ) from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem @@ -49,10 +60,19 @@ __all__ = [ "list_unread_for_user", "list_new_for_latest_run_for_user", "retry_pdf_for_user", + "hydrate_pdf_enrichment_state", + "schedule_retry_pdf_enrichment_for_row", + "list_pdf_queue_items", + "list_pdf_queue_page", + "count_pdf_queue_items", + "enqueue_all_missing_pdf_jobs", + "enqueue_retry_pdf_job_for_publication_id", "schedule_missing_pdf_enrichment_for_user", "count_for_user", + "count_favorite_for_user", "count_unread_for_user", "count_latest_for_user", "mark_all_unread_as_read_for_user", "mark_selected_as_read_for_user", + "set_publication_favorite_for_user", ] diff --git a/app/services/domains/publications/counts.py b/app/services/domains/publications/counts.py index 98aa57d..8f65172 100644 --- a/app/services/domains/publications/counts.py +++ b/app/services/domains/publications/counts.py @@ -19,6 +19,7 @@ async def count_for_user( user_id: int, mode: str = MODE_ALL, scholar_profile_id: int | None = None, + favorite_only: bool = False, ) -> int: resolved_mode = resolve_publication_view_mode(mode) latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id) @@ -30,6 +31,8 @@ async def count_for_user( ) if scholar_profile_id is not None: stmt = stmt.where(ScholarProfile.id == scholar_profile_id) + if favorite_only: + stmt = stmt.where(ScholarPublication.is_favorite.is_(True)) if resolved_mode == MODE_UNREAD: stmt = stmt.where(ScholarPublication.is_read.is_(False)) if resolved_mode == MODE_LATEST: @@ -45,12 +48,14 @@ async def count_unread_for_user( *, user_id: int, scholar_profile_id: int | None = None, + favorite_only: bool = False, ) -> int: return await count_for_user( db_session, user_id=user_id, mode=MODE_UNREAD, scholar_profile_id=scholar_profile_id, + favorite_only=favorite_only, ) @@ -59,10 +64,27 @@ async def count_latest_for_user( *, user_id: int, scholar_profile_id: int | None = None, + favorite_only: bool = False, ) -> int: return await count_for_user( db_session, user_id=user_id, mode=MODE_LATEST, scholar_profile_id=scholar_profile_id, + favorite_only=favorite_only, + ) + + +async def count_favorite_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int | None = None, +) -> int: + return await count_for_user( + db_session, + user_id=user_id, + mode=MODE_ALL, + scholar_profile_id=scholar_profile_id, + favorite_only=True, ) diff --git a/app/services/domains/publications/enrichment.py b/app/services/domains/publications/enrichment.py index 5c09357..381107a 100644 --- a/app/services/domains/publications/enrichment.py +++ b/app/services/domains/publications/enrichment.py @@ -1,135 +1,73 @@ from __future__ import annotations -import asyncio import logging -import time -from app.db.session import get_session_factory -from app.services.domains.publications.listing import ( - missing_pdf_items, - resolve_and_persist_oa_metadata, +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.domains.publications.pdf_queue import ( + enqueue_missing_pdf_jobs, + enqueue_retry_pdf_job, + overlay_pdf_job_state, ) from app.services.domains.publications.types import PublicationListItem -from app.settings import settings logger = logging.getLogger(__name__) -_enrichment_lock = asyncio.Lock() -_inflight_publications: set[tuple[int, int]] = set() -_recent_attempt_seconds: dict[tuple[int, int], float] = {} -_scheduled_tasks: set[asyncio.Task[None]] = set() - - -def _cooldown_seconds() -> float: - return max(float(settings.unpaywall_retry_cooldown_seconds), 1.0) - - -def _prune_recent_attempts(now_seconds: float, *, cooldown_seconds: float) -> None: - expiry = cooldown_seconds * 3 - stale_keys = [ - key for key, attempted_seconds in _recent_attempt_seconds.items() - if now_seconds - attempted_seconds >= expiry - ] - for key in stale_keys: - _recent_attempt_seconds.pop(key, None) - - -async def _claim_items( - *, - user_id: int, - items: list[PublicationListItem], - max_items: int, -) -> list[PublicationListItem]: - candidates = missing_pdf_items(items, limit=max_items) - if not candidates: - return [] - now_seconds = time.monotonic() - cooldown_seconds = _cooldown_seconds() - claimed: list[PublicationListItem] = [] - async with _enrichment_lock: - _prune_recent_attempts(now_seconds, cooldown_seconds=cooldown_seconds) - for item in candidates: - key = (user_id, item.publication_id) - attempted_seconds = _recent_attempt_seconds.get(key) - if key in _inflight_publications: - continue - if attempted_seconds is not None and now_seconds - attempted_seconds < cooldown_seconds: - continue - _inflight_publications.add(key) - _recent_attempt_seconds[key] = now_seconds - claimed.append(item) - return claimed - - -async def _release_claims(*, user_id: int, publication_ids: list[int]) -> None: - async with _enrichment_lock: - for publication_id in publication_ids: - _inflight_publications.discard((user_id, publication_id)) - - -def _on_task_done(task: asyncio.Task[None]) -> None: - _scheduled_tasks.discard(task) - try: - task.result() - except Exception: - logger.exception( - "publications.enrichment.task_failed", - extra={"event": "publications.enrichment.task_failed"}, - ) - - -async def _run_enrichment( - *, - user_id: int, - request_email: str | None, - items: list[PublicationListItem], -) -> None: - publication_ids = [item.publication_id for item in items] - try: - session_factory = get_session_factory() - async with session_factory() as db_session: - await resolve_and_persist_oa_metadata( - db_session, - rows=items, - unpaywall_email=request_email, - ) - finally: - await _release_claims(user_id=user_id, publication_ids=publication_ids) - logger.info( - "publications.enrichment.completed", - extra={ - "event": "publications.enrichment.completed", - "user_id": user_id, - "publication_count": len(items), - }, - ) - async def schedule_missing_pdf_enrichment_for_user( + db_session: AsyncSession, *, user_id: int, request_email: str | None, items: list[PublicationListItem], max_items: int, ) -> int: - claimed_items = await _claim_items(user_id=user_id, items=items, max_items=max_items) - if not claimed_items: - return 0 - task = asyncio.create_task( - _run_enrichment( - user_id=user_id, - request_email=request_email, - items=claimed_items, - ) + queued_ids = await enqueue_missing_pdf_jobs( + db_session, + user_id=user_id, + request_email=request_email, + rows=items, + max_items=max_items, ) - _scheduled_tasks.add(task) - task.add_done_callback(_on_task_done) logger.info( "publications.enrichment.scheduled", extra={ "event": "publications.enrichment.scheduled", "user_id": user_id, - "publication_count": len(claimed_items), + "publication_count": len(queued_ids), }, ) - return len(claimed_items) + return len(queued_ids) + + +async def schedule_retry_pdf_enrichment_for_row( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + item: PublicationListItem, +) -> bool: + queued = await enqueue_retry_pdf_job( + db_session, + user_id=user_id, + request_email=request_email, + row=item, + ) + logger.info( + "publications.enrichment.retry_scheduled", + extra={ + "event": "publications.enrichment.retry_scheduled", + "user_id": user_id, + "publication_id": item.publication_id, + "queued": queued, + }, + ) + return queued + + +async def hydrate_pdf_enrichment_state( + db_session: AsyncSession, + *, + items: list[PublicationListItem], +) -> list[PublicationListItem]: + return await overlay_pdf_job_state(db_session, rows=items) diff --git a/app/services/domains/publications/listing.py b/app/services/domains/publications/listing.py index 69893a9..376168a 100644 --- a/app/services/domains/publications/listing.py +++ b/app/services/domains/publications/listing.py @@ -16,94 +16,6 @@ from app.services.domains.publications.queries import ( unread_item_from_row, ) from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem -from app.services.domains.unpaywall.application import resolve_publication_oa_metadata -from sqlalchemy import update -from app.db.models import Publication - - -def _with_oa_overrides( - rows: list[PublicationListItem], - oa_data: dict[int, tuple[str | None, str | None]], -) -> list[PublicationListItem]: - return [ - PublicationListItem( - publication_id=row.publication_id, - scholar_profile_id=row.scholar_profile_id, - scholar_label=row.scholar_label, - title=row.title, - year=row.year, - citation_count=row.citation_count, - venue_text=row.venue_text, - pub_url=row.pub_url, - doi=(oa_data.get(row.publication_id) or (None, None))[0] or row.doi, - pdf_url=(oa_data.get(row.publication_id) or (None, None))[1] or row.pdf_url, - is_read=row.is_read, - first_seen_at=row.first_seen_at, - is_new_in_latest_run=row.is_new_in_latest_run, - ) - for row in rows - ] - - -def _resolved_fields( - *, - row: PublicationListItem, - resolved: tuple[str | None, str | None] | None, -) -> tuple[str | None, str | None]: - if resolved is None: - return row.doi, row.pdf_url - resolved_doi, resolved_pdf = resolved - return resolved_doi or row.doi, resolved_pdf or row.pdf_url - - -async def _persist_resolved_metadata( - db_session: AsyncSession, - *, - rows: list[PublicationListItem], - oa_data: dict[int, tuple[str | None, str | None]], -) -> None: - by_id = {row.publication_id: row for row in rows} - updates: list[tuple[int, str | None, str | None]] = [] - for publication_id, resolved in oa_data.items(): - existing = by_id.get(publication_id) - if existing is None: - continue - next_doi, next_pdf = _resolved_fields(row=existing, resolved=resolved) - if next_doi == existing.doi and next_pdf == existing.pdf_url: - continue - updates.append((publication_id, next_doi, next_pdf)) - for publication_id, doi, pdf_url in updates: - await db_session.execute( - update(Publication) - .where(Publication.id == publication_id) - .values(doi=doi, pdf_url=pdf_url) - ) - if updates: - await db_session.commit() - - -def missing_pdf_items( - rows: list[PublicationListItem], - *, - limit: int, -) -> list[PublicationListItem]: - bounded_limit = max(0, int(limit)) - if bounded_limit == 0: - return [] - return [row for row in rows if not row.pdf_url][:bounded_limit] - - -async def resolve_and_persist_oa_metadata( - db_session: AsyncSession, - *, - rows: list[PublicationListItem], - unpaywall_email: str | None = None, -) -> dict[int, tuple[str | None, str | None]]: - if not rows: - return {} - oa_data = await resolve_publication_oa_metadata(rows, request_email=unpaywall_email) - await _persist_resolved_metadata(db_session, rows=rows, oa_data=oa_data) - return oa_data async def list_for_user( @@ -112,7 +24,9 @@ async def list_for_user( user_id: int, mode: str = MODE_ALL, scholar_profile_id: int | None = None, + favorite_only: bool = False, limit: int = 300, + offset: int = 0, ) -> list[PublicationListItem]: resolved_mode = resolve_publication_view_mode(mode) latest_run_id = await get_latest_completed_run_id_for_user(db_session, user_id=user_id) @@ -122,7 +36,9 @@ async def list_for_user( mode=resolved_mode, latest_run_id=latest_run_id, scholar_profile_id=scholar_profile_id, + favorite_only=favorite_only, limit=limit, + offset=offset, ) ) rows = [ @@ -138,22 +54,13 @@ async def retry_pdf_for_user( user_id: int, scholar_profile_id: int, publication_id: int, - unpaywall_email: str | None = None, ) -> PublicationListItem | None: - item = await get_publication_item_for_user( + return await get_publication_item_for_user( db_session, user_id=user_id, scholar_profile_id=scholar_profile_id, publication_id=publication_id, ) - if item is None: - return None - oa_data = await resolve_and_persist_oa_metadata( - db_session, - rows=[item], - unpaywall_email=unpaywall_email, - ) - return _with_oa_overrides([item], oa_data)[0] async def list_unread_for_user( @@ -168,7 +75,9 @@ async def list_unread_for_user( mode=MODE_UNREAD, latest_run_id=None, scholar_profile_id=None, + favorite_only=False, limit=limit, + offset=0, ) ) return [unread_item_from_row(row) for row in result.all()] diff --git a/app/services/domains/publications/pdf_queue.py b/app/services/domains/publications/pdf_queue.py new file mode 100644 index 0000000..b420d46 --- /dev/null +++ b/app/services/domains/publications/pdf_queue.py @@ -0,0 +1,877 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +import logging + +from sqlalchemy import Select, func, literal, or_, select, union_all +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import ( + Publication, + PublicationPdfJob, + PublicationPdfJobEvent, + ScholarProfile, + ScholarPublication, + User, +) +from app.db.session import get_session_factory +from app.services.domains.publications.types import PublicationListItem +from app.services.domains.unpaywall.application import ( + FAILURE_RESOLUTION_EXCEPTION, + OaResolutionOutcome, + resolve_publication_oa_outcomes, +) +from app.settings import settings + +PDF_STATUS_UNTRACKED = "untracked" +PDF_STATUS_QUEUED = "queued" +PDF_STATUS_RUNNING = "running" +PDF_STATUS_RESOLVED = "resolved" +PDF_STATUS_FAILED = "failed" + +PDF_EVENT_QUEUED = "queued" +PDF_EVENT_ATTEMPT_STARTED = "attempt_started" +PDF_EVENT_RESOLVED = "resolved" +PDF_EVENT_FAILED = "failed" + +logger = logging.getLogger(__name__) +_scheduled_tasks: set[asyncio.Task[None]] = set() + + +@dataclass(frozen=True) +class PdfQueueListItem: + publication_id: int + title: str + doi: str | None + pdf_url: str | None + status: str + attempt_count: int + last_failure_reason: str | None + last_failure_detail: str | None + last_source: str | None + requested_by_user_id: int | None + requested_by_email: str | None + queued_at: datetime | None + last_attempt_at: datetime | None + resolved_at: datetime | None + updated_at: datetime + + +@dataclass(frozen=True) +class PdfRequeueResult: + publication_exists: bool + queued: bool + + +@dataclass(frozen=True) +class PdfBulkQueueResult: + requested_count: int + queued_count: int + + +@dataclass(frozen=True) +class PdfQueuePage: + items: list[PdfQueueListItem] + total_count: int + limit: int + offset: int + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _publication_ids(rows: list[PublicationListItem]) -> list[int]: + return sorted({row.publication_id for row in rows}) + + +def _status_from_job(row: PublicationListItem, job: PublicationPdfJob | None) -> str: + if row.pdf_url: + return PDF_STATUS_RESOLVED + if job is None: + return PDF_STATUS_UNTRACKED + return job.status + + +def _item_from_row_and_job( + row: PublicationListItem, + job: PublicationPdfJob | None, +) -> PublicationListItem: + return PublicationListItem( + publication_id=row.publication_id, + scholar_profile_id=row.scholar_profile_id, + scholar_label=row.scholar_label, + title=row.title, + year=row.year, + citation_count=row.citation_count, + venue_text=row.venue_text, + pub_url=row.pub_url, + doi=row.doi, + pdf_url=row.pdf_url, + is_read=row.is_read, + is_favorite=row.is_favorite, + first_seen_at=row.first_seen_at, + is_new_in_latest_run=row.is_new_in_latest_run, + pdf_status=_status_from_job(row, job), + pdf_attempt_count=int(job.attempt_count) if job is not None else 0, + pdf_failure_reason=job.last_failure_reason if job is not None else None, + pdf_failure_detail=job.last_failure_detail if job is not None else None, + ) + + +def _queueable_rows( + rows: list[PublicationListItem], + *, + max_items: int, +) -> list[PublicationListItem]: + bounded = max(0, int(max_items)) + if bounded == 0: + return [] + candidates = [row for row in rows if not row.pdf_url] + return candidates[:bounded] + + +def _bounded_limit(limit: int, *, max_value: int = 500) -> int: + return max(1, min(int(limit), max_value)) + + +def _bounded_offset(offset: int) -> int: + return max(int(offset), 0) + + +def _auto_retry_interval_seconds() -> int: + return max(int(settings.pdf_auto_retry_interval_seconds), 1) + + +def _auto_retry_first_interval_seconds() -> int: + return max(int(settings.pdf_auto_retry_first_interval_seconds), 1) + + +def _auto_retry_max_attempts() -> int: + return max(int(settings.pdf_auto_retry_max_attempts), 1) + + +def _retry_interval_seconds_for_attempt_count(attempt_count: int) -> int: + if int(attempt_count) <= 1: + return _auto_retry_first_interval_seconds() + return _auto_retry_interval_seconds() + + +def _cooldown_active( + *, + last_attempt_at: datetime | None, + attempt_count: int, +) -> bool: + if last_attempt_at is None: + return False + elapsed = (_utcnow() - last_attempt_at).total_seconds() + return elapsed < _retry_interval_seconds_for_attempt_count(int(attempt_count)) + + +def _can_enqueue_job( + job: PublicationPdfJob | None, + *, + force_retry: bool, +) -> bool: + if job is None: + return True + if job.status in {PDF_STATUS_QUEUED, PDF_STATUS_RUNNING}: + return False + if force_retry: + return job.status in {PDF_STATUS_FAILED, PDF_STATUS_RESOLVED, PDF_STATUS_UNTRACKED} + if job.status == PDF_STATUS_RESOLVED: + return False + if int(job.attempt_count) >= _auto_retry_max_attempts(): + return False + if _cooldown_active( + last_attempt_at=job.last_attempt_at, + attempt_count=int(job.attempt_count), + ): + return False + return True + + +def _event_row( + *, + publication_id: int, + user_id: int | None, + event_type: str, + status: str | None, + source: str | None = None, + failure_reason: str | None = None, + message: str | None = None, +) -> PublicationPdfJobEvent: + return PublicationPdfJobEvent( + publication_id=publication_id, + user_id=user_id, + event_type=event_type, + status=status, + source=source, + failure_reason=failure_reason, + message=message, + ) + + +def _queued_job( + *, + publication_id: int, + user_id: int, +) -> PublicationPdfJob: + now = _utcnow() + return PublicationPdfJob( + publication_id=publication_id, + status=PDF_STATUS_QUEUED, + queued_at=now, + last_requested_by_user_id=user_id, + ) + + +def _mark_job_queued(job: PublicationPdfJob, *, user_id: int) -> None: + now = _utcnow() + job.status = PDF_STATUS_QUEUED + job.queued_at = now + job.last_requested_by_user_id = user_id + job.last_failure_reason = None + job.last_failure_detail = None + job.last_source = None + + +def _state_map(jobs: list[PublicationPdfJob]) -> dict[int, PublicationPdfJob]: + return {int(job.publication_id): job for job in jobs} + + +async def _jobs_for_publication_ids( + db_session: AsyncSession, + *, + publication_ids: list[int], +) -> dict[int, PublicationPdfJob]: + if not publication_ids: + return {} + result = await db_session.execute( + select(PublicationPdfJob).where(PublicationPdfJob.publication_id.in_(publication_ids)) + ) + return _state_map(list(result.scalars())) + + +async def overlay_pdf_job_state( + db_session: AsyncSession, + *, + rows: list[PublicationListItem], +) -> list[PublicationListItem]: + if not rows: + return [] + jobs = await _jobs_for_publication_ids( + db_session, + publication_ids=_publication_ids(rows), + ) + return [_item_from_row_and_job(row, jobs.get(row.publication_id)) for row in rows] + + +async def _enqueue_rows( + db_session: AsyncSession, + *, + user_id: int, + rows: list[PublicationListItem], + force_retry: bool, +) -> list[PublicationListItem]: + if not rows: + return [] + queued: list[PublicationListItem] = [] + jobs = await _jobs_for_publication_ids( + db_session, + publication_ids=_publication_ids(rows), + ) + for row in rows: + job = jobs.get(row.publication_id) + if not _can_enqueue_job(job, force_retry=force_retry): + continue + if job is None: + job = _queued_job(publication_id=row.publication_id, user_id=user_id) + jobs[row.publication_id] = job + db_session.add(job) + else: + _mark_job_queued(job, user_id=user_id) + db_session.add( + _event_row( + publication_id=row.publication_id, + user_id=user_id, + event_type=PDF_EVENT_QUEUED, + status=PDF_STATUS_QUEUED, + ) + ) + queued.append(row) + if queued: + await db_session.commit() + return queued + + +def _register_task(task: asyncio.Task[None]) -> None: + _scheduled_tasks.add(task) + + +def _drop_finished_task(task: asyncio.Task[None]) -> None: + _scheduled_tasks.discard(task) + try: + task.result() + except Exception: + logger.exception( + "publications.pdf_queue.task_failed", + extra={"event": "publications.pdf_queue.task_failed"}, + ) + + +async def _mark_attempt_started( + *, + publication_id: int, + user_id: int, +) -> None: + session_factory = get_session_factory() + async with session_factory() as db_session: + job = await db_session.get(PublicationPdfJob, publication_id) + if job is None: + job = _queued_job(publication_id=publication_id, user_id=user_id) + db_session.add(job) + job.status = PDF_STATUS_RUNNING + job.last_attempt_at = _utcnow() + job.attempt_count = int(job.attempt_count) + 1 + db_session.add( + _event_row( + publication_id=publication_id, + user_id=user_id, + event_type=PDF_EVENT_ATTEMPT_STARTED, + status=PDF_STATUS_RUNNING, + ) + ) + await db_session.commit() + + +def _failed_outcome( + *, + row: PublicationListItem, +) -> OaResolutionOutcome: + return OaResolutionOutcome( + publication_id=row.publication_id, + doi=row.doi, + pdf_url=None, + failure_reason=FAILURE_RESOLUTION_EXCEPTION, + source=None, + used_crossref=False, + ) + + +async def _fetch_outcome_for_row( + *, + row: PublicationListItem, + request_email: str | None, +) -> OaResolutionOutcome: + outcomes = await resolve_publication_oa_outcomes([row], request_email=request_email) + outcome = outcomes.get(row.publication_id) + if outcome is not None: + return outcome + return _failed_outcome(row=row) + + +def _apply_publication_update( + publication: Publication, + *, + doi: str | None, + pdf_url: str | None, +) -> None: + if doi and publication.doi != doi: + publication.doi = doi + if pdf_url and publication.pdf_url != pdf_url: + publication.pdf_url = pdf_url + + +def _apply_job_outcome(job: PublicationPdfJob, *, outcome: OaResolutionOutcome) -> None: + job.last_source = outcome.source + if outcome.pdf_url: + job.status = PDF_STATUS_RESOLVED + job.resolved_at = _utcnow() + job.last_failure_reason = None + job.last_failure_detail = None + return + job.status = PDF_STATUS_FAILED + job.last_failure_reason = outcome.failure_reason + job.last_failure_detail = outcome.failure_reason + + +def _result_event(outcome: OaResolutionOutcome) -> tuple[str, str]: + if outcome.pdf_url: + return PDF_EVENT_RESOLVED, PDF_STATUS_RESOLVED + return PDF_EVENT_FAILED, PDF_STATUS_FAILED + + +async def _persist_outcome( + *, + publication_id: int, + user_id: int, + outcome: OaResolutionOutcome, +) -> None: + session_factory = get_session_factory() + async with session_factory() as db_session: + publication = await db_session.get(Publication, publication_id) + job = await db_session.get(PublicationPdfJob, publication_id) + if publication is None or job is None: + return + _apply_publication_update(publication, doi=outcome.doi, pdf_url=outcome.pdf_url) + _apply_job_outcome(job, outcome=outcome) + event_type, status = _result_event(outcome) + db_session.add( + _event_row( + publication_id=publication_id, + user_id=user_id, + event_type=event_type, + status=status, + source=outcome.source, + failure_reason=outcome.failure_reason, + message=outcome.failure_reason, + ) + ) + await db_session.commit() + + +async def _resolve_publication_row( + *, + user_id: int, + request_email: str | None, + row: PublicationListItem, +) -> None: + await _mark_attempt_started(publication_id=row.publication_id, user_id=user_id) + try: + outcome = await _fetch_outcome_for_row(row=row, request_email=request_email) + except Exception as exc: # pragma: no cover - defensive network boundary + logger.warning( + "publications.pdf_queue.resolve_failed", + extra={ + "event": "publications.pdf_queue.resolve_failed", + "publication_id": row.publication_id, + "error": str(exc), + }, + ) + outcome = _failed_outcome(row=row) + await _persist_outcome( + publication_id=row.publication_id, + user_id=user_id, + outcome=outcome, + ) + + +async def _run_resolution_task( + *, + user_id: int, + request_email: str | None, + rows: list[PublicationListItem], +) -> None: + for row in rows: + await _resolve_publication_row( + user_id=user_id, + request_email=request_email, + row=row, + ) + + +def _schedule_rows( + *, + user_id: int, + request_email: str | None, + rows: list[PublicationListItem], +) -> None: + if not rows: + return + task = asyncio.create_task( + _run_resolution_task( + user_id=user_id, + request_email=request_email, + rows=rows, + ) + ) + _register_task(task) + task.add_done_callback(_drop_finished_task) + + +async def enqueue_missing_pdf_jobs( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + rows: list[PublicationListItem], + max_items: int, +) -> list[int]: + queueable = _queueable_rows(rows, max_items=max_items) + queued_rows = await _enqueue_rows( + db_session, + user_id=user_id, + rows=queueable, + force_retry=False, + ) + _schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) + return [row.publication_id for row in queued_rows] + + +async def enqueue_retry_pdf_job( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + row: PublicationListItem, +) -> bool: + queued_rows = await _enqueue_rows( + db_session, + user_id=user_id, + rows=[row], + force_retry=True, + ) + _schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) + return bool(queued_rows) + + +def _retry_item_label(display_name: str | None, scholar_id: str | None) -> str: + return str(display_name or scholar_id or "unknown") + + +def _retry_item_from_publication( + publication: Publication, + *, + link_row: tuple | None, +) -> PublicationListItem: + if link_row is None: + scholar_profile_id = 0 + scholar_label = "unknown" + is_read = True + first_seen_at = publication.created_at or _utcnow() + else: + scholar_profile_id = int(link_row[0]) + scholar_label = _retry_item_label(link_row[1], link_row[2]) + is_read = bool(link_row[3]) + first_seen_at = link_row[4] or publication.created_at or _utcnow() + return PublicationListItem( + publication_id=int(publication.id), + scholar_profile_id=scholar_profile_id, + scholar_label=scholar_label, + title=publication.title_raw, + year=publication.year, + citation_count=int(publication.citation_count or 0), + venue_text=publication.venue_text, + pub_url=publication.pub_url, + doi=publication.doi, + pdf_url=publication.pdf_url, + is_read=is_read, + first_seen_at=first_seen_at, + is_new_in_latest_run=False, + ) + + +async def _retry_item_for_publication_id( + db_session: AsyncSession, + *, + publication_id: int, +) -> PublicationListItem | None: + publication = await db_session.get(Publication, publication_id) + if publication is None: + return None + result = await db_session.execute( + select( + ScholarProfile.id, + ScholarProfile.display_name, + ScholarProfile.scholar_id, + ScholarPublication.is_read, + ScholarPublication.created_at, + ) + .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) + .where(ScholarPublication.publication_id == publication_id) + .order_by(ScholarPublication.created_at.asc()) + .limit(1) + ) + return _retry_item_from_publication(publication, link_row=result.one_or_none()) + + +async def enqueue_retry_pdf_job_for_publication_id( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + publication_id: int, +) -> PdfRequeueResult: + row = await _retry_item_for_publication_id( + db_session, + publication_id=publication_id, + ) + if row is None: + return PdfRequeueResult(publication_exists=False, queued=False) + queued = await enqueue_retry_pdf_job( + db_session, + user_id=user_id, + request_email=request_email, + row=row, + ) + return PdfRequeueResult(publication_exists=True, queued=queued) + + +def _queue_candidate_from_publication(publication: Publication) -> PublicationListItem: + return PublicationListItem( + publication_id=int(publication.id), + scholar_profile_id=0, + scholar_label="admin", + title=publication.title_raw, + year=publication.year, + citation_count=int(publication.citation_count or 0), + venue_text=publication.venue_text, + pub_url=publication.pub_url, + doi=publication.doi, + pdf_url=publication.pdf_url, + is_read=True, + first_seen_at=publication.created_at or _utcnow(), + is_new_in_latest_run=False, + ) + + +async def _missing_pdf_candidates( + db_session: AsyncSession, + *, + limit: int, +) -> list[PublicationListItem]: + bounded_limit = max(1, min(int(limit), 5000)) + result = await db_session.execute( + select(Publication) + .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) + .where(Publication.pdf_url.is_(None)) + .where( + or_( + PublicationPdfJob.publication_id.is_(None), + PublicationPdfJob.status.notin_([PDF_STATUS_QUEUED, PDF_STATUS_RUNNING]), + ) + ) + .order_by(Publication.updated_at.desc(), Publication.id.desc()) + .limit(bounded_limit) + ) + return [ + _queue_candidate_from_publication(publication) + for publication in result.scalars() + ] + + +async def enqueue_all_missing_pdf_jobs( + db_session: AsyncSession, + *, + user_id: int, + request_email: str | None, + limit: int = 1000, +) -> PdfBulkQueueResult: + candidates = await _missing_pdf_candidates(db_session, limit=limit) + queued_rows = await _enqueue_rows( + db_session, + user_id=user_id, + rows=candidates, + force_retry=True, + ) + _schedule_rows(user_id=user_id, request_email=request_email, rows=queued_rows) + return PdfBulkQueueResult( + requested_count=len(candidates), + queued_count=len(queued_rows), + ) + + +def _tracked_queue_select_base(*, status: str | None) -> Select[tuple]: + stmt = ( + select( + PublicationPdfJob.publication_id, + Publication.title_raw, + Publication.doi, + Publication.pdf_url, + PublicationPdfJob.status, + PublicationPdfJob.attempt_count, + PublicationPdfJob.last_failure_reason, + PublicationPdfJob.last_failure_detail, + PublicationPdfJob.last_source, + PublicationPdfJob.last_requested_by_user_id, + User.email, + PublicationPdfJob.queued_at, + PublicationPdfJob.last_attempt_at, + PublicationPdfJob.resolved_at, + PublicationPdfJob.updated_at, + ) + .join(Publication, Publication.id == PublicationPdfJob.publication_id) + .outerjoin(User, User.id == PublicationPdfJob.last_requested_by_user_id) + ) + if status: + stmt = stmt.where(PublicationPdfJob.status == status) + return stmt + + +def _tracked_queue_select(*, limit: int, offset: int, status: str | None) -> Select[tuple]: + return ( + _tracked_queue_select_base(status=status) + .order_by(PublicationPdfJob.updated_at.desc()) + .limit(_bounded_limit(limit)) + .offset(_bounded_offset(offset)) + ) + + +def _untracked_queue_select_base() -> Select[tuple]: + return ( + select( + Publication.id, + Publication.title_raw, + Publication.doi, + Publication.pdf_url, + literal(PDF_STATUS_UNTRACKED), + literal(0), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + literal(None), + Publication.updated_at, + ) + .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) + .where(Publication.pdf_url.is_(None)) + .where(PublicationPdfJob.publication_id.is_(None)) + ) + + +def _untracked_queue_select(*, limit: int, offset: int) -> Select[tuple]: + return ( + _untracked_queue_select_base() + .order_by(Publication.updated_at.desc(), Publication.id.desc()) + .limit(_bounded_limit(limit)) + .offset(_bounded_offset(offset)) + ) + + +def _all_queue_select(*, limit: int, offset: int) -> Select[tuple]: + union_stmt = union_all( + _tracked_queue_select_base(status=None), + _untracked_queue_select_base(), + ).subquery() + return ( + select(union_stmt) + .order_by(union_stmt.c.updated_at.desc()) + .limit(_bounded_limit(limit)) + .offset(_bounded_offset(offset)) + ) + + +def _tracked_queue_count_select(*, status: str | None) -> Select[tuple]: + stmt = select(func.count()).select_from(PublicationPdfJob) + if status: + stmt = stmt.where(PublicationPdfJob.status == status) + return stmt + + +def _untracked_queue_count_select() -> Select[tuple]: + return ( + select(func.count()) + .select_from(Publication) + .outerjoin(PublicationPdfJob, PublicationPdfJob.publication_id == Publication.id) + .where(Publication.pdf_url.is_(None)) + .where(PublicationPdfJob.publication_id.is_(None)) + ) + + +def _queue_item_from_row(row: tuple) -> PdfQueueListItem: + return PdfQueueListItem( + publication_id=int(row[0]), + title=str(row[1] or ""), + doi=row[2], + pdf_url=row[3], + status=str(row[4] or PDF_STATUS_UNTRACKED), + attempt_count=int(row[5] or 0), + last_failure_reason=row[6], + last_failure_detail=row[7], + last_source=row[8], + requested_by_user_id=int(row[9]) if row[9] is not None else None, + requested_by_email=row[10], + queued_at=row[11], + last_attempt_at=row[12], + resolved_at=row[13], + updated_at=row[14], + ) + + +async def list_pdf_queue_items( + db_session: AsyncSession, + *, + limit: int = 100, + offset: int = 0, + status: str | None = None, +) -> list[PdfQueueListItem]: + bounded_limit = _bounded_limit(limit) + bounded_offset = _bounded_offset(offset) + normalized_status = (status or "").strip().lower() or None + if normalized_status == PDF_STATUS_UNTRACKED: + result = await db_session.execute( + _untracked_queue_select( + limit=bounded_limit, + offset=bounded_offset, + ) + ) + return [_queue_item_from_row(row) for row in result.all()] + if normalized_status is None: + result = await db_session.execute( + _all_queue_select( + limit=bounded_limit, + offset=bounded_offset, + ) + ) + return [_queue_item_from_row(row) for row in result.all()] + result = await db_session.execute( + _tracked_queue_select( + limit=bounded_limit, + offset=bounded_offset, + status=normalized_status, + ) + ) + return [_queue_item_from_row(row) for row in result.all()] + + +async def count_pdf_queue_items( + db_session: AsyncSession, + *, + status: str | None = None, +) -> int: + normalized_status = (status or "").strip().lower() or None + if normalized_status == PDF_STATUS_UNTRACKED: + result = await db_session.execute(_untracked_queue_count_select()) + return int(result.scalar_one() or 0) + tracked_result = await db_session.execute( + _tracked_queue_count_select(status=normalized_status) + ) + tracked_count = int(tracked_result.scalar_one() or 0) + if normalized_status is not None: + return tracked_count + untracked_result = await db_session.execute(_untracked_queue_count_select()) + untracked_count = int(untracked_result.scalar_one() or 0) + return tracked_count + untracked_count + + +async def list_pdf_queue_page( + db_session: AsyncSession, + *, + limit: int = 100, + offset: int = 0, + status: str | None = None, +) -> PdfQueuePage: + bounded_limit = _bounded_limit(limit) + bounded_offset = _bounded_offset(offset) + items = await list_pdf_queue_items( + db_session, + limit=bounded_limit, + offset=bounded_offset, + status=status, + ) + total_count = await count_pdf_queue_items( + db_session, + status=status, + ) + return PdfQueuePage( + items=items, + total_count=total_count, + limit=bounded_limit, + offset=bounded_offset, + ) diff --git a/app/services/domains/publications/queries.py b/app/services/domains/publications/queries.py index b55d567..292ec8e 100644 --- a/app/services/domains/publications/queries.py +++ b/app/services/domains/publications/queries.py @@ -36,7 +36,9 @@ def publications_query( mode: str, latest_run_id: int | None, scholar_profile_id: int | None, + favorite_only: bool, limit: int, + offset: int = 0, ) -> Select[tuple]: scholar_label = ScholarProfile.display_name stmt = ( @@ -53,6 +55,7 @@ def publications_query( Publication.doi, Publication.pdf_url, ScholarPublication.is_read, + ScholarPublication.is_favorite, ScholarPublication.first_seen_run_id, ScholarPublication.created_at, ) @@ -60,10 +63,13 @@ def publications_query( .join(ScholarProfile, ScholarProfile.id == ScholarPublication.scholar_profile_id) .where(ScholarProfile.user_id == user_id) .order_by(ScholarPublication.created_at.desc(), Publication.id.desc()) + .offset(max(int(offset), 0)) .limit(limit) ) if scholar_profile_id is not None: stmt = stmt.where(ScholarProfile.id == scholar_profile_id) + if favorite_only: + stmt = stmt.where(ScholarPublication.is_favorite.is_(True)) if mode == MODE_UNREAD: stmt = stmt.where(ScholarPublication.is_read.is_(False)) if mode == MODE_LATEST: @@ -93,6 +99,7 @@ def publication_query_for_user( Publication.doi, Publication.pdf_url, ScholarPublication.is_read, + ScholarPublication.is_favorite, ScholarPublication.first_seen_run_id, ScholarPublication.created_at, ) @@ -146,6 +153,7 @@ def publication_list_item_from_row( doi, pdf_url, is_read, + is_favorite, first_seen_run_id, created_at, ) = row @@ -161,6 +169,7 @@ def publication_list_item_from_row( doi=doi, pdf_url=pdf_url, is_read=bool(is_read), + is_favorite=bool(is_favorite), first_seen_at=created_at, is_new_in_latest_run=( latest_run_id is not None and int(first_seen_run_id or 0) == latest_run_id @@ -182,6 +191,7 @@ def unread_item_from_row(row: tuple) -> UnreadPublicationItem: doi, pdf_url, _is_read, + _is_favorite, _first_seen_run_id, _created_at, ) = row diff --git a/app/services/domains/publications/read_state.py b/app/services/domains/publications/read_state.py index 37a9815..41fc31a 100644 --- a/app/services/domains/publications/read_state.py +++ b/app/services/domains/publications/read_state.py @@ -16,16 +16,20 @@ def _normalized_selection_pairs(selections: list[tuple[int, int]]) -> set[tuple[ return pairs +def _scoped_scholar_ids_query(*, user_id: int): + return ( + select(ScholarProfile.id) + .where(ScholarProfile.user_id == user_id) + .scalar_subquery() + ) + + async def mark_all_unread_as_read_for_user( db_session: AsyncSession, *, user_id: int, ) -> int: - scholar_ids = ( - select(ScholarProfile.id) - .where(ScholarProfile.user_id == user_id) - .scalar_subquery() - ) + scholar_ids = _scoped_scholar_ids_query(user_id=user_id) stmt = ( update(ScholarPublication) .where( @@ -49,11 +53,7 @@ async def mark_selected_as_read_for_user( if not normalized_pairs: return 0 - scholar_ids = ( - select(ScholarProfile.id) - .where(ScholarProfile.user_id == user_id) - .scalar_subquery() - ) + scholar_ids = _scoped_scholar_ids_query(user_id=user_id) stmt = ( update(ScholarPublication) .where( @@ -69,3 +69,26 @@ async def mark_selected_as_read_for_user( result = await db_session.execute(stmt) await db_session.commit() return int(result.rowcount or 0) + + +async def set_publication_favorite_for_user( + db_session: AsyncSession, + *, + user_id: int, + scholar_profile_id: int, + publication_id: int, + is_favorite: bool, +) -> int: + scholar_ids = _scoped_scholar_ids_query(user_id=user_id) + stmt = ( + update(ScholarPublication) + .where( + ScholarPublication.scholar_profile_id.in_(scholar_ids), + ScholarPublication.scholar_profile_id == int(scholar_profile_id), + ScholarPublication.publication_id == int(publication_id), + ) + .values(is_favorite=bool(is_favorite)) + ) + result = await db_session.execute(stmt) + await db_session.commit() + return int(result.rowcount or 0) diff --git a/app/services/domains/publications/types.py b/app/services/domains/publications/types.py index f3a12c0..d1d4bad 100644 --- a/app/services/domains/publications/types.py +++ b/app/services/domains/publications/types.py @@ -19,6 +19,11 @@ class PublicationListItem: is_read: bool first_seen_at: datetime is_new_in_latest_run: bool + is_favorite: bool = False + pdf_status: str = "untracked" + pdf_attempt_count: int = 0 + pdf_failure_reason: str | None = None + pdf_failure_detail: str | None = None @dataclass(frozen=True) diff --git a/app/services/domains/unpaywall/__init__.py b/app/services/domains/unpaywall/__init__.py index 762e664..a1baa12 100644 --- a/app/services/domains/unpaywall/__init__.py +++ b/app/services/domains/unpaywall/__init__.py @@ -1,5 +1,10 @@ from __future__ import annotations -from app.services.domains.unpaywall.application import resolve_publication_pdf_urls + +async def resolve_publication_pdf_urls(*args, **kwargs): + from app.services.domains.unpaywall.application import resolve_publication_pdf_urls as _impl + + return await _impl(*args, **kwargs) + __all__ = ["resolve_publication_pdf_urls"] diff --git a/app/services/domains/unpaywall/application.py b/app/services/domains/unpaywall/application.py index 703b138..44c7560 100644 --- a/app/services/domains/unpaywall/application.py +++ b/app/services/domains/unpaywall/application.py @@ -1,21 +1,44 @@ from __future__ import annotations +from dataclasses import dataclass import logging import re +from typing import TYPE_CHECKING from urllib.parse import unquote from app.services.domains.crossref.application import discover_doi_for_publication -from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem from app.services.domains.doi.normalize import normalize_doi +from app.services.domains.unpaywall.pdf_discovery import ( + looks_like_pdf_url, + resolve_pdf_from_landing_page, +) +from app.services.domains.unpaywall.rate_limit import wait_for_unpaywall_slot from app.settings import settings +if TYPE_CHECKING: + from app.services.domains.publications.types import PublicationListItem, UnreadPublicationItem + DOI_PATTERN = re.compile(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", re.I) DOI_PREFIX_RE = re.compile(r"\bdoi\s*[:=]\s*(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I) DOI_URL_RE = re.compile(r"(?:https?://)?(?:dx\.)?doi\.org/(10\.\d{4,9}/[-._;()/:A-Z0-9]+)", re.I) UNPAYWALL_URL_TEMPLATE = "https://api.unpaywall.org/v2/{doi}" +FAILURE_MISSING_DOI = "missing_doi" +FAILURE_NO_RECORD = "no_unpaywall_record" +FAILURE_NO_PDF = "no_pdf_found" +FAILURE_RESOLUTION_EXCEPTION = "resolution_exception" logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class OaResolutionOutcome: + publication_id: int + doi: str | None + pdf_url: str | None + failure_reason: str | None + source: str | None + used_crossref: bool + + def _extract_doi_candidate(text: str | None) -> str | None: if not text: return None @@ -57,21 +80,89 @@ def _publication_doi(item: PublicationListItem | UnreadPublicationItem) -> str | ) -def _payload_pdf_url(payload: dict) -> str | None: +def _payload_locations(payload: dict) -> list[dict]: + locations: list[dict] = [] best = payload.get("best_oa_location") if isinstance(best, dict): - pdf_url = best.get("url_for_pdf") - if isinstance(pdf_url, str) and pdf_url.strip(): - return pdf_url.strip() - locations = payload.get("oa_locations") - if not isinstance(locations, list): + locations.append(best) + oa_locations = payload.get("oa_locations") + if isinstance(oa_locations, list): + locations.extend(location for location in oa_locations if isinstance(location, dict)) + return locations + + +def _location_value(location: dict, key: str) -> str | None: + value = location.get(key) + if not isinstance(value, str): return None - for location in locations: - if not isinstance(location, dict): + normalized = value.strip() + return normalized or None + + +def _payload_pdf_candidates(payload: dict) -> list[str]: + candidates: list[str] = [] + seen: set[str] = set() + for location in _payload_locations(payload): + candidate = _location_value(location, "url_for_pdf") + if candidate is None or candidate in seen: continue - pdf_url = location.get("url_for_pdf") - if isinstance(pdf_url, str) and pdf_url.strip(): - return pdf_url.strip() + seen.add(candidate) + candidates.append(candidate) + return candidates + + +def _payload_landing_candidates(payload: dict) -> list[str]: + candidates: list[str] = [] + seen: set[str] = set() + for location in _payload_locations(payload): + candidate = _location_value(location, "url") + if candidate is None or candidate in seen: + continue + seen.add(candidate) + candidates.append(candidate) + return candidates + + +def _crawl_targets( + *, + payload: dict, + pdf_candidates: list[str], +) -> list[str]: + targets = _payload_landing_candidates(payload) + seen = set(targets) + for candidate in pdf_candidates: + if candidate in seen: + continue + targets.append(candidate) + seen.add(candidate) + doi = normalize_doi(str(payload.get("doi") or "")) + doi_landing_url = f"https://doi.org/{doi}" if doi else None + if doi_landing_url and doi_landing_url not in seen: + targets.append(doi_landing_url) + return targets + + +def _has_direct_payload_pdf(payload: dict) -> bool: + return any(looks_like_pdf_url(candidate) for candidate in _payload_pdf_candidates(payload)) + + +async def _resolved_pdf_url_from_payload( + payload: dict, + *, + client, +) -> str | None: + pdf_candidates = _payload_pdf_candidates(payload) + for candidate in pdf_candidates: + if looks_like_pdf_url(candidate): + return candidate + for page_url in _crawl_targets(payload=payload, pdf_candidates=pdf_candidates)[:3]: + discovered = await resolve_pdf_from_landing_page(client, page_url=page_url) + if discovered: + logger.info( + "unpaywall.pdf_discovered_from_landing", + extra={"event": "unpaywall.pdf_discovered_from_landing", "landing_url": page_url}, + ) + return discovered return None @@ -81,6 +172,7 @@ async def _fetch_unpaywall_payload_by_doi( doi: str, email: str, ) -> dict | None: + await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) response = await client.get( UNPAYWALL_URL_TEMPLATE.format(doi=doi), params={"email": email}, @@ -130,7 +222,7 @@ async def _resolve_item_payload( payload: dict | None = None if doi: payload = await _fetch_unpaywall_payload_by_doi(client=client, doi=doi, email=email) - if payload is not None and _payload_pdf_url(payload): + if payload is not None and _has_direct_payload_pdf(payload): return payload, False if not allow_crossref or not settings.crossref_enabled: return payload, False @@ -151,9 +243,13 @@ async def _resolve_item_payload( return payload, True -def _doi_and_pdf_from_payload(payload: dict) -> tuple[str | None, str | None]: +async def _doi_and_pdf_from_payload( + payload: dict, + *, + client, +) -> tuple[str | None, str | None]: doi = normalize_doi(str(payload.get("doi") or "")) - return doi, _payload_pdf_url(payload) + return doi, await _resolved_pdf_url_from_payload(payload, client=client) def _resolution_targets(items: list[PublicationListItem]) -> list[PublicationListItem]: @@ -164,11 +260,165 @@ def _crossref_budget_value() -> int: return max(int(settings.crossref_max_lookups_per_request), 0) +def _outcome_with_failure( + *, + item: PublicationListItem, + failure_reason: str, + used_crossref: bool, +) -> OaResolutionOutcome: + return OaResolutionOutcome( + publication_id=item.publication_id, + doi=_publication_doi(item), + pdf_url=None, + failure_reason=failure_reason, + source=None, + used_crossref=used_crossref, + ) + + +def _missing_payload_failure_reason(item: PublicationListItem, *, used_crossref: bool) -> str: + if _publication_doi(item): + return FAILURE_NO_RECORD + if used_crossref: + return FAILURE_NO_RECORD + return FAILURE_MISSING_DOI + + +def _source_name(*, used_crossref: bool) -> str: + return "crossref+unpaywall" if used_crossref else "unpaywall" + + +def _outcome_from_payload( + *, + item: PublicationListItem, + doi: str | None, + pdf_url: str | None, + used_crossref: bool, +) -> OaResolutionOutcome: + return OaResolutionOutcome( + publication_id=item.publication_id, + doi=doi, + pdf_url=pdf_url, + failure_reason=None if pdf_url else FAILURE_NO_PDF, + source=_source_name(used_crossref=used_crossref), + used_crossref=used_crossref, + ) + + +def _resolved_pdf_count(outcomes: dict[int, OaResolutionOutcome]) -> int: + return sum(1 for outcome in outcomes.values() if outcome.pdf_url) + + +def _outcome_metadata(outcomes: dict[int, OaResolutionOutcome]) -> dict[int, tuple[str | None, str | None]]: + return { + publication_id: (outcome.doi, outcome.pdf_url) + for publication_id, outcome in outcomes.items() + } + + +async def _resolve_outcome_for_item( + *, + client, + item: PublicationListItem, + email: str, + allow_crossref: bool, +) -> OaResolutionOutcome: + payload, used_crossref = await _resolve_item_payload( + client=client, + item=item, + email=email, + allow_crossref=allow_crossref, + ) + if not isinstance(payload, dict): + return _outcome_with_failure( + item=item, + failure_reason=_missing_payload_failure_reason(item, used_crossref=used_crossref), + used_crossref=used_crossref, + ) + doi, pdf_url = await _doi_and_pdf_from_payload(payload, client=client) + return _outcome_from_payload( + item=item, + doi=doi, + pdf_url=pdf_url, + used_crossref=used_crossref, + ) + + +def _doi_input_count(items: list[PublicationListItem]) -> int: + return len([item for item in items if _publication_doi(item)]) + + +def _search_attempt_count(*, targets: list[PublicationListItem]) -> int: + return len([item for item in targets if not _publication_doi(item)]) + + +async def _safe_outcome_for_item( + *, + client, + item: PublicationListItem, + email: str, + allow_crossref: bool, +) -> OaResolutionOutcome: + try: + return await _resolve_outcome_for_item( + client=client, + item=item, + email=email, + allow_crossref=allow_crossref, + ) + except Exception as exc: # pragma: no cover - defensive network boundary + logger.warning( + "unpaywall.resolve_item_failed", + extra={ + "event": "unpaywall.resolve_item_failed", + "publication_id": item.publication_id, + "error": str(exc), + }, + ) + return _outcome_with_failure( + item=item, + failure_reason=FAILURE_RESOLUTION_EXCEPTION, + used_crossref=allow_crossref and settings.crossref_enabled, + ) + + +async def _resolve_outcomes_with_client( + *, + client, + targets: list[PublicationListItem], + email: str, +) -> dict[int, OaResolutionOutcome]: + outcomes: dict[int, OaResolutionOutcome] = {} + crossref_budget = _crossref_budget_value() + crossref_lookups = 0 + for item in targets: + allow_crossref = crossref_budget > 0 and crossref_lookups < crossref_budget + outcome = await _safe_outcome_for_item( + client=client, + item=item, + email=email, + allow_crossref=allow_crossref, + ) + if outcome.used_crossref: + crossref_lookups += 1 + outcomes[item.publication_id] = outcome + return outcomes + + async def resolve_publication_oa_metadata( items: list[PublicationListItem], *, request_email: str | None = None, ) -> dict[int, tuple[str | None, str | None]]: + outcomes = await resolve_publication_oa_outcomes(items, request_email=request_email) + return _outcome_metadata(outcomes) + + +async def resolve_publication_oa_outcomes( + items: list[PublicationListItem], + *, + request_email: str | None = None, +) -> dict[int, OaResolutionOutcome]: if not settings.unpaywall_enabled: return {} email = _email_for_request(request_email) @@ -178,35 +428,21 @@ async def resolve_publication_oa_metadata( import httpx timeout_seconds = max(float(settings.unpaywall_timeout_seconds), 0.5) - resolved: dict[int, tuple[str | None, str | None]] = {} - crossref_budget = _crossref_budget_value() - crossref_lookups = 0 targets = _resolution_targets(items)[: max(int(settings.unpaywall_max_items_per_request), 0)] async with httpx.AsyncClient(timeout=timeout_seconds) as client: - for item in targets: - allow_crossref = crossref_budget > 0 and crossref_lookups < crossref_budget - payload, used_crossref = await _resolve_item_payload( - client=client, - item=item, - email=email, - allow_crossref=allow_crossref, - ) - if used_crossref: - crossref_lookups += 1 - if not isinstance(payload, dict): - continue - resolved[item.publication_id] = _doi_and_pdf_from_payload(payload) - resolved_count = sum(1 for _doi, pdf in resolved.values() if pdf) - doi_input_count = len([item for item in items if _publication_doi(item)]) - target_doi_count = len([item for item in targets if _publication_doi(item)]) + outcomes = await _resolve_outcomes_with_client( + client=client, + targets=targets, + email=email, + ) _log_resolution_summary( publication_count=len(items), - doi_input_count=doi_input_count, - search_attempt_count=max(0, len(targets) - target_doi_count), - resolved_pdf_count=resolved_count, + doi_input_count=_doi_input_count(items), + search_attempt_count=_search_attempt_count(targets=targets), + resolved_pdf_count=_resolved_pdf_count(outcomes), email=email, ) - return resolved + return outcomes async def resolve_publication_pdf_urls( diff --git a/app/services/domains/unpaywall/pdf_discovery.py b/app/services/domains/unpaywall/pdf_discovery.py new file mode 100644 index 0000000..5f82a58 --- /dev/null +++ b/app/services/domains/unpaywall/pdf_discovery.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from html.parser import HTMLParser +import re +from urllib.parse import urljoin, urlparse + +from app.services.domains.unpaywall.rate_limit import wait_for_unpaywall_slot +from app.settings import settings + +PDF_MIME = "application/pdf" +URL_RE = re.compile(r"https?://[^\s\"'<>]+", re.I) + + +class _LandingPdfParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.base_href: str | None = None + self.candidates: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attrs_map = {key.lower(): (value or "").strip() for key, value in attrs} + if tag == "base": + self.base_href = attrs_map.get("href") or self.base_href + return + if tag == "meta": + self._append_meta_candidate(attrs_map) + return + if tag == "link": + self._append_link_candidate(attrs_map) + return + if tag == "a": + self._append_anchor_candidate(attrs_map) + + def _append_meta_candidate(self, attrs_map: dict[str, str]) -> None: + meta_name = (attrs_map.get("name") or attrs_map.get("property") or "").lower() + if meta_name != "citation_pdf_url": + return + content = attrs_map.get("content") + if content: + self.candidates.append(content) + + def _append_link_candidate(self, attrs_map: dict[str, str]) -> None: + href = attrs_map.get("href") + link_type = (attrs_map.get("type") or "").lower() + if href and "pdf" in link_type: + self.candidates.append(href) + + def _append_anchor_candidate(self, attrs_map: dict[str, str]) -> None: + href = attrs_map.get("href") + if href: + self.candidates.append(href) + + +def looks_like_pdf_url(url: str | None) -> bool: + if not isinstance(url, str): + return False + value = url.strip() + if not value: + return False + parsed = urlparse(value) + path = (parsed.path or "").lower() + query = (parsed.query or "").lower() + return path.endswith(".pdf") or ".pdf" in query + + +def _normalized_candidate_urls(*, page_url: str, html: str) -> list[str]: + parser = _LandingPdfParser() + parser.feed(html) + parser.close() + base_url = urljoin(page_url, parser.base_href) if parser.base_href else page_url + raw_candidates = [*parser.candidates, *_text_url_candidates(html)] + deduped: list[str] = [] + seen: set[str] = set() + for raw in raw_candidates: + absolute = urljoin(base_url, raw.strip()) + parsed = urlparse(absolute) + if parsed.scheme not in {"http", "https"}: + continue + if absolute in seen: + continue + seen.add(absolute) + deduped.append(absolute) + return sorted(deduped, key=_candidate_sort_key) + + +def _text_url_candidates(html: str) -> list[str]: + candidates: list[str] = [] + for match in URL_RE.findall(html): + cleaned = match.rstrip(".,);]}>") + if "pdf" not in cleaned.lower(): + continue + candidates.append(cleaned) + return candidates + + +def _candidate_sort_key(candidate: str) -> tuple[int, str]: + lowered = candidate.lower() + if looks_like_pdf_url(candidate): + return (0, lowered) + if any(token in lowered for token in ("doi", "full", "article", "download")): + return (1, lowered) + return (2, lowered) + + +def _is_html_response(response) -> bool: + content_type = str(response.headers.get("content-type") or "").lower() + return "text/html" in content_type or "application/xhtml+xml" in content_type + + +async def _fetch_page_html(client, *, page_url: str) -> str | None: + await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) + response = await client.get(page_url, follow_redirects=True) + if response.status_code != 200 or not _is_html_response(response): + return None + text = response.text or "" + return text[: max(int(settings.unpaywall_pdf_discovery_max_html_bytes), 0)] + + +async def _candidate_is_pdf(client, *, candidate_url: str) -> bool: + if looks_like_pdf_url(candidate_url): + return True + await wait_for_unpaywall_slot(min_interval_seconds=settings.unpaywall_min_interval_seconds) + response = await client.get(candidate_url, follow_redirects=True) + content_type = str(response.headers.get("content-type") or "").lower() + return response.status_code == 200 and PDF_MIME in content_type + + +def _candidate_limit() -> int: + return max(int(settings.unpaywall_pdf_discovery_max_candidates), 1) + + +async def _resolve_pdf_from_candidate_page(client, *, candidate_url: str) -> str | None: + html = await _fetch_page_html(client, page_url=candidate_url) + if not html: + return None + nested_candidates = _normalized_candidate_urls(page_url=candidate_url, html=html) + for nested in nested_candidates[: _candidate_limit()]: + if await _candidate_is_pdf(client, candidate_url=nested): + return nested + return None + + +async def resolve_pdf_from_landing_page(client, *, page_url: str) -> str | None: + if not settings.unpaywall_pdf_discovery_enabled: + return None + html = await _fetch_page_html(client, page_url=page_url) + if not html: + return None + candidates = _normalized_candidate_urls(page_url=page_url, html=html) + for candidate in candidates[: _candidate_limit()]: + if await _candidate_is_pdf(client, candidate_url=candidate): + return candidate + nested_pdf = await _resolve_pdf_from_candidate_page(client, candidate_url=candidate) + if nested_pdf: + return nested_pdf + return None diff --git a/app/services/domains/unpaywall/rate_limit.py b/app/services/domains/unpaywall/rate_limit.py new file mode 100644 index 0000000..76d8a3d --- /dev/null +++ b/app/services/domains/unpaywall/rate_limit.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import asyncio +import time + +_REQUEST_LOCK = asyncio.Lock() +_LAST_REQUEST_AT = 0.0 + + +async def wait_for_unpaywall_slot(*, min_interval_seconds: float) -> None: + global _LAST_REQUEST_AT + interval = max(float(min_interval_seconds), 0.0) + async with _REQUEST_LOCK: + elapsed = time.monotonic() - _LAST_REQUEST_AT + remaining = interval - elapsed + if remaining > 0: + await asyncio.sleep(remaining) + _LAST_REQUEST_AT = time.monotonic() diff --git a/app/settings.py b/app/settings.py index 82988ac..43871cf 100644 --- a/app/settings.py +++ b/app/settings.py @@ -232,8 +232,21 @@ class Settings: unpaywall_enabled: bool = _env_bool("UNPAYWALL_ENABLED", True) unpaywall_email: str = _env_str("UNPAYWALL_EMAIL", "") unpaywall_timeout_seconds: float = _env_float("UNPAYWALL_TIMEOUT_SECONDS", 4.0) + unpaywall_min_interval_seconds: float = _env_float("UNPAYWALL_MIN_INTERVAL_SECONDS", 0.6) unpaywall_max_items_per_request: int = _env_int("UNPAYWALL_MAX_ITEMS_PER_REQUEST", 20) unpaywall_retry_cooldown_seconds: int = _env_int("UNPAYWALL_RETRY_COOLDOWN_SECONDS", 1800) + pdf_auto_retry_interval_seconds: int = _env_int( + "PDF_AUTO_RETRY_INTERVAL_SECONDS", + 86_400, + ) + pdf_auto_retry_first_interval_seconds: int = _env_int( + "PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS", + 3_600, + ) + pdf_auto_retry_max_attempts: int = _env_int("PDF_AUTO_RETRY_MAX_ATTEMPTS", 3) + unpaywall_pdf_discovery_enabled: bool = _env_bool("UNPAYWALL_PDF_DISCOVERY_ENABLED", True) + unpaywall_pdf_discovery_max_candidates: int = _env_int("UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES", 5) + unpaywall_pdf_discovery_max_html_bytes: int = _env_int("UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES", 500_000) crossref_enabled: bool = _env_bool("CROSSREF_ENABLED", True) crossref_max_rows: int = _env_int("CROSSREF_MAX_ROWS", 10) crossref_timeout_seconds: float = _env_float("CROSSREF_TIMEOUT_SECONDS", 8.0) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..33a4a56 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# Documentation Index + +Use this directory for project documentation outside `README.md` and `agents.md`. + +## Deploy + +- `docs/deploy/quickstart.md`: deployment modes, local dev startup, and quality command reference. + +## Reference + +- `docs/reference/environment.md`: complete environment-variable catalog and contract/audit notes. +- `docs/reference/api_contract.md`: API envelope contract and endpoint semantics. + +## Architecture + +- `docs/architecture/domain_boundaries.md`: domain boundaries, user-scoped data rules, and frontend behavior notes. + +## Operations + +- `docs/ops/scrape_safety_runbook.md`: scrape cooldown and blocked-IP operations. +- `docs/ops/db_runbook.md`: backup, restore, and verification workflows. +- `docs/ops/migration_checklist.md`: migration planning and rollout checklist. + +## Frontend + +- `docs/frontend/theme_phase0_inventory.md`: theme-token inventory and adoption notes. + +## Contribution + +- `docs/contributing.md`: contribution policy and merge checklist. diff --git a/docs/architecture/domain_boundaries.md b/docs/architecture/domain_boundaries.md new file mode 100644 index 0000000..0a169e6 --- /dev/null +++ b/docs/architecture/domain_boundaries.md @@ -0,0 +1,25 @@ +# Domain Boundaries + +## Data Model Rules + +- Scholar tracking is user-scoped. +- Publications are global/deduplicated records. +- Read/favorite/visibility state stays on scholar-publication link rows. + +## Service Boundaries + +Canonical business logic belongs in `app/services/domains/*`. + +- `app/services/domains/ingestion/*`: run orchestration, continuation queue, scheduler, and scrape safety. +- `app/services/domains/scholar/*`: fail-fast scholar parsing and source fetch adapters. +- `app/services/domains/scholars/*`: scholar CRUD, profile image, and name-search controls. +- `app/services/domains/publications/*`: listing/read-state, favorite toggles, enrichment scheduling, and retry paths. +- `app/services/domains/crossref/*` + `app/services/domains/unpaywall/*`: DOI/OA enrichment with bounded pacing. +- `app/services/domains/runs/*`: run history and continuation queue operations. +- `app/services/domains/portability/*`: import/export workflows. + +## Frontend Behavior Notes + +- Mobile primary nav is in a left drawer and closes on route change or logout. +- Long list views use internal scroll containers. +- Name search remains intentionally constrained due upstream anti-bot behavior; production onboarding should prefer scholar ID/profile URL. diff --git a/CONTRIBUTING.md b/docs/contributing.md similarity index 100% rename from CONTRIBUTING.md rename to docs/contributing.md diff --git a/docs/deploy/quickstart.md b/docs/deploy/quickstart.md new file mode 100644 index 0000000..b959889 --- /dev/null +++ b/docs/deploy/quickstart.md @@ -0,0 +1,68 @@ +# Deploy and Dev Quickstart + +## Required Setup + +1. Copy `.env.example` to `.env`. +2. Set `POSTGRES_PASSWORD`. +3. Set `SESSION_SECRET_KEY`. + +## Deploy Method A: Prebuilt Image + +```bash +docker compose pull +docker compose up -d +``` + +Open: +- App/API: `http://localhost:8000` +- Health endpoint: `http://localhost:8000/healthz` + +Upgrade: + +```bash +docker compose pull +docker compose up -d +``` + +## Deploy Method B: Local Source + Dev Frontend + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build +``` + +Open: +- API: `http://localhost:8000` +- Frontend dev server: `http://localhost:5173` + +Stop: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml down +``` + +## Quality Commands + +Backend: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest tests/unit +docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app uv run pytest -m integration +``` + +Frontend: + +```bash +cd frontend +npm install +npm run typecheck +npm run test:run +npm run build +``` + +Repository checks: + +```bash +python3 scripts/check_frontend_api_contract.py +python3 scripts/check_env_contract.py +./scripts/check_no_generated_artifacts.sh +``` diff --git a/frontend/src/theme/THEME_PHASE0_INVENTORY.md b/docs/frontend/theme_phase0_inventory.md similarity index 100% rename from frontend/src/theme/THEME_PHASE0_INVENTORY.md rename to docs/frontend/theme_phase0_inventory.md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..72a5e53 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,12 @@ +--- +slug: / +--- + +# scholarr Documentation + +- Start here: `docs/deploy/quickstart.md` +- Runtime config: `docs/reference/environment.md` +- API contract: `docs/reference/api_contract.md` +- Domain boundaries: `docs/architecture/domain_boundaries.md` +- Ops runbooks: `docs/ops/` +- Frontend theme inventory: `docs/frontend/theme_phase0_inventory.md` diff --git a/docs/ops/db_runbook.md b/docs/ops/db_runbook.md new file mode 100644 index 0000000..68cbbfc --- /dev/null +++ b/docs/ops/db_runbook.md @@ -0,0 +1,100 @@ +# Database Operations Runbook + +This runbook defines backup, restore, and verification procedures for the +PostgreSQL database used by `scholarr`. + +## Objectives + +- Keep data loss bounded via scheduled backups. +- Provide repeatable restore procedures. +- Verify backups by running regular restore drills. + +Recommended starting targets: + +- `RPO`: 24 hours (maximum acceptable data loss). +- `RTO`: 60 minutes (maximum acceptable restore time). + +## Backup Strategy + +Use logical backups from the running `db` compose service. + +- Custom-format backup (recommended for restore flexibility): + +```bash +scripts/db/backup_full.sh +``` + +- Plain SQL backup: + +```bash +scripts/db/backup_full.sh --plain +``` + +Optional environment variables: + +- `BACKUP_DIR`: destination directory (default: `/backups`) +- `BACKUP_PREFIX`: backup filename prefix (default: `scholarr`) +- `USE_DEV_COMPOSE=1`: include `docker-compose.dev.yml` + +## Restore Strategy + +Restore from a `.dump` (custom format) or `.sql` file into the running `db` +compose service. + +- Restore without schema wipe: + +```bash +scripts/db/restore_dump.sh --file backups/scholarr_20260220T120000Z.dump +``` + +- Restore with full `public` schema reset: + +```bash +scripts/db/restore_dump.sh --file backups/scholarr_20260220T120000Z.dump --wipe-public +``` + +## Safety Checklist Before Restore + +1. Pause writes (stop app/scheduler or set maintenance mode). +2. Create a fresh pre-restore backup. +3. Validate target dump checksum/size. +4. Confirm operator, scope, and rollback plan. + +## Post-Restore Verification + +1. Run migrations head check. +2. Run health endpoint checks. +3. Verify table row counts for core tables: + - `users` + - `scholar_profiles` + - `publications` + - `scholar_publications` + - `crawl_runs` +4. Run integrity report and require zero failures: + +```bash +python3 scripts/db/check_integrity.py +``` + +5. Run API smoke checks for scholars/publications/runs pages. + +## Data Cleanup and Repair + +Use the audited repair job for scholar-publication relinking cleanup: + +```bash +python3 scripts/db/repair_publication_links.py --user-id --requested-by "" --apply +``` + +Dry-run first, then re-run with `--apply` once scope and summary counts match expectation. + +If cleanup changes schema assumptions, follow `docs/ops/migration_checklist.md` before any migration rollout. + +## Restore Drill Cadence + +- Run at least one full restore drill per month. +- Record: + - backup artifact used, + - restore start/end timestamps, + - issues encountered, + - achieved `RTO`. diff --git a/docs/ops/migration_checklist.md b/docs/ops/migration_checklist.md new file mode 100644 index 0000000..106b9fc --- /dev/null +++ b/docs/ops/migration_checklist.md @@ -0,0 +1,48 @@ +# Migration Checklist + +Use this checklist for every schema/data migration. + +## 1. Design Review + +- Define change type: `expand`, `backfill`, `contract`. +- Document expected lock behavior and index impact. +- Confirm backward compatibility with currently deployed app version. +- Define rollback strategy before implementation. + +## 2. Pre-Migration Controls + +- Capture a fresh backup before applying migration. +- Confirm migration head revision and working tree cleanliness. +- Prepare validation queries for new/changed tables and indexes. +- Identify high-risk tables (large row count, hot write paths). + +## 3. Implementation Standards + +- Keep migrations idempotent when feasible. +- Prefer additive steps first (`nullable`, new index, new table). +- For destructive changes, separate into later contract migration. +- Avoid large blocking rewrites in a single deployment step. + +## 4. Verification + +- Apply migration in staging against production-like snapshot. +- Verify: + - expected tables/columns/indexes, + - app startup and health endpoint, + - affected API flows, + - data consistency queries. + +## 5. Rollout and Recovery + +- Apply migration during planned window. +- Monitor logs/errors and DB metrics during rollout. +- If rollback needed: + - follow downgrade/recovery runbook, + - restore from backup if downgrade is unsafe, + - document incident timeline. + +## 6. Post-Migration Tasks + +- Update `README.md` / `.env.example` / ops docs. +- Add/update integration tests for new schema assumptions. +- Record migration notes in changelog. diff --git a/docs/reference/api_contract.md b/docs/reference/api_contract.md new file mode 100644 index 0000000..1994943 --- /dev/null +++ b/docs/reference/api_contract.md @@ -0,0 +1,29 @@ +# API Contract + +## Envelope Invariant + +All API responses under `/api/v1` use one of these envelopes: + +- Success: `{"data": ..., "meta": {"request_id": "..."}}` +- Error: `{"error": {"code": "...", "message": "...", "details": ...}, "meta": {"request_id": "..."}}` + +`meta.request_id` must be present for success and error responses. + +## Publications Semantics + +- `GET /api/v1/publications` supports `mode=all|unread|latest`. +- `mode=new` is currently accepted as a compatibility alias for `latest`. +- Pagination controls: `page`, `page_size` (with backward-compatible `limit`/`offset` support). +- Pagination fields in response: `page`, `page_size`, `has_prev`, `has_next`, `total_count`. +- `unread` represents read-state (`is_read=false`). +- `latest` represents discovery-state (`first seen in the latest completed run`). + +Publication payloads expose: +- `pub_url` (canonical scholar detail URL) +- `doi` (normalized DOI) +- `pdf_url` (resolved OA PDF when available) + +## Scholar Portability + +- `GET /api/v1/scholars/export` exports tracked scholars and scholar-publication link state. +- `POST /api/v1/scholars/import` imports that payload while preserving global publication deduplication. diff --git a/docs/reference/environment.md b/docs/reference/environment.md new file mode 100644 index 0000000..0dafb9b --- /dev/null +++ b/docs/reference/environment.md @@ -0,0 +1,68 @@ +# Environment Reference and Audit + +This document is the source-of-truth audit for what remains configurable versus internal defaults. + +## Decision Summary + +- Keep in `.env.example`: all runtime and compose controls currently exposed by `app/settings.py` plus compose/bootstrap extras. +- Move to internal defaults only: none at this time. +- Internal fallback behavior still exists in code to keep local/test startup resilient when values are omitted. + +## Configurable Variables (By Section) + +### Compose + Database + +`POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `DATABASE_URL`, `TEST_DATABASE_URL`, `SCHOLARR_IMAGE` + +### App Runtime + Networking + +`APP_NAME`, `APP_HOST`, `APP_PORT`, `APP_HOST_PORT`, `APP_RELOAD`, `MIGRATE_ON_START`, `FRONTEND_ENABLED`, `FRONTEND_DIST_DIR` + +### Database Pool + +`DATABASE_POOL_MODE`, `DATABASE_POOL_SIZE`, `DATABASE_POOL_MAX_OVERFLOW`, `DATABASE_POOL_TIMEOUT_SECONDS` + +### Frontend Dev Overrides + +`FRONTEND_HOST_PORT`, `CHOKIDAR_USEPOLLING`, `VITE_DEV_API_PROXY_TARGET` + +### Auth + Session + +`SESSION_SECRET_KEY`, `SESSION_COOKIE_SECURE`, `LOGIN_RATE_LIMIT_ATTEMPTS`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS` + +### HTTP Security Headers + CSP + +`SECURITY_HEADERS_ENABLED`, `SECURITY_X_CONTENT_TYPE_OPTIONS`, `SECURITY_X_FRAME_OPTIONS`, `SECURITY_REFERRER_POLICY`, `SECURITY_PERMISSIONS_POLICY`, `SECURITY_CROSS_ORIGIN_OPENER_POLICY`, `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY`, `SECURITY_CSP_ENABLED`, `SECURITY_CSP_POLICY`, `SECURITY_CSP_DOCS_POLICY`, `SECURITY_CSP_REPORT_ONLY`, `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED`, `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE`, `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS`, `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` + +### Logging + +`LOG_LEVEL`, `LOG_FORMAT`, `LOG_REQUESTS`, `LOG_UVICORN_ACCESS`, `LOG_REQUEST_SKIP_PATHS`, `LOG_REDACT_FIELDS` + +### Scheduler + Ingestion Safety + +`SCHEDULER_ENABLED`, `SCHEDULER_TICK_SECONDS`, `SCHEDULER_QUEUE_BATCH_SIZE`, `INGESTION_AUTOMATION_ALLOWED`, `INGESTION_MANUAL_RUN_ALLOWED`, `INGESTION_MIN_RUN_INTERVAL_MINUTES`, `INGESTION_MIN_REQUEST_DELAY_SECONDS`, `INGESTION_NETWORK_ERROR_RETRIES`, `INGESTION_RETRY_BACKOFF_SECONDS`, `INGESTION_MAX_PAGES_PER_SCHOLAR`, `INGESTION_PAGE_SIZE`, `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD`, `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD`, `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD`, `INGESTION_SAFETY_COOLDOWN_BLOCKED_SECONDS`, `INGESTION_SAFETY_COOLDOWN_NETWORK_SECONDS`, `INGESTION_CONTINUATION_QUEUE_ENABLED`, `INGESTION_CONTINUATION_BASE_DELAY_SECONDS`, `INGESTION_CONTINUATION_MAX_DELAY_SECONDS`, `INGESTION_CONTINUATION_MAX_ATTEMPTS` + +### Scholar Images + Name Search Safety + +`SCHOLAR_IMAGE_UPLOAD_DIR`, `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES`, `SCHOLAR_NAME_SEARCH_ENABLED`, `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS`, `SCHOLAR_NAME_SEARCH_BLOCKED_CACHE_TTL_SECONDS`, `SCHOLAR_NAME_SEARCH_CACHE_MAX_ENTRIES`, `SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS`, `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS`, `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD`, `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS`, `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD`, `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` + +### OA Enrichment + PDF Resolution + +`UNPAYWALL_ENABLED`, `UNPAYWALL_EMAIL`, `UNPAYWALL_TIMEOUT_SECONDS`, `UNPAYWALL_MIN_INTERVAL_SECONDS`, `UNPAYWALL_MAX_ITEMS_PER_REQUEST`, `UNPAYWALL_RETRY_COOLDOWN_SECONDS`, `UNPAYWALL_PDF_DISCOVERY_ENABLED`, `UNPAYWALL_PDF_DISCOVERY_MAX_CANDIDATES`, `UNPAYWALL_PDF_DISCOVERY_MAX_HTML_BYTES`, `PDF_AUTO_RETRY_INTERVAL_SECONDS`, `PDF_AUTO_RETRY_FIRST_INTERVAL_SECONDS`, `PDF_AUTO_RETRY_MAX_ATTEMPTS`, `CROSSREF_ENABLED`, `CROSSREF_MAX_ROWS`, `CROSSREF_TIMEOUT_SECONDS`, `CROSSREF_MIN_INTERVAL_SECONDS`, `CROSSREF_MAX_LOOKUPS_PER_REQUEST` + +### Startup Bootstrap + DB Wait + +`BOOTSTRAP_ADMIN_ON_START`, `BOOTSTRAP_ADMIN_EMAIL`, `BOOTSTRAP_ADMIN_PASSWORD`, `BOOTSTRAP_ADMIN_FORCE_PASSWORD`, `DB_WAIT_TIMEOUT_SECONDS`, `DB_WAIT_INTERVAL_SECONDS` + +## Drift Guard + +CI enforces env parity with: + +```bash +python3 scripts/check_env_contract.py +``` + +The check fails when: +- `app/settings.py` references a variable missing from `.env.example`. +- `.env.example` contains an unknown key (outside the approved compose/bootstrap extras). +- `.env.example` contains duplicate keys. diff --git a/frontend/index.html b/frontend/index.html index 91a3496..c23810e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,12 @@ + + + + + + scholarr + + + + diff --git a/frontend/src/components/ui/AppRefreshButton.vue b/frontend/src/components/ui/AppRefreshButton.vue new file mode 100644 index 0000000..f340217 --- /dev/null +++ b/frontend/src/components/ui/AppRefreshButton.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/ui/AppTabs.vue b/frontend/src/components/ui/AppTabs.vue new file mode 100644 index 0000000..9f29b60 --- /dev/null +++ b/frontend/src/components/ui/AppTabs.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/components/ui/AppThemePicker.vue b/frontend/src/components/ui/AppThemePicker.vue new file mode 100644 index 0000000..4fdf9f6 --- /dev/null +++ b/frontend/src/components/ui/AppThemePicker.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/features/admin_dbops/index.ts b/frontend/src/features/admin_dbops/index.ts new file mode 100644 index 0000000..e3269e1 --- /dev/null +++ b/frontend/src/features/admin_dbops/index.ts @@ -0,0 +1,153 @@ +import { apiRequest } from "@/lib/api/client"; + +export interface AdminDbIntegrityCheck { + name: string; + count: number; + severity: "warning" | "failure" | "metric"; + message: string; +} + +export interface AdminDbIntegrityReport { + status: "ok" | "warning" | "failed"; + checked_at: string; + failures: string[]; + warnings: string[]; + checks: AdminDbIntegrityCheck[]; +} + +export interface AdminDbRepairJob { + id: number; + job_name: string; + requested_by: string | null; + dry_run: boolean; + status: string; + scope: Record; + summary: Record; + error_text: string | null; + started_at: string | null; + finished_at: string | null; + created_at: string; + updated_at: string; +} + +export interface AdminPdfQueueItem { + publication_id: number; + title: string; + doi: string | null; + pdf_url: string | null; + status: string; + attempt_count: number; + last_failure_reason: string | null; + last_failure_detail: string | null; + last_source: string | null; + requested_by_user_id: number | null; + requested_by_email: string | null; + queued_at: string | null; + last_attempt_at: string | null; + resolved_at: string | null; + updated_at: string; +} + +export interface AdminPdfQueuePage { + items: AdminPdfQueueItem[]; + total_count: number; + page: number; + page_size: number; + has_next: boolean; + has_prev: boolean; +} + +export interface AdminPdfQueueRequeueResult { + publication_id: number; + queued: boolean; + status: string; + message: string; +} + +export interface AdminPdfQueueBulkEnqueueResult { + requested_count: number; + queued_count: number; + message: string; +} + +export interface TriggerPublicationLinkRepairPayload { + scope_mode?: "single_user" | "all_users"; + user_id?: number; + scholar_profile_ids?: number[]; + dry_run?: boolean; + gc_orphan_publications?: boolean; + requested_by?: string; + confirmation_text?: string; +} + +export interface TriggerPublicationLinkRepairResult { + job_id: number; + status: string; + scope: Record; + summary: Record; +} + +export async function getAdminDbIntegrityReport(): Promise { + const response = await apiRequest("/admin/db/integrity", { method: "GET" }); + return response.data; +} + +export async function listAdminDbRepairJobs(limit = 30): Promise { + const parsedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.trunc(limit))) : 30; + const response = await apiRequest<{ jobs: AdminDbRepairJob[] }>( + `/admin/db/repair-jobs?limit=${parsedLimit}`, + { method: "GET" }, + ); + return response.data.jobs; +} + +export async function triggerPublicationLinkRepair( + payload: TriggerPublicationLinkRepairPayload, +): Promise { + const response = await apiRequest( + "/admin/db/repairs/publication-links", + { + method: "POST", + body: payload, + }, + ); + return response.data; +} + +export async function listAdminPdfQueue( + page = 1, + pageSize = 100, + status: string | null = null, +): Promise { + const parsedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1; + const parsedPageSize = Number.isFinite(pageSize) ? Math.max(1, Math.min(500, Math.trunc(pageSize))) : 100; + const params = new URLSearchParams(); + params.set("page", String(parsedPage)); + params.set("page_size", String(parsedPageSize)); + if (status && status.trim().length > 0) { + params.set("status", status.trim().toLowerCase()); + } + const response = await apiRequest( + `/admin/db/pdf-queue?${params.toString()}`, + { method: "GET" }, + ); + return response.data; +} + +export async function requeueAdminPdfLookup(publicationId: number): Promise { + const id = Number.isFinite(publicationId) ? Math.trunc(publicationId) : 0; + const response = await apiRequest( + `/admin/db/pdf-queue/${Math.max(1, id)}/requeue`, + { method: "POST" }, + ); + return response.data; +} + +export async function requeueAllAdminPdfLookups(limit = 1000): Promise { + const parsedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(5000, Math.trunc(limit))) : 1000; + const response = await apiRequest( + `/admin/db/pdf-queue/requeue-all?limit=${parsedLimit}`, + { method: "POST" }, + ); + return response.data; +} diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts index ccf3ee0..ae4e538 100644 --- a/frontend/src/features/dashboard/index.ts +++ b/frontend/src/features/dashboard/index.ts @@ -41,7 +41,7 @@ function countQueueStatuses(statuses: string[]): QueueHealth { export async function fetchDashboardSnapshot(): Promise { const [publications, runsPayload, queueItems] = await Promise.all([ - listPublications({ mode: "latest", limit: 20 }), + listPublications({ mode: "latest", page: 1, pageSize: 20 }), listRuns({ limit: 20 }), listQueueItems(200), ]); diff --git a/frontend/src/features/publications/index.ts b/frontend/src/features/publications/index.ts index a36c7e5..2eddfbb 100644 --- a/frontend/src/features/publications/index.ts +++ b/frontend/src/features/publications/index.ts @@ -13,26 +13,39 @@ export interface PublicationItem { pub_url: string | null; doi: string | null; pdf_url: string | null; + pdf_status: "untracked" | "queued" | "running" | "resolved" | "failed"; + pdf_attempt_count: number; + pdf_failure_reason: string | null; + pdf_failure_detail: string | null; is_read: boolean; + is_favorite: boolean; first_seen_at: string; is_new_in_latest_run: boolean; } export interface PublicationsResult { mode: PublicationMode; + favorite_only: boolean; selected_scholar_profile_id: number | null; unread_count: number; + favorites_count: number; latest_count: number; // Compatibility alias for latest_count; retained while API clients migrate. new_count: number; total_count: number; + page: number; + page_size: number; + has_next: boolean; + has_prev: boolean; publications: PublicationItem[]; } export interface PublicationsQuery { mode?: PublicationMode; + favoriteOnly?: boolean; scholarProfileId?: number; - limit?: number; + page?: number; + pageSize?: number; } export interface PublicationSelection { @@ -46,12 +59,18 @@ export async function listPublications(query: PublicationsQuery = {}): Promise

( @@ -87,10 +106,16 @@ export async function markSelectedRead(selections: PublicationSelection[]): Prom export interface RetryPublicationPdfResult { message: string; + queued: boolean; resolved_pdf: boolean; publication: PublicationItem; } +export interface TogglePublicationFavoriteResult { + message: string; + publication: PublicationItem; +} + export async function retryPublicationPdf( publicationId: number, scholarProfileId: number, @@ -104,3 +129,21 @@ export async function retryPublicationPdf( ); return response.data; } + +export async function togglePublicationFavorite( + publicationId: number, + scholarProfileId: number, + isFavorite: boolean, +): Promise { + const response = await apiRequest( + `/publications/${publicationId}/favorite`, + { + method: "POST", + body: { + scholar_profile_id: scholarProfileId, + is_favorite: isFavorite, + }, + }, + ); + return response.data; +} diff --git a/frontend/src/features/settings/SettingsAdminPanel.vue b/frontend/src/features/settings/SettingsAdminPanel.vue new file mode 100644 index 0000000..7f5788e --- /dev/null +++ b/frontend/src/features/settings/SettingsAdminPanel.vue @@ -0,0 +1,826 @@ + + + diff --git a/frontend/src/logo.png b/frontend/src/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5fbd13eb45890819c9bcd5f54cb3fe3e83644986 GIT binary patch literal 106296 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

mO5nFdHp@a!8aJh&;3 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 uyesD?pFYLGkfQ -import { computed, onMounted, ref } from "vue"; - -import AppPage from "@/components/layout/AppPage.vue"; -import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue"; -import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue"; -import AppButton from "@/components/ui/AppButton.vue"; -import AppCard from "@/components/ui/AppCard.vue"; -import AppCheckbox from "@/components/ui/AppCheckbox.vue"; -import AppHelpHint from "@/components/ui/AppHelpHint.vue"; -import AppInput from "@/components/ui/AppInput.vue"; -import AppModal from "@/components/ui/AppModal.vue"; -import AppTable from "@/components/ui/AppTable.vue"; -import { - createAdminUser, - listAdminUsers, - resetAdminUserPassword, - setAdminUserActive, - type AdminUser, -} from "@/features/admin_users"; -import { ApiRequestError } from "@/lib/api/errors"; - -const loading = ref(true); -const creating = ref(false); -const togglingUserId = ref(null); -const resettingPassword = ref(false); -const activeUserId = ref(null); -const users = ref([]); - -const email = ref(""); -const password = ref(""); -const createIsAdmin = ref(false); -const resetPassword = ref(""); - -const errorMessage = ref(null); -const errorRequestId = ref(null); -const successMessage = ref(null); - -const activeUser = computed(() => users.value.find((item) => item.id === activeUserId.value) ?? null); - -function formatDate(value: string): string { - const asDate = new Date(value); - if (Number.isNaN(asDate.getTime())) { - return value; - } - return asDate.toLocaleString(); -} - -function userRoleLabel(user: AdminUser): string { - return user.is_admin ? "Admin" : "User"; -} - -function statusDotClass(user: AdminUser): string { - return user.is_active - ? "bg-success-500 ring-success-200" - : "bg-ink-muted/70 ring-stroke-default"; -} - -function openUserModal(user: AdminUser): void { - activeUserId.value = user.id; - resetPassword.value = ""; -} - -function closeUserModal(): void { - activeUserId.value = null; - resetPassword.value = ""; -} - -async function loadUsers(): Promise { - loading.value = true; - errorMessage.value = null; - errorRequestId.value = null; - - try { - users.value = await listAdminUsers(); - if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) { - closeUserModal(); - } - } catch (error) { - users.value = []; - if (error instanceof ApiRequestError) { - errorMessage.value = error.message; - errorRequestId.value = error.requestId; - } else { - errorMessage.value = "Unable to load users."; - } - } finally { - loading.value = false; - } -} - -async function onCreateUser(): Promise { - creating.value = true; - successMessage.value = null; - errorMessage.value = null; - errorRequestId.value = null; - - try { - if (!email.value.trim() || !password.value) { - throw new Error("Email and password are required."); - } - - const created = await createAdminUser({ - email: email.value.trim(), - password: password.value, - is_admin: createIsAdmin.value, - }); - - email.value = ""; - password.value = ""; - createIsAdmin.value = false; - successMessage.value = `User created: ${created.email}`; - await loadUsers(); - } catch (error) { - if (error instanceof ApiRequestError) { - errorMessage.value = error.message; - errorRequestId.value = error.requestId; - } else if (error instanceof Error) { - errorMessage.value = error.message; - } else { - errorMessage.value = "Unable to create user."; - } - } finally { - creating.value = false; - } -} - -async function onToggleUser(user: AdminUser): Promise { - togglingUserId.value = user.id; - successMessage.value = null; - - try { - const updated = await setAdminUserActive(user.id, !user.is_active); - successMessage.value = `${updated.email} is now ${updated.is_active ? "active" : "inactive"}.`; - await loadUsers(); - } catch (error) { - if (error instanceof ApiRequestError) { - errorMessage.value = error.message; - errorRequestId.value = error.requestId; - } else { - errorMessage.value = "Unable to update user."; - } - } finally { - togglingUserId.value = null; - } -} - -async function onResetPassword(): Promise { - const user = activeUser.value; - if (!user) { - return; - } - - resettingPassword.value = true; - successMessage.value = null; - errorMessage.value = null; - errorRequestId.value = null; - - try { - const candidate = resetPassword.value.trim(); - if (candidate.length < 12) { - throw new Error("New password must be at least 12 characters."); - } - - const result = await resetAdminUserPassword(user.id, candidate); - successMessage.value = result.message || `Password reset for ${user.email}.`; - resetPassword.value = ""; - } catch (error) { - if (error instanceof ApiRequestError) { - errorMessage.value = error.message; - errorRequestId.value = error.requestId; - } else if (error instanceof Error) { - errorMessage.value = error.message; - } else { - errorMessage.value = "Unable to reset password."; - } - } finally { - resettingPassword.value = false; - } -} - -onMounted(() => { - void loadUsers(); -}); - - - diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index 8c26da7..e61df8d 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -1,5 +1,5 @@