Skip to content

Commit 4c52ecc

Browse files
authored
fix(Dashboard): Copying a Dashboard does not commit the transaction (#29776)
1 parent 06c9f33 commit 4c52ecc

File tree

4 files changed

+123
-2
lines changed

4 files changed

+123
-2
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
import logging
18+
from functools import partial
19+
from typing import Any
20+
21+
from superset import is_feature_enabled, security_manager
22+
from superset.commands.base import BaseCommand
23+
from superset.commands.dashboard.exceptions import (
24+
DashboardCopyError,
25+
DashboardForbiddenError,
26+
DashboardInvalidError,
27+
)
28+
from superset.daos.dashboard import DashboardDAO
29+
from superset.models.dashboard import Dashboard
30+
from superset.utils.decorators import on_error, transaction
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
class CopyDashboardCommand(BaseCommand):
36+
def __init__(self, original_dash: Dashboard, data: dict[str, Any]) -> None:
37+
self._original_dash = original_dash
38+
self._properties = data.copy()
39+
40+
@transaction(on_error=partial(on_error, reraise=DashboardCopyError))
41+
def run(self) -> Dashboard:
42+
self.validate()
43+
return DashboardDAO.copy_dashboard(self._original_dash, self._properties)
44+
45+
def validate(self) -> None:
46+
if not self._properties.get("dashboard_title") or not self._properties.get(
47+
"json_metadata"
48+
):
49+
raise DashboardInvalidError()
50+
if is_feature_enabled("DASHBOARD_RBAC") and not security_manager.is_owner(
51+
self._original_dash
52+
):
53+
raise DashboardForbiddenError()

superset/commands/dashboard/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,7 @@ class DashboardImportError(ImportFailedError):
7676

7777
class DashboardAccessDeniedError(ForbiddenError):
7878
message = _("You don't have access to this dashboard.")
79+
80+
81+
class DashboardCopyError(CommandInvalidError):
82+
message = _("Dashboard cannot be copied due to invalid parameters.")

superset/dashboards/api.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@
3434

3535
from superset import db, is_feature_enabled, thumbnail_cache
3636
from superset.charts.schemas import ChartEntityResponseSchema
37+
from superset.commands.dashboard.copy import CopyDashboardCommand
3738
from superset.commands.dashboard.create import CreateDashboardCommand
3839
from superset.commands.dashboard.delete import DeleteDashboardCommand
3940
from superset.commands.dashboard.exceptions import (
4041
DashboardAccessDeniedError,
42+
DashboardCopyError,
4143
DashboardCreateFailedError,
4244
DashboardDeleteFailedError,
4345
DashboardForbiddenError,
@@ -1606,9 +1608,11 @@ def copy_dash(self, original_dash: Dashboard) -> Response:
16061608
return self.response_400(message=error.messages)
16071609

16081610
try:
1609-
dash = DashboardDAO.copy_dashboard(original_dash, data)
1611+
dash = CopyDashboardCommand(original_dash, data).run()
16101612
except DashboardForbiddenError:
16111613
return self.response_403()
1614+
except DashboardCopyError:
1615+
return self.response_400()
16121616

16131617
return self.response(
16141618
200,

tests/integration_tests/dashboards/commands_tests.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
from werkzeug.utils import secure_filename
2323

2424
from superset import db, security_manager
25-
from superset.commands.dashboard.exceptions import DashboardNotFoundError
25+
from superset.commands.dashboard.copy import CopyDashboardCommand
26+
from superset.commands.dashboard.exceptions import (
27+
DashboardForbiddenError,
28+
DashboardInvalidError,
29+
DashboardNotFoundError,
30+
)
2631
from superset.commands.dashboard.export import (
2732
append_charts,
2833
ExportDashboardsCommand,
@@ -36,6 +41,7 @@
3641
from superset.models.dashboard import Dashboard
3742
from superset.models.slice import Slice
3843
from superset.utils import json
44+
from superset.utils.core import override_user
3945
from tests.integration_tests.base_tests import SupersetTestCase
4046
from tests.integration_tests.fixtures.importexport import (
4147
chart_config,
@@ -660,3 +666,57 @@ def test_import_v1_dashboard_validation(self):
660666
"table_name": ["Missing data for required field."],
661667
}
662668
}
669+
670+
671+
class TestCopyDashboardCommand(SupersetTestCase):
672+
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
673+
def test_copy_dashboard_command(self):
674+
"""Test that an admin user can copy a dashboard"""
675+
with self.client.application.test_request_context():
676+
example_dashboard = (
677+
db.session.query(Dashboard).filter_by(slug="world_health").one()
678+
)
679+
copy_data = {"dashboard_title": "Copied Dashboard", "json_metadata": "{}"}
680+
681+
with override_user(security_manager.find_user("admin")):
682+
command = CopyDashboardCommand(example_dashboard, copy_data)
683+
copied_dashboard = command.run()
684+
685+
assert copied_dashboard.dashboard_title == "Copied Dashboard"
686+
assert copied_dashboard.slug != example_dashboard.slug
687+
assert copied_dashboard.slices == example_dashboard.slices
688+
689+
db.session.delete(copied_dashboard)
690+
db.session.commit()
691+
692+
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
693+
def test_copy_dashboard_command_no_access(self):
694+
"""Test that a non-owner user cannot copy a dashboard if DASHBOARD_RBAC is enabled"""
695+
with self.client.application.test_request_context():
696+
example_dashboard = (
697+
db.session.query(Dashboard).filter_by(slug="world_health").one()
698+
)
699+
copy_data = {"dashboard_title": "Copied Dashboard", "json_metadata": "{}"}
700+
701+
with override_user(security_manager.find_user("gamma")):
702+
with patch(
703+
"superset.commands.dashboard.copy.is_feature_enabled",
704+
return_value=True,
705+
):
706+
command = CopyDashboardCommand(example_dashboard, copy_data)
707+
with self.assertRaises(DashboardForbiddenError):
708+
command.run()
709+
710+
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
711+
def test_copy_dashboard_command_invalid_data(self):
712+
"""Test that invalid data raises a DashboardInvalidError"""
713+
with self.client.application.test_request_context():
714+
example_dashboard = (
715+
db.session.query(Dashboard).filter_by(slug="world_health").one()
716+
)
717+
invalid_copy_data = {"dashboard_title": "", "json_metadata": "{}"}
718+
719+
with override_user(security_manager.find_user("admin")):
720+
command = CopyDashboardCommand(example_dashboard, invalid_copy_data)
721+
with self.assertRaises(DashboardInvalidError):
722+
command.run()

0 commit comments

Comments
 (0)