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: display warning and prevent creation on limit #2526

Merged
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
1398479
Return limits and maxs in the API response, validate disable button, …
novakzaballa Jul 25, 2023
d92fa99
Add validation for limit alerts
novakzaballa Jul 26, 2023
273a07b
Update serializers
novakzaballa Jul 26, 2023
03c8642
Add Test
novakzaballa Jul 26, 2023
cbc4d49
Update serializers
novakzaballa Jul 26, 2023
d00a307
Merge branch 'main' into feature/display-warning-and-prevent-creation…
novakzaballa Jul 27, 2023
244f2c4
Add alert_warning design system, add read_only_fields
novakzaballa Jul 27, 2023
420a7f4
Use annotate for calculate totals, move and rename test
novakzaballa Aug 1, 2023
33004fa
Update env view
novakzaballa Aug 2, 2023
71eecfa
Update limit alert
novakzaballa Aug 3, 2023
3bd32ac
Add alert toast
novakzaballa Aug 3, 2023
c4b4397
Merge branch 'main' into feature/display-warning-and-prevent-creation…
novakzaballa Aug 4, 2023
c5522c2
Update toast text
novakzaballa Aug 4, 2023
b4b69cf
Add types to displayToastAlert function
novakzaballa Aug 4, 2023
480733e
Merge branch 'main' into feature/display-warning-and-prevent-creation…
novakzaballa Aug 4, 2023
5cd3658
Delete alertLimit component
novakzaballa Aug 4, 2023
6188849
exclude deleted rows in totals
novakzaballa Aug 4, 2023
a9eaa10
Delete alert toast
novakzaballa Aug 4, 2023
68c2881
add WarningMessage, Warning icon and styles
novakzaballa Aug 6, 2023
417d301
Use WarningMessage
novakzaballa Aug 6, 2023
96511af
Merge branch 'main' into feature/display-warning-and-prevent-creation…
novakzaballa Aug 7, 2023
9a2ec99
Add types, add Number.MAX_SAFE_INTEGER, change warningMessage color
novakzaballa Aug 7, 2023
72ab33e
Change Error message
novakzaballa Aug 7, 2023
fef7879
Update segmentOverrides
novakzaballa Aug 7, 2023
5f6cfb3
Number.MAX_SAFE_INTEGER deleted
novakzaballa Aug 7, 2023
01695e4
Update segmentOverrides
novakzaballa Aug 7, 2023
772d6da
Bug with total segment overrides fixed
novakzaballa Aug 8, 2023
46a5fa3
bug with select fixed and "create feature-specific segment" button is…
novakzaballa Aug 8, 2023
264290d
Disable select when the limit is reached
novakzaballa Aug 8, 2023
95f36f5
Adding default fallback values to limits and counts for backwards com…
novakzaballa Aug 8, 2023
e8d9b3f
Merge branch 'main' into feature/display-warning-and-prevent-creation…
matthewelwell Aug 9, 2023
d656954
Revert API changes
matthewelwell Aug 9, 2023
210b0d7
merge main
novakzaballa Aug 24, 2023
c6836cd
solve conflicts
novakzaballa Aug 25, 2023
5b6f3bb
Reset changelog
matthewelwell Aug 31, 2023
b8c5a18
Reset changelog
matthewelwell Aug 31, 2023
6270848
Merge branch 'main' into feature/display-warning-and-prevent-creation…
matthewelwell Aug 31, 2023
323e153
fix: Added a defualt value for threshold in calculateRemainingLimitsP…
novakzaballa Aug 31, 2023
e8f463d
fix: change percentage from 30 to 70
novakzaballa Aug 31, 2023
5171523
Get the value of maxApiCalls from "get-subscription-metadata" endpoint
novakzaballa Sep 7, 2023
3b7c45f
Wording / spacing changes
matthewelwell Sep 11, 2023
98fbeea
Small tweak to wording / spacing
matthewelwell Sep 11, 2023
3a6f086
Spacing
matthewelwell Sep 11, 2023
75558d4
Remove upgrade icon
matthewelwell Sep 11, 2023
62d2a4b
Merge branch 'main' into feature/display-warning-and-prevent-creation…
novakzaballa Sep 12, 2023
6d30b9d
feat: Change max api calls alert banner
novakzaballa Sep 12, 2023
16b7f8b
Migrate changes to RTK
kyle-ssg Sep 13, 2023
6586b7d
fix: Add SegmentOverridesLimit
novakzaballa Sep 13, 2023
c9c2ace
Fix: Delete unnecessary code
novakzaballa Sep 14, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/audit/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

from audit.models import AuditLog
from environments.serializers import EnvironmentSerializerLight
from projects.serializers import ProjectSerializer
from projects.serializers import ProjectListSerializer
from users.serializers import UserListSerializer


class AuditLogSerializer(serializers.ModelSerializer):
author = UserListSerializer()
environment = EnvironmentSerializerLight()
project = ProjectSerializer()
project = ProjectListSerializer()

class Meta:
model = AuditLog
Expand Down
4 changes: 4 additions & 0 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ class Environment(
help_text="If true, will hide sensitive data(e.g: traits, description etc) from the SDK endpoints",
)

@property
def total_segment_overrides(self) -> int:
return self.feature_segments.count()

objects = EnvironmentManager()

class Meta:
Expand Down
15 changes: 10 additions & 5 deletions api/environments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@
ReadOnlyIfNotValidPlanMixin,
)
from projects.models import Project
from projects.serializers import ProjectSerializer
from projects.serializers import ProjectListSerializer
from util.drf_writable_nested.serializers import (
DeleteBeforeUpdateWritableNestedModelSerializer,
)


class EnvironmentSerializerFull(serializers.ModelSerializer):
feature_states = FeatureStateSerializerFull(many=True)
project = ProjectSerializer()
project = ProjectListSerializer()

class Meta:
model = Environment
Expand Down Expand Up @@ -65,15 +65,20 @@ def get_use_mv_v2_evaluation(self, instance: Environment) -> bool:
return instance.use_identity_composite_key_for_hashing


class EnvironmentRetrieveSerializerLight(EnvironmentSerializerLight):
class Meta(EnvironmentSerializerLight.Meta):
fields = EnvironmentSerializerLight.Meta.fields + ("total_segment_overrides",)


class EnvironmentSerializerWithMetadata(
SerializerWithMetadata,
DeleteBeforeUpdateWritableNestedModelSerializer,
EnvironmentSerializerLight,
EnvironmentRetrieveSerializerLight,
):
metadata = MetadataSerializer(required=False, many=True)

class Meta(EnvironmentSerializerLight.Meta):
fields = EnvironmentSerializerLight.Meta.fields + ("metadata",)
class Meta(EnvironmentRetrieveSerializerLight.Meta):
fields = EnvironmentRetrieveSerializerLight.Meta.fields + ("metadata",)

def get_organisation_from_validated_data(self, validated_data) -> Organisation:
return validated_data.get("project").organisation
Expand Down
4 changes: 2 additions & 2 deletions api/organisations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
PermissionModelSerializer,
UserObjectPermissionsSerializer,
)
from projects.serializers import ProjectSerializer
from projects.serializers import ProjectListSerializer
from users.serializers import UserIdSerializer
from webhooks.mixins import TriggerSampleWebhookMixin
from webhooks.webhooks import WebhookType
Expand Down Expand Up @@ -118,7 +118,7 @@ def create(self, request, **kwargs):
def projects(self, request, pk):
organisation = self.get_object()
projects = organisation.projects.all()
return Response(ProjectSerializer(projects, many=True).data)
return Response(ProjectListSerializer(projects, many=True).data)

@action(detail=True, methods=["POST"])
def invite(self, request, pk):
Expand Down
8 changes: 8 additions & 0 deletions api/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ def is_edge_project_by_default(self) -> bool:
and self.created_date >= settings.EDGE_RELEASE_DATETIME
)

@property
def total_features(self) -> int:
return self.features.count()

@property
def total_segments(self) -> int:
return self.segments.count()

def is_feature_name_valid(self, feature_name: str) -> bool:
"""
Validate the feature name based on the feature_name_regex attribute.
Expand Down
15 changes: 12 additions & 3 deletions api/projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from users.serializers import UserListSerializer, UserPermissionGroupSerializer


class ProjectSerializer(serializers.ModelSerializer):
class ProjectListSerializer(serializers.ModelSerializer):
migration_status = serializers.SerializerMethodField(
help_text="Edge migration status of the project; can be one of: "
+ ", ".join([k.value for k in ProjectIdentityMigrationStatus])
Expand All @@ -39,10 +39,8 @@ class Meta:
def get_migration_status(self, obj: Project) -> str:
if not settings.PROJECT_METADATA_TABLE_NAME_DYNAMO:
migration_status = ProjectIdentityMigrationStatus.NOT_APPLICABLE.value

elif obj.is_edge_project_by_default:
migration_status = ProjectIdentityMigrationStatus.MIGRATION_COMPLETED.value

else:
migration_status = IdentityMigrator(obj.id).migration_status.value

Expand All @@ -58,6 +56,17 @@ def get_use_edge_identities(self, obj: Project) -> bool:
)


class ProjectRetrieveSerializer(ProjectListSerializer):
class Meta(ProjectListSerializer.Meta):
fields = ProjectListSerializer.Meta.fields + (
"max_segments_allowed",
"max_features_allowed",
"max_segment_overrides_allowed",
"total_features",
"total_segments",
)


class CreateUpdateUserProjectPermissionSerializer(
CreateUpdateUserPermissionSerializerABC
):
Expand Down
44 changes: 35 additions & 9 deletions api/projects/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
from django.utils import timezone

from environments.dynamodb.types import ProjectIdentityMigrationStatus
from projects.serializers import ProjectSerializer
from projects.serializers import (
ProjectListSerializer,
ProjectRetrieveSerializer,
)


def test_ProjectSerializer_get_migration_status_returns_migration_not_applicable_if_not_configured(
def test_ProjectListSerializer_get_migration_status_returns_migration_not_applicable_if_not_configured(
mocker, project, settings
):
# Given
Expand All @@ -16,7 +19,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_not_applicable
"projects.serializers.IdentityMigrator", autospec=True
)

serializer = ProjectSerializer()
serializer = ProjectListSerializer()

# When
migration_status = serializer.get_migration_status(project)
Expand All @@ -26,7 +29,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_not_applicable
mocked_identity_migrator.assert_not_called()


def test_ProjectSerializer_get_migration_status_returns_migration_completed_for_new_projects(
def test_ProjectListSerializer_get_migration_status_returns_migration_completed_for_new_projects(
mocker, project, settings
):
# Given
Expand All @@ -36,7 +39,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_completed_for_
"projects.serializers.IdentityMigrator", autospec=True
)

serializer = ProjectSerializer()
serializer = ProjectListSerializer()

# When
migration_status = serializer.get_migration_status(project)
Expand All @@ -46,7 +49,7 @@ def test_ProjectSerializer_get_migration_status_returns_migration_completed_for_
mocked_identity_migrator.assert_not_called()


def test_ProjectSerializer_get_migration_status_calls_migrator_with_correct_arguments_for_old_projects(
def test_ProjectListSerializer_get_migration_status_calls_migrator_with_correct_arguments_for_old_projects(
mocker, project, settings
):
# Given
Expand All @@ -57,7 +60,7 @@ def test_ProjectSerializer_get_migration_status_calls_migrator_with_correct_argu

settings.EDGE_RELEASE_DATETIME = timezone.now()

serializer = ProjectSerializer()
serializer = ProjectListSerializer()

# When
migration_status = serializer.get_migration_status(project)
Expand All @@ -78,9 +81,32 @@ def test_ProjectSerializer_get_migration_status_calls_migrator_with_correct_argu
(ProjectIdentityMigrationStatus.NOT_APPLICABLE.value, False),
],
)
def test_ProjectSerializer_get_use_edge_identities(project, migration_status, expected):
def test_ProjectListSerializer_get_use_edge_identities(
project, migration_status, expected
):
# Given
serializer = ProjectSerializer(context={"migration_status": migration_status})
serializer = ProjectListSerializer(context={"migration_status": migration_status})

# When/Then
assert expected is serializer.get_use_edge_identities(project)


@pytest.mark.parametrize(
"migration_status, expected",
[
(ProjectIdentityMigrationStatus.MIGRATION_COMPLETED.value, True),
(ProjectIdentityMigrationStatus.MIGRATION_IN_PROGRESS.value, False),
(ProjectIdentityMigrationStatus.MIGRATION_NOT_STARTED.value, False),
(ProjectIdentityMigrationStatus.NOT_APPLICABLE.value, False),
],
)
def test_ProjectRetrieveSerializer_get_use_edge_identities(
project, migration_status, expected
):
# Given
serializer = ProjectRetrieveSerializer(
context={"migration_status": migration_status}
)

# When/Then
assert expected is serializer.get_use_edge_identities(project)
61 changes: 61 additions & 0 deletions api/projects/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,67 @@ def setUp(self):
def _get_detail_url(self, project_id):
return reverse("api-v1:projects:project-detail", args=[project_id])

def test_get_project_data(self):
# Given
project_name = "Test project"
hide_disabled_flags = False
enable_dynamo_db = False
prevent_flag_defaults = True
enable_realtime_updates = False
only_allow_lower_case_feature_names = True

Project.objects.create(
name=project_name,
organisation=self.organisation,
hide_disabled_flags=hide_disabled_flags,
enable_dynamo_db=enable_dynamo_db,
prevent_flag_defaults=prevent_flag_defaults,
enable_realtime_updates=enable_realtime_updates,
only_allow_lower_case_feature_names=only_allow_lower_case_feature_names,
)
url = reverse("api-v1:projects:project-list")

# When
response = self.client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK
assert response.json()[0]["name"] == project_name
assert response.json()[0]["hide_disabled_flags"] is hide_disabled_flags
assert response.json()[0]["enable_dynamo_db"] is enable_dynamo_db
assert response.json()[0]["prevent_flag_defaults"] is prevent_flag_defaults
assert response.json()[0]["enable_realtime_updates"] is enable_realtime_updates
assert (
response.json()[0]["only_allow_lower_case_feature_names"]
is only_allow_lower_case_feature_names
)
assert "max_segments_allowed" not in response.json()[0].keys()
assert "max_features_allowed" not in response.json()[0].keys()
assert "max_segment_overrides_allowed" not in response.json()[0].keys()
assert "total_features" not in response.json()[0].keys()
assert "total_segments" not in response.json()[0].keys()

def test_get_project_data_by_id(self):
# Given
project_name = "Test project"
project = Project.objects.create(
name=project_name,
organisation=self.organisation,
)
url = reverse("api-v1:projects:project-detail", args=[project.id])

# When
response = self.client.get(url)

# Then
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == project_name
assert response.json()["max_segments_allowed"] == 100
assert response.json()["max_features_allowed"] == 400
assert response.json()["max_segment_overrides_allowed"] == 100
assert response.json()["total_features"] == 0
assert response.json()["total_segments"] == 0

def test_create_project_returns_403_if_user_is_not_organisation_admin(self):
# Given
not_permitted_user = FFAdminUser.objects.create(email="[email protected]")
Expand Down
12 changes: 10 additions & 2 deletions api/projects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
CreateUpdateUserProjectPermissionSerializer,
ListUserPermissionGroupProjectPermissionSerializer,
ListUserProjectPermissionSerializer,
ProjectSerializer,
ProjectListSerializer,
ProjectRetrieveSerializer,
)


Expand All @@ -70,7 +71,14 @@
),
)
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
def get_serializer_class(self):
if self.action == "list":
return ProjectListSerializer
elif self.action == "retrieve":
return ProjectRetrieveSerializer

return ProjectListSerializer

permission_classes = [ProjectPermissions | MasterAPIKeyProjectPermissions]
pagination_class = None

Expand Down
6 changes: 5 additions & 1 deletion frontend/common/providers/FeatureListProvider.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react'
import FeatureListStore from 'common/stores/feature-list-store'
import ProjectStore from 'common/stores/project-store'

const FeatureListProvider = class extends React.Component {
static displayName = 'FeatureListProvider'
Expand All @@ -11,8 +12,9 @@ const FeatureListProvider = class extends React.Component {
isLoading: FeatureListStore.isLoading,
isSaving: FeatureListStore.isSaving,
lastSaved: FeatureListStore.getLastSaved(),
maxFeaturesAllowed: ProjectStore.getMaxFeaturesAllowed(),
projectFlags: FeatureListStore.getProjectFlags(),
usageData: FeatureListStore.getFeatureUsage(),
totalFeatures: ProjectStore.getTotalFeatures(),
}
ES6Component(this)
this.listenTo(FeatureListStore, 'change', () => {
Expand All @@ -22,7 +24,9 @@ const FeatureListProvider = class extends React.Component {
isLoading: FeatureListStore.isLoading,
isSaving: FeatureListStore.isSaving,
lastSaved: FeatureListStore.getLastSaved(),
maxFeaturesAllowed: ProjectStore.getMaxFeaturesAllowed(),
projectFlags: FeatureListStore.getProjectFlags(),
totalFeatures: ProjectStore.getTotalFeatures(),
usageData: FeatureListStore.getFeatureUsage(),
})
})
Expand Down
15 changes: 15 additions & 0 deletions frontend/common/stores/project-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,21 @@ const store = Object.assign({}, BaseStore, {
return Promise.resolve(store.getEnvironmentIdFromKey(apiKey))
})
},
getMaxFeaturesAllowed: () => {
return store.model && store.model.max_features_allowed
},
getMaxSegmentOverridesAllowed: () => {
return store.model && store.model.max_segment_overrides_allowed
},
getMaxSegmentsAllowed: () => {
return store.model && store.model.max_segments_allowed
},
getTotalFeatures: () => {
return store.model && store.model.total_features
},
getTotalSegments: () => {
return store.model && store.model.total_segments
},
getEnvs: () => store.model && store.model.environments,
id: 'project',
model: null,
Expand Down
Loading