[dashboard] Refactor API using SIP-35 (#9315)

* [dashboard] Refactor API using SIP-35

* [dashboard] Fix, import

* [dashboard] more tests

* [dashboards] a misc of improvements

* [charts] Fix, DAO and tests

* [dashboards] small exceptions refactor

* [dashboards] lint

* [dashboards] Improves comments on base classes

* [dashboards] lint
This commit is contained in:
Daniel Vaz Gaspar 2020-03-20 16:32:03 +00:00 committed by GitHub
parent ccf21f6f1b
commit c34df6b7b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1475 additions and 403 deletions

View File

@ -152,7 +152,7 @@ class SupersetAppInitializer:
)
from superset.views.chart.api import ChartRestApi
from superset.views.chart.views import SliceModelView, SliceAsync
from superset.views.dashboard.api import DashboardRestApi
from superset.dashboards.api import DashboardRestApi
from superset.views.dashboard.views import (
DashboardModelView,
Dashboard,

View File

@ -26,7 +26,7 @@ class BaseCommand(ABC):
def run(self):
"""
Run executes the command. Can raise command exceptions
:return:
:raises: CommandException
"""
pass
@ -35,5 +35,6 @@ class BaseCommand(ABC):
"""
Validate is normally called by run to validate data.
Will raise exception if validation fails
:raises: CommandException
"""
pass

View File

@ -14,30 +14,25 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import List, Optional
from typing import List
from flask_babel import lazy_gettext as _
from marshmallow import ValidationError
from superset.exceptions import SupersetException
class CommandException(Exception):
class CommandException(SupersetException):
""" Common base class for Command exceptions. """
message = ""
def __init__(self, message: str = "", exception: Optional[Exception] = None):
if message:
self.message = message
self._exception = exception
super().__init__(self.message)
@property
def exception(self):
return self._exception
pass
class CommandInvalidError(CommandException):
""" Common base class for Command Invalid errors. """
status = 422
def __init__(self, message=""):
self._invalid_exceptions = list()
super().__init__(self.message)
@ -56,16 +51,27 @@ class CommandInvalidError(CommandException):
class UpdateFailedError(CommandException):
status = 500
message = "Command update failed"
class CreateFailedError(CommandException):
status = 500
message = "Command create failed"
class DeleteFailedError(CommandException):
status = 500
message = "Command delete failed"
class ForbiddenError(CommandException):
status = 500
message = "Action is forbidden"
class OwnersNotFoundValidationError(ValidationError):
status = 422
def __init__(self):
super().__init__(_("Owners are invalid"), field_names=["owners"])

View File

@ -18,15 +18,14 @@ from typing import List, Optional
from flask_appbuilder.security.sqla.models import User
from superset.datasets.commands.exceptions import OwnersNotFoundValidationError
from superset.datasets.dao import DatasetDAO
from superset.commands.exceptions import OwnersNotFoundValidationError
from superset.extensions import security_manager
def populate_owners(user: User, owners_ids: Optional[List[int]] = None) -> List[User]:
"""
Helper function for commands, will fetch all users from owners id's
Can raise ValidationError
:param user: The current user
:param owners_ids: A List of owners by id's
"""
@ -36,7 +35,7 @@ def populate_owners(user: User, owners_ids: Optional[List[int]] = None) -> List[
if user.id not in owners_ids:
owners.append(user)
for owner_id in owners_ids:
owner = DatasetDAO.get_owner_by_id(owner_id)
owner = security_manager.get_user_by_id(owner_id)
if not owner:
raise OwnersNotFoundValidationError()
owners.append(owner)

16
superset/dao/__init__.py Normal file
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.

111
superset/dao/base.py Normal file
View File

@ -0,0 +1,111 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Dict, Optional
from flask_appbuilder.models.filters import BaseFilter
from flask_appbuilder.models.sqla import Model
from flask_appbuilder.models.sqla.interface import SQLAInterface
from sqlalchemy.exc import SQLAlchemyError
from superset.dao.exceptions import (
DAOConfigError,
DAOCreateFailedError,
DAODeleteFailedError,
DAOUpdateFailedError,
)
from superset.extensions import db
class BaseDAO:
"""
Base DAO, implement base CRUD sqlalchemy operations
"""
model_cls: Optional[Model] = None
"""
Child classes need to state the Model class so they don't need to implement basic
create, update and delete methods
""" # pylint: disable=pointless-string-statement
base_filter: Optional[BaseFilter] = None
"""
Child classes can register base filtering to be aplied to all filter methods
""" # pylint: disable=pointless-string-statement
@classmethod
def find_by_id(cls, model_id: int) -> Model:
"""
Retrives a model by id, if defined applies `base_filter`
"""
query = db.session.query(cls.model_cls)
if cls.base_filter:
data_model = SQLAInterface(cls.model_cls, db.session)
query = cls.base_filter( # pylint: disable=not-callable
"id", data_model
).apply(query, None)
return query.filter_by(id=model_id).one_or_none()
@classmethod
def create(cls, properties: Dict, commit=True) -> Optional[Model]:
"""
Generic for creating models
:raises: DAOCreateFailedError
"""
if cls.model_cls is None:
raise DAOConfigError()
model = cls.model_cls() # pylint: disable=not-callable
for key, value in properties.items():
setattr(model, key, value)
try:
db.session.add(model)
if commit:
db.session.commit()
except SQLAlchemyError as e: # pragma: no cover
db.session.rollback()
raise DAOCreateFailedError(exception=e)
return model
@classmethod
def update(cls, model: Model, properties: Dict, commit=True) -> Optional[Model]:
"""
Generic update a model
:raises: DAOCreateFailedError
"""
for key, value in properties.items():
setattr(model, key, value)
try:
db.session.merge(model)
if commit:
db.session.commit()
except SQLAlchemyError as e: # pragma: no cover
db.session.rollback()
raise DAOUpdateFailedError(exception=e)
return model
@classmethod
def delete(cls, model: Model, commit=True):
"""
Generic delete a model
:raises: DAOCreateFailedError
"""
try:
db.session.delete(model)
if commit:
db.session.commit()
except SQLAlchemyError as e: # pragma: no cover
db.session.rollback()
raise DAODeleteFailedError(exception=e)
return model

View File

@ -0,0 +1,57 @@
# 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 superset.exceptions import SupersetException
class DAOException(SupersetException):
"""
Base DAO exception class
"""
pass
class DAOCreateFailedError(DAOException):
"""
DAO Create failed
"""
message = "Create failed"
class DAOUpdateFailedError(DAOException):
"""
DAO Update failed
"""
message = "Updated failed"
class DAODeleteFailedError(DAOException):
"""
DAO Delete failed
"""
message = "Delete failed"
class DAOConfigError(DAOException):
"""
DAO is miss configured
"""
message = "DAO is not configured correctly missing model definition"

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.

391
superset/dashboards/api.py Normal file
View File

@ -0,0 +1,391 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from flask import g, make_response, request, Response
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
from superset.constants import RouteMethod
from superset.dashboards.commands.bulk_delete import BulkDeleteDashboardCommand
from superset.dashboards.commands.create import CreateDashboardCommand
from superset.dashboards.commands.delete import DeleteDashboardCommand
from superset.dashboards.commands.exceptions import (
DashboardBulkDeleteFailedError,
DashboardCreateFailedError,
DashboardDeleteFailedError,
DashboardForbiddenError,
DashboardInvalidError,
DashboardNotFoundError,
DashboardUpdateFailedError,
)
from superset.dashboards.commands.update import UpdateDashboardCommand
from superset.dashboards.filters import DashboardFilter
from superset.dashboards.schemas import (
DashboardPostSchema,
DashboardPutSchema,
get_delete_ids_schema,
get_export_ids_schema,
)
from superset.models.dashboard import Dashboard
from superset.views.base import generate_download_headers
from superset.views.base_api import BaseSupersetModelRestApi
logger = logging.getLogger(__name__)
class DashboardRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(Dashboard)
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
RouteMethod.EXPORT,
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
}
resource_name = "dashboard"
allow_browser_login = True
class_permission_name = "DashboardModelView"
show_columns = [
"id",
"charts",
"css",
"dashboard_title",
"json_metadata",
"owners.id",
"owners.username",
"changed_by_name",
"changed_by_url",
"changed_by.username",
"changed_on",
"position_json",
"published",
"url",
"slug",
"table_names",
]
order_columns = ["dashboard_title", "changed_on", "published", "changed_by_fk"]
list_columns = [
"changed_by_name",
"changed_by_url",
"changed_by.username",
"changed_on",
"dashboard_title",
"id",
"published",
"slug",
"url",
]
edit_columns = [
"dashboard_title",
"slug",
"owners",
"position_json",
"css",
"json_metadata",
"published",
]
search_columns = ("dashboard_title", "slug", "owners", "published")
add_columns = edit_columns
base_order = ("changed_on", "desc")
add_model_schema = DashboardPostSchema()
edit_model_schema = DashboardPutSchema()
base_filters = [["slice", DashboardFilter, lambda: []]]
openapi_spec_tag = "Dashboards"
order_rel_fields = {
"slices": ("slice_name", "asc"),
"owners": ("first_name", "asc"),
}
filter_rel_fields_field = {"owners": "first_name"}
allowed_rel_fields = {"owners"}
@expose("/", methods=["POST"])
@protect()
@safe
def post(self) -> Response:
"""Creates a new Dashboard
---
post:
description: >-
Create a new Dashboard
requestBody:
description: Dashboard schema
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
responses:
201:
description: Dashboard added
content:
application/json:
schema:
type: object
properties:
id:
type: number
result:
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
if not request.is_json:
return self.response_400(message="Request is not JSON")
item = self.add_model_schema.load(request.json)
# This validates custom Schema with custom validations
if item.errors:
return self.response_400(message=item.errors)
try:
new_model = CreateDashboardCommand(g.user, item.data).run()
return self.response(201, id=new_model.id, result=item.data)
except DashboardInvalidError as e:
return self.response_422(message=e.normalized_messages())
except DashboardCreateFailedError as e:
logger.error(f"Error creating model {self.__class__.__name__}: {e}")
return self.response_422(message=str(e))
@expose("/<pk>", methods=["PUT"])
@protect()
@safe
def put( # pylint: disable=too-many-return-statements, arguments-differ
self, pk: int
) -> Response:
"""Changes a Dashboard
---
put:
description: >-
Changes a Dashboard
parameters:
- in: path
schema:
type: integer
name: pk
requestBody:
description: Dashboard schema
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
responses:
200:
description: Dashboard changed
content:
application/json:
schema:
type: object
properties:
id:
type: number
result:
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
if not request.is_json:
return self.response_400(message="Request is not JSON")
item = self.edit_model_schema.load(request.json)
# This validates custom Schema with custom validations
if item.errors:
return self.response_400(message=item.errors)
try:
changed_model = UpdateDashboardCommand(g.user, pk, item.data).run()
return self.response(200, id=changed_model.id, result=item.data)
except DashboardNotFoundError:
return self.response_404()
except DashboardForbiddenError:
return self.response_403()
except DashboardInvalidError as e:
return self.response_422(message=e.normalized_messages())
except DashboardUpdateFailedError as e:
logger.error(f"Error updating model {self.__class__.__name__}: {e}")
return self.response_422(message=str(e))
@expose("/<pk>", methods=["DELETE"])
@protect()
@safe
def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ
"""Deletes a Dashboard
---
delete:
description: >-
Deletes a Dashboard
parameters:
- in: path
schema:
type: integer
name: pk
responses:
200:
description: Dashboard deleted
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
try:
DeleteDashboardCommand(g.user, pk).run()
return self.response(200, message="OK")
except DashboardNotFoundError:
return self.response_404()
except DashboardForbiddenError:
return self.response_403()
except DashboardDeleteFailedError as e:
logger.error(f"Error deleting model {self.__class__.__name__}: {e}")
return self.response_422(message=str(e))
@expose("/", methods=["DELETE"])
@protect()
@safe
@rison(get_delete_ids_schema)
def bulk_delete(self, **kwargs) -> Response: # pylint: disable=arguments-differ
"""Delete bulk Dashboards
---
delete:
description: >-
Deletes multiple Dashboards in a bulk operation
parameters:
- in: query
name: q
content:
application/json:
schema:
type: array
items:
type: integer
responses:
200:
description: Dashboard bulk delete
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
item_ids = kwargs["rison"]
try:
BulkDeleteDashboardCommand(g.user, item_ids).run()
return self.response(
200,
message=ngettext(
f"Deleted %(num)d dashboard",
f"Deleted %(num)d dashboards",
num=len(item_ids),
),
)
except DashboardNotFoundError:
return self.response_404()
except DashboardForbiddenError:
return self.response_403()
except DashboardBulkDeleteFailedError as e:
return self.response_422(message=str(e))
@expose("/export/", methods=["GET"])
@protect()
@safe
@rison(get_export_ids_schema)
def export(self, **kwargs):
"""Export dashboards
---
get:
description: >-
Exports multiple Dashboards and downloads them as YAML files
parameters:
- in: query
name: q
content:
application/json:
schema:
type: array
items:
type: integer
responses:
200:
description: Dashboard export
content:
text/plain:
schema:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
query = self.datamodel.session.query(Dashboard).filter(
Dashboard.id.in_(kwargs["rison"])
)
query = self._base_filters.apply_all(query)
ids = [item.id for item in query.all()]
if not ids:
return self.response_404()
export = Dashboard.export_dashboards(ids)
resp = make_response(export, 200)
resp.headers["Content-Disposition"] = generate_download_headers("json")[
"Content-Disposition"
]
return resp

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,61 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import List, Optional
from flask_appbuilder.security.sqla.models import User
from superset.commands.base import BaseCommand
from superset.commands.exceptions import DeleteFailedError
from superset.dashboards.commands.exceptions import (
DashboardBulkDeleteFailedError,
DashboardForbiddenError,
DashboardNotFoundError,
)
from superset.dashboards.dao import DashboardDAO
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.views.base import check_ownership
logger = logging.getLogger(__name__)
class BulkDeleteDashboardCommand(BaseCommand):
def __init__(self, user: User, model_ids: List[int]):
self._actor = user
self._model_ids = model_ids
self._models: Optional[List[Dashboard]] = None
def run(self):
self.validate()
try:
DashboardDAO.bulk_delete(self._models)
except DeleteFailedError as e:
logger.exception(e.exception)
raise DashboardBulkDeleteFailedError()
def validate(self) -> None:
# Validate/populate model exists
self._models = DashboardDAO.find_by_ids(self._model_ids)
if not self._models or len(self._models) != len(self._model_ids):
raise DashboardNotFoundError()
# Check ownership
for model in self._models:
try:
check_ownership(model)
except SupersetSecurityException:
raise DashboardForbiddenError()

View File

@ -0,0 +1,67 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Dict, List, Optional
from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.utils import populate_owners
from superset.dao.exceptions import DAOCreateFailedError
from superset.dashboards.commands.exceptions import (
DashboardCreateFailedError,
DashboardInvalidError,
DashboardSlugExistsValidationError,
)
from superset.dashboards.dao import DashboardDAO
logger = logging.getLogger(__name__)
class CreateDashboardCommand(BaseCommand):
def __init__(self, user: User, data: Dict):
self._actor = user
self._properties = data.copy()
def run(self):
self.validate()
try:
dashboard = DashboardDAO.create(self._properties)
except DAOCreateFailedError as e:
logger.exception(e.exception)
raise DashboardCreateFailedError()
return dashboard
def validate(self) -> None:
exceptions = list()
owner_ids: Optional[List[int]] = self._properties.get("owners")
slug: str = self._properties.get("slug", "")
# Validate slug uniqueness
if not DashboardDAO.validate_slug_uniqueness(slug):
exceptions.append(DashboardSlugExistsValidationError())
try:
owners = populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except ValidationError as e:
exceptions.append(e)
if exceptions:
exception = DashboardInvalidError()
exception.add_list(exceptions)
raise exception

View File

@ -0,0 +1,61 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Optional
from flask_appbuilder.security.sqla.models import User
from superset.commands.base import BaseCommand
from superset.dao.exceptions import DAODeleteFailedError
from superset.dashboards.commands.exceptions import (
DashboardDeleteFailedError,
DashboardForbiddenError,
DashboardNotFoundError,
)
from superset.dashboards.dao import DashboardDAO
from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.views.base import check_ownership
logger = logging.getLogger(__name__)
class DeleteDashboardCommand(BaseCommand):
def __init__(self, user: User, model_id: int):
self._actor = user
self._model_id = model_id
self._model: Optional[Dashboard] = None
def run(self):
self.validate()
try:
dashboard = DashboardDAO.delete(self._model)
except DAODeleteFailedError as e:
logger.exception(e.exception)
raise DashboardDeleteFailedError()
return dashboard
def validate(self) -> None:
# Validate/populate model exists
self._model = DashboardDAO.find_by_id(self._model_id)
if not self._model:
raise DashboardNotFoundError()
# Check ownership
try:
check_ownership(self._model)
except SupersetSecurityException:
raise DashboardForbiddenError()

View File

@ -0,0 +1,64 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from flask_babel import lazy_gettext as _
from marshmallow.validate import ValidationError
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
ForbiddenError,
UpdateFailedError,
)
class DashboardSlugExistsValidationError(ValidationError):
"""
Marshmallow validation error for dashboard slug already exists
"""
def __init__(self):
super().__init__(_("Must be unique"), field_names=["slug"])
class DashboardInvalidError(CommandInvalidError):
message = _("Dashboard parameters are invalid.")
class DashboardNotFoundError(CommandException):
message = _("Dashboard not found.")
class DashboardCreateFailedError(CreateFailedError):
message = _("Dashboard could not be created.")
class DashboardBulkDeleteFailedError(CreateFailedError):
message = _("Dashboards could not be deleted.")
class DashboardUpdateFailedError(UpdateFailedError):
message = _("Dashboard could not be updated.")
class DashboardDeleteFailedError(DeleteFailedError):
message = _("Dashboard could not be deleted.")
class DashboardForbiddenError(ForbiddenError):
message = _("Changing this Dashboard is forbidden")

View File

@ -0,0 +1,87 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Dict, List, Optional
from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.utils import populate_owners
from superset.connectors.sqla.models import SqlaTable
from superset.dao.exceptions import DAOUpdateFailedError
from superset.dashboards.commands.exceptions import (
DashboardForbiddenError,
DashboardInvalidError,
DashboardNotFoundError,
DashboardSlugExistsValidationError,
DashboardUpdateFailedError,
)
from superset.dashboards.dao import DashboardDAO
from superset.exceptions import SupersetSecurityException
from superset.views.base import check_ownership
logger = logging.getLogger(__name__)
class UpdateDashboardCommand(BaseCommand):
def __init__(self, user: User, model_id: int, data: Dict):
self._actor = user
self._model_id = model_id
self._properties = data.copy()
self._model: Optional[SqlaTable] = None
def run(self):
self.validate()
try:
dashboard = DashboardDAO.update(self._model, self._properties)
except DAOUpdateFailedError as e:
logger.exception(e.exception)
raise DashboardUpdateFailedError()
return dashboard
def validate(self) -> None:
exceptions: List[ValidationError] = []
owner_ids: Optional[List[int]] = self._properties.get("owners")
slug: str = self._properties.get("slug", "")
# Validate/populate model exists
self._model = DashboardDAO.find_by_id(self._model_id)
if not self._model:
raise DashboardNotFoundError()
# Check ownership
try:
check_ownership(self._model)
except SupersetSecurityException:
raise DashboardForbiddenError()
# Validate slug uniqueness
if not DashboardDAO.validate_update_slug_uniqueness(self._model_id, slug):
exceptions.append(DashboardSlugExistsValidationError())
# Validate/Populate owner
if owner_ids is None:
owner_ids = [owner.id for owner in self._model.owners]
try:
owners = populate_owners(self._actor, owner_ids)
self._properties["owners"] = owners
except ValidationError as e:
exceptions.append(e)
if exceptions:
exception = DashboardInvalidError()
exception.add_list(exceptions)
raise exception

View File

@ -0,0 +1,74 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import List
from flask_appbuilder.models.sqla.interface import SQLAInterface
from sqlalchemy.exc import SQLAlchemyError
from superset.dao.base import BaseDAO
from superset.dashboards.filters import DashboardFilter
from superset.extensions import db
from superset.models.dashboard import Dashboard
logger = logging.getLogger(__name__)
class DashboardDAO(BaseDAO):
model_cls = Dashboard
base_filter = DashboardFilter
@staticmethod
def find_by_ids(model_ids: List[int]) -> List[Dashboard]:
query = db.session.query(Dashboard).filter(Dashboard.id.in_(model_ids))
data_model = SQLAInterface(Dashboard, db.session)
query = DashboardFilter("id", data_model).apply(query, None)
return query.all()
@staticmethod
def validate_slug_uniqueness(slug: str) -> bool:
if not slug:
return True
dashboard_query = db.session.query(Dashboard).filter(Dashboard.slug == slug)
return not db.session.query(dashboard_query.exists()).scalar()
@staticmethod
def validate_update_slug_uniqueness(dashboard_id: int, slug: str) -> bool:
dashboard_query = db.session.query(Dashboard).filter(
Dashboard.slug == slug, Dashboard.id != dashboard_id
)
return not db.session.query(dashboard_query.exists()).scalar()
@staticmethod
def bulk_delete(models: List[Dashboard], commit=True):
item_ids = [model.id for model in models]
# bulk delete, first delete related data
for model in models:
model.slices = []
model.owners = []
db.session.merge(model)
# bulk delete itself
try:
db.session.query(Dashboard).filter(Dashboard.id.in_(item_ids)).delete(
synchronize_session="fetch"
)
if commit:
db.session.commit()
except SQLAlchemyError as e:
if commit:
db.session.rollback()
raise e

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 FavStar
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.views.base import BaseFilter, get_user_roles
class DashboardFilter(BaseFilter): # pylint: disable=too-few-public-methods
"""
List dashboards with the following criteria:
1. Those which the user owns
2. Those which the user has favorited
3. Those which have been published (if they have access to at least one slice)
If the user is an admin show them all dashboards.
This means they do not get curation but can still sort by "published"
if they wish to see those dashboards which are published first
"""
def apply(self, query, value):
user_roles = [role.name.lower() for role in list(get_user_roles())]
if "admin" in user_roles:
return query
datasource_perms = security_manager.user_view_menu_names("datasource_access")
schema_perms = security_manager.user_view_menu_names("schema_access")
all_datasource_access = security_manager.all_datasource_access()
published_dash_query = (
db.session.query(Dashboard.id)
.join(Dashboard.slices)
.filter(
and_(
Dashboard.published == True, # pylint: disable=singleton-comparison
or_(
Slice.perm.in_(datasource_perms),
Slice.schema_perm.in_(schema_perms),
all_datasource_access,
),
)
)
)
users_favorite_dash_query = db.session.query(FavStar.obj_id).filter(
and_(
FavStar.user_id == security_manager.user_model.get_user_id(),
FavStar.class_name == "Dashboard",
)
)
owner_ids_query = (
db.session.query(Dashboard.id)
.join(Dashboard.owners)
.filter(
security_manager.user_model.id
== security_manager.user_model.get_user_id()
)
)
query = query.filter(
or_(
Dashboard.id.in_(owner_ids_query),
Dashboard.id.in_(published_dash_query),
Dashboard.id.in_(users_favorite_dash_query),
)
)
return query

View File

@ -0,0 +1,87 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import json
import re
from marshmallow import fields, pre_load, Schema
from marshmallow.validate import Length, ValidationError
from superset.exceptions import SupersetException
from superset.utils import core as utils
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
def validate_json(value):
try:
utils.validate_json(value)
except SupersetException:
raise ValidationError("JSON not valid")
def validate_json_metadata(value):
if not value:
return
try:
value_obj = json.loads(value)
except json.decoder.JSONDecodeError:
raise ValidationError("JSON not valid")
errors = DashboardJSONMetadataSchema(strict=True).validate(value_obj, partial=False)
if errors:
raise ValidationError(errors)
class DashboardJSONMetadataSchema(Schema):
timed_refresh_immune_slices = fields.List(fields.Integer())
filter_scopes = fields.Dict()
expanded_slices = fields.Dict()
refresh_frequency = fields.Integer()
default_filters = fields.Str()
stagger_refresh = fields.Boolean()
stagger_time = fields.Integer()
color_scheme = fields.Str()
label_colors = fields.Dict()
class BaseDashboardSchema(Schema):
@pre_load
def pre_load(self, data): # pylint: disable=no-self-use
if data.get("slug"):
data["slug"] = data["slug"].strip()
data["slug"] = data["slug"].replace(" ", "-")
data["slug"] = re.sub(r"[^\w\-]+", "", data["slug"])
class DashboardPostSchema(BaseDashboardSchema):
dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
slug = fields.String(allow_none=True, validate=[Length(1, 255)])
owners = fields.List(fields.Integer())
position_json = fields.String(validate=validate_json)
css = fields.String()
json_metadata = fields.String(validate=validate_json_metadata)
published = fields.Boolean()
class DashboardPutSchema(BaseDashboardSchema):
dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
slug = fields.String(allow_none=True, validate=Length(0, 255))
owners = fields.List(fields.Integer(allow_none=True))
position_json = fields.String(allow_none=True, validate=validate_json)
css = fields.String(allow_none=True)
json_metadata = fields.String(allow_none=True, validate=validate_json_metadata)
published = fields.Boolean(allow_none=True)

View File

@ -21,8 +21,8 @@ from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.exceptions import CreateFailedError
from superset.datasets.commands.base import populate_owners
from superset.commands.utils import populate_owners
from superset.dao.exceptions import DAOCreateFailedError
from superset.datasets.commands.exceptions import (
DatabaseNotFoundValidationError,
DatasetCreateFailedError,
@ -44,7 +44,7 @@ class CreateDatasetCommand(BaseCommand):
self.validate()
try:
dataset = DatasetDAO.create(self._properties)
except CreateFailedError as e:
except DAOCreateFailedError as e:
logger.exception(e.exception)
raise DatasetCreateFailedError()
return dataset

View File

@ -20,8 +20,8 @@ from typing import Optional
from flask_appbuilder.security.sqla.models import User
from superset.commands.base import BaseCommand
from superset.commands.exceptions import DeleteFailedError
from superset.connectors.sqla.models import SqlaTable
from superset.dao.exceptions import DAODeleteFailedError
from superset.datasets.commands.exceptions import (
DatasetDeleteFailedError,
DatasetForbiddenError,
@ -44,7 +44,7 @@ class DeleteDatasetCommand(BaseCommand):
self.validate()
try:
dataset = DatasetDAO.delete(self._model)
except DeleteFailedError as e:
except DAODeleteFailedError as e:
logger.exception(e.exception)
raise DatasetDeleteFailedError()
return dataset

View File

@ -21,9 +21,9 @@ from flask_appbuilder.security.sqla.models import User
from marshmallow import ValidationError
from superset.commands.base import BaseCommand
from superset.commands.exceptions import UpdateFailedError
from superset.commands.utils import populate_owners
from superset.connectors.sqla.models import SqlaTable
from superset.datasets.commands.base import populate_owners
from superset.dao.exceptions import DAOUpdateFailedError
from superset.datasets.commands.exceptions import (
DatabaseChangeValidationError,
DatasetExistsValidationError,
@ -50,7 +50,7 @@ class UpdateDatasetCommand(BaseCommand):
self.validate()
try:
dataset = DatasetDAO.update(self._model, self._properties)
except UpdateFailedError as e:
except DAOUpdateFailedError as e:
logger.exception(e.exception)
raise DatasetUpdateFailedError()
return dataset

View File

@ -14,10 +14,22 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Optional
class SupersetException(Exception):
status = 500
message = ""
def __init__(self, message: str = "", exception: Optional[Exception] = None):
if message:
self.message = message
self._exception = exception
super().__init__(self.message)
@property
def exception(self):
return self._exception
class SupersetTimeoutException(SupersetException):

View File

@ -26,11 +26,6 @@ from superset.legacy import update_time_range
from superset.models.slice import Slice
from superset.utils import core as utils
from superset.views.base import api, BaseSupersetView, handle_api_exception
from superset.views.chart import api as chart_api # pylint: disable=unused-import
from superset.views.dashboard import ( # pylint: disable=unused-import
api as dashboard_api,
)
from superset.views.database import api as database_api # pylint: disable=unused-import
class Api(BaseSupersetView):

View File

@ -1,319 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import json
import logging
import re
from typing import Dict, List, Optional
from flask import current_app, g, make_response
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import lazy_gettext as _, ngettext
from marshmallow import fields, post_load, pre_load, Schema, ValidationError
from marshmallow.validate import Length
from sqlalchemy.exc import SQLAlchemyError
from superset.constants import RouteMethod
from superset.exceptions import SupersetException, SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.utils import core as utils
from superset.views.base import check_ownership, generate_download_headers
from superset.views.base_api import BaseOwnedModelRestApi
from superset.views.base_schemas import BaseOwnedSchema, validate_owner
from .mixin import DashboardMixin
logger = logging.getLogger(__name__)
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
class DashboardJSONMetadataSchema(Schema):
timed_refresh_immune_slices = fields.List(fields.Integer())
filter_scopes = fields.Dict()
expanded_slices = fields.Dict()
refresh_frequency = fields.Integer()
default_filters = fields.Str()
stagger_refresh = fields.Boolean()
stagger_time = fields.Integer()
color_scheme = fields.Str()
label_colors = fields.Dict()
def validate_json(value):
try:
utils.validate_json(value)
except SupersetException:
raise ValidationError("JSON not valid")
def validate_json_metadata(value):
if not value:
return
try:
value_obj = json.loads(value)
except json.decoder.JSONDecodeError:
raise ValidationError("JSON not valid")
errors = DashboardJSONMetadataSchema(strict=True).validate(value_obj, partial=False)
if errors:
raise ValidationError(errors)
def validate_slug_uniqueness(value):
# slug is not required but must be unique
if value:
item = (
current_app.appbuilder.get_session.query(Dashboard.id)
.filter_by(slug=value)
.one_or_none()
)
if item:
raise ValidationError("Must be unique")
class BaseDashboardSchema(BaseOwnedSchema):
@pre_load
def pre_load(self, data): # pylint: disable=no-self-use
super().pre_load(data)
if data.get("slug"):
data["slug"] = data["slug"].strip()
data["slug"] = data["slug"].replace(" ", "-")
data["slug"] = re.sub(r"[^\w\-]+", "", data["slug"])
if "owners" in data and data["owners"] is None:
data["owners"] = []
class DashboardPostSchema(BaseDashboardSchema):
__class_model__ = Dashboard
dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
slug = fields.String(
allow_none=True, validate=[Length(1, 255), validate_slug_uniqueness]
)
owners = fields.List(fields.Integer(validate=validate_owner))
position_json = fields.String(validate=validate_json)
css = fields.String()
json_metadata = fields.String(validate=validate_json_metadata)
published = fields.Boolean()
class DashboardPutSchema(BaseDashboardSchema):
dashboard_title = fields.String(allow_none=True, validate=Length(0, 500))
slug = fields.String(allow_none=True, validate=Length(0, 255))
owners = fields.List(fields.Integer(validate=validate_owner))
position_json = fields.String(validate=validate_json)
css = fields.String()
json_metadata = fields.String(allow_none=True, validate=validate_json_metadata)
published = fields.Boolean()
@post_load
def make_object(self, data: Dict, discard: Optional[List[str]] = None) -> Dashboard:
self.instance = super().make_object(data, [])
for slc in self.instance.slices:
slc.owners = list(set(self.instance.owners) | set(slc.owners))
return self.instance
get_export_ids_schema = {"type": "array", "items": {"type": "integer"}}
class DashboardRestApi(DashboardMixin, BaseOwnedModelRestApi):
datamodel = SQLAInterface(Dashboard)
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
RouteMethod.EXPORT,
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
}
resource_name = "dashboard"
allow_browser_login = True
class_permission_name = "DashboardModelView"
show_columns = [
"id",
"charts",
"css",
"dashboard_title",
"json_metadata",
"owners.id",
"owners.username",
"changed_by_name",
"changed_by_url",
"changed_by.username",
"changed_on",
"position_json",
"published",
"url",
"slug",
"table_names",
]
order_columns = ["dashboard_title", "changed_on", "published", "changed_by_fk"]
list_columns = [
"changed_by_name",
"changed_by_url",
"changed_by.username",
"changed_on",
"dashboard_title",
"id",
"published",
"slug",
"url",
]
add_model_schema = DashboardPostSchema()
edit_model_schema = DashboardPutSchema()
order_rel_fields = {
"slices": ("slice_name", "asc"),
"owners": ("first_name", "asc"),
}
filter_rel_fields_field = {"owners": "first_name"}
allowed_rel_fields = {"owners"}
@expose("/", methods=["DELETE"])
@protect()
@safe
@rison(get_delete_ids_schema)
def bulk_delete(self, **kwargs): # pylint: disable=arguments-differ
"""Delete bulk Dashboards
---
delete:
parameters:
- in: query
name: q
content:
application/json:
schema:
type: array
items:
type: integer
responses:
200:
description: Dashboard bulk delete
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
item_ids = kwargs["rison"]
query = self.datamodel.session.query(Dashboard).filter(
Dashboard.id.in_(item_ids)
)
items = self._base_filters.apply_all(query).all()
if not items:
return self.response_404()
# Check user ownership over the items
for item in items:
try:
check_ownership(item)
except SupersetSecurityException as e:
logger.warning(
f"Dashboard {item} was not deleted, "
f"because the user ({g.user}) does not own it"
)
return self.response(403, message=_("No dashboards deleted"))
except SQLAlchemyError as e:
logger.error(f"Error checking dashboard ownership {e}")
return self.response_422(message=str(e))
# bulk delete, first delete related data
for item in items:
try:
item.slices = []
item.owners = []
self.datamodel.session.merge(item)
except SQLAlchemyError as e:
logger.error(f"Error bulk deleting related data on dashboards {e}")
self.datamodel.session.rollback()
return self.response_422(message=str(e))
# bulk delete itself
try:
self.datamodel.session.query(Dashboard).filter(
Dashboard.id.in_(item_ids)
).delete(synchronize_session="fetch")
except SQLAlchemyError as e:
logger.error(f"Error bulk deleting dashboards {e}")
self.datamodel.session.rollback()
return self.response_422(message=str(e))
self.datamodel.session.commit()
return self.response(
200,
message=ngettext(
f"Deleted %(num)d dashboard",
f"Deleted %(num)d dashboards",
num=len(items),
),
)
@expose("/export/", methods=["GET"])
@protect()
@safe
@rison(get_export_ids_schema)
def export(self, **kwargs):
"""Export dashboards
---
get:
parameters:
- in: query
name: q
content:
application/json:
schema:
type: array
items:
type: integer
responses:
200:
description: Dashboard export
content:
text/plain:
schema:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
query = self.datamodel.session.query(Dashboard).filter(
Dashboard.id.in_(kwargs["rison"])
)
query = self._base_filters.apply_all(query)
ids = [item.id for item in query.all()]
if not ids:
return self.response_404()
export = Dashboard.export_dashboards(ids)
resp = make_response(export, 200)
resp.headers["Content-Disposition"] = generate_download_headers("json")[
"Content-Disposition"
]
return resp

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

@ -20,20 +20,30 @@ import json
from typing import List, Optional
import prison
from sqlalchemy.sql import func
import tests.test_app
from superset import db, security_manager
from superset.models import core as models
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.views.base import generate_download_headers
from .base_api_tests import ApiOwnersTestCaseMixin
from .base_tests import SupersetTestCase
from tests.base_api_tests import ApiOwnersTestCaseMixin
from tests.base_tests import SupersetTestCase
class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
resource_name = "dashboard"
dashboard_data = {
"dashboard_title": "title1_changed",
"slug": "slug1_changed",
"position_json": '{"b": "B"}',
"css": "css_changed",
"json_metadata": '{"a": "A"}',
"published": False,
}
def __init__(self, *args, **kwargs):
super(DashboardApiTests, self).__init__(*args, **kwargs)
@ -47,13 +57,13 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
css: str = "",
json_metadata: str = "",
published: bool = False,
) -> models.Dashboard:
) -> Dashboard:
obj_owners = list()
slices = slices or []
for owner in owners:
user = db.session.query(security_manager.user_model).get(owner)
obj_owners.append(user)
dashboard = models.Dashboard(
dashboard = Dashboard(
dashboard_title=dashboard_title,
slug=slug,
owners=obj_owners,
@ -67,6 +77,122 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
db.session.commit()
return dashboard
def test_get_dashboard(self):
"""
Dashboard API: Test get dashboard
"""
admin = self.get_user("admin")
dashboard = self.insert_dashboard("title", "slug1", [admin.id])
self.login(username="admin")
uri = f"api/v1/dashboard/{dashboard.id}"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
expected_result = {
"changed_by": None,
"changed_by_name": "",
"changed_by_url": "",
"charts": [],
"id": dashboard.id,
"css": "",
"dashboard_title": "title",
"json_metadata": "",
"owners": [{"id": 1, "username": "admin"}],
"position_json": "",
"published": False,
"url": f"/superset/dashboard/slug1/",
"slug": "slug1",
"table_names": "",
}
data = json.loads(rv.data.decode("utf-8"))
self.assertIn("changed_on", data["result"])
for key, value in data["result"].items():
# We can't assert timestamp
if key != "changed_on":
self.assertEqual(value, expected_result[key])
# rollback changes
db.session.delete(dashboard)
db.session.commit()
def test_get_dashboard_not_found(self):
"""
Dashboard API: Test get dashboard not found
"""
max_id = db.session.query(func.max(Dashboard.id)).scalar()
self.login(username="admin")
uri = f"api/v1/dashboard/{max_id + 1}"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 404)
def test_get_dashboard_no_data_access(self):
"""
Dashboard API: Test get dashboard without data access
"""
admin = self.get_user("admin")
dashboard = self.insert_dashboard("title", "slug1", [admin.id])
self.login(username="gamma")
uri = f"api/v1/dashboard/{dashboard.id}"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 404)
# rollback changes
db.session.delete(dashboard)
db.session.commit()
def test_get_dashboards_filter(self):
"""
Dashboard API: Test get dashboards filter
"""
admin = self.get_user("admin")
gamma = self.get_user("gamma")
dashboard = self.insert_dashboard("title", "slug1", [admin.id, gamma.id])
self.login(username="admin")
arguments = {
"filters": [{"col": "dashboard_title", "opr": "sw", "value": "ti"}]
}
uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(data["count"], 1)
arguments = {
"filters": [
{"col": "owners", "opr": "rel_m_m", "value": [admin.id, gamma.id]}
]
}
uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(data["count"], 1)
# rollback changes
db.session.delete(dashboard)
db.session.commit()
def test_get_dashboards_no_data_access(self):
"""
Dashboard API: Test get dashboards no data access
"""
admin = self.get_user("admin")
dashboard = self.insert_dashboard("title", "slug1", [admin.id])
self.login(username="gamma")
arguments = {
"filters": [{"col": "dashboard_title", "opr": "sw", "value": "ti"}]
}
uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)
self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(data["count"], 0)
# rollback changes
db.session.delete(dashboard)
db.session.commit()
def test_delete_dashboard(self):
"""
Dashboard API: Test delete
@ -77,7 +203,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.delete(uri)
self.assertEqual(rv.status_code, 200)
model = db.session.query(models.Dashboard).get(dashboard_id)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model, None)
def test_delete_bulk_dashboards(self):
@ -104,7 +230,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
expected_response = {"message": f"Deleted {dashboard_count} dashboards"}
self.assertEqual(response, expected_response)
for dashboard_id in dashboard_ids:
model = db.session.query(models.Dashboard).get(dashboard_id)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model, None)
def test_delete_bulk_dashboards_bad_request(self):
@ -150,7 +276,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.delete(uri)
self.assertEqual(rv.status_code, 200)
model = db.session.query(models.Dashboard).get(dashboard_id)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model, None)
def test_delete_bulk_dashboard_admin_not_owned(self):
@ -179,7 +305,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
self.assertEqual(response, expected_response)
for dashboard_id in dashboard_ids:
model = db.session.query(models.Dashboard).get(dashboard_id)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model, None)
def test_delete_dashboard_not_owned(self):
@ -250,7 +376,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.delete(uri)
self.assertEqual(rv.status_code, 403)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": "No dashboards deleted"}
expected_response = {"message": "Forbidden"}
self.assertEqual(response, expected_response)
# nothing is delete in bulk with a list of owned and not owned dashboards
@ -259,7 +385,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.delete(uri)
self.assertEqual(rv.status_code, 403)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": "No dashboards deleted"}
expected_response = {"message": "Forbidden"}
self.assertEqual(response, expected_response)
for dashboard in dashboards:
@ -288,7 +414,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 201)
data = json.loads(rv.data.decode("utf-8"))
model = db.session.query(models.Dashboard).get(data.get("id"))
model = db.session.query(Dashboard).get(data.get("id"))
db.session.delete(model)
db.session.commit()
@ -302,7 +428,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 201)
data = json.loads(rv.data.decode("utf-8"))
model = db.session.query(models.Dashboard).get(data.get("id"))
model = db.session.query(Dashboard).get(data.get("id"))
db.session.delete(model)
db.session.commit()
@ -316,7 +442,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 201)
data = json.loads(rv.data.decode("utf-8"))
model = db.session.query(models.Dashboard).get(data.get("id"))
model = db.session.query(Dashboard).get(data.get("id"))
db.session.delete(model)
db.session.commit()
@ -326,7 +452,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 201)
data = json.loads(rv.data.decode("utf-8"))
model = db.session.query(models.Dashboard).get(data.get("id"))
model = db.session.query(Dashboard).get(data.get("id"))
db.session.delete(model)
db.session.commit()
@ -338,7 +464,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
self.login(username="admin")
uri = "api/v1/dashboard/"
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 422)
self.assertEqual(rv.status_code, 400)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {
"message": {"dashboard_title": ["Length must be between 0 and 500."]}
@ -366,7 +492,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
dashboard_data = {"dashboard_title": "title2", "slug": "a" * 256}
uri = "api/v1/dashboard/"
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 422)
self.assertEqual(rv.status_code, 400)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": {"slug": ["Length must be between 1 and 255."]}}
self.assertEqual(response, expected_response)
@ -384,7 +510,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 422)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": {"owners": {"0": ["User 1000 does not exist"]}}}
expected_response = {"message": {"owners": ["Owners are invalid"]}}
self.assertEqual(response, expected_response)
def test_create_dashboard_validate_json(self):
@ -395,13 +521,13 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
self.login(username="admin")
uri = "api/v1/dashboard/"
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 422)
self.assertEqual(rv.status_code, 400)
dashboard_data = {"dashboard_title": "title1", "json_metadata": '{"A:"a"}'}
self.login(username="admin")
uri = "api/v1/dashboard/"
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 422)
self.assertEqual(rv.status_code, 400)
dashboard_data = {
"dashboard_title": "title1",
@ -410,34 +536,56 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
self.login(username="admin")
uri = "api/v1/dashboard/"
rv = self.client.post(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 422)
self.assertEqual(rv.status_code, 400)
def test_update_dashboard(self):
"""
Dashboard API: Test update
"""
admin_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,
}
admin = self.get_user("admin")
dashboard_id = self.insert_dashboard("title1", "slug1", [admin.id]).id
self.login(username="admin")
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.put(uri, json=dashboard_data)
rv = self.client.put(uri, json=self.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)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model.dashboard_title, self.dashboard_data["dashboard_title"])
self.assertEqual(model.slug, self.dashboard_data["slug"])
self.assertEqual(model.position_json, self.dashboard_data["position_json"])
self.assertEqual(model.css, self.dashboard_data["css"])
self.assertEqual(model.json_metadata, self.dashboard_data["json_metadata"])
self.assertEqual(model.published, self.dashboard_data["published"])
self.assertEqual(model.owners, [admin])
db.session.delete(model)
db.session.commit()
def test_update_partial_dashboard(self):
"""
Dashboard API: Test update partial
"""
admin_id = self.get_user("admin").id
dashboard_id = self.insert_dashboard("title1", "slug1", [admin_id]).id
self.login(username="admin")
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.put(
uri, json={"json_metadata": self.dashboard_data["json_metadata"]}
)
self.assertEqual(rv.status_code, 200)
rv = self.client.put(
uri, json={"dashboard_title": self.dashboard_data["dashboard_title"]}
)
self.assertEqual(rv.status_code, 200)
rv = self.client.put(uri, json={"slug": self.dashboard_data["slug"]})
self.assertEqual(rv.status_code, 200)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model.json_metadata, self.dashboard_data["json_metadata"])
self.assertEqual(model.dashboard_title, self.dashboard_data["dashboard_title"])
self.assertEqual(model.slug, self.dashboard_data["slug"])
db.session.delete(model)
db.session.commit()
@ -453,7 +601,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.put(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 200)
model = db.session.query(models.Dashboard).get(dashboard_id)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertIn(admin, model.owners)
for slc in model.slices:
self.assertIn(admin, slc.owners)
@ -471,12 +619,34 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.put(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 200)
model = db.session.query(models.Dashboard).get(dashboard_id)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model.dashboard_title, "title1_changed")
self.assertEqual(model.slug, "slug1-changed")
db.session.delete(model)
db.session.commit()
def test_update_dashboard_validate_slug(self):
"""
Dashboard API: Test update validate slug
"""
admin_id = self.get_user("admin").id
dashboard1 = self.insert_dashboard("title1", "slug-1", [admin_id])
dashboard2 = self.insert_dashboard("title2", "slug-2", [admin_id])
self.login(username="admin")
# Check for slug uniqueness
dashboard_data = {"dashboard_title": "title2", "slug": "slug 1"}
uri = f"api/v1/dashboard/{dashboard2.id}"
rv = self.client.put(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 422)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": {"slug": ["Must be unique"]}}
self.assertEqual(response, expected_response)
db.session.delete(dashboard1)
db.session.delete(dashboard2)
db.session.commit()
def test_update_published(self):
"""
Dashboard API: Test update published patch
@ -491,7 +661,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
rv = self.client.put(uri, json=dashboard_data)
self.assertEqual(rv.status_code, 200)
model = db.session.query(models.Dashboard).get(dashboard.id)
model = db.session.query(Dashboard).get(dashboard.id)
self.assertEqual(model.published, True)
self.assertEqual(model.slug, "slug1")
self.assertIn(admin, model.owners)
@ -552,7 +722,7 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
def test_export_not_allowed(self):
"""
Dashboard API: Test dashboard export not not allowed
Dashboard API: Test dashboard export not allowed
"""
admin_id = self.get_user("admin").id
dashboard = self.insert_dashboard("title", "slug1", [admin_id], published=False)

View File

@ -22,12 +22,12 @@ from unittest.mock import patch
import prison
from superset import db, security_manager
from superset.commands.exceptions import (
CreateFailedError,
DeleteFailedError,
UpdateFailedError,
)
from superset.connectors.sqla.models import SqlaTable
from superset.dao.exceptions import (
DAOCreateFailedError,
DAODeleteFailedError,
DAOUpdateFailedError,
)
from superset.models.core import Database
from superset.utils.core import get_example_database
@ -279,7 +279,7 @@ class DatasetApiTests(SupersetTestCase):
"""
Dataset API: Test create dataset sqlalchemy error
"""
mock_dao_create.side_effect = CreateFailedError()
mock_dao_create.side_effect = DAOCreateFailedError()
self.login(username="admin")
example_db = get_example_database()
dataset_data = {
@ -379,7 +379,7 @@ class DatasetApiTests(SupersetTestCase):
"""
Dataset API: Test update dataset sqlalchemy error
"""
mock_dao_update.side_effect = UpdateFailedError()
mock_dao_update.side_effect = DAOUpdateFailedError()
table = self.insert_dataset("ab_permission", "", [], get_example_database())
self.login(username="admin")
@ -438,7 +438,7 @@ class DatasetApiTests(SupersetTestCase):
"""
Dataset API: Test delete dataset sqlalchemy error
"""
mock_dao_delete.side_effect = DeleteFailedError()
mock_dao_delete.side_effect = DAODeleteFailedError()
admin = self.get_user("admin")
table = self.insert_dataset(