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 21f722c  feat: add endpoint to export saved queries using new format 
(#11447)
21f722c is described below

commit 21f722c9bc912b539f952f582e7ec8374d1d9d71
Author: Beto Dealmeida <[email protected]>
AuthorDate: Fri Oct 30 08:32:16 2020 -0700

    feat: add endpoint to export saved queries using new format (#11447)
    
    * Add UUID to saved_query
    
    * Reuse function from previous migration
    
    * Point to new head
    
    * feat: add backend to export saved queries using new format
---
 superset/charts/api.py                            |   5 +-
 superset/databases/api.py                         |   5 +-
 superset/datasets/api.py                          |   7 +-
 superset/models/sql_lab.py                        |   1 -
 superset/queries/saved_queries/api.py             |  68 +++++++++++++-
 superset/queries/saved_queries/commands/export.py |  92 ++++++++++++++++++
 superset/queries/saved_queries/schemas.py         |   1 +
 tests/queries/saved_queries/api_tests.py          |  49 ++++++++++
 tests/queries/saved_queries/commands_tests.py     | 109 ++++++++++++++++++++++
 9 files changed, 326 insertions(+), 11 deletions(-)

diff --git a/superset/charts/api.py b/superset/charts/api.py
index 6ebe3cb..3ab4e4a 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -175,6 +175,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
     apispec_parameter_schemas = {
         "screenshot_query_schema": screenshot_query_schema,
         "get_delete_ids_schema": get_delete_ids_schema,
+        "get_export_ids_schema": get_export_ids_schema,
     }
     """ Add extra schemas to the OpenAPI components schema section """
     openapi_spec_methods = openapi_spec_methods_override
@@ -733,9 +734,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
             content:
               application/json:
                 schema:
-                  type: array
-                  items:
-                    type: integer
+                  $ref: '#/components/schemas/get_export_ids_schema'
           responses:
             200:
               description: A zip file with chart(s), dataset(s) and 
database(s) as YAML
diff --git a/superset/databases/api.py b/superset/databases/api.py
index 6667795..dc54c6b 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -167,6 +167,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
 
     apispec_parameter_schemas = {
         "database_schemas_query_schema": database_schemas_query_schema,
+        "get_export_ids_schema": get_export_ids_schema,
     }
     openapi_spec_tag = "Database"
     openapi_spec_component_schemas = (
@@ -682,9 +683,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
             content:
               application/json:
                 schema:
-                  type: array
-                  items:
-                    type: integer
+                  $ref: '#/components/schemas/get_export_ids_schema'
           responses:
             200:
               description: A zip file with database(s) and dataset(s) as YAML
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index 19c85d0..17ac891 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -166,6 +166,9 @@ class DatasetRestApi(BaseSupersetModelRestApi):
     allowed_rel_fields = {"database", "owners"}
     allowed_distinct_fields = {"schema"}
 
+    apispec_parameter_schemas = {
+        "get_export_ids_schema": get_export_ids_schema,
+    }
     openapi_spec_component_schemas = (DatasetRelatedObjectsResponse,)
 
     @expose("/", methods=["POST"])
@@ -360,9 +363,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
             content:
               application/json:
                 schema:
-                  type: array
-                  items:
-                    type: integer
+                  $ref: '#/components/schemas/get_export_ids_schema'
           responses:
             200:
               description: Dataset export
diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py
index 6c805b2..aeec9c2 100644
--- a/superset/models/sql_lab.py
+++ b/superset/models/sql_lab.py
@@ -188,7 +188,6 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin, 
ImportMixin):
 
     export_parent = "database"
     export_fields = [
-        "db_id",
         "schema",
         "label",
         "description",
diff --git a/superset/queries/saved_queries/api.py 
b/superset/queries/saved_queries/api.py
index b37fc7d..cb57897 100644
--- a/superset/queries/saved_queries/api.py
+++ b/superset/queries/saved_queries/api.py
@@ -15,9 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 import logging
+from datetime import datetime
+from io import BytesIO
 from typing import Any
+from zipfile import ZipFile
 
-from flask import g, Response
+from flask import g, Response, send_file
 from flask_appbuilder.api import expose, protect, rison, safe
 from flask_appbuilder.models.sqla.interface import SQLAInterface
 from flask_babel import ngettext
@@ -32,6 +35,7 @@ from superset.queries.saved_queries.commands.exceptions 
import (
     SavedQueryBulkDeleteFailedError,
     SavedQueryNotFoundError,
 )
+from superset.queries.saved_queries.commands.export import 
ExportSavedQueriesCommand
 from superset.queries.saved_queries.filters import (
     SavedQueryAllTextFilter,
     SavedQueryFavoriteFilter,
@@ -39,6 +43,7 @@ from superset.queries.saved_queries.filters import (
 )
 from superset.queries.saved_queries.schemas import (
     get_delete_ids_schema,
+    get_export_ids_schema,
     openapi_spec_methods_override,
 )
 from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics
@@ -50,6 +55,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
     datamodel = SQLAInterface(SavedQuery)
 
     include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
+        RouteMethod.EXPORT,
         RouteMethod.RELATED,
         RouteMethod.DISTINCT,
         "bulk_delete",  # not using RouteMethod since locally defined
@@ -114,6 +120,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
 
     apispec_parameter_schemas = {
         "get_delete_ids_schema": get_delete_ids_schema,
+        "get_export_ids_schema": get_export_ids_schema,
     }
     openapi_spec_tag = "Queries"
     openapi_spec_methods = openapi_spec_methods_override
@@ -183,3 +190,62 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
             return self.response_404()
         except SavedQueryBulkDeleteFailedError as ex:
             return self.response_422(message=str(ex))
+
+    @expose("/export/", methods=["GET"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @rison(get_export_ids_schema)
+    def export(self, **kwargs: Any) -> Response:
+        """Export saved queries
+        ---
+        get:
+          description: >-
+            Exports multiple saved queries and downloads them as YAML files
+          parameters:
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/get_export_ids_schema'
+          responses:
+            200:
+              description: A zip file with saved query(ies) 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"saved_query_export_{timestamp}"
+        filename = f"{root}.zip"
+
+        buf = BytesIO()
+        with ZipFile(buf, "w") as bundle:
+            try:
+                for file_name, file_content in ExportSavedQueriesCommand(
+                    requested_ids
+                ).run():
+                    with bundle.open(f"{root}/{file_name}", "w") as fp:
+                        fp.write(file_content.encode())
+            except SavedQueryNotFoundError:
+                return self.response_404()
+        buf.seek(0)
+
+        return send_file(
+            buf,
+            mimetype="application/zip",
+            as_attachment=True,
+            attachment_filename=filename,
+        )
diff --git a/superset/queries/saved_queries/commands/export.py 
b/superset/queries/saved_queries/commands/export.py
new file mode 100644
index 0000000..44c0c1f
--- /dev/null
+++ b/superset/queries/saved_queries/commands/export.py
@@ -0,0 +1,92 @@
+# 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
+import logging
+from typing import Iterator, List, Tuple
+
+import yaml
+
+from superset.commands.base import BaseCommand
+from superset.queries.saved_queries.commands.exceptions import 
SavedQueryNotFoundError
+from superset.queries.saved_queries.dao import SavedQueryDAO
+from superset.utils.dict_import_export import IMPORT_EXPORT_VERSION, sanitize
+from superset.models.sql_lab import SavedQuery
+
+logger = logging.getLogger(__name__)
+
+
+class ExportSavedQueriesCommand(BaseCommand):
+    def __init__(self, query_ids: List[int]):
+        self.query_ids = query_ids
+
+        # this will be set when calling validate()
+        self._models: List[SavedQuery] = []
+
+    @staticmethod
+    def export_saved_query(query: SavedQuery) -> Iterator[Tuple[str, str]]:
+        # build filename based on database, optional schema, and label
+        database_slug = sanitize(query.database.database_name)
+        schema_slug = sanitize(query.schema)
+        query_slug = sanitize(query.label) or str(query.uuid)
+        file_name = f"queries/{database_slug}/{schema_slug}/{query_slug}.yaml"
+
+        payload = query.export_to_dict(
+            recursive=False,
+            include_parent_ref=False,
+            include_defaults=True,
+            export_uuids=True,
+        )
+        payload["version"] = IMPORT_EXPORT_VERSION
+        payload["database_uuid"] = str(query.database.uuid)
+
+        file_content = yaml.safe_dump(payload, sort_keys=False)
+        yield file_name, file_content
+
+        # include database as well
+        file_name = f"databases/{database_slug}.yaml"
+
+        payload = query.database.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
+        if "extra" in payload:
+            try:
+                payload["extra"] = json.loads(payload["extra"])
+            except json.decoder.JSONDecodeError:
+                logger.info("Unable to decode `extra` field: %s", 
payload["extra"])
+
+        payload["version"] = IMPORT_EXPORT_VERSION
+
+        file_content = yaml.safe_dump(payload, sort_keys=False)
+        yield file_name, file_content
+
+    def run(self) -> Iterator[Tuple[str, str]]:
+        self.validate()
+
+        for query in self._models:
+            yield from self.export_saved_query(query)
+
+    def validate(self) -> None:
+        self._models = SavedQueryDAO.find_by_ids(self.query_ids)
+        if len(self._models) != len(self.query_ids):
+            raise SavedQueryNotFoundError()
diff --git a/superset/queries/saved_queries/schemas.py 
b/superset/queries/saved_queries/schemas.py
index d875e1a..afc9075 100644
--- a/superset/queries/saved_queries/schemas.py
+++ b/superset/queries/saved_queries/schemas.py
@@ -31,3 +31,4 @@ openapi_spec_methods_override = {
 }
 
 get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
+get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
diff --git a/tests/queries/saved_queries/api_tests.py 
b/tests/queries/saved_queries/api_tests.py
index 0b7df82..ce55f24 100644
--- a/tests/queries/saved_queries/api_tests.py
+++ b/tests/queries/saved_queries/api_tests.py
@@ -17,7 +17,9 @@
 # isort:skip_file
 """Unit tests for Superset"""
 import json
+from io import BytesIO
 from typing import Optional
+from zipfile import is_zipfile
 
 import pytest
 import prison
@@ -680,3 +682,50 @@ class TestSavedQueryApi(SupersetTestCase):
         uri = f"api/v1/saved_query/?q={prison.dumps(saved_query_ids)}"
         rv = self.delete_assert_metric(uri, "bulk_delete")
         assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_saved_queries")
+    def test_export(self):
+        """
+        Saved Query API: Test export
+        """
+        admin = self.get_user("admin")
+        sample_query = (
+            db.session.query(SavedQuery).filter(SavedQuery.created_by == 
admin).first()
+        )
+
+        self.login(username="admin")
+        argument = [sample_query.id]
+        uri = f"api/v1/saved_query/export/?q={prison.dumps(argument)}"
+        rv = self.client.get(uri)
+        assert rv.status_code == 200
+        buf = BytesIO(rv.data)
+        assert is_zipfile(buf)
+
+    @pytest.mark.usefixtures("create_saved_queries")
+    def test_export_not_found(self):
+        """
+        Saved Query API: Test export
+        """
+        max_id = db.session.query(func.max(SavedQuery.id)).scalar()
+
+        self.login(username="admin")
+        argument = [max_id + 1, max_id + 2]
+        uri = f"api/v1/saved_query/export/?q={prison.dumps(argument)}"
+        rv = self.client.get(uri)
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_saved_queries")
+    def test_export_not_allowed(self):
+        """
+        Saved Query API: Test export
+        """
+        admin = self.get_user("admin")
+        sample_query = (
+            db.session.query(SavedQuery).filter(SavedQuery.created_by == 
admin).first()
+        )
+
+        self.login(username="gamma")
+        argument = [sample_query.id]
+        uri = f"api/v1/saved_query/export/?q={prison.dumps(argument)}"
+        rv = self.client.get(uri)
+        assert rv.status_code == 404
diff --git a/tests/queries/saved_queries/commands_tests.py 
b/tests/queries/saved_queries/commands_tests.py
new file mode 100644
index 0000000..acd81af
--- /dev/null
+++ b/tests/queries/saved_queries/commands_tests.py
@@ -0,0 +1,109 @@
+# 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.models.sql_lab import SavedQuery
+from superset.queries.saved_queries.commands.exceptions import 
SavedQueryNotFoundError
+from superset.queries.saved_queries.commands.export import 
ExportSavedQueriesCommand
+from superset.utils.core import get_example_database
+from tests.base_tests import SupersetTestCase
+
+
+class TestExportSavedQueriesCommand(SupersetTestCase):
+    def setUp(self):
+        self.example_database = get_example_database()
+        self.example_query = SavedQuery(
+            database=self.example_database,
+            created_by=self.get_user("admin"),
+            sql="SELECT 42",
+            label="The answer",
+            schema="schema1",
+            description="Answer to the Ultimate Question of Life, the 
Universe, and Everything",
+        )
+        db.session.add(self.example_query)
+        db.session.commit()
+
+    def tearDown(self):
+        db.session.delete(self.example_query)
+        db.session.commit()
+
+    @patch("superset.queries.saved_queries.filters.g")
+    def test_export_query_command(self, mock_g):
+        mock_g.user = security_manager.find_user("admin")
+
+        command = ExportSavedQueriesCommand(query_ids=[self.example_query.id])
+        contents = dict(command.run())
+
+        expected = [
+            "queries/examples/schema1/the_answer.yaml",
+            "databases/examples.yaml",
+        ]
+        assert expected == list(contents.keys())
+
+        metadata = 
yaml.safe_load(contents["queries/examples/schema1/the_answer.yaml"])
+        assert metadata == {
+            "schema": "schema1",
+            "label": "The answer",
+            "description": "Answer to the Ultimate Question of Life, the 
Universe, and Everything",
+            "sql": "SELECT 42",
+            "uuid": str(self.example_query.uuid),
+            "version": "1.0.0",
+            "database_uuid": str(self.example_database.uuid),
+        }
+
+    @patch("superset.queries.saved_queries.filters.g")
+    def test_export_query_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")
+
+        command = ExportSavedQueriesCommand(query_ids=[self.example_query.id])
+        contents = command.run()
+        with self.assertRaises(SavedQueryNotFoundError):
+            next(contents)
+
+    @patch("superset.queries.saved_queries.filters.g")
+    def test_export_query_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 = ExportSavedQueriesCommand(query_ids=[-1])
+        contents = command.run()
+        with self.assertRaises(SavedQueryNotFoundError):
+            next(contents)
+
+    @patch("superset.queries.saved_queries.filters.g")
+    def test_export_query_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")
+
+        command = ExportSavedQueriesCommand(query_ids=[self.example_query.id])
+        contents = dict(command.run())
+
+        metadata = 
yaml.safe_load(contents["queries/examples/schema1/the_answer.yaml"])
+        assert list(metadata.keys()) == [
+            "schema",
+            "label",
+            "description",
+            "sql",
+            "uuid",
+            "version",
+            "database_uuid",
+        ]

Reply via email to