Add custom last_job_host_summary__failed filter to all host list endpoints (#16699)

* Add custom last_job_host_summary__failed filter to HostList

The denormalized last_job_host_summary FK was removed (AAP-68024), so
filtering by ?last_job_host_summary__failed=True on /v2/hosts/ would
400 or return stale results. Add custom handling that annotates the
queryset with a Subquery on JobHostSummary (same pattern used by the
dashboard counts) and reserves the param names from FieldLookupBackend
so the generic filter skips them.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Extend last_job_host_summary failed filter to inventory and group host lists

Build on AlanCoding's HostList filter (ansible/awx#16698) by moving the
Subquery-based latest-summary logic into HostRelatedSearchMixin so
InventoryHostsList, GroupHostsList, and GroupAllHostsList honor the same
query params as the global hosts list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address review: validate filter values and apply both summary filters

Require True for last_job_host_summary failed query params, apply failed
and not-failed filters independently (AND semantics), add GroupHostsList
coverage, and document mixin helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lgalis
2026-10-14 17:56:14 -04:00
committed by GitHub
co-authored by Cursor
parent e5227b4601
commit 1ac181813a
2 changed files with 160 additions and 6 deletions
+40 -5
View File
@@ -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):
@@ -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&not__last_job_host_summary__failed=True'
response = get(url, admin_user)
assert response.status_code == 200
assert get_host_names(response) == []