flagsmith-common 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- common/__init__.py +0 -0
- common/core/__init__.py +0 -0
- common/core/app.py +6 -0
- common/core/logging.py +24 -0
- common/core/main.py +40 -0
- common/core/management/__init__.py +0 -0
- common/core/management/commands/__init__.py +0 -0
- common/core/management/commands/start.py +41 -0
- common/core/metrics.py +23 -0
- common/core/urls.py +17 -0
- common/core/utils.py +90 -0
- common/core/views.py +23 -0
- common/environments/permissions.py +15 -0
- common/features/__init__.py +0 -0
- common/features/multivariate/__init__.py +0 -0
- common/features/multivariate/serializers.py +19 -0
- common/features/serializers.py +68 -0
- common/features/versioning/__init__.py +0 -0
- common/features/versioning/serializers.py +13 -0
- common/gunicorn/__init__.py +0 -0
- common/gunicorn/conf.py +18 -0
- common/gunicorn/constants.py +1 -0
- common/gunicorn/logging.py +94 -0
- common/gunicorn/metrics.py +14 -0
- common/gunicorn/middleware.py +28 -0
- common/gunicorn/utils.py +61 -0
- common/metadata/serializers.py +100 -0
- common/organisations/permissions.py +10 -0
- common/projects/permissions.py +40 -0
- common/prometheus/__init__.py +3 -0
- common/prometheus/utils.py +18 -0
- common/py.typed +0 -0
- common/segments/serializers.py +338 -0
- common/test_tools/__init__.py +3 -0
- common/test_tools/plugin.py +34 -0
- common/test_tools/types.py +11 -0
- common/types.py +45 -0
- flagsmith_common-1.5.0.dist-info/LICENSE +28 -0
- flagsmith_common-1.5.0.dist-info/METADATA +128 -0
- flagsmith_common-1.5.0.dist-info/RECORD +42 -0
- flagsmith_common-1.5.0.dist-info/WHEEL +4 -0
- flagsmith_common-1.5.0.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
from django.apps import apps
|
|
4
|
+
from django.contrib.contenttypes.models import ContentType
|
|
5
|
+
from django.db import models
|
|
6
|
+
from rest_framework import serializers
|
|
7
|
+
|
|
8
|
+
if typing.TYPE_CHECKING:
|
|
9
|
+
from common.types import (
|
|
10
|
+
Metadata, # noqa: F401
|
|
11
|
+
MetadataModelFieldRequirement,
|
|
12
|
+
Organisation,
|
|
13
|
+
Project,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MetadataSerializer(serializers.ModelSerializer["Metadata"]):
|
|
18
|
+
class Meta:
|
|
19
|
+
model = apps.get_model("metadata", "Metadata")
|
|
20
|
+
fields = ("id", "model_field", "field_value")
|
|
21
|
+
|
|
22
|
+
def validate(self, data: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
|
23
|
+
data = super().validate(data)
|
|
24
|
+
if not data["model_field"].field.is_field_value_valid(data["field_value"]):
|
|
25
|
+
raise serializers.ValidationError(
|
|
26
|
+
f"Invalid value for field {data['model_field'].field.name}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SerializerWithMetadata(serializers.Serializer[models.Model]):
|
|
33
|
+
def get_organisation(
|
|
34
|
+
self,
|
|
35
|
+
validated_data: dict[str, typing.Any] | None = None,
|
|
36
|
+
) -> "Organisation":
|
|
37
|
+
return self.get_project(validated_data).organisation
|
|
38
|
+
|
|
39
|
+
def get_project(
|
|
40
|
+
self,
|
|
41
|
+
validated_data: dict[str, typing.Any] | None = None,
|
|
42
|
+
) -> "Project":
|
|
43
|
+
raise NotImplementedError()
|
|
44
|
+
|
|
45
|
+
def get_required_for_object(
|
|
46
|
+
self,
|
|
47
|
+
requirement: "MetadataModelFieldRequirement",
|
|
48
|
+
data: dict[str, typing.Any],
|
|
49
|
+
) -> models.Model:
|
|
50
|
+
model_name = requirement.content_type.model
|
|
51
|
+
try:
|
|
52
|
+
instance: models.Model = getattr(self, f"get_{model_name}")(data)
|
|
53
|
+
except AttributeError:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"`get_{model_name}_from_validated_data` method does not exist"
|
|
56
|
+
)
|
|
57
|
+
return instance
|
|
58
|
+
|
|
59
|
+
def validate_required_metadata(
|
|
60
|
+
self,
|
|
61
|
+
data: dict[str, typing.Any],
|
|
62
|
+
) -> None:
|
|
63
|
+
metadata = data.get("metadata", [])
|
|
64
|
+
|
|
65
|
+
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
|
66
|
+
|
|
67
|
+
organisation = self.get_organisation(data)
|
|
68
|
+
|
|
69
|
+
requirements: models.QuerySet["MetadataModelFieldRequirement"] = apps.get_model(
|
|
70
|
+
"metadata", "MetadataModelFieldRequirement"
|
|
71
|
+
).objects.filter(
|
|
72
|
+
model_field__content_type=content_type,
|
|
73
|
+
model_field__field__organisation=organisation,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
for requirement in requirements:
|
|
77
|
+
required_for = self.get_required_for_object(requirement, data)
|
|
78
|
+
if required_for.pk == requirement.object_id:
|
|
79
|
+
if not any(
|
|
80
|
+
[
|
|
81
|
+
field["model_field"] == requirement.model_field
|
|
82
|
+
for field in metadata
|
|
83
|
+
]
|
|
84
|
+
):
|
|
85
|
+
raise serializers.ValidationError(
|
|
86
|
+
{
|
|
87
|
+
"metadata": f"Missing required metadata field: {requirement.model_field.field.name}"
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def validate(
|
|
92
|
+
self,
|
|
93
|
+
data: dict[str, typing.Any],
|
|
94
|
+
) -> dict[str, typing.Any]:
|
|
95
|
+
data = super().validate(data)
|
|
96
|
+
self.validate_required_metadata(data)
|
|
97
|
+
return data
|
|
98
|
+
|
|
99
|
+
class Meta:
|
|
100
|
+
model: typing.Type[models.Model] = models.Model
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE_PROJECT = "CREATE_PROJECT"
|
|
2
|
+
MANAGE_USER_GROUPS = "MANAGE_USER_GROUPS"
|
|
3
|
+
|
|
4
|
+
ORGANISATION_PERMISSIONS = (
|
|
5
|
+
(CREATE_PROJECT, "Allows the user to create projects in this organisation."),
|
|
6
|
+
(
|
|
7
|
+
MANAGE_USER_GROUPS,
|
|
8
|
+
"Allows the user to manage the groups in the organisation and their members.",
|
|
9
|
+
),
|
|
10
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
CREATE_PROJECT_LEVEL_CHANGE_REQUESTS = "CREATE_PROJECT_LEVEL_CHANGE_REQUESTS"
|
|
16
|
+
|
|
17
|
+
TAG_SUPPORTED_PERMISSIONS = [DELETE_FEATURE]
|
|
18
|
+
|
|
19
|
+
PROJECT_PERMISSIONS = [
|
|
20
|
+
(VIEW_PROJECT, "View permission for the given project."),
|
|
21
|
+
(CREATE_ENVIRONMENT, "Ability to create an environment in the given project."),
|
|
22
|
+
(DELETE_FEATURE, "Ability to delete features in the given project."),
|
|
23
|
+
(CREATE_FEATURE, "Ability to create features in the given project."),
|
|
24
|
+
(EDIT_FEATURE, "Ability to edit features in the given project."),
|
|
25
|
+
(MANAGE_SEGMENTS, "Ability to manage segments in the given project."),
|
|
26
|
+
(VIEW_AUDIT_LOG, "Allows the user to view the audit logs for this organisation."),
|
|
27
|
+
(
|
|
28
|
+
MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS,
|
|
29
|
+
"Ability to create, delete, and publish change requests associated with a project.",
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS,
|
|
33
|
+
"Ability to approve project level change requests.",
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
CREATE_PROJECT_LEVEL_CHANGE_REQUESTS,
|
|
37
|
+
"Ability to create project level change requests.",
|
|
38
|
+
),
|
|
39
|
+
(MANAGE_TAGS, "Allows the user to manage tags in the given project."),
|
|
40
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import prometheus_client
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from prometheus_client.metrics import MetricWrapperBase
|
|
6
|
+
from prometheus_client.multiprocess import MultiProcessCollector
|
|
7
|
+
|
|
8
|
+
T = typing.TypeVar("T", bound=MetricWrapperBase)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Histogram(prometheus_client.Histogram):
|
|
12
|
+
DEFAULT_BUCKETS = settings.PROMETHEUS_HISTOGRAM_BUCKETS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_registry() -> prometheus_client.CollectorRegistry:
|
|
16
|
+
registry = prometheus_client.CollectorRegistry()
|
|
17
|
+
MultiProcessCollector(registry) # type: ignore[no-untyped-call]
|
|
18
|
+
return registry
|
common/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
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 ( # type: ignore[import-untyped]
|
|
13
|
+
RecursiveField,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from common.metadata.serializers import (
|
|
17
|
+
MetadataSerializer,
|
|
18
|
+
SerializerWithMetadata,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from common.types import ( # noqa: F401
|
|
23
|
+
Condition,
|
|
24
|
+
Project,
|
|
25
|
+
Rule,
|
|
26
|
+
Segment,
|
|
27
|
+
)
|
|
28
|
+
from common.types import (
|
|
29
|
+
SegmentRule as SegmentRule_,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConditionSerializer(serializers.ModelSerializer["Condition"]):
|
|
36
|
+
delete = serializers.BooleanField(write_only=True, required=False)
|
|
37
|
+
version_of = RecursiveField(required=False, allow_null=True)
|
|
38
|
+
|
|
39
|
+
class Meta:
|
|
40
|
+
model = apps.get_model("segments", "Condition")
|
|
41
|
+
fields = (
|
|
42
|
+
"id",
|
|
43
|
+
"operator",
|
|
44
|
+
"property",
|
|
45
|
+
"value",
|
|
46
|
+
"description",
|
|
47
|
+
"delete",
|
|
48
|
+
"version_of",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
super(ConditionSerializer, self).validate(attrs)
|
|
53
|
+
if attrs.get("operator") != PERCENTAGE_SPLIT and not attrs.get("property"):
|
|
54
|
+
raise ValidationError({"property": ["This field may not be blank."]})
|
|
55
|
+
return attrs
|
|
56
|
+
|
|
57
|
+
def to_internal_value(self, data: dict[str, Any]) -> Any:
|
|
58
|
+
# convert value to a string - conversion to correct value type is handled elsewhere
|
|
59
|
+
data["value"] = str(data["value"]) if "value" in data else None
|
|
60
|
+
return super(ConditionSerializer, self).to_internal_value(data)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RuleSerializer(serializers.ModelSerializer["Rule"]):
|
|
64
|
+
delete = serializers.BooleanField(write_only=True, required=False)
|
|
65
|
+
conditions = ConditionSerializer(many=True, required=False)
|
|
66
|
+
rules: ListSerializer["Rule"] = ListSerializer(
|
|
67
|
+
child=RecursiveField(), required=False
|
|
68
|
+
)
|
|
69
|
+
version_of = RecursiveField(required=False, allow_null=True)
|
|
70
|
+
|
|
71
|
+
class Meta:
|
|
72
|
+
model = apps.get_model("segments", "SegmentRule")
|
|
73
|
+
fields = ("id", "type", "rules", "conditions", "delete", "version_of")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SegmentSerializer(serializers.ModelSerializer["Segment"], SerializerWithMetadata):
|
|
77
|
+
rules = RuleSerializer(many=True)
|
|
78
|
+
metadata = MetadataSerializer(required=False, many=True)
|
|
79
|
+
|
|
80
|
+
class Meta:
|
|
81
|
+
model = apps.get_model("segments", "Segment")
|
|
82
|
+
fields = "__all__"
|
|
83
|
+
|
|
84
|
+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
|
85
|
+
attrs = super().validate(attrs)
|
|
86
|
+
self.validate_required_metadata(attrs)
|
|
87
|
+
if not attrs.get("rules"):
|
|
88
|
+
raise ValidationError(
|
|
89
|
+
{"rules": "Segment cannot be created without any rules."}
|
|
90
|
+
)
|
|
91
|
+
return attrs
|
|
92
|
+
|
|
93
|
+
def get_project(
|
|
94
|
+
self,
|
|
95
|
+
validated_data: dict[str, Any] | None = None,
|
|
96
|
+
) -> "Project":
|
|
97
|
+
project: "Project"
|
|
98
|
+
if validated_data and "project" in validated_data:
|
|
99
|
+
project = validated_data["project"]
|
|
100
|
+
return project
|
|
101
|
+
project = apps.get_model("projects", "Project").objects.get(
|
|
102
|
+
id=self.context["view"].kwargs["project_pk"]
|
|
103
|
+
)
|
|
104
|
+
return project
|
|
105
|
+
|
|
106
|
+
def create(self, validated_data: dict[str, Any]) -> "Segment":
|
|
107
|
+
project = validated_data["project"]
|
|
108
|
+
self.validate_project_segment_limit(project)
|
|
109
|
+
|
|
110
|
+
rules_data = validated_data.pop("rules", [])
|
|
111
|
+
metadata_data = validated_data.pop("metadata", [])
|
|
112
|
+
self.validate_segment_rules_conditions_limit(rules_data)
|
|
113
|
+
|
|
114
|
+
# create segment with nested rules and conditions
|
|
115
|
+
segment: "Segment" = apps.get_model("segments", "Segment").objects.create(
|
|
116
|
+
**validated_data
|
|
117
|
+
)
|
|
118
|
+
self._update_or_create_segment_rules(
|
|
119
|
+
rules_data, segment=segment, is_create=True
|
|
120
|
+
)
|
|
121
|
+
self._update_or_create_metadata(metadata_data, segment=segment)
|
|
122
|
+
return segment
|
|
123
|
+
|
|
124
|
+
def update(
|
|
125
|
+
self,
|
|
126
|
+
instance: "Segment",
|
|
127
|
+
validated_data: dict[str, Any],
|
|
128
|
+
) -> "Segment":
|
|
129
|
+
# use the initial data since we need the ids included to determine which to update & which to create
|
|
130
|
+
rules_data = self.initial_data.pop("rules", [])
|
|
131
|
+
metadata_data = validated_data.pop("metadata", [])
|
|
132
|
+
self.validate_segment_rules_conditions_limit(rules_data)
|
|
133
|
+
|
|
134
|
+
# Create a version of the segment now that we're updating.
|
|
135
|
+
cloned_segment = instance.deep_clone()
|
|
136
|
+
logger.info(
|
|
137
|
+
f"Updating cloned segment {cloned_segment.id} for original segment {instance.id}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
self._update_segment_rules(rules_data, segment=instance)
|
|
142
|
+
self._update_or_create_metadata(metadata_data, segment=instance)
|
|
143
|
+
|
|
144
|
+
# remove rules from validated data to prevent error trying to create segment with nested rules
|
|
145
|
+
del validated_data["rules"]
|
|
146
|
+
response = super().update(instance, validated_data)
|
|
147
|
+
except Exception:
|
|
148
|
+
# Since there was a problem during the update we now delete the cloned segment,
|
|
149
|
+
# since we no longer need a versioned segment.
|
|
150
|
+
instance.refresh_from_db()
|
|
151
|
+
instance.version = cloned_segment.version
|
|
152
|
+
instance.save()
|
|
153
|
+
cloned_segment.hard_delete()
|
|
154
|
+
raise
|
|
155
|
+
|
|
156
|
+
return response
|
|
157
|
+
|
|
158
|
+
def validate_project_segment_limit(self, project: "Project") -> None:
|
|
159
|
+
if (
|
|
160
|
+
apps.get_model("segments", "Segment")
|
|
161
|
+
.live_objects.filter(project=project)
|
|
162
|
+
.count()
|
|
163
|
+
>= project.max_segments_allowed
|
|
164
|
+
):
|
|
165
|
+
raise ValidationError(
|
|
166
|
+
{
|
|
167
|
+
"project": "The project has reached the maximum allowed segments limit."
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def validate_segment_rules_conditions_limit(
|
|
172
|
+
self, rules_data: list[dict[str, Any]]
|
|
173
|
+
) -> None:
|
|
174
|
+
if self.instance and getattr(self.instance, "whitelisted_segment", None):
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
count = self._calculate_condition_count(rules_data)
|
|
178
|
+
|
|
179
|
+
if count > settings.SEGMENT_RULES_CONDITIONS_LIMIT:
|
|
180
|
+
raise ValidationError(
|
|
181
|
+
{
|
|
182
|
+
"segment": f"The segment has {count} conditions, which exceeds the maximum "
|
|
183
|
+
f"condition count of {settings.SEGMENT_RULES_CONDITIONS_LIMIT}."
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _calculate_condition_count(
|
|
188
|
+
self,
|
|
189
|
+
rules_data: list[dict[str, Any]],
|
|
190
|
+
) -> int:
|
|
191
|
+
count: int = 0
|
|
192
|
+
|
|
193
|
+
for rule_data in rules_data:
|
|
194
|
+
child_rules: list[dict[str, Any]] = rule_data.get("rules", [])
|
|
195
|
+
if child_rules:
|
|
196
|
+
count += self._calculate_condition_count(child_rules)
|
|
197
|
+
conditions: list[dict[str, Any]] = rule_data.get("conditions", [])
|
|
198
|
+
for condition in conditions:
|
|
199
|
+
if condition.get("delete", False) is True:
|
|
200
|
+
continue
|
|
201
|
+
count += 1
|
|
202
|
+
return count
|
|
203
|
+
|
|
204
|
+
def _update_segment_rules(
|
|
205
|
+
self,
|
|
206
|
+
rules_data: list[dict[str, Any]],
|
|
207
|
+
segment: "Segment | None" = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Since we don't have a unique identifier for the rules / conditions for the update, we assume that the client
|
|
211
|
+
passes up the new configuration for the rules of the segment and simply wipe the old ones and create new ones
|
|
212
|
+
"""
|
|
213
|
+
Segment = apps.get_model("segments", "Segment")
|
|
214
|
+
|
|
215
|
+
# traverse the rules / conditions tree - if no ids are provided, then maintain the previous behaviour (clear
|
|
216
|
+
# existing rules and create the ones that were sent)
|
|
217
|
+
# note: we do this to preserve backwards compatibility after adding logic to include the id in requests
|
|
218
|
+
if not Segment.id_exists_in_rules_data(rules_data):
|
|
219
|
+
assert segment
|
|
220
|
+
segment.rules.set([])
|
|
221
|
+
|
|
222
|
+
self._update_or_create_segment_rules(rules_data, segment=segment)
|
|
223
|
+
|
|
224
|
+
def _update_or_create_segment_rules(
|
|
225
|
+
self,
|
|
226
|
+
rules_data: list[dict[str, Any]],
|
|
227
|
+
segment: "Segment | None" = None,
|
|
228
|
+
rule: "Rule | None" = None,
|
|
229
|
+
is_create: bool = False,
|
|
230
|
+
) -> None:
|
|
231
|
+
if all(x is None for x in {segment, rule}):
|
|
232
|
+
raise RuntimeError("Can't create rule without parent segment or rule")
|
|
233
|
+
|
|
234
|
+
for rule_data in rules_data:
|
|
235
|
+
child_rules = rule_data.pop("rules", [])
|
|
236
|
+
conditions = rule_data.pop("conditions", [])
|
|
237
|
+
|
|
238
|
+
child_rule = self._update_or_create_segment_rule(
|
|
239
|
+
rule_data, segment=segment, rule=rule
|
|
240
|
+
)
|
|
241
|
+
if not child_rule:
|
|
242
|
+
# child rule was deleted
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
self._update_or_create_conditions(
|
|
246
|
+
conditions, child_rule, is_create=is_create, segment=segment
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
self._update_or_create_segment_rules(
|
|
250
|
+
child_rules, rule=child_rule, is_create=is_create
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _update_or_create_metadata(
|
|
254
|
+
self,
|
|
255
|
+
metadata_data: list[dict[str, Any]],
|
|
256
|
+
segment: "Segment | None" = None,
|
|
257
|
+
) -> None:
|
|
258
|
+
Metadata = apps.get_model("metadata", "Metadata")
|
|
259
|
+
Segment = apps.get_model("segments", "Segment")
|
|
260
|
+
assert segment
|
|
261
|
+
if len(metadata_data) == 0:
|
|
262
|
+
Metadata.objects.filter(object_id=segment.id).delete()
|
|
263
|
+
return
|
|
264
|
+
if metadata_data is not None:
|
|
265
|
+
for metadata_item in metadata_data:
|
|
266
|
+
metadata_model_field = metadata_item.pop("model_field", None)
|
|
267
|
+
if metadata_item.get("delete"):
|
|
268
|
+
Metadata.objects.filter(model_field=metadata_model_field).delete()
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
Metadata.objects.update_or_create(
|
|
272
|
+
model_field=metadata_model_field,
|
|
273
|
+
defaults={
|
|
274
|
+
**metadata_item,
|
|
275
|
+
"content_type": ContentType.objects.get_for_model(Segment),
|
|
276
|
+
"object_id": segment.id,
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _update_or_create_segment_rule(
|
|
282
|
+
rule_data: dict[str, Any],
|
|
283
|
+
segment: "Segment | None" = None,
|
|
284
|
+
rule: "Rule | None" = None,
|
|
285
|
+
) -> "SegmentRule_ | None":
|
|
286
|
+
SegmentRule = apps.get_model("segments", "SegmentRule")
|
|
287
|
+
rule_id = rule_data.pop("id", None)
|
|
288
|
+
if rule_id is not None:
|
|
289
|
+
segment_rule: "SegmentRule_" = SegmentRule.objects.get(id=rule_id)
|
|
290
|
+
assert rule
|
|
291
|
+
matching_segment = segment or rule.get_segment()
|
|
292
|
+
|
|
293
|
+
if segment_rule.get_segment() != matching_segment:
|
|
294
|
+
raise ValidationError({"segment": "Mismatched segment is not allowed"})
|
|
295
|
+
|
|
296
|
+
if rule_data.get("delete"):
|
|
297
|
+
SegmentRule.objects.filter(id=rule_id).delete()
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
segment_rule, _ = SegmentRule.objects.update_or_create(
|
|
301
|
+
id=rule_id, defaults={"segment": segment, "rule": rule, **rule_data}
|
|
302
|
+
)
|
|
303
|
+
return segment_rule
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _update_or_create_conditions(
|
|
307
|
+
conditions_data: list[dict[str, Any]],
|
|
308
|
+
rule: "Rule",
|
|
309
|
+
segment: models.Model | None = None,
|
|
310
|
+
is_create: bool = False,
|
|
311
|
+
) -> None:
|
|
312
|
+
Condition = apps.get_model("segments", "Condition")
|
|
313
|
+
for condition_data in conditions_data:
|
|
314
|
+
condition_id = condition_data.pop("id", None)
|
|
315
|
+
if condition_id is not None:
|
|
316
|
+
condition = Condition.objects.filter(id=condition_id).first()
|
|
317
|
+
if condition is None:
|
|
318
|
+
raise ValidationError(
|
|
319
|
+
{"condition": "Condition can't be found and is likely deleted"}
|
|
320
|
+
)
|
|
321
|
+
matching_segment = segment or rule.get_segment()
|
|
322
|
+
if condition._get_segment() != matching_segment:
|
|
323
|
+
raise ValidationError(
|
|
324
|
+
{"segment": "Mismatched segment is not allowed"}
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if condition_data.get("delete"):
|
|
328
|
+
Condition.objects.filter(id=condition_id).delete()
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
Condition.objects.update_or_create(
|
|
332
|
+
id=condition_id,
|
|
333
|
+
defaults={
|
|
334
|
+
**condition_data,
|
|
335
|
+
"created_with_segment": is_create,
|
|
336
|
+
"rule": rule,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import Generator
|
|
2
|
+
|
|
3
|
+
import prometheus_client
|
|
4
|
+
import pytest
|
|
5
|
+
from prometheus_client.metrics import MetricWrapperBase
|
|
6
|
+
|
|
7
|
+
from common.test_tools.types import AssertMetricFixture
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def assert_metric_impl() -> Generator[AssertMetricFixture, None, None]:
|
|
11
|
+
registry = prometheus_client.REGISTRY
|
|
12
|
+
collectors = [*registry._collector_to_names]
|
|
13
|
+
|
|
14
|
+
def _assert_metric(
|
|
15
|
+
*,
|
|
16
|
+
name: str,
|
|
17
|
+
labels: dict[str, str],
|
|
18
|
+
value: float | int,
|
|
19
|
+
) -> None:
|
|
20
|
+
metric_value = registry.get_sample_value(name, labels)
|
|
21
|
+
assert metric_value == value, (
|
|
22
|
+
f"Metric {name} not found in registry:\n"
|
|
23
|
+
f"{prometheus_client.generate_latest(registry).decode()}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
yield _assert_metric
|
|
27
|
+
|
|
28
|
+
# Reset registry state
|
|
29
|
+
for collector in collectors:
|
|
30
|
+
if isinstance(collector, MetricWrapperBase):
|
|
31
|
+
collector.clear()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
assert_metric = pytest.fixture(assert_metric_impl)
|
common/types.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
if typing.TYPE_CHECKING:
|
|
4
|
+
from django.contrib.contenttypes.models import ContentType
|
|
5
|
+
from django.db import models
|
|
6
|
+
|
|
7
|
+
FeatureStateValue: typing.TypeAlias = models.Model
|
|
8
|
+
FeatureSegment: typing.TypeAlias = models.Model
|
|
9
|
+
Condition: typing.TypeAlias = models.Model
|
|
10
|
+
MultivariateFeatureStateValue: typing.TypeAlias = models.Model
|
|
11
|
+
Metadata: typing.TypeAlias = models.Model
|
|
12
|
+
Organisation: typing.TypeAlias = models.Model
|
|
13
|
+
|
|
14
|
+
class SoftDeleteExportableModel(models.Model):
|
|
15
|
+
def hard_delete(self) -> None: ...
|
|
16
|
+
|
|
17
|
+
class Segment(SoftDeleteExportableModel):
|
|
18
|
+
id: int
|
|
19
|
+
version: int | None
|
|
20
|
+
rules = models.ForeignKey("Rule", on_delete=models.CASCADE)
|
|
21
|
+
|
|
22
|
+
def deep_clone(self) -> "Segment": ...
|
|
23
|
+
|
|
24
|
+
class Rule(models.Model):
|
|
25
|
+
def get_segment(self) -> Segment: ...
|
|
26
|
+
|
|
27
|
+
class SegmentRule(Rule):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
class Project(models.Model):
|
|
31
|
+
organisation: "Organisation"
|
|
32
|
+
max_segments_allowed: int
|
|
33
|
+
|
|
34
|
+
class MetadataField(models.Model):
|
|
35
|
+
name: str
|
|
36
|
+
organisation: "Organisation"
|
|
37
|
+
|
|
38
|
+
class MetadataModelField(models.Model):
|
|
39
|
+
field: "MetadataField"
|
|
40
|
+
content_type: ContentType
|
|
41
|
+
|
|
42
|
+
class MetadataModelFieldRequirement(models.Model):
|
|
43
|
+
model_field: "MetadataModelField"
|
|
44
|
+
object_id: int
|
|
45
|
+
content_type: ContentType
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Flagsmith
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|