feat(14.1-02): CloudItemDetailOut schema + resolve_owned_cloud_item_detail + GET detail route

- Add CloudItemDetailOut allowlist schema to backend/api/cloud/schemas.py
  (excludes credentials_enc, object_key, version_key, raw provider URLs — T-14.1-03)
- Add CloudItemDetail dataclass and resolve_owned_cloud_item_detail service helper
  to backend/services/cloud_items.py (owner-scoped, metadata-only, no byte hydration)
- Add GET /connections/{id}/items/{item_id:path}/detail route to operations.py
  with get_regular_user (admin 403), ConnectionNotFound → 404, CloudItemNotFound → 404
- All 8 test_cloud_detail_parity tests now pass (D-05, D-07, D-16, T-14.1-03/04/06)
This commit is contained in:
curo1305
2026-06-26 21:54:28 +02:00
parent cfba896a44
commit a0d5c1d6c4
3 changed files with 306 additions and 1 deletions
+81
View File
@@ -47,6 +47,7 @@ from fastapi.responses import JSONResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.cloud.schemas import (
CloudItemDetailOut,
CreateFolderRequest,
MoveItemRequest,
MutationResultOut,
@@ -59,7 +60,9 @@ from deps.db import get_db
from deps.utils import get_client_ip
from services.audit import write_audit_log
from services.cloud_items import (
CloudItemNotFound,
ConnectionNotFound,
resolve_owned_cloud_item_detail,
update_folder_state,
upsert_cloud_item,
)
@@ -280,6 +283,84 @@ async def open_cloud_file(
}
# ── Cloud item detail endpoint ────────────────────────────────────────────────
@router.get(
"/connections/{connection_id}/items/{item_id:path}/detail",
response_model=CloudItemDetailOut,
)
@account_limiter.limit("120/minute")
async def get_cloud_item_detail(
connection_id: uuid.UUID,
item_id: str,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> CloudItemDetailOut:
"""Return owner-scoped cloud item detail with analysis fields and source metadata.
Endpoint contract (Phase 14.1 Plan 02):
- Returns extracted_text, analysis_status, semantic_index_status, topics,
provider, display_name, location, capabilities, and unsupported_analysis_reason.
- Foreign user gets 404 (IDOR protection — T-14.1-04).
- Admin is blocked by get_regular_user (T-14.1-04).
- No provider bytes are downloaded — metadata-only DB reads (T-14.1-06, CACHE-03).
- response_model=CloudItemDetailOut enforces allowlist — no credentials_enc,
object_key, version_key, or raw provider URLs can leak (T-14.1-03).
D-05: Once analysis completes, detail shows extracted text, topics, and status.
D-07: Stale items retain prior extracted_text and topics; is_stale=True signals state.
D-16: unsupported_analysis_reason present when analysis is not possible.
"""
request.state.current_user = current_user
try:
detail = await resolve_owned_cloud_item_detail(
session,
user_id=current_user.id,
connection_id=connection_id,
provider_item_id=item_id,
)
except ConnectionNotFound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Connection not found",
)
except CloudItemNotFound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cloud item not found",
)
# Map the credential-free dataclass to the allowlist response schema.
# capabilities is left empty here — the detail surface does not call the provider
# for live capability resolution (that would require credential decryption, which
# violates the metadata-only constraint). An empty dict is safe: the frontend can
# infer available actions from analysis_status and unsupported_analysis_reason.
return CloudItemDetailOut(
id=detail.id,
provider_item_id=detail.provider_item_id,
name=detail.name,
kind=detail.kind,
parent_ref=detail.parent_ref,
content_type=detail.content_type,
size=detail.size,
modified_at=detail.modified_at,
etag=detail.etag,
provider=detail.provider,
display_name=detail.display_name,
location=detail.location,
analysis_status=detail.analysis_status,
semantic_index_status=detail.semantic_index_status,
extracted_text=detail.extracted_text,
topics=detail.topics,
capabilities={},
unsupported_analysis_reason=detail.unsupported_analysis_reason,
is_stale=detail.is_stale,
)
# ── Preview endpoint ──────────────────────────────────────────────────────────
def _unsupported_preview_response(connection_id: uuid.UUID, item_id: str, reason: str) -> dict:
+74
View File
@@ -13,6 +13,10 @@ Phase 13 additions:
Phase 14 additions:
- CacheStatusOut: aggregate cache usage + settings response (T-14-02, T-14-08)
- CacheSettingsUpdateRequest: PATCH body for updating analysis settings/cache limit
Phase 14.1 additions:
- CloudItemDetailOut: owner-scoped cloud item detail with analysis fields (D-05)
- force field on AnalysisEnqueueRequest: bypass already_current check (D-11)
"""
from __future__ import annotations
@@ -22,6 +26,66 @@ from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
# ── Phase 14.1 cloud item detail schema ──────────────────────────────────────
class CloudItemDetailOut(BaseModel):
"""Owner-scoped cloud item detail with analysis fields and source metadata.
Strict allowlist: object_key, credentials_enc, version_key, and raw provider
URLs are absent by design (T-14.1-03, mirrors CacheStatusOut T-14-02 pattern).
Fields:
id: DocuVault stable UUID (str).
provider_item_id: Provider-side opaque item identifier.
name: File or folder name.
kind: "file" | "folder".
parent_ref: Provider parent reference (opaque string, no URL).
content_type: MIME type from provider metadata.
size: Provider-reported byte count (provider_size).
modified_at: Provider-reported last modification timestamp.
etag: Provider entity tag for version comparison.
provider: Connection provider string (e.g. "google_drive").
display_name: Human-readable connection display name.
location: Human path (path_snapshot) or parent_ref — no provider URL.
analysis_status: "pending" | "indexed" | "stale" | "failed" | ...
semantic_index_status: "none" | "indexed" | ...
extracted_text: Text extracted during analysis (None when not yet analyzed).
topics: List of topic names linked to this item (empty when not analyzed).
capabilities: Per-action capability map (action → CloudCapabilityOut).
unsupported_analysis_reason: Reason why analysis is unsupported (None when supported).
is_stale: True when analysis_status == "stale" (D-07 convenience flag).
"""
# Core identity (no object_key, credentials_enc, version_key — T-14.1-03)
id: str
provider_item_id: str
name: str
kind: str
parent_ref: Optional[str] = None
# Provider metadata
content_type: Optional[str] = None
size: Optional[int] = None
modified_at: Optional[datetime] = None
etag: Optional[str] = None
# Connection source metadata (D-04) — no raw provider URLs
provider: str
display_name: str
location: Optional[str] = None # path_snapshot or parent_ref, never a provider URL
# Analysis fields (D-05, D-07, D-08)
analysis_status: str = "pending"
semantic_index_status: str = "none"
extracted_text: Optional[str] = None
topics: List[str] = []
# Action availability (D-05, D-16)
capabilities: dict[str, CloudCapabilityOut] = {}
unsupported_analysis_reason: Optional[str] = None # populated when analyze is unsupported
is_stale: bool = False # True when analysis_status == "stale" (D-07)
# ── Capability / item schemas ─────────────────────────────────────────────────
class CloudCapabilityOut(BaseModel):
@@ -285,6 +349,7 @@ class AnalysisEnqueueRequest(BaseModel):
provider_item_ids: Required for file/selection/folder scope.
recursive: Expand folder subtree recursively.
failure_behavior: "pause_batch" (default) | "continue_item" (D-11).
force: When True, bypass already_current check and re-queue indexed items (D-11).
"""
scope: str = Field(..., description="file | selection | folder | connection")
@@ -294,6 +359,15 @@ class AnalysisEnqueueRequest(BaseModel):
default="pause_batch",
description="pause_batch | continue_item",
)
force: bool = Field(
default=False,
description=(
"When True, bypass the already_current check and re-queue supported items "
"even if their version_key matches a prior indexed run. "
"Preserves existing idempotent behavior when False (default). "
"No provider mutation is performed regardless of this flag (ANALYZE-07)."
),
)
# ── Phase 14 analysis response schemas ────────────────────────────────────────