diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 305734cf7c..d7e0e90066 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1715,6 +1715,7 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) if obj.kind == 'constructed': res['source_inventories'] = self.reverse('api:inventory_source_inventories', kwargs={'pk': obj.pk}) + res['url'] = self.reverse('api:constructed_inventory_detail', kwargs={'pk': obj.pk}) return res def to_representation(self, obj): @@ -1756,6 +1757,81 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): return super(InventorySerializer, self).validate(attrs) +class ConstructedFieldMixin(serializers.Field): + def get_attribute(self, instance): + if not hasattr(instance, '_constructed_inv_src'): + instance._constructed_inv_src = instance.inventory_sources.first() + inv_src = instance._constructed_inv_src + return super().get_attribute(inv_src) # yoink + + +class ConstructedCharField(ConstructedFieldMixin, serializers.CharField): + pass + + +class ConstructedIntegerField(ConstructedFieldMixin, serializers.IntegerField): + pass + + +class ConstructedInventorySerializer(InventorySerializer): + source_vars = ConstructedCharField( + required=False, + default=None, + allow_blank=True, + help_text=_('The source_vars for the related auto-created inventory source, special to constructed inventory.'), + ) + update_cache_timeout = ConstructedIntegerField( + required=False, + allow_null=True, + min_value=0, + default=None, + help_text=_('The cache timeout for the related auto-created inventory source, special to constructed inventory'), + ) + limit = ConstructedCharField( + required=False, + default=None, + allow_blank=True, + help_text=_('The limit to restrict the returned hosts for the related auto-created inventory source, special to constructed inventory.'), + ) + + class Meta: + model = Inventory + fields = ('*', '-host_filter', 'source_vars', 'update_cache_timeout', 'limit') + + def pop_inv_src_data(self, data): + inv_src_data = {} + for field in ('source_vars', 'update_cache_timeout', 'limit'): + if field in data: + # values always need to be removed, as they are not valid for Inventory model + value = data.pop(field) + # null is not valid for any of those fields, taken as not-provided + if value is not None: + inv_src_data[field] = value + return inv_src_data + + def apply_inv_src_data(self, inventory, inv_src_data): + if inv_src_data: + update_fields = [] + inv_src = inventory.inventory_sources.first() + for field, value in inv_src_data.items(): + setattr(inv_src, field, value) + update_fields.append(field) + if update_fields: + inv_src.save(update_fields=update_fields) + + def create(self, validated_data): + inv_src_data = self.pop_inv_src_data(validated_data) + inventory = super().create(validated_data) + self.apply_inv_src_data(inventory, inv_src_data) + return inventory + + def update(self, obj, validated_data): + inv_src_data = self.pop_inv_src_data(validated_data) + obj = super().update(obj, validated_data) + self.apply_inv_src_data(obj, inv_src_data) + return obj + + class InventoryScriptSerializer(InventorySerializer): class Meta: fields = () diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index c7d3592c93..3be18d5249 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -6,6 +6,8 @@ from django.urls import re_path from awx.api.views.inventory import ( InventoryList, InventoryDetail, + ConstructedInventoryDetail, + ConstructedInventoryList, InventoryActivityStreamList, InventorySourceInventoriesList, InventoryJobTemplateList, @@ -50,4 +52,10 @@ urls = [ re_path(r'^(?P[0-9]+)/copy/$', InventoryCopy.as_view(), name='inventory_copy'), ] -__all__ = ['urls'] +# Constructed inventory special views +constructed_inventory_urls = [ + re_path(r'^$', ConstructedInventoryList.as_view(), name='constructed_inventory_list'), + re_path(r'^(?P[0-9]+)/$', ConstructedInventoryDetail.as_view(), name='constructed_inventory_detail'), +] + +__all__ = ['urls', 'constructed_inventory_urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 5d0818b191..6db1b75287 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -39,7 +39,7 @@ from .organization import urls as organization_urls from .user import urls as user_urls from .project import urls as project_urls from .project_update import urls as project_update_urls -from .inventory import urls as inventory_urls +from .inventory import urls as inventory_urls, constructed_inventory_urls from .execution_environments import urls as execution_environment_urls from .team import urls as team_urls from .host import urls as host_urls @@ -110,6 +110,7 @@ v2_urls = [ re_path(r'^project_updates/', include(project_update_urls)), re_path(r'^teams/', include(team_urls)), re_path(r'^inventories/', include(inventory_urls)), + re_path(r'^constructed_inventories/', include(constructed_inventory_urls)), re_path(r'^hosts/', include(host_urls)), re_path(r'^groups/', include(group_urls)), re_path(r'^inventory_sources/', include(inventory_source_urls)), diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 7490fba796..a33097a725 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -31,6 +31,7 @@ from awx.api.views.labels import LabelSubListCreateAttachDetachView from awx.api.serializers import ( InventorySerializer, + ConstructedInventorySerializer, ActivityStreamSerializer, RoleSerializer, InstanceGroupSerializer, @@ -82,7 +83,9 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie # Do not allow changes to an Inventory kind. if kind is not None and obj.kind != kind: - return Response(dict(error=_('You cannot turn a regular inventory into a "smart" inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED) + return Response( + dict(error=_('You cannot turn a regular inventory into a "smart" or "constructed" inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED + ) return super(InventoryDetail, self).update(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): @@ -97,6 +100,20 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie return Response(dict(error=_("{0}".format(e))), status=status.HTTP_400_BAD_REQUEST) +class ConstructedInventoryDetail(InventoryDetail): + + serializer_class = ConstructedInventorySerializer + + +class ConstructedInventoryList(InventoryList): + + serializer_class = ConstructedInventorySerializer + + def get_queryset(self): + r = super().get_queryset() + return r.filter(kind='constructed') + + class InventorySourceInventoriesList(SubListAttachDetachAPIView): model = Inventory serializer_class = InventorySerializer diff --git a/awx/api/views/root.py b/awx/api/views/root.py index d879e4537e..f5b2457dcc 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -101,6 +101,7 @@ class ApiVersionRootView(APIView): data['tokens'] = reverse('api:o_auth2_token_list', request=request) data['metrics'] = reverse('api:metrics_view', request=request) data['inventory'] = reverse('api:inventory_list', request=request) + data['constructed_inventory'] = reverse('api:constructed_inventory_list', request=request) data['inventory_sources'] = reverse('api:inventory_source_list', request=request) data['inventory_updates'] = reverse('api:inventory_update_list', request=request) data['groups'] = reverse('api:group_list', request=request) diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index 80357a22f9..c8139a340a 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -594,3 +594,45 @@ class TestControlledBySCM: rando, expect=403, ) + + +@pytest.mark.django_db +class TestConstructedInventory: + @pytest.fixture + def constructed_inventory(self, organization): + return Inventory.objects.create(name='constructed-test-inventory', kind='constructed', organization=organization) + + def test_get_constructed_inventory(self, constructed_inventory, admin_user, get): + inv_src = constructed_inventory.inventory_sources.first() + inv_src.update_cache_timeout = 53 + inv_src.save(update_fields=['update_cache_timeout']) + r = get(url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), user=admin_user, expect=200) + assert r.data['update_cache_timeout'] == 53 + + def test_patch_constructed_inventory(self, constructed_inventory, admin_user, patch): + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 0 + assert inv_src.limit == '' + r = patch( + url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), + data=dict(update_cache_timeout=54, limit='foobar'), + user=admin_user, + expect=200, + ) + assert r.data['update_cache_timeout'] == 54 + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 54 + assert inv_src.limit == 'foobar' + + def test_create_constructed_inventory(self, constructed_inventory, admin_user, post, organization): + r = post( + url=reverse('api:constructed_inventory_list'), + data=dict(name='constructed-inventory-just-created', kind='constructed', organization=organization.id, update_cache_timeout=55, limit='foobar'), + user=admin_user, + expect=201, + ) + pk = r.data['id'] + constructed_inventory = Inventory.objects.get(pk=pk) + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 55 + assert inv_src.limit == 'foobar'