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:
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Sequence
|
||||
from typing import List, Optional, Sequence
|
||||
|
||||
from sqlalchemy import select, update
|
||||
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 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 ─────────────────────────────────────────────────────
|
||||
|
||||
async def resolve_owned_connection(
|
||||
@@ -62,6 +103,115 @@ async def resolve_owned_connection(
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_cloud_children(
|
||||
|
||||
Reference in New Issue
Block a user