Skip to content

Commit 27fbd8b

Browse files
authored
Merge pull request #4 from Flagsmith/feat/add_workflows_change_request_concerns
feat: Add workflows change request concerns
2 parents ce530a6 + 98e0166 commit 27fbd8b

File tree

6 files changed

+696
-102
lines changed

6 files changed

+696
-102
lines changed

common/environments/permissions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Maintain a list of permissions here
2+
VIEW_ENVIRONMENT = "VIEW_ENVIRONMENT"
3+
UPDATE_FEATURE_STATE = "UPDATE_FEATURE_STATE"
4+
MANAGE_IDENTITIES = "MANAGE_IDENTITIES"
5+
VIEW_IDENTITIES = "VIEW_IDENTITIES"
6+
CREATE_CHANGE_REQUEST = "CREATE_CHANGE_REQUEST"
7+
APPROVE_CHANGE_REQUEST = "APPROVE_CHANGE_REQUEST"
8+
MANAGE_SEGMENT_OVERRIDES = "MANAGE_SEGMENT_OVERRIDES"
9+
10+
TAG_SUPPORTED_PERMISSIONS = [UPDATE_FEATURE_STATE]

common/metadata/serializers.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from django.apps import apps
2+
from django.contrib.contenttypes.models import ContentType
3+
from django.db import models
4+
from rest_framework import serializers
5+
6+
7+
class MetadataSerializer(serializers.ModelSerializer):
8+
class Meta:
9+
model = apps.get_model("metadata", "Metadata")
10+
fields = ("id", "model_field", "field_value")
11+
12+
def validate(self, data):
13+
data = super().validate(data)
14+
if not data["model_field"].field.is_field_value_valid(data["field_value"]):
15+
raise serializers.ValidationError(
16+
f"Invalid value for field {data['model_field'].field.name}"
17+
)
18+
19+
return data
20+
21+
22+
class SerializerWithMetadata(serializers.BaseSerializer):
23+
def get_organisation(self, validated_data: dict = None) -> models.Model:
24+
return self.get_project(validated_data).organisation
25+
26+
def get_project(self, validated_data: dict = None) -> models.Model:
27+
raise NotImplementedError()
28+
29+
def get_required_for_object(
30+
self, requirement: models.Model, data: dict
31+
) -> models.Model:
32+
model_name = requirement.content_type.model
33+
try:
34+
return getattr(self, f"get_{model_name}")(data)
35+
except AttributeError:
36+
raise ValueError(
37+
f"`get_{model_name}_from_validated_data` method does not exist"
38+
)
39+
40+
def validate_required_metadata(self, data):
41+
metadata = data.get("metadata", [])
42+
43+
content_type = ContentType.objects.get_for_model(self.Meta.model)
44+
45+
organisation = self.get_organisation(data)
46+
47+
requirements = apps.get_model(
48+
"metadata", "MetadataModelFieldRequirement"
49+
).objects.filter(
50+
model_field__content_type=content_type,
51+
model_field__field__organisation=organisation,
52+
)
53+
54+
for requirement in requirements:
55+
required_for = self.get_required_for_object(requirement, data)
56+
if required_for.id == requirement.object_id:
57+
if not any(
58+
[
59+
field["model_field"] == requirement.model_field
60+
for field in metadata
61+
]
62+
):
63+
raise serializers.ValidationError(
64+
{
65+
"metadata": f"Missing required metadata field: {requirement.model_field.field.name}"
66+
}
67+
)
68+
69+
def validate(self, data):
70+
data = super().validate(data)
71+
self.validate_required_metadata(data)
72+
return data

common/projects/permissions.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
VIEW_AUDIT_LOG = "VIEW_AUDIT_LOG"
2+
3+
# Maintain a list of permissions here
4+
VIEW_PROJECT = "VIEW_PROJECT"
5+
CREATE_ENVIRONMENT = "CREATE_ENVIRONMENT"
6+
DELETE_FEATURE = "DELETE_FEATURE"
7+
CREATE_FEATURE = "CREATE_FEATURE"
8+
EDIT_FEATURE = "EDIT_FEATURE"
9+
MANAGE_SEGMENTS = "MANAGE_SEGMENTS"
10+
MANAGE_TAGS = "MANAGE_TAGS"
11+
12+
# Note that this does not impact change requests in an environment
13+
MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS = "MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS"
14+
APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS = "APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS"
15+
16+
TAG_SUPPORTED_PERMISSIONS = [DELETE_FEATURE]
17+
18+
PROJECT_PERMISSIONS = [
19+
(VIEW_PROJECT, "View permission for the given project."),
20+
(CREATE_ENVIRONMENT, "Ability to create an environment in the given project."),
21+
(DELETE_FEATURE, "Ability to delete features in the given project."),
22+
(CREATE_FEATURE, "Ability to create features in the given project."),
23+
(EDIT_FEATURE, "Ability to edit features in the given project."),
24+
(MANAGE_SEGMENTS, "Ability to manage segments in the given project."),
25+
(VIEW_AUDIT_LOG, "Allows the user to view the audit logs for this organisation."),
26+
(
27+
MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS,
28+
"Ability to manage change requests associated with a project.",
29+
),
30+
(
31+
APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS,
32+
"Ability to approve project level change requests.",
33+
),
34+
(MANAGE_TAGS, "Allows the user to manage tags in the given project."),
35+
]

common/segments/serializers.py

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import logging
2+
from typing import Any, Optional
3+
4+
from django.apps import apps
5+
from django.conf import settings
6+
from django.contrib.contenttypes.models import ContentType
7+
from django.db import models
8+
from flag_engine.segments.constants import PERCENTAGE_SPLIT
9+
from rest_framework import serializers
10+
from rest_framework.exceptions import ValidationError
11+
from rest_framework.serializers import ListSerializer
12+
from rest_framework_recursive.fields import RecursiveField
13+
14+
from common.metadata.serializers import (
15+
MetadataSerializer,
16+
SerializerWithMetadata,
17+
)
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
class ConditionSerializer(serializers.ModelSerializer):
23+
delete = serializers.BooleanField(write_only=True, required=False)
24+
25+
class Meta:
26+
model = apps.get_model("segments", "Condition")
27+
fields = ("id", "operator", "property", "value", "description", "delete")
28+
29+
def validate(self, attrs):
30+
super(ConditionSerializer, self).validate(attrs)
31+
if attrs.get("operator") != PERCENTAGE_SPLIT and not attrs.get("property"):
32+
raise ValidationError({"property": ["This field may not be blank."]})
33+
return attrs
34+
35+
def to_internal_value(self, data):
36+
# convert value to a string - conversion to correct value type is handled elsewhere
37+
data["value"] = str(data["value"]) if "value" in data else None
38+
return super(ConditionSerializer, self).to_internal_value(data)
39+
40+
41+
class RuleSerializer(serializers.ModelSerializer):
42+
delete = serializers.BooleanField(write_only=True, required=False)
43+
conditions = ConditionSerializer(many=True, required=False)
44+
rules = ListSerializer(child=RecursiveField(), required=False)
45+
46+
class Meta:
47+
model = apps.get_model("segments", "SegmentRule")
48+
fields = ("id", "type", "rules", "conditions", "delete")
49+
50+
51+
class SegmentSerializer(serializers.ModelSerializer, SerializerWithMetadata):
52+
rules = RuleSerializer(many=True)
53+
metadata = MetadataSerializer(required=False, many=True)
54+
55+
class Meta:
56+
model = apps.get_model("segments", "Segment")
57+
fields = "__all__"
58+
59+
def validate(self, attrs):
60+
attrs = super().validate(attrs)
61+
self.validate_required_metadata(attrs)
62+
if not attrs.get("rules"):
63+
raise ValidationError(
64+
{"rules": "Segment cannot be created without any rules."}
65+
)
66+
return attrs
67+
68+
def get_project(self, validated_data: dict = None) -> models.Model:
69+
return validated_data.get("project") or apps.get_model(
70+
"projects", "Project"
71+
).objects.get(id=self.context["view"].kwargs["project_pk"])
72+
73+
def create(self, validated_data: dict) -> models.Model:
74+
Segment = apps.get_model("segments", "Segment")
75+
project = validated_data["project"]
76+
self.validate_project_segment_limit(project)
77+
78+
rules_data = validated_data.pop("rules", [])
79+
metadata_data = validated_data.pop("metadata", [])
80+
self.validate_segment_rules_conditions_limit(rules_data)
81+
82+
# create segment with nested rules and conditions
83+
segment = Segment.objects.create(**validated_data)
84+
self._update_or_create_segment_rules(
85+
rules_data, segment=segment, is_create=True
86+
)
87+
self._update_or_create_metadata(metadata_data, segment=segment)
88+
return segment
89+
90+
def update(
91+
self, instance: models.Model, validated_data: dict[str, Any]
92+
) -> models.Model:
93+
# use the initial data since we need the ids included to determine which to update & which to create
94+
rules_data = self.initial_data.pop("rules", [])
95+
metadata_data = validated_data.pop("metadata", [])
96+
self.validate_segment_rules_conditions_limit(rules_data)
97+
98+
# Create a version of the segment now that we're updating.
99+
cloned_segment = instance.deep_clone()
100+
logger.info(
101+
f"Updating cloned segment {cloned_segment.id} for original segment {instance.id}"
102+
)
103+
104+
try:
105+
self._update_segment_rules(rules_data, segment=instance)
106+
self._update_or_create_metadata(metadata_data, segment=instance)
107+
108+
# remove rules from validated data to prevent error trying to create segment with nested rules
109+
del validated_data["rules"]
110+
response = super().update(instance, validated_data)
111+
except Exception:
112+
# Since there was a problem during the update we now delete the cloned segment,
113+
# since we no longer need a versioned segment.
114+
instance.refresh_from_db()
115+
instance.version = cloned_segment.version
116+
instance.save()
117+
cloned_segment.hard_delete()
118+
raise
119+
120+
return response
121+
122+
def validate_project_segment_limit(self, project: models.Model) -> None:
123+
if project.segments.count() >= project.max_segments_allowed:
124+
raise ValidationError(
125+
{
126+
"project": "The project has reached the maximum allowed segments limit."
127+
}
128+
)
129+
130+
def validate_segment_rules_conditions_limit(
131+
self, rules_data: dict[str, object]
132+
) -> None:
133+
if self.instance and getattr(self.instance, "whitelisted_segment", None):
134+
return
135+
136+
count = self._calculate_condition_count(rules_data)
137+
138+
if count > settings.SEGMENT_RULES_CONDITIONS_LIMIT:
139+
raise ValidationError(
140+
{
141+
"segment": f"The segment has {count} conditions, which exceeds the maximum "
142+
f"condition count of {settings.SEGMENT_RULES_CONDITIONS_LIMIT}."
143+
}
144+
)
145+
146+
def _calculate_condition_count(
147+
self,
148+
rules_data: dict[str, object],
149+
) -> None:
150+
count: int = 0
151+
152+
for rule_data in rules_data:
153+
child_rules = rule_data.get("rules", [])
154+
if child_rules:
155+
count += self._calculate_condition_count(child_rules)
156+
conditions = rule_data.get("conditions", [])
157+
for condition in conditions:
158+
if condition.get("delete", False) is True:
159+
continue
160+
count += 1
161+
return count
162+
163+
def _update_segment_rules(
164+
self, rules_data: dict, segment: Optional[models.Model] = None
165+
) -> None:
166+
"""
167+
Since we don't have a unique identifier for the rules / conditions for the update, we assume that the client
168+
passes up the new configuration for the rules of the segment and simply wipe the old ones and create new ones
169+
"""
170+
Segment = apps.get_model("segments", "Segment")
171+
172+
# traverse the rules / conditions tree - if no ids are provided, then maintain the previous behaviour (clear
173+
# existing rules and create the ones that were sent)
174+
# note: we do this to preserve backwards compatibility after adding logic to include the id in requests
175+
if not Segment.id_exists_in_rules_data(rules_data):
176+
segment.rules.set([])
177+
178+
self._update_or_create_segment_rules(rules_data, segment=segment)
179+
180+
def _update_or_create_segment_rules(
181+
self,
182+
rules_data: dict,
183+
segment: Optional[models.Model] = None,
184+
rule: Optional[models.Model] = None,
185+
is_create: bool = False,
186+
) -> None:
187+
if all(x is None for x in {segment, rule}):
188+
raise RuntimeError("Can't create rule without parent segment or rule")
189+
190+
for rule_data in rules_data:
191+
child_rules = rule_data.pop("rules", [])
192+
conditions = rule_data.pop("conditions", [])
193+
194+
child_rule = self._update_or_create_segment_rule(
195+
rule_data, segment=segment, rule=rule
196+
)
197+
if not child_rule:
198+
# child rule was deleted
199+
continue
200+
201+
self._update_or_create_conditions(
202+
conditions, child_rule, is_create=is_create
203+
)
204+
205+
self._update_or_create_segment_rules(
206+
child_rules, rule=child_rule, is_create=is_create
207+
)
208+
209+
def _update_or_create_metadata(
210+
self, metadata_data: dict, segment: Optional[models.Model] = None
211+
) -> None:
212+
Metadata = apps.get_model("metadata", "Metadata")
213+
Segment = apps.get_model("segments", "Segment")
214+
if len(metadata_data) == 0:
215+
Metadata.objects.filter(object_id=segment.id).delete()
216+
return
217+
if metadata_data is not None:
218+
for metadata_item in metadata_data:
219+
metadata_model_field = metadata_item.pop("model_field", None)
220+
if metadata_item.get("delete"):
221+
Metadata.objects.filter(model_field=metadata_model_field).delete()
222+
continue
223+
224+
Metadata.objects.update_or_create(
225+
model_field=metadata_model_field,
226+
defaults={
227+
**metadata_item,
228+
"content_type": ContentType.objects.get_for_model(Segment),
229+
"object_id": segment.id,
230+
},
231+
)
232+
233+
@staticmethod
234+
def _update_or_create_segment_rule(
235+
rule_data: dict,
236+
segment: Optional[models.Model] = None,
237+
rule: Optional[models.Model] = None,
238+
) -> Optional[models.Model]:
239+
SegmentRule = apps.get_model("segments", "SegmentRule")
240+
rule_id = rule_data.pop("id", None)
241+
if rule_data.get("delete"):
242+
SegmentRule.objects.filter(id=rule_id).delete()
243+
return
244+
245+
segment_rule, _ = SegmentRule.objects.update_or_create(
246+
id=rule_id, defaults={"segment": segment, "rule": rule, **rule_data}
247+
)
248+
return segment_rule
249+
250+
@staticmethod
251+
def _update_or_create_conditions(
252+
conditions_data: dict, rule: models.Model, is_create: bool = False
253+
) -> None:
254+
Condition = apps.get_model("segments", "Condition")
255+
for condition in conditions_data:
256+
condition_id = condition.pop("id", None)
257+
if condition.get("delete"):
258+
Condition.objects.filter(id=condition_id).delete()
259+
continue
260+
261+
Condition.objects.update_or_create(
262+
id=condition_id,
263+
defaults={**condition, "created_with_segment": is_create, "rule": rule},
264+
)

0 commit comments

Comments
 (0)