You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by dp...@apache.org on 2019/12/16 21:10:51 UTC

[incubator-superset] branch master updated: [dashboard] feat: REST API (#8694)

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 2f2ac00  [dashboard] feat: REST API (#8694)
2f2ac00 is described below

commit 2f2ac00a09d2749ba7e55f977215ed4668a0179a
Author: Daniel Vaz Gaspar <da...@gmail.com>
AuthorDate: Mon Dec 16 21:10:33 2019 +0000

    [dashboard] feat: REST API (#8694)
---
 superset/models/core.py              |   1 +
 superset/utils/core.py               |   3 +-
 superset/views/api.py                |   2 +
 superset/views/base.py               |  21 ++-
 superset/views/core.py               | 219 +--------------------
 superset/views/dashboard.py          |  42 -----
 superset/views/dashboard/__init__.py |  16 ++
 superset/views/dashboard/api.py      | 301 +++++++++++++++++++++++++++++
 superset/views/dashboard/filters.py  |  84 +++++++++
 superset/views/dashboard/mixin.py    |  82 ++++++++
 superset/views/dashboard/views.py    | 147 +++++++++++++++
 tests/base_tests.py                  |  15 ++
 tests/dashboard_api_tests.py         | 355 +++++++++++++++++++++++++++++++++++
 13 files changed, 1029 insertions(+), 259 deletions(-)

diff --git a/superset/models/core.py b/superset/models/core.py
index 33a7c8e..79ff3b0 100755
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -497,6 +497,7 @@ class Dashboard(  # pylint: disable=too-many-instance-attributes
         meta = MetaData(bind=self.get_sqla_engine())
         meta.reflect()
 
+    @renders("dashboard_title")
     def dashboard_link(self) -> Markup:
         title = escape(self.dashboard_title or "<empty>")
         return Markup(f'<a href="{self.url}">{title}</a>')
diff --git a/superset/utils/core.py b/superset/utils/core.py
index 3a4b18c..2c9cbac 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -536,7 +536,8 @@ def validate_json(obj):
     if obj:
         try:
             json.loads(obj)
-        except Exception:
+        except Exception as e:
+            logging.error(f"JSON is not valid {e}")
             raise SupersetException("JSON is not valid")
 
 
diff --git a/superset/views/api.py b/superset/views/api.py
index 296720c..f95b5ca 100644
--- a/superset/views/api.py
+++ b/superset/views/api.py
@@ -27,6 +27,8 @@ from superset.legacy import update_time_range
 from superset.utils import core as utils
 
 from .base import api, BaseSupersetView, handle_api_exception
+from .dashboard import api as dashboard_api  # pylint: disable=unused-import
+from .database import api as database_api  # pylint: disable=unused-import
 
 
 class Api(BaseSupersetView):
diff --git a/superset/views/base.py b/superset/views/base.py
index d57b5e0..6db5b03 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -23,13 +23,14 @@ from typing import Any, Dict, Optional
 import simplejson as json
 import yaml
 from flask import abort, flash, g, get_flashed_messages, redirect, Response, session
-from flask_appbuilder import BaseView, ModelView
+from flask_appbuilder import BaseView, Model, ModelView
 from flask_appbuilder.actions import action
 from flask_appbuilder.forms import DynamicForm
 from flask_appbuilder.models.sqla.filters import BaseFilter
 from flask_appbuilder.widgets import ListWidget
 from flask_babel import get_locale, gettext as __, lazy_gettext as _
 from flask_wtf.form import FlaskForm
+from marshmallow import Schema
 from sqlalchemy import or_
 from werkzeug.exceptions import HTTPException
 from wtforms.fields.core import Field, UnboundField
@@ -352,6 +353,24 @@ class DatasourceFilter(BaseFilter):  # pylint: disable=too-few-public-methods
         )
 
 
+class BaseSupersetSchema(Schema):
+    """
+    Extends Marshmallow schema so that we can pass a Model to load
+    (following marshamallow-sqlalchemy pattern). This is useful
+    to perform partial model merges on HTTP PUT
+    """
+
+    def __init__(self, **kwargs):
+        self.instance = None
+        super().__init__(**kwargs)
+
+    def load(
+        self, data, many=None, partial=None, instance: Model = None, **kwargs
+    ):  # pylint: disable=arguments-differ
+        self.instance = instance
+        return super().load(data, many=many, partial=partial, **kwargs)
+
+
 class CsvResponse(Response):  # pylint: disable=too-many-ancestors
     """
     Override Response to take into account csv encoding from config.py
diff --git a/superset/views/core.py b/superset/views/core.py
index 690b532..ec24716 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -40,7 +40,6 @@ from flask import (
     url_for,
 )
 from flask_appbuilder import expose
-from flask_appbuilder.actions import action
 from flask_appbuilder.models.sqla.interface import SQLAInterface
 from flask_appbuilder.security.decorators import has_access, has_access_api
 from flask_appbuilder.security.sqla import models as ab_models
@@ -103,7 +102,9 @@ from .base import (
     json_success,
     SupersetModelView,
 )
-from .database import api as database_api, views as in_views
+from .dashboard import views as dash_views
+from .dashboard.filters import DashboardFilter
+from .database import views as in_views
 from .utils import (
     apply_display_max_row_limit,
     bootstrap_user_data,
@@ -255,69 +256,6 @@ class SliceFilter(BaseFilter):
         )
 
 
-class DashboardFilter(BaseFilter):
-    """
-    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, func):
-        Dash = models.Dashboard
-        User = ab_models.User
-        Slice = models.Slice
-        Favorites = models.FavStar
-
-        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(Dash.id)
-            .join(Dash.slices)
-            .filter(
-                and_(
-                    Dash.published == True,  # noqa
-                    or_(
-                        Slice.perm.in_(datasource_perms),
-                        Slice.schema_perm.in_(schema_perms),
-                        all_datasource_access,
-                    ),
-                )
-            )
-        )
-
-        users_favorite_dash_query = db.session.query(Favorites.obj_id).filter(
-            and_(
-                Favorites.user_id == User.get_user_id(),
-                Favorites.class_name == "Dashboard",
-            )
-        )
-        owner_ids_query = (
-            db.session.query(Dash.id)
-            .join(Dash.owners)
-            .filter(User.id == User.get_user_id())
-        )
-
-        query = query.filter(
-            or_(
-                Dash.id.in_(owner_ids_query),
-                Dash.id.in_(published_dash_query),
-                Dash.id.in_(users_favorite_dash_query),
-            )
-        )
-
-        return query
-
-
 if config["ENABLE_ACCESS_REQUEST"]:
 
     class AccessRequestsModelView(SupersetModelView, DeleteMixin):
@@ -495,116 +433,8 @@ class SliceAddView(SliceModelView):
 appbuilder.add_view_no_menu(SliceAddView)
 
 
-class DashboardModelView(SupersetModelView, DeleteMixin):
-    route_base = "/dashboard"
-    datamodel = SQLAInterface(models.Dashboard)
-
-    list_title = _("Dashboards")
-    show_title = _("Show Dashboard")
-    add_title = _("Add Dashboard")
-    edit_title = _("Edit Dashboard")
-
-    list_columns = ["dashboard_link", "creator", "published", "modified"]
-    order_columns = ["modified", "published"]
-    edit_columns = [
-        "dashboard_title",
-        "slug",
-        "owners",
-        "position_json",
-        "css",
-        "json_metadata",
-        "published",
-    ]
-    show_columns = edit_columns + ["table_names", "charts"]
-    search_columns = ("dashboard_title", "slug", "owners", "published")
-    add_columns = edit_columns
-    base_order = ("changed_on", "desc")
-    description_columns = {
-        "position_json": _(
-            "This json object describes the positioning of the widgets in "
-            "the dashboard. It is dynamically generated when adjusting "
-            "the widgets size and positions by using drag & drop in "
-            "the dashboard view"
-        ),
-        "css": _(
-            "The CSS for individual dashboards can be altered here, or "
-            "in the dashboard view where changes are immediately "
-            "visible"
-        ),
-        "slug": _("To get a readable URL for your dashboard"),
-        "json_metadata": _(
-            "This JSON object is generated dynamically when clicking "
-            "the save or overwrite button in the dashboard view. It "
-            "is exposed here for reference and for power users who may "
-            "want to alter specific parameters."
-        ),
-        "owners": _("Owners is a list of users who can alter the dashboard."),
-        "published": _(
-            "Determines whether or not this dashboard is "
-            "visible in the list of all dashboards"
-        ),
-    }
-    base_filters = [["slice", DashboardFilter, lambda: []]]
-    label_columns = {
-        "dashboard_link": _("Dashboard"),
-        "dashboard_title": _("Title"),
-        "slug": _("Slug"),
-        "charts": _("Charts"),
-        "owners": _("Owners"),
-        "creator": _("Creator"),
-        "modified": _("Modified"),
-        "position_json": _("Position JSON"),
-        "css": _("CSS"),
-        "json_metadata": _("JSON Metadata"),
-        "table_names": _("Underlying Tables"),
-    }
-
-    def pre_add(self, obj):
-        obj.slug = obj.slug or None
-        if obj.slug:
-            obj.slug = obj.slug.strip()
-            obj.slug = obj.slug.replace(" ", "-")
-            obj.slug = re.sub(r"[^\w\-]+", "", obj.slug)
-        if g.user not in obj.owners:
-            obj.owners.append(g.user)
-        utils.validate_json(obj.json_metadata)
-        utils.validate_json(obj.position_json)
-        owners = [o for o in obj.owners]
-        for slc in obj.slices:
-            slc.owners = list(set(owners) | set(slc.owners))
-
-    def pre_update(self, obj):
-        check_ownership(obj)
-        self.pre_add(obj)
-
-    def pre_delete(self, obj):
-        check_ownership(obj)
-
-    @action("mulexport", __("Export"), __("Export dashboards?"), "fa-database")
-    def mulexport(self, items):
-        if not isinstance(items, list):
-            items = [items]
-        ids = "".join("&id={}".format(d.id) for d in items)
-        return redirect("/dashboard/export_dashboards_form?{}".format(ids[1:]))
-
-    @event_logger.log_this
-    @has_access
-    @expose("/export_dashboards_form")
-    def download_dashboards(self):
-        if request.args.get("action") == "go":
-            ids = request.args.getlist("id")
-            return Response(
-                models.Dashboard.export_dashboards(ids),
-                headers=generate_download_headers("json"),
-                mimetype="application/text",
-            )
-        return self.render_template(
-            "superset/export_dashboards.html", dashboards_url="/dashboard/list"
-        )
-
-
 appbuilder.add_view(
-    DashboardModelView,
+    dash_views.DashboardModelView,
     "Dashboards",
     label=__("Dashboards"),
     icon="fa-dashboard",
@@ -613,47 +443,6 @@ appbuilder.add_view(
 )
 
 
-class DashboardModelViewAsync(DashboardModelView):
-    route_base = "/dashboardasync"
-    list_columns = [
-        "id",
-        "dashboard_link",
-        "creator",
-        "modified",
-        "dashboard_title",
-        "changed_on",
-        "url",
-        "changed_by_name",
-    ]
-    label_columns = {
-        "dashboard_link": _("Dashboard"),
-        "dashboard_title": _("Title"),
-        "creator": _("Creator"),
-        "modified": _("Modified"),
-    }
-
-
-appbuilder.add_view_no_menu(DashboardModelViewAsync)
-
-
-class DashboardAddView(DashboardModelView):
-    route_base = "/dashboardaddview"
-    list_columns = [
-        "id",
-        "dashboard_link",
-        "creator",
-        "modified",
-        "dashboard_title",
-        "changed_on",
-        "url",
-        "changed_by_name",
-    ]
-    show_columns = list(set(DashboardModelView.edit_columns + list_columns))
-
-
-appbuilder.add_view_no_menu(DashboardAddView)
-
-
 @talisman(force_https=False)
 @app.route("/health")
 def health():
diff --git a/superset/views/dashboard.py b/superset/views/dashboard.py
deleted file mode 100644
index e09e201..0000000
--- a/superset/views/dashboard.py
+++ /dev/null
@@ -1,42 +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.
-from flask import g, redirect
-from flask_appbuilder import expose
-from flask_appbuilder.security.decorators import has_access
-
-from superset import appbuilder, db
-from superset.models import core as models
-
-from .base import BaseSupersetView
-
-
-class Dashboard(BaseSupersetView):
-    """The base views for Superset!"""
-
-    @has_access
-    @expose("/new/")
-    def new(self):  # pylint: disable=no-self-use
-        """Creates a new, blank dashboard and redirects to it in edit mode"""
-        new_dashboard = models.Dashboard(
-            dashboard_title="[ untitled dashboard ]", owners=[g.user]
-        )
-        db.session.add(new_dashboard)
-        db.session.commit()
-        return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true")
-
-
-appbuilder.add_view_no_menu(Dashboard)
diff --git a/superset/views/dashboard/__init__.py b/superset/views/dashboard/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/superset/views/dashboard/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/superset/views/dashboard/api.py b/superset/views/dashboard/api.py
new file mode 100644
index 0000000..284fe4b
--- /dev/null
+++ b/superset/views/dashboard/api.py
@@ -0,0 +1,301 @@
+# 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 flask import current_app, g, request
+from flask_appbuilder import ModelRestApi
+from flask_appbuilder.api import expose, protect, safe
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from marshmallow import fields, post_load, pre_load, Schema, ValidationError
+from marshmallow.validate import Length
+from sqlalchemy.exc import SQLAlchemyError
+
+import superset.models.core as models
+from superset import appbuilder
+from superset.exceptions import SupersetException
+from superset.utils import core as utils
+from superset.views.base import BaseSupersetSchema
+
+from .mixin import DashboardMixin
+
+
+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()
+    filter_immune_slice_fields = fields.Dict()
+    stagger_refresh = fields.Boolean()
+    stagger_time = fields.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)
+
+
+def validate_slug_uniqueness(value):
+    # slug is not required but must be unique
+    if value:
+        item = (
+            current_app.appbuilder.get_session.query(models.Dashboard.id)
+            .filter_by(slug=value)
+            .one_or_none()
+        )
+        if item:
+            raise ValidationError("Must be unique")
+
+
+def validate_owners(value):
+    owner = (
+        current_app.appbuilder.get_session.query(
+            current_app.appbuilder.sm.user_model.id
+        )
+        .filter_by(id=value)
+        .one_or_none()
+    )
+    if not owner:
+        raise ValidationError(f"User {value} does not exist")
+
+
+class BaseDashboardSchema(BaseSupersetSchema):
+    @staticmethod
+    def set_owners(instance, owners):
+        owner_objs = list()
+        if g.user.id not in owners:
+            owners.append(g.user.id)
+        for owner_id in owners:
+            user = current_app.appbuilder.get_session.query(
+                current_app.appbuilder.sm.user_model
+            ).get(owner_id)
+            owner_objs.append(user)
+        instance.owners = owner_objs
+
+    @pre_load
+    def pre_load(self, data):  # pylint: disable=no-self-use
+        data["slug"] = data.get("slug")
+        data["owners"] = data.get("owners", [])
+        if data["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), validate_slug_uniqueness]
+    )
+    owners = fields.List(fields.Integer(validate=validate_owners))
+    position_json = fields.String(validate=validate_json)
+    css = fields.String()
+    json_metadata = fields.String(validate=validate_json_metadata)
+    published = fields.Boolean()
+
+    @post_load
+    def make_object(self, data):  # pylint: disable=no-self-use
+        instance = models.Dashboard()
+        self.set_owners(instance, data["owners"])
+        for field in data:
+            if field == "owners":
+                self.set_owners(instance, data["owners"])
+            else:
+                setattr(instance, field, data.get(field))
+        return instance
+
+
+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_owners))
+    position_json = fields.String(validate=validate_json)
+    css = fields.String()
+    json_metadata = fields.String(validate=validate_json_metadata)
+    published = fields.Boolean()
+
+    @post_load
+    def make_object(self, data):  # pylint: disable=no-self-use
+        if "owners" not in data and g.user not in self.instance.owners:
+            self.instance.owners.append(g.user)
+        for field in data:
+            if field == "owners":
+                self.set_owners(self.instance, data["owners"])
+            else:
+                setattr(self.instance, field, data.get(field))
+        for slc in self.instance.slices:
+            slc.owners = list(set(self.instance.owners) | set(slc.owners))
+        return self.instance
+
+
+class DashboardRestApi(DashboardMixin, ModelRestApi):
+    datamodel = SQLAInterface(models.Dashboard)
+
+    resource_name = "dashboard"
+    allow_browser_login = True
+
+    class_permission_name = "DashboardModelView"
+    method_permission_name = {
+        "get_list": "list",
+        "get": "show",
+        "post": "add",
+        "put": "edit",
+        "delete": "delete",
+        "info": "list",
+    }
+    exclude_route_methods = ("info",)
+    show_columns = [
+        "dashboard_title",
+        "slug",
+        "owners.id",
+        "owners.username",
+        "position_json",
+        "css",
+        "json_metadata",
+        "published",
+        "table_names",
+        "charts",
+    ]
+
+    add_model_schema = DashboardPostSchema()
+    edit_model_schema = DashboardPutSchema()
+
+    @expose("/", methods=["POST"])
+    @protect()
+    @safe
+    def post(self):
+        """Creates a new dashboard
+        ---
+        post:
+          requestBody:
+            description: Model 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: string
+                      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_422(message=item.errors)
+        try:
+            self.datamodel.add(item.data, raise_exception=True)
+            return self.response(
+                201,
+                result=self.add_model_schema.dump(item.data, many=False).data,
+                id=item.data.id,
+            )
+        except SQLAlchemyError as e:
+            return self.response_422(message=str(e))
+
+    @expose("/<pk>", methods=["PUT"])
+    @protect()
+    @safe
+    def put(self, pk):
+        """Changes a dashboard
+        ---
+        put:
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          requestBody:
+            description: Model schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+          responses:
+            200:
+              description: Item changed
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+            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'
+        """
+        if not request.is_json:
+            self.response_400(message="Request is not JSON")
+        item = self.datamodel.get(pk, self._base_filters)
+        if not item:
+            return self.response_404()
+
+        item = self.edit_model_schema.load(request.json, instance=item)
+        if item.errors:
+            return self.response_422(message=item.errors)
+        try:
+            self.datamodel.edit(item.data, raise_exception=True)
+            return self.response(
+                200, result=self.edit_model_schema.dump(item.data, many=False).data
+            )
+        except SQLAlchemyError as e:
+            return self.response_422(message=str(e))
+
+
+appbuilder.add_api(DashboardRestApi)
diff --git a/superset/views/dashboard/filters.py b/superset/views/dashboard/filters.py
new file mode 100644
index 0000000..447e554
--- /dev/null
+++ b/superset/views/dashboard/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 Dashboard, FavStar, Slice
+from superset.views.base import BaseFilter
+
+from ..base import 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/views/dashboard/mixin.py b/superset/views/dashboard/mixin.py
new file mode 100644
index 0000000..b3b5a8d
--- /dev/null
+++ b/superset/views/dashboard/mixin.py
@@ -0,0 +1,82 @@
+# 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 .filters import DashboardFilter
+
+
+class DashboardMixin:  # pylint: disable=too-few-public-methods
+
+    list_title = _("Dashboards")
+    show_title = _("Show Dashboard")
+    add_title = _("Add Dashboard")
+    edit_title = _("Edit Dashboard")
+
+    list_columns = ["dashboard_link", "creator", "published", "modified"]
+    order_columns = ["dashboard_link", "modified", "published"]
+    edit_columns = [
+        "dashboard_title",
+        "slug",
+        "owners",
+        "position_json",
+        "css",
+        "json_metadata",
+        "published",
+    ]
+    show_columns = edit_columns + ["table_names", "charts"]
+    search_columns = ("dashboard_title", "slug", "owners", "published")
+    add_columns = edit_columns
+    base_order = ("changed_on", "desc")
+    description_columns = {
+        "position_json": _(
+            "This json object describes the positioning of the widgets in "
+            "the dashboard. It is dynamically generated when adjusting "
+            "the widgets size and positions by using drag & drop in "
+            "the dashboard view"
+        ),
+        "css": _(
+            "The CSS for individual dashboards can be altered here, or "
+            "in the dashboard view where changes are immediately "
+            "visible"
+        ),
+        "slug": _("To get a readable URL for your dashboard"),
+        "json_metadata": _(
+            "This JSON object is generated dynamically when clicking "
+            "the save or overwrite button in the dashboard view. It "
+            "is exposed here for reference and for power users who may "
+            "want to alter specific parameters."
+        ),
+        "owners": _("Owners is a list of users who can alter the dashboard."),
+        "published": _(
+            "Determines whether or not this dashboard is "
+            "visible in the list of all dashboards"
+        ),
+    }
+    base_filters = [["slice", DashboardFilter, lambda: []]]
+    label_columns = {
+        "dashboard_link": _("Dashboard"),
+        "dashboard_title": _("Title"),
+        "slug": _("Slug"),
+        "charts": _("Charts"),
+        "owners": _("Owners"),
+        "creator": _("Creator"),
+        "modified": _("Modified"),
+        "position_json": _("Position JSON"),
+        "css": _("CSS"),
+        "json_metadata": _("JSON Metadata"),
+        "table_names": _("Underlying Tables"),
+    }
diff --git a/superset/views/dashboard/views.py b/superset/views/dashboard/views.py
new file mode 100644
index 0000000..bef9e7b
--- /dev/null
+++ b/superset/views/dashboard/views.py
@@ -0,0 +1,147 @@
+# 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 re
+
+from flask import g, redirect, request, Response
+from flask_appbuilder import expose
+from flask_appbuilder.actions import action
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_appbuilder.security.decorators import has_access
+from flask_babel import gettext as __, lazy_gettext as _
+
+import superset.models.core as models
+from superset import appbuilder, db, event_logger
+from superset.utils import core as utils
+
+from ..base import (
+    BaseSupersetView,
+    check_ownership,
+    DeleteMixin,
+    generate_download_headers,
+    SupersetModelView,
+)
+from .mixin import DashboardMixin
+
+
+class DashboardModelView(
+    DashboardMixin, SupersetModelView, DeleteMixin
+):  # pylint: disable=too-many-ancestors
+    route_base = "/dashboard"
+    datamodel = SQLAInterface(models.Dashboard)
+
+    @action("mulexport", __("Export"), __("Export dashboards?"), "fa-database")
+    @staticmethod
+    def mulexport(items):
+        if not isinstance(items, list):
+            items = [items]
+        ids = "".join("&id={}".format(d.id) for d in items)
+        return redirect("/dashboard/export_dashboards_form?{}".format(ids[1:]))
+
+    @event_logger.log_this
+    @has_access
+    @expose("/export_dashboards_form")
+    def download_dashboards(self):
+        if request.args.get("action") == "go":
+            ids = request.args.getlist("id")
+            return Response(
+                models.Dashboard.export_dashboards(ids),
+                headers=generate_download_headers("json"),
+                mimetype="application/text",
+            )
+        return self.render_template(
+            "superset/export_dashboards.html", dashboards_url="/dashboard/list"
+        )
+
+    def pre_add(self, item):
+        item.slug = item.slug or None
+        if item.slug:
+            item.slug = item.slug.strip()
+            item.slug = item.slug.replace(" ", "-")
+            item.slug = re.sub(r"[^\w\-]+", "", item.slug)
+        if g.user not in item.owners:
+            item.owners.append(g.user)
+        utils.validate_json(item.json_metadata)
+        utils.validate_json(item.position_json)
+        owners = [o for o in item.owners]
+        for slc in item.slices:
+            slc.owners = list(set(owners) | set(slc.owners))
+
+    def pre_update(self, item):
+        check_ownership(item)
+        self.pre_add(item)
+
+    def pre_delete(self, item):  # pylint: disable=no-self-use
+        check_ownership(item)
+
+
+class Dashboard(BaseSupersetView):
+    """The base views for Superset!"""
+
+    @has_access
+    @expose("/new/")
+    def new(self):  # pylint: disable=no-self-use
+        """Creates a new, blank dashboard and redirects to it in edit mode"""
+        new_dashboard = models.Dashboard(
+            dashboard_title="[ untitled dashboard ]", owners=[g.user]
+        )
+        db.session.add(new_dashboard)
+        db.session.commit()
+        return redirect(f"/superset/dashboard/{new_dashboard.id}/?edit=true")
+
+
+appbuilder.add_view_no_menu(Dashboard)
+
+
+class DashboardModelViewAsync(DashboardModelView):  # pylint: disable=too-many-ancestors
+    route_base = "/dashboardasync"
+    list_columns = [
+        "id",
+        "dashboard_link",
+        "creator",
+        "modified",
+        "dashboard_title",
+        "changed_on",
+        "url",
+        "changed_by_name",
+    ]
+    label_columns = {
+        "dashboard_link": _("Dashboard"),
+        "dashboard_title": _("Title"),
+        "creator": _("Creator"),
+        "modified": _("Modified"),
+    }
+
+
+appbuilder.add_view_no_menu(DashboardModelViewAsync)
+
+
+class DashboardAddView(DashboardModelView):  # pylint: disable=too-many-ancestors
+    route_base = "/dashboardaddview"
+    list_columns = [
+        "id",
+        "dashboard_link",
+        "creator",
+        "modified",
+        "dashboard_title",
+        "changed_on",
+        "url",
+        "changed_by_name",
+    ]
+    show_columns = list(set(DashboardModelView.edit_columns + list_columns))
+
+
+appbuilder.add_view_no_menu(DashboardAddView)
diff --git a/tests/base_tests.py b/tests/base_tests.py
index 6d8adc9..666102c 100644
--- a/tests/base_tests.py
+++ b/tests/base_tests.py
@@ -18,6 +18,7 @@
 """Unit tests for Superset"""
 import imp
 import json
+from typing import Union
 from unittest.mock import Mock
 
 import pandas as pd
@@ -43,6 +44,20 @@ class SupersetTestCase(TestCase):
     def create_app(self):
         return app
 
+    @staticmethod
+    def create_user(
+        username: str,
+        password: str,
+        role_name: str,
+        first_name: str = "admin",
+        last_name: str = "user",
+        email: str = "admin@fab.org",
+    ) -> Union[ab_models.User, bool]:
+        role_admin = security_manager.find_role(role_name)
+        return security_manager.add_user(
+            username, first_name, last_name, email, role_admin, password
+        )
+
     @classmethod
     def create_druid_test_objects(cls):
         # create druid cluster and druid datasources
diff --git a/tests/dashboard_api_tests.py b/tests/dashboard_api_tests.py
new file mode 100644
index 0000000..1e1a3ee
--- /dev/null
+++ b/tests/dashboard_api_tests.py
@@ -0,0 +1,355 @@
+# 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.
+"""Unit tests for Superset"""
+import json
+from typing import List
+
+from flask_appbuilder.security.sqla import models as ab_models
+
+from superset import db, security_manager
+from superset.models import core as models
+
+from .base_tests import SupersetTestCase
+
+
+class DashboardApiTests(SupersetTestCase):
+    def __init__(self, *args, **kwargs):
+        super(DashboardApiTests, self).__init__(*args, **kwargs)
+
+    def insert_dashboard(
+        self,
+        dashboard_title: str,
+        slug: str,
+        owners: List[int],
+        position_json: str = "",
+        css: str = "",
+        json_metadata: str = "",
+        published: bool = False,
+    ) -> models.Dashboard:
+        obj_owners = list()
+        for owner in owners:
+            user = db.session.query(security_manager.user_model).get(owner)
+            obj_owners.append(user)
+        dashboard = models.Dashboard(
+            dashboard_title=dashboard_title,
+            slug=slug,
+            owners=obj_owners,
+            position_json=position_json,
+            css=css,
+            json_metadata=json_metadata,
+            published=published,
+        )
+        db.session.add(dashboard)
+        db.session.commit()
+        return dashboard
+
+    def get_user(self, username: str) -> ab_models.User:
+        user = (
+            db.session.query(security_manager.user_model)
+            .filter_by(username=username)
+            .one_or_none()
+        )
+        return user
+
+    def test_delete_dashboard(self):
+        """
+            Dashboard API: Test delete
+        """
+        admin_id = self.get_user("admin").id
+        dashboard_id = self.insert_dashboard("title", "slug1", [admin_id]).id
+        self.login(username="admin")
+        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)
+        self.assertEqual(model, None)
+
+    def test_delete_not_found_dashboard(self):
+        """
+            Dashboard API: Test not found delete
+        """
+        self.login(username="admin")
+        dashboard_id = 1000
+        uri = f"api/v1/dashboard/{dashboard_id}"
+        rv = self.client.delete(uri)
+        self.assertEqual(rv.status_code, 404)
+
+    def test_delete_dashboard_admin_not_owned(self):
+        """
+            Dashboard API: Test admin delete not owned
+        """
+        gamma_id = self.get_user("gamma").id
+        dashboard_id = self.insert_dashboard("title", "slug1", [gamma_id]).id
+
+        self.login(username="admin")
+        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)
+        self.assertEqual(model, None)
+
+    def test_delete_dashboard_not_owned(self):
+        """
+            Dashboard API: Test delete try not owned
+        """
+        user_alpha1 = self.create_user(
+            "alpha1", "password", "Alpha", email="alpha1@superset.org"
+        )
+        user_alpha2 = self.create_user(
+            "alpha2", "password", "Alpha", email="alpha2@superset.org"
+        )
+        dashboard = self.insert_dashboard("title", "slug1", [user_alpha1.id])
+        self.login(username="alpha2", password="password")
+        uri = f"api/v1/dashboard/{dashboard.id}"
+        rv = self.client.delete(uri)
+        self.assertEqual(rv.status_code, 404)
+        db.session.delete(dashboard)
+        db.session.delete(user_alpha1)
+        db.session.delete(user_alpha2)
+        db.session.commit()
+
+    def test_create_dashboard(self):
+        """
+            Dashboard API: Test create dashboard
+        """
+        admin_id = self.get_user("admin").id
+        dashboard_data = {
+            "dashboard_title": "title1",
+            "slug": "slug1",
+            "owners": [admin_id],
+            "position_json": '{"a": "A"}',
+            "css": "css",
+            "json_metadata": '{"b": "B"}',
+            "published": True,
+        }
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        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"))
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_create_simple_dashboard(self):
+        """
+            Dashboard API: Test create simple dashboard
+        """
+        dashboard_data = {"dashboard_title": "title1"}
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        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"))
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_create_dashboard_empty(self):
+        """
+            Dashboard API: Test create empty
+        """
+        dashboard_data = {}
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        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"))
+        db.session.delete(model)
+        db.session.commit()
+
+        dashboard_data = {"dashboard_title": ""}
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        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"))
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_create_dashboard_validate_title(self):
+        """
+            Dashboard API: Test create dashboard validate title
+        """
+        dashboard_data = {"dashboard_title": "a" * 600}
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        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": {"dashboard_title": ["Length must be between 0 and 500."]}
+        }
+        self.assertEqual(response, expected_response)
+
+    def test_create_dashboard_validate_slug(self):
+        """
+            Dashboard API: Test create validate slug
+        """
+        admin_id = self.get_user("admin").id
+        dashboard = self.insert_dashboard("title1", "slug1", [admin_id])
+        self.login(username="admin")
+
+        # Check for slug uniqueness
+        dashboard_data = {"dashboard_title": "title2", "slug": "slug1"}
+        uri = f"api/v1/dashboard/"
+        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": {"slug": ["Must be unique"]}}
+        self.assertEqual(response, expected_response)
+
+        # Check for slug max size
+        dashboard_data = {"dashboard_title": "title2", "slug": "a" * 256}
+        uri = f"api/v1/dashboard/"
+        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": {"slug": ["Length must be between 1 and 255."]}}
+        self.assertEqual(response, expected_response)
+
+        db.session.delete(dashboard)
+        db.session.commit()
+
+    def test_create_dashboard_validate_owners(self):
+        """
+            Dashboard API: Test create validate owners
+        """
+        dashboard_data = {"dashboard_title": "title1", "owners": [1000]}
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        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"]}}}
+        self.assertEqual(response, expected_response)
+
+    def test_create_dashboard_validate_json(self):
+        """
+            Dashboard API: Test create validate json
+        """
+        dashboard_data = {"dashboard_title": "title1", "position_json": '{"A:"a"}'}
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        rv = self.client.post(uri, json=dashboard_data)
+        self.assertEqual(rv.status_code, 422)
+
+        dashboard_data = {"dashboard_title": "title1", "json_metadata": '{"A:"a"}'}
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        rv = self.client.post(uri, json=dashboard_data)
+        self.assertEqual(rv.status_code, 422)
+
+        dashboard_data = {
+            "dashboard_title": "title1",
+            "json_metadata": '{"refresh_frequency": "A"}',
+        }
+        self.login(username="admin")
+        uri = f"api/v1/dashboard/"
+        rv = self.client.post(uri, json=dashboard_data)
+        self.assertEqual(rv.status_code, 422)
+
+    def test_update_dashboard(self):
+        """
+            Dashboard API: Test update
+        """
+        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)
+        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)
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_update_dashboard_new_owner(self):
+        """
+            Dashboard API: Test update set new owner to current user
+        """
+        gamma_id = self.get_user("gamma").id
+        admin = self.get_user("admin")
+        dashboard_id = self.insert_dashboard("title1", "slug1", [gamma_id]).id
+        dashboard_data = {"dashboard_title": "title1_changed"}
+        self.login(username="admin")
+        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)
+        self.assertIn(admin, model.owners)
+        for slc in model.slices:
+            self.assertIn(admin, slc.owners)
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_update_dashboard_slug_formatting(self):
+        """
+            Dashboard API: Test update slug formatting
+        """
+        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"}
+        self.login(username="admin")
+        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)
+        self.assertEqual(model.dashboard_title, "title1_changed")
+        self.assertEqual(model.slug, "slug1-changed")
+        db.session.delete(model)
+        db.session.commit()
+
+    def test_update_dashboard_not_owned(self):
+        """
+            Dashboard API: Test update slug formatting
+        """
+        """
+            Dashboard API: Test delete try not owned
+        """
+        user_alpha1 = self.create_user(
+            "alpha1", "password", "Alpha", email="alpha1@superset.org"
+        )
+        user_alpha2 = self.create_user(
+            "alpha2", "password", "Alpha", email="alpha2@superset.org"
+        )
+        dashboard = self.insert_dashboard("title", "slug1", [user_alpha1.id])
+        self.login(username="alpha2", password="password")
+        dashboard_data = {"dashboard_title": "title1_changed", "slug": "slug1 changed"}
+        uri = f"api/v1/dashboard/{dashboard.id}"
+        rv = self.client.put(uri, json=dashboard_data)
+        self.assertEqual(rv.status_code, 404)
+        db.session.delete(dashboard)
+        db.session.delete(user_alpha1)
+        db.session.delete(user_alpha2)
+        db.session.commit()