Skip to content

Commit

Permalink
Add an API endpoint to support cloning of environment (#203)
Browse files Browse the repository at this point in the history
* feat(env_clone): Add an api to support environment cloning

* fixup! feat(env_clone): Add an api to support environment cloning

* docs(env_clone): fix swagger docs

* squash! feat(env_clone): Add an api to support environment cloning

* test(integration): Create a fixture to return env dict response

* test(clone_env): Add integration test to check 200

* test: remove environment_dict fixture

* refactor(clone-env): move clonning logic to serializer

* fix: Add permission check for env clone action

* refactor(clone-env): move permission check to has_permission

* fixup! refactor(clone-env): move permission check to has_permission

* squash! refactor permission for clone

* test(env_clone): Add test to check sourece state env after clone

* wip: permission changes

* use Q objects to generate filter
  • Loading branch information
gagantrivedi authored Aug 2, 2021
1 parent 01f8a6e commit 96f5006
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 13 deletions.
17 changes: 15 additions & 2 deletions api/environments/models.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

import logging
from copy import deepcopy

from django.conf import settings
from django.core.cache import caches
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from django_lifecycle import LifecycleModel, hook, BEFORE_SAVE, AFTER_CREATE
from django_lifecycle import AFTER_CREATE, BEFORE_SAVE, LifecycleModel, hook

from app.utils import create_hash
from environments.exceptions import EnvironmentHeaderNotPresentError
from features.models import FeatureState
from projects.models import Project
from util.history.custom_simple_history import NonWritingHistoricalRecords
import logging

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,6 +64,17 @@ def create_feature_states(self):
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
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()
return clone

@staticmethod
def get_environment_from_request(request):
try:
Expand Down
21 changes: 13 additions & 8 deletions api/environments/permissions/permissions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.db.models import Q
from rest_framework import exceptions
from rest_framework.permissions import BasePermission

Expand All @@ -22,16 +23,21 @@ def has_object_permission(self, request, view, obj):

class EnvironmentPermissions(BasePermission):
def has_permission(self, request, view):
if view.action not in ["clone", "create"]:
# return true as all users can list and specific object permissions will be handled later
return True
try:
if view.action == "create":

if view.action == "clone":
api_key = request.path_info.split("/")[-3]
project_lookup = Q(environments__api_key=api_key)

elif view.action == "create":
project_id = request.data.get("project")
project = Project.objects.get(id=project_id)
return request.user.has_project_permission(
"CREATE_ENVIRONMENT", project
)
project_lookup = Q(id=project_id)

# return true as all users can list and specific object permissions will be handled later
return True
project = Project.objects.get(project_lookup)
return request.user.has_project_permission("CREATE_ENVIRONMENT", project)

except Project.DoesNotExist:
return False
Expand All @@ -48,7 +54,6 @@ def has_object_permission(self, request, view, obj):

if view.action == "user_permissions":
return True

return False


Expand Down
15 changes: 15 additions & 0 deletions api/environments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ def _create_audit_log(self, instance, created):
)


class CloneEnvironmentSerializer(EnvironmentSerializerLight):
class Meta:
model = Environment
fields = ("id", "name", "api_key", "project")
read_only_fields = ("id", "api_key", "project")

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


class WebhookSerializer(serializers.ModelSerializer):
class Meta:
model = Webhook
Expand Down
41 changes: 41 additions & 0 deletions api/environments/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,47 @@ def test_on_creation_save_feature_is_created_with_the_correct_default(self):
self.environment.save()
self.assertFalse(FeatureState.objects.get().enabled)

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

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

# Then
self.assertNotEqual(clone.name, self.environment.name)
self.assertNotEqual(clone.api_key, self.environment.api_key)

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

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

# Then
feature_states = FeatureState.objects.filter(environment=clone)
assert feature_states.count() == 1

def test_clone_does_not_modify_source_feature_state(self):
# Given
self.environment.save()
source_feature_state_before_clone = FeatureState.objects.filter(
environment=self.environment
).first()

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

# Then
assert source_feature_state_before_clone == source_feature_state_after_clone

@mock.patch("environments.models.environment_cache")
def test_get_from_cache_stores_environment_in_cache_on_success(self, mock_cache):
# Given
Expand Down
18 changes: 16 additions & 2 deletions api/environments/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

import logging

from django.utils.decorators import method_decorator
from drf_yasg2 import openapi
from drf_yasg2.utils import swagger_auto_schema
Expand All @@ -17,7 +19,6 @@
MyUserObjectPermissionsSerializer,
PermissionModelSerializer,
)
import logging

from .identities.traits.models import Trait
from .identities.traits.serializers import (
Expand All @@ -30,7 +31,11 @@
UserEnvironmentPermission,
UserPermissionGroupEnvironmentPermission,
)
from .serializers import EnvironmentSerializerLight, WebhookSerializer
from .serializers import (
CloneEnvironmentSerializer,
EnvironmentSerializerLight,
WebhookSerializer,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -58,6 +63,8 @@ def get_serializer_class(self):
return TraitKeysSerializer
if self.action == "delete_traits":
return DeleteAllTraitKeysSerializer
if self.action == "clone":
return CloneEnvironmentSerializer
return EnvironmentSerializerLight

def get_serializer_context(self):
Expand Down Expand Up @@ -104,6 +111,13 @@ def trait_keys(self, request, *args, **kwargs):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

@action(detail=True, methods=["POST"])
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())
return Response(serializer.data, status=status.HTTP_200_OK)

@action(detail=True, methods=["POST"], url_path="delete-traits")
def delete_traits(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
Expand Down
3 changes: 2 additions & 1 deletion api/tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ def environment_api_key():


@pytest.fixture()
def environment(admin_client, project, environment_api_key):
def environment(admin_client, project, environment_api_key) -> int:
environment_data = {
"name": "Test Environment",
"api_key": environment_api_key,
"project": project,
}
url = reverse("api-v1:environments:environment-list")

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

Expand Down
16 changes: 16 additions & 0 deletions api/tests/integration/environments/test_environments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.urls import reverse
from rest_framework import status


def test_clone_environment_returns_200(
admin_client, project, environment, environment_api_key
):
# Given
env_name = "Cloned env"
url = reverse("api-v1:environments:environment-clone", args=[environment_api_key])
# When
res = admin_client.post(url, {"name": env_name})

# Then
assert res.status_code == status.HTTP_200_OK
assert res.json()["name"] == env_name

0 comments on commit 96f5006

Please sign in to comment.