- POST /analysis/connections/{id}/estimate: scope estimate without bytes (T-14-04)
- POST /analysis/connections/{id}/jobs: enqueue job, returns job_id (ANALYZE-01)
- GET /analysis/jobs, GET /analysis/jobs/{id}: job status list and detail
- POST /analysis/jobs/{id}/cancel: batch cancel (queued items immediate; running cooperative)
- POST /analysis/jobs/{id}/items/{item_id}/cancel|skip|retry: per-item controls (ANALYZE-05)
- AnalysisEstimateRequest/Out, AnalysisEnqueueRequest/Out, AnalysisJobOut: typed schemas
- AnalysisJobOut: both simple (waiting/working/done/skipped/failed) and per-stage counts
- All routes use get_regular_user — admin blocked (T-14-01)
- Response schemas exclude credentials_enc, object_key, raw provider URLs (T-14-02)
- 14/14 test_cloud_analysis_api.py pass; 7 new analysis security tests pass (33/33 total)
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""
|
|
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
|