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:
@@ -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:
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user