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 <33317356+villebro@users.noreply.github.com>

* 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 <ofek.israel@nieslen.com>
Co-authored-by: amitmiran137 <amit.miran@nielsen.com>
Co-authored-by: Amit Miran <47772523+amitmiran137@users.noreply.github.com>
Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
This commit is contained in:
ofekisr 2021-09-23 11:27:59 +03:00 committed by GitHub
parent 997320ac1a
commit 84f7614e97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 3317 additions and 23 deletions

View File

@ -14,7 +14,7 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from marshmallow import ValidationError from marshmallow import ValidationError
@ -31,6 +31,26 @@ class CommandException(SupersetException):
return repr(self) 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): class CommandInvalidError(CommandException):
""" Common base class for Command Invalid errors. """ """ Common base class for Command Invalid errors. """

View File

@ -18,7 +18,7 @@
from datetime import datetime from datetime import datetime
from datetime import timezone from datetime import timezone
from typing import Iterator, List, Tuple from typing import Iterator, List, Tuple, Type
import yaml import yaml
from flask_appbuilder import Model from flask_appbuilder import Model
@ -33,8 +33,8 @@ METADATA_FILE_NAME = "metadata.yaml"
class ExportModelsCommand(BaseCommand): class ExportModelsCommand(BaseCommand):
dao = BaseDAO dao: Type[BaseDAO] = BaseDAO
not_found = CommandException not_found: Type[CommandException] = CommandException
def __init__(self, model_ids: List[int]): def __init__(self, model_ids: List[int]):
self.model_ids = model_ids self.model_ids = model_ids

View File

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

View File

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

View File

@ -14,16 +14,18 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
from typing import Optional
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from marshmallow.validate import ValidationError from marshmallow.validate import ValidationError
from superset.commands.exceptions import ( from superset.commands.exceptions import (
CommandException,
CommandInvalidError, CommandInvalidError,
CreateFailedError, CreateFailedError,
DeleteFailedError, DeleteFailedError,
ForbiddenError, ForbiddenError,
ImportFailedError, ImportFailedError,
ObjectNotFoundError,
UpdateFailedError, UpdateFailedError,
) )
@ -41,8 +43,11 @@ class DashboardInvalidError(CommandInvalidError):
message = _("Dashboard parameters are invalid.") message = _("Dashboard parameters are invalid.")
class DashboardNotFoundError(CommandException): class DashboardNotFoundError(ObjectNotFoundError):
message = _("Dashboard not found.") def __init__(
self, dashboard_id: Optional[str] = None, exception: Optional[Exception] = None
) -> None:
super().__init__("Dashboard", dashboard_id, exception)
class DashboardCreateFailedError(CreateFailedError): class DashboardCreateFailedError(CreateFailedError):

View File

@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,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)

View File

@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,64 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
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

View File

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

View File

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

View File

@ -141,6 +141,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.queries.saved_queries.api import SavedQueryRestApi from superset.queries.saved_queries.api import SavedQueryRestApi
from superset.reports.api import ReportScheduleRestApi from superset.reports.api import ReportScheduleRestApi
from superset.reports.logs.api import ReportExecutionLogRestApi 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.access_requests import AccessRequestsModelView
from superset.views.alerts import ( from superset.views.alerts import (
AlertLogModelView, AlertLogModelView,
@ -208,6 +209,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(SavedQueryRestApi) appbuilder.add_api(SavedQueryRestApi)
appbuilder.add_api(ReportScheduleRestApi) appbuilder.add_api(ReportScheduleRestApi)
appbuilder.add_api(ReportExecutionLogRestApi) appbuilder.add_api(ReportExecutionLogRestApi)
appbuilder.add_api(FilterSetRestApi)
# #
# Setup regular views # Setup regular views
# #

View File

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

View File

@ -23,6 +23,7 @@ from functools import partial
from typing import Any, Callable, Dict, List, Set, Tuple, Type, Union from typing import Any, Callable, Dict, List, Set, Tuple, Type, Union
import sqlalchemy as sqla import sqlalchemy as sqla
from flask import g
from flask_appbuilder import Model from flask_appbuilder import Model
from flask_appbuilder.models.decorators import renders from flask_appbuilder.models.decorators import renders
from flask_appbuilder.security.sqla.models import User 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 sqlalchemy.sql.elements import BinaryExpression
from superset import app, ConnectorRegistry, db, is_feature_enabled, security_manager 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.base.models import BaseDatasource
from superset.connectors.druid.models import DruidColumn, DruidMetric from superset.connectors.druid.models import DruidColumn, DruidMetric
from superset.connectors.sqla.models import SqlMetric, TableColumn from superset.connectors.sqla.models import SqlMetric, TableColumn
from superset.extensions import cache_manager from superset.extensions import cache_manager
from superset.models.filter_set import FilterSet
from superset.models.helpers import AuditMixinNullable, ImportExportMixin from superset.models.helpers import AuditMixinNullable, ImportExportMixin
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.models.tags import DashboardUpdater from superset.models.tags import DashboardUpdater
@ -129,6 +132,7 @@ DashboardRoles = Table(
) )
# pylint: disable=too-many-public-methods
class Dashboard(Model, AuditMixinNullable, ImportExportMixin): class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
"""The dashboard object!""" """The dashboard object!"""
@ -144,6 +148,9 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
owners = relationship(security_manager.user_model, secondary=dashboard_user) owners = relationship(security_manager.user_model, secondary=dashboard_user)
published = Column(Boolean, default=False) published = Column(Boolean, default=False)
roles = relationship(security_manager.role_model, secondary=DashboardRoles) roles = relationship(security_manager.role_model, secondary=DashboardRoles)
_filter_sets = relationship(
"FilterSet", back_populates="dashboard", cascade="all, delete"
)
export_fields = [ export_fields = [
"dashboard_title", "dashboard_title",
"position_json", "position_json",
@ -178,6 +185,29 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin):
.all() .all()
} }
@property
def filter_sets(self) -> Dict[int, FilterSet]:
return {fs.id: fs for fs in self._filter_sets}
@property
def filter_sets_lst(self) -> Dict[int, FilterSet]:
if is_user_admin():
return self._filter_sets
current_user = g.user.id
filter_sets_by_owner_type: Dict[str, List[Any]] = {"Dashboard": [], "User": []}
for fs in self._filter_sets:
filter_sets_by_owner_type[fs.owner_type].append(fs)
user_filter_sets = list(
filter(
lambda filter_set: filter_set.owner_id == current_user,
filter_sets_by_owner_type["User"],
)
)
return {
fs.id: fs
for fs in user_filter_sets + filter_sets_by_owner_type["Dashboard"]
}
@property @property
def charts(self) -> List[BaseDatasource]: def charts(self) -> List[BaseDatasource]:
return [slc.chart for slc in self.slices] 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)) qry = session.query(Dashboard).filter(id_or_slug_filter(id_or_slug))
return qry.one_or_none() 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: def id_or_slug_filter(id_or_slug: str) -> BinaryExpression:
if id_or_slug.isdigit(): if id_or_slug.isdigit():

View File

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

View File

@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@ -0,0 +1,321 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import json
from typing import Any, Dict, Generator, List, TYPE_CHECKING
import pytest
from superset import security_manager as sm
from superset.dashboards.filter_sets.consts import (
DESCRIPTION_FIELD,
JSON_METADATA_FIELD,
NAME_FIELD,
OWNER_ID_FIELD,
OWNER_TYPE_FIELD,
USER_OWNER_TYPE,
)
from superset.models.dashboard import Dashboard
from superset.models.filter_set import FilterSet
from tests.integration_tests.dashboards.filter_sets.consts import (
ADMIN_USERNAME_FOR_TEST,
DASHBOARD_OWNER_USERNAME,
FILTER_SET_OWNER_USERNAME,
REGULAR_USER,
)
from tests.integration_tests.dashboards.superset_factory_util import (
create_dashboard,
create_database,
create_datasource_table,
create_slice,
)
from tests.integration_tests.test_app import app
if TYPE_CHECKING:
from flask.ctx import AppContext
from flask.testing import FlaskClient
from flask_appbuilder.security.sqla.models import (
Role,
User,
ViewMenu,
PermissionView,
)
from flask_appbuilder.security.manager import BaseSecurityManager
from sqlalchemy.orm import Session
from superset.models.slice import Slice
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
security_manager: BaseSecurityManager = sm
# @pytest.fixture(autouse=True, scope="session")
# def setup_sample_data() -> Any:
# pass
@pytest.fixture(autouse=True)
def expire_on_commit_true() -> Generator[None, None, None]:
ctx: AppContext
with app.app_context() as ctx:
ctx.app.appbuilder.get_session.configure(expire_on_commit=False)
yield
ctx.app.appbuilder.get_session.configure(expire_on_commit=True)
@pytest.fixture(autouse=True, scope="module")
def test_users() -> Generator[Dict[str, int], None, None]:
usernames = [
ADMIN_USERNAME_FOR_TEST,
DASHBOARD_OWNER_USERNAME,
FILTER_SET_OWNER_USERNAME,
REGULAR_USER,
]
with app.app_context():
filter_set_role = build_filter_set_role()
admin_role: Role = security_manager.find_role("Admin")
usernames_to_ids = create_test_users(admin_role, filter_set_role, usernames)
yield usernames_to_ids
ctx: AppContext
delete_users(usernames_to_ids)
def delete_users(usernames_to_ids: Dict[str, int]) -> None:
with app.app_context() as ctx:
session: Session = ctx.app.appbuilder.get_session
for username in usernames_to_ids.keys():
session.delete(security_manager.find_user(username))
session.commit()
def create_test_users(
admin_role: Role, filter_set_role: Role, usernames: List[str]
) -> Dict[str, int]:
users: List[User] = []
for username in usernames:
user = build_user(username, filter_set_role, admin_role)
users.append(user)
return {user.username: user.id for user in users}
def build_user(username: str, filter_set_role: Role, admin_role: Role) -> User:
roles_to_add = (
[admin_role] if username == ADMIN_USERNAME_FOR_TEST else [filter_set_role]
)
user: User = security_manager.add_user(
username, "test", "test", username, roles_to_add, password="general"
)
if not user:
user = security_manager.find_user(username)
if user is None:
raise Exception("Failed to build the user {}".format(username))
return user
def build_filter_set_role() -> Role:
filter_set_role: Role = security_manager.add_role("filter_set_role")
filterset_view_name: ViewMenu = security_manager.find_view_menu("FilterSets")
all_datasource_view_name: ViewMenu = security_manager.find_view_menu(
"all_datasource_access"
)
pvms: List[PermissionView] = security_manager.find_permissions_view_menu(
filterset_view_name
) + security_manager.find_permissions_view_menu(all_datasource_view_name)
for pvm in pvms:
security_manager.add_permission_role(filter_set_role, pvm)
return filter_set_role
@pytest.fixture
def client() -> Generator[FlaskClient[Any], None, None]:
with app.test_client() as client:
yield client
@pytest.fixture
def dashboard() -> Generator[Dashboard, None, None]:
dashboard: Dashboard
slice_: Slice
datasource: SqlaTable
database: Database
session: Session
try:
with app.app_context() as ctx:
dashboard_owner_user = security_manager.find_user(DASHBOARD_OWNER_USERNAME)
database = create_database("test_database_filter_sets")
datasource = create_datasource_table(
name="test_datasource", database=database, owners=[dashboard_owner_user]
)
slice_ = create_slice(
datasource=datasource, name="test_slice", owners=[dashboard_owner_user]
)
dashboard = create_dashboard(
dashboard_title="test_dashboard",
published=True,
slices=[slice_],
owners=[dashboard_owner_user],
)
session = ctx.app.appbuilder.get_session
session.add(dashboard)
session.commit()
yield dashboard
except Exception as ex:
print(str(ex))
finally:
with app.app_context() as ctx:
session = ctx.app.appbuilder.get_session
try:
dashboard.owners = []
slice_.owners = []
datasource.owners = []
session.merge(dashboard)
session.merge(slice_)
session.merge(datasource)
session.commit()
session.delete(dashboard)
session.delete(slice_)
session.delete(datasource)
session.delete(database)
session.commit()
except Exception as ex:
print(str(ex))
@pytest.fixture
def dashboard_id(dashboard) -> int:
return dashboard.id
@pytest.fixture
def filtersets(
dashboard_id: int, test_users: Dict[str, int], dumped_valid_json_metadata: str
) -> Generator[Dict[str, List[FilterSet]], None, None]:
try:
with app.app_context() as ctx:
session: Session = ctx.app.appbuilder.get_session
first_filter_set = FilterSet(
name="filter_set_1_of_" + str(dashboard_id),
dashboard_id=dashboard_id,
json_metadata=dumped_valid_json_metadata,
owner_id=dashboard_id,
owner_type="Dashboard",
)
second_filter_set = FilterSet(
name="filter_set_2_of_" + str(dashboard_id),
json_metadata=dumped_valid_json_metadata,
dashboard_id=dashboard_id,
owner_id=dashboard_id,
owner_type="Dashboard",
)
third_filter_set = FilterSet(
name="filter_set_3_of_" + str(dashboard_id),
json_metadata=dumped_valid_json_metadata,
dashboard_id=dashboard_id,
owner_id=test_users[FILTER_SET_OWNER_USERNAME],
owner_type="User",
)
forth_filter_set = FilterSet(
name="filter_set_4_of_" + str(dashboard_id),
json_metadata=dumped_valid_json_metadata,
dashboard_id=dashboard_id,
owner_id=test_users[FILTER_SET_OWNER_USERNAME],
owner_type="User",
)
session.add(first_filter_set)
session.add(second_filter_set)
session.add(third_filter_set)
session.add(forth_filter_set)
session.commit()
yv = {
"Dashboard": [first_filter_set, second_filter_set],
FILTER_SET_OWNER_USERNAME: [third_filter_set, forth_filter_set],
}
yield yv
except Exception as ex:
print(str(ex))
@pytest.fixture
def filterset_id(filtersets: Dict[str, List[FilterSet]]) -> int:
return filtersets["Dashboard"][0].id
@pytest.fixture
def valid_json_metadata() -> Dict[str, Any]:
return {"nativeFilters": {}}
@pytest.fixture
def dumped_valid_json_metadata(valid_json_metadata: Dict[str, Any]) -> str:
return json.dumps(valid_json_metadata)
@pytest.fixture
def exists_user_id() -> int:
return 1
@pytest.fixture
def valid_filter_set_data_for_create(
dashboard_id: int, dumped_valid_json_metadata: str, exists_user_id: int
) -> Dict[str, Any]:
name = "test_filter_set_of_dashboard_" + str(dashboard_id)
return {
NAME_FIELD: name,
DESCRIPTION_FIELD: "description of " + name,
JSON_METADATA_FIELD: dumped_valid_json_metadata,
OWNER_TYPE_FIELD: USER_OWNER_TYPE,
OWNER_ID_FIELD: exists_user_id,
}
@pytest.fixture
def valid_filter_set_data_for_update(
dashboard_id: int, dumped_valid_json_metadata: str, exists_user_id: int
) -> Dict[str, Any]:
name = "name_changed_test_filter_set_of_dashboard_" + str(dashboard_id)
return {
NAME_FIELD: name,
DESCRIPTION_FIELD: "changed description of " + name,
JSON_METADATA_FIELD: dumped_valid_json_metadata,
}
@pytest.fixture
def not_exists_dashboard(dashboard_id: int) -> int:
return dashboard_id + 1
@pytest.fixture
def not_exists_user_id() -> int:
return 99999
@pytest.fixture()
def dashboard_based_filter_set_dict(
filtersets: Dict[str, List[FilterSet]]
) -> Dict[str, Any]:
return filtersets["Dashboard"][0].to_dict()
@pytest.fixture()
def user_based_filter_set_dict(
filtersets: Dict[str, List[FilterSet]]
) -> Dict[str, Any]:
return filtersets[FILTER_SET_OWNER_USERNAME][0].to_dict()

View File

@ -0,0 +1,22 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
FILTER_SET_URI = "api/v1/dashboard/{dashboard_id}/filtersets"
ADMIN_USERNAME_FOR_TEST = "admin@filterset.com"
DASHBOARD_OWNER_USERNAME = "dash_owner_user@filterset.com"
FILTER_SET_OWNER_USERNAME = "fs_owner_user@filterset.com"
REGULAR_USER = "regular_user@filterset.com"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -82,10 +82,10 @@ def create_dashboard(
json_metadata: str = "", json_metadata: str = "",
position_json: str = "", position_json: str = "",
) -> Dashboard: ) -> Dashboard:
dashboard_title = dashboard_title or random_title() dashboard_title = dashboard_title if dashboard_title is not None else random_title()
slug = slug or random_slug() slug = slug if slug is not None else random_slug()
owners = owners or [] owners = owners if owners is not None else []
slices = slices or [] slices = slices if slices is not None else []
return Dashboard( return Dashboard(
dashboard_title=dashboard_title, dashboard_title=dashboard_title,
slug=slug, slug=slug,
@ -109,25 +109,40 @@ def create_slice_to_db(
datasource_id: Optional[int] = None, datasource_id: Optional[int] = None,
owners: Optional[List[User]] = None, owners: Optional[List[User]] = None,
) -> Slice: ) -> Slice:
slice_ = create_slice(datasource_id, name, owners) slice_ = create_slice(datasource_id, name=name, owners=owners)
insert_model(slice_) insert_model(slice_)
inserted_slices_ids.append(slice_.id) inserted_slices_ids.append(slice_.id)
return slice_ return slice_
def create_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: ) -> Slice:
name = name or random_str() name = name if name is not None else random_str()
owners = owners or [] 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 = (
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( return Slice(
slice_name=name, slice_name=name,
datasource_id=datasource_id, datasource_id=datasource_id,
owners=owners, owners=owners,
datasource_type="table", datasource_type=datasource_type,
) )
@ -136,7 +151,7 @@ def create_datasource_table_to_db(
db_id: Optional[int] = None, db_id: Optional[int] = None,
owners: Optional[List[User]] = None, owners: Optional[List[User]] = None,
) -> SqlaTable: ) -> SqlaTable:
sqltable = create_datasource_table(name, db_id, owners) sqltable = create_datasource_table(name, db_id, owners=owners)
insert_model(sqltable) insert_model(sqltable)
inserted_sqltables_ids.append(sqltable.id) inserted_sqltables_ids.append(sqltable.id)
return sqltable return sqltable
@ -145,11 +160,14 @@ def create_datasource_table_to_db(
def create_datasource_table( def create_datasource_table(
name: Optional[str] = None, name: Optional[str] = None,
db_id: Optional[int] = None, db_id: Optional[int] = None,
database: Optional[Database] = None,
owners: Optional[List[User]] = None, owners: Optional[List[User]] = None,
) -> SqlaTable: ) -> SqlaTable:
name = name or random_str() name = name if name is not None else random_str()
owners = owners or [] owners = owners if owners is not None else []
db_id = db_id or create_database_to_db(name=name + "_db").id 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) 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: 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:") return Database(database_name=name, sqlalchemy_uri="sqlite:///:memory:")

View File

@ -29,7 +29,7 @@ SQLALCHEMY_DATABASE_URI = "sqlite:///" + os.path.join(
) )
DEBUG = False DEBUG = False
SUPERSET_WEBSERVER_PORT = 8081 SUPERSET_WEBSERVER_PORT = 8081
SILENCE_FAB = False
# Allowing SQLALCHEMY_DATABASE_URI and SQLALCHEMY_EXAMPLES_URI to be defined as an env vars for # Allowing SQLALCHEMY_DATABASE_URI and SQLALCHEMY_EXAMPLES_URI to be defined as an env vars for
# continuous integration # continuous integration
if "SUPERSET__SQLALCHEMY_DATABASE_URI" in os.environ: if "SUPERSET__SQLALCHEMY_DATABASE_URI" in os.environ: