Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add state feature filter #3541

Merged
merged 13 commits into from
Mar 15, 2024
Merged
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
Loading