mirror of
https://github.com/ansible/awx.git
synced 2026-03-02 17:28:51 -03:30
Merge pull request #903 from ansible/item_copy
Implement item copy feature
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import six
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -26,6 +27,10 @@ from rest_framework import generics
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework import views
|
from rest_framework import views
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
|
||||||
|
# cryptography
|
||||||
|
from cryptography.fernet import InvalidToken
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.api.filters import FieldLookupBackend
|
from awx.api.filters import FieldLookupBackend
|
||||||
@@ -33,9 +38,9 @@ from awx.main.models import * # noqa
|
|||||||
from awx.main.access import access_registry
|
from awx.main.access import access_registry
|
||||||
from awx.main.utils import * # noqa
|
from awx.main.utils import * # noqa
|
||||||
from awx.main.utils.db import get_all_field_names
|
from awx.main.utils.db import get_all_field_names
|
||||||
from awx.api.serializers import ResourceAccessListElementSerializer
|
from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer
|
||||||
from awx.api.versioning import URLPathVersioning, get_request_version
|
from awx.api.versioning import URLPathVersioning, get_request_version
|
||||||
from awx.api.metadata import SublistAttachDetatchMetadata
|
from awx.api.metadata import SublistAttachDetatchMetadata, Metadata
|
||||||
|
|
||||||
__all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView',
|
__all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView',
|
||||||
'ListCreateAPIView', 'SubListAPIView', 'SubListCreateAPIView',
|
'ListCreateAPIView', 'SubListAPIView', 'SubListCreateAPIView',
|
||||||
@@ -47,7 +52,8 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView',
|
|||||||
'ResourceAccessList',
|
'ResourceAccessList',
|
||||||
'ParentMixin',
|
'ParentMixin',
|
||||||
'DeleteLastUnattachLabelMixin',
|
'DeleteLastUnattachLabelMixin',
|
||||||
'SubListAttachDetachAPIView',]
|
'SubListAttachDetachAPIView',
|
||||||
|
'CopyAPIView']
|
||||||
|
|
||||||
logger = logging.getLogger('awx.api.generics')
|
logger = logging.getLogger('awx.api.generics')
|
||||||
analytics_logger = logging.getLogger('awx.analytics.performance')
|
analytics_logger = logging.getLogger('awx.analytics.performance')
|
||||||
@@ -757,3 +763,152 @@ class ResourceAccessList(ParentMixin, ListAPIView):
|
|||||||
for r in roles:
|
for r in roles:
|
||||||
ancestors.update(set(r.ancestors.all()))
|
ancestors.update(set(r.ancestors.all()))
|
||||||
return User.objects.filter(roles__in=list(ancestors)).distinct()
|
return User.objects.filter(roles__in=list(ancestors)).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def trigger_delayed_deep_copy(*args, **kwargs):
|
||||||
|
from awx.main.tasks import deep_copy_model_obj
|
||||||
|
connection.on_commit(lambda: deep_copy_model_obj.delay(*args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
class CopyAPIView(GenericAPIView):
|
||||||
|
|
||||||
|
serializer_class = CopySerializer
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
copy_return_serializer_class = None
|
||||||
|
new_in_330 = True
|
||||||
|
new_in_api_v2 = True
|
||||||
|
|
||||||
|
def _get_copy_return_serializer(self, *args, **kwargs):
|
||||||
|
if not self.copy_return_serializer_class:
|
||||||
|
return self.get_serializer(*args, **kwargs)
|
||||||
|
serializer_class_store = self.serializer_class
|
||||||
|
self.serializer_class = self.copy_return_serializer_class
|
||||||
|
ret = self.get_serializer(*args, **kwargs)
|
||||||
|
self.serializer_class = serializer_class_store
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decrypt_model_field_if_needed(obj, field_name, field_val):
|
||||||
|
if field_name in getattr(type(obj), 'REENCRYPTION_BLACKLIST_AT_COPY', []):
|
||||||
|
return field_val
|
||||||
|
if isinstance(field_val, dict):
|
||||||
|
for sub_field in field_val:
|
||||||
|
if isinstance(sub_field, six.string_types) \
|
||||||
|
and isinstance(field_val[sub_field], six.string_types):
|
||||||
|
try:
|
||||||
|
field_val[sub_field] = decrypt_field(obj, field_name, sub_field)
|
||||||
|
except InvalidToken:
|
||||||
|
# Catching the corner case with v1 credential fields
|
||||||
|
field_val[sub_field] = decrypt_field(obj, sub_field)
|
||||||
|
elif isinstance(field_val, six.string_types):
|
||||||
|
field_val = decrypt_field(obj, field_name)
|
||||||
|
return field_val
|
||||||
|
|
||||||
|
def _build_create_dict(self, obj):
|
||||||
|
ret = {}
|
||||||
|
if self.copy_return_serializer_class:
|
||||||
|
all_fields = Metadata().get_serializer_info(
|
||||||
|
self._get_copy_return_serializer(), method='POST'
|
||||||
|
)
|
||||||
|
for field_name, field_info in all_fields.items():
|
||||||
|
if not hasattr(obj, field_name) or field_info.get('read_only', True):
|
||||||
|
continue
|
||||||
|
ret[field_name] = CopyAPIView._decrypt_model_field_if_needed(
|
||||||
|
obj, field_name, getattr(obj, field_name)
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def copy_model_obj(old_parent, new_parent, model, obj, creater, copy_name='', create_kwargs=None):
|
||||||
|
fields_to_preserve = set(getattr(model, 'FIELDS_TO_PRESERVE_AT_COPY', []))
|
||||||
|
fields_to_discard = set(getattr(model, 'FIELDS_TO_DISCARD_AT_COPY', []))
|
||||||
|
m2m_to_preserve = {}
|
||||||
|
o2m_to_preserve = {}
|
||||||
|
create_kwargs = create_kwargs or {}
|
||||||
|
for field_name in fields_to_discard:
|
||||||
|
create_kwargs.pop(field_name, None)
|
||||||
|
for field in model._meta.get_fields():
|
||||||
|
try:
|
||||||
|
field_val = getattr(obj, field.name)
|
||||||
|
except AttributeError:
|
||||||
|
continue
|
||||||
|
# Adjust copy blacklist fields here.
|
||||||
|
if field.name in fields_to_discard or field.name in [
|
||||||
|
'id', 'pk', 'polymorphic_ctype', 'unifiedjobtemplate_ptr', 'created_by', 'modified_by'
|
||||||
|
] or field.name.endswith('_role'):
|
||||||
|
create_kwargs.pop(field.name, None)
|
||||||
|
continue
|
||||||
|
if field.one_to_many:
|
||||||
|
if field.name in fields_to_preserve:
|
||||||
|
o2m_to_preserve[field.name] = field_val
|
||||||
|
elif field.many_to_many:
|
||||||
|
if field.name in fields_to_preserve and not old_parent:
|
||||||
|
m2m_to_preserve[field.name] = field_val
|
||||||
|
elif field.many_to_one and not field_val:
|
||||||
|
create_kwargs.pop(field.name, None)
|
||||||
|
elif field.many_to_one and field_val == old_parent:
|
||||||
|
create_kwargs[field.name] = new_parent
|
||||||
|
elif field.name == 'name' and not old_parent:
|
||||||
|
create_kwargs[field.name] = copy_name or field_val + ' copy'
|
||||||
|
elif field.name in fields_to_preserve:
|
||||||
|
create_kwargs[field.name] = CopyAPIView._decrypt_model_field_if_needed(
|
||||||
|
obj, field.name, field_val
|
||||||
|
)
|
||||||
|
new_obj = model.objects.create(**create_kwargs)
|
||||||
|
# Need to save separatedly because Djang-crum get_current_user would
|
||||||
|
# not work properly in non-request-response-cycle context.
|
||||||
|
new_obj.created_by = creater
|
||||||
|
new_obj.save()
|
||||||
|
for m2m in m2m_to_preserve:
|
||||||
|
for related_obj in m2m_to_preserve[m2m].all():
|
||||||
|
getattr(new_obj, m2m).add(related_obj)
|
||||||
|
if not old_parent:
|
||||||
|
sub_objects = []
|
||||||
|
for o2m in o2m_to_preserve:
|
||||||
|
for sub_obj in o2m_to_preserve[o2m].all():
|
||||||
|
sub_model = type(sub_obj)
|
||||||
|
sub_objects.append((sub_model.__module__, sub_model.__name__, sub_obj.pk))
|
||||||
|
return new_obj, sub_objects
|
||||||
|
ret = {obj: new_obj}
|
||||||
|
for o2m in o2m_to_preserve:
|
||||||
|
for sub_obj in o2m_to_preserve[o2m].all():
|
||||||
|
ret.update(CopyAPIView.copy_model_obj(obj, new_obj, type(sub_obj), sub_obj, creater))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
create_kwargs = self._build_create_dict(obj)
|
||||||
|
for key in create_kwargs:
|
||||||
|
create_kwargs[key] = getattr(create_kwargs[key], 'pk', None) or create_kwargs[key]
|
||||||
|
return Response({'can_copy': request.user.can_access(self.model, 'add', create_kwargs)})
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
obj = self.get_object()
|
||||||
|
create_kwargs = self._build_create_dict(obj)
|
||||||
|
create_kwargs_check = {}
|
||||||
|
for key in create_kwargs:
|
||||||
|
create_kwargs_check[key] = getattr(create_kwargs[key], 'pk', None) or create_kwargs[key]
|
||||||
|
if not request.user.can_access(self.model, 'add', create_kwargs_check):
|
||||||
|
raise PermissionDenied()
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
if not serializer.is_valid():
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
new_obj, sub_objs = CopyAPIView.copy_model_obj(
|
||||||
|
None, None, self.model, obj, request.user, create_kwargs=create_kwargs,
|
||||||
|
copy_name=serializer.validated_data.get('name', '')
|
||||||
|
)
|
||||||
|
if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role:
|
||||||
|
new_obj.admin_role.members.add(request.user)
|
||||||
|
if sub_objs:
|
||||||
|
permission_check_func = None
|
||||||
|
if hasattr(type(self), 'deep_copy_permission_check_func'):
|
||||||
|
permission_check_func = (
|
||||||
|
type(self).__module__, type(self).__name__, 'deep_copy_permission_check_func'
|
||||||
|
)
|
||||||
|
trigger_delayed_deep_copy(
|
||||||
|
self.model.__module__, self.model.__name__,
|
||||||
|
obj.pk, new_obj.pk, request.user.pk, sub_objs,
|
||||||
|
permission_check_func=permission_check_func
|
||||||
|
)
|
||||||
|
serializer = self._get_copy_return_serializer(new_obj)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -130,6 +130,22 @@ def reverse_gfk(content_object, request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CopySerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
name = serializers.CharField()
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
name = attrs.get('name')
|
||||||
|
view = self.context.get('view', None)
|
||||||
|
obj = view.get_object()
|
||||||
|
if name == obj.name:
|
||||||
|
raise serializers.ValidationError(_(
|
||||||
|
'The original object is already named {}, a copy from'
|
||||||
|
' it cannot have the same name.'.format(name)
|
||||||
|
))
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class BaseSerializerMetaclass(serializers.SerializerMetaclass):
|
class BaseSerializerMetaclass(serializers.SerializerMetaclass):
|
||||||
'''
|
'''
|
||||||
Custom metaclass to enable attribute inheritance from Meta objects on
|
Custom metaclass to enable attribute inheritance from Meta objects on
|
||||||
@@ -1003,6 +1019,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
|
|||||||
notification_templates_error = self.reverse('api:project_notification_templates_error_list', kwargs={'pk': obj.pk}),
|
notification_templates_error = self.reverse('api:project_notification_templates_error_list', kwargs={'pk': obj.pk}),
|
||||||
access_list = self.reverse('api:project_access_list', kwargs={'pk': obj.pk}),
|
access_list = self.reverse('api:project_access_list', kwargs={'pk': obj.pk}),
|
||||||
object_roles = self.reverse('api:project_object_roles_list', kwargs={'pk': obj.pk}),
|
object_roles = self.reverse('api:project_object_roles_list', kwargs={'pk': obj.pk}),
|
||||||
|
copy = self.reverse('api:project_copy', kwargs={'pk': obj.pk}),
|
||||||
))
|
))
|
||||||
if obj.organization:
|
if obj.organization:
|
||||||
res['organization'] = self.reverse('api:organization_detail',
|
res['organization'] = self.reverse('api:organization_detail',
|
||||||
@@ -1156,6 +1173,7 @@ class InventorySerializer(BaseSerializerWithVariables):
|
|||||||
access_list = self.reverse('api:inventory_access_list', kwargs={'pk': obj.pk}),
|
access_list = self.reverse('api:inventory_access_list', kwargs={'pk': obj.pk}),
|
||||||
object_roles = self.reverse('api:inventory_object_roles_list', kwargs={'pk': obj.pk}),
|
object_roles = self.reverse('api:inventory_object_roles_list', kwargs={'pk': obj.pk}),
|
||||||
instance_groups = self.reverse('api:inventory_instance_groups_list', kwargs={'pk': obj.pk}),
|
instance_groups = self.reverse('api:inventory_instance_groups_list', kwargs={'pk': obj.pk}),
|
||||||
|
copy = self.reverse('api:inventory_copy', kwargs={'pk': obj.pk}),
|
||||||
))
|
))
|
||||||
if obj.insights_credential:
|
if obj.insights_credential:
|
||||||
res['insights_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.insights_credential.pk})
|
res['insights_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.insights_credential.pk})
|
||||||
@@ -1513,6 +1531,7 @@ class CustomInventoryScriptSerializer(BaseSerializer):
|
|||||||
res = super(CustomInventoryScriptSerializer, self).get_related(obj)
|
res = super(CustomInventoryScriptSerializer, self).get_related(obj)
|
||||||
res.update(dict(
|
res.update(dict(
|
||||||
object_roles = self.reverse('api:inventory_script_object_roles_list', kwargs={'pk': obj.pk}),
|
object_roles = self.reverse('api:inventory_script_object_roles_list', kwargs={'pk': obj.pk}),
|
||||||
|
copy = self.reverse('api:inventory_script_copy', kwargs={'pk': obj.pk}),
|
||||||
))
|
))
|
||||||
|
|
||||||
if obj.organization:
|
if obj.organization:
|
||||||
@@ -2070,6 +2089,7 @@ class CredentialSerializer(BaseSerializer):
|
|||||||
object_roles = self.reverse('api:credential_object_roles_list', kwargs={'pk': obj.pk}),
|
object_roles = self.reverse('api:credential_object_roles_list', kwargs={'pk': obj.pk}),
|
||||||
owner_users = self.reverse('api:credential_owner_users_list', kwargs={'pk': obj.pk}),
|
owner_users = self.reverse('api:credential_owner_users_list', kwargs={'pk': obj.pk}),
|
||||||
owner_teams = self.reverse('api:credential_owner_teams_list', kwargs={'pk': obj.pk}),
|
owner_teams = self.reverse('api:credential_owner_teams_list', kwargs={'pk': obj.pk}),
|
||||||
|
copy = self.reverse('api:credential_copy', kwargs={'pk': obj.pk}),
|
||||||
))
|
))
|
||||||
|
|
||||||
# TODO: remove when API v1 is removed
|
# TODO: remove when API v1 is removed
|
||||||
@@ -2547,6 +2567,7 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
|
|||||||
labels = self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}),
|
labels = self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}),
|
||||||
object_roles = self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}),
|
object_roles = self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}),
|
||||||
instance_groups = self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}),
|
instance_groups = self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}),
|
||||||
|
copy = self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}),
|
||||||
))
|
))
|
||||||
if obj.host_config_key:
|
if obj.host_config_key:
|
||||||
res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk})
|
res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk})
|
||||||
@@ -3795,6 +3816,7 @@ class NotificationTemplateSerializer(BaseSerializer):
|
|||||||
res.update(dict(
|
res.update(dict(
|
||||||
test = self.reverse('api:notification_template_test', kwargs={'pk': obj.pk}),
|
test = self.reverse('api:notification_template_test', kwargs={'pk': obj.pk}),
|
||||||
notifications = self.reverse('api:notification_template_notification_list', kwargs={'pk': obj.pk}),
|
notifications = self.reverse('api:notification_template_notification_list', kwargs={'pk': obj.pk}),
|
||||||
|
copy = self.reverse('api:notification_template_copy', kwargs={'pk': obj.pk}),
|
||||||
))
|
))
|
||||||
if obj.organization:
|
if obj.organization:
|
||||||
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk})
|
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from awx.api.views import (
|
|||||||
CredentialObjectRolesList,
|
CredentialObjectRolesList,
|
||||||
CredentialOwnerUsersList,
|
CredentialOwnerUsersList,
|
||||||
CredentialOwnerTeamsList,
|
CredentialOwnerTeamsList,
|
||||||
|
CredentialCopy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ urls = [
|
|||||||
url(r'^(?P<pk>[0-9]+)/object_roles/$', CredentialObjectRolesList.as_view(), name='credential_object_roles_list'),
|
url(r'^(?P<pk>[0-9]+)/object_roles/$', CredentialObjectRolesList.as_view(), name='credential_object_roles_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/owner_users/$', CredentialOwnerUsersList.as_view(), name='credential_owner_users_list'),
|
url(r'^(?P<pk>[0-9]+)/owner_users/$', CredentialOwnerUsersList.as_view(), name='credential_owner_users_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'),
|
url(r'^(?P<pk>[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'),
|
||||||
|
url(r'^(?P<pk>[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'),
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ['urls']
|
__all__ = ['urls']
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from awx.api.views import (
|
|||||||
InventoryAccessList,
|
InventoryAccessList,
|
||||||
InventoryObjectRolesList,
|
InventoryObjectRolesList,
|
||||||
InventoryInstanceGroupsList,
|
InventoryInstanceGroupsList,
|
||||||
|
InventoryCopy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ urls = [
|
|||||||
url(r'^(?P<pk>[0-9]+)/access_list/$', InventoryAccessList.as_view(), name='inventory_access_list'),
|
url(r'^(?P<pk>[0-9]+)/access_list/$', InventoryAccessList.as_view(), name='inventory_access_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/object_roles/$', InventoryObjectRolesList.as_view(), name='inventory_object_roles_list'),
|
url(r'^(?P<pk>[0-9]+)/object_roles/$', InventoryObjectRolesList.as_view(), name='inventory_object_roles_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/instance_groups/$', InventoryInstanceGroupsList.as_view(), name='inventory_instance_groups_list'),
|
url(r'^(?P<pk>[0-9]+)/instance_groups/$', InventoryInstanceGroupsList.as_view(), name='inventory_instance_groups_list'),
|
||||||
|
url(r'^(?P<pk>[0-9]+)/copy/$', InventoryCopy.as_view(), name='inventory_copy'),
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ['urls']
|
__all__ = ['urls']
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from awx.api.views import (
|
|||||||
InventoryScriptList,
|
InventoryScriptList,
|
||||||
InventoryScriptDetail,
|
InventoryScriptDetail,
|
||||||
InventoryScriptObjectRolesList,
|
InventoryScriptObjectRolesList,
|
||||||
|
InventoryScriptCopy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ urls = [
|
|||||||
url(r'^$', InventoryScriptList.as_view(), name='inventory_script_list'),
|
url(r'^$', InventoryScriptList.as_view(), name='inventory_script_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/$', InventoryScriptDetail.as_view(), name='inventory_script_detail'),
|
url(r'^(?P<pk>[0-9]+)/$', InventoryScriptDetail.as_view(), name='inventory_script_detail'),
|
||||||
url(r'^(?P<pk>[0-9]+)/object_roles/$', InventoryScriptObjectRolesList.as_view(), name='inventory_script_object_roles_list'),
|
url(r'^(?P<pk>[0-9]+)/object_roles/$', InventoryScriptObjectRolesList.as_view(), name='inventory_script_object_roles_list'),
|
||||||
|
url(r'^(?P<pk>[0-9]+)/copy/$', InventoryScriptCopy.as_view(), name='inventory_script_copy'),
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ['urls']
|
__all__ = ['urls']
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from awx.api.views import (
|
|||||||
JobTemplateAccessList,
|
JobTemplateAccessList,
|
||||||
JobTemplateObjectRolesList,
|
JobTemplateObjectRolesList,
|
||||||
JobTemplateLabelList,
|
JobTemplateLabelList,
|
||||||
|
JobTemplateCopy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ urls = [
|
|||||||
url(r'^(?P<pk>[0-9]+)/access_list/$', JobTemplateAccessList.as_view(), name='job_template_access_list'),
|
url(r'^(?P<pk>[0-9]+)/access_list/$', JobTemplateAccessList.as_view(), name='job_template_access_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/object_roles/$', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'),
|
url(r'^(?P<pk>[0-9]+)/object_roles/$', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/labels/$', JobTemplateLabelList.as_view(), name='job_template_label_list'),
|
url(r'^(?P<pk>[0-9]+)/labels/$', JobTemplateLabelList.as_view(), name='job_template_label_list'),
|
||||||
|
url(r'^(?P<pk>[0-9]+)/copy/$', JobTemplateCopy.as_view(), name='job_template_copy'),
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ['urls']
|
__all__ = ['urls']
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from awx.api.views import (
|
|||||||
NotificationTemplateDetail,
|
NotificationTemplateDetail,
|
||||||
NotificationTemplateTest,
|
NotificationTemplateTest,
|
||||||
NotificationTemplateNotificationList,
|
NotificationTemplateNotificationList,
|
||||||
|
NotificationTemplateCopy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ urls = [
|
|||||||
url(r'^(?P<pk>[0-9]+)/$', NotificationTemplateDetail.as_view(), name='notification_template_detail'),
|
url(r'^(?P<pk>[0-9]+)/$', NotificationTemplateDetail.as_view(), name='notification_template_detail'),
|
||||||
url(r'^(?P<pk>[0-9]+)/test/$', NotificationTemplateTest.as_view(), name='notification_template_test'),
|
url(r'^(?P<pk>[0-9]+)/test/$', NotificationTemplateTest.as_view(), name='notification_template_test'),
|
||||||
url(r'^(?P<pk>[0-9]+)/notifications/$', NotificationTemplateNotificationList.as_view(), name='notification_template_notification_list'),
|
url(r'^(?P<pk>[0-9]+)/notifications/$', NotificationTemplateNotificationList.as_view(), name='notification_template_notification_list'),
|
||||||
|
url(r'^(?P<pk>[0-9]+)/copy/$', NotificationTemplateCopy.as_view(), name='notification_template_copy'),
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ['urls']
|
__all__ = ['urls']
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ from awx.api.views import (
|
|||||||
ProjectNotificationTemplatesSuccessList,
|
ProjectNotificationTemplatesSuccessList,
|
||||||
ProjectObjectRolesList,
|
ProjectObjectRolesList,
|
||||||
ProjectAccessList,
|
ProjectAccessList,
|
||||||
|
ProjectCopy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
urls = [
|
urls = [
|
||||||
url(r'^$', ProjectList.as_view(), name='project_list'),
|
url(r'^$', ProjectList.as_view(), name='project_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/$', ProjectDetail.as_view(), name='project_detail'),
|
url(r'^(?P<pk>[0-9]+)/$', ProjectDetail.as_view(), name='project_detail'),
|
||||||
url(r'^(?P<pk>[0-9]+)/playbooks/$', ProjectPlaybooks.as_view(), name='project_playbooks'),
|
url(r'^(?P<pk>[0-9]+)/playbooks/$', ProjectPlaybooks.as_view(), name='project_playbooks'),
|
||||||
@@ -39,6 +40,7 @@ urls = [
|
|||||||
name='project_notification_templates_success_list'),
|
name='project_notification_templates_success_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/object_roles/$', ProjectObjectRolesList.as_view(), name='project_object_roles_list'),
|
url(r'^(?P<pk>[0-9]+)/object_roles/$', ProjectObjectRolesList.as_view(), name='project_object_roles_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/access_list/$', ProjectAccessList.as_view(), name='project_access_list'),
|
url(r'^(?P<pk>[0-9]+)/access_list/$', ProjectAccessList.as_view(), name='project_access_list'),
|
||||||
|
url(r'^(?P<pk>[0-9]+)/copy/$', ProjectCopy.as_view(), name='project_copy'),
|
||||||
]
|
]
|
||||||
|
|
||||||
__all__ = ['urls']
|
__all__ = ['urls']
|
||||||
|
|||||||
@@ -1563,6 +1563,12 @@ class ProjectObjectRolesList(SubListAPIView):
|
|||||||
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCopy(CopyAPIView):
|
||||||
|
|
||||||
|
model = Project
|
||||||
|
copy_return_serializer_class = ProjectSerializer
|
||||||
|
|
||||||
|
|
||||||
class UserList(ListCreateAPIView):
|
class UserList(ListCreateAPIView):
|
||||||
|
|
||||||
model = User
|
model = User
|
||||||
@@ -1944,6 +1950,12 @@ class CredentialObjectRolesList(SubListAPIView):
|
|||||||
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialCopy(CopyAPIView):
|
||||||
|
|
||||||
|
model = Credential
|
||||||
|
copy_return_serializer_class = CredentialSerializer
|
||||||
|
|
||||||
|
|
||||||
class InventoryScriptList(ListCreateAPIView):
|
class InventoryScriptList(ListCreateAPIView):
|
||||||
|
|
||||||
model = CustomInventoryScript
|
model = CustomInventoryScript
|
||||||
@@ -1981,6 +1993,12 @@ class InventoryScriptObjectRolesList(SubListAPIView):
|
|||||||
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryScriptCopy(CopyAPIView):
|
||||||
|
|
||||||
|
model = CustomInventoryScript
|
||||||
|
copy_return_serializer_class = CustomInventoryScriptSerializer
|
||||||
|
|
||||||
|
|
||||||
class InventoryList(ListCreateAPIView):
|
class InventoryList(ListCreateAPIView):
|
||||||
|
|
||||||
model = Inventory
|
model = Inventory
|
||||||
@@ -2108,6 +2126,12 @@ class InventoryJobTemplateList(SubListAPIView):
|
|||||||
return qs.filter(inventory=parent)
|
return qs.filter(inventory=parent)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCopy(CopyAPIView):
|
||||||
|
|
||||||
|
model = Inventory
|
||||||
|
copy_return_serializer_class = InventorySerializer
|
||||||
|
|
||||||
|
|
||||||
class HostRelatedSearchMixin(object):
|
class HostRelatedSearchMixin(object):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -3479,6 +3503,12 @@ class JobTemplateObjectRolesList(SubListAPIView):
|
|||||||
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
return Role.objects.filter(content_type=content_type, object_id=po.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class JobTemplateCopy(CopyAPIView):
|
||||||
|
|
||||||
|
model = JobTemplate
|
||||||
|
copy_return_serializer_class = JobTemplateSerializer
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobNodeList(WorkflowsEnforcementMixin, ListAPIView):
|
class WorkflowJobNodeList(WorkflowsEnforcementMixin, ListAPIView):
|
||||||
|
|
||||||
model = WorkflowJobNode
|
model = WorkflowJobNode
|
||||||
@@ -3642,10 +3672,10 @@ class WorkflowJobTemplateDetail(WorkflowsEnforcementMixin, RetrieveUpdateDestroy
|
|||||||
new_in_310 = True
|
new_in_310 = True
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, GenericAPIView):
|
class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, CopyAPIView):
|
||||||
|
|
||||||
model = WorkflowJobTemplate
|
model = WorkflowJobTemplate
|
||||||
serializer_class = EmptySerializer
|
copy_return_serializer_class = WorkflowJobTemplateSerializer
|
||||||
new_in_310 = True
|
new_in_310 = True
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
@@ -3662,17 +3692,20 @@ class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, GenericAPIView):
|
|||||||
data.update(messages)
|
data.update(messages)
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
@staticmethod
|
||||||
obj = self.get_object()
|
def deep_copy_permission_check_func(user, new_objs):
|
||||||
if not request.user.can_access(self.model, 'copy', obj):
|
for obj in new_objs:
|
||||||
raise PermissionDenied()
|
for field_name in obj._get_workflow_job_field_names():
|
||||||
new_obj = obj.user_copy(request.user)
|
item = getattr(obj, field_name, None)
|
||||||
if request.user not in new_obj.admin_role:
|
if item is None:
|
||||||
new_obj.admin_role.members.add(request.user)
|
continue
|
||||||
data = OrderedDict()
|
if field_name in ['inventory']:
|
||||||
data.update(WorkflowJobTemplateSerializer(
|
if not user.can_access(item.__class__, 'use', item):
|
||||||
new_obj, context=self.get_serializer_context()).to_representation(new_obj))
|
setattr(obj, field_name, None)
|
||||||
return Response(data, status=status.HTTP_201_CREATED)
|
if field_name in ['unified_job_template']:
|
||||||
|
if not user.can_access(item.__class__, 'start', item, validate_license=False):
|
||||||
|
setattr(obj, field_name, None)
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobTemplateLabelList(WorkflowsEnforcementMixin, JobTemplateLabelList):
|
class WorkflowJobTemplateLabelList(WorkflowsEnforcementMixin, JobTemplateLabelList):
|
||||||
@@ -4827,6 +4860,12 @@ class NotificationTemplateNotificationList(SubListAPIView):
|
|||||||
new_in_300 = True
|
new_in_300 = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTemplateCopy(CopyAPIView):
|
||||||
|
|
||||||
|
model = NotificationTemplate
|
||||||
|
copy_return_serializer_class = NotificationTemplateSerializer
|
||||||
|
|
||||||
|
|
||||||
class NotificationList(ListAPIView):
|
class NotificationList(ListAPIView):
|
||||||
|
|
||||||
model = Notification
|
model = Notification
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin):
|
|||||||
an inventory source contains lists and hosts.
|
an inventory source contains lists and hosts.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = ['hosts', 'groups', 'instance_groups']
|
||||||
KIND_CHOICES = [
|
KIND_CHOICES = [
|
||||||
('', _('Hosts have a direct link to this inventory.')),
|
('', _('Hosts have a direct link to this inventory.')),
|
||||||
('smart', _('Hosts for inventory generated using the host_filter property.')),
|
('smart', _('Hosts for inventory generated using the host_filter property.')),
|
||||||
@@ -505,6 +506,10 @@ class Host(CommonModelNameNotUnique):
|
|||||||
A managed node
|
A managed node
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = [
|
||||||
|
'name', 'description', 'groups', 'inventory', 'enabled', 'instance_id', 'variables'
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
unique_together = (("name", "inventory"),) # FIXME: Add ('instance_id', 'inventory') after migration.
|
unique_together = (("name", "inventory"),) # FIXME: Add ('instance_id', 'inventory') after migration.
|
||||||
@@ -692,6 +697,10 @@ class Group(CommonModelNameNotUnique):
|
|||||||
groups.
|
groups.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = [
|
||||||
|
'name', 'description', 'inventory', 'children', 'parents', 'hosts', 'variables'
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
unique_together = (("name", "inventory"),)
|
unique_together = (("name", "inventory"),)
|
||||||
|
|||||||
@@ -220,6 +220,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
A job template is a reusable job definition for applying a project (with
|
A job template is a reusable job definition for applying a project (with
|
||||||
playbook) to an inventory source with a given credential.
|
playbook) to an inventory source with a given credential.
|
||||||
'''
|
'''
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = [
|
||||||
|
'labels', 'instance_groups', 'credentials', 'survey_spec'
|
||||||
|
]
|
||||||
|
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
|
||||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')]
|
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -229,6 +229,8 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = ['labels', 'instance_groups', 'credentials']
|
||||||
|
FIELDS_TO_DISCARD_AT_COPY = ['local_path']
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ class WorkflowNodeBase(CreatedModifiedModel, LaunchTimeConfig):
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowJobTemplateNode(WorkflowNodeBase):
|
class WorkflowJobTemplateNode(WorkflowNodeBase):
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = [
|
||||||
|
'unified_job_template', 'workflow_job_template', 'success_nodes', 'failure_nodes',
|
||||||
|
'always_nodes', 'credentials', 'inventory', 'extra_data', 'survey_passwords',
|
||||||
|
'char_prompts'
|
||||||
|
]
|
||||||
|
REENCRYPTION_BLACKLIST_AT_COPY = ['extra_data', 'survey_passwords']
|
||||||
|
|
||||||
workflow_job_template = models.ForeignKey(
|
workflow_job_template = models.ForeignKey(
|
||||||
'WorkflowJobTemplate',
|
'WorkflowJobTemplate',
|
||||||
related_name='workflow_job_template_nodes',
|
related_name='workflow_job_template_nodes',
|
||||||
@@ -283,6 +290,9 @@ class WorkflowJobOptions(BaseModel):
|
|||||||
class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin):
|
class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin):
|
||||||
|
|
||||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = [
|
||||||
|
'labels', 'instance_groups', 'workflow_job_template_nodes', 'credentials', 'survey_spec'
|
||||||
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
@@ -394,11 +404,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
|||||||
node_list.append(node.pk)
|
node_list.append(node.pk)
|
||||||
return node_list
|
return node_list
|
||||||
|
|
||||||
def user_copy(self, user):
|
|
||||||
new_wfjt = self.copy_unified_jt()
|
|
||||||
new_wfjt.copy_nodes_from_original(original=self, user=user)
|
|
||||||
return new_wfjt
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
|
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from collections import OrderedDict, namedtuple
|
|||||||
import ConfigParser
|
import ConfigParser
|
||||||
import cStringIO
|
import cStringIO
|
||||||
import functools
|
import functools
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -31,6 +32,7 @@ from celery.signals import celeryd_init, worker_process_init, worker_shutdown, w
|
|||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction, DatabaseError, IntegrityError
|
from django.db import transaction, DatabaseError, IntegrityError
|
||||||
|
from django.db.models.fields.related import ForeignKey
|
||||||
from django.utils.timezone import now, timedelta
|
from django.utils.timezone import now, timedelta
|
||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
@@ -2259,6 +2261,62 @@ class RunSystemJob(BaseTask):
|
|||||||
return settings.BASE_DIR
|
return settings.BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _reconstruct_relationships(copy_mapping):
|
||||||
|
for old_obj, new_obj in copy_mapping.items():
|
||||||
|
model = type(old_obj)
|
||||||
|
for field_name in getattr(model, 'FIELDS_TO_PRESERVE_AT_COPY', []):
|
||||||
|
field = model._meta.get_field(field_name)
|
||||||
|
if isinstance(field, ForeignKey):
|
||||||
|
if getattr(new_obj, field_name, None):
|
||||||
|
continue
|
||||||
|
related_obj = getattr(old_obj, field_name)
|
||||||
|
related_obj = copy_mapping.get(related_obj, related_obj)
|
||||||
|
setattr(new_obj, field_name, related_obj)
|
||||||
|
elif field.many_to_many:
|
||||||
|
for related_obj in getattr(old_obj, field_name).all():
|
||||||
|
getattr(new_obj, field_name).add(copy_mapping.get(related_obj, related_obj))
|
||||||
|
new_obj.save()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, queue='tower', base=LogErrorsTask)
|
||||||
|
def deep_copy_model_obj(
|
||||||
|
self, model_module, model_name, obj_pk, new_obj_pk,
|
||||||
|
user_pk, sub_obj_list, permission_check_func=None
|
||||||
|
):
|
||||||
|
logger.info('Deep copy {} from {} to {}.'.format(model_name, obj_pk, new_obj_pk))
|
||||||
|
from awx.api.generics import CopyAPIView
|
||||||
|
model = getattr(importlib.import_module(model_module), model_name, None)
|
||||||
|
if model is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
obj = model.objects.get(pk=obj_pk)
|
||||||
|
new_obj = model.objects.get(pk=new_obj_pk)
|
||||||
|
creater = User.objects.get(pk=user_pk)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
logger.warning("Object or user no longer exists.")
|
||||||
|
return
|
||||||
|
with transaction.atomic():
|
||||||
|
copy_mapping = {}
|
||||||
|
for sub_obj_setup in sub_obj_list:
|
||||||
|
sub_model = getattr(importlib.import_module(sub_obj_setup[0]),
|
||||||
|
sub_obj_setup[1], None)
|
||||||
|
if sub_model is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sub_obj = sub_model.objects.get(pk=sub_obj_setup[2])
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
continue
|
||||||
|
copy_mapping.update(CopyAPIView.copy_model_obj(
|
||||||
|
obj, new_obj, sub_model, sub_obj, creater
|
||||||
|
))
|
||||||
|
_reconstruct_relationships(copy_mapping)
|
||||||
|
if permission_check_func:
|
||||||
|
permission_check_func = getattr(getattr(
|
||||||
|
importlib.import_module(permission_check_func[0]), permission_check_func[1]
|
||||||
|
), permission_check_func[2])
|
||||||
|
permission_check_func(creater, copy_mapping.values())
|
||||||
|
|
||||||
|
|
||||||
celery_app.register_task(RunJob())
|
celery_app.register_task(RunJob())
|
||||||
celery_app.register_task(RunProjectUpdate())
|
celery_app.register_task(RunProjectUpdate())
|
||||||
celery_app.register_task(RunInventoryUpdate())
|
celery_app.register_task(RunInventoryUpdate())
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ from awx.main.models.inventory import (
|
|||||||
Group,
|
Group,
|
||||||
Inventory,
|
Inventory,
|
||||||
InventoryUpdate,
|
InventoryUpdate,
|
||||||
InventorySource
|
InventorySource,
|
||||||
|
CustomInventoryScript
|
||||||
)
|
)
|
||||||
from awx.main.models.organization import (
|
from awx.main.models.organization import (
|
||||||
Organization,
|
Organization,
|
||||||
@@ -496,6 +497,13 @@ def inventory_update(inventory_source):
|
|||||||
return InventoryUpdate.objects.create(inventory_source=inventory_source)
|
return InventoryUpdate.objects.create(inventory_source=inventory_source)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def inventory_script(organization):
|
||||||
|
return CustomInventoryScript.objects.create(name='test inv script',
|
||||||
|
organization=organization,
|
||||||
|
script='#!/usr/bin/python')
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def host(group, inventory):
|
def host(group, inventory):
|
||||||
return group.hosts.create(name='single-host', inventory=inventory)
|
return group.hosts.create(name='single-host', inventory=inventory)
|
||||||
|
|||||||
@@ -191,25 +191,6 @@ class TestWorkflowJobTemplate:
|
|||||||
assert (test_view.is_valid_relation(nodes[2], node_assoc_1) ==
|
assert (test_view.is_valid_relation(nodes[2], node_assoc_1) ==
|
||||||
{'Error': 'Cannot associate failure_nodes when always_nodes have been associated.'})
|
{'Error': 'Cannot associate failure_nodes when always_nodes have been associated.'})
|
||||||
|
|
||||||
def test_wfjt_copy(self, wfjt, job_template, inventory, admin_user):
|
|
||||||
old_nodes = wfjt.workflow_job_template_nodes.all()
|
|
||||||
node1 = old_nodes[1]
|
|
||||||
node1.unified_job_template = job_template
|
|
||||||
node1.save()
|
|
||||||
node2 = old_nodes[2]
|
|
||||||
node2.inventory = inventory
|
|
||||||
node2.save()
|
|
||||||
new_wfjt = wfjt.user_copy(admin_user)
|
|
||||||
for fd in ['description', 'survey_spec', 'survey_enabled', 'extra_vars']:
|
|
||||||
assert getattr(wfjt, fd) == getattr(new_wfjt, fd)
|
|
||||||
assert new_wfjt.organization == wfjt.organization
|
|
||||||
assert len(new_wfjt.workflow_job_template_nodes.all()) == 3
|
|
||||||
nodes = new_wfjt.workflow_job_template_nodes.all()
|
|
||||||
assert nodes[0].success_nodes.all()[0] == nodes[1]
|
|
||||||
assert nodes[1].failure_nodes.all()[0] == nodes[2]
|
|
||||||
assert nodes[1].unified_job_template == job_template
|
|
||||||
assert nodes[2].inventory == inventory
|
|
||||||
|
|
||||||
def test_wfjt_unique_together_with_org(self, organization):
|
def test_wfjt_unique_together_with_org(self, organization):
|
||||||
wfjt1 = WorkflowJobTemplate(name='foo', organization=organization)
|
wfjt1 = WorkflowJobTemplate(name='foo', organization=organization)
|
||||||
wfjt1.save()
|
wfjt1.save()
|
||||||
|
|||||||
214
awx/main/tests/functional/test_copy.py
Normal file
214
awx/main/tests/functional/test_copy.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import pytest
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from awx.api.versioning import reverse
|
||||||
|
from awx.main.utils import decrypt_field
|
||||||
|
from awx.main.models.workflow import WorkflowJobTemplateNode
|
||||||
|
from awx.main.models.jobs import JobTemplate
|
||||||
|
from awx.main.tasks import deep_copy_model_obj
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_job_template_copy(post, get, project, inventory, machine_credential, vault_credential,
|
||||||
|
credential, alice, job_template_with_survey_passwords, admin):
|
||||||
|
job_template_with_survey_passwords.project = project
|
||||||
|
job_template_with_survey_passwords.inventory = inventory
|
||||||
|
job_template_with_survey_passwords.save()
|
||||||
|
job_template_with_survey_passwords.credentials.add(credential)
|
||||||
|
job_template_with_survey_passwords.credentials.add(machine_credential)
|
||||||
|
job_template_with_survey_passwords.credentials.add(vault_credential)
|
||||||
|
job_template_with_survey_passwords.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
|
||||||
|
alice, expect=200
|
||||||
|
).data['can_copy'] is False
|
||||||
|
assert get(
|
||||||
|
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
|
||||||
|
admin, expect=200
|
||||||
|
).data['can_copy'] is True
|
||||||
|
jt_copy_pk = post(
|
||||||
|
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
|
||||||
|
{'name': 'new jt name'}, admin, expect=201
|
||||||
|
).data['id']
|
||||||
|
jt_copy = type(job_template_with_survey_passwords).objects.get(pk=jt_copy_pk)
|
||||||
|
assert jt_copy.created_by == admin
|
||||||
|
assert jt_copy.name == 'new jt name'
|
||||||
|
assert jt_copy.project == project
|
||||||
|
assert jt_copy.inventory == inventory
|
||||||
|
assert jt_copy.playbook == job_template_with_survey_passwords.playbook
|
||||||
|
assert jt_copy.credentials.count() == 3
|
||||||
|
assert credential in jt_copy.credentials.all()
|
||||||
|
assert vault_credential in jt_copy.credentials.all()
|
||||||
|
assert machine_credential in jt_copy.credentials.all()
|
||||||
|
assert job_template_with_survey_passwords.survey_spec == jt_copy.survey_spec
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_project_copy(post, get, project, organization, scm_credential, alice):
|
||||||
|
project.credential = scm_credential
|
||||||
|
project.save()
|
||||||
|
project.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse('api:project_copy', kwargs={'pk': project.pk}), alice, expect=200
|
||||||
|
).data['can_copy'] is False
|
||||||
|
project.organization.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse('api:project_copy', kwargs={'pk': project.pk}), alice, expect=200
|
||||||
|
).data['can_copy'] is True
|
||||||
|
project_copy_pk = post(
|
||||||
|
reverse('api:project_copy', kwargs={'pk': project.pk}),
|
||||||
|
{'name': 'copied project'}, alice, expect=201
|
||||||
|
).data['id']
|
||||||
|
project_copy = type(project).objects.get(pk=project_copy_pk)
|
||||||
|
assert project_copy.created_by == alice
|
||||||
|
assert project_copy.name == 'copied project'
|
||||||
|
assert project_copy.organization == organization
|
||||||
|
assert project_copy.credential == scm_credential
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_inventory_copy(inventory, group_factory, post, get, alice, organization):
|
||||||
|
group_1_1 = group_factory('g_1_1')
|
||||||
|
group_2_1 = group_factory('g_2_1')
|
||||||
|
group_2_2 = group_factory('g_2_2')
|
||||||
|
group_2_1.parents.add(group_1_1)
|
||||||
|
group_2_2.parents.add(group_1_1)
|
||||||
|
group_2_2.parents.add(group_2_1)
|
||||||
|
host = group_1_1.hosts.create(name='host', inventory=inventory)
|
||||||
|
group_2_1.hosts.add(host)
|
||||||
|
inventory.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse('api:inventory_copy', kwargs={'pk': inventory.pk}), alice, expect=200
|
||||||
|
).data['can_copy'] is False
|
||||||
|
inventory.organization.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse('api:inventory_copy', kwargs={'pk': inventory.pk}), alice, expect=200
|
||||||
|
).data['can_copy'] is True
|
||||||
|
with mock.patch('awx.api.generics.trigger_delayed_deep_copy') as deep_copy_mock:
|
||||||
|
inv_copy_pk = post(
|
||||||
|
reverse('api:inventory_copy', kwargs={'pk': inventory.pk}),
|
||||||
|
{'name': 'new inv name'}, alice, expect=201
|
||||||
|
).data['id']
|
||||||
|
inventory_copy = type(inventory).objects.get(pk=inv_copy_pk)
|
||||||
|
args, kwargs = deep_copy_mock.call_args
|
||||||
|
deep_copy_model_obj(*args, **kwargs)
|
||||||
|
group_1_1_copy = inventory_copy.groups.get(name='g_1_1')
|
||||||
|
group_2_1_copy = inventory_copy.groups.get(name='g_2_1')
|
||||||
|
group_2_2_copy = inventory_copy.groups.get(name='g_2_2')
|
||||||
|
host_copy = inventory_copy.hosts.get(name='host')
|
||||||
|
assert inventory_copy.organization == organization
|
||||||
|
assert inventory_copy.created_by == alice
|
||||||
|
assert inventory_copy.name == 'new inv name'
|
||||||
|
assert set(group_1_1_copy.parents.all()) == set()
|
||||||
|
assert set(group_2_1_copy.parents.all()) == set([group_1_1_copy])
|
||||||
|
assert set(group_2_2_copy.parents.all()) == set([group_1_1_copy, group_2_1_copy])
|
||||||
|
assert set(group_1_1_copy.hosts.all()) == set([host_copy])
|
||||||
|
assert set(group_2_1_copy.hosts.all()) == set([host_copy])
|
||||||
|
assert set(group_2_2_copy.hosts.all()) == set()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_workflow_job_template_copy(workflow_job_template, post, get, admin, organization):
|
||||||
|
workflow_job_template.organization = organization
|
||||||
|
workflow_job_template.save()
|
||||||
|
jts = [JobTemplate.objects.create(name='test-jt-{}'.format(i)) for i in range(0, 5)]
|
||||||
|
nodes = [
|
||||||
|
WorkflowJobTemplateNode.objects.create(
|
||||||
|
workflow_job_template=workflow_job_template, unified_job_template=jts[i]
|
||||||
|
) for i in range(0, 5)
|
||||||
|
]
|
||||||
|
nodes[0].success_nodes.add(nodes[1])
|
||||||
|
nodes[1].success_nodes.add(nodes[2])
|
||||||
|
nodes[0].failure_nodes.add(nodes[3])
|
||||||
|
nodes[3].failure_nodes.add(nodes[4])
|
||||||
|
with mock.patch('awx.api.generics.trigger_delayed_deep_copy') as deep_copy_mock:
|
||||||
|
wfjt_copy_id = post(
|
||||||
|
reverse('api:workflow_job_template_copy', kwargs={'pk': workflow_job_template.pk}),
|
||||||
|
{'name': 'new wfjt name'}, admin, expect=201
|
||||||
|
).data['id']
|
||||||
|
wfjt_copy = type(workflow_job_template).objects.get(pk=wfjt_copy_id)
|
||||||
|
args, kwargs = deep_copy_mock.call_args
|
||||||
|
deep_copy_model_obj(*args, **kwargs)
|
||||||
|
assert wfjt_copy.organization == organization
|
||||||
|
assert wfjt_copy.created_by == admin
|
||||||
|
assert wfjt_copy.name == 'new wfjt name'
|
||||||
|
copied_node_list = [x for x in wfjt_copy.workflow_job_template_nodes.all()]
|
||||||
|
copied_node_list.sort(key=lambda x: int(x.unified_job_template.name[-1]))
|
||||||
|
for node, success_count, failure_count, always_count in zip(
|
||||||
|
copied_node_list,
|
||||||
|
[1, 1, 0, 0, 0],
|
||||||
|
[1, 0, 0, 1, 0],
|
||||||
|
[0, 0, 0, 0, 0]
|
||||||
|
):
|
||||||
|
assert node.success_nodes.count() == success_count
|
||||||
|
assert node.failure_nodes.count() == failure_count
|
||||||
|
assert node.always_nodes.count() == always_count
|
||||||
|
assert copied_node_list[1] in copied_node_list[0].success_nodes.all()
|
||||||
|
assert copied_node_list[2] in copied_node_list[1].success_nodes.all()
|
||||||
|
assert copied_node_list[3] in copied_node_list[0].failure_nodes.all()
|
||||||
|
assert copied_node_list[4] in copied_node_list[3].failure_nodes.all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_credential_copy(post, get, machine_credential, credentialtype_ssh, admin):
|
||||||
|
assert get(
|
||||||
|
reverse('api:credential_copy', kwargs={'pk': machine_credential.pk}), admin, expect=200
|
||||||
|
).data['can_copy'] is True
|
||||||
|
credential_copy_pk = post(
|
||||||
|
reverse('api:credential_copy', kwargs={'pk': machine_credential.pk}),
|
||||||
|
{'name': 'copied credential'}, admin, expect=201
|
||||||
|
).data['id']
|
||||||
|
credential_copy = type(machine_credential).objects.get(pk=credential_copy_pk)
|
||||||
|
assert credential_copy.created_by == admin
|
||||||
|
assert credential_copy.name == 'copied credential'
|
||||||
|
assert credential_copy.credential_type == credentialtype_ssh
|
||||||
|
assert credential_copy.inputs['username'] == machine_credential.inputs['username']
|
||||||
|
assert (decrypt_field(credential_copy, 'password') ==
|
||||||
|
decrypt_field(machine_credential, 'password'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_notification_template_copy(post, get, notification_template_with_encrypt,
|
||||||
|
organization, alice):
|
||||||
|
#notification_template_with_encrypt.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse(
|
||||||
|
'api:notification_template_copy', kwargs={'pk': notification_template_with_encrypt.pk}
|
||||||
|
), alice, expect=200
|
||||||
|
).data['can_copy'] is False
|
||||||
|
notification_template_with_encrypt.organization.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse(
|
||||||
|
'api:notification_template_copy', kwargs={'pk': notification_template_with_encrypt.pk}
|
||||||
|
), alice, expect=200
|
||||||
|
).data['can_copy'] is True
|
||||||
|
nt_copy_pk = post(
|
||||||
|
reverse(
|
||||||
|
'api:notification_template_copy', kwargs={'pk': notification_template_with_encrypt.pk}
|
||||||
|
), {'name': 'copied nt'}, alice, expect=201
|
||||||
|
).data['id']
|
||||||
|
notification_template_copy = type(notification_template_with_encrypt).objects.get(pk=nt_copy_pk)
|
||||||
|
assert notification_template_copy.created_by == alice
|
||||||
|
assert notification_template_copy.name == 'copied nt'
|
||||||
|
assert notification_template_copy.organization == organization
|
||||||
|
assert (decrypt_field(notification_template_with_encrypt, 'notification_configuration', 'token') ==
|
||||||
|
decrypt_field(notification_template_copy, 'notification_configuration', 'token'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_inventory_script_copy(post, get, inventory_script, organization, alice):
|
||||||
|
assert get(
|
||||||
|
reverse('api:inventory_script_copy', kwargs={'pk': inventory_script.pk}), alice, expect=200
|
||||||
|
).data['can_copy'] is False
|
||||||
|
inventory_script.organization.admin_role.members.add(alice)
|
||||||
|
assert get(
|
||||||
|
reverse('api:inventory_script_copy', kwargs={'pk': inventory_script.pk}), alice, expect=200
|
||||||
|
).data['can_copy'] is True
|
||||||
|
is_copy_pk = post(
|
||||||
|
reverse('api:inventory_script_copy', kwargs={'pk': inventory_script.pk}),
|
||||||
|
{'name': 'copied inv script'}, alice, expect=201
|
||||||
|
).data['id']
|
||||||
|
inventory_script_copy = type(inventory_script).objects.get(pk=is_copy_pk)
|
||||||
|
assert inventory_script_copy.created_by == alice
|
||||||
|
assert inventory_script_copy.name == 'copied inv script'
|
||||||
|
assert inventory_script_copy.organization == organization
|
||||||
166
docs/resource_copy.md
Normal file
166
docs/resource_copy.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
Starting from Tower 3.3 and API v2, user are able to copy some existing resource objects to quickly
|
||||||
|
create new resource objects via POSTing to corresponding `/copy/` endpoint. A new `CopyAPIView` class
|
||||||
|
is introduced as the base view class for `/copy/` endpoints. It mimics the process of manually fetching
|
||||||
|
fields from the existing object to create a new object, plus the ability to automatically detect sub
|
||||||
|
structures of existing objects and make a background task-based deep copy when necessary.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
If an AWX resource is copiable, all of its object detail API views will have a related URL field
|
||||||
|
`"copy"`, which has form `/api/<version>/<resource name>/<object pk>/copy/`. GET to this endpoint
|
||||||
|
will return `can_copy`, which is a boolean indicating whether the current user can execute a copy
|
||||||
|
operation; POST to this endpoint actually copies the resource object. One field `name` is required
|
||||||
|
which will later be used as the name of the created copy. Upon success, 201 will be returned, along
|
||||||
|
with the created copy.
|
||||||
|
|
||||||
|
For some resources like credential, the copy process is not time-consuming, thus the entire copy
|
||||||
|
process will take place in the request-response cycle, and the created object copy is returned as
|
||||||
|
POST response.
|
||||||
|
|
||||||
|
For some other resources like inventory, the copy process can take longer, depending on the number
|
||||||
|
of sub-objects to copy (will explain later). Thus, although the created copy will be returned, the
|
||||||
|
copy process is not finished yet. All sub-objects (like all hosts and groups of an inventory) will
|
||||||
|
not be created until after the background copy task is finished in success.
|
||||||
|
|
||||||
|
Currently the available list of copiable resources are:
|
||||||
|
|
||||||
|
- job templates
|
||||||
|
- projects
|
||||||
|
- inventories
|
||||||
|
- workflows
|
||||||
|
- credentials
|
||||||
|
- notifications
|
||||||
|
- inventory scripts
|
||||||
|
|
||||||
|
For most of the resources above, only the object to be copied itself will be copied; For some resources
|
||||||
|
like inventories, however, sub resources belonging to the resource will also be copied to maintain the
|
||||||
|
full functionality of the copied new resource. In specific:
|
||||||
|
|
||||||
|
- When an inventory is copied, all its hosts, groups and inventory sources are copied.
|
||||||
|
- When a workflow job template is copied, all its workflow job template nodes are copied.
|
||||||
|
|
||||||
|
## How to add a copy end-point for a resource
|
||||||
|
The copy behavior of different resources largely follow the same pattern, therefore a unified way of
|
||||||
|
enabling copy capability for resources is available for developers:
|
||||||
|
|
||||||
|
Firstly, create a `/copy/` url endpoint for the target resource.
|
||||||
|
|
||||||
|
Secondly, create a view class as handler to `/copy/` endpoint. This view class should be subclassed
|
||||||
|
from `awx.api.generics.CopyAPIView`. Here is an example:
|
||||||
|
```python
|
||||||
|
class JobTemplateCopy(CopyAPIView):
|
||||||
|
|
||||||
|
model = JobTemplate
|
||||||
|
copy_return_serializer_class = JobTemplateSerializer
|
||||||
|
```
|
||||||
|
Note the above example declares a custom class attribute `copy_return_serializer_class`. This attribute
|
||||||
|
is used by `CopyAPIView` to render the created copy in POST response, so in most cases the value should
|
||||||
|
be the same as `serializer_class` of corresponding resource detail view, like here the value is the
|
||||||
|
`serializer_class` of `JobTemplateDetail`.
|
||||||
|
|
||||||
|
Thirdly, for the underlying model of the resource, Add 2 macros, `FIELDS_TO_PRESERVE_AT_COPY` and
|
||||||
|
`FIELDS_TO_DISCARD_AT_COPY`, as needed. Here is an example:
|
||||||
|
```python
|
||||||
|
class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin):
|
||||||
|
'''
|
||||||
|
A job template is a reusable job definition for applying a project (with
|
||||||
|
playbook) to an inventory source with a given credential.
|
||||||
|
'''
|
||||||
|
FIELDS_TO_PRESERVE_AT_COPY = [
|
||||||
|
'labels', 'instance_groups', 'credentials', 'survey_spec'
|
||||||
|
]
|
||||||
|
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
|
||||||
|
```
|
||||||
|
When copying a resource object, basically all fields necessary for creating a new resource (fields
|
||||||
|
composing a valid POST body for creating new resources) are extracted from the original object and
|
||||||
|
used to create the copy.
|
||||||
|
|
||||||
|
However, sometimes we need more fields to be copied, like `credentials` of a job template, which
|
||||||
|
cannot be provided during creation. In this case we list such fields in `FIELDS_TO_PRESERVE_AT_COPY`
|
||||||
|
so that these fields won't be missed.
|
||||||
|
|
||||||
|
On the other hand, sometimes we do not want to include some fields provided in create POST body,
|
||||||
|
like `vault_credential` and `credential` fields used for creating a job template, which do not have
|
||||||
|
tangible field correspondence in `JobTemplate` model. In this case we list such fields in
|
||||||
|
`FIELDS_TO_DISCARD_AT_COPY` so that those fields won't be included.
|
||||||
|
|
||||||
|
For models that will be part of a deep copy, like hosts and workflow job template nodes, the related
|
||||||
|
POST body for creating a new object is not available. Therefore all necessary fields for creating
|
||||||
|
a new resource should also be included in `FIELDS_TO_PRESERVE_AT_COPY`.
|
||||||
|
|
||||||
|
Lastly, unit test copy behavior of the new endpoint in `/awx/main/tests/functional/test_copy.py` and
|
||||||
|
update docs (like this doc).
|
||||||
|
|
||||||
|
Fields in `FIELDS_TO_PRESERVE_AT_COPY` must be solid model fields, while fields in
|
||||||
|
`FIELDS_TO_DISCARD_AT_COPY` do not need to be. Note there are hidden fields not visible from model
|
||||||
|
definition, namely reverse relationships and fields inherited from super classes or mix-ins. A help
|
||||||
|
script `tools/scripts/list_fields.py` is available to inspect a model and list details of all its
|
||||||
|
available fields.
|
||||||
|
```
|
||||||
|
# In shell_plus
|
||||||
|
>>> from list_fields import pretty_print_model_fields
|
||||||
|
>>> pretty_print_model_fields(JobTemplate)
|
||||||
|
```
|
||||||
|
|
||||||
|
`CopyAPIView` will automatically detect sub objects of an object, and do a deep copy of all sub objects
|
||||||
|
as a background celery task. There are sometimes permission issues with sub object copy. For example,
|
||||||
|
when copying nodes of a workflow job template, there are cases where the user performing copy has no use
|
||||||
|
permission of related credential and inventory of some nodes, and it is desired those fields will be
|
||||||
|
`None`. In order to do that, developer should provide a static method `deep_copy_permission_check_func`
|
||||||
|
under corresponding specific copy view. Like
|
||||||
|
```python
|
||||||
|
class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, CopyAPIView):
|
||||||
|
|
||||||
|
model = WorkflowJobTemplate
|
||||||
|
copy_return_serializer_class = WorkflowJobTemplateSerializer
|
||||||
|
|
||||||
|
# Other code
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deep_copy_permission_check_func(user, new_objs):
|
||||||
|
# method body
|
||||||
|
|
||||||
|
# Other code
|
||||||
|
```
|
||||||
|
Static method `deep_copy_permission_check_func` must have and only have two arguments: `user`, the
|
||||||
|
user performing the copy; `new_objs`, a list of all sub objects of the created copy. Sub objects in
|
||||||
|
`new_objs` are initially populated disregarding any permission constraints, developer shall check
|
||||||
|
`user`'s permission against these new sub objects and react like unlink related objects or sending
|
||||||
|
warning logs. `deep_copy_permission_check_func` should not return anything.
|
||||||
|
|
||||||
|
Lastly, macro `REENCRYPTION_BLACKLIST_AT_COPY` is available as part of a model definition. It is a
|
||||||
|
list of field names which will escape re-encryption during copy. For example, `extra_data` field
|
||||||
|
of workflow job template nodes.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
* Credentials should be able to copy themselves. The behavior of copying credential A shall be exactly
|
||||||
|
the same as creating a credential B with all needed fields for creation coming from credential A.
|
||||||
|
* Inventories should be able to copy themselves. The behavior of copying inventory A shall be exactly
|
||||||
|
the same as creating an inventory B with all needed fields for creation coming from inventory A. Other
|
||||||
|
than that, inventory B should inherit A's `instance_groups`, and have exactly the same host and group
|
||||||
|
structures as A.
|
||||||
|
* Inventory scripts should be able to copy themselves. The behavior of copying inventory script A
|
||||||
|
shall be exactly the same as creating an inventory script B with all needed fields for creation
|
||||||
|
coming from inventory script A.
|
||||||
|
* Job templates should be able to copy themselves. The behavior of copying job template A
|
||||||
|
shall be exactly the same as creating a job template B with all needed fields for creation
|
||||||
|
coming from job template A. Other than that, job template B should inherit A's `labels`,
|
||||||
|
`instance_groups`, `credentials` and `survey_spec`.
|
||||||
|
* Notification templates should be able to copy themselves. The behavior of copying notification
|
||||||
|
template A shall be exactly the same as creating a notification template B with all needed fields
|
||||||
|
for creation coming from notification template A.
|
||||||
|
* Projects should be able to copy themselves. The behavior of copying project A shall be the
|
||||||
|
same as creating a project B with all needed fields for creation coming from project A, except for
|
||||||
|
`local_path`, which will be populated by triggered project update. Other than that, project B
|
||||||
|
should inherit A's `labels`, `instance_groups` and `credentials`.
|
||||||
|
* Workflow Job templates should be able to copy themselves. The behavior of copying workflow job
|
||||||
|
template A shall be exactly the same as creating a workflow job template B with all needed fields
|
||||||
|
for creation coming from workflow job template A. Other than that, workflow job template B should
|
||||||
|
inherit A's `labels`, `instance_groups`, `credentials` and `survey_spec`, and have exactly the
|
||||||
|
same workflow job template node structure as A.
|
||||||
|
* In all copy processes, `name` field of the created copy of the original object should be able to
|
||||||
|
customize in the POST body.
|
||||||
|
* The permission for a user to make a copy for an existing resource object should be the same as the
|
||||||
|
permission for a user to create a brand new resource object using fields from the existing object.
|
||||||
|
* The RBAC behavior of original workflow job template `/copy/` should be pertained. That is, if the
|
||||||
|
user has no necessary permission to the related project and credential of a workflow job template
|
||||||
|
node, the copied workflow job template node should have those fields empty.
|
||||||
46
tools/scripts/list_fields.py
Executable file
46
tools/scripts/list_fields.py
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
__all__ = ['pretty_print_model_fields']
|
||||||
|
|
||||||
|
|
||||||
|
def _get_class_full_name(cls_):
|
||||||
|
return cls_.__module__ + '.' + cls_.__name__
|
||||||
|
|
||||||
|
|
||||||
|
class _ModelFieldRow(object):
|
||||||
|
|
||||||
|
def __init__(self, field):
|
||||||
|
self.field = field
|
||||||
|
self.name = field.name
|
||||||
|
self.type_ = _get_class_full_name(type(field))
|
||||||
|
if self.field.many_to_many\
|
||||||
|
or self.field.many_to_one\
|
||||||
|
or self.field.one_to_many\
|
||||||
|
or self.field.one_to_one:
|
||||||
|
self.related_model = _get_class_full_name(self.field.remote_field.model)
|
||||||
|
else:
|
||||||
|
self.related_model = 'N/A'
|
||||||
|
|
||||||
|
def pretty_print(self, max_name_len, max_type_len, max_rel_model_len):
|
||||||
|
row = []
|
||||||
|
row.append(self.name)
|
||||||
|
row.append(' ' * (max_name_len - len(self.name)))
|
||||||
|
row.append('|')
|
||||||
|
row.append(self.type_)
|
||||||
|
row.append(' ' * (max_type_len - len(self.type_)))
|
||||||
|
row.append('|')
|
||||||
|
row.append(self.related_model)
|
||||||
|
row.append(' ' * (max_rel_model_len - len(self.related_model)))
|
||||||
|
print(''.join(row))
|
||||||
|
|
||||||
|
|
||||||
|
def pretty_print_model_fields(model):
|
||||||
|
field_info_rows = []
|
||||||
|
max_lens = [0, 0, 0]
|
||||||
|
for field in model._meta.get_fields():
|
||||||
|
field_info_rows.append(_ModelFieldRow(field))
|
||||||
|
max_lens[0] = max(max_lens[0], len(field_info_rows[-1].name))
|
||||||
|
max_lens[1] = max(max_lens[1], len(field_info_rows[-1].type_))
|
||||||
|
max_lens[2] = max(max_lens[2], len(field_info_rows[-1].related_model))
|
||||||
|
print('=' * (sum(max_lens) + len(max_lens) - 1))
|
||||||
|
for row in field_info_rows:
|
||||||
|
row.pretty_print(*max_lens)
|
||||||
|
print('=' * (sum(max_lens) + len(max_lens) - 1))
|
||||||
Reference in New Issue
Block a user