From b88b1111bd46fa1d20b96a5f4393d3e88be83b3c Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 1 Jul 2019 16:32:38 -0400 Subject: [PATCH 01/57] Add workflow pause/approve node --- awx/main/models/workflow.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index d413f05666..26cc1424df 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -38,7 +38,8 @@ from awx.main.fields import JSONField from copy import copy from urllib.parse import urljoin -__all__ = ['WorkflowJobTemplate', 'WorkflowJob', 'WorkflowJobOptions', 'WorkflowJobNode', 'WorkflowJobTemplateNode',] +__all__ = ['WorkflowJobTemplate', 'WorkflowJob', 'WorkflowJobOptions', 'WorkflowJobNode', + 'WorkflowJobTemplateNode', 'WorkflowApprovalTemplate', 'WorkflowApproval'] logger = logging.getLogger('awx.main.models.workflow') @@ -600,3 +601,23 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio # WorkflowJobs don't _actually_ run anything in the dispatcher, so # there's no point in asking the dispatcher if it knows about this task return self.status == 'running' + + +class WorkflowApprovalTemplate(UnifiedJobTemplate): + + class Meta: + app_label = 'main' + + @classmethod + def _get_unified_job_class(cls): + return WorkflowApproval + + @classmethod + def _get_unified_job_field_names(cls): + return ['name', 'description'] + + +class WorkflowApproval(UnifiedJob): + + class Meta: + app_label = 'main' From 72a65f74fd5799d58679c5ebc0d1c9bcc180dc83 Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 1 Jul 2019 16:43:48 -0400 Subject: [PATCH 02/57] Add migration file --- .../migrations/0082_v360_workflowapproval.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 awx/main/migrations/0082_v360_workflowapproval.py diff --git a/awx/main/migrations/0082_v360_workflowapproval.py b/awx/main/migrations/0082_v360_workflowapproval.py new file mode 100644 index 0000000000..7e8b03bd88 --- /dev/null +++ b/awx/main/migrations/0082_v360_workflowapproval.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-07-01 19:41 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0081_v360_notify_on_start'), + ] + + operations = [ + migrations.CreateModel( + name='WorkflowApproval', + fields=[ + ('unifiedjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='main.UnifiedJob')), + ], + options={ + 'manager_inheritance_from_future': True, + }, + bases=('main.unifiedjob',), + ), + migrations.CreateModel( + name='WorkflowApprovalTemplate', + fields=[ + ('unifiedjobtemplate_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='main.UnifiedJobTemplate')), + ], + options={ + 'manager_inheritance_from_future': True, + }, + bases=('main.unifiedjobtemplate',), + ), + ] From 9024a514a62e1fa9132558566cea74168579804b Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 3 Jul 2019 14:46:59 -0400 Subject: [PATCH 03/57] Add API endpoints for workflow approvals --- awx/api/serializers.py | 71 ++++++++++++-- awx/api/urls/urls.py | 5 + awx/api/urls/workflow_approval.py | 21 +++++ awx/api/urls/workflow_approval_template.py | 28 ++++++ awx/api/views/__init__.py | 94 +++++++++++++++++++ awx/api/views/root.py | 2 + awx/main/access.py | 46 +++++++++ .../migrations/0082_v360_workflowapproval.py | 7 +- awx/main/models/__init__.py | 3 +- awx/main/models/workflow.py | 38 +++++++- .../functional/models/test_unified_job.py | 6 +- 11 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 awx/api/urls/workflow_approval.py create mode 100644 awx/api/urls/workflow_approval_template.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5f38158cda..977c076d51 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -50,16 +50,16 @@ from awx.main.constants import ( CENSOR_VALUE, ) from awx.main.models import ( - ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, - CredentialInputSource, CredentialType, CustomInventoryScript, - Group, Host, Instance, InstanceGroup, Inventory, InventorySource, - InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, - JobLaunchConfig, JobNotificationMixin, JobTemplate, Label, Notification, - NotificationTemplate, OAuth2AccessToken, OAuth2Application, Organization, - Project, ProjectUpdate, ProjectUpdateEvent, RefreshToken, Role, Schedule, - StdoutMaxBytesExceeded, SystemJob, SystemJobEvent, SystemJobTemplate, - Team, UnifiedJob, UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, - WorkflowJobTemplate, WorkflowJobTemplateNode + ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource, + CredentialType, CustomInventoryScript, Group, Host, Instance, + InstanceGroup, Inventory, InventorySource, InventoryUpdate, + InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig, + JobNotificationMixin, JobTemplate, Label, Notification, NotificationTemplate, + OAuth2AccessToken, OAuth2Application, Organization, Project, + ProjectUpdate, ProjectUpdateEvent, RefreshToken, Role, Schedule, + SystemJob, SystemJobEvent, SystemJobTemplate, Team, UnifiedJob, + UnifiedJobTemplate, WorkflowApproval, WorkflowApprovalTemplate, WorkflowJob, + WorkflowJobNode, WorkflowJobTemplate, WorkflowJobTemplateNode, StdoutMaxBytesExceeded ) from awx.main.models.base import VERBOSITY_CHOICES, NEW_JOB_TYPE_CHOICES from awx.main.models.rbac import ( @@ -681,6 +681,8 @@ class UnifiedJobTemplateSerializer(BaseSerializer): serializer_class = SystemJobTemplateSerializer elif isinstance(obj, WorkflowJobTemplate): serializer_class = WorkflowJobTemplateSerializer + elif isinstance(obj, WorkflowApprovalTemplate): + serializer_class = WorkflowApprovalTemplateSerializer return serializer_class def to_representation(self, obj): @@ -782,6 +784,8 @@ class UnifiedJobSerializer(BaseSerializer): serializer_class = SystemJobSerializer elif isinstance(obj, WorkflowJob): serializer_class = WorkflowJobSerializer + elif isinstance(obj, WorkflowApproval): + serializer_class = WorkflowApprovalSerializer return serializer_class def to_representation(self, obj): @@ -838,6 +842,8 @@ class UnifiedJobListSerializer(UnifiedJobSerializer): serializer_class = SystemJobListSerializer elif isinstance(obj, WorkflowJob): serializer_class = WorkflowJobListSerializer + elif isinstance(obj, WorkflowApproval): + serializer_class = WorkflowApprovalListSerializer return serializer_class def to_representation(self, obj): @@ -3395,6 +3401,51 @@ class WorkflowJobCancelSerializer(WorkflowJobSerializer): fields = ('can_cancel',) +class WorkflowApprovalSerializer(UnifiedJobSerializer): + + class Meta: + model = WorkflowApproval + fields = ('*', 'result_stdout', '-controller_node', '-execution_node',) + + def get_related(self, obj): + res = super(WorkflowApprovalSerializer, self).get_related(obj) + + if obj.workflow_approval_template: + res['workflow_approval_template'] = self.reverse('api:workflow_approval_template_detail', + kwargs={'pk': obj.workflow_approval_template.pk}) + res['notifications'] = self.reverse('api:workflow_approval_notifications_list', kwargs={'pk': obj.pk}) + return res + + def get_result_stdout(self, obj): + return obj.result_stdout + + +class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer): + + class Meta: + fields = ('*', '-execution_node', '-controller_node',) + + +class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): + + class Meta: + model = WorkflowApprovalTemplate + fields = ('*',) + + def get_related(self, obj): + res = super(WorkflowApprovalTemplateSerializer, self).get_related(obj) + if 'last_job' in res: + del res['last_job'] + + res.update(dict( + jobs = self.reverse('api:workflow_approval_template_jobs_list', kwargs={'pk': obj.pk}), + notification_templates_started = self.reverse('api:workflow_approval_template_notification_templates_started_list', kwargs={'pk': obj.pk}), + notification_templates_success = self.reverse('api:workflow_approval_template_notification_templates_success_list', kwargs={'pk': obj.pk}), + notification_templates_error = self.reverse('api:workflow_approval_template_notification_templates_error_list', kwargs={'pk': obj.pk}), + )) + return res + + class LaunchConfigurationBaseSerializer(BaseSerializer): scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) job_type = serializers.ChoiceField(allow_blank=True, allow_null=True, required=False, default=None, diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 31eb6b78d0..97819b5ce9 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -71,6 +71,8 @@ from .instance import urls as instance_urls from .instance_group import urls as instance_group_urls from .oauth2 import urls as oauth2_urls from .oauth2_root import urls as oauth2_root_urls +from .workflow_approval_template import urls as workflow_approval_template_urls +from .workflow_approval import urls as workflow_approval_urls v2_urls = [ @@ -131,8 +133,11 @@ v2_urls = [ url(r'^unified_job_templates/$', UnifiedJobTemplateList.as_view(), name='unified_job_template_list'), url(r'^unified_jobs/$', UnifiedJobList.as_view(), name='unified_job_list'), url(r'^activity_stream/', include(activity_stream_urls)), + url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)), + url(r'^workflow_approval/', include(workflow_approval_urls)), ] + app_name = 'api' urlpatterns = [ url(r'^$', ApiRootView.as_view(), name='api_root_view'), diff --git a/awx/api/urls/workflow_approval.py b/awx/api/urls/workflow_approval.py new file mode 100644 index 0000000000..b66fe751c7 --- /dev/null +++ b/awx/api/urls/workflow_approval.py @@ -0,0 +1,21 @@ +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + +from django.conf.urls import url + +from awx.api.views import ( + WorkflowApprovalList, + WorkflowApprovalDetail, + WorkflowApprovalNotificationsList, +) + + +urls = [ + url(r'^$', WorkflowApprovalList.as_view(), name='workflow_approval_list'), + url(r'^(?P[0-9]+)/$', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'), + url(r'^(?P[0-9]+)/approve/$', WorkflowApprovalDetail.as_view(), name='approved_workflow'), + url(r'^(?P[0-9]+)/reject/$', WorkflowApprovalDetail.as_view(), name='rejected_workflow'), + url(r'^(?P[0-9]+)/notifications/$', WorkflowApprovalNotificationsList.as_view(), name='workflow_approval_notifications_list'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/workflow_approval_template.py b/awx/api/urls/workflow_approval_template.py new file mode 100644 index 0000000000..e0955f0904 --- /dev/null +++ b/awx/api/urls/workflow_approval_template.py @@ -0,0 +1,28 @@ +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + +from django.conf.urls import url + +from awx.api.views import ( + WorfklowApprovalTemplateList, + WorfklowApprovalTemplateDetail, + WorkflowApprovalTemplateJobsList, + WorkflowApprovalTemplateNotificationTemplatesErrorList, + WorkflowApprovalTemplateNotificationTemplatesStartedList, + WorkflowApprovalTemplateNotificationTemplatesSuccessList, +) + + +urls = [ + url(r'^$', WorfklowApprovalTemplateList.as_view(), name='workflow_approval_template_list'), + url(r'^(?P[0-9]+)/$', WorfklowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), + url(r'^(?P[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'), + url(r'^(?P[0-9]+)/notification_templates_started/$', WorkflowApprovalTemplateNotificationTemplatesStartedList.as_view(), + name='workflow_approval_template_notification_templates_started_list'), + url(r'^(?P[0-9]+)/notification_templates_error/$', WorkflowApprovalTemplateNotificationTemplatesErrorList.as_view(), + name='workflow_approval_template_notification_templates_error_list'), + url(r'^(?P[0-9]+)/notification_templates_success/$', WorkflowApprovalTemplateNotificationTemplatesSuccessList.as_view(), + name='workflow_approval_template_notification_templates_success_list'), +] + +__all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index a6601ad607..0c8e52a988 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4405,3 +4405,97 @@ for attr, value in list(locals().items()): name = camelcase_to_underscore(attr) view = value.as_view() setattr(this_module, name, view) + + +class WorfklowApprovalTemplateList(ListAPIView): + + model = models.WorkflowApprovalTemplate + serializer_class = serializers.WorkflowApprovalTemplateSerializer + + def get(self, request, *args, **kwargs): + if not request.user.is_superuser and not request.user.is_system_auditor: + raise PermissionDenied(_("Superuser privileges needed.")) + return super(WorfklowApprovalTemplateList, self).get(request, *args, **kwargs) + + +class WorfklowApprovalTemplateDetail(RetrieveAPIView): + + model = models.WorkflowApprovalTemplate + serializer_class = serializers.WorkflowApprovalTemplateSerializer + + +class WorkflowApprovalTemplateNotificationTemplatesAnyList(SubListCreateAttachDetachAPIView): + + model = models.NotificationTemplate + serializer_class = serializers.NotificationTemplateSerializer + parent_model = models.WorkflowApprovalTemplate + + +class WorkflowApprovalTemplateNotificationTemplatesStartedList(WorkflowApprovalTemplateNotificationTemplatesAnyList): + + relationship = 'notification_templates_started' + + +class WorkflowApprovalTemplateNotificationTemplatesErrorList(WorkflowApprovalTemplateNotificationTemplatesAnyList): + + relationship = 'notification_templates_error' + + +class WorkflowApprovalTemplateNotificationTemplatesSuccessList(WorkflowApprovalTemplateNotificationTemplatesAnyList): + + relationship = 'notification_templates_success' + + +class WorkflowApprovalTemplateLaunch(GenericAPIView): + + model = models.WorkflowApprovalTemplate + obj_permission_type = 'start' + serializer_class = serializers.EmptySerializer + + def get(self, request, *args, **kwargs): + return Response({}) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + new_job = obj.create_unified_job(extra_vars=request.data.get('extra_vars', {})) + new_job.signal_start() + data = OrderedDict() + data['workflow_approval'] = new_job.id + data.update(serializers.WorkflowApprovalSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job)) + headers = {'Location': new_job.get_absolute_url(request)} + return Response(data, status=status.HTTP_201_CREATED, headers=headers) + + +class WorkflowApprovalTemplateJobsList(SubListAPIView): + + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalListSerializer + parent_model = models.WorkflowApprovalTemplate + relationship = 'approvals' + parent_key = 'workflow_approval_template' + + +class WorkflowApprovalList(ListCreateAPIView): + + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalListSerializer + + def get(self, request, *args, **kwargs): + if not request.user.is_superuser and not request.user.is_system_auditor: + raise PermissionDenied(_("Superuser privileges needed.")) + return super(WorkflowApprovalList, self).get(request, *args, **kwargs) + + +class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): + + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalSerializer + + +class WorkflowApprovalNotificationsList(SubListAPIView): + + model = models.Notification + serializer_class = serializers.NotificationSerializer + parent_model = models.WorkflowApproval + relationship = 'notifications' + search_fields = ('subject', 'notification_type', 'body',) diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 50e7ada0d6..bf85b4866e 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -124,6 +124,8 @@ class ApiVersionRootView(APIView): data['activity_stream'] = reverse('api:activity_stream_list', request=request) data['workflow_job_templates'] = reverse('api:workflow_job_template_list', request=request) data['workflow_jobs'] = reverse('api:workflow_job_list', request=request) + data['workflow_approval_templates'] = reverse('api:workflow_approval_template_list', request=request) + data['workflow_approval'] = reverse('api:workflow_approval_list', request=request) data['workflow_job_template_nodes'] = reverse('api:workflow_job_template_node_list', request=request) data['workflow_job_nodes'] = reverse('api:workflow_job_node_list', request=request) return Response(data) diff --git a/awx/main/access.py b/awx/main/access.py index 78aaa2f5d2..f732ae00c9 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -37,6 +37,7 @@ from awx.main.models import ( ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent, SystemJobTemplate, Team, UnifiedJob, UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, WorkflowJobTemplate, WorkflowJobTemplateNode, + WorkflowApproval, WorkflowApprovalTemplate, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR ) from awx.main.models.mixins import ResourceMixin @@ -2769,5 +2770,50 @@ class RoleAccess(BaseAccess): return False +class WorkflowApprovalAccess(BaseAccess): + ''' + I can approve workflows when: + - I'm authenticated + I can create when: + - I'm a superuser: + ''' + + model = WorkflowApproval + prefetch_related = ('created_by', 'modified_by',) + + def can_read(self, obj): + return True + + def can_use(self, obj): + return True + + def filtered_queryset(self): + return self.model.objects.all() + + def can_start(self, obj, validate_license=True): + return False + + +class WorkflowApprovalTemplateAccess(BaseAccess): + ''' + I can approve workflows when: + - I'm authenticated + I can create when: + - I'm a superuser: + ''' + + model = WorkflowApprovalTemplate + prefetch_related = ('created_by', 'modified_by',) + + def can_read(self, obj): + return True + + def can_use(self, obj): + return True + + def filtered_queryset(self): + return self.model.objects.all() + + for cls in BaseAccess.__subclasses__(): access_registry[cls.model] = cls diff --git a/awx/main/migrations/0082_v360_workflowapproval.py b/awx/main/migrations/0082_v360_workflowapproval.py index 7e8b03bd88..80a188c274 100644 --- a/awx/main/migrations/0082_v360_workflowapproval.py +++ b/awx/main/migrations/0082_v360_workflowapproval.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.20 on 2019-07-01 19:41 +# Generated by Django 1.11.20 on 2019-07-03 14:38 from __future__ import unicode_literals from django.db import migrations, models @@ -33,4 +33,9 @@ class Migration(migrations.Migration): }, bases=('main.unifiedjobtemplate',), ), + migrations.AddField( + model_name='workflowapproval', + name='workflow_approval_template', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approvals', to='main.WorkflowApprovalTemplate'), + ), ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index d0ed13ef54..a85653e112 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -55,7 +55,7 @@ from awx.main.models.notifications import ( # noqa from awx.main.models.label import Label # noqa from awx.main.models.workflow import ( # noqa WorkflowJob, WorkflowJobNode, WorkflowJobOptions, WorkflowJobTemplate, - WorkflowJobTemplateNode, + WorkflowJobTemplateNode, WorkflowApproval, WorkflowApprovalTemplate, ) from awx.main.models.channels import ChannelGroup # noqa from awx.api.versioning import reverse @@ -212,4 +212,3 @@ prevent_search(RefreshToken._meta.get_field('token')) prevent_search(OAuth2Application._meta.get_field('client_secret')) prevent_search(OAuth2Application._meta.get_field('client_id')) prevent_search(Grant._meta.get_field('code')) - diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 26cc1424df..3975b4dbf1 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -34,6 +34,7 @@ from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemp from awx.main.models.credential import Credential from awx.main.redact import REPLACE_STR from awx.main.fields import JSONField +from awx.main.utils import schedule_task_manager from copy import copy from urllib.parse import urljoin @@ -604,7 +605,6 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio class WorkflowApprovalTemplate(UnifiedJobTemplate): - class Meta: app_label = 'main' @@ -616,8 +616,42 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): def _get_unified_job_field_names(cls): return ['name', 'description'] + def get_absolute_url(self, request=None): + return reverse('api:workflow_approval_template_detail', kwargs={'pk': self.pk}, request=request) + class WorkflowApproval(UnifiedJob): - class Meta: app_label = 'main' + + workflow_approval_template = models.ForeignKey( + 'WorkflowApprovalTemplate', + related_name='approvals', + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + ) + + @classmethod + def _get_unified_job_template_class(cls): + return WorkflowApprovalTemplate + + def get_absolute_url(self, request=None): + return reverse('api:workflow_approval_detail', kwargs={'pk': self.pk}, request=request) + + @property + def event_class(self): + return None + + def approve(self, request=None): + self.status = 'successful' + self.save() + schedule_task_manager() + return reverse('api:approved_workflow', kwargs={'pk': self.pk}, request=request) + + def reject(self, request=None): + self.status = 'failed' + self.save() + schedule_task_manager() + return reverse('api:rejected_workflow', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py index 402e2ac1f6..0db76aac4c 100644 --- a/awx/main/tests/functional/models/test_unified_job.py +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -10,7 +10,7 @@ from django.contrib.contenttypes.models import ContentType # AWX from awx.main.models import ( UnifiedJobTemplate, Job, JobTemplate, WorkflowJobTemplate, - Project, WorkflowJob, Schedule, + WorkflowApprovalTemplate, Project, WorkflowJob, Schedule, Credential ) @@ -20,7 +20,9 @@ def test_subclass_types(rando): assert set(UnifiedJobTemplate._submodels_with_roles()) == set([ ContentType.objects.get_for_model(JobTemplate).id, ContentType.objects.get_for_model(Project).id, - ContentType.objects.get_for_model(WorkflowJobTemplate).id + ContentType.objects.get_for_model(WorkflowJobTemplate).id, + ContentType.objects.get_for_model(WorkflowApprovalTemplate).id + ]) From d76e9125e8c3b023a4adda29747a09d3d509bd01 Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 3 Jul 2019 16:47:15 -0400 Subject: [PATCH 04/57] Clean up redundancies --- awx/api/serializers.py | 5 +---- awx/api/views/__init__.py | 20 -------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 977c076d51..15c5433a45 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3405,7 +3405,7 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer): class Meta: model = WorkflowApproval - fields = ('*', 'result_stdout', '-controller_node', '-execution_node',) + fields = ('*', '-controller_node', '-execution_node',) def get_related(self, obj): res = super(WorkflowApprovalSerializer, self).get_related(obj) @@ -3416,9 +3416,6 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer): res['notifications'] = self.reverse('api:workflow_approval_notifications_list', kwargs={'pk': obj.pk}) return res - def get_result_stdout(self, obj): - return obj.result_stdout - class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 0c8e52a988..1a75177f9c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4446,26 +4446,6 @@ class WorkflowApprovalTemplateNotificationTemplatesSuccessList(WorkflowApprovalT relationship = 'notification_templates_success' -class WorkflowApprovalTemplateLaunch(GenericAPIView): - - model = models.WorkflowApprovalTemplate - obj_permission_type = 'start' - serializer_class = serializers.EmptySerializer - - def get(self, request, *args, **kwargs): - return Response({}) - - def post(self, request, *args, **kwargs): - obj = self.get_object() - new_job = obj.create_unified_job(extra_vars=request.data.get('extra_vars', {})) - new_job.signal_start() - data = OrderedDict() - data['workflow_approval'] = new_job.id - data.update(serializers.WorkflowApprovalSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job)) - headers = {'Location': new_job.get_absolute_url(request)} - return Response(data, status=status.HTTP_201_CREATED, headers=headers) - - class WorkflowApprovalTemplateJobsList(SubListAPIView): model = models.WorkflowApproval From 82e0b2121b27a4c261515f580d37811a957b7e65 Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 10 Jul 2019 16:12:48 -0400 Subject: [PATCH 05/57] Add approve/deny endpoints, fix some typos --- awx/api/serializers.py | 12 ++++++++- awx/api/urls/workflow_approval.py | 6 +++-- awx/api/urls/workflow_approval_template.py | 8 +++--- awx/api/views/__init__.py | 29 ++++++++++++++++------ awx/main/access.py | 2 +- awx/main/models/workflow.py | 6 ++--- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 15c5433a45..f526fcfdc7 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3401,11 +3401,18 @@ class WorkflowJobCancelSerializer(WorkflowJobSerializer): fields = ('can_cancel',) +class WorkflowApprovalViewSerializer(UnifiedJobSerializer): + + class Meta: + model = WorkflowApproval + fields = [] + + class WorkflowApprovalSerializer(UnifiedJobSerializer): class Meta: model = WorkflowApproval - fields = ('*', '-controller_node', '-execution_node',) + fields = (['*', '-controller_node', '-execution_node',]) def get_related(self, obj): res = super(WorkflowApprovalSerializer, self).get_related(obj) @@ -3414,9 +3421,12 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer): res['workflow_approval_template'] = self.reverse('api:workflow_approval_template_detail', kwargs={'pk': obj.workflow_approval_template.pk}) res['notifications'] = self.reverse('api:workflow_approval_notifications_list', kwargs={'pk': obj.pk}) + res['approve'] = self.reverse('api:workflow_approval_approve', kwargs={'pk': obj.pk}) + res['deny'] = self.reverse('api:workflow_approval_deny', kwargs={'pk': obj.pk}) return res + class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer): class Meta: diff --git a/awx/api/urls/workflow_approval.py b/awx/api/urls/workflow_approval.py index b66fe751c7..682111b8da 100644 --- a/awx/api/urls/workflow_approval.py +++ b/awx/api/urls/workflow_approval.py @@ -6,6 +6,8 @@ from django.conf.urls import url from awx.api.views import ( WorkflowApprovalList, WorkflowApprovalDetail, + WorkflowApprovalApprove, + WorkflowApprovalDeny, WorkflowApprovalNotificationsList, ) @@ -13,8 +15,8 @@ from awx.api.views import ( urls = [ url(r'^$', WorkflowApprovalList.as_view(), name='workflow_approval_list'), url(r'^(?P[0-9]+)/$', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'), - url(r'^(?P[0-9]+)/approve/$', WorkflowApprovalDetail.as_view(), name='approved_workflow'), - url(r'^(?P[0-9]+)/reject/$', WorkflowApprovalDetail.as_view(), name='rejected_workflow'), + url(r'^(?P[0-9]+)/approve/$', WorkflowApprovalApprove.as_view(), name='workflow_approval_approve'), + url(r'^(?P[0-9]+)/deny/$', WorkflowApprovalDeny.as_view(), name='workflow_approval_deny'), url(r'^(?P[0-9]+)/notifications/$', WorkflowApprovalNotificationsList.as_view(), name='workflow_approval_notifications_list'), ] diff --git a/awx/api/urls/workflow_approval_template.py b/awx/api/urls/workflow_approval_template.py index e0955f0904..1d6345d01f 100644 --- a/awx/api/urls/workflow_approval_template.py +++ b/awx/api/urls/workflow_approval_template.py @@ -4,8 +4,8 @@ from django.conf.urls import url from awx.api.views import ( - WorfklowApprovalTemplateList, - WorfklowApprovalTemplateDetail, + WorkflowApprovalTemplateList, + WorkflowApprovalTemplateDetail, WorkflowApprovalTemplateJobsList, WorkflowApprovalTemplateNotificationTemplatesErrorList, WorkflowApprovalTemplateNotificationTemplatesStartedList, @@ -14,8 +14,8 @@ from awx.api.views import ( urls = [ - url(r'^$', WorfklowApprovalTemplateList.as_view(), name='workflow_approval_template_list'), - url(r'^(?P[0-9]+)/$', WorfklowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), + url(r'^$', WorkflowApprovalTemplateList.as_view(), name='workflow_approval_template_list'), + url(r'^(?P[0-9]+)/$', WorkflowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), url(r'^(?P[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'), url(r'^(?P[0-9]+)/notification_templates_started/$', WorkflowApprovalTemplateNotificationTemplatesStartedList.as_view(), name='workflow_approval_template_notification_templates_started_list'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 1a75177f9c..58fff401c1 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4407,18 +4407,13 @@ for attr, value in list(locals().items()): setattr(this_module, name, view) -class WorfklowApprovalTemplateList(ListAPIView): +class WorkflowApprovalTemplateList(ListCreateAPIView): model = models.WorkflowApprovalTemplate serializer_class = serializers.WorkflowApprovalTemplateSerializer - def get(self, request, *args, **kwargs): - if not request.user.is_superuser and not request.user.is_system_auditor: - raise PermissionDenied(_("Superuser privileges needed.")) - return super(WorfklowApprovalTemplateList, self).get(request, *args, **kwargs) - -class WorfklowApprovalTemplateDetail(RetrieveAPIView): +class WorkflowApprovalTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): model = models.WorkflowApprovalTemplate serializer_class = serializers.WorkflowApprovalTemplateSerializer @@ -4472,6 +4467,26 @@ class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): serializer_class = serializers.WorkflowApprovalSerializer +class WorkflowApprovalApprove(RetrieveAPIView): + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalViewSerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + obj.approve() + return Response(status=status.HTTP_202_ACCEPTED) + + +class WorkflowApprovalDeny(RetrieveAPIView): + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalViewSerializer + + def post(self, request, *args, **kwargs): + obj = self.get_object() + obj.deny() + return Response(status=status.HTTP_202_ACCEPTED) + + class WorkflowApprovalNotificationsList(SubListAPIView): model = models.Notification diff --git a/awx/main/access.py b/awx/main/access.py index f732ae00c9..bcd9cd1dab 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2812,7 +2812,7 @@ class WorkflowApprovalTemplateAccess(BaseAccess): return True def filtered_queryset(self): - return self.model.objects.all() + return self.model.filter(workflowjobtemplatenodes__workflow_job_template=WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role')) for cls in BaseAccess.__subclasses__(): diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 3975b4dbf1..eabfcb9133 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -648,10 +648,10 @@ class WorkflowApproval(UnifiedJob): self.status = 'successful' self.save() schedule_task_manager() - return reverse('api:approved_workflow', kwargs={'pk': self.pk}, request=request) + return reverse('api:workflow_approval_approve', kwargs={'pk': self.pk}, request=request) - def reject(self, request=None): + def deny(self, request=None): self.status = 'failed' self.save() schedule_task_manager() - return reverse('api:rejected_workflow', kwargs={'pk': self.pk}, request=request) + return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request) From 0720857022798744e266920a7af3164f15236a8a Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 11 Jul 2019 15:50:14 -0400 Subject: [PATCH 06/57] Add initial support for workflow pause approve --- .../features/templates/templates.strings.js | 5 +- awx/ui/client/legacy/styles/ansible-ui.less | 5 - awx/ui/client/lib/components/_index.less | 1 + .../components/approvalsDrawer/_index.less | 56 ++ .../approvalsDrawer.directive.js | 86 ++++ .../approvalsDrawer.partial.html | 79 +++ .../lib/components/components.strings.js | 4 + awx/ui/client/lib/components/index.js | 2 + .../client/lib/components/layout/_index.less | 17 + .../lib/components/layout/layout.directive.js | 12 + .../lib/components/layout/layout.partial.html | 7 + awx/ui/client/lib/models/index.js | 20 +- awx/ui/client/src/app.js | 25 +- .../login/loginModal/loginModal.controller.js | 21 +- .../client/src/templates/templates.service.js | 359 ++++++------- .../workflow-chart/workflow-chart.block.less | 8 +- .../workflow-chart.directive.js | 43 +- .../forms/workflow-node-form.controller.js | 56 +- .../forms/workflow-node-form.partial.html | 50 +- .../workflow-maker/workflow-maker.block.less | 11 +- .../workflow-maker.controller.js | 477 +++++++++++------- .../workflow-maker.partial.html | 2 +- 22 files changed, 917 insertions(+), 429 deletions(-) create mode 100644 awx/ui/client/lib/components/approvalsDrawer/_index.less create mode 100644 awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js create mode 100644 awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index d3c963b7e4..ffc36b32f0 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -117,7 +117,7 @@ function TemplatesStrings (BaseString) { TOTAL_NODES: t.s('TOTAL NODES'), ADD_A_NODE: t.s('ADD A NODE'), EDIT_TEMPLATE: t.s('EDIT TEMPLATE'), - JOBS: t.s('JOBS'), + JOBS: t.s('Jobs'), PLEASE_CLICK_THE_START_BUTTON: t.s('Please click the start button to build your workflow.'), PLEASE_HOVER_OVER_A_TEMPLATE: t.s('Please hover over a template for additional options.'), EDIT_LINK_TOOLTIP: t.s('Click to edit link'), @@ -144,7 +144,8 @@ function TemplatesStrings (BaseString) { UNSAVED_CHANGES_PROMPT_TEXT: t.s('Are you sure you want to exit the Workflow Creator without saving your changes?'), EXIT: t.s('EXIT'), CANCEL: t.s('CANCEL'), - SAVE_AND_EXIT: t.s('SAVE & EXIT') + SAVE_AND_EXIT: t.s('SAVE & EXIT'), + PAUSE_NODE: t.s('Pause Node') }; } diff --git a/awx/ui/client/legacy/styles/ansible-ui.less b/awx/ui/client/legacy/styles/ansible-ui.less index 90824e43f5..708cc44496 100644 --- a/awx/ui/client/legacy/styles/ansible-ui.less +++ b/awx/ui/client/legacy/styles/ansible-ui.less @@ -1981,11 +1981,6 @@ tr td button i { box-shadow: none !important; } -.select2-container { - margin-left: 2px; - margin-top: 2px; -} - .form-control + .select2-container--disabled .select2-selection { background-color: @ebgrey !important; } diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index 81dc40c580..ea4da9a9c9 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -1,4 +1,5 @@ @import 'action/_index'; +@import 'approvalsDrawer/_index'; @import 'dialog/_index'; @import 'input/_index'; @import 'launchTemplateButton/_index'; diff --git a/awx/ui/client/lib/components/approvalsDrawer/_index.less b/awx/ui/client/lib/components/approvalsDrawer/_index.less new file mode 100644 index 0000000000..abca0e2166 --- /dev/null +++ b/awx/ui/client/lib/components/approvalsDrawer/_index.less @@ -0,0 +1,56 @@ +.at-ApprovalsDrawer { + position: absolute; + right: 0px; + top: 0; + height: 100%; + width: 540px; + background-color: white; + z-index: 1000000; + animation-duration: 0.5s; + // TODO: fix animation? + // animation-name: slidein; + padding: 20px; + box-shadow: -3px 0px 8px -2px #aaaaaa; + + &-header { + display: flex; + width: 100%; + margin-bottom: 20px; + } + + &-title { + flex: 1 0 auto; + color: #606060; + font-size: 14px; + font-weight: bold; + width: calc(82%); + } + + &-exit { + justify-content: flex-end; + display: flex; + + button { + height: 20px; + font-size: 20px; + color: #D7D7D7; + line-height: 1; + opacity: 1; + } + + button:hover{ + color: @default-icon; + opacity: 1; + } + } +} + +@keyframes slidein { + from { + width: 0px; + } + + to { + width: 540px; + } +} \ No newline at end of file diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js new file mode 100644 index 0000000000..556d216559 --- /dev/null +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js @@ -0,0 +1,86 @@ +const templateUrl = require('~components/approvalsDrawer/approvalsDrawer.partial.html'); + +function AtApprovalsDrawerController (strings, Rest, GetBasePath, $rootScope) { + const vm = this || {}; + + const toolbarSortDefault = { + label: `${strings.get('sort.CREATED_ASCENDING')}`, + value: 'created' + }; + + vm.toolbarSortValue = toolbarSortDefault; + + // This will probably need to be expanded + vm.toolbarSortOptions = [ + toolbarSortDefault, + { label: `${strings.get('sort.CREATED_DESCENDING')}`, value: '-created' } + ]; + + vm.queryset = { + page_size: 5 + }; + + vm.emptyListReason = strings.get('approvals.NONE'); + + const loadTheList = () => { + Rest.setUrl(`${GetBasePath('workflow_approval')}?page_size=5&order_by=created&status=pending`); + Rest.get() + .then(({ data }) => { + vm.dataset = data; + vm.approvals = data.results; + vm.count = data.count; + $rootScope.pendingApprovalCount = data.count; + vm.listLoaded = true; + }); + }; + + loadTheList(); + + vm.onToolbarSort = (sort) => { + vm.toolbarSortValue = sort; + + // TODO: this... + // const queryParams = Object.assign( + // {}, + // $state.params.user_search, + // paginateQuerySet, + // { order_by: sort.value } + // ); + + // // Update URL with params + // $state.go('.', { + // user_search: queryParams + // }, { notify: false, location: 'replace' }); + + // rather than ^^ we want to just re-load the data based on new params + }; + + vm.approve = (approval) => { + Rest.setUrl(`${GetBasePath('workflow_approval')}${approval.id}/approve`); + Rest.post() + .then(() => loadTheList()); + }; + + vm.deny = (approval) => { + Rest.setUrl(`${GetBasePath('workflow_approval')}${approval.id}/deny`); + Rest.post() + .then(() => loadTheList()); + }; +} + +AtApprovalsDrawerController.$inject = ['ComponentsStrings', 'Rest', 'GetBasePath', '$rootScope']; + +function atApprovalsDrawer () { + return { + restrict: 'E', + transclude: true, + templateUrl, + controller: AtApprovalsDrawerController, + controllerAs: 'vm', + scope: { + closeApprovals: '&' + }, + }; +} + +export default atApprovalsDrawer; diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html new file mode 100644 index 0000000000..55e8a0ab2f --- /dev/null +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html @@ -0,0 +1,79 @@ +
+
+
+
+ + NOTIFICATIONS + + + {{vm.count}} + +
+
+ +
+
+ + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+
Continue workflow job?
+ + +
+
+
+
+
+ + +
\ No newline at end of file diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 8ca1e26115..772ce84f37 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -119,6 +119,10 @@ function ComponentsStrings (BaseString) { EXPANDED: t.s('Expanded'), SORT_BY: t.s('SORT BY') }; + + ns.approvals = { + NONE: t.s('There are no jobs awaiting approval') + }; } ComponentsStrings.$inject = ['BaseStringService']; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 1e5767fd36..cc3cd55bb0 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -2,6 +2,7 @@ import atLibServices from '~services'; import actionGroup from '~components/action/action-group.directive'; import actionButton from '~components/action/action-button.directive'; +import approvalsDrawer from '~components/approvalsDrawer/approvalsDrawer.directive'; import dialog from '~components/dialog/dialog.component'; import divider from '~components/utility/divider.directive'; import dynamicSelect from '~components/input/dynamic-select.directive'; @@ -60,6 +61,7 @@ angular ]) .directive('atActionGroup', actionGroup) .directive('atActionButton', actionButton) + .directive('atApprovalsDrawer', approvalsDrawer) .component('atDialog', dialog) .directive('atDivider', divider) .directive('atDynamicSelect', dynamicSelect) diff --git a/awx/ui/client/lib/components/layout/_index.less b/awx/ui/client/lib/components/layout/_index.less index f1ee8c2ac3..ef22c873e1 100644 --- a/awx/ui/client/lib/components/layout/_index.less +++ b/awx/ui/client/lib/components/layout/_index.less @@ -81,6 +81,23 @@ opacity: 0; } } + + .at-Layout-topNavApprovals { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + div { + margin-left: 10px; + padding: 5px; + border-radius: 3px; + background-color: red; + color: white; + height: 15px; + font-size: 10px; + } + } } &-sideContainer { diff --git a/awx/ui/client/lib/components/layout/layout.directive.js b/awx/ui/client/lib/components/layout/layout.directive.js index 7d1f457f57..a5d9332f2a 100644 --- a/awx/ui/client/lib/components/layout/layout.directive.js +++ b/awx/ui/client/lib/components/layout/layout.directive.js @@ -25,6 +25,10 @@ function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions } }); + $scope.$watch('$root.pendingApprovalCount', () => { + vm.approvalsCount = _.get($scope, '$root.pendingApprovalCount') || 0; + }); + $scope.$watch('$root.socketStatus', (newStatus) => { vm.socketState = newStatus; vm.socketIconClass = `icon-socket-${vm.socketState}`; @@ -42,6 +46,14 @@ function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions } }; + vm.openApprovals = () => { + vm.showApprovals = true; + }; + + vm.closeApprovals = () => { + vm.showApprovals = false; + }; + function checkOrgAdmin () { const usersPath = `/api/v2/users/${vm.currentUserId}/admin_of_organizations/`; $http.get(usersPath) diff --git a/awx/ui/client/lib/components/layout/layout.partial.html b/awx/ui/client/lib/components/layout/layout.partial.html index d282ade9f1..3d36b064c6 100644 --- a/awx/ui/client/lib/components/layout/layout.partial.html +++ b/awx/ui/client/lib/components/layout/layout.partial.html @@ -14,6 +14,12 @@ {{ $parent.layoutVm.currentUsername }} + +
+ +
{{vm.approvalsCount}}
+
+
@@ -104,4 +110,5 @@ + diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js index a851d1f29f..6d8cc142e9 100644 --- a/awx/ui/client/lib/models/index.js +++ b/awx/ui/client/lib/models/index.js @@ -1,7 +1,7 @@ import atLibServices from '~services'; -import Application from '~models/Application'; import AdHocCommand from '~models/AdHocCommand'; +import Application from '~models/Application'; import Base from '~models/Base'; import Config from '~models/Config'; import Credential from '~models/Credential'; @@ -19,16 +19,16 @@ import Me from '~models/Me'; import NotificationTemplate from '~models/NotificationTemplate'; import Organization from '~models/Organization'; import Project from '~models/Project'; -import Schedule from '~models/Schedule'; import ProjectUpdate from '~models/ProjectUpdate'; +import Schedule from '~models/Schedule'; import SystemJob from '~models/SystemJob'; import Token from '~models/Token'; +import UnifiedJob from '~models/UnifiedJob'; import UnifiedJobTemplate from '~models/UnifiedJobTemplate'; +import User from '~models/User'; import WorkflowJob from '~models/WorkflowJob'; import WorkflowJobTemplate from '~models/WorkflowJobTemplate'; import WorkflowJobTemplateNode from '~models/WorkflowJobTemplateNode'; -import UnifiedJob from '~models/UnifiedJob'; -import User from '~models/User'; import ModelsStrings from '~models/models.strings'; @@ -38,8 +38,8 @@ angular .module(MODULE_NAME, [ atLibServices ]) - .service('ApplicationModel', Application) .service('AdHocCommandModel', AdHocCommand) + .service('ApplicationModel', Application) .service('BaseModel', Base) .service('ConfigModel', Config) .service('CredentialModel', Credential) @@ -54,19 +54,19 @@ angular .service('JobModel', Job) .service('JobTemplateModel', JobTemplate) .service('MeModel', Me) + .service('ModelsStrings', ModelsStrings) .service('NotificationTemplate', NotificationTemplate) .service('OrganizationModel', Organization) .service('ProjectModel', Project) - .service('ScheduleModel', Schedule) - .service('UnifiedJobModel', UnifiedJob) .service('ProjectUpdateModel', ProjectUpdate) + .service('ScheduleModel', Schedule) .service('SystemJobModel', SystemJob) .service('TokenModel', Token) + .service('UnifiedJobModel', UnifiedJob) .service('UnifiedJobTemplateModel', UnifiedJobTemplate) + .service('UserModel', User) .service('WorkflowJobModel', WorkflowJob) .service('WorkflowJobTemplateModel', WorkflowJobTemplate) - .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode) - .service('UserModel', User) - .service('ModelsStrings', ModelsStrings); + .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode); export default MODULE_NAME; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 65d08e16dd..5c94436659 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -161,16 +161,16 @@ angular // }) } ]) - .run(['$stateExtender', '$q', '$compile', '$cookies', '$rootScope', '$log', '$stateParams', + .run(['$q', '$cookies', '$rootScope', '$log', '$stateParams', 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', - 'LoadConfig', 'Store', 'pendoService', 'Prompt', 'Rest', - 'Wait', 'ProcessErrors', '$state', 'GetBasePath', 'ConfigService', - '$filter', 'SocketService', 'AppStrings', '$transitions', - function($stateExtender, $q, $compile, $cookies, $rootScope, $log, $stateParams, + 'LoadConfig', 'Store', 'pendoService', 'Rest', + '$state', 'GetBasePath', 'ConfigService', + 'SocketService', 'AppStrings', '$transitions', + function($q, $cookies, $rootScope, $log, $stateParams, CheckLicense, $location, Authorization, LoadBasePaths, Timer, - LoadConfig, Store, pendoService, Prompt, Rest, Wait, - ProcessErrors, $state, GetBasePath, ConfigService, - $filter, SocketService, AppStrings, $transitions) { + LoadConfig, Store, pendoService, Rest, + $state, GetBasePath, ConfigService, + SocketService, AppStrings, $transitions) { $rootScope.$state = $state; $rootScope.$state.matches = function(stateName) { @@ -387,6 +387,15 @@ angular } }); }); + + Rest.setUrl(`${GetBasePath('workflow_approval')}?status=pending&page_size=1`); + Rest.get() + .then(({data}) => { + $rootScope.pendingApprovalCount = data.count; + }) + .catch(() => { + // TODO: handle this + }); } } diff --git a/awx/ui/client/src/login/loginModal/loginModal.controller.js b/awx/ui/client/src/login/loginModal/loginModal.controller.js index 1d92ae0f94..e5df9bf1b6 100644 --- a/awx/ui/client/src/login/loginModal/loginModal.controller.js +++ b/awx/ui/client/src/login/loginModal/loginModal.controller.js @@ -39,14 +39,14 @@ * This is usage information. */ -export default ['$log', '$cookies', '$compile', '$rootScope', +export default ['$log', '$cookies', '$rootScope', '$location', 'Authorization', 'Alert', 'Wait', 'Timer', 'Empty', '$scope', 'pendoService', 'ConfigService', - 'CheckLicense', 'SocketService', - function ($log, $cookies, $compile, $rootScope, $location, - Authorization, Alert, Wait, Timer, Empty, - scope, pendoService, ConfigService, CheckLicense, - SocketService) { + 'CheckLicense', 'SocketService', 'Rest', 'GetBasePath', + function ($log, $cookies, $rootScope, + $location, Authorization, Alert, Wait, Timer, + Empty, scope, pendoService, ConfigService, + CheckLicense, SocketService, Rest, GetBasePath) { var lastPath, lastUser, sessionExpired, loginAgain, preAuthUrl; loginAgain = function() { @@ -139,6 +139,15 @@ export default ['$log', '$cookies', '$compile', '$rootScope', Alert('Error', 'Failed to access user information. GET returned status: ' + status, 'alert-danger', loginAgain); }); }); + + Rest.setUrl(`${GetBasePath('workflow_approval')}?status=pending&page_size=1`); + Rest.get() + .then(({data}) => { + $rootScope.pendingApprovalCount = data.count; + }) + .catch(() => { + // TODO: handle this + }); }); // Call the API to get an auth token diff --git a/awx/ui/client/src/templates/templates.service.js b/awx/ui/client/src/templates/templates.service.js index f73c77b876..ef718064fd 100644 --- a/awx/ui/client/src/templates/templates.service.js +++ b/awx/ui/client/src/templates/templates.service.js @@ -75,221 +75,230 @@ export default ['Rest', 'GetBasePath', '$q', 'NextPage', function(Rest, GetBaseP }).catch(function(response){ return $q.reject( response ); }); - }, + }, - getAllWorkflowJobTemplateLabels: function(id) { - Rest.setUrl(GetBasePath('workflow_job_templates') + id + "/labels?page_size=200"); - return Rest.get() - .then(function(res) { - if (res.data.next) { - return NextPage({ - url: res.data.next, - arrayOfValues: res.data.results - }).then(function(labels) { - return labels; - }).catch(function(response){ - return $q.reject( response ); - }); - } - else { - return $q.resolve( res.data.results ); - } - }).catch(function(response){ - return $q.reject( response ); - }); - }, - getJobTemplate: function(id) { - var url = GetBasePath('job_templates'); + getAllWorkflowJobTemplateLabels: function(id) { + Rest.setUrl(GetBasePath('workflow_job_templates') + id + "/labels?page_size=200"); + return Rest.get() + .then(function(res) { + if (res.data.next) { + return NextPage({ + url: res.data.next, + arrayOfValues: res.data.results + }).then(function(labels) { + return labels; + }).catch(function(response){ + return $q.reject( response ); + }); + } + else { + return $q.resolve( res.data.results ); + } + }).catch(function(response){ + return $q.reject( response ); + }); + }, + getJobTemplate: function(id) { + var url = GetBasePath('job_templates'); - url = url + id; + url = url + id; - Rest.setUrl(url); - return Rest.get(); - }, - addWorkflowNode: function(params) { - // params.url - // params.data + Rest.setUrl(url); + return Rest.get(); + }, + addWorkflowNode: function(params) { + // params.url + // params.data - Rest.setUrl(params.url); - return Rest.post(params.data); - }, - editWorkflowNode: function(params) { - // params.id - // params.data + Rest.setUrl(params.url); + return Rest.post(params.data); + }, + editWorkflowNode: function(params) { + // params.id + // params.data - var url = GetBasePath('workflow_job_template_nodes') + params.id; + var url = GetBasePath('workflow_job_template_nodes') + params.id; - Rest.setUrl(url); - return Rest.put(params.data); - }, - getJobTemplateLaunchInfo: function(id) { - var url = GetBasePath('job_templates'); + Rest.setUrl(url); + return Rest.put(params.data); + }, + getJobTemplateLaunchInfo: function(id) { + var url = GetBasePath('job_templates'); - url = url + id + '/launch'; + url = url + id + '/launch'; - Rest.setUrl(url); - return Rest.get(); - }, - getWorkflowJobTemplateNodes: function(id, page) { - var url = GetBasePath('workflow_job_templates'); + Rest.setUrl(url); + return Rest.get(); + }, + getWorkflowJobTemplateNodes: function(id, page) { + var url = GetBasePath('workflow_job_templates'); - url = url + id + '/workflow_nodes?page_size=200'; + url = url + id + '/workflow_nodes?page_size=200'; - if(page) { - url += '&page=' + page; - } + if(page) { + url += '&page=' + page; + } - Rest.setUrl(url); - return Rest.get(); - }, - updateWorkflowJobTemplate: function(params) { - // params.id - // params.data + Rest.setUrl(url); + return Rest.get(); + }, + updateWorkflowJobTemplate: function(params) { + // params.id + // params.data - var url = GetBasePath('workflow_job_templates'); + var url = GetBasePath('workflow_job_templates'); - url = url + params.id; + url = url + params.id; - Rest.setUrl(url); - return Rest.patch(params.data); - }, - getWorkflowJobTemplate: function(id) { - var url = GetBasePath('workflow_job_templates'); + Rest.setUrl(url); + return Rest.patch(params.data); + }, + getWorkflowJobTemplate: function(id) { + var url = GetBasePath('workflow_job_templates'); - url = url + id; + url = url + id; - Rest.setUrl(url); - return Rest.get(); - }, - deleteWorkflowJobTemplateNode: function(id) { - var url = GetBasePath('workflow_job_template_nodes') + id; + Rest.setUrl(url); + return Rest.get(); + }, + deleteWorkflowJobTemplateNode: function(id) { + var url = GetBasePath('workflow_job_template_nodes') + id; - Rest.setUrl(url); - return Rest.destroy(); - }, - disassociateWorkflowNode: function(params) { - //params.parentId - //params.nodeId - //params.edge + Rest.setUrl(url); + return Rest.destroy(); + }, + disassociateWorkflowNode: function(params) { + //params.parentId + //params.nodeId + //params.edge - var url = GetBasePath('workflow_job_template_nodes') + params.parentId; + var url = GetBasePath('workflow_job_template_nodes') + params.parentId; - if(params.edge === 'success') { - url = url + '/success_nodes'; - } - else if(params.edge === 'failure') { - url = url + '/failure_nodes'; - } - else if(params.edge === 'always') { - url = url + '/always_nodes'; - } + if(params.edge === 'success') { + url = url + '/success_nodes'; + } + else if(params.edge === 'failure') { + url = url + '/failure_nodes'; + } + else if(params.edge === 'always') { + url = url + '/always_nodes'; + } - Rest.setUrl(url); - return Rest.post({ - "id": params.nodeId, - "disassociate": true - }); - }, - associateWorkflowNode: function(params) { - //params.parentId - //params.nodeId - //params.edge + Rest.setUrl(url); + return Rest.post({ + "id": params.nodeId, + "disassociate": true + }); + }, + associateWorkflowNode: function(params) { + //params.parentId + //params.nodeId + //params.edge - var url = GetBasePath('workflow_job_template_nodes') + params.parentId; + var url = GetBasePath('workflow_job_template_nodes') + params.parentId; - if(params.edge === 'success') { - url = url + '/success_nodes'; - } - else if(params.edge === 'failure') { - url = url + '/failure_nodes'; - } - else if(params.edge === 'always') { - url = url + '/always_nodes'; - } + if(params.edge === 'success') { + url = url + '/success_nodes'; + } + else if(params.edge === 'failure') { + url = url + '/failure_nodes'; + } + else if(params.edge === 'always') { + url = url + '/always_nodes'; + } - Rest.setUrl(url); - return Rest.post({ - id: params.nodeId - }); - }, - getUnifiedJobTemplate: function(id) { - var url = GetBasePath('unified_job_templates'); + Rest.setUrl(url); + return Rest.post({ + id: params.nodeId + }); + }, + getUnifiedJobTemplate: function(id) { + var url = GetBasePath('unified_job_templates'); - url = url + "?id=" + id; + url = url + "?id=" + id; - Rest.setUrl(url); - return Rest.get(); - }, - getCredential: function(id) { - var url = GetBasePath('credentials'); + Rest.setUrl(url); + return Rest.get(); + }, + getCredential: function(id) { + var url = GetBasePath('credentials'); - url = url + id; + url = url + id; - Rest.setUrl(url); - return Rest.get(); - }, - getInventory: function(id) { - var url = GetBasePath('inventory'); + Rest.setUrl(url); + return Rest.get(); + }, + getInventory: function(id) { + var url = GetBasePath('inventory'); - url = url + id; + url = url + id; - Rest.setUrl(url); - return Rest.get(); - }, - getWorkflowCopy: function(id) { - let url = GetBasePath('workflow_job_templates'); + Rest.setUrl(url); + return Rest.get(); + }, + getWorkflowCopy: function(id) { + let url = GetBasePath('workflow_job_templates'); - url = url + id + '/copy'; + url = url + id + '/copy'; - Rest.setUrl(url); - return Rest.get(); - }, - copyWorkflow: function(id) { - let url = GetBasePath('workflow_job_templates'); + Rest.setUrl(url); + return Rest.get(); + }, + copyWorkflow: function(id) { + let url = GetBasePath('workflow_job_templates'); - url = url + id + '/copy'; + url = url + id + '/copy'; - Rest.setUrl(url); - return Rest.post(); - }, - getWorkflowJobTemplateOptions: function() { - var deferred = $q.defer(); + Rest.setUrl(url); + return Rest.post(); + }, + getWorkflowJobTemplateOptions: function() { + var deferred = $q.defer(); - let url = GetBasePath('workflow_job_templates'); + let url = GetBasePath('workflow_job_templates'); - Rest.setUrl(url); - Rest.options() - .then(({data}) => { - deferred.resolve(data); - }).catch(({msg, code}) => { - deferred.reject(msg, code); - }); + Rest.setUrl(url); + Rest.options() + .then(({data}) => { + deferred.resolve(data); + }).catch(({msg, code}) => { + deferred.reject(msg, code); + }); - return deferred.promise; - }, - getJobTemplateOptions: function() { - var deferred = $q.defer(); + return deferred.promise; + }, + getJobTemplateOptions: function() { + var deferred = $q.defer(); - let url = GetBasePath('job_templates'); + let url = GetBasePath('job_templates'); - Rest.setUrl(url); - Rest.options() - .then(({data}) => { - deferred.resolve(data); - }).catch(({msg, code}) => { - deferred.reject(msg, code); - }); + Rest.setUrl(url); + Rest.options() + .then(({data}) => { + deferred.resolve(data); + }).catch(({msg, code}) => { + deferred.reject(msg, code); + }); - return deferred.promise; - }, - postWorkflowNodeCredential: function(params) { - // params.id - // params.data + return deferred.promise; + }, + postWorkflowNodeCredential: function(params) { + // params.id + // params.data - var url = GetBasePath('workflow_job_template_nodes') + params.id + '/credentials'; + var url = GetBasePath('workflow_job_template_nodes') + params.id + '/credentials'; - Rest.setUrl(url); - return Rest.post(params.data); - } + Rest.setUrl(url); + return Rest.post(params.data); + }, + createApprovalTemplate: (params) => { + params = params || {}; + Rest.setUrl(GetBasePath('workflow_approval_templates')); + return Rest.post(params); + }, + patchApprovalTemplate: ({id, data}) => { + Rest.setUrl(`${GetBasePath('workflow_approval_templates')}/${id}`); + return Rest.patch(data); + } }; }]; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index 830a02dcd1..c1719b07ac 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -142,8 +142,10 @@ } .WorkflowChart-deletedText { - width: 90px; + width: 180px; + height: 14px; color: @default-interface-txt; + text-align: center; } .WorkflowChart-activeNode { fill: @default-link; @@ -159,7 +161,11 @@ } .WorkflowChart-nameText { + width: 180px; + height: 20px; + line-height: 18px; font-size: 10px; + text-align: center; } .WorkflowChart-tooltip { diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index fb98fb05d0..8156407b5b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -770,12 +770,23 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); baseSvg.selectAll(".WorkflowChart-nameText") - .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) + .attr("x", 0) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : (nodeH / 2) - 10; }) .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) - .text(function (d) { + .html(function (d) { const name = _.get(d, 'unifiedJobTemplate.name'); - return name ? wrap(name) : ""; + const wrappedName = name ? wrap(name) : ""; + // TODO: clean this up + if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type === 'workflow_approval') { + return ` +
+ +
+ ${wrappedName} +
`; + } else { + return `${wrappedName}`; + } }); baseSvg.selectAll(".WorkflowChart-detailsLink") @@ -884,19 +895,31 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("class", "WorkflowChart-activeNode") .style("display", function(d) { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); - thisNode.append("text") - .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) + thisNode.append("foreignObject") + // .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("x", 0) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : (nodeH / 2) - 10; }) .attr("dy", ".35em") .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") - .text(function (d) { + .html(function (d) { const name = _.get(d, 'unifiedJobTemplate.name'); - return name ? wrap(name) : ""; + const wrappedName = name ? wrap(name) : ""; + // TODO: clean this up + if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type === 'workflow_approval') { + return ` +
+ +
+ ${wrappedName} +
`; + } else { + return `${wrappedName}`; + } }); thisNode.append("foreignObject") - .attr("x", 62) + .attr("x", 0) .attr("y", 22) .attr("dy", ".35em") .attr("text-anchor", "middle") diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index c089de6b7c..86c440482f 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -32,6 +32,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.strings = TemplatesStrings; $scope.editNodeHelpMessage = null; + $scope.pauseNode = {}; let templateList = _.cloneDeep(TemplateList); delete templateList.actions; @@ -463,6 +464,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } $scope.promptData = null; + $scope.pauseNode = {}; $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj); if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { @@ -616,26 +618,48 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }) ); + CreateSelect2({ + element: '#workflow-node-types', + multiple: false + }); + $q.all(listPromises) .then(() => { if ($scope.nodeConfig.mode === "edit") { - // Make sure that we have the full unified job template object - if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject) { - // This is a node that we got back from the api with an incomplete - // unified job template so we're going to pull down the whole object - TemplatesService.getUnifiedJobTemplate($scope.nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.id) - .then(({data}) => { - $scope.nodeConfig.node.fullUnifiedJobTemplateObject = data.results[0]; - finishConfiguringEdit(); - }, (error) => { - ProcessErrors($scope, error.data, error.status, null, { - hdr: 'Error!', - msg: 'Failed to get unified job template. GET returned ' + - 'status: ' + error.status - }); - }); + if ($scope.nodeConfig.node.unifiedJobTemplate && $scope.nodeConfig.node.unifiedJobTemplate.unified_job_type === "workflow_approval") { + $scope.selectedTemplate = null; + $scope.activeTab = "pause"; + CreateSelect2({ + element: '#workflow_node_edge', + multiple: false + }); + + $scope.pauseNode = { + isPauseNode: true, + name: $scope.nodeConfig.node.unifiedJobTemplate.name, + description: $scope.nodeConfig.node.unifiedJobTemplate.description, + }; + + $scope.nodeFormDataLoaded = true; } else { - finishConfiguringEdit(); + // Make sure that we have the full unified job template object + if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject) { + // This is a node that we got back from the api with an incomplete + // unified job template so we're going to pull down the whole object + TemplatesService.getUnifiedJobTemplate($scope.nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.id) + .then(({data}) => { + $scope.nodeConfig.node.fullUnifiedJobTemplateObject = data.results[0]; + finishConfiguringEdit(); + }, (error) => { + ProcessErrors($scope, error.data, error.status, null, { + hdr: 'Error!', + msg: 'Failed to get unified job template. GET returned ' + + 'status: ' + error.status + }); + }); + } else { + finishConfiguringEdit(); + } } } else { finishConfiguringAdd(); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index ed1a2f1470..089b24af5c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -1,9 +1,18 @@
{{nodeConfig.mode === 'edit' ? nodeConfig.node.fullUnifiedJobTemplateObject.name || nodeConfig.node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_NODE')}}
-
-
{{strings.get('workflow_maker.JOBS')}}
-
{{strings.get('workflow_maker.PROJECT_SYNC')}}
-
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
+
+
@@ -103,6 +112,36 @@
+
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
@@ -238,7 +277,8 @@ - + +
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index 760ceafc7d..87f20ce5db 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -296,8 +296,8 @@ border-bottom-left-radius: 5px; } -.WorkflowMaker-formTab { - margin-right: 10px; +.WorkflowMaker-formTypeDropdown { + margin-bottom: 20px; } .WorkflowMaker-preventBodyScrolling { @@ -314,6 +314,13 @@ margin-bottom: 20px; } +.WorkflowMaker-pauseCheckbox { + input { + margin-right: 5px; + } + margin-bottom: 20px; +} + .Key-list { margin: 0; padding: 20px; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 68fcfda0b9..d5e166a7b5 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -140,59 +140,126 @@ export default ['$scope', 'TemplatesService', }; if ($scope.graphState.arrayOfNodesForChart.length > 1) { + let approvalTemplatePromises = []; let addPromises = []; let editPromises = []; let credentialRequests = []; Object.keys(nodeRef).map((workflowMakerNodeId) => { - if (nodeRef[workflowMakerNodeId].isNew) { - addPromises.push(TemplatesService.addWorkflowNode({ - url: $scope.workflowJobTemplateObj.related.workflow_nodes, - data: buildSendableNodeData(nodeRef[workflowMakerNodeId]) - }).then(({data}) => { - nodeRef[workflowMakerNodeId].originalNodeObject = data; - nodeIdToChartNodeIdMapping[data.id] = parseInt(workflowMakerNodeId); - if (_.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.ask_credential_on_launch')) { - // This finds the credentials that were selected in the prompt but don't occur - // in the template defaults - let credentialIdsToPost = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter((credFromPrompt) => { - let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); - return !defaultCreds.some((defaultCred) => { - return credFromPrompt.id === defaultCred.id; + const node = nodeRef[workflowMakerNodeId]; + if (node.isNew) { + if (node.unifiedJobTemplate && node.unifiedJobTemplate.unified_job_type === "workflow_approval") { + approvalTemplatePromises.push(TemplatesService.createApprovalTemplate({ + name: node.unifiedJobTemplate.name + }).then(({data: approvalTemplateData}) => { + addPromises.push(TemplatesService.addWorkflowNode({ + url: $scope.workflowJobTemplateObj.related.workflow_nodes, + data: { + unified_job_template: approvalTemplateData.id + } + }).then(({data: nodeData}) => { + node.originalNodeObject = nodeData; + nodeIdToChartNodeIdMapping[nodeData.id] = parseInt(workflowMakerNodeId); + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') }); + })); + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') }); - - credentialIdsToPost.forEach((credentialToPost) => { - credentialRequests.push({ - id: data.id, + })); + } else { + addPromises.push(TemplatesService.addWorkflowNode({ + url: $scope.workflowJobTemplateObj.related.workflow_nodes, + data: buildSendableNodeData(node) + }).then(({data}) => { + node.originalNodeObject = data; + nodeIdToChartNodeIdMapping[data.id] = parseInt(workflowMakerNodeId); + if (_.get(node, 'promptData.launchConf.ask_credential_on_launch')) { + // This finds the credentials that were selected in the prompt but don't occur + // in the template defaults + let credentialIdsToPost = node.promptData.prompts.credentials.value.filter((credFromPrompt) => { + let defaultCreds = _.get(node, 'promptData.launchConf.defaults.credentials', []); + return !defaultCreds.some((defaultCred) => { + return credFromPrompt.id === defaultCred.id; + }); + }); + + credentialIdsToPost.forEach((credentialToPost) => { + credentialRequests.push({ + id: data.id, + data: { + id: credentialToPost.id + } + }); + }); + } + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + })); + } + } else if (node.isEdited) { + if (node.unifiedJobTemplate && node.unifiedJobTemplate.unified_job_type === "workflow_approval") { + if (node.originalNodeObject.summary_fields.unified_job_template.unified_job_type === "workflow_approval") { + approvalTemplatePromises.push(TemplatesService.patchApprovalTemplate({ + id: node.originalNodeObject.summary_fields.unified_job_template.id, + data: { + name: node.unifiedJobTemplate.name, + description: node.unifiedJobTemplate.description + } + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + })); + } else { + approvalTemplatePromises.push(TemplatesService.createApprovalTemplate({ + name: node.unifiedJobTemplate.name + }).then(({data: approvalTemplateData}) => { + // Make sure that this isn't overwriting everything on the node... + editPromises.push(TemplatesService.editWorkflowNode({ + url: $scope.workflowJobTemplateObj.related.workflow_nodes, data: { - id: credentialToPost.id + unified_job_template: approvalTemplateData.id } + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + })); + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') }); - }); + })); } - }).catch(({ data, status }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') - }); - })); - } else if (nodeRef[workflowMakerNodeId].isEdited) { - editPromises.push(TemplatesService.editWorkflowNode({ - id: nodeRef[workflowMakerNodeId].originalNodeObject.id, - data: buildSendableNodeData(nodeRef[workflowMakerNodeId]) - })); + } else { + editPromises.push(TemplatesService.editWorkflowNode({ + id: node.originalNodeObject.id, + data: buildSendableNodeData(node) + })); + } - if (_.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.ask_credential_on_launch')) { - let credentialsNotInPriorCredentials = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter((credFromPrompt) => { - let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); + if (_.get(node, 'promptData.launchConf.ask_credential_on_launch')) { + let credentialsNotInPriorCredentials = node.promptData.prompts.credentials.value.filter((credFromPrompt) => { + let defaultCreds = _.get(node, 'promptData.launchConf.defaults.credentials', []); return !defaultCreds.some((defaultCred) => { return credFromPrompt.id === defaultCred.id; }); }); let credentialsToAdd = credentialsNotInPriorCredentials.filter((credNotInPrior) => { - let previousOverrides = _.get(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides', []); + let previousOverrides = _.get(node, 'promptData.prompts.credentials.previousOverrides', []); return !previousOverrides.some((priorCred) => { return credNotInPrior.id === priorCred.id; }); @@ -200,8 +267,8 @@ export default ['$scope', 'TemplatesService', let credentialsToRemove = []; - if (_.has(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides')) { - credentialsToRemove = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.previousOverrides.filter((priorCred) => { + if (_.has(node, 'promptData.prompts.credentials.previousOverrides')) { + credentialsToRemove = node.promptData.prompts.credentials.previousOverrides.filter((priorCred) => { return !credentialsNotInPriorCredentials.some((credNotInPrior) => { return priorCred.id === credNotInPrior.id; }); @@ -210,7 +277,7 @@ export default ['$scope', 'TemplatesService', credentialsToAdd.forEach((credentialToAdd) => { credentialRequests.push({ - id: nodeRef[workflowMakerNodeId].originalNodeObject.id, + id: node.originalNodeObject.id, data: { id: credentialToAdd.id } @@ -219,7 +286,7 @@ export default ['$scope', 'TemplatesService', credentialsToRemove.forEach((credentialToRemove) => { credentialRequests.push({ - id: nodeRef[workflowMakerNodeId].originalNodeObject.id, + id: node.originalNodeObject.id, data: { id: credentialToRemove.id, disassociate: true @@ -235,172 +302,177 @@ export default ['$scope', 'TemplatesService', return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); }); - $q.all(addPromises.concat(editPromises, deletePromises)) + $q.all(approvalTemplatePromises) .then(() => { - let disassociatePromises = []; - let associatePromises = []; - let linkMap = {}; - - // Build a link map for easy access - $scope.graphState.arrayOfLinksForChart.forEach(link => { - // link.source.id of 1 is our artificial start node - if (link.source.id !== 1) { - const sourceNodeId = nodeRef[link.source.id].originalNodeObject.id; - const targetNodeId = nodeRef[link.target.id].originalNodeObject.id; - if (!linkMap[sourceNodeId]) { - linkMap[sourceNodeId] = {}; + $q.all(addPromises.concat(editPromises, deletePromises)) + .then(() => { + let disassociatePromises = []; + let associatePromises = []; + let linkMap = {}; + + // Build a link map for easy access + $scope.graphState.arrayOfLinksForChart.forEach(link => { + // link.source.id of 1 is our artificial start node + if (link.source.id !== 1) { + const sourceNodeId = nodeRef[link.source.id].originalNodeObject.id; + const targetNodeId = nodeRef[link.target.id].originalNodeObject.id; + if (!linkMap[sourceNodeId]) { + linkMap[sourceNodeId] = {}; + } + + linkMap[sourceNodeId][targetNodeId] = link.edgeType; } - - linkMap[sourceNodeId][targetNodeId] = link.edgeType; - } - }); - - Object.keys(nodeRef).map((workflowNodeId) => { - let nodeId = nodeRef[workflowNodeId].originalNodeObject.id; - if (nodeRef[workflowNodeId].originalNodeObject.success_nodes) { - nodeRef[workflowNodeId].originalNodeObject.success_nodes.forEach((successNodeId) => { - if ( - !deletedNodeIds.includes(successNodeId) && - (!linkMap[nodeId] || - !linkMap[nodeId][successNodeId] || - linkMap[nodeId][successNodeId] !== "success") - ) { - disassociatePromises.push( - TemplatesService.disassociateWorkflowNode({ - parentId: nodeId, - nodeId: successNodeId, - edge: "success" - }) - ); - } - }); - } - if (nodeRef[workflowNodeId].originalNodeObject.failure_nodes) { - nodeRef[workflowNodeId].originalNodeObject.failure_nodes.forEach((failureNodeId) => { - if ( - !deletedNodeIds.includes(failureNodeId) && - (!linkMap[nodeId] || - !linkMap[nodeId][failureNodeId] || - linkMap[nodeId][failureNodeId] !== "failure") - ) { - disassociatePromises.push( - TemplatesService.disassociateWorkflowNode({ - parentId: nodeId, - nodeId: failureNodeId, - edge: "failure" - }) - ); - } - }); - } - if (nodeRef[workflowNodeId].originalNodeObject.always_nodes) { - nodeRef[workflowNodeId].originalNodeObject.always_nodes.forEach((alwaysNodeId) => { - if ( - !deletedNodeIds.includes(alwaysNodeId) && - (!linkMap[nodeId] || - !linkMap[nodeId][alwaysNodeId] || - linkMap[nodeId][alwaysNodeId] !== "always") - ) { - disassociatePromises.push( - TemplatesService.disassociateWorkflowNode({ - parentId: nodeId, - nodeId: alwaysNodeId, - edge: "always" - }) - ); - } - }); - } - }); - - Object.keys(linkMap).map((sourceNodeId) => { - Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { - const sourceChartNodeId = nodeIdToChartNodeIdMapping[sourceNodeId]; - const targetChartNodeId = nodeIdToChartNodeIdMapping[targetNodeId]; - switch(linkMap[sourceNodeId][targetNodeId]) { - case "success": + }); + + Object.keys(nodeRef).map((workflowNodeId) => { + let nodeId = nodeRef[workflowNodeId].originalNodeObject.id; + if (nodeRef[workflowNodeId].originalNodeObject.success_nodes) { + nodeRef[workflowNodeId].originalNodeObject.success_nodes.forEach((successNodeId) => { if ( - !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + !deletedNodeIds.includes(successNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][successNodeId] || + linkMap[nodeId][successNodeId] !== "success") ) { - associatePromises.push( - TemplatesService.associateWorkflowNode({ - parentId: parseInt(sourceNodeId), - nodeId: parseInt(targetNodeId), + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: successNodeId, edge: "success" }) ); } - break; - case "failure": + }); + } + if (nodeRef[workflowNodeId].originalNodeObject.failure_nodes) { + nodeRef[workflowNodeId].originalNodeObject.failure_nodes.forEach((failureNodeId) => { if ( - !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + !deletedNodeIds.includes(failureNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][failureNodeId] || + linkMap[nodeId][failureNodeId] !== "failure") ) { - associatePromises.push( - TemplatesService.associateWorkflowNode({ - parentId: parseInt(sourceNodeId), - nodeId: parseInt(targetNodeId), + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: failureNodeId, edge: "failure" }) ); } - break; - case "always": + }); + } + if (nodeRef[workflowNodeId].originalNodeObject.always_nodes) { + nodeRef[workflowNodeId].originalNodeObject.always_nodes.forEach((alwaysNodeId) => { if ( - !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + !deletedNodeIds.includes(alwaysNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][alwaysNodeId] || + linkMap[nodeId][alwaysNodeId] !== "always") ) { - associatePromises.push( - TemplatesService.associateWorkflowNode({ - parentId: parseInt(sourceNodeId), - nodeId: parseInt(targetNodeId), + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: alwaysNodeId, edge: "always" }) ); } - break; + }); } }); - }); - - $q.all(disassociatePromises) - .then(() => { - let credentialPromises = credentialRequests.map((request) => { - return TemplatesService.postWorkflowNodeCredential({ - id: request.id, - data: request.data - }); - }); - - return $q.all(associatePromises.concat(credentialPromises)) - .then(() => { - Wait('stop'); - $scope.workflowChangesUnsaved = false; - $scope.workflowChangesStarted = false; - $scope.closeDialog(); - }).catch(({ data, status }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') - }); - }); - }).catch(({ - data, - status - }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') + + Object.keys(linkMap).map((sourceNodeId) => { + Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { + const sourceChartNodeId = nodeIdToChartNodeIdMapping[sourceNodeId]; + const targetChartNodeId = nodeIdToChartNodeIdMapping[targetNodeId]; + switch(linkMap[sourceNodeId][targetNodeId]) { + case "success": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "success" + }) + ); + } + break; + case "failure": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "failure" + }) + ); + } + break; + case "always": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "always" + }) + ); + } + break; + } }); }); - }).catch(({ data, status }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') + + $q.all(disassociatePromises) + .then(() => { + let credentialPromises = credentialRequests.map((request) => { + return TemplatesService.postWorkflowNodeCredential({ + id: request.id, + data: request.data + }); + }); + + return $q.all(associatePromises.concat(credentialPromises)) + .then(() => { + Wait('stop'); + $scope.workflowChangesUnsaved = false; + $scope.workflowChangesStarted = false; + $scope.closeDialog(); + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + }); + }).catch(({ + data, + status + }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + }); + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); }); + }) + .catch(() => { + // TODO: handle }); - } else { let deletePromises = deletedNodeIds.map((nodeId) => { @@ -511,17 +583,27 @@ export default ['$scope', 'TemplatesService', $scope.formState.showNodeForm = true; }; - $scope.confirmNodeForm = (selectedTemplate, promptData, edgeType) => { + $scope.confirmNodeForm = (selectedTemplate, promptData, edgeType, pauseNode) => { $scope.workflowChangesUnsaved = true; const nodeId = $scope.nodeConfig.nodeId; if ($scope.nodeConfig.mode === "add") { - if (selectedTemplate && edgeType && edgeType.value) { - nodeRef[$scope.nodeConfig.nodeId] = { - fullUnifiedJobTemplateObject: selectedTemplate, - promptData, - isNew: true - }; - + if (edgeType && edgeType.value) { + if (selectedTemplate) { + nodeRef[$scope.nodeConfig.nodeId] = { + fullUnifiedJobTemplateObject: selectedTemplate, + promptData, + isNew: true + }; + } else if (pauseNode && pauseNode.isPauseNode) { + nodeRef[$scope.nodeConfig.nodeId] = { + unifiedJobTemplate: { + name: pauseNode.name, + description: pauseNode.description, + unified_job_type: "workflow_approval" + }, + isNew: true + }; + } $scope.graphState.nodeBeingAdded = null; $scope.graphState.arrayOfLinksForChart.map( (link) => { @@ -534,6 +616,7 @@ export default ['$scope', 'TemplatesService', } else if ($scope.nodeConfig.mode === "edit") { if (selectedTemplate) { nodeRef[$scope.nodeConfig.nodeId].fullUnifiedJobTemplateObject = selectedTemplate; + nodeRef[$scope.nodeConfig.nodeId].unifiedJobTemplate = selectedTemplate; nodeRef[$scope.nodeConfig.nodeId].promptData = _.cloneDeep(promptData); nodeRef[$scope.nodeConfig.nodeId].isEdited = true; $scope.graphState.nodeBeingEdited = null; @@ -546,12 +629,30 @@ export default ['$scope', 'TemplatesService', link.source.unifiedJobTemplate = selectedTemplate; } }); + } else if (pauseNode && pauseNode.isPauseNode) { + // If it's a _new_ pause node then we'll want to create the new ujt + // If it's an existing pause node then we'll want to update the ujt + nodeRef[$scope.nodeConfig.nodeId].unifiedJobTemplate = { + name: pauseNode.name, + description: pauseNode.description, + unified_job_type: "workflow_approval" + }, + nodeRef[$scope.nodeConfig.nodeId].isEdited = true; } } $scope.graphState.arrayOfNodesForChart.map( (node) => { if (node.id === nodeId) { - node.unifiedJobTemplate = selectedTemplate; + if (pauseNode && pauseNode.isPauseNode) { + node.unifiedJobTemplate = { + unified_job_type: 'workflow_approval', + name: pauseNode.name, + description: pauseNode.description + }; + } else { + node.unifiedJobTemplate = selectedTemplate; + } + } }); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 4e986586ea..d8685582fe 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -128,7 +128,7 @@
- + From 1d814beca189f95fe35eb8669b30f2772a5d1253 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 11 Jul 2019 16:13:10 -0400 Subject: [PATCH 07/57] Fix linting error --- .../workflows/workflow-maker/workflow-maker.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index d5e166a7b5..e58c387e19 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -636,7 +636,7 @@ export default ['$scope', 'TemplatesService', name: pauseNode.name, description: pauseNode.description, unified_job_type: "workflow_approval" - }, + }; nodeRef[$scope.nodeConfig.nodeId].isEdited = true; } } From e0cdc4ff8047a460f33e5c402461f0ba9bfe5572 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 15 Jul 2019 15:39:06 -0400 Subject: [PATCH 08/57] Approval drawer cleanup and workflow node form UX cleanup --- .../components/approvalsDrawer/_index.less | 48 ++++-- .../approvalsDrawer.directive.js | 51 +++--- .../approvalsDrawer.partial.html | 154 +++++++++--------- .../lib/components/components.strings.js | 7 +- .../shared/paginate/paginate.controller.js | 4 +- .../forms/workflow-node-form.partial.html | 2 +- .../workflow-maker.controller.js | 43 ++--- .../workflow-maker.partial.html | 2 +- 8 files changed, 166 insertions(+), 145 deletions(-) diff --git a/awx/ui/client/lib/components/approvalsDrawer/_index.less b/awx/ui/client/lib/components/approvalsDrawer/_index.less index abca0e2166..521a23c05e 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/_index.less +++ b/awx/ui/client/lib/components/approvalsDrawer/_index.less @@ -1,16 +1,25 @@ .at-ApprovalsDrawer { - position: absolute; - right: 0px; + position: fixed; top: 0; - height: 100%; - width: 540px; - background-color: white; - z-index: 1000000; - animation-duration: 0.5s; - // TODO: fix animation? - // animation-name: slidein; - padding: 20px; - box-shadow: -3px 0px 8px -2px #aaaaaa; + right: 0; + bottom: 0; + left: 0; + // z-index of the nav header is 1040 + z-index: 1041; + background-color: rgba(0, 0, 0, 0.3); + + &-drawer { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 540px; + background-color: @default-bg; + animation-name: slidein; + animation-duration: 250ms; + padding: 20px; + overflow-y: scroll; + } &-header { display: flex; @@ -20,12 +29,23 @@ &-title { flex: 1 0 auto; - color: #606060; + color: @default-interface-txt; font-size: 14px; font-weight: bold; width: calc(82%); } + &-actionRow { + display: flex; + justify-content: flex-end; + width: 100%; + margin-top: 10px; + + button { + margin-left: 15px; + } + } + &-exit { justify-content: flex-end; display: flex; @@ -33,7 +53,7 @@ button { height: 20px; font-size: 20px; - color: #D7D7D7; + color: @d7grey; line-height: 1; opacity: 1; } @@ -41,7 +61,7 @@ button:hover{ color: @default-icon; opacity: 1; - } + } } } diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js index 556d216559..0f6136a516 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js @@ -8,52 +8,36 @@ function AtApprovalsDrawerController (strings, Rest, GetBasePath, $rootScope) { value: 'created' }; + vm.strings = strings; vm.toolbarSortValue = toolbarSortDefault; + vm.queryset = { + page: 1, + page_size: 5, + order_by: 'created', + status: 'pending' + }; + vm.emptyListReason = vm.strings.get('approvals.NONE'); // This will probably need to be expanded vm.toolbarSortOptions = [ toolbarSortDefault, - { label: `${strings.get('sort.CREATED_DESCENDING')}`, value: '-created' } + { label: `${vm.strings.get('sort.CREATED_DESCENDING')}`, value: '-created' } ]; - vm.queryset = { - page_size: 5 - }; - - vm.emptyListReason = strings.get('approvals.NONE'); - const loadTheList = () => { - Rest.setUrl(`${GetBasePath('workflow_approval')}?page_size=5&order_by=created&status=pending`); - Rest.get() + const queryParams = Object.keys(vm.queryset).map(key => `${key}=${vm.queryset[key]}`).join('&'); + Rest.setUrl(`${GetBasePath('workflow_approval')}?${queryParams}`); + return Rest.get() .then(({ data }) => { vm.dataset = data; vm.approvals = data.results; vm.count = data.count; $rootScope.pendingApprovalCount = data.count; - vm.listLoaded = true; }); }; - loadTheList(); - - vm.onToolbarSort = (sort) => { - vm.toolbarSortValue = sort; - - // TODO: this... - // const queryParams = Object.assign( - // {}, - // $state.params.user_search, - // paginateQuerySet, - // { order_by: sort.value } - // ); - - // // Update URL with params - // $state.go('.', { - // user_search: queryParams - // }, { notify: false, location: 'replace' }); - - // rather than ^^ we want to just re-load the data based on new params - }; + loadTheList() + .then(() => { vm.listLoaded = true; }); vm.approve = (approval) => { Rest.setUrl(`${GetBasePath('workflow_approval')}${approval.id}/approve`); @@ -66,6 +50,13 @@ function AtApprovalsDrawerController (strings, Rest, GetBasePath, $rootScope) { Rest.post() .then(() => loadTheList()); }; + + vm.onToolbarSort = (sort) => { + vm.toolbarSortValue = sort; + vm.queryset.page = 1; + vm.queryset.order_by = sort.value; + loadTheList(); + }; } AtApprovalsDrawerController.$inject = ['ComponentsStrings', 'Rest', 'GetBasePath', '$rootScope']; diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html index 55e8a0ab2f..bd208c0f55 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html @@ -1,79 +1,79 @@ -
-
-
-
- - NOTIFICATIONS - - - {{vm.count}} - -
-
- -
-
- - - - -
-
-
- - - -
-
-
- - - - - -
-
-
-
Continue workflow job?
- - -
-
+
+
+
+
+ + {{:: vm.strings.get('approvals.NOTIFICATIONS') }} + + + {{vm.count}} +
- - - - +
+ +
+
+ + + + +
+
+
+ + + +
+
+
+ + + + + +
+
+
+
{{:: vm.strings.get('approvals.CONTINUE') }}
+ + +
+
+
+
+
+ + +
\ No newline at end of file diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 772ce84f37..0360a4970e 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -121,7 +121,12 @@ function ComponentsStrings (BaseString) { }; ns.approvals = { - NONE: t.s('There are no jobs awaiting approval') + NONE: t.s('There are no jobs awaiting approval'), + APPROVE: t.s('APPROVE'), + DENY: t.s('DENY'), + CONTINUE: t.s('Continue workflow job?'), + NOTIFICATIONS: t.s('NOTIFICATIONS'), + WORKFLOW_TEMPLATE: t.s('Workflow Template') }; } diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js index a16de19a7e..10a30bcaef 100644 --- a/awx/ui/client/src/shared/paginate/paginate.controller.js +++ b/awx/ui/client/src/shared/paginate/paginate.controller.js @@ -1,5 +1,5 @@ -export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'QuerySet', '$interpolate', - function($scope, $stateParams, $state, $filter, GetBasePath, qs, $interpolate) { +export default ['$scope', '$stateParams', '$state', 'GetBasePath', 'QuerySet', '$interpolate', + function($scope, $stateParams, $state, GetBasePath, qs, $interpolate) { let pageSize = $scope.querySet ? $scope.querySet.page_size || 20 : $stateParams[`${$scope.iterator}_search`].page_size || 20, queryset, path; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index 089b24af5c..b01876a044 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -281,4 +281,4 @@
-
+
\ No newline at end of file diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index e58c387e19..53cd0076a9 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -226,7 +226,7 @@ export default ['$scope', 'TemplatesService', }).then(({data: approvalTemplateData}) => { // Make sure that this isn't overwriting everything on the node... editPromises.push(TemplatesService.editWorkflowNode({ - url: $scope.workflowJobTemplateObj.related.workflow_nodes, + id: node.originalNodeObject.id, data: { unified_job_template: approvalTemplateData.id } @@ -583,26 +583,31 @@ export default ['$scope', 'TemplatesService', $scope.formState.showNodeForm = true; }; - $scope.confirmNodeForm = (selectedTemplate, promptData, edgeType, pauseNode) => { + $scope.confirmNodeForm = (nodeFormData) => { + const { edgeType, selectedTemplate, promptData } = nodeFormData; + const isPauseNode = selectedTemplate.type === "workflow_approval" + || selectedTemplate.unified_job_type === "workflow_approval"; + // edgeType, selectedTemplate, promptData + // can determine pause node by looking at the type (?) or maybe unified_job_type $scope.workflowChangesUnsaved = true; const nodeId = $scope.nodeConfig.nodeId; if ($scope.nodeConfig.mode === "add") { - if (edgeType && edgeType.value) { - if (selectedTemplate) { + if (edgeType && edgeType.value && selectedTemplate) { + if (isPauseNode) { + nodeRef[$scope.nodeConfig.nodeId] = { + unifiedJobTemplate: { + name: selectedTemplate.name, + description: selectedTemplate.description, + unified_job_type: "workflow_approval" + }, + isNew: true + }; + } else { nodeRef[$scope.nodeConfig.nodeId] = { fullUnifiedJobTemplateObject: selectedTemplate, promptData, isNew: true }; - } else if (pauseNode && pauseNode.isPauseNode) { - nodeRef[$scope.nodeConfig.nodeId] = { - unifiedJobTemplate: { - name: pauseNode.name, - description: pauseNode.description, - unified_job_type: "workflow_approval" - }, - isNew: true - }; } $scope.graphState.nodeBeingAdded = null; @@ -629,12 +634,12 @@ export default ['$scope', 'TemplatesService', link.source.unifiedJobTemplate = selectedTemplate; } }); - } else if (pauseNode && pauseNode.isPauseNode) { + } else if (isPauseNode) { // If it's a _new_ pause node then we'll want to create the new ujt // If it's an existing pause node then we'll want to update the ujt nodeRef[$scope.nodeConfig.nodeId].unifiedJobTemplate = { - name: pauseNode.name, - description: pauseNode.description, + name: selectedTemplate.name, + description: selectedTemplate.description, unified_job_type: "workflow_approval" }; nodeRef[$scope.nodeConfig.nodeId].isEdited = true; @@ -643,11 +648,11 @@ export default ['$scope', 'TemplatesService', $scope.graphState.arrayOfNodesForChart.map( (node) => { if (node.id === nodeId) { - if (pauseNode && pauseNode.isPauseNode) { + if (isPauseNode) { node.unifiedJobTemplate = { unified_job_type: 'workflow_approval', - name: pauseNode.name, - description: pauseNode.description + name: selectedTemplate.name, + description: selectedTemplate.description }; } else { node.unifiedJobTemplate = selectedTemplate; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index d8685582fe..fabaa4c04c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -128,7 +128,7 @@
- + From 83f9681941eba2a02150d35e94be9de021097f5d Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 15 Jul 2019 16:17:48 -0400 Subject: [PATCH 09/57] Fix jshint errors --- .../workflows/workflow-maker/workflow-maker.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 53cd0076a9..72a74a5b8a 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -585,8 +585,8 @@ export default ['$scope', 'TemplatesService', $scope.confirmNodeForm = (nodeFormData) => { const { edgeType, selectedTemplate, promptData } = nodeFormData; - const isPauseNode = selectedTemplate.type === "workflow_approval" - || selectedTemplate.unified_job_type === "workflow_approval"; + const isPauseNode = selectedTemplate.type === "workflow_approval" || + selectedTemplate.unified_job_type === "workflow_approval"; // edgeType, selectedTemplate, promptData // can determine pause node by looking at the type (?) or maybe unified_job_type $scope.workflowChangesUnsaved = true; From 320284267cd5de9326ff34fbcd09e075391069d0 Mon Sep 17 00:00:00 2001 From: beeankha Date: Thu, 18 Jul 2019 09:33:16 -0400 Subject: [PATCH 10/57] Add new endpoint for creation of approval nodes --- awx/api/serializers.py | 21 +++++++++- awx/api/urls/urls.py | 4 +- awx/api/urls/workflow_job_template_node.py | 2 + awx/api/views/__init__.py | 19 ++++++++- awx/api/views/root.py | 4 +- awx/main/access.py | 24 +++++++---- awx/main/fields.py | 2 +- awx/main/management/commands/cleanup_jobs.py | 2 + .../migrations/0082_v360_workflowapproval.py | 41 ------------------- awx/main/models/__init__.py | 4 +- awx/main/models/mixins.py | 1 - awx/main/models/organization.py | 4 ++ awx/main/models/rbac.py | 4 +- awx/main/models/workflow.py | 40 ++++++++++++++++++ awx/main/registrar.py | 2 +- awx/main/signals.py | 24 ++++++++++- .../approvalsDrawer.directive.js | 6 +-- awx/ui/client/src/app.js | 2 +- .../login/loginModal/loginModal.controller.js | 2 +- .../workflow-maker.controller.js | 16 ++++---- 20 files changed, 148 insertions(+), 76 deletions(-) delete mode 100644 awx/main/migrations/0082_v360_workflowapproval.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f526fcfdc7..d4ab2811e6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3099,7 +3099,7 @@ class JobRelaunchSerializer(BaseSerializer): attrs = super(JobRelaunchSerializer, self).validate(attrs) return attrs - +# &&&&&& class JobCreateScheduleSerializer(BaseSerializer): can_schedule = serializers.SerializerMethodField() @@ -3437,7 +3437,7 @@ class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): class Meta: model = WorkflowApprovalTemplate - fields = ('*',) + fields = ('*', 'timeout', 'name',) def get_related(self, obj): res = super(WorkflowApprovalTemplateSerializer, self).get_related(obj) @@ -3453,6 +3453,15 @@ class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): return res +# class WorkflowJobTemplateApprovalSerializer(UnifiedJobTemplateSerializer): +# class Meta: +# model = WorkflowJobTemplateApproval +# fields = ('*',) +# +# def post(self, obj): +# return # POST only!!! + + class LaunchConfigurationBaseSerializer(BaseSerializer): scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) job_type = serializers.ChoiceField(allow_blank=True, allow_null=True, required=False, default=None, @@ -3592,6 +3601,7 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): def get_related(self, obj): res = super(WorkflowJobTemplateNodeSerializer, self).get_related(obj) + res['create_approval_job_template'] = self.reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': obj.pk}) res['success_nodes'] = self.reverse('api:workflow_job_template_node_success_nodes_list', kwargs={'pk': obj.pk}) res['failure_nodes'] = self.reverse('api:workflow_job_template_node_failure_nodes_list', kwargs={'pk': obj.pk}) res['always_nodes'] = self.reverse('api:workflow_job_template_node_always_nodes_list', kwargs={'pk': obj.pk}) @@ -3660,6 +3670,13 @@ class WorkflowJobTemplateNodeDetailSerializer(WorkflowJobTemplateNodeSerializer) field_kwargs.pop('queryset', None) return field_class, field_kwargs +# &&&&&& +class WorkflowJobTemplateNodeCreateApprovalSerializer(BaseSerializer): + + class Meta: + model = WorkflowApprovalTemplate + fields = ('timeout', 'name', 'description',) + class JobListSerializer(JobSerializer, UnifiedJobListSerializer): pass diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 97819b5ce9..beaa7532c7 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -133,8 +133,8 @@ v2_urls = [ url(r'^unified_job_templates/$', UnifiedJobTemplateList.as_view(), name='unified_job_template_list'), url(r'^unified_jobs/$', UnifiedJobList.as_view(), name='unified_job_list'), url(r'^activity_stream/', include(activity_stream_urls)), - url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)), - url(r'^workflow_approval/', include(workflow_approval_urls)), + url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)), # &&&&&& Take this line out completely? + url(r'^workflow_approvals/', include(workflow_approval_urls)), ] diff --git a/awx/api/urls/workflow_job_template_node.py b/awx/api/urls/workflow_job_template_node.py index 14cb49137e..76ba375cb7 100644 --- a/awx/api/urls/workflow_job_template_node.py +++ b/awx/api/urls/workflow_job_template_node.py @@ -10,6 +10,7 @@ from awx.api.views import ( WorkflowJobTemplateNodeFailureNodesList, WorkflowJobTemplateNodeAlwaysNodesList, WorkflowJobTemplateNodeCredentialsList, + WorkflowJobTemplateNodeCreateApproval, ) @@ -20,6 +21,7 @@ urls = [ url(r'^(?P[0-9]+)/failure_nodes/$', WorkflowJobTemplateNodeFailureNodesList.as_view(), name='workflow_job_template_node_failure_nodes_list'), url(r'^(?P[0-9]+)/always_nodes/$', WorkflowJobTemplateNodeAlwaysNodesList.as_view(), name='workflow_job_template_node_always_nodes_list'), url(r'^(?P[0-9]+)/credentials/$', WorkflowJobTemplateNodeCredentialsList.as_view(), name='workflow_job_template_node_credentials_list'), + url(r'^(?P[0-9]+)/create_approval_job_template/$', WorkflowJobTemplateNodeCreateApproval.as_view(), name='workflow_job_template_node_create_approval'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 58fff401c1..8fa5bbff45 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3013,6 +3013,21 @@ class WorkflowJobTemplateNodeChildrenBaseList(EnforceParentRelationshipMixin, Su return None +class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): + + model = models.WorkflowJobTemplateNode + serializer_class = serializers.WorkflowJobTemplateNodeCreateApprovalSerializer + +# &&&&&& + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + obj = self.get_object() + approval_template = obj.create_approval_template(**serializer.validated_data) + return Response(data={'id':approval_template.pk}, status=status.HTTP_200_OK) + + class WorkflowJobTemplateNodeSuccessNodesList(WorkflowJobTemplateNodeChildrenBaseList): relationship = 'success_nodes' @@ -3582,7 +3597,7 @@ class JobRelaunch(RetrieveAPIView): headers = {'Location': new_job.get_absolute_url(request=request)} return Response(data, status=status.HTTP_201_CREATED, headers=headers) - +# &&&&&& Reference class JobCreateSchedule(RetrieveAPIView): model = models.Job @@ -4466,7 +4481,7 @@ class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): model = models.WorkflowApproval serializer_class = serializers.WorkflowApprovalSerializer - +# &&&&&& Include checks in the below two post methods class WorkflowApprovalApprove(RetrieveAPIView): model = models.WorkflowApproval serializer_class = serializers.WorkflowApprovalViewSerializer diff --git a/awx/api/views/root.py b/awx/api/views/root.py index bf85b4866e..df5f6f7f0e 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -124,8 +124,8 @@ class ApiVersionRootView(APIView): data['activity_stream'] = reverse('api:activity_stream_list', request=request) data['workflow_job_templates'] = reverse('api:workflow_job_template_list', request=request) data['workflow_jobs'] = reverse('api:workflow_job_list', request=request) - data['workflow_approval_templates'] = reverse('api:workflow_approval_template_list', request=request) - data['workflow_approval'] = reverse('api:workflow_approval_list', request=request) + data['workflow_approval_templates'] = reverse('api:workflow_approval_template_list', request=request) # &&&&&& Take this line out completely? + data['workflow_approvals'] = reverse('api:workflow_approval_list', request=request) data['workflow_job_template_nodes'] = reverse('api:workflow_job_template_node_list', request=request) data['workflow_job_nodes'] = reverse('api:workflow_job_node_list', request=request) return Response(data) diff --git a/awx/main/access.py b/awx/main/access.py index bcd9cd1dab..5b7017d9f3 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2792,24 +2792,32 @@ class WorkflowApprovalAccess(BaseAccess): def can_start(self, obj, validate_license=True): return False + # &&&&&& ??? Start of the RBAC method ??? + # def can_approve_or_deny(self, obj): + # if self.user.is_superuser: # &&&&&& add "or self.user.approval_role"? + # return True + # return self.can_change(obj, ????) +# &&&&&& Why is the below not showing up as a class now?? class WorkflowApprovalTemplateAccess(BaseAccess): ''' + I can create approval nodes when: + - I can approve workflows when: - - I'm authenticated - I can create when: - - I'm a superuser: + - ''' model = WorkflowApprovalTemplate prefetch_related = ('created_by', 'modified_by',) - def can_read(self, obj): - return True - - def can_use(self, obj): - return True + # &&&&&& I need to get the admin role of the WFJT, where WFJT is provided in the key portion vs the data (Alan said that, what does it mean exactly???) + @check_superuser + def can_add(self, data): + if data is None: # Hide direct creation in API browser + return False + return ( + self.check_related('workflow_approval_template', UnifiedJobTemplate, role_field='admin_role') def filtered_queryset(self): return self.model.filter(workflowjobtemplatenodes__workflow_job_template=WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role')) diff --git a/awx/main/fields.py b/awx/main/fields.py index d0286f553a..d395803c7c 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -164,7 +164,7 @@ def is_implicit_parent(parent_role, child_role): # The only singleton implicit parent is the system admin being # a parent of the system auditor role return bool( - child_role.singleton_name == ROLE_SINGLETON_SYSTEM_AUDITOR and + child_role.singleton_name == ROLE_SINGLETON_SYSTEM_AUDITOR and parent_role.singleton_name == ROLE_SINGLETON_SYSTEM_ADMINISTRATOR ) # Get the list of implicit parents that were defined at the class level. diff --git a/awx/main/management/commands/cleanup_jobs.py b/awx/main/management/commands/cleanup_jobs.py index 81f405da4c..f43a34c6b6 100644 --- a/awx/main/management/commands/cleanup_jobs.py +++ b/awx/main/management/commands/cleanup_jobs.py @@ -200,6 +200,8 @@ class Command(BaseCommand): skipped += WorkflowJob.objects.filter(created__gte=self.cutoff).count() return skipped, deleted +# &&&&&& Add cleanup of orphaned approval nodes here? + def cleanup_notifications(self): skipped, deleted = 0, 0 notifications = Notification.objects.filter(created__lt=self.cutoff) diff --git a/awx/main/migrations/0082_v360_workflowapproval.py b/awx/main/migrations/0082_v360_workflowapproval.py deleted file mode 100644 index 80a188c274..0000000000 --- a/awx/main/migrations/0082_v360_workflowapproval.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.20 on 2019-07-03 14:38 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0081_v360_notify_on_start'), - ] - - operations = [ - migrations.CreateModel( - name='WorkflowApproval', - fields=[ - ('unifiedjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='main.UnifiedJob')), - ], - options={ - 'manager_inheritance_from_future': True, - }, - bases=('main.unifiedjob',), - ), - migrations.CreateModel( - name='WorkflowApprovalTemplate', - fields=[ - ('unifiedjobtemplate_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='main.UnifiedJobTemplate')), - ], - options={ - 'manager_inheritance_from_future': True, - }, - bases=('main.unifiedjobtemplate',), - ), - migrations.AddField( - model_name='workflowapproval', - name='workflow_approval_template', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approvals', to='main.WorkflowApprovalTemplate'), - ), - ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index a85653e112..627578a854 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -174,7 +174,7 @@ def o_auth2_token_get_absolute_url(self, request=None): OAuth2AccessToken.add_to_class('get_absolute_url', o_auth2_token_get_absolute_url) - +# &&&&&& "Add model here" - Alan from awx.main.registrar import activity_stream_registrar # noqa activity_stream_registrar.connect(Organization) activity_stream_registrar.connect(Inventory) @@ -202,6 +202,8 @@ activity_stream_registrar.connect(User) activity_stream_registrar.connect(WorkflowJobTemplate) activity_stream_registrar.connect(WorkflowJobTemplateNode) activity_stream_registrar.connect(WorkflowJob) +# activity_stream_registrar.connect(WorkflowApproval) &&&&&& +# activity_stream_registrar.connect(WorkflowApprovalTemplate) activity_stream_registrar.connect(OAuth2Application) activity_stream_registrar.connect(OAuth2AccessToken) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 47176f2550..d63ec5eb5d 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -483,4 +483,3 @@ class RelatedJobsMixin(object): raise RuntimeError("Programmer error. Expected _get_active_jobs() to return a QuerySet.") return [dict(id=t[0], type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')] - diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 1c77c9e5be..21fb0fd7d1 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -89,6 +89,10 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi 'notification_admin_role', 'credential_admin_role', 'job_template_admin_role',], ) +# &&&&&& The below keeps complaining - fixed by new migration file, perhaps? + approval_role = ImplicitRoleField( + parent_role='admin_role', + ) def get_absolute_url(self, request=None): diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index a0b5b6785f..39a2109ad5 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -48,6 +48,7 @@ role_names = { 'read_role': _('Read'), 'update_role': _('Update'), 'use_role': _('Use'), + 'approval_role': _('Approve'), # &&&&&& Added this here! } role_descriptions = { @@ -70,6 +71,7 @@ role_descriptions = { 'read_role': _('May view settings for the %s'), 'update_role': _('May update the %s'), 'use_role': _('Can use the %s in a job template'), + 'approval_role': _('Can approve or deny a workflow approval node'), # &&&&&& ...and here! } @@ -480,7 +482,7 @@ def get_roles_on_resource(resource, accessor): ).values_list('role_field', flat=True).distinct() ] - +# &&&&&& This area is giving trouble? def role_summary_fields_generator(content_object, role_field): global role_descriptions global role_names diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index eabfcb9133..a0948e36b9 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -162,6 +162,13 @@ class WorkflowJobTemplateNode(WorkflowNodeBase): new_node.credentials.add(cred) return new_node + def create_approval_template(self, **kwargs): + approval_template = WorkflowApprovalTemplate(**kwargs) + approval_template.save() + self.unified_job_template = approval_template + self.save() + return approval_template + class WorkflowJobNode(WorkflowNodeBase): job = models.OneToOneField( @@ -388,6 +395,11 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, 'organization.auditor_role', 'execute_role', 'admin_role' ]) +# &&&&&& The below keeps complaining - fixed by new migration file, perhaps? + approval_role = ImplicitRoleField(parent_role=[ + 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, + 'organization.approval_role', 'admin_role', + ]) @property def workflow_nodes(self): @@ -608,6 +620,12 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): class Meta: app_label = 'main' + timeout = models.IntegerField( + blank=True, + default=0, + help_text=_("The amount of time (in seconds) before the approval node expires and fails."), + ) + @classmethod def _get_unified_job_class(cls): return WorkflowApproval @@ -619,6 +637,28 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): def get_absolute_url(self, request=None): return reverse('api:workflow_approval_template_detail', kwargs={'pk': self.pk}, request=request) + # @property + # def notification_templates(self): + # # Return all notification_templates defined on the Job Template, on the Project, and on the Organization for each trigger type + # base_notification_templates = NotificationTemplate.objects + # error_notification_templates = list(base_notification_templates.filter( + # unifiedjobtemplate_notification_templates_for_errors__in=[self, self.project])) + # started_notification_templates = list(base_notification_templates.filter( + # unifiedjobtemplate_notification_templates_for_started__in=[self, self.project])) + # success_notification_templates = list(base_notification_templates.filter( + # unifiedjobtemplate_notification_templates_for_success__in=[self, self.project])) +# &&&&&& Approvals don't have orgs! How to pull them in? Alan said to "get creative"! + # if self.project is not None and self.project.organization is not None: + # error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter( + # organization_notification_templates_for_errors=self.project.organization))) + # started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter( + # organization_notification_templates_for_started=self.project.organization))) + # success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter( + # organization_notification_templates_for_success=self.project.organization))) + # return dict(error=list(error_notification_templates), + # started=list(started_notification_templates), + # success=list(success_notification_templates)) + class WorkflowApproval(UnifiedJob): class Meta: diff --git a/awx/main/registrar.py b/awx/main/registrar.py index 6d0ccfe495..f7f32d839b 100644 --- a/awx/main/registrar.py +++ b/awx/main/registrar.py @@ -3,7 +3,7 @@ from django.db.models.signals import pre_save, post_save, pre_delete, m2m_changed - +# &&&&&& Where the signals are hooked up ?? class ActivityStreamRegistrar(object): def __init__(self): diff --git a/awx/main/signals.py b/awx/main/signals.py index da2898e43f..fa50b97fe9 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -355,6 +355,26 @@ def update_host_last_job_after_job_deleted(sender, **kwargs): for host in Host.objects.filter(pk__in=hosts_pks): _update_host_last_jhs(host) + +# &&&&&& Argh. Which one looks better? +# @receiver(pre_delete, sender=Job) +# def delete_detached_approval_nodes(sender, instance, **kwargs): +# for l in instance.labels.all(): +# if l.is_candidate_for_detach(): +# l.delete() +# +# +# @receiver(pre_delete, sender=Organization) +# def delete_detached_approval_nodes(sender, instance, **kwargs): +# approval_node = ??? +# user = get_current_user_or_none() +# for node in approval_node: +# try: +# node.schedule_deletion(user_id=getattr(user, 'id', None)) +# except RuntimeError as e: +# logger.debug(e) + + # Set via ActivityStreamRegistrar to record activity stream events @@ -434,7 +454,7 @@ def model_serializer_mapping(): models.OAuth2Application: serializers.OAuth2ApplicationSerializer, } - +# &&&&&& Can customize how/what the activity stream shows info def activity_stream_create(sender, instance, created, **kwargs): if created and activity_stream_enabled: # TODO: remove deprecated_group conditional in 3.3 @@ -462,6 +482,8 @@ def activity_stream_create(sender, instance, created, **kwargs): object1=object1, changes=json.dumps(changes), actor=get_current_user_or_none()) + # if type(instance) == WorkflowApproval: &&&&&& + # changes['status'] = #??? #TODO: Weird situation where cascade SETNULL doesn't work # it might actually be a good idea to remove all of these FK references since # we don't really use them anyway. diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js index 0f6136a516..b215f95620 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js @@ -26,7 +26,7 @@ function AtApprovalsDrawerController (strings, Rest, GetBasePath, $rootScope) { const loadTheList = () => { const queryParams = Object.keys(vm.queryset).map(key => `${key}=${vm.queryset[key]}`).join('&'); - Rest.setUrl(`${GetBasePath('workflow_approval')}?${queryParams}`); + Rest.setUrl(`${GetBasePath('workflow_approvals')}?${queryParams}`); return Rest.get() .then(({ data }) => { vm.dataset = data; @@ -40,13 +40,13 @@ function AtApprovalsDrawerController (strings, Rest, GetBasePath, $rootScope) { .then(() => { vm.listLoaded = true; }); vm.approve = (approval) => { - Rest.setUrl(`${GetBasePath('workflow_approval')}${approval.id}/approve`); + Rest.setUrl(`${GetBasePath('workflow_approvals')}${approval.id}/approve`); Rest.post() .then(() => loadTheList()); }; vm.deny = (approval) => { - Rest.setUrl(`${GetBasePath('workflow_approval')}${approval.id}/deny`); + Rest.setUrl(`${GetBasePath('workflow_approvals')}${approval.id}/deny`); Rest.post() .then(() => loadTheList()); }; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 5c94436659..0e3cf2c9b2 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -388,7 +388,7 @@ angular }); }); - Rest.setUrl(`${GetBasePath('workflow_approval')}?status=pending&page_size=1`); + Rest.setUrl(`${GetBasePath('workflow_approvals')}?status=pending&page_size=1`); Rest.get() .then(({data}) => { $rootScope.pendingApprovalCount = data.count; diff --git a/awx/ui/client/src/login/loginModal/loginModal.controller.js b/awx/ui/client/src/login/loginModal/loginModal.controller.js index e5df9bf1b6..4cb975c29c 100644 --- a/awx/ui/client/src/login/loginModal/loginModal.controller.js +++ b/awx/ui/client/src/login/loginModal/loginModal.controller.js @@ -140,7 +140,7 @@ export default ['$log', '$cookies', '$rootScope', }); }); - Rest.setUrl(`${GetBasePath('workflow_approval')}?status=pending&page_size=1`); + Rest.setUrl(`${GetBasePath('workflow_approvals')}?status=pending&page_size=1`); Rest.get() .then(({data}) => { $rootScope.pendingApprovalCount = data.count; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 72a74a5b8a..925c9a03fd 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -127,7 +127,7 @@ export default ['$scope', 'TemplatesService', if (_.has(node, 'fullUnifiedJobTemplateObject') && (node.fullUnifiedJobTemplateObject.type === "workflow_job_template" || - node.fullUnifiedJobTemplateObject.type === "job_template") && + node.fullUnifiedJobTemplateObject.type === "job_template") && node.promptData ) { sendableNodeData = PromptService.bundlePromptDataForSaving({ @@ -188,7 +188,7 @@ export default ['$scope', 'TemplatesService', return credFromPrompt.id === defaultCred.id; }); }); - + credentialIdsToPost.forEach((credentialToPost) => { credentialRequests.push({ id: data.id, @@ -309,7 +309,7 @@ export default ['$scope', 'TemplatesService', let disassociatePromises = []; let associatePromises = []; let linkMap = {}; - + // Build a link map for easy access $scope.graphState.arrayOfLinksForChart.forEach(link => { // link.source.id of 1 is our artificial start node @@ -319,11 +319,11 @@ export default ['$scope', 'TemplatesService', if (!linkMap[sourceNodeId]) { linkMap[sourceNodeId] = {}; } - + linkMap[sourceNodeId][targetNodeId] = link.edgeType; } }); - + Object.keys(nodeRef).map((workflowNodeId) => { let nodeId = nodeRef[workflowNodeId].originalNodeObject.id; if (nodeRef[workflowNodeId].originalNodeObject.success_nodes) { @@ -381,7 +381,7 @@ export default ['$scope', 'TemplatesService', }); } }); - + Object.keys(linkMap).map((sourceNodeId) => { Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { const sourceChartNodeId = nodeIdToChartNodeIdMapping[sourceNodeId]; @@ -432,7 +432,7 @@ export default ['$scope', 'TemplatesService', } }); }); - + $q.all(disassociatePromises) .then(() => { let credentialPromises = credentialRequests.map((request) => { @@ -441,7 +441,7 @@ export default ['$scope', 'TemplatesService', data: request.data }); }); - + return $q.all(associatePromises.concat(credentialPromises)) .then(() => { Wait('stop'); From 294d6551b992b52ae0b7d6acfe8bce330925725b Mon Sep 17 00:00:00 2001 From: beeankha Date: Thu, 18 Jul 2019 12:07:22 -0400 Subject: [PATCH 11/57] Polishing up work on new endpoint --- awx/api/serializers.py | 9 ++-- awx/api/urls/urls.py | 2 +- awx/api/urls/workflow_approval_template.py | 2 - awx/api/urls/workflow_job_template_node.py | 2 +- awx/api/views/__init__.py | 13 ++---- awx/api/views/root.py | 1 - awx/main/access.py | 20 ++++----- awx/main/management/commands/cleanup_jobs.py | 2 - .../migrations/0082_v360_workflowapproval.py | 43 +++++++++++++++++++ awx/main/models/__init__.py | 2 +- awx/main/models/organization.py | 1 - awx/main/models/rbac.py | 6 +-- awx/main/models/workflow.py | 23 ---------- awx/main/registrar.py | 2 +- awx/main/signals.py | 23 +--------- 15 files changed, 71 insertions(+), 80 deletions(-) create mode 100644 awx/main/migrations/0082_v360_workflowapproval.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d4ab2811e6..35f1e33a98 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3099,7 +3099,7 @@ class JobRelaunchSerializer(BaseSerializer): attrs = super(JobRelaunchSerializer, self).validate(attrs) return attrs -# &&&&&& + class JobCreateScheduleSerializer(BaseSerializer): can_schedule = serializers.SerializerMethodField() @@ -3601,7 +3601,7 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): def get_related(self, obj): res = super(WorkflowJobTemplateNodeSerializer, self).get_related(obj) - res['create_approval_job_template'] = self.reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': obj.pk}) + res['create_approval_template'] = self.reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': obj.pk}) res['success_nodes'] = self.reverse('api:workflow_job_template_node_success_nodes_list', kwargs={'pk': obj.pk}) res['failure_nodes'] = self.reverse('api:workflow_job_template_node_failure_nodes_list', kwargs={'pk': obj.pk}) res['always_nodes'] = self.reverse('api:workflow_job_template_node_always_nodes_list', kwargs={'pk': obj.pk}) @@ -3670,13 +3670,16 @@ class WorkflowJobTemplateNodeDetailSerializer(WorkflowJobTemplateNodeSerializer) field_kwargs.pop('queryset', None) return field_class, field_kwargs -# &&&&&& + class WorkflowJobTemplateNodeCreateApprovalSerializer(BaseSerializer): class Meta: model = WorkflowApprovalTemplate fields = ('timeout', 'name', 'description',) + def to_representation(self, obj): + return {} + class JobListSerializer(JobSerializer, UnifiedJobListSerializer): pass diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index beaa7532c7..ede960ecb6 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -133,7 +133,7 @@ v2_urls = [ url(r'^unified_job_templates/$', UnifiedJobTemplateList.as_view(), name='unified_job_template_list'), url(r'^unified_jobs/$', UnifiedJobList.as_view(), name='unified_job_list'), url(r'^activity_stream/', include(activity_stream_urls)), - url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)), # &&&&&& Take this line out completely? + url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)), url(r'^workflow_approvals/', include(workflow_approval_urls)), ] diff --git a/awx/api/urls/workflow_approval_template.py b/awx/api/urls/workflow_approval_template.py index 1d6345d01f..e379196826 100644 --- a/awx/api/urls/workflow_approval_template.py +++ b/awx/api/urls/workflow_approval_template.py @@ -4,7 +4,6 @@ from django.conf.urls import url from awx.api.views import ( - WorkflowApprovalTemplateList, WorkflowApprovalTemplateDetail, WorkflowApprovalTemplateJobsList, WorkflowApprovalTemplateNotificationTemplatesErrorList, @@ -14,7 +13,6 @@ from awx.api.views import ( urls = [ - url(r'^$', WorkflowApprovalTemplateList.as_view(), name='workflow_approval_template_list'), url(r'^(?P[0-9]+)/$', WorkflowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), url(r'^(?P[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'), url(r'^(?P[0-9]+)/notification_templates_started/$', WorkflowApprovalTemplateNotificationTemplatesStartedList.as_view(), diff --git a/awx/api/urls/workflow_job_template_node.py b/awx/api/urls/workflow_job_template_node.py index 76ba375cb7..868c728a88 100644 --- a/awx/api/urls/workflow_job_template_node.py +++ b/awx/api/urls/workflow_job_template_node.py @@ -21,7 +21,7 @@ urls = [ url(r'^(?P[0-9]+)/failure_nodes/$', WorkflowJobTemplateNodeFailureNodesList.as_view(), name='workflow_job_template_node_failure_nodes_list'), url(r'^(?P[0-9]+)/always_nodes/$', WorkflowJobTemplateNodeAlwaysNodesList.as_view(), name='workflow_job_template_node_always_nodes_list'), url(r'^(?P[0-9]+)/credentials/$', WorkflowJobTemplateNodeCredentialsList.as_view(), name='workflow_job_template_node_credentials_list'), - url(r'^(?P[0-9]+)/create_approval_job_template/$', WorkflowJobTemplateNodeCreateApproval.as_view(), name='workflow_job_template_node_create_approval'), + url(r'^(?P[0-9]+)/create_approval_template/$', WorkflowJobTemplateNodeCreateApproval.as_view(), name='workflow_job_template_node_create_approval'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 8fa5bbff45..cf08ea554c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3018,7 +3018,6 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): model = models.WorkflowJobTemplateNode serializer_class = serializers.WorkflowJobTemplateNodeCreateApprovalSerializer -# &&&&&& def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) if not serializer.is_valid(): @@ -3597,7 +3596,7 @@ class JobRelaunch(RetrieveAPIView): headers = {'Location': new_job.get_absolute_url(request=request)} return Response(data, status=status.HTTP_201_CREATED, headers=headers) -# &&&&&& Reference + class JobCreateSchedule(RetrieveAPIView): model = models.Job @@ -4422,12 +4421,6 @@ for attr, value in list(locals().items()): setattr(this_module, name, view) -class WorkflowApprovalTemplateList(ListCreateAPIView): - - model = models.WorkflowApprovalTemplate - serializer_class = serializers.WorkflowApprovalTemplateSerializer - - class WorkflowApprovalTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): model = models.WorkflowApprovalTemplate @@ -4481,11 +4474,12 @@ class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): model = models.WorkflowApproval serializer_class = serializers.WorkflowApprovalSerializer -# &&&&&& Include checks in the below two post methods + class WorkflowApprovalApprove(RetrieveAPIView): model = models.WorkflowApproval serializer_class = serializers.WorkflowApprovalViewSerializer + # &&&&&& To address later def post(self, request, *args, **kwargs): obj = self.get_object() obj.approve() @@ -4496,6 +4490,7 @@ class WorkflowApprovalDeny(RetrieveAPIView): model = models.WorkflowApproval serializer_class = serializers.WorkflowApprovalViewSerializer + # &&&&&& To address later def post(self, request, *args, **kwargs): obj = self.get_object() obj.deny() diff --git a/awx/api/views/root.py b/awx/api/views/root.py index df5f6f7f0e..922041c8b5 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -124,7 +124,6 @@ class ApiVersionRootView(APIView): data['activity_stream'] = reverse('api:activity_stream_list', request=request) data['workflow_job_templates'] = reverse('api:workflow_job_template_list', request=request) data['workflow_jobs'] = reverse('api:workflow_job_list', request=request) - data['workflow_approval_templates'] = reverse('api:workflow_approval_template_list', request=request) # &&&&&& Take this line out completely? data['workflow_approvals'] = reverse('api:workflow_approval_list', request=request) data['workflow_job_template_nodes'] = reverse('api:workflow_job_template_node_list', request=request) data['workflow_job_nodes'] = reverse('api:workflow_job_node_list', request=request) diff --git a/awx/main/access.py b/awx/main/access.py index 5b7017d9f3..7dd41e3500 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2788,18 +2788,17 @@ class WorkflowApprovalAccess(BaseAccess): return True def filtered_queryset(self): - return self.model.objects.all() + return self.model.objects.filter( + unified_job_node__in=WorkflowJobNode.accessible_pk_qs( + self.user, 'read_role')) - def can_start(self, obj, validate_license=True): - return False - # &&&&&& ??? Start of the RBAC method ??? + # &&&&&& # def can_approve_or_deny(self, obj): - # if self.user.is_superuser: # &&&&&& add "or self.user.approval_role"? + # if self.user.is_superuser: or "self.user.approval_role"? # return True # return self.can_change(obj, ????) -# &&&&&& Why is the below not showing up as a class now?? class WorkflowApprovalTemplateAccess(BaseAccess): ''' I can create approval nodes when: @@ -2811,16 +2810,17 @@ class WorkflowApprovalTemplateAccess(BaseAccess): model = WorkflowApprovalTemplate prefetch_related = ('created_by', 'modified_by',) - # &&&&&& I need to get the admin role of the WFJT, where WFJT is provided in the key portion vs the data (Alan said that, what does it mean exactly???) @check_superuser def can_add(self, data): if data is None: # Hide direct creation in API browser return False - return ( - self.check_related('workflow_approval_template', UnifiedJobTemplate, role_field='admin_role') + else: + return (self.check_related('workflow_approval_template', UnifiedJobTemplate, role_field='admin_role')) def filtered_queryset(self): - return self.model.filter(workflowjobtemplatenodes__workflow_job_template=WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role')) + return self.model.objects.filter( + workflowjobtemplatenodes__workflow_job_template__in=WorkflowJobTemplate.accessible_pk_qs( + self.user, 'read_role')) for cls in BaseAccess.__subclasses__(): diff --git a/awx/main/management/commands/cleanup_jobs.py b/awx/main/management/commands/cleanup_jobs.py index f43a34c6b6..81f405da4c 100644 --- a/awx/main/management/commands/cleanup_jobs.py +++ b/awx/main/management/commands/cleanup_jobs.py @@ -200,8 +200,6 @@ class Command(BaseCommand): skipped += WorkflowJob.objects.filter(created__gte=self.cutoff).count() return skipped, deleted -# &&&&&& Add cleanup of orphaned approval nodes here? - def cleanup_notifications(self): skipped, deleted = 0, 0 notifications = Notification.objects.filter(created__lt=self.cutoff) diff --git a/awx/main/migrations/0082_v360_workflowapproval.py b/awx/main/migrations/0082_v360_workflowapproval.py new file mode 100644 index 0000000000..570402a3f1 --- /dev/null +++ b/awx/main/migrations/0082_v360_workflowapproval.py @@ -0,0 +1,43 @@ +# Generated by Django 2.2.2 on 2019-07-18 14:12 + +import awx.main.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0081_v360_notify_on_start'), + ] + + operations = [ + migrations.CreateModel( + name='WorkflowApprovalTemplate', + fields=[ + ('unifiedjobtemplate_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='main.UnifiedJobTemplate')), + ('timeout', models.IntegerField(blank=True, default=0, help_text='The amount of time (in seconds) before the approval node expires and fails.')), + ], + bases=('main.unifiedjobtemplate',), + ), + migrations.AddField( + model_name='organization', + name='approval_role', + field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role='admin_role', related_name='+', to='main.Role'), + preserve_default='True', + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='approval_role', + field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['singleton:system_auditor', 'organization.approval_role', 'admin_role'], related_name='+', to='main.Role'), + preserve_default='True', + ), + migrations.CreateModel( + name='WorkflowApproval', + fields=[ + ('unifiedjob_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='main.UnifiedJob')), + ('workflow_approval_template', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approvals', to='main.WorkflowApprovalTemplate')), + ], + bases=('main.unifiedjob',), + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 627578a854..1704fe345b 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -174,7 +174,7 @@ def o_auth2_token_get_absolute_url(self, request=None): OAuth2AccessToken.add_to_class('get_absolute_url', o_auth2_token_get_absolute_url) -# &&&&&& "Add model here" - Alan +# &&&&&& Add model here from awx.main.registrar import activity_stream_registrar # noqa activity_stream_registrar.connect(Organization) activity_stream_registrar.connect(Inventory) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 21fb0fd7d1..96b88a6b64 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -89,7 +89,6 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi 'notification_admin_role', 'credential_admin_role', 'job_template_admin_role',], ) -# &&&&&& The below keeps complaining - fixed by new migration file, perhaps? approval_role = ImplicitRoleField( parent_role='admin_role', ) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index 39a2109ad5..67d21e873d 100644 --- a/awx/main/models/rbac.py +++ b/awx/main/models/rbac.py @@ -48,7 +48,7 @@ role_names = { 'read_role': _('Read'), 'update_role': _('Update'), 'use_role': _('Use'), - 'approval_role': _('Approve'), # &&&&&& Added this here! + 'approval_role': _('Approve'), } role_descriptions = { @@ -71,7 +71,7 @@ role_descriptions = { 'read_role': _('May view settings for the %s'), 'update_role': _('May update the %s'), 'use_role': _('Can use the %s in a job template'), - 'approval_role': _('Can approve or deny a workflow approval node'), # &&&&&& ...and here! + 'approval_role': _('Can approve or deny a workflow approval node'), } @@ -482,7 +482,7 @@ def get_roles_on_resource(resource, accessor): ).values_list('role_field', flat=True).distinct() ] -# &&&&&& This area is giving trouble? + def role_summary_fields_generator(content_object, role_field): global role_descriptions global role_names diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index a0948e36b9..19775fcbc7 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -395,7 +395,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, 'organization.auditor_role', 'execute_role', 'admin_role' ]) -# &&&&&& The below keeps complaining - fixed by new migration file, perhaps? approval_role = ImplicitRoleField(parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, 'organization.approval_role', 'admin_role', @@ -637,28 +636,6 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): def get_absolute_url(self, request=None): return reverse('api:workflow_approval_template_detail', kwargs={'pk': self.pk}, request=request) - # @property - # def notification_templates(self): - # # Return all notification_templates defined on the Job Template, on the Project, and on the Organization for each trigger type - # base_notification_templates = NotificationTemplate.objects - # error_notification_templates = list(base_notification_templates.filter( - # unifiedjobtemplate_notification_templates_for_errors__in=[self, self.project])) - # started_notification_templates = list(base_notification_templates.filter( - # unifiedjobtemplate_notification_templates_for_started__in=[self, self.project])) - # success_notification_templates = list(base_notification_templates.filter( - # unifiedjobtemplate_notification_templates_for_success__in=[self, self.project])) -# &&&&&& Approvals don't have orgs! How to pull them in? Alan said to "get creative"! - # if self.project is not None and self.project.organization is not None: - # error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter( - # organization_notification_templates_for_errors=self.project.organization))) - # started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter( - # organization_notification_templates_for_started=self.project.organization))) - # success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter( - # organization_notification_templates_for_success=self.project.organization))) - # return dict(error=list(error_notification_templates), - # started=list(started_notification_templates), - # success=list(success_notification_templates)) - class WorkflowApproval(UnifiedJob): class Meta: diff --git a/awx/main/registrar.py b/awx/main/registrar.py index f7f32d839b..6d0ccfe495 100644 --- a/awx/main/registrar.py +++ b/awx/main/registrar.py @@ -3,7 +3,7 @@ from django.db.models.signals import pre_save, post_save, pre_delete, m2m_changed -# &&&&&& Where the signals are hooked up ?? + class ActivityStreamRegistrar(object): def __init__(self): diff --git a/awx/main/signals.py b/awx/main/signals.py index fa50b97fe9..8b1de3a082 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -356,25 +356,6 @@ def update_host_last_job_after_job_deleted(sender, **kwargs): _update_host_last_jhs(host) -# &&&&&& Argh. Which one looks better? -# @receiver(pre_delete, sender=Job) -# def delete_detached_approval_nodes(sender, instance, **kwargs): -# for l in instance.labels.all(): -# if l.is_candidate_for_detach(): -# l.delete() -# -# -# @receiver(pre_delete, sender=Organization) -# def delete_detached_approval_nodes(sender, instance, **kwargs): -# approval_node = ??? -# user = get_current_user_or_none() -# for node in approval_node: -# try: -# node.schedule_deletion(user_id=getattr(user, 'id', None)) -# except RuntimeError as e: -# logger.debug(e) - - # Set via ActivityStreamRegistrar to record activity stream events @@ -454,7 +435,7 @@ def model_serializer_mapping(): models.OAuth2Application: serializers.OAuth2ApplicationSerializer, } -# &&&&&& Can customize how/what the activity stream shows info + def activity_stream_create(sender, instance, created, **kwargs): if created and activity_stream_enabled: # TODO: remove deprecated_group conditional in 3.3 @@ -482,8 +463,6 @@ def activity_stream_create(sender, instance, created, **kwargs): object1=object1, changes=json.dumps(changes), actor=get_current_user_or_none()) - # if type(instance) == WorkflowApproval: &&&&&& - # changes['status'] = #??? #TODO: Weird situation where cascade SETNULL doesn't work # it might actually be a good idea to remove all of these FK references since # we don't really use them anyway. From 4a801c60b974e7b0ec7a589dfdca581cfc947e3c Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 18 Jul 2019 15:36:01 -0400 Subject: [PATCH 12/57] Cleanup and changes to the way approval templates are created --- .../features/templates/templates.strings.js | 5 +- awx/ui/client/src/templates/main.js | 316 +------ .../client/src/templates/templates.service.js | 10 +- .../workflow-chart/workflow-chart.block.less | 1 + .../workflow-chart.directive.js | 801 +++++++++--------- .../templates/workflows/workflow-key/main.js | 11 + .../workflow-key/workflow-key.directive.js | 18 + .../workflow-key/workflow-key.partial.html | 43 + .../workflows/workflow-maker/forms/main.js | 4 +- .../forms/workflow-node-form.service.js | 67 ++ .../workflow-maker/workflow-maker.block.less | 15 +- .../workflow-maker.controller.js | 319 ++++--- .../workflow-maker.partial.html | 34 +- .../workflow-results.partial.html | 30 +- 14 files changed, 741 insertions(+), 933 deletions(-) create mode 100644 awx/ui/client/src/templates/workflows/workflow-key/main.js create mode 100644 awx/ui/client/src/templates/workflows/workflow-key/workflow-key.directive.js create mode 100644 awx/ui/client/src/templates/workflows/workflow-key/workflow-key.partial.html create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.service.js diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index ffc36b32f0..8b0713ae4c 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -110,9 +110,12 @@ function TemplatesStrings (BaseString) { ON_SUCCESS: t.s('On Success'), ON_FAILURE: t.s('On Failure'), ALWAYS: t.s('Always'), + PAUSE: t.s('Wait For Approval'), + JOB_TEMPLATE: t.s('Job Template'), PROJECT_SYNC: t.s('Project Sync'), INVENTORY_SYNC: t.s('Inventory Sync'), WORKFLOW: t.s('Workflow'), + TEMPLATE: t.s('Template'), WARNING: t.s('Warning'), TOTAL_NODES: t.s('TOTAL NODES'), ADD_A_NODE: t.s('ADD A NODE'), @@ -145,7 +148,7 @@ function TemplatesStrings (BaseString) { EXIT: t.s('EXIT'), CANCEL: t.s('CANCEL'), SAVE_AND_EXIT: t.s('SAVE & EXIT'), - PAUSE_NODE: t.s('Pause Node') + APPROVAL: t.s('Approval') }; } diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index 70d8b24b00..4f301cdb91 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -12,6 +12,7 @@ import workflowEdit from './workflows/edit-workflow/main'; import labels from './labels/main'; import prompt from './prompt/main'; import workflowChart from './workflows/workflow-chart/main'; +import workflowKey from './workflows/workflow-key/main'; import workflowMaker from './workflows/workflow-maker/main'; import workflowControls from './workflows/workflow-controls/main'; import WorkflowForm from './workflows.form'; @@ -31,7 +32,7 @@ import { export default angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, prompt.name, workflowAdd.name, workflowEdit.name, - workflowChart.name, workflowMaker.name, workflowControls.name + workflowChart.name, workflowKey.name, workflowMaker.name, workflowControls.name ]) .service('TemplatesService', templatesService) .factory('WorkflowForm', WorkflowForm) @@ -499,320 +500,7 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p views: { 'modal': { template: `` - }, - 'jobTemplateList@templates.editWorkflowJobTemplate.workflowMaker': { - templateProvider: function(WorkflowMakerJobTemplateList, generateList) { - - let html = generateList.build({ - list: WorkflowMakerJobTemplateList, - input_type: 'radio', - mode: 'lookup' - }); - return html; - }, - // $scope encapsulated in this controller will be a initialized as child of 'modal' $scope, because of element hierarchy - controller: ['$scope', 'WorkflowMakerJobTemplateList', 'JobTemplateDataset', - function($scope, list, Dataset) { - - init(); - - function init() { - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - - $scope.$watch('wf_maker_templates', function(){ - if($scope.selectedTemplate){ - $scope.wf_maker_templates.forEach(function(row, i) { - if(row.id === $scope.selectedTemplate.id) { - $scope.wf_maker_templates[i].checked = 1; - } - else { - $scope.wf_maker_templates[i].checked = 0; - } - }); - } - }); - } - - $scope.toggle_row = function(selectedRow) { - if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { - $scope.wf_maker_templates.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.wf_maker_templates[i].checked = 1; - $scope.selection[list.iterator] = { - id: row.id, - name: row.name - }; - - $scope.templateManuallySelected(row); - } - }); - } - }; - - $scope.$watch('selectedTemplate', () => { - $scope.wf_maker_templates.forEach(function(row, i) { - if(_.has($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { - $scope.wf_maker_templates[i].checked = 1; - } - else { - $scope.wf_maker_templates[i].checked = 0; - } - }); - }); - - $scope.$watch('activeTab', () => { - if(!$scope.activeTab || $scope.activeTab !== "jobs") { - $scope.wf_maker_templates.forEach(function(row, i) { - $scope.wf_maker_templates[i].checked = 0; - }); - } - }); - - $scope.$on('clearWorkflowLists', function() { - $scope.wf_maker_templates.forEach(function(row, i) { - $scope.wf_maker_templates[i].checked = 0; - }); - }); - } - ] - }, - 'inventorySyncList@templates.editWorkflowJobTemplate.workflowMaker': { - templateProvider: function(WorkflowInventorySourcesList, generateList) { - let html = generateList.build({ - list: WorkflowInventorySourcesList, - input_type: 'radio', - mode: 'lookup' - }); - return html; - }, - // encapsulated $scope in this controller will be a initialized as child of 'modal' $scope, because of element hierarchy - controller: ['$scope', 'WorkflowInventorySourcesList', 'InventorySourcesDataset', - function($scope, list, Dataset) { - - init(); - - function init() { - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - - $scope.$watch('wf_maker_inventory_sources', function(){ - if($scope.selectedTemplate){ - $scope.wf_maker_inventory_sources.forEach(function(row, i) { - if(row.id === $scope.selectedTemplate.id) { - $scope.wf_maker_inventory_sources[i].checked = 1; - } - else { - $scope.wf_maker_inventory_sources[i].checked = 0; - } - }); - } - }); - } - - $scope.toggle_row = function(selectedRow) { - if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { - $scope.wf_maker_inventory_sources.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.wf_maker_inventory_sources[i].checked = 1; - $scope.selection[list.iterator] = { - id: row.id, - name: row.name - }; - - $scope.templateManuallySelected(row); - } - }); - } - }; - - $scope.$watch('selectedTemplate', () => { - $scope.wf_maker_inventory_sources.forEach(function(row, i) { - if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { - $scope.wf_maker_inventory_sources[i].checked = 1; - } - else { - $scope.wf_maker_inventory_sources[i].checked = 0; - } - }); - }); - - $scope.$watch('activeTab', () => { - if(!$scope.activeTab || $scope.activeTab !== "inventory_sync") { - $scope.wf_maker_inventory_sources.forEach(function(row, i) { - $scope.wf_maker_inventory_sources[i].checked = 0; - }); - } - }); - - $scope.$on('clearWorkflowLists', function() { - $scope.wf_maker_inventory_sources.forEach(function(row, i) { - $scope.wf_maker_inventory_sources[i].checked = 0; - }); - }); - } - ] - }, - 'projectSyncList@templates.editWorkflowJobTemplate.workflowMaker': { - templateProvider: function(WorkflowProjectList, generateList) { - let html = generateList.build({ - list: WorkflowProjectList, - input_type: 'radio', - mode: 'lookup' - }); - return html; - }, - // encapsulated $scope in this controller will be a initialized as child of 'modal' $scope, because of element hierarchy - controller: ['$scope', 'WorkflowProjectList', 'ProjectDataset', - function($scope, list, Dataset) { - - init(); - - function init() { - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - - $scope.$watch('wf_maker_projects', function(){ - if($scope.selectedTemplate){ - $scope.wf_maker_projects.forEach(function(row, i) { - if(row.id === $scope.selectedTemplate.id) { - $scope.wf_maker_projects[i].checked = 1; - } - else { - $scope.wf_maker_projects[i].checked = 0; - } - }); - } - }); - } - - $scope.toggle_row = function(selectedRow) { - if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { - $scope.wf_maker_projects.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.wf_maker_projects[i].checked = 1; - $scope.selection[list.iterator] = { - id: row.id, - name: row.name - }; - - $scope.templateManuallySelected(row); - } - }); - } - }; - - $scope.$watch('selectedTemplate', () => { - $scope.wf_maker_projects.forEach(function(row, i) { - if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { - $scope.wf_maker_projects[i].checked = 1; - } - else { - $scope.wf_maker_projects[i].checked = 0; - } - }); - }); - - $scope.$watch('activeTab', () => { - if(!$scope.activeTab || $scope.activeTab !== "project_sync") { - $scope.wf_maker_projects.forEach(function(row, i) { - $scope.wf_maker_projects[i].checked = 0; - }); - } - }); - - $scope.$on('clearWorkflowLists', function() { - $scope.wf_maker_projects.forEach(function(row, i) { - $scope.wf_maker_projects[i].checked = 0; - }); - }); - } - ] } - }, - resolve: { - JobTemplateDataset: ['WorkflowMakerJobTemplateList', 'QuerySet', '$stateParams', 'GetBasePath', - (list, qs, $stateParams, GetBasePath) => { - let path = GetBasePath(list.basePath); - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ], - ProjectDataset: ['WorkflowProjectList', 'QuerySet', '$stateParams', 'GetBasePath', - (list, qs, $stateParams, GetBasePath) => { - let path = GetBasePath(list.basePath); - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ], - InventorySourcesDataset: ['InventorySourcesList', 'QuerySet', '$stateParams', 'GetBasePath', - (list, qs, $stateParams, GetBasePath) => { - let path = GetBasePath(list.basePath); - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ], - WorkflowMakerJobTemplateList: ['TemplateList', 'i18n', - (TemplateList, i18n) => { - let list = _.cloneDeep(TemplateList); - delete list.actions; - delete list.fields.type; - delete list.fields.description; - delete list.fields.smart_status; - delete list.fields.labels; - delete list.fieldActions; - list.name = 'wf_maker_templates'; - list.iterator = 'wf_maker_template'; - list.fields.name.columnClass = "col-md-8"; - list.fields.name.tag = i18n._('WORKFLOW'); - list.fields.name.showTag = "{{wf_maker_template.type === 'workflow_job_template'}}"; - list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; - list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; - list.basePath = 'unified_job_templates'; - list.fields.info = { - ngInclude: "'/static/partials/job-template-details.html'", - type: 'template', - columnClass: 'col-md-3', - infoHeaderClass: 'col-md-3', - label: '', - nosort: true - }; - list.maxVisiblePages = 5; - list.searchBarFullWidth = true; - - return list; - } - ], - WorkflowProjectList: ['ProjectList', - (ProjectList) => { - let list = _.cloneDeep(ProjectList); - delete list.fields.status; - delete list.fields.scm_type; - delete list.fields.last_updated; - list.name = 'wf_maker_projects'; - list.iterator = 'wf_maker_project'; - list.fields.name.columnClass = "col-md-11"; - list.maxVisiblePages = 5; - list.searchBarFullWidth = true; - list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; - list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; - - return list; - } - ], - WorkflowInventorySourcesList: ['InventorySourcesList', - (InventorySourcesList) => { - let list = _.cloneDeep(InventorySourcesList); - list.name = 'wf_maker_inventory_sources'; - list.iterator = 'wf_maker_inventory_source'; - list.maxVisiblePages = 5; - list.searchBarFullWidth = true; - list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; - list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; - - return list; - } - ] } }; diff --git a/awx/ui/client/src/templates/templates.service.js b/awx/ui/client/src/templates/templates.service.js index ef718064fd..f97899ddd8 100644 --- a/awx/ui/client/src/templates/templates.service.js +++ b/awx/ui/client/src/templates/templates.service.js @@ -291,13 +291,13 @@ export default ['Rest', 'GetBasePath', '$q', 'NextPage', function(Rest, GetBaseP Rest.setUrl(url); return Rest.post(params.data); }, - createApprovalTemplate: (params) => { - params = params || {}; - Rest.setUrl(GetBasePath('workflow_approval_templates')); - return Rest.post(params); + createApprovalTemplate: ({url, data}) => { + data = data || {}; + Rest.setUrl(url); + return Rest.post(data); }, patchApprovalTemplate: ({id, data}) => { - Rest.setUrl(`${GetBasePath('workflow_approval_templates')}/${id}`); + Rest.setUrl(`/api/v2/workflow_approval_templates/${id}`); return Rest.patch(data); } }; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index c1719b07ac..a9c1550d60 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -117,6 +117,7 @@ .WorkflowChart-nodeTypeLetter { fill: @default-bg; + font-size: 10px; } .WorkflowChart-nodeStatus--running { diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 8156407b5b..e4c64231d5 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -109,11 +109,11 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', // TODO: this function is hacky and we need to come up with a better solution // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 const wrap = (text) => { - if(text && text.length > maxNodeTextLength) { - return text.substring(0,maxNodeTextLength) + '...'; + if(text) { + return text.length > maxNodeTextLength ? text.substring(0,maxNodeTextLength) + '...' : text; } else { - return text; + return ''; } }; @@ -143,7 +143,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', translation = [translation[0], translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; - svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); + svgGroup.attr("transform", `translate(${translation})scale(${scale})`); scope.workflowZoomed({ zoom: scale @@ -160,7 +160,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', translateX = unscaledOffsetX*scale - ((scale*windowWidth)-windowWidth)/2, translateY = unscaledOffsetY*scale - ((scale*windowHeight)-windowHeight)/2; - svgGroup.attr("transform", "translate(" + [translateX, translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); + svgGroup.attr("transform", `translate(${[translateX, translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]})scale(${scale})`); zoomObj.scale(scale); zoomObj.translate([translateX, translateY]); }; @@ -178,27 +178,32 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', translateX = translateCoords[0]; translateY = direction === 'up' ? translateCoords[1] - distance : translateCoords[1] + distance; } - svgGroup.attr("transform", "translate(" + translateX + "," + (translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)) + ")scale(" + scale + ")"); + svgGroup.attr("transform", `translate(${translateX},${(translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale))})scale(${scale})`); zoomObj.translate([translateX, translateY]); }; const resetZoomAndPan = () => { - svgGroup.attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); + svgGroup.attr("transform", `translate(0,${(windowHeight/2 - rootH/2 - startNodeOffsetY)})scale(1)`); // Update the zoomObj zoomObj.scale(1); zoomObj.translate([0,0]); }; const zoomToFitChart = () => { - let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), + const startNodeWidth = scope.mode === 'details' ? 25 : 60, + graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), availableScreenSpace = calcAvailableScreenSpace(), - currentZoomValue = zoomObj.scale(), - unscaledH = graphDimensions.height/currentZoomValue, + currentZoomValue = zoomObj.scale(); + + // For some reason the start node isn't accounted for in the width... add it + graphDimensions.width = graphDimensions.width + (startNodeWidth*currentZoomValue); + + const unscaledH = graphDimensions.height/currentZoomValue, unscaledW = graphDimensions.width/currentZoomValue, scaleNeededForMaxHeight = (availableScreenSpace.height)/unscaledH, scaleNeededForMaxWidth = (availableScreenSpace.width)/unscaledW, lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), - scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10); + scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 1000)/1000); manualZoom(scaleToFit*100); @@ -206,100 +211,99 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', zoom: scaleToFit }); - svgGroup.attr("transform", "translate(0," + (windowHeight/2 - (nodeH*scaleToFit/2)) + ")scale(" + scaleToFit + ")"); + svgGroup.attr("transform", `translate(0, ${(windowHeight/2 - (nodeH*scaleToFit/2))})scale(${scaleToFit})`); zoomObj.translate([0, windowHeight/2 - (nodeH*scaleToFit/2) - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); }; + const buildLinkTooltip = (d) => { + let edgeTypeLabel; + switch(d.edgeType) { + case "always": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); + break; + case "success": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); + break; + case "failure": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); + break; + } + let linkInstructionText = !scope.readOnly ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); + let linkTooltip = svgGroup.append("g") + .attr("class", "WorkflowChart-tooltip"); + const tipRef = linkTooltip.append("foreignObject") + // In order for this to work in FF a height of at least 1 must be present + .attr("width", 100) + .attr("height", 1) + .style("overflow", "visible") + .html(` +
+
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
+
${linkInstructionText}
+
+ `); + const tipDimensions = tipRef.select('.WorkflowChart-tooltipContents').node().getBoundingClientRect(); + let sourceNode = d3.select(`#node-${d.source.id}`); + const sourceNodeX = d3.transform(sourceNode.attr("transform")).translate[0]; + const sourceNodeY = d3.transform(sourceNode.attr("transform")).translate[1]; + let targetNode = d3.select(`#node-${d.target.id}`); + const targetNodeX = d3.transform(targetNode.attr("transform")).translate[0]; + const targetNodeY = d3.transform(targetNode.attr("transform")).translate[1]; + let xPos, yPos, arrowPoints; + const scaledHeight = tipDimensions.height/zoomObj.scale(); + + if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { + xPos = (sourceNodeX + nodeW + targetNodeX)/2 - 50; + yPos = sourceNodeY + nodeH/2 - scaledHeight - 20; + arrowPoints = { + pt1: { + x: xPos + 40, + y: yPos + scaledHeight + }, + pt2: { + x: xPos + 60, + y: yPos + scaledHeight + }, + pt3: { + x: xPos + 50, + y: yPos + scaledHeight + 10 + } + }; + } else { + xPos = (sourceNodeX + nodeW + targetNodeX)/2 - 120; + yPos = (sourceNodeY + (nodeH/2) + targetNodeY + (nodeH/2))/2 - (scaledHeight/2); + arrowPoints = { + pt1: { + x: xPos + 100, + y: yPos + (scaledHeight/2) - 10 + }, + pt2: { + x: xPos + 100, + y: yPos + (scaledHeight/2) + 10 + }, + pt3: { + x: xPos + 110, + y: yPos + (scaledHeight/2) + } + }; + } + + linkTooltip.append("polygon") + .attr("class", "WorkflowChart-tooltipArrow") + .attr("points", `${arrowPoints.pt1.x},${arrowPoints.pt1.y} ${arrowPoints.pt2.x},${arrowPoints.pt2.y} ${arrowPoints.pt3.x},${arrowPoints.pt3.y}`); + + tipRef.attr('height', scaledHeight); + tipRef.attr("transform", `translate(${xPos},${yPos})`); + }; + const updateGraph = () => { if(scope.dimensionsSet) { - const buildLinkTooltip = (d) => { - let edgeTypeLabel; - switch(d.edgeType) { - case "always": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); - break; - case "success": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); - break; - case "failure": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); - break; - } - let linkInstructionText = !scope.readOnly ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); - let linkTooltip = svgGroup.append("g") - .attr("class", "WorkflowChart-tooltip"); - const tipRef = linkTooltip.append("foreignObject") - // In order for this to work in FF a height of at least 1 must be present - .attr("width", 100) - .attr("height", 1) - .style("overflow", "visible") - .html(function(){ - return `
-
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
-
${linkInstructionText}
-
`; - }); - const tipDimensions = tipRef.select('.WorkflowChart-tooltipContents').node().getBoundingClientRect(); - let sourceNode = d3.select(`#node-${d.source.id}`); - const sourceNodeX = d3.transform(sourceNode.attr("transform")).translate[0]; - const sourceNodeY = d3.transform(sourceNode.attr("transform")).translate[1]; - let targetNode = d3.select(`#node-${d.target.id}`); - const targetNodeX = d3.transform(targetNode.attr("transform")).translate[0]; - const targetNodeY = d3.transform(targetNode.attr("transform")).translate[1]; - let xPos, yPos, arrowPoints; - const scaledHeight = tipDimensions.height/zoomObj.scale(); - - if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { - xPos = (sourceNodeX + nodeW + targetNodeX)/2 - 50; - yPos = sourceNodeY + nodeH/2 - scaledHeight - 20; - arrowPoints = { - pt1: { - x: xPos + 40, - y: yPos + scaledHeight - }, - pt2: { - x: xPos + 60, - y: yPos + scaledHeight - }, - pt3: { - x: xPos + 50, - y: yPos + scaledHeight + 10 - } - }; - } else { - xPos = (sourceNodeX + nodeW + targetNodeX)/2 - 120; - yPos = (sourceNodeY + (nodeH/2) + targetNodeY + (nodeH/2))/2 - (scaledHeight/2); - arrowPoints = { - pt1: { - x: xPos + 100, - y: yPos + (scaledHeight/2) - 10 - }, - pt2: { - x: xPos + 100, - y: yPos + (scaledHeight/2) + 10 - }, - pt3: { - x: xPos + 110, - y: yPos + (scaledHeight/2) - } - }; - } - - linkTooltip.append("polygon") - .attr("class", "WorkflowChart-tooltipArrow") - .attr("points", function() { - return `${arrowPoints.pt1.x},${arrowPoints.pt1.y} ${arrowPoints.pt2.x},${arrowPoints.pt2.y} ${arrowPoints.pt3.x},${arrowPoints.pt3.y}`; - }); - - tipRef.attr('height', scaledHeight); - tipRef.attr("transform", `translate(${xPos},${yPos})`); - }; - let g = new dagre.graphlib.Graph(); g.setGraph({rankdir: 'LR', nodesep: 30, ranksep: 120}); - g.setDefaultEdgeLabel(function() { return {}; }); + // This is needed for Dagre + g.setDefaultEdgeLabel(() => { return {}; }); scope.graphState.arrayOfNodesForChart.forEach((node) => { if (node.id === 1) { @@ -326,19 +330,19 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); let links = svgGroup.selectAll(".WorkflowChart-link") - .data(scope.graphState.arrayOfLinksForChart, function(d) { return `${d.source.id}-${d.target.id}`; }); + .data(scope.graphState.arrayOfLinksForChart, (d) => { return `${d.source.id}-${d.target.id}`; }); // Remove any stale links links.exit().remove(); // Update existing links baseSvg.selectAll(".WorkflowChart-link") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + .attr("id", (d) => `link-${d.source.id}-${d.target.id}`); baseSvg.selectAll(".WorkflowChart-linkPath") .transition() .attr("d", lineData) - .attr('stroke', function(d) { + .attr('stroke', (d) => { let edgeType = d.edgeType; if(edgeType) { if(edgeType === "failure") { @@ -357,8 +361,8 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); baseSvg.selectAll(".WorkflowChart-linkOverlay") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) - .attr("class", function(d) { + .attr("id", (d) => `link-${d.source.id}-${d.target.id}-overlay`) + .attr("class", (d) => { let linkClasses = ["WorkflowChart-linkOverlay"]; if ( scope.graphState.linkBeingEdited && @@ -369,7 +373,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } return linkClasses.join(' '); }) - .attr("points",function(d) { + .attr("points",(d) => { let x1 = nodePositionMap[d.target.id].x; let y1 = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); let x2 = nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].width; @@ -387,12 +391,12 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); baseSvg.selectAll(".WorkflowChart-circleBetweenNodes") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) - .attr("cx", function(d) { + .attr("id", (d) => `link-${d.source.id}-${d.target.id}-add`) + .style("display", (d) => { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("cx", (d) => { return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; }) - .attr("cy", function(d) { + .attr("cy", (d) => { const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); const halfSourceHeight = nodePositionMap[d.source.id].height/2; const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); @@ -408,8 +412,8 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); baseSvg.selectAll(".WorkflowChart-betweenNodesIcon") - .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) - .attr("transform", function(d) { + .style("display", (d) => { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("transform", (d) => { let translate; const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); @@ -423,17 +427,17 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', yPos = yPos + 4; } - translate = "translate(" + (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2 + "," + yPos + ")"; + translate = `translate(${(nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2}, ${yPos})`; return translate; }); // Add any new links let linkEnter = links.enter().append("g") .attr("class", "WorkflowChart-link") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + .attr("id", (d) => `link-${d.source.id}-${d.target.id}`); linkEnter.append("polygon", "g") - .attr("class", function(d) { + .attr("class", (d) => { let linkClasses = ["WorkflowChart-linkOverlay"]; if ( scope.graphState.linkBeingEdited && @@ -444,9 +448,9 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } return linkClasses.join(' '); }) - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) + .attr("id", (d) => `link-${d.source.id}-${d.target.id}-overlay`) .call(edit_link) - .attr("points",function(d) { + .attr("points",(d) => { let x1 = nodePositionMap[d.target.id].x; let y1 = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); let x2 = nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].width; @@ -462,7 +466,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', return [pt1, pt2, pt3, pt4].join(" "); }) - .on("mouseover", function(d) { + .on("mouseover", (d) => { if( d.edgeType !== 'placeholder' && !scope.graphState.isLinkMode && @@ -478,10 +482,10 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', buildLinkTooltip(d); } }) - .on("mouseout", function(d){ + .on("mouseout", (d) => { if(d.source.id !== 1 && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); - d3.select("#link-" + d.source.id + "-" + d.target.id) + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-linkHovering", false); } $('.WorkflowChart-tooltip').remove(); @@ -492,7 +496,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("class", "WorkflowChart-linkPath") .attr("d", lineData) .call(edit_link) - .on("mouseenter", function(d) { + .on("mouseenter", (d) => { if( d.edgeType !== 'placeholder' && !scope.graphState.isLinkMode && @@ -508,15 +512,15 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', buildLinkTooltip(d); } }) - .on("mouseleave", function(d){ + .on("mouseleave", (d) => { if(d.source.id !== 1 && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); - d3.select("#link-" + d.source.id + "-" + d.target.id) + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-linkHovering", false); } $('.WorkflowChart-tooltip').remove(); }) - .attr('stroke', function(d) { + .attr('stroke', (d) => { let edgeType = d.edgeType; if(d.edgeType) { if(edgeType === "failure") { @@ -535,14 +539,14 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); linkEnter.append("circle") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) + .attr("id", (d) => `link-${d.source.id}-${d.target.id}-add`) .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") - .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) - .attr("cx", function(d) { + .style("display", (d) => { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("cx", (d) => { return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; }) - .attr("cy", function(d) { + .attr("cy", (d) => { const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); const halfSourceHeight = nodePositionMap[d.source.id].height/2; const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); @@ -557,14 +561,14 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', return yPos; }) .call(add_node_with_child) - .on("mouseover", function(d) { + .on("mouseover", (d) => { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); - d3.select("#link-" + d.source.id + "-" + d.target.id) + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-addHovering", true); }) - .on("mouseout", function(d){ + .on("mouseout", (d) => { $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); - d3.select("#link-" + d.source.id + "-" + d.target.id) + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-addHovering", false); }); @@ -575,10 +579,8 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .size(60) .type("cross") ) - .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) - .attr("transform", function(d) { - let translate; - + .style("display", (d) => { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("transform", (d) => { const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); const halfSourceHeight = nodePositionMap[d.source.id].height/2; const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); @@ -590,23 +592,22 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', yPos = yPos + 4; } - translate = "translate(" + (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2 + "," + yPos + ")"; - return translate; + return `translate(${(nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2}, ${yPos})`; }) .call(add_node_with_child) - .on("mouseover", function(d) { + .on("mouseover", (d) => { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); - d3.select("#link-" + d.source.id + "-" + d.target.id) + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-addHovering", true); }) - .on("mouseout", function(d){ + .on("mouseout", (d) => { $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); - d3.select("#link-" + d.source.id + "-" + d.target.id) + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-addHovering", false); }); let nodes = svgGroup.selectAll('.WorkflowChart-node') - .data(scope.graphState.arrayOfNodesForChart, function(d) { return d.id; }); + .data(scope.graphState.arrayOfNodesForChart, (d) => { return d.id; }); // Remove any stale nodes nodes.exit().remove(); @@ -614,33 +615,33 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', // Update existing nodes baseSvg.selectAll(".WorkflowChart-node") .transition() - .attr("transform", function (d) { + .attr("transform", (d) => { // Update prior x and prior y d.px = d.x; d.py = d.y; - return "translate(" + nodePositionMap[d.id].x + "," + normalizeY(nodePositionMap[d.id].y) + ")"; + return `translate(${nodePositionMap[d.id].x}, ${normalizeY(nodePositionMap[d.id].y)})`; }); baseSvg.selectAll(".WorkflowChart-nodeAddCircle") - .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", (d) => { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeAddIcon") - .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", (d) => { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-linkCircle") - .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", (d) => { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeLinkIcon") - .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", (d) => { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeRemoveCircle") - .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", (d) => { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeRemoveIcon") - .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", (d) => { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-rect") - .attr('stroke', function(d) { + .attr('stroke', (d) => { if(d.job && d.job.status) { if(d.job.status === "successful"){ return "#5cb85c"; @@ -656,30 +657,26 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', return "#D7D7D7"; } }) - .attr("class", function(d) { + .attr("class", (d) => { let classString = d.id === scope.graphState.nodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; return classString; }); baseSvg.selectAll(".WorkflowChart-nodeOverlay") - .attr("class", function(d) { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }); + .attr("class", (d) => { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }); baseSvg.selectAll(".WorkflowChart-nodeTypeCircle") - .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || - d.unifiedJobTemplate.type === "inventory_source" || - d.unifiedJobTemplate.unified_job_type === "inventory_update" || - d.unifiedJobTemplate.type === "workflow_job_template" || - d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); + .style("display", (d) => d.unifiedJobTemplate ? null : "none"); baseSvg.selectAll(".WorkflowChart-nodeTypeLetter") - .text(function (d) { + .text((d) => { let nodeTypeLetter = ""; if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { switch (d.unifiedJobTemplate.type) { + case "job_template": + nodeTypeLetter = "JT"; + break; case "project": nodeTypeLetter = "P"; break; @@ -692,6 +689,9 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { switch (d.unifiedJobTemplate.unified_job_type) { + case "job": + nodeTypeLetter = "JT"; + break; case "project_update": nodeTypeLetter = "P"; break; @@ -705,50 +705,88 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } return nodeTypeLetter; }) - .style("display", function (d) { + .style("display", (d) => { return d.unifiedJobTemplate && - (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || - d.unifiedJobTemplate.type === "inventory_source" || - d.unifiedJobTemplate.unified_job_type === "inventory_update" || - d.unifiedJobTemplate.type === "workflow_job_template" || - d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; + d.unifiedJobTemplate.type !== "workflow_approval_template" && + d.unifiedJobTemplate.unified_job_type !== "workflow_approval" ? null : "none"; }); - baseSvg.selectAll(".WorkflowChart-nodeStatus") - .attr("class", function(d) { + baseSvg.selectAll(".WorkflowChart-nodeTypeLetter") + .text((d) => { + let nodeTypeLetter = ""; + if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { + switch (d.unifiedJobTemplate.type) { + case "job_template": + nodeTypeLetter = "JT"; + break; + case "project": + nodeTypeLetter = "P"; + break; + case "inventory_source": + nodeTypeLetter = "I"; + break; + case "workflow_job_template": + nodeTypeLetter = "W"; + break; + } + } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { + switch (d.unifiedJobTemplate.unified_job_type) { + case "job": + nodeTypeLetter = "JT"; + break; + case "project_update": + nodeTypeLetter = "P"; + break; + case "inventory_update": + nodeTypeLetter = "I"; + break; + case "workflow_job": + nodeTypeLetter = "W"; + break; + } + } + return nodeTypeLetter; + }) + .style("display", (d) => { + return d.unifiedJobTemplate && + d.unifiedJobTemplate.type !== "workflow_approval_template" && + d.unifiedJobTemplate.unified_job_type !== "workflow_approval" ? null : "none"; + }); - let statusClass = "WorkflowChart-nodeStatus "; + baseSvg.selectAll(".WorkflowChart-pauseIcon") + .style("display", (d) => { + return d.unifiedJobTemplate && + (d.unifiedJobTemplate.type === "workflow_approval_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_approval") ? null : "none"; + }); + + baseSvg.selectAll(".WorkflowChart-nodeStatus") + .attr("class", (d) => { + let statusClasses = ["WorkflowChart-nodeStatus"]; if(d.job){ switch(d.job.status) { case "pending": - statusClass += "WorkflowChart-nodeStatus--running"; - break; case "waiting": - statusClass += "WorkflowChart-nodeStatus--running"; - break; case "running": - statusClass += "WorkflowChart-nodeStatus--running"; + statusClasses.push("WorkflowChart-nodeStatus--running"); break; case "successful": - statusClass += "WorkflowChart-nodeStatus--success"; + statusClasses.push("WorkflowChart-nodeStatus--success"); break; case "failed": - statusClass += "WorkflowChart-nodeStatus--failed"; - break; case "error": - statusClass += "WorkflowChart-nodeStatus--failed"; + statusClasses.push("WorkflowChart-nodeStatus--failed"); break; case "canceled": - statusClass += "WorkflowChart-nodeStatus--canceled"; + statusClasses.push("WorkflowChart-nodeStatus--canceled"); break; } } - return statusClass; + return statusClasses.join(' '); }) - .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .style("display", (d) => { return d.job && d.job.status ? null : "none"; }) .transition() .duration(0) .attr("r", 6) @@ -770,28 +808,20 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); baseSvg.selectAll(".WorkflowChart-nameText") - .attr("x", 0) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : (nodeH / 2) - 10; }) + .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) - .html(function (d) { - const name = _.get(d, 'unifiedJobTemplate.name'); - const wrappedName = name ? wrap(name) : ""; - // TODO: clean this up - if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type === 'workflow_approval') { - return ` -
- -
- ${wrappedName} -
`; - } else { - return `${wrappedName}`; - } - }); + .text((d) => wrap(_.get(d, 'unifiedJobTemplate.name'))); baseSvg.selectAll(".WorkflowChart-detailsLink") - .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }) - .html(function (d) { + .style("display", (d) => { + const isApprovalStep = d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type === 'workflow_approval'; + return d.job && + !isApprovalStep && + d.job.status && + d.job.id ? null : "none"; + }) + .html((d) => { let href = ""; if (d.job) { href = `/#/workflow_node_results/${d.job.id}`; @@ -800,27 +830,25 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); baseSvg.selectAll(".WorkflowChart-deletedText") - .style("display", function(d){ return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); + .style("display", (d) => { return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-activeNode") - .style("display", function(d) { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); + .style("display", (d) => { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); baseSvg.selectAll(".WorkflowChart-elapsed") - .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + .style("display", (d) => { return (d.job && d.job.elapsed) ? null : "none"; }); baseSvg.selectAll(".WorkflowChart-addLinkCircle") - .attr("fill", function(d) { return scope.graphState.addLinkSource === d.id ? "#337AB7" : "#D7D7D7"; }) - .style("display", function(d) { return scope.graphState.isLinkMode && !d.isInvalidLinkTarget ? null : "none"; }); + .attr("fill", (d) => { return scope.graphState.addLinkSource === d.id ? "#337AB7" : "#D7D7D7"; }) + .style("display", (d) => { return scope.graphState.isLinkMode && !d.isInvalidLinkTarget ? null : "none"; }); // Add new nodes const nodeEnter = nodes .enter() .append('g') .attr("class", "WorkflowChart-node") - .attr("id", function(d){return "node-" + d.id;}) - .attr("transform", function (d) { - return "translate(" + nodePositionMap[d.id].x + "," + normalizeY(nodePositionMap[d.id].y) + ")"; - }); + .attr("id", (d) => `node-${d.id}`) + .attr("transform", (d) => `translate(${nodePositionMap[d.id].x},${normalizeY(nodePositionMap[d.id].y)})`); nodeEnter.each(function(d) { let thisNode = d3.select(this); @@ -852,7 +880,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("y", 30) .attr("dy", ".35em") .attr("class", "WorkflowChart-startText") - .text(function () { return TemplatesStrings.get('workflow_maker.START'); }) + .text(TemplatesStrings.get('workflow_maker.START')) .call(add_node_without_child); } else { @@ -861,13 +889,13 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("cx", nodeW) .attr("r", 8) .attr("class", "WorkflowChart-addLinkCircle") - .style("display", function() { return scope.graphState.isLinkMode ? null : "none"; }); + .style("display", scope.graphState.isLinkMode ? null : "none"); thisNode.append("rect") .attr("width", nodeW) .attr("height", nodeH) .attr("rx", 5) .attr("ry", 5) - .attr('stroke', function(d) { + .attr('stroke', (d) => { if(d.job && d.job.status) { if(d.job.status === "successful"){ return "#5cb85c"; @@ -884,7 +912,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } }) .attr('stroke-width', "2px") - .attr("class", function(d) { + .attr("class", (d) => { let classString = d.id === scope.graphState.nodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; classString += !_.get(d, 'unifiedJobTemplate.name') ? " WorkflowChart-dashedNode" : ""; return classString; @@ -893,30 +921,15 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', thisNode.append("path") .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) .attr("class", "WorkflowChart-activeNode") - .style("display", function(d) { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); + .style("display", (d) => { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); - thisNode.append("foreignObject") - // .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("x", 0) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : (nodeH / 2) - 10; }) + thisNode.append("text") + .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) .attr("dy", ".35em") .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") - .html(function (d) { - const name = _.get(d, 'unifiedJobTemplate.name'); - const wrappedName = name ? wrap(name) : ""; - // TODO: clean this up - if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type === 'workflow_approval') { - return ` -
- -
- ${wrappedName} -
`; - } else { - return `${wrappedName}`; - } - }); + .text((d) => wrap(_.get(d, 'unifiedJobTemplate.name'))); thisNode.append("foreignObject") .attr("x", 0) @@ -924,94 +937,107 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("dy", ".35em") .attr("text-anchor", "middle") .attr("class", "WorkflowChart-defaultText WorkflowChart-deletedText") - .html(function () { - return `${TemplatesStrings.get('workflow_maker.DELETED')}`; - }) - .style("display", function(d) { return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); + .html(`${TemplatesStrings.get('workflow_maker.DELETED')}`) + .style("display", (d) => { return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); thisNode.append("circle") .attr("cy", nodeH) .attr("r", 10) .attr("class", "WorkflowChart-nodeTypeCircle") - .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || - d.unifiedJobTemplate.type === "inventory_source" || - d.unifiedJobTemplate.unified_job_type === "inventory_update" || - d.unifiedJobTemplate.type === "workflow_job_template" || - d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); + .style("display", (d) => d.unifiedJobTemplate ? null : "none"); + thisNode.append("text") .attr("y", nodeH) .attr("dy", ".35em") .attr("text-anchor", "middle") .attr("class", "WorkflowChart-nodeTypeLetter") - .text(function (d) { - let nodeTypeLetter = ""; - if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { - switch (d.unifiedJobTemplate.type) { - case "project": - nodeTypeLetter = "P"; - break; - case "inventory_source": - nodeTypeLetter = "I"; - break; - case "workflow_job_template": - nodeTypeLetter = "W"; - break; - } - } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { - switch (d.unifiedJobTemplate.unified_job_type) { - case "project_update": - nodeTypeLetter = "P"; - break; - case "inventory_update": - nodeTypeLetter = "I"; - break; - case "workflow_job": - nodeTypeLetter = "W"; - break; - } - } - return nodeTypeLetter; + .text((d) => { + let nodeTypeLetter = ""; + if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { + switch (d.unifiedJobTemplate.type) { + case "job_template": + nodeTypeLetter = "JT"; + break; + case "project": + nodeTypeLetter = "P"; + break; + case "inventory_source": + nodeTypeLetter = "I"; + break; + case "workflow_job_template": + nodeTypeLetter = "W"; + break; + } + } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { + switch (d.unifiedJobTemplate.unified_job_type) { + case "job": + nodeTypeLetter = "JT"; + break; + case "project_update": + nodeTypeLetter = "P"; + break; + case "inventory_update": + nodeTypeLetter = "I"; + break; + case "workflow_job": + nodeTypeLetter = "W"; + break; + } + } + return nodeTypeLetter; }) - .style("display", function (d) { + .style("display", (d) => { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "job_template" || + d.unifiedJobTemplate.unified_job_type === "job" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" || d.unifiedJobTemplate.type === "workflow_job_template" || d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); + }); + + thisNode.append("foreignObject") + .attr("x", -5) + .attr("y", nodeH - 9) + .attr("dy", ".35em") + .attr("height", "15px") + .attr("width", "11px") + .attr("class", "WorkflowChart-pauseIcon") + .html(``) + .style("display", (d) => { + return d.unifiedJobTemplate && + (d.unifiedJobTemplate.type === "workflow_approval_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_approval") ? null : "none"; + }); thisNode.append("rect") .attr("width", nodeW) .attr("height", nodeH) - .attr("class", function(d) { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }) + .attr("class", (d) => { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }) .call(node_click) - .on("mouseover", function(d) { + .on("mouseover", (d) => { if(d.id !== 1) { $(`#node-${d.id}`).appendTo(`#aw-workflow-chart-g`); let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; if(resourceName && resourceName.length > maxNodeTextLength) { + const sanitizedResourceName = $filter('sanitize')(resourceName); // When the graph is initially rendered all the links come after the nodes (when you look at the dom). // SVG components are painted in order of appearance. There is no concept of z-index, only the order. // As such, we need to move the nodes after the links so that when the tooltip renders it shows up on top // of the links and not underneath them. I tried rendering the links before the nodes but that lead to // some weird link animation that I didn't care to try to fix. - svgGroup.selectAll("g.WorkflowChart-node").each(function() { - this.parentNode.appendChild(this); - }); + svgGroup.selectAll("g.WorkflowChart-node").each(() => this.parentNode.appendChild(this)); // After the nodes have been properly placed after the links, we need to make sure that the node that // the user is hovering over is at the very end of the list. This way the tooltip will appear on top // of all other nodes. - svgGroup.selectAll("g.WorkflowChart-node").sort(function (a) { + svgGroup.selectAll("g.WorkflowChart-node").sort((a) => { return (a.index !== d.index) ? -1 : 1; }); // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place // it properly on the workflow - let tooltipDimensionChecker = $(""); + let tooltipDimensionChecker = $(``); $('body').append(tooltipDimensionChecker); let tipWidth = $(tooltipDimensionChecker).outerWidth(); let tipHeight = $(tooltipDimensionChecker).outerHeight(); @@ -1023,9 +1049,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("width", tipWidth) .attr("height", tipHeight+20) .attr("class", "WorkflowChart-tooltip") - .html(function(){ - return "
" + $filter('sanitize')(resourceName) + "
"; - }); + .html(`
${sanitizedResourceName}
`); } if (scope.graphState.isLinkMode && !d.isInvalidLinkTarget && scope.graphState.addLinkSource !== d.id) { @@ -1076,15 +1100,15 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("stroke", "#D7D7D7") .attr('marker-mid', "url(#aw-workflow-chart-arrow)"); } - d3.select("#node-" + d.id) + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", true); } }) - .on("mouseout", function(d){ + .on("mouseout", (d) => { $('.WorkflowChart-tooltip').remove(); $('.WorkflowChart-potentialLink').remove(); if(d.id !== 1) { - d3.select("#node-" + d.id) + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", false); } }); @@ -1095,11 +1119,15 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("width", "40px") .attr("dy", ".35em") .attr("class", "WorkflowChart-detailsLink") - .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }) - .on("mousedown", function(){ - d3.event.stopPropagation(); + .style("display", (d) => { + const isApprovalStep = d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type === 'workflow_approval'; + return d.job && + !isApprovalStep && + d.job.status && + d.job.id ? null : "none"; }) - .html(function (d) { + .on("mousedown", () => d3.event.stopPropagation()) + .html((d) => { let href = ""; if (d.job) { href = `/#/workflow_node_results/${d.job.id}`; @@ -1107,64 +1135,64 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', return `
${TemplatesStrings.get('workflow_maker.DETAILS')}`; }); thisNode.append("circle") - .attr("id", function(d){return "node-" + d.id + "-add";}) + .attr("id", (d) => `node-${d.id}-add`) .attr("cx", nodeW) .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-nodeAddCircle") - .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", (d) => { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_node_without_child) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) + .on("mouseover", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", true); - d3.select("#node-" + d.id + "-add") + d3.select(`#node-${d.id}-add`) .classed("WorkflowChart-addHovering", true); }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) + .on("mouseout", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", false); - d3.select("#node-" + d.id + "-add") + d3.select(`#node-${d.id}-add`) .classed("WorkflowChart-addHovering", false); }); thisNode.append("path") .attr("class", "WorkflowChart-nodeAddIcon") .style("fill", "white") - .attr("transform", function() { return "translate(" + nodeW + "," + 0 + ")"; }) + .attr("transform", `translate(${nodeW}, 0)`) .attr("d", d3.svg.symbol() .size(60) .type("cross") ) - .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", (d) => { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_node_without_child) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) + .on("mouseover", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", true); - d3.select("#node-" + d.id + "-add") + d3.select(`#node-${d.id}-add`) .classed("WorkflowChart-addHovering", true); }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) + .on("mouseout", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", false); - d3.select("#node-" + d.id + "-add") + d3.select(`#node-${d.id}-add`) .classed("WorkflowChart-addHovering", false); }); thisNode.append("circle") - .attr("id", function(d){return "node-" + d.id + "-link";}) + .attr("id", (d) => `node-${d.id}-link`) .attr("cx", nodeW) .attr("cy", nodeH/2) .attr("r", 10) .attr("class", "WorkflowChart-linkCircle") - .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", (d) => { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_link) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) + .on("mouseover", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", true); - d3.select("#node-" + d.id + "-link") + d3.select(`#node-${d.id}-link`) .classed("WorkflowChart-linkButtonHovering", true); }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) + .on("mouseout", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", false); - d3.select("#node-" + d.id + "-link") + d3.select(`#node-${d.id}-link`) .classed("WorkflowChart-linkButtonHovering", false); }); thisNode.append("foreignObject") @@ -1173,123 +1201,130 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("height", "17px") .attr("width", "13px") .style("font-size","14px") - .html(function () { - return ``; - }) + .html(``) .attr("class", "WorkflowChart-nodeLinkIcon") - .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", (d) => { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_link) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) + .on("mouseover", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", true); - d3.select("#node-" + d.id + "-link") + d3.select(`#node-${d.id}-link`) .classed("WorkflowChart-linkButtonHovering", true); }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) + .on("mouseout", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", false); - d3.select("#node-" + d.id + "-link") + d3.select(`#node-${d.id}-link`) .classed("WorkflowChart-linkButtonHovering", false); }); thisNode.append("circle") - .attr("id", function(d){return "node-" + d.id + "-remove";}) + .attr("id", (d) => `node-${d.id}-remove`) .attr("cx", nodeW) .attr("cy", nodeH) .attr("r", 10) .attr("class", "WorkflowChart-nodeRemoveCircle") - .style("display", function(d) { return (d.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", (d) => { return (d.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) + .on("mouseover", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", true); - d3.select("#node-" + d.id + "-remove") + d3.select(`#node-${d.id}-remove`) .classed("removeHovering", true); }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) + .on("mouseout", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", false); - d3.select("#node-" + d.id + "-remove") + d3.select(`#node-${d.id}-remove`) .classed("removeHovering", false); }); thisNode.append("path") .attr("class", "WorkflowChart-nodeRemoveIcon") .style("fill", "white") - .attr("transform", function() { return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; }) + .attr("transform", `translate(${nodeW}, ${nodeH}) rotate(-45)`) .attr("d", d3.svg.symbol() .size(60) .type("cross") ) - .style("display", function(d) { return (d.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", (d) => { return (d.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) + .on("mouseover", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", true); - d3.select("#node-" + d.id + "-remove") + d3.select(`#node-${d.id}-remove`) .classed("removeHovering", true); }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) + .on("mouseout", (d) => { + d3.select(`#node-${d.id}`) .classed("WorkflowChart-nodeHovering", false); - d3.select("#node-" + d.id + "-remove") + d3.select(`#node-${d.id}-remove`) .classed("removeHovering", false); }); thisNode.append("circle") - .attr("class", function(d) { - - let statusClass = "WorkflowChart-nodeStatus "; - + .attr("class", (d) => { + let statusClasses = ["WorkflowChart-nodeStatus"]; + if(d.job){ switch(d.job.status) { case "pending": - statusClass += "WorkflowChart-nodeStatus--running"; - break; case "waiting": - statusClass += "WorkflowChart-nodeStatus--running"; - break; case "running": - statusClass += "WorkflowChart-nodeStatus--running"; + statusClasses.push("WorkflowChart-nodeStatus--running"); break; case "successful": - statusClass += "WorkflowChart-nodeStatus--success"; + statusClasses.push("WorkflowChart-nodeStatus--success"); break; case "failed": - statusClass += "WorkflowChart-nodeStatus--failed"; - break; case "error": - statusClass += "WorkflowChart-nodeStatus--failed"; + statusClasses.push("WorkflowChart-nodeStatus--failed"); break; case "canceled": - statusClass += "WorkflowChart-nodeStatus--canceled"; + statusClasses.push("WorkflowChart-nodeStatus--canceled"); break; } } - - return statusClass; + + return statusClasses.join(' '); }) - .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .style("display", (d) => { return d.job && d.job.status ? null : "none"; }) .attr("cy", 10) .attr("cx", 10) - .attr("r", 6); + .attr("r", 6) + .each(function(d) { + if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { + // Pulse the circle + let circle = d3.select(this); + (function repeat() { + circle = circle.transition() + .duration(2000) + .attr("r", 6) + .transition() + .duration(2000) + .attr("r", 0) + .ease('sine') + .each("end", repeat); + })(); + } + }); thisNode.append("foreignObject") .attr("x", 5) .attr("y", 43) .style("font-size","0.7em") .attr("class", "WorkflowChart-elapsed") - .html(function (d) { + .html((d) => { if(d.job && d.job.elapsed) { let elapsedMs = d.job.elapsed * 1000; let elapsedMoment = moment.duration(elapsedMs); let paddedElapsedMoment = Math.floor(elapsedMoment.asHours()) < 10 ? "0" + Math.floor(elapsedMoment.asHours()) : Math.floor(elapsedMoment.asHours()); let elapsedString = paddedElapsedMoment + moment.utc(elapsedMs).format(":mm:ss"); - return "
" + elapsedString + "
"; + return `
${elapsedString}
`; } else { return ""; } }) - .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + .style("display", (d) => { return (d.job && d.job.elapsed) ? null : "none"; }); } }); @@ -1303,7 +1338,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', svgGroup.selectAll(".WorkflowChart-node").order(); } else if(!scope.watchDimensionsSet){ - scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ + scope.watchDimensionsSet = scope.$watch('dimensionsSet', () => { if(scope.dimensionsSet) { scope.watchDimensionsSet(); scope.watchDimensionsSet = null; @@ -1314,7 +1349,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }; function add_node_without_child() { - this.on("click", function(d) { + this.on("click", (d) => { if(!scope.readOnly && !scope.graphState.isLinkMode) { scope.addNodeWithoutChild({ parent: d @@ -1324,7 +1359,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } function add_node_with_child() { - this.on("click", function(d) { + this.on("click", (d) => { if(!scope.readOnly && !scope.graphState.isLinkMode && d.edgeType !== 'placeholder') { scope.addNodeWithChild({ link: d @@ -1334,7 +1369,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } function remove_node() { - this.on("click", function(d) { + this.on("click", (d) => { if(d.id !== 1 && !scope.readOnly && !scope.graphState.isLinkMode) { scope.deleteNode({ nodeToDelete: d @@ -1344,7 +1379,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } function node_click() { - this.on("click", function(d) { + this.on("click", (d) => { if(d.id !== scope.graphState.nodeBeingAdded){ if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget && scope.graphState.addLinkSource !== d.id) { $('.WorkflowChart-potentialLink').remove(); @@ -1362,7 +1397,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } function edit_link() { - this.on("click", function(d) { + this.on("click", (d) => { if(!scope.graphState.isLinkMode && d.source.id !== 1 && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details'){ scope.editLink({ linkToEdit: d @@ -1372,7 +1407,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', } function add_link() { - this.on("click", function(d) { + this.on("click", (d) => { if (!scope.readOnly && !scope.graphState.isLinkMode) { scope.selectNodeForLinking({ nodeToStartLink: d @@ -1381,29 +1416,29 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', }); } - scope.$on('refreshWorkflowChart', function(){ + scope.$on('refreshWorkflowChart', () => { if(scope.graphState) { updateGraph(); } }); - scope.$on('panWorkflowChart', function(evt, params) { + scope.$on('panWorkflowChart', (evt, params) => { manualPan(params.direction); }); - scope.$on('resetWorkflowChart', function(){ + scope.$on('resetWorkflowChart', () => { resetZoomAndPan(); }); - scope.$on('zoomWorkflowChart', function(evt, params) { + scope.$on('zoomWorkflowChart', (evt, params) => { manualZoom(params.zoom); }); - scope.$on('zoomToFitChart', function() { + scope.$on('zoomToFitChart', () => { zoomToFitChart(); }); - let clearWatchGraphState = scope.$watch('graphState.arrayOfNodesForChart', function(newVal) { + let clearWatchGraphState = scope.$watch('graphState.arrayOfNodesForChart', (newVal) => { if(newVal) { updateGraph(); clearWatchGraphState(); @@ -1432,10 +1467,10 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', scope.dimensionsSet = true; line = d3.svg.line() - .x(function (d) { + .x((d) => { return d.x; }) - .y(function (d) { + .y((d) => { return d.y; }); @@ -1449,7 +1484,7 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', svgGroup = baseSvg.append("g") .attr("id", "aw-workflow-chart-g") - .attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); + .attr("transform", `translate(0, ${(windowHeight/2 - rootH/2 - startNodeOffsetY)})`); const defs = baseSvg.append("defs"); @@ -1469,16 +1504,16 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', angular.element($window).on('resize', onResize); scope.$on('$destroy', cleanUpResize); - scope.$on('workflowDetailsResized', function(){ + scope.$on('workflowDetailsResized', () => { $('.WorkflowMaker-chart').hide(); - $timeout(function(){ + $timeout(() => { onResize(); $('.WorkflowMaker-chart').show(); }); }); } else { - scope.$on('workflowMakerModalResized', function(){ + scope.$on('workflowMakerModalResized', () => { let dimensions = calcAvailableScreenSpace(); $('.WorkflowMaker-chart').css("width", dimensions.width); diff --git a/awx/ui/client/src/templates/workflows/workflow-key/main.js b/awx/ui/client/src/templates/workflows/workflow-key/main.js new file mode 100644 index 0000000000..5721249659 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-key/main.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2019 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowKey from './workflow-key.directive'; + +export default + angular.module('workflowKey', []) + .directive('workflowKey', workflowKey); diff --git a/awx/ui/client/src/templates/workflows/workflow-key/workflow-key.directive.js b/awx/ui/client/src/templates/workflows/workflow-key/workflow-key.directive.js new file mode 100644 index 0000000000..b8f92c0fa9 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-key/workflow-key.directive.js @@ -0,0 +1,18 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['templateUrl', 'TemplatesStrings', + function(templateUrl, TemplatesStrings) { + return { + scope: {}, + templateUrl: templateUrl('templates/workflows/workflow-key/workflow-key'), + restrict: 'E', + link: function(scope) { + scope.strings = TemplatesStrings; + } + }; + } +]; diff --git a/awx/ui/client/src/templates/workflows/workflow-key/workflow-key.partial.html b/awx/ui/client/src/templates/workflows/workflow-key/workflow-key.partial.html new file mode 100644 index 0000000000..6c3aac61a6 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-key/workflow-key.partial.html @@ -0,0 +1,43 @@ +
    +
  • +

    {{:: strings.get('workflow_maker.KEY')}}

    +
  • +
  • +
    +

    {{:: strings.get('workflow_maker.ON_SUCCESS')}}

    +
  • +
  • +
    +

    {{:: strings.get('workflow_maker.ON_FAILURE')}}

    +
  • +
  • +
    +

    {{:: strings.get('workflow_maker.ALWAYS')}}

    +
  • +
  • +
    JT
    +

    {{:: strings.get('workflow_maker.JOB_TEMPLATE')}}

    +
  • +
  • +
    P
    +

    {{:: strings.get('workflow_maker.PROJECT_SYNC')}}

    +
  • +
  • +
    I
    +

    {{:: strings.get('workflow_maker.INVENTORY_SYNC')}}

    +
  • +
  • +
    W
    +

    {{:: strings.get('workflow_maker.WORKFLOW')}}

    +
  • +
  • +
    + +
    +

    {{:: strings.get('workflow_maker.PAUSE')}}

    +
  • +
  • +
    !
    +

    {{:: strings.get('workflow_maker.WARNING')}}

    +
  • +
\ No newline at end of file diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js index 426eaa131c..39153756eb 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js @@ -1,7 +1,9 @@ import workflowLinkForm from './workflow-link-form.directive'; import workflowNodeForm from './workflow-node-form.directive'; +import workflowNodeFormService from './workflow-node-form.service'; export default angular.module('templates.workflowMaker.forms', []) .directive('workflowLinkForm', workflowLinkForm) - .directive('workflowNodeForm', workflowNodeForm); + .directive('workflowNodeForm', workflowNodeForm) + .service('WorkflowNodeFormService', workflowNodeFormService); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.service.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.service.js new file mode 100644 index 0000000000..460f6ccfa0 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.service.js @@ -0,0 +1,67 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['TemplateList', 'ProjectList', 'InventorySourcesList', 'i18n', + function(TemplateList, ProjectList, InventorySourcesList, i18n){ + return { + inventorySourceListDefinition: function() { + const inventorySourceList = _.cloneDeep(InventorySourcesList); + inventorySourceList.name = 'wf_maker_inventory_sources'; + inventorySourceList.iterator = 'wf_maker_inventory_source'; + inventorySourceList.maxVisiblePages = 5; + inventorySourceList.searchBarFullWidth = true; + inventorySourceList.disableRow = "{{ readOnly }}"; + inventorySourceList.disableRowValue = 'readOnly'; + + return inventorySourceList; + }, + projectListDefinition: function(){ + const projectList = _.cloneDeep(ProjectList); + delete projectList.fields.status; + delete projectList.fields.scm_type; + delete projectList.fields.last_updated; + projectList.name = 'wf_maker_projects'; + projectList.iterator = 'wf_maker_project'; + projectList.fields.name.columnClass = "col-md-11"; + projectList.maxVisiblePages = 5; + projectList.searchBarFullWidth = true; + projectList.disableRow = "{{ readOnly }}"; + projectList.disableRowValue = 'readOnly'; + + return projectList; + }, + templateListDefinition: function(){ + const templateList = _.cloneDeep(TemplateList); + delete templateList.actions; + delete templateList.fields.type; + delete templateList.fields.description; + delete templateList.fields.smart_status; + delete templateList.fields.labels; + delete templateList.fieldActions; + templateList.name = 'wf_maker_templates'; + templateList.iterator = 'wf_maker_template'; + templateList.fields.name.columnClass = "col-md-8"; + templateList.fields.name.tag = i18n._('WORKFLOW'); + templateList.fields.name.showTag = "{{wf_maker_template.type === 'workflow_job_template'}}"; + templateList.disableRow = "{{ readOnly }}"; + templateList.disableRowValue = 'readOnly'; + templateList.basePath = 'unified_job_templates'; + templateList.fields.info = { + ngInclude: "'/static/partials/job-template-details.html'", + type: 'template', + columnClass: 'col-md-3', + infoHeaderClass: 'col-md-3', + label: '', + nosort: true + }; + templateList.maxVisiblePages = 5; + templateList.searchBarFullWidth = true; + + return templateList; + } + }; + } +]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index 87f20ce5db..f163c845e3 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -7,6 +7,15 @@ .ui-dialog-buttonpane, .ui-dialog-titlebar { display:none; } + + input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + input[type="number"] { + -moz-appearance: textfield; + } } .WorkflowMaker-header { @@ -380,11 +389,11 @@ .Key-icon--circle { border-radius: 50%; - width: 20px; - height: 20px; + width: 24px; + height: 24px; color: @default-bg; text-align: center; - line-height: 20px; + line-height: 24px; margin: 4px 5px 5px 0px; } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 925c9a03fd..fb81dd1618 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -145,21 +145,24 @@ export default ['$scope', 'TemplatesService', let editPromises = []; let credentialRequests = []; + // TODO: clean up data generation of approval template requests Object.keys(nodeRef).map((workflowMakerNodeId) => { const node = nodeRef[workflowMakerNodeId]; if (node.isNew) { if (node.unifiedJobTemplate && node.unifiedJobTemplate.unified_job_type === "workflow_approval") { - approvalTemplatePromises.push(TemplatesService.createApprovalTemplate({ - name: node.unifiedJobTemplate.name - }).then(({data: approvalTemplateData}) => { - addPromises.push(TemplatesService.addWorkflowNode({ - url: $scope.workflowJobTemplateObj.related.workflow_nodes, + addPromises.push(TemplatesService.addWorkflowNode({ + url: $scope.workflowJobTemplateObj.related.workflow_nodes, + data: {} + }).then(({data}) => { + approvalTemplatePromises.push(TemplatesService.createApprovalTemplate({ + url: data.related.create_approval_template, data: { - unified_job_template: approvalTemplateData.id + name: node.unifiedJobTemplate.name, + description: node.unifiedJobTemplate.description } - }).then(({data: nodeData}) => { - node.originalNodeObject = nodeData; - nodeIdToChartNodeIdMapping[nodeData.id] = parseInt(workflowMakerNodeId); + }).then(() => { + node.originalNodeObject = data; + nodeIdToChartNodeIdMapping[data.id] = parseInt(workflowMakerNodeId); }).catch(({ data, status }) => { Wait('stop'); ProcessErrors($scope, data, status, null, { @@ -222,20 +225,11 @@ export default ['$scope', 'TemplatesService', })); } else { approvalTemplatePromises.push(TemplatesService.createApprovalTemplate({ - name: node.unifiedJobTemplate.name - }).then(({data: approvalTemplateData}) => { - // Make sure that this isn't overwriting everything on the node... - editPromises.push(TemplatesService.editWorkflowNode({ - id: node.originalNodeObject.id, - data: { - unified_job_template: approvalTemplateData.id - } - }).catch(({ data, status }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') - }); - })); + url: node.originalNodeObject.related.create_approval_template, + data: { + name: node.unifiedJobTemplate.name, + description: node.unifiedJobTemplate.description + } }).catch(({ data, status }) => { Wait('stop'); ProcessErrors($scope, data, status, null, { @@ -302,176 +296,173 @@ export default ['$scope', 'TemplatesService', return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); }); - $q.all(approvalTemplatePromises) + $q.all(addPromises.concat(editPromises, deletePromises)) .then(() => { - $q.all(addPromises.concat(editPromises, deletePromises)) - .then(() => { - let disassociatePromises = []; - let associatePromises = []; - let linkMap = {}; + $q.all(approvalTemplatePromises) + .then(() => { + let disassociatePromises = []; + let associatePromises = []; + let linkMap = {}; - // Build a link map for easy access - $scope.graphState.arrayOfLinksForChart.forEach(link => { - // link.source.id of 1 is our artificial start node - if (link.source.id !== 1) { - const sourceNodeId = nodeRef[link.source.id].originalNodeObject.id; - const targetNodeId = nodeRef[link.target.id].originalNodeObject.id; - if (!linkMap[sourceNodeId]) { - linkMap[sourceNodeId] = {}; + // Build a link map for easy access + $scope.graphState.arrayOfLinksForChart.forEach(link => { + // link.source.id of 1 is our artificial start node + if (link.source.id !== 1) { + const sourceNodeId = nodeRef[link.source.id].originalNodeObject.id; + const targetNodeId = nodeRef[link.target.id].originalNodeObject.id; + if (!linkMap[sourceNodeId]) { + linkMap[sourceNodeId] = {}; + } + + linkMap[sourceNodeId][targetNodeId] = link.edgeType; } + }); - linkMap[sourceNodeId][targetNodeId] = link.edgeType; - } - }); - - Object.keys(nodeRef).map((workflowNodeId) => { - let nodeId = nodeRef[workflowNodeId].originalNodeObject.id; - if (nodeRef[workflowNodeId].originalNodeObject.success_nodes) { - nodeRef[workflowNodeId].originalNodeObject.success_nodes.forEach((successNodeId) => { - if ( - !deletedNodeIds.includes(successNodeId) && - (!linkMap[nodeId] || - !linkMap[nodeId][successNodeId] || - linkMap[nodeId][successNodeId] !== "success") - ) { - disassociatePromises.push( - TemplatesService.disassociateWorkflowNode({ - parentId: nodeId, - nodeId: successNodeId, - edge: "success" - }) - ); - } - }); - } - if (nodeRef[workflowNodeId].originalNodeObject.failure_nodes) { - nodeRef[workflowNodeId].originalNodeObject.failure_nodes.forEach((failureNodeId) => { - if ( - !deletedNodeIds.includes(failureNodeId) && - (!linkMap[nodeId] || - !linkMap[nodeId][failureNodeId] || - linkMap[nodeId][failureNodeId] !== "failure") - ) { - disassociatePromises.push( - TemplatesService.disassociateWorkflowNode({ - parentId: nodeId, - nodeId: failureNodeId, - edge: "failure" - }) - ); - } - }); - } - if (nodeRef[workflowNodeId].originalNodeObject.always_nodes) { - nodeRef[workflowNodeId].originalNodeObject.always_nodes.forEach((alwaysNodeId) => { - if ( - !deletedNodeIds.includes(alwaysNodeId) && - (!linkMap[nodeId] || - !linkMap[nodeId][alwaysNodeId] || - linkMap[nodeId][alwaysNodeId] !== "always") - ) { - disassociatePromises.push( - TemplatesService.disassociateWorkflowNode({ - parentId: nodeId, - nodeId: alwaysNodeId, - edge: "always" - }) - ); - } - }); - } - }); - - Object.keys(linkMap).map((sourceNodeId) => { - Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { - const sourceChartNodeId = nodeIdToChartNodeIdMapping[sourceNodeId]; - const targetChartNodeId = nodeIdToChartNodeIdMapping[targetNodeId]; - switch(linkMap[sourceNodeId][targetNodeId]) { - case "success": + Object.keys(nodeRef).map((workflowNodeId) => { + let nodeId = nodeRef[workflowNodeId].originalNodeObject.id; + if (nodeRef[workflowNodeId].originalNodeObject.success_nodes) { + nodeRef[workflowNodeId].originalNodeObject.success_nodes.forEach((successNodeId) => { if ( - !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + !deletedNodeIds.includes(successNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][successNodeId] || + linkMap[nodeId][successNodeId] !== "success") ) { - associatePromises.push( - TemplatesService.associateWorkflowNode({ - parentId: parseInt(sourceNodeId), - nodeId: parseInt(targetNodeId), + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: successNodeId, edge: "success" }) ); } - break; - case "failure": + }); + } + if (nodeRef[workflowNodeId].originalNodeObject.failure_nodes) { + nodeRef[workflowNodeId].originalNodeObject.failure_nodes.forEach((failureNodeId) => { if ( - !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + !deletedNodeIds.includes(failureNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][failureNodeId] || + linkMap[nodeId][failureNodeId] !== "failure") ) { - associatePromises.push( - TemplatesService.associateWorkflowNode({ - parentId: parseInt(sourceNodeId), - nodeId: parseInt(targetNodeId), + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: failureNodeId, edge: "failure" }) ); } - break; - case "always": + }); + } + if (nodeRef[workflowNodeId].originalNodeObject.always_nodes) { + nodeRef[workflowNodeId].originalNodeObject.always_nodes.forEach((alwaysNodeId) => { if ( - !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + !deletedNodeIds.includes(alwaysNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][alwaysNodeId] || + linkMap[nodeId][alwaysNodeId] !== "always") ) { - associatePromises.push( - TemplatesService.associateWorkflowNode({ - parentId: parseInt(sourceNodeId), - nodeId: parseInt(targetNodeId), + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: alwaysNodeId, edge: "always" }) ); } - break; + }); } }); - }); - $q.all(disassociatePromises) - .then(() => { - let credentialPromises = credentialRequests.map((request) => { - return TemplatesService.postWorkflowNodeCredential({ - id: request.id, - data: request.data - }); - }); - - return $q.all(associatePromises.concat(credentialPromises)) - .then(() => { - Wait('stop'); - $scope.workflowChangesUnsaved = false; - $scope.workflowChangesStarted = false; - $scope.closeDialog(); - }).catch(({ data, status }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') - }); - }); - }).catch(({ - data, - status - }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') + Object.keys(linkMap).map((sourceNodeId) => { + Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { + const sourceChartNodeId = nodeIdToChartNodeIdMapping[sourceNodeId]; + const targetChartNodeId = nodeIdToChartNodeIdMapping[targetNodeId]; + switch(linkMap[sourceNodeId][targetNodeId]) { + case "success": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "success" + }) + ); + } + break; + case "failure": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "failure" + }) + ); + } + break; + case "always": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "always" + }) + ); + } + break; + } }); }); - }).catch(({ data, status }) => { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER') + + $q.all(disassociatePromises) + .then(() => { + let credentialPromises = credentialRequests.map((request) => { + return TemplatesService.postWorkflowNodeCredential({ + id: request.id, + data: request.data + }); + }); + + return $q.all(associatePromises.concat(credentialPromises)) + .then(() => { + Wait('stop'); + $scope.workflowChangesUnsaved = false; + $scope.workflowChangesStarted = false; + $scope.closeDialog(); + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + }); + }).catch(({ + data, + status + }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + }); }); + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') }); - }) - .catch(() => { - // TODO: handle }); } else { diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index fabaa4c04c..92e904aa3d 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -68,39 +68,7 @@
-
    -
  • -

    {{strings.get('workflow_maker.KEY')}}

    -
  • -
  • -
    -

    {{strings.get('workflow_maker.ON_SUCCESS')}}

    -
  • -
  • -
    -

    {{strings.get('workflow_maker.ON_FAILURE')}}

    -
  • -
  • -
    -

    {{strings.get('workflow_maker.ALWAYS')}}

    -
  • -
  • -
    P
    -

    {{strings.get('workflow_maker.PROJECT_SYNC')}}

    -
  • -
  • -
    I
    -

    {{strings.get('workflow_maker.INVENTORY_SYNC')}}

    -
  • -
  • -
    W
    -

    {{strings.get('workflow_maker.WORKFLOW')}}

    -
  • -
  • -
    !
    -

    {{strings.get('workflow_maker.WARNING')}}

    -
  • -
+
{{strings.get('workflow_maker.TOTAL_NODES')}} diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html index 4fad491288..61ca637641 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.partial.html +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -327,35 +327,7 @@
-
    -
  • -

    {{strings.legend.KEY}}

    -
  • -
  • -
    -

    {{strings.legend.ON_SUCCESS}}

    -
  • -
  • -
    -

    {{strings.legend.ON_FAILURE}}

    -
  • -
  • -
    -

    {{strings.legend.ALWAYS}}

    -
  • -
  • -
    P
    -

    {{strings.legend.PROJECT_SYNC}}

    -
  • -
  • -
    I
    -

    {{strings.legend.INVENTORY_SYNC}}

    -
  • -
  • -
    W
    -

    {{strings.legend.WORKFLOW}}

    -
  • -
+
From 24c5404c258d598ac38eaea8710e6e6b1a0b048f Mon Sep 17 00:00:00 2001 From: beeankha Date: Thu, 18 Jul 2019 15:52:04 -0400 Subject: [PATCH 13/57] Fix error related to workflow_approval_templates/N endpoint --- awx/main/access.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/main/access.py b/awx/main/access.py index 7dd41e3500..bd834754f5 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2787,6 +2787,9 @@ class WorkflowApprovalAccess(BaseAccess): def can_use(self, obj): return True + def can_start(self, obj, validate_license=True): + return True + def filtered_queryset(self): return self.model.objects.filter( unified_job_node__in=WorkflowJobNode.accessible_pk_qs( From 453e142635e4eb14f5275dcac925aa104632e022 Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 19 Jul 2019 16:24:35 -0400 Subject: [PATCH 14/57] Fix UJT-related error, add notification placeholders --- awx/api/serializers.py | 4 ++- awx/api/urls/workflow_approval_template.py | 6 ++--- awx/api/views/__init__.py | 4 +-- awx/main/models/base.py | 7 +++++ awx/main/models/notifications.py | 1 - awx/main/models/workflow.py | 27 ++++++++++++++++++- awx/main/scheduler/task_manager.py | 6 +++++ awx/main/signals.py | 19 +++++++++++++ .../functional/api/test_workflow_node.py | 20 -------------- 9 files changed, 66 insertions(+), 28 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 35f1e33a98..e0e9d2c3bd 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3446,7 +3446,7 @@ class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): res.update(dict( jobs = self.reverse('api:workflow_approval_template_jobs_list', kwargs={'pk': obj.pk}), - notification_templates_started = self.reverse('api:workflow_approval_template_notification_templates_started_list', kwargs={'pk': obj.pk}), + notification_templates_needs_approval = self.reverse('api:workflow_approval_template_notification_templates_needs_approval', kwargs={'pk': obj.pk}), notification_templates_success = self.reverse('api:workflow_approval_template_notification_templates_success_list', kwargs={'pk': obj.pk}), notification_templates_error = self.reverse('api:workflow_approval_template_notification_templates_error_list', kwargs={'pk': obj.pk}), )) @@ -3520,6 +3520,8 @@ class LaunchConfigurationBaseSerializer(BaseSerializer): ujt = attrs['unified_job_template'] elif self.instance: ujt = self.instance.unified_job_template + if ujt is None: + return {'workflow_job_template': attrs['workflow_job_template']} # build additional field survey_passwords to track redacted variables password_dict = {} diff --git a/awx/api/urls/workflow_approval_template.py b/awx/api/urls/workflow_approval_template.py index e379196826..ee6d793bde 100644 --- a/awx/api/urls/workflow_approval_template.py +++ b/awx/api/urls/workflow_approval_template.py @@ -7,7 +7,7 @@ from awx.api.views import ( WorkflowApprovalTemplateDetail, WorkflowApprovalTemplateJobsList, WorkflowApprovalTemplateNotificationTemplatesErrorList, - WorkflowApprovalTemplateNotificationTemplatesStartedList, + WorkflowApprovalTemplateNotificationTemplatesNeedsApprovalList, WorkflowApprovalTemplateNotificationTemplatesSuccessList, ) @@ -15,8 +15,8 @@ from awx.api.views import ( urls = [ url(r'^(?P[0-9]+)/$', WorkflowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), url(r'^(?P[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'), - url(r'^(?P[0-9]+)/notification_templates_started/$', WorkflowApprovalTemplateNotificationTemplatesStartedList.as_view(), - name='workflow_approval_template_notification_templates_started_list'), + url(r'^(?P[0-9]+)/notification_templates_needs_approval/$', WorkflowApprovalTemplateNotificationTemplatesNeedsApprovalList.as_view(), + name='workflow_approval_template_notification_templates_needs_approval'), url(r'^(?P[0-9]+)/notification_templates_error/$', WorkflowApprovalTemplateNotificationTemplatesErrorList.as_view(), name='workflow_approval_template_notification_templates_error_list'), url(r'^(?P[0-9]+)/notification_templates_success/$', WorkflowApprovalTemplateNotificationTemplatesSuccessList.as_view(), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index cf08ea554c..b716e01f05 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4434,9 +4434,9 @@ class WorkflowApprovalTemplateNotificationTemplatesAnyList(SubListCreateAttachDe parent_model = models.WorkflowApprovalTemplate -class WorkflowApprovalTemplateNotificationTemplatesStartedList(WorkflowApprovalTemplateNotificationTemplatesAnyList): +class WorkflowApprovalTemplateNotificationTemplatesNeedsApprovalList(WorkflowApprovalTemplateNotificationTemplatesAnyList): - relationship = 'notification_templates_started' + relationship = 'notification_templates_needs_approval' class WorkflowApprovalTemplateNotificationTemplatesErrorList(WorkflowApprovalTemplateNotificationTemplatesAnyList): diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 9925dc6049..341aa6fb1d 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -392,6 +392,13 @@ class NotificationFieldsModel(BaseModel): related_name='%(class)s_notification_templates_for_started' ) + # &&&&&& Placeholder for workflow pause/approve notifications + # notification_templates_needs_approval = models.ManyToManyField( + # "NotificationTemplate", + # blank=True, + # related_name='%(class)s_notification_templates_for_needs_approval' + # ) + def prevent_search(relation): """ diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index e3b69128d8..b1d902de90 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -486,6 +486,5 @@ class JobNotificationMixin(object): def send_it(local_nt=nt, local_subject=notification_subject, local_body=notification_body): def _func(): send_notifications.delay([local_nt.generate_notification(local_subject, local_body).id], - job_id=self.id) return _func connection.on_commit(send_it()) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 19775fcbc7..75d7c546bb 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -72,7 +72,7 @@ class WorkflowNodeBase(CreatedModifiedModel, LaunchTimeConfig): unified_job_template = models.ForeignKey( 'UnifiedJobTemplate', related_name='%(class)ss', - blank=False, + blank=True, null=True, default=None, on_delete=models.SET_NULL, @@ -636,6 +636,31 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): def get_absolute_url(self, request=None): return reverse('api:workflow_approval_template_detail', kwargs={'pk': self.pk}, request=request) + # @property + # def notification_templates(self): + # # Return all notification_templates defined on the Job Template, on the Project, and on the Organization for each trigger type + # base_notification_templates = NotificationTemplate.objects.all() + # error_notification_templates = list(base_notification_templates.filter( + # unifiedjobtemplate_notification_templates_for_errors__in=[self])) + # needs_approval_notification_templates = list(base_notification_templates.filter( + # notification_templates_needs_approval__in=[self])) + # success_notification_templates = list(base_notification_templates.filter( + # unifiedjobtemplate_notification_templates_for_success__in=[self])) + # return dict(error=list(error_notification_templates), + # needs_approval=list(needs_approval_notification_templates), + # success=list(success_notification_templates)) +# &&&&&& Approval nodes don't have orgs! + # if self.project is not None and self.project.organization is not None: + # error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter( + # organization_notification_templates_for_errors=self.project.organization))) + # started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter( + # organization_notification_templates_for_started=self.project.organization))) + # success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter( + # organization_notification_templates_for_success=self.project.organization))) + # return dict(error=list(error_notification_templates), + # needs_approval=list(needs_approval_notification_templates), + # success=list(success_notification_templates)) + class WorkflowApproval(UnifiedJob): class Meta: diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index b79abbad0d..bcecee809d 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -23,6 +23,7 @@ from awx.main.models import ( Project, ProjectUpdate, SystemJob, + # &&&&&& WorkflowApproval, WorkflowJob, WorkflowJobTemplate ) @@ -238,6 +239,11 @@ class TaskManager(): task.send_notification_templates('running') logger.debug('Transitioning %s to running status.', task.log_format) schedule_task_manager() + # elif type(task) is WorkflowApproval: (&&&&&& placeholder for notification work) + # task.status = 'pending' + # task.send_notification_templates('pending') + # logger.debug('Transitioning %s to pending status.', task.log_format) + # schedule_task_manager() elif not task.supports_isolation() and rampart_group.controller_id: # non-Ansible jobs on isolated instances run on controller task.instance_group = rampart_group.controller diff --git a/awx/main/signals.py b/awx/main/signals.py index 8b1de3a082..9a5dcec578 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -638,6 +638,25 @@ def delete_inventory_for_org(sender, instance, **kwargs): logger.debug(e) +# &&&&&& Placeholder code below for approval node deletion. +# @receiver(pre_delete, sender=Job) +# def delete_detached_approval_nodes(sender, instance, **kwargs): +# for l in instance.labels.all(): +# if l.is_candidate_for_detach(): +# l.delete() +# +# +# @receiver(pre_delete, sender=Organization) +# def delete_detached_approval_nodes(sender, instance, **kwargs): +# approval_node = ??? +# user = get_current_user_or_none() +# for node in approval_node: +# try: +# node.schedule_deletion(user_id=getattr(user, 'id', None)) +# except RuntimeError as e: +# logger.debug(e) + + @receiver(post_save, sender=Session) def save_user_session_membership(sender, **kwargs): session = kwargs.get('instance', None) diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index 04b02e87a1..4402ce3812 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -26,26 +26,6 @@ def node(workflow_job_template, post, admin_user, job_template): ) - -@pytest.mark.django_db -def test_blank_UJT_unallowed(workflow_job_template, post, admin_user): - url = reverse('api:workflow_job_template_workflow_nodes_list', - kwargs={'pk': workflow_job_template.pk}) - r = post(url, {}, user=admin_user, expect=400) - assert 'unified_job_template' in r.data - - -@pytest.mark.django_db -def test_cannot_remove_UJT(node, patch, admin_user): - r = patch( - node.get_absolute_url(), - data={'unified_job_template': None}, - user=admin_user, - expect=400 - ) - assert 'unified_job_template' in r.data - - @pytest.mark.django_db def test_node_rejects_unprompted_fields(inventory, project, workflow_job_template, post, admin_user): job_template = JobTemplate.objects.create( From 64c94d478d23bd427f3280567e407c4df2c10cd1 Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 24 Jul 2019 14:30:20 -0400 Subject: [PATCH 15/57] Add more RBAC, filter out AJT/AJs from unified jobs lists Comment out placeholder in serializer --- awx/api/serializers.py | 9 ++++--- awx/api/urls/workflow_approval_template.py | 9 ------- awx/api/views/__init__.py | 22 ---------------- awx/main/access.py | 16 ++++++++---- ...oval.py => 0083_v360_workflow_approval.py} | 2 +- awx/main/models/__init__.py | 6 ++--- awx/main/models/activity_stream.py | 3 +++ awx/main/models/base.py | 7 ------ awx/main/models/workflow.py | 25 ------------------- awx/main/scheduler/task_manager.py | 6 ----- awx/main/signals.py | 7 ++++++ 11 files changed, 31 insertions(+), 81 deletions(-) rename awx/main/migrations/{0082_v360_workflowapproval.py => 0083_v360_workflow_approval.py} (97%) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e0e9d2c3bd..fb0b6852f3 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3446,9 +3446,12 @@ class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): res.update(dict( jobs = self.reverse('api:workflow_approval_template_jobs_list', kwargs={'pk': obj.pk}), - notification_templates_needs_approval = self.reverse('api:workflow_approval_template_notification_templates_needs_approval', kwargs={'pk': obj.pk}), - notification_templates_success = self.reverse('api:workflow_approval_template_notification_templates_success_list', kwargs={'pk': obj.pk}), - notification_templates_error = self.reverse('api:workflow_approval_template_notification_templates_error_list', kwargs={'pk': obj.pk}), + # &&&&&& Placeholder for notification things! + # notification_templates_started = self.reverse('api:workflow_approval_template_notification_templates_started_list', kwargs={'pk': obj.pk}), + # notification_templates_needs_approval = self.reverse( + #'api:workflow_approval_template_notification_templates_needs_approval_list', kwargs={'pk': obj.pk}), + # notification_templates_success = self.reverse('api:workflow_approval_template_notification_templates_success_list', kwargs={'pk': obj.pk}), + # notification_templates_error = self.reverse('api:workflow_approval_template_notification_templates_error_list', kwargs={'pk': obj.pk}), )) return res diff --git a/awx/api/urls/workflow_approval_template.py b/awx/api/urls/workflow_approval_template.py index ee6d793bde..8a22ee83b3 100644 --- a/awx/api/urls/workflow_approval_template.py +++ b/awx/api/urls/workflow_approval_template.py @@ -6,21 +6,12 @@ from django.conf.urls import url from awx.api.views import ( WorkflowApprovalTemplateDetail, WorkflowApprovalTemplateJobsList, - WorkflowApprovalTemplateNotificationTemplatesErrorList, - WorkflowApprovalTemplateNotificationTemplatesNeedsApprovalList, - WorkflowApprovalTemplateNotificationTemplatesSuccessList, ) urls = [ url(r'^(?P[0-9]+)/$', WorkflowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), url(r'^(?P[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'), - url(r'^(?P[0-9]+)/notification_templates_needs_approval/$', WorkflowApprovalTemplateNotificationTemplatesNeedsApprovalList.as_view(), - name='workflow_approval_template_notification_templates_needs_approval'), - url(r'^(?P[0-9]+)/notification_templates_error/$', WorkflowApprovalTemplateNotificationTemplatesErrorList.as_view(), - name='workflow_approval_template_notification_templates_error_list'), - url(r'^(?P[0-9]+)/notification_templates_success/$', WorkflowApprovalTemplateNotificationTemplatesSuccessList.as_view(), - name='workflow_approval_template_notification_templates_success_list'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index b716e01f05..3d53916033 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4427,28 +4427,6 @@ class WorkflowApprovalTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpda serializer_class = serializers.WorkflowApprovalTemplateSerializer -class WorkflowApprovalTemplateNotificationTemplatesAnyList(SubListCreateAttachDetachAPIView): - - model = models.NotificationTemplate - serializer_class = serializers.NotificationTemplateSerializer - parent_model = models.WorkflowApprovalTemplate - - -class WorkflowApprovalTemplateNotificationTemplatesNeedsApprovalList(WorkflowApprovalTemplateNotificationTemplatesAnyList): - - relationship = 'notification_templates_needs_approval' - - -class WorkflowApprovalTemplateNotificationTemplatesErrorList(WorkflowApprovalTemplateNotificationTemplatesAnyList): - - relationship = 'notification_templates_error' - - -class WorkflowApprovalTemplateNotificationTemplatesSuccessList(WorkflowApprovalTemplateNotificationTemplatesAnyList): - - relationship = 'notification_templates_success' - - class WorkflowApprovalTemplateJobsList(SubListAPIView): model = models.WorkflowApproval diff --git a/awx/main/access.py b/awx/main/access.py index bd834754f5..ea3299b63a 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2795,11 +2795,13 @@ class WorkflowApprovalAccess(BaseAccess): unified_job_node__in=WorkflowJobNode.accessible_pk_qs( self.user, 'read_role')) - # &&&&&& - # def can_approve_or_deny(self, obj): - # if self.user.is_superuser: or "self.user.approval_role"? - # return True - # return self.can_change(obj, ????) + def get_queryset(self): + return super(UnifiedJobTemplateAccess, self).get_queryset().exclude( + workflowapprovaltemplate__isnull=False) + + def can_approve_or_deny(self, obj): + if self.user.approval_role: + return True class WorkflowApprovalTemplateAccess(BaseAccess): @@ -2825,6 +2827,10 @@ class WorkflowApprovalTemplateAccess(BaseAccess): workflowjobtemplatenodes__workflow_job_template__in=WorkflowJobTemplate.accessible_pk_qs( self.user, 'read_role')) + def get_queryset(self): + return super(UnifiedJobAccess, self).get_queryset().exclude( + workflowapproval__isnull=False) + for cls in BaseAccess.__subclasses__(): access_registry[cls.model] = cls diff --git a/awx/main/migrations/0082_v360_workflowapproval.py b/awx/main/migrations/0083_v360_workflow_approval.py similarity index 97% rename from awx/main/migrations/0082_v360_workflowapproval.py rename to awx/main/migrations/0083_v360_workflow_approval.py index 570402a3f1..66b6bc0504 100644 --- a/awx/main/migrations/0082_v360_workflowapproval.py +++ b/awx/main/migrations/0083_v360_workflow_approval.py @@ -8,7 +8,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('main', '0081_v360_notify_on_start'), + ('main', '0082_v360_workflowapproval'), ] operations = [ diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 1704fe345b..65d246ee5f 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -174,7 +174,7 @@ def o_auth2_token_get_absolute_url(self, request=None): OAuth2AccessToken.add_to_class('get_absolute_url', o_auth2_token_get_absolute_url) -# &&&&&& Add model here + from awx.main.registrar import activity_stream_registrar # noqa activity_stream_registrar.connect(Organization) activity_stream_registrar.connect(Inventory) @@ -202,8 +202,8 @@ activity_stream_registrar.connect(User) activity_stream_registrar.connect(WorkflowJobTemplate) activity_stream_registrar.connect(WorkflowJobTemplateNode) activity_stream_registrar.connect(WorkflowJob) -# activity_stream_registrar.connect(WorkflowApproval) &&&&&& -# activity_stream_registrar.connect(WorkflowApprovalTemplate) +activity_stream_registrar.connect(WorkflowApproval) +activity_stream_registrar.connect(WorkflowApprovalTemplate) activity_stream_registrar.connect(OAuth2Application) activity_stream_registrar.connect(OAuth2AccessToken) diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index bcc2ab20ef..852cea3eac 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -66,6 +66,9 @@ class ActivityStream(models.Model): workflow_job_node = models.ManyToManyField("WorkflowJobNode", blank=True) workflow_job_template = models.ManyToManyField("WorkflowJobTemplate", blank=True) workflow_job = models.ManyToManyField("WorkflowJob", blank=True) +# Possibly adding workflow_approval-related fields here?? &&&&&& +# workflow_approval_template = models.ManyToManyField("WorkflowApprovalTemplate", blank=True) +# workflow_approval = models.ManyToManyField("WorkflowApproval", blank=True) unified_job_template = models.ManyToManyField("UnifiedJobTemplate", blank=True, related_name='activity_stream_as_unified_job_template+') unified_job = models.ManyToManyField("UnifiedJob", blank=True, related_name='activity_stream_as_unified_job+') ad_hoc_command = models.ManyToManyField("AdHocCommand", blank=True) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 341aa6fb1d..9925dc6049 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -392,13 +392,6 @@ class NotificationFieldsModel(BaseModel): related_name='%(class)s_notification_templates_for_started' ) - # &&&&&& Placeholder for workflow pause/approve notifications - # notification_templates_needs_approval = models.ManyToManyField( - # "NotificationTemplate", - # blank=True, - # related_name='%(class)s_notification_templates_for_needs_approval' - # ) - def prevent_search(relation): """ diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 75d7c546bb..197d069adc 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -636,31 +636,6 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): def get_absolute_url(self, request=None): return reverse('api:workflow_approval_template_detail', kwargs={'pk': self.pk}, request=request) - # @property - # def notification_templates(self): - # # Return all notification_templates defined on the Job Template, on the Project, and on the Organization for each trigger type - # base_notification_templates = NotificationTemplate.objects.all() - # error_notification_templates = list(base_notification_templates.filter( - # unifiedjobtemplate_notification_templates_for_errors__in=[self])) - # needs_approval_notification_templates = list(base_notification_templates.filter( - # notification_templates_needs_approval__in=[self])) - # success_notification_templates = list(base_notification_templates.filter( - # unifiedjobtemplate_notification_templates_for_success__in=[self])) - # return dict(error=list(error_notification_templates), - # needs_approval=list(needs_approval_notification_templates), - # success=list(success_notification_templates)) -# &&&&&& Approval nodes don't have orgs! - # if self.project is not None and self.project.organization is not None: - # error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter( - # organization_notification_templates_for_errors=self.project.organization))) - # started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter( - # organization_notification_templates_for_started=self.project.organization))) - # success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter( - # organization_notification_templates_for_success=self.project.organization))) - # return dict(error=list(error_notification_templates), - # needs_approval=list(needs_approval_notification_templates), - # success=list(success_notification_templates)) - class WorkflowApproval(UnifiedJob): class Meta: diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index bcecee809d..b79abbad0d 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -23,7 +23,6 @@ from awx.main.models import ( Project, ProjectUpdate, SystemJob, - # &&&&&& WorkflowApproval, WorkflowJob, WorkflowJobTemplate ) @@ -239,11 +238,6 @@ class TaskManager(): task.send_notification_templates('running') logger.debug('Transitioning %s to running status.', task.log_format) schedule_task_manager() - # elif type(task) is WorkflowApproval: (&&&&&& placeholder for notification work) - # task.status = 'pending' - # task.send_notification_templates('pending') - # logger.debug('Transitioning %s to pending status.', task.log_format) - # schedule_task_manager() elif not task.supports_isolation() and rampart_group.controller_id: # non-Ansible jobs on isolated instances run on controller task.instance_group = rampart_group.controller diff --git a/awx/main/signals.py b/awx/main/signals.py index 9a5dcec578..fb9bf39872 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -430,6 +430,8 @@ def model_serializer_mapping(): models.Label: serializers.LabelSerializer, models.WorkflowJobTemplate: serializers.WorkflowJobTemplateWithSpecSerializer, models.WorkflowJobTemplateNode: serializers.WorkflowJobTemplateNodeSerializer, + models.WorkflowApproval: serializers.WorkflowApprovalSerializer, + models.WorkflowApprovalTemplate: serializers.WorkflowApprovalTemplateSerializer, # &&&&&& models.WorkflowJob: serializers.WorkflowJobSerializer, models.OAuth2AccessToken: serializers.OAuth2TokenSerializer, models.OAuth2Application: serializers.OAuth2ApplicationSerializer, @@ -504,6 +506,11 @@ def activity_stream_update(sender, instance, **kwargs): activity_entry.setting = conf_to_dict(instance) activity_entry.save() +# &&&&&& + # if isinstance(obj1, WorkflowApprovalTemplate) or isinstance(obj2_actual, WorkflowApprovalTemplate): + # continue + + def activity_stream_delete(sender, instance, **kwargs): if not activity_stream_enabled: From 3357c967744034472ba05a1a56ea3a5471acd956 Mon Sep 17 00:00:00 2001 From: beeankha Date: Thu, 25 Jul 2019 15:03:26 -0400 Subject: [PATCH 16/57] Enable deletion of orphaned approval nodes Update serializer to include workflow approval for activity stream --- awx/api/serializers.py | 18 ++++---- awx/api/views/__init__.py | 2 - awx/main/access.py | 11 ++--- .../migrations/0083_v360_workflow_approval.py | 24 +++++++++- awx/main/models/__init__.py | 2 +- awx/main/models/activity_stream.py | 5 +-- awx/main/signals.py | 45 +++++++++---------- 7 files changed, 61 insertions(+), 46 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index fb0b6852f3..c73a4148cf 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3456,13 +3456,13 @@ class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): return res -# class WorkflowJobTemplateApprovalSerializer(UnifiedJobTemplateSerializer): -# class Meta: -# model = WorkflowJobTemplateApproval -# fields = ('*',) -# -# def post(self, obj): -# return # POST only!!! +class WorkflowJobTemplateApprovalSerializer(UnifiedJobTemplateSerializer): + class Meta: + model = WorkflowApprovalTemplate + fields = ('*',) + + def post(self, obj): + return # POST only!!! class LaunchConfigurationBaseSerializer(BaseSerializer): @@ -4746,7 +4746,8 @@ class ActivityStreamSerializer(BaseSerializer): ('o_auth2_access_token', ('id', 'user_id', 'description', 'application_id', 'scope')), ('o_auth2_application', ('id', 'name', 'description')), ('credential_type', ('id', 'name', 'description', 'kind', 'managed_by_tower')), - ('ad_hoc_command', ('id', 'name', 'status', 'limit')) + ('ad_hoc_command', ('id', 'name', 'status', 'limit')), + ('workflow_approval', ('id', 'unified_job_id')), ] return field_list @@ -4855,6 +4856,7 @@ class ActivityStreamSerializer(BaseSerializer): def _summarize_parent_ujt(self, obj, fk, summary_fields): summary_keys = {'job': 'job_template', 'workflow_job_template_node': 'workflow_job_template', + 'workflow_approval': 'workflow_approval_template', 'schedule': 'unified_job_template'} if fk not in summary_keys: return diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 3d53916033..a2a4bc3daa 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -839,8 +839,6 @@ class SystemJobEventsList(SubListAPIView): return super(SystemJobEventsList, self).finalize_response(request, response, *args, **kwargs) - - class ProjectUpdateCancel(RetrieveAPIView): model = models.ProjectUpdate diff --git a/awx/main/access.py b/awx/main/access.py index ea3299b63a..65fe0badd5 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2631,6 +2631,7 @@ class ActivityStreamAccess(BaseAccess): app_set = OAuth2ApplicationAccess(self.user).filtered_queryset() token_set = OAuth2TokenAccess(self.user).filtered_queryset() +# &&&&&& Activity Stream + RBAC here?? return qs.filter( Q(ad_hoc_command__inventory__in=inventory_set) | Q(o_auth2_application__in=app_set) | @@ -2796,11 +2797,11 @@ class WorkflowApprovalAccess(BaseAccess): self.user, 'read_role')) def get_queryset(self): - return super(UnifiedJobTemplateAccess, self).get_queryset().exclude( - workflowapprovaltemplate__isnull=False) + return super(WorkflowApprovalAccess, self).get_queryset().exclude( + workflow_approval_template__isnull=False) def can_approve_or_deny(self, obj): - if self.user.approval_role: + if self.user.approval_role or self.user.system_administrator: return True @@ -2828,8 +2829,8 @@ class WorkflowApprovalTemplateAccess(BaseAccess): self.user, 'read_role')) def get_queryset(self): - return super(UnifiedJobAccess, self).get_queryset().exclude( - workflowapproval__isnull=False) + return super(WorkflowApprovalTemplateAccess, self).get_queryset().filter( + approvals__isnull=False) for cls in BaseAccess.__subclasses__(): diff --git a/awx/main/migrations/0083_v360_workflow_approval.py b/awx/main/migrations/0083_v360_workflow_approval.py index 66b6bc0504..351d4427da 100644 --- a/awx/main/migrations/0083_v360_workflow_approval.py +++ b/awx/main/migrations/0083_v360_workflow_approval.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2.2 on 2019-07-18 14:12 +# Generated by Django 2.2.2 on 2019-07-25 19:16 import awx.main.fields from django.db import migrations, models @@ -8,7 +8,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('main', '0082_v360_workflowapproval'), + ('main', '0082_v360_webhook_http_method'), ] operations = [ @@ -32,6 +32,16 @@ class Migration(migrations.Migration): field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['singleton:system_auditor', 'organization.approval_role', 'admin_role'], related_name='+', to='main.Role'), preserve_default='True', ), + migrations.AlterField( + model_name='workflowjobnode', + name='unified_job_template', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobnodes', to='main.UnifiedJobTemplate'), + ), + migrations.AlterField( + model_name='workflowjobtemplatenode', + name='unified_job_template', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobtemplatenodes', to='main.UnifiedJobTemplate'), + ), migrations.CreateModel( name='WorkflowApproval', fields=[ @@ -40,4 +50,14 @@ class Migration(migrations.Migration): ], bases=('main.unifiedjob',), ), + migrations.AddField( + model_name='activitystream', + name='workflow_approval', + field=models.ManyToManyField(blank=True, to='main.WorkflowApproval'), + ), + migrations.AddField( + model_name='activitystream', + name='workflow_approval_template', + field=models.ManyToManyField(blank=True, to='main.WorkflowApprovalTemplate'), + ), ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 65d246ee5f..6988951ce2 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -203,7 +203,7 @@ activity_stream_registrar.connect(WorkflowJobTemplate) activity_stream_registrar.connect(WorkflowJobTemplateNode) activity_stream_registrar.connect(WorkflowJob) activity_stream_registrar.connect(WorkflowApproval) -activity_stream_registrar.connect(WorkflowApprovalTemplate) +# activity_stream_registrar.connect(WorkflowApprovalTemplate) activity_stream_registrar.connect(OAuth2Application) activity_stream_registrar.connect(OAuth2AccessToken) diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index 852cea3eac..24d32f21d9 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -66,9 +66,8 @@ class ActivityStream(models.Model): workflow_job_node = models.ManyToManyField("WorkflowJobNode", blank=True) workflow_job_template = models.ManyToManyField("WorkflowJobTemplate", blank=True) workflow_job = models.ManyToManyField("WorkflowJob", blank=True) -# Possibly adding workflow_approval-related fields here?? &&&&&& -# workflow_approval_template = models.ManyToManyField("WorkflowApprovalTemplate", blank=True) -# workflow_approval = models.ManyToManyField("WorkflowApproval", blank=True) + workflow_approval_template = models.ManyToManyField("WorkflowApprovalTemplate", blank=True) + workflow_approval = models.ManyToManyField("WorkflowApproval", blank=True) unified_job_template = models.ManyToManyField("UnifiedJobTemplate", blank=True, related_name='activity_stream_as_unified_job_template+') unified_job = models.ManyToManyField("UnifiedJob", blank=True, related_name='activity_stream_as_unified_job+') ad_hoc_command = models.ManyToManyField("AdHocCommand", blank=True) diff --git a/awx/main/signals.py b/awx/main/signals.py index fb9bf39872..34658473f7 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -34,8 +34,8 @@ from awx.main.models import ( InventorySource, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobTemplate, OAuth2AccessToken, Organization, Project, ProjectUpdateEvent, Role, SystemJob, SystemJobEvent, SystemJobTemplate, UnifiedJob, - UnifiedJobTemplate, User, UserSessionMembership, - ROLE_SINGLETON_SYSTEM_ADMINISTRATOR + UnifiedJobTemplate, User, UserSessionMembership, WorkflowJobTemplateNode, + WorkflowApprovalTemplate, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR ) from awx.main.constants import CENSOR_VALUE from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, get_current_apps @@ -431,7 +431,7 @@ def model_serializer_mapping(): models.WorkflowJobTemplate: serializers.WorkflowJobTemplateWithSpecSerializer, models.WorkflowJobTemplateNode: serializers.WorkflowJobTemplateNodeSerializer, models.WorkflowApproval: serializers.WorkflowApprovalSerializer, - models.WorkflowApprovalTemplate: serializers.WorkflowApprovalTemplateSerializer, # &&&&&& + models.WorkflowApprovalTemplate: serializers.WorkflowApprovalTemplateSerializer, models.WorkflowJob: serializers.WorkflowJobSerializer, models.OAuth2AccessToken: serializers.OAuth2TokenSerializer, models.OAuth2Application: serializers.OAuth2ApplicationSerializer, @@ -506,11 +506,6 @@ def activity_stream_update(sender, instance, **kwargs): activity_entry.setting = conf_to_dict(instance) activity_entry.save() -# &&&&&& - # if isinstance(obj1, WorkflowApprovalTemplate) or isinstance(obj2_actual, WorkflowApprovalTemplate): - # continue - - def activity_stream_delete(sender, instance, **kwargs): if not activity_stream_enabled: @@ -645,23 +640,23 @@ def delete_inventory_for_org(sender, instance, **kwargs): logger.debug(e) -# &&&&&& Placeholder code below for approval node deletion. -# @receiver(pre_delete, sender=Job) -# def delete_detached_approval_nodes(sender, instance, **kwargs): -# for l in instance.labels.all(): -# if l.is_candidate_for_detach(): -# l.delete() -# -# -# @receiver(pre_delete, sender=Organization) -# def delete_detached_approval_nodes(sender, instance, **kwargs): -# approval_node = ??? -# user = get_current_user_or_none() -# for node in approval_node: -# try: -# node.schedule_deletion(user_id=getattr(user, 'id', None)) -# except RuntimeError as e: -# logger.debug(e) +@receiver(pre_delete, sender=WorkflowJobTemplateNode) +def delete_approval_nodes(sender, instance, **kwargs): + if type(instance.unified_job_template) is WorkflowApprovalTemplate: + instance.unified_job_template.delete() + + +# When setting UJT to anything other than "is approval node" - update this comment! +@receiver(pre_save, sender=WorkflowJobTemplateNode) +def placeholder_name(sender, instance, **kwargs): + try: + old = WorkflowJobTemplateNode.objects.get(id=instance.id) + except sender.DoesNotExist: + return + if old.unified_job_template == instance.unified_job_template: + return + if type(old.unified_job_template) is WorkflowApprovalTemplate: + old.unified_job_template.delete() @receiver(post_save, sender=Session) From 013792f0f87a43853014a00afeae51b2bd22e705 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 30 Jul 2019 13:34:50 -0400 Subject: [PATCH 17/57] Prompt bug cleanup. Filter workflow_approval jobs out of jobs list. Add initial support for timeout. --- .../client/features/jobs/routes/jobs.route.js | 1 + .../features/templates/templates.strings.js | 3 +- .../components/approvalsDrawer/_index.less | 12 -- .../approvalsDrawer.partial.html | 3 +- .../client/lib/components/layout/_index.less | 4 +- awx/ui/client/src/app.js | 11 +- .../login/loginModal/loginModal.controller.js | 11 +- .../src/templates/prompt/prompt.controller.js | 1 + .../src/templates/prompt/prompt.partial.html | 2 +- .../client/src/templates/templates.service.js | 9 - .../workflow-maker.controller.js | 161 +++++++++--------- 11 files changed, 103 insertions(+), 115 deletions(-) diff --git a/awx/ui/client/features/jobs/routes/jobs.route.js b/awx/ui/client/features/jobs/routes/jobs.route.js index 427d7d165d..73deb2d492 100644 --- a/awx/ui/client/features/jobs/routes/jobs.route.js +++ b/awx/ui/client/features/jobs/routes/jobs.route.js @@ -15,6 +15,7 @@ export default { job_search: { value: { not__launch_type: 'sync', + not__type: 'workflow_approval', order_by: '-finished' }, dynamic: true, diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 8b0713ae4c..b0aa4620ba 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -148,7 +148,8 @@ function TemplatesStrings (BaseString) { EXIT: t.s('EXIT'), CANCEL: t.s('CANCEL'), SAVE_AND_EXIT: t.s('SAVE & EXIT'), - APPROVAL: t.s('Approval') + APPROVAL: t.s('Approval'), + TIMEOUT_POPOVER: t.s('The amount of time (in seconds) to wait before this approval step is automatically denied. Defaults to 0 for no timeout.') }; } diff --git a/awx/ui/client/lib/components/approvalsDrawer/_index.less b/awx/ui/client/lib/components/approvalsDrawer/_index.less index 521a23c05e..5c637f3591 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/_index.less +++ b/awx/ui/client/lib/components/approvalsDrawer/_index.less @@ -15,8 +15,6 @@ height: 100%; width: 540px; background-color: @default-bg; - animation-name: slidein; - animation-duration: 250ms; padding: 20px; overflow-y: scroll; } @@ -63,14 +61,4 @@ opacity: 1; } } -} - -@keyframes slidein { - from { - width: 0px; - } - - to { - width: 540px; - } } \ No newline at end of file diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html index bd208c0f55..1b476efcf2 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html @@ -28,7 +28,6 @@
- + + + + ng-if="approval.approval_expiration" + class="at-ApprovalsDrawer--expires" + value-bind-html="{{:: vm.strings.get('approvals.EXPIRES') }} {{ approval.approval_expiration | longDate }}"> + + - -
-
-
+
+
{{:: vm.strings.get('approvals.CONTINUE') }}
- +
diff --git a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js index 5a9435c10a..58d262b26a 100644 --- a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js @@ -93,6 +93,10 @@ export default function BuildAnchor($log, $filter) { case 'o_auth2_application': url += `applications/${obj.id}`; break; + case 'workflow_approval': + url += `workflows/${activity.summary_fields.workflow_job[0].id}` + name = activity.summary_fields.workflow_job[0].name; + break; default: url += resource + 's/' + obj.id + '/'; } diff --git a/awx/ui/client/src/activity-stream/factories/build-description.factory.js b/awx/ui/client/src/activity-stream/factories/build-description.factory.js index ddb05b0662..ea7c3aaef1 100644 --- a/awx/ui/client/src/activity-stream/factories/build-description.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-description.factory.js @@ -124,7 +124,20 @@ export default function BuildDescription(BuildAnchor, $log, i18n) { break; // expected outcome: "operation " case 'update': - activity.description += activity.object1 + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + if (activity.object1 === 'workflow_approval' + && _.has(activity, 'changes.status') + && activity.changes.status.length === 2 + ) { + let operationText = ''; + if (activity.changes.status[1] === 'successful') { + operationText = i18n._('approved'); + } else if (activity.changes.status[1] === 'failed') { + operationText = i18n._('denied'); + } + activity.description = `${operationText} ${activity.object1} ${BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity)}`; + } else { + activity.description += activity.object1 + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + } break; case 'create': activity.description += activity.object1 + BuildAnchor(activity.changes, activity.object1, activity); diff --git a/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js b/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js index e6b0ff87be..3dbf3a0d14 100644 --- a/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js +++ b/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js @@ -50,11 +50,22 @@ export default ['templateUrl', 'i18n', function(templateUrl, i18n) { } else { let search = { - or__object1__in: $scope.streamTarget && $scope.streamTarget === 'template' ? 'job_template,workflow_job_template' : $scope.streamTarget, - or__object2__in: $scope.streamTarget && $scope.streamTarget === 'template' ? 'job_template,workflow_job_template' : $scope.streamTarget, + or__object1__in: $scope.streamTarget, + or__object2__in: $scope.streamTarget, page_size: '20', order_by: '-timestamp' }; + + if ($scope.streamTarget && $scope.streamTarget === 'template') { + search.or__object1__in = 'job_template,workflow_job_template'; + search.or__object2__in = 'job_template,workflow_job_template'; + } + + if ($scope.streamTarget && $scope.streamTarget === 'job') { + search.or__object1__in = 'job,workflow_approval'; + search.or__object2__in = 'job,workflow_approval'; + } + // Attach the taget to the query parameters $state.go('activityStream', {target: $scope.streamTarget, id: null, activity_search: search}); } diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 5dac887aba..54e05c4a1e 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -198,6 +198,10 @@ angular document.title = `Ansible ${$rootScope.BRAND_NAME} ${title}`; }); + $rootScope.$on('ws-approval', () => { + fetchApprovalsCount(); + }); + function activateTab() { // Make the correct tab active var base = $location.path().replace(/^\//, '').split('/')[0]; @@ -210,6 +214,20 @@ angular } } + function fetchApprovalsCount() { + Rest.setUrl(`${GetBasePath('workflow_approvals')}?status=pending&page_size=1`); + Rest.get() + .then(({data}) => { + $rootScope.pendingApprovalCount = data.count; + }) + .catch(({data, status}) => { + ProcessErrors({}, data, status, null, { + hdr: i18n._('Error!'), + msg: i18n._('Failed to get workflow jobs pending approval. GET returned status: ') + status + }); + }); + } + if ($rootScope.removeConfigReady) { $rootScope.removeConfigReady(); } @@ -387,18 +405,7 @@ angular } }); }); - - Rest.setUrl(`${GetBasePath('workflow_approvals')}?status=pending&page_size=1`); - Rest.get() - .then(({data}) => { - $rootScope.pendingApprovalCount = data.count; - }) - .catch(({data, status}) => { - ProcessErrors({}, data, status, null, { - hdr: i18n._('Error!'), - msg: i18n._('Failed to get workflow jobs pending approval. GET returned status: ') + status - }); - }); + fetchApprovalsCount(); } } diff --git a/awx/ui/client/src/bread-crumb/bread-crumb.directive.js b/awx/ui/client/src/bread-crumb/bread-crumb.directive.js index c3f8466b81..47c1b82e61 100644 --- a/awx/ui/client/src/bread-crumb/bread-crumb.directive.js +++ b/awx/ui/client/src/bread-crumb/bread-crumb.directive.js @@ -75,11 +75,22 @@ export default stateGoParams.target = streamConfig.activityStreamTarget; let isTemplateTarget = _.includes(['template', 'job_template', 'workflow_job_template'], streamConfig.activityStreamTarget); stateGoParams.activity_search = { - or__object1__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget, - or__object2__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget, + or__object1__in: streamConfig.activityStreamTarget, + or__object2__in: streamConfig.activityStreamTarget, order_by: '-timestamp', page_size: '20', }; + + if (isTemplateTarget) { + stateGoParams.activity_search.or__object1__in = 'job_template,workflow_job_template'; + stateGoParams.activity_search.or__object2__in = 'job_template,workflow_job_template'; + } + + if (streamConfig.activityStreamTarget === 'job') { + stateGoParams.activity_search.or__object1__in = 'job,workflow_approval'; + stateGoParams.activity_search.or__object2__in = 'job,workflow_approval'; + } + if (streamConfig.activityStreamTarget && streamConfig.activityStreamId && !streamConfig.noActivityStreamID) { stateGoParams.activity_search[streamConfig.activityStreamTarget] = $state.params[streamConfig.activityStreamId]; } diff --git a/awx/ui/client/src/home/dashboard/graphs/dashboard-graphs.partial.html b/awx/ui/client/src/home/dashboard/graphs/dashboard-graphs.partial.html index d9fac74d47..4d1d547a86 100644 --- a/awx/ui/client/src/home/dashboard/graphs/dashboard-graphs.partial.html +++ b/awx/ui/client/src/home/dashboard/graphs/dashboard-graphs.partial.html @@ -94,9 +94,12 @@
- +
diff --git a/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js b/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js index 96e0b81786..51b56ec3bd 100644 --- a/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js +++ b/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js @@ -18,19 +18,17 @@ function JobStatusGraph($window, adjustGraphSize, templateUrl, i18n, moment, gra return { restrict: 'E', scope: { - data: '=' + data: '=', + period: '=', + jobType: '=', + status: '=' }, templateUrl: templateUrl('home/dashboard/graphs/job-status/job_status_graph'), link: link }; function link(scope, element) { - var job_type, - job_status_chart = nv.models.lineChart(); - - scope.period="month"; - scope.jobType="all"; - scope.status="both"; + var job_status_chart = nv.models.lineChart(); scope.$watchCollection('data', function(value) { if (value) { @@ -129,8 +127,6 @@ function JobStatusGraph($window, adjustGraphSize, templateUrl, i18n, moment, gra // when the Period drop down filter is used, create a new graph based on the $('.n').off('click').on("click", function(){ - period = this.getAttribute("id"); - $('#period-dropdown-display') .html(` ${this.text} @@ -139,13 +135,11 @@ function JobStatusGraph($window, adjustGraphSize, templateUrl, i18n, moment, gra scope.$parent.isFailed = true; scope.$parent.isSuccessful = true; - recreateGraph(period, job_type); + recreateGraph(this.getAttribute("id"), scope.jobType, scope.status); }); //On click, update with new data $('.m').off('click').on("click", function(){ - job_type = this.getAttribute("id"); - $('#type-dropdown-display') .html(` ${this.text} @@ -154,19 +148,17 @@ function JobStatusGraph($window, adjustGraphSize, templateUrl, i18n, moment, gra scope.$parent.isFailed = true; scope.$parent.isSuccessful = true; - recreateGraph(period, job_type); + recreateGraph(scope.period, this.getAttribute("id"), scope.status); }); $('.o').off('click').on('click', function() { - var job_status = this.getAttribute('id'); - $('#status-dropdown-display') .html(` ${this.text} `); - recreateGraph(scope.period, scope.jobType, job_status); + recreateGraph(scope.period, scope.jobType, this.getAttribute("id")); }); adjustGraphSize(job_status_chart, element); diff --git a/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.service.js b/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.service.js index 100fc24bd1..8d7f5fb690 100644 --- a/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.service.js +++ b/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.service.js @@ -4,95 +4,44 @@ * All Rights Reserved *************************************************/ -export default -["Rest", - "GetBasePath", - "ProcessErrors", - "$rootScope", - "$q", - "$timeout", - JobStatusGraphData]; - -function JobStatusGraphData(Rest, getBasePath, processErrors, $rootScope, $q, $timeout) { - - function getData(period, jobType, status) { - var url, dash_path = getBasePath('dashboard'); - if(dash_path === '' ){ - processErrors(null, - null, - null, - null, { - hdr: 'Error!', - msg: "There was an error. Please try again." - }); - return; - } - url = dash_path + 'graphs/jobs/?period='+period+'&job_type='+jobType; - Rest.setHeader({'X-WS-Session-Quiet': true}); - Rest.setUrl(url); - return Rest.get() - .then(function(value) { - if(status === "successful" || status === "failed"){ - delete value.data.jobs[status]; - } - return value.data; - }) - .catch(function(response) { - var errorMessage = 'Failed to get: ' + response.url + ' GET returned: ' + response.status; - - processErrors(null, - response.data, - response.status, - null, { - hdr: 'Error!', - msg: errorMessage - }); - return $q.reject(response); - }); - } +export default ["Rest", "GetBasePath", "ProcessErrors", "$q", JobStatusGraphData]; +function JobStatusGraphData(Rest, getBasePath, processErrors, $q) { return { - pendingRefresh: false, - refreshTimerRunning: false, - refreshTimer: angular.noop, - destroyWatcher: angular.noop, - setupWatcher: function(period, jobType) { - const that = this; - that.destroyWatcher = - $rootScope.$on('ws-jobs', function() { - if (!that.refreshTimerRunning) { - that.timebandGetData(period, jobType); - } else { - that.pendingRefresh = true; - } - }); - }, - timebandGetData: function(period, jobType) { - getData(period, jobType).then(function(result) { - $rootScope. - $broadcast('DataReceived:JobStatusGraph', - result); - return result; - }); - this.pendingRefresh = false; - this.refreshTimerRunning = true; - this.refreshTimer = $timeout(() => { - if (this.pendingRefresh) { - this.timebandGetData(period, jobType); - } else { - this.refreshTimerRunning = false; - } - }, 5000); - }, get: function(period, jobType, status) { - - this.destroyWatcher(); - $timeout.cancel(this.refreshTimer); - this.refreshTimerRunning = false; - this.pendingRefresh = false; - this.setupWatcher(period, jobType); - - return getData(period, jobType, status); + var url, dash_path = getBasePath('dashboard'); + if(dash_path === '' ){ + processErrors(null, + null, + null, + null, { + hdr: 'Error!', + msg: "There was an error. Please try again." + }); + return; + } + url = dash_path + 'graphs/jobs/?period='+period+'&job_type='+jobType; + Rest.setHeader({'X-WS-Session-Quiet': true}); + Rest.setUrl(url); + return Rest.get() + .then(function(value) { + if(status === "successful" || status === "failed"){ + delete value.data.jobs[status]; + } + return value.data; + }) + .catch(function(response) { + var errorMessage = 'Failed to get: ' + response.url + ' GET returned: ' + response.status; + + processErrors(null, + response.data, + response.status, + null, { + hdr: 'Error!', + msg: errorMessage + }); + return $q.reject(response); + }); } }; diff --git a/awx/ui/client/src/home/home.controller.js b/awx/ui/client/src/home/home.controller.js index 4af7adbb16..abebcd54b7 100644 --- a/awx/ui/client/src/home/home.controller.js +++ b/awx/ui/client/src/home/home.controller.js @@ -4,9 +4,9 @@ * All Rights Reserved *************************************************/ -export default ['$scope', '$rootScope','Wait', '$timeout', +export default ['$scope','Wait', '$timeout', 'i18n', 'Rest', 'GetBasePath', 'ProcessErrors', 'graphData', - function($scope, $rootScope, Wait, $timeout, + function($scope, Wait, $timeout, i18n, Rest, GetBasePath, ProcessErrors, graphData) { var dataCount = 0; @@ -53,16 +53,10 @@ export default ['$scope', '$rootScope','Wait', '$timeout', $scope.removeDashboardReady = $scope.$on('dashboardReady', function (e, data) { $scope.dashboardCountsData = data; $scope.graphData = graphData; + $scope.graphData.period = "month"; + $scope.graphData.jobType = "all"; + $scope.graphData.status = "both"; $scope.$emit('dashboardDataLoadComplete'); - - var cleanupJobListener = - $rootScope.$on('DataReceived:JobStatusGraph', function(e, data) { - $scope.graphData.jobStatus = data; - }); - - $scope.$on('$destroy', function() { - cleanupJobListener(); - }); }); if ($scope.removeDashboardJobsListReady) { @@ -81,40 +75,6 @@ export default ['$scope', '$rootScope','Wait', '$timeout', $scope.$emit('dashboardDataLoadComplete'); }); - $scope.refresh = function () { - Wait('start'); - Rest.setUrl(GetBasePath('dashboard')); - Rest.get() - .then(({data}) => { - $scope.dashboardData = data; - $scope.$emit('dashboardReady', data); - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to get dashboard: ' + status }); - }); - Rest.setUrl(GetBasePath("unified_jobs") + "?order_by=-finished&page_size=5&finished__isnull=false&type=workflow_job,job"); - Rest.setHeader({'X-WS-Session-Quiet': true}); - Rest.get() - .then(({data}) => { - data = data.results; - $scope.$emit('dashboardJobsListReady', data); - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to get dashboard jobs list: ' + status }); - }); - Rest.setUrl(GetBasePath("unified_job_templates") + "?order_by=-last_job_run&page_size=5&last_job_run__isnull=false&type=workflow_job_template,job_template"); - Rest.get() - .then(({data}) => { - data = data.results; - $scope.$emit('dashboardJobTemplatesListReady', data); - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to get dashboard job templates list: ' + status }); - }); - }; - - $scope.refresh(); - function refreshLists () { Rest.setUrl(GetBasePath('dashboard')); Rest.get() @@ -122,7 +82,7 @@ export default ['$scope', '$rootScope','Wait', '$timeout', $scope.dashboardData = data; }) .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to get dashboard host graph data: ' + status }); + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard host graph data: ${status}`) }); }); Rest.setUrl(GetBasePath("unified_jobs") + "?order_by=-finished&page_size=5&finished__isnull=false&type=workflow_job,job"); @@ -132,7 +92,7 @@ export default ['$scope', '$rootScope','Wait', '$timeout', $scope.dashboardJobsListData = data.results; }) .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to get dashboard jobs list: ' + status }); + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard jobs list: ${status}`) }); }); Rest.setUrl(GetBasePath("unified_job_templates") + "?order_by=-last_job_run&page_size=5&last_job_run__isnull=false&type=workflow_job_template,job_template"); @@ -141,8 +101,24 @@ export default ['$scope', '$rootScope','Wait', '$timeout', $scope.dashboardJobTemplatesListData = data.results; }) .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to get dashboard jobs list: ' + status }); + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard jobs list: ${status}`) }); }); + + if ($scope.graphData) { + Rest.setUrl(`${GetBasePath('dashboard')}graphs/jobs/?period=${$scope.graphData.period}&job_type=${$scope.graphData.jobType}`); + Rest.setHeader({'X-WS-Session-Quiet': true}); + Rest.get() + .then(function(value) { + if($scope.graphData.status === "successful" || $scope.graphData.status === "failed"){ + delete value.data.jobs[$scope.graphData.status]; + } + $scope.graphData.jobStatus = value.data; + }) + .catch(function({data, status}) { + processErrors(null, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard graph data: ${response.status}`)}); + }); + } + pendingRefresh = false; refreshTimerRunning = true; $timeout(() => { @@ -154,5 +130,35 @@ export default ['$scope', '$rootScope','Wait', '$timeout', }, 5000); } + Wait('start'); + Rest.setUrl(GetBasePath('dashboard')); + Rest.get() + .then(({data}) => { + $scope.dashboardData = data; + $scope.$emit('dashboardReady', data); + }) + .catch(({data, status}) => { + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard: ${status}`) }); + }); + Rest.setUrl(GetBasePath("unified_jobs") + "?order_by=-finished&page_size=5&finished__isnull=false&type=workflow_job,job"); + Rest.setHeader({'X-WS-Session-Quiet': true}); + Rest.get() + .then(({data}) => { + data = data.results; + $scope.$emit('dashboardJobsListReady', data); + }) + .catch(({data, status}) => { + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard jobs list: ${status}`) }); + }); + Rest.setUrl(GetBasePath("unified_job_templates") + "?order_by=-last_job_run&page_size=5&last_job_run__isnull=false&type=workflow_job_template,job_template"); + Rest.get() + .then(({data}) => { + data = data.results; + $scope.$emit('dashboardJobTemplatesListReady', data); + }) + .catch(({data, status}) => { + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard job templates list: ${status}`) }); + }); + } ]; diff --git a/awx/ui/client/src/home/home.route.js b/awx/ui/client/src/home/home.route.js index ffc9d2681a..3f6c657dd0 100644 --- a/awx/ui/client/src/home/home.route.js +++ b/awx/ui/client/src/home/home.route.js @@ -10,12 +10,7 @@ export default { params: { licenseMissing: null }, data: { activityStream: true, - refreshButton: true, - socket: { - "groups": { - "jobs": ["status_changed"] - } - }, + refreshButton: true }, ncyBreadcrumb: { label: N_("DASHBOARD") diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js index 0c69e900ad..8074e96d06 100644 --- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js +++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js @@ -175,12 +175,7 @@ let lists = [{ }, data: { activityStream: true, - activityStreamTarget: 'organization', - socket: { - "groups": { - "jobs": ["status_changed"] - } - }, + activityStreamTarget: 'organization' }, ncyBreadcrumb: { parent: "organizations.edit", diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index b0a770f02f..c492bcb5e5 100644 --- a/awx/ui/client/src/shared/socket/socket.service.js +++ b/awx/ui/client/src/shared/socket/socket.service.js @@ -96,6 +96,14 @@ export default $log.debug('Received From Server: ' + e.data); var data = JSON.parse(e.data), str = ""; + + if (data.group_name === 'jobs' + && 'type' in data + && data.type === 'workflow_approval' + ) { + $rootScope.$broadcast('ws-approval'); + } + if(!window.liveUpdates && data.group_name !== "control" && $state.current.name !== "output"){ $log.debug('Message from server dropped: ' + e.data); needsRefreshAfterBlur = true; @@ -254,21 +262,23 @@ export default // requires a subscribe or an unsubscribe var self = this; return socketPromise.promise.then(function(){ - if(!state.data || !state.data.socket){ - _.merge(state.data, {socket: {groups: {}}}); - self.unsubscribe(state); + if (_.get(state, 'data.socket.groups.jobs')) { + if (!state.data.socket.groups.jobs.includes("status_changed")) { + state.data.socket.groups.jobs.push("status_changed"); + } } - else{ - ["job_events", "ad_hoc_command_events", "workflow_events", + else if(!state.data || !state.data.socket){ + _.merge(state.data, {socket: {groups: {jobs: ["status_changed"]}}}); + } + ["job_events", "ad_hoc_command_events", "workflow_events", "project_update_events", "inventory_update_events", "system_job_events" - ].forEach(function(group) { - if(state.data && state.data.socket && state.data.socket.groups.hasOwnProperty(group)){ - state.data.socket.groups[group] = [id]; - } - }); - self.subscribe(state); - } + ].forEach(function(group) { + if(state.data && state.data.socket && state.data.socket.groups.hasOwnProperty(group)){ + state.data.socket.groups[group] = [id]; + } + }); + self.subscribe(state); return true; }); } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index ea6f4eea01..323f13824c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -608,10 +608,23 @@ export default ['$scope', 'TemplatesService', } } else if ($scope.nodeConfig.mode === "edit") { if (selectedTemplate) { - nodeRef[$scope.nodeConfig.nodeId].fullUnifiedJobTemplateObject = selectedTemplate; - nodeRef[$scope.nodeConfig.nodeId].unifiedJobTemplate = selectedTemplate; - nodeRef[$scope.nodeConfig.nodeId].promptData = _.cloneDeep(promptData); - nodeRef[$scope.nodeConfig.nodeId].isEdited = true; + if (isPauseNode) { + // If it's a _new_ pause node then we'll want to create the new ujt + // If it's an existing pause node then we'll want to update the ujt + nodeRef[$scope.nodeConfig.nodeId].unifiedJobTemplate = { + name: selectedTemplate.name, + description: selectedTemplate.description, + timeout: selectedTemplate.timeout, + unified_job_type: "workflow_approval" + }; + nodeRef[$scope.nodeConfig.nodeId].isEdited = true; + } else { + nodeRef[$scope.nodeConfig.nodeId].fullUnifiedJobTemplateObject = selectedTemplate; + nodeRef[$scope.nodeConfig.nodeId].unifiedJobTemplate = selectedTemplate; + nodeRef[$scope.nodeConfig.nodeId].promptData = _.cloneDeep(promptData); + nodeRef[$scope.nodeConfig.nodeId].isEdited = true; + } + $scope.graphState.nodeBeingEdited = null; $scope.graphState.arrayOfLinksForChart.map( (link) => { @@ -622,16 +635,6 @@ export default ['$scope', 'TemplatesService', link.source.unifiedJobTemplate = selectedTemplate; } }); - } else if (isPauseNode) { - // If it's a _new_ pause node then we'll want to create the new ujt - // If it's an existing pause node then we'll want to update the ujt - nodeRef[$scope.nodeConfig.nodeId].unifiedJobTemplate = { - name: selectedTemplate.name, - description: selectedTemplate.description, - timeout: selectedTemplate.timeout, - unified_job_type: "workflow_approval" - }; - nodeRef[$scope.nodeConfig.nodeId].isEdited = true; } } diff --git a/awx/ui/client/src/workflow-results/workflow-results.route.js b/awx/ui/client/src/workflow-results/workflow-results.route.js index 85d81cc49d..149a211129 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.route.js +++ b/awx/ui/client/src/workflow-results/workflow-results.route.js @@ -15,13 +15,6 @@ export default { parent: 'jobs', label: '{{ workflow.id }} - {{ workflow.name }}' }, - data: { - socket: { - "groups":{ - "jobs": ["status_changed"] - } - } - }, templateUrl: templateUrl('workflow-results/workflow-results'), controller: workflowResultsController, resolve: { From bdf4defdbe95eecdd0e6880b6e83ab78607a6d55 Mon Sep 17 00:00:00 2001 From: Elijah DeLee Date: Tue, 13 Aug 2019 14:24:27 -0400 Subject: [PATCH 31/57] Add approval node logic to awxkit Co-authored-by: --- awxkit/awxkit/api/pages/__init__.py | 1 + awxkit/awxkit/api/pages/workflow_approvals.py | 30 +++++++ .../api/pages/workflow_job_template_nodes.py | 83 +++++++++++++++---- awxkit/awxkit/api/resources.py | 2 + 4 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 awxkit/awxkit/api/pages/workflow_approvals.py diff --git a/awxkit/awxkit/api/pages/__init__.py b/awxkit/awxkit/api/pages/__init__.py index 98dabd8681..19cf7323ca 100644 --- a/awxkit/awxkit/api/pages/__init__.py +++ b/awxkit/awxkit/api/pages/__init__.py @@ -32,6 +32,7 @@ from .workflow_job_templates import * # NOQA from .workflow_job_template_nodes import * # NOQA from .workflow_jobs import * # NOQA from .workflow_job_nodes import * # NOQA +from .workflow_approvals import * # NOQA from .settings import * # NOQA from .instances import * # NOQA from .instance_groups import * # NOQA diff --git a/awxkit/awxkit/api/pages/workflow_approvals.py b/awxkit/awxkit/api/pages/workflow_approvals.py new file mode 100644 index 0000000000..d4ededcdec --- /dev/null +++ b/awxkit/awxkit/api/pages/workflow_approvals.py @@ -0,0 +1,30 @@ +from awxkit.api.pages import UnifiedJob +from awxkit.api.resources import resources +from . import page +from awxkit import exceptions + + +class WorkflowApproval(UnifiedJob): + + def approve(self): + try: + self.related.approve.post() + except exceptions.NoContent: + pass + + def deny(self): + try: + self.related.deny.post() + except exceptions.NoContent: + pass + + +page.register_page(resources.workflow_approval, WorkflowApproval) + + +class WorkflowApprovals(page.PageList, WorkflowApproval): + + pass + + +page.register_page(resources.workflow_approvals, WorkflowApprovals) diff --git a/awxkit/awxkit/api/pages/workflow_job_template_nodes.py b/awxkit/awxkit/api/pages/workflow_job_template_nodes.py index 9c8f673ab7..94928abe16 100644 --- a/awxkit/awxkit/api/pages/workflow_job_template_nodes.py +++ b/awxkit/awxkit/api/pages/workflow_job_template_nodes.py @@ -3,7 +3,7 @@ import awxkit.exceptions as exc from awxkit.api.pages import base, WorkflowJobTemplate, UnifiedJobTemplate, JobTemplate from awxkit.api.mixins import HasCreate, DSAdapter from awxkit.api.resources import resources -from awxkit.utils import update_payload, PseudoNamespace, suppress +from awxkit.utils import update_payload, PseudoNamespace, suppress, random_title from . import page @@ -12,11 +12,24 @@ class WorkflowJobTemplateNode(HasCreate, base.Base): dependencies = [WorkflowJobTemplate, UnifiedJobTemplate] def payload(self, workflow_job_template, unified_job_template, **kwargs): - payload = PseudoNamespace(workflow_job_template=workflow_job_template.id, - unified_job_template=unified_job_template.id) + if not unified_job_template: + # May pass "None" to explicitly create an approval node + payload = PseudoNamespace( + workflow_job_template=workflow_job_template.id) + else: + payload = PseudoNamespace( + workflow_job_template=workflow_job_template.id, + unified_job_template=unified_job_template.id) - optional_fields = ('diff_mode', 'extra_data', 'limit', 'job_tags', 'job_type', 'skip_tags', 'verbosity', - 'extra_data') + optional_fields = ( + 'diff_mode', + 'extra_data', + 'limit', + 'job_tags', + 'job_type', + 'skip_tags', + 'verbosity', + 'extra_data') update_payload(payload, optional_fields, kwargs) @@ -25,21 +38,45 @@ class WorkflowJobTemplateNode(HasCreate, base.Base): return payload - def create_payload(self, workflow_job_template=WorkflowJobTemplate, unified_job_template=JobTemplate, **kwargs): - self.create_and_update_dependencies(workflow_job_template, unified_job_template) - payload = self.payload(workflow_job_template=self.ds.workflow_job_template, - unified_job_template=self.ds.unified_job_template, **kwargs) + def create_payload( + self, + workflow_job_template=WorkflowJobTemplate, + unified_job_template=JobTemplate, + **kwargs): + if not unified_job_template: + self.create_and_update_dependencies(workflow_job_template) + payload = self.payload( + workflow_job_template=self.ds.workflow_job_template, + unified_job_template=None, + **kwargs) + else: + self.create_and_update_dependencies( + workflow_job_template, unified_job_template) + payload = self.payload( + workflow_job_template=self.ds.workflow_job_template, + unified_job_template=self.ds.unified_job_template, + **kwargs) payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store) return payload - def create(self, workflow_job_template=WorkflowJobTemplate, unified_job_template=JobTemplate, **kwargs): - payload = self.create_payload(workflow_job_template=workflow_job_template, - unified_job_template=unified_job_template, **kwargs) - return self.update_identity(WorkflowJobTemplateNodes(self.connection).post(payload)) + def create( + self, + workflow_job_template=WorkflowJobTemplate, + unified_job_template=JobTemplate, + **kwargs): + payload = self.create_payload( + workflow_job_template=workflow_job_template, + unified_job_template=unified_job_template, + **kwargs) + return self.update_identity( + WorkflowJobTemplateNodes( + self.connection).post(payload)) def _add_node(self, endpoint, unified_job_template): - node = endpoint.post(dict(unified_job_template=unified_job_template.id)) - node.create_and_update_dependencies(self.ds.workflow_job_template, unified_job_template) + node = endpoint.post( + dict(unified_job_template=unified_job_template.id)) + node.create_and_update_dependencies( + self.ds.workflow_job_template, unified_job_template) return node def add_always_node(self, unified_job_template): @@ -67,9 +104,20 @@ class WorkflowJobTemplateNode(HasCreate, base.Base): self.related.credentials.post( dict(id=cred.id, disassociate=True)) + def make_approval_node( + self, + **kwargs + ): + if 'name' not in kwargs: + kwargs['name'] = 'approval node {}'.format(random_title()) + self.related.create_approval_template.post(kwargs) + return self.get() + page.register_page([resources.workflow_job_template_node, - (resources.workflow_job_template_nodes, 'post')], WorkflowJobTemplateNode) + (resources.workflow_job_template_nodes, + 'post')], + WorkflowJobTemplateNode) class WorkflowJobTemplateNodes(page.PageList, WorkflowJobTemplateNode): @@ -81,4 +129,5 @@ page.register_page([resources.workflow_job_template_nodes, resources.workflow_job_template_workflow_nodes, resources.workflow_job_template_node_always_nodes, resources.workflow_job_template_node_failure_nodes, - resources.workflow_job_template_node_success_nodes], WorkflowJobTemplateNodes) + resources.workflow_job_template_node_success_nodes], + WorkflowJobTemplateNodes) diff --git a/awxkit/awxkit/api/resources.py b/awxkit/awxkit/api/resources.py index d07ce5d6a8..657a41b7f3 100644 --- a/awxkit/awxkit/api/resources.py +++ b/awxkit/awxkit/api/resources.py @@ -81,6 +81,8 @@ class Resources(object): _inventory_update_events = r'inventory_updates/\d+/events/' _inventory_updates = 'inventory_updates/' _inventory_variable_data = r'inventories/\d+/variable_data/' + _workflow_approval = r'workflow_approvals/\d+/' + _workflow_approvals = 'workflow_approvals/' _job = r'jobs/\d+/' _job_cancel = r'jobs/\d+/cancel/' _job_create_schedule = r'jobs/\d+/create_schedule/' From 73485b220e0967c79da1944c6f5b056885fff64e Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 13 Aug 2019 15:36:15 -0400 Subject: [PATCH 32/57] fix jshint errors --- .../src/activity-stream/factories/build-anchor.factory.js | 2 +- .../activity-stream/factories/build-description.factory.js | 6 +++--- awx/ui/client/src/home/home.controller.js | 2 +- awx/ui/client/src/shared/socket/socket.service.js | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js index 58d262b26a..09992c6029 100644 --- a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js @@ -94,7 +94,7 @@ export default function BuildAnchor($log, $filter) { url += `applications/${obj.id}`; break; case 'workflow_approval': - url += `workflows/${activity.summary_fields.workflow_job[0].id}` + url += `workflows/${activity.summary_fields.workflow_job[0].id}`; name = activity.summary_fields.workflow_job[0].name; break; default: diff --git a/awx/ui/client/src/activity-stream/factories/build-description.factory.js b/awx/ui/client/src/activity-stream/factories/build-description.factory.js index ea7c3aaef1..ba82d5250f 100644 --- a/awx/ui/client/src/activity-stream/factories/build-description.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-description.factory.js @@ -124,9 +124,9 @@ export default function BuildDescription(BuildAnchor, $log, i18n) { break; // expected outcome: "operation " case 'update': - if (activity.object1 === 'workflow_approval' - && _.has(activity, 'changes.status') - && activity.changes.status.length === 2 + if (activity.object1 === 'workflow_approval' && + _.has(activity, 'changes.status') && + activity.changes.status.length === 2 ) { let operationText = ''; if (activity.changes.status[1] === 'successful') { diff --git a/awx/ui/client/src/home/home.controller.js b/awx/ui/client/src/home/home.controller.js index abebcd54b7..a395e97b01 100644 --- a/awx/ui/client/src/home/home.controller.js +++ b/awx/ui/client/src/home/home.controller.js @@ -115,7 +115,7 @@ export default ['$scope','Wait', '$timeout', 'i18n', $scope.graphData.jobStatus = value.data; }) .catch(function({data, status}) { - processErrors(null, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard graph data: ${response.status}`)}); + ProcessErrors(null, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard graph data: ${status}`)}); }); } diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index c492bcb5e5..24c7ec0234 100644 --- a/awx/ui/client/src/shared/socket/socket.service.js +++ b/awx/ui/client/src/shared/socket/socket.service.js @@ -97,9 +97,9 @@ export default var data = JSON.parse(e.data), str = ""; - if (data.group_name === 'jobs' - && 'type' in data - && data.type === 'workflow_approval' + if (data.group_name === 'jobs' && + 'type' in data && + data.type === 'workflow_approval' ) { $rootScope.$broadcast('ws-approval'); } From 761dad060cf734c43fdd208df4ce23f659e8ad68 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 13 Aug 2019 20:16:09 -0400 Subject: [PATCH 33/57] allow org/WF admins to create approval templates --- awx/api/views/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 78f0d1485a..cbf41d2fe7 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3025,6 +3025,14 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): approval_template = obj.create_approval_template(**serializer.validated_data) return Response(data={'id':approval_template.pk}, status=status.HTTP_200_OK) + def check_permissions(self, request): + if request.method == 'POST': + if request.user not in self.get_object().workflow_job_template.admin_role: + self.permission_denied(request) + else: + if request.user not in self.get_object().workflow_job_template.read_role: + self.permission_denied(request) + class WorkflowJobTemplateNodeSuccessNodesList(WorkflowJobTemplateNodeChildrenBaseList): relationship = 'success_nodes' From f7d6f4538c81da244996948ab5a76cd64cc3597e Mon Sep 17 00:00:00 2001 From: beeankha Date: Tue, 13 Aug 2019 20:50:08 -0400 Subject: [PATCH 34/57] Emit approve/deny status for websockets, update doc string + a comment --- awx/api/permissions.py | 1 - awx/api/views/__init__.py | 3 +-- awx/main/access.py | 9 +++++++-- awx/main/models/workflow.py | 2 ++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/awx/api/permissions.py b/awx/api/permissions.py index c344778bea..09e6f0f1bc 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -249,4 +249,3 @@ class InstanceGroupTowerPermission(ModelAccessPermission): if request.method == 'DELETE' and obj.name == "tower": return False return super(InstanceGroupTowerPermission, self).has_object_permission(request, view, obj) - diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index cbf41d2fe7..726f14295b 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4487,8 +4487,7 @@ class WorkflowApprovalDeny(RetrieveAPIView): obj.deny(request) return Response(status=status.HTTP_204_NO_CONTENT) - - +# Placeholder code for approval notification support class WorkflowApprovalNotificationsList(SubListAPIView): model = models.Notification diff --git a/awx/main/access.py b/awx/main/access.py index 936d80efab..32f96a2e35 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -134,7 +134,7 @@ def check_user_access_with_errors(user, model_class, action, *args, **kwargs): access_instance = access_class(user, save_messages=True) access_method = getattr(access_instance, 'can_%s' % action, None) result = access_method(*args, **kwargs) - logger.debug('%s.%s %r returned %r', access_instance.__class__.__name__, + logger.error('%s.%s %r returned %r', access_instance.__class__.__name__, access_method.__name__, args, result) return (result, access_instance.messages) @@ -2824,13 +2824,18 @@ class WorkflowApprovalTemplateAccess(BaseAccess): @check_superuser def can_add(self, data): + ''' + A user can create an approval template if they are a superuser, an org admin + of the org connected to the workflow, or if they are assigned as admins to + the workflow. + ''' if data is None: # Hide direct creation in API browser return False else: return (self.check_related('workflow_approval_template', UnifiedJobTemplate, role_field='admin_role')) def can_start(self, obj, validate_license=False): - # Super users can start any job + # for copying WFJTs that contain approval nodes if self.user.is_superuser: return True diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index e59c98f1cc..c2b9427df9 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -684,6 +684,7 @@ class WorkflowApproval(UnifiedJob): from awx.main.signals import model_serializer_mapping # circular import self.status = 'successful' self.save() + self.websocket_emit_status(self.status) changes = model_to_dict(self, model_serializer_mapping()) changes['status'] = ['pending', 'successful'] activity_entry = ActivityStream( @@ -701,6 +702,7 @@ class WorkflowApproval(UnifiedJob): from awx.main.signals import model_serializer_mapping # circular import self.status = 'failed' self.save() + self.websocket_emit_status(self.status) changes = model_to_dict(self, model_serializer_mapping()) changes['status'] = ['pending', 'failed'] activity_entry = ActivityStream( From cf436eea3758cf9f9ee3a09d413afc5429f9d17c Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 14 Aug 2019 15:10:35 -0400 Subject: [PATCH 35/57] Update RBAC for adding approval nodes --- awx/api/serializers.py | 6 ++++++ awx/api/views/__init__.py | 6 ++++-- awx/main/access.py | 3 --- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b8f64aec2b..082e12bd73 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3659,6 +3659,12 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): res['workflow_job'] = self.reverse('api:workflow_job_detail', kwargs={'pk': obj.workflow_job.pk}) return res + def get_summary_fields(self, obj): + summary_fields = super(WorkflowJobNodeSerializer, self).get_summary_fields(obj) + if isinstance(obj.job, WorkflowApproval): + summary_fields['job']['timed_out'] = obj.job.timed_out + return summary_fields + class WorkflowJobNodeListSerializer(WorkflowJobNodeSerializer): pass diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 726f14295b..fd1a36c0e4 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3026,11 +3026,12 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): return Response(data={'id':approval_template.pk}, status=status.HTTP_200_OK) def check_permissions(self, request): + obj = self.get_object().workflow_job_template if request.method == 'POST': - if request.user not in self.get_object().workflow_job_template.admin_role: + if not request.user.can_access(models.WorkflowJobTemplate, 'change', obj, request.data): self.permission_denied(request) else: - if request.user not in self.get_object().workflow_job_template.read_role: + if not request.user.can_access(models.WorkflowJobTemplate, 'read', obj): self.permission_denied(request) @@ -4487,6 +4488,7 @@ class WorkflowApprovalDeny(RetrieveAPIView): obj.deny(request) return Response(status=status.HTTP_204_NO_CONTENT) + # Placeholder code for approval notification support class WorkflowApprovalNotificationsList(SubListAPIView): diff --git a/awx/main/access.py b/awx/main/access.py index 32f96a2e35..d39ab65b49 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2790,9 +2790,6 @@ class WorkflowApprovalAccess(BaseAccess): model = WorkflowApproval prefetch_related = ('created_by', 'modified_by',) - def can_read(self, obj): - return True - def can_use(self, obj): return True From aac8c9fb048bb4c249c67f7c29f540ac16ddbe49 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 15 Aug 2019 16:21:49 -0400 Subject: [PATCH 36/57] Rename workflow approval migration. Add approval option back to workflow node form. --- ...oval.py => 0084_v360_workflow_approval.py} | 2 +- .../forms/workflow-node-form.controller.js | 691 +++++++++--------- .../forms/workflow-node-form.partial.html | 311 ++++---- 3 files changed, 518 insertions(+), 486 deletions(-) rename awx/main/migrations/{0083_v360_workflow_approval.py => 0084_v360_workflow_approval.py} (98%) diff --git a/awx/main/migrations/0083_v360_workflow_approval.py b/awx/main/migrations/0084_v360_workflow_approval.py similarity index 98% rename from awx/main/migrations/0083_v360_workflow_approval.py rename to awx/main/migrations/0084_v360_workflow_approval.py index eeb73d83b6..2f4f3fc812 100644 --- a/awx/main/migrations/0083_v360_workflow_approval.py +++ b/awx/main/migrations/0084_v360_workflow_approval.py @@ -8,7 +8,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('main', '0082_v360_webhook_http_method'), + ('main', '0083_v360_job_branch_overrirde'), ] operations = [ diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 86c440482f..4bd9590869 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -6,11 +6,11 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q', 'TemplatesStrings', 'CreateSelect2', 'Empty', 'QuerySet', '$filter', - 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', 'ProcessErrors', + 'GetBasePath', 'WorkflowNodeFormService', 'ProcessErrors', 'i18n', 'ParseTypeChange', 'WorkflowJobTemplateModel', function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q, TemplatesStrings, CreateSelect2, Empty, qs, $filter, - GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors, + GetBasePath, WorkflowNodeFormService, ProcessErrors, i18n, ParseTypeChange, WorkflowJobTemplate ) { @@ -32,60 +32,14 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.strings = TemplatesStrings; $scope.editNodeHelpMessage = null; - $scope.pauseNode = {}; - let templateList = _.cloneDeep(TemplateList); - delete templateList.actions; - delete templateList.fields.type; - delete templateList.fields.description; - delete templateList.fields.smart_status; - delete templateList.fields.labels; - delete templateList.fieldActions; - templateList.name = 'wf_maker_templates'; - templateList.iterator = 'wf_maker_template'; - templateList.fields.name.columnClass = "col-md-8"; - templateList.fields.name.tag = i18n._('WORKFLOW'); - templateList.fields.name.showTag = "{{wf_maker_template.type === 'workflow_job_template'}}"; - templateList.disableRow = "{{ readOnly }}"; - templateList.disableRowValue = 'readOnly'; - templateList.basePath = 'unified_job_templates'; - templateList.fields.info = { - ngInclude: "'/static/partials/job-template-details.html'", - type: 'template', - columnClass: 'col-md-3', - infoHeaderClass: 'col-md-3', - label: '', - nosort: true - }; - templateList.maxVisiblePages = 5; - templateList.searchBarFullWidth = true; - $scope.templateList = templateList; - - let inventorySourceList = _.cloneDeep(InventorySourcesList); - inventorySourceList.name = 'wf_maker_inventory_sources'; - inventorySourceList.iterator = 'wf_maker_inventory_source'; - inventorySourceList.maxVisiblePages = 5; - inventorySourceList.searchBarFullWidth = true; - inventorySourceList.disableRow = "{{ readOnly }}"; - inventorySourceList.disableRowValue = 'readOnly'; - $scope.inventorySourceList = inventorySourceList; - - let projectList = _.cloneDeep(ProjectList); - delete projectList.fields.status; - delete projectList.fields.scm_type; - delete projectList.fields.last_updated; - projectList.name = 'wf_maker_projects'; - projectList.iterator = 'wf_maker_project'; - projectList.fields.name.columnClass = "col-md-11"; - projectList.maxVisiblePages = 5; - projectList.searchBarFullWidth = true; - projectList.disableRow = "{{ readOnly }}"; - projectList.disableRowValue = 'readOnly'; - $scope.projectList = projectList; + $scope.templateList = WorkflowNodeFormService.templateListDefinition(); + $scope.inventorySourceList = WorkflowNodeFormService.inventorySourceListDefinition(); + $scope.projectList = WorkflowNodeFormService.projectListDefinition(); const checkCredentialsForRequiredPasswords = () => { let credentialRequiresPassword = false; - $scope.promptData.prompts.credentials.value.forEach((credential) => { + $scope.jobNodeState.promptData.prompts.credentials.value.forEach((credential) => { if ((credential.passwords_needed && credential.passwords_needed.length > 0) || (_.has(credential, 'inputs.vault_password') && @@ -95,86 +49,143 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } }); - $scope.credentialRequiresPassword = credentialRequiresPassword; + $scope.jobNodeState.credentialRequiresPassword = credentialRequiresPassword; }; const watchForPromptChanges = () => { let promptDataToWatch = [ - 'promptData.prompts.inventory.value', - 'promptData.prompts.verbosity.value', - 'missingSurveyValue' + 'jobNodeState.promptData.prompts.inventory.value', + 'jobNodeState.promptData.prompts.verbosity.value', + 'jobNodeState.missingSurveyValue' ]; promptWatcher = $scope.$watchGroup(promptDataToWatch, () => { - const templateType = _.get($scope, 'promptData.templateType'); + const templateType = _.get($scope, 'jobNodeState.promptData.templateType'); let missingPromptValue = false; - if ($scope.missingSurveyValue) { + if ($scope.jobNodeState.missingSurveyValue) { missingPromptValue = true; } if (templateType !== "workflow_job_template") { - if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { + if (!$scope.jobNodeState.promptData.prompts.inventory.value || !$scope.jobNodeState.promptData.prompts.inventory.value.id) { missingPromptValue = true; } } - $scope.promptModalMissingReqFields = missingPromptValue; + $scope.jobNodeState.promptModalMissingReqFields = missingPromptValue; }); - if ($scope.promptData.launchConf.ask_credential_on_launch && $scope.credentialRequiresPassword) { - credentialsWatcher = $scope.$watch('promptData.prompts.credentials', () => { + if ($scope.jobNodeState.promptData.launchConf.ask_credential_on_launch && $scope.jobNodeState.credentialRequiresPassword) { + credentialsWatcher = $scope.$watch('jobNodeState.promptData.prompts.credentials', () => { checkCredentialsForRequiredPasswords(); }); } }; - const finishConfiguringAdd = () => { - $scope.selectedTemplate = null; - $scope.activeTab = "jobs"; - const alwaysOption = { - label: $scope.strings.get('workflow_maker.ALWAYS'), - value: 'always' - }; - const successOption = { - label: $scope.strings.get('workflow_maker.ON_SUCCESS'), - value: 'success' - }; - const failureOption = { - label: $scope.strings.get('workflow_maker.ON_FAILURE'), - value: 'failure' - }; - $scope.edgeTypeOptions = [alwaysOption]; - switch($scope.nodeConfig.newNodeIsRoot) { - case true: - $scope.edgeType = alwaysOption; - break; - case false: - $scope.edgeType = successOption; - $scope.edgeTypeOptions.push(successOption, failureOption); - break; + const clearWatchers = () => { + if (promptWatcher) { + promptWatcher(); } + + if (surveyQuestionWatcher) { + surveyQuestionWatcher(); + } + + if (credentialsWatcher) { + credentialsWatcher(); + } + }; + + const select2ifyDropdowns = () => { + CreateSelect2({ + element: '#workflow-node-types', + multiple: false + }); CreateSelect2({ element: '#workflow_node_edge', multiple: false }); + }; - $scope.nodeFormDataLoaded = true; + const formatPopOverDetails = (model) => { + const popOverDetails = {}; + popOverDetails.playbook = model.playbook || i18n._('NONE SELECTED'); + Object.keys(model.summary_fields).forEach(field => { + if (field === 'project') { + popOverDetails.project = model.summary_fields[field].name || i18n._('NONE SELECTED'); + } + if (field === 'inventory') { + popOverDetails.inventory = model.summary_fields[field].name || i18n._('NONE SELECTED'); + } + if (field === 'credentials') { + if (model.summary_fields[field].length <= 0) { + popOverDetails.credentials = i18n._('NONE SELECTED'); + } + else { + const credentialNames = model.summary_fields[field].map(({name}) => name); + popOverDetails.credentials = credentialNames.join('
'); + } + } + }); + model.popOver = ` + + + + + + + + + + + + + + + + + +
${i18n._('INVENTORY')} ${$filter('sanitize')(popOverDetails.inventory)}
${i18n._('PROJECT')} ${$filter('sanitize')(popOverDetails.project)}
${i18n._('PLAYBOOK')} ${$filter('sanitize')(popOverDetails.playbook)}
${i18n._('CREDENTIAL')} ${$filter('sanitize')(popOverDetails.credentials)}
+ `; + }; + + const updateSelectedRow = () => { + let unifiedJobTemplateId; + switch($scope.activeTab) { + case 'templates': + unifiedJobTemplateId = _.get($scope, 'jobNodeState.selectedTemplate.id') || null; + $scope.wf_maker_templates.forEach((row, i) => { + if (row.type === 'job_template') { + formatPopOverDetails(row); + } + $scope.wf_maker_templates[i].checked = (row.id === unifiedJobTemplateId) ? 1 : 0; + }); + break; + case 'project_syncs': + unifiedJobTemplateId = _.get($scope, 'projectNodeState.selectedTemplate.id') || null; + $scope.wf_maker_projects.forEach((row, i) => { + $scope.wf_maker_projects[i].checked = (row.id === unifiedJobTemplateId) ? 1 : 0; + }); + break; + case 'inventory_syncs': + unifiedJobTemplateId = _.get($scope, 'inventoryNodeState.selectedTemplate.id') || null; + $scope.wf_maker_inventory_sources.forEach((row, i) => { + $scope.wf_maker_inventory_sources[i].checked = (row.id === unifiedJobTemplateId) ? 1 : 0; + }); + break; + } }; const getEditNodeHelpMessage = (selectedTemplate, workflowJobTemplateObj) => { if (selectedTemplate) { if (selectedTemplate.type === "workflow_job_template") { - if (workflowJobTemplateObj.inventory) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); - } + if (workflowJobTemplateObj.inventory && selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); } - if (workflowJobTemplateObj.ask_inventory_on_launch) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); - } + if (workflowJobTemplateObj.ask_inventory_on_launch && selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); } } @@ -210,19 +221,19 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService let jobTemplate = templateType === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); if (_.get($scope, 'nodeConfig.node.promptData') && !_.isEmpty($scope.nodeConfig.node.promptData)) { - $scope.promptData = _.cloneDeep($scope.nodeConfig.node.promptData); - const launchConf = $scope.promptData.launchConf; + $scope.jobNodeState.promptData = _.cloneDeep($scope.nodeConfig.node.promptData); + const launchConf = $scope.jobNodeState.promptData.launchConf; if (!shouldShowPromptButton(launchConf)) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; + $scope.jobNodeState.showPromptButton = false; + $scope.jobNodeState.promptModalMissingReqFields = false; } else { - $scope.showPromptButton = true; + $scope.jobNodeState.showPromptButton = true; if (templateType !== "workflow_job_template" && launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { - $scope.promptModalMissingReqFields = true; + $scope.jobNodeState.promptModalMissingReqFields = true; } else { - $scope.promptModalMissingReqFields = false; + $scope.jobNodeState.promptModalMissingReqFields = false; } } watchForPromptChanges(); @@ -286,9 +297,9 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService ((!$scope.nodeConfig.node.fullUnifiedJobTemplateObject.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeConfig.node.fullUnifiedJobTemplateObject.project) ) { - $scope.selectedTemplateInvalid = true; + $scope.jobNodeState.selectedTemplateInvalid = true; } else { - $scope.selectedTemplateInvalid = false; + $scope.jobNodeState.selectedTemplateInvalid = false; } let credentialRequiresPassword = false; @@ -307,19 +318,19 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } }); - $scope.credentialRequiresPassword = credentialRequiresPassword; + $scope.jobNodeState.credentialRequiresPassword = credentialRequiresPassword; if (!shouldShowPromptButton(launchConf)) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; + $scope.jobNodeState.showPromptButton = false; + $scope.jobNodeState.promptModalMissingReqFields = false; $scope.nodeFormDataLoaded = true; } else { - $scope.showPromptButton = true; + $scope.jobNodeState.showPromptButton = true; if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { - $scope.promptModalMissingReqFields = true; + $scope.jobNodeState.promptModalMissingReqFields = true; } else { - $scope.promptModalMissingReqFields = false; + $scope.jobNodeState.promptModalMissingReqFields = false; } if (responses[1].data.survey_enabled) { @@ -332,7 +343,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService extra_data: jsyaml.safeLoad(prompts.variables.value) }); - $scope.missingSurveyValue = processed.missingSurveyValue; + $scope.jobNodeState.missingSurveyValue = processed.missingSurveyValue; $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); @@ -351,14 +362,14 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id }; - surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { + surveyQuestionWatcher = $scope.$watch('jobNodeState.promptData.surveyQuestions', () => { let missingSurveyValue = false; - _.each($scope.promptData.surveyQuestions, (question) => { + _.each($scope.jobNodeState.promptData.surveyQuestions, (question) => { if (question.required && (Empty(question.model) || question.model === [])) { missingSurveyValue = true; } }); - $scope.missingSurveyValue = missingSurveyValue; + $scope.jobNodeState.missingSurveyValue = missingSurveyValue; }, true); checkCredentialsForRequiredPasswords(); @@ -368,7 +379,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.nodeFormDataLoaded = true; }); } else { - $scope.nodeConfig.node.promptData = $scope.promptData = { + $scope.nodeConfig.node.promptData = $scope.jobNodeState.promptData = { launchConf: launchConf, launchOptions: launchOptions, prompts: prompts, @@ -389,39 +400,46 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject')) { - $scope.selectedTemplate = $scope.nodeConfig.node.fullUnifiedJobTemplateObject; + const selectedTemplate = $scope.nodeConfig.node.fullUnifiedJobTemplateObject; - if ($scope.selectedTemplate.unified_job_type) { - switch ($scope.selectedTemplate.unified_job_type) { + if (selectedTemplate.unified_job_type) { + switch (selectedTemplate.unified_job_type) { case "job": - $scope.activeTab = "jobs"; + $scope.activeTab = "templates"; + $scope.jobNodeState.selectedTemplate = selectedTemplate; break; case "project_update": $scope.activeTab = "project_syncs"; + $scope.projectNodeState.selectedTemplate = selectedTemplate; break; case "inventory_update": $scope.activeTab = "inventory_syncs"; + $scope.inventoryNodeState.selectedTemplate = selectedTemplate; break; } - } else if ($scope.selectedTemplate.type) { - switch ($scope.selectedTemplate.type) { + } else if (selectedTemplate.type) { + switch (selectedTemplate.type) { case "job_template": - $scope.activeTab = "jobs"; - break; case "workflow_job_template": - $scope.activeTab = "jobs"; + $scope.activeTab = "templates"; + $scope.jobNodeState.selectedTemplate = selectedTemplate; break; case "project": $scope.activeTab = "project_syncs"; + $scope.projectNodeState.selectedTemplate = selectedTemplate; break; case "inventory_source": $scope.activeTab = "inventory_syncs"; + $scope.inventoryNodeState.selectedTemplate = selectedTemplate; break; } } + updateSelectedRow(); } else { - $scope.activeTab = "jobs"; + $scope.activeTab = "templates"; } + + select2ifyDropdowns(); } else { $scope.jobTags = $scope.nodeConfig.node.originalNodeObject.job_tags ? $scope.nodeConfig.node.originalNodeObject.job_tags.split(',').map((tag) => (tag)) : []; $scope.skipTags = $scope.nodeConfig.node.originalNodeObject.skip_tags ? $scope.nodeConfig.node.originalNodeObject.skip_tags.split(',').map((tag) => (tag)) : []; @@ -449,121 +467,30 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }; - const templateManuallySelected = (selectedTemplate) => { - - if (promptWatcher) { - promptWatcher(); - } - - if (surveyQuestionWatcher) { - surveyQuestionWatcher(); - } - - if (credentialsWatcher) { - credentialsWatcher(); - } - - $scope.promptData = null; - $scope.pauseNode = {}; - $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj); - - if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { - let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); - - $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) - .then((responses) => { - let launchConf = responses[1].data; - - let credentialRequiresPassword = false; - let selectedTemplateInvalid = false; - - if (selectedTemplate.type !== "workflow_job_template") { - if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { - selectedTemplateInvalid = true; - } - - if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { - credentialRequiresPassword = true; - } - } - - $scope.credentialRequiresPassword = credentialRequiresPassword; - $scope.selectedTemplateInvalid = selectedTemplateInvalid; - $scope.selectedTemplate = angular.copy(selectedTemplate); - - if (!shouldShowPromptButton(launchConf)) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - } else { - $scope.showPromptButton = true; - $scope.promptModalMissingReqFields = false; - - if (selectedTemplate.type !== "workflow_job_template") { - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { - $scope.promptModalMissingReqFields = true; - } - } - - if (launchConf.survey_enabled) { - // go out and get the survey questions - jobTemplate.getSurveyQuestions(selectedTemplate.id) - .then((surveyQuestionRes) => { - - let processed = PromptService.processSurveyQuestions({ - surveyQuestions: surveyQuestionRes.data.spec - }); - - $scope.missingSurveyValue = processed.missingSurveyValue; - - $scope.promptData = { - launchConf: responses[1].data, - launchOptions: responses[0].data, - surveyQuestions: processed.surveyQuestions, - template: selectedTemplate.id, - templateType: selectedTemplate.type, - prompts: PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data - }), - }; - - surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { - let missingSurveyValue = false; - _.each($scope.promptData.surveyQuestions, (question) => { - if (question.required && (Empty(question.model) || question.model === [])) { - missingSurveyValue = true; - } - }); - $scope.missingSurveyValue = missingSurveyValue; - }, true); - - watchForPromptChanges(); - }); - } else { - $scope.promptData = { - launchConf: responses[1].data, - launchOptions: responses[0].data, - template: selectedTemplate.id, - templateType: selectedTemplate.type, - prompts: PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data - }), - }; - - watchForPromptChanges(); - } - } - }); - } else { - $scope.selectedTemplate = angular.copy(selectedTemplate); - $scope.selectedTemplateInvalid = false; - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - } - }; - const setupNodeForm = () => { + $scope.jobNodeState = { + credentialRequiresPassword: false, + missingSurveyValue: false, + promptData: null, + promptModalMissingReqFields: false, + searchTags: [], + selectedTemplate: null, + selectedTemplateInvalid: false, + showPromptButton: false + }; + $scope.projectNodeState = { + searchTags: [], + selectedTemplate: null + }; + $scope.inventoryNodeState = { + searchTags: [], + selectedTemplate: null, + }; + $scope.approvalNodeState = { + name: null, + description: null, + timeout: 0 + }; $scope.nodeFormDataLoaded = false; $scope.wf_maker_template_queryset = { page_size: '10', @@ -618,32 +545,23 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }) ); - CreateSelect2({ - element: '#workflow-node-types', - multiple: false - }); - $q.all(listPromises) .then(() => { if ($scope.nodeConfig.mode === "edit") { if ($scope.nodeConfig.node.unifiedJobTemplate && $scope.nodeConfig.node.unifiedJobTemplate.unified_job_type === "workflow_approval") { - $scope.selectedTemplate = null; - $scope.activeTab = "pause"; - CreateSelect2({ - element: '#workflow_node_edge', - multiple: false - }); + $scope.activeTab = "approval"; + select2ifyDropdowns(); - $scope.pauseNode = { - isPauseNode: true, + $scope.approvalNodeState = { name: $scope.nodeConfig.node.unifiedJobTemplate.name, description: $scope.nodeConfig.node.unifiedJobTemplate.description, + timeout: $scope.nodeConfig.node.unifiedJobTemplate.timeout }; - + $scope.nodeFormDataLoaded = true; } else { // Make sure that we have the full unified job template object - if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject) { + if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject && _.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.unified_job_template')) { // This is a node that we got back from the api with an incomplete // unified job template so we're going to pull down the whole object TemplatesService.getUnifiedJobTemplate($scope.nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.id) @@ -662,104 +580,211 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } } } else { - finishConfiguringAdd(); + $scope.activeTab = "templates"; + const alwaysOption = { + label: $scope.strings.get('workflow_maker.ALWAYS'), + value: 'always' + }; + const successOption = { + label: $scope.strings.get('workflow_maker.ON_SUCCESS'), + value: 'success' + }; + const failureOption = { + label: $scope.strings.get('workflow_maker.ON_FAILURE'), + value: 'failure' + }; + $scope.edgeTypeOptions = [alwaysOption]; + switch($scope.nodeConfig.newNodeIsRoot) { + case true: + $scope.edgeType = alwaysOption; + break; + case false: + $scope.edgeType = successOption; + $scope.edgeTypeOptions.push(successOption, failureOption); + break; + } + select2ifyDropdowns(); + + $scope.nodeFormDataLoaded = true; } }); }; - const formatPopOverDetails = (model) => { - const popOverDetails = {}; - popOverDetails.playbook = model.playbook || i18n._('NONE SELECTED'); - Object.keys(model.summary_fields).forEach(field => { - if (field === 'project') { - popOverDetails.project = model.summary_fields[field].name || i18n._('NONE SELECTED'); - } - if (field === 'inventory') { - popOverDetails.inventory = model.summary_fields[field].name || i18n._('NONE SELECTED'); - } - if (field === 'credentials') { - if (model.summary_fields[field].length <= 0) { - popOverDetails.credentials = i18n._('NONE SELECTED'); - } - else { - const credentialNames = model.summary_fields[field].map(({name}) => name); - popOverDetails.credentials = credentialNames.join('
'); - } - } - }); - model.popOver = ` -
-
${i18n._('INVENTORY')}
-
${$filter('sanitize')(popOverDetails.inventory)}
-
-
-
${i18n._('PROJECT')}
-
${$filter('sanitize')(popOverDetails.project)}
-
-
-
${i18n._('PLAYBOOK')}
-
${$filter('sanitize')(popOverDetails.playbook)}
-
-
-
${i18n._('CREDENTIAL')}
-
${$filter('sanitize')(popOverDetails.credentials)}
-
- `; + $scope.confirmNodeForm = () => { + const nodeFormData = { + edgeType: $scope.edgeType + }; + + if ($scope.activeTab === "approval") { + nodeFormData.selectedTemplate = { + name: $scope.approvalNodeState.name, + description: $scope.approvalNodeState.description, + timeout: $scope.approvalNodeState.timeout, + unified_job_type: "workflow_approval" + }; + } else if($scope.activeTab === "templates") { + nodeFormData.selectedTemplate = $scope.jobNodeState.selectedTemplate; + nodeFormData.promptData = $scope.jobNodeState.promptData; + } else if($scope.activeTab === "project_syncs") { + nodeFormData.selectedTemplate = $scope.projectNodeState.selectedTemplate; + } else if($scope.activeTab === "inventory_syncs") { + nodeFormData.selectedTemplate = $scope.inventoryNodeState.selectedTemplate; + } + + $scope.select({ nodeFormData }); }; $scope.openPromptModal = () => { - $scope.promptData.triggerModalOpen = true; + $scope.jobNodeState.promptData.triggerModalOpen = true; }; - $scope.toggle_row = (selectedRow) => { + $scope.selectIsDisabled = () => { + if($scope.activeTab === "templates") { + return !($scope.jobNodeState.selectedTemplate) || + $scope.jobNodeState.promptModalMissingReqFields || + $scope.jobNodeState.credentialRequiresPassword || + $scope.jobNodeState.selectedTemplateInvalid; + } else if($scope.activeTab === "project_syncs") { + return !$scope.projectNodeState.selectedTemplate; + } else if($scope.activeTab === "inventory_syncs") { + return !$scope.inventoryNodeState.selectedTemplate; + } else if ($scope.activeTab === "approval") { + return !($scope.approvalNodeState.name && $scope.approvalNodeState.name !== "") || $scope.workflow_approval.pauseTimeout.$error.min; + } + }; + + $scope.selectTemplate = (selectedTemplate) => { if (!$scope.readOnly) { - templateManuallySelected(selectedRow); + clearWatchers(); + + $scope.approvalNodeState = { + name: null, + description: null, + timeout: 0 + }; + $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj); + + if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { + let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); + $scope.jobNodeState.promptData = null; + + $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) + .then((responses) => { + let launchConf = responses[1].data; + + let credentialRequiresPassword = false; + let selectedTemplateInvalid = false; + + if (selectedTemplate.type !== "workflow_job_template") { + if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { + selectedTemplateInvalid = true; + } + + if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { + credentialRequiresPassword = true; + } + } + + $scope.jobNodeState.credentialRequiresPassword = credentialRequiresPassword; + $scope.jobNodeState.selectedTemplateInvalid = selectedTemplateInvalid; + $scope.jobNodeState.selectedTemplate = angular.copy(selectedTemplate); + updateSelectedRow(); + + if (!launchConf.survey_enabled && + !launchConf.ask_inventory_on_launch && + !launchConf.ask_credential_on_launch && + !launchConf.ask_verbosity_on_launch && + !launchConf.ask_job_type_on_launch && + !launchConf.ask_limit_on_launch && + !launchConf.ask_tags_on_launch && + !launchConf.ask_skip_tags_on_launch && + !launchConf.ask_diff_mode_on_launch && + !launchConf.credential_needed_to_start && + !launchConf.ask_variables_on_launch && + launchConf.variables_needed_to_start.length === 0) { + $scope.jobNodeState.showPromptButton = false; + $scope.jobNodeState.promptModalMissingReqFields = false; + } else { + $scope.jobNodeState.showPromptButton = true; + $scope.jobNodeState.promptModalMissingReqFields = false; + + if (selectedTemplate.type !== "workflow_job_template") { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { + $scope.jobNodeState.promptModalMissingReqFields = true; + } + } + + if (launchConf.survey_enabled) { + // go out and get the survey questions + jobTemplate.getSurveyQuestions(selectedTemplate.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec + }); + + $scope.jobNodeState.missingSurveyValue = processed.missingSurveyValue; + + $scope.jobNodeState.promptData = { + launchConf: responses[1].data, + launchOptions: responses[0].data, + surveyQuestions: processed.surveyQuestions, + template: selectedTemplate.id, + templateType: selectedTemplate.type, + prompts: PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data + }), + }; + + surveyQuestionWatcher = $scope.$watch('jobNodeState.promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.jobNodeState.promptData.surveyQuestions, (question) => { + if (question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.jobNodeState.missingSurveyValue = missingSurveyValue; + }, true); + + watchForPromptChanges(); + }); + } else { + $scope.jobNodeState.promptData = { + launchConf: responses[1].data, + launchOptions: responses[0].data, + template: selectedTemplate.id, + templateType: selectedTemplate.type, + prompts: PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data + }), + }; + + watchForPromptChanges(); + } + } + }); + } else { + if (selectedTemplate.type === "project") { + $scope.projectNodeState.selectedTemplate = angular.copy(selectedTemplate); + } else if (selectedTemplate.type === "inventory_source") { + $scope.inventoryNodeState.selectedTemplate = angular.copy(selectedTemplate); + } + updateSelectedRow(); + } } }; $scope.$watch('nodeConfig.nodeId', (newNodeId, oldNodeId) => { if (newNodeId !== oldNodeId) { + clearWatchers(); setupNodeForm(); } }); - $scope.$watchGroup(['wf_maker_templates', 'wf_maker_projects', 'wf_maker_inventory_sources', 'activeTab', 'selectedTemplate.id'], () => { - const unifiedJobTemplateId = _.get($scope, 'selectedTemplate.id') || null; - switch($scope.activeTab) { - case 'jobs': - $scope.wf_maker_templates.forEach((row, i) => { - if (row.type === 'job_template') { - formatPopOverDetails(row); - } - if(row.id === unifiedJobTemplateId) { - $scope.wf_maker_templates[i].checked = 1; - } - else { - $scope.wf_maker_templates[i].checked = 0; - } - }); - break; - case 'project_syncs': - $scope.wf_maker_projects.forEach((row, i) => { - if(row.id === unifiedJobTemplateId) { - $scope.wf_maker_projects[i].checked = 1; - } - else { - $scope.wf_maker_projects[i].checked = 0; - } - }); - break; - case 'inventory_syncs': - $scope.wf_maker_inventory_sources.forEach((row, i) => { - if(row.id === unifiedJobTemplateId) { - $scope.wf_maker_inventory_sources[i].checked = 1; - } - else { - $scope.wf_maker_inventory_sources[i].checked = 0; - } - }); - break; - } + $scope.$watchGroup(['wf_maker_templates', 'wf_maker_projects', 'wf_maker_inventory_sources', 'activeTab'], () => { + updateSelectedRow(); }); setupNodeForm(); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index b01876a044..88626d9fdc 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -8,38 +8,38 @@ name="activeTab" aria-hidden="true" > - + - +
-
-
- +
+
+
-
+
No records matched your search.
-
PLEASE ADD ITEMS TO THIS LIST
+
PLEASE ADD ITEMS TO THIS LIST
-
+
- +
-
+
{{wf_maker_template.name}} {{:: strings.get('workflow_maker.WORKFLOW') }} @@ -53,29 +53,29 @@
-
- +
+
-
+
No records matched your search.
-
No Projects Have Been Created
+
No Projects Have Been Created
-
+
- +
-
{{ wf_maker_project.name }}
+
{{ wf_maker_project.name }}
@@ -83,67 +83,70 @@
-
- +
+
-
+
No records matched your search.
-
PLEASE ADD ITEMS TO THIS LIST
+
PLEASE ADD ITEMS TO THIS LIST
-
+
- +
-
{{ wf_maker_inventory_source.name }}
+
{{ wf_maker_inventory_source.name }}
-
- -
-
- -
- -
+
+
+ -
-
- -
- -
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
Please enter a number greater than or equal to 0.
-
+
-
+
{{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} @@ -154,101 +157,105 @@ {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}
-
-
- -
- -
-
-
-
- {{:: strings.get('workflow_maker.READ_ONLY_PROMPT_VALUES')}} -
-
- {{:: strings.get('workflow_maker.READ_ONLY_NO_PROMPT_VALUES')}} -
-
-
{{:: strings.get('prompt.JOB_TYPE') }}
-
- {{:: strings.get('prompt.PLAYBOOK_RUN') }} - {{:: strings.get('prompt.CHECK') }} +
+
+ + {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}
-
-
{{:: strings.get('prompt.INVENTORY') }}
-
-
-
-
{{:: strings.get('prompt.LIMIT') }}
-
-
-
-
{{:: strings.get('prompt.VERBOSITY') }}
-
-
-
-
- {{:: strings.get('prompt.JOB_TAGS') }}  - - - - +
+ +
+
-
-
-
- {{tag}} +
+
+
+ {{:: strings.get('workflow_maker.READ_ONLY_PROMPT_VALUES')}} +
+
+ {{:: strings.get('workflow_maker.READ_ONLY_NO_PROMPT_VALUES')}} +
+
+
{{:: strings.get('prompt.JOB_TYPE') }}
+
+ {{:: strings.get('prompt.PLAYBOOK_RUN') }} + {{:: strings.get('prompt.CHECK') }} +
+
+
+
{{:: strings.get('prompt.INVENTORY') }}
+
+
+
+
{{:: strings.get('prompt.LIMIT') }}
+
+
+
+
{{:: strings.get('prompt.VERBOSITY') }}
+
+
+
+
+ {{:: strings.get('prompt.JOB_TAGS') }}  + + + + +
+
+
+
+ {{tag}} +
-
-
-
- {{:: strings.get('prompt.SKIP_TAGS') }}  - - - - -
-
-
-
- {{tag}} +
+
+ {{:: strings.get('prompt.SKIP_TAGS') }}  + + + + +
+
+
+
+ {{tag}} +
@@ -256,8 +263,8 @@
{{:: strings.get('prompt.SHOW_CHANGES') }}
- {{:: strings.get('ON') }} - {{:: strings.get('OFF') }} + {{:: strings.get('ON') }} + {{:: strings.get('OFF') }}
@@ -269,16 +276,16 @@
+
+
+
+
+ + + + +
+
-
-
-
- - - - - -
-
\ No newline at end of file From dd89e46ee6e46388aa25701ff89224837018632f Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 15 Aug 2019 15:52:29 -0400 Subject: [PATCH 37/57] change up a few activity stream and approval drawer issues --- awx/api/serializers.py | 11 ++++++++++ awx/main/models/workflow.py | 22 ------------------- awx/main/signals.py | 2 +- .../approvalsDrawer.partial.html | 12 ++++++++-- .../factories/build-anchor.factory.js | 2 +- .../factories/build-description.factory.js | 8 ++++++- 6 files changed, 30 insertions(+), 27 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 082e12bd73..3dd5d017c8 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3441,6 +3441,17 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer): return res +class WorkflowApprovalActivityStreamSerializer(WorkflowApprovalSerializer): + """ + timed_out and status are usually read-only fields + However, when we generate an activity stream record, we *want* to record + these types of changes. This serializer allows us to do so. + """ + status = serializers.ChoiceField(choices=JobTemplate.JOB_TEMPLATE_STATUS_CHOICES) + timed_out = serializers.BooleanField() + + + class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer): class Meta: diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index c2b9427df9..840a556262 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -681,38 +681,16 @@ class WorkflowApproval(UnifiedJob): return 'workflow_approval_template' def approve(self, request=None): - from awx.main.signals import model_serializer_mapping # circular import self.status = 'successful' self.save() self.websocket_emit_status(self.status) - changes = model_to_dict(self, model_serializer_mapping()) - changes['status'] = ['pending', 'successful'] - activity_entry = ActivityStream( - operation='update', - object1='workflow_approval', - actor=request.user, - changes=json.dumps(changes), - ) - activity_entry.save() - getattr(activity_entry, 'workflow_approval').add(self.pk) schedule_task_manager() return reverse('api:workflow_approval_approve', kwargs={'pk': self.pk}, request=request) def deny(self, request=None): - from awx.main.signals import model_serializer_mapping # circular import self.status = 'failed' self.save() self.websocket_emit_status(self.status) - changes = model_to_dict(self, model_serializer_mapping()) - changes['status'] = ['pending', 'failed'] - activity_entry = ActivityStream( - operation='update', - object1='workflow_approval', - actor=request.user, - changes=json.dumps(changes), - ) - activity_entry.save() - getattr(activity_entry, 'workflow_approval').add(self.pk) schedule_task_manager() return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/signals.py b/awx/main/signals.py index 627711f530..a655c19b3b 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -430,7 +430,7 @@ def model_serializer_mapping(): models.Label: serializers.LabelSerializer, models.WorkflowJobTemplate: serializers.WorkflowJobTemplateWithSpecSerializer, models.WorkflowJobTemplateNode: serializers.WorkflowJobTemplateNodeSerializer, - models.WorkflowApproval: serializers.WorkflowApprovalSerializer, + models.WorkflowApproval: serializers.WorkflowApprovalActivityStreamSerializer, models.WorkflowApprovalTemplate: serializers.WorkflowApprovalTemplateSerializer, models.WorkflowJob: serializers.WorkflowJobSerializer, models.OAuth2AccessToken: serializers.OAuth2TokenSerializer, diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html index 01ff402042..7ef3b29443 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html @@ -30,11 +30,19 @@
+
+ + + + +
@@ -78,4 +86,4 @@ hide-view-per-page="true">
-
\ No newline at end of file +
diff --git a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js index 09992c6029..7063f0110a 100644 --- a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js @@ -95,7 +95,7 @@ export default function BuildAnchor($log, $filter) { break; case 'workflow_approval': url += `workflows/${activity.summary_fields.workflow_job[0].id}`; - name = activity.summary_fields.workflow_job[0].name; + name = activity.summary_fields.workflow_job[0].name + ' | ' + activity.summary_fields.workflow_approval[0].name; break; default: url += resource + 's/' + obj.id + '/'; diff --git a/awx/ui/client/src/activity-stream/factories/build-description.factory.js b/awx/ui/client/src/activity-stream/factories/build-description.factory.js index ba82d5250f..4e25e21b48 100644 --- a/awx/ui/client/src/activity-stream/factories/build-description.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-description.factory.js @@ -132,7 +132,13 @@ export default function BuildDescription(BuildAnchor, $log, i18n) { if (activity.changes.status[1] === 'successful') { operationText = i18n._('approved'); } else if (activity.changes.status[1] === 'failed') { - operationText = i18n._('denied'); + if (activity.changes.timed_out && activity.changes.timed_out[1] === true) { + operationText = i18n._('timed out'); + } else { + operationText = i18n._('denied'); + } + } else { + operationText = i18n._('updated'); } activity.description = `${operationText} ${activity.object1} ${BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity)}`; } else { From 667fce5012b2520e33fbb4f4a1bcd602a933d0eb Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 16 Aug 2019 16:00:18 -0400 Subject: [PATCH 38/57] Fix flake8 errors, update doc strings, ... ... and return full object details when doing a POST to create new approval nodes. --- awx/api/urls/workflow_approval.py | 2 -- awx/api/views/__init__.py | 20 +++++++------------ awx/main/access.py | 33 ++++++++++++++++++------------- awx/main/models/workflow.py | 4 +--- 4 files changed, 27 insertions(+), 32 deletions(-) diff --git a/awx/api/urls/workflow_approval.py b/awx/api/urls/workflow_approval.py index 682111b8da..dc58da1d3a 100644 --- a/awx/api/urls/workflow_approval.py +++ b/awx/api/urls/workflow_approval.py @@ -8,7 +8,6 @@ from awx.api.views import ( WorkflowApprovalDetail, WorkflowApprovalApprove, WorkflowApprovalDeny, - WorkflowApprovalNotificationsList, ) @@ -17,7 +16,6 @@ urls = [ url(r'^(?P[0-9]+)/$', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'), url(r'^(?P[0-9]+)/approve/$', WorkflowApprovalApprove.as_view(), name='workflow_approval_approve'), url(r'^(?P[0-9]+)/deny/$', WorkflowApprovalDeny.as_view(), name='workflow_approval_deny'), - url(r'^(?P[0-9]+)/notifications/$', WorkflowApprovalNotificationsList.as_view(), name='workflow_approval_notifications_list'), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index fd1a36c0e4..cde1ccc6bc 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3018,12 +3018,16 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): serializer_class = serializers.WorkflowJobTemplateNodeCreateApprovalSerializer def post(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + obj = self.get_object() + serializer = self.get_serializer(instance=obj, data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - obj = self.get_object() approval_template = obj.create_approval_template(**serializer.validated_data) - return Response(data={'id':approval_template.pk}, status=status.HTTP_200_OK) + data = serializers.WorkflowApprovalTemplateSerializer( + approval_template, + context=self.get_serializer_context() + ).data + return Response(data, status=status.HTTP_200_OK) def check_permissions(self, request): obj = self.get_object().workflow_job_template @@ -4487,13 +4491,3 @@ class WorkflowApprovalDeny(RetrieveAPIView): return Response("This workflow step has already been approved or denied.", status=status.HTTP_400_BAD_REQUEST) obj.deny(request) return Response(status=status.HTTP_204_NO_CONTENT) - - -# Placeholder code for approval notification support -class WorkflowApprovalNotificationsList(SubListAPIView): - - model = models.Notification - serializer_class = serializers.NotificationSerializer - parent_model = models.WorkflowApproval - relationship = 'notifications' - search_fields = ('subject', 'notification_type', 'body',) diff --git a/awx/main/access.py b/awx/main/access.py index d39ab65b49..7533db162e 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -134,7 +134,7 @@ def check_user_access_with_errors(user, model_class, action, *args, **kwargs): access_instance = access_class(user, save_messages=True) access_method = getattr(access_instance, 'can_%s' % action, None) result = access_method(*args, **kwargs) - logger.error('%s.%s %r returned %r', access_instance.__class__.__name__, + logger.debug('%s.%s %r returned %r', access_instance.__class__.__name__, access_method.__name__, args, result) return (result, access_instance.messages) @@ -2781,10 +2781,15 @@ class RoleAccess(BaseAccess): class WorkflowApprovalAccess(BaseAccess): ''' - I can approve workflows when: - - I'm authenticated - I can create when: - - I'm a superuser: + A user can create an approval template if they are a superuser, an org admin + of the org connected to the workflow, or if they are assigned as admins to + the workflow. + + A user can approve a workflow when they are: + - a superuser + - a workflow admin + - an organization admin + - any user who has explicitly been assigned the "approver" role ''' model = WorkflowApproval @@ -2810,10 +2815,15 @@ class WorkflowApprovalAccess(BaseAccess): class WorkflowApprovalTemplateAccess(BaseAccess): ''' - I can create approval nodes when: - - - I can approve workflows when: - - + A user can create an approval template if they are a superuser, an org admin + of the org connected to the workflow, or if they are assigned as admins to + the workflow. + + A user can approve a workflow when they are: + - a superuser + - a workflow admin + - an organization admin + - any user who has explicitly been assigned the "approver" role ''' model = WorkflowApprovalTemplate @@ -2821,11 +2831,6 @@ class WorkflowApprovalTemplateAccess(BaseAccess): @check_superuser def can_add(self, data): - ''' - A user can create an approval template if they are a superuser, an org admin - of the org connected to the workflow, or if they are assigned as admins to - the workflow. - ''' if data is None: # Hide direct creation in API browser return False else: diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 840a556262..99c76ff77f 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -2,7 +2,6 @@ # All Rights Reserved. # Python -import json import logging # Django @@ -32,11 +31,10 @@ from awx.main.models.mixins import ( RelatedJobsMixin, ) from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate -from awx.main.models.activity_stream import ActivityStream from awx.main.models.credential import Credential from awx.main.redact import REPLACE_STR from awx.main.fields import JSONField -from awx.main.utils import model_to_dict, schedule_task_manager +from awx.main.utils import schedule_task_manager from copy import copy From aab04bcbb181cd5c6c9ffc78ea3e29442f0981bb Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 19 Aug 2019 09:39:11 -0400 Subject: [PATCH 39/57] Fix accidental deletions, update docstrings... ... and update migration file for rebase. --- awx/main/access.py | 8 ++++++-- ...orkflow_approval.py => 0085_v360_workflow_approval.py} | 2 +- awx/main/models/notifications.py | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) rename awx/main/migrations/{0084_v360_workflow_approval.py => 0085_v360_workflow_approval.py} (98%) diff --git a/awx/main/access.py b/awx/main/access.py index 7533db162e..63b826791f 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2781,7 +2781,7 @@ class RoleAccess(BaseAccess): class WorkflowApprovalAccess(BaseAccess): ''' - A user can create an approval template if they are a superuser, an org admin + A user can create an workflow approval if they are a superuser, an org admin of the org connected to the workflow, or if they are assigned as admins to the workflow. @@ -2790,6 +2790,8 @@ class WorkflowApprovalAccess(BaseAccess): - a workflow admin - an organization admin - any user who has explicitly been assigned the "approver" role + + A user can see approvals if they have read access to the associated WorkflowJobTemplate. ''' model = WorkflowApproval @@ -2823,7 +2825,9 @@ class WorkflowApprovalTemplateAccess(BaseAccess): - a superuser - a workflow admin - an organization admin - - any user who has explicitly been assigned the "approver" role + - any user who has explicitly been assigned the "approver" role at the workflow or organization level + + A user can see approval templates if they have read access to the associated WorkflowJobTemplate. ''' model = WorkflowApprovalTemplate diff --git a/awx/main/migrations/0084_v360_workflow_approval.py b/awx/main/migrations/0085_v360_workflow_approval.py similarity index 98% rename from awx/main/migrations/0084_v360_workflow_approval.py rename to awx/main/migrations/0085_v360_workflow_approval.py index 2f4f3fc812..a63740ac89 100644 --- a/awx/main/migrations/0084_v360_workflow_approval.py +++ b/awx/main/migrations/0085_v360_workflow_approval.py @@ -8,7 +8,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('main', '0083_v360_job_branch_overrirde'), + ('main', '0084_v360_token_description'), ] operations = [ diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index b1d902de90..677991c88e 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -465,7 +465,6 @@ class JobNotificationMixin(object): from awx.main.tasks import send_notifications # avoid circular import if status not in ['running', 'succeeded', 'failed']: raise ValueError(_("status must be either running, succeeded or failed")) - try: notification_templates = self.get_notification_templates() except Exception: From 9bbc14c5a1819740b4804c4abba3d7568bd5764e Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 19 Aug 2019 14:45:26 -0400 Subject: [PATCH 40/57] Update AWX docs to include info about wf approvals --- awx/main/access.py | 4 ++-- docs/workflow.md | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 63b826791f..e81b69e16b 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2781,7 +2781,7 @@ class RoleAccess(BaseAccess): class WorkflowApprovalAccess(BaseAccess): ''' - A user can create an workflow approval if they are a superuser, an org admin + A user can create a workflow approval if they are a superuser, an org admin of the org connected to the workflow, or if they are assigned as admins to the workflow. @@ -2817,7 +2817,7 @@ class WorkflowApprovalAccess(BaseAccess): class WorkflowApprovalTemplateAccess(BaseAccess): ''' - A user can create an approval template if they are a superuser, an org admin + A user can create a workflow approval if they are a superuser, an org admin of the org connected to the workflow, or if they are assigned as admins to the workflow. diff --git a/docs/workflow.md b/docs/workflow.md index 8043b0f37e..cedc8cd2f9 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -54,6 +54,32 @@ In the event that spawning the workflow would result in recursion, the child wor will be marked as failed with a message explaining that recursion was detected. This is to prevent saturation of the task system with an infinite chain of workflows. +#### Workflow Approval Nodes + +The workflow approval node feature enables users to add steps in a workflow in between nodes within workflows so that a user (as long as they have approval permissions, explained in further detail below) can give the "yes" or "no" to continue on to the next step in the workflow. + +**RBAC Setup for Workflow Approval Nodes** + +A user can _create_ a workflow approval if they are: +- a Superuser +- an Org Admin of the organization connected to the workflow +- a Workflow Admin in the organization connected to the workflow +- assigned as admins to a particular workflow + +A user can _approve_ a workflow when they are: +- a Superuser +- a Workflow Admin +- an Organization Admin +- any user who has explicitly been assigned the "approver" role + +A user can _view_ approvals if they: +- have Read access to the associated Workflow Job Template + +**Other Workflow Approval Node Features** + +A timeout can be set for each approval node. This field defaults to `0` for no expiration. + + ### DAG Formation and Restrictions The DAG structure of a workflow is enforced by associating workflow job template nodes via endpoints `/workflow_job_template_nodes/\d+/*_nodes/`, where `*` has options `success`, `failure` and `always`. There is one restriction that is enforced when setting up new connections and that is the cycle restriction, since it's a DAG. From 5fc3b2c3f5f094301dcb288cefd13d2076bc39db Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 19 Aug 2019 16:10:18 -0400 Subject: [PATCH 41/57] Add timed out text to workflow job node. Change timeout to minutes and seconds. Remove workflow template badge in approvals drawer. --- .../client/features/jobs/routes/jobs.route.js | 1 - .../features/templates/templates.strings.js | 3 +- .../components/approvalsDrawer/_index.less | 1 + .../approvalsDrawer.partial.html | 3 +- .../client/lib/components/layout/_index.less | 28 ++++++++++++------- .../lib/components/layout/layout.partial.html | 4 +-- .../workflow-chart/workflow-chart.block.less | 8 ++++++ .../workflow-chart.directive.js | 12 ++++++++ .../forms/workflow-node-form.controller.js | 18 ++++++++---- .../forms/workflow-node-form.partial.html | 13 +++++++-- .../workflow-maker/workflow-maker.block.less | 15 +++++++--- 11 files changed, 78 insertions(+), 28 deletions(-) diff --git a/awx/ui/client/features/jobs/routes/jobs.route.js b/awx/ui/client/features/jobs/routes/jobs.route.js index 73deb2d492..427d7d165d 100644 --- a/awx/ui/client/features/jobs/routes/jobs.route.js +++ b/awx/ui/client/features/jobs/routes/jobs.route.js @@ -15,7 +15,6 @@ export default { job_search: { value: { not__launch_type: 'sync', - not__type: 'workflow_approval', order_by: '-finished' }, dynamic: true, diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index b0aa4620ba..38ab92750e 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -149,7 +149,8 @@ function TemplatesStrings (BaseString) { CANCEL: t.s('CANCEL'), SAVE_AND_EXIT: t.s('SAVE & EXIT'), APPROVAL: t.s('Approval'), - TIMEOUT_POPOVER: t.s('The amount of time (in seconds) to wait before this approval step is automatically denied. Defaults to 0 for no timeout.') + TIMEOUT_POPOVER: t.s('The amount of time to wait before this approval step is automatically denied. Defaults to 0 for no timeout.'), + TIMED_OUT: t.s('APPROVAL TIMED OUT') }; } diff --git a/awx/ui/client/lib/components/approvalsDrawer/_index.less b/awx/ui/client/lib/components/approvalsDrawer/_index.less index 79771c1d0c..8687fee546 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/_index.less +++ b/awx/ui/client/lib/components/approvalsDrawer/_index.less @@ -38,6 +38,7 @@ justify-content: flex-end; width: 100%; margin-top: 10px; + line-height: 30px; button { margin-left: 15px; diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html index 7ef3b29443..c57ddca622 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html @@ -30,8 +30,7 @@
+ header-state="workflowResults({pid: {{approval.summary_fields.source_workflow_job.id}}})">
diff --git a/awx/ui/client/lib/components/layout/_index.less b/awx/ui/client/lib/components/layout/_index.less index 8c63ec603b..4457e69640 100644 --- a/awx/ui/client/lib/components/layout/_index.less +++ b/awx/ui/client/lib/components/layout/_index.less @@ -82,21 +82,29 @@ } } - .at-Layout-topNavApprovals { + .at-Layout-Approvals { display: flex; align-items: center; justify-content: center; height: 100%; + } - div { - margin-left: 10px; - padding: 5px; - border-radius: 3px; - background-color: @at-red-bright; - color: @at-white; - height: 15px; - font-size: 10px; - } + .at-Layout-ApprovalsBadge { + margin-left: 10px; + padding: 5px; + border-radius: 3px; + background-color: @at-gray-646972; + color: @at-white; + height: 16px; + font-size: 11px; + font-weight: bold; + cursor: default; + display: flex; + align-items: center; + } + + .at-Layout-ApprovalsBadgeActive { + background-color: @at-red-bright; } } diff --git a/awx/ui/client/lib/components/layout/layout.partial.html b/awx/ui/client/lib/components/layout/layout.partial.html index d092ce2d95..d48c607abc 100644 --- a/awx/ui/client/lib/components/layout/layout.partial.html +++ b/awx/ui/client/lib/components/layout/layout.partial.html @@ -15,9 +15,9 @@ -
+
-
{{vm.approvalsCount}}
+ {{vm.approvalsCount}}
diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index a9c1550d60..8519f28f95 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -148,6 +148,7 @@ color: @default-interface-txt; text-align: center; } + .WorkflowChart-activeNode { fill: @default-link; } @@ -169,6 +170,13 @@ text-align: center; } +.WorkflowChart-timedOutText { + width: 180px; + height: 14px; + color: @default-err; + text-align: center; +} + .WorkflowChart-tooltip { pointer-events: none; text-align: center; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index e4c64231d5..ae1da75a35 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -832,6 +832,9 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', baseSvg.selectAll(".WorkflowChart-deletedText") .style("display", (d) => { return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); + baseSvg.selectAll(".WorkflowChart-timedOutText") + .style("display", (d) => { return d.job && d.job.timed_out ? null : "none"; }); + baseSvg.selectAll(".WorkflowChart-activeNode") .style("display", (d) => { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); @@ -940,6 +943,15 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .html(`${TemplatesStrings.get('workflow_maker.DELETED')}`) .style("display", (d) => { return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); + thisNode.append("foreignObject") + .attr("x", 0) + .attr("y", 22) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-defaultText WorkflowChart-timedOutText") + .html(`${TemplatesStrings.get('workflow_maker.TIMED_OUT')}`) + .style("display", (d) => { return d.job && d.job.timed_out ? null : "none"; }); + thisNode.append("circle") .attr("cy", nodeH) .attr("r", 10) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 4bd9590869..ad77581a6a 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -489,7 +489,8 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.approvalNodeState = { name: null, description: null, - timeout: 0 + timeoutMinutes: 0, + timeoutSeconds: 0 }; $scope.nodeFormDataLoaded = false; $scope.wf_maker_template_queryset = { @@ -552,10 +553,14 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.activeTab = "approval"; select2ifyDropdowns(); + const timeoutMinutes = Math.floor($scope.nodeConfig.node.unifiedJobTemplate.timeout / 60); + const timeoutSeconds = $scope.nodeConfig.node.unifiedJobTemplate.timeout - timeoutMinutes * 60; + $scope.approvalNodeState = { name: $scope.nodeConfig.node.unifiedJobTemplate.name, description: $scope.nodeConfig.node.unifiedJobTemplate.description, - timeout: $scope.nodeConfig.node.unifiedJobTemplate.timeout + timeoutMinutes, + timeoutSeconds }; $scope.nodeFormDataLoaded = true; @@ -616,10 +621,12 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }; if ($scope.activeTab === "approval") { + const timeout = $scope.approvalNodeState.timeoutMinutes * 60 + $scope.approvalNodeState.timeoutSeconds; + nodeFormData.selectedTemplate = { name: $scope.approvalNodeState.name, description: $scope.approvalNodeState.description, - timeout: $scope.approvalNodeState.timeout, + timeout, unified_job_type: "workflow_approval" }; } else if($scope.activeTab === "templates") { @@ -649,7 +656,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } else if($scope.activeTab === "inventory_syncs") { return !$scope.inventoryNodeState.selectedTemplate; } else if ($scope.activeTab === "approval") { - return !($scope.approvalNodeState.name && $scope.approvalNodeState.name !== "") || $scope.workflow_approval.pauseTimeout.$error.min; + return !($scope.approvalNodeState.name && $scope.approvalNodeState.name !== "") || $scope.workflow_approval.pauseTimeoutMinutes.$error.min || $scope.workflow_approval.pauseTimeoutSeconds.$error.min; } }; @@ -660,7 +667,8 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.approvalNodeState = { name: null, description: null, - timeout: 0 + timeoutMinutes: 0, + timeoutSeconds: 0 }; $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index 88626d9fdc..d2d8cbeb7d 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -133,15 +133,22 @@
-
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index f163c845e3..bb17089dc9 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -323,11 +323,18 @@ margin-bottom: 20px; } -.WorkflowMaker-pauseCheckbox { - input { - margin-right: 5px; +.WorkflowMaker-timeoutInput { + .ui-spinner { + width: 100px; } - margin-bottom: 20px; +} + +.WorkflowMaker-timeoutSeconds { + margin-left: 10px; +} + +.WorkflowMaker-timeoutLabel { + margin-left: 3px; } .Key-list { From 3fa9497e3c2d88a10893d9d096eba012ea1ac717 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 20 Aug 2019 15:53:17 -0400 Subject: [PATCH 42/57] Various bug fixes and minor ux enhancements --- .../features/templates/templates.strings.js | 5 +- .../approvalsDrawer.partial.html | 4 +- .../lib/components/components.strings.js | 1 + .../client/lib/components/layout/_index.less | 1 - .../workflow-chart/workflow-chart.block.less | 9 +- .../workflow-chart.directive.js | 26 ++- .../forms/workflow-node-form.partial.html | 199 +++++++++--------- 7 files changed, 136 insertions(+), 109 deletions(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 38ab92750e..13e3ce6639 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -150,7 +150,10 @@ function TemplatesStrings (BaseString) { SAVE_AND_EXIT: t.s('SAVE & EXIT'), APPROVAL: t.s('Approval'), TIMEOUT_POPOVER: t.s('The amount of time to wait before this approval step is automatically denied. Defaults to 0 for no timeout.'), - TIMED_OUT: t.s('APPROVAL TIMED OUT') + TIMED_OUT: t.s('APPROVAL TIMED OUT'), + TIMEOUT: t.s('Timeout'), + APPROVED: t.s('APPROVED'), + DENIED: t.s('DENIED') }; } diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html index c57ddca622..b9132e79cd 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html @@ -30,13 +30,13 @@
+ header-state="workflowResults({id: {{approval.summary_fields.source_workflow_job.id}}})">
+ value-bind-html="{{:: vm.strings.get('approvals.APPROVAL') }}"> diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 8c36b86d18..436e0b3dda 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -121,6 +121,7 @@ function ComponentsStrings (BaseString) { }; ns.approvals = { + APPROVAL: t.s('APPROVAL'), NONE: t.s('There are no jobs awaiting approval'), APPROVE: t.s('APPROVE'), DENY: t.s('DENY'), diff --git a/awx/ui/client/lib/components/layout/_index.less b/awx/ui/client/lib/components/layout/_index.less index 4457e69640..4491118bef 100644 --- a/awx/ui/client/lib/components/layout/_index.less +++ b/awx/ui/client/lib/components/layout/_index.less @@ -97,7 +97,6 @@ color: @at-white; height: 16px; font-size: 11px; - font-weight: bold; cursor: default; display: flex; align-items: center; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index 8519f28f95..6f2e0584e6 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -170,13 +170,20 @@ text-align: center; } -.WorkflowChart-timedOutText { +.WorkflowChart-timedOutText, .WorkflowChart-deniedText { width: 180px; height: 14px; color: @default-err; text-align: center; } +.WorkflowChart-approvedText { + width: 180px; + height: 14px; + color: @default-succ; + text-align: center; +} + .WorkflowChart-tooltip { pointer-events: none; text-align: center; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index ae1da75a35..8963ad9e2b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -835,6 +835,12 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', baseSvg.selectAll(".WorkflowChart-timedOutText") .style("display", (d) => { return d.job && d.job.timed_out ? null : "none"; }); + baseSvg.selectAll(".WorkflowChart-deniedText") + .style("display", (d) => { return d.job && d.job.type === "workflow_approval" && d.job.status === "failed" && !d.job.timed_out ? null : "none"; }); + + baseSvg.selectAll(".WorkflowChart-approvedText") + .style("display", (d) => { return d.job && d.job.type === "workflow_approval" && d.job.status === "successful" && !d.job.timed_out ? null : "none"; }); + baseSvg.selectAll(".WorkflowChart-activeNode") .style("display", (d) => { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); @@ -950,7 +956,25 @@ export default ['moment', '$timeout', '$window', '$filter', 'TemplatesStrings', .attr("text-anchor", "middle") .attr("class", "WorkflowChart-defaultText WorkflowChart-timedOutText") .html(`${TemplatesStrings.get('workflow_maker.TIMED_OUT')}`) - .style("display", (d) => { return d.job && d.job.timed_out ? null : "none"; }); + .style("display", (d) => { return d.job && d.job.type === "workflow_approval" && d.job.timed_out ? null : "none"; }); + + thisNode.append("foreignObject") + .attr("x", 0) + .attr("y", 22) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-defaultText WorkflowChart-deniedText") + .html(`${TemplatesStrings.get('workflow_maker.DENIED')}`) + .style("display", (d) => { return d.job && d.job.type === "workflow_approval" && d.job.status === "failed" && !d.job.timed_out ? null : "none"; }); + + thisNode.append("foreignObject") + .attr("x", 0) + .attr("y", 22) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-defaultText WorkflowChart-approvedText") + .html(`${TemplatesStrings.get('workflow_maker.APPROVED')}`) + .style("display", (d) => { return d.job && d.job.type === "workflow_approval" && d.job.status === "successful" && !d.job.timed_out ? null : "none"; }); thisNode.append("circle") .attr("cy", nodeH) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index d2d8cbeb7d..b79ca9e79b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -159,110 +159,104 @@ {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }}
-
+
{{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}
-
-
- - {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} +
+
+ +
+ +
+
+
+
+ {{:: strings.get('workflow_maker.READ_ONLY_PROMPT_VALUES')}} +
+
+ {{:: strings.get('workflow_maker.READ_ONLY_NO_PROMPT_VALUES')}} +
+
+
{{:: strings.get('prompt.JOB_TYPE') }}
+
+ {{:: strings.get('prompt.PLAYBOOK_RUN') }} + {{:: strings.get('prompt.CHECK') }}
-
- -
- -
+
+
{{:: strings.get('prompt.INVENTORY') }}
+
-
-
- {{:: strings.get('workflow_maker.READ_ONLY_PROMPT_VALUES')}} +
+
{{:: strings.get('prompt.LIMIT') }}
+
+
+
+
{{:: strings.get('prompt.VERBOSITY') }}
+
+
+
+
+ {{:: strings.get('prompt.JOB_TAGS') }}  + + + +
-
- {{:: strings.get('workflow_maker.READ_ONLY_NO_PROMPT_VALUES')}} -
-
-
{{:: strings.get('prompt.JOB_TYPE') }}
-
- {{:: strings.get('prompt.PLAYBOOK_RUN') }} - {{:: strings.get('prompt.CHECK') }} -
-
-
-
{{:: strings.get('prompt.INVENTORY') }}
-
-
-
-
{{:: strings.get('prompt.LIMIT') }}
-
-
-
-
{{:: strings.get('prompt.VERBOSITY') }}
-
-
-
-
- {{:: strings.get('prompt.JOB_TAGS') }}  - - - - -
-
-
-
- {{tag}} -
+
+
+
+ {{tag}}
-
-
- {{:: strings.get('prompt.SKIP_TAGS') }}  - - - - -
-
-
-
- {{tag}} -
+
+
+
+ {{:: strings.get('prompt.SKIP_TAGS') }}  + + + + +
+
+
+
+ {{tag}}
@@ -283,16 +277,15 @@
-
-
-
-
- - - - -
-
+
+
+
+ + + + +
+
\ No newline at end of file From 582bbda9c440bb802a0371dc04dd13057908e85a Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 21 Aug 2019 15:57:13 -0400 Subject: [PATCH 43/57] Fix bug in Activity Stream, add tests. --- awx/api/serializers.py | 1 + awx/main/models/__init__.py | 1 + awx/main/models/workflow.py | 4 + .../functional/api/test_workflow_node.py | 100 +++++++++++++++++- .../factories/build-anchor.factory.js | 4 + 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3dd5d017c8..e20e191824 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4881,6 +4881,7 @@ class ActivityStreamSerializer(BaseSerializer): def _summarize_parent_ujt(self, obj, fk, summary_fields): summary_keys = {'job': 'job_template', 'workflow_job_template_node': 'workflow_job_template', + 'workflow_approval_template': 'workflow_job_template', 'workflow_approval': 'workflow_job', 'schedule': 'unified_job_template'} if fk not in summary_keys: diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 88631aa94a..65d246ee5f 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -203,6 +203,7 @@ activity_stream_registrar.connect(WorkflowJobTemplate) activity_stream_registrar.connect(WorkflowJobTemplateNode) activity_stream_registrar.connect(WorkflowJob) activity_stream_registrar.connect(WorkflowApproval) +activity_stream_registrar.connect(WorkflowApprovalTemplate) activity_stream_registrar.connect(OAuth2Application) activity_stream_registrar.connect(OAuth2AccessToken) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 99c76ff77f..17f5da0b7d 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -640,6 +640,10 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): def get_absolute_url(self, request=None): return reverse('api:workflow_approval_template_detail', kwargs={'pk': self.pk}, request=request) + @property + def workflow_job_template(self): + return self.workflowjobtemplatenodes.first().workflow_job_template + class WorkflowApproval(UnifiedJob): class Meta: diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index 4402ce3812..4c366bfe19 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -3,8 +3,13 @@ import json from awx.api.versioning import reverse +from awx.main.models.activity_stream import ActivityStream from awx.main.models.jobs import JobTemplate -from awx.main.models.workflow import WorkflowJobTemplateNode +from awx.main.models.workflow import ( + WorkflowApprovalTemplate, + WorkflowJobTemplate, + WorkflowJobTemplateNode, +) from awx.main.models.credential import Credential @@ -19,13 +24,20 @@ def job_template(inventory, project): @pytest.fixture -def node(workflow_job_template, post, admin_user, job_template): +def node(workflow_job_template, admin_user, job_template): return WorkflowJobTemplateNode.objects.create( workflow_job_template=workflow_job_template, unified_job_template=job_template ) +@pytest.fixture +def approval_node(workflow_job_template, admin_user): + return WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template + ) + + @pytest.mark.django_db def test_node_rejects_unprompted_fields(inventory, project, workflow_job_template, post, admin_user): job_template = JobTemplate.objects.create( @@ -56,6 +68,90 @@ def test_node_accepts_prompted_fields(inventory, project, workflow_job_template, user=admin_user, expect=201) +@pytest.mark.django_db +class TestApprovalNodes(): + def test_approval_node_creation(self, post, approval_node, admin_user): + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + post(url, {'name': 'Test', 'description': 'Approval Node', 'timeout': 0}, + user=admin_user, expect=200) + + approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk) + assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate) + assert approval_node.unified_job_template.name=='Test' + assert approval_node.unified_job_template.description=='Approval Node' + assert approval_node.unified_job_template.timeout==0 + + def test_approval_node_creation_failure(self, post, approval_node, admin_user): + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + post(url, {'name': '', 'description': 'Approval Node', 'timeout': 0}, + user=admin_user, expect=400) + # Leave off a required param to assert that you get a 400 + approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk) + assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate) is False + + @pytest.mark.parametrize("is_admin, is_org_admin, status", [ + [True, False, 200], + [False, False, 403], + [False, True, 200], + ]) + def test_approval_node_creation_rbac(self, post, approval_node, alice, is_admin, is_org_admin, status): + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + if is_admin is True: + approval_node.workflow_job_template.admin_role.members.add(alice) + if is_org_admin is True: + approval_node.workflow_job_template.organization.admin_role.members.add(alice) + post(url, {'name': 'Test', 'description': 'Approval Node', 'timeout': 0}, + user=alice, expect=status) + + @pytest.mark.django_db + def test_approval_node_exists(self, post, approval_node, admin_user, get): + workflow_job_template = WorkflowJobTemplate.objects.create() + approval_node = WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template + ) + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + post(url, {'name': 'URL Test', 'description': 'An approval', 'timeout': 0}, + user=admin_user) + get(url, admin_user, expect=200) + + @pytest.mark.django_db + def test_activity_stream_create_wf_approval(self, post, admin_user, workflow_job_template): + wfjn = WorkflowJobTemplateNode.objects.create(workflow_job_template=workflow_job_template) + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': wfjn.pk, 'version': 'v2'}) + post(url, {'name': 'Activity Stream Test', 'description': 'Approval Node', 'timeout': 0}, + user=admin_user) + + qs1 = ActivityStream.objects.filter(organization__isnull=False) + assert qs1.count() == 1 + assert qs1[0].operation == 'create' + + qs2 = ActivityStream.objects.filter(organization__isnull=True) + assert qs2.count() == 5 + assert qs2[0].operation == 'create' + assert qs2[1].operation == 'create' + assert qs2[2].operation == 'create' + assert qs2[3].operation == 'create' + assert qs2[4].operation == 'update' + + def test_approval_node_cleanup(self, post, approval_node, admin_user, get): + workflow_job_template = WorkflowJobTemplate.objects.create() + approval_node = WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template + ) + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + + post(url, {'name': 'URL Test', 'description': 'An approval', 'timeout': 0}, + user=admin_user) + workflow_job_template.delete() + get(url, admin_user, expect=404) + + @pytest.mark.django_db class TestExclusiveRelationshipEnforcement(): @pytest.fixture diff --git a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js index 7063f0110a..7081d24db4 100644 --- a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js @@ -97,6 +97,10 @@ export default function BuildAnchor($log, $filter) { url += `workflows/${activity.summary_fields.workflow_job[0].id}`; name = activity.summary_fields.workflow_job[0].name + ' | ' + activity.summary_fields.workflow_approval[0].name; break; + case 'workflow_approval_template': + url += `templates/workflow_job_template/${activity.summary_fields.workflow_job_template[0].id}/workflow-maker`; + name = activity.summary_fields.workflow_job_template[0].name + ' | ' + activity.summary_fields.workflow_approval_template[0].name; + break; default: url += resource + 's/' + obj.id + '/'; } From 8b23ff71b4854aaf63221e92894bb63ad1e6f746 Mon Sep 17 00:00:00 2001 From: beeankha Date: Thu, 22 Aug 2019 15:54:51 -0400 Subject: [PATCH 44/57] Update/add more functional tests --- awx/main/signals.py | 2 +- .../functional/api/test_workflow_node.py | 96 ++++++++++++++++--- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/awx/main/signals.py b/awx/main/signals.py index a655c19b3b..9846b11dd3 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -658,7 +658,7 @@ def delete_approval_node_type_change(sender, instance, **kwargs): old.unified_job_template.delete() -@receiver(post_delete, sender=WorkflowApprovalTemplate) +@receiver(pre_delete, sender=WorkflowApprovalTemplate) def deny_orphaned_approvals(sender, instance, **kwargs): for approval in WorkflowApproval.objects.filter(workflow_approval_template=instance, status='pending'): approval.deny() diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index 4c366bfe19..5c5e7de2e0 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -7,10 +7,12 @@ from awx.main.models.activity_stream import ActivityStream from awx.main.models.jobs import JobTemplate from awx.main.models.workflow import ( WorkflowApprovalTemplate, + WorkflowJob, WorkflowJobTemplate, WorkflowJobTemplateNode, ) from awx.main.models.credential import Credential +from awx.main.scheduler import TaskManager @pytest.fixture @@ -83,18 +85,19 @@ class TestApprovalNodes(): assert approval_node.unified_job_template.timeout==0 def test_approval_node_creation_failure(self, post, approval_node, admin_user): + # This test leaves off a required param to assert that user will get a 400. url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': approval_node.pk, 'version': 'v2'}) - post(url, {'name': '', 'description': 'Approval Node', 'timeout': 0}, - user=admin_user, expect=400) - # Leave off a required param to assert that you get a 400 + r = post(url, {'name': '', 'description': 'Approval Node', 'timeout': 0}, + user=admin_user, expect=400) approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk) assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate) is False + assert {'name': ['This field may not be blank.']} == json.loads(r.content) @pytest.mark.parametrize("is_admin, is_org_admin, status", [ - [True, False, 200], - [False, False, 403], - [False, True, 200], + [True, False, 200], # if they're a WFJT admin, they get a 200 + [False, False, 403], # if they're not a WFJT *nor* org admin, they get a 403 + [False, True, 200], # if they're an organization admin, they get a 200 ]) def test_approval_node_creation_rbac(self, post, approval_node, alice, is_admin, is_org_admin, status): url = reverse('api:workflow_job_template_node_create_approval', @@ -107,7 +110,7 @@ class TestApprovalNodes(): user=alice, expect=status) @pytest.mark.django_db - def test_approval_node_exists(self, post, approval_node, admin_user, get): + def test_approval_node_exists(self, post, admin_user, get): workflow_job_template = WorkflowJobTemplate.objects.create() approval_node = WorkflowJobTemplateNode.objects.create( workflow_job_template=workflow_job_template @@ -132,11 +135,41 @@ class TestApprovalNodes(): qs2 = ActivityStream.objects.filter(organization__isnull=True) assert qs2.count() == 5 - assert qs2[0].operation == 'create' - assert qs2[1].operation == 'create' - assert qs2[2].operation == 'create' - assert qs2[3].operation == 'create' - assert qs2[4].operation == 'update' + assert list(qs2.values_list('operation', 'object1')) == [('create', 'user'), + ('create', 'workflow_job_template'), + ('create', 'workflow_job_template_node'), + ('create', 'workflow_approval_template'), + ('update', 'workflow_job_template_node'), + ] + + @pytest.mark.django_db + def test_approval_node_actions(self, post, admin_user, job_template): + # This test ensures that a user (with permissions to do so) can approve/deny + # workflow approvals. Also asserts that trying to approve/deny approvals + # that have already been dealt with will throw an error. + wfjt = WorkflowJobTemplate.objects.create(name='foobar') + node = wfjt.workflow_nodes.create(unified_job_template=job_template) + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': node.pk, 'version': 'v2'}) + post(url, {'name': 'Approve/Deny Test', 'description': '', 'timeout': 0}, + user=admin_user, expect=200) + post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), + user=admin_user, expect=201) + wf_job = WorkflowJob.objects.first() + TaskManager().schedule() + TaskManager().schedule() + wfj_node = wf_job.workflow_nodes.first() + approval = wfj_node.job + assert approval.name == 'Approve/Deny Test' + post(reverse('api:workflow_approval_approve', kwargs={'pk': approval.pk}), + user=admin_user, expect=204) + # Test that there is an activity stream entry that was created for the "approve" action. + qs = ActivityStream.objects.order_by('-timestamp').first() + assert qs.object1 == 'workflow_approval' + assert qs.changes == '{"status": ["pending", "successful"]}' + assert qs.operation == 'update' + post(reverse('api:workflow_approval_approve', kwargs={'pk': approval.pk}), + user=admin_user, expect=403) def test_approval_node_cleanup(self, post, approval_node, admin_user, get): workflow_job_template = WorkflowJobTemplate.objects.create() @@ -148,9 +181,48 @@ class TestApprovalNodes(): post(url, {'name': 'URL Test', 'description': 'An approval', 'timeout': 0}, user=admin_user) + assert WorkflowApprovalTemplate.objects.count() == 1 workflow_job_template.delete() + assert WorkflowApprovalTemplate.objects.count() == 0 get(url, admin_user, expect=404) + def test_changed_approval_deletion(self, post, approval_node, admin_user, workflow_job_template, job_template): + # This test verifies that when an approval node changes into something else + # (in this case, a job template), then the previously-set WorkflowApprovalTemplate + # is automatically deleted. + workflow_job_template = WorkflowJobTemplate.objects.create() + approval_node = WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template + ) + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + post(url, {'name': 'URL Test', 'description': 'An approval', 'timeout': 0}, + user=admin_user) + assert WorkflowApprovalTemplate.objects.count() == 1 + approval_node.unified_job_template = job_template + approval_node.save() + assert WorkflowApprovalTemplate.objects.count() == 0 + + def test_deleted_approval_denial(self, post, approval_node, admin_user, workflow_job_template): + # Verifying that when a WorkflowApprovalTemplate is deleted, any/all of + # its pending approvals are auto-denied (vs left in 'pending' state). + workflow_job_template = WorkflowJobTemplate.objects.create() + approval_node = WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template + ) + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + post(url, {'name': 'URL Test', 'description': 'An approval', 'timeout': 0}, + user=admin_user) + assert WorkflowApprovalTemplate.objects.count() == 1 + approval_template = WorkflowApprovalTemplate.objects.first() + approval = approval_template.create_unified_job() + approval.status = 'pending' + approval.save() + approval_template.delete() + approval.refresh_from_db() + assert approval.status == 'failed' + @pytest.mark.django_db class TestExclusiveRelationshipEnforcement(): From b5c0f58137c664bc6e7a73fcba92f1096f4c4c9b Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 23 Aug 2019 08:32:08 -0400 Subject: [PATCH 45/57] Add test for approve node denial --- .../functional/api/test_workflow_node.py | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index 5c5e7de2e0..cf9b7dfe3b 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -6,6 +6,7 @@ from awx.api.versioning import reverse from awx.main.models.activity_stream import ActivityStream from awx.main.models.jobs import JobTemplate from awx.main.models.workflow import ( + WorkflowApproval, WorkflowApprovalTemplate, WorkflowJob, WorkflowJobTemplate, @@ -143,15 +144,15 @@ class TestApprovalNodes(): ] @pytest.mark.django_db - def test_approval_node_actions(self, post, admin_user, job_template): - # This test ensures that a user (with permissions to do so) can approve/deny - # workflow approvals. Also asserts that trying to approve/deny approvals + def test_approval_node_approve(self, post, admin_user, job_template): + # This test ensures that a user (with permissions to do so) can APPROVE + # workflow approvals. Also asserts that trying to APPROVE approvals # that have already been dealt with will throw an error. wfjt = WorkflowJobTemplate.objects.create(name='foobar') node = wfjt.workflow_nodes.create(unified_job_template=job_template) url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': node.pk, 'version': 'v2'}) - post(url, {'name': 'Approve/Deny Test', 'description': '', 'timeout': 0}, + post(url, {'name': 'Approve/Deny Test1', 'description': '', 'timeout': 0}, user=admin_user, expect=200) post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), user=admin_user, expect=201) @@ -160,17 +161,48 @@ class TestApprovalNodes(): TaskManager().schedule() wfj_node = wf_job.workflow_nodes.first() approval = wfj_node.job - assert approval.name == 'Approve/Deny Test' + assert approval.name == 'Approve/Deny Test1' post(reverse('api:workflow_approval_approve', kwargs={'pk': approval.pk}), user=admin_user, expect=204) # Test that there is an activity stream entry that was created for the "approve" action. qs = ActivityStream.objects.order_by('-timestamp').first() assert qs.object1 == 'workflow_approval' assert qs.changes == '{"status": ["pending", "successful"]}' + assert WorkflowApproval.objects.get(pk=approval.pk).status == 'successful' assert qs.operation == 'update' post(reverse('api:workflow_approval_approve', kwargs={'pk': approval.pk}), user=admin_user, expect=403) + @pytest.mark.django_db + def test_approval_node_deny(self, post, admin_user, job_template): + # This test ensures that a user (with permissions to do so) can DENY + # workflow approvals. Also asserts that trying to DENY approvals + # that have already been dealt with will throw an error. + wfjt = WorkflowJobTemplate.objects.create(name='foobar') + node = wfjt.workflow_nodes.create(unified_job_template=job_template) + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': node.pk, 'version': 'v2'}) + post(url, {'name': 'Approve/Deny Test2', 'description': '', 'timeout': 0}, + user=admin_user, expect=200) + post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), + user=admin_user, expect=201) + wf_job = WorkflowJob.objects.first() + TaskManager().schedule() + TaskManager().schedule() + wfj_node = wf_job.workflow_nodes.first() + approval = wfj_node.job + assert approval.name == 'Approve/Deny Test2' + post(reverse('api:workflow_approval_deny', kwargs={'pk': approval.pk}), + user=admin_user, expect=204) + # Test that there is an activity stream entry that was created for the "approve" action. + qs = ActivityStream.objects.order_by('-timestamp').first() + assert qs.object1 == 'workflow_approval' + assert qs.changes == '{"status": ["pending", "failed"]}' + assert WorkflowApproval.objects.get(pk=approval.pk).status == 'failed' + assert qs.operation == 'update' + post(reverse('api:workflow_approval_deny', kwargs={'pk': approval.pk}), + user=admin_user, expect=403) + def test_approval_node_cleanup(self, post, approval_node, admin_user, get): workflow_job_template = WorkflowJobTemplate.objects.create() approval_node = WorkflowJobTemplateNode.objects.create( From 703de8f3c0948ab7b2ea7730c1858cfa8cecb4f8 Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 23 Aug 2019 08:34:32 -0400 Subject: [PATCH 46/57] Edit minor typo --- awx/main/tests/functional/api/test_workflow_node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index cf9b7dfe3b..04b7780815 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -194,7 +194,7 @@ class TestApprovalNodes(): assert approval.name == 'Approve/Deny Test2' post(reverse('api:workflow_approval_deny', kwargs={'pk': approval.pk}), user=admin_user, expect=204) - # Test that there is an activity stream entry that was created for the "approve" action. + # Test that there is an activity stream entry that was created for the "deny" action. qs = ActivityStream.objects.order_by('-timestamp').first() assert qs.object1 == 'workflow_approval' assert qs.changes == '{"status": ["pending", "failed"]}' From 9f0307404ed9843658934e4d2b09e238d1e9c0ee Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 23 Aug 2019 10:31:23 -0400 Subject: [PATCH 47/57] Fix loading pending approval count on login --- .../approvalsDrawer.directive.js | 1 - .../authentication.service.js | 1 + .../authenticationServices/timer.factory.js | 2 +- .../login/loginModal/loginModal.controller.js | 24 +++++++++---------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js index b215f95620..47443c524e 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js @@ -18,7 +18,6 @@ function AtApprovalsDrawerController (strings, Rest, GetBasePath, $rootScope) { }; vm.emptyListReason = vm.strings.get('approvals.NONE'); - // This will probably need to be expanded vm.toolbarSortOptions = [ toolbarSortDefault, { label: `${vm.strings.get('sort.CREATED_DESCENDING')}`, value: '-created' } diff --git a/awx/ui/client/src/login/authenticationServices/authentication.service.js b/awx/ui/client/src/login/authenticationServices/authentication.service.js index 241f531aef..e57ffa18f6 100644 --- a/awx/ui/client/src/login/authenticationServices/authentication.service.js +++ b/awx/ui/client/src/login/authenticationServices/authentication.service.js @@ -112,6 +112,7 @@ export default $rootScope.login_username = null; $rootScope.login_password = null; $rootScope.userLoggedOut = true; + $rootScope.pendingApprovalCount = 0; if ($rootScope.sessionTimer) { $rootScope.sessionTimer.clearTimers(); } diff --git a/awx/ui/client/src/login/authenticationServices/timer.factory.js b/awx/ui/client/src/login/authenticationServices/timer.factory.js index 6f36a1ce5d..a5678b51e4 100644 --- a/awx/ui/client/src/login/authenticationServices/timer.factory.js +++ b/awx/ui/client/src/login/authenticationServices/timer.factory.js @@ -32,7 +32,7 @@ export default timeout: null, getSessionTime: function () { - if(Store('sessionTime')){ + if(Store('sessionTime') && Store('sessionTime')[$rootScope.current_user.id]){ return Store('sessionTime')[$rootScope.current_user.id].time; } else { diff --git a/awx/ui/client/src/login/loginModal/loginModal.controller.js b/awx/ui/client/src/login/loginModal/loginModal.controller.js index f47f3a70a8..6ce18559f6 100644 --- a/awx/ui/client/src/login/loginModal/loginModal.controller.js +++ b/awx/ui/client/src/login/loginModal/loginModal.controller.js @@ -132,6 +132,18 @@ export default ['$log', '$cookies', '$rootScope', 'ProcessErrors', $rootScope.user_is_system_auditor = data.results[0].is_system_auditor; scope.$emit('AuthorizationGetLicense'); }); + + Rest.setUrl(`${GetBasePath('workflow_approvals')}?status=pending&page_size=1`); + Rest.get() + .then(({data}) => { + $rootScope.pendingApprovalCount = data.count; + }) + .catch(({data, status}) => { + ProcessErrors({}, data, status, null, { + hdr: i18n._('Error!'), + msg: i18n._('Failed to get workflow jobs pending approval. GET returned status: ') + status + }); + }); }) .catch(({data, status}) => { Authorization.logout().then( () => { @@ -139,18 +151,6 @@ export default ['$log', '$cookies', '$rootScope', 'ProcessErrors', Alert('Error', 'Failed to access user information. GET returned status: ' + status, 'alert-danger', loginAgain); }); }); - - Rest.setUrl(`${GetBasePath('workflow_approvals')}?status=pending&page_size=1`); - Rest.get() - .then(({data}) => { - $rootScope.pendingApprovalCount = data.count; - }) - .catch(({data, status}) => { - ProcessErrors({}, data, status, null, { - hdr: i18n._('Error!'), - msg: i18n._('Failed to get workflow jobs pending approval. GET returned status: ') + status - }); - }); }); // Call the API to get an auth token From ea509f518efd3080eff8429088d33b8b263622c8 Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 23 Aug 2019 12:45:13 -0400 Subject: [PATCH 48/57] Addressing comments, updating tests, etc. --- awx/api/permissions.py | 4 ++-- awx/api/serializers.py | 9 --------- awx/api/views/__init__.py | 4 ++-- awx/main/access.py | 2 -- awx/main/scheduler/task_manager.py | 5 +++-- awx/main/tests/functional/api/test_workflow_node.py | 12 ++++++------ 6 files changed, 13 insertions(+), 23 deletions(-) diff --git a/awx/api/permissions.py b/awx/api/permissions.py index 09e6f0f1bc..34ee7f76fb 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -198,8 +198,8 @@ class TaskPermission(ModelAccessPermission): class WorkflowApprovalPermission(ModelAccessPermission): ''' - Permission check used by workflow approval and deny views - to determine who can has access to approve and deny paused workflow nodes + Permission check used by workflow `approval` and `deny` views to determine + who has access to approve and deny paused workflow nodes ''' def check_post_permissions(self, request, view, obj=None): diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e20e191824..158b74c024 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3473,15 +3473,6 @@ class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): return res -class WorkflowJobTemplateApprovalSerializer(UnifiedJobTemplateSerializer): - class Meta: - model = WorkflowApprovalTemplate - fields = ('*',) - - def post(self, obj): - return # POST only!!! - - class LaunchConfigurationBaseSerializer(BaseSerializer): scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) job_type = serializers.ChoiceField(allow_blank=True, allow_null=True, required=False, default=None, diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index cde1ccc6bc..0ce6a6d72f 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4473,7 +4473,7 @@ class WorkflowApprovalApprove(RetrieveAPIView): if not request.user.can_access(models.WorkflowApproval, 'approve_or_deny', obj): raise PermissionDenied(detail=_("User does not have permission to approve or deny this workflow.")) if obj.status != 'pending': - return Response("This workflow step has already been approved or denied.", status=status.HTTP_400_BAD_REQUEST) + return Response({"error": _("This workflow step has already been approved or denied.")}, status=status.HTTP_400_BAD_REQUEST) obj.approve(request) return Response(status=status.HTTP_204_NO_CONTENT) @@ -4488,6 +4488,6 @@ class WorkflowApprovalDeny(RetrieveAPIView): if not request.user.can_access(models.WorkflowApproval, 'approve_or_deny', obj): raise PermissionDenied(detail=_("User does not have permission to approve or deny this workflow.")) if obj.status != 'pending': - return Response("This workflow step has already been approved or denied.", status=status.HTTP_400_BAD_REQUEST) + return Response({"error": _("This workflow step has already been approved or denied.")}, status=status.HTTP_400_BAD_REQUEST) obj.deny(request) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/awx/main/access.py b/awx/main/access.py index e81b69e16b..e6181afb30 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2809,8 +2809,6 @@ class WorkflowApprovalAccess(BaseAccess): self.user, 'read_role')) def can_approve_or_deny(self, obj): - if obj.status != 'pending': - return False if self.user in obj.workflow_job_template.approval_role or self.user.is_superuser: return True diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index c3d129cf46..df02d6f030 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -527,10 +527,11 @@ class TaskManager(): if task.timeout == 0: continue if (now - task.created) >= approval_timeout_seconds: - logger.warn("The approval node {} ({}) has expired after {} seconds.".format(task.name, task.pk, task.timeout)) + timeout_message = "The approval node {} ({}) has expired after {} seconds.".format(task.name, task.pk, task.timeout) + logger.warn(timeout_message) task.timed_out = True task.status = 'failed' - task.job_explanation = _("This approval node has timed out.") + task.job_explanation = _(timeout_message) task.save(update_fields=['status', 'job_explanation', 'timed_out']) def calculate_capacity_consumed(self, tasks): diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index 04b7780815..64c22898df 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -152,7 +152,7 @@ class TestApprovalNodes(): node = wfjt.workflow_nodes.create(unified_job_template=job_template) url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': node.pk, 'version': 'v2'}) - post(url, {'name': 'Approve/Deny Test1', 'description': '', 'timeout': 0}, + post(url, {'name': 'Approve Test', 'description': '', 'timeout': 0}, user=admin_user, expect=200) post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), user=admin_user, expect=201) @@ -161,7 +161,7 @@ class TestApprovalNodes(): TaskManager().schedule() wfj_node = wf_job.workflow_nodes.first() approval = wfj_node.job - assert approval.name == 'Approve/Deny Test1' + assert approval.name == 'Approve Test' post(reverse('api:workflow_approval_approve', kwargs={'pk': approval.pk}), user=admin_user, expect=204) # Test that there is an activity stream entry that was created for the "approve" action. @@ -171,7 +171,7 @@ class TestApprovalNodes(): assert WorkflowApproval.objects.get(pk=approval.pk).status == 'successful' assert qs.operation == 'update' post(reverse('api:workflow_approval_approve', kwargs={'pk': approval.pk}), - user=admin_user, expect=403) + user=admin_user, expect=400) @pytest.mark.django_db def test_approval_node_deny(self, post, admin_user, job_template): @@ -182,7 +182,7 @@ class TestApprovalNodes(): node = wfjt.workflow_nodes.create(unified_job_template=job_template) url = reverse('api:workflow_job_template_node_create_approval', kwargs={'pk': node.pk, 'version': 'v2'}) - post(url, {'name': 'Approve/Deny Test2', 'description': '', 'timeout': 0}, + post(url, {'name': 'Deny Test', 'description': '', 'timeout': 0}, user=admin_user, expect=200) post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}), user=admin_user, expect=201) @@ -191,7 +191,7 @@ class TestApprovalNodes(): TaskManager().schedule() wfj_node = wf_job.workflow_nodes.first() approval = wfj_node.job - assert approval.name == 'Approve/Deny Test2' + assert approval.name == 'Deny Test' post(reverse('api:workflow_approval_deny', kwargs={'pk': approval.pk}), user=admin_user, expect=204) # Test that there is an activity stream entry that was created for the "deny" action. @@ -201,7 +201,7 @@ class TestApprovalNodes(): assert WorkflowApproval.objects.get(pk=approval.pk).status == 'failed' assert qs.operation == 'update' post(reverse('api:workflow_approval_deny', kwargs={'pk': approval.pk}), - user=admin_user, expect=403) + user=admin_user, expect=400) def test_approval_node_cleanup(self, post, approval_node, admin_user, get): workflow_job_template = WorkflowJobTemplate.objects.create() From b2819793df96177acd01473d1b554fa6fdb75571 Mon Sep 17 00:00:00 2001 From: beeankha Date: Fri, 23 Aug 2019 14:50:48 -0400 Subject: [PATCH 49/57] Set view's permission classes to be more explicit --- awx/api/views/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 0ce6a6d72f..ba276a422c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -3016,6 +3016,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): model = models.WorkflowJobTemplateNode serializer_class = serializers.WorkflowJobTemplateNodeCreateApprovalSerializer + permission_classes = [] def post(self, request, *args, **kwargs): obj = self.get_object() From 2e58a47118a341c9d07f5b9d2ae614dbab13d903 Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 26 Aug 2019 09:29:44 -0400 Subject: [PATCH 50/57] Minor change to fix rebase conflict. --- .../workflow-maker/forms/workflow-node-form.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index ad77581a6a..136b2e6cdf 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -353,7 +353,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService value: $scope.extraVars }; - $scope.nodeConfig.node.promptData = $scope.promptData = { + $scope.nodeConfig.node.promptData = $scope.jobNodeState.promptData = { launchConf: launchConf, launchOptions: launchOptions, prompts: prompts, From 459012e879e8c00da7bfccf363ca43e244009740 Mon Sep 17 00:00:00 2001 From: beeankha Date: Mon, 26 Aug 2019 16:28:24 -0400 Subject: [PATCH 51/57] Fix 500 error on workflow_approvals endpoint --- awx/main/access.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/main/access.py b/awx/main/access.py index e6181afb30..a976a2dbb6 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2809,7 +2809,10 @@ class WorkflowApprovalAccess(BaseAccess): self.user, 'read_role')) def can_approve_or_deny(self, obj): - if self.user in obj.workflow_job_template.approval_role or self.user.is_superuser: + if ( + (obj.workflow_job_template and self.user in obj.workflow_job_template.approval_role) or + self.user.is_superuser + ): return True From 1eeab7e0d5ef84bf88055feff75e6bd68fe8d372 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 26 Aug 2019 16:46:46 -0400 Subject: [PATCH 52/57] add approval timeout to the summary fields for WorkflowJobTemplateNodes --- awx/api/serializers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 158b74c024..66c06c3ce6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3636,6 +3636,12 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): field_kwargs.pop('queryset', None) return field_class, field_kwargs + def get_summary_fields(self, obj): + summary_fields = super(WorkflowJobTemplateNodeSerializer, self).get_summary_fields(obj) + if isinstance(obj.unified_job_template, WorkflowApprovalTemplate): + summary_fields['unified_job_template']['timeout'] = obj.unified_job_template.timeout + return summary_fields + class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) From 2ac1c3d1e147d321e6cfd21ac61c945cc4553c81 Mon Sep 17 00:00:00 2001 From: beeankha Date: Tue, 27 Aug 2019 10:39:25 -0400 Subject: [PATCH 53/57] Update timeout info on AWX docs. --- docs/workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/workflow.md b/docs/workflow.md index cedc8cd2f9..3ccfb7d367 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -77,7 +77,7 @@ A user can _view_ approvals if they: **Other Workflow Approval Node Features** -A timeout can be set for each approval node. This field defaults to `0` for no expiration. +A timeout (in minutes and seconds) can be set for each approval node. These fields default to `0` for no expiration. ### DAG Formation and Restrictions From b9f75ecad715a37b4311f208d9321b5c0c7352cf Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 27 Aug 2019 15:42:49 -0400 Subject: [PATCH 54/57] update migration numbering for WF approval --- ...v360_workflow_approval.py => 0086_v360_workflow_approval.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0085_v360_workflow_approval.py => 0086_v360_workflow_approval.py} (98%) diff --git a/awx/main/migrations/0085_v360_workflow_approval.py b/awx/main/migrations/0086_v360_workflow_approval.py similarity index 98% rename from awx/main/migrations/0085_v360_workflow_approval.py rename to awx/main/migrations/0086_v360_workflow_approval.py index a63740ac89..fa3cadbc2f 100644 --- a/awx/main/migrations/0085_v360_workflow_approval.py +++ b/awx/main/migrations/0086_v360_workflow_approval.py @@ -8,7 +8,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('main', '0084_v360_token_description'), + ('main', '0085_v360_add_notificationtemplate_messages'), ] operations = [ From 23f75cf74ac73e2523a0632c889a69dbf2153293 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 27 Aug 2019 15:51:04 -0400 Subject: [PATCH 55/57] fix a bug introduced in rebase --- awx/main/models/notifications.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 677991c88e..f21eb05a63 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -485,5 +485,6 @@ class JobNotificationMixin(object): def send_it(local_nt=nt, local_subject=notification_subject, local_body=notification_body): def _func(): send_notifications.delay([local_nt.generate_notification(local_subject, local_body).id], + job_id=self.id) return _func connection.on_commit(send_it()) From 073f6dbf07a032db99a140e538b15151891f57af Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 28 Aug 2019 09:33:15 -0400 Subject: [PATCH 56/57] Fix flake8 error --- awx/main/models/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index f21eb05a63..7ecdc244db 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -485,6 +485,6 @@ class JobNotificationMixin(object): def send_it(local_nt=nt, local_subject=notification_subject, local_body=notification_body): def _func(): send_notifications.delay([local_nt.generate_notification(local_subject, local_body).id], - job_id=self.id) + job_id=self.id) return _func connection.on_commit(send_it()) From f229418ae23d0c7288ff71b42c453abc2acae16a Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 27 Aug 2019 17:05:56 -0400 Subject: [PATCH 57/57] Styles cleanup --- awx/ui/client/lib/components/approvalsDrawer/_index.less | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/awx/ui/client/lib/components/approvalsDrawer/_index.less b/awx/ui/client/lib/components/approvalsDrawer/_index.less index 8687fee546..a2c58c854a 100644 --- a/awx/ui/client/lib/components/approvalsDrawer/_index.less +++ b/awx/ui/client/lib/components/approvalsDrawer/_index.less @@ -30,7 +30,6 @@ color: @default-interface-txt; font-size: 14px; font-weight: bold; - width: calc(82%); } &--actionRow { @@ -66,4 +65,4 @@ &--expires { color: @default-err; } -} \ No newline at end of file +}