mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(mcp): MCP service implementation (PRs 3-9 consolidated) (#35877)
This commit is contained in:
16
tests/unit_tests/mcp_service/dashboard/__init__.py
Normal file
16
tests/unit_tests/mcp_service/dashboard/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
16
tests/unit_tests/mcp_service/dashboard/tool/__init__.py
Normal file
16
tests/unit_tests/mcp_service/dashboard/tool/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,450 @@
|
||||
# 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 dashboard generation MCP tools
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.utils import json
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server():
|
||||
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_chart(id: int = 1, slice_name: str = "Test Chart") -> Mock:
|
||||
"""Create a mock chart object."""
|
||||
chart = Mock()
|
||||
chart.id = id
|
||||
chart.slice_name = slice_name
|
||||
chart.uuid = f"chart-uuid-{id}"
|
||||
return chart
|
||||
|
||||
|
||||
def _mock_dashboard(id: int = 1, title: str = "Test Dashboard") -> Mock:
|
||||
"""Create a mock dashboard object."""
|
||||
dashboard = Mock()
|
||||
dashboard.id = id
|
||||
dashboard.dashboard_title = title
|
||||
dashboard.slug = f"test-dashboard-{id}"
|
||||
dashboard.description = "Test dashboard description"
|
||||
dashboard.published = True
|
||||
dashboard.created_on = "2024-01-01"
|
||||
dashboard.changed_on = "2024-01-01"
|
||||
dashboard.created_by = Mock()
|
||||
dashboard.created_by.username = "test_user"
|
||||
dashboard.changed_by = Mock()
|
||||
dashboard.changed_by.username = "test_user"
|
||||
dashboard.uuid = f"dashboard-uuid-{id}"
|
||||
dashboard.slices = []
|
||||
dashboard.owners = [] # Add missing owners attribute
|
||||
dashboard.tags = [] # Add missing tags attribute
|
||||
return dashboard
|
||||
|
||||
|
||||
class TestGenerateDashboard:
|
||||
"""Tests for generate_dashboard MCP tool."""
|
||||
|
||||
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_dashboard_basic(
|
||||
self, mock_db_session, mock_create_command, mcp_server
|
||||
):
|
||||
"""Test basic dashboard generation with valid charts."""
|
||||
# Mock database query for charts
|
||||
mock_query = Mock()
|
||||
mock_filter = Mock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = [
|
||||
_mock_chart(id=1, slice_name="Sales Chart"),
|
||||
_mock_chart(id=2, slice_name="Revenue Chart"),
|
||||
]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
# Mock dashboard creation
|
||||
mock_dashboard = _mock_dashboard(id=10, title="Analytics Dashboard")
|
||||
mock_create_command.return_value.run.return_value = mock_dashboard
|
||||
|
||||
request = {
|
||||
"chart_ids": [1, 2],
|
||||
"dashboard_title": "Analytics Dashboard",
|
||||
"description": "Dashboard for analytics",
|
||||
"published": True,
|
||||
}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool("generate_dashboard", {"request": request})
|
||||
|
||||
assert result.data.error is None
|
||||
assert result.data.dashboard is not None
|
||||
assert result.data.dashboard.id == 10
|
||||
assert result.data.dashboard.dashboard_title == "Analytics Dashboard"
|
||||
assert result.data.dashboard.chart_count == 2
|
||||
assert "/superset/dashboard/10/" in result.data.dashboard_url
|
||||
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_dashboard_missing_charts(self, mock_db_session, mcp_server):
|
||||
"""Test error handling when some charts don't exist."""
|
||||
# Mock database query returning only chart 1 (chart 2 missing)
|
||||
mock_query = Mock()
|
||||
mock_filter = Mock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = [
|
||||
_mock_chart(id=1),
|
||||
# Chart 2 is missing from the result
|
||||
]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
request = {"chart_ids": [1, 2], "dashboard_title": "Test Dashboard"}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool("generate_dashboard", {"request": request})
|
||||
|
||||
assert result.data.error is not None
|
||||
assert "Charts not found: [2]" in result.data.error
|
||||
assert result.data.dashboard is None
|
||||
assert result.data.dashboard_url is None
|
||||
|
||||
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_dashboard_single_chart(
|
||||
self, mock_db_session, mock_create_command, mcp_server
|
||||
):
|
||||
"""Test dashboard generation with a single chart."""
|
||||
# Mock database query for single chart
|
||||
mock_query = Mock()
|
||||
mock_filter = Mock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = [_mock_chart(id=5, slice_name="Single Chart")]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
mock_dashboard = _mock_dashboard(id=20, title="Single Chart Dashboard")
|
||||
mock_create_command.return_value.run.return_value = mock_dashboard
|
||||
|
||||
request = {
|
||||
"chart_ids": [5],
|
||||
"dashboard_title": "Single Chart Dashboard",
|
||||
"published": False,
|
||||
}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool("generate_dashboard", {"request": request})
|
||||
|
||||
assert result.data.error is None
|
||||
assert result.data.dashboard.chart_count == 1
|
||||
assert result.data.dashboard.published is True # From mock
|
||||
|
||||
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_dashboard_many_charts(
|
||||
self, mock_db_session, mock_create_command, mcp_server
|
||||
):
|
||||
"""Test dashboard generation with many charts (grid layout)."""
|
||||
# Mock 6 charts
|
||||
chart_ids = list(range(1, 7))
|
||||
mock_query = Mock()
|
||||
mock_filter = Mock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = [
|
||||
_mock_chart(id=i, slice_name=f"Chart {i}") for i in chart_ids
|
||||
]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
mock_dashboard = _mock_dashboard(id=30, title="Multi Chart Dashboard")
|
||||
mock_create_command.return_value.run.return_value = mock_dashboard
|
||||
|
||||
request = {"chart_ids": chart_ids, "dashboard_title": "Multi Chart Dashboard"}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool("generate_dashboard", {"request": request})
|
||||
|
||||
assert result.data.error is None
|
||||
assert result.data.dashboard.chart_count == 6
|
||||
|
||||
# Verify CreateDashboardCommand was called with proper layout
|
||||
mock_create_command.assert_called_once()
|
||||
call_args = mock_create_command.call_args[0][0]
|
||||
|
||||
# Check position_json contains proper layout
|
||||
position_json = json.loads(call_args["position_json"])
|
||||
assert "ROOT_ID" in position_json
|
||||
assert "GRID_ID" in position_json
|
||||
assert "DASHBOARD_VERSION_KEY" in position_json
|
||||
assert position_json["DASHBOARD_VERSION_KEY"] == "v2"
|
||||
|
||||
# ROOT should only contain GRID
|
||||
assert position_json["ROOT_ID"]["children"] == ["GRID_ID"]
|
||||
|
||||
# GRID should contain rows (6 charts = 3 rows in 2-chart layout)
|
||||
grid_children = position_json["GRID_ID"]["children"]
|
||||
assert len(grid_children) == 3
|
||||
|
||||
# Check each chart has proper structure
|
||||
for i, chart_id in enumerate(chart_ids):
|
||||
chart_key = f"CHART-{chart_id}"
|
||||
row_index = i // 2 # 2 charts per row
|
||||
row_key = f"ROW-{row_index}"
|
||||
|
||||
# Chart should exist
|
||||
assert chart_key in position_json
|
||||
chart_data = position_json[chart_key]
|
||||
assert chart_data["type"] == "CHART"
|
||||
assert "meta" in chart_data
|
||||
assert chart_data["meta"]["chartId"] == chart_id
|
||||
|
||||
# Row should exist and contain charts (up to 2 per row)
|
||||
assert row_key in position_json
|
||||
row_data = position_json[row_key]
|
||||
assert row_data["type"] == "ROW"
|
||||
assert chart_key in row_data["children"]
|
||||
|
||||
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_dashboard_creation_failure(
|
||||
self, mock_db_session, mock_create_command, mcp_server
|
||||
):
|
||||
"""Test error handling when dashboard creation fails."""
|
||||
mock_query = Mock()
|
||||
mock_filter = Mock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = [_mock_chart(id=1)]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
mock_create_command.return_value.run.side_effect = Exception("Creation failed")
|
||||
|
||||
request = {"chart_ids": [1], "dashboard_title": "Failed Dashboard"}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool("generate_dashboard", {"request": request})
|
||||
|
||||
assert result.data.error is not None
|
||||
assert "Failed to create dashboard" in result.data.error
|
||||
assert result.data.dashboard is None
|
||||
|
||||
@patch("superset.commands.dashboard.create.CreateDashboardCommand")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_dashboard_minimal_request(
|
||||
self, mock_db_session, mock_create_command, mcp_server
|
||||
):
|
||||
"""Test dashboard generation with minimal required parameters."""
|
||||
# Mock database query for single chart
|
||||
mock_query = Mock()
|
||||
mock_filter = Mock()
|
||||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.all.return_value = [_mock_chart(id=3)]
|
||||
mock_db_session.query.return_value = mock_query
|
||||
|
||||
mock_dashboard = _mock_dashboard(id=40, title="Minimal Dashboard")
|
||||
mock_create_command.return_value.run.return_value = mock_dashboard
|
||||
|
||||
request = {
|
||||
"chart_ids": [3],
|
||||
"dashboard_title": "Minimal Dashboard",
|
||||
# No description, published defaults to True
|
||||
}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool("generate_dashboard", {"request": request})
|
||||
|
||||
assert result.data.error is None
|
||||
assert result.data.dashboard.dashboard_title == "Minimal Dashboard"
|
||||
|
||||
# Check that description was not included in call
|
||||
call_args = mock_create_command.call_args[0][0]
|
||||
assert call_args["published"] is True # Default value
|
||||
assert (
|
||||
"description" not in call_args or call_args.get("description") is None
|
||||
)
|
||||
|
||||
|
||||
class TestAddChartToExistingDashboard:
|
||||
"""Tests for add_chart_to_existing_dashboard MCP tool."""
|
||||
|
||||
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_chart_to_dashboard_basic(
|
||||
self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
|
||||
):
|
||||
"""Test adding a chart to an existing dashboard."""
|
||||
# Mock existing dashboard with some charts
|
||||
mock_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
|
||||
mock_dashboard.slices = [Mock(id=10), Mock(id=20)] # Existing charts
|
||||
mock_dashboard.position_json = json.dumps(
|
||||
{
|
||||
"ROOT_ID": {
|
||||
"children": ["CHART-10", "CHART-20"],
|
||||
"id": "ROOT_ID",
|
||||
"type": "ROOT",
|
||||
},
|
||||
"CHART-10": {"id": "CHART-10", "type": "CHART", "parents": ["ROOT_ID"]},
|
||||
"CHART-10_POSITION": {"h": 16, "w": 24, "x": 0, "y": 0},
|
||||
"CHART-20": {"id": "CHART-20", "type": "CHART", "parents": ["ROOT_ID"]},
|
||||
"CHART-20_POSITION": {"h": 16, "w": 24, "x": 24, "y": 0},
|
||||
}
|
||||
)
|
||||
mock_find_dashboard.return_value = mock_dashboard
|
||||
|
||||
# Mock chart to add
|
||||
mock_chart = _mock_chart(id=30, slice_name="New Chart")
|
||||
mock_db_session.get.return_value = mock_chart
|
||||
|
||||
# Mock updated dashboard
|
||||
updated_dashboard = _mock_dashboard(id=1, title="Existing Dashboard")
|
||||
updated_dashboard.slices = [Mock(id=10), Mock(id=20), Mock(id=30)]
|
||||
mock_update_command.return_value.run.return_value = updated_dashboard
|
||||
|
||||
request = {"dashboard_id": 1, "chart_id": 30}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"add_chart_to_existing_dashboard", {"request": request}
|
||||
)
|
||||
|
||||
assert result.data.error is None
|
||||
assert result.data.dashboard is not None
|
||||
assert result.data.dashboard.chart_count == 3
|
||||
assert result.data.position is not None
|
||||
assert "row" in result.data.position # Should have row info
|
||||
assert "chart_key" in result.data.position
|
||||
assert "/superset/dashboard/1/" in result.data.dashboard_url
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_chart_dashboard_not_found(self, mock_find_dashboard, mcp_server):
|
||||
"""Test error when dashboard doesn't exist."""
|
||||
mock_find_dashboard.return_value = None
|
||||
|
||||
request = {"dashboard_id": 999, "chart_id": 1}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"add_chart_to_existing_dashboard", {"request": request}
|
||||
)
|
||||
|
||||
assert result.data.error is not None
|
||||
assert "Dashboard with ID 999 not found" in result.data.error
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_chart_chart_not_found(
|
||||
self, mock_db_session, mock_find_dashboard, mcp_server
|
||||
):
|
||||
"""Test error when chart doesn't exist."""
|
||||
mock_find_dashboard.return_value = _mock_dashboard()
|
||||
mock_db_session.get.return_value = None
|
||||
|
||||
request = {"dashboard_id": 1, "chart_id": 999}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"add_chart_to_existing_dashboard", {"request": request}
|
||||
)
|
||||
|
||||
assert result.data.error is not None
|
||||
assert "Chart with ID 999 not found" in result.data.error
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_chart_already_in_dashboard(
|
||||
self, mock_db_session, mock_find_dashboard, mcp_server
|
||||
):
|
||||
"""Test error when chart is already in dashboard."""
|
||||
mock_dashboard = _mock_dashboard()
|
||||
mock_dashboard.slices = [Mock(id=5)] # Chart 5 already exists
|
||||
mock_find_dashboard.return_value = mock_dashboard
|
||||
|
||||
mock_db_session.get.return_value = _mock_chart(id=5)
|
||||
|
||||
request = {"dashboard_id": 1, "chart_id": 5}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"add_chart_to_existing_dashboard", {"request": request}
|
||||
)
|
||||
|
||||
assert result.data.error is not None
|
||||
assert "Chart 5 is already in dashboard 1" in result.data.error
|
||||
|
||||
@patch("superset.commands.dashboard.update.UpdateDashboardCommand")
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@patch("superset.db.session")
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_chart_empty_dashboard(
|
||||
self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server
|
||||
):
|
||||
"""Test adding chart to dashboard with no existing layout."""
|
||||
mock_dashboard = _mock_dashboard(id=2)
|
||||
mock_dashboard.slices = []
|
||||
mock_dashboard.position_json = "{}" # Empty layout
|
||||
mock_find_dashboard.return_value = mock_dashboard
|
||||
|
||||
mock_chart = _mock_chart(id=15)
|
||||
mock_db_session.get.return_value = mock_chart
|
||||
|
||||
updated_dashboard = _mock_dashboard(id=2)
|
||||
updated_dashboard.slices = [Mock(id=15)]
|
||||
mock_update_command.return_value.run.return_value = updated_dashboard
|
||||
|
||||
request = {"dashboard_id": 2, "chart_id": 15}
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"add_chart_to_existing_dashboard", {"request": request}
|
||||
)
|
||||
|
||||
assert result.data.error is None
|
||||
assert "row" in result.data.position # Should have row info
|
||||
assert result.data.position.get("row") == 0 # First row
|
||||
|
||||
# Verify update was called with proper layout structure
|
||||
call_args = mock_update_command.call_args[0][1]
|
||||
layout = json.loads(call_args["position_json"])
|
||||
assert "ROOT_ID" in layout
|
||||
assert "GRID_ID" in layout
|
||||
assert "ROW-0" in layout
|
||||
assert "CHART-15" in layout
|
||||
@@ -0,0 +1,573 @@
|
||||
# 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 MCP dashboard tools (list_dashboards, get_dashboard_info)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.dashboard.schemas import (
|
||||
ListDashboardsRequest,
|
||||
)
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server():
|
||||
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
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_basic(mock_list, mcp_server):
|
||||
dashboard = Mock()
|
||||
dashboard.id = 1
|
||||
dashboard.dashboard_title = "Test Dashboard"
|
||||
dashboard.slug = "test-dashboard"
|
||||
dashboard.url = "/dashboard/1"
|
||||
dashboard.published = True
|
||||
dashboard.changed_by_name = "admin"
|
||||
dashboard.changed_on = None
|
||||
dashboard.changed_on_humanized = None
|
||||
dashboard.created_by_name = "admin"
|
||||
dashboard.created_on = None
|
||||
dashboard.created_on_humanized = None
|
||||
dashboard.tags = []
|
||||
dashboard.owners = []
|
||||
dashboard.slices = []
|
||||
dashboard.description = None
|
||||
dashboard.css = None
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.json_metadata = None
|
||||
dashboard.position_json = None
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
dashboard.uuid = "test-dashboard-uuid-1"
|
||||
dashboard.thumbnail_url = None
|
||||
dashboard.roles = []
|
||||
dashboard.charts = []
|
||||
dashboard._mapping = {
|
||||
"id": dashboard.id,
|
||||
"dashboard_title": dashboard.dashboard_title,
|
||||
"slug": dashboard.slug,
|
||||
"url": dashboard.url,
|
||||
"published": dashboard.published,
|
||||
"changed_by_name": dashboard.changed_by_name,
|
||||
"changed_on": dashboard.changed_on,
|
||||
"changed_on_humanized": dashboard.changed_on_humanized,
|
||||
"created_by_name": dashboard.created_by_name,
|
||||
"created_on": dashboard.created_on,
|
||||
"created_on_humanized": dashboard.created_on_humanized,
|
||||
"tags": dashboard.tags,
|
||||
"owners": dashboard.owners,
|
||||
"charts": [],
|
||||
}
|
||||
mock_list.return_value = ([dashboard], 1)
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListDashboardsRequest(page=1, page_size=10)
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
dashboards = result.data.dashboards
|
||||
assert len(dashboards) == 1
|
||||
assert dashboards[0].dashboard_title == "Test Dashboard"
|
||||
assert dashboards[0].uuid == "test-dashboard-uuid-1"
|
||||
assert dashboards[0].slug == "test-dashboard"
|
||||
assert dashboards[0].published is True
|
||||
|
||||
# Verify UUID and slug are in default columns
|
||||
assert "uuid" in result.data.columns_requested
|
||||
assert "slug" in result.data.columns_requested
|
||||
assert "uuid" in result.data.columns_loaded
|
||||
assert "slug" in result.data.columns_loaded
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_with_filters(mock_list, mcp_server):
|
||||
dashboard = Mock()
|
||||
dashboard.id = 1
|
||||
dashboard.dashboard_title = "Filtered Dashboard"
|
||||
dashboard.slug = "filtered-dashboard"
|
||||
dashboard.url = "/dashboard/2"
|
||||
dashboard.published = True
|
||||
dashboard.changed_by_name = "admin"
|
||||
dashboard.changed_on = None
|
||||
dashboard.changed_on_humanized = None
|
||||
dashboard.created_by_name = "admin"
|
||||
dashboard.created_on = None
|
||||
dashboard.created_on_humanized = None
|
||||
dashboard.tags = []
|
||||
dashboard.owners = []
|
||||
dashboard.slices = []
|
||||
dashboard.description = None
|
||||
dashboard.css = None
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.json_metadata = None
|
||||
dashboard.position_json = None
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
dashboard.uuid = None
|
||||
dashboard.thumbnail_url = None
|
||||
dashboard.roles = []
|
||||
dashboard.charts = []
|
||||
dashboard._mapping = {
|
||||
"id": dashboard.id,
|
||||
"dashboard_title": dashboard.dashboard_title,
|
||||
"slug": dashboard.slug,
|
||||
"url": dashboard.url,
|
||||
"published": dashboard.published,
|
||||
"changed_by_name": dashboard.changed_by_name,
|
||||
"changed_on": dashboard.changed_on,
|
||||
"changed_on_humanized": dashboard.changed_on_humanized,
|
||||
"created_by_name": dashboard.created_by_name,
|
||||
"created_on": dashboard.created_on,
|
||||
"created_on_humanized": dashboard.created_on_humanized,
|
||||
"tags": dashboard.tags,
|
||||
"owners": dashboard.owners,
|
||||
"charts": [],
|
||||
}
|
||||
mock_list.return_value = ([dashboard], 1)
|
||||
async with Client(mcp_server) as client:
|
||||
filters = [
|
||||
{"col": "dashboard_title", "opr": "sw", "value": "Sales"},
|
||||
{"col": "published", "opr": "eq", "value": True},
|
||||
]
|
||||
request = ListDashboardsRequest(
|
||||
filters=filters,
|
||||
select_columns=["id", "dashboard_title"],
|
||||
order_column="changed_on",
|
||||
order_direction="desc",
|
||||
page=1,
|
||||
page_size=50,
|
||||
)
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
assert result.data.count == 1
|
||||
assert result.data.dashboards[0].dashboard_title == "Filtered Dashboard"
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_with_string_filters(mock_list, mcp_server):
|
||||
mock_list.return_value = ([], 0)
|
||||
async with Client(mcp_server) as client: # noqa: F841
|
||||
filters = '[{"col": "dashboard_title", "opr": "sw", "value": "Sales"}]'
|
||||
|
||||
# Test that string filters cause validation error at schema level
|
||||
with pytest.raises(ValueError, match="validation error"):
|
||||
ListDashboardsRequest(filters=filters) # noqa: F841
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_api_error(mock_list, mcp_server):
|
||||
mock_list.side_effect = ToolError("API request failed")
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
request = ListDashboardsRequest()
|
||||
await client.call_tool("list_dashboards", {"request": request.model_dump()})
|
||||
assert "API request failed" in str(excinfo.value)
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_with_search(mock_list, mcp_server):
|
||||
dashboard = Mock()
|
||||
dashboard.id = 1
|
||||
dashboard.dashboard_title = "search_dashboard"
|
||||
dashboard.slug = "search-dashboard"
|
||||
dashboard.url = "/dashboard/1"
|
||||
dashboard.published = True
|
||||
dashboard.changed_by_name = "admin"
|
||||
dashboard.changed_on = None
|
||||
dashboard.changed_on_humanized = None
|
||||
dashboard.created_by_name = "admin"
|
||||
dashboard.created_on = None
|
||||
dashboard.created_on_humanized = None
|
||||
dashboard.tags = []
|
||||
dashboard.owners = []
|
||||
dashboard.slices = []
|
||||
dashboard.description = None
|
||||
dashboard.css = None
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.json_metadata = None
|
||||
dashboard.position_json = None
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
dashboard.uuid = None
|
||||
dashboard.thumbnail_url = None
|
||||
dashboard.roles = []
|
||||
dashboard.charts = []
|
||||
dashboard._mapping = {
|
||||
"id": dashboard.id,
|
||||
"dashboard_title": dashboard.dashboard_title,
|
||||
"slug": dashboard.slug,
|
||||
"url": dashboard.url,
|
||||
"published": dashboard.published,
|
||||
"changed_by_name": dashboard.changed_by_name,
|
||||
"changed_on": dashboard.changed_on,
|
||||
"changed_on_humanized": dashboard.changed_on_humanized,
|
||||
"created_by_name": dashboard.created_by_name,
|
||||
"created_on": dashboard.created_on,
|
||||
"created_on_humanized": dashboard.created_on_humanized,
|
||||
"tags": dashboard.tags,
|
||||
"owners": dashboard.owners,
|
||||
"charts": [],
|
||||
}
|
||||
mock_list.return_value = ([dashboard], 1)
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListDashboardsRequest(search="search_dashboard")
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
assert result.data.count == 1
|
||||
assert result.data.dashboards[0].dashboard_title == "search_dashboard"
|
||||
args, kwargs = mock_list.call_args
|
||||
assert kwargs["search"] == "search_dashboard"
|
||||
assert "dashboard_title" in kwargs["search_columns"]
|
||||
assert "slug" in kwargs["search_columns"]
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_with_simple_filters(mock_list, mcp_server):
|
||||
mock_list.return_value = ([], 0)
|
||||
async with Client(mcp_server) as client:
|
||||
filters = [
|
||||
{"col": "dashboard_title", "opr": "eq", "value": "Sales"},
|
||||
{"col": "published", "opr": "eq", "value": True},
|
||||
]
|
||||
request = ListDashboardsRequest(filters=filters)
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
assert hasattr(result.data, "count")
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_info_success(mock_info, mcp_server):
|
||||
dashboard = Mock()
|
||||
dashboard.id = 1
|
||||
dashboard.dashboard_title = "Test Dashboard"
|
||||
dashboard.slug = "test-dashboard"
|
||||
dashboard.description = "Test description"
|
||||
dashboard.css = None
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.json_metadata = None
|
||||
dashboard.position_json = None
|
||||
dashboard.published = True
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
dashboard.created_on = None
|
||||
dashboard.changed_on = None
|
||||
dashboard.created_by = None
|
||||
dashboard.changed_by = None
|
||||
dashboard.uuid = None
|
||||
dashboard.url = "/dashboard/1"
|
||||
dashboard.thumbnail_url = None
|
||||
dashboard.created_on_humanized = None
|
||||
dashboard.changed_on_humanized = None
|
||||
dashboard.slices = []
|
||||
dashboard.owners = []
|
||||
dashboard.tags = []
|
||||
dashboard.roles = []
|
||||
dashboard.charts = []
|
||||
dashboard._mapping = {
|
||||
"id": dashboard.id,
|
||||
"dashboard_title": dashboard.dashboard_title,
|
||||
"slug": dashboard.slug,
|
||||
"url": dashboard.url,
|
||||
"published": dashboard.published,
|
||||
"changed_by_name": dashboard.changed_by_name,
|
||||
"changed_on": dashboard.changed_on,
|
||||
"changed_on_humanized": dashboard.changed_on_humanized,
|
||||
"created_by_name": dashboard.created_by_name,
|
||||
"created_on": dashboard.created_on,
|
||||
"created_on_humanized": dashboard.created_on_humanized,
|
||||
"tags": dashboard.tags,
|
||||
"owners": dashboard.owners,
|
||||
"charts": [],
|
||||
}
|
||||
mock_info.return_value = dashboard # Only the dashboard object
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_dashboard_info", {"request": {"identifier": 1}}
|
||||
)
|
||||
assert result.data["dashboard_title"] == "Test Dashboard"
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_info_not_found(mock_info, mcp_server):
|
||||
mock_info.return_value = None # Not found returns None
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_dashboard_info", {"request": {"identifier": 999}}
|
||||
)
|
||||
assert result.data["error_type"] == "not_found"
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_info_access_denied(mock_info, mcp_server):
|
||||
mock_info.return_value = None # Access denied returns None
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_dashboard_info", {"request": {"identifier": 1}}
|
||||
)
|
||||
assert result.data["error_type"] == "not_found"
|
||||
|
||||
|
||||
# TODO (Phase 3+): Add tests for get_dashboard_available_filters tool
|
||||
|
||||
|
||||
@patch("superset.mcp_service.mcp_core.ModelGetInfoCore._find_object")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_info_by_uuid(mock_find_object, mcp_server):
|
||||
"""Test getting dashboard info using UUID identifier."""
|
||||
dashboard = Mock()
|
||||
dashboard.id = 1
|
||||
dashboard.dashboard_title = "Test Dashboard UUID"
|
||||
dashboard.slug = "test-dashboard-uuid"
|
||||
dashboard.description = "Test description"
|
||||
dashboard.css = ""
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.json_metadata = "{}"
|
||||
dashboard.position_json = "{}"
|
||||
dashboard.published = True
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
dashboard.created_on = None
|
||||
dashboard.changed_on = None
|
||||
dashboard.created_by = None
|
||||
dashboard.changed_by = None
|
||||
dashboard.uuid = "c3d4e5f6-g7h8-9012-cdef-gh3456789012"
|
||||
dashboard.url = "/dashboard/1"
|
||||
dashboard.thumbnail_url = None
|
||||
dashboard.created_on_humanized = "2 days ago"
|
||||
dashboard.changed_on_humanized = "1 day ago"
|
||||
dashboard.slices = []
|
||||
dashboard.owners = []
|
||||
dashboard.tags = []
|
||||
dashboard.roles = []
|
||||
|
||||
mock_find_object.return_value = dashboard
|
||||
async with Client(mcp_server) as client:
|
||||
uuid_str = "c3d4e5f6-g7h8-9012-cdef-gh3456789012"
|
||||
result = await client.call_tool(
|
||||
"get_dashboard_info", {"request": {"identifier": uuid_str}}
|
||||
)
|
||||
assert result.data["dashboard_title"] == "Test Dashboard UUID"
|
||||
|
||||
|
||||
@patch("superset.mcp_service.mcp_core.ModelGetInfoCore._find_object")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboard_info_by_slug(mock_find_object, mcp_server):
|
||||
"""Test getting dashboard info using slug identifier."""
|
||||
dashboard = Mock()
|
||||
dashboard.id = 2
|
||||
dashboard.dashboard_title = "Test Dashboard Slug"
|
||||
dashboard.slug = "test-dashboard-slug"
|
||||
dashboard.description = "Test description"
|
||||
dashboard.css = ""
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.json_metadata = "{}"
|
||||
dashboard.position_json = "{}"
|
||||
dashboard.published = True
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
dashboard.created_on = None
|
||||
dashboard.changed_on = None
|
||||
dashboard.created_by = None
|
||||
dashboard.changed_by = None
|
||||
dashboard.uuid = "d4e5f6g7-h8i9-0123-defg-hi4567890123"
|
||||
dashboard.url = "/dashboard/2"
|
||||
dashboard.thumbnail_url = None
|
||||
dashboard.created_on_humanized = "2 days ago"
|
||||
dashboard.changed_on_humanized = "1 day ago"
|
||||
dashboard.slices = []
|
||||
dashboard.owners = []
|
||||
dashboard.tags = []
|
||||
dashboard.roles = []
|
||||
|
||||
mock_find_object.return_value = dashboard
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_dashboard_info", {"request": {"identifier": "test-dashboard-slug"}}
|
||||
)
|
||||
assert result.data["dashboard_title"] == "Test Dashboard Slug"
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_custom_uuid_slug_columns(mock_list, mcp_server):
|
||||
"""Test that custom column selection includes UUID and slug when explicitly
|
||||
requested."""
|
||||
dashboard = Mock()
|
||||
dashboard.id = 1
|
||||
dashboard.dashboard_title = "Custom Columns Dashboard"
|
||||
dashboard.slug = "custom-dashboard"
|
||||
dashboard.uuid = "test-custom-uuid-123"
|
||||
dashboard.url = "/dashboard/1"
|
||||
dashboard.published = True
|
||||
dashboard.changed_by_name = "admin"
|
||||
dashboard.changed_on = None
|
||||
dashboard.changed_on_humanized = None
|
||||
dashboard.created_by_name = "admin"
|
||||
dashboard.created_on = None
|
||||
dashboard.created_on_humanized = None
|
||||
dashboard.tags = []
|
||||
dashboard.owners = []
|
||||
dashboard.slices = []
|
||||
dashboard.description = None
|
||||
dashboard.css = None
|
||||
dashboard.certified_by = None
|
||||
dashboard.certification_details = None
|
||||
dashboard.json_metadata = None
|
||||
dashboard.position_json = None
|
||||
dashboard.is_managed_externally = False
|
||||
dashboard.external_url = None
|
||||
dashboard.thumbnail_url = None
|
||||
dashboard.roles = []
|
||||
dashboard.charts = []
|
||||
dashboard._mapping = {
|
||||
"id": dashboard.id,
|
||||
"dashboard_title": dashboard.dashboard_title,
|
||||
"slug": dashboard.slug,
|
||||
"uuid": dashboard.uuid,
|
||||
"url": dashboard.url,
|
||||
"published": dashboard.published,
|
||||
"changed_by_name": dashboard.changed_by_name,
|
||||
"changed_on": dashboard.changed_on,
|
||||
"changed_on_humanized": dashboard.changed_on_humanized,
|
||||
"created_by_name": dashboard.created_by_name,
|
||||
"created_on": dashboard.created_on,
|
||||
"created_on_humanized": dashboard.created_on_humanized,
|
||||
"tags": dashboard.tags,
|
||||
"owners": dashboard.owners,
|
||||
"charts": [],
|
||||
}
|
||||
mock_list.return_value = ([dashboard], 1)
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListDashboardsRequest(
|
||||
select_columns=["id", "dashboard_title", "uuid", "slug"],
|
||||
page=1,
|
||||
page_size=10,
|
||||
)
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
dashboards = result.data.dashboards
|
||||
assert len(dashboards) == 1
|
||||
assert dashboards[0].uuid == "test-custom-uuid-123"
|
||||
assert dashboards[0].slug == "custom-dashboard"
|
||||
|
||||
# Verify custom columns include UUID and slug
|
||||
assert "uuid" in result.data.columns_requested
|
||||
assert "slug" in result.data.columns_requested
|
||||
assert "uuid" in result.data.columns_loaded
|
||||
assert "slug" in result.data.columns_loaded
|
||||
|
||||
|
||||
class TestDashboardSortableColumns:
|
||||
"""Test sortable columns configuration for dashboard tools."""
|
||||
|
||||
def test_dashboard_sortable_columns_definition(self):
|
||||
"""Test that dashboard sortable columns are properly defined."""
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import (
|
||||
SORTABLE_DASHBOARD_COLUMNS,
|
||||
)
|
||||
|
||||
assert SORTABLE_DASHBOARD_COLUMNS == [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
# Ensure no computed properties are included
|
||||
assert "changed_on_delta_humanized" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
assert "changed_by_name" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
assert "uuid" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_with_valid_order_column(self, mock_list, mcp_server):
|
||||
"""Test list_dashboards with valid order column."""
|
||||
mock_list.return_value = ([], 0)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Test with valid sortable column
|
||||
request = ListDashboardsRequest(
|
||||
order_column="dashboard_title", order_direction="desc"
|
||||
)
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
# Verify the DAO was called with the correct order column
|
||||
mock_list.assert_called_once()
|
||||
call_args = mock_list.call_args[1]
|
||||
assert call_args["order_column"] == "dashboard_title"
|
||||
assert call_args["order_direction"] == "desc"
|
||||
|
||||
# Verify the result
|
||||
assert result.data.count == 0
|
||||
assert result.data.dashboards == []
|
||||
|
||||
def test_sortable_columns_in_docstring(self):
|
||||
"""Test that sortable columns are documented in tool docstring."""
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import (
|
||||
list_dashboards,
|
||||
SORTABLE_DASHBOARD_COLUMNS,
|
||||
)
|
||||
|
||||
# Check list_dashboards docstring (stored in description after @mcp.tool)
|
||||
assert hasattr(list_dashboards, "description")
|
||||
assert "Sortable columns for order_column:" in list_dashboards.description
|
||||
for col in SORTABLE_DASHBOARD_COLUMNS:
|
||||
assert col in list_dashboards.description
|
||||
Reference in New Issue
Block a user