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 <danielvazgas...@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 = "ad...@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="alp...@superset.org" + ) + user_alpha2 = self.create_user( + "alpha2", "password", "Alpha", email="alp...@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="alp...@superset.org" + ) + user_alpha2 = self.create_user( + "alpha2", "password", "Alpha", email="alp...@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()