[dashboard] feat: REST API (#8694)

This commit is contained in:
Daniel Vaz Gaspar 2019-12-16 21:10:33 +00:00 committed by GitHub
parent 1719c4b5a3
commit 2f2ac00a09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1029 additions and 259 deletions

View File

@ -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>')

View File

@ -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")

View File

@ -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):

View File

@ -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

View File

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

View File

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

View File

@ -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.

View File

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

View File

@ -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

View File

@ -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"),
}

View File

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

View File

@ -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

View File

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