| # 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. |
| # pylint: disable=no-self-use, invalid-name |
| |
| import itertools |
| import json |
| from unittest.mock import MagicMock, patch |
| |
| import pytest |
| import yaml |
| from werkzeug.utils import secure_filename |
| |
| from superset import db, security_manager |
| from superset.commands.exceptions import CommandInvalidError |
| from superset.commands.importers.exceptions import IncorrectVersionError |
| from superset.connectors.sqla.models import SqlaTable |
| from superset.dashboards.commands.exceptions import DashboardNotFoundError |
| from superset.dashboards.commands.export import ( |
| append_charts, |
| ExportDashboardsCommand, |
| get_default_position, |
| ) |
| from superset.dashboards.commands.importers import v0, v1 |
| from superset.models.core import Database |
| from superset.models.dashboard import Dashboard |
| from superset.models.slice import Slice |
| from tests.base_tests import SupersetTestCase |
| from tests.fixtures.importexport import ( |
| chart_config, |
| dashboard_config, |
| dashboard_export, |
| dashboard_metadata_config, |
| database_config, |
| dataset_config, |
| dataset_metadata_config, |
| ) |
| from tests.fixtures.world_bank_dashboard import load_world_bank_dashboard_with_slices |
| |
| |
| class TestExportDashboardsCommand(SupersetTestCase): |
| @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") |
| @patch("superset.security.manager.g") |
| @patch("superset.views.base.g") |
| def test_export_dashboard_command(self, mock_g1, mock_g2): |
| mock_g1.user = security_manager.find_user("admin") |
| mock_g2.user = security_manager.find_user("admin") |
| |
| example_dashboard = ( |
| db.session.query(Dashboard).filter_by(slug="world_health").one() |
| ) |
| command = ExportDashboardsCommand([example_dashboard.id]) |
| contents = dict(command.run()) |
| |
| expected_paths = { |
| "metadata.yaml", |
| "dashboards/World_Banks_Data.yaml", |
| "datasets/examples/wb_health_population.yaml", |
| "databases/examples.yaml", |
| } |
| for chart in example_dashboard.slices: |
| chart_slug = secure_filename(chart.slice_name) |
| expected_paths.add(f"charts/{chart_slug}_{chart.id}.yaml") |
| assert expected_paths == set(contents.keys()) |
| |
| metadata = yaml.safe_load(contents["dashboards/World_Banks_Data.yaml"]) |
| |
| # remove chart UUIDs from metadata so we can compare |
| for chart_info in metadata["position"].values(): |
| if isinstance(chart_info, dict) and "uuid" in chart_info.get("meta", {}): |
| del chart_info["meta"]["chartId"] |
| del chart_info["meta"]["uuid"] |
| |
| assert metadata == { |
| "dashboard_title": "World Bank's Data", |
| "description": None, |
| "css": None, |
| "slug": "world_health", |
| "uuid": str(example_dashboard.uuid), |
| "position": { |
| "CHART-36bfc934": { |
| "children": [], |
| "id": "CHART-36bfc934", |
| "meta": {"height": 25, "sliceName": "Region Filter", "width": 2}, |
| "type": "CHART", |
| }, |
| "CHART-37982887": { |
| "children": [], |
| "id": "CHART-37982887", |
| "meta": { |
| "height": 25, |
| "sliceName": "World's Population", |
| "width": 2, |
| }, |
| "type": "CHART", |
| }, |
| "CHART-17e0f8d8": { |
| "children": [], |
| "id": "CHART-17e0f8d8", |
| "meta": { |
| "height": 92, |
| "sliceName": "Most Populated Countries", |
| "width": 3, |
| }, |
| "type": "CHART", |
| }, |
| "CHART-2ee52f30": { |
| "children": [], |
| "id": "CHART-2ee52f30", |
| "meta": {"height": 38, "sliceName": "Growth Rate", "width": 6}, |
| "type": "CHART", |
| }, |
| "CHART-2d5b6871": { |
| "children": [], |
| "id": "CHART-2d5b6871", |
| "meta": {"height": 52, "sliceName": "% Rural", "width": 7}, |
| "type": "CHART", |
| }, |
| "CHART-0fd0d252": { |
| "children": [], |
| "id": "CHART-0fd0d252", |
| "meta": { |
| "height": 50, |
| "sliceName": "Life Expectancy VS Rural %", |
| "width": 8, |
| }, |
| "type": "CHART", |
| }, |
| "CHART-97f4cb48": { |
| "children": [], |
| "id": "CHART-97f4cb48", |
| "meta": {"height": 38, "sliceName": "Rural Breakdown", "width": 3}, |
| "type": "CHART", |
| }, |
| "CHART-b5e05d6f": { |
| "children": [], |
| "id": "CHART-b5e05d6f", |
| "meta": { |
| "height": 50, |
| "sliceName": "World's Pop Growth", |
| "width": 4, |
| }, |
| "type": "CHART", |
| }, |
| "CHART-e76e9f5f": { |
| "children": [], |
| "id": "CHART-e76e9f5f", |
| "meta": {"height": 50, "sliceName": "Box plot", "width": 4}, |
| "type": "CHART", |
| }, |
| "CHART-a4808bba": { |
| "children": [], |
| "id": "CHART-a4808bba", |
| "meta": {"height": 50, "sliceName": "Treemap", "width": 8}, |
| "type": "CHART", |
| }, |
| "CHART-3nc0d8sk": { |
| "children": [], |
| "id": "CHART-3nc0d8sk", |
| "meta": {"height": 50, "sliceName": "Treemap", "width": 8}, |
| "type": "CHART", |
| }, |
| "COLUMN-071bbbad": { |
| "children": ["ROW-1e064e3c", "ROW-afdefba9"], |
| "id": "COLUMN-071bbbad", |
| "meta": {"background": "BACKGROUND_TRANSPARENT", "width": 9}, |
| "type": "COLUMN", |
| }, |
| "COLUMN-fe3914b8": { |
| "children": ["CHART-36bfc934", "CHART-37982887"], |
| "id": "COLUMN-fe3914b8", |
| "meta": {"background": "BACKGROUND_TRANSPARENT", "width": 2}, |
| "type": "COLUMN", |
| }, |
| "GRID_ID": { |
| "children": ["ROW-46632bc2", "ROW-3fa26c5d", "ROW-812b3f13"], |
| "id": "GRID_ID", |
| "type": "GRID", |
| }, |
| "HEADER_ID": { |
| "id": "HEADER_ID", |
| "meta": {"text": "World's Bank Data"}, |
| "type": "HEADER", |
| }, |
| "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"}, |
| "ROW-1e064e3c": { |
| "children": ["COLUMN-fe3914b8", "CHART-2d5b6871"], |
| "id": "ROW-1e064e3c", |
| "meta": {"background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| }, |
| "ROW-3fa26c5d": { |
| "children": ["CHART-b5e05d6f", "CHART-0fd0d252"], |
| "id": "ROW-3fa26c5d", |
| "meta": {"background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| }, |
| "ROW-46632bc2": { |
| "children": ["COLUMN-071bbbad", "CHART-17e0f8d8"], |
| "id": "ROW-46632bc2", |
| "meta": {"background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| }, |
| "ROW-812b3f13": { |
| "children": ["CHART-a4808bba", "CHART-e76e9f5f"], |
| "id": "ROW-812b3f13", |
| "meta": {"background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| }, |
| "ROW-afdefba9": { |
| "children": ["CHART-2ee52f30", "CHART-97f4cb48"], |
| "id": "ROW-afdefba9", |
| "meta": {"background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| }, |
| "DASHBOARD_VERSION_KEY": "v2", |
| }, |
| "metadata": {"mock_key": "mock_value"}, |
| "version": "1.0.0", |
| } |
| |
| @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") |
| @patch("superset.security.manager.g") |
| @patch("superset.views.base.g") |
| def test_export_dashboard_command_no_access(self, mock_g1, mock_g2): |
| """Test that users can't export datasets they don't have access to""" |
| mock_g1.user = security_manager.find_user("gamma") |
| mock_g2.user = security_manager.find_user("gamma") |
| |
| example_dashboard = ( |
| db.session.query(Dashboard).filter_by(slug="world_health").one() |
| ) |
| command = ExportDashboardsCommand([example_dashboard.id]) |
| contents = command.run() |
| with self.assertRaises(DashboardNotFoundError): |
| next(contents) |
| |
| @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") |
| @patch("superset.security.manager.g") |
| @patch("superset.views.base.g") |
| def test_export_dashboard_command_invalid_dataset(self, mock_g1, mock_g2): |
| """Test that an error is raised when exporting an invalid dataset""" |
| mock_g1.user = security_manager.find_user("admin") |
| mock_g2.user = security_manager.find_user("admin") |
| command = ExportDashboardsCommand([-1]) |
| contents = command.run() |
| with self.assertRaises(DashboardNotFoundError): |
| next(contents) |
| |
| @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") |
| @patch("superset.security.manager.g") |
| @patch("superset.views.base.g") |
| def test_export_dashboard_command_key_order(self, mock_g1, mock_g2): |
| """Test that they keys in the YAML have the same order as export_fields""" |
| mock_g1.user = security_manager.find_user("admin") |
| mock_g2.user = security_manager.find_user("admin") |
| |
| example_dashboard = ( |
| db.session.query(Dashboard).filter_by(slug="world_health").one() |
| ) |
| command = ExportDashboardsCommand([example_dashboard.id]) |
| contents = dict(command.run()) |
| |
| metadata = yaml.safe_load(contents["dashboards/World_Banks_Data.yaml"]) |
| assert list(metadata.keys()) == [ |
| "dashboard_title", |
| "description", |
| "css", |
| "slug", |
| "uuid", |
| "position", |
| "metadata", |
| "version", |
| ] |
| |
| @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") |
| @patch("superset.dashboards.commands.export.suffix") |
| def test_append_charts(self, mock_suffix): |
| """Test that oprhaned charts are added to the dashbaord position""" |
| # return deterministic IDs |
| mock_suffix.side_effect = (str(i) for i in itertools.count(1)) |
| |
| position = get_default_position("example") |
| chart_1 = db.session.query(Slice).filter_by(slice_name="Region Filter").one() |
| new_position = append_charts(position, {chart_1}) |
| assert new_position == { |
| "DASHBOARD_VERSION_KEY": "v2", |
| "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"}, |
| "GRID_ID": { |
| "children": ["ROW-N-2"], |
| "id": "GRID_ID", |
| "parents": ["ROOT_ID"], |
| "type": "GRID", |
| }, |
| "HEADER_ID": { |
| "id": "HEADER_ID", |
| "meta": {"text": "example"}, |
| "type": "HEADER", |
| }, |
| "ROW-N-2": { |
| "children": ["CHART-1"], |
| "id": "ROW-N-2", |
| "meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| "parents": ["ROOT_ID", "GRID_ID"], |
| }, |
| "CHART-1": { |
| "children": [], |
| "id": "CHART-1", |
| "meta": { |
| "chartId": chart_1.id, |
| "height": 50, |
| "sliceName": "Region Filter", |
| "uuid": str(chart_1.uuid), |
| "width": 4, |
| }, |
| "type": "CHART", |
| "parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"], |
| }, |
| } |
| |
| chart_2 = ( |
| db.session.query(Slice).filter_by(slice_name="World's Population").one() |
| ) |
| new_position = append_charts(new_position, {chart_2}) |
| assert new_position == { |
| "DASHBOARD_VERSION_KEY": "v2", |
| "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"}, |
| "GRID_ID": { |
| "children": ["ROW-N-2", "ROW-N-4"], |
| "id": "GRID_ID", |
| "parents": ["ROOT_ID"], |
| "type": "GRID", |
| }, |
| "HEADER_ID": { |
| "id": "HEADER_ID", |
| "meta": {"text": "example"}, |
| "type": "HEADER", |
| }, |
| "ROW-N-2": { |
| "children": ["CHART-1"], |
| "id": "ROW-N-2", |
| "meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| "parents": ["ROOT_ID", "GRID_ID"], |
| }, |
| "ROW-N-4": { |
| "children": ["CHART-3"], |
| "id": "ROW-N-4", |
| "meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"}, |
| "type": "ROW", |
| "parents": ["ROOT_ID", "GRID_ID"], |
| }, |
| "CHART-1": { |
| "children": [], |
| "id": "CHART-1", |
| "meta": { |
| "chartId": chart_1.id, |
| "height": 50, |
| "sliceName": "Region Filter", |
| "uuid": str(chart_1.uuid), |
| "width": 4, |
| }, |
| "type": "CHART", |
| "parents": ["ROOT_ID", "GRID_ID", "ROW-N-2"], |
| }, |
| "CHART-3": { |
| "children": [], |
| "id": "CHART-3", |
| "meta": { |
| "chartId": chart_2.id, |
| "height": 50, |
| "sliceName": "World's Population", |
| "uuid": str(chart_2.uuid), |
| "width": 4, |
| }, |
| "type": "CHART", |
| "parents": ["ROOT_ID", "GRID_ID", "ROW-N-4"], |
| }, |
| } |
| |
| position = {"DASHBOARD_VERSION_KEY": "v2"} |
| new_position = append_charts(position, [chart_1, chart_2]) |
| assert new_position == { |
| "CHART-5": { |
| "children": [], |
| "id": "CHART-5", |
| "meta": { |
| "chartId": chart_1.id, |
| "height": 50, |
| "sliceName": "Region Filter", |
| "uuid": str(chart_1.uuid), |
| "width": 4, |
| }, |
| "type": "CHART", |
| }, |
| "CHART-6": { |
| "children": [], |
| "id": "CHART-6", |
| "meta": { |
| "chartId": chart_2.id, |
| "height": 50, |
| "sliceName": "World's Population", |
| "uuid": str(chart_2.uuid), |
| "width": 4, |
| }, |
| "type": "CHART", |
| }, |
| "DASHBOARD_VERSION_KEY": "v2", |
| } |
| |
| |
| class TestImportDashboardsCommand(SupersetTestCase): |
| def test_import_v0_dashboard_cli_export(self): |
| num_dashboards = db.session.query(Dashboard).count() |
| num_charts = db.session.query(Slice).count() |
| num_datasets = db.session.query(SqlaTable).count() |
| num_databases = db.session.query(Database).count() |
| |
| contents = { |
| "20201119_181105.json": json.dumps(dashboard_export), |
| } |
| command = v0.ImportDashboardsCommand(contents) |
| command.run() |
| |
| new_num_dashboards = db.session.query(Dashboard).count() |
| new_num_charts = db.session.query(Slice).count() |
| new_num_datasets = db.session.query(SqlaTable).count() |
| new_num_databases = db.session.query(Database).count() |
| assert new_num_dashboards == num_dashboards + 1 |
| assert new_num_charts == num_charts + 1 |
| assert new_num_datasets == num_datasets + 1 |
| assert new_num_databases == num_databases |
| |
| dashboard = ( |
| db.session.query(Dashboard).filter_by(dashboard_title="Births 2").one() |
| ) |
| assert len(dashboard.slices) == 1 |
| chart = dashboard.slices[0] |
| assert chart.slice_name == "Number of California Births" |
| |
| dataset = chart.table |
| assert dataset.table_name == "birth_names_2" |
| |
| database = dataset.database |
| assert database.database_name == "examples" |
| |
| db.session.delete(dashboard) |
| db.session.delete(chart) |
| db.session.delete(dataset) |
| db.session.commit() |
| |
| def test_import_v1_dashboard(self): |
| """Test that we can import a dashboard""" |
| contents = { |
| "metadata.yaml": yaml.safe_dump(dashboard_metadata_config), |
| "databases/imported_database.yaml": yaml.safe_dump(database_config), |
| "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config), |
| "charts/imported_chart.yaml": yaml.safe_dump(chart_config), |
| "dashboards/imported_dashboard.yaml": yaml.safe_dump(dashboard_config), |
| } |
| command = v1.ImportDashboardsCommand(contents) |
| command.run() |
| |
| dashboard = ( |
| db.session.query(Dashboard).filter_by(uuid=dashboard_config["uuid"]).one() |
| ) |
| |
| assert len(dashboard.slices) == 1 |
| chart = dashboard.slices[0] |
| assert str(chart.uuid) == chart_config["uuid"] |
| new_chart_id = chart.id |
| |
| assert dashboard.dashboard_title == "Test dash" |
| assert dashboard.description is None |
| assert dashboard.css == "" |
| assert dashboard.slug is None |
| assert json.loads(dashboard.position_json) == { |
| "CHART-SVAlICPOSJ": { |
| "children": [], |
| "id": "CHART-SVAlICPOSJ", |
| "meta": { |
| "chartId": new_chart_id, |
| "height": 50, |
| "sliceName": "Number of California Births", |
| "uuid": "0c23747a-6528-4629-97bf-e4b78d3b9df1", |
| "width": 4, |
| }, |
| "parents": ["ROOT_ID", "GRID_ID", "ROW-dP_CHaK2q"], |
| "type": "CHART", |
| }, |
| "DASHBOARD_VERSION_KEY": "v2", |
| "GRID_ID": { |
| "children": ["ROW-dP_CHaK2q"], |
| "id": "GRID_ID", |
| "parents": ["ROOT_ID"], |
| "type": "GRID", |
| }, |
| "HEADER_ID": { |
| "id": "HEADER_ID", |
| "meta": {"text": "Test dash"}, |
| "type": "HEADER", |
| }, |
| "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"}, |
| "ROW-dP_CHaK2q": { |
| "children": ["CHART-SVAlICPOSJ"], |
| "id": "ROW-dP_CHaK2q", |
| "meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"}, |
| "parents": ["ROOT_ID", "GRID_ID"], |
| "type": "ROW", |
| }, |
| } |
| assert json.loads(dashboard.json_metadata) == { |
| "color_scheme": None, |
| "default_filters": "{}", |
| "expanded_slices": {str(new_chart_id): True}, |
| "filter_scopes": { |
| str(new_chart_id): { |
| "region": {"scope": ["ROOT_ID"], "immune": [new_chart_id]} |
| }, |
| }, |
| "import_time": 1604342885, |
| "refresh_frequency": 0, |
| "remote_id": 7, |
| "timed_refresh_immune_slices": [new_chart_id], |
| } |
| |
| dataset = chart.table |
| assert str(dataset.uuid) == dataset_config["uuid"] |
| |
| database = dataset.database |
| assert str(database.uuid) == database_config["uuid"] |
| |
| db.session.delete(dashboard) |
| db.session.delete(chart) |
| db.session.delete(dataset) |
| db.session.delete(database) |
| db.session.commit() |
| |
| def test_import_v1_dashboard_multiple(self): |
| """Test that a dashboard can be imported multiple times""" |
| num_dashboards = db.session.query(Dashboard).count() |
| |
| contents = { |
| "metadata.yaml": yaml.safe_dump(dashboard_metadata_config), |
| "databases/imported_database.yaml": yaml.safe_dump(database_config), |
| "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config), |
| "charts/imported_chart.yaml": yaml.safe_dump(chart_config), |
| "dashboards/imported_dashboard.yaml": yaml.safe_dump(dashboard_config), |
| } |
| command = v1.ImportDashboardsCommand(contents, overwrite=True) |
| command.run() |
| command.run() |
| |
| new_num_dashboards = db.session.query(Dashboard).count() |
| assert new_num_dashboards == num_dashboards + 1 |
| |
| dashboard = ( |
| db.session.query(Dashboard).filter_by(uuid=dashboard_config["uuid"]).one() |
| ) |
| chart = dashboard.slices[0] |
| dataset = chart.table |
| database = dataset.database |
| |
| db.session.delete(dashboard) |
| db.session.delete(chart) |
| db.session.delete(dataset) |
| db.session.delete(database) |
| db.session.commit() |
| |
| def test_import_v1_dashboard_validation(self): |
| """Test different validations applied when importing a dashboard""" |
| # metadata.yaml must be present |
| contents = { |
| "databases/imported_database.yaml": yaml.safe_dump(database_config), |
| "datasets/imported_dataset.yaml": yaml.safe_dump(dataset_config), |
| "charts/imported_chart.yaml": yaml.safe_dump(chart_config), |
| "dashboards/imported_dashboard.yaml": yaml.safe_dump(dashboard_config), |
| } |
| command = v1.ImportDashboardsCommand(contents) |
| with pytest.raises(IncorrectVersionError) as excinfo: |
| command.run() |
| assert str(excinfo.value) == "Missing metadata.yaml" |
| |
| # version should be 1.0.0 |
| contents["metadata.yaml"] = yaml.safe_dump( |
| { |
| "version": "2.0.0", |
| "type": "Database", |
| "timestamp": "2020-11-04T21:27:44.423819+00:00", |
| } |
| ) |
| command = v1.ImportDashboardsCommand(contents) |
| with pytest.raises(IncorrectVersionError) as excinfo: |
| command.run() |
| assert str(excinfo.value) == "Must be equal to 1.0.0." |
| |
| # type should be Database |
| contents["metadata.yaml"] = yaml.safe_dump(dataset_metadata_config) |
| command = v1.ImportDashboardsCommand(contents) |
| with pytest.raises(CommandInvalidError) as excinfo: |
| command.run() |
| assert str(excinfo.value) == "Error importing dashboard" |
| assert excinfo.value.normalized_messages() == { |
| "metadata.yaml": {"type": ["Must be equal to Dashboard."]} |
| } |
| |
| # must also validate datasets |
| broken_config = dataset_config.copy() |
| del broken_config["table_name"] |
| contents["metadata.yaml"] = yaml.safe_dump(dashboard_metadata_config) |
| contents["datasets/imported_dataset.yaml"] = yaml.safe_dump(broken_config) |
| command = v1.ImportDashboardsCommand(contents) |
| with pytest.raises(CommandInvalidError) as excinfo: |
| command.run() |
| assert str(excinfo.value) == "Error importing dashboard" |
| assert excinfo.value.normalized_messages() == { |
| "datasets/imported_dataset.yaml": { |
| "table_name": ["Missing data for required field."], |
| } |
| } |