Core changes: - Add @defgroup/@ingroup to 1791 C2+ contracts (555 files) for HCA 128× pre-training DSA grouping - Add §0.1 Pre-Training Frequency matrix to semantics-core - Add §VIII Attention Architecture rules (ATTN_1-4) with MLA/CSA/HCA/DSA mechanics - Add @defgroup/@ingroup to canonical syntax (§II) and all contract examples Agent prompts (5 files): - Add ZERO-STATE RATIONALE with MLA/CSA/HCA/DSA compression mechanics - Add pre-training note: @RATIONALE/@REJECTED are in-context learned tags - svelte-coder: add missing #region contract, fix Svelte rule violations - python-coder/fullstack-coder: honor function contracts from speckit plan - qa-tester: add attention compliance audit (P3 ATTN_1-4 checks) Skills (6 files): - Translate all axiom_config descriptions to English - Fix doc_dirs to index .opencode/ and .specify/ - Deduplicate 5× complexity_rules → single global_tags catalog - Reduce semantics-svelte 591→485 lines (remove duplicate code blocks) - Fix semantics-testing: 'Short IDs' → 'Short hierarchical IDs' - Fix all examples: flat IDs → hierarchical Domain.Name format - Fix Svelte examples: replace raw Tailwind + <button> with semantic tokens + /ui Speckit workflow (commands + templates): - speckit.plan: add Function-Level Contracts for C3+ with @PRE/@POST/@TEST_EDGE - speckit.plan: add Attention Compliance Gate (ATTN_1-4 before contract generation) - speckit.tasks: add function contract inlining format (constraints in task description) - speckit.specify: load semantics-core for spec density rules - spec-template: add #region contract, @SEMANTICS grouping, hierarchical IDs - ux-reference-template: add #region wrapper - plan-template: add attention gate, @defgroup/@ingroup guidance - tasks-template: add attention audit + rebuild + orphan check tasks - constitution.md: translate to English, add Principle VIII (attention-optimized contracts) Reference modules rewritten (hierarchical IDs + full contracts): - Auth.Jwt: 6 child contracts with @RATIONALE/@REJECTED/@TEST_EDGE - Api.Auth: 5 endpoints with @TEST_EDGE + molecular CoT markers - Migration.Model: @defgroup Migration with 18 @ACTION + 6 @INVARIANT Scripts: - add_defgroup_ingroup.py: zero-risk additive @ingroup migration (1791 insertions) - migrate_hierarchical.py: flat→hierarchical ID dry-run analysis (792 contracts) - merge_prompts.py: merge all prompts/skills/commands into one review file Config: - axiom_config.yaml: 749→395 lines (-47%), English, doc_dirs include prompts - Fix test_datasets.py import collision (rename → test_datasets_routes.py) - Fix test_preview.py: SupersetClient→get_superset_client, AsyncMock, logger f-string
690 lines
31 KiB
Python
690 lines
31 KiB
Python
# #region DatasetsApi [C:5] [TYPE Module] [SEMANTICS fastapi, dataset, api, search, mapping, mapped-fields]
|
|
# @defgroup Api Module group.
|
|
#
|
|
# @BRIEF API endpoints for the Dataset Hub - listing datasets with mapping progress
|
|
# @LAYER API
|
|
# @RELATION DEPENDS_ON -> [AppDependencies]
|
|
# @RELATION DEPENDS_ON -> [ResourceService]
|
|
# @RELATION DEPENDS_ON -> [SupersetClient]
|
|
#
|
|
# @PRE SupersetClient is available; env_id is valid.
|
|
# @POST Returns dataset metadata with mapping status.
|
|
# @SIDE_EFFECT Reads from Superset API and task manager.
|
|
# @DATA_CONTRACT Input -> DatasetQuery, Output -> DatasetsResponse, DatasetDetailResponse
|
|
# @INVARIANT All dataset responses include last_task metadata
|
|
|
|
|
|
import re
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from ...core.logger import belief_scope, logger
|
|
from ...core.async_superset_client import AsyncSupersetClient
|
|
from ...dependencies import get_config_manager, get_resource_service, get_task_manager, has_permission
|
|
|
|
router = APIRouter(prefix="/api/datasets", tags=["Datasets"])
|
|
|
|
# #region MappedFields [C:1] [TYPE DataClass]
|
|
# @BRIEF DTO for dataset mapping progress statistics
|
|
class MappedFields(BaseModel):
|
|
total: int
|
|
mapped: int
|
|
# #endregion MappedFields
|
|
|
|
# #region LastTask [C:1] [TYPE DataClass]
|
|
# @BRIEF DTO for the most recent task associated with a dataset
|
|
class LastTask(BaseModel):
|
|
task_id: str | None = None
|
|
status: str | None = Field(None, pattern="^RUNNING|SUCCESS|ERROR|WAITING_INPUT$")
|
|
# #endregion LastTask
|
|
|
|
# #region DatasetItem [C:1] [TYPE DataClass]
|
|
# @BRIEF Summary DTO for a dataset in the hub listing
|
|
class DatasetItem(BaseModel):
|
|
id: int
|
|
table_name: str
|
|
schema_name: str = Field(..., alias="schema")
|
|
database: str
|
|
mapped_fields: MappedFields | None = None
|
|
last_task: LastTask | None = None
|
|
metric_count: int = 0
|
|
|
|
model_config = ConfigDict(validate_by_name=True)
|
|
# #endregion DatasetItem
|
|
|
|
# #region LinkedDashboard [C:1] [TYPE DataClass]
|
|
# @BRIEF DTO for a dashboard linked to a dataset
|
|
class LinkedDashboard(BaseModel):
|
|
id: int
|
|
title: str
|
|
slug: str | None = None
|
|
# #endregion LinkedDashboard
|
|
|
|
# #region DatasetColumn [C:1] [TYPE DataClass]
|
|
# @BRIEF DTO for a single dataset column's metadata
|
|
class DatasetColumn(BaseModel):
|
|
id: int
|
|
name: str
|
|
type: str | None = None
|
|
is_dttm: bool = False
|
|
is_active: bool = True
|
|
description: str | None = None
|
|
# #endregion DatasetColumn
|
|
|
|
# #region MetricItem [C:1] [TYPE DataClass]
|
|
# @BRIEF Pydantic DTO for a dataset metric — carries Superset metric metadata.
|
|
# @RELATION DEPENDS_ON -> [DatasetDetailResponse]
|
|
class MetricItem(BaseModel):
|
|
id: int
|
|
metric_name: str
|
|
expression: str | None = None
|
|
verbose_name: str | None = None
|
|
description: str | None = None
|
|
metric_type: str | None = None
|
|
# #endregion MetricItem
|
|
|
|
# #region DatasetDetailResponse [C:2] [TYPE DataClass]
|
|
# @ingroup Api
|
|
# @BRIEF Detailed DTO for a dataset including columns, linked dashboards, and metrics.
|
|
# @RELATION DEPENDS_ON -> [MetricItem]
|
|
class DatasetDetailResponse(BaseModel):
|
|
id: int
|
|
table_name: str | None = None
|
|
schema_name: str | None = Field(None, alias="schema")
|
|
database: str
|
|
description: str | None = None
|
|
columns: list[DatasetColumn]
|
|
column_count: int
|
|
metrics: list[MetricItem] = []
|
|
metric_count: int = 0
|
|
sql: str | None = None
|
|
linked_dashboards: list[LinkedDashboard]
|
|
linked_dashboard_count: int
|
|
is_sqllab_view: bool = False
|
|
created_on: str | None = None
|
|
changed_on: str | None = None
|
|
|
|
model_config = ConfigDict(validate_by_name=True)
|
|
# #endregion DatasetDetailResponse
|
|
|
|
# #region StatsCounts [C:1] [TYPE DataClass]
|
|
# @BRIEF Aggregate statistics for the Stats Bar — computed from full dataset list.
|
|
class StatsCounts(BaseModel):
|
|
total: int
|
|
unmapped_count: int
|
|
mapped_count: int
|
|
linked_count: int
|
|
# #endregion StatsCounts
|
|
|
|
# #region DatasetsResponse [C:2] [TYPE DataClass]
|
|
# @ingroup Api
|
|
# @BRIEF Paginated response DTO for dataset listings with stats.
|
|
# @RELATION DEPENDS_ON -> [StatsCounts]
|
|
class DatasetsResponse(BaseModel):
|
|
datasets: list[DatasetItem]
|
|
stats: StatsCounts
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
# #endregion DatasetsResponse
|
|
|
|
# #region TaskResponse [C:1] [TYPE DataClass]
|
|
# @BRIEF Response DTO containing a task ID for tracking
|
|
class TaskResponse(BaseModel):
|
|
task_id: str
|
|
# #endregion TaskResponse
|
|
|
|
# #region ColumnDescriptionUpdate [C:1] [TYPE DataClass]
|
|
# @BRIEF Request DTO for inline-edit of a column description.
|
|
class ColumnDescriptionUpdate(BaseModel):
|
|
description: str
|
|
# #endregion ColumnDescriptionUpdate
|
|
|
|
# #region MetricDescriptionUpdate [C:1] [TYPE DataClass]
|
|
# @BRIEF Request DTO for inline-edit of a metric description.
|
|
class MetricDescriptionUpdate(BaseModel):
|
|
description: str
|
|
# #endregion MetricDescriptionUpdate
|
|
|
|
# #region get_dataset_ids [C:4] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Fetch list of all dataset IDs from a specific environment (without pagination)
|
|
# @PRE env_id must be a valid environment ID
|
|
# @POST Returns a list of all dataset IDs
|
|
# @RELATION CALLS -> [EXT:method:get_datasets_with_status]
|
|
@router.get("/ids")
|
|
async def get_dataset_ids(
|
|
env_id: str,
|
|
search: str | None = None,
|
|
config_manager=Depends(get_config_manager),
|
|
task_manager=Depends(get_task_manager),
|
|
resource_service=Depends(get_resource_service),
|
|
_ = Depends(has_permission("plugin:migration", "READ"))
|
|
):
|
|
with belief_scope("get_dataset_ids", f"env_id={env_id}, search={search}"):
|
|
# Validate environment exists
|
|
environments = config_manager.get_environments()
|
|
env = next((e for e in environments if e.id == env_id), None)
|
|
if not env:
|
|
logger.error(f"[get_dataset_ids][Coherence:Failed] Environment not found: {env_id}")
|
|
raise HTTPException(status_code=404, detail="Environment not found")
|
|
|
|
try:
|
|
# Get all tasks for status lookup
|
|
all_tasks = task_manager.get_all_tasks()
|
|
|
|
# Fetch datasets with status using ResourceService
|
|
datasets = await resource_service.get_datasets_with_status(env, all_tasks)
|
|
|
|
# Apply search filter if provided
|
|
if search:
|
|
search_lower = search.lower()
|
|
datasets = [
|
|
d for d in datasets
|
|
if search_lower in d.get('table_name', '').lower()
|
|
]
|
|
|
|
# Extract and return just the IDs
|
|
dataset_ids = [d['id'] for d in datasets]
|
|
logger.info(f"[get_dataset_ids][Coherence:OK] Returning {len(dataset_ids)} dataset IDs")
|
|
|
|
return {"dataset_ids": dataset_ids}
|
|
|
|
except Exception as e:
|
|
logger.error(f"[get_dataset_ids][Coherence:Failed] Failed to fetch dataset IDs: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Failed to fetch dataset IDs: {e!s}")
|
|
# #endregion get_dataset_ids
|
|
|
|
# #region get_datasets [C:4] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Fetch list of datasets from a specific environment with mapping progress and stats.
|
|
# @PRE env_id must be a valid environment ID
|
|
# @PRE page must be >= 1 if provided
|
|
# @PRE page_size must be between 1 and 100 if provided
|
|
# @POST Returns a list of datasets with enhanced metadata, pagination info, and StatsCounts.
|
|
# @POST Response includes pagination metadata (page, page_size, total, total_pages) and stats object.
|
|
# @RATIONALE Stats counts returned in the same response as datasets to avoid an extra API call for the Stats Bar (FR-022).
|
|
# @RELATION CALLS -> [EXT:method:get_datasets_with_status]
|
|
@router.get("", response_model=DatasetsResponse)
|
|
async def get_datasets(
|
|
env_id: str,
|
|
search: str | None = None,
|
|
filter: str | None = None,
|
|
page: int = 1,
|
|
page_size: int = 10,
|
|
config_manager=Depends(get_config_manager),
|
|
task_manager=Depends(get_task_manager),
|
|
resource_service=Depends(get_resource_service),
|
|
_ = Depends(has_permission("plugin:migration", "READ"))
|
|
):
|
|
with belief_scope("get_datasets", f"env_id={env_id}, search={search}, filter={filter}, page={page}, page_size={page_size}"):
|
|
# Validate pagination parameters
|
|
if page < 1:
|
|
logger.error(f"[get_datasets][Coherence:Failed] Invalid page: {page}")
|
|
raise HTTPException(status_code=400, detail="Page must be >= 1")
|
|
if page_size < 1 or page_size > 100:
|
|
logger.error(f"[get_datasets][Coherence:Failed] Invalid page_size: {page_size}")
|
|
raise HTTPException(status_code=400, detail="Page size must be between 1 and 100")
|
|
|
|
# Validate environment exists
|
|
environments = config_manager.get_environments()
|
|
env = next((e for e in environments if e.id == env_id), None)
|
|
if not env:
|
|
logger.error(f"[get_datasets][Coherence:Failed] Environment not found: {env_id}")
|
|
raise HTTPException(status_code=404, detail="Environment not found")
|
|
|
|
try:
|
|
# Get all tasks for status lookup
|
|
all_tasks = task_manager.get_all_tasks()
|
|
|
|
# Fetch datasets with status using ResourceService
|
|
datasets = await resource_service.get_datasets_with_status(env, all_tasks)
|
|
|
|
# Compute stats from full list BEFORE filtering/pagination
|
|
total_datasets = len(datasets)
|
|
unmapped = sum(
|
|
1 for d in datasets
|
|
if d.get("mapped_fields") and d["mapped_fields"].get("mapped", 0) == 0
|
|
)
|
|
mapped = sum(
|
|
1 for d in datasets
|
|
if d.get("mapped_fields") and d["mapped_fields"].get("mapped", 0) > 0
|
|
)
|
|
linked = sum(
|
|
1 for d in datasets
|
|
if d.get("linked_dashboard_count", 0) > 0
|
|
)
|
|
stats = StatsCounts(
|
|
total=total_datasets,
|
|
unmapped_count=unmapped,
|
|
mapped_count=mapped,
|
|
linked_count=linked,
|
|
)
|
|
|
|
# Apply server-side filter (before pagination) — FR-026
|
|
if filter and filter in ("unmapped", "mapped", "linked"):
|
|
if filter == "unmapped":
|
|
datasets = [
|
|
d for d in datasets
|
|
if d.get("mapped_fields") and d["mapped_fields"].get("mapped", 0) == 0
|
|
]
|
|
elif filter == "mapped":
|
|
datasets = [
|
|
d for d in datasets
|
|
if d.get("mapped_fields") and d["mapped_fields"].get("mapped", 0) > 0
|
|
]
|
|
elif filter == "linked":
|
|
datasets = [
|
|
d for d in datasets
|
|
if d.get("linked_dashboard_count", 0) > 0
|
|
]
|
|
|
|
# Apply search filter if provided
|
|
if search:
|
|
search_lower = search.lower()
|
|
datasets = [
|
|
d for d in datasets
|
|
if search_lower in d.get('table_name', '').lower()
|
|
]
|
|
|
|
# Calculate pagination
|
|
total = len(datasets)
|
|
total_pages = (total + page_size - 1) // page_size if total > 0 else 1
|
|
start_idx = (page - 1) * page_size
|
|
end_idx = start_idx + page_size
|
|
|
|
# Slice datasets for current page
|
|
paginated_datasets = datasets[start_idx:end_idx]
|
|
|
|
logger.info(f"[get_datasets][Coherence:OK] Returning {len(paginated_datasets)} datasets (page {page}/{total_pages}, total: {total}, stats: total={stats.total} unmapped={stats.unmapped_count} mapped={stats.mapped_count} linked={stats.linked_count})")
|
|
|
|
return DatasetsResponse(
|
|
datasets=paginated_datasets,
|
|
stats=stats,
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[get_datasets][Coherence:Failed] Failed to fetch datasets: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Failed to fetch datasets: {e!s}")
|
|
# #endregion get_datasets
|
|
|
|
# #region MapColumnsRequest [C:1] [TYPE DataClass]
|
|
# @BRIEF Request DTO for initiating column mapping
|
|
class MapColumnsRequest(BaseModel):
|
|
env_id: str = Field(..., description="Environment ID")
|
|
dataset_ids: list[int] = Field(..., description="List of dataset IDs to map")
|
|
source_type: str = Field(..., description="Source type: 'sqllab' or 'xlsx'")
|
|
database_id: int | None = Field(None, description="Superset database ID for SQL Lab source")
|
|
sql_query: str | None = Field(None, description="Custom SQL query for SQL Lab (optional, defaults to information_schema.columns query)")
|
|
file_data: str | None = Field(None, description="File path or data for XLSX source")
|
|
# #endregion MapColumnsRequest
|
|
|
|
# #region map_columns [C:4] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Trigger bulk column mapping for datasets
|
|
# @PRE User has permission plugin:mapper:execute
|
|
# @PRE env_id is a valid environment ID
|
|
# @PRE dataset_ids is a non-empty list
|
|
# @POST Returns task_id for tracking mapping progress
|
|
# @POST Task is created and queued for execution
|
|
# @RELATION DISPATCHES -> [MapperPlugin]
|
|
# @RELATION CALLS -> [create_task]
|
|
@router.post("/map-columns", response_model=TaskResponse)
|
|
async def map_columns(
|
|
request: MapColumnsRequest,
|
|
config_manager=Depends(get_config_manager),
|
|
task_manager=Depends(get_task_manager),
|
|
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
|
|
):
|
|
with belief_scope("map_columns", f"env={request.env_id}, count={len(request.dataset_ids)}, source={request.source_type}"):
|
|
# Validate request
|
|
if not request.dataset_ids:
|
|
logger.error("[map_columns][Coherence:Failed] No dataset IDs provided")
|
|
raise HTTPException(status_code=400, detail="At least one dataset ID must be provided")
|
|
|
|
# Validate source type
|
|
if request.source_type not in ['sqllab', 'xlsx']:
|
|
logger.error(f"[map_columns][Coherence:Failed] Invalid source type: {request.source_type}")
|
|
raise HTTPException(status_code=400, detail="Source type must be 'sqllab' or 'xlsx'")
|
|
|
|
# Validate database_id for sqllab source
|
|
if request.source_type == 'sqllab' and not request.database_id:
|
|
raise HTTPException(status_code=400, detail="database_id is required for 'sqllab' source type")
|
|
|
|
# Validate environment exists
|
|
environments = config_manager.get_environments()
|
|
env = next((e for e in environments if e.id == request.env_id), None)
|
|
|
|
if not env:
|
|
logger.error(f"[map_columns][Coherence:Failed] Environment not found: {request.env_id}")
|
|
raise HTTPException(status_code=404, detail="Environment not found")
|
|
|
|
try:
|
|
# Create mapping task
|
|
task_params = {
|
|
'env': request.env_id,
|
|
'dataset_id': request.dataset_ids[0] if request.dataset_ids else None,
|
|
'source': 'sqllab' if request.source_type == 'sqllab' else 'excel',
|
|
'database_id': request.database_id,
|
|
'sql_query': request.sql_query,
|
|
'file_data': request.file_data
|
|
}
|
|
|
|
task_obj = await task_manager.create_task(
|
|
plugin_id='dataset-mapper',
|
|
params=task_params
|
|
)
|
|
|
|
logger.info(f"[map_columns][Coherence:OK] Mapping task created: {task_obj.id} for {len(request.dataset_ids)} datasets")
|
|
|
|
return TaskResponse(task_id=str(task_obj.id))
|
|
|
|
except Exception as e:
|
|
logger.error(f"[map_columns][Coherence:Failed] Failed to create mapping task: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Failed to create mapping task: {e!s}")
|
|
# #endregion map_columns
|
|
|
|
# #region GenerateDocsRequest [C:1] [TYPE DataClass]
|
|
# @BRIEF Request DTO for initiating documentation generation
|
|
class GenerateDocsRequest(BaseModel):
|
|
env_id: str = Field(..., description="Environment ID")
|
|
dataset_ids: list[int] = Field(..., description="List of dataset IDs to generate docs for")
|
|
llm_provider: str = Field(..., description="LLM provider to use")
|
|
options: dict | None = Field(None, description="Additional options for documentation generation")
|
|
# #endregion GenerateDocsRequest
|
|
|
|
# #region generate_docs [C:4] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Trigger bulk documentation generation for datasets
|
|
# @PRE User has permission plugin:llm_analysis:execute
|
|
# @PRE env_id is a valid environment ID
|
|
# @PRE dataset_ids is a non-empty list
|
|
# @POST Returns task_id for tracking documentation generation progress
|
|
# @POST Task is created and queued for execution
|
|
# @RELATION DISPATCHES -> [DocumentationPlugin]
|
|
# @RELATION CALLS -> [create_task]
|
|
@router.post("/generate-docs", response_model=TaskResponse)
|
|
async def generate_docs(
|
|
request: GenerateDocsRequest,
|
|
config_manager=Depends(get_config_manager),
|
|
task_manager=Depends(get_task_manager),
|
|
_ = Depends(has_permission("plugin:llm_analysis", "EXECUTE"))
|
|
):
|
|
with belief_scope("generate_docs", f"env={request.env_id}, count={len(request.dataset_ids)}, provider={request.llm_provider}"):
|
|
# Validate request
|
|
if not request.dataset_ids:
|
|
logger.error("[generate_docs][Coherence:Failed] No dataset IDs provided")
|
|
raise HTTPException(status_code=400, detail="At least one dataset ID must be provided")
|
|
|
|
# Validate environment exists
|
|
environments = config_manager.get_environments()
|
|
env = next((e for e in environments if e.id == request.env_id), None)
|
|
|
|
if not env:
|
|
logger.error(f"[generate_docs][Coherence:Failed] Environment not found: {request.env_id}")
|
|
raise HTTPException(status_code=404, detail="Environment not found")
|
|
|
|
try:
|
|
# Create documentation generation task
|
|
task_params = {
|
|
'environment_id': request.env_id,
|
|
'dataset_id': str(request.dataset_ids[0]) if request.dataset_ids else None,
|
|
'provider_id': request.llm_provider,
|
|
'options': request.options or {}
|
|
}
|
|
|
|
task_obj = await task_manager.create_task(
|
|
plugin_id='llm_documentation',
|
|
params=task_params
|
|
)
|
|
|
|
logger.info(f"[generate_docs][Coherence:OK] Documentation generation task created: {task_obj.id} for {len(request.dataset_ids)} datasets")
|
|
|
|
return TaskResponse(task_id=str(task_obj.id))
|
|
|
|
except Exception as e:
|
|
logger.error(f"[generate_docs][Coherence:Failed] Failed to create documentation generation task: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Failed to create documentation generation task: {e!s}")
|
|
# #endregion generate_docs
|
|
|
|
# #region get_dataset_detail [C:4] [TYPE Function]
|
|
# @ingroup Api
|
|
# @BRIEF Get detailed dataset information including columns and linked dashboards
|
|
# @PRE env_id is a valid environment ID
|
|
# @PRE dataset_id is a valid dataset ID
|
|
# @POST Returns detailed dataset info with columns and linked dashboards
|
|
# @RELATION CALLS -> [SupersetClientGetDatasetDetail]
|
|
@router.get("/{dataset_id}", response_model=DatasetDetailResponse)
|
|
async def get_dataset_detail(
|
|
env_id: str,
|
|
dataset_id: int,
|
|
config_manager=Depends(get_config_manager),
|
|
_ = Depends(has_permission("plugin:migration", "READ"))
|
|
):
|
|
with belief_scope("get_dataset_detail", f"env_id={env_id}, dataset_id={dataset_id}"):
|
|
# Validate environment exists
|
|
environments = config_manager.get_environments()
|
|
env = next((e for e in environments if e.id == env_id), None)
|
|
if not env:
|
|
logger.error(f"[get_dataset_detail][Coherence:Failed] Environment not found: {env_id}")
|
|
raise HTTPException(status_code=404, detail="Environment not found")
|
|
|
|
try:
|
|
# Fetch detailed dataset info using AsyncSupersetClient
|
|
client = AsyncSupersetClient(env)
|
|
dataset_detail = await client.get_dataset_detail(dataset_id)
|
|
|
|
# Normalize 'database' field: Superset returns an object, model expects a string
|
|
if isinstance(dataset_detail.get("database"), dict):
|
|
db_obj = dataset_detail["database"]
|
|
dataset_detail["database"] = db_obj.get("database_name", str(db_obj.get("id", "")))
|
|
|
|
logger.info(f"[get_dataset_detail][Coherence:OK] Retrieved dataset {dataset_id} with {dataset_detail['column_count']} columns and {dataset_detail['linked_dashboard_count']} linked dashboards")
|
|
|
|
return DatasetDetailResponse(**dataset_detail)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[get_dataset_detail][Coherence:Failed] Failed to fetch dataset detail: {e}")
|
|
raise HTTPException(status_code=503, detail=f"Failed to fetch dataset detail: {e!s}")
|
|
# #endregion get_dataset_detail
|
|
|
|
# #region _strip_html_tags [C:2] [TYPE Function] [SEMANTICS helper,validation,html]
|
|
# @BRIEF Detect and strip HTML tags from a text string. Uses simple regex <[^>]*>.
|
|
# If HTML is detected, strips tags and logs a warning. Returns cleaned text.
|
|
# @REJECTED Reject-with-400 approach was rejected in favour of silent stripping — more user-friendly
|
|
# and matches the UX expectation that inline-edited descriptions are plain text only.
|
|
def _strip_html_tags(text: str) -> str:
|
|
if not text:
|
|
return text
|
|
if re.search(r"<[^>]*>", text):
|
|
cleaned = re.sub(r"<[^>]*>", "", text)
|
|
logger.warning("[_strip_html_tags] HTML tags detected and stripped from description")
|
|
return cleaned
|
|
return text
|
|
# #endregion _strip_html_tags
|
|
|
|
# #region update_column_description [C:4] [TYPE Endpoint]
|
|
# @ingroup Api
|
|
# @BRIEF Save description for a single dataset column. Internal: GET full dataset from Superset, modify one column's description, PUT back.
|
|
# @PRE dataset_id and column_id must exist in the target environment.
|
|
# @POST Column description in Superset is updated. Response confirms success.
|
|
# @SIDE_EFFECT Mutates dataset metadata in upstream Superset instance via PUT.
|
|
# @VALIDATION: description — string, max 2000 chars, plain text only (HTML stripped), may be empty string to clear description, no trimming applied. 404 if dataset or column not found. 502 if Superset upstream fails.
|
|
# @ERROR 400 — description exceeds max length or contains non-plaintext content. 404 — dataset_id or column_id not found. 502 — Superset upstream failure (GET or PUT).
|
|
# @RELATION CALLS -> [EXT:method:SupersetClient.get_dataset]
|
|
# @RELATION CALLS -> [EXT:method:SupersetClient.update_dataset]
|
|
# @RATIONALE Must perform GET→modify→PUT because Superset has no PATCH for individual columns — only full object PUT with override_columns=false.
|
|
# @REJECTED Direct PUT from frontend — rejected because frontend would need to handle full Superset payload structure.
|
|
@router.put("/{dataset_id}/columns/{column_id}/description")
|
|
async def update_column_description(
|
|
dataset_id: int,
|
|
column_id: int,
|
|
env_id: str = Query(..., description="Environment ID"),
|
|
body: ColumnDescriptionUpdate = ...,
|
|
config_manager=Depends(get_config_manager),
|
|
_ = Depends(has_permission("plugin:migration", "WRITE"))
|
|
):
|
|
with belief_scope("update_column_description", f"dataset_id={dataset_id}, column_id={column_id}, env_id={env_id}"):
|
|
# Validate and sanitize description
|
|
description = _strip_html_tags(body.description)
|
|
if len(description) > 2000:
|
|
logger.error(f"[update_column_description][Coherence:Failed] Description exceeds 2000 chars: {len(description)}")
|
|
raise HTTPException(status_code=400, detail="Description must not exceed 2000 characters")
|
|
|
|
# Validate environment
|
|
environments = config_manager.get_environments()
|
|
env = next((e for e in environments if e.id == env_id), None)
|
|
if not env:
|
|
logger.error(f"[update_column_description][Coherence:Failed] Environment not found: {env_id}")
|
|
raise HTTPException(status_code=404, detail="Environment not found")
|
|
|
|
try:
|
|
client = AsyncSupersetClient(env)
|
|
|
|
# GET full dataset
|
|
logger.reason("Fetching full dataset from Superset for column description update",
|
|
extra={"src": "update_column_description"})
|
|
try:
|
|
response = await client.get_dataset(dataset_id)
|
|
except Exception as e:
|
|
logger.error(f"[update_column_description][Coherence:Failed] Superset GET failed: {e}")
|
|
raise HTTPException(status_code=502, detail=f"Failed to fetch dataset from Superset: {e!s}")
|
|
|
|
if isinstance(response, dict) and "result" in response:
|
|
dataset = response["result"]
|
|
else:
|
|
dataset = response
|
|
|
|
if not dataset or not dataset.get("id"):
|
|
logger.error(f"[update_column_description][Coherence:Failed] Dataset {dataset_id} not found")
|
|
raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found")
|
|
|
|
# Find and modify the target column
|
|
columns = dataset.get("columns", [])
|
|
found = False
|
|
for col in columns:
|
|
if col.get("id") == column_id:
|
|
col["description"] = description
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
logger.error(f"[update_column_description][Coherence:Failed] Column {column_id} not found in dataset {dataset_id}")
|
|
raise HTTPException(status_code=404, detail=f"Column {column_id} not found in dataset {dataset_id}")
|
|
|
|
# PUT back with override_columns=false
|
|
put_payload = dataset
|
|
try:
|
|
await client.update_dataset(dataset_id, put_payload, override_columns=False)
|
|
except Exception as e:
|
|
logger.error(f"[update_column_description][Coherence:Failed] Superset PUT failed: {e}")
|
|
raise HTTPException(status_code=502, detail=f"Failed to update dataset in Superset: {e!s}")
|
|
|
|
logger.reflect(
|
|
f"Updated column {column_id} description for dataset {dataset_id}",
|
|
extra={"src": "update_column_description"},
|
|
)
|
|
return {"success": True, "description": description}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[update_column_description][Coherence:Failed] Unexpected error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Unexpected error: {e!s}")
|
|
# #endregion update_column_description
|
|
|
|
# #region update_metric_description [C:4] [TYPE Endpoint]
|
|
# @ingroup Api
|
|
# @BRIEF Save description for a single dataset metric. Mirror of update_column_description for metrics.
|
|
# @PRE dataset_id and metric_id must exist in the target environment.
|
|
# @POST Metric description in Superset is updated.
|
|
# @SIDE_EFFECT Mutates dataset metadata in upstream Superset instance via PUT.
|
|
# @VALIDATION: description — string, max 2000 chars, plain text only (HTML stripped), may be empty string to clear description, no trimming applied. 404 if dataset or metric not found. 502 if Superset upstream fails.
|
|
# @ERROR 400 — description exceeds max length or contains non-plaintext content. 404 — dataset_id or metric_id not found. 502 — Superset upstream failure (GET or PUT).
|
|
# @RELATION CALLS -> [EXT:method:SupersetClient.get_dataset]
|
|
# @RELATION CALLS -> [EXT:method:SupersetClient.update_dataset]
|
|
@router.put("/{dataset_id}/metrics/{metric_id}/description")
|
|
async def update_metric_description(
|
|
dataset_id: int,
|
|
metric_id: int,
|
|
env_id: str = Query(..., description="Environment ID"),
|
|
body: MetricDescriptionUpdate = ...,
|
|
config_manager=Depends(get_config_manager),
|
|
_ = Depends(has_permission("plugin:migration", "WRITE"))
|
|
):
|
|
with belief_scope("update_metric_description", f"dataset_id={dataset_id}, metric_id={metric_id}, env_id={env_id}"):
|
|
# Validate and sanitize description
|
|
description = _strip_html_tags(body.description)
|
|
if len(description) > 2000:
|
|
logger.error(f"[update_metric_description][Coherence:Failed] Description exceeds 2000 chars: {len(description)}")
|
|
raise HTTPException(status_code=400, detail="Description must not exceed 2000 characters")
|
|
|
|
# Validate environment
|
|
environments = config_manager.get_environments()
|
|
env = next((e for e in environments if e.id == env_id), None)
|
|
if not env:
|
|
logger.error(f"[update_metric_description][Coherence:Failed] Environment not found: {env_id}")
|
|
raise HTTPException(status_code=404, detail="Environment not found")
|
|
|
|
try:
|
|
client = AsyncSupersetClient(env)
|
|
|
|
# GET full dataset
|
|
logger.reason("Fetching full dataset from Superset for metric description update",
|
|
extra={"src": "update_metric_description"})
|
|
try:
|
|
response = await client.get_dataset(dataset_id)
|
|
except Exception as e:
|
|
logger.error(f"[update_metric_description][Coherence:Failed] Superset GET failed: {e}")
|
|
raise HTTPException(status_code=502, detail=f"Failed to fetch dataset from Superset: {e!s}")
|
|
|
|
if isinstance(response, dict) and "result" in response:
|
|
dataset = response["result"]
|
|
else:
|
|
dataset = response
|
|
|
|
if not dataset or not dataset.get("id"):
|
|
logger.error(f"[update_metric_description][Coherence:Failed] Dataset {dataset_id} not found")
|
|
raise HTTPException(status_code=404, detail=f"Dataset {dataset_id} not found")
|
|
|
|
# Find and modify the target metric
|
|
metrics = dataset.get("metrics", [])
|
|
found = False
|
|
for metric in metrics:
|
|
if metric.get("id") == metric_id:
|
|
metric["description"] = description
|
|
found = True
|
|
break
|
|
|
|
if not found:
|
|
logger.error(f"[update_metric_description][Coherence:Failed] Metric {metric_id} not found in dataset {dataset_id}")
|
|
raise HTTPException(status_code=404, detail=f"Metric {metric_id} not found in dataset {dataset_id}")
|
|
|
|
# PUT back with override_columns=false
|
|
try:
|
|
await client.update_dataset(dataset_id, dataset, override_columns=False)
|
|
except Exception as e:
|
|
logger.error(f"[update_metric_description][Coherence:Failed] Superset PUT failed: {e}")
|
|
raise HTTPException(status_code=502, detail=f"Failed to update dataset in Superset: {e!s}")
|
|
|
|
logger.reflect(
|
|
f"Updated metric {metric_id} description for dataset {dataset_id}",
|
|
extra={"src": "update_metric_description"},
|
|
)
|
|
return {"success": True, "description": description}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[update_metric_description][Coherence:Failed] Unexpected error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Unexpected error: {e!s}")
|
|
# #endregion update_metric_description
|
|
|
|
# #endregion DatasetsApi
|