""" Whitelisted Pydantic response schemas for the cloud API package. All schemas are explicit allowlists — credentials_enc, tokens, and passwords are deliberately absent. T-12-03: credential exclusion by design. Phase 13 additions: - ConnectionHealthOut: typed health status response (D-12, CONN-03) - ReconnectOut: typed reconnect response (CONN-01..03) - ContentResultOut: typed open/preview/download response (D-02, D-18, T-13-14) - MutationResultOut: typed kind/reason mutation response (D-05..11) Phase 14 additions: - CacheStatusOut: aggregate cache usage + settings response (T-14-02, T-14-08) - CacheSettingsUpdateRequest: PATCH body for updating analysis settings/cache limit """ from __future__ import annotations from datetime import datetime from typing import List, Optional from pydantic import BaseModel, Field, field_validator # ── Capability / item schemas ───────────────────────────────────────────────── class CloudCapabilityOut(BaseModel): """Whitelisted capability descriptor. reason/message only when not supported.""" action: str state: str # "supported" | "unsupported" | "temporarily_unavailable" reason: Optional[str] = None message: Optional[str] = None class CloudItemOut(BaseModel): """Normalized cloud item metadata. No credentials or byte content.""" id: str # DocuVault stable UUID provider_item_id: str name: str kind: str # "file" | "folder" parent_ref: Optional[str] = None content_type: Optional[str] = None size: Optional[int] = None modified_at: Optional[datetime] = None etag: Optional[str] = None capabilities: dict[str, CloudCapabilityOut] = {} # ── Freshness / folder state schemas ───────────────────────────────────────── class FolderFreshnessOut(BaseModel): """Freshness and error state for a browsed folder.""" refresh_state: str # "fresh" | "refreshing" | "warning" last_refreshed_at: Optional[datetime] = None error_code: Optional[str] = None error_message: Optional[str] = None # ── Browse response ─────────────────────────────────────────────────────────── class CloudBrowseResponse(BaseModel): """Owner-scoped connection-ID browse response. T-12-01: items are always scoped to the resolved connection which is owned by the requesting user. credentials_enc is never included. """ connection_id: str provider: str display_name: str parent_ref: Optional[str] items: List[CloudItemOut] capabilities: dict[str, CloudCapabilityOut] freshness: FolderFreshnessOut # ── Connection schemas ──────────────────────────────────────────────────────── class ConnectionRenameRequest(BaseModel): """Validated PATCH body for renaming a connection display name. Only display_name is accepted — mass assignment prevention. """ display_name: str = Field(..., max_length=255) @field_validator("display_name") @classmethod def must_be_nonblank(cls, v: str) -> str: stripped = v.strip() if not stripped: raise ValueError("display_name must not be blank") return stripped # ── Phase 13 health / reconnect schemas ──────────────────────────────────────── class ConnectionHealthOut(BaseModel): """Typed connection health response. D-12: Explicit health status available without probing on every browse. CONN-03: Never exposes credentials_enc, tokens, or raw provider URLs. status: 'healthy' | 'degraded' | 'auth_failed' | 'offline' """ status: str connection_id: str provider: str display_name: Optional[str] = None class ReconnectOut(BaseModel): """Typed reconnect result. CONN-01: No new connection row created (reconnected patches in place). CONN-02: credentials_enc updated with re-encrypted token. CONN-03: No credentials, tokens, or provider URLs in response. D-14: Cached metadata preserved as stale. """ status: str connection_id: str provider: str display_name: Optional[str] = None reconnected: bool = True # ── Phase 13 content result schemas ─────────────────────────────────────────── class ContentResultOut(BaseModel): """Typed open/preview result body. D-02: Provider credentials and raw provider URLs must never appear in responses. D-18: Preview is binary-only; unsupported formats fall back to download fallback. T-13-14: Stable kind/reason codes let the frontend route without parsing provider payloads. kind: 'open' | 'preview' | 'download' | 'unsupported_preview' reason: discriminator code (e.g. 'binary_supported', 'unsupported_format', 'authorized') url: DocuVault-scoped authorized URL (never a raw provider URL) """ kind: str reason: Optional[str] = None url: Optional[str] = None content_type: Optional[str] = None # ── Phase 13 mutation result schemas ────────────────────────────────────────── class MutationResultOut(BaseModel): """Typed mutation result body used by rename, move, delete, and create-folder. T-13-14: Stable kind/reason codes let the frontend route without parsing raw provider error payloads. kind: 'renamed' | 'moved' | 'deleted' | 'folder' | 'conflict' | 'stale' | 'offline' | 'reauth_required' | 'invalid_destination' | 'unsupported_operation' reason: discriminator detail (e.g. 'trashed', 'permanent', 'name_collision', 'item_changed', 'provider_unreachable', 'token_expired', 'self_destination', 'cross_connection', 'provider_unsupported') """ kind: str reason: Optional[str] = None name: Optional[str] = None provider_item_id: Optional[str] = None parent_ref: Optional[str] = None # ── Phase 13 mutation request schemas ───────────────────────────────────────── class CreateFolderRequest(BaseModel): """Request body for POST /connections/{id}/folders.""" parent_ref: Optional[str] = None name: str = Field(..., max_length=255) @field_validator("name") @classmethod def must_be_nonblank(cls, v: str) -> str: stripped = v.strip() if not stripped: raise ValueError("name must not be blank") return stripped class RenameItemRequest(BaseModel): """Request body for PATCH /connections/{id}/items/{item_id}/rename.""" new_name: str = Field(..., max_length=255) etag: Optional[str] = None @field_validator("new_name") @classmethod def must_be_nonblank(cls, v: str) -> str: stripped = v.strip() if not stripped: raise ValueError("new_name must not be blank") return stripped class MoveItemRequest(BaseModel): """Request body for POST /connections/{id}/items/{item_id}/move.""" destination_parent_ref: str destination_connection_id: Optional[str] = None etag: Optional[str] = None # ── Phase 14 cache schemas ───────────────────────────────────────────────────── class CacheStatusOut(BaseModel): """Aggregate cache usage and user analysis settings. T-14-02: object_key, credentials_enc, and raw provider URLs are absent by design. This schema is a strict allowlist — no internal MinIO keys or credentials can leak through it. entry_count: Number of active (non-evicted) cache entries. total_bytes: Sum of size_bytes across active entries. cache_limit_bytes: User's preferred cache byte ceiling. tier_cap_bytes: Maximum allowed cache limit for this tier. analysis_progress_detail: "simple" | "detailed" — default "simple". analysis_failure_behavior:"pause_batch" | "continue_item" — default "pause_batch". """ entry_count: int = 0 total_bytes: int = 0 cache_limit_bytes: int tier_cap_bytes: int analysis_progress_detail: str analysis_failure_behavior: str class CacheSettingsUpdateRequest(BaseModel): """PATCH body for updating analysis preferences and cache limit. Only provided (non-None) fields are applied. Enum validation and bounds checking are performed in the service layer. Mass-assignment prevention: only the three fields below are accepted. """ analysis_progress_detail: Optional[str] = Field( default=None, description="Progress label verbosity: 'simple' or 'detailed'", ) analysis_failure_behavior: Optional[str] = Field( default=None, description="Batch failure mode: 'pause_batch' or 'continue_item'", ) cloud_cache_limit_bytes: Optional[int] = Field( default=None, ge=1, description="Preferred byte ceiling for the local byte cache", ) # ── Phase 14 analysis request schemas ───────────────────────────────────────── class AnalysisEstimateRequest(BaseModel): """Request body for POST /analysis/connections/{id}/estimate. scope: "file" | "selection" | "folder" | "connection" provider_item_ids: Required for file/selection/folder scope. Omitted for connection scope. recursive: Expand folder subtree recursively (folder scope only; connection always recursive). """ scope: str = Field(..., description="file | selection | folder | connection") provider_item_ids: Optional[List[str]] = Field( default=None, description="Provider item IDs for file/selection/folder scope", ) recursive: bool = Field( default=False, description="Expand folder children recursively (folder scope)", ) class AnalysisEnqueueRequest(BaseModel): """Request body for POST /analysis/connections/{id}/jobs. scope: "file" | "selection" | "folder" | "connection" provider_item_ids: Required for file/selection/folder scope. recursive: Expand folder subtree recursively. failure_behavior: "pause_batch" (default) | "continue_item" (D-11). """ scope: str = Field(..., description="file | selection | folder | connection") provider_item_ids: Optional[List[str]] = Field(default=None) recursive: bool = Field(default=False) failure_behavior: str = Field( default="pause_batch", description="pause_batch | continue_item", ) # ── Phase 14 analysis response schemas ──────────────────────────────────────── class AnalysisEstimateOut(BaseModel): """Estimate response — no credentials, bytes, or object_key (T-14-02). supported_count: Files that can be analysed. unsupported_count: Items that cannot be analysed (unsupported type, folder). total_provider_bytes: Sum of provider_size across supported items. recursive: Whether the estimate was recursive. is_partial: True when metadata expansion was incomplete. scope_kind: Echo of the requested scope. """ supported_count: int = 0 unsupported_count: int = 0 total_provider_bytes: int = 0 recursive: bool = False is_partial: bool = False scope_kind: str class AnalysisJobOut(BaseModel): """Job status response — no credentials or object_key (T-14-02). Exposes both simple and detailed aggregate counts. Caller requests detail via ?detail=true query parameter. Simple (default): waiting_count, working_count, done_count, skipped_count, failed_count, total_count. Detailed: queued_count, downloading_count, extracting_count, classifying_count, indexed_count, already_current_count, cancelled_count, failed_count, unsupported_count. """ job_id: str connection_id: str scope_kind: str status: str failure_behavior: str recursive: bool = False total_count: int = 0 # Simple labels (always present) waiting_count: int = 0 working_count: int = 0 done_count: int = 0 skipped_count: int = 0 failed_count: int = 0 # Detailed labels (always present — 0 when detail=false, per-stage values when detail=true) queued_count: int = 0 downloading_count: int = 0 extracting_count: int = 0 classifying_count: int = 0 indexed_count: int = 0 already_current_count: int = 0 cancelled_count: int = 0 unsupported_count: int = 0 created_at: Optional[datetime] = None started_at: Optional[datetime] = None finished_at: Optional[datetime] = None class AnalysisEnqueueOut(BaseModel): """Enqueue response — returns a stable job_id for status polling (ANALYZE-01). No credentials or object_key in response (T-14-02). """ job_id: str status: str total_count: int = 0 queued_count: int = 0 already_current_count: int = 0 unsupported_count: int = 0 class AnalysisJobItemOut(BaseModel): """Per-item job status — no credentials, object_key, or raw provider data (T-14-02).""" id: str cloud_item_id: str provider_item_id: str status: str error_code: Optional[str] = None error_message: Optional[str] = None retry_count: int = 0 created_at: Optional[datetime] = None finished_at: Optional[datetime] = None class AnalysisControlOut(BaseModel): """Generic control result for cancel/skip/retry operations.""" kind: str # "cancelled" | "skipped" | "retried" reason: Optional[str] = None job_id: Optional[str] = None item_id: Optional[str] = None