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()