diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 380f633580..c27e6e9649 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -35,7 +35,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ # Django REST Framework -from rest_framework.exceptions import APIException, PermissionDenied, ParseError, NotFound +from rest_framework.exceptions import APIException, PermissionDenied, ParseError, NotFound, ValidationError from rest_framework.parsers import FormParser from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer, StaticHTMLRenderer @@ -1872,6 +1872,8 @@ class CredentialTypeExternalTest(OIDCCredentialTestMixin, SubDetailAPIView): class HostRelatedSearchMixin(object): + rest_filters_reserved_names = ('last_job_host_summary__failed', 'not__last_job_host_summary__failed') + @property def related_search_fields(self): # Edge-case handle: https://github.com/ansible/ansible-tower/issues/7712 @@ -1879,6 +1881,39 @@ class HostRelatedSearchMixin(object): ret.append('ansible_facts') return ret + def _validate_host_latest_summary_filter_value(self, param_name, value): + """Latest-summary host filters only support the documented ``?param=True`` form.""" + if value != 'True': + raise ValidationError({param_name: _('filter value must be True')}) + + def _apply_latest_summary_failed_filter(self, qs): + """Filter hosts by whether their latest JobHostSummary failed. + + Honors ``last_job_host_summary__failed`` and ``not__last_job_host_summary__failed`` + with the same AND semantics as other list query string filters. + """ + params = self.request.query_params + failed_param = params.get('last_job_host_summary__failed', None) + not_failed_param = params.get('not__last_job_host_summary__failed', None) + if failed_param is None and not_failed_param is None: + return qs + if failed_param is not None: + self._validate_host_latest_summary_filter_value('last_job_host_summary__failed', failed_param) + if not_failed_param is not None: + self._validate_host_latest_summary_filter_value('not__last_job_host_summary__failed', not_failed_param) + latest_failed = Subquery(models.JobHostSummary.objects.filter(host_id=OuterRef('pk')).order_by('-id').values('failed')[:1]) + qs = qs.annotate(_latest_failed=latest_failed) + if failed_param is not None: + qs = qs.filter(_latest_failed=True) + if not_failed_param is not None: + qs = qs.filter(_latest_failed=False) + return qs + + def _with_host_list_queryset(self, qs): + """Apply latest-summary list filters and serializer annotations.""" + qs = self._apply_latest_summary_failed_filter(qs) + return qs.with_latest_summary_id() + class HostMetricList(ListAPIView): name = _("Host Metrics List") @@ -1935,7 +1970,7 @@ class HostList(HostRelatedSearchMixin, ListCreateAPIView): filter_qs = SmartFilter.query_from_string(filter_string) qs &= filter_qs qs = qs.distinct() - return qs.with_latest_summary_id() + return self._with_host_list_queryset(qs) def list(self, *args, **kwargs): try: @@ -1987,7 +2022,7 @@ class InventoryHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIVie resource_purpose = 'hosts of an inventory' def get_queryset(self): - return super().get_queryset().with_latest_summary_id() + return self._with_host_list_queryset(super().get_queryset()) class HostGroupsList(SubListCreateAttachDetachAPIView): @@ -2173,7 +2208,7 @@ class GroupHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIView): resource_purpose = 'hosts of a group' def get_queryset(self): - return super().get_queryset().with_latest_summary_id() + return self._with_host_list_queryset(super().get_queryset()) def update_raw_data(self, data): data.pop('inventory', None) @@ -2206,7 +2241,7 @@ class GroupAllHostsList(HostRelatedSearchMixin, SubListAPIView): self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model).distinct() # need distinct for '&' operator sublist_qs = parent.all_hosts.distinct() - return (qs & sublist_qs).with_latest_summary_id() + return self._with_host_list_queryset(qs & sublist_qs) class GroupInventorySourcesList(SubListAPIView): diff --git a/awx/main/tests/functional/api/test_host_filter.py b/awx/main/tests/functional/api/test_host_filter.py index fdbf9b6f79..6dd837cdb9 100644 --- a/awx/main/tests/functional/api/test_host_filter.py +++ b/awx/main/tests/functional/api/test_host_filter.py @@ -3,7 +3,7 @@ import urllib.parse from awx.api.versioning import reverse -from awx.main.models import Organization, Host, Group, Inventory +from awx.main.models import Organization, Host, Group, Inventory, Job, JobEvent @pytest.fixture @@ -271,3 +271,122 @@ def test_invalid_host_filter(get, admin_user, invalid_filter): params = "?host_filter=%s" % urllib.parse.quote(invalid_filter, safe='') response = get(url + params, admin_user) assert response.status_code == 400 + + +@pytest.fixture +def hosts_with_job_summaries(): + """Three hosts: one with a failed last job, one with a successful last job, one with no job.""" + org = Organization.objects.create(name='summary-org') + inv = Inventory.objects.create(name='summary-inv', organization=org) + host_failed = Host.objects.create(name='host-failed', inventory=inv) + host_ok = Host.objects.create(name='host-ok', inventory=inv) + Host.objects.create(name='host-no-job', inventory=inv) + group = Group.objects.create(name='summary-group', inventory=inv) + group.hosts.add(host_failed, host_ok) + + job = Job(inventory=inv) + job.save() + host_map = {h.name: h.id for h in inv.hosts.all()} + JobEvent.create_from_data( + job_id=job.pk, + parent_uuid='abc123', + event='playbook_on_stats', + event_data={ + 'ok': {host_ok.name: 1}, + 'changed': {}, + 'dark': {}, + 'failures': {host_failed.name: 1}, + 'ignored': {}, + 'processed': {}, + 'rescued': {}, + 'skipped': {}, + }, + host_map=host_map, + ).save() + + return {'inventory': inv, 'group': group} + + +@pytest.mark.django_db +def test_last_job_host_summary_failed_filter(hosts_with_job_summaries, get, admin_user): + """?last_job_host_summary__failed=True returns only hosts whose latest job summary is failed.""" + url = reverse('api:host_list') + '?last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + names = get_host_names(response) + assert names == ['host-failed'] + + +@pytest.mark.django_db +def test_not_last_job_host_summary_failed_filter(hosts_with_job_summaries, get, admin_user): + """?not__last_job_host_summary__failed=True returns only hosts whose latest job summary is not failed.""" + url = reverse('api:host_list') + '?not__last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + names = get_host_names(response) + assert names == ['host-ok'] + + +@pytest.mark.django_db +def test_inventory_last_job_host_summary_failed_filter(hosts_with_job_summaries, get, admin_user): + inv = hosts_with_job_summaries['inventory'] + url = reverse('api:inventory_hosts_list', kwargs={'pk': inv.pk}) + '?last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + names = get_host_names(response) + assert names == ['host-failed'] + + +@pytest.mark.django_db +def test_inventory_not_last_job_host_summary_failed_filter(hosts_with_job_summaries, get, admin_user): + inv = hosts_with_job_summaries['inventory'] + url = reverse('api:inventory_hosts_list', kwargs={'pk': inv.pk}) + '?not__last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + names = get_host_names(response) + assert names == ['host-ok'] + + +@pytest.mark.django_db +def test_group_all_hosts_last_job_host_summary_failed_filter(hosts_with_job_summaries, get, admin_user): + group = hosts_with_job_summaries['group'] + url = reverse('api:group_all_hosts_list', kwargs={'pk': group.pk}) + '?last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + names = get_host_names(response) + assert names == ['host-failed'] + + +@pytest.mark.django_db +def test_group_all_hosts_not_last_job_host_summary_failed_filter(hosts_with_job_summaries, get, admin_user): + group = hosts_with_job_summaries['group'] + url = reverse('api:group_all_hosts_list', kwargs={'pk': group.pk}) + '?not__last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + names = get_host_names(response) + assert names == ['host-ok'] + + +@pytest.mark.django_db +def test_group_hosts_last_job_host_summary_failed_filter(hosts_with_job_summaries, get, admin_user): + group = hosts_with_job_summaries['group'] + url = reverse('api:group_hosts_list', kwargs={'pk': group.pk}) + '?last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + names = get_host_names(response) + assert names == ['host-failed'] + + +@pytest.mark.django_db +def test_last_job_host_summary_failed_filter_invalid_value(hosts_with_job_summaries, get, admin_user): + url = reverse('api:host_list') + '?last_job_host_summary__failed=False' + response = get(url, admin_user) + assert response.status_code == 400 + + +@pytest.mark.django_db +def test_last_job_host_summary_failed_filter_both_params(hosts_with_job_summaries, get, admin_user): + url = reverse('api:host_list') + '?last_job_host_summary__failed=True¬__last_job_host_summary__failed=True' + response = get(url, admin_user) + assert response.status_code == 200 + assert get_host_names(response) == []