-
Notifications
You must be signed in to change notification settings - Fork 406
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: Create versioning for segments #4138
Changes from all commits
2ad4381
f6746e1
8df7f91
a582253
730d064
42f93a9
c7eb529
f06cb3d
641c02d
d40d5ed
7341357
41f086d
ed56775
922001d
4842576
b6bbf72
8ed85c4
a37def6
934d2cd
17b2c9d
558bc40
c76211d
1eb9469
7bb7384
ec6f40b
4481e4b
669a079
34e7e46
8f38dcd
e5c7b47
70e974a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
class SegmentAuditLogHelper: | ||
def __init__(self) -> None: | ||
self.skip_audit_log = {} | ||
|
||
def should_skip_audit_log(self, segment_id: int) -> None | bool: | ||
return self.skip_audit_log.get(segment_id) | ||
|
||
def set_skip_audit_log(self, segment_id: int) -> None: | ||
self.skip_audit_log[segment_id] = True | ||
|
||
def unset_skip_audit_log(self, segment_id: int) -> None: | ||
if segment_id in self.skip_audit_log: | ||
del self.skip_audit_log[segment_id] | ||
|
||
|
||
segment_audit_log_helper = SegmentAuditLogHelper() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
from core.models import SoftDeleteExportableManager | ||
from django.db.models import F | ||
|
||
|
||
class SegmentManager(SoftDeleteExportableManager): | ||
def get_queryset(self): | ||
""" | ||
Returns only the canonical segments, which will always be | ||
the highest version. | ||
""" | ||
return super().get_queryset().filter(id=F("version_of")) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
# Generated by Django 3.2.25 on 2024-06-10 15:31 | ||
import os | ||
|
||
import django.db.models.deletion | ||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
("segments", "0022_add_soft_delete_to_segment_rules_and_conditions"), | ||
] | ||
|
||
operations = [ | ||
migrations.AddField( | ||
model_name="historicalsegment", | ||
name="version", | ||
field=models.IntegerField(null=True), | ||
), | ||
migrations.AddField( | ||
model_name="historicalsegment", | ||
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.segment", | ||
), | ||
), | ||
migrations.AddField( | ||
model_name="segment", | ||
name="version", | ||
field=models.IntegerField(null=True), | ||
), | ||
migrations.AddField( | ||
model_name="segment", | ||
name="version_of", | ||
field=models.ForeignKey( | ||
blank=True, | ||
null=True, | ||
on_delete=django.db.models.deletion.CASCADE, | ||
related_name="versioned_segments", | ||
to="segments.segment", | ||
), | ||
), | ||
migrations.RunSQL( | ||
sql=open( | ||
os.path.join( | ||
os.path.dirname(__file__), | ||
"sql", | ||
"0023_add_versioning_to_segments.sql", | ||
) | ||
).read(), | ||
reverse_sql=migrations.RunSQL.noop, | ||
), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
UPDATE segments_segment SET version_of_id = id; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,15 +1,23 @@ | ||
import logging | ||
import typing | ||
import uuid | ||
from copy import deepcopy | ||
|
||
from core.models import ( | ||
SoftDeleteExportableManager, | ||
SoftDeleteExportableModel, | ||
abstract_base_auditable_model_factory, | ||
) | ||
from django.conf import settings | ||
from django.contrib.contenttypes.fields import GenericRelation | ||
from django.core.exceptions import ValidationError | ||
from django.db import models | ||
from django_lifecycle import ( | ||
AFTER_CREATE, | ||
BEFORE_CREATE, | ||
LifecycleModelMixin, | ||
hook, | ||
) | ||
from flag_engine.segments import constants | ||
|
||
from audit.constants import ( | ||
|
@@ -22,10 +30,14 @@ | |
from metadata.models import Metadata | ||
from projects.models import Project | ||
|
||
from .helpers import segment_audit_log_helper | ||
from .managers import SegmentManager | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class Segment( | ||
LifecycleModelMixin, | ||
SoftDeleteExportableModel, | ||
abstract_base_auditable_model_factory(["uuid"]), | ||
): | ||
|
@@ -45,14 +57,44 @@ class Segment( | |
Feature, on_delete=models.CASCADE, related_name="segments", null=True | ||
) | ||
|
||
# This defaults to 1 for newly created segments. | ||
version = models.IntegerField(null=True) | ||
|
||
# The related_name is not useful without specifying all_objects as a manager. | ||
version_of = models.ForeignKey( | ||
"self", | ||
on_delete=models.CASCADE, | ||
related_name="versioned_segments", | ||
null=True, | ||
blank=True, | ||
) | ||
metadata = GenericRelation(Metadata) | ||
|
||
# Only serves segments that are the canonical version. | ||
objects = SegmentManager() | ||
|
||
# Includes versioned segments. | ||
all_objects = SoftDeleteExportableManager() | ||
|
||
class Meta: | ||
ordering = ("id",) # explicit ordering to prevent pagination warnings | ||
|
||
def __str__(self): | ||
return "Segment - %s" % self.name | ||
|
||
def get_skip_create_audit_log(self) -> bool: | ||
skip = segment_audit_log_helper.should_skip_audit_log(self.id) | ||
if skip is not None: | ||
return skip | ||
|
||
try: | ||
if self.version_of_id and self.version_of_id != self.id: | ||
return True | ||
except Segment.DoesNotExist: | ||
return True | ||
|
||
return False | ||
|
||
@staticmethod | ||
def id_exists_in_rules_data(rules_data: typing.List[dict]) -> bool: | ||
""" | ||
|
@@ -84,6 +126,49 @@ def id_exists_in_rules_data(rules_data: typing.List[dict]) -> bool: | |
|
||
return False | ||
|
||
@hook(BEFORE_CREATE, when="version_of", is_now=None) | ||
def set_default_version_to_one_if_new_segment(self): | ||
if self.version is None: | ||
self.version = 1 | ||
|
||
@hook(AFTER_CREATE, when="version_of", is_now=None) | ||
def set_version_of_to_self_if_none(self): | ||
""" | ||
This allows the segment model to reference all versions of | ||
itself including itself. | ||
""" | ||
segment_audit_log_helper.set_skip_audit_log(self.id) | ||
self.version_of = self | ||
self.save() | ||
zachaysan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
segment_audit_log_helper.unset_skip_audit_log(self.id) | ||
|
||
def deep_clone(self) -> "Segment": | ||
cloned_segment = deepcopy(self) | ||
cloned_segment.id = None | ||
cloned_segment.uuid = uuid.uuid4() | ||
cloned_segment.version_of = self | ||
cloned_segment.save() | ||
|
||
segment_audit_log_helper.set_skip_audit_log(self.id) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Couldn't (shouldn't) we resort to overriding the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't want to run the save hook every time with these changes so I think they are better in-place. |
||
self.version += 1 | ||
self.save() | ||
segment_audit_log_helper.unset_skip_audit_log(self.id) | ||
|
||
cloned_rules = [] | ||
for rule in self.rules.all(): | ||
cloned_rule = rule.deep_clone(cloned_segment) | ||
cloned_rules.append(cloned_rule) | ||
|
||
cloned_segment.refresh_from_db() | ||
|
||
assert ( | ||
len(self.rules.all()) | ||
== len(cloned_rules) | ||
== len(cloned_segment.rules.all()) | ||
), "Mismatch during rules creation" | ||
|
||
return cloned_segment | ||
|
||
def get_create_log_message(self, history_instance) -> typing.Optional[str]: | ||
return SEGMENT_CREATED_MESSAGE % self.name | ||
|
||
|
@@ -128,6 +213,10 @@ def __str__(self): | |
str(self.segment) if self.segment else str(self.rule), | ||
) | ||
|
||
def get_skip_create_audit_log(self) -> bool: | ||
segment = self.get_segment() | ||
return segment.version_of_id != segment.id | ||
|
||
def get_segment(self): | ||
""" | ||
rules can be a child of a parent rule instead of a segment, this method iterates back up the tree to find the | ||
|
@@ -136,10 +225,46 @@ def get_segment(self): | |
TODO: denormalise the segment information so that we don't have to make multiple queries here in complex cases | ||
""" | ||
rule = self | ||
while not rule.segment: | ||
while not rule.segment_id: | ||
rule = rule.rule | ||
return rule.segment | ||
|
||
def deep_clone(self, cloned_segment: Segment) -> "SegmentRule": | ||
if self.rule: | ||
# Since we're expecting a rule that is only belonging to a | ||
# segment, since a rule either belongs to a segment xor belongs | ||
# to a rule, we don't expect there also to be a rule associated. | ||
assert False, "Unexpected rule, expecting segment set not rule" | ||
zachaysan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
cloned_rule = deepcopy(self) | ||
cloned_rule.segment = cloned_segment | ||
cloned_rule.uuid = uuid.uuid4() | ||
cloned_rule.id = None | ||
cloned_rule.save() | ||
|
||
# Conditions are only part of the sub-rules. | ||
assert self.conditions.exists() is False | ||
|
||
for sub_rule in self.rules.all(): | ||
if sub_rule.rules.exists(): | ||
assert False, "Expected two layers of rules, not more" | ||
|
||
cloned_sub_rule = deepcopy(sub_rule) | ||
cloned_sub_rule.rule = cloned_rule | ||
cloned_sub_rule.uuid = uuid.uuid4() | ||
cloned_sub_rule.id = None | ||
cloned_sub_rule.save() | ||
|
||
cloned_conditions = [] | ||
for condition in sub_rule.conditions.all(): | ||
cloned_condition = deepcopy(condition) | ||
cloned_condition.rule = cloned_sub_rule | ||
cloned_condition.uuid = uuid.uuid4() | ||
cloned_condition.id = None | ||
cloned_conditions.append(cloned_condition) | ||
Condition.objects.bulk_create(cloned_conditions) | ||
|
||
return cloned_rule | ||
|
||
|
||
class Condition( | ||
SoftDeleteExportableModel, abstract_base_auditable_model_factory(["uuid"]) | ||
|
@@ -188,6 +313,10 @@ def __str__(self): | |
self.value, | ||
) | ||
|
||
def get_skip_create_audit_log(self) -> bool: | ||
segment = self.rule.get_segment() | ||
return segment.version_of_id != segment.id | ||
|
||
def get_update_log_message(self, history_instance) -> typing.Optional[str]: | ||
return f"Condition updated on segment '{self._get_segment().name}'." | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After trying multiple different approaches including instance variables,
.initial_state
,.state
all of which were wiped during the save process I finally resorted to using a helper to track the state of which audit logs can be created.