diff --git a/api/poetry.lock b/api/poetry.lock index cf05d38d5831..5d5499d2d21a 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1439,8 +1439,8 @@ flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/Flagsmith/flagsmith-common" -reference = "v1.4.2" -resolved_reference = "5ac44b7c8dce96c043c9a0c38c7cbde04886fcf9" +reference = "feat/add_version_of_to_rules_and_conditions" +resolved_reference = "18ca20eac4fc192b1de0a98d642b21b9f1bc927e" [[package]] name = "flagsmith-flag-engine" @@ -4328,14 +4328,14 @@ develop = false [package.dependencies] djangorestframework = "*" djangorestframework-recursive = "*" -flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.2"} +flagsmith-common = {git = "https://github.com/Flagsmith/flagsmith-common", branch = "feat/add_version_of_to_rules_and_conditions"} flagsmith-flag-engine = "*" [package.source] type = "git" url = "https://github.com/flagsmith/flagsmith-workflows" -reference = "v2.7.6" -resolved_reference = "0d843c8879bb86ac4b726b1cb0472a5cf3cc34d4" +reference = "feat/add_matches_to_workflows" +resolved_reference = "3a9a9d9d4ffa9270eb0769f96a81c5c4df0ba0bf" [[package]] name = "wrapt" @@ -4457,4 +4457,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11, <3.13" -content-hash = "1d3c289404e691bbe2c044d0794e81a46fc982104960f1edcfd8a07c2100c49e" +content-hash = "dbe06e66324583b141e39cecaa0d22ca96c5602ad6bd5398a0344d30849b2058" diff --git a/api/pyproject.toml b/api/pyproject.toml index 38657f2aa7ce..2fd69b046561 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -171,7 +171,7 @@ hubspot-api-client = "^8.2.1" djangorestframework-dataclasses = "^1.3.1" pyotp = "^2.9.0" flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task-processor", tag = "v1.2.0" } -flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.2" } +flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", branch = "feat/add_version_of_to_rules_and_conditions" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" structlog = "^24.4.0" @@ -198,7 +198,7 @@ flagsmith-ldap = { git = "https://github.com/flagsmith/flagsmith-ldap", tag = "v optional = true [tool.poetry.group.workflows.dependencies] -workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", tag = "v2.7.6" } +workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", branch = "feat/add_matches_to_workflows" } [tool.poetry.group.licensing] optional = true diff --git a/api/segments/migrations/0027_version_rules_and_conditions.py b/api/segments/migrations/0027_version_rules_and_conditions.py new file mode 100644 index 000000000000..141390eb4b1c --- /dev/null +++ b/api/segments/migrations/0027_version_rules_and_conditions.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.17 on 2025-01-08 15:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("segments", "0026_add_change_request_to_segments"), + ] + + operations = [ + migrations.AddField( + model_name="condition", + name="version_of", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="versioned_conditions", + to="segments.condition", + ), + ), + migrations.AddField( + model_name="historicalcondition", + name="version_of", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="segments.condition", + ), + ), + migrations.AddField( + model_name="segmentrule", + name="version_of", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="versioned_rules", + to="segments.segmentrule", + ), + ), + ] diff --git a/api/segments/models.py b/api/segments/models.py index 22b8c30d19d1..6cbd571c20a0 100644 --- a/api/segments/models.py +++ b/api/segments/models.py @@ -198,6 +198,113 @@ def deep_clone(self) -> "Segment": return cloned_segment + def update_segment_with_matches_from_current_segment( + self, current_segment: "Segment" + ) -> bool: + """ + Assign matching rules of the calling object (i.e., self) to the rules + of the given segment (i.e., current_segment) in order to update the + rules, subrules, and conditions of the calling object. This is done + from the context of a change request related to the calling object. + + This method iterates through the rules of the provided `Segment` and + attempts to match them to the calling object's (i.e., self) rules and + sub-rules, updating versioning where matches are found. + + This is done in order to set the `version_of` field for matched rules + and conditions so that the frontend can more reliably diff rules and + conditions between a change request for a the current segment and the + calling object (i.e., self) itself. + + Args: + current_segment (Segment): The segment whose rules are being matched + against the current object's rules which + will have its rules and conditions updated. + + Returns: + bool: + - `True` if any rules or sub-rules match between the calling + object (i.e., self) and the current segment. + - `False` if no matches are found. + + Process: + 1. Retrieve all rules associated with the calling object and the segment. + 2. For each rule in the current segment: + - Check its sub-rules against the calling object's rules and sub-rules. + - A match is determined if the sub-rule's type and properties align + with those of the calling object's sub-rules. + - If a match is found: + - Update the `version_of` field for the matched sub-rule and rule. + - Track the matched rules and sub-rules to avoid duplicate processing. + 3. Perform a bulk update on matched rules and sub-rules to persist + versioning changes. + + Side Effects: + - Updates the `version_of` field for matched rules and sub-rules for the + calling object (i.e., self). + - Indirectly updates the `version_of` field on sub-rules' conditions. + """ + + modified_rules = self.rules.all() + matched_rules = set() + matched_sub_rules = set() + + for current_rule in current_segment.rules.all(): + for current_sub_rule in current_rule.rules.all(): + + # Because we must proceed to the next current_sub_rule + # to get the next available match since it has now been + # matched to a candidate modified_sub_rule we set the + # sub_rule_matched bool to track the state between + # iterations. Otherwise different rules would have the + # same value for their version_of field. + sub_rule_matched = False + for modified_rule in modified_rules: + if sub_rule_matched: + break + + # Because a segment's rules can only have subrules that match + # if the segment-level rules also match, we need to ensure that + # the currently matched rules correspond to the current rule. + # Consider a scenario where a subrule's version_of attribute + # points to a different subrule, whose owning rule differs + # from the subrule's sibling's parent rule. Such a mismatch + # would lead to inconsistencies and unintended behavior. + if ( + modified_rule in matched_rules + and modified_rule.version_of != current_rule + ): + continue + + # To eliminate false matches we force the types + # to be the same for the rules. + if current_rule.type != modified_rule.type: + continue + + for modified_sub_rule in modified_rule.rules.all(): + # If a subrule has already been matched, + # we avoid assigning conditions since it + # has already been handled. + if modified_sub_rule in matched_sub_rules: + continue + + # If a subrule matches, we assign the parent + # rule and the subrule together. + if modified_sub_rule.assign_conditions_if_matching_rule( + current_sub_rule + ): + modified_sub_rule.version_of = current_sub_rule + sub_rule_matched = True + matched_sub_rules.add(modified_sub_rule) + modified_rule.version_of = current_rule + matched_rules.add(modified_rule) + break + + SegmentRule.objects.bulk_update( + matched_rules | matched_sub_rules, fields=["version_of"] + ) + return bool(matched_rules | matched_sub_rules) + def get_create_log_message(self, history_instance) -> typing.Optional[str]: return SEGMENT_CREATED_MESSAGE % self.name @@ -224,6 +331,13 @@ class SegmentRule(SoftDeleteExportableModel): rule = models.ForeignKey( "self", on_delete=models.CASCADE, related_name="rules", null=True, blank=True ) + version_of = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + related_name="versioned_rules", + null=True, + blank=True, + ) type = models.CharField(max_length=50, choices=RULE_TYPES) @@ -261,6 +375,100 @@ def get_segment(self): rule = rule.rule return rule.segment + def assign_conditions_if_matching_rule( # noqa: C901 + self, current_sub_rule: "SegmentRule" + ) -> bool: + """ + Determines whether the current object matches the given rule + and assigns conditions with the `version_of` field. + + These assignments are done in order to allow the frontend to + provide a diff capability during change requests for segments. + By knowing which version a condition is for the frontend can + show a more accurate diff between the segment and the change request. + + This method compares the type and conditions of the current object with + the specified `SegmentRule` to determine if they are compatible. + + Returns: + bool: + - `True` if the calling object's (i.e., self) type matches the rule's type + and the conditions are compatible. + - `False` if the types do not match or no conditions are compatible. + + Process: + 1. If the types do not match, return `False`. + 2. If both the rule and calling object (i.e., self) have no conditions, return `True`. + 3. Compare each condition in the rule against the calling object's (i.e., self) conditions: + - First match conditions that are an exact match of property, operator, + and value. + - A condition matches if the `property` attributes are equal or if there + is no property but has a matching operator. + - Mark matched conditions and update the versioning. + 4. Return `True` if at least one condition matches; otherwise, return `False`. + + Side Effects: + Updates the `version_of` field for matched conditions using a bulk update for the + conditions of the calling object (i.e., self). + """ + + if current_sub_rule.type != self.type: + return False + + current_conditions = current_sub_rule.conditions.all() + modified_conditions = self.conditions.all() + + if not current_conditions and not modified_conditions: + # Empty rule with the same type matches. + return True + + matched_conditions = set() + + # In order to provide accurate diffs we first go through the conditions + # and collect conditions that have matching values (property, operator, value). + for current_condition in current_conditions: + for modified_condition in modified_conditions: + if modified_condition in matched_conditions: + continue + if ( + current_condition.property == modified_condition.property + and current_condition.operator == modified_condition.operator + and current_condition.value == modified_condition.value + ): + matched_conditions.add(modified_condition) + modified_condition.version_of = current_condition + break + + # Next we go through the collection again and collect matching conditions + # with special logic to collect conditions that have no properties based + # on their operator equivalence. + for current_condition in current_conditions: + for modified_condition in modified_conditions: + if modified_condition in matched_conditions: + continue + if not current_condition.property and not modified_condition.property: + if current_condition.operator == modified_condition.operator: + matched_conditions.add(modified_condition) + modified_condition.version_of = current_condition + break + + elif current_condition.property == modified_condition.property: + matched_conditions.add(modified_condition) + modified_condition.version_of = current_condition + break + + # If the subrule has no matching conditions we consider the response to + # be False, as the subrule could be a better match for some other candidate + # subrule, so the calling method can try the next subrule available. + if not matched_conditions: + return False + + Condition.objects.bulk_update(matched_conditions, fields=["version_of"]) + + # Since the subrule has at least partial condition overlap, we return True + # for the match indicator. + return True + def deep_clone(self, cloned_segment: Segment) -> "SegmentRule": if self.rule: # Since we're expecting a rule that is only belonging to a @@ -268,6 +476,7 @@ def deep_clone(self, cloned_segment: Segment) -> "SegmentRule": # to a rule, we don't expect there also to be a rule associated. assert False, "Unexpected rule, expecting segment set not rule" cloned_rule = deepcopy(self) + cloned_rule.version_of = self cloned_rule.segment = cloned_segment cloned_rule.uuid = uuid.uuid4() cloned_rule.id = None @@ -284,6 +493,7 @@ def deep_clone(self, cloned_segment: Segment) -> "SegmentRule": assert False, "Expected two layers of rules, not more" cloned_sub_rule = deepcopy(sub_rule) + cloned_sub_rule.version_of = sub_rule cloned_sub_rule.rule = cloned_rule cloned_sub_rule.uuid = uuid.uuid4() cloned_sub_rule.id = None @@ -296,6 +506,7 @@ def deep_clone(self, cloned_segment: Segment) -> "SegmentRule": cloned_conditions = [] for condition in sub_rule.conditions.all(): cloned_condition = deepcopy(condition) + cloned_condition.version_of = condition cloned_condition.rule = cloned_sub_rule cloned_condition.uuid = uuid.uuid4() cloned_condition.id = None @@ -348,6 +559,13 @@ class Condition( rule = models.ForeignKey( SegmentRule, on_delete=models.CASCADE, related_name="conditions" ) + version_of = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + related_name="versioned_conditions", + null=True, + blank=True, + ) created_at = models.DateTimeField(null=True, auto_now_add=True) updated_at = models.DateTimeField(null=True, auto_now=True) diff --git a/api/tests/unit/segments/test_unit_segments_models.py b/api/tests/unit/segments/test_unit_segments_models.py index 60ae2750e110..7b39eb3ea589 100644 --- a/api/tests/unit/segments/test_unit_segments_models.py +++ b/api/tests/unit/segments/test_unit_segments_models.py @@ -1,7 +1,11 @@ from unittest.mock import PropertyMock import pytest -from flag_engine.segments.constants import EQUAL, PERCENTAGE_SPLIT +from flag_engine.segments.constants import ( + EQUAL, + GREATER_THAN, + PERCENTAGE_SPLIT, +) from pytest_mock import MockerFixture from features.models import Feature @@ -451,3 +455,441 @@ def test_segment_get_skip_create_audit_log_when_exception( # Then assert result is True + + +def test_saving_rule_with_version_of_set(segment: Segment) -> None: + # Given + base_rule = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) + new_segment = Segment.objects.create(name="NewSegment", project=segment.project) + + # When + rule = SegmentRule.objects.create( + segment=new_segment, type=SegmentRule.ALL_RULE, version_of=base_rule + ) + + # Then + assert rule.version_of == base_rule + + +def test_saving_condition_with_version_of_set(segment: Segment) -> None: + # Given + rule1 = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) + new_segment = Segment.objects.create(name="NewSegment", project=segment.project) + rule2 = SegmentRule.objects.create( + segment=new_segment, type=SegmentRule.ALL_RULE, version_of=rule1 + ) + condition1 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=rule1 + ) + + # When + condition2 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=rule2, version_of=condition1 + ) + + # Then + assert condition2.version_of == condition1 + + +def test_update_segment_with_matches_from_current_segment_with_two_levels_of_rules_and_two_conditions( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create two parent rules, both with the same type and then two + # matching subrules, both with matching types as well. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + rule3 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + rule4 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # Finally we create the conditions associated with the subrules with different + # values set between them and a missing description on the second condition. + condition1 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=rule3, description="SomeDescription" + ) + condition2 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.2, rule=rule4 + ) + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + condition1.refresh_from_db() + condition2.refresh_from_db() + + # Now we see that the rules and conditions match for the segment that was + # assigned to, since the condition matching logic allowed the rules to match + # as well. + assert rule1.version_of == rule2 + assert rule2.version_of is None + assert rule3.version_of == rule4 + assert rule4.version_of is None + assert condition1.version_of == condition2 + assert condition2.version_of is None + + +def test_update_segment_with_matches_from_current_segment_with_condition_operator_mismatch( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create two parent rules, both with the same type and then two + # matching subrules, both with matching types as well. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + rule3 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + rule4 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # Finally we create conditions that have matching property names with + # mismatched operators so the frontend will be able to diff even when + # a condition's operator is different. + condition1 = Condition.objects.create( + property="scale", + operator=EQUAL, + value=0.1, + rule=rule3, + description="Setting scale to equal", + ) + condition2 = Condition.objects.create( + property="scale", + operator=GREATER_THAN, + value=0.2, + rule=rule4, + description="Setting scale to greater than", + ) + + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + condition1.refresh_from_db() + condition2.refresh_from_db() + # The parent rule and subrule of the target segment match + assert rule1.version_of == rule2 + assert rule2.version_of is None + assert rule3.version_of == rule4 + assert rule4.version_of is None + # The condition with the mismatched operator matches. + assert condition1.version_of == condition2 + assert condition2.version_of is None + + +def test_update_segment_with_matches_from_current_segment_with_conditions_not_matching( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create two parent rules, both with the same type and then two + # matching subrules, both with matching types as well. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + rule3 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + rule4 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # Finally we create conditions that have mis-matched property names so + # the frontend will not to be able to diff them since there will be no match + condition1 = Condition.objects.create( + property="age", + operator=EQUAL, + value=21, + rule=rule3, + description="Setting age to equal", + ) + condition2 = Condition.objects.create( + property="scale", + operator=GREATER_THAN, + value=0.2, + rule=rule4, + description="Setting scale to greater than", + ) + + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + condition1.refresh_from_db() + condition2.refresh_from_db() + # The parent rule and subrule of the target segments do not match. + assert rule1.version_of is None + assert rule2.version_of is None + assert rule3.version_of is None + assert rule4.version_of is None + # The condition with the mismatched property also doesn't match. + assert condition1.version_of is None + assert condition2.version_of is None + + +def test_update_segment_with_matches_from_current_segment_mismatched_rule_type( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create parent rules with mismatched types with subrules with + # matching subrule types. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.NONE_RULE) + rule3 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + rule4 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # We next create matching conditions. + condition1 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=rule3, description="SomeDescription" + ) + condition2 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.2, rule=rule4 + ) + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + condition1.refresh_from_db() + condition2.refresh_from_db() + # Note that none of the rules or conditions matched because the parent rule + # had a mismatched type so the entire collection is skipped. + assert rule1.version_of is None + assert rule2.version_of is None + assert rule3.version_of is None + assert rule4.version_of is None + assert condition1.version_of is None + assert condition2.version_of is None + + +def test_update_segment_with_matches_from_current_segment_mismatched_sub_rule_type( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create matching parent rules (based on type) and mismatched + # subrules which have different types. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + rule3 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.NONE_RULE) + rule4 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # Finally we create matching conditions. + condition1 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=rule3, description="SomeDescription" + ) + condition2 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.2, rule=rule4 + ) + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + condition1.refresh_from_db() + condition2.refresh_from_db() + # See that since the subrules didn't match none of the collection is + # assigned to be a version_of. + assert rule1.version_of is None + assert rule2.version_of is None + assert rule3.version_of is None + assert rule4.version_of is None + assert condition1.version_of is None + assert condition2.version_of is None + + +def test_update_segment_with_matches_from_current_segment_multiple_sub_rules( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create two parent rules for each segment. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule3 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + rule4 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + + # We assign two rules to the first parent rule. + rule5 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + rule6 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + + # We assign two rules to the second parent rule. + rule7 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + rule8 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # We assign one rule to the third parent rule and two rules to the fourth + # parent rule. + rule9 = SegmentRule.objects.create(rule=rule3, type=SegmentRule.ALL_RULE) + rule10 = SegmentRule.objects.create(rule=rule4, type=SegmentRule.ALL_RULE) + rule11 = SegmentRule.objects.create(rule=rule4, type=SegmentRule.ALL_RULE) + + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + rule5.refresh_from_db() + rule6.refresh_from_db() + rule7.refresh_from_db() + rule8.refresh_from_db() + rule9.refresh_from_db() + rule10.refresh_from_db() + + # First we verify that the two parent rules match each other. + assert rule1.version_of == rule3 + assert rule2.version_of == rule4 + + # Next we verify that the non-targeted parent rules are not assigned. + assert rule3.version_of is None + assert rule4.version_of is None + + # Lastly we verify that our subrules are assigned to their proper targets. + assert rule5.version_of == rule9 + assert rule6.version_of is None + assert rule7.version_of == rule10 + assert rule8.version_of == rule11 + assert rule9.version_of is None + assert rule10.version_of is None + assert rule11.version_of is None + + +def test_update_segment_with_matches_from_current_segment_with_multiple_conditions( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create the parent rules for the segments and each with one subrule. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + rule3 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + rule4 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # Lastly we create two sets of conditions per subrule with matching conditions. + condition1 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=rule3, description="SomeDescription" + ) + condition2 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.2, rule=rule3 + ) + condition3 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.3, rule=rule4 + ) + condition4 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.4, rule=rule4 + ) + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + condition1.refresh_from_db() + condition2.refresh_from_db() + condition3.refresh_from_db() + condition4.refresh_from_db() + + # Now the parent rule (rule1) is set to the other parent rule and the + # subrule (rule3) is set to the other subrule with both conditions + # set to their matching conditions from the other subrule. + assert rule1.version_of == rule2 + assert rule2.version_of is None + assert rule3.version_of == rule4 + assert rule4.version_of is None + assert condition1.version_of == condition3 + assert condition2.version_of == condition4 + assert condition3.version_of is None + assert condition4.version_of is None + + +def test_update_segment_with_matches_from_current_segment_with_exact_condition_match( + project: Project, +) -> None: + # Given + # First we create our two segments, one that will be assign from the other. + segment1 = Segment.objects.create(name="Segment1", project=project) + segment2 = Segment.objects.create(name="Segment2", project=project) + + # Next we create the parent rules for the segments and each with one subrule. + rule1 = SegmentRule.objects.create(segment=segment1, type=SegmentRule.ALL_RULE) + rule2 = SegmentRule.objects.create(segment=segment2, type=SegmentRule.ALL_RULE) + rule3 = SegmentRule.objects.create(rule=rule1, type=SegmentRule.ALL_RULE) + rule4 = SegmentRule.objects.create(rule=rule2, type=SegmentRule.ALL_RULE) + + # Lastly we create a number of conditions to attempt matching to, with an exact + # matching condition present. + condition1 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, + value=0.1, + rule=rule3, + ) + condition2 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.2, rule=rule4 + ) + condition3 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=rule4 + ) + condition4 = Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.4, rule=rule4 + ) + # When + segment1.update_segment_with_matches_from_current_segment(segment2) + + # Then + rule1.refresh_from_db() + rule2.refresh_from_db() + rule3.refresh_from_db() + rule4.refresh_from_db() + condition1.refresh_from_db() + condition2.refresh_from_db() + condition3.refresh_from_db() + condition4.refresh_from_db() + + # Now the parent rule (rule1) is set to the other parent rule and the + # subrule (rule3) is set to the other subrule with the exact + # matching conditions being set. + assert rule1.version_of == rule2 + assert rule2.version_of is None + assert rule3.version_of == rule4 + assert rule4.version_of is None + assert condition1.version_of == condition3 + assert condition2.version_of is None + assert condition3.version_of is None + assert condition4.version_of is None