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.",
)
state_enabled = serializers.BooleanField(
allow_null=True,
required=False,
default=None,
help_text="Boolean value to filter features as enabled or disabled.",
)
state_search = serializers.CharField(
required=False,
default=None,
help_text="Value of type int, string, or boolean to filter features based on their values",
)

def validate_tags(self, tags):
try:
Expand Down
49 changes: 49 additions & 0 deletions api/features/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ def get_queryset(self):
)
)

if query_data["state_search"] or query_data["state_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 +189,53 @@ 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"
)
state_enabled = query_data["state_enabled"]
state_search = query_data["state_search"]
environment_id = query_data["environment"]

filter_search_q = Q()
if state_search is not None:
filter_search_q = filter_search_q | Q(
feature_state_value__string_value__icontains=state_search,
)

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

if state_search.isdigit():
integer_search = int(state_search)
filter_search_q = filter_search_q | Q(
feature_state_value__integer_value=integer_search
)
filter_enabled_q = Q()
if state_enabled is not None:
filter_enabled_q = filter_enabled_q | Q(enabled=state_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
151 changes: 151 additions & 0 deletions api/tests/unit/features/test_unit_features_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,157 @@ def test_list_features_with_intersection_tag(
assert response.data["results"][0]["tags"] == [tag1.id, tag2.id]


def test_list_features_with_sort_by_state_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.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.save()

feature_state_value3 = (
feature3.feature_states.filter(environment=environment)
.first()
.feature_state_value
)
feature_state_value3.string_value = "present"
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.save()

base_url = reverse("api-v1:projects:project-features-list", args=[project.id])
url = (
f"{base_url}?environment={environment.id}&"
"state_search=1945&state_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_sort_by_state_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.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.save()

feature_state_value3 = (
feature3.feature_states.filter(environment=environment)
.first()
.feature_state_value
)
feature_state_value3.string_value = "present"
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.save()

base_url = reverse("api-v1:projects:project-features-list", args=[project.id])
url = (
f"{base_url}?environment={environment.id}&"
"state_search=true&state_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