""" Cloud analysis user settings helpers — Phase 14. Provides get_or_create_user_analysis_settings and update_user_analysis_settings: the single authority for reading and mutating per-user analysis preferences. Rules: - Service raises ValueError only — never HTTPException (CLAUDE.md). - Default values are defined here; the router layer never hard-codes them. - Tier/admin cache limit maximums are enforced as a seam: pass the admin maximum via max_cache_limit_bytes to update_user_analysis_settings. - Invalid enum values are rejected before touching the database. """ from __future__ import annotations import uuid from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from db.models import UserAnalysisSettings # ── Valid enum values ───────────────────────────────────────────────────────── VALID_PROGRESS_DETAIL = {"simple", "detailed"} VALID_FAILURE_BEHAVIOR = {"pause_batch", "continue_item"} # Absolute floor for cache limit — prevents pathologically small values MIN_CACHE_LIMIT_BYTES = 1 * 1024 * 1024 # 1 MB # Default tier maximum — overridable by caller (admin/tier seam) DEFAULT_MAX_CACHE_LIMIT_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB # Default per-user cache limit when row is created DEFAULT_CACHE_LIMIT_BYTES = 512 * 1024 * 1024 # 512 MB # Default enum values (mirrors migration server_defaults) DEFAULT_PROGRESS_DETAIL = "simple" DEFAULT_FAILURE_BEHAVIOR = "pause_batch" # ── Public API ───────────────────────────────────────────────────────────────── async def get_or_create_user_analysis_settings( session: AsyncSession, *, user_id: uuid.UUID, ) -> UserAnalysisSettings: """Return the user's analysis settings row, creating defaults if absent. Never raises — creates the row on first access so callers always receive a valid settings object. Args: session: Active async SQLAlchemy session. user_id: Authenticated user UUID. Returns: UserAnalysisSettings with current preferences or newly created defaults. """ uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id)) result = await session.execute( select(UserAnalysisSettings).where(UserAnalysisSettings.user_id == uid) ) existing = result.scalars().first() if existing is not None: return existing # Create defaults on first access settings_row = UserAnalysisSettings( user_id=uid, analysis_progress_detail=DEFAULT_PROGRESS_DETAIL, analysis_failure_behavior=DEFAULT_FAILURE_BEHAVIOR, cloud_cache_limit_bytes=DEFAULT_CACHE_LIMIT_BYTES, ) session.add(settings_row) await session.flush() return settings_row async def update_user_analysis_settings( session: AsyncSession, *, user_id: uuid.UUID, analysis_progress_detail: Optional[str] = None, analysis_failure_behavior: Optional[str] = None, cloud_cache_limit_bytes: Optional[int] = None, max_cache_limit_bytes: int = DEFAULT_MAX_CACHE_LIMIT_BYTES, ) -> UserAnalysisSettings: """Update analysis preferences for the user. Only provided (non-None) fields are updated. Enum values and cache limit bounds are validated before the database is touched. Args: session: Active async SQLAlchemy session. user_id: Authenticated user UUID. analysis_progress_detail: "simple" | "detailed" (or None to leave unchanged). analysis_failure_behavior: "pause_batch" | "continue_item" (or None). cloud_cache_limit_bytes: User-preferred cache byte limit (or None). max_cache_limit_bytes: Tier/admin maximum (default 5 GB). Caller supplies this so the seam is explicit. Returns: Updated UserAnalysisSettings row. Raises: ValueError: Invalid enum value or cache limit out of bounds. """ if analysis_progress_detail is not None: if analysis_progress_detail not in VALID_PROGRESS_DETAIL: raise ValueError( f"Invalid analysis_progress_detail {analysis_progress_detail!r}. " f"Valid values: {VALID_PROGRESS_DETAIL}" ) if analysis_failure_behavior is not None: if analysis_failure_behavior not in VALID_FAILURE_BEHAVIOR: raise ValueError( f"Invalid analysis_failure_behavior {analysis_failure_behavior!r}. " f"Valid values: {VALID_FAILURE_BEHAVIOR}" ) if cloud_cache_limit_bytes is not None: if cloud_cache_limit_bytes < MIN_CACHE_LIMIT_BYTES: raise ValueError( f"cloud_cache_limit_bytes must be at least {MIN_CACHE_LIMIT_BYTES} bytes, " f"got {cloud_cache_limit_bytes}" ) if cloud_cache_limit_bytes > max_cache_limit_bytes: raise ValueError( f"cloud_cache_limit_bytes {cloud_cache_limit_bytes} exceeds tier maximum " f"{max_cache_limit_bytes}" ) # Fetch or create the row (never fails) row = await get_or_create_user_analysis_settings(session, user_id=user_id) if analysis_progress_detail is not None: row.analysis_progress_detail = analysis_progress_detail if analysis_failure_behavior is not None: row.analysis_failure_behavior = analysis_failure_behavior if cloud_cache_limit_bytes is not None: row.cloud_cache_limit_bytes = cloud_cache_limit_bytes await session.flush() return row def build_default_settings() -> dict: """Return a dict of default settings values for use in API responses. Used when no row exists yet and the caller needs a response without committing a new row (e.g. GET /cache before any settings are saved). """ return { "analysis_progress_detail": DEFAULT_PROGRESS_DETAIL, "analysis_failure_behavior": DEFAULT_FAILURE_BEHAVIOR, "cloud_cache_limit_bytes": DEFAULT_CACHE_LIMIT_BYTES, }