diff --git a/api/features/serializers.py b/api/features/serializers.py index 70a55bda4f28..c45d80ad109d 100644 --- a/api/features/serializers.py +++ b/api/features/serializers.py @@ -99,7 +99,31 @@ class FeatureQuerySerializer(serializers.Serializer): help_text="Integer ID of the environment to view features in the context of.", ) - def validate_tags(self, tags): + owners = serializers.CharField( + required=False, + help_text="Comma separated list of owner ids to filter on", + ) + group_owners = serializers.CharField( + required=False, + help_text="Comma separated list of group owner ids to filter on", + ) + + def validate_owners(self, owners: str) -> list[int]: + try: + return [int(owner_id.strip()) for owner_id in owners.split(",")] + except ValueError: + raise serializers.ValidationError("Owner IDs must be integers.") + + def validate_group_owners(self, group_owners: str) -> list[int]: + try: + return [ + int(group_owner_id.strip()) + for group_owner_id in group_owners.split(",") + ] + except ValueError: + raise serializers.ValidationError("Group owner IDs must be integers.") + + def validate_tags(self, tags: str) -> list[int]: try: return [int(tag_id.strip()) for tag_id in tags.split(",")] except ValueError: diff --git a/api/features/views.py b/api/features/views.py index 62cb0b610def..7ba4d8675f88 100644 --- a/api/features/views.py +++ b/api/features/views.py @@ -300,7 +300,26 @@ def _trigger_feature_state_change_webhooks( feature_state, WebhookEventType.FLAG_DELETED ) - def _filter_queryset(self, queryset: QuerySet) -> QuerySet: + def filter_owners_and_group_owners( + self, + queryset: QuerySet[Feature], + query_data: dict[str, typing.Any], + ) -> QuerySet[Feature]: + owners_q = Q() + if query_data.get("owners"): + owners_q = owners_q | Q( + owners__id__in=query_data["owners"], + ) + + group_owners_q = Q() + if query_data.get("group_owners"): + group_owners_q = group_owners_q | Q( + group_owners__id__in=query_data["group_owners"], + ) + + return queryset.filter(owners_q | group_owners_q) + + def _filter_queryset(self, queryset: QuerySet[Feature]) -> QuerySet[Feature]: query_serializer = FeatureQuerySerializer(data=self.request.query_params) query_serializer.is_valid(raise_exception=True) query_data = query_serializer.validated_data @@ -324,6 +343,8 @@ def _filter_queryset(self, queryset: QuerySet) -> QuerySet: if "is_archived" in query_serializer.initial_data: queryset = queryset.filter(is_archived=query_data["is_archived"]) + queryset = self.filter_owners_and_group_owners(queryset, query_data) + return queryset diff --git a/api/tests/unit/features/test_unit_features_views.py b/api/tests/unit/features/test_unit_features_views.py index 276a1f586012..3c1c5db262b2 100644 --- a/api/tests/unit/features/test_unit_features_views.py +++ b/api/tests/unit/features/test_unit_features_views.py @@ -2622,3 +2622,136 @@ def test_feature_list_last_modified_values( feature_data["last_modified_in_current_environment"] == two_hours_ago.isoformat() ) + + +def test_filter_features_with_owners( + staff_client: APIClient, + staff_user: FFAdminUser, + admin_user: FFAdminUser, + project: Project, + feature: Feature, + with_project_permissions: WithProjectPermissionsCallable, + environment: Environment, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) + + feature2 = Feature.objects.create( + name="included_feature", project=project, initial_value="initial_value" + ) + Feature.objects.create( + name="not_included_feature", project=project, initial_value="gone" + ) + + # Include admin only in the first feature. + feature.owners.add(admin_user) + + # Include staff only in the second feature. + feature2.owners.add(staff_user) + + base_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + + # Search for both users in the owners query param. + url = ( + f"{base_url}?environment={environment.id}&" + f"owners={admin_user.id},{staff_user.id}" + ) + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + assert len(response.data["results"]) == 2 + assert response.data["results"][0]["id"] == feature.id + assert response.data["results"][1]["id"] == feature2.id + + +def test_filter_features_with_group_owners( + staff_client: APIClient, + project: Project, + organisation: Organisation, + feature: Feature, + with_project_permissions: WithProjectPermissionsCallable, + environment: Environment, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) + + feature2 = Feature.objects.create( + name="included_feature", project=project, initial_value="initial_value" + ) + Feature.objects.create( + name="not_included_feature", project=project, initial_value="gone" + ) + + group_1 = UserPermissionGroup.objects.create( + name="Test Group", organisation=organisation + ) + group_2 = UserPermissionGroup.objects.create( + name="Second Group", organisation=organisation + ) + + feature.group_owners.add(group_1) + feature2.group_owners.add(group_2) + + base_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + + # Search for both users in the owners query param. + url = ( + f"{base_url}?environment={environment.id}&" + f"group_owners={group_1.id},{group_2.id}" + ) + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + assert len(response.data["results"]) == 2 + assert response.data["results"][0]["id"] == feature.id + assert response.data["results"][1]["id"] == feature2.id + + +def test_filter_features_with_owners_and_group_owners_together( + staff_client: APIClient, + staff_user: FFAdminUser, + project: Project, + organisation: Organisation, + feature: Feature, + with_project_permissions: WithProjectPermissionsCallable, + environment: Environment, +) -> None: + # Given + with_project_permissions([VIEW_PROJECT]) + + feature2 = Feature.objects.create( + name="included_feature", project=project, initial_value="initial_value" + ) + Feature.objects.create( + name="not_included_feature", project=project, initial_value="gone" + ) + + group_1 = UserPermissionGroup.objects.create( + name="Test Group", organisation=organisation + ) + + feature.group_owners.add(group_1) + feature2.owners.add(staff_user) + + base_url = reverse("api-v1:projects:project-features-list", args=[project.id]) + + # Search for both users in the owners query param. + url = ( + f"{base_url}?environment={environment.id}&" + f"group_owners={group_1.id}&owners={staff_user.id}" + ) + # When + response = staff_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + + assert len(response.data["results"]) == 2 + assert response.data["results"][0]["id"] == feature.id + assert response.data["results"][1]["id"] == feature2.id