|
6 | 6 | from django.conf import settings |
7 | 7 | from django.contrib.contenttypes.fields import GenericRelation |
8 | 8 | from django.core.exceptions import ValidationError |
9 | | -from django.db import models |
| 9 | +from django.db import models, transaction |
10 | 10 | from django_lifecycle import ( # type: ignore[import-untyped] |
11 | 11 | AFTER_CREATE, |
12 | 12 | BEFORE_CREATE, |
@@ -114,35 +114,72 @@ def set_version_of_to_self_if_none(self): # type: ignore[no-untyped-def] |
114 | 114 | self.version_of = self |
115 | 115 | self.save_without_historical_record() |
116 | 116 |
|
117 | | - def _clone_segment_rules(self, cloned_segment: "Segment") -> list["SegmentRule"]: |
118 | | - cloned_rules = [] |
119 | | - for rule in self.rules.all(): |
120 | | - cloned_rule = rule.deep_clone(cloned_segment) |
121 | | - cloned_rules.append(cloned_rule) |
122 | | - cloned_segment.refresh_from_db() |
123 | | - assert ( |
124 | | - len(self.rules.all()) |
125 | | - == len(cloned_rules) |
126 | | - == len(cloned_segment.rules.all()) |
127 | | - ), "Mismatch during rules creation" |
128 | | - |
129 | | - return cloned_rules |
130 | | - |
131 | | - # TODO: To be depreacted in flagsmith-common and flagsmith-workflows |
132 | | - def deep_clone(self) -> "Segment": |
| 117 | + @transaction.atomic |
| 118 | + def clone(self, is_revision: bool = False, **extra_attrs: typing.Any) -> "Segment": |
| 119 | + """ |
| 120 | + Create a revision of the segment |
| 121 | + """ |
133 | 122 | cloned_segment = deepcopy(self) |
134 | | - cloned_segment.id = None |
| 123 | + cloned_segment.pk = None |
135 | 124 | cloned_segment.uuid = uuid.uuid4() |
136 | | - cloned_segment.version_of = self |
| 125 | + cloned_segment.version_of = None # Unset for now |
| 126 | + cloned_segment.version = 0 # Unset for now |
| 127 | + for attr_name, value in extra_attrs.items(): |
| 128 | + setattr(cloned_segment, attr_name, value) |
137 | 129 | cloned_segment.save() |
138 | 130 |
|
139 | | - self.version += 1 # type: ignore[operator] |
140 | | - self.save_without_historical_record() |
| 131 | + cloned_segment.copy_rules_and_conditions_from(self) |
141 | 132 |
|
142 | | - self._clone_segment_rules(cloned_segment) |
| 133 | + # Handle versioning |
| 134 | + version_of = self if is_revision else cloned_segment |
| 135 | + cloned_segment.version_of = extra_attrs.get("version_of", version_of) |
| 136 | + cloned_segment.version = self.version if is_revision else 1 |
| 137 | + Segment.objects.filter(pk=cloned_segment.pk).update( |
| 138 | + version_of=cloned_segment.version_of, |
| 139 | + version=cloned_segment.version, |
| 140 | + ) |
| 141 | + |
| 142 | + # Increase self version |
| 143 | + if is_revision: |
| 144 | + self.version = (self.version or 1) + 1 |
| 145 | + Segment.objects.filter(pk=self.pk).update(version=self.version) |
143 | 146 |
|
144 | 147 | return cloned_segment |
145 | 148 |
|
| 149 | + def copy_rules_and_conditions_from(self, source_segment: "Segment") -> None: |
| 150 | + """ |
| 151 | + Recursively copy rules and conditions from another segment |
| 152 | + """ |
| 153 | + assert transaction.get_connection().in_atomic_block, "Must run in a transaction" |
| 154 | + |
| 155 | + # Delete existing rules |
| 156 | + SegmentRule.objects.filter(segment=self).delete() |
| 157 | + |
| 158 | + source_rules = SegmentRule.objects.filter( |
| 159 | + models.Q(segment=source_segment) | models.Q(rule__segment=source_segment) |
| 160 | + ) |
| 161 | + |
| 162 | + # Ensure top-level rules are cloned first (for dependencies) |
| 163 | + source_rules = source_rules.order_by(models.F("rule").asc(nulls_first=True)) |
| 164 | + |
| 165 | + rule_to_cloned_rule_map: dict[SegmentRule, SegmentRule] = {} |
| 166 | + for rule in source_rules: |
| 167 | + cloned_rule = deepcopy(rule) |
| 168 | + cloned_rule.pk = None |
| 169 | + cloned_rule.uuid = uuid.uuid4() |
| 170 | + cloned_rule.segment = self if rule.segment else None |
| 171 | + cloned_rule.rule = rule_to_cloned_rule_map.get(rule.rule) |
| 172 | + cloned_rule.save() |
| 173 | + rule_to_cloned_rule_map[rule] = cloned_rule |
| 174 | + |
| 175 | + source_conditions = Condition.objects.filter(rule__in=rule_to_cloned_rule_map) |
| 176 | + for condition in source_conditions: |
| 177 | + cloned_condition = deepcopy(condition) |
| 178 | + cloned_condition.pk = None |
| 179 | + cloned_condition.uuid = uuid.uuid4() |
| 180 | + cloned_condition.rule = rule_to_cloned_rule_map[condition.rule] |
| 181 | + cloned_condition.save() |
| 182 | + |
146 | 183 | def get_create_log_message(self, history_instance) -> typing.Optional[str]: # type: ignore[no-untyped-def] |
147 | 184 | return SEGMENT_CREATED_MESSAGE % self.name |
148 | 185 |
|
@@ -180,94 +217,28 @@ class SegmentRule( |
180 | 217 |
|
181 | 218 | history_record_class_path = "segments.models.HistoricalSegmentRule" |
182 | 219 |
|
183 | | - def clean(self): # type: ignore[no-untyped-def] |
184 | | - super().clean() |
185 | | - parents = [self.segment, self.rule] |
186 | | - num_parents = sum(parent is not None for parent in parents) |
187 | | - if num_parents != 1: |
188 | | - raise ValidationError( |
189 | | - "Segment rule must have exactly one parent, %d found", |
190 | | - num_parents, # type: ignore[arg-type] |
191 | | - ) |
192 | | - |
193 | 220 | def __str__(self): # type: ignore[no-untyped-def] |
194 | 221 | return "%s rule for %s" % ( |
195 | 222 | self.type, |
196 | 223 | str(self.segment) if self.segment else str(self.rule), |
197 | 224 | ) |
198 | 225 |
|
199 | | - def get_skip_create_audit_log(self) -> bool: |
200 | | - try: |
201 | | - segment = self.get_segment() # type: ignore[no-untyped-call] |
202 | | - if segment.deleted_at: |
203 | | - return True |
204 | | - return segment.version_of_id != segment.id # type: ignore[no-any-return] |
205 | | - except (Segment.DoesNotExist, SegmentRule.DoesNotExist): |
206 | | - # handle hard delete |
207 | | - return True |
208 | | - |
209 | | - def _get_project(self) -> typing.Optional[Project]: |
210 | | - return self.get_segment().project # type: ignore[no-untyped-call,no-any-return] |
211 | | - |
212 | | - def get_segment(self): # type: ignore[no-untyped-def] |
213 | | - """ |
214 | | - rules can be a child of a parent rule instead of a segment, this method iterates back up the tree to find the |
215 | | - segment |
216 | | -
|
217 | | - TODO: denormalise the segment information so that we don't have to make multiple queries here in complex cases |
218 | | - """ |
219 | | - rule = self |
220 | | - while not rule.segment_id: |
221 | | - rule = rule.rule # type: ignore[assignment] |
222 | | - return rule.segment |
223 | | - |
224 | | - def deep_clone(self, cloned_segment: Segment) -> "SegmentRule": |
225 | | - if self.rule: |
226 | | - # Since we're expecting a rule that is only belonging to a |
227 | | - # segment, since a rule either belongs to a segment xor belongs |
228 | | - # to a rule, we don't expect there also to be a rule associated. |
229 | | - assert False, "Unexpected rule, expecting segment set not rule" |
230 | | - cloned_rule = deepcopy(self) |
231 | | - cloned_rule.segment = cloned_segment |
232 | | - cloned_rule.uuid = uuid.uuid4() |
233 | | - cloned_rule.id = None |
234 | | - cloned_rule.save() |
235 | | - logger.info( |
236 | | - f"Deep copying rule {self.id} for cloned rule {cloned_rule.id} for cloned segment {cloned_segment.id}" |
237 | | - ) |
| 226 | + def clean(self) -> None: |
| 227 | + super().clean() |
| 228 | + self._validate_one_parent() |
238 | 229 |
|
239 | | - # Conditions are only part of the sub-rules. |
240 | | - assert self.conditions.exists() is False |
241 | | - |
242 | | - for sub_rule in self.rules.all(): |
243 | | - if sub_rule.rules.exists(): |
244 | | - assert False, "Expected two layers of rules, not more" |
245 | | - |
246 | | - cloned_sub_rule = deepcopy(sub_rule) |
247 | | - cloned_sub_rule.rule = cloned_rule |
248 | | - cloned_sub_rule.uuid = uuid.uuid4() |
249 | | - cloned_sub_rule.id = None |
250 | | - cloned_sub_rule.save() |
251 | | - logger.info( |
252 | | - f"Deep copying sub rule {sub_rule.id} for cloned sub rule {cloned_sub_rule.id} " |
253 | | - f"for cloned segment {cloned_segment.id}" |
| 230 | + def _validate_one_parent(self) -> None: |
| 231 | + parents = [self.segment, self.rule] |
| 232 | + if (num_parents := sum(parent is not None for parent in parents)) != 1: |
| 233 | + raise ValidationError( |
| 234 | + f"SegmentRule must have exactly one parent, {num_parents} found" |
254 | 235 | ) |
255 | 236 |
|
256 | | - cloned_conditions = [] |
257 | | - for condition in sub_rule.conditions.all(): |
258 | | - cloned_condition = deepcopy(condition) |
259 | | - cloned_condition.rule = cloned_sub_rule |
260 | | - cloned_condition.uuid = uuid.uuid4() |
261 | | - cloned_condition.id = None |
262 | | - cloned_conditions.append(cloned_condition) |
263 | | - logger.info( |
264 | | - f"Cloning condition {condition.id} for cloned condition {cloned_condition.uuid} " |
265 | | - f"for cloned segment {cloned_segment.id}" |
266 | | - ) |
267 | | - |
268 | | - Condition.objects.bulk_create(cloned_conditions) |
269 | | - |
270 | | - return cloned_rule |
| 237 | + def get_skip_create_audit_log(self) -> bool: |
| 238 | + # NOTE: We'll transition to storing rules and conditions in JSON so |
| 239 | + # individual audit logs for rules and conditions is irrelevant. |
| 240 | + # This model will be deleted as of https://github.com/Flagsmith/flagsmith/issues/5846 |
| 241 | + return True |
271 | 242 |
|
272 | 243 |
|
273 | 244 | class ConditionManager(SoftDeleteExportableManager): |
@@ -330,52 +301,31 @@ class Condition( |
330 | 301 |
|
331 | 302 | objects: typing.ClassVar[ConditionManager] = ConditionManager() |
332 | 303 |
|
333 | | - def __str__(self): # type: ignore[no-untyped-def] |
| 304 | + def __str__(self) -> str: |
334 | 305 | return "Condition for %s: %s %s %s" % ( |
335 | 306 | str(self.rule), |
336 | 307 | self.property, |
337 | 308 | self.operator, |
338 | 309 | self.value, |
339 | 310 | ) |
340 | 311 |
|
341 | | - def get_skip_create_audit_log(self) -> bool: |
342 | | - try: |
343 | | - if self.rule.deleted_at: |
344 | | - return True |
345 | | - |
346 | | - segment = self.rule.get_segment() # type: ignore[no-untyped-call] |
347 | | - if segment.deleted_at: |
348 | | - return True |
| 312 | + def get_skip_create_audit_log(self) -> bool: # pragma: no cover |
| 313 | + # NOTE: We'll transition to storing rules and conditions in JSON so |
| 314 | + # individual audit logs for rules and conditions is irrelevant. |
| 315 | + # This model will be deleted as of https://github.com/Flagsmith/flagsmith/issues/5846 |
| 316 | + return True |
349 | 317 |
|
350 | | - return segment.version_of_id != segment.id # type: ignore[no-any-return] |
351 | | - except (Segment.DoesNotExist, SegmentRule.DoesNotExist): |
352 | | - # handle hard delete |
353 | | - return True |
| 318 | + def get_update_log_message(self, _: typing.Any) -> None: # pragma: no cover |
| 319 | + return None |
354 | 320 |
|
355 | | - def get_update_log_message(self, history_instance) -> typing.Optional[str]: # type: ignore[no-untyped-def] |
356 | | - return f"Condition updated on segment '{self._get_segment().name}'." |
| 321 | + def get_create_log_message(self, _: typing.Any) -> None: # pragma: no cover |
| 322 | + return None |
357 | 323 |
|
358 | | - def get_create_log_message(self, history_instance) -> typing.Optional[str]: # type: ignore[no-untyped-def,return] |
359 | | - if not self.created_with_segment: |
360 | | - return f"Condition added to segment '{self._get_segment().name}'." |
361 | | - |
362 | | - def get_delete_log_message(self, history_instance) -> typing.Optional[str]: # type: ignore[no-untyped-def,return] |
363 | | - if not self._get_segment().deleted_at: |
364 | | - return f"Condition removed from segment '{self._get_segment().name}'." |
365 | | - |
366 | | - def get_audit_log_related_object_id(self, history_instance) -> int: # type: ignore[no-untyped-def] |
367 | | - return self._get_segment().id |
368 | | - |
369 | | - def _get_segment(self) -> Segment: |
370 | | - """ |
371 | | - Temporarily cache the segment on the condition object to reduce number of queries. |
372 | | - """ |
373 | | - if not hasattr(self, "segment"): |
374 | | - setattr(self, "segment", self.rule.get_segment()) # type: ignore[no-untyped-call] |
375 | | - return self.segment # type: ignore[no-any-return] |
| 324 | + def get_delete_log_message(self, _: typing.Any) -> None: # pragma: no cover |
| 325 | + return None |
376 | 326 |
|
377 | | - def _get_project(self) -> typing.Optional[Project]: |
378 | | - return self.rule.get_segment().project # type: ignore[no-untyped-call,no-any-return] |
| 327 | + def get_audit_log_related_object_id(self, _: typing.Any) -> int: # pragma: no cover |
| 328 | + raise NotImplementedError("No longer used, will be removed soon.") |
379 | 329 |
|
380 | 330 |
|
381 | 331 | class WhitelistedSegment(models.Model): |
|
0 commit comments