diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 6c5e4711f6..07e6af7f64 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1105,6 +1105,7 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer): cancel = self.reverse('api:project_update_cancel', kwargs={'pk': obj.pk}), scm_inventory_updates = self.reverse('api:project_update_scm_inventory_updates', kwargs={'pk': obj.pk}), notifications = self.reverse('api:project_update_notifications_list', kwargs={'pk': obj.pk}), + events = self.reverse('api:project_update_events_list', kwargs={'pk': obj.pk}), )) return res @@ -1726,6 +1727,7 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri res.update(dict( cancel = self.reverse('api:inventory_update_cancel', kwargs={'pk': obj.pk}), notifications = self.reverse('api:inventory_update_notifications_list', kwargs={'pk': obj.pk}), + events = self.reverse('api:inventory_update_events_list', kwargs={'pk': obj.pk}), )) if obj.source_project_update_id: res['source_project_update'] = self.reverse('api:project_update_detail', @@ -2962,6 +2964,7 @@ class SystemJobSerializer(UnifiedJobSerializer): res['notifications'] = self.reverse('api:system_job_notifications_list', kwargs={'pk': obj.pk}) if obj.can_cancel or True: res['cancel'] = self.reverse('api:system_job_cancel', kwargs={'pk': obj.pk}) + res['events'] = self.reverse('api:system_job_events_list', kwargs={'pk': obj.pk}) return res def get_result_stdout(self, obj): @@ -3415,6 +3418,41 @@ class JobEventWebSocketSerializer(JobEventSerializer): return 'job_events' +class ProjectUpdateEventSerializer(JobEventSerializer): + + class Meta: + model = ProjectUpdateEvent + fields = ('*', '-name', '-description', '-job', '-job_id', + '-parent_uuid', '-parent', '-host', 'project_update') + + def get_related(self, obj): + res = super(JobEventSerializer, self).get_related(obj) + res['project_update'] = self.reverse( + 'api:project_update_detail', kwargs={'pk': obj.project_update_id} + ) + return res + + +class ProjectUpdateEventWebSocketSerializer(ProjectUpdateEventSerializer): + created = serializers.SerializerMethodField() + modified = serializers.SerializerMethodField() + event_name = serializers.CharField(source='event') + group_name = serializers.SerializerMethodField() + + class Meta: + model = ProjectUpdateEvent + fields = ('*', 'event_name', 'group_name',) + + def get_created(self, obj): + return obj.created.isoformat() + + def get_modified(self, obj): + return obj.modified.isoformat() + + def get_group_name(self, obj): + return 'project_update_events' + + class AdHocCommandEventSerializer(BaseSerializer): event_display = serializers.CharField(source='get_event_display', read_only=True) @@ -3474,6 +3512,76 @@ class AdHocCommandEventWebSocketSerializer(AdHocCommandEventSerializer): return 'ad_hoc_command_events' +class InventoryUpdateEventSerializer(AdHocCommandEventSerializer): + + class Meta: + model = InventoryUpdateEvent + fields = ('*', '-name', '-description', '-ad_hoc_command', '-host', + '-host_name', 'inventory_update') + + def get_related(self, obj): + res = super(AdHocCommandEventSerializer, self).get_related(obj) + res['inventory_update'] = self.reverse( + 'api:inventory_update_detail', kwargs={'pk': obj.inventory_update_id} + ) + return res + + +class InventoryUpdateEventWebSocketSerializer(InventoryUpdateEventSerializer): + created = serializers.SerializerMethodField() + modified = serializers.SerializerMethodField() + event_name = serializers.CharField(source='event') + group_name = serializers.SerializerMethodField() + + class Meta: + model = InventoryUpdateEvent + fields = ('*', 'event_name', 'group_name',) + + def get_created(self, obj): + return obj.created.isoformat() + + def get_modified(self, obj): + return obj.modified.isoformat() + + def get_group_name(self, obj): + return 'inventory_update_events' + + +class SystemJobEventSerializer(AdHocCommandEventSerializer): + + class Meta: + model = SystemJobEvent + fields = ('*', '-name', '-description', '-ad_hoc_command', '-host', + '-host_name', 'system_job') + + def get_related(self, obj): + res = super(AdHocCommandEventSerializer, self).get_related(obj) + res['system_job'] = self.reverse( + 'api:system_job_detail', kwargs={'pk': obj.system_job_id} + ) + return res + + +class SystemJobEventWebSocketSerializer(SystemJobEventSerializer): + created = serializers.SerializerMethodField() + modified = serializers.SerializerMethodField() + event_name = serializers.CharField(source='event') + group_name = serializers.SerializerMethodField() + + class Meta: + model = SystemJobEvent + fields = ('*', 'event_name', 'group_name',) + + def get_created(self, obj): + return obj.created.isoformat() + + def get_modified(self, obj): + return obj.modified.isoformat() + + def get_group_name(self, obj): + return 'system_job_events' + + class JobLaunchSerializer(BaseSerializer): # Representational fields diff --git a/awx/api/urls/inventory_update.py b/awx/api/urls/inventory_update.py index 2636f846a8..5d68831d7b 100644 --- a/awx/api/urls/inventory_update.py +++ b/awx/api/urls/inventory_update.py @@ -9,6 +9,7 @@ from awx.api.views import ( InventoryUpdateCancel, InventoryUpdateStdout, InventoryUpdateNotificationsList, + InventoryUpdateEventsList, ) @@ -18,6 +19,7 @@ urls = [ url(r'^(?P[0-9]+)/cancel/$', InventoryUpdateCancel.as_view(), name='inventory_update_cancel'), url(r'^(?P[0-9]+)/stdout/$', InventoryUpdateStdout.as_view(), name='inventory_update_stdout'), url(r'^(?P[0-9]+)/notifications/$', InventoryUpdateNotificationsList.as_view(), name='inventory_update_notifications_list'), + url(r'^(?P[0-9]+)/events/$', InventoryUpdateEventsList.as_view(), name='inventory_update_events_list'), ] __all__ = ['urls'] diff --git a/awx/api/urls/project_update.py b/awx/api/urls/project_update.py index 2f77f20718..03356602ca 100644 --- a/awx/api/urls/project_update.py +++ b/awx/api/urls/project_update.py @@ -10,6 +10,7 @@ from awx.api.views import ( ProjectUpdateStdout, ProjectUpdateScmInventoryUpdates, ProjectUpdateNotificationsList, + ProjectUpdateEventsList, ) @@ -20,6 +21,7 @@ urls = [ url(r'^(?P[0-9]+)/stdout/$', ProjectUpdateStdout.as_view(), name='project_update_stdout'), url(r'^(?P[0-9]+)/scm_inventory_updates/$', ProjectUpdateScmInventoryUpdates.as_view(), name='project_update_scm_inventory_updates'), url(r'^(?P[0-9]+)/notifications/$', ProjectUpdateNotificationsList.as_view(), name='project_update_notifications_list'), + url(r'^(?P[0-9]+)/events/$', ProjectUpdateEventsList.as_view(), name='project_update_events_list'), ] __all__ = ['urls'] diff --git a/awx/api/urls/system_job.py b/awx/api/urls/system_job.py index 1d7ca6e105..b95d1d7329 100644 --- a/awx/api/urls/system_job.py +++ b/awx/api/urls/system_job.py @@ -8,6 +8,7 @@ from awx.api.views import ( SystemJobDetail, SystemJobCancel, SystemJobNotificationsList, + SystemJobEventsList ) @@ -16,6 +17,7 @@ urls = [ url(r'^(?P[0-9]+)/$', SystemJobDetail.as_view(), name='system_job_detail'), url(r'^(?P[0-9]+)/cancel/$', SystemJobCancel.as_view(), name='system_job_cancel'), url(r'^(?P[0-9]+)/notifications/$', SystemJobNotificationsList.as_view(), name='system_job_notifications_list'), + url(r'^(?P[0-9]+)/events/$', SystemJobEventsList.as_view(), name='system_job_events_list'), ] __all__ = ['urls'] diff --git a/awx/api/views.py b/awx/api/views.py index 08af3c8658..df4511024b 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1366,6 +1366,45 @@ class ProjectUpdateDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): new_in_13 = True +class ProjectUpdateEventsList(SubListAPIView): + + model = ProjectUpdateEvent + serializer_class = ProjectUpdateEventSerializer + parent_model = ProjectUpdate + relationship = 'project_update_events' + view_name = _('Project Update Events List') + + def finalize_response(self, request, response, *args, **kwargs): + response['X-UI-Max-Events'] = settings.MAX_UI_JOB_EVENTS + return super(ProjectUpdateEventsList, self).finalize_response(request, response, *args, **kwargs) + + +class SystemJobEventsList(SubListAPIView): + + model = SystemJobEvent + serializer_class = SystemJobEventSerializer + parent_model = SystemJob + relationship = 'system_job_events' + view_name = _('System Job Events List') + + def finalize_response(self, request, response, *args, **kwargs): + response['X-UI-Max-Events'] = settings.MAX_UI_JOB_EVENTS + return super(SystemJobEventsList, self).finalize_response(request, response, *args, **kwargs) + + +class InventoryUpdateEventsList(SubListAPIView): + + model = InventoryUpdateEvent + serializer_class = InventoryUpdateEventSerializer + parent_model = InventoryUpdate + relationship = 'inventory_update_events' + view_name = _('Inventory Update Events List') + + def finalize_response(self, request, response, *args, **kwargs): + response['X-UI-Max-Events'] = settings.MAX_UI_JOB_EVENTS + return super(InventoryUpdateEventsList, self).finalize_response(request, response, *args, **kwargs) + + class ProjectUpdateCancel(RetrieveAPIView): model = ProjectUpdate diff --git a/awx/main/access.py b/awx/main/access.py index 3630bde216..174396e59b 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1987,6 +1987,64 @@ class JobEventAccess(BaseAccess): return False +class ProjectUpdateEventAccess(BaseAccess): + ''' + I can see project update event records whenever I can access the project update + ''' + + model = ProjectUpdateEvent + + def filtered_queryset(self): + return self.model.objects.filter( + Q(project_update__in=ProjectUpdate.accessible_pk_qs(self.user, 'read_role'))) + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + + +class InventoryUpdateEventAccess(BaseAccess): + ''' + I can see inventory update event records whenever I can access the inventory update + ''' + + model = InventoryUpdateEvent + + def filtered_queryset(self): + return self.model.objects.filter( + Q(inventory_update__in=InventoryUpdate.accessible_pk_qs(self.user, 'read_role'))) + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + + +class SystemJobEventAccess(BaseAccess): + ''' + I can only see manage System Jobs events if I'm a super user + ''' + model = SystemJobEvent + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + + class UnifiedJobTemplateAccess(BaseAccess): ''' I can see a unified job template whenever I can see the same project, diff --git a/awx/main/management/commands/replay_job_events.py b/awx/main/management/commands/replay_job_events.py index 83b3f1741c..47ff723678 100644 --- a/awx/main/management/commands/replay_job_events.py +++ b/awx/main/management/commands/replay_job_events.py @@ -12,11 +12,17 @@ from awx.main.models import ( UnifiedJob, Job, AdHocCommand, + ProjectUpdate, + InventoryUpdate, + SystemJob ) from awx.main.consumers import emit_channel_notification from awx.api.serializers import ( JobEventWebSocketSerializer, AdHocCommandEventWebSocketSerializer, + ProjectUpdateEventWebSocketSerializer, + InventoryUpdateEventWebSocketSerializer, + SystemJobEventWebSocketSerializer ) @@ -60,7 +66,16 @@ class ReplayJobEvents(): return self.replay_elapsed().total_seconds() - (self.recording_elapsed(created).total_seconds() * (1.0 / speed)) def get_job_events(self, job): - job_events = job.job_events.order_by('created') + if type(job) is Job: + job_events = job.job_events.order_by('created') + elif type(job) is AdHocCommand: + job_events = job.ad_hoc_command_events.order_by('created') + elif type(job) is ProjectUpdate: + job_events = job.project_update_events.order_by('created') + elif type(job) is InventoryUpdate: + job_events = job.inventory_update_events.order_by('created') + elif type(job) is SystemJob: + job_events = job.system_job_events.order_by('created') if job_events.count() == 0: raise RuntimeError("No events for job id {}".format(job.id)) return job_events @@ -70,6 +85,12 @@ class ReplayJobEvents(): return JobEventWebSocketSerializer elif type(job) is AdHocCommand: return AdHocCommandEventWebSocketSerializer + elif type(job) is ProjectUpdate: + return ProjectUpdateEventWebSocketSerializer + elif type(job) is InventoryUpdate: + return InventoryUpdateEventWebSocketSerializer + elif type(job) is SystemJob: + return SystemJobEventWebSocketSerializer else: raise RuntimeError("Job is of type {} and replay is not yet supported.".format(type(job))) sys.exit(1) diff --git a/awx/main/signals.py b/awx/main/signals.py index f3e1138a10..bfb5b768cd 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -24,7 +24,7 @@ from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_gr from awx.main.tasks import update_inventory_computed_fields from awx.main.fields import is_implicit_parent -from awx.main.consumers import emit_channel_notification +from awx.main import consumers from awx.conf.utils import conf_to_dict @@ -48,7 +48,7 @@ def emit_job_event_detail(sender, **kwargs): created = kwargs['created'] if created: event_serialized = JobEventWebSocketSerializer(instance).data - emit_channel_notification('job_events-' + str(instance.job.id), event_serialized) + consumers.emit_channel_notification('job_events-' + str(instance.job.id), event_serialized) def emit_ad_hoc_command_event_detail(sender, **kwargs): @@ -56,7 +56,31 @@ def emit_ad_hoc_command_event_detail(sender, **kwargs): created = kwargs['created'] if created: event_serialized = AdHocCommandEventWebSocketSerializer(instance).data - emit_channel_notification('ad_hoc_command_events-' + str(instance.ad_hoc_command_id), event_serialized) + consumers.emit_channel_notification('ad_hoc_command_events-' + str(instance.ad_hoc_command_id), event_serialized) + + +def emit_project_update_event_detail(sender, **kwargs): + instance = kwargs['instance'] + created = kwargs['created'] + if created: + event_serialized = ProjectUpdateEventWebSocketSerializer(instance).data + consumers.emit_channel_notification('project_update_events-' + str(instance.project_update_id), event_serialized) + + +def emit_inventory_update_event_detail(sender, **kwargs): + instance = kwargs['instance'] + created = kwargs['created'] + if created: + event_serialized = InventoryUpdateEventWebSocketSerializer(instance).data + consumers.emit_channel_notification('inventory_update_events-' + str(instance.inventory_update_id), event_serialized) + + +def emit_system_job_event_detail(sender, **kwargs): + instance = kwargs['instance'] + created = kwargs['created'] + if created: + event_serialized = SystemJobEventWebSocketSerializer(instance).data + consumers.emit_channel_notification('system_job_events-' + str(instance.system_job_id), event_serialized) def emit_update_inventory_computed_fields(sender, **kwargs): @@ -222,6 +246,9 @@ connect_computed_field_signals() post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) +post_save.connect(emit_project_update_event_detail, sender=ProjectUpdateEvent) +post_save.connect(emit_inventory_update_event_detail, sender=InventoryUpdateEvent) +post_save.connect(emit_system_job_event_detail, sender=SystemJobEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) m2m_changed.connect(org_admin_edit_members, Role.members.through) m2m_changed.connect(rbac_activity_stream, Role.members.through) diff --git a/awx/main/tests/functional/models/test_events.py b/awx/main/tests/functional/models/test_events.py new file mode 100644 index 0000000000..b5f16e75ce --- /dev/null +++ b/awx/main/tests/functional/models/test_events.py @@ -0,0 +1,69 @@ +import mock +import pytest + +from awx.main.models import (Job, JobEvent, ProjectUpdate, ProjectUpdateEvent, + AdHocCommand, AdHocCommandEvent, InventoryUpdate, + InventorySource, InventoryUpdateEvent, SystemJob, + SystemJobEvent) + + +@pytest.mark.django_db +@mock.patch('awx.main.consumers.emit_channel_notification') +def test_job_event_websocket_notifications(emit): + j = Job(id=123) + j.save() + JobEvent.create_from_data(job_id=j.pk) + assert len(emit.call_args_list) == 1 + topic, payload = emit.call_args_list[0][0] + assert topic == 'job_events-123' + assert payload['job'] == 123 + + +@pytest.mark.django_db +@mock.patch('awx.main.consumers.emit_channel_notification') +def test_ad_hoc_event_websocket_notifications(emit): + ahc = AdHocCommand(id=123) + ahc.save() + AdHocCommandEvent.create_from_data(ad_hoc_command_id=ahc.pk) + assert len(emit.call_args_list) == 1 + topic, payload = emit.call_args_list[0][0] + assert topic == 'ad_hoc_command_events-123' + assert payload['ad_hoc_command'] == 123 + + +@pytest.mark.django_db +@mock.patch('awx.main.consumers.emit_channel_notification') +def test_project_update_event_websocket_notifications(emit, project): + pu = ProjectUpdate(id=123, project=project) + pu.save() + ProjectUpdateEvent.create_from_data(project_update_id=pu.pk) + assert len(emit.call_args_list) == 1 + topic, payload = emit.call_args_list[0][0] + assert topic == 'project_update_events-123' + assert payload['project_update'] == 123 + + +@pytest.mark.django_db +@mock.patch('awx.main.consumers.emit_channel_notification') +def test_inventory_update_event_websocket_notifications(emit, inventory): + source = InventorySource() + source.save() + iu = InventoryUpdate(id=123, inventory_source=source) + iu.save() + InventoryUpdateEvent.create_from_data(inventory_update_id=iu.pk) + assert len(emit.call_args_list) == 1 + topic, payload = emit.call_args_list[0][0] + assert topic == 'inventory_update_events-123' + assert payload['inventory_update'] == 123 + + +@pytest.mark.django_db +@mock.patch('awx.main.consumers.emit_channel_notification') +def test_system_job_event_websocket_notifications(emit, inventory): + j = SystemJob(id=123) + j.save() + SystemJobEvent.create_from_data(system_job_id=j.pk) + assert len(emit.call_args_list) == 1 + topic, payload = emit.call_args_list[0][0] + assert topic == 'system_job_events-123' + assert payload['system_job'] == 123 diff --git a/awx/main/tests/unit/models/test_events.py b/awx/main/tests/unit/models/test_events.py new file mode 100644 index 0000000000..71be98a167 --- /dev/null +++ b/awx/main/tests/unit/models/test_events.py @@ -0,0 +1,46 @@ +from datetime import datetime +from django.utils.timezone import utc +import mock +import pytest + +from awx.main.models import (JobEvent, ProjectUpdateEvent, AdHocCommandEvent, + InventoryUpdateEvent, SystemJobEvent) + + +@pytest.mark.parametrize('job_identifier, cls', [ + ['job_id', JobEvent], + ['project_update_id', ProjectUpdateEvent], + ['ad_hoc_command_id', AdHocCommandEvent], + ['inventory_update_id', InventoryUpdateEvent], + ['system_job_id', SystemJobEvent], +]) +@pytest.mark.parametrize('created', [ + datetime(2018, 1, 1).isoformat(), datetime(2018, 1, 1) +]) +def test_event_parse_created(job_identifier, cls, created): + with mock.patch.object(cls, 'objects') as manager: + cls.create_from_data(**{ + job_identifier: 123, + 'created': created + }) + expected_created = datetime(2018, 1, 1).replace(tzinfo=utc) + manager.create.assert_called_with(**{ + job_identifier: 123, + 'created': expected_created + }) + + +@pytest.mark.parametrize('job_identifier, cls', [ + ['job_id', JobEvent], + ['project_update_id', ProjectUpdateEvent], + ['ad_hoc_command_id', AdHocCommandEvent], + ['inventory_update_id', InventoryUpdateEvent], + ['system_job_id', SystemJobEvent], +]) +def test_playbook_event_strip_invalid_keys(job_identifier, cls): + with mock.patch.object(cls, 'objects') as manager: + cls.create_from_data(**{ + job_identifier: 123, + 'extra_key': 'extra_value' + }) + manager.create.assert_called_with(**{job_identifier: 123})