diff --git a/superset/commands/exceptions.py b/superset/commands/exceptions.py index bb8992aeb0e26..40c059765b6e7 100644 --- a/superset/commands/exceptions.py +++ b/superset/commands/exceptions.py @@ -14,7 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from flask_babel import lazy_gettext as _ from marshmallow import ValidationError @@ -31,6 +31,26 @@ def __repr__(self) -> str: return repr(self) +class ObjectNotFoundError(CommandException): + status = 404 + message_format = "{} {}not found." + + def __init__( + self, + object_type: str, + object_id: Optional[str] = None, + exception: Optional[Exception] = None, + ) -> None: + super().__init__( + _( + self.message_format.format( + object_type, '"%s" ' % object_id if object_id else "" + ) + ), + exception, + ) + + class CommandInvalidError(CommandException): """ Common base class for Command Invalid errors. """ diff --git a/superset/commands/export.py b/superset/commands/export.py index 5bf117cca31ef..2b54de87852e9 100644 --- a/superset/commands/export.py +++ b/superset/commands/export.py @@ -18,7 +18,7 @@ from datetime import datetime from datetime import timezone -from typing import Iterator, List, Tuple +from typing import Iterator, List, Tuple, Type import yaml from flask_appbuilder import Model @@ -33,8 +33,8 @@ class ExportModelsCommand(BaseCommand): - dao = BaseDAO - not_found = CommandException + dao: Type[BaseDAO] = BaseDAO + not_found: Type[CommandException] = CommandException def __init__(self, model_ids: List[int]): self.model_ids = model_ids diff --git a/superset/common/not_authrized_object.py b/superset/common/not_authrized_object.py new file mode 100644 index 0000000000000..7295da9aa7ef1 --- /dev/null +++ b/superset/common/not_authrized_object.py @@ -0,0 +1,39 @@ +# 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 Any, Optional + +from superset.exceptions import SupersetException + + +class NotAuthorizedObject: + def __init__(self, what_not_authorized: str): + self._what_not_authorized = what_not_authorized + + def __getattr__(self, item: Any) -> None: + raise NotAuthorizedException(self._what_not_authorized) + + def __getitem__(self, item: Any) -> None: + raise NotAuthorizedException(self._what_not_authorized) + + +class NotAuthorizedException(SupersetException): + def __init__( + self, what_not_authorized: str = "", exception: Optional[Exception] = None + ) -> None: + super().__init__( + "The user is not authorized to " + what_not_authorized, exception + ) diff --git a/superset/common/request_contexed_based.py b/superset/common/request_contexed_based.py new file mode 100644 index 0000000000000..0b06a0ccbe1d5 --- /dev/null +++ b/superset/common/request_contexed_based.py @@ -0,0 +1,39 @@ +# 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 __future__ import annotations + +from typing import List, TYPE_CHECKING + +from flask import g + +from superset import conf, security_manager + +if TYPE_CHECKING: + from flask_appbuilder.security.sqla.models import Role + + +def get_user_roles() -> List[Role]: + if g.user.is_anonymous: + public_role = conf.get("AUTH_ROLE_PUBLIC") + return [security_manager.get_public_role()] if public_role else [] + return g.user.roles + + +def is_user_admin() -> bool: + user_roles = [role.name.lower() for role in get_user_roles()] + admin_role = conf.get("AUTH_ROLE_ADMIN").lower() + return admin_role in user_roles diff --git a/superset/dashboards/commands/exceptions.py b/superset/dashboards/commands/exceptions.py index ee85c1f391808..1a5bdaf789f67 100644 --- a/superset/dashboards/commands/exceptions.py +++ b/superset/dashboards/commands/exceptions.py @@ -14,16 +14,18 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Optional + from flask_babel import lazy_gettext as _ from marshmallow.validate import ValidationError from superset.commands.exceptions import ( - CommandException, CommandInvalidError, CreateFailedError, DeleteFailedError, ForbiddenError, ImportFailedError, + ObjectNotFoundError, UpdateFailedError, ) @@ -41,8 +43,11 @@ class DashboardInvalidError(CommandInvalidError): message = _("Dashboard parameters are invalid.") -class DashboardNotFoundError(CommandException): - message = _("Dashboard not found.") +class DashboardNotFoundError(ObjectNotFoundError): + def __init__( + self, dashboard_id: Optional[str] = None, exception: Optional[Exception] = None + ) -> None: + super().__init__("Dashboard", dashboard_id, exception) class DashboardCreateFailedError(CreateFailedError): diff --git a/superset/dashboards/filter_sets/__init__.py b/superset/dashboards/filter_sets/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/superset/dashboards/filter_sets/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/superset/dashboards/filter_sets/api.py b/superset/dashboards/filter_sets/api.py new file mode 100644 index 0000000000000..24c4e8655f4f7 --- /dev/null +++ b/superset/dashboards/filter_sets/api.py @@ -0,0 +1,374 @@ +# 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 Any, cast + +from flask import g, request, Response +from flask_appbuilder.api import ( + expose, + get_list_schema, + permission_name, + protect, + rison, + safe, +) +from flask_appbuilder.models.sqla.interface import SQLAInterface +from marshmallow import ValidationError + +from superset.commands.exceptions import ObjectNotFoundError +from superset.dashboards.commands.exceptions import DashboardNotFoundError +from superset.dashboards.dao import DashboardDAO +from superset.dashboards.filter_sets.commands.create import CreateFilterSetCommand +from superset.dashboards.filter_sets.commands.delete import DeleteFilterSetCommand +from superset.dashboards.filter_sets.commands.exceptions import ( + FilterSetCreateFailedError, + FilterSetDeleteFailedError, + FilterSetForbiddenError, + FilterSetNotFoundError, + FilterSetUpdateFailedError, + UserIsNotDashboardOwnerError, +) +from superset.dashboards.filter_sets.commands.update import UpdateFilterSetCommand +from superset.dashboards.filter_sets.consts import ( + DASHBOARD_FIELD, + DASHBOARD_ID_FIELD, + DESCRIPTION_FIELD, + FILTER_SET_API_PERMISSIONS_NAME, + JSON_METADATA_FIELD, + NAME_FIELD, + OWNER_ID_FIELD, + OWNER_OBJECT_FIELD, + OWNER_TYPE_FIELD, + PARAMS_PROPERTY, +) +from superset.dashboards.filter_sets.filters import FilterSetFilter +from superset.dashboards.filter_sets.schemas import ( + FilterSetPostSchema, + FilterSetPutSchema, +) +from superset.extensions import event_logger +from superset.models.filter_set import FilterSet +from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics + +logger = logging.getLogger(__name__) + + +class FilterSetRestApi(BaseSupersetModelRestApi): + # pylint: disable=arguments-differ + include_route_methods = {"get_list", "put", "post", "delete"} + datamodel = SQLAInterface(FilterSet) + resource_name = "dashboard" + class_permission_name = FILTER_SET_API_PERMISSIONS_NAME + allow_browser_login = True + csrf_exempt = False + add_exclude_columns = [ + "id", + OWNER_OBJECT_FIELD, + DASHBOARD_FIELD, + JSON_METADATA_FIELD, + ] + add_model_schema = FilterSetPostSchema() + edit_model_schema = FilterSetPutSchema() + edit_exclude_columns = [ + "id", + OWNER_OBJECT_FIELD, + DASHBOARD_FIELD, + JSON_METADATA_FIELD, + ] + list_columns = [ + "created_on", + "changed_on", + "created_by_fk", + "changed_by_fk", + NAME_FIELD, + DESCRIPTION_FIELD, + OWNER_TYPE_FIELD, + OWNER_ID_FIELD, + DASHBOARD_ID_FIELD, + PARAMS_PROPERTY, + ] + show_exclude_columns = [OWNER_OBJECT_FIELD, DASHBOARD_FIELD, JSON_METADATA_FIELD] + search_columns = ["id", NAME_FIELD, OWNER_ID_FIELD, DASHBOARD_ID_FIELD] + base_filters = [[OWNER_ID_FIELD, FilterSetFilter, ""]] + + def __init__(self) -> None: + self.datamodel.get_search_columns_list = lambda: [] + super().__init__() + + def _init_properties(self) -> None: + # pylint: disable=bad-super-call + super(BaseSupersetModelRestApi, self)._init_properties() + + @expose("//filtersets", methods=["GET"]) + @protect() + @safe + @permission_name("get") + @rison(get_list_schema) + def get_list(self, dashboard_id: int, **kwargs: Any) -> Response: + """ + Gets a dashboard's Filter sets + --- + get: + description: >- + Get a dashboard's list of filter sets + parameters: + - in: path + schema: + type: integer + name: dashboard_id + description: The id of the dashboard + responses: + 200: + description: FilterSets + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + description: Name of the Filter set + type: string + json_metadata: + description: metadata of the filter set + type: string + description: + description: A description field of the filter set + type: string + owner_id: + description: A description field of the filter set + type: integer + owner_type: + description: the Type of the owner ( Dashboard/User) + type: integer + parameters: + description: JSON schema defining the needed parameters + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + """ + if not DashboardDAO.find_by_id(cast(int, dashboard_id)): + return self.response(404, message="dashboard '%s' not found" % dashboard_id) + rison_data = kwargs.setdefault("rison", {}) + rison_data.setdefault("filters", []) + rison_data["filters"].append( + {"col": "dashboard_id", "opr": "eq", "value": str(dashboard_id)} + ) + return self.get_list_headless(**kwargs) + + @expose("//filtersets", methods=["POST"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post", + log_to_statsd=False, + ) + def post(self, dashboard_id: int) -> Response: + """ + Creates a new Dashboard's Filter Set + --- + post: + description: >- + Create a new Dashboard's Filter Set. + parameters: + - in: path + schema: + type: integer + name: dashboard_id + description: The id of the dashboard + requestBody: + description: Filter set schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + responses: + 201: + description: Filter set added + content: + application/json: + schema: + type: object + properties: + id: + type: number + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + if not request.is_json: + return self.response_400(message="Request is not JSON") + try: + item = self.add_model_schema.load(request.json) + new_model = CreateFilterSetCommand(g.user, dashboard_id, item).run() + return self.response(201, id=new_model.id, result=item) + except ValidationError as error: + return self.response_400(message=error.messages) + except UserIsNotDashboardOwnerError: + return self.response_403() + except FilterSetCreateFailedError as error: + return self.response_400(message=error.message) + except DashboardNotFoundError: + return self.response_404() + + @expose("//filtersets/", methods=["PUT"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put", + log_to_statsd=False, + ) + def put(self, dashboard_id: int, pk: int) -> Response: + """Changes a Dashboard's Filter set + --- + put: + description: >- + Changes a Dashboard's Filter set. + parameters: + - in: path + schema: + type: integer + name: dashboard_id + - in: path + schema: + type: integer + name: pk + requestBody: + description: Filter set schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + responses: + 200: + description: Filter set 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") + try: + item = self.edit_model_schema.load(request.json) + changed_model = UpdateFilterSetCommand(g.user, dashboard_id, pk, item).run() + return self.response(200, id=changed_model.id, result=item) + except ValidationError as error: + return self.response_400(message=error.messages) + except ( + ObjectNotFoundError, + FilterSetForbiddenError, + FilterSetUpdateFailedError, + ) as err: + logger.error(err) + return self.response(err.status) + + @expose("//filtersets/", methods=["DELETE"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete", + log_to_statsd=False, + ) + def delete(self, dashboard_id: int, pk: int) -> Response: + """ + Deletes a Dashboard's FilterSet + --- + delete: + description: >- + Deletes a Dashboard. + parameters: + - in: path + schema: + type: integer + name: dashboard_id + - in: path + schema: + type: integer + name: pk + responses: + 200: + description: Filter set 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: + changed_model = DeleteFilterSetCommand(g.user, dashboard_id, pk).run() + return self.response(200, id=changed_model.id) + except ValidationError as error: + return self.response_400(message=error.messages) + except FilterSetNotFoundError: + return self.response(200) + except ( + ObjectNotFoundError, + FilterSetForbiddenError, + FilterSetDeleteFailedError, + ) as err: + logger.error(err) + return self.response(err.status) diff --git a/superset/dashboards/filter_sets/commands/__init__.py b/superset/dashboards/filter_sets/commands/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/superset/dashboards/filter_sets/commands/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/superset/dashboards/filter_sets/commands/base.py b/superset/dashboards/filter_sets/commands/base.py new file mode 100644 index 0000000000000..af31bbd7a1a94 --- /dev/null +++ b/superset/dashboards/filter_sets/commands/base.py @@ -0,0 +1,91 @@ +# 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 cast, Optional + +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User + +from superset.common.not_authrized_object import NotAuthorizedException +from superset.common.request_contexed_based import is_user_admin +from superset.dashboards.commands.exceptions import DashboardNotFoundError +from superset.dashboards.dao import DashboardDAO +from superset.dashboards.filter_sets.commands.exceptions import ( + FilterSetForbiddenError, + FilterSetNotFoundError, +) +from superset.dashboards.filter_sets.consts import USER_OWNER_TYPE +from superset.models.dashboard import Dashboard +from superset.models.filter_set import FilterSet + +logger = logging.getLogger(__name__) + + +class BaseFilterSetCommand: + # pylint: disable=C0103 + _dashboard: Dashboard + _filter_set_id: Optional[int] + _filter_set: Optional[FilterSet] + + def __init__(self, user: User, dashboard_id: int): + self._actor = user + self._is_actor_admin = is_user_admin() + self._dashboard_id = dashboard_id + + def run(self) -> Model: + pass + + def _validate_filterset_dashboard_exists(self) -> None: + self._dashboard = DashboardDAO.get_by_id_or_slug(str(self._dashboard_id)) + if not self._dashboard: + raise DashboardNotFoundError() + + def is_user_dashboard_owner(self) -> bool: + return self._is_actor_admin or self._dashboard.is_actor_owner() + + def validate_exist_filter_use_cases_set(self) -> None: # pylint: disable=C0103 + self._validate_filter_set_exists_and_set_when_exists() + self.check_ownership() + + def _validate_filter_set_exists_and_set_when_exists(self) -> None: + self._filter_set = self._dashboard.filter_sets.get( + cast(int, self._filter_set_id), None + ) + if not self._filter_set: + raise FilterSetNotFoundError(str(self._filter_set_id)) + + def check_ownership(self) -> None: + try: + if not self._is_actor_admin: + filter_set: FilterSet = cast(FilterSet, self._filter_set) + if filter_set.owner_type == USER_OWNER_TYPE: + if self._actor.id != filter_set.owner_id: + raise FilterSetForbiddenError( + str(self._filter_set_id), + "The user is not the owner of the filter_set", + ) + elif not self.is_user_dashboard_owner(): + raise FilterSetForbiddenError( + str(self._filter_set_id), + "The user is not an owner of the filter_set's dashboard", + ) + except NotAuthorizedException as err: + raise FilterSetForbiddenError( + str(self._filter_set_id), "user not authorized to access the filterset", + ) from err + except FilterSetForbiddenError as err: + raise err diff --git a/superset/dashboards/filter_sets/commands/create.py b/superset/dashboards/filter_sets/commands/create.py new file mode 100644 index 0000000000000..b74e6d3041628 --- /dev/null +++ b/superset/dashboards/filter_sets/commands/create.py @@ -0,0 +1,78 @@ +# 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 Any, Dict + +from flask import g +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User + +from superset import security_manager +from superset.dashboards.filter_sets.commands.base import BaseFilterSetCommand +from superset.dashboards.filter_sets.commands.exceptions import ( + DashboardIdInconsistencyError, + FilterSetCreateFailedError, + UserIsNotDashboardOwnerError, +) +from superset.dashboards.filter_sets.consts import ( + DASHBOARD_ID_FIELD, + DASHBOARD_OWNER_TYPE, + OWNER_ID_FIELD, + OWNER_TYPE_FIELD, +) +from superset.dashboards.filter_sets.dao import FilterSetDAO + +logger = logging.getLogger(__name__) + + +class CreateFilterSetCommand(BaseFilterSetCommand): + # pylint: disable=C0103 + def __init__(self, user: User, dashboard_id: int, data: Dict[str, Any]): + super().__init__(user, dashboard_id) + self._properties = data.copy() + + def run(self) -> Model: + self.validate() + self._properties[DASHBOARD_ID_FIELD] = self._dashboard.id + filter_set = FilterSetDAO.create(self._properties, commit=True) + return filter_set + + def validate(self) -> None: + self._validate_filterset_dashboard_exists() + if self._properties[OWNER_TYPE_FIELD] == DASHBOARD_OWNER_TYPE: + self._validate_owner_id_is_dashboard_id() + self._validate_user_is_the_dashboard_owner() + else: + self._validate_owner_id_exists() + + def _validate_owner_id_exists(self) -> None: + owner_id = self._properties[OWNER_ID_FIELD] + if not (g.user.id == owner_id or security_manager.get_user_by_id(owner_id)): + raise FilterSetCreateFailedError( + str(self._dashboard_id), "owner_id does not exists" + ) + + def _validate_user_is_the_dashboard_owner(self) -> None: + if not self.is_user_dashboard_owner(): + raise UserIsNotDashboardOwnerError(str(self._dashboard_id)) + + def _validate_owner_id_is_dashboard_id(self) -> None: + if ( + self._properties.get(OWNER_ID_FIELD, self._dashboard_id) + != self._dashboard_id + ): + raise DashboardIdInconsistencyError(str(self._dashboard_id)) diff --git a/superset/dashboards/filter_sets/commands/delete.py b/superset/dashboards/filter_sets/commands/delete.py new file mode 100644 index 0000000000000..18d7fed8f2bb8 --- /dev/null +++ b/superset/dashboards/filter_sets/commands/delete.py @@ -0,0 +1,56 @@ +# 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_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User + +from superset.dao.exceptions import DAODeleteFailedError +from superset.dashboards.filter_sets.commands.base import BaseFilterSetCommand +from superset.dashboards.filter_sets.commands.exceptions import ( + FilterSetDeleteFailedError, + FilterSetForbiddenError, + FilterSetNotFoundError, +) +from superset.dashboards.filter_sets.dao import FilterSetDAO + +logger = logging.getLogger(__name__) + + +class DeleteFilterSetCommand(BaseFilterSetCommand): + def __init__(self, user: User, dashboard_id: int, filter_set_id: int): + super().__init__(user, dashboard_id) + self._filter_set_id = filter_set_id + + def run(self) -> Model: + try: + self.validate() + return FilterSetDAO.delete(self._filter_set, commit=True) + except DAODeleteFailedError as err: + raise FilterSetDeleteFailedError(str(self._filter_set_id), "") from err + + def validate(self) -> None: + self._validate_filterset_dashboard_exists() + try: + self.validate_exist_filter_use_cases_set() + except FilterSetNotFoundError as err: + if FilterSetDAO.find_by_id(self._filter_set_id): # type: ignore + raise FilterSetForbiddenError( + 'the filter-set does not related to dashboard "%s"' + % str(self._dashboard_id) + ) from err + raise err diff --git a/superset/dashboards/filter_sets/commands/exceptions.py b/superset/dashboards/filter_sets/commands/exceptions.py new file mode 100644 index 0000000000000..ade0bbbe9090a --- /dev/null +++ b/superset/dashboards/filter_sets/commands/exceptions.py @@ -0,0 +1,94 @@ +# 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 Optional + +from flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import ( + CreateFailedError, + DeleteFailedError, + ForbiddenError, + ObjectNotFoundError, + UpdateFailedError, +) + + +class FilterSetNotFoundError(ObjectNotFoundError): + def __init__( + self, filterset_id: Optional[str] = None, exception: Optional[Exception] = None + ) -> None: + super().__init__("FilterSet", filterset_id, exception) + + +class FilterSetCreateFailedError(CreateFailedError): + base_message = 'CreateFilterSetCommand of dashboard "%s" failed: ' + + def __init__( + self, dashboard_id: str, reason: str = "", exception: Optional[Exception] = None + ) -> None: + super().__init__((self.base_message % dashboard_id) + reason, exception) + + +class FilterSetUpdateFailedError(UpdateFailedError): + base_message = 'UpdateFilterSetCommand of filter_set "%s" failed: ' + + def __init__( + self, filterset_id: str, reason: str = "", exception: Optional[Exception] = None + ) -> None: + super().__init__((self.base_message % filterset_id) + reason, exception) + + +class FilterSetDeleteFailedError(DeleteFailedError): + base_message = 'DeleteFilterSetCommand of filter_set "%s" failed: ' + + def __init__( + self, filterset_id: str, reason: str = "", exception: Optional[Exception] = None + ) -> None: + super().__init__((self.base_message % filterset_id) + reason, exception) + + +class UserIsNotDashboardOwnerError(FilterSetCreateFailedError): + reason = ( + "cannot create dashboard owner filterset based when" + " the user is not the dashboard owner" + ) + + def __init__( + self, dashboard_id: str, exception: Optional[Exception] = None + ) -> None: + super().__init__(dashboard_id, self.reason, exception) + + +class DashboardIdInconsistencyError(FilterSetCreateFailedError): + reason = ( + "cannot create dashboard owner filterset based when the" + " ownerid is not the dashboard id" + ) + + def __init__( + self, dashboard_id: str, exception: Optional[Exception] = None + ) -> None: + super().__init__(dashboard_id, self.reason, exception) + + +class FilterSetForbiddenError(ForbiddenError): + message_format = 'Changing FilterSet "{}" is forbidden: {}' + + def __init__( + self, filterset_id: str, reason: str = "", exception: Optional[Exception] = None + ) -> None: + super().__init__(_(self.message_format.format(filterset_id, reason)), exception) diff --git a/superset/dashboards/filter_sets/commands/update.py b/superset/dashboards/filter_sets/commands/update.py new file mode 100644 index 0000000000000..d2c43f085212b --- /dev/null +++ b/superset/dashboards/filter_sets/commands/update.py @@ -0,0 +1,56 @@ +# 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 Any, Dict + +from flask_appbuilder.models.sqla import Model +from flask_appbuilder.security.sqla.models import User + +from superset.dao.exceptions import DAOUpdateFailedError +from superset.dashboards.filter_sets.commands.base import BaseFilterSetCommand +from superset.dashboards.filter_sets.commands.exceptions import ( + FilterSetUpdateFailedError, +) +from superset.dashboards.filter_sets.consts import OWNER_ID_FIELD, OWNER_TYPE_FIELD +from superset.dashboards.filter_sets.dao import FilterSetDAO + +logger = logging.getLogger(__name__) + + +class UpdateFilterSetCommand(BaseFilterSetCommand): + def __init__( + self, user: User, dashboard_id: int, filter_set_id: int, data: Dict[str, Any] + ): + super().__init__(user, dashboard_id) + self._filter_set_id = filter_set_id + self._properties = data.copy() + + def run(self) -> Model: + try: + self.validate() + if ( + OWNER_TYPE_FIELD in self._properties + and self._properties[OWNER_TYPE_FIELD] == "Dashboard" + ): + self._properties[OWNER_ID_FIELD] = self._dashboard_id + return FilterSetDAO.update(self._filter_set, self._properties, commit=True) + except DAOUpdateFailedError as err: + raise FilterSetUpdateFailedError(str(self._filter_set_id), "") from err + + def validate(self) -> None: + self._validate_filterset_dashboard_exists() + self.validate_exist_filter_use_cases_set() diff --git a/superset/dashboards/filter_sets/consts.py b/superset/dashboards/filter_sets/consts.py new file mode 100644 index 0000000000000..ff60a4f8bc3ed --- /dev/null +++ b/superset/dashboards/filter_sets/consts.py @@ -0,0 +1,30 @@ +# 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. +USER_OWNER_TYPE = "User" +DASHBOARD_OWNER_TYPE = "Dashboard" + +NAME_FIELD = "name" +DESCRIPTION_FIELD = "description" +JSON_METADATA_FIELD = "json_metadata" +OWNER_ID_FIELD = "owner_id" +OWNER_TYPE_FIELD = "owner_type" +DASHBOARD_ID_FIELD = "dashboard_id" +OWNER_OBJECT_FIELD = "owner_object" +DASHBOARD_FIELD = "dashboard" +PARAMS_PROPERTY = "params" + +FILTER_SET_API_PERMISSIONS_NAME = "FilterSets" diff --git a/superset/dashboards/filter_sets/dao.py b/superset/dashboards/filter_sets/dao.py new file mode 100644 index 0000000000000..949aa6d3fdf25 --- /dev/null +++ b/superset/dashboards/filter_sets/dao.py @@ -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. +import logging +from typing import Any, Dict + +from flask_appbuilder.models.sqla import Model +from sqlalchemy.exc import SQLAlchemyError + +from superset.dao.base import BaseDAO +from superset.dao.exceptions import DAOConfigError, DAOCreateFailedError +from superset.dashboards.filter_sets.consts import ( + DASHBOARD_ID_FIELD, + DESCRIPTION_FIELD, + JSON_METADATA_FIELD, + NAME_FIELD, + OWNER_ID_FIELD, + OWNER_TYPE_FIELD, +) +from superset.extensions import db +from superset.models.filter_set import FilterSet + +logger = logging.getLogger(__name__) + + +class FilterSetDAO(BaseDAO): + model_cls = FilterSet + + @classmethod + def create(cls, properties: Dict[str, Any], commit: bool = True) -> Model: + if cls.model_cls is None: + raise DAOConfigError() + model = FilterSet() + setattr(model, NAME_FIELD, properties[NAME_FIELD]) + setattr(model, JSON_METADATA_FIELD, properties[JSON_METADATA_FIELD]) + setattr(model, DESCRIPTION_FIELD, properties.get(DESCRIPTION_FIELD, None)) + setattr( + model, + OWNER_ID_FIELD, + properties.get(OWNER_ID_FIELD, properties[DASHBOARD_ID_FIELD]), + ) + setattr(model, OWNER_TYPE_FIELD, properties[OWNER_TYPE_FIELD]) + setattr(model, DASHBOARD_ID_FIELD, properties[DASHBOARD_ID_FIELD]) + try: + db.session.add(model) + if commit: + db.session.commit() + except SQLAlchemyError as ex: # pragma: no cover + db.session.rollback() + raise DAOCreateFailedError() from ex + return model diff --git a/superset/dashboards/filter_sets/filters.py b/superset/dashboards/filter_sets/filters.py new file mode 100644 index 0000000000000..0083f40d1a578 --- /dev/null +++ b/superset/dashboards/filter_sets/filters.py @@ -0,0 +1,58 @@ +# 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 __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from flask import g +from sqlalchemy import and_, or_ + +from superset.dashboards.filter_sets.consts import DASHBOARD_OWNER_TYPE, USER_OWNER_TYPE +from superset.models.dashboard import dashboard_user +from superset.models.filter_set import FilterSet +from superset.views.base import BaseFilter, is_user_admin + +if TYPE_CHECKING: + from sqlalchemy.orm.query import Query + + +class FilterSetFilter(BaseFilter): # pylint: disable=too-few-public-methods) + def apply(self, query: Query, value: Any) -> Query: + if is_user_admin(): + return query + current_user_id = g.user.id + + filter_set_ids_by_dashboard_owners = ( # pylint: disable=C0103 + query.from_self(FilterSet.id) + .join(dashboard_user, FilterSet.owner_id == dashboard_user.c.dashboard_id) + .filter( + and_( + FilterSet.owner_type == DASHBOARD_OWNER_TYPE, + dashboard_user.c.user_id == current_user_id, + ) + ) + ) + + return query.filter( + or_( + and_( + FilterSet.owner_type == USER_OWNER_TYPE, + FilterSet.owner_id == current_user_id, + ), + FilterSet.id.in_(filter_set_ids_by_dashboard_owners), + ) + ) diff --git a/superset/dashboards/filter_sets/schemas.py b/superset/dashboards/filter_sets/schemas.py new file mode 100644 index 0000000000000..3c0436d697b23 --- /dev/null +++ b/superset/dashboards/filter_sets/schemas.py @@ -0,0 +1,93 @@ +# 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 Any, cast, Dict, Mapping + +from marshmallow import fields, post_load, Schema, ValidationError +from marshmallow.validate import Length, OneOf + +from superset.dashboards.filter_sets.consts import ( + DASHBOARD_OWNER_TYPE, + JSON_METADATA_FIELD, + OWNER_ID_FIELD, + OWNER_TYPE_FIELD, + USER_OWNER_TYPE, +) + + +class JsonMetadataSchema(Schema): + nativeFilters = fields.Mapping(required=True, allow_none=False) + dataMask = fields.Mapping(required=False, allow_none=False) + + +class FilterSetSchema(Schema): + json_metadata_schema: JsonMetadataSchema = JsonMetadataSchema() + + def _validate_json_meta_data(self, json_meta_data: str) -> None: + try: + self.json_metadata_schema.loads(json_meta_data) + except Exception as ex: + raise ValidationError("failed to parse json_metadata to json") from ex + + +class FilterSetPostSchema(FilterSetSchema): + json_metadata_schema: JsonMetadataSchema = JsonMetadataSchema() + # pylint: disable=W0613 + name = fields.String(required=True, allow_none=False, validate=Length(0, 500),) + description = fields.String( + required=False, allow_none=True, validate=[Length(1, 1000)] + ) + json_metadata = fields.String(allow_none=False, required=True) + + owner_type = fields.String( + required=True, validate=OneOf([USER_OWNER_TYPE, DASHBOARD_OWNER_TYPE]) + ) + owner_id = fields.Int(required=False) + + @post_load + def validate( + self, data: Mapping[Any, Any], *, many: Any, partial: Any + ) -> Dict[str, Any]: + self._validate_json_meta_data(data[JSON_METADATA_FIELD]) + if data[OWNER_TYPE_FIELD] == USER_OWNER_TYPE and OWNER_ID_FIELD not in data: + raise ValidationError("owner_id is mandatory when owner_type is User") + return cast(Dict[str, Any], data) + + +class FilterSetPutSchema(FilterSetSchema): + name = fields.String(required=False, allow_none=False, validate=Length(0, 500)) + description = fields.String( + required=False, allow_none=False, validate=[Length(1, 1000)] + ) + json_metadata = fields.String(required=False, allow_none=False) + owner_type = fields.String( + allow_none=False, required=False, validate=OneOf([DASHBOARD_OWNER_TYPE]) + ) + + @post_load + def validate( # pylint: disable=unused-argument + self, data: Mapping[Any, Any], *, many: Any, partial: Any + ) -> Dict[str, Any]: + if JSON_METADATA_FIELD in data: + self._validate_json_meta_data(data[JSON_METADATA_FIELD]) + return cast(Dict[str, Any], data) + + +def validate_pair(first_field: str, second_field: str, data: Dict[str, Any]) -> None: + if first_field in data and second_field not in data: + raise ValidationError( + "{} must be included alongside {}".format(first_field, second_field) + ) diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index d19c1d131fff9..b8a1098cf0b9d 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -141,6 +141,7 @@ def init_views(self) -> None: from superset.queries.saved_queries.api import SavedQueryRestApi from superset.reports.api import ReportScheduleRestApi from superset.reports.logs.api import ReportExecutionLogRestApi + from superset.dashboards.filter_sets.api import FilterSetRestApi from superset.views.access_requests import AccessRequestsModelView from superset.views.alerts import ( AlertLogModelView, @@ -208,6 +209,7 @@ def init_views(self) -> None: appbuilder.add_api(SavedQueryRestApi) appbuilder.add_api(ReportScheduleRestApi) appbuilder.add_api(ReportExecutionLogRestApi) + appbuilder.add_api(FilterSetRestApi) # # Setup regular views # diff --git a/superset/migrations/versions/3ebe0993c770_filterset_table.py b/superset/migrations/versions/3ebe0993c770_filterset_table.py new file mode 100644 index 0000000000000..b509f895a0386 --- /dev/null +++ b/superset/migrations/versions/3ebe0993c770_filterset_table.py @@ -0,0 +1,56 @@ +# 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. +"""add filter set model + +Revision ID: 3ebe0993c770 +Revises: 07071313dd52 +Create Date: 2021-03-29 11:15:48.831225 + +""" + +# revision identifiers, used by Alembic. +revision = "3ebe0993c770" +down_revision = "181091c0ef16" + +import sqlalchemy as sa +from alembic import op + + +def upgrade(): + op.create_table( + "filter_sets", + sa.Column("created_on", sa.DateTime(), nullable=True), + sa.Column("changed_on", sa.DateTime(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.VARCHAR(500), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("json_metadata", sa.Text(), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("owner_type", sa.VARCHAR(255), nullable=False), + sa.Column( + "dashboard_id", sa.Integer(), sa.ForeignKey("dashboards.id"), nullable=False + ), + sa.Column("created_by_fk", sa.Integer(), nullable=True), + sa.Column("changed_by_fk", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["changed_by_fk"], ["ab_user.id"]), + sa.ForeignKeyConstraint(["created_by_fk"], ["ab_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + op.drop_table("filter_sets") diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index 3b15841d7011e..0db1ddf15048d 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -23,6 +23,7 @@ from typing import Any, Callable, Dict, List, Set, Tuple, Type, Union import sqlalchemy as sqla +from flask import g from flask_appbuilder import Model from flask_appbuilder.models.decorators import renders from flask_appbuilder.security.sqla.models import User @@ -46,10 +47,12 @@ from sqlalchemy.sql.elements import BinaryExpression from superset import app, ConnectorRegistry, db, is_feature_enabled, security_manager +from superset.common.request_contexed_based import is_user_admin from superset.connectors.base.models import BaseDatasource from superset.connectors.druid.models import DruidColumn, DruidMetric from superset.connectors.sqla.models import SqlMetric, TableColumn from superset.extensions import cache_manager +from superset.models.filter_set import FilterSet from superset.models.helpers import AuditMixinNullable, ImportExportMixin from superset.models.slice import Slice from superset.models.tags import DashboardUpdater @@ -129,6 +132,7 @@ def copy_dashboard( ) +# pylint: disable=too-many-public-methods class Dashboard(Model, AuditMixinNullable, ImportExportMixin): """The dashboard object!""" @@ -144,6 +148,9 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin): owners = relationship(security_manager.user_model, secondary=dashboard_user) published = Column(Boolean, default=False) roles = relationship(security_manager.role_model, secondary=DashboardRoles) + _filter_sets = relationship( + "FilterSet", back_populates="dashboard", cascade="all, delete" + ) export_fields = [ "dashboard_title", "position_json", @@ -178,6 +185,29 @@ def datasources(self) -> Set[BaseDatasource]: .all() } + @property + def filter_sets(self) -> Dict[int, FilterSet]: + return {fs.id: fs for fs in self._filter_sets} + + @property + def filter_sets_lst(self) -> Dict[int, FilterSet]: + if is_user_admin(): + return self._filter_sets + current_user = g.user.id + filter_sets_by_owner_type: Dict[str, List[Any]] = {"Dashboard": [], "User": []} + for fs in self._filter_sets: + filter_sets_by_owner_type[fs.owner_type].append(fs) + user_filter_sets = list( + filter( + lambda filter_set: filter_set.owner_id == current_user, + filter_sets_by_owner_type["User"], + ) + ) + return { + fs.id: fs + for fs in user_filter_sets + filter_sets_by_owner_type["Dashboard"] + } + @property def charts(self) -> List[BaseDatasource]: return [slc.chart for slc in self.slices] @@ -397,6 +427,11 @@ def get(cls, id_or_slug: str) -> Dashboard: qry = session.query(Dashboard).filter(id_or_slug_filter(id_or_slug)) return qry.one_or_none() + def is_actor_owner(self) -> bool: + if g.user is None or g.user.is_anonymous or not g.user.is_authenticated: + return False + return g.user.id in set(map(lambda user: user.id, self.owners)) + def id_or_slug_filter(id_or_slug: str) -> BinaryExpression: if id_or_slug.isdigit(): diff --git a/superset/models/filter_set.py b/superset/models/filter_set.py new file mode 100644 index 0000000000000..2d3b218793dcf --- /dev/null +++ b/superset/models/filter_set.py @@ -0,0 +1,106 @@ +# 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 __future__ import annotations + +import json +import logging +from typing import Any, Dict + +from flask_appbuilder import Model +from sqlalchemy import Column, ForeignKey, Integer, MetaData, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy_utils import generic_relationship + +from superset import app, db +from superset.models.helpers import AuditMixinNullable + +metadata = Model.metadata # pylint: disable=no-member +config = app.config +logger = logging.getLogger(__name__) + + +class FilterSet(Model, AuditMixinNullable): + __tablename__ = "filter_sets" + id = Column(Integer, primary_key=True) + name = Column(String(500), nullable=False, unique=True) + description = Column(Text, nullable=True) + json_metadata = Column(Text, nullable=False) + dashboard_id = Column(Integer, ForeignKey("dashboards.id")) + dashboard = relationship("Dashboard", back_populates="_filter_sets") + owner_id = Column(Integer, nullable=False) + owner_type = Column(String(255), nullable=False) + owner_object = generic_relationship(owner_type, owner_id) + + def __repr__(self) -> str: + return f"FilterSet<{self.name or self.id}>" + + @property + def url(self) -> str: + return f"/api/filtersets/{self.id}/" + + @property + def sqla_metadata(self) -> None: + # pylint: disable=no-member + meta = MetaData(bind=self.get_sqla_engine()) + meta.reflect() + + @property + def changed_by_name(self) -> str: + if not self.changed_by: + return "" + return str(self.changed_by) + + @property + def changed_by_url(self) -> str: + if not self.changed_by: + return "" + return f"/superset/profile/{self.changed_by.username}" + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "params": self.params, + "dashboard_id": self.dashboard_id, + "owner_type": self.owner_type, + "owner_id": self.owner_id, + } + + @classmethod + def get(cls, _id: int) -> FilterSet: + session = db.session() + qry = session.query(FilterSet).filter(_id) + return qry.one_or_none() + + @classmethod + def get_by_name(cls, name: str) -> FilterSet: + session = db.session() + qry = session.query(FilterSet).filter(FilterSet.name == name) + return qry.one_or_none() + + @classmethod + def get_by_dashboard_id(cls, dashboard_id: int) -> FilterSet: + session = db.session() + qry = session.query(FilterSet).filter(FilterSet.dashboard_id == dashboard_id) + return qry.all() + + @property + def params(self) -> Dict[str, Any]: + if self.json_metadata: + return json.loads(self.json_metadata) + return {} diff --git a/tests/integration_tests/dashboards/filter_sets/__init__.py b/tests/integration_tests/dashboards/filter_sets/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/integration_tests/dashboards/filter_sets/conftest.py b/tests/integration_tests/dashboards/filter_sets/conftest.py new file mode 100644 index 0000000000000..36642b194dbce --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/conftest.py @@ -0,0 +1,321 @@ +# 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 __future__ import annotations + +import json +from typing import Any, Dict, Generator, List, TYPE_CHECKING + +import pytest + +from superset import security_manager as sm +from superset.dashboards.filter_sets.consts import ( + DESCRIPTION_FIELD, + JSON_METADATA_FIELD, + NAME_FIELD, + OWNER_ID_FIELD, + OWNER_TYPE_FIELD, + USER_OWNER_TYPE, +) +from superset.models.dashboard import Dashboard +from superset.models.filter_set import FilterSet +from tests.integration_tests.dashboards.filter_sets.consts import ( + ADMIN_USERNAME_FOR_TEST, + DASHBOARD_OWNER_USERNAME, + FILTER_SET_OWNER_USERNAME, + REGULAR_USER, +) +from tests.integration_tests.dashboards.superset_factory_util import ( + create_dashboard, + create_database, + create_datasource_table, + create_slice, +) +from tests.integration_tests.test_app import app + +if TYPE_CHECKING: + from flask.ctx import AppContext + from flask.testing import FlaskClient + from flask_appbuilder.security.sqla.models import ( + Role, + User, + ViewMenu, + PermissionView, + ) + from flask_appbuilder.security.manager import BaseSecurityManager + from sqlalchemy.orm import Session + from superset.models.slice import Slice + from superset.connectors.sqla.models import SqlaTable + from superset.models.core import Database + + +security_manager: BaseSecurityManager = sm + + +# @pytest.fixture(autouse=True, scope="session") +# def setup_sample_data() -> Any: +# pass + + +@pytest.fixture(autouse=True) +def expire_on_commit_true() -> Generator[None, None, None]: + ctx: AppContext + with app.app_context() as ctx: + ctx.app.appbuilder.get_session.configure(expire_on_commit=False) + yield + ctx.app.appbuilder.get_session.configure(expire_on_commit=True) + + +@pytest.fixture(autouse=True, scope="module") +def test_users() -> Generator[Dict[str, int], None, None]: + usernames = [ + ADMIN_USERNAME_FOR_TEST, + DASHBOARD_OWNER_USERNAME, + FILTER_SET_OWNER_USERNAME, + REGULAR_USER, + ] + with app.app_context(): + filter_set_role = build_filter_set_role() + admin_role: Role = security_manager.find_role("Admin") + usernames_to_ids = create_test_users(admin_role, filter_set_role, usernames) + yield usernames_to_ids + ctx: AppContext + delete_users(usernames_to_ids) + + +def delete_users(usernames_to_ids: Dict[str, int]) -> None: + with app.app_context() as ctx: + session: Session = ctx.app.appbuilder.get_session + for username in usernames_to_ids.keys(): + session.delete(security_manager.find_user(username)) + session.commit() + + +def create_test_users( + admin_role: Role, filter_set_role: Role, usernames: List[str] +) -> Dict[str, int]: + users: List[User] = [] + for username in usernames: + user = build_user(username, filter_set_role, admin_role) + users.append(user) + return {user.username: user.id for user in users} + + +def build_user(username: str, filter_set_role: Role, admin_role: Role) -> User: + roles_to_add = ( + [admin_role] if username == ADMIN_USERNAME_FOR_TEST else [filter_set_role] + ) + user: User = security_manager.add_user( + username, "test", "test", username, roles_to_add, password="general" + ) + if not user: + user = security_manager.find_user(username) + if user is None: + raise Exception("Failed to build the user {}".format(username)) + return user + + +def build_filter_set_role() -> Role: + filter_set_role: Role = security_manager.add_role("filter_set_role") + filterset_view_name: ViewMenu = security_manager.find_view_menu("FilterSets") + all_datasource_view_name: ViewMenu = security_manager.find_view_menu( + "all_datasource_access" + ) + pvms: List[PermissionView] = security_manager.find_permissions_view_menu( + filterset_view_name + ) + security_manager.find_permissions_view_menu(all_datasource_view_name) + for pvm in pvms: + security_manager.add_permission_role(filter_set_role, pvm) + return filter_set_role + + +@pytest.fixture +def client() -> Generator[FlaskClient[Any], None, None]: + with app.test_client() as client: + yield client + + +@pytest.fixture +def dashboard() -> Generator[Dashboard, None, None]: + dashboard: Dashboard + slice_: Slice + datasource: SqlaTable + database: Database + session: Session + try: + with app.app_context() as ctx: + dashboard_owner_user = security_manager.find_user(DASHBOARD_OWNER_USERNAME) + database = create_database("test_database_filter_sets") + datasource = create_datasource_table( + name="test_datasource", database=database, owners=[dashboard_owner_user] + ) + slice_ = create_slice( + datasource=datasource, name="test_slice", owners=[dashboard_owner_user] + ) + dashboard = create_dashboard( + dashboard_title="test_dashboard", + published=True, + slices=[slice_], + owners=[dashboard_owner_user], + ) + session = ctx.app.appbuilder.get_session + session.add(dashboard) + session.commit() + yield dashboard + except Exception as ex: + print(str(ex)) + finally: + with app.app_context() as ctx: + session = ctx.app.appbuilder.get_session + try: + dashboard.owners = [] + slice_.owners = [] + datasource.owners = [] + session.merge(dashboard) + session.merge(slice_) + session.merge(datasource) + session.commit() + session.delete(dashboard) + session.delete(slice_) + session.delete(datasource) + session.delete(database) + session.commit() + except Exception as ex: + print(str(ex)) + + +@pytest.fixture +def dashboard_id(dashboard) -> int: + return dashboard.id + + +@pytest.fixture +def filtersets( + dashboard_id: int, test_users: Dict[str, int], dumped_valid_json_metadata: str +) -> Generator[Dict[str, List[FilterSet]], None, None]: + try: + with app.app_context() as ctx: + session: Session = ctx.app.appbuilder.get_session + first_filter_set = FilterSet( + name="filter_set_1_of_" + str(dashboard_id), + dashboard_id=dashboard_id, + json_metadata=dumped_valid_json_metadata, + owner_id=dashboard_id, + owner_type="Dashboard", + ) + second_filter_set = FilterSet( + name="filter_set_2_of_" + str(dashboard_id), + json_metadata=dumped_valid_json_metadata, + dashboard_id=dashboard_id, + owner_id=dashboard_id, + owner_type="Dashboard", + ) + third_filter_set = FilterSet( + name="filter_set_3_of_" + str(dashboard_id), + json_metadata=dumped_valid_json_metadata, + dashboard_id=dashboard_id, + owner_id=test_users[FILTER_SET_OWNER_USERNAME], + owner_type="User", + ) + forth_filter_set = FilterSet( + name="filter_set_4_of_" + str(dashboard_id), + json_metadata=dumped_valid_json_metadata, + dashboard_id=dashboard_id, + owner_id=test_users[FILTER_SET_OWNER_USERNAME], + owner_type="User", + ) + session.add(first_filter_set) + session.add(second_filter_set) + session.add(third_filter_set) + session.add(forth_filter_set) + session.commit() + yv = { + "Dashboard": [first_filter_set, second_filter_set], + FILTER_SET_OWNER_USERNAME: [third_filter_set, forth_filter_set], + } + yield yv + except Exception as ex: + print(str(ex)) + + +@pytest.fixture +def filterset_id(filtersets: Dict[str, List[FilterSet]]) -> int: + return filtersets["Dashboard"][0].id + + +@pytest.fixture +def valid_json_metadata() -> Dict[str, Any]: + return {"nativeFilters": {}} + + +@pytest.fixture +def dumped_valid_json_metadata(valid_json_metadata: Dict[str, Any]) -> str: + return json.dumps(valid_json_metadata) + + +@pytest.fixture +def exists_user_id() -> int: + return 1 + + +@pytest.fixture +def valid_filter_set_data_for_create( + dashboard_id: int, dumped_valid_json_metadata: str, exists_user_id: int +) -> Dict[str, Any]: + name = "test_filter_set_of_dashboard_" + str(dashboard_id) + return { + NAME_FIELD: name, + DESCRIPTION_FIELD: "description of " + name, + JSON_METADATA_FIELD: dumped_valid_json_metadata, + OWNER_TYPE_FIELD: USER_OWNER_TYPE, + OWNER_ID_FIELD: exists_user_id, + } + + +@pytest.fixture +def valid_filter_set_data_for_update( + dashboard_id: int, dumped_valid_json_metadata: str, exists_user_id: int +) -> Dict[str, Any]: + name = "name_changed_test_filter_set_of_dashboard_" + str(dashboard_id) + return { + NAME_FIELD: name, + DESCRIPTION_FIELD: "changed description of " + name, + JSON_METADATA_FIELD: dumped_valid_json_metadata, + } + + +@pytest.fixture +def not_exists_dashboard(dashboard_id: int) -> int: + return dashboard_id + 1 + + +@pytest.fixture +def not_exists_user_id() -> int: + return 99999 + + +@pytest.fixture() +def dashboard_based_filter_set_dict( + filtersets: Dict[str, List[FilterSet]] +) -> Dict[str, Any]: + return filtersets["Dashboard"][0].to_dict() + + +@pytest.fixture() +def user_based_filter_set_dict( + filtersets: Dict[str, List[FilterSet]] +) -> Dict[str, Any]: + return filtersets[FILTER_SET_OWNER_USERNAME][0].to_dict() diff --git a/tests/integration_tests/dashboards/filter_sets/consts.py b/tests/integration_tests/dashboards/filter_sets/consts.py new file mode 100644 index 0000000000000..f54f00fea8b75 --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/consts.py @@ -0,0 +1,22 @@ +# 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. +FILTER_SET_URI = "api/v1/dashboard/{dashboard_id}/filtersets" + +ADMIN_USERNAME_FOR_TEST = "admin@filterset.com" +DASHBOARD_OWNER_USERNAME = "dash_owner_user@filterset.com" +FILTER_SET_OWNER_USERNAME = "fs_owner_user@filterset.com" +REGULAR_USER = "regular_user@filterset.com" diff --git a/tests/integration_tests/dashboards/filter_sets/create_api_tests.py b/tests/integration_tests/dashboards/filter_sets/create_api_tests.py new file mode 100644 index 0000000000000..cbdaef9b95a01 --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/create_api_tests.py @@ -0,0 +1,630 @@ +# 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 __future__ import annotations + +from typing import Any, Dict, TYPE_CHECKING + +from superset.dashboards.filter_sets.consts import ( + DASHBOARD_OWNER_TYPE, + DESCRIPTION_FIELD, + JSON_METADATA_FIELD, + NAME_FIELD, + OWNER_ID_FIELD, + OWNER_TYPE_FIELD, + USER_OWNER_TYPE, +) +from tests.integration_tests.base_tests import login +from tests.integration_tests.dashboards.filter_sets.consts import ( + ADMIN_USERNAME_FOR_TEST, + DASHBOARD_OWNER_USERNAME, + FILTER_SET_OWNER_USERNAME, +) +from tests.integration_tests.dashboards.filter_sets.utils import ( + call_create_filter_set, + get_filter_set_by_dashboard_id, + get_filter_set_by_name, +) + +if TYPE_CHECKING: + from flask.testing import FlaskClient + + +def assert_filterset_was_not_created(filter_set_data: Dict[str, Any]) -> None: + assert get_filter_set_by_name(str(filter_set_data["name"])) is None + + +def assert_filterset_was_created(filter_set_data: Dict[str, Any]) -> None: + assert get_filter_set_by_name(filter_set_data["name"]) is not None + + +class TestCreateFilterSetsApi: + def test_with_extra_field__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create["extra"] = "val" + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert response.json["message"]["extra"][0] == "Unknown field." + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_with_id_field__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create["id"] = 1 + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert response.json["message"]["id"][0] == "Unknown field." + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_with_dashboard_not_exists__404( + self, + not_exists_dashboard: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # act + login(client, "admin") + response = call_create_filter_set( + client, not_exists_dashboard, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 404 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_without_name__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create.pop(NAME_FIELD, None) + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert get_filter_set_by_dashboard_id(dashboard_id) == [] + + def test_with_none_name__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[NAME_FIELD] = None + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_with_int_as_name__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[NAME_FIELD] = 4 + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_without_description__201( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create.pop(DESCRIPTION_FIELD, None) + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_with_none_description__201( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[DESCRIPTION_FIELD] = None + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_with_int_as_description__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[DESCRIPTION_FIELD] = 1 + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_without_json_metadata__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create.pop(JSON_METADATA_FIELD, None) + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_with_invalid_json_metadata__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[DESCRIPTION_FIELD] = {} + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_without_owner_type__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create.pop(OWNER_TYPE_FIELD, None) + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_with_invalid_owner_type__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = "OTHER_TYPE" + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_without_owner_id_when_owner_type_is_user__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create.pop(OWNER_ID_FIELD, None) + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_without_owner_id_when_owner_type_is_dashboard__201( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = DASHBOARD_OWNER_TYPE + valid_filter_set_data_for_create.pop(OWNER_ID_FIELD, None) + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_with_not_exists_owner__400( + self, + dashboard_id: int, + valid_filter_set_data_for_create: Dict[str, Any], + not_exists_user_id: int, + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = not_exists_user_id + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_created(valid_filter_set_data_for_create) + + def test_when_caller_is_admin_and_owner_is_admin__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + ADMIN_USERNAME_FOR_TEST + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_admin_and_owner_is_dashboard_owner__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + DASHBOARD_OWNER_USERNAME + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_admin_and_owner_is_regular_user__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + FILTER_SET_OWNER_USERNAME + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_admin_and_owner_type_is_dashboard__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = DASHBOARD_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = dashboard_id + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_dashboard_owner_and_owner_is_admin__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + ADMIN_USERNAME_FOR_TEST + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_dashboard_owner_and_owner_is_dashboard_owner__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + DASHBOARD_OWNER_USERNAME + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_dashboard_owner_and_owner_is_regular_user__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + FILTER_SET_OWNER_USERNAME + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_dashboard_owner_and_owner_type_is_dashboard__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = DASHBOARD_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = dashboard_id + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_regular_user_and_owner_is_admin__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, FILTER_SET_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + ADMIN_USERNAME_FOR_TEST + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_regular_user_and_owner_is_dashboard_owner__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, FILTER_SET_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + DASHBOARD_OWNER_USERNAME + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_regular_user_and_owner_is_regular_user__201( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, FILTER_SET_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = USER_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = test_users[ + FILTER_SET_OWNER_USERNAME + ] + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 201 + assert_filterset_was_created(valid_filter_set_data_for_create) + + def test_when_caller_is_regular_user_and_owner_type_is_dashboard__403( + self, + dashboard_id: int, + test_users: Dict[str, int], + valid_filter_set_data_for_create: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, FILTER_SET_OWNER_USERNAME) + valid_filter_set_data_for_create[OWNER_TYPE_FIELD] = DASHBOARD_OWNER_TYPE + valid_filter_set_data_for_create[OWNER_ID_FIELD] = dashboard_id + + # act + response = call_create_filter_set( + client, dashboard_id, valid_filter_set_data_for_create + ) + + # assert + assert response.status_code == 403 + assert_filterset_was_not_created(valid_filter_set_data_for_create) diff --git a/tests/integration_tests/dashboards/filter_sets/delete_api_tests.py b/tests/integration_tests/dashboards/filter_sets/delete_api_tests.py new file mode 100644 index 0000000000000..12a9bff9e13b8 --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/delete_api_tests.py @@ -0,0 +1,209 @@ +# 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 __future__ import annotations + +from typing import Any, Dict, List, TYPE_CHECKING + +from tests.integration_tests.base_tests import login +from tests.integration_tests.dashboards.filter_sets.consts import ( + DASHBOARD_OWNER_USERNAME, + FILTER_SET_OWNER_USERNAME, + REGULAR_USER, +) +from tests.integration_tests.dashboards.filter_sets.utils import ( + call_delete_filter_set, + collect_all_ids, + get_filter_set_by_name, +) + +if TYPE_CHECKING: + from flask.testing import FlaskClient + from superset.models.filter_set import FilterSet + + +def assert_filterset_was_not_deleted(filter_set_dict: Dict[str, Any]) -> None: + assert get_filter_set_by_name(filter_set_dict["name"]) is not None + + +def assert_filterset_deleted(filter_set_dict: Dict[str, Any]) -> None: + assert get_filter_set_by_name(filter_set_dict["name"]) is None + + +class TestDeleteFilterSet: + def test_with_dashboard_exists_filterset_not_exists__200( + self, + dashboard_id: int, + filtersets: Dict[str, List[FilterSet]], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + filter_set_id = max(collect_all_ids(filtersets)) + 1 + + response = call_delete_filter_set(client, {"id": filter_set_id}, dashboard_id) + # assert + assert response.status_code == 200 + + def test_with_dashboard_not_exists_filterset_not_exists__404( + self, + not_exists_dashboard: int, + filtersets: Dict[str, List[FilterSet]], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + filter_set_id = max(collect_all_ids(filtersets)) + 1 + + response = call_delete_filter_set( + client, {"id": filter_set_id}, not_exists_dashboard + ) + # assert + assert response.status_code == 404 + + def test_with_dashboard_not_exists_filterset_exists__404( + self, + not_exists_dashboard: int, + dashboard_based_filter_set_dict: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + + # act + response = call_delete_filter_set( + client, dashboard_based_filter_set_dict, not_exists_dashboard + ) + # assert + assert response.status_code == 404 + assert_filterset_was_not_deleted(dashboard_based_filter_set_dict) + + def test_when_caller_is_admin_and_owner_type_is_user__200( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + # act + response = call_delete_filter_set(client, user_based_filter_set_dict) + + # assert + assert response.status_code == 200 + assert_filterset_deleted(user_based_filter_set_dict) + + def test_when_caller_is_admin_and_owner_type_is_dashboard__200( + self, + test_users: Dict[str, int], + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + # act + response = call_delete_filter_set(client, dashboard_based_filter_set_dict) + + # assert + assert response.status_code == 200 + assert_filterset_deleted(dashboard_based_filter_set_dict) + + def test_when_caller_is_dashboard_owner_and_owner_is_other_user_403( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + + # act + response = call_delete_filter_set(client, user_based_filter_set_dict) + + # assert + assert response.status_code == 403 + assert_filterset_was_not_deleted(user_based_filter_set_dict) + + def test_when_caller_is_dashboard_owner_and_owner_type_is_dashboard__200( + self, + test_users: Dict[str, int], + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + + # act + response = call_delete_filter_set(client, dashboard_based_filter_set_dict) + + # assert + assert response.status_code == 200 + assert_filterset_deleted(dashboard_based_filter_set_dict) + + def test_when_caller_is_filterset_owner__200( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, FILTER_SET_OWNER_USERNAME) + + # act + response = call_delete_filter_set(client, user_based_filter_set_dict) + + # assert + assert response.status_code == 200 + assert_filterset_deleted(user_based_filter_set_dict) + + def test_when_caller_is_regular_user_and_owner_type_is_user__403( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, REGULAR_USER) + + # act + response = call_delete_filter_set(client, user_based_filter_set_dict) + + # assert + assert response.status_code == 403 + assert_filterset_was_not_deleted(user_based_filter_set_dict) + + def test_when_caller_is_regular_user_and_owner_type_is_dashboard__403( + self, + test_users: Dict[str, int], + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, REGULAR_USER) + + # act + response = call_delete_filter_set(client, dashboard_based_filter_set_dict) + + # assert + assert response.status_code == 403 + assert_filterset_was_not_deleted(dashboard_based_filter_set_dict) diff --git a/tests/integration_tests/dashboards/filter_sets/get_api_tests.py b/tests/integration_tests/dashboards/filter_sets/get_api_tests.py new file mode 100644 index 0000000000000..3db2d472eae71 --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/get_api_tests.py @@ -0,0 +1,129 @@ +# 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 __future__ import annotations + +from typing import Any, Dict, List, Set, TYPE_CHECKING + +from tests.integration_tests.base_tests import login +from tests.integration_tests.dashboards.filter_sets.consts import ( + DASHBOARD_OWNER_USERNAME, + FILTER_SET_OWNER_USERNAME, + REGULAR_USER, +) +from tests.integration_tests.dashboards.filter_sets.utils import ( + call_get_filter_sets, + collect_all_ids, +) + +if TYPE_CHECKING: + from flask.testing import FlaskClient + from superset.models.filter_set import FilterSet + + +class TestGetFilterSetsApi: + def test_with_dashboard_not_exists__404( + self, not_exists_dashboard: int, client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + + # act + response = call_get_filter_sets(client, not_exists_dashboard) + + # assert + assert response.status_code == 404 + + def test_dashboards_without_filtersets__200( + self, dashboard_id: int, client: FlaskClient[Any] + ): + # arrange + login(client, "admin") + + # act + response = call_get_filter_sets(client, dashboard_id) + + # assert + assert response.status_code == 200 + assert response.is_json and response.json["count"] == 0 + + def test_when_caller_admin__200( + self, + dashboard_id: int, + filtersets: Dict[str, List[FilterSet]], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + expected_ids: Set[int] = collect_all_ids(filtersets) + + # act + response = call_get_filter_sets(client, dashboard_id) + + # assert + assert response.status_code == 200 + assert response.is_json and set(response.json["ids"]) == expected_ids + + def test_when_caller_dashboard_owner__200( + self, + dashboard_id: int, + filtersets: Dict[str, List[FilterSet]], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + expected_ids = collect_all_ids(filtersets["Dashboard"]) + + # act + response = call_get_filter_sets(client, dashboard_id) + + # assert + assert response.status_code == 200 + assert response.is_json and set(response.json["ids"]) == expected_ids + + def test_when_caller_filterset_owner__200( + self, + dashboard_id: int, + filtersets: Dict[str, List[FilterSet]], + client: FlaskClient[Any], + ): + # arrange + login(client, FILTER_SET_OWNER_USERNAME) + expected_ids = collect_all_ids(filtersets[FILTER_SET_OWNER_USERNAME]) + + # act + response = call_get_filter_sets(client, dashboard_id) + + # assert + assert response.status_code == 200 + assert response.is_json and set(response.json["ids"]) == expected_ids + + def test_when_caller_regular_user__200( + self, + dashboard_id: int, + filtersets: Dict[str, List[int]], + client: FlaskClient[Any], + ): + # arrange + login(client, REGULAR_USER) + expected_ids: Set[int] = set() + + # act + response = call_get_filter_sets(client, dashboard_id) + + # assert + assert response.status_code == 200 + assert response.is_json and set(response.json["ids"]) == expected_ids diff --git a/tests/integration_tests/dashboards/filter_sets/update_api_tests.py b/tests/integration_tests/dashboards/filter_sets/update_api_tests.py new file mode 100644 index 0000000000000..fcaeaab016c6c --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/update_api_tests.py @@ -0,0 +1,519 @@ +# 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 __future__ import annotations + +import json +from typing import Any, Dict, List, TYPE_CHECKING + +from superset.dashboards.filter_sets.consts import ( + DESCRIPTION_FIELD, + JSON_METADATA_FIELD, + NAME_FIELD, + OWNER_TYPE_FIELD, + PARAMS_PROPERTY, +) +from tests.integration_tests.base_tests import login +from tests.integration_tests.dashboards.filter_sets.consts import ( + DASHBOARD_OWNER_USERNAME, + FILTER_SET_OWNER_USERNAME, + REGULAR_USER, +) +from tests.integration_tests.dashboards.filter_sets.utils import ( + call_update_filter_set, + collect_all_ids, + get_filter_set_by_name, +) + +if TYPE_CHECKING: + from flask.testing import FlaskClient + from superset.models.filter_set import FilterSet + + +def merge_two_filter_set_dict( + first: Dict[Any, Any], second: Dict[Any, Any] +) -> Dict[Any, Any]: + for d in [first, second]: + if JSON_METADATA_FIELD in d: + if PARAMS_PROPERTY not in d: + d.setdefault(PARAMS_PROPERTY, json.loads(d[JSON_METADATA_FIELD])) + d.pop(JSON_METADATA_FIELD) + return {**first, **second} + + +def assert_filterset_was_not_updated(filter_set_dict: Dict[str, Any]) -> None: + assert filter_set_dict == get_filter_set_by_name(filter_set_dict["name"]).to_dict() + + +def assert_filterset_updated( + filter_set_dict_before: Dict[str, Any], data_updated: Dict[str, Any] +) -> None: + expected_data = merge_two_filter_set_dict(filter_set_dict_before, data_updated) + assert expected_data == get_filter_set_by_name(expected_data["name"]).to_dict() + + +class TestUpdateFilterSet: + def test_with_dashboard_exists_filterset_not_exists__404( + self, + dashboard_id: int, + filtersets: Dict[str, List[FilterSet]], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + filter_set_id = max(collect_all_ids(filtersets)) + 1 + + response = call_update_filter_set( + client, {"id": filter_set_id}, {}, dashboard_id + ) + # assert + assert response.status_code == 404 + + def test_with_dashboard_not_exists_filterset_not_exists__404( + self, + not_exists_dashboard: int, + filtersets: Dict[str, List[FilterSet]], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + filter_set_id = max(collect_all_ids(filtersets)) + 1 + + response = call_update_filter_set( + client, {"id": filter_set_id}, {}, not_exists_dashboard + ) + # assert + assert response.status_code == 404 + + def test_with_dashboard_not_exists_filterset_exists__404( + self, + not_exists_dashboard: int, + dashboard_based_filter_set_dict: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, {}, not_exists_dashboard + ) + # assert + assert response.status_code == 404 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_extra_field__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update["extra"] = "val" + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert response.json["message"]["extra"][0] == "Unknown field." + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_id_field__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update["id"] = 1 + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert response.json["message"]["id"][0] == "Unknown field." + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_none_name__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[NAME_FIELD] = None + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_int_as_name__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[NAME_FIELD] = 4 + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_without_name__200( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update.pop(NAME_FIELD, None) + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + assert_filterset_updated( + dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_with_none_description__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[DESCRIPTION_FIELD] = None + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_int_as_description__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[DESCRIPTION_FIELD] = 1 + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_without_description__200( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update.pop(DESCRIPTION_FIELD, None) + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + assert_filterset_updated( + dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_with_invalid_json_metadata__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[DESCRIPTION_FIELD] = {} + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_json_metadata__200( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + valid_json_metadata: Dict[Any, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_json_metadata["nativeFilters"] = {"changed": "changed"} + valid_filter_set_data_for_update[JSON_METADATA_FIELD] = json.dumps( + valid_json_metadata + ) + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + assert_filterset_updated( + dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_with_invalid_owner_type__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[OWNER_TYPE_FIELD] = "OTHER_TYPE" + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_user_owner_type__400( + self, + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[OWNER_TYPE_FIELD] = "User" + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 400 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) + + def test_with_dashboard_owner_type__200( + self, + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + valid_filter_set_data_for_update[OWNER_TYPE_FIELD] = "Dashboard" + + # act + response = call_update_filter_set( + client, user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + user_based_filter_set_dict["owner_id"] = user_based_filter_set_dict[ + "dashboard_id" + ] + assert_filterset_updated( + user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_when_caller_is_admin_and_owner_type_is_user__200( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + # act + response = call_update_filter_set( + client, user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + assert_filterset_updated( + user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_when_caller_is_admin_and_owner_type_is_dashboard__200( + self, + test_users: Dict[str, int], + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, "admin") + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + assert_filterset_updated( + dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_when_caller_is_dashboard_owner_and_owner_is_other_user_403( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + + # act + response = call_update_filter_set( + client, user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 403 + assert_filterset_was_not_updated(user_based_filter_set_dict) + + def test_when_caller_is_dashboard_owner_and_owner_type_is_dashboard__200( + self, + test_users: Dict[str, int], + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, DASHBOARD_OWNER_USERNAME) + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + assert_filterset_updated( + dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_when_caller_is_filterset_owner__200( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, FILTER_SET_OWNER_USERNAME) + + # act + response = call_update_filter_set( + client, user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 200 + assert_filterset_updated( + user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + def test_when_caller_is_regular_user_and_owner_type_is_user__403( + self, + test_users: Dict[str, int], + user_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, REGULAR_USER) + + # act + response = call_update_filter_set( + client, user_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 403 + assert_filterset_was_not_updated(user_based_filter_set_dict) + + def test_when_caller_is_regular_user_and_owner_type_is_dashboard__403( + self, + test_users: Dict[str, int], + dashboard_based_filter_set_dict: Dict[str, Any], + valid_filter_set_data_for_update: Dict[str, Any], + client: FlaskClient[Any], + ): + # arrange + login(client, REGULAR_USER) + + # act + response = call_update_filter_set( + client, dashboard_based_filter_set_dict, valid_filter_set_data_for_update + ) + + # assert + assert response.status_code == 403 + assert_filterset_was_not_updated(dashboard_based_filter_set_dict) diff --git a/tests/integration_tests/dashboards/filter_sets/utils.py b/tests/integration_tests/dashboards/filter_sets/utils.py new file mode 100644 index 0000000000000..a63e4164d8959 --- /dev/null +++ b/tests/integration_tests/dashboards/filter_sets/utils.py @@ -0,0 +1,102 @@ +# 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 __future__ import annotations + +from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Union + +from superset.models.filter_set import FilterSet +from tests.integration_tests.dashboards.filter_sets.consts import FILTER_SET_URI +from tests.integration_tests.test_app import app + +if TYPE_CHECKING: + from flask import Response + from flask.testing import FlaskClient + + +def call_create_filter_set( + client: FlaskClient[Any], dashboard_id: int, data: Dict[str, Any] +) -> Response: + uri = FILTER_SET_URI.format(dashboard_id=dashboard_id) + return client.post(uri, json=data) + + +def call_get_filter_sets(client: FlaskClient[Any], dashboard_id: int) -> Response: + uri = FILTER_SET_URI.format(dashboard_id=dashboard_id) + return client.get(uri) + + +def call_delete_filter_set( + client: FlaskClient[Any], + filter_set_dict_to_update: Dict[str, Any], + dashboard_id: Optional[int] = None, +) -> Response: + dashboard_id = ( + dashboard_id + if dashboard_id is not None + else filter_set_dict_to_update["dashboard_id"] + ) + uri = "{}/{}".format( + FILTER_SET_URI.format(dashboard_id=dashboard_id), + filter_set_dict_to_update["id"], + ) + return client.delete(uri) + + +def call_update_filter_set( + client: FlaskClient[Any], + filter_set_dict_to_update: Dict[str, Any], + data: Dict[str, Any], + dashboard_id: Optional[int] = None, +) -> Response: + dashboard_id = ( + dashboard_id + if dashboard_id is not None + else filter_set_dict_to_update["dashboard_id"] + ) + uri = "{}/{}".format( + FILTER_SET_URI.format(dashboard_id=dashboard_id), + filter_set_dict_to_update["id"], + ) + return client.put(uri, json=data) + + +def get_filter_set_by_name(name: str) -> FilterSet: + with app.app_context(): + return FilterSet.get_by_name(name) + + +def get_filter_set_by_id(id_: int) -> FilterSet: + with app.app_context(): + return FilterSet.get(id_) + + +def get_filter_set_by_dashboard_id(dashboard_id: int) -> FilterSet: + with app.app_context(): + return FilterSet.get_by_dashboard_id(dashboard_id) + + +def collect_all_ids( + filtersets: Union[Dict[str, List[FilterSet]], List[FilterSet]] +) -> Set[int]: + if isinstance(filtersets, dict): + filtersets_lists: List[List[FilterSet]] = list(filtersets.values()) + ids: Set[int] = set() + lst: List[FilterSet] + for lst in filtersets_lists: + ids.update(set(map(lambda fs: fs.id, lst))) + return ids + return set(map(lambda fs: fs.id, filtersets)) diff --git a/tests/integration_tests/dashboards/superset_factory_util.py b/tests/integration_tests/dashboards/superset_factory_util.py index dfd2f67441a45..b67c60ca0736f 100644 --- a/tests/integration_tests/dashboards/superset_factory_util.py +++ b/tests/integration_tests/dashboards/superset_factory_util.py @@ -82,10 +82,10 @@ def create_dashboard( json_metadata: str = "", position_json: str = "", ) -> Dashboard: - dashboard_title = dashboard_title or random_title() - slug = slug or random_slug() - owners = owners or [] - slices = slices or [] + dashboard_title = dashboard_title if dashboard_title is not None else random_title() + slug = slug if slug is not None else random_slug() + owners = owners if owners is not None else [] + slices = slices if slices is not None else [] return Dashboard( dashboard_title=dashboard_title, slug=slug, @@ -109,25 +109,40 @@ def create_slice_to_db( datasource_id: Optional[int] = None, owners: Optional[List[User]] = None, ) -> Slice: - slice_ = create_slice(datasource_id, name, owners) + slice_ = create_slice(datasource_id, name=name, owners=owners) insert_model(slice_) inserted_slices_ids.append(slice_.id) return slice_ def create_slice( - datasource_id: Optional[int], name: Optional[str], owners: Optional[List[User]] + datasource_id: Optional[int] = None, + datasource: Optional[SqlaTable] = None, + name: Optional[str] = None, + owners: Optional[List[User]] = None, ) -> Slice: - name = name or random_str() - owners = owners or [] + name = name if name is not None else random_str() + owners = owners if owners is not None else [] + datasource_type = "table" + if datasource: + return Slice( + slice_name=name, + table=datasource, + owners=owners, + datasource_type=datasource_type, + ) + datasource_id = ( - datasource_id or create_datasource_table_to_db(name=name + "_table").id + datasource_id + if datasource_id is not None + else create_datasource_table_to_db(name=name + "_table").id ) + return Slice( slice_name=name, datasource_id=datasource_id, owners=owners, - datasource_type="table", + datasource_type=datasource_type, ) @@ -136,7 +151,7 @@ def create_datasource_table_to_db( db_id: Optional[int] = None, owners: Optional[List[User]] = None, ) -> SqlaTable: - sqltable = create_datasource_table(name, db_id, owners) + sqltable = create_datasource_table(name, db_id, owners=owners) insert_model(sqltable) inserted_sqltables_ids.append(sqltable.id) return sqltable @@ -145,11 +160,14 @@ def create_datasource_table_to_db( def create_datasource_table( name: Optional[str] = None, db_id: Optional[int] = None, + database: Optional[Database] = None, owners: Optional[List[User]] = None, ) -> SqlaTable: - name = name or random_str() - owners = owners or [] - db_id = db_id or create_database_to_db(name=name + "_db").id + name = name if name is not None else random_str() + owners = owners if owners is not None else [] + if database: + return SqlaTable(table_name=name, database=database, owners=owners) + db_id = db_id if db_id is not None else create_database_to_db(name=name + "_db").id return SqlaTable(table_name=name, database_id=db_id, owners=owners) @@ -161,7 +179,7 @@ def create_database_to_db(name: Optional[str] = None) -> Database: def create_database(name: Optional[str] = None) -> Database: - name = name or random_str() + name = name if name is not None else random_str() return Database(database_name=name, sqlalchemy_uri="sqlite:///:memory:") diff --git a/tests/integration_tests/superset_test_config.py b/tests/integration_tests/superset_test_config.py index a83758bf5147d..698440c36383c 100644 --- a/tests/integration_tests/superset_test_config.py +++ b/tests/integration_tests/superset_test_config.py @@ -29,7 +29,7 @@ ) DEBUG = False SUPERSET_WEBSERVER_PORT = 8081 - +SILENCE_FAB = False # Allowing SQLALCHEMY_DATABASE_URI and SQLALCHEMY_EXAMPLES_URI to be defined as an env vars for # continuous integration if "SUPERSET__SQLALCHEMY_DATABASE_URI" in os.environ: