Add ruff and mypy to dev dependencies with configuration in pyproject.toml. Add a lint CI job that runs ruff check, ruff format --check, and mypy. Auto-fix import sorting and formatting across the codebase. Exclude alembic/versions from linting (auto-generated migrations). Ignore B008 (FastAPI Depends pattern) and RUF001 (unicode in user-facing strings). 21 ruff lint errors and 50 mypy errors remain for manual review. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
152 lines
4.8 KiB
Python
152 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import UserSetting
|
|
|
|
|
|
class UserSettingsServiceError(ValueError):
|
|
"""Raised for expected settings-validation failures."""
|
|
|
|
|
|
NAV_PAGE_DASHBOARD = "dashboard"
|
|
NAV_PAGE_SCHOLARS = "scholars"
|
|
NAV_PAGE_PUBLICATIONS = "publications"
|
|
NAV_PAGE_SETTINGS = "settings"
|
|
NAV_PAGE_STYLE_GUIDE = "style-guide"
|
|
NAV_PAGE_RUNS = "runs"
|
|
NAV_PAGE_USERS = "users"
|
|
|
|
ALLOWED_NAV_PAGES = (
|
|
NAV_PAGE_DASHBOARD,
|
|
NAV_PAGE_SCHOLARS,
|
|
NAV_PAGE_PUBLICATIONS,
|
|
NAV_PAGE_SETTINGS,
|
|
NAV_PAGE_STYLE_GUIDE,
|
|
NAV_PAGE_RUNS,
|
|
NAV_PAGE_USERS,
|
|
)
|
|
REQUIRED_NAV_PAGES = (
|
|
NAV_PAGE_DASHBOARD,
|
|
NAV_PAGE_SCHOLARS,
|
|
NAV_PAGE_SETTINGS,
|
|
)
|
|
DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES)
|
|
HARD_MIN_RUN_INTERVAL_MINUTES = 15
|
|
HARD_MIN_REQUEST_DELAY_SECONDS = 2
|
|
|
|
|
|
def resolve_run_interval_minimum(configured_minimum: int | None) -> int:
|
|
try:
|
|
parsed = int(configured_minimum) if configured_minimum is not None else HARD_MIN_RUN_INTERVAL_MINUTES
|
|
except (TypeError, ValueError):
|
|
parsed = HARD_MIN_RUN_INTERVAL_MINUTES
|
|
return max(HARD_MIN_RUN_INTERVAL_MINUTES, parsed)
|
|
|
|
|
|
def resolve_request_delay_minimum(configured_minimum: int | None) -> int:
|
|
try:
|
|
parsed = int(configured_minimum) if configured_minimum is not None else HARD_MIN_REQUEST_DELAY_SECONDS
|
|
except (TypeError, ValueError):
|
|
parsed = HARD_MIN_REQUEST_DELAY_SECONDS
|
|
return max(HARD_MIN_REQUEST_DELAY_SECONDS, parsed)
|
|
|
|
|
|
def parse_run_interval_minutes(value: str, *, minimum: int = HARD_MIN_RUN_INTERVAL_MINUTES) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except ValueError as exc:
|
|
raise UserSettingsServiceError("Check interval must be a whole number.") from exc
|
|
effective_minimum = resolve_run_interval_minimum(minimum)
|
|
if parsed < effective_minimum:
|
|
raise UserSettingsServiceError(f"Check interval must be at least {effective_minimum} minutes.")
|
|
return parsed
|
|
|
|
|
|
def parse_request_delay_seconds(value: str, *, minimum: int = HARD_MIN_REQUEST_DELAY_SECONDS) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except ValueError as exc:
|
|
raise UserSettingsServiceError("Request delay must be a whole number.") from exc
|
|
effective_minimum = resolve_request_delay_minimum(minimum)
|
|
if parsed < effective_minimum:
|
|
raise UserSettingsServiceError(f"Request delay must be at least {effective_minimum} seconds.")
|
|
return parsed
|
|
|
|
|
|
def parse_nav_visible_pages(value: object) -> list[str]:
|
|
if not isinstance(value, list):
|
|
raise UserSettingsServiceError("Navigation visibility must be a list of page ids.")
|
|
|
|
deduped: list[str] = []
|
|
seen: set[str] = set()
|
|
|
|
for raw_page in value:
|
|
if not isinstance(raw_page, str):
|
|
raise UserSettingsServiceError("Navigation visibility entries must be strings.")
|
|
|
|
page_id = raw_page.strip()
|
|
if page_id not in ALLOWED_NAV_PAGES:
|
|
raise UserSettingsServiceError(f"Unsupported navigation page id: {page_id}")
|
|
|
|
if page_id in seen:
|
|
continue
|
|
|
|
seen.add(page_id)
|
|
deduped.append(page_id)
|
|
|
|
missing_required = [page for page in REQUIRED_NAV_PAGES if page not in seen]
|
|
if missing_required:
|
|
raise UserSettingsServiceError("Dashboard, Scholars, and Settings must remain visible.")
|
|
|
|
return deduped
|
|
|
|
|
|
from app.settings import settings as app_settings
|
|
|
|
|
|
async def get_or_create_settings(
|
|
db_session: AsyncSession,
|
|
*,
|
|
user_id: int,
|
|
) -> UserSetting:
|
|
result = await db_session.execute(select(UserSetting).where(UserSetting.user_id == user_id))
|
|
settings = result.scalar_one_or_none()
|
|
if settings is not None:
|
|
return settings
|
|
|
|
settings = UserSetting(
|
|
user_id=user_id,
|
|
openalex_api_key=app_settings.openalex_api_key,
|
|
crossref_api_token=app_settings.crossref_api_token,
|
|
crossref_api_mailto=app_settings.crossref_api_mailto,
|
|
)
|
|
db_session.add(settings)
|
|
await db_session.commit()
|
|
await db_session.refresh(settings)
|
|
return settings
|
|
|
|
|
|
async def update_settings(
|
|
db_session: AsyncSession,
|
|
*,
|
|
settings: UserSetting,
|
|
auto_run_enabled: bool,
|
|
run_interval_minutes: int,
|
|
request_delay_seconds: int,
|
|
nav_visible_pages: list[str],
|
|
openalex_api_key: str | None,
|
|
crossref_api_token: str | None,
|
|
crossref_api_mailto: str | None,
|
|
) -> UserSetting:
|
|
settings.auto_run_enabled = auto_run_enabled
|
|
settings.run_interval_minutes = run_interval_minutes
|
|
settings.request_delay_seconds = request_delay_seconds
|
|
settings.nav_visible_pages = nav_visible_pages
|
|
settings.openalex_api_key = openalex_api_key
|
|
settings.crossref_api_token = crossref_api_token
|
|
settings.crossref_api_mailto = crossref_api_mailto
|
|
await db_session.commit()
|
|
await db_session.refresh(settings)
|
|
return settings
|