mirror of
https://github.com/apache/superset.git
synced 2026-06-11 02:29:19 +00:00
Compare commits
2 Commits
mcp-delete
...
fix/helm-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de30eed14f | ||
|
|
3da2a210c7 |
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.17.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -111,9 +111,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| init.resources | object | `{}` | |
|
||||
| init.tolerations | list | `[]` | |
|
||||
| init.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to init job |
|
||||
| initImage.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| initImage.repository | string | `"apache/superset"` | |
|
||||
| initImage.tag | string | `"dockerize"` | |
|
||||
| nameOverride | string | `nil` | Provide a name to override the name of the chart |
|
||||
| nodeSelector | object | `{}` | |
|
||||
| postgresql | object | see `values.yaml` | Configuration values for the postgresql dependency. ref: https://github.com/bitnami/charts/tree/main/bitnami/postgresql |
|
||||
|
||||
@@ -194,11 +194,6 @@ image:
|
||||
|
||||
imagePullSecrets: []
|
||||
|
||||
initImage:
|
||||
repository: apache/superset
|
||||
tag: dockerize
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8088
|
||||
@@ -303,15 +298,28 @@ supersetNode:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# bash's /dev/tcp redirect performs a TCP connect; no external
|
||||
# `dockerize`, `nc`, or busybox needed. SECONDS-based deadline
|
||||
# mirrors the prior `dockerize -timeout 120s` behaviour.
|
||||
SECONDS=0
|
||||
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -407,15 +415,31 @@ supersetWorker:
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -495,15 +519,31 @@ supersetCeleryBeat:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -594,15 +634,31 @@ supersetCeleryFlower:
|
||||
# @default -- a container waiting for postgres and redis
|
||||
initContainers:
|
||||
- name: wait-for-postgres-redis
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
wait_for() {
|
||||
local host=$1 port=$2 name=$3
|
||||
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for $name at $host:$port after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "$name at $host:$port is up"
|
||||
}
|
||||
wait_for "$DB_HOST" "$DB_PORT" postgres
|
||||
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
@@ -764,15 +820,26 @@ init:
|
||||
# @default -- a container waiting for postgres
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
|
||||
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: "{{ tpl .Values.envFromSecret . }}"
|
||||
command:
|
||||
- /bin/sh
|
||||
- /bin/bash
|
||||
- -c
|
||||
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
|
||||
- |
|
||||
# See supersetNode.initContainers for the rationale.
|
||||
SECONDS=0
|
||||
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
|
||||
if [ "$SECONDS" -ge 120 ]; then
|
||||
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
|
||||
sleep 2
|
||||
done
|
||||
echo "postgres at $DB_HOST:$DB_PORT is up"
|
||||
resources:
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
|
||||
@@ -130,7 +130,6 @@ Dashboard Management:
|
||||
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
|
||||
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
|
||||
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
|
||||
- delete_dashboard: Permanently delete a dashboard by ID; requires confirm=true (requires write access)
|
||||
|
||||
Annotation Layers:
|
||||
- list_annotation_layers: List annotation layers with advanced filters (1-based pagination)
|
||||
@@ -680,7 +679,6 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
|
||||
)
|
||||
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
||||
add_chart_to_existing_dashboard,
|
||||
delete_dashboard,
|
||||
generate_dashboard,
|
||||
get_dashboard_info,
|
||||
get_dashboard_layout,
|
||||
|
||||
@@ -598,60 +598,6 @@ class AddChartToDashboardResponse(BaseModel):
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
|
||||
class DeleteDashboardRequest(BaseModel):
|
||||
"""Request schema for deleting a dashboard."""
|
||||
|
||||
dashboard_id: int = Field(..., description="ID of the dashboard to delete")
|
||||
confirm: bool = Field(
|
||||
...,
|
||||
description=(
|
||||
"Explicit confirmation of the deletion. Deleting a dashboard is "
|
||||
"permanent and cannot be undone. The tool refuses to delete unless "
|
||||
"this is set to true."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DeletedDashboardSummary(BaseModel):
|
||||
"""Summary of a dashboard targeted for deletion."""
|
||||
|
||||
id: int = Field(..., description="ID of the dashboard")
|
||||
dashboard_title: str | None = Field(None, description="Title of the dashboard")
|
||||
slug: str | None = Field(None, description="Slug of the dashboard")
|
||||
|
||||
@field_validator("dashboard_title", "slug")
|
||||
@classmethod
|
||||
def sanitize_text_for_llm_context(cls, value: str | None) -> str | None:
|
||||
"""Wrap user-controlled dashboard text before LLM exposure."""
|
||||
if value is None:
|
||||
return value
|
||||
return sanitize_for_llm_context(value, field_path=("dashboard",))
|
||||
|
||||
|
||||
class DeleteDashboardResponse(BaseModel):
|
||||
"""Response schema for deleting a dashboard."""
|
||||
|
||||
deleted: bool = Field(
|
||||
False, description="True when the dashboard was permanently deleted"
|
||||
)
|
||||
dashboard: DeletedDashboardSummary | None = Field(
|
||||
None, description="Summary of the deleted (or targeted) dashboard"
|
||||
)
|
||||
error: str | None = Field(None, description="Error message, if operation failed")
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
|
||||
"""Wrap error text before it is exposed to LLM context.
|
||||
|
||||
The error may echo the dashboard-controlled title — it must be wrapped
|
||||
so the LLM treats it as data, not instructions.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
|
||||
class GenerateDashboardRequest(BaseModel):
|
||||
"""Request schema for generating a dashboard."""
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
# under the License.
|
||||
|
||||
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
|
||||
from .delete_dashboard import delete_dashboard
|
||||
from .generate_dashboard import generate_dashboard
|
||||
from .get_dashboard_info import get_dashboard_info
|
||||
from .get_dashboard_layout import get_dashboard_layout
|
||||
@@ -28,5 +27,4 @@ __all__ = [
|
||||
"get_dashboard_layout",
|
||||
"generate_dashboard",
|
||||
"add_chart_to_existing_dashboard",
|
||||
"delete_dashboard",
|
||||
]
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
MCP tool: delete_dashboard
|
||||
|
||||
This tool permanently deletes a dashboard. It requires an explicit
|
||||
``confirm=true`` safety gate so callers must state destructive intent.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.dashboard.schemas import (
|
||||
DeleteDashboardRequest,
|
||||
DeleteDashboardResponse,
|
||||
DeletedDashboardSummary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Dashboard",
|
||||
method_permission_name="write",
|
||||
annotations=ToolAnnotations(
|
||||
title="Delete dashboard",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=True,
|
||||
),
|
||||
)
|
||||
async def delete_dashboard(
|
||||
request: DeleteDashboardRequest, ctx: Context
|
||||
) -> DeleteDashboardResponse:
|
||||
"""
|
||||
Permanently delete a dashboard by ID. The charts on the dashboard are
|
||||
NOT deleted — only the dashboard itself. This action cannot be undone,
|
||||
so the tool refuses to run unless ``confirm=true`` is explicitly passed.
|
||||
"""
|
||||
from superset.commands.dashboard.delete import DeleteDashboardCommand
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardDeleteFailedError,
|
||||
DashboardForbiddenError,
|
||||
DashboardNotFoundError,
|
||||
)
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
|
||||
if not request.confirm:
|
||||
await ctx.warning(
|
||||
"Deletion of dashboard %s not confirmed" % (request.dashboard_id,)
|
||||
)
|
||||
return DeleteDashboardResponse(
|
||||
deleted=False,
|
||||
dashboard=None,
|
||||
error=(
|
||||
f"Deletion not confirmed. Deleting dashboard "
|
||||
f"{request.dashboard_id} is permanent and cannot be undone. "
|
||||
"Re-run with confirm=true to proceed."
|
||||
),
|
||||
)
|
||||
|
||||
summary: DeletedDashboardSummary | None = None
|
||||
try:
|
||||
with event_logger.log_context(action="mcp.delete_dashboard.validation"):
|
||||
dashboard = DashboardDAO.find_by_id(request.dashboard_id)
|
||||
if not dashboard:
|
||||
return DeleteDashboardResponse(
|
||||
deleted=False,
|
||||
dashboard=None,
|
||||
error=(
|
||||
f"Dashboard with ID {request.dashboard_id} not found. "
|
||||
"Use list_dashboards to get valid dashboard IDs."
|
||||
),
|
||||
)
|
||||
summary = DeletedDashboardSummary(
|
||||
id=dashboard.id,
|
||||
dashboard_title=dashboard.dashboard_title,
|
||||
slug=dashboard.slug,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.delete_dashboard.delete"):
|
||||
DeleteDashboardCommand([request.dashboard_id]).run()
|
||||
|
||||
logger.info("Deleted dashboard %s", request.dashboard_id)
|
||||
await ctx.info("Deleted dashboard %s" % (request.dashboard_id,))
|
||||
return DeleteDashboardResponse(deleted=True, dashboard=summary, error=None)
|
||||
|
||||
except DashboardNotFoundError:
|
||||
return DeleteDashboardResponse(
|
||||
deleted=False,
|
||||
dashboard=None,
|
||||
error=(
|
||||
f"Dashboard with ID {request.dashboard_id} not found. "
|
||||
"Use list_dashboards to get valid dashboard IDs."
|
||||
),
|
||||
)
|
||||
|
||||
except DashboardForbiddenError:
|
||||
await ctx.warning(
|
||||
"Permission denied deleting dashboard %s" % (request.dashboard_id,)
|
||||
)
|
||||
return DeleteDashboardResponse(
|
||||
deleted=False,
|
||||
dashboard=summary,
|
||||
error=(
|
||||
f"You don't have permission to delete dashboard "
|
||||
f"with ID {request.dashboard_id}."
|
||||
),
|
||||
)
|
||||
|
||||
except DashboardDeleteFailedError as exc:
|
||||
await ctx.error(
|
||||
"Failed to delete dashboard %s: %s" % (request.dashboard_id, str(exc))
|
||||
)
|
||||
return DeleteDashboardResponse(
|
||||
deleted=False,
|
||||
dashboard=summary,
|
||||
error=f"Failed to delete dashboard {request.dashboard_id}: {exc.message}",
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
await ctx.error(
|
||||
"Unexpected error deleting dashboard: %s: %s"
|
||||
% (type(exc).__name__, str(exc))
|
||||
)
|
||||
raise
|
||||
@@ -1,152 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Unit tests for the delete_dashboard MCP tool.
|
||||
|
||||
Covers:
|
||||
- Successful delete (happy path)
|
||||
- confirm=false refusal (safety gate)
|
||||
- Dashboard not found
|
||||
- Permission denied (user does not own the dashboard)
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server() -> object:
|
||||
"""Return the FastMCP app instance for use in MCP client tests."""
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_auth():
|
||||
"""Mock authentication for all tests."""
|
||||
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
|
||||
mock_user = Mock()
|
||||
mock_user.id = 1
|
||||
mock_user.username = "admin"
|
||||
mock_get_user.return_value = mock_user
|
||||
yield mock_get_user
|
||||
|
||||
|
||||
def _mock_dashboard(id: int = 1, title: str = "Sales Dashboard") -> Mock:
|
||||
"""Create a minimal mock Dashboard object."""
|
||||
dashboard = Mock()
|
||||
dashboard.id = id
|
||||
dashboard.dashboard_title = title
|
||||
dashboard.slug = f"test-dashboard-{id}"
|
||||
return dashboard
|
||||
|
||||
|
||||
@patch("superset.commands.dashboard.delete.DeleteDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_delete(
|
||||
mock_find_by_id: Mock, mock_delete_cmd_cls: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""Happy path: dashboard deleted, summary echoed back."""
|
||||
mock_find_by_id.return_value = _mock_dashboard(id=1, title="Sales Dashboard")
|
||||
mock_delete_cmd = Mock()
|
||||
mock_delete_cmd.run.return_value = None
|
||||
mock_delete_cmd_cls.return_value = mock_delete_cmd
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"delete_dashboard",
|
||||
{"request": {"dashboard_id": 1, "confirm": True}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["deleted"] is True
|
||||
assert content["error"] is None
|
||||
assert content["dashboard"]["id"] == 1
|
||||
assert "Sales Dashboard" in content["dashboard"]["dashboard_title"]
|
||||
assert "test-dashboard-1" in content["dashboard"]["slug"]
|
||||
mock_delete_cmd_cls.assert_called_once_with([1])
|
||||
mock_delete_cmd.run.assert_called_once()
|
||||
|
||||
|
||||
@patch("superset.commands.dashboard.delete.DeleteDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_confirmed_refusal(
|
||||
mock_find_by_id: Mock, mock_delete_cmd_cls: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""confirm=false: the tool refuses and nothing is deleted."""
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"delete_dashboard",
|
||||
{"request": {"dashboard_id": 1, "confirm": False}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["deleted"] is False
|
||||
assert content["dashboard"] is None
|
||||
assert "confirm" in (content["error"] or "").lower()
|
||||
mock_find_by_id.assert_not_called()
|
||||
mock_delete_cmd_cls.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_not_found(mock_find_by_id: Mock, mcp_server: object) -> None:
|
||||
"""Returns a clear error when the target dashboard does not exist."""
|
||||
mock_find_by_id.return_value = None
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"delete_dashboard",
|
||||
{"request": {"dashboard_id": 999, "confirm": True}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["deleted"] is False
|
||||
assert content["dashboard"] is None
|
||||
assert "not found" in (content["error"] or "").lower()
|
||||
|
||||
|
||||
@patch("superset.commands.dashboard.delete.DeleteDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_denied(
|
||||
mock_find_by_id: Mock, mock_delete_cmd_cls: Mock, mcp_server: object
|
||||
) -> None:
|
||||
"""Returns a structured error when the user cannot delete the dashboard."""
|
||||
from superset.commands.dashboard.exceptions import DashboardForbiddenError
|
||||
|
||||
mock_find_by_id.return_value = _mock_dashboard(id=1, title="Sales Dashboard")
|
||||
mock_delete_cmd = Mock()
|
||||
mock_delete_cmd.run.side_effect = DashboardForbiddenError()
|
||||
mock_delete_cmd_cls.return_value = mock_delete_cmd
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"delete_dashboard",
|
||||
{"request": {"dashboard_id": 1, "confirm": True}},
|
||||
)
|
||||
|
||||
content = result.structured_content
|
||||
assert content["deleted"] is False
|
||||
assert "permission" in (content["error"] or "").lower()
|
||||
assert content["dashboard"]["id"] == 1
|
||||
@@ -1,81 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from superset.utils.core import split
|
||||
|
||||
|
||||
def test_split_empty_string():
|
||||
assert list(split("")) == [""]
|
||||
|
||||
|
||||
def test_split_leading_delimiter():
|
||||
assert list(split(" a")) == [
|
||||
"",
|
||||
"a",
|
||||
]
|
||||
|
||||
|
||||
def test_split_trailing_delimiter():
|
||||
assert list(split("a ")) == [
|
||||
"a",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def test_split_only_delimiter():
|
||||
assert list(split(" ")) == [
|
||||
"",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def test_split_nested_parentheses():
|
||||
assert list(
|
||||
split(
|
||||
"a,(b,(c,d))",
|
||||
delimiter=",",
|
||||
)
|
||||
) == [
|
||||
"a",
|
||||
"(b,(c,d))",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_separator_found():
|
||||
assert list(split("a b")) == [
|
||||
"a",
|
||||
"b",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_separator_not_found():
|
||||
assert list(split("ab")) == [
|
||||
"ab",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_parentheses():
|
||||
assert list(split("(a b)")) == [
|
||||
"(a b)",
|
||||
]
|
||||
|
||||
|
||||
def test_branch_escaped_quote():
|
||||
assert list(split(r'"a\"b c" d')) == [
|
||||
r'"a\"b c"',
|
||||
"d",
|
||||
]
|
||||
Reference in New Issue
Block a user