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

dpgaspar 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 c34df6b  [dashboard] Refactor API using SIP-35 (#9315)
c34df6b is described below

commit c34df6b7b356fc28e3c3a80a975ecdf2de7c0681
Author: Daniel Vaz Gaspar <[email protected]>
AuthorDate: Fri Mar 20 16:32:03 2020 +0000

    [dashboard] Refactor API using SIP-35 (#9315)
    
    * [dashboard] Refactor API using SIP-35
    
    * [dashboard] Fix, import
    
    * [dashboard] more tests
    
    * [dashboards] a misc of improvements
    
    * [charts] Fix, DAO and tests
    
    * [dashboards] small exceptions refactor
    
    * [dashboards] lint
    
    * [dashboards] Improves comments on base classes
    
    * [dashboards] lint
---
 superset/app.py                                    |   2 +-
 superset/commands/base.py                          |   3 +-
 superset/commands/exceptions.py                    |  32 +-
 .../commands/base.py => commands/utils.py}         |   7 +-
 superset/{commands/base.py => dao/__init__.py}     |  23 --
 superset/dao/base.py                               | 111 ++++++
 superset/{ => dao}/exceptions.py                   |  45 +--
 .../{commands/base.py => dashboards/__init__.py}   |  23 --
 superset/dashboards/api.py                         | 391 +++++++++++++++++++++
 .../base.py => dashboards/commands/__init__.py}    |  23 --
 .../commands/bulk_delete.py}                       |  42 +--
 .../{datasets => dashboards}/commands/create.py    |  50 +--
 .../{datasets => dashboards}/commands/delete.py    |  32 +-
 superset/dashboards/commands/exceptions.py         |  64 ++++
 .../{datasets => dashboards}/commands/update.py    |  57 ++-
 superset/dashboards/dao.py                         |  74 ++++
 superset/dashboards/filters.py                     |  84 +++++
 superset/dashboards/schemas.py                     |  87 +++++
 superset/datasets/commands/create.py               |   6 +-
 superset/datasets/commands/delete.py               |   4 +-
 superset/datasets/commands/update.py               |   6 +-
 superset/exceptions.py                             |  12 +
 superset/views/api.py                              |   5 -
 superset/views/dashboard/api.py                    | 319 -----------------
 .../base.py => tests/dashboards/__init__.py        |  23 --
 .../api_tests.py}                                  | 254 ++++++++++---
 tests/dataset_api_tests.py                         |  16 +-
 27 files changed, 1182 insertions(+), 613 deletions(-)

diff --git a/superset/app.py b/superset/app.py
index dc706fd..f1eace3 100644
--- a/superset/app.py
+++ b/superset/app.py
@@ -152,7 +152,7 @@ class SupersetAppInitializer:
         )
         from superset.views.chart.api import ChartRestApi
         from superset.views.chart.views import SliceModelView, SliceAsync
-        from superset.views.dashboard.api import DashboardRestApi
+        from superset.dashboards.api import DashboardRestApi
         from superset.views.dashboard.views import (
             DashboardModelView,
             Dashboard,
diff --git a/superset/commands/base.py b/superset/commands/base.py
index 9889d6f..44f46eb 100644
--- a/superset/commands/base.py
+++ b/superset/commands/base.py
@@ -26,7 +26,7 @@ class BaseCommand(ABC):
     def run(self):
         """
         Run executes the command. Can raise command exceptions
-        :return:
+        :raises: CommandException
         """
         pass
 
@@ -35,5 +35,6 @@ class BaseCommand(ABC):
         """
         Validate is normally called by run to validate data.
         Will raise exception if validation fails
+        :raises: CommandException
         """
         pass
diff --git a/superset/commands/exceptions.py b/superset/commands/exceptions.py
index 83b3e1d..61a18eb 100644
--- a/superset/commands/exceptions.py
+++ b/superset/commands/exceptions.py
@@ -14,30 +14,25 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from typing import List, Optional
+from typing import List
 
+from flask_babel import lazy_gettext as _
 from marshmallow import ValidationError
 
+from superset.exceptions import SupersetException
 
-class CommandException(Exception):
-    """ Common base class for Command exceptions. """
-
-    message = ""
 
-    def __init__(self, message: str = "", exception: Optional[Exception] = 
None):
-        if message:
-            self.message = message
-        self._exception = exception
-        super().__init__(self.message)
+class CommandException(SupersetException):
+    """ Common base class for Command exceptions. """
 
-    @property
-    def exception(self):
-        return self._exception
+    pass
 
 
 class CommandInvalidError(CommandException):
     """ Common base class for Command Invalid errors. """
 
+    status = 422
+
     def __init__(self, message=""):
         self._invalid_exceptions = list()
         super().__init__(self.message)
@@ -56,16 +51,27 @@ class CommandInvalidError(CommandException):
 
 
 class UpdateFailedError(CommandException):
+    status = 500
     message = "Command update failed"
 
 
 class CreateFailedError(CommandException):
+    status = 500
     message = "Command create failed"
 
 
 class DeleteFailedError(CommandException):
+    status = 500
     message = "Command delete failed"
 
 
 class ForbiddenError(CommandException):
+    status = 500
     message = "Action is forbidden"
+
+
+class OwnersNotFoundValidationError(ValidationError):
+    status = 422
+
+    def __init__(self):
+        super().__init__(_("Owners are invalid"), field_names=["owners"])
diff --git a/superset/datasets/commands/base.py b/superset/commands/utils.py
similarity index 88%
rename from superset/datasets/commands/base.py
rename to superset/commands/utils.py
index 646dfc3..9865549 100644
--- a/superset/datasets/commands/base.py
+++ b/superset/commands/utils.py
@@ -18,15 +18,14 @@ from typing import List, Optional
 
 from flask_appbuilder.security.sqla.models import User
 
-from superset.datasets.commands.exceptions import OwnersNotFoundValidationError
-from superset.datasets.dao import DatasetDAO
+from superset.commands.exceptions import OwnersNotFoundValidationError
+from superset.extensions import security_manager
 
 
 def populate_owners(user: User, owners_ids: Optional[List[int]] = None) -> 
List[User]:
     """
     Helper function for commands, will fetch all users from owners id's
     Can raise ValidationError
-
     :param user: The current user
     :param owners_ids: A List of owners by id's
     """
@@ -36,7 +35,7 @@ def populate_owners(user: User, owners_ids: 
Optional[List[int]] = None) -> List[
     if user.id not in owners_ids:
         owners.append(user)
     for owner_id in owners_ids:
-        owner = DatasetDAO.get_owner_by_id(owner_id)
+        owner = security_manager.get_user_by_id(owner_id)
         if not owner:
             raise OwnersNotFoundValidationError()
         owners.append(owner)
diff --git a/superset/commands/base.py b/superset/dao/__init__.py
similarity index 61%
copy from superset/commands/base.py
copy to superset/dao/__init__.py
index 9889d6f..13a8339 100644
--- a/superset/commands/base.py
+++ b/superset/dao/__init__.py
@@ -14,26 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from abc import ABC, abstractmethod
-
-
-class BaseCommand(ABC):
-    """
-        Base class for all Command like Superset Logic objects
-    """
-
-    @abstractmethod
-    def run(self):
-        """
-        Run executes the command. Can raise command exceptions
-        :return:
-        """
-        pass
-
-    @abstractmethod
-    def validate(self) -> None:
-        """
-        Validate is normally called by run to validate data.
-        Will raise exception if validation fails
-        """
-        pass
diff --git a/superset/dao/base.py b/superset/dao/base.py
new file mode 100644
index 0000000..4f19efc
--- /dev/null
+++ b/superset/dao/base.py
@@ -0,0 +1,111 @@
+# 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 typing import Dict, Optional
+
+from flask_appbuilder.models.filters import BaseFilter
+from flask_appbuilder.models.sqla import Model
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.dao.exceptions import (
+    DAOConfigError,
+    DAOCreateFailedError,
+    DAODeleteFailedError,
+    DAOUpdateFailedError,
+)
+from superset.extensions import db
+
+
+class BaseDAO:
+    """
+    Base DAO, implement base CRUD sqlalchemy operations
+    """
+
+    model_cls: Optional[Model] = None
+    """
+    Child classes need to state the Model class so they don't need to 
implement basic
+    create, update and delete methods
+    """  # pylint: disable=pointless-string-statement
+    base_filter: Optional[BaseFilter] = None
+    """
+    Child classes can register base filtering to be aplied to all filter 
methods
+    """  # pylint: disable=pointless-string-statement
+
+    @classmethod
+    def find_by_id(cls, model_id: int) -> Model:
+        """
+        Retrives a model by id, if defined applies `base_filter`
+        """
+        query = db.session.query(cls.model_cls)
+        if cls.base_filter:
+            data_model = SQLAInterface(cls.model_cls, db.session)
+            query = cls.base_filter(  # pylint: disable=not-callable
+                "id", data_model
+            ).apply(query, None)
+        return query.filter_by(id=model_id).one_or_none()
+
+    @classmethod
+    def create(cls, properties: Dict, commit=True) -> Optional[Model]:
+        """
+        Generic for creating models
+        :raises: DAOCreateFailedError
+        """
+        if cls.model_cls is None:
+            raise DAOConfigError()
+        model = cls.model_cls()  # pylint: disable=not-callable
+        for key, value in properties.items():
+            setattr(model, key, value)
+        try:
+            db.session.add(model)
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError as e:  # pragma: no cover
+            db.session.rollback()
+            raise DAOCreateFailedError(exception=e)
+        return model
+
+    @classmethod
+    def update(cls, model: Model, properties: Dict, commit=True) -> 
Optional[Model]:
+        """
+        Generic update a model
+        :raises: DAOCreateFailedError
+        """
+        for key, value in properties.items():
+            setattr(model, key, value)
+        try:
+            db.session.merge(model)
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError as e:  # pragma: no cover
+            db.session.rollback()
+            raise DAOUpdateFailedError(exception=e)
+        return model
+
+    @classmethod
+    def delete(cls, model: Model, commit=True):
+        """
+        Generic delete a model
+        :raises: DAOCreateFailedError
+        """
+        try:
+            db.session.delete(model)
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError as e:  # pragma: no cover
+            db.session.rollback()
+            raise DAODeleteFailedError(exception=e)
+        return model
diff --git a/superset/exceptions.py b/superset/dao/exceptions.py
similarity index 56%
copy from superset/exceptions.py
copy to superset/dao/exceptions.py
index c13c700..5a6bbbe 100644
--- a/superset/exceptions.py
+++ b/superset/dao/exceptions.py
@@ -14,39 +14,44 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from superset.exceptions import SupersetException
 
 
-class SupersetException(Exception):
-    status = 500
+class DAOException(SupersetException):
+    """
+    Base DAO exception class
+    """
 
-
-class SupersetTimeoutException(SupersetException):
     pass
 
 
-class SupersetSecurityException(SupersetException):
-    status = 401
-
-    def __init__(self, msg, link=None):
-        super(SupersetSecurityException, self).__init__(msg)
-        self.link = link
+class DAOCreateFailedError(DAOException):
+    """
+    DAO Create failed
+    """
 
+    message = "Create failed"
 
-class NoDataException(SupersetException):
-    status = 400
 
+class DAOUpdateFailedError(DAOException):
+    """
+    DAO Update failed
+    """
 
-class NullValueException(SupersetException):
-    status = 400
+    message = "Updated failed"
 
 
-class SupersetTemplateException(SupersetException):
-    pass
+class DAODeleteFailedError(DAOException):
+    """
+    DAO Delete failed
+    """
 
+    message = "Delete failed"
 
-class SpatialException(SupersetException):
-    pass
 
+class DAOConfigError(DAOException):
+    """
+    DAO is miss configured
+    """
 
-class DatabaseNotFound(SupersetException):
-    status = 400
+    message = "DAO is not configured correctly missing model definition"
diff --git a/superset/commands/base.py b/superset/dashboards/__init__.py
similarity index 61%
copy from superset/commands/base.py
copy to superset/dashboards/__init__.py
index 9889d6f..13a8339 100644
--- a/superset/commands/base.py
+++ b/superset/dashboards/__init__.py
@@ -14,26 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from abc import ABC, abstractmethod
-
-
-class BaseCommand(ABC):
-    """
-        Base class for all Command like Superset Logic objects
-    """
-
-    @abstractmethod
-    def run(self):
-        """
-        Run executes the command. Can raise command exceptions
-        :return:
-        """
-        pass
-
-    @abstractmethod
-    def validate(self) -> None:
-        """
-        Validate is normally called by run to validate data.
-        Will raise exception if validation fails
-        """
-        pass
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
new file mode 100644
index 0000000..e960c4c
--- /dev/null
+++ b/superset/dashboards/api.py
@@ -0,0 +1,391 @@
+# 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.
+import logging
+
+from flask import g, make_response, request, Response
+from flask_appbuilder.api import expose, protect, rison, safe
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_babel import ngettext
+
+from superset.constants import RouteMethod
+from superset.dashboards.commands.bulk_delete import BulkDeleteDashboardCommand
+from superset.dashboards.commands.create import CreateDashboardCommand
+from superset.dashboards.commands.delete import DeleteDashboardCommand
+from superset.dashboards.commands.exceptions import (
+    DashboardBulkDeleteFailedError,
+    DashboardCreateFailedError,
+    DashboardDeleteFailedError,
+    DashboardForbiddenError,
+    DashboardInvalidError,
+    DashboardNotFoundError,
+    DashboardUpdateFailedError,
+)
+from superset.dashboards.commands.update import UpdateDashboardCommand
+from superset.dashboards.filters import DashboardFilter
+from superset.dashboards.schemas import (
+    DashboardPostSchema,
+    DashboardPutSchema,
+    get_delete_ids_schema,
+    get_export_ids_schema,
+)
+from superset.models.dashboard import Dashboard
+from superset.views.base import generate_download_headers
+from superset.views.base_api import BaseSupersetModelRestApi
+
+logger = logging.getLogger(__name__)
+
+
+class DashboardRestApi(BaseSupersetModelRestApi):
+    datamodel = SQLAInterface(Dashboard)
+    include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
+        RouteMethod.EXPORT,
+        RouteMethod.RELATED,
+        "bulk_delete",  # not using RouteMethod since locally defined
+    }
+    resource_name = "dashboard"
+    allow_browser_login = True
+
+    class_permission_name = "DashboardModelView"
+    show_columns = [
+        "id",
+        "charts",
+        "css",
+        "dashboard_title",
+        "json_metadata",
+        "owners.id",
+        "owners.username",
+        "changed_by_name",
+        "changed_by_url",
+        "changed_by.username",
+        "changed_on",
+        "position_json",
+        "published",
+        "url",
+        "slug",
+        "table_names",
+    ]
+    order_columns = ["dashboard_title", "changed_on", "published", 
"changed_by_fk"]
+    list_columns = [
+        "changed_by_name",
+        "changed_by_url",
+        "changed_by.username",
+        "changed_on",
+        "dashboard_title",
+        "id",
+        "published",
+        "slug",
+        "url",
+    ]
+    edit_columns = [
+        "dashboard_title",
+        "slug",
+        "owners",
+        "position_json",
+        "css",
+        "json_metadata",
+        "published",
+    ]
+    search_columns = ("dashboard_title", "slug", "owners", "published")
+    add_columns = edit_columns
+    base_order = ("changed_on", "desc")
+
+    add_model_schema = DashboardPostSchema()
+    edit_model_schema = DashboardPutSchema()
+
+    base_filters = [["slice", DashboardFilter, lambda: []]]
+
+    openapi_spec_tag = "Dashboards"
+    order_rel_fields = {
+        "slices": ("slice_name", "asc"),
+        "owners": ("first_name", "asc"),
+    }
+    filter_rel_fields_field = {"owners": "first_name"}
+    allowed_rel_fields = {"owners"}
+
+    @expose("/", methods=["POST"])
+    @protect()
+    @safe
+    def post(self) -> Response:
+        """Creates a new Dashboard
+        ---
+        post:
+          description: >-
+            Create a new Dashboard
+          requestBody:
+            description: Dashboard schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+          responses:
+            201:
+              description: Dashboard added
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: 
'#/components/schemas/{{self.__class__.__name__}}.post'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        item = self.add_model_schema.load(request.json)
+        # This validates custom Schema with custom validations
+        if item.errors:
+            return self.response_400(message=item.errors)
+        try:
+            new_model = CreateDashboardCommand(g.user, item.data).run()
+            return self.response(201, id=new_model.id, result=item.data)
+        except DashboardInvalidError as e:
+            return self.response_422(message=e.normalized_messages())
+        except DashboardCreateFailedError as e:
+            logger.error(f"Error creating model {self.__class__.__name__}: 
{e}")
+            return self.response_422(message=str(e))
+
+    @expose("/<pk>", methods=["PUT"])
+    @protect()
+    @safe
+    def put(  # pylint: disable=too-many-return-statements, arguments-differ
+        self, pk: int
+    ) -> Response:
+        """Changes a Dashboard
+        ---
+        put:
+          description: >-
+            Changes a Dashboard
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          requestBody:
+            description: Dashboard schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+          responses:
+            200:
+              description: Dashboard changed
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: 
'#/components/schemas/{{self.__class__.__name__}}.put'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        item = self.edit_model_schema.load(request.json)
+        # This validates custom Schema with custom validations
+        if item.errors:
+            return self.response_400(message=item.errors)
+        try:
+            changed_model = UpdateDashboardCommand(g.user, pk, item.data).run()
+            return self.response(200, id=changed_model.id, result=item.data)
+        except DashboardNotFoundError:
+            return self.response_404()
+        except DashboardForbiddenError:
+            return self.response_403()
+        except DashboardInvalidError as e:
+            return self.response_422(message=e.normalized_messages())
+        except DashboardUpdateFailedError as e:
+            logger.error(f"Error updating model {self.__class__.__name__}: 
{e}")
+            return self.response_422(message=str(e))
+
+    @expose("/<pk>", methods=["DELETE"])
+    @protect()
+    @safe
+    def delete(self, pk: int) -> Response:  # pylint: disable=arguments-differ
+        """Deletes a Dashboard
+        ---
+        delete:
+          description: >-
+            Deletes a Dashboard
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          responses:
+            200:
+              description: Dashboard deleted
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            DeleteDashboardCommand(g.user, pk).run()
+            return self.response(200, message="OK")
+        except DashboardNotFoundError:
+            return self.response_404()
+        except DashboardForbiddenError:
+            return self.response_403()
+        except DashboardDeleteFailedError as e:
+            logger.error(f"Error deleting model {self.__class__.__name__}: 
{e}")
+            return self.response_422(message=str(e))
+
+    @expose("/", methods=["DELETE"])
+    @protect()
+    @safe
+    @rison(get_delete_ids_schema)
+    def bulk_delete(self, **kwargs) -> Response:  # pylint: 
disable=arguments-differ
+        """Delete bulk Dashboards
+        ---
+        delete:
+          description: >-
+            Deletes multiple Dashboards in a bulk operation
+          parameters:
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  type: array
+                  items:
+                    type: integer
+          responses:
+            200:
+              description: Dashboard bulk delete
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        item_ids = kwargs["rison"]
+        try:
+            BulkDeleteDashboardCommand(g.user, item_ids).run()
+            return self.response(
+                200,
+                message=ngettext(
+                    f"Deleted %(num)d dashboard",
+                    f"Deleted %(num)d dashboards",
+                    num=len(item_ids),
+                ),
+            )
+        except DashboardNotFoundError:
+            return self.response_404()
+        except DashboardForbiddenError:
+            return self.response_403()
+        except DashboardBulkDeleteFailedError as e:
+            return self.response_422(message=str(e))
+
+    @expose("/export/", methods=["GET"])
+    @protect()
+    @safe
+    @rison(get_export_ids_schema)
+    def export(self, **kwargs):
+        """Export dashboards
+        ---
+        get:
+          description: >-
+            Exports multiple Dashboards and downloads them as YAML files
+          parameters:
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  type: array
+                  items:
+                    type: integer
+          responses:
+            200:
+              description: Dashboard export
+              content:
+                text/plain:
+                  schema:
+                    type: string
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        query = self.datamodel.session.query(Dashboard).filter(
+            Dashboard.id.in_(kwargs["rison"])
+        )
+        query = self._base_filters.apply_all(query)
+        ids = [item.id for item in query.all()]
+        if not ids:
+            return self.response_404()
+        export = Dashboard.export_dashboards(ids)
+        resp = make_response(export, 200)
+        resp.headers["Content-Disposition"] = 
generate_download_headers("json")[
+            "Content-Disposition"
+        ]
+        return resp
diff --git a/superset/commands/base.py 
b/superset/dashboards/commands/__init__.py
similarity index 61%
copy from superset/commands/base.py
copy to superset/dashboards/commands/__init__.py
index 9889d6f..13a8339 100644
--- a/superset/commands/base.py
+++ b/superset/dashboards/commands/__init__.py
@@ -14,26 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from abc import ABC, abstractmethod
-
-
-class BaseCommand(ABC):
-    """
-        Base class for all Command like Superset Logic objects
-    """
-
-    @abstractmethod
-    def run(self):
-        """
-        Run executes the command. Can raise command exceptions
-        :return:
-        """
-        pass
-
-    @abstractmethod
-    def validate(self) -> None:
-        """
-        Validate is normally called by run to validate data.
-        Will raise exception if validation fails
-        """
-        pass
diff --git a/superset/datasets/commands/delete.py 
b/superset/dashboards/commands/bulk_delete.py
similarity index 59%
copy from superset/datasets/commands/delete.py
copy to superset/dashboards/commands/bulk_delete.py
index d61c56a..07f2bef 100644
--- a/superset/datasets/commands/delete.py
+++ b/superset/dashboards/commands/bulk_delete.py
@@ -15,47 +15,47 @@
 # specific language governing permissions and limitations
 # under the License.
 import logging
-from typing import Optional
+from typing import List, Optional
 
 from flask_appbuilder.security.sqla.models import User
 
 from superset.commands.base import BaseCommand
 from superset.commands.exceptions import DeleteFailedError
-from superset.connectors.sqla.models import SqlaTable
-from superset.datasets.commands.exceptions import (
-    DatasetDeleteFailedError,
-    DatasetForbiddenError,
-    DatasetNotFoundError,
+from superset.dashboards.commands.exceptions import (
+    DashboardBulkDeleteFailedError,
+    DashboardForbiddenError,
+    DashboardNotFoundError,
 )
-from superset.datasets.dao import DatasetDAO
+from superset.dashboards.dao import DashboardDAO
 from superset.exceptions import SupersetSecurityException
+from superset.models.dashboard import Dashboard
 from superset.views.base import check_ownership
 
 logger = logging.getLogger(__name__)
 
 
-class DeleteDatasetCommand(BaseCommand):
-    def __init__(self, user: User, model_id: int):
+class BulkDeleteDashboardCommand(BaseCommand):
+    def __init__(self, user: User, model_ids: List[int]):
         self._actor = user
-        self._model_id = model_id
-        self._model: Optional[SqlaTable] = None
+        self._model_ids = model_ids
+        self._models: Optional[List[Dashboard]] = None
 
     def run(self):
         self.validate()
         try:
-            dataset = DatasetDAO.delete(self._model)
+            DashboardDAO.bulk_delete(self._models)
         except DeleteFailedError as e:
             logger.exception(e.exception)
-            raise DatasetDeleteFailedError()
-        return dataset
+            raise DashboardBulkDeleteFailedError()
 
     def validate(self) -> None:
         # Validate/populate model exists
-        self._model = DatasetDAO.find_by_id(self._model_id)
-        if not self._model:
-            raise DatasetNotFoundError()
+        self._models = DashboardDAO.find_by_ids(self._model_ids)
+        if not self._models or len(self._models) != len(self._model_ids):
+            raise DashboardNotFoundError()
         # Check ownership
-        try:
-            check_ownership(self._model)
-        except SupersetSecurityException:
-            raise DatasetForbiddenError()
+        for model in self._models:
+            try:
+                check_ownership(model)
+            except SupersetSecurityException:
+                raise DashboardForbiddenError()
diff --git a/superset/datasets/commands/create.py 
b/superset/dashboards/commands/create.py
similarity index 53%
copy from superset/datasets/commands/create.py
copy to superset/dashboards/commands/create.py
index 344770b..da79677 100644
--- a/superset/datasets/commands/create.py
+++ b/superset/dashboards/commands/create.py
@@ -21,21 +21,19 @@ from flask_appbuilder.security.sqla.models import User
 from marshmallow import ValidationError
 
 from superset.commands.base import BaseCommand
-from superset.commands.exceptions import CreateFailedError
-from superset.datasets.commands.base import populate_owners
-from superset.datasets.commands.exceptions import (
-    DatabaseNotFoundValidationError,
-    DatasetCreateFailedError,
-    DatasetExistsValidationError,
-    DatasetInvalidError,
-    TableNotFoundValidationError,
+from superset.commands.utils import populate_owners
+from superset.dao.exceptions import DAOCreateFailedError
+from superset.dashboards.commands.exceptions import (
+    DashboardCreateFailedError,
+    DashboardInvalidError,
+    DashboardSlugExistsValidationError,
 )
-from superset.datasets.dao import DatasetDAO
+from superset.dashboards.dao import DashboardDAO
 
 logger = logging.getLogger(__name__)
 
 
-class CreateDatasetCommand(BaseCommand):
+class CreateDashboardCommand(BaseCommand):
     def __init__(self, user: User, data: Dict):
         self._actor = user
         self._properties = data.copy()
@@ -43,34 +41,20 @@ class CreateDatasetCommand(BaseCommand):
     def run(self):
         self.validate()
         try:
-            dataset = DatasetDAO.create(self._properties)
-        except CreateFailedError as e:
+            dashboard = DashboardDAO.create(self._properties)
+        except DAOCreateFailedError as e:
             logger.exception(e.exception)
-            raise DatasetCreateFailedError()
-        return dataset
+            raise DashboardCreateFailedError()
+        return dashboard
 
     def validate(self) -> None:
         exceptions = list()
-        database_id = self._properties["database"]
-        table_name = self._properties["table_name"]
-        schema = self._properties.get("schema", "")
         owner_ids: Optional[List[int]] = self._properties.get("owners")
+        slug: str = self._properties.get("slug", "")
 
-        # Validate uniqueness
-        if not DatasetDAO.validate_uniqueness(database_id, table_name):
-            exceptions.append(DatasetExistsValidationError(table_name))
-
-        # Validate/Populate database
-        database = DatasetDAO.get_database_by_id(database_id)
-        if not database:
-            exceptions.append(DatabaseNotFoundValidationError())
-        self._properties["database"] = database
-
-        # Validate table exists on dataset
-        if database and not DatasetDAO.validate_table_exists(
-            database, table_name, schema
-        ):
-            exceptions.append(TableNotFoundValidationError(table_name))
+        # Validate slug uniqueness
+        if not DashboardDAO.validate_slug_uniqueness(slug):
+            exceptions.append(DashboardSlugExistsValidationError())
 
         try:
             owners = populate_owners(self._actor, owner_ids)
@@ -78,6 +62,6 @@ class CreateDatasetCommand(BaseCommand):
         except ValidationError as e:
             exceptions.append(e)
         if exceptions:
-            exception = DatasetInvalidError()
+            exception = DashboardInvalidError()
             exception.add_list(exceptions)
             raise exception
diff --git a/superset/datasets/commands/delete.py 
b/superset/dashboards/commands/delete.py
similarity index 68%
copy from superset/datasets/commands/delete.py
copy to superset/dashboards/commands/delete.py
index d61c56a..bc19b7c 100644
--- a/superset/datasets/commands/delete.py
+++ b/superset/dashboards/commands/delete.py
@@ -20,42 +20,42 @@ from typing import Optional
 from flask_appbuilder.security.sqla.models import User
 
 from superset.commands.base import BaseCommand
-from superset.commands.exceptions import DeleteFailedError
-from superset.connectors.sqla.models import SqlaTable
-from superset.datasets.commands.exceptions import (
-    DatasetDeleteFailedError,
-    DatasetForbiddenError,
-    DatasetNotFoundError,
+from superset.dao.exceptions import DAODeleteFailedError
+from superset.dashboards.commands.exceptions import (
+    DashboardDeleteFailedError,
+    DashboardForbiddenError,
+    DashboardNotFoundError,
 )
-from superset.datasets.dao import DatasetDAO
+from superset.dashboards.dao import DashboardDAO
 from superset.exceptions import SupersetSecurityException
+from superset.models.dashboard import Dashboard
 from superset.views.base import check_ownership
 
 logger = logging.getLogger(__name__)
 
 
-class DeleteDatasetCommand(BaseCommand):
+class DeleteDashboardCommand(BaseCommand):
     def __init__(self, user: User, model_id: int):
         self._actor = user
         self._model_id = model_id
-        self._model: Optional[SqlaTable] = None
+        self._model: Optional[Dashboard] = None
 
     def run(self):
         self.validate()
         try:
-            dataset = DatasetDAO.delete(self._model)
-        except DeleteFailedError as e:
+            dashboard = DashboardDAO.delete(self._model)
+        except DAODeleteFailedError as e:
             logger.exception(e.exception)
-            raise DatasetDeleteFailedError()
-        return dataset
+            raise DashboardDeleteFailedError()
+        return dashboard
 
     def validate(self) -> None:
         # Validate/populate model exists
-        self._model = DatasetDAO.find_by_id(self._model_id)
+        self._model = DashboardDAO.find_by_id(self._model_id)
         if not self._model:
-            raise DatasetNotFoundError()
+            raise DashboardNotFoundError()
         # Check ownership
         try:
             check_ownership(self._model)
         except SupersetSecurityException:
-            raise DatasetForbiddenError()
+            raise DashboardForbiddenError()
diff --git a/superset/dashboards/commands/exceptions.py 
b/superset/dashboards/commands/exceptions.py
new file mode 100644
index 0000000..76d7237
--- /dev/null
+++ b/superset/dashboards/commands/exceptions.py
@@ -0,0 +1,64 @@
+# 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 flask_babel import lazy_gettext as _
+from marshmallow.validate import ValidationError
+
+from superset.commands.exceptions import (
+    CommandException,
+    CommandInvalidError,
+    CreateFailedError,
+    DeleteFailedError,
+    ForbiddenError,
+    UpdateFailedError,
+)
+
+
+class DashboardSlugExistsValidationError(ValidationError):
+    """
+    Marshmallow validation error for dashboard slug already exists
+    """
+
+    def __init__(self):
+        super().__init__(_("Must be unique"), field_names=["slug"])
+
+
+class DashboardInvalidError(CommandInvalidError):
+    message = _("Dashboard parameters are invalid.")
+
+
+class DashboardNotFoundError(CommandException):
+    message = _("Dashboard not found.")
+
+
+class DashboardCreateFailedError(CreateFailedError):
+    message = _("Dashboard could not be created.")
+
+
+class DashboardBulkDeleteFailedError(CreateFailedError):
+    message = _("Dashboards could not be deleted.")
+
+
+class DashboardUpdateFailedError(UpdateFailedError):
+    message = _("Dashboard could not be updated.")
+
+
+class DashboardDeleteFailedError(DeleteFailedError):
+    message = _("Dashboard could not be deleted.")
+
+
+class DashboardForbiddenError(ForbiddenError):
+    message = _("Changing this Dashboard is forbidden")
diff --git a/superset/datasets/commands/update.py 
b/superset/dashboards/commands/update.py
similarity index 60%
copy from superset/datasets/commands/update.py
copy to superset/dashboards/commands/update.py
index b3deeab..27cd13d 100644
--- a/superset/datasets/commands/update.py
+++ b/superset/dashboards/commands/update.py
@@ -21,25 +21,24 @@ from flask_appbuilder.security.sqla.models import User
 from marshmallow import ValidationError
 
 from superset.commands.base import BaseCommand
-from superset.commands.exceptions import UpdateFailedError
+from superset.commands.utils import populate_owners
 from superset.connectors.sqla.models import SqlaTable
-from superset.datasets.commands.base import populate_owners
-from superset.datasets.commands.exceptions import (
-    DatabaseChangeValidationError,
-    DatasetExistsValidationError,
-    DatasetForbiddenError,
-    DatasetInvalidError,
-    DatasetNotFoundError,
-    DatasetUpdateFailedError,
+from superset.dao.exceptions import DAOUpdateFailedError
+from superset.dashboards.commands.exceptions import (
+    DashboardForbiddenError,
+    DashboardInvalidError,
+    DashboardNotFoundError,
+    DashboardSlugExistsValidationError,
+    DashboardUpdateFailedError,
 )
-from superset.datasets.dao import DatasetDAO
+from superset.dashboards.dao import DashboardDAO
 from superset.exceptions import SupersetSecurityException
 from superset.views.base import check_ownership
 
 logger = logging.getLogger(__name__)
 
 
-class UpdateDatasetCommand(BaseCommand):
+class UpdateDashboardCommand(BaseCommand):
     def __init__(self, user: User, model_id: int, data: Dict):
         self._actor = user
         self._model_id = model_id
@@ -49,42 +48,40 @@ class UpdateDatasetCommand(BaseCommand):
     def run(self):
         self.validate()
         try:
-            dataset = DatasetDAO.update(self._model, self._properties)
-        except UpdateFailedError as e:
+            dashboard = DashboardDAO.update(self._model, self._properties)
+        except DAOUpdateFailedError as e:
             logger.exception(e.exception)
-            raise DatasetUpdateFailedError()
-        return dataset
+            raise DashboardUpdateFailedError()
+        return dashboard
 
     def validate(self) -> None:
-        exceptions = list()
+        exceptions: List[ValidationError] = []
         owner_ids: Optional[List[int]] = self._properties.get("owners")
+        slug: str = self._properties.get("slug", "")
+
         # Validate/populate model exists
-        self._model = DatasetDAO.find_by_id(self._model_id)
+        self._model = DashboardDAO.find_by_id(self._model_id)
         if not self._model:
-            raise DatasetNotFoundError()
+            raise DashboardNotFoundError()
         # Check ownership
         try:
             check_ownership(self._model)
         except SupersetSecurityException:
-            raise DatasetForbiddenError()
+            raise DashboardForbiddenError()
+
+        # Validate slug uniqueness
+        if not DashboardDAO.validate_update_slug_uniqueness(self._model_id, 
slug):
+            exceptions.append(DashboardSlugExistsValidationError())
 
-        database_id = self._properties.get("database", None)
-        table_name = self._properties.get("table_name", None)
-        # Validate uniqueness
-        if not DatasetDAO.validate_update_uniqueness(
-            self._model.database_id, self._model_id, table_name
-        ):
-            exceptions.append(DatasetExistsValidationError(table_name))
-        # Validate/Populate database not allowed to change
-        if database_id and database_id != self._model:
-            exceptions.append(DatabaseChangeValidationError())
         # Validate/Populate owner
+        if owner_ids is None:
+            owner_ids = [owner.id for owner in self._model.owners]
         try:
             owners = populate_owners(self._actor, owner_ids)
             self._properties["owners"] = owners
         except ValidationError as e:
             exceptions.append(e)
         if exceptions:
-            exception = DatasetInvalidError()
+            exception = DashboardInvalidError()
             exception.add_list(exceptions)
             raise exception
diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py
new file mode 100644
index 0000000..e635750
--- /dev/null
+++ b/superset/dashboards/dao.py
@@ -0,0 +1,74 @@
+# 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.
+import logging
+from typing import List
+
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.dao.base import BaseDAO
+from superset.dashboards.filters import DashboardFilter
+from superset.extensions import db
+from superset.models.dashboard import Dashboard
+
+logger = logging.getLogger(__name__)
+
+
+class DashboardDAO(BaseDAO):
+    model_cls = Dashboard
+    base_filter = DashboardFilter
+
+    @staticmethod
+    def find_by_ids(model_ids: List[int]) -> List[Dashboard]:
+        query = db.session.query(Dashboard).filter(Dashboard.id.in_(model_ids))
+        data_model = SQLAInterface(Dashboard, db.session)
+        query = DashboardFilter("id", data_model).apply(query, None)
+        return query.all()
+
+    @staticmethod
+    def validate_slug_uniqueness(slug: str) -> bool:
+        if not slug:
+            return True
+        dashboard_query = db.session.query(Dashboard).filter(Dashboard.slug == 
slug)
+        return not db.session.query(dashboard_query.exists()).scalar()
+
+    @staticmethod
+    def validate_update_slug_uniqueness(dashboard_id: int, slug: str) -> bool:
+        dashboard_query = db.session.query(Dashboard).filter(
+            Dashboard.slug == slug, Dashboard.id != dashboard_id
+        )
+        return not db.session.query(dashboard_query.exists()).scalar()
+
+    @staticmethod
+    def bulk_delete(models: List[Dashboard], commit=True):
+        item_ids = [model.id for model in models]
+        # bulk delete, first delete related data
+        for model in models:
+            model.slices = []
+            model.owners = []
+            db.session.merge(model)
+        # bulk delete itself
+        try:
+            
db.session.query(Dashboard).filter(Dashboard.id.in_(item_ids)).delete(
+                synchronize_session="fetch"
+            )
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError as e:
+            if commit:
+                db.session.rollback()
+            raise e
diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py
new file mode 100644
index 0000000..0b4338d
--- /dev/null
+++ b/superset/dashboards/filters.py
@@ -0,0 +1,84 @@
+# 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 sqlalchemy import and_, or_
+
+from superset import db, security_manager
+from superset.models.core import FavStar
+from superset.models.dashboard import Dashboard
+from superset.models.slice import Slice
+from superset.views.base import BaseFilter, get_user_roles
+
+
+class DashboardFilter(BaseFilter):  # pylint: disable=too-few-public-methods
+    """
+    List dashboards with the following criteria:
+        1. Those which the user owns
+        2. Those which the user has favorited
+        3. Those which have been published (if they have access to at least 
one slice)
+
+    If the user is an admin show them all dashboards.
+    This means they do not get curation but can still sort by "published"
+    if they wish to see those dashboards which are published first
+    """
+
+    def apply(self, query, value):
+        user_roles = [role.name.lower() for role in list(get_user_roles())]
+        if "admin" in user_roles:
+            return query
+
+        datasource_perms = 
security_manager.user_view_menu_names("datasource_access")
+        schema_perms = security_manager.user_view_menu_names("schema_access")
+        all_datasource_access = security_manager.all_datasource_access()
+        published_dash_query = (
+            db.session.query(Dashboard.id)
+            .join(Dashboard.slices)
+            .filter(
+                and_(
+                    Dashboard.published == True,  # pylint: 
disable=singleton-comparison
+                    or_(
+                        Slice.perm.in_(datasource_perms),
+                        Slice.schema_perm.in_(schema_perms),
+                        all_datasource_access,
+                    ),
+                )
+            )
+        )
+
+        users_favorite_dash_query = db.session.query(FavStar.obj_id).filter(
+            and_(
+                FavStar.user_id == security_manager.user_model.get_user_id(),
+                FavStar.class_name == "Dashboard",
+            )
+        )
+        owner_ids_query = (
+            db.session.query(Dashboard.id)
+            .join(Dashboard.owners)
+            .filter(
+                security_manager.user_model.id
+                == security_manager.user_model.get_user_id()
+            )
+        )
+
+        query = query.filter(
+            or_(
+                Dashboard.id.in_(owner_ids_query),
+                Dashboard.id.in_(published_dash_query),
+                Dashboard.id.in_(users_favorite_dash_query),
+            )
+        )
+
+        return query
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
new file mode 100644
index 0000000..a6b7cd4
--- /dev/null
+++ b/superset/dashboards/schemas.py
@@ -0,0 +1,87 @@
+# 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.
+import json
+import re
+
+from marshmallow import fields, pre_load, Schema
+from marshmallow.validate import Length, ValidationError
+
+from superset.exceptions import SupersetException
+from superset.utils import core as utils
+
+get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
+get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
+
+
+def validate_json(value):
+    try:
+        utils.validate_json(value)
+    except SupersetException:
+        raise ValidationError("JSON not valid")
+
+
+def validate_json_metadata(value):
+    if not value:
+        return
+    try:
+        value_obj = json.loads(value)
+    except json.decoder.JSONDecodeError:
+        raise ValidationError("JSON not valid")
+    errors = DashboardJSONMetadataSchema(strict=True).validate(value_obj, 
partial=False)
+    if errors:
+        raise ValidationError(errors)
+
+
+class DashboardJSONMetadataSchema(Schema):
+    timed_refresh_immune_slices = fields.List(fields.Integer())
+    filter_scopes = fields.Dict()
+    expanded_slices = fields.Dict()
+    refresh_frequency = fields.Integer()
+    default_filters = fields.Str()
+    stagger_refresh = fields.Boolean()
+    stagger_time = fields.Integer()
+    color_scheme = fields.Str()
+    label_colors = fields.Dict()
+
+
+class BaseDashboardSchema(Schema):
+    @pre_load
+    def pre_load(self, data):  # pylint: disable=no-self-use
+        if data.get("slug"):
+            data["slug"] = data["slug"].strip()
+            data["slug"] = data["slug"].replace(" ", "-")
+            data["slug"] = re.sub(r"[^\w\-]+", "", data["slug"])
+
+
+class DashboardPostSchema(BaseDashboardSchema):
+    dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
+    slug = fields.String(allow_none=True, validate=[Length(1, 255)])
+    owners = fields.List(fields.Integer())
+    position_json = fields.String(validate=validate_json)
+    css = fields.String()
+    json_metadata = fields.String(validate=validate_json_metadata)
+    published = fields.Boolean()
+
+
+class DashboardPutSchema(BaseDashboardSchema):
+    dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
+    slug = fields.String(allow_none=True, validate=Length(0, 255))
+    owners = fields.List(fields.Integer(allow_none=True))
+    position_json = fields.String(allow_none=True, validate=validate_json)
+    css = fields.String(allow_none=True)
+    json_metadata = fields.String(allow_none=True, 
validate=validate_json_metadata)
+    published = fields.Boolean(allow_none=True)
diff --git a/superset/datasets/commands/create.py 
b/superset/datasets/commands/create.py
index 344770b..466e35d 100644
--- a/superset/datasets/commands/create.py
+++ b/superset/datasets/commands/create.py
@@ -21,8 +21,8 @@ from flask_appbuilder.security.sqla.models import User
 from marshmallow import ValidationError
 
 from superset.commands.base import BaseCommand
-from superset.commands.exceptions import CreateFailedError
-from superset.datasets.commands.base import populate_owners
+from superset.commands.utils import populate_owners
+from superset.dao.exceptions import DAOCreateFailedError
 from superset.datasets.commands.exceptions import (
     DatabaseNotFoundValidationError,
     DatasetCreateFailedError,
@@ -44,7 +44,7 @@ class CreateDatasetCommand(BaseCommand):
         self.validate()
         try:
             dataset = DatasetDAO.create(self._properties)
-        except CreateFailedError as e:
+        except DAOCreateFailedError as e:
             logger.exception(e.exception)
             raise DatasetCreateFailedError()
         return dataset
diff --git a/superset/datasets/commands/delete.py 
b/superset/datasets/commands/delete.py
index d61c56a..85837f9 100644
--- a/superset/datasets/commands/delete.py
+++ b/superset/datasets/commands/delete.py
@@ -20,8 +20,8 @@ from typing import Optional
 from flask_appbuilder.security.sqla.models import User
 
 from superset.commands.base import BaseCommand
-from superset.commands.exceptions import DeleteFailedError
 from superset.connectors.sqla.models import SqlaTable
+from superset.dao.exceptions import DAODeleteFailedError
 from superset.datasets.commands.exceptions import (
     DatasetDeleteFailedError,
     DatasetForbiddenError,
@@ -44,7 +44,7 @@ class DeleteDatasetCommand(BaseCommand):
         self.validate()
         try:
             dataset = DatasetDAO.delete(self._model)
-        except DeleteFailedError as e:
+        except DAODeleteFailedError as e:
             logger.exception(e.exception)
             raise DatasetDeleteFailedError()
         return dataset
diff --git a/superset/datasets/commands/update.py 
b/superset/datasets/commands/update.py
index b3deeab..05a0b96 100644
--- a/superset/datasets/commands/update.py
+++ b/superset/datasets/commands/update.py
@@ -21,9 +21,9 @@ from flask_appbuilder.security.sqla.models import User
 from marshmallow import ValidationError
 
 from superset.commands.base import BaseCommand
-from superset.commands.exceptions import UpdateFailedError
+from superset.commands.utils import populate_owners
 from superset.connectors.sqla.models import SqlaTable
-from superset.datasets.commands.base import populate_owners
+from superset.dao.exceptions import DAOUpdateFailedError
 from superset.datasets.commands.exceptions import (
     DatabaseChangeValidationError,
     DatasetExistsValidationError,
@@ -50,7 +50,7 @@ class UpdateDatasetCommand(BaseCommand):
         self.validate()
         try:
             dataset = DatasetDAO.update(self._model, self._properties)
-        except UpdateFailedError as e:
+        except DAOUpdateFailedError as e:
             logger.exception(e.exception)
             raise DatasetUpdateFailedError()
         return dataset
diff --git a/superset/exceptions.py b/superset/exceptions.py
index c13c700..605627c 100644
--- a/superset/exceptions.py
+++ b/superset/exceptions.py
@@ -14,10 +14,22 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from typing import Optional
 
 
 class SupersetException(Exception):
     status = 500
+    message = ""
+
+    def __init__(self, message: str = "", exception: Optional[Exception] = 
None):
+        if message:
+            self.message = message
+        self._exception = exception
+        super().__init__(self.message)
+
+    @property
+    def exception(self):
+        return self._exception
 
 
 class SupersetTimeoutException(SupersetException):
diff --git a/superset/views/api.py b/superset/views/api.py
index ba0281e..e82aa86 100644
--- a/superset/views/api.py
+++ b/superset/views/api.py
@@ -26,11 +26,6 @@ from superset.legacy import update_time_range
 from superset.models.slice import Slice
 from superset.utils import core as utils
 from superset.views.base import api, BaseSupersetView, handle_api_exception
-from superset.views.chart import api as chart_api  # pylint: 
disable=unused-import
-from superset.views.dashboard import (  # pylint: disable=unused-import
-    api as dashboard_api,
-)
-from superset.views.database import api as database_api  # pylint: 
disable=unused-import
 
 
 class Api(BaseSupersetView):
diff --git a/superset/views/dashboard/api.py b/superset/views/dashboard/api.py
deleted file mode 100644
index f547cbf..0000000
--- a/superset/views/dashboard/api.py
+++ /dev/null
@@ -1,319 +0,0 @@
-# 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.
-import json
-import logging
-import re
-from typing import Dict, List, Optional
-
-from flask import current_app, g, make_response
-from flask_appbuilder.api import expose, protect, rison, safe
-from flask_appbuilder.models.sqla.interface import SQLAInterface
-from flask_babel import lazy_gettext as _, ngettext
-from marshmallow import fields, post_load, pre_load, Schema, ValidationError
-from marshmallow.validate import Length
-from sqlalchemy.exc import SQLAlchemyError
-
-from superset.constants import RouteMethod
-from superset.exceptions import SupersetException, SupersetSecurityException
-from superset.models.dashboard import Dashboard
-from superset.utils import core as utils
-from superset.views.base import check_ownership, generate_download_headers
-from superset.views.base_api import BaseOwnedModelRestApi
-from superset.views.base_schemas import BaseOwnedSchema, validate_owner
-
-from .mixin import DashboardMixin
-
-logger = logging.getLogger(__name__)
-get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
-
-
-class DashboardJSONMetadataSchema(Schema):
-    timed_refresh_immune_slices = fields.List(fields.Integer())
-    filter_scopes = fields.Dict()
-    expanded_slices = fields.Dict()
-    refresh_frequency = fields.Integer()
-    default_filters = fields.Str()
-    stagger_refresh = fields.Boolean()
-    stagger_time = fields.Integer()
-    color_scheme = fields.Str()
-    label_colors = fields.Dict()
-
-
-def validate_json(value):
-    try:
-        utils.validate_json(value)
-    except SupersetException:
-        raise ValidationError("JSON not valid")
-
-
-def validate_json_metadata(value):
-    if not value:
-        return
-    try:
-        value_obj = json.loads(value)
-    except json.decoder.JSONDecodeError:
-        raise ValidationError("JSON not valid")
-    errors = DashboardJSONMetadataSchema(strict=True).validate(value_obj, 
partial=False)
-    if errors:
-        raise ValidationError(errors)
-
-
-def validate_slug_uniqueness(value):
-    # slug is not required but must be unique
-    if value:
-        item = (
-            current_app.appbuilder.get_session.query(Dashboard.id)
-            .filter_by(slug=value)
-            .one_or_none()
-        )
-        if item:
-            raise ValidationError("Must be unique")
-
-
-class BaseDashboardSchema(BaseOwnedSchema):
-    @pre_load
-    def pre_load(self, data):  # pylint: disable=no-self-use
-        super().pre_load(data)
-        if data.get("slug"):
-            data["slug"] = data["slug"].strip()
-            data["slug"] = data["slug"].replace(" ", "-")
-            data["slug"] = re.sub(r"[^\w\-]+", "", data["slug"])
-        if "owners" in data and data["owners"] is None:
-            data["owners"] = []
-
-
-class DashboardPostSchema(BaseDashboardSchema):
-    __class_model__ = Dashboard
-
-    dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
-    slug = fields.String(
-        allow_none=True, validate=[Length(1, 255), validate_slug_uniqueness]
-    )
-    owners = fields.List(fields.Integer(validate=validate_owner))
-    position_json = fields.String(validate=validate_json)
-    css = fields.String()
-    json_metadata = fields.String(validate=validate_json_metadata)
-    published = fields.Boolean()
-
-
-class DashboardPutSchema(BaseDashboardSchema):
-    dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
-    slug = fields.String(allow_none=True, validate=Length(0, 255))
-    owners = fields.List(fields.Integer(validate=validate_owner))
-    position_json = fields.String(validate=validate_json)
-    css = fields.String()
-    json_metadata = fields.String(allow_none=True, 
validate=validate_json_metadata)
-    published = fields.Boolean()
-
-    @post_load
-    def make_object(self, data: Dict, discard: Optional[List[str]] = None) -> 
Dashboard:
-        self.instance = super().make_object(data, [])
-        for slc in self.instance.slices:
-            slc.owners = list(set(self.instance.owners) | set(slc.owners))
-        return self.instance
-
-
-get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
-
-
-class DashboardRestApi(DashboardMixin, BaseOwnedModelRestApi):
-    datamodel = SQLAInterface(Dashboard)
-    include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
-        RouteMethod.EXPORT,
-        RouteMethod.RELATED,
-        "bulk_delete",  # not using RouteMethod since locally defined
-    }
-    resource_name = "dashboard"
-    allow_browser_login = True
-
-    class_permission_name = "DashboardModelView"
-    show_columns = [
-        "id",
-        "charts",
-        "css",
-        "dashboard_title",
-        "json_metadata",
-        "owners.id",
-        "owners.username",
-        "changed_by_name",
-        "changed_by_url",
-        "changed_by.username",
-        "changed_on",
-        "position_json",
-        "published",
-        "url",
-        "slug",
-        "table_names",
-    ]
-    order_columns = ["dashboard_title", "changed_on", "published", 
"changed_by_fk"]
-    list_columns = [
-        "changed_by_name",
-        "changed_by_url",
-        "changed_by.username",
-        "changed_on",
-        "dashboard_title",
-        "id",
-        "published",
-        "slug",
-        "url",
-    ]
-
-    add_model_schema = DashboardPostSchema()
-    edit_model_schema = DashboardPutSchema()
-
-    order_rel_fields = {
-        "slices": ("slice_name", "asc"),
-        "owners": ("first_name", "asc"),
-    }
-    filter_rel_fields_field = {"owners": "first_name"}
-    allowed_rel_fields = {"owners"}
-
-    @expose("/", methods=["DELETE"])
-    @protect()
-    @safe
-    @rison(get_delete_ids_schema)
-    def bulk_delete(self, **kwargs):  # pylint: disable=arguments-differ
-        """Delete bulk Dashboards
-        ---
-        delete:
-          parameters:
-          - in: query
-            name: q
-            content:
-              application/json:
-                schema:
-                  type: array
-                  items:
-                    type: integer
-          responses:
-            200:
-              description: Dashboard bulk delete
-              content:
-                application/json:
-                  schema:
-                    type: object
-                    properties:
-                      message:
-                        type: string
-            401:
-              $ref: '#/components/responses/401'
-            403:
-              $ref: '#/components/responses/401'
-            404:
-              $ref: '#/components/responses/404'
-            422:
-              $ref: '#/components/responses/422'
-            500:
-              $ref: '#/components/responses/500'
-        """
-        item_ids = kwargs["rison"]
-        query = self.datamodel.session.query(Dashboard).filter(
-            Dashboard.id.in_(item_ids)
-        )
-        items = self._base_filters.apply_all(query).all()
-        if not items:
-            return self.response_404()
-        # Check user ownership over the items
-        for item in items:
-            try:
-                check_ownership(item)
-            except SupersetSecurityException as e:
-                logger.warning(
-                    f"Dashboard {item} was not deleted, "
-                    f"because the user ({g.user}) does not own it"
-                )
-                return self.response(403, message=_("No dashboards deleted"))
-            except SQLAlchemyError as e:
-                logger.error(f"Error checking dashboard ownership {e}")
-                return self.response_422(message=str(e))
-        # bulk delete, first delete related data
-        for item in items:
-            try:
-                item.slices = []
-                item.owners = []
-                self.datamodel.session.merge(item)
-            except SQLAlchemyError as e:
-                logger.error(f"Error bulk deleting related data on dashboards 
{e}")
-                self.datamodel.session.rollback()
-                return self.response_422(message=str(e))
-        # bulk delete itself
-        try:
-            self.datamodel.session.query(Dashboard).filter(
-                Dashboard.id.in_(item_ids)
-            ).delete(synchronize_session="fetch")
-        except SQLAlchemyError as e:
-            logger.error(f"Error bulk deleting dashboards {e}")
-            self.datamodel.session.rollback()
-            return self.response_422(message=str(e))
-        self.datamodel.session.commit()
-        return self.response(
-            200,
-            message=ngettext(
-                f"Deleted %(num)d dashboard",
-                f"Deleted %(num)d dashboards",
-                num=len(items),
-            ),
-        )
-
-    @expose("/export/", methods=["GET"])
-    @protect()
-    @safe
-    @rison(get_export_ids_schema)
-    def export(self, **kwargs):
-        """Export dashboards
-        ---
-        get:
-          parameters:
-          - in: query
-            name: q
-            content:
-              application/json:
-                schema:
-                  type: array
-                  items:
-                    type: integer
-          responses:
-            200:
-              description: Dashboard export
-              content:
-                text/plain:
-                  schema:
-                    type: string
-            400:
-              $ref: '#/components/responses/400'
-            401:
-              $ref: '#/components/responses/401'
-            404:
-              $ref: '#/components/responses/404'
-            422:
-              $ref: '#/components/responses/422'
-            500:
-              $ref: '#/components/responses/500'
-        """
-        query = self.datamodel.session.query(Dashboard).filter(
-            Dashboard.id.in_(kwargs["rison"])
-        )
-        query = self._base_filters.apply_all(query)
-        ids = [item.id for item in query.all()]
-        if not ids:
-            return self.response_404()
-        export = Dashboard.export_dashboards(ids)
-        resp = make_response(export, 200)
-        resp.headers["Content-Disposition"] = 
generate_download_headers("json")[
-            "Content-Disposition"
-        ]
-        return resp
diff --git a/superset/commands/base.py b/tests/dashboards/__init__.py
similarity index 61%
copy from superset/commands/base.py
copy to tests/dashboards/__init__.py
index 9889d6f..13a8339 100644
--- a/superset/commands/base.py
+++ b/tests/dashboards/__init__.py
@@ -14,26 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from abc import ABC, abstractmethod
-
-
-class BaseCommand(ABC):
-    """
-        Base class for all Command like Superset Logic objects
-    """
-
-    @abstractmethod
-    def run(self):
-        """
-        Run executes the command. Can raise command exceptions
-        :return:
-        """
-        pass
-
-    @abstractmethod
-    def validate(self) -> None:
-        """
-        Validate is normally called by run to validate data.
-        Will raise exception if validation fails
-        """
-        pass
diff --git a/tests/dashboard_api_tests.py b/tests/dashboards/api_tests.py
similarity index 70%
rename from tests/dashboard_api_tests.py
rename to tests/dashboards/api_tests.py
index 83881f4..1c8a050 100644
--- a/tests/dashboard_api_tests.py
+++ b/tests/dashboards/api_tests.py
@@ -20,20 +20,30 @@ import json
 from typing import List, Optional
 
 import prison
+from sqlalchemy.sql import func
 
 import tests.test_app
 from superset import db, security_manager
-from superset.models import core as models
+from superset.models.dashboard import Dashboard
 from superset.models.slice import Slice
 from superset.views.base import generate_download_headers
 
-from .base_api_tests import ApiOwnersTestCaseMixin
-from .base_tests import SupersetTestCase
+from tests.base_api_tests import ApiOwnersTestCaseMixin
+from tests.base_tests import SupersetTestCase
 
 
 class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
     resource_name = "dashboard"
 
+    dashboard_data = {
+        "dashboard_title": "title1_changed",
+        "slug": "slug1_changed",
+        "position_json": '{"b": "B"}',
+        "css": "css_changed",
+        "json_metadata": '{"a": "A"}',
+        "published": False,
+    }
+
     def __init__(self, *args, **kwargs):
         super(DashboardApiTests, self).__init__(*args, **kwargs)
 
@@ -47,13 +57,13 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         css: str = "",
         json_metadata: str = "",
         published: bool = False,
-    ) -> models.Dashboard:
+    ) -> Dashboard:
         obj_owners = list()
         slices = slices or []
         for owner in owners:
             user = db.session.query(security_manager.user_model).get(owner)
             obj_owners.append(user)
-        dashboard = models.Dashboard(
+        dashboard = Dashboard(
             dashboard_title=dashboard_title,
             slug=slug,
             owners=obj_owners,
@@ -67,6 +77,122 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         db.session.commit()
         return dashboard
 
+    def test_get_dashboard(self):
+        """
+            Dashboard API: Test get dashboard
+        """
+        admin = self.get_user("admin")
+        dashboard = self.insert_dashboard("title", "slug1", [admin.id])
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/{dashboard.id}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        expected_result = {
+            "changed_by": None,
+            "changed_by_name": "",
+            "changed_by_url": "",
+            "charts": [],
+            "id": dashboard.id,
+            "css": "",
+            "dashboard_title": "title",
+            "json_metadata": "",
+            "owners": [{"id": 1, "username": "admin"}],
+            "position_json": "",
+            "published": False,
+            "url": f"/superset/dashboard/slug1/",
+            "slug": "slug1",
+            "table_names": "",
+        }
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertIn("changed_on", data["result"])
+        for key, value in data["result"].items():
+            # We can't assert timestamp
+            if key != "changed_on":
+                self.assertEqual(value, expected_result[key])
+        # rollback changes
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    def test_get_dashboard_not_found(self):
+        """
+            Dashboard API: Test get dashboard not found
+        """
+        max_id = db.session.query(func.max(Dashboard.id)).scalar()
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/{max_id + 1}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 404)
+
+    def test_get_dashboard_no_data_access(self):
+        """
+            Dashboard API: Test get dashboard without data access
+        """
+        admin = self.get_user("admin")
+        dashboard = self.insert_dashboard("title", "slug1", [admin.id])
+
+        self.login(username="gamma")
+        uri = f"api/v1/dashboard/{dashboard.id}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 404)
+        # rollback changes
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    def test_get_dashboards_filter(self):
+        """
+            Dashboard API: Test get dashboards filter
+        """
+        admin = self.get_user("admin")
+        gamma = self.get_user("gamma")
+        dashboard = self.insert_dashboard("title", "slug1", [admin.id, 
gamma.id])
+
+        self.login(username="admin")
+
+        arguments = {
+            "filters": [{"col": "dashboard_title", "opr": "sw", "value": "ti"}]
+        }
+        uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(data["count"], 1)
+
+        arguments = {
+            "filters": [
+                {"col": "owners", "opr": "rel_m_m", "value": [admin.id, 
gamma.id]}
+            ]
+        }
+        uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(data["count"], 1)
+
+        # rollback changes
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    def test_get_dashboards_no_data_access(self):
+        """
+            Dashboard API: Test get dashboards no data access
+        """
+        admin = self.get_user("admin")
+        dashboard = self.insert_dashboard("title", "slug1", [admin.id])
+
+        self.login(username="gamma")
+        arguments = {
+            "filters": [{"col": "dashboard_title", "opr": "sw", "value": "ti"}]
+        }
+        uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
+        rv = self.client.get(uri)
+        self.assertEqual(rv.status_code, 200)
+        data = json.loads(rv.data.decode("utf-8"))
+        self.assertEqual(data["count"], 0)
+
+        # rollback changes
+        db.session.delete(dashboard)
+        db.session.commit()
+
     def test_delete_dashboard(self):
         """
             Dashboard API: Test delete
@@ -77,7 +203,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         uri = f"api/v1/dashboard/{dashboard_id}"
         rv = self.client.delete(uri)
         self.assertEqual(rv.status_code, 200)
-        model = db.session.query(models.Dashboard).get(dashboard_id)
+        model = db.session.query(Dashboard).get(dashboard_id)
         self.assertEqual(model, None)
 
     def test_delete_bulk_dashboards(self):
@@ -104,7 +230,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         expected_response = {"message": f"Deleted {dashboard_count} 
dashboards"}
         self.assertEqual(response, expected_response)
         for dashboard_id in dashboard_ids:
-            model = db.session.query(models.Dashboard).get(dashboard_id)
+            model = db.session.query(Dashboard).get(dashboard_id)
             self.assertEqual(model, None)
 
     def test_delete_bulk_dashboards_bad_request(self):
@@ -150,7 +276,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         uri = f"api/v1/dashboard/{dashboard_id}"
         rv = self.client.delete(uri)
         self.assertEqual(rv.status_code, 200)
-        model = db.session.query(models.Dashboard).get(dashboard_id)
+        model = db.session.query(Dashboard).get(dashboard_id)
         self.assertEqual(model, None)
 
     def test_delete_bulk_dashboard_admin_not_owned(self):
@@ -179,7 +305,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         self.assertEqual(response, expected_response)
 
         for dashboard_id in dashboard_ids:
-            model = db.session.query(models.Dashboard).get(dashboard_id)
+            model = db.session.query(Dashboard).get(dashboard_id)
             self.assertEqual(model, None)
 
     def test_delete_dashboard_not_owned(self):
@@ -250,7 +376,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.delete(uri)
         self.assertEqual(rv.status_code, 403)
         response = json.loads(rv.data.decode("utf-8"))
-        expected_response = {"message": "No dashboards deleted"}
+        expected_response = {"message": "Forbidden"}
         self.assertEqual(response, expected_response)
 
         # nothing is delete in bulk with a list of owned and not owned 
dashboards
@@ -259,7 +385,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.delete(uri)
         self.assertEqual(rv.status_code, 403)
         response = json.loads(rv.data.decode("utf-8"))
-        expected_response = {"message": "No dashboards deleted"}
+        expected_response = {"message": "Forbidden"}
         self.assertEqual(response, expected_response)
 
         for dashboard in dashboards:
@@ -288,7 +414,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.post(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 201)
         data = json.loads(rv.data.decode("utf-8"))
-        model = db.session.query(models.Dashboard).get(data.get("id"))
+        model = db.session.query(Dashboard).get(data.get("id"))
         db.session.delete(model)
         db.session.commit()
 
@@ -302,7 +428,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.post(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 201)
         data = json.loads(rv.data.decode("utf-8"))
-        model = db.session.query(models.Dashboard).get(data.get("id"))
+        model = db.session.query(Dashboard).get(data.get("id"))
         db.session.delete(model)
         db.session.commit()
 
@@ -316,7 +442,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.post(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 201)
         data = json.loads(rv.data.decode("utf-8"))
-        model = db.session.query(models.Dashboard).get(data.get("id"))
+        model = db.session.query(Dashboard).get(data.get("id"))
         db.session.delete(model)
         db.session.commit()
 
@@ -326,7 +452,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.post(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 201)
         data = json.loads(rv.data.decode("utf-8"))
-        model = db.session.query(models.Dashboard).get(data.get("id"))
+        model = db.session.query(Dashboard).get(data.get("id"))
         db.session.delete(model)
         db.session.commit()
 
@@ -338,7 +464,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         self.login(username="admin")
         uri = "api/v1/dashboard/"
         rv = self.client.post(uri, json=dashboard_data)
-        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(rv.status_code, 400)
         response = json.loads(rv.data.decode("utf-8"))
         expected_response = {
             "message": {"dashboard_title": ["Length must be between 0 and 
500."]}
@@ -366,7 +492,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         dashboard_data = {"dashboard_title": "title2", "slug": "a" * 256}
         uri = "api/v1/dashboard/"
         rv = self.client.post(uri, json=dashboard_data)
-        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(rv.status_code, 400)
         response = json.loads(rv.data.decode("utf-8"))
         expected_response = {"message": {"slug": ["Length must be between 1 
and 255."]}}
         self.assertEqual(response, expected_response)
@@ -384,7 +510,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.post(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 422)
         response = json.loads(rv.data.decode("utf-8"))
-        expected_response = {"message": {"owners": {"0": ["User 1000 does not 
exist"]}}}
+        expected_response = {"message": {"owners": ["Owners are invalid"]}}
         self.assertEqual(response, expected_response)
 
     def test_create_dashboard_validate_json(self):
@@ -395,13 +521,13 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         self.login(username="admin")
         uri = "api/v1/dashboard/"
         rv = self.client.post(uri, json=dashboard_data)
-        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(rv.status_code, 400)
 
         dashboard_data = {"dashboard_title": "title1", "json_metadata": 
'{"A:"a"}'}
         self.login(username="admin")
         uri = "api/v1/dashboard/"
         rv = self.client.post(uri, json=dashboard_data)
-        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(rv.status_code, 400)
 
         dashboard_data = {
             "dashboard_title": "title1",
@@ -410,34 +536,56 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         self.login(username="admin")
         uri = "api/v1/dashboard/"
         rv = self.client.post(uri, json=dashboard_data)
-        self.assertEqual(rv.status_code, 422)
+        self.assertEqual(rv.status_code, 400)
 
     def test_update_dashboard(self):
         """
             Dashboard API: Test update
         """
+        admin = self.get_user("admin")
+        dashboard_id = self.insert_dashboard("title1", "slug1", [admin.id]).id
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/{dashboard_id}"
+        rv = self.client.put(uri, json=self.dashboard_data)
+        self.assertEqual(rv.status_code, 200)
+        model = db.session.query(Dashboard).get(dashboard_id)
+        self.assertEqual(model.dashboard_title, 
self.dashboard_data["dashboard_title"])
+        self.assertEqual(model.slug, self.dashboard_data["slug"])
+        self.assertEqual(model.position_json, 
self.dashboard_data["position_json"])
+        self.assertEqual(model.css, self.dashboard_data["css"])
+        self.assertEqual(model.json_metadata, 
self.dashboard_data["json_metadata"])
+        self.assertEqual(model.published, self.dashboard_data["published"])
+        self.assertEqual(model.owners, [admin])
+
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_update_partial_dashboard(self):
+        """
+            Dashboard API: Test update partial
+        """
         admin_id = self.get_user("admin").id
         dashboard_id = self.insert_dashboard("title1", "slug1", [admin_id]).id
-        dashboard_data = {
-            "dashboard_title": "title1_changed",
-            "slug": "slug1_changed",
-            "owners": [admin_id],
-            "position_json": '{"b": "B"}',
-            "css": "css_changed",
-            "json_metadata": '{"a": "A"}',
-            "published": False,
-        }
         self.login(username="admin")
         uri = f"api/v1/dashboard/{dashboard_id}"
-        rv = self.client.put(uri, json=dashboard_data)
+        rv = self.client.put(
+            uri, json={"json_metadata": self.dashboard_data["json_metadata"]}
+        )
         self.assertEqual(rv.status_code, 200)
-        model = db.session.query(models.Dashboard).get(dashboard_id)
-        self.assertEqual(model.dashboard_title, "title1_changed")
-        self.assertEqual(model.slug, "slug1_changed")
-        self.assertEqual(model.position_json, '{"b": "B"}')
-        self.assertEqual(model.css, "css_changed")
-        self.assertEqual(model.json_metadata, '{"a": "A"}')
-        self.assertEqual(model.published, False)
+
+        rv = self.client.put(
+            uri, json={"dashboard_title": 
self.dashboard_data["dashboard_title"]}
+        )
+        self.assertEqual(rv.status_code, 200)
+
+        rv = self.client.put(uri, json={"slug": self.dashboard_data["slug"]})
+        self.assertEqual(rv.status_code, 200)
+
+        model = db.session.query(Dashboard).get(dashboard_id)
+        self.assertEqual(model.json_metadata, 
self.dashboard_data["json_metadata"])
+        self.assertEqual(model.dashboard_title, 
self.dashboard_data["dashboard_title"])
+        self.assertEqual(model.slug, self.dashboard_data["slug"])
+
         db.session.delete(model)
         db.session.commit()
 
@@ -453,7 +601,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         uri = f"api/v1/dashboard/{dashboard_id}"
         rv = self.client.put(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 200)
-        model = db.session.query(models.Dashboard).get(dashboard_id)
+        model = db.session.query(Dashboard).get(dashboard_id)
         self.assertIn(admin, model.owners)
         for slc in model.slices:
             self.assertIn(admin, slc.owners)
@@ -471,12 +619,34 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         uri = f"api/v1/dashboard/{dashboard_id}"
         rv = self.client.put(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 200)
-        model = db.session.query(models.Dashboard).get(dashboard_id)
+        model = db.session.query(Dashboard).get(dashboard_id)
         self.assertEqual(model.dashboard_title, "title1_changed")
         self.assertEqual(model.slug, "slug1-changed")
         db.session.delete(model)
         db.session.commit()
 
+    def test_update_dashboard_validate_slug(self):
+        """
+            Dashboard API: Test update validate slug
+        """
+        admin_id = self.get_user("admin").id
+        dashboard1 = self.insert_dashboard("title1", "slug-1", [admin_id])
+        dashboard2 = self.insert_dashboard("title2", "slug-2", [admin_id])
+
+        self.login(username="admin")
+        # Check for slug uniqueness
+        dashboard_data = {"dashboard_title": "title2", "slug": "slug 1"}
+        uri = f"api/v1/dashboard/{dashboard2.id}"
+        rv = self.client.put(uri, json=dashboard_data)
+        self.assertEqual(rv.status_code, 422)
+        response = json.loads(rv.data.decode("utf-8"))
+        expected_response = {"message": {"slug": ["Must be unique"]}}
+        self.assertEqual(response, expected_response)
+
+        db.session.delete(dashboard1)
+        db.session.delete(dashboard2)
+        db.session.commit()
+
     def test_update_published(self):
         """
             Dashboard API: Test update published patch
@@ -491,7 +661,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
         rv = self.client.put(uri, json=dashboard_data)
         self.assertEqual(rv.status_code, 200)
 
-        model = db.session.query(models.Dashboard).get(dashboard.id)
+        model = db.session.query(Dashboard).get(dashboard.id)
         self.assertEqual(model.published, True)
         self.assertEqual(model.slug, "slug1")
         self.assertIn(admin, model.owners)
@@ -552,7 +722,7 @@ class DashboardApiTests(SupersetTestCase, 
ApiOwnersTestCaseMixin):
 
     def test_export_not_allowed(self):
         """
-            Dashboard API: Test dashboard export not not allowed
+            Dashboard API: Test dashboard export not allowed
         """
         admin_id = self.get_user("admin").id
         dashboard = self.insert_dashboard("title", "slug1", [admin_id], 
published=False)
diff --git a/tests/dataset_api_tests.py b/tests/dataset_api_tests.py
index 8474191..a55140a 100644
--- a/tests/dataset_api_tests.py
+++ b/tests/dataset_api_tests.py
@@ -22,12 +22,12 @@ from unittest.mock import patch
 import prison
 
 from superset import db, security_manager
-from superset.commands.exceptions import (
-    CreateFailedError,
-    DeleteFailedError,
-    UpdateFailedError,
-)
 from superset.connectors.sqla.models import SqlaTable
+from superset.dao.exceptions import (
+    DAOCreateFailedError,
+    DAODeleteFailedError,
+    DAOUpdateFailedError,
+)
 from superset.models.core import Database
 from superset.utils.core import get_example_database
 
@@ -279,7 +279,7 @@ class DatasetApiTests(SupersetTestCase):
         """
             Dataset API: Test create dataset sqlalchemy error
         """
-        mock_dao_create.side_effect = CreateFailedError()
+        mock_dao_create.side_effect = DAOCreateFailedError()
         self.login(username="admin")
         example_db = get_example_database()
         dataset_data = {
@@ -379,7 +379,7 @@ class DatasetApiTests(SupersetTestCase):
         """
             Dataset API: Test update dataset sqlalchemy error
         """
-        mock_dao_update.side_effect = UpdateFailedError()
+        mock_dao_update.side_effect = DAOUpdateFailedError()
 
         table = self.insert_dataset("ab_permission", "", [], 
get_example_database())
         self.login(username="admin")
@@ -438,7 +438,7 @@ class DatasetApiTests(SupersetTestCase):
         """
             Dataset API: Test delete dataset sqlalchemy error
         """
-        mock_dao_delete.side_effect = DeleteFailedError()
+        mock_dao_delete.side_effect = DAODeleteFailedError()
 
         admin = self.get_user("admin")
         table = self.insert_dataset(

Reply via email to