From 0ebe57cbf4dc008414463f7ddb50079cba40c2c0 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 20 Jan 2023 09:21:59 -0500 Subject: [PATCH] Start on new constructed inventory API view Make the GET function work at most basic level Basic functionality of updating working Add functional test for the GET and PATCH views Add constructed inventory list view for direct creation Add limit field to constructed inventory serializer --- awx/api/serializers.py | 76 +++++++++++++++++++ awx/api/urls/inventory.py | 10 ++- awx/api/urls/urls.py | 3 +- awx/api/views/inventory.py | 19 ++++- awx/api/views/root.py | 1 + .../tests/functional/api/test_inventory.py | 42 ++++++++++ 6 files changed, 148 insertions(+), 3 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5115d51b00..b7475f5bd6 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'