diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 79d30a2c9f..d87ff574cb 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -961,14 +961,27 @@ class UnifiedJobSerializer(BaseSerializer): 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: - 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): field_names = super(UnifiedJobListSerializer, self).get_field_names(declared_fields, info) # Meta multiple inheritance and -field_name options don't seem to be # 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): if type(self) is UnifiedJobListSerializer: diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 1ec0f83134..47b6288959 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -127,6 +127,7 @@ from awx.api.views.mixin import ( RelatedJobsPreventDeleteMixin, UnifiedJobDeletionMixin, NoTruncateMixin, + UnifiedJobExcludeMixin, ) from awx.api.pagination import UnifiedJobEventPagination from awx.main.utils import set_environ @@ -3851,7 +3852,7 @@ class SystemJobTemplateNotificationTemplatesSuccessList(SystemJobTemplateNotific resource_purpose = 'notification templates triggered on system job success' -class JobList(ListAPIView): +class JobList(UnifiedJobExcludeMixin, ListAPIView): model = models.Job serializer_class = serializers.JobListSerializer resource_purpose = 'jobs' @@ -4568,7 +4569,7 @@ class UnifiedJobTemplateList(ListAPIView): resource_purpose = 'unified job templates' -class UnifiedJobList(ListAPIView): +class UnifiedJobList(UnifiedJobExcludeMixin, ListAPIView): model = models.UnifiedJob serializer_class = serializers.UnifiedJobListSerializer search_fields = ('description', 'name', 'job__playbook') diff --git a/awx/api/views/mixin.py b/awx/api/views/mixin.py index beb0aca007..24b5f859ab 100644 --- a/awx/api/views/mixin.py +++ b/awx/api/views/mixin.py @@ -233,3 +233,9 @@ class NoTruncateMixin(object): if self.request.query_params.get('no_truncate'): context.update(no_truncate=True) 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',) diff --git a/awx/main/tests/functional/api/test_unified_jobs_view.py b/awx/main/tests/functional/api/test_unified_jobs_view.py index 9a0955ba80..eaec5016b0 100644 --- a/awx/main/tests/functional/api/test_unified_jobs_view.py +++ b/awx/main/tests/functional/api/test_unified_jobs_view.py @@ -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) url = reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk}) 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 diff --git a/awx/main/tests/unit/api/serializers/test_unified_serializers.py b/awx/main/tests/unit/api/serializers/test_unified_serializers.py index 47451d849a..36558f92cb 100644 --- a/awx/main/tests/unit/api/serializers/test_unified_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_unified_serializers.py @@ -39,7 +39,7 @@ def test_unified_job_detail_exclusive_fields(): For each type, assert that the only fields allowed to be exclusive to 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__(): list_serializer = getattr(serializers, '{}ListSerializer'.format(cls.__name__)) detail_serializer = getattr(serializers, '{}Serializer'.format(cls.__name__))