# #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