This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 633355a  feat: export charts as ZIP files (#11349)
633355a is described below

commit 633355ab000823a0a8e1f8737c91db036517eaec
Author: Beto Dealmeida <[email protected]>
AuthorDate: Thu Oct 22 12:06:58 2020 -0700

    feat: export charts as ZIP files (#11349)
    
    * Export datasets as ZIP files
    
    * Add logging when failing to parse extra
    
    * Export charts as Zip file
    
    * Fix lint
---
 superset/charts/api.py             |  66 +++++++++++++++++++++++-
 superset/charts/commands/export.py |  83 ++++++++++++++++++++++++++++++
 superset/charts/schemas.py         |   1 +
 superset/datasets/api.py           |   2 +-
 tests/charts/api_tests.py          |  43 ++++++++++++++++
 tests/charts/commands_tests.py     | 101 +++++++++++++++++++++++++++++++++++++
 6 files changed, 294 insertions(+), 2 deletions(-)

diff --git a/superset/charts/api.py b/superset/charts/api.py
index cdf633e..3ef27d0 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -16,10 +16,13 @@
 # under the License.
 import json
 import logging
+from datetime import datetime
+from io import BytesIO
 from typing import Any, Dict
+from zipfile import ZipFile
 
 import simplejson
-from flask import g, make_response, redirect, request, Response, url_for
+from flask import g, make_response, redirect, request, Response, send_file, 
url_for
 from flask_appbuilder.api import expose, protect, rison, safe
 from flask_appbuilder.models.sqla.interface import SQLAInterface
 from flask_babel import gettext as _, ngettext
@@ -40,6 +43,7 @@ from superset.charts.commands.exceptions import (
     ChartNotFoundError,
     ChartUpdateFailedError,
 )
+from superset.charts.commands.export import ExportChartsCommand
 from superset.charts.commands.update import UpdateChartCommand
 from superset.charts.filters import ChartAllTextFilter, ChartFavoriteFilter, 
ChartFilter
 from superset.charts.schemas import (
@@ -48,6 +52,7 @@ from superset.charts.schemas import (
     ChartPostSchema,
     ChartPutSchema,
     get_delete_ids_schema,
+    get_export_ids_schema,
     openapi_spec_methods_override,
     screenshot_query_schema,
     thumbnail_query_schema,
@@ -709,3 +714,62 @@ class ChartRestApi(BaseSupersetModelRestApi):
         return Response(
             FileWrapper(screenshot), mimetype="image/png", 
direct_passthrough=True
         )
+
+    @expose("/export/", methods=["GET"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @rison(get_export_ids_schema)
+    def export(self, **kwargs: Any) -> Response:
+        """Export charts
+        ---
+        get:
+          description: >-
+            Exports multiple charts and downloads them as YAML files
+          parameters:
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  type: array
+                  items:
+                    type: integer
+          responses:
+            200:
+              description: A zip file with chart(s), dataset(s) and 
database(s) as YAML
+              content:
+                application/zip:
+                  schema:
+                    type: string
+                    format: binary
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        requested_ids = kwargs["rison"]
+        timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
+        root = f"chart_export_{timestamp}"
+        filename = f"{root}.zip"
+
+        buf = BytesIO()
+        with ZipFile(buf, "w") as bundle:
+            try:
+                for file_name, file_content in 
ExportChartsCommand(requested_ids).run():
+                    with bundle.open(f"{root}/{file_name}", "w") as fp:
+                        fp.write(file_content.encode())
+            except ChartNotFoundError:
+                return self.response_404()
+        buf.seek(0)
+
+        return send_file(
+            buf,
+            mimetype="application/zip",
+            as_attachment=True,
+            attachment_filename=filename,
+        )
diff --git a/superset/charts/commands/export.py 
b/superset/charts/commands/export.py
new file mode 100644
index 0000000..00e0fd4
--- /dev/null
+++ b/superset/charts/commands/export.py
@@ -0,0 +1,83 @@
+# 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.
+# isort:skip_file
+
+import json
+from typing import Iterator, List, Tuple
+
+import yaml
+
+from superset.commands.base import BaseCommand
+from superset.charts.commands.exceptions import ChartNotFoundError
+from superset.charts.dao import ChartDAO
+from superset.datasets.commands.export import ExportDatasetsCommand
+from superset.utils.dict_import_export import IMPORT_EXPORT_VERSION, sanitize
+from superset.models.slice import Slice
+
+
+# keys present in the standard export that are not needed
+REMOVE_KEYS = ["datasource_type", "datasource_name"]
+
+
+class ExportChartsCommand(BaseCommand):
+    def __init__(self, chart_ids: List[int]):
+        self.chart_ids = chart_ids
+
+        # this will be set when calling validate()
+        self._models: List[Slice] = []
+
+    @staticmethod
+    def export_chart(chart: Slice) -> Iterator[Tuple[str, str]]:
+        chart_slug = sanitize(chart.slice_name)
+        file_name = f"charts/{chart_slug}.yaml"
+
+        payload = chart.export_to_dict(
+            recursive=False,
+            include_parent_ref=False,
+            include_defaults=True,
+            export_uuids=True,
+        )
+        # TODO (betodealmeida): move this logic to export_to_dict once this
+        # becomes the default export endpoint
+        for key in REMOVE_KEYS:
+            del payload[key]
+        if "params" in payload:
+            try:
+                payload["params"] = json.loads(payload["params"])
+            except json.decoder.JSONDecodeError:
+                pass
+
+        payload["version"] = IMPORT_EXPORT_VERSION
+        if chart.table:
+            payload["dataset_uuid"] = str(chart.table.uuid)
+
+        file_content = yaml.safe_dump(payload, sort_keys=False)
+        yield file_name, file_content
+
+        if chart.table:
+            yield from ExportDatasetsCommand([chart.table.id]).run()
+
+    def run(self) -> Iterator[Tuple[str, str]]:
+        self.validate()
+
+        for chart in self._models:
+            yield from self.export_chart(chart)
+
+    def validate(self) -> None:
+        self._models = ChartDAO.find_by_ids(self.chart_ids)
+        if len(self._models) != len(self.chart_ids):
+            raise ChartNotFoundError()
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 58012a8..f54621c 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -45,6 +45,7 @@ screenshot_query_schema = {
         "thumb_size": width_height_schema,
     },
 }
+get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
 
 #
 # Column schema descriptions
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index 2ed051f..fff69bf 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -347,7 +347,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
     @statsd_metrics
     @rison(get_export_ids_schema)
     def export(self, **kwargs: Any) -> Response:
-        """Export dashboards
+        """Export datasets
         ---
         get:
           description: >-
diff --git a/tests/charts/api_tests.py b/tests/charts/api_tests.py
index c7023a8..4d3a67e 100644
--- a/tests/charts/api_tests.py
+++ b/tests/charts/api_tests.py
@@ -19,7 +19,9 @@
 import json
 from typing import List, Optional
 from datetime import datetime
+from io import BytesIO
 from unittest import mock
+from zipfile import is_zipfile
 
 import humanize
 import prison
@@ -1073,3 +1075,44 @@ class TestChartApi(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         result = response_payload["result"][0]["query"]
         if get_example_database().backend != "presto":
             assert "('boy' = 'boy')" in result
+
+    def test_export_chart(self):
+        """
+        Chart API: Test export dataset
+        """
+        example_chart = db.session.query(Slice).all()[0]
+        argument = [example_chart.id]
+        uri = f"api/v1/chart/export/?q={prison.dumps(argument)}"
+
+        self.login(username="admin")
+        rv = self.get_assert_metric(uri, "export")
+
+        assert rv.status_code == 200
+
+        buf = BytesIO(rv.data)
+        assert is_zipfile(buf)
+
+    def test_export_chart_not_found(self):
+        """
+        Dataset API: Test export dataset not found
+        """
+        # Just one does not exist and we get 404
+        argument = [-1, 1]
+        uri = f"api/v1/chart/export/?q={prison.dumps(argument)}"
+        self.login(username="admin")
+        rv = self.get_assert_metric(uri, "export")
+
+        assert rv.status_code == 404
+
+    def test_export_chart_gamma(self):
+        """
+        Dataset API: Test export dataset has gamma
+        """
+        example_chart = db.session.query(Slice).all()[0]
+        argument = [example_chart.id]
+        uri = f"api/v1/chart/export/?q={prison.dumps(argument)}"
+
+        self.login(username="gamma")
+        rv = self.client.get(uri)
+
+        assert rv.status_code == 404
diff --git a/tests/charts/commands_tests.py b/tests/charts/commands_tests.py
new file mode 100644
index 0000000..6923757
--- /dev/null
+++ b/tests/charts/commands_tests.py
@@ -0,0 +1,101 @@
+# 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 unittest.mock import patch
+
+import yaml
+
+from superset import db, security_manager
+from superset.charts.commands.exceptions import ChartNotFoundError
+from superset.charts.commands.export import ExportChartsCommand
+from superset.models.slice import Slice
+from tests.base_tests import SupersetTestCase
+
+
+class TestExportChartsCommand(SupersetTestCase):
+    @patch("superset.security.manager.g")
+    def test_export_chart_command(self, mock_g):
+        mock_g.user = security_manager.find_user("admin")
+
+        example_chart = db.session.query(Slice).all()[0]
+        command = ExportChartsCommand(chart_ids=[example_chart.id])
+        contents = dict(command.run())
+
+        expected = [
+            "charts/energy_sankey.yaml",
+            "datasets/examples/energy_usage.yaml",
+            "databases/examples.yaml",
+        ]
+        assert expected == list(contents.keys())
+
+        metadata = yaml.safe_load(contents["charts/energy_sankey.yaml"])
+        assert metadata == {
+            "slice_name": "Energy Sankey",
+            "viz_type": "sankey",
+            "params": {
+                "collapsed_fieldsets": "",
+                "groupby": ["source", "target",],
+                "metric": "sum__value",
+                "row_limit": "5000",
+                "slice_name": "Energy Sankey",
+                "viz_type": "sankey",
+            },
+            "cache_timeout": None,
+            "dataset_uuid": str(example_chart.table.uuid),
+            "uuid": str(example_chart.uuid),
+            "version": "1.0.0",
+        }
+
+    @patch("superset.security.manager.g")
+    def test_export_chart_command_no_access(self, mock_g):
+        """Test that users can't export datasets they don't have access to"""
+        mock_g.user = security_manager.find_user("gamma")
+
+        example_chart = db.session.query(Slice).all()[0]
+        command = ExportChartsCommand(chart_ids=[example_chart.id])
+        contents = command.run()
+        with self.assertRaises(ChartNotFoundError):
+            next(contents)
+
+    @patch("superset.security.manager.g")
+    def test_export_chart_command_invalid_dataset(self, mock_g):
+        """Test that an error is raised when exporting an invalid dataset"""
+        mock_g.user = security_manager.find_user("admin")
+        command = ExportChartsCommand(chart_ids=[-1])
+        contents = command.run()
+        with self.assertRaises(ChartNotFoundError):
+            next(contents)
+
+    @patch("superset.security.manager.g")
+    def test_export_chart_command_key_order(self, mock_g):
+        """Test that they keys in the YAML have the same order as 
export_fields"""
+        mock_g.user = security_manager.find_user("admin")
+
+        example_chart = db.session.query(Slice).all()[0]
+        command = ExportChartsCommand(chart_ids=[example_chart.id])
+        contents = dict(command.run())
+
+        metadata = yaml.safe_load(contents["charts/energy_sankey.yaml"])
+        assert list(metadata.keys()) == [
+            "slice_name",
+            "viz_type",
+            "params",
+            "cache_timeout",
+            "uuid",
+            "version",
+            "dataset_uuid",
+        ]

Reply via email to