diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 5f7ec6c64df..6d726abed78 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -123,6 +123,10 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link: - chart_type="pie": Pie chart for proportional data (set donut=True for donut) - chart_type="pivot_table": Interactive pivot table for cross-tabulation - chart_type="mixed_timeseries": Dual-series chart combining two chart types +- chart_type="handlebars": Custom HTML template chart (KPI cards, leaderboards, reports) + Requires handlebars_template with Handlebars HTML template string. + Supports query_mode="aggregate" (with metrics/groupby) or "raw" (with columns). + Data available as {{{{data}}}} array; helpers: dateFormat, formatNumber, stringify. Time grain for temporal x-axis (time_grain parameter): - PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index 41190554b3d..e1292037d3f 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -31,6 +31,7 @@ from superset.mcp_service.chart.schemas import ( ChartSemantics, ColumnRef, FilterConfig, + HandlebarsChartConfig, MixedTimeseriesChartConfig, PieChartConfig, PivotTableChartConfig, @@ -309,7 +310,8 @@ def map_config_to_form_data( | XYChartConfig | PieChartConfig | PivotTableChartConfig - | MixedTimeseriesChartConfig, + | MixedTimeseriesChartConfig + | HandlebarsChartConfig, dataset_id: int | str | None = None, ) -> Dict[str, Any]: """Map chart config to Superset form_data.""" @@ -323,6 +325,8 @@ def map_config_to_form_data( return map_pivot_table_config(config) elif isinstance(config, MixedTimeseriesChartConfig): return map_mixed_timeseries_config(config, dataset_id=dataset_id) + elif isinstance(config, HandlebarsChartConfig): + return map_handlebars_config(config) else: raise ValueError(f"Unsupported config type: {type(config)}") @@ -638,6 +642,44 @@ def map_pie_config(config: PieChartConfig) -> Dict[str, Any]: return form_data +def map_handlebars_config(config: HandlebarsChartConfig) -> Dict[str, Any]: + """Map handlebars chart config to Superset form_data.""" + form_data: Dict[str, Any] = { + "viz_type": "handlebars", + "handlebars_template": config.handlebars_template, + "row_limit": config.row_limit, + "order_desc": config.order_desc, + } + + if config.style_template: + form_data["styleTemplate"] = config.style_template + + if config.query_mode == "raw": + form_data["query_mode"] = "raw" + if config.columns: + form_data["all_columns"] = [col.name for col in config.columns] + else: + form_data["query_mode"] = "aggregate" + if config.groupby: + form_data["groupby"] = [col.name for col in config.groupby] + if config.metrics: + form_data["metrics"] = [create_metric_object(col) for col in config.metrics] + if config.filters: + form_data["adhoc_filters"] = [ + { + "clause": "WHERE", + "expressionType": "SIMPLE", + "subject": filter_config.column, + "operator": map_filter_operator(filter_config.op), + "comparator": filter_config.value, + } + for filter_config in config.filters + if filter_config is not None + ] + + return form_data + + def map_pivot_table_config(config: PivotTableChartConfig) -> Dict[str, Any]: """Map pivot table config to Superset form_data.""" if not config.rows: @@ -908,12 +950,28 @@ def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str: return f"{primary} + {secondary}" +def _handlebars_chart_what(config: HandlebarsChartConfig) -> str: + """Build the 'what' portion for a handlebars chart name. + + Uses parentheses instead of en-dash to avoid collision with + ``generate_chart_name``'s ``\u2013`` context separator. + """ + if config.query_mode == "raw" and config.columns: + cols = ", ".join(col.name for col in config.columns[:3]) + return f"Handlebars ({cols})" + elif config.metrics: + metrics = ", ".join(col.name for col in config.metrics[:3]) + return f"Handlebars ({metrics})" + return "Handlebars Chart" + + def generate_chart_name( config: TableChartConfig | XYChartConfig | PieChartConfig | PivotTableChartConfig - | MixedTimeseriesChartConfig, + | MixedTimeseriesChartConfig + | HandlebarsChartConfig, dataset_name: str | None = None, ) -> str: """Generate a descriptive chart name following a standard format. @@ -944,6 +1002,9 @@ def generate_chart_name( elif isinstance(config, MixedTimeseriesChartConfig): what = _mixed_timeseries_what(config) context = _summarize_filters(config.filters) + elif isinstance(config, HandlebarsChartConfig): + what = _handlebars_chart_what(config) + context = _summarize_filters(getattr(config, "filters", None)) else: return "Chart" @@ -953,6 +1014,31 @@ def generate_chart_name( return _truncate(name) +def _resolve_viz_type(config: Any) -> str: + """Resolve the Superset viz_type from a chart config object.""" + chart_type = getattr(config, "chart_type", "unknown") + if chart_type == "xy": + kind = getattr(config, "kind", "line") + viz_type_map = { + "line": "echarts_timeseries_line", + "bar": "echarts_timeseries_bar", + "area": "echarts_area", + "scatter": "echarts_timeseries_scatter", + } + return viz_type_map.get(kind, "echarts_timeseries_line") + elif chart_type == "table": + return getattr(config, "viz_type", "table") + elif chart_type == "pie": + return "pie" + elif chart_type == "pivot_table": + return "pivot_table_v2" + elif chart_type == "mixed_timeseries": + return "mixed_timeseries" + elif chart_type == "handlebars": + return "handlebars" + return "unknown" + + def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities: """Analyze chart capabilities based on type and configuration.""" if chart: @@ -1003,29 +1089,6 @@ def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilit ) -def _resolve_viz_type(config: Any) -> str: - """Resolve viz_type from a chart config object.""" - chart_type = getattr(config, "chart_type", "unknown") - if chart_type == "xy": - kind = getattr(config, "kind", "line") - viz_type_map = { - "line": "echarts_timeseries_line", - "bar": "echarts_timeseries_bar", - "area": "echarts_area", - "scatter": "echarts_timeseries_scatter", - } - return viz_type_map.get(kind, "echarts_timeseries_line") - elif chart_type == "table": - return getattr(config, "viz_type", "table") - elif chart_type == "pie": - return "pie" - elif chart_type == "pivot_table": - return "pivot_table_v2" - elif chart_type == "mixed_timeseries": - return "mixed_timeseries" - return "unknown" - - def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics: """Generate semantic understanding of the chart.""" if chart: @@ -1052,6 +1115,10 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics: "Combines two different chart types on the same time axis " "for comparing related metrics with different scales" ), + "handlebars": ( + "Renders data using a custom Handlebars HTML template for " + "fully flexible layouts like KPI cards, leaderboards, and reports" + ), } primary_insight = insights_map.get( diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index b84bf9bc38b..f78c15f510d 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -659,6 +659,108 @@ class MixedTimeseriesChartConfig(BaseModel): return _normalize_group_by_input(v) +class HandlebarsChartConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + chart_type: Literal["handlebars"] = Field( + ..., + description=( + "Chart type discriminator - MUST be 'handlebars' for custom HTML " + "template charts. Handlebars charts render query results using " + "Handlebars templates, enabling fully custom layouts like KPI cards, " + "leaderboards, and formatted reports." + ), + ) + handlebars_template: str = Field( + ..., + description=( + "Handlebars HTML template string. Data is available as {{data}} array. " + "Built-in helpers: {{dateFormat val format='YYYY-MM-DD'}}, " + "{{formatNumber val}}, {{stringify obj}}. " + "Example: '
| {{this.name}} |
test
", + query_mode="aggregate", + ) + + def test_raw_mode_requires_columns(self) -> None: + with pytest.raises(ValueError, match="requires 'columns'"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + query_mode="raw", + ) + + def test_template_min_length(self) -> None: + with pytest.raises(ValueError, match="at least 1 character"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + + def test_raw_mode_rejects_metrics(self) -> None: + with pytest.raises(ValueError, match="does not use 'metrics'"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + query_mode="raw", + columns=[ColumnRef(name="product")], + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + + def test_raw_mode_rejects_groupby(self) -> None: + with pytest.raises(ValueError, match="does not use 'groupby'"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + query_mode="raw", + columns=[ColumnRef(name="product")], + groupby=[ColumnRef(name="region")], + ) + + def test_aggregate_mode_requires_aggregate_function(self) -> None: + with pytest.raises(ValueError, match="Missing aggregate for"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + query_mode="aggregate", + metrics=[ColumnRef(name="sales")], + ) + + def test_extra_fields_forbidden(self) -> None: + with pytest.raises(ValueError, match="Extra inputs"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + unknown_field="bad", + ) + + def test_full_aggregate_config(self) -> None: + template = ( + "test
", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + row_limit=0, + ) + + def test_row_limit_too_high(self) -> None: + with pytest.raises(ValueError, match="less than or equal to 50000"): + HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + row_limit=50001, + ) + + +class TestMapHandlebarsConfig: + """Test map_handlebars_config function.""" + + def test_aggregate_mode_basic(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="{{data}}
", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + result = map_handlebars_config(config) + + assert result["viz_type"] == "handlebars" + assert result["handlebars_template"] == "{{data}}
" + assert result["query_mode"] == "aggregate" + assert result["row_limit"] == 1000 + assert result["order_desc"] is True + assert len(result["metrics"]) == 1 + assert result["metrics"][0]["aggregate"] == "SUM" + + def test_aggregate_mode_with_groupby(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="{{data}}
", + groupby=[ColumnRef(name="region")], + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + result = map_handlebars_config(config) + + assert result["groupby"] == ["region"] + + def test_raw_mode(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="count", aggregate="COUNT")], + filters=[FilterConfig(column="status", op="=", value="active")], + ) + result = map_handlebars_config(config) + + assert "adhoc_filters" in result + assert len(result["adhoc_filters"]) == 1 + assert result["adhoc_filters"][0]["subject"] == "status" + assert result["adhoc_filters"][0]["operator"] == "==" + assert result["adhoc_filters"][0]["comparator"] == "active" + + def test_with_style_template(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + style_template="p { font-size: 24px; }", + ) + result = map_handlebars_config(config) + + assert result["styleTemplate"] == "p { font-size: 24px; }" + + def test_without_style_template(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + ) + result = map_handlebars_config(config) + + assert "styleTemplate" not in result + + def test_custom_row_limit_and_order(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="x", aggregate="COUNT")], + row_limit=50, + order_desc=False, + ) + result = map_handlebars_config(config) + + assert result["row_limit"] == 50 + assert result["order_desc"] is False + + +class TestMapConfigToFormDataHandlebars: + """Test map_config_to_form_data dispatches to handlebars correctly.""" + + def test_dispatches_handlebars_config(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + result = map_config_to_form_data(config) + assert result["viz_type"] == "handlebars" + + +class TestGenerateChartNameHandlebars: + """Test generate_chart_name for handlebars configs.""" + + def test_raw_mode_with_columns(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + query_mode="raw", + columns=[ColumnRef(name="product"), ColumnRef(name="price")], + ) + name = generate_chart_name(config) + assert "Handlebars" in name + assert "product" in name + assert "price" in name + + def test_aggregate_mode_with_metrics(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + name = generate_chart_name(config) + assert "Handlebars" in name + assert "sales" in name + + def test_aggregate_mode_no_groupby(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="count", aggregate="COUNT")], + ) + name = generate_chart_name(config) + assert "Handlebars" in name + assert "count" in name + + def test_truncates_to_three_columns(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + query_mode="raw", + columns=[ + ColumnRef(name="alpha"), + ColumnRef(name="bravo"), + ColumnRef(name="charlie"), + ColumnRef(name="delta"), + ], + ) + name = generate_chart_name(config) + assert "alpha" in name + assert "bravo" in name + assert "charlie" in name + assert "delta" not in name + + +class TestResolveVizType: + """Test _resolve_viz_type helper.""" + + def test_xy_line(self) -> None: + config = MagicMock(chart_type="xy", kind="line") + assert _resolve_viz_type(config) == "echarts_timeseries_line" + + def test_xy_bar(self) -> None: + config = MagicMock(chart_type="xy", kind="bar") + assert _resolve_viz_type(config) == "echarts_timeseries_bar" + + def test_xy_area(self) -> None: + config = MagicMock(chart_type="xy", kind="area") + assert _resolve_viz_type(config) == "echarts_area" + + def test_xy_scatter(self) -> None: + config = MagicMock(chart_type="xy", kind="scatter") + assert _resolve_viz_type(config) == "echarts_timeseries_scatter" + + def test_table(self) -> None: + config = MagicMock(chart_type="table", viz_type="table") + assert _resolve_viz_type(config) == "table" + + def test_ag_grid_table(self) -> None: + config = MagicMock(chart_type="table", viz_type="ag-grid-table") + assert _resolve_viz_type(config) == "ag-grid-table" + + def test_handlebars(self) -> None: + config = MagicMock(chart_type="handlebars") + assert _resolve_viz_type(config) == "handlebars" + + def test_unknown(self) -> None: + config = MagicMock(chart_type="unknown_type") + assert _resolve_viz_type(config) == "unknown" + + +class TestAnalyzeChartCapabilitiesHandlebars: + """Test analyze_chart_capabilities for handlebars config.""" + + def test_handlebars_capabilities(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + caps = analyze_chart_capabilities(None, config) + + assert caps.supports_interaction is False + assert caps.supports_drill_down is False + assert caps.supports_export is True + assert "url" in caps.optimal_formats + + +class TestAnalyzeChartSemanticsHandlebars: + """Test analyze_chart_semantics for handlebars config.""" + + def test_handlebars_semantics(self) -> None: + config = HandlebarsChartConfig( + chart_type="handlebars", + handlebars_template="test
", + metrics=[ColumnRef(name="sales", aggregate="SUM")], + ) + semantics = analyze_chart_semantics(None, config) + + assert ( + "Handlebars" in semantics.primary_insight + or "template" in semantics.primary_insight + ) + assert semantics.data_story is not None + + +class TestSchemaValidatorHandlebars: + """Test SchemaValidator pre-validation for handlebars chart type.""" + + def test_handlebars_accepted_as_valid_chart_type(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "{{data}}
", + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is True + assert request is not None + assert error is None + + def test_missing_handlebars_template(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_HANDLEBARS_TEMPLATE" + + def test_empty_handlebars_template(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": " ", + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_HANDLEBARS_TEMPLATE" + + def test_non_string_handlebars_template(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": 123, + "metrics": [{"name": "sales", "aggregate": "SUM"}], + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_HANDLEBARS_TEMPLATE" + + def test_invalid_query_mode_rejected(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "test
", + "query_mode": "invalid", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_QUERY_MODE" + + def test_raw_mode_missing_columns(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "test
", + "query_mode": "raw", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_RAW_COLUMNS" + + def test_aggregate_mode_missing_metrics(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "test
", + "query_mode": "aggregate", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_AGGREGATE_METRICS" + + def test_default_aggregate_mode_missing_metrics(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "test
", + }, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "MISSING_AGGREGATE_METRICS" + + def test_handlebars_raw_mode_valid(self) -> None: + data = { + "dataset_id": 1, + "config": { + "chart_type": "handlebars", + "handlebars_template": "{{#each data}}{{this.name}}{{/each}}
", + "query_mode": "raw", + "columns": [{"name": "product"}, {"name": "price"}], + }, + } + is_valid, request, error = SchemaValidator.validate_request(data) + assert is_valid is True + assert request is not None + assert error is None + + def test_non_string_chart_type_rejected(self) -> None: + data = { + "dataset_id": 1, + "config": {"chart_type": ["handlebars"]}, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert error.error_code == "INVALID_CHART_TYPE" + + def test_handlebars_in_error_messages(self) -> None: + """Verify 'handlebars' appears in missing chart_type suggestions.""" + data = { + "dataset_id": 1, + "config": {}, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + suggestions_text = " ".join(error.suggestions or []) + assert "handlebars" in suggestions_text + + def test_invalid_chart_type_mentions_handlebars(self) -> None: + """Invalid chart_type error should mention handlebars as option.""" + data = { + "dataset_id": 1, + "config": {"chart_type": "invalid"}, + } + is_valid, _, error = SchemaValidator.validate_request(data) + assert is_valid is False + assert error is not None + assert "handlebars" in (error.details or "")