Files
ss-tools/backend/src/api/routes/datasets.py
busya 846d2cb9a9 032: T019 — remaining route files migrated to async SupersetClient
All routes (assistant, migration, datasets, git) now use AsyncSupersetClient.
_helpers.py sync->async for dashboard ref resolution.
_detail_routes.py import fixed.

Known residual: MigrationDryRunService and IdMappingService still sync.
2026-06-04 20:02:33 +03:00

680 lines
31 KiB
Python

# #region DatasetsApi [C:5] [TYPE Module] [SEMANTICS fastapi, dataset, api, search, mapping, mapped-fields]
#
# @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]
# @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]
# @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]
# @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]
# @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]
# @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]
# @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]
# @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]
# @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]
# @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