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(limits): Add limits to features, segments and segment overrides #2480

Merged
merged 15 commits into from
Jul 21, 2023
4 changes: 4 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,3 +893,7 @@
DOMAIN_OVERRIDE = env.str("FLAGSMITH_DOMAIN", "")
# Used when no Django site is specified.
DEFAULT_DOMAIN = "app.flagsmith.com"

MAX_SEGMENTS_ALLOWED = env.int("MAX_SEGMENTS_ALLOWED", 30)
MAX_SEGMENT_OVERRIDE_ALLOWED = env.int("MAX_SEGMENT_OVERRIDE_ALLOWED", 100)
MAX_FEATURES_ALLOWED = env.int("MAX_FEATURES_ALLOWED", 200)
34 changes: 33 additions & 1 deletion api/features/serializers.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import typing

import django.core.exceptions
from django.conf import settings
from drf_writable_nested import WritableNestedModelSerializer
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied

from environments.identities.models import Identity
from environments.models import Environment
from environments.sdk.serializers_mixins import (
HideSensitiveFieldsSerializerMixin,
)
from projects.models import Project
from users.serializers import UserIdsSerializer, UserListSerializer
from util.drf_writable_nested.serializers import (
DeleteBeforeUpdateWritableNestedModelSerializer,
Expand Down Expand Up @@ -119,7 +122,10 @@ def to_internal_value(self, data):
data["initial_value"] = str(data["initial_value"])
return super(ListCreateFeatureSerializer, self).to_internal_value(data)

def create(self, validated_data):
def create(self, validated_data: dict) -> Feature:
project = self.context["project"]
self.validate_project_features_limit(project)

# Add the default(User creating the feature) owner of the feature
# NOTE: pop the user before passing the data to create
user = validated_data.pop("user", None)
Expand All @@ -128,6 +134,14 @@ def create(self, validated_data):
instance.owners.add(user)
return instance

def validate_project_features_limit(self, project: Project) -> None:
if project.features.count() >= settings.MAX_FEATURES_ALLOWED:
raise serializers.ValidationError(
{
"project": "The Project has reached the maximum allowed features limit."
}
)

def validate_multivariate_options(self, multivariate_options):
if multivariate_options:
user = self.context["request"].user
Expand Down Expand Up @@ -427,3 +441,21 @@ def _get_save_kwargs(self, field_name):
kwargs["feature"] = self.context.get("feature")
kwargs["environment"] = self.context.get("environment")
return kwargs

def create(self, validated_data: dict) -> FeatureState:
environment = validated_data["environment"]
self.validate_environment_segment_override_limit(environment)
return super().create(validated_data)

def validate_environment_segment_override_limit(
self, environment: Environment
) -> None:
if (
environment.feature_segments.count()
>= settings.MAX_SEGMENT_OVERRIDE_ALLOWED
):
raise serializers.ValidationError(
{
"environment": "The environment has reached the maximum allowed segments overrides limit."
}
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a feeling we may also need to add this to the SimpleFeatureStateSerializer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that deal with segment override, right? Unless we want to limit identity overrides?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does, yeah - it's how the UI used to manage segment overrides before we added the endpoint to do it in a single call.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do this in another PR, I'd like to test this in staging and release it asap.

19 changes: 14 additions & 5 deletions api/segments/serializers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import typing

from django.conf import settings
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import ListSerializer
from rest_framework_recursive.fields import RecursiveField

from projects.models import Project
from segments.models import PERCENTAGE_SPLIT, Condition, Segment, SegmentRule


Expand Down Expand Up @@ -52,19 +54,26 @@ def validate(self, attrs):
return attrs

def create(self, validated_data):
"""
Override create method to create segment with nested rules and conditions
project = validated_data["project"]
self.validate_project_segment_limit(project)

:param validated_data: validated json data
:return: created Segment object
"""
rules_data = validated_data.pop("rules", [])

# create segment with nested rules and conditions
segment = Segment.objects.create(**validated_data)
self._update_or_create_segment_rules(
rules_data, segment=segment, is_create=True
)
return segment

def validate_project_segment_limit(self, project: Project) -> None:
if project.segments.count() >= settings.MAX_SEGMENTS_ALLOWED:
raise ValidationError(
{
"project": "The project has reached the maximum allowed segments limit."
}
)

def update(self, instance, validated_data):
# use the initial data since we need the ids included to determine which to update & which to create
rules_data = self.initial_data.pop("rules", [])
Expand Down
59 changes: 59 additions & 0 deletions api/tests/unit/features/test_unit_features_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,3 +777,62 @@ def test_list_features_provides_segment_overrides_for_dynamo_enabled_project(
assert response_json["count"] == 1
assert response_json["results"][0]["num_segment_overrides"] == 1
assert response_json["results"][0]["num_identity_overrides"] is None


def test_create_segment_override_reaching_max_limit(
admin_client, feature, segment, environment, settings
):
# Given
settings.MAX_SEGMENT_OVERRIDE_ALLOWED = 1
url = reverse(
"api-v1:environments:create-segment-override",
args=[environment.api_key, feature.id],
)

data = {
"feature_state_value": {"string_value": "value"},
"enabled": True,
"feature_segment": {"segment": segment.id},
}

# Now, crate the first override
response = admin_client.post(
url, data=json.dumps(data), content_type="application/json"
)

assert response.status_code == status.HTTP_201_CREATED

# Then
# Try to create another override
response = admin_client.post(
url, data=json.dumps(data), content_type="application/json"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["environment"]
== "The environment has reached the maximum allowed segments overrides limit."
)
assert environment.feature_segments.count() == 1


@pytest.mark.parametrize(
"client", [lazy_fixture("master_api_key_client"), lazy_fixture("admin_client")]
)
def test_create_feature_reaching_max_limit(client, project, settings):
# Given
settings.MAX_FEATURES_ALLOWED = 1

url = reverse("api-v1:projects:project-features-list", args=[project.id])

# Now, crate the first feature
response = client.post(url, data={"name": "test_feature", "project": project.id})
assert response.status_code == status.HTTP_201_CREATED

# Then
# Try to create another feature
response = client.post(url, data={"name": "second_feature", "project": project.id})
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["project"]
== "The Project has reached the maximum allowed features limit."
)
36 changes: 36 additions & 0 deletions api/tests/unit/segments/test_unit_segments_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,42 @@ def test_can_create_segments_with_condition_that_has_null_value(project, client)
assert res.status_code == status.HTTP_201_CREATED


@pytest.mark.parametrize(
"client", [lazy_fixture("master_api_key_client"), lazy_fixture("admin_client")]
)
def test_create_segments_reaching_max_limit(project, client, settings):
# Given
# let's reduce the max segments allowed to 1
settings.MAX_SEGMENTS_ALLOWED = 1

url = reverse("api-v1:projects:project-segments-list", args=[project.id])
data = {
"name": "New segment name",
"project": project.id,
"rules": [
{
"type": "ALL",
"rules": [],
"conditions": [{"operator": EQUAL, "property": "test-property"}],
}
],
}

# Now, let's create the firs segment
res = client.post(url, data=json.dumps(data), content_type="application/json")
assert res.status_code == status.HTTP_201_CREATED

# Then
# Let's try to create a second segment
res = client.post(url, data=json.dumps(data), content_type="application/json")
assert res.status_code == status.HTTP_400_BAD_REQUEST
assert (
res.json()["project"]
== "The project has reached the maximum allowed segments limit."
)
assert project.segments.count() == 1


@pytest.mark.parametrize(
"client", [lazy_fixture("master_api_key_client"), lazy_fixture("admin_client")]
)
Expand Down