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 sqlalchemy.ext.asyncio import AsyncSession
from api.cloud.schemas import ( from api.cloud.schemas import (
CloudItemDetailOut,
CreateFolderRequest, CreateFolderRequest,
MoveItemRequest, MoveItemRequest,
MutationResultOut, MutationResultOut,
@@ -59,7 +60,9 @@ from deps.db import get_db
from deps.utils import get_client_ip from deps.utils import get_client_ip
from services.audit import write_audit_log from services.audit import write_audit_log
from services.cloud_items import ( from services.cloud_items import (
CloudItemNotFound,
ConnectionNotFound, ConnectionNotFound,
resolve_owned_cloud_item_detail,
update_folder_state, update_folder_state,
upsert_cloud_item, 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 ────────────────────────────────────────────────────────── # ── Preview endpoint ──────────────────────────────────────────────────────────
def _unsupported_preview_response(connection_id: uuid.UUID, item_id: str, reason: str) -> dict: 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: Phase 14 additions:
- CacheStatusOut: aggregate cache usage + settings response (T-14-02, T-14-08) - CacheStatusOut: aggregate cache usage + settings response (T-14-02, T-14-08)
- CacheSettingsUpdateRequest: PATCH body for updating analysis settings/cache limit - 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 from __future__ import annotations
@@ -22,6 +26,66 @@ from typing import List, Optional
from pydantic import BaseModel, Field, field_validator 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 ───────────────────────────────────────────────── # ── Capability / item schemas ─────────────────────────────────────────────────
class CloudCapabilityOut(BaseModel): class CloudCapabilityOut(BaseModel):
@@ -285,6 +349,7 @@ class AnalysisEnqueueRequest(BaseModel):
provider_item_ids: Required for file/selection/folder scope. provider_item_ids: Required for file/selection/folder scope.
recursive: Expand folder subtree recursively. recursive: Expand folder subtree recursively.
failure_behavior: "pause_batch" (default) | "continue_item" (D-11). 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") scope: str = Field(..., description="file | selection | folder | connection")
@@ -294,6 +359,15 @@ class AnalysisEnqueueRequest(BaseModel):
default="pause_batch", default="pause_batch",
description="pause_batch | continue_item", 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 ──────────────────────────────────────── # ── Phase 14 analysis response schemas ────────────────────────────────────────
+151 -1
View File
@@ -14,7 +14,7 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Sequence from typing import List, Optional, Sequence
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -33,6 +33,47 @@ class CloudItemNotFound(ValueError):
"""Cloud item does not exist for the given owner/connection.""" """Cloud item does not exist for the given owner/connection."""
# ── Cloud item detail dataclass ───────────────────────────────────────────────
@dataclass
class CloudItemDetail:
"""Credential-free detail payload for a single cloud item.
Built by resolve_owned_cloud_item_detail from DB metadata only — no byte
hydration is performed (T-14.1-06 / CACHE-03).
Forbidden fields: object_key, credentials_enc, version_key, raw provider URLs.
"""
# Core item identity
id: str
provider_item_id: str
name: str
kind: str
parent_ref: Optional[str]
# Provider metadata
content_type: Optional[str]
size: Optional[int]
modified_at: Optional[datetime]
etag: Optional[str]
# Connection source metadata (D-04) — no raw provider URLs or credentials
provider: str
display_name: str
location: Optional[str] # path_snapshot or parent_ref; never a provider URL
# Analysis fields (D-05, D-07, D-08)
analysis_status: str
semantic_index_status: str
extracted_text: Optional[str]
topics: List[str]
# Capability / unsupported-analysis convenience fields (D-16)
is_stale: bool
unsupported_analysis_reason: Optional[str]
# ── Connection resolution ───────────────────────────────────────────────────── # ── Connection resolution ─────────────────────────────────────────────────────
async def resolve_owned_connection( async def resolve_owned_connection(
@@ -62,6 +103,115 @@ async def resolve_owned_connection(
return conn return conn
# ── Cloud item detail resolution ─────────────────────────────────────────────
async def resolve_owned_cloud_item_detail(
session: AsyncSession,
*,
user_id,
connection_id,
provider_item_id: str,
) -> CloudItemDetail:
"""Return owner-scoped cloud item detail from DB metadata only.
Performs three DB reads:
1. resolve_owned_connection — verifies the connection is owned by user_id.
2. Select CloudItem by (connection_id, provider_item_id, user_id).
3. Select CloudItemTopic→Topic join for topic names.
No provider bytes are downloaded. No get_object or hydrate_and_cache_bytes
is called (T-14.1-06, CACHE-03). This function is metadata-only.
Args:
session: Active async SQLAlchemy session.
user_id: Authenticated user UUID.
connection_id: Cloud connection UUID.
provider_item_id: Provider-side opaque item identifier.
Returns:
CloudItemDetail dataclass populated from DB rows.
Raises:
ConnectionNotFound: Connection does not exist or belongs to another user.
CloudItemNotFound: Cloud item does not exist for the given connection/owner.
"""
from db.models import CloudItemTopic, Topic # local import avoids circular
# 1. Ownership gate (T-14.1-04, T-14.1-06)
conn = await resolve_owned_connection(
session, connection_id=connection_id, user_id=user_id
)
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
cid = connection_id if isinstance(connection_id, uuid.UUID) else uuid.UUID(str(connection_id))
# 2. Resolve the cloud item (must belong to same user)
result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == cid,
CloudItem.provider_item_id == provider_item_id,
CloudItem.user_id == uid,
CloudItem.deleted_at.is_(None),
)
)
item = result.scalars().first()
if item is None:
raise CloudItemNotFound(
f"Cloud item {provider_item_id!r} not found in connection {connection_id!r}"
)
# 3. Resolve topic names via CloudItemTopic→Topic join (metadata-only)
topics_result = await session.execute(
select(Topic.name).join(
CloudItemTopic, CloudItemTopic.topic_id == Topic.id
).where(
CloudItemTopic.cloud_item_id == item.id
)
)
topic_names: List[str] = list(topics_result.scalars().all())
# 4. Derive convenience / source metadata fields
# location: prefer path_snapshot (human path), fall back to parent_ref (opaque)
# — never expose raw provider URLs in this field (T-14.1-03)
location: Optional[str] = getattr(item, "path_snapshot", None) or item.parent_ref
# unsupported_analysis_reason: populated when the item cannot be analyzed
# Uses the same logic as cloud_analysis._is_supported without importing that module
unsupported_reason: Optional[str] = None
if item.kind == "folder":
unsupported_reason = "Folders cannot be analyzed"
else:
# Simple extension/MIME check duplicated here to avoid a cross-service import.
# The authoritative supported-type check lives in services.cloud_analysis._is_supported.
from services.cloud_analysis import _is_supported as _ca_is_supported
if not _ca_is_supported(item):
unsupported_reason = "File type is not supported for analysis"
is_stale = (item.analysis_status == "stale")
return CloudItemDetail(
id=str(item.id),
provider_item_id=item.provider_item_id,
name=item.name,
kind=item.kind,
parent_ref=item.parent_ref,
content_type=item.content_type,
size=item.provider_size,
modified_at=item.modified_at,
etag=item.etag,
provider=conn.provider,
display_name=conn.display_name_override or conn.display_name,
location=location,
analysis_status=item.analysis_status,
semantic_index_status=item.semantic_index_status,
extracted_text=item.extracted_text,
topics=topic_names,
is_stale=is_stale,
unsupported_analysis_reason=unsupported_reason,
)
# ── Item listing ────────────────────────────────────────────────────────────── # ── Item listing ──────────────────────────────────────────────────────────────
async def list_cloud_children( async def list_cloud_children(