mirror of
https://github.com/ansible/awx.git
synced 2026-10-29 22:03:20 -02:30
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:
@@ -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¬__last_job_host_summary__failed=True'
|
||||
response = get(url, admin_user)
|
||||
assert response.status_code == 200
|
||||
assert get_host_names(response) == []
|
||||
|
||||
Reference in New Issue
Block a user