feat: remove extra_vars from jobs and unified_jobs list endpoint. Add… (#16461)

feat: add exclude query parameter to unified_jobs and jobs list endpoints

Allow clients to exclude heavy fields (artifacts, extra_vars) from list
responses via ?exclude=artifacts,extra_vars. These fields are now included
by default instead of being stripped.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Braun
2026-07-08 17:05:41 +02:00
committed by GitHub
parent 9d5bf22f90
commit d0576d7823
5 changed files with 144 additions and 5 deletions

View File

@@ -961,14 +961,27 @@ class UnifiedJobSerializer(BaseSerializer):
class UnifiedJobListSerializer(UnifiedJobSerializer): class UnifiedJobListSerializer(UnifiedJobSerializer):
OPTIONAL_EXCLUDE_FIELDS = frozenset({'artifacts', 'extra_vars'})
_ALWAYS_STRIPPED_FIELDS = frozenset({'job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished'})
class Meta: class Meta:
fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished', '-artifacts') fields = ('*', '-job_args', '-job_cwd', '-job_env', '-result_traceback', '-event_processing_finished')
def _requested_excludes(self):
request = self.context.get('request')
if request is None:
return frozenset()
raw = request.query_params.get('exclude', '')
requested = {name.strip() for name in raw.split(',') if name.strip()}
return frozenset(requested) & self.OPTIONAL_EXCLUDE_FIELDS
def get_field_names(self, declared_fields, info): def get_field_names(self, declared_fields, info):
field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info) field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info)
# Meta multiple inheritance and -field_name options don't seem to be # Meta multiple inheritance and -field_name options don't seem to be
# taking effect above, so remove the undesired fields here. # taking effect above, so remove the undesired fields here.
return tuple(x for x in field_names if x not in ('job_args', 'job_cwd', 'job_env', 'result_traceback', 'event_processing_finished', 'artifacts')) strip = self._ALWAYS_STRIPPED_FIELDS | self._requested_excludes()
return tuple(x for x in field_names if x not in strip)
def get_types(self): def get_types(self):
if type(self) is UnifiedJobListSerializer: if type(self) is UnifiedJobListSerializer:

View File

@@ -127,6 +127,7 @@ from awx.api.views.mixin import (
RelatedJobsPreventDeleteMixin, RelatedJobsPreventDeleteMixin,
UnifiedJobDeletionMixin, UnifiedJobDeletionMixin,
NoTruncateMixin, NoTruncateMixin,
UnifiedJobExcludeMixin,
) )
from awx.api.pagination import UnifiedJobEventPagination from awx.api.pagination import UnifiedJobEventPagination
from awx.main.utils import set_environ from awx.main.utils import set_environ
@@ -3851,7 +3852,7 @@ class SystemJobTemplateNotificationTemplatesSuccessList(SystemJobTemplateNotific
resource_purpose = 'notification templates triggered on system job success' resource_purpose = 'notification templates triggered on system job success'
class JobList(ListAPIView): class JobList(UnifiedJobExcludeMixin, ListAPIView):
model = models.Job model = models.Job
serializer_class = serializers.JobListSerializer serializer_class = serializers.JobListSerializer
resource_purpose = 'jobs' resource_purpose = 'jobs'
@@ -4568,7 +4569,7 @@ class UnifiedJobTemplateList(ListAPIView):
resource_purpose = 'unified job templates' resource_purpose = 'unified job templates'
class UnifiedJobList(ListAPIView): class UnifiedJobList(UnifiedJobExcludeMixin, ListAPIView):
model = models.UnifiedJob model = models.UnifiedJob
serializer_class = serializers.UnifiedJobListSerializer serializer_class = serializers.UnifiedJobListSerializer
search_fields = ('description', 'name', 'job__playbook') search_fields = ('description', 'name', 'job__playbook')

View File

@@ -233,3 +233,9 @@ class NoTruncateMixin(object):
if self.request.query_params.get('no_truncate'): if self.request.query_params.get('no_truncate'):
context.update(no_truncate=True) context.update(no_truncate=True)
return context return context
class UnifiedJobExcludeMixin(object):
# Reserve the name 'exclude' so we can use it as a query param. Otherwise, the rest-filters backend
# would treat it as a model field lookup.
rest_filters_reserved_names = ('exclude',)

View File

@@ -145,3 +145,122 @@ def test_delete_ad_hoc_command_in_active_state(ad_hoc_command_factory, delete, a
adhoc = ad_hoc_command_factory(initial_state=status) adhoc = ad_hoc_command_factory(initial_state=status)
url = reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk}) url = reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk})
delete(url, None, admin, expect=403) delete(url, None, admin, expect=403)
@pytest.fixture
def job_with_heavy_fields(job_factory):
job = job_factory()
job.extra_vars = '{"some_var": "some_value"}'
job.artifacts = {"some_artifact": "some_value"}
job.save()
return job
def _job_result(response, job_id):
for row in response.data['results']:
if row['id'] == job_id:
return row
raise AssertionError('job {} not found in {}'.format(job_id, [r['id'] for r in response.data['results']]))
@pytest.mark.django_db
def test_unified_jobs_list_includes_heavy_fields_by_default(get, admin, job_with_heavy_fields):
response = get(reverse('api:unified_job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' in row
assert 'extra_vars' in row
@pytest.mark.django_db
def test_unified_jobs_list_exclude_artifacts(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&exclude=artifacts'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' not in row
assert 'extra_vars' in row
@pytest.mark.django_db
def test_unified_jobs_list_exclude_extra_vars(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&exclude=extra_vars'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'extra_vars' not in row
assert 'artifacts' in row
@pytest.mark.django_db
def test_unified_jobs_list_exclude_both(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&exclude=artifacts,extra_vars'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' not in row
assert 'extra_vars' not in row
@pytest.mark.django_db
def test_unified_jobs_list_exclude_tolerates_whitespace(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&exclude=%20artifacts%20,%20extra_vars%20'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' not in row
assert 'extra_vars' not in row
@pytest.mark.django_db
def test_unified_jobs_list_exclude_ignores_unknown(get, admin, job_with_heavy_fields):
response = get(
reverse('api:unified_job_list') + '?id={}&exclude=does_not_exist'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' in row
assert 'extra_vars' in row
@pytest.mark.django_db
def test_unified_jobs_list_exclude_does_not_honor_always_stripped(get, admin, job_with_heavy_fields):
# Always-stripped fields like event_processing_finished, job_args, result_traceback
# must remain stripped regardless of the ?exclude= param — they cannot be re-included.
response = get(
reverse('api:unified_job_list') + '?id={}'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'event_processing_finished' not in row
assert 'job_args' not in row
assert 'result_traceback' not in row
@pytest.mark.django_db
def test_jobs_list_includes_heavy_fields_by_default(get, admin, job_with_heavy_fields):
response = get(reverse('api:job_list') + '?id={}'.format(job_with_heavy_fields.id), admin, expect=200)
row = _job_result(response, job_with_heavy_fields.id)
assert 'artifacts' in row
assert 'extra_vars' in row
@pytest.mark.django_db
def test_jobs_list_exclude_extra_vars(get, admin, job_with_heavy_fields):
response = get(
reverse('api:job_list') + '?id={}&exclude=extra_vars'.format(job_with_heavy_fields.id),
admin,
expect=200,
)
row = _job_result(response, job_with_heavy_fields.id)
assert 'extra_vars' not in row
assert 'artifacts' in row

View File

@@ -39,7 +39,7 @@ def test_unified_job_detail_exclusive_fields():
For each type, assert that the only fields allowed to be exclusive to For each type, assert that the only fields allowed to be exclusive to
detail view are the allowed types detail view are the allowed types
""" """
allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished', 'artifacts')) allowed_detail_fields = frozenset(('result_traceback', 'job_args', 'job_cwd', 'job_env', 'event_processing_finished'))
for cls in UnifiedJob.__subclasses__(): for cls in UnifiedJob.__subclasses__():
list_serializer = getattr(serializers, '{}ListSerializer'.format(cls.__name__)) list_serializer = getattr(serializers, '{}ListSerializer'.format(cls.__name__))
detail_serializer = getattr(serializers, '{}Serializer'.format(cls.__name__)) detail_serializer = getattr(serializers, '{}Serializer'.format(cls.__name__))