diff --git a/api/features/serializers.py b/api/features/serializers.py index c45d80ad109d..356daa4bb941 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -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, diff --git a/api/features/views.py b/api/features/views.py index 7ba4d8675f88..e735c8194b7c 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -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 @@ -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"], @@ -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}, diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 3c1c5db262b2..82d5cce11135 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -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 @@ -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,