diff --git a/awx/api/generics.py b/awx/api/generics.py index c66c9b7348..06f80887ae 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -34,7 +34,8 @@ from rest_framework.negotiation import DefaultContentNegotiation # AWX from awx.api.filters import FieldLookupBackend from awx.main.models import ( - UnifiedJob, UnifiedJobTemplate, User, Role, Credential + UnifiedJob, UnifiedJobTemplate, User, Role, Credential, + WorkflowJobTemplateNode, WorkflowApprovalTemplate ) from awx.main.access import access_registry from awx.main.utils import ( @@ -882,6 +883,21 @@ class CopyAPIView(GenericAPIView): create_kwargs[field.name] = CopyAPIView._decrypt_model_field_if_needed( obj, field.name, field_val ) + + # WorkflowJobTemplateNodes that represent an approval are *special*; + # when we copy them, we actually want to *copy* the UJT they point at + # rather than share the template reference between nodes in disparate + # workflows + if ( + isinstance(obj, WorkflowJobTemplateNode) and + isinstance(getattr(obj, 'unified_job_template'), WorkflowApprovalTemplate) + ): + new_approval_template, sub_objs = CopyAPIView.copy_model_obj( + None, None, WorkflowApprovalTemplate, + obj.unified_job_template, creater + ) + create_kwargs['unified_job_template'] = new_approval_template + new_obj = model.objects.create(**create_kwargs) logger.debug('Deep copy: Created new object {}({})'.format( new_obj, model diff --git a/awx/api/permissions.py b/awx/api/permissions.py index 3c4de0ad27..34ee7f76fb 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -17,7 +17,7 @@ logger = logging.getLogger('awx.api.permissions') __all__ = ['ModelAccessPermission', 'JobTemplateCallbackPermission', 'VariableDataPermission', 'TaskPermission', 'ProjectUpdatePermission', 'InventoryInventorySourcesUpdatePermission', - 'UserPermission', 'IsSuperUser', 'InstanceGroupTowerPermission',] + 'UserPermission', 'IsSuperUser', 'InstanceGroupTowerPermission', 'WorkflowApprovalPermission'] class ModelAccessPermission(permissions.BasePermission): @@ -196,6 +196,17 @@ class TaskPermission(ModelAccessPermission): return False +class WorkflowApprovalPermission(ModelAccessPermission): + ''' + 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): + approval = get_object_or_400(view.model, pk=view.kwargs['pk']) + return check_user_access(request.user, view.model, 'approve_or_deny', approval) + + class ProjectUpdatePermission(ModelAccessPermission): ''' Permission check used by ProjectUpdateView to determine who can update projects @@ -238,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/serializers.py b/awx/api/serializers.py index 5f38158cda..66c06c3ce6 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 ( @@ -121,6 +121,8 @@ SUMMARIZABLE_FK_FIELDS = { 'job_template': DEFAULT_SUMMARY_FIELDS, 'workflow_job_template': DEFAULT_SUMMARY_FIELDS, 'workflow_job': DEFAULT_SUMMARY_FIELDS, + 'workflow_approval_template': DEFAULT_SUMMARY_FIELDS + ('timeout',), + 'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout',), 'schedule': DEFAULT_SUMMARY_FIELDS + ('next_run',), 'unified_job_template': DEFAULT_SUMMARY_FIELDS + ('unified_job_type',), 'last_job': DEFAULT_SUMMARY_FIELDS + ('finished', 'status', 'failed', 'license_error'), @@ -681,6 +683,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 +786,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 +844,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 +3403,76 @@ class WorkflowJobCancelSerializer(WorkflowJobSerializer): fields = ('can_cancel',) +class WorkflowApprovalViewSerializer(UnifiedJobSerializer): + + class Meta: + model = WorkflowApproval + fields = [] + + +class WorkflowApprovalSerializer(UnifiedJobSerializer): + + can_approve_or_deny = serializers.SerializerMethodField() + approval_expiration = serializers.SerializerMethodField() + timed_out = serializers.ReadOnlyField() + + class Meta: + model = WorkflowApproval + fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out',) + + def get_approval_expiration(self, obj): + if obj.status != 'pending' or obj.timeout == 0: + return None + return obj.created + timedelta(seconds=obj.timeout) + + def get_can_approve_or_deny(self, obj): + request = self.context.get('request', None) + allowed = request.user.can_access(WorkflowApproval, 'approve_or_deny', obj) + return allowed is True and obj.status == 'pending' + + 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['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 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: + fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out',) + + +class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): + + class Meta: + model = WorkflowApprovalTemplate + fields = ('*', 'timeout', 'name',) + + 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}),)) + 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, @@ -3453,6 +3531,10 @@ class LaunchConfigurationBaseSerializer(BaseSerializer): ujt = attrs['unified_job_template'] elif self.instance: ujt = self.instance.unified_job_template + if ujt is None: + if 'workflow_job_template' in attrs: + return {'workflow_job_template': attrs['workflow_job_template']} + return {} # build additional field survey_passwords to track redacted variables password_dict = {} @@ -3534,6 +3616,7 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): def get_related(self, obj): res = super(WorkflowJobTemplateNodeSerializer, self).get_related(obj) + 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}) @@ -3553,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) @@ -3578,6 +3667,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 @@ -3603,6 +3698,16 @@ class WorkflowJobTemplateNodeDetailSerializer(WorkflowJobTemplateNodeSerializer) 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 @@ -4663,7 +4768,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', 'name', 'unified_job_id')), ] return field_list @@ -4772,6 +4878,8 @@ 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: return diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 31eb6b78d0..ede960ecb6 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_approvals/', 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..dc58da1d3a --- /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, + WorkflowApprovalApprove, + WorkflowApprovalDeny, +) + + +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/$', WorkflowApprovalApprove.as_view(), name='workflow_approval_approve'), + url(r'^(?P[0-9]+)/deny/$', WorkflowApprovalDeny.as_view(), name='workflow_approval_deny'), +] + +__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..8a22ee83b3 --- /dev/null +++ b/awx/api/urls/workflow_approval_template.py @@ -0,0 +1,17 @@ +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + +from django.conf.urls import url + +from awx.api.views import ( + WorkflowApprovalTemplateDetail, + WorkflowApprovalTemplateJobsList, +) + + +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'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/workflow_job_template_node.py b/awx/api/urls/workflow_job_template_node.py index 14cb49137e..868c728a88 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_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 a6601ad607..ba276a422c 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -91,7 +91,8 @@ from awx.main.redact import UriCleaner from awx.api.permissions import ( JobTemplateCallbackPermission, TaskPermission, ProjectUpdatePermission, InventoryInventorySourcesUpdatePermission, UserPermission, - InstanceGroupTowerPermission, VariableDataPermission + InstanceGroupTowerPermission, VariableDataPermission, + WorkflowApprovalPermission ) from awx.api import renderers from awx.api import serializers @@ -839,8 +840,6 @@ class SystemJobEventsList(SubListAPIView): return super(SystemJobEventsList, self).finalize_response(request, response, *args, **kwargs) - - class ProjectUpdateCancel(RetrieveAPIView): model = models.ProjectUpdate @@ -3013,6 +3012,34 @@ class WorkflowJobTemplateNodeChildrenBaseList(EnforceParentRelationshipMixin, Su return None +class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView): + + model = models.WorkflowJobTemplateNode + serializer_class = serializers.WorkflowJobTemplateNodeCreateApprovalSerializer + permission_classes = [] + + def post(self, request, *args, **kwargs): + 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) + approval_template = obj.create_approval_template(**serializer.validated_data) + 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 + if request.method == 'POST': + if not request.user.can_access(models.WorkflowJobTemplate, 'change', obj, request.data): + self.permission_denied(request) + else: + if not request.user.can_access(models.WorkflowJobTemplate, 'read', obj): + self.permission_denied(request) + + class WorkflowJobTemplateNodeSuccessNodesList(WorkflowJobTemplateNodeChildrenBaseList): relationship = 'success_nodes' @@ -4405,3 +4432,63 @@ for attr, value in list(locals().items()): name = camelcase_to_underscore(attr) view = value.as_view() setattr(this_module, name, view) + + +class WorkflowApprovalTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): + + model = models.WorkflowApprovalTemplate + serializer_class = serializers.WorkflowApprovalTemplateSerializer + + +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): + return super(WorkflowApprovalList, self).get(request, *args, **kwargs) + + +class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): + + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalSerializer + + +class WorkflowApprovalApprove(RetrieveAPIView): + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalViewSerializer + permission_classes = (WorkflowApprovalPermission,) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + 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({"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) + + +class WorkflowApprovalDeny(RetrieveAPIView): + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalViewSerializer + permission_classes = (WorkflowApprovalPermission,) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + 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({"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/api/views/root.py b/awx/api/views/root.py index 50e7ada0d6..922041c8b5 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -124,6 +124,7 @@ 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_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 78aaa2f5d2..a976a2dbb6 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 @@ -2377,13 +2378,18 @@ class UnifiedJobTemplateAccess(BaseAccess): return self.model.objects.filter( Q(pk__in=self.model.accessible_pk_qs(self.user, 'read_role')) | Q(inventorysource__inventory__id__in=Inventory._accessible_pk_qs( - Inventory, self.user, 'read_role'))) + Inventory, self.user, 'read_role')) + ) def can_start(self, obj, validate_license=True): access_class = access_registry[obj.__class__] access_instance = access_class(self.user) return access_instance.can_start(obj, validate_license=validate_license) + def get_queryset(self): + return super(UnifiedJobTemplateAccess, self).get_queryset().filter( + workflowapprovaltemplate__isnull=True) + class UnifiedJobAccess(BaseAccess): ''' @@ -2430,6 +2436,10 @@ class UnifiedJobAccess(BaseAccess): ) return qs + def get_queryset(self): + return super(UnifiedJobAccess, self).get_queryset().filter( + workflowapproval__isnull=True) + class ScheduleAccess(BaseAccess): ''' @@ -2769,5 +2779,80 @@ class RoleAccess(BaseAccess): return False +class WorkflowApprovalAccess(BaseAccess): + ''' + 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. + + 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 see approvals if they have read access to the associated WorkflowJobTemplate. + ''' + + model = WorkflowApproval + prefetch_related = ('created_by', 'modified_by',) + + 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__workflow_job__unified_job_template__in=WorkflowJobTemplate.accessible_pk_qs( + self.user, 'read_role')) + + def can_approve_or_deny(self, obj): + if ( + (obj.workflow_job_template and self.user in obj.workflow_job_template.approval_role) or + self.user.is_superuser + ): + return True + + +class WorkflowApprovalTemplateAccess(BaseAccess): + ''' + 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. + + 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 at the workflow or organization level + + A user can see approval templates if they have read access to the associated WorkflowJobTemplate. + ''' + + model = WorkflowApprovalTemplate + prefetch_related = ('created_by', 'modified_by',) + + @check_superuser + def can_add(self, data): + 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): + # for copying WFJTs that contain approval nodes + if self.user.is_superuser: + return True + + return self.user in obj.workflow_job_template.execute_role + + def filtered_queryset(self): + return self.model.objects.filter( + workflowjobtemplatenodes__workflow_job_template__in=WorkflowJobTemplate.accessible_pk_qs( + self.user, 'read_role')) + + for cls in BaseAccess.__subclasses__(): access_registry[cls.model] = cls 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/migrations/0086_v360_workflow_approval.py b/awx/main/migrations/0086_v360_workflow_approval.py new file mode 100644 index 0000000000..fa3cadbc2f --- /dev/null +++ b/awx/main/migrations/0086_v360_workflow_approval.py @@ -0,0 +1,83 @@ +# Generated by Django 2.2.4 on 2019-08-02 17:51 + +import awx.main.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0085_v360_add_notificationtemplate_messages'), + ] + + 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=['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=[ + ('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',), + ), + 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'), + ), + migrations.AlterField( + model_name='organization', + name='read_role', + field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['member_role', 'auditor_role', 'execute_role', 'project_admin_role', 'inventory_admin_role', 'workflow_admin_role', 'notification_admin_role', 'credential_admin_role', 'job_template_admin_role', 'approval_role'], related_name='+', to='main.Role'), + ), + migrations.AlterField( + model_name='workflowjobtemplate', + name='read_role', + field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['singleton:system_auditor', 'organization.auditor_role', 'execute_role', 'admin_role', 'approval_role'], related_name='+', to='main.Role'), + ), + migrations.AddField( + model_name='workflowapproval', + name='timeout', + field=models.IntegerField(blank=True, default=0, help_text='The amount of time (in seconds) before the approval node expires and fails.'), + ), + migrations.AddField( + model_name='workflowapproval', + name='timed_out', + field=models.BooleanField(default=False, help_text='Shows when an approval node (with a timeout assigned to it) has timed out.'), + ), + ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index f80ae42d6f..2dbe959511 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -56,7 +56,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 @@ -203,6 +203,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) @@ -213,4 +215,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/activity_stream.py b/awx/main/models/activity_stream.py index 20042c82df..85666e49d2 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -67,6 +67,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) + 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/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/notifications.py b/awx/main/models/notifications.py index e3b69128d8..7ecdc244db 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: diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 1c77c9e5be..60505b6e0a 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -87,7 +87,10 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi 'execute_role', 'project_admin_role', 'inventory_admin_role', 'workflow_admin_role', 'notification_admin_role', 'credential_admin_role', - 'job_template_admin_role',], + 'job_template_admin_role', 'approval_role',], + ) + approval_role = ImplicitRoleField( + parent_role='admin_role', ) diff --git a/awx/main/models/rbac.py b/awx/main/models/rbac.py index a0b5b6785f..67d21e873d 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'), } 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'), } diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index b9b6ecc092..7a2542f9f2 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1173,7 +1173,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique def websocket_emit_data(self): ''' Return extra data that should be included when submitting data to the browser over the websocket connection ''' - websocket_data = dict() + websocket_data = dict(type=self.get_real_instance_class()._meta.verbose_name.replace(' ', '_')) if self.spawned_by_workflow: websocket_data.update(dict(workflow_job_id=self.workflow_job_id, workflow_node_id=self.workflow_node_id)) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 9f958886ab..b7483e24cd 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -35,11 +35,14 @@ 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 -__all__ = ['WorkflowJobTemplate', 'WorkflowJob', 'WorkflowJobOptions', 'WorkflowJobNode', 'WorkflowJobTemplateNode',] +__all__ = ['WorkflowJobTemplate', 'WorkflowJob', 'WorkflowJobOptions', 'WorkflowJobNode', + 'WorkflowJobTemplateNode', 'WorkflowApprovalTemplate', 'WorkflowApproval'] logger = logging.getLogger('awx.main.models.workflow') @@ -71,7 +74,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, @@ -161,6 +164,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( @@ -385,7 +395,11 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl ]) read_role = ImplicitRoleField(parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, - 'organization.auditor_role', 'execute_role', 'admin_role' + 'organization.auditor_role', 'execute_role', 'admin_role', + 'approval_role', + ]) + approval_role = ImplicitRoleField(parent_role=[ + 'organization.approval_role', 'admin_role', ]) @property @@ -601,3 +615,92 @@ 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): + + FIELDS_TO_PRESERVE_AT_COPY = ['description', 'timeout',] + + 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 + + @classmethod + def _get_unified_job_field_names(cls): + return ['name', 'description', 'timeout'] + + 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: + app_label = 'main' + + workflow_approval_template = models.ForeignKey( + 'WorkflowApprovalTemplate', + related_name='approvals', + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + ) + timeout = models.IntegerField( + blank=True, + default=0, + help_text=_("The amount of time (in seconds) before the approval node expires and fails."), + ) + timed_out = models.BooleanField( + default=False, + help_text=_("Shows when an approval node (with a timeout assigned to it) has timed out.") + ) + + + @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 _get_parent_field_name(self): + return 'workflow_approval_template' + + def approve(self, request=None): + self.status = 'successful' + self.save() + self.websocket_emit_status(self.status) + schedule_task_manager() + return reverse('api:workflow_approval_approve', kwargs={'pk': self.pk}, request=request) + + def deny(self, request=None): + self.status = 'failed' + self.save() + self.websocket_emit_status(self.status) + schedule_task_manager() + return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request) + + @property + def workflow_job_template(self): + return self.unified_job_node.workflow_job.unified_job_template + + @property + def workflow_job(self): + return self.unified_job_node.workflow_job diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index b79abbad0d..df02d6f030 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 ) @@ -518,6 +519,21 @@ class TaskManager(): if not found_acceptable_queue: logger.debug("{} couldn't be scheduled on graph, waiting for next cycle".format(task.log_format)) + def timeout_approval_node(self): + workflow_approvals = WorkflowApproval.objects.filter(status='pending') + now = tz_now() + for task in workflow_approvals: + approval_timeout_seconds = timedelta(seconds=task.timeout) + if task.timeout == 0: + continue + if (now - task.created) >= approval_timeout_seconds: + 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 = _(timeout_message) + task.save(update_fields=['status', 'job_explanation', 'timed_out']) + def calculate_capacity_consumed(self, tasks): self.graph = InstanceGroup.objects.capacity_values(tasks=tasks, graph=self.graph) @@ -573,6 +589,8 @@ class TaskManager(): self.spawn_workflow_graph_jobs(running_workflow_tasks) + self.timeout_approval_node() + self.process_tasks(all_sorted_tasks) return finished_wfjs diff --git a/awx/main/signals.py b/awx/main/signals.py index da2898e43f..9846b11dd3 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, + WorkflowApproval, 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 @@ -355,6 +355,7 @@ 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) + # Set via ActivityStreamRegistrar to record activity stream events @@ -429,6 +430,8 @@ def model_serializer_mapping(): models.Label: serializers.LabelSerializer, models.WorkflowJobTemplate: serializers.WorkflowJobTemplateWithSpecSerializer, models.WorkflowJobTemplateNode: serializers.WorkflowJobTemplateNodeSerializer, + models.WorkflowApproval: serializers.WorkflowApprovalActivityStreamSerializer, + models.WorkflowApprovalTemplate: serializers.WorkflowApprovalTemplateSerializer, models.WorkflowJob: serializers.WorkflowJobSerializer, models.OAuth2AccessToken: serializers.OAuth2TokenSerializer, models.OAuth2Application: serializers.OAuth2ApplicationSerializer, @@ -637,6 +640,30 @@ def delete_inventory_for_org(sender, instance, **kwargs): logger.debug(e) +@receiver(pre_delete, sender=WorkflowJobTemplateNode) +def delete_approval_templates(sender, instance, **kwargs): + if type(instance.unified_job_template) is WorkflowApprovalTemplate: + instance.unified_job_template.delete() + + +@receiver(pre_save, sender=WorkflowJobTemplateNode) +def delete_approval_node_type_change(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(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() + + @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..64c22898df 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -3,9 +3,17 @@ 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 ( + WorkflowApproval, + WorkflowApprovalTemplate, + WorkflowJob, + WorkflowJobTemplate, + WorkflowJobTemplateNode, +) from awx.main.models.credential import Credential +from awx.main.scheduler import TaskManager @pytest.fixture @@ -19,31 +27,18 @@ 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.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 +@pytest.fixture +def approval_node(workflow_job_template, admin_user): + return WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template ) - assert 'unified_job_template' in r.data @pytest.mark.django_db @@ -76,6 +71,191 @@ 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): + # 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'}) + 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], # 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', + 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, 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 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_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 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 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 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=400) + + @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': '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 == '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. + 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=400) + + 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) + 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(): @pytest.fixture 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 + ]) diff --git a/awx/main/tests/functional/test_copy.py b/awx/main/tests/functional/test_copy.py index a4d2859110..7be582d6c8 100644 --- a/awx/main/tests/functional/test_copy.py +++ b/awx/main/tests/functional/test_copy.py @@ -3,7 +3,9 @@ from unittest import mock from awx.api.versioning import reverse from awx.main.utils import decrypt_field -from awx.main.models.workflow import WorkflowJobTemplateNode +from awx.main.models.workflow import ( + WorkflowJobTemplate, WorkflowJobTemplateNode, WorkflowApprovalTemplate +) from awx.main.models.jobs import JobTemplate from awx.main.tasks import deep_copy_model_obj @@ -175,6 +177,76 @@ def test_workflow_job_template_copy(workflow_job_template, post, get, admin, org assert copied_node_list[4] in copied_node_list[3].failure_nodes.all() +@pytest.mark.django_db +def test_workflow_approval_node_copy(workflow_job_template, post, get, admin, organization): + workflow_job_template.organization = organization + workflow_job_template.save() + ajts = [ + WorkflowApprovalTemplate.objects.create( + name='test-approval-{}'.format(i), + description='description-{}'.format(i), + timeout=30 + ) + for i in range(0, 5) + ] + nodes = [ + WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template, unified_job_template=ajts[i] + ) for i in range(0, 5) + ] + nodes[0].success_nodes.add(nodes[1]) + nodes[1].success_nodes.add(nodes[2]) + nodes[0].failure_nodes.add(nodes[3]) + nodes[3].failure_nodes.add(nodes[4]) + assert WorkflowJobTemplate.objects.count() == 1 + assert WorkflowJobTemplateNode.objects.count() == 5 + assert WorkflowApprovalTemplate.objects.count() == 5 + + with mock.patch('awx.api.generics.trigger_delayed_deep_copy') as deep_copy_mock: + wfjt_copy_id = post( + reverse('api:workflow_job_template_copy', kwargs={'pk': workflow_job_template.pk}), + {'name': 'new wfjt name'}, admin, expect=201 + ).data['id'] + wfjt_copy = type(workflow_job_template).objects.get(pk=wfjt_copy_id) + args, kwargs = deep_copy_mock.call_args + deep_copy_model_obj(*args, **kwargs) + assert wfjt_copy.organization == organization + assert wfjt_copy.created_by == admin + assert wfjt_copy.name == 'new wfjt name' + + assert WorkflowJobTemplate.objects.count() == 2 + assert WorkflowJobTemplateNode.objects.count() == 10 + assert WorkflowApprovalTemplate.objects.count() == 10 + original_templates = [ + x.unified_job_template for x in workflow_job_template.workflow_job_template_nodes.all() + ] + copied_templates = [ + x.unified_job_template for x in wfjt_copy.workflow_job_template_nodes.all() + ] + + # make sure shallow fields like `timeout` are copied properly + for i, t in enumerate(original_templates): + assert t.timeout == 30 + assert t.description == 'description-{}'.format(i) + + for i, t in enumerate(copied_templates): + assert t.timeout == 30 + assert t.description == 'description-{}'.format(i) + + # the Approval Template IDs on the *original* WFJT should not match *any* + # of the Approval Template IDs on the *copied* WFJT + assert not set([x.id for x in original_templates]).intersection( + set([x.id for x in copied_templates]) + ) + + # if you remove the " copy" suffix from the copied template names, they + # should match the original templates + assert ( + set([x.name for x in original_templates]) == + set([x.name.replace(' copy', '') for x in copied_templates]) + ) + + @pytest.mark.django_db def test_credential_copy(post, get, machine_credential, credentialtype_ssh, admin): assert get( diff --git a/awx/ui/client/features/templates/routes/organizationsTemplatesList.route.js b/awx/ui/client/features/templates/routes/organizationsTemplatesList.route.js index c0c7c3fdb7..4d306b2857 100644 --- a/awx/ui/client/features/templates/routes/organizationsTemplatesList.route.js +++ b/awx/ui/client/features/templates/routes/organizationsTemplatesList.route.js @@ -10,12 +10,7 @@ export default { name: 'organizations.job_templates', data: { activityStream: true, - activityStreamTarget: 'template', - socket: { - "groups": { - "jobs": ["status_changed"] - } - } + activityStreamTarget: 'template' }, params: { template_search: { diff --git a/awx/ui/client/features/templates/routes/templatesList.route.js b/awx/ui/client/features/templates/routes/templatesList.route.js index 63d58fb83d..4b17ab471d 100644 --- a/awx/ui/client/features/templates/routes/templatesList.route.js +++ b/awx/ui/client/features/templates/routes/templatesList.route.js @@ -13,12 +13,7 @@ export default { }, data: { activityStream: true, - activityStreamTarget: 'template', - socket: { - "groups": { - "jobs": ["status_changed"] - } - } + activityStreamTarget: 'template' }, params: { template_search: { diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index d3c963b7e4..13e3ce6639 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -110,14 +110,17 @@ 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'), 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 +147,13 @@ 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'), + 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'), + TIMEOUT: t.s('Timeout'), + APPROVED: t.s('APPROVED'), + DENIED: t.s('DENIED') }; } 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..a2c58c854a --- /dev/null +++ b/awx/ui/client/lib/components/approvalsDrawer/_index.less @@ -0,0 +1,68 @@ +.at-ApprovalsDrawer { + position: fixed; + top: 0; + 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; + padding: 20px; + overflow-y: scroll; + } + + &--header { + display: flex; + width: 100%; + margin-bottom: 20px; + } + + &--title { + flex: 1 0 auto; + color: @default-interface-txt; + font-size: 14px; + font-weight: bold; + } + + &--actionRow { + display: flex; + justify-content: flex-end; + width: 100%; + margin-top: 10px; + line-height: 30px; + + button { + margin-left: 15px; + } + } + + &--exit { + justify-content: flex-end; + display: flex; + + button { + height: 20px; + font-size: 20px; + color: @d7grey; + line-height: 1; + opacity: 1; + } + + button:hover{ + color: @default-icon; + opacity: 1; + } + } + + &--expires { + color: @default-err; + } +} 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..47443c524e --- /dev/null +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.directive.js @@ -0,0 +1,76 @@ +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.strings = strings; + vm.toolbarSortValue = toolbarSortDefault; + vm.queryset = { + page: 1, + page_size: 5, + order_by: 'created', + status: 'pending' + }; + vm.emptyListReason = vm.strings.get('approvals.NONE'); + + vm.toolbarSortOptions = [ + toolbarSortDefault, + { label: `${vm.strings.get('sort.CREATED_DESCENDING')}`, value: '-created' } + ]; + + const loadTheList = () => { + const queryParams = Object.keys(vm.queryset).map(key => `${key}=${vm.queryset[key]}`).join('&'); + Rest.setUrl(`${GetBasePath('workflow_approvals')}?${queryParams}`); + return Rest.get() + .then(({ data }) => { + vm.dataset = data; + vm.approvals = data.results; + vm.count = data.count; + $rootScope.pendingApprovalCount = data.count; + }); + }; + + loadTheList() + .then(() => { vm.listLoaded = true; }); + + vm.approve = (approval) => { + Rest.setUrl(`${GetBasePath('workflow_approvals')}${approval.id}/approve`); + Rest.post() + .then(() => loadTheList()); + }; + + vm.deny = (approval) => { + Rest.setUrl(`${GetBasePath('workflow_approvals')}${approval.id}/deny`); + 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']; + +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..b9132e79cd --- /dev/null +++ b/awx/ui/client/lib/components/approvalsDrawer/approvalsDrawer.partial.html @@ -0,0 +1,88 @@ +
+
+
+
+ + {{:: vm.strings.get('approvals.NOTIFICATIONS') }} + + + {{vm.count}} + +
+
+ +
+
+ + + + +
+
+
+ + +
+
+
+ + + + +
+
+ + + + + + + +
+
+
+
{{:: vm.strings.get('approvals.CONTINUE') }}
+ + +
+
+
+
+
+ + +
+
diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 8ca1e26115..436e0b3dda 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -119,6 +119,18 @@ function ComponentsStrings (BaseString) { EXPANDED: t.s('Expanded'), SORT_BY: t.s('SORT BY') }; + + ns.approvals = { + APPROVAL: t.s('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'), + EXPIRES: t.s('Expires:'), + EXPIRES_NEVER: t.s('Expires: Never') + }; } 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..4491118bef 100644 --- a/awx/ui/client/lib/components/layout/_index.less +++ b/awx/ui/client/lib/components/layout/_index.less @@ -81,6 +81,30 @@ opacity: 0; } } + + .at-Layout-Approvals { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + } + + .at-Layout-ApprovalsBadge { + margin-left: 10px; + padding: 5px; + border-radius: 3px; + background-color: @at-gray-646972; + color: @at-white; + height: 16px; + font-size: 11px; + cursor: default; + display: flex; + align-items: center; + } + + .at-Layout-ApprovalsBadgeActive { + background-color: @at-red-bright; + } } &-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..d48c607abc 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/activity-stream/factories/build-anchor.factory.js b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js index 5a9435c10a..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 @@ -93,6 +93,14 @@ 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 + ' | ' + 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 + '/'; } 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..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 @@ -124,7 +124,26 @@ 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') { + 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 { + 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 65d08e16dd..54e05c4a1e 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', 'ProcessErrors', + 'SocketService', 'AppStrings', '$transitions', 'i18n', + 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, ProcessErrors, + SocketService, AppStrings, $transitions, i18n) { $rootScope.$state = $state; $rootScope.$state.matches = function(stateName) { @@ -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,6 +405,7 @@ angular } }); }); + 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..a395e97b01 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: ${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/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 1d92ae0f94..6ce18559f6 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', 'ProcessErrors', '$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', 'i18n', + function ($log, $cookies, $rootScope, ProcessErrors, + $location, Authorization, Alert, Wait, Timer, + Empty, scope, pendoService, ConfigService, + CheckLicense, SocketService, Rest, GetBasePath, i18n) { var lastPath, lastUser, sessionExpired, loginAgain, preAuthUrl; loginAgain = function() { @@ -132,6 +132,18 @@ export default ['$log', '$cookies', '$compile', '$rootScope', $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( () => { 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/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/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index b0a770f02f..24c7ec0234 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/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/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index d97b91681b..5aeff6e63b 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -218,6 +218,7 @@ export default [ 'ProcessErrors', 'CredentialTypeModel', 'TemplatesStrings', '$f if(vm.steps[step].tab) { if(vm.steps[step].tab.order === currentTab.order) { vm.steps[step].tab._active = false; + vm.steps[step].tab._disabled = true; } else if(vm.steps[step].tab.order === currentTab.order + 1) { activeTab = currentTab; vm.steps[step].tab._active = true; diff --git a/awx/ui/client/src/templates/prompt/prompt.partial.html b/awx/ui/client/src/templates/prompt/prompt.partial.html index 1ebfcb9977..6bd2a123af 100644 --- a/awx/ui/client/src/templates/prompt/prompt.partial.html +++ b/awx/ui/client/src/templates/prompt/prompt.partial.html @@ -45,7 +45,7 @@
- - + + \ No newline at end of file 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 760ceafc7d..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 @@ -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 { @@ -296,8 +305,8 @@ border-bottom-left-radius: 5px; } -.WorkflowMaker-formTab { - margin-right: 10px; +.WorkflowMaker-formTypeDropdown { + margin-bottom: 20px; } .WorkflowMaker-preventBodyScrolling { @@ -314,6 +323,20 @@ margin-bottom: 20px; } +.WorkflowMaker-timeoutInput { + .ui-spinner { + width: 100px; + } +} + +.WorkflowMaker-timeoutSeconds { + margin-left: 10px; +} + +.WorkflowMaker-timeoutLabel { + margin-left: 3px; +} + .Key-list { margin: 0; padding: 20px; @@ -373,11 +396,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 68fcfda0b9..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 @@ -5,11 +5,11 @@ *************************************************/ export default ['$scope', 'TemplatesService', - 'ProcessErrors', '$q', + 'ProcessErrors', '$q', 'Rest', 'PromptService', 'TemplatesStrings', 'WorkflowChartService', 'Wait', '$state', function ($scope, TemplatesService, - ProcessErrors, $q, + ProcessErrors, $q, Rest, PromptService, TemplatesStrings, WorkflowChartService, Wait, $state ) { @@ -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({ @@ -140,59 +140,116 @@ 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") { + addPromises.push(TemplatesService.addWorkflowNode({ + url: $scope.workflowJobTemplateObj.related.workflow_nodes, + data: {} + }).then(({data: newNodeData}) => { + Rest.setUrl(newNodeData.related.create_approval_template); + approvalTemplatePromises.push(Rest.post({ + name: node.unifiedJobTemplate.name, + description: node.unifiedJobTemplate.description, + timeout: node.unifiedJobTemplate.timeout + }).then(() => { + node.originalNodeObject = newNodeData; + nodeIdToChartNodeIdMapping[newNodeData.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') }); + })); + } else { + addPromises.push(TemplatesService.addWorkflowNode({ + url: $scope.workflowJobTemplateObj.related.workflow_nodes, + data: buildSendableNodeData(node) + }).then(({data: newNodeData}) => { + node.originalNodeObject = newNodeData; + nodeIdToChartNodeIdMapping[newNodeData.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 - } + credentialIdsToPost.forEach((credentialToPost) => { + credentialRequests.push({ + id: newNodeData.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") { + Rest.setUrl(node.originalNodeObject.related.unified_job_template); + approvalTemplatePromises.push(Rest.patch({ + name: node.unifiedJobTemplate.name, + description: node.unifiedJobTemplate.description, + timeout: node.unifiedJobTemplate.timeout + }).catch(({ data, status }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER') + }); + })); + } else { + Rest.setUrl(node.originalNodeObject.related.create_approval_template); + approvalTemplatePromises.push(Rest.post({ + name: node.unifiedJobTemplate.name, + description: node.unifiedJobTemplate.description, + timeout: node.unifiedJobTemplate.timeout + }).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 +257,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 +267,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 +276,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 @@ -237,162 +294,165 @@ export default ['$scope', 'TemplatesService', $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; - } - }); - - 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": - 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; - } - }); - }); - - $q.all(disassociatePromises) + $q.all(approvalTemplatePromises) .then(() => { - let credentialPromises = credentialRequests.map((request) => { - return TemplatesService.postWorkflowNodeCredential({ - id: request.id, - data: request.data - }); + let disassociatePromises = []; + 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; + } }); - return $q.all(associatePromises.concat(credentialPromises)) + 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" + }) + ); + } + }); + } + }); + + $q.all(disassociatePromises) .then(() => { - Wait('stop'); - $scope.workflowChangesUnsaved = false; - $scope.workflowChangesStarted = false; - $scope.closeDialog(); - }).catch(({ data, status }) => { + let associatePromises = []; + 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; + } + }); + }); + + 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(({ data, status }) => { Wait('stop'); @@ -400,7 +460,6 @@ export default ['$scope', 'TemplatesService', hdr: $scope.strings.get('error.HEADER') }); }); - } else { let deletePromises = deletedNodeIds.map((nodeId) => { @@ -511,17 +570,33 @@ export default ['$scope', 'TemplatesService', $scope.formState.showNodeForm = true; }; - $scope.confirmNodeForm = (selectedTemplate, promptData, edgeType) => { + $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 (selectedTemplate && edgeType && edgeType.value) { - nodeRef[$scope.nodeConfig.nodeId] = { - fullUnifiedJobTemplateObject: selectedTemplate, - promptData, - isNew: true - }; - + if (edgeType && edgeType.value && selectedTemplate) { + if (isPauseNode) { + nodeRef[$scope.nodeConfig.nodeId] = { + unifiedJobTemplate: { + name: selectedTemplate.name, + description: selectedTemplate.description, + timeout: selectedTemplate.timeout, + unified_job_type: "workflow_approval" + }, + isNew: true + }; + } else { + nodeRef[$scope.nodeConfig.nodeId] = { + fullUnifiedJobTemplateObject: selectedTemplate, + promptData, + isNew: true + }; + } $scope.graphState.nodeBeingAdded = null; $scope.graphState.arrayOfLinksForChart.map( (link) => { @@ -533,9 +608,23 @@ export default ['$scope', 'TemplatesService', } } else if ($scope.nodeConfig.mode === "edit") { if (selectedTemplate) { - nodeRef[$scope.nodeConfig.nodeId].fullUnifiedJobTemplateObject = 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) => { @@ -551,7 +640,17 @@ export default ['$scope', 'TemplatesService', $scope.graphState.arrayOfNodesForChart.map( (node) => { if (node.id === nodeId) { - node.unifiedJobTemplate = selectedTemplate; + if (isPauseNode) { + node.unifiedJobTemplate = { + unified_job_type: 'workflow_approval', + name: selectedTemplate.name, + description: selectedTemplate.description, + timeout: selectedTemplate.timeout, + }; + } 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..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')}} @@ -128,7 +96,7 @@
- + 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}}

    -
  • -
+
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: { 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/' diff --git a/docs/workflow.md b/docs/workflow.md index 8043b0f37e..3ccfb7d367 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 (in minutes and seconds) can be set for each approval node. These fields default 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.