Skip to content

Commit

Permalink
feat: Add state feature filter (#3541)
Browse files Browse the repository at this point in the history
  • Loading branch information
zachaysan authored Mar 15, 2024
1 parent 8012407 commit 2ffe8e9
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 0 deletions.
11 changes: 11 additions & 0 deletions api/features/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ class FeatureQuerySerializer(serializers.Serializer):
required=False,
help_text="Integer ID of the environment to view features in the context of.",
)
is_enabled = serializers.BooleanField(
allow_null=True,
required=False,
default=None,
help_text="Boolean value to filter features as enabled or disabled.",
)
value_search = serializers.CharField(
required=False,
default=None,
help_text="Value of type int, string, or boolean to filter features based on their values",
)

owners = serializers.CharField(
required=False,
Expand Down
53 changes: 53 additions & 0 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
EnvironmentKeyPermissions,
NestedEnvironmentPermissions,
)
from features.value_types import BOOLEAN, INTEGER, STRING
from projects.models import Project
from projects.permissions import VIEW_PROJECT
from users.models import FFAdminUser, UserPermissionGroup
Expand Down Expand Up @@ -148,6 +149,8 @@ def get_queryset(self):
)
)

if query_data["value_search"] or query_data["is_enabled"] is not None:
queryset = self.apply_state_to_queryset(query_data, queryset)
sort = "%s%s" % (
"-" if query_data["sort_direction"] == "DESC" else "",
query_data["sort_field"],
Expand Down Expand Up @@ -187,6 +190,56 @@ def get_serializer_context(self):

return context

def apply_state_to_queryset(
self, query_data: dict[str, typing.Any], queryset: QuerySet[Feature]
) -> QuerySet[Feature]:
if not query_data.get("environment"):
raise serializers.ValidationError(
"Environment is required in order to filter by state search or by state enabled"
)
is_enabled = query_data["is_enabled"]
value_search = query_data["value_search"]
environment_id = query_data["environment"]

filter_search_q = Q()
if value_search is not None:
filter_search_q = filter_search_q | Q(
feature_state_value__string_value__icontains=value_search,
feature_state_value__type=STRING,
)

if value_search.lower() in {"true", "false"}:
boolean_search = value_search.lower() == "true"
filter_search_q = filter_search_q | Q(
feature_state_value__boolean_value=boolean_search,
feature_state_value__type=BOOLEAN,
)

if value_search.isdigit():
integer_search = int(value_search)
filter_search_q = filter_search_q | Q(
feature_state_value__integer_value=integer_search,
feature_state_value__type=INTEGER,
)
filter_enabled_q = Q()
if is_enabled is not None:
filter_enabled_q = filter_enabled_q | Q(enabled=is_enabled)

base_q = Q(
identity__isnull=True,
feature_segment__isnull=True,
)
if not getattr(self, "environment", None):
self.environment = Environment.objects.get(id=environment_id)

feature_states = FeatureState.objects.get_live_feature_states(
environment=self.environment,
additional_filters=base_q & filter_search_q & filter_enabled_q,
)

feature_ids = {fs.feature_id for fs in feature_states}
return queryset.filter(id__in=feature_ids)

@swagger_auto_schema(
request_body=FeatureGroupOwnerInputSerializer,
responses={200: ProjectFeatureSerializer},
Expand Down
154 changes: 154 additions & 0 deletions api/tests/unit/features/test_unit_features_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
FeatureStateValue,
)
from features.multivariate.models import MultivariateFeatureOption
from features.value_types import BOOLEAN, INTEGER, STRING
from features.versioning.models import EnvironmentFeatureVersion
from organisations.models import Organisation, OrganisationRole
from permissions.models import PermissionModel
Expand Down Expand Up @@ -2510,6 +2511,159 @@ def test_list_features_with_intersection_tag(
assert response.data["results"][0]["tags"] == [tag1.id, tag2.id]


def test_list_features_with_filter_by_value_search_string_and_int(
staff_client: APIClient,
project: Project,
feature: Feature,
with_project_permissions: WithProjectPermissionsCallable,
environment: Environment,
) -> None:
# Given
with_project_permissions([VIEW_PROJECT])
feature2 = Feature.objects.create(
name="another_feature", project=project, initial_value="initial_value"
)
feature3 = Feature.objects.create(
name="missing_feature", project=project, initial_value="gone"
)
feature4 = Feature.objects.create(
name="fancy_feature", project=project, initial_value="fancy"
)

Environment.objects.create(
name="Out of test scope environment",
project=project,
)

feature_state1 = feature.feature_states.filter(environment=environment).first()
feature_state1.enabled = True
feature_state1.save()

feature_state_value1 = feature_state1.feature_state_value
feature_state_value1.string_value = None
feature_state_value1.integer_value = 1945
feature_state_value1.type = INTEGER
feature_state_value1.save()

feature_state2 = feature2.feature_states.filter(environment=environment).first()
feature_state2.enabled = True
feature_state2.save()

feature_state_value2 = feature_state2.feature_state_value
feature_state_value2.string_value = None
feature_state_value2.boolean_value = True
feature_state_value2.type = BOOLEAN
feature_state_value2.save()

feature_state_value3 = (
feature3.feature_states.filter(environment=environment)
.first()
.feature_state_value
)
feature_state_value3.string_value = "present"
feature_state_value3.type = STRING
feature_state_value3.save()

feature_state4 = feature4.feature_states.filter(environment=environment).first()
feature_state4.enabled = True
feature_state4.save()

feature_state_value4 = feature_state4.feature_state_value
feature_state_value4.string_value = "year 1945"
feature_state_value4.type = STRING
feature_state_value4.save()

base_url = reverse("api-v1:projects:project-features-list", args=[project.id])
url = f"{base_url}?environment={environment.id}&value_search=1945&is_enabled=true"

# When
response = staff_client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK

# Only two features met the criteria.
assert len(response.data["results"]) == 2
features = {result["name"] for result in response.data["results"]}
assert feature.name in features
assert feature4.name in features


def test_list_features_with_filter_by_search_value_boolean(
staff_client: APIClient,
project: Project,
feature: Feature,
with_project_permissions: WithProjectPermissionsCallable,
environment: Environment,
) -> None:
# Given
with_project_permissions([VIEW_PROJECT])
feature2 = Feature.objects.create(
name="another_feature", project=project, initial_value="initial_value"
)
feature3 = Feature.objects.create(
name="missing_feature", project=project, initial_value="gone"
)
feature4 = Feature.objects.create(
name="fancy_feature", project=project, initial_value="fancy"
)

Environment.objects.create(
name="Out of test scope environment",
project=project,
)

feature_state1 = feature.feature_states.filter(environment=environment).first()
feature_state1.enabled = True
feature_state1.save()

feature_state_value1 = feature_state1.feature_state_value
feature_state_value1.string_value = None
feature_state_value1.integer_value = 1945
feature_state_value1.type = INTEGER
feature_state_value1.save()

feature_state2 = feature2.feature_states.filter(environment=environment).first()
feature_state2.enabled = False
feature_state2.save()

feature_state_value2 = feature_state2.feature_state_value
feature_state_value2.string_value = None
feature_state_value2.boolean_value = True
feature_state_value2.type = BOOLEAN
feature_state_value2.save()

feature_state_value3 = (
feature3.feature_states.filter(environment=environment)
.first()
.feature_state_value
)
feature_state_value3.string_value = "present"
feature_state_value3.type = STRING
feature_state_value3.save()

feature_state4 = feature4.feature_states.filter(environment=environment).first()
feature_state4.enabled = True
feature_state4.save()

feature_state_value4 = feature_state4.feature_state_value
feature_state_value4.string_value = "year 1945"
feature_state_value4.type = STRING
feature_state_value4.save()

base_url = reverse("api-v1:projects:project-features-list", args=[project.id])
url = f"{base_url}?environment={environment.id}&value_search=true&is_enabled=false"

# When
response = staff_client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK

assert len(response.data["results"]) == 1
assert response.data["results"][0]["name"] == feature2.name


def test_simple_feature_state_returns_only_latest_versions(
staff_client: APIClient,
staff_user: FFAdminUser,
Expand Down

0 comments on commit 2ffe8e9

Please sign in to comment.