diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 6ed8026960..38eb617db4 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -9,7 +9,7 @@ import operator import re import six import urllib -from collections import OrderedDict +from collections import defaultdict, OrderedDict from datetime import timedelta # OAuth2 @@ -3130,6 +3130,48 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): return summary_fields +class JobDetailSerializer(JobSerializer): + + host_status_counts = serializers.SerializerMethodField( + help_text=_('A count of hosts uniquely assigned to each status.'), + ) + playbook_counts = serializers.SerializerMethodField( + help_text=_('A count of all plays and tasks for the job run.'), + ) + + class Meta: + model = Job + fields = ('*', 'host_status_counts', 'playbook_counts',) + + def get_playbook_counts(self, obj): + task_count = obj.job_events.filter(event='playbook_on_task_start').count() + play_count = obj.job_events.filter(event='playbook_on_play_start').count() + + data = {'play_count': play_count, 'task_count': task_count} + + return data + + def get_host_status_counts(self, obj): + try: + event_data = obj.job_events.only('event_data').get(event='playbook_on_stats').event_data + except JobEvent.DoesNotExist: + event_data = {} + + host_status = {} + host_status_keys = ['skipped', 'ok', 'changed', 'failures', 'dark'] + + for key in host_status_keys: + for host in event_data.get(key, {}): + host_status[host] = key + + host_status_counts = defaultdict(lambda: 0) + + for value in host_status.values(): + host_status_counts[value] += 1 + + return host_status_counts + + class JobCancelSerializer(BaseSerializer): can_cancel = serializers.BooleanField(read_only=True) diff --git a/awx/api/views.py b/awx/api/views.py index 9365062fe0..f7a939e839 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -4080,7 +4080,7 @@ class JobDetail(UnifiedJobDeletionMixin, RetrieveUpdateDestroyAPIView): model = Job metadata_class = JobTypeMetadata - serializer_class = JobSerializer + serializer_class = JobDetailSerializer def update(self, request, *args, **kwargs): obj = self.get_object() diff --git a/awx/main/tests/unit/api/serializers/test_job_serializers.py b/awx/main/tests/unit/api/serializers/test_job_serializers.py index 3c1529cba1..d3fd514ecc 100644 --- a/awx/main/tests/unit/api/serializers/test_job_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_serializers.py @@ -1,4 +1,5 @@ # Python +from collections import namedtuple import pytest import mock import json @@ -7,6 +8,7 @@ from six.moves import xrange # AWX from awx.api.serializers import ( + JobDetailSerializer, JobSerializer, JobOptionsSerializer, ) @@ -14,6 +16,7 @@ from awx.api.serializers import ( from awx.main.models import ( Label, Job, + JobEvent, ) @@ -53,6 +56,7 @@ def jobs(mocker): @mock.patch('awx.api.serializers.UnifiedJobTemplateSerializer.get_related', lambda x,y: {}) @mock.patch('awx.api.serializers.JobOptionsSerializer.get_related', lambda x,y: {}) class TestJobSerializerGetRelated(): + @pytest.mark.parametrize("related_resource_name", [ 'job_events', 'relaunch', @@ -76,6 +80,7 @@ class TestJobSerializerGetRelated(): @mock.patch('awx.api.serializers.BaseSerializer.to_representation', lambda self,obj: { 'extra_vars': obj.extra_vars}) class TestJobSerializerSubstitution(): + def test_survey_password_hide(self, mocker): job = mocker.MagicMock(**{ 'display_extra_vars.return_value': '{\"secret_key\": \"$encrypted$\"}', @@ -90,6 +95,7 @@ class TestJobSerializerSubstitution(): @mock.patch('awx.api.serializers.BaseSerializer.get_summary_fields', lambda x,y: {}) class TestJobOptionsSerializerGetSummaryFields(): + def test__summary_field_labels_10_max(self, mocker, job_template, labels): job_template.labels.all = mocker.MagicMock(**{'return_value': labels}) @@ -101,3 +107,45 @@ class TestJobOptionsSerializerGetSummaryFields(): def test_labels_exists(self, test_get_summary_fields, job_template): test_get_summary_fields(JobOptionsSerializer, job_template, 'labels') + + +class TestJobDetailSerializerGetHostStatusCountFields(object): + + def test_hosts_are_counted_once(self, job, mocker): + mock_event = JobEvent(**{ + 'event': 'playbook_on_stats', + 'event_data': { + 'skipped': { + 'localhost': 2, + 'fiz': 1, + }, + 'ok': { + 'localhost': 1, + 'foo': 2, + }, + 'changed': { + 'localhost': 1, + 'bar': 3, + }, + 'dark': { + 'localhost': 2, + 'fiz': 2, + } + } + }) + + mock_qs = namedtuple('mock_qs', ['get'])(mocker.MagicMock(return_value=mock_event)) + job.job_events.only = mocker.MagicMock(return_value=mock_qs) + + serializer = JobDetailSerializer() + host_status_counts = serializer.get_host_status_counts(job) + + assert host_status_counts == {'ok': 1, 'changed': 1, 'dark': 2} + + def test_host_status_counts_is_empty_dict_without_stats_event(self, job, mocker): + job.job_events = JobEvent.objects.none() + + serializer = JobDetailSerializer() + host_status_counts = serializer.get_host_status_counts(job) + + assert host_status_counts == {}