Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/environments/identities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
import typing

from django.db import models
from django.db.models import Q, Prefetch
from django.db.models import Prefetch, Q
from django.utils.encoding import python_2_unicode_compatible

from environments.models import Environment
from environments.identities.traits.models import Trait
from environments.models import Environment
from features.models import FeatureState
from features.multivariate.models import MultivariateFeatureStateValue

Expand Down
9 changes: 7 additions & 2 deletions api/environments/identities/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
from environments.identities.models import Identity
from environments.identities.traits.models import Trait
from environments.models import FLOAT, Environment
from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue
from features.value_types import INTEGER, STRING, BOOLEAN
from features.models import (
Feature,
FeatureSegment,
FeatureState,
FeatureStateValue,
)
from features.value_types import BOOLEAN, INTEGER, STRING
from organisations.models import Organisation
from projects.models import Project
from segments.models import (
Expand Down
2 changes: 1 addition & 1 deletion api/environments/identities/traits/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from simple_history.models import HistoricalRecords

from environments.identities.traits.exceptions import TraitPersistenceError
from environments.models import INTEGER, STRING, BOOLEAN, FLOAT
from environments.models import BOOLEAN, FLOAT, INTEGER, STRING


@python_2_unicode_compatible
Expand Down
19 changes: 15 additions & 4 deletions api/environments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,25 @@ def __str__(self):
return "Project %s - Environment %s" % (self.project.name, self.name)

def clone(self, name: str, api_key: str = None) -> "Environment":
# Creates a copy/clone of the object
"""
Creates a clone of the environment, related objects and returns the
cloned object after saving it to the database.
# NOTE: clone will not trigger create hooks
"""
clone = deepcopy(self)
# update the state to let django know that this object is not coming from database
# ref: https://docs.djangoproject.com/en/3.2/topics/db/queries/#copying-model-instances
clone._state.adding = True
clone.id = None
clone.name = name
clone.api_key = api_key if api_key else create_hash()
clone.save()
for feature_segment in self.feature_segments.all():
feature_segment.clone(clone)

# Since identities are closely tied to the enviroment
# it does not make much sense to clone them, hence
# only clone feature states without identities
for feature_state in self.feature_states.filter(identity=None):
feature_state.clone(clone)

return clone

@staticmethod
Expand Down
1 change: 0 additions & 1 deletion api/environments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ def create(self, validated_data):
name = validated_data.get("name")
source_env = validated_data.get("source_env")
clone = source_env.clone(name)
clone.save()
self._create_audit_log(clone, True)
return clone

Expand Down
30 changes: 26 additions & 4 deletions api/environments/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ def test_clone_does_not_modify_the_original_instance(self):

# When
clone = self.environment.clone(name="Cloned env")
clone.save()

# Then
self.assertNotEqual(clone.name, self.environment.name)
Expand All @@ -73,7 +72,6 @@ def test_clone_save_creates_feature_states(self):

# When
clone = self.environment.clone(name="Cloned env")
clone.save()

# Then
feature_states = FeatureState.objects.filter(environment=clone)
Expand All @@ -87,15 +85,39 @@ def test_clone_does_not_modify_source_feature_state(self):
).first()

# When
clone = self.environment.clone(name="Cloned env")
clone.save()
self.environment.clone(name="Cloned env")
source_feature_state_after_clone = FeatureState.objects.filter(
environment=self.environment
).first()

# Then
assert source_feature_state_before_clone == source_feature_state_after_clone

def test_clone_does_not_create_identity(self):
# Given
self.environment.save()
Identity.objects.create(
environment=self.environment, identifier="test_identity"
)
# When
clone = self.environment.clone(name="Cloned env")

# Then
assert clone.identities.count() == 0

def test_clone_clones_the_feature_states(self):
# Given
self.environment.save()

# Enable the feature in the source environment
self.environment.feature_states.update(enabled=True)

# When
clone = self.environment.clone(name="Cloned env")

# Then
assert clone.feature_states.first().enabled is True

@mock.patch("environments.models.environment_cache")
def test_get_from_cache_stores_environment_in_cache_on_success(self, mock_cache):
# Given
Expand Down
5 changes: 4 additions & 1 deletion api/environments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ def trait_keys(self, request, *args, **kwargs):
def clone(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(source_env=self.get_object())
clone = serializer.save(source_env=self.get_object())
UserEnvironmentPermission.objects.create(
user=self.request.user, environment=clone, admin=True
)
return Response(serializer.data, status=status.HTTP_200_OK)

@action(detail=True, methods=["POST"], url_path="delete-traits")
Expand Down
33 changes: 29 additions & 4 deletions api/features/feature_segments/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@

from environments.identities.models import Identity
from environments.identities.traits.models import Trait
from environments.models import Environment, STRING
from features.models import Feature, FeatureSegment, FeatureState, FeatureStateValue
from features.utils import INTEGER, BOOLEAN
from environments.models import STRING, Environment
from features.models import (
Feature,
FeatureSegment,
FeatureState,
FeatureStateValue,
)
from features.utils import BOOLEAN, INTEGER
from organisations.models import Organisation
from projects.models import Project
from segments.models import Segment, SegmentRule, Condition, EQUAL
from segments.models import EQUAL, Condition, Segment, SegmentRule


@pytest.mark.django_db
Expand Down Expand Up @@ -135,3 +140,23 @@ def test_feature_segments_are_created_with_correct_priority(self):
assert feature_segment_3.priority == 0
assert feature_segment_4.priority == 0
assert feature_segment_5.priority == 0

def test_clone_creates_a_new_object(self):
# Given
feature_segment = FeatureSegment.objects.create(
feature=self.remote_config,
segment=self.segment,
environment=self.environment,
priority=1,
)
new_env = Environment.objects.create(
name="Test environment New", project=self.project
)

# When
feature_segment_clone = feature_segment.clone(new_env)

# Then
assert feature_segment_clone.id != feature_segment.id
assert feature_segment_clone.priority == feature_segment.priority
Comment thread
matthewelwell marked this conversation as resolved.
assert feature_segment_clone.environment.id == new_env.id
46 changes: 42 additions & 4 deletions api/features/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
import typing
from copy import deepcopy

from django.core.exceptions import (
NON_FIELD_ERRORS,
Expand Down Expand Up @@ -48,6 +49,7 @@
logger = logging.getLogger(__name__)

if typing.TYPE_CHECKING:
from environments.models import Environment
from environments.identities.models import Identity


Expand Down Expand Up @@ -182,23 +184,31 @@ def __str__(self):
+ str(self.priority)
)

# noinspection PyTypeChecker
def get_value(self):
return get_correctly_typed_value(self.value_type, self.value)

def __lt__(self, other):
"""
Kind of counter intuitive but since priority 1 is highest, we want to check if priority is GREATER than the
priority of the other feature segment.
"""
return other and self.priority > other.priority

def clone(self, environment: "Environment") -> "FeatureSegment":
clone = deepcopy(self)
clone.id = None
clone.environment = environment
clone.save()
return clone

# noinspection PyTypeChecker
def get_value(self):
return get_correctly_typed_value(self.value_type, self.value)


@python_2_unicode_compatible
class FeatureState(LifecycleModel, models.Model):
feature = models.ForeignKey(
Feature, related_name="feature_states", on_delete=models.CASCADE
)

environment = models.ForeignKey(
"environments.Environment",
related_name="feature_states",
Expand Down Expand Up @@ -281,6 +291,27 @@ def __gt__(self, other):
# it has a feature_segment or an identity
return not (other.feature_segment or other.identity)

def clone(self, env: "Environment") -> "FeatureState":
# Clonning the Identity is not allowed because they are closely tied
# to the enviroment
assert self.identity is None
clone = deepcopy(self)
clone.id = None
clone.feature_segment = (
FeatureSegment.objects.get(
environment=env,
feature=clone.feature,
segment=self.feature_segment.segment,
)
if self.feature_segment
else None
)
clone.environment = env
clone.save()
# clone the related objects
self.feature_state_value.clone(clone)
return clone

def get_feature_state_value(self, identity: "Identity" = None) -> typing.Any:
feature_state_value = (
self.get_multivariate_feature_state_value(identity)
Expand Down Expand Up @@ -435,3 +466,10 @@ class FeatureStateValue(AbstractBaseFeatureValueModel):
string_value = models.CharField(null=True, max_length=20000, blank=True)

history = HistoricalRecords()

def clone(self, feature_state: FeatureState) -> "FeatureStateValue":
clone = deepcopy(self)
clone.id = None
clone.feature_state = feature_state
clone.save()
return clone
44 changes: 44 additions & 0 deletions api/tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import uuid

import pytest
Expand Down Expand Up @@ -79,3 +80,46 @@ def sdk_client(environment_api_key):
client = APIClient()
client.credentials(HTTP_X_ENVIRONMENT_KEY=environment_api_key)
return client


@pytest.fixture()
def feature(admin_client, project):
data = {
"name": "test feature",
"initial_value": "default_value",
"project": project,
}
url = reverse("api-v1:projects:project-features-list", args=[project])

response = admin_client.post(url, data=data)
return response.json()["id"]


@pytest.fixture()
def segment(admin_client, project):
url = reverse("api-v1:projects:project-segments-list", args=[project])
data = {
"name": "Test Segment",
"project": project,
"rules": [{"type": "ALL", "rules": [], "conditions": []}],
}

response = admin_client.post(
url, data=json.dumps(data), content_type="application/json"
)
return response.json()["id"]


@pytest.fixture()
def feature_segment(admin_client, segment, feature, environment):
data = {
"feature": feature,
"segment": segment,
"environment": environment,
}
url = reverse("api-v1:features:feature-segment-list")

response = admin_client.post(
url, data=json.dumps(data), content_type="application/json"
)
return response.json()["id"]
Loading