mirror of
https://github.com/ansible/awx.git
synced 2026-05-07 09:27:36 -02:30
Merge pull request #1690 from chrismeyersfsu/fix-labels_disassociate
delete label on last disassociate
This commit is contained in:
@@ -25,6 +25,7 @@ from rest_framework import views
|
|||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
|
from awx.main.models import Label
|
||||||
from awx.main.utils import * # noqa
|
from awx.main.utils import * # noqa
|
||||||
from awx.api.serializers import ResourceAccessListElementSerializer
|
from awx.api.serializers import ResourceAccessListElementSerializer
|
||||||
|
|
||||||
@@ -35,7 +36,8 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView',
|
|||||||
'RetrieveUpdateDestroyAPIView', 'DestroyAPIView',
|
'RetrieveUpdateDestroyAPIView', 'DestroyAPIView',
|
||||||
'SubDetailAPIView',
|
'SubDetailAPIView',
|
||||||
'ResourceAccessList',
|
'ResourceAccessList',
|
||||||
'ParentMixin',]
|
'ParentMixin',
|
||||||
|
'DeleteLastUnattachLabelMixin',]
|
||||||
|
|
||||||
logger = logging.getLogger('awx.api.generics')
|
logger = logging.getLogger('awx.api.generics')
|
||||||
|
|
||||||
@@ -399,12 +401,15 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView):
|
|||||||
else:
|
else:
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
def unattach(self, request, *args, **kwargs):
|
def unattach_validate(self, request):
|
||||||
sub_id = request.data.get('id', None)
|
sub_id = request.data.get('id', None)
|
||||||
|
res = None
|
||||||
if not sub_id:
|
if not sub_id:
|
||||||
data = dict(msg='"id" is required to disassociate')
|
data = dict(msg='"id" is required to disassociate')
|
||||||
return Response(data, status=status.HTTP_400_BAD_REQUEST)
|
res = Response(data, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return (sub_id, res)
|
||||||
|
|
||||||
|
def unattach_by_id(self, request, sub_id):
|
||||||
parent = self.get_parent_object()
|
parent = self.get_parent_object()
|
||||||
parent_key = getattr(self, 'parent_key', None)
|
parent_key = getattr(self, 'parent_key', None)
|
||||||
relationship = getattrd(parent, self.relationship)
|
relationship = getattrd(parent, self.relationship)
|
||||||
@@ -421,6 +426,12 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView):
|
|||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def unattach(self, request, *args, **kwargs):
|
||||||
|
(sub_id, res) = self.unattach_validate(request)
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
return self.unattach_by_id(request, sub_id)
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if not isinstance(request.data, dict):
|
if not isinstance(request.data, dict):
|
||||||
return Response('invalid type for post data',
|
return Response('invalid type for post data',
|
||||||
@@ -430,6 +441,21 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView):
|
|||||||
else:
|
else:
|
||||||
return self.attach(request, *args, **kwargs)
|
return self.attach(request, *args, **kwargs)
|
||||||
|
|
||||||
|
class DeleteLastUnattachLabelMixin(object):
|
||||||
|
def unattach(self, request, *args, **kwargs):
|
||||||
|
(sub_id, res) = super(DeleteLastUnattachLabelMixin, self).unattach_validate(request, *args, **kwargs)
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
|
||||||
|
res = super(DeleteLastUnattachLabelMixin, self).unattach_by_id(request, sub_id)
|
||||||
|
|
||||||
|
label = Label.objects.get(id=sub_id)
|
||||||
|
|
||||||
|
if label.is_detached():
|
||||||
|
label.delete()
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
class SubDetailAPIView(generics.RetrieveAPIView, GenericAPIView, ParentMixin):
|
class SubDetailAPIView(generics.RetrieveAPIView, GenericAPIView, ParentMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -2282,7 +2282,7 @@ class JobTemplateNotifiersSuccessList(SubListCreateAttachDetachAPIView):
|
|||||||
parent_model = JobTemplate
|
parent_model = JobTemplate
|
||||||
relationship = 'notifiers_success'
|
relationship = 'notifiers_success'
|
||||||
|
|
||||||
class JobTemplateLabelList(SubListCreateAttachDetachAPIView):
|
class JobTemplateLabelList(SubListCreateAttachDetachAPIView, DeleteLastUnattachLabelMixin):
|
||||||
|
|
||||||
model = Label
|
model = Label
|
||||||
serializer_class = LabelSerializer
|
serializer_class = LabelSerializer
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models.base import CommonModelNameNotUnique
|
from awx.main.models.base import CommonModelNameNotUnique
|
||||||
|
from awx.main.models.unified_jobs import UnifiedJobTemplate, UnifiedJob
|
||||||
|
|
||||||
__all__ = ('Label', )
|
__all__ = ('Label', )
|
||||||
|
|
||||||
@@ -39,3 +40,19 @@ class Label(CommonModelNameNotUnique):
|
|||||||
jobtemplate_labels__isnull=True
|
jobtemplate_labels__isnull=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_detached(self):
|
||||||
|
return bool(
|
||||||
|
Label.objects.filter(
|
||||||
|
id=self.id,
|
||||||
|
unifiedjob_labels__isnull=True,
|
||||||
|
unifiedjobtemplate_labels__isnull=True
|
||||||
|
).count())
|
||||||
|
|
||||||
|
def is_candidate_for_detach(self):
|
||||||
|
c1 = UnifiedJob.objects.filter(labels__in=[self.id]).count()
|
||||||
|
c2 = UnifiedJobTemplate.objects.filter(labels__in=[self.id]).count()
|
||||||
|
if (c1 + c2 - 1) == 0:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ def org_admin_edit_members(instance, action, model, reverse, pk_set, **kwargs):
|
|||||||
if action == 'pre_remove':
|
if action == 'pre_remove':
|
||||||
instance.content_object.admin_role.children.remove(user.admin_role)
|
instance.content_object.admin_role.children.remove(user.admin_role)
|
||||||
|
|
||||||
|
def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs):
|
||||||
|
for l in instance.labels.all():
|
||||||
|
if l.is_candidate_for_detach():
|
||||||
|
l.delete()
|
||||||
|
|
||||||
post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host)
|
post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host)
|
||||||
post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host)
|
post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host)
|
||||||
@@ -175,7 +179,8 @@ m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through)
|
|||||||
m2m_changed.connect(org_admin_edit_members, Role.members.through)
|
m2m_changed.connect(org_admin_edit_members, Role.members.through)
|
||||||
post_save.connect(sync_superuser_status_to_rbac, sender=User)
|
post_save.connect(sync_superuser_status_to_rbac, sender=User)
|
||||||
post_save.connect(create_user_role, sender=User)
|
post_save.connect(create_user_role, sender=User)
|
||||||
|
pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJob)
|
||||||
|
pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJobTemplate)
|
||||||
|
|
||||||
# Migrate hosts, groups to parent group(s) whenever a group is deleted
|
# Migrate hosts, groups to parent group(s) whenever a group is deleted
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
|
||||||
# Python
|
# Python
|
||||||
import pytest
|
import pytest
|
||||||
|
import mock
|
||||||
|
|
||||||
# DRF
|
# DRF
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.api.generics import ParentMixin, SubListCreateAttachDetachAPIView
|
from awx.api.generics import ParentMixin, SubListCreateAttachDetachAPIView, DeleteLastUnattachLabelMixin
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def get_object_or_404(mocker):
|
def get_object_or_404(mocker):
|
||||||
@@ -37,7 +38,7 @@ def parent_relationship_factory(mocker):
|
|||||||
return (serializer, mock_parent_relationship)
|
return (serializer, mock_parent_relationship)
|
||||||
return rf
|
return rf
|
||||||
|
|
||||||
# TODO: Test create and associate failure (i.e. id doesn't exist or record already exists)
|
# TODO: Test create and associate failure (i.e. id doesn't exist, record already exists, permission denied)
|
||||||
# TODO: Mock and check return (Response)
|
# TODO: Mock and check return (Response)
|
||||||
class TestSubListCreateAttachDetachAPIView:
|
class TestSubListCreateAttachDetachAPIView:
|
||||||
def test_attach_create_and_associate(self, mocker, get_object_or_400, parent_relationship_factory, mock_response_new):
|
def test_attach_create_and_associate(self, mocker, get_object_or_400, parent_relationship_factory, mock_response_new):
|
||||||
@@ -65,6 +66,97 @@ class TestSubListCreateAttachDetachAPIView:
|
|||||||
mock_parent_relationship.wife.add.assert_called_with(get_object_or_400.return_value)
|
mock_parent_relationship.wife.add.assert_called_with(get_object_or_400.return_value)
|
||||||
mock_response_new.assert_called_with(Response, status=status.HTTP_204_NO_CONTENT)
|
mock_response_new.assert_called_with(Response, status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
def test_unattach_validate_ok(self, mocker):
|
||||||
|
mock_request = mocker.MagicMock(data=dict(id=1))
|
||||||
|
serializer = SubListCreateAttachDetachAPIView()
|
||||||
|
|
||||||
|
(sub_id, res) = serializer.unattach_validate(mock_request)
|
||||||
|
|
||||||
|
assert sub_id == 1
|
||||||
|
assert res is None
|
||||||
|
|
||||||
|
def test_unattach_validate_missing_id(self, mocker):
|
||||||
|
mock_request = mocker.MagicMock(data=dict())
|
||||||
|
serializer = SubListCreateAttachDetachAPIView()
|
||||||
|
|
||||||
|
(sub_id, res) = serializer.unattach_validate(mock_request)
|
||||||
|
|
||||||
|
assert sub_id is None
|
||||||
|
assert type(res) is Response
|
||||||
|
|
||||||
|
def test_unattach_by_id_ok(self, mocker, parent_relationship_factory, get_object_or_400):
|
||||||
|
(serializer, mock_parent_relationship) = parent_relationship_factory(SubListCreateAttachDetachAPIView, 'wife')
|
||||||
|
mock_request = mocker.MagicMock()
|
||||||
|
mock_sub = mocker.MagicMock(name="object to unattach")
|
||||||
|
get_object_or_400.return_value = mock_sub
|
||||||
|
|
||||||
|
res = serializer.unattach_by_id(mock_request, 1)
|
||||||
|
|
||||||
|
assert type(res) is Response
|
||||||
|
assert res.status_code == status.HTTP_204_NO_CONTENT
|
||||||
|
mock_parent_relationship.wife.remove.assert_called_with(mock_sub)
|
||||||
|
|
||||||
|
def test_unattach_ok(self, mocker):
|
||||||
|
mock_request = mocker.MagicMock()
|
||||||
|
mock_sub_id = mocker.MagicMock()
|
||||||
|
view = SubListCreateAttachDetachAPIView()
|
||||||
|
view.unattach_validate = mocker.MagicMock()
|
||||||
|
view.unattach_by_id = mocker.MagicMock()
|
||||||
|
view.unattach_validate.return_value = (mock_sub_id, None)
|
||||||
|
|
||||||
|
view.unattach(mock_request)
|
||||||
|
|
||||||
|
view.unattach_validate.assert_called_with(mock_request)
|
||||||
|
view.unattach_by_id.assert_called_with(mock_request, mock_sub_id)
|
||||||
|
|
||||||
|
def test_unattach_invalid(self, mocker):
|
||||||
|
mock_request = mocker.MagicMock()
|
||||||
|
mock_res = mocker.MagicMock()
|
||||||
|
view = SubListCreateAttachDetachAPIView()
|
||||||
|
view.unattach_validate = mocker.MagicMock()
|
||||||
|
view.unattach_by_id = mocker.MagicMock()
|
||||||
|
view.unattach_validate.return_value = (None, mock_res)
|
||||||
|
|
||||||
|
view.unattach(mock_request)
|
||||||
|
|
||||||
|
view.unattach_validate.assert_called_with(mock_request)
|
||||||
|
view.unattach_by_id.assert_not_called()
|
||||||
|
|
||||||
|
class TestDeleteLastUnattachLabelMixin:
|
||||||
|
@mock.patch('awx.api.generics.super')
|
||||||
|
def test_unattach_ok(self, super, mocker):
|
||||||
|
mock_request = mocker.MagicMock()
|
||||||
|
mock_sub_id = mocker.MagicMock()
|
||||||
|
super.return_value = super
|
||||||
|
super.unattach_validate = mocker.MagicMock(return_value=(mock_sub_id, None))
|
||||||
|
super.unattach_by_id = mocker.MagicMock()
|
||||||
|
mock_label = mocker.patch('awx.api.generics.Label')
|
||||||
|
mock_label.objects.get.return_value = mock_label
|
||||||
|
mock_label.is_detached.return_value = True
|
||||||
|
|
||||||
|
view = DeleteLastUnattachLabelMixin()
|
||||||
|
|
||||||
|
view.unattach(mock_request, None, None)
|
||||||
|
|
||||||
|
super.unattach_validate.assert_called_with(mock_request, None, None)
|
||||||
|
super.unattach_by_id.assert_called_with(mock_request, mock_sub_id)
|
||||||
|
mock_label.is_detached.assert_called_with()
|
||||||
|
mock_label.objects.get.assert_called_with(id=mock_sub_id)
|
||||||
|
mock_label.delete.assert_called_with()
|
||||||
|
|
||||||
|
@mock.patch('awx.api.generics.super')
|
||||||
|
def test_unattach_fail(self, super, mocker):
|
||||||
|
mock_request = mocker.MagicMock()
|
||||||
|
mock_response = mocker.MagicMock()
|
||||||
|
super.return_value = super
|
||||||
|
super.unattach_validate = mocker.MagicMock(return_value=(None, mock_response))
|
||||||
|
view = DeleteLastUnattachLabelMixin()
|
||||||
|
|
||||||
|
res = view.unattach(mock_request, None, None)
|
||||||
|
|
||||||
|
super.unattach_validate.assert_called_with(mock_request, None, None)
|
||||||
|
assert mock_response == res
|
||||||
|
|
||||||
class TestParentMixin:
|
class TestParentMixin:
|
||||||
def test_get_parent_object(self, mocker, get_object_or_404):
|
def test_get_parent_object(self, mocker, get_object_or_404):
|
||||||
parent_mixin = ParentMixin()
|
parent_mixin = ParentMixin()
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
from awx.main.models.label import Label
|
from awx.main.models.label import Label
|
||||||
|
from awx.main.models.unified_jobs import UnifiedJobTemplate, UnifiedJob
|
||||||
|
|
||||||
|
|
||||||
def test_get_orphaned_labels(mocker):
|
def test_get_orphaned_labels(mocker):
|
||||||
mock_query_set = mocker.MagicMock()
|
mock_query_set = mocker.MagicMock()
|
||||||
@@ -8,3 +12,54 @@ def test_get_orphaned_labels(mocker):
|
|||||||
|
|
||||||
assert mock_query_set == ret
|
assert mock_query_set == ret
|
||||||
Label.objects.filter.assert_called_with(organization=None, jobtemplate_labels__isnull=True)
|
Label.objects.filter.assert_called_with(organization=None, jobtemplate_labels__isnull=True)
|
||||||
|
|
||||||
|
def test_is_detached(mocker):
|
||||||
|
mock_query_set = mocker.MagicMock()
|
||||||
|
Label.objects.filter = mocker.MagicMock(return_value=mock_query_set)
|
||||||
|
mock_query_set.count.return_value = 1
|
||||||
|
|
||||||
|
label = Label(id=37)
|
||||||
|
ret = label.is_detached()
|
||||||
|
|
||||||
|
assert ret is True
|
||||||
|
Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True)
|
||||||
|
mock_query_set.count.assert_called_with()
|
||||||
|
|
||||||
|
def test_is_detached_not(mocker):
|
||||||
|
mock_query_set = mocker.MagicMock()
|
||||||
|
Label.objects.filter = mocker.MagicMock(return_value=mock_query_set)
|
||||||
|
mock_query_set.count.return_value = 0
|
||||||
|
|
||||||
|
label = Label(id=37)
|
||||||
|
ret = label.is_detached()
|
||||||
|
|
||||||
|
assert ret is False
|
||||||
|
Label.objects.filter.assert_called_with(id=37, unifiedjob_labels__isnull=True, unifiedjobtemplate_labels__isnull=True)
|
||||||
|
mock_query_set.count.assert_called_with()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("jt_count,j_count,expected", [
|
||||||
|
(1, 0, True),
|
||||||
|
(0, 1, True),
|
||||||
|
(1, 1, False),
|
||||||
|
])
|
||||||
|
def test_is_candidate_for_detach(mocker, jt_count, j_count, expected):
|
||||||
|
mock_job_qs = mocker.MagicMock()
|
||||||
|
mock_job_qs.count = mocker.MagicMock(return_value=j_count)
|
||||||
|
UnifiedJob.objects = mocker.MagicMock()
|
||||||
|
UnifiedJob.objects.filter = mocker.MagicMock(return_value=mock_job_qs)
|
||||||
|
|
||||||
|
mock_jt_qs = mocker.MagicMock()
|
||||||
|
mock_jt_qs.count = mocker.MagicMock(return_value=jt_count)
|
||||||
|
UnifiedJobTemplate.objects = mocker.MagicMock()
|
||||||
|
UnifiedJobTemplate.objects.filter = mocker.MagicMock(return_value=mock_jt_qs)
|
||||||
|
|
||||||
|
label = Label(id=37)
|
||||||
|
ret = label.is_candidate_for_detach()
|
||||||
|
|
||||||
|
UnifiedJob.objects.filter.assert_called_with(labels__in=[label.id])
|
||||||
|
UnifiedJobTemplate.objects.filter.assert_called_with(labels__in=[label.id])
|
||||||
|
mock_job_qs.count.assert_called_with()
|
||||||
|
mock_jt_qs.count.assert_called_with()
|
||||||
|
|
||||||
|
assert ret is expected
|
||||||
|
|
||||||
|
|||||||
17
awx/main/tests/unit/test_signals.py
Normal file
17
awx/main/tests/unit/test_signals.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from awx.main import signals
|
||||||
|
|
||||||
|
class TestCleanupDetachedLabels:
|
||||||
|
def test_cleanup_detached_labels_on_deleted_parent(self, mocker):
|
||||||
|
mock_labels = [mocker.MagicMock(), mocker.MagicMock()]
|
||||||
|
mock_instance = mocker.MagicMock()
|
||||||
|
mock_instance.labels.all = mocker.MagicMock()
|
||||||
|
mock_instance.labels.all.return_value = mock_labels
|
||||||
|
mock_labels[0].is_candidate_for_detach.return_value = True
|
||||||
|
mock_labels[1].is_candidate_for_detach.return_value = False
|
||||||
|
|
||||||
|
signals.cleanup_detached_labels_on_deleted_parent(None, mock_instance)
|
||||||
|
|
||||||
|
mock_labels[0].is_candidate_for_detach.assert_called_with()
|
||||||
|
mock_labels[1].is_candidate_for_detach.assert_called_with()
|
||||||
|
mock_labels[0].delete.assert_called_with()
|
||||||
|
mock_labels[1].delete.assert_not_called()
|
||||||
Reference in New Issue
Block a user