This is an automated email from the ASF dual-hosted git repository.
amitmiran pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 84f7614 feat(filter-set): Add filterset resource (#14015)
84f7614 is described below
commit 84f7614e97b71bd8952f78d82cabf766afc0b98b
Author: ofekisr <[email protected]>
AuthorDate: Thu Sep 23 11:27:59 2021 +0300
feat(filter-set): Add filterset resource (#14015)
* Add filterset resource
* fix: fix pre-commit
* add tests
* add tests and fixes based of failures
* Fix pre-commit errors
* chore init filterset resource under ff constraint
* Fix migration conflicts
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* Fix pylint and migrations issues
* add tests and fixes based of failures
* Fix missing license
* fix down revision
* update down_revision
* fix: update down_revision
* chore: add description to migration
* fix: type
* refactor: is_user_admin
* fix: use get_public_role
* fix: move import to the relevant location
* chore: add openSpec api schema
* chore: cover all openspec API
* fix: pre-commit and lint
* fix: put and post schemas
* fix: undo superset_test_config.py
* fix: limit filterSetsApi to include_route_methods = {"get_list", "put",
"post", "delete"}
* renaming some params
* chore: add debug in test config
* fix: rename database to different name
* fix: try to make conftest.py harmless
* fix: pre-commit
* fix: new down_revision ref
* fix: bad ref
* fix: bad ref 2
* fix: bad ref 3
* fix: add api in initiatior
* fix: open spec
* fix: convert name to str to include int usecases
* fix: pylint
* fix: pylint
* Update superset/common/request_contexed_based.py
Co-authored-by: Ville Brofeldt <[email protected]>
* chore: resolve PR comments
* chore: resolve PR comments
* chore: resolve PR comments
* fix failed tests
* fix pylint
* Update conftest.py
* chore remove BaseCommand to remove abstraction
* chore remove BaseCommand to remove abstraction
* chore remove BaseCommand to remove abstraction
* chore remove BaseCommand to remove abstraction
* chore fix migration
Co-authored-by: Ofeknielsen <[email protected]>
Co-authored-by: amitmiran137 <[email protected]>
Co-authored-by: Amit Miran <[email protected]>
Co-authored-by: Ville Brofeldt <[email protected]>
---
superset/commands/exceptions.py | 22 +-
superset/commands/export.py | 6 +-
superset/common/not_authrized_object.py | 39 ++
superset/common/request_contexed_based.py | 39 ++
superset/dashboards/commands/exceptions.py | 11 +-
superset/dashboards/filter_sets/__init__.py | 16 +
superset/dashboards/filter_sets/api.py | 374 ++++++++++++
.../dashboards/filter_sets/commands/__init__.py | 16 +
superset/dashboards/filter_sets/commands/base.py | 91 +++
superset/dashboards/filter_sets/commands/create.py | 78 +++
superset/dashboards/filter_sets/commands/delete.py | 56 ++
.../dashboards/filter_sets/commands/exceptions.py | 94 +++
superset/dashboards/filter_sets/commands/update.py | 56 ++
superset/dashboards/filter_sets/consts.py | 30 +
superset/dashboards/filter_sets/dao.py | 64 +++
superset/dashboards/filter_sets/filters.py | 58 ++
superset/dashboards/filter_sets/schemas.py | 93 +++
superset/initialization/__init__.py | 2 +
.../versions/3ebe0993c770_filterset_table.py | 56 ++
superset/models/dashboard.py | 35 ++
superset/models/filter_set.py | 106 ++++
.../dashboards/filter_sets/__init__.py | 16 +
.../dashboards/filter_sets/conftest.py | 321 +++++++++++
.../dashboards/filter_sets/consts.py | 22 +
.../dashboards/filter_sets/create_api_tests.py | 630 +++++++++++++++++++++
.../dashboards/filter_sets/delete_api_tests.py | 209 +++++++
.../dashboards/filter_sets/get_api_tests.py | 129 +++++
.../dashboards/filter_sets/update_api_tests.py | 519 +++++++++++++++++
.../dashboards/filter_sets/utils.py | 102 ++++
.../dashboards/superset_factory_util.py | 48 +-
tests/integration_tests/superset_test_config.py | 2 +-
31 files changed, 3317 insertions(+), 23 deletions(-)
diff --git a/superset/commands/exceptions.py b/superset/commands/exceptions.py
index bb8992a..40c0597 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 @@ class CommandException(SupersetException):
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 5bf117c..2b54de8 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 @@ METADATA_FILE_NAME = "metadata.yaml"
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 0000000..7295da9
--- /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 0000000..0b06a0c
--- /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 ee85c1f..1a5bdaf 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 0000000..13a8339
--- /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 0000000..24c4e86
--- /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("/<int:dashboard_id>/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("/<int:dashboard_id>/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("/<int:dashboard_id>/filtersets/<int:pk>", 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("/<int:dashboard_id>/filtersets/<int:pk>", 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 0000000..13a8339
--- /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 0000000..af31bbd
--- /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 0000000..b74e6d3
--- /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 0000000..18d7fed
--- /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 0000000..ade0bbb
--- /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 0000000..d2c43f0
--- /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 0000000..ff60a4f
--- /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 0000000..949aa6d
--- /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 0000000..0083f40
--- /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 0000000..3c0436d
--- /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 d19c1d1..b8a1098 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -141,6 +141,7 @@ class SupersetAppInitializer: # pylint:
disable=too-many-public-methods
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 @@ class SupersetAppInitializer: # pylint:
disable=too-many-public-methods
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 0000000..b509f89
--- /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 3b15841..0db1ddf 100644
--- a/superset/models/dashboard.py
+++ b/superset/models/dashboard.py
@@ -23,6 +23,7 @@ from functools import partial
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 import join, select
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 @@ DashboardRoles = Table(
)
+# 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",
@@ -179,6 +186,29 @@ class Dashboard(Model, AuditMixinNullable,
ImportExportMixin):
}
@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 @@ class Dashboard(Model, AuditMixinNullable,
ImportExportMixin):
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 0000000..2d3b218
--- /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 0000000..13a8339
--- /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 0000000..36642b1
--- /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
+
+
[email protected](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)
+
+
[email protected](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
+
+
[email protected]
+def client() -> Generator[FlaskClient[Any], None, None]:
+ with app.test_client() as client:
+ yield client
+
+
[email protected]
+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))
+
+
[email protected]
+def dashboard_id(dashboard) -> int:
+ return dashboard.id
+
+
[email protected]
+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))
+
+
[email protected]
+def filterset_id(filtersets: Dict[str, List[FilterSet]]) -> int:
+ return filtersets["Dashboard"][0].id
+
+
[email protected]
+def valid_json_metadata() -> Dict[str, Any]:
+ return {"nativeFilters": {}}
+
+
[email protected]
+def dumped_valid_json_metadata(valid_json_metadata: Dict[str, Any]) -> str:
+ return json.dumps(valid_json_metadata)
+
+
[email protected]
+def exists_user_id() -> int:
+ return 1
+
+
[email protected]
+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,
+ }
+
+
[email protected]
+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,
+ }
+
+
[email protected]
+def not_exists_dashboard(dashboard_id: int) -> int:
+ return dashboard_id + 1
+
+
[email protected]
+def not_exists_user_id() -> int:
+ return 99999
+
+
[email protected]()
+def dashboard_based_filter_set_dict(
+ filtersets: Dict[str, List[FilterSet]]
+) -> Dict[str, Any]:
+ return filtersets["Dashboard"][0].to_dict()
+
+
[email protected]()
+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 0000000..f54f00f
--- /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 = "[email protected]"
+DASHBOARD_OWNER_USERNAME = "[email protected]"
+FILTER_SET_OWNER_USERNAME = "[email protected]"
+REGULAR_USER = "[email protected]"
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 0000000..cbdaef9
--- /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 0000000..12a9bff
--- /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 0000000..3db2d47
--- /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 0000000..fcaeaab
--- /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 0000000..a63e416
--- /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 dfd2f67..b67c60c 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 a83758b..698440c 100644
--- a/tests/integration_tests/superset_test_config.py
+++ b/tests/integration_tests/superset_test_config.py
@@ -29,7 +29,7 @@ SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(
)
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: