Merge pull request #4264 from beeankha/workflow_pause_approve

Workflow Approval Nodes

Reviewed-by: Ryan Petrello
             https://github.com/ryanpetrello
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-08-28 22:25:39 +00:00 committed by GitHub
commit 2918b6c927
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 3179 additions and 1808 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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'),

View File

@ -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<pk>[0-9]+)/$', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'),
url(r'^(?P<pk>[0-9]+)/approve/$', WorkflowApprovalApprove.as_view(), name='workflow_approval_approve'),
url(r'^(?P<pk>[0-9]+)/deny/$', WorkflowApprovalDeny.as_view(), name='workflow_approval_deny'),
]
__all__ = ['urls']

View File

@ -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<pk>[0-9]+)/$', WorkflowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'),
url(r'^(?P<pk>[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'),
]
__all__ = ['urls']

View File

@ -10,6 +10,7 @@ from awx.api.views import (
WorkflowJobTemplateNodeFailureNodesList,
WorkflowJobTemplateNodeAlwaysNodesList,
WorkflowJobTemplateNodeCredentialsList,
WorkflowJobTemplateNodeCreateApproval,
)
@ -20,6 +21,7 @@ urls = [
url(r'^(?P<pk>[0-9]+)/failure_nodes/$', WorkflowJobTemplateNodeFailureNodesList.as_view(), name='workflow_job_template_node_failure_nodes_list'),
url(r'^(?P<pk>[0-9]+)/always_nodes/$', WorkflowJobTemplateNodeAlwaysNodesList.as_view(), name='workflow_job_template_node_always_nodes_list'),
url(r'^(?P<pk>[0-9]+)/credentials/$', WorkflowJobTemplateNodeCredentialsList.as_view(), name='workflow_job_template_node_credentials_list'),
url(r'^(?P<pk>[0-9]+)/create_approval_template/$', WorkflowJobTemplateNodeCreateApproval.as_view(), name='workflow_job_template_node_create_approval'),
]
__all__ = ['urls']

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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.

View File

@ -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.'),
),
]

View File

@ -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'))

View File

@ -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)

View File

@ -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')]

View File

@ -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:

View File

@ -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',
)

View File

@ -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'),
}

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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
])

View File

@ -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(

View File

@ -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: {

View File

@ -13,12 +13,7 @@ export default {
},
data: {
activityStream: true,
activityStreamTarget: 'template',
socket: {
"groups": {
"jobs": ["status_changed"]
}
}
activityStreamTarget: 'template'
},
params: {
template_search: {

View File

@ -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')
};
}

View File

@ -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;
}

View File

@ -1,4 +1,5 @@
@import 'action/_index';
@import 'approvalsDrawer/_index';
@import 'dialog/_index';
@import 'input/_index';
@import 'launchTemplateButton/_index';

View File

@ -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;
}
}

View File

@ -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;

View File

@ -0,0 +1,88 @@
<div class="at-ApprovalsDrawer">
<div class="at-ApprovalsDrawer--drawer" ng-if="vm.listLoaded">
<div class="at-ApprovalsDrawer--header">
<div class="at-ApprovalsDrawer--title">
<span>
{{:: vm.strings.get('approvals.NOTIFICATIONS') }}
</span>
<span class="at-Panel-headingTitleBadge">
{{vm.count}}
</span>
</div>
<div class="at-ApprovalsDrawer--exit">
<button class="close" ng-click="closeApprovals()">
<i class="fa fa-times-circle"></i>
</button>
</div>
</div>
<at-list-toolbar
ng-if="vm.approvals.length > 0"
sort-only="true"
sort-value="vm.toolbarSortValue"
sort-options="vm.toolbarSortOptions"
on-sort="vm.onToolbarSort">
</at-list-toolbar>
<at-list results="vm.approvals" id="approvals_list" empty-list-reason="{{ vm.emptyListReason }}">
<at-row ng-repeat="approval in vm.approvals"
id="approval-row-{{ approval.id }}">
<div class="at-Row-items">
<div class="at-Row-container">
<div class="at-Row-container">
<at-row-item
header-value="{{ approval.summary_fields.source_workflow_job.name }}"
header-state="workflowResults({id: {{approval.summary_fields.source_workflow_job.id}}})">
</at-row-item>
</div>
</div>
<div class="at-Row-container--wrapped">
<at-row-item
value-bind-html="<b>{{:: vm.strings.get('approvals.APPROVAL') }}</b>">
</at-row-item>
<at-row-item
value-bind-html="{{ approval.name }}">
</at-row-item>
</div>
<div class="at-Row-container--wrapped">
<at-row-item
value-bind-html="{{ approval.created | longDate }}">
</at-row-item>
<!-- todo: translate strings -->
<at-row-item
ng-if="approval.approval_expiration"
class="at-ApprovalsDrawer--expires"
value-bind-html="{{:: vm.strings.get('approvals.EXPIRES') }} {{ approval.approval_expiration | longDate }}">
</at-row-item>
<at-row-item
ng-if="!approval.approval_expiration"
class="at-ApprovalsDrawer--expires"
value-bind-html="{{:: vm.strings.get('approvals.EXPIRES_NEVER') }}">
</at-row-item>
</div>
<div class="at-Row-container--wrapped" ng-if="approval.can_approve_or_deny">
<div class="at-ApprovalsDrawer--actionRow">
<div>{{:: vm.strings.get('approvals.CONTINUE') }}</div>
<button class="btn at-Button--success"
ng-click="vm.approve(approval)"
type="button">
{{:: vm.strings.get('approvals.APPROVE') }}
</button>
<button class="btn at-Button--error"
ng-click="vm.deny(approval)"
type="button">
{{:: vm.strings.get('approvals.DENY') }}
</button>
</div>
</div>
</div>
</at-row>
</at-list>
<paginate
collection="vm.approvals"
dataset="vm.dataset"
iterator="template"
base-path="unified_job_templates"
query-set="vm.queryset"
hide-view-per-page="true">
</paginate>
</div>
</div>

View File

@ -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'];

View File

@ -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)

View File

@ -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 {

View File

@ -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)

View File

@ -14,6 +14,12 @@
<span>{{ $parent.layoutVm.currentUsername }}</span>
</a>
</at-top-nav-item>
<at-top-nav-item ng-click="vm.openApprovals()">
<div class="at-Layout-Approvals">
<i class="fa fa-bell" alt="{{ vm.getString('NOTIFICATIONS') }}"></i>
<span class="at-Layout-ApprovalsBadge" ng-class="{'at-Layout-ApprovalsBadgeActive': vm.approvalsCount && vm.approvalsCount > 0}">{{vm.approvalsCount}}</span>
</div>
</at-top-nav-item>
<at-top-nav-item>
<a ui-sref="about">
<i class="fa fa-info-circle" alt="{{ vm.getString('ABOUT') }}"></i>
@ -104,4 +110,5 @@
<ng-transclude></ng-transclude>
</div>
</div>
<at-approvals-drawer ng-if="vm.isLoggedIn && vm.showApprovals" close-approvals="vm.closeApprovals()"></at-approvals-drawer>
</div>

View File

@ -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;

View File

@ -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 + '/';
}

View File

@ -124,7 +124,26 @@ export default function BuildDescription(BuildAnchor, $log, i18n) {
break;
// expected outcome: "operation <object1>"
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);

View File

@ -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});
}

View File

@ -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();
}
}

View File

@ -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];
}

View File

@ -94,9 +94,12 @@
<div class="DashboardGraphs-graphContainer" auto-size-module
graph-type="jobsStatus"
ng-class="{'is-selected': jobStatusSelected }">
<job-status-graph class="DashboardGraphs-graph
DashboardGraphs-graph--jobStatusGraph"
data="graphData.jobStatus" period="month" job-type="all">
<job-status-graph
class="DashboardGraphs-graph DashboardGraphs-graph--jobStatusGraph"
data="graphData.jobStatus"
period="graphData.period"
job-type="graphData.jobType"
status="graphData.status">
</job-status-graph>
</div>
</div>

View File

@ -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(`
<span>${this.text}</span>
@ -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(`
<span>${this.text}</span>
@ -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(`
<span>${this.text}</span>
<i class="fa fa-angle-down DashboardGraphs-filterIcon"></i>
`);
recreateGraph(scope.period, scope.jobType, job_status);
recreateGraph(scope.period, scope.jobType, this.getAttribute("id"));
});
adjustGraphSize(job_status_chart, element);

View File

@ -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);
});
}
};

View File

@ -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}`) });
});
}
];

View File

@ -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")

View File

@ -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();
}

View File

@ -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 {

View File

@ -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( () => {

View File

@ -175,12 +175,7 @@ let lists = [{
},
data: {
activityStream: true,
activityStreamTarget: 'organization',
socket: {
"groups": {
"jobs": ["status_changed"]
}
},
activityStreamTarget: 'organization'
},
ncyBreadcrumb: {
parent: "organizations.edit",

View File

@ -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;

View File

@ -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;
});
}

View File

@ -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: `<workflow-maker ng-if="includeWorkflowMaker" workflow-job-template-obj="workflow_job_template_obj" can-add-or-edit="canAddOrEdit"></workflow-maker>`
},
'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;
}
]
}
};

View File

@ -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;

View File

@ -45,7 +45,7 @@
<div class="Prompt-footer">
<button id="prompt_cancel" class="Prompt-defaultButton" ng-click="vm.cancel()" ng-show="!vm.readOnlyPrompts">{{:: vm.strings.get('CANCEL') }}</button>
<button id="prompt_close" class="Prompt-defaultButton" ng-click="vm.cancel()" ng-show="vm.readOnlyPrompts">{{:: vm.strings.get('CLOSE') }}</button>
<button id="prompt_inventory_next" class="Prompt-actionButton" ng-show="vm.steps.inventory.tab._active" ng-click="vm.next(vm.steps.inventory.tab)" ng-disabled="vm.promptData.templateType === 'workflow_job_template' && !vm.promptDataClone.prompts.inventory.value.id && vm.promptDataClone.launchConf.defaults.inventory.id && !vm.readOnlyPrompts">{{:: vm.strings.get('NEXT') }}</button>
<button id="prompt_inventory_next" class="Prompt-actionButton" ng-show="vm.steps.inventory.tab._active" ng-click="vm.next(vm.steps.inventory.tab)" ng-disabled="((vm.promptData.templateType === 'job_template' && !vm.promptDataClone.prompts.inventory.value.id) || (vm.promptData.templateType === 'workflow_job_template' && !vm.promptDataClone.prompts.inventory.value.id && vm.promptDataClone.launchConf.defaults.inventory.id)) && !vm.readOnlyPrompts">{{:: vm.strings.get('NEXT') }}</button>
<button id="prompt_credential_next" class="Prompt-actionButton"
ng-show="vm.steps.credential.tab._active"
ng-click="vm.next(vm.steps.credential.tab)"

View File

@ -75,221 +75,221 @@ export default ['Rest', 'GetBasePath', '$q', 'NextPage', function(Rest, GetBaseP
}).catch(function(response){
return $q.reject( response );
});
},
},
getAllWorkflowJobTemplateLabels: function(id) {
Rest.setUrl(GetBasePath('workflow_job_templates') + id + "/labels?page_size=200");
return Rest.get()
.then(function(res) {
if (res.data.next) {
return NextPage({
url: res.data.next,
arrayOfValues: res.data.results
}).then(function(labels) {
return labels;
}).catch(function(response){
return $q.reject( response );
});
}
else {
return $q.resolve( res.data.results );
}
}).catch(function(response){
return $q.reject( response );
});
},
getJobTemplate: function(id) {
var url = GetBasePath('job_templates');
getAllWorkflowJobTemplateLabels: function(id) {
Rest.setUrl(GetBasePath('workflow_job_templates') + id + "/labels?page_size=200");
return Rest.get()
.then(function(res) {
if (res.data.next) {
return NextPage({
url: res.data.next,
arrayOfValues: res.data.results
}).then(function(labels) {
return labels;
}).catch(function(response){
return $q.reject( response );
});
}
else {
return $q.resolve( res.data.results );
}
}).catch(function(response){
return $q.reject( response );
});
},
getJobTemplate: function(id) {
var url = GetBasePath('job_templates');
url = url + id;
url = url + id;
Rest.setUrl(url);
return Rest.get();
},
addWorkflowNode: function(params) {
// params.url
// params.data
Rest.setUrl(url);
return Rest.get();
},
addWorkflowNode: function(params) {
// params.url
// params.data
Rest.setUrl(params.url);
return Rest.post(params.data);
},
editWorkflowNode: function(params) {
// params.id
// params.data
Rest.setUrl(params.url);
return Rest.post(params.data);
},
editWorkflowNode: function(params) {
// params.id
// params.data
var url = GetBasePath('workflow_job_template_nodes') + params.id;
var url = GetBasePath('workflow_job_template_nodes') + params.id;
Rest.setUrl(url);
return Rest.put(params.data);
},
getJobTemplateLaunchInfo: function(id) {
var url = GetBasePath('job_templates');
Rest.setUrl(url);
return Rest.put(params.data);
},
getJobTemplateLaunchInfo: function(id) {
var url = GetBasePath('job_templates');
url = url + id + '/launch';
url = url + id + '/launch';
Rest.setUrl(url);
return Rest.get();
},
getWorkflowJobTemplateNodes: function(id, page) {
var url = GetBasePath('workflow_job_templates');
Rest.setUrl(url);
return Rest.get();
},
getWorkflowJobTemplateNodes: function(id, page) {
var url = GetBasePath('workflow_job_templates');
url = url + id + '/workflow_nodes?page_size=200';
url = url + id + '/workflow_nodes?page_size=200';
if(page) {
url += '&page=' + page;
}
if(page) {
url += '&page=' + page;
}
Rest.setUrl(url);
return Rest.get();
},
updateWorkflowJobTemplate: function(params) {
// params.id
// params.data
Rest.setUrl(url);
return Rest.get();
},
updateWorkflowJobTemplate: function(params) {
// params.id
// params.data
var url = GetBasePath('workflow_job_templates');
var url = GetBasePath('workflow_job_templates');
url = url + params.id;
url = url + params.id;
Rest.setUrl(url);
return Rest.patch(params.data);
},
getWorkflowJobTemplate: function(id) {
var url = GetBasePath('workflow_job_templates');
Rest.setUrl(url);
return Rest.patch(params.data);
},
getWorkflowJobTemplate: function(id) {
var url = GetBasePath('workflow_job_templates');
url = url + id;
url = url + id;
Rest.setUrl(url);
return Rest.get();
},
deleteWorkflowJobTemplateNode: function(id) {
var url = GetBasePath('workflow_job_template_nodes') + id;
Rest.setUrl(url);
return Rest.get();
},
deleteWorkflowJobTemplateNode: function(id) {
var url = GetBasePath('workflow_job_template_nodes') + id;
Rest.setUrl(url);
return Rest.destroy();
},
disassociateWorkflowNode: function(params) {
//params.parentId
//params.nodeId
//params.edge
Rest.setUrl(url);
return Rest.destroy();
},
disassociateWorkflowNode: function(params) {
//params.parentId
//params.nodeId
//params.edge
var url = GetBasePath('workflow_job_template_nodes') + params.parentId;
var url = GetBasePath('workflow_job_template_nodes') + params.parentId;
if(params.edge === 'success') {
url = url + '/success_nodes';
}
else if(params.edge === 'failure') {
url = url + '/failure_nodes';
}
else if(params.edge === 'always') {
url = url + '/always_nodes';
}
if(params.edge === 'success') {
url = url + '/success_nodes';
}
else if(params.edge === 'failure') {
url = url + '/failure_nodes';
}
else if(params.edge === 'always') {
url = url + '/always_nodes';
}
Rest.setUrl(url);
return Rest.post({
"id": params.nodeId,
"disassociate": true
});
},
associateWorkflowNode: function(params) {
//params.parentId
//params.nodeId
//params.edge
Rest.setUrl(url);
return Rest.post({
"id": params.nodeId,
"disassociate": true
});
},
associateWorkflowNode: function(params) {
//params.parentId
//params.nodeId
//params.edge
var url = GetBasePath('workflow_job_template_nodes') + params.parentId;
var url = GetBasePath('workflow_job_template_nodes') + params.parentId;
if(params.edge === 'success') {
url = url + '/success_nodes';
}
else if(params.edge === 'failure') {
url = url + '/failure_nodes';
}
else if(params.edge === 'always') {
url = url + '/always_nodes';
}
if(params.edge === 'success') {
url = url + '/success_nodes';
}
else if(params.edge === 'failure') {
url = url + '/failure_nodes';
}
else if(params.edge === 'always') {
url = url + '/always_nodes';
}
Rest.setUrl(url);
return Rest.post({
id: params.nodeId
});
},
getUnifiedJobTemplate: function(id) {
var url = GetBasePath('unified_job_templates');
Rest.setUrl(url);
return Rest.post({
id: params.nodeId
});
},
getUnifiedJobTemplate: function(id) {
var url = GetBasePath('unified_job_templates');
url = url + "?id=" + id;
url = url + "?id=" + id;
Rest.setUrl(url);
return Rest.get();
},
getCredential: function(id) {
var url = GetBasePath('credentials');
Rest.setUrl(url);
return Rest.get();
},
getCredential: function(id) {
var url = GetBasePath('credentials');
url = url + id;
url = url + id;
Rest.setUrl(url);
return Rest.get();
},
getInventory: function(id) {
var url = GetBasePath('inventory');
Rest.setUrl(url);
return Rest.get();
},
getInventory: function(id) {
var url = GetBasePath('inventory');
url = url + id;
url = url + id;
Rest.setUrl(url);
return Rest.get();
},
getWorkflowCopy: function(id) {
let url = GetBasePath('workflow_job_templates');
Rest.setUrl(url);
return Rest.get();
},
getWorkflowCopy: function(id) {
let url = GetBasePath('workflow_job_templates');
url = url + id + '/copy';
url = url + id + '/copy';
Rest.setUrl(url);
return Rest.get();
},
copyWorkflow: function(id) {
let url = GetBasePath('workflow_job_templates');
Rest.setUrl(url);
return Rest.get();
},
copyWorkflow: function(id) {
let url = GetBasePath('workflow_job_templates');
url = url + id + '/copy';
url = url + id + '/copy';
Rest.setUrl(url);
return Rest.post();
},
getWorkflowJobTemplateOptions: function() {
var deferred = $q.defer();
Rest.setUrl(url);
return Rest.post();
},
getWorkflowJobTemplateOptions: function() {
var deferred = $q.defer();
let url = GetBasePath('workflow_job_templates');
let url = GetBasePath('workflow_job_templates');
Rest.setUrl(url);
Rest.options()
.then(({data}) => {
deferred.resolve(data);
}).catch(({msg, code}) => {
deferred.reject(msg, code);
});
Rest.setUrl(url);
Rest.options()
.then(({data}) => {
deferred.resolve(data);
}).catch(({msg, code}) => {
deferred.reject(msg, code);
});
return deferred.promise;
},
getJobTemplateOptions: function() {
var deferred = $q.defer();
return deferred.promise;
},
getJobTemplateOptions: function() {
var deferred = $q.defer();
let url = GetBasePath('job_templates');
let url = GetBasePath('job_templates');
Rest.setUrl(url);
Rest.options()
.then(({data}) => {
deferred.resolve(data);
}).catch(({msg, code}) => {
deferred.reject(msg, code);
});
Rest.setUrl(url);
Rest.options()
.then(({data}) => {
deferred.resolve(data);
}).catch(({msg, code}) => {
deferred.reject(msg, code);
});
return deferred.promise;
},
postWorkflowNodeCredential: function(params) {
// params.id
// params.data
return deferred.promise;
},
postWorkflowNodeCredential: function(params) {
// params.id
// params.data
var url = GetBasePath('workflow_job_template_nodes') + params.id + '/credentials';
var url = GetBasePath('workflow_job_template_nodes') + params.id + '/credentials';
Rest.setUrl(url);
return Rest.post(params.data);
}
Rest.setUrl(url);
return Rest.post(params.data);
}
};
}];

View File

@ -117,6 +117,7 @@
.WorkflowChart-nodeTypeLetter {
fill: @default-bg;
font-size: 10px;
}
.WorkflowChart-nodeStatus--running {
@ -142,9 +143,12 @@
}
.WorkflowChart-deletedText {
width: 90px;
width: 180px;
height: 14px;
color: @default-interface-txt;
text-align: center;
}
.WorkflowChart-activeNode {
fill: @default-link;
}
@ -159,7 +163,25 @@
}
.WorkflowChart-nameText {
width: 180px;
height: 20px;
line-height: 18px;
font-size: 10px;
text-align: center;
}
.WorkflowChart-timedOutText, .WorkflowChart-deniedText {
width: 180px;
height: 14px;
color: @default-err;
text-align: center;
}
.WorkflowChart-approvedText {
width: 180px;
height: 14px;
color: @default-succ;
text-align: center;
}
.WorkflowChart-tooltip {

View File

@ -0,0 +1,11 @@
/*************************************************
* Copyright (c) 2019 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import workflowKey from './workflow-key.directive';
export default
angular.module('workflowKey', [])
.directive('workflowKey', workflowKey);

View File

@ -0,0 +1,18 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default ['templateUrl', 'TemplatesStrings',
function(templateUrl, TemplatesStrings) {
return {
scope: {},
templateUrl: templateUrl('templates/workflows/workflow-key/workflow-key'),
restrict: 'E',
link: function(scope) {
scope.strings = TemplatesStrings;
}
};
}
];

View File

@ -0,0 +1,43 @@
<ul class="Key-list noselect">
<li class="Key-listItem">
<p class="Key-heading">{{:: strings.get('workflow_maker.KEY')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--success"></div>
<p class="Key-listItemContent">{{:: strings.get('workflow_maker.ON_SUCCESS')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--fail"></div>
<p class="Key-listItemContent">{{:: strings.get('workflow_maker.ON_FAILURE')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--always"></div>
<p class="Key-listItemContent">{{:: strings.get('workflow_maker.ALWAYS')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">JT</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{:: strings.get('workflow_maker.JOB_TEMPLATE')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">P</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{:: strings.get('workflow_maker.PROJECT_SYNC')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">I</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{:: strings.get('workflow_maker.INVENTORY_SYNC')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">W</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{:: strings.get('workflow_maker.WORKFLOW')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">
<span class="fa fa-pause"></span>
</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{:: strings.get('workflow_maker.PAUSE')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--warning">!</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{:: strings.get('workflow_maker.WARNING')}}</p>
</li>
</ul>

View File

@ -1,7 +1,9 @@
import workflowLinkForm from './workflow-link-form.directive';
import workflowNodeForm from './workflow-node-form.directive';
import workflowNodeFormService from './workflow-node-form.service';
export default
angular.module('templates.workflowMaker.forms', [])
.directive('workflowLinkForm', workflowLinkForm)
.directive('workflowNodeForm', workflowNodeForm);
.directive('workflowNodeForm', workflowNodeForm)
.service('WorkflowNodeFormService', workflowNodeFormService);

View File

@ -6,11 +6,11 @@
export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q',
'TemplatesStrings', 'CreateSelect2', 'Empty', 'QuerySet', '$filter',
'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', 'ProcessErrors',
'GetBasePath', 'WorkflowNodeFormService', 'ProcessErrors',
'i18n', 'ParseTypeChange', 'WorkflowJobTemplateModel',
function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q,
TemplatesStrings, CreateSelect2, Empty, qs, $filter,
GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors,
GetBasePath, WorkflowNodeFormService, ProcessErrors,
i18n, ParseTypeChange, WorkflowJobTemplate
) {
@ -33,58 +33,13 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
$scope.strings = TemplatesStrings;
$scope.editNodeHelpMessage = null;
let templateList = _.cloneDeep(TemplateList);
delete templateList.actions;
delete templateList.fields.type;
delete templateList.fields.description;
delete templateList.fields.smart_status;
delete templateList.fields.labels;
delete templateList.fieldActions;
templateList.name = 'wf_maker_templates';
templateList.iterator = 'wf_maker_template';
templateList.fields.name.columnClass = "col-md-8";
templateList.fields.name.tag = i18n._('WORKFLOW');
templateList.fields.name.showTag = "{{wf_maker_template.type === 'workflow_job_template'}}";
templateList.disableRow = "{{ readOnly }}";
templateList.disableRowValue = 'readOnly';
templateList.basePath = 'unified_job_templates';
templateList.fields.info = {
ngInclude: "'/static/partials/job-template-details.html'",
type: 'template',
columnClass: 'col-md-3',
infoHeaderClass: 'col-md-3',
label: '',
nosort: true
};
templateList.maxVisiblePages = 5;
templateList.searchBarFullWidth = true;
$scope.templateList = templateList;
let inventorySourceList = _.cloneDeep(InventorySourcesList);
inventorySourceList.name = 'wf_maker_inventory_sources';
inventorySourceList.iterator = 'wf_maker_inventory_source';
inventorySourceList.maxVisiblePages = 5;
inventorySourceList.searchBarFullWidth = true;
inventorySourceList.disableRow = "{{ readOnly }}";
inventorySourceList.disableRowValue = 'readOnly';
$scope.inventorySourceList = inventorySourceList;
let projectList = _.cloneDeep(ProjectList);
delete projectList.fields.status;
delete projectList.fields.scm_type;
delete projectList.fields.last_updated;
projectList.name = 'wf_maker_projects';
projectList.iterator = 'wf_maker_project';
projectList.fields.name.columnClass = "col-md-11";
projectList.maxVisiblePages = 5;
projectList.searchBarFullWidth = true;
projectList.disableRow = "{{ readOnly }}";
projectList.disableRowValue = 'readOnly';
$scope.projectList = projectList;
$scope.templateList = WorkflowNodeFormService.templateListDefinition();
$scope.inventorySourceList = WorkflowNodeFormService.inventorySourceListDefinition();
$scope.projectList = WorkflowNodeFormService.projectListDefinition();
const checkCredentialsForRequiredPasswords = () => {
let credentialRequiresPassword = false;
$scope.promptData.prompts.credentials.value.forEach((credential) => {
$scope.jobNodeState.promptData.prompts.credentials.value.forEach((credential) => {
if ((credential.passwords_needed &&
credential.passwords_needed.length > 0) ||
(_.has(credential, 'inputs.vault_password') &&
@ -94,86 +49,143 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
}
});
$scope.credentialRequiresPassword = credentialRequiresPassword;
$scope.jobNodeState.credentialRequiresPassword = credentialRequiresPassword;
};
const watchForPromptChanges = () => {
let promptDataToWatch = [
'promptData.prompts.inventory.value',
'promptData.prompts.verbosity.value',
'missingSurveyValue'
'jobNodeState.promptData.prompts.inventory.value',
'jobNodeState.promptData.prompts.verbosity.value',
'jobNodeState.missingSurveyValue'
];
promptWatcher = $scope.$watchGroup(promptDataToWatch, () => {
const templateType = _.get($scope, 'promptData.templateType');
const templateType = _.get($scope, 'jobNodeState.promptData.templateType');
let missingPromptValue = false;
if ($scope.missingSurveyValue) {
if ($scope.jobNodeState.missingSurveyValue) {
missingPromptValue = true;
}
if (templateType !== "workflow_job_template") {
if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) {
if (!$scope.jobNodeState.promptData.prompts.inventory.value || !$scope.jobNodeState.promptData.prompts.inventory.value.id) {
missingPromptValue = true;
}
}
$scope.promptModalMissingReqFields = missingPromptValue;
$scope.jobNodeState.promptModalMissingReqFields = missingPromptValue;
});
if ($scope.promptData.launchConf.ask_credential_on_launch && $scope.credentialRequiresPassword) {
credentialsWatcher = $scope.$watch('promptData.prompts.credentials', () => {
if ($scope.jobNodeState.promptData.launchConf.ask_credential_on_launch && $scope.jobNodeState.credentialRequiresPassword) {
credentialsWatcher = $scope.$watch('jobNodeState.promptData.prompts.credentials', () => {
checkCredentialsForRequiredPasswords();
});
}
};
const finishConfiguringAdd = () => {
$scope.selectedTemplate = null;
$scope.activeTab = "jobs";
const alwaysOption = {
label: $scope.strings.get('workflow_maker.ALWAYS'),
value: 'always'
};
const successOption = {
label: $scope.strings.get('workflow_maker.ON_SUCCESS'),
value: 'success'
};
const failureOption = {
label: $scope.strings.get('workflow_maker.ON_FAILURE'),
value: 'failure'
};
$scope.edgeTypeOptions = [alwaysOption];
switch($scope.nodeConfig.newNodeIsRoot) {
case true:
$scope.edgeType = alwaysOption;
break;
case false:
$scope.edgeType = successOption;
$scope.edgeTypeOptions.push(successOption, failureOption);
break;
const clearWatchers = () => {
if (promptWatcher) {
promptWatcher();
}
if (surveyQuestionWatcher) {
surveyQuestionWatcher();
}
if (credentialsWatcher) {
credentialsWatcher();
}
};
const select2ifyDropdowns = () => {
CreateSelect2({
element: '#workflow-node-types',
multiple: false
});
CreateSelect2({
element: '#workflow_node_edge',
multiple: false
});
};
$scope.nodeFormDataLoaded = true;
const formatPopOverDetails = (model) => {
const popOverDetails = {};
popOverDetails.playbook = model.playbook || i18n._('NONE SELECTED');
Object.keys(model.summary_fields).forEach(field => {
if (field === 'project') {
popOverDetails.project = model.summary_fields[field].name || i18n._('NONE SELECTED');
}
if (field === 'inventory') {
popOverDetails.inventory = model.summary_fields[field].name || i18n._('NONE SELECTED');
}
if (field === 'credentials') {
if (model.summary_fields[field].length <= 0) {
popOverDetails.credentials = i18n._('NONE SELECTED');
}
else {
const credentialNames = model.summary_fields[field].map(({name}) => name);
popOverDetails.credentials = credentialNames.join('<br />');
}
}
});
model.popOver = `
<table>
<tr>
<td>${i18n._('INVENTORY')}&nbsp;</td>
<td>${$filter('sanitize')(popOverDetails.inventory)}</td>
</tr>
<tr>
<td>${i18n._('PROJECT')}&nbsp;</td>
<td>${$filter('sanitize')(popOverDetails.project)}</td>
</tr>
<tr>
<td>${i18n._('PLAYBOOK')}&nbsp;</td>
<td>${$filter('sanitize')(popOverDetails.playbook)}</td>
</tr>
<tr>
<td>${i18n._('CREDENTIAL')}&nbsp;</td>
<td>${$filter('sanitize')(popOverDetails.credentials)}</td>
</tr>
</table>
`;
};
const updateSelectedRow = () => {
let unifiedJobTemplateId;
switch($scope.activeTab) {
case 'templates':
unifiedJobTemplateId = _.get($scope, 'jobNodeState.selectedTemplate.id') || null;
$scope.wf_maker_templates.forEach((row, i) => {
if (row.type === 'job_template') {
formatPopOverDetails(row);
}
$scope.wf_maker_templates[i].checked = (row.id === unifiedJobTemplateId) ? 1 : 0;
});
break;
case 'project_syncs':
unifiedJobTemplateId = _.get($scope, 'projectNodeState.selectedTemplate.id') || null;
$scope.wf_maker_projects.forEach((row, i) => {
$scope.wf_maker_projects[i].checked = (row.id === unifiedJobTemplateId) ? 1 : 0;
});
break;
case 'inventory_syncs':
unifiedJobTemplateId = _.get($scope, 'inventoryNodeState.selectedTemplate.id') || null;
$scope.wf_maker_inventory_sources.forEach((row, i) => {
$scope.wf_maker_inventory_sources[i].checked = (row.id === unifiedJobTemplateId) ? 1 : 0;
});
break;
}
};
const getEditNodeHelpMessage = (selectedTemplate, workflowJobTemplateObj) => {
if (selectedTemplate) {
if (selectedTemplate.type === "workflow_job_template") {
if (workflowJobTemplateObj.inventory) {
if (selectedTemplate.ask_inventory_on_launch) {
return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE');
}
if (workflowJobTemplateObj.inventory && selectedTemplate.ask_inventory_on_launch) {
return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE');
}
if (workflowJobTemplateObj.ask_inventory_on_launch) {
if (selectedTemplate.ask_inventory_on_launch) {
return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE');
}
if (workflowJobTemplateObj.ask_inventory_on_launch && selectedTemplate.ask_inventory_on_launch) {
return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE');
}
}
@ -209,19 +221,19 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
let jobTemplate = templateType === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate();
if (_.get($scope, 'nodeConfig.node.promptData') && !_.isEmpty($scope.nodeConfig.node.promptData)) {
$scope.promptData = _.cloneDeep($scope.nodeConfig.node.promptData);
const launchConf = $scope.promptData.launchConf;
$scope.jobNodeState.promptData = _.cloneDeep($scope.nodeConfig.node.promptData);
const launchConf = $scope.jobNodeState.promptData.launchConf;
if (!shouldShowPromptButton(launchConf)) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
$scope.jobNodeState.showPromptButton = false;
$scope.jobNodeState.promptModalMissingReqFields = false;
} else {
$scope.showPromptButton = true;
$scope.jobNodeState.showPromptButton = true;
if (templateType !== "workflow_job_template" && launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) {
$scope.promptModalMissingReqFields = true;
$scope.jobNodeState.promptModalMissingReqFields = true;
} else {
$scope.promptModalMissingReqFields = false;
$scope.jobNodeState.promptModalMissingReqFields = false;
}
}
watchForPromptChanges();
@ -285,9 +297,9 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
((!$scope.nodeConfig.node.fullUnifiedJobTemplateObject.inventory && !launchConf.ask_inventory_on_launch) ||
!$scope.nodeConfig.node.fullUnifiedJobTemplateObject.project)
) {
$scope.selectedTemplateInvalid = true;
$scope.jobNodeState.selectedTemplateInvalid = true;
} else {
$scope.selectedTemplateInvalid = false;
$scope.jobNodeState.selectedTemplateInvalid = false;
}
let credentialRequiresPassword = false;
@ -306,19 +318,19 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
}
});
$scope.credentialRequiresPassword = credentialRequiresPassword;
$scope.jobNodeState.credentialRequiresPassword = credentialRequiresPassword;
if (!shouldShowPromptButton(launchConf)) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
$scope.jobNodeState.showPromptButton = false;
$scope.jobNodeState.promptModalMissingReqFields = false;
$scope.nodeFormDataLoaded = true;
} else {
$scope.showPromptButton = true;
$scope.jobNodeState.showPromptButton = true;
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) {
$scope.promptModalMissingReqFields = true;
$scope.jobNodeState.promptModalMissingReqFields = true;
} else {
$scope.promptModalMissingReqFields = false;
$scope.jobNodeState.promptModalMissingReqFields = false;
}
if (responses[1].data.survey_enabled) {
@ -331,7 +343,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
extra_data: jsyaml.safeLoad(prompts.variables.value)
});
$scope.missingSurveyValue = processed.missingSurveyValue;
$scope.jobNodeState.missingSurveyValue = processed.missingSurveyValue;
$scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data);
@ -341,7 +353,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
value: $scope.extraVars
};
$scope.nodeConfig.node.promptData = $scope.promptData = {
$scope.nodeConfig.node.promptData = $scope.jobNodeState.promptData = {
launchConf: launchConf,
launchOptions: launchOptions,
prompts: prompts,
@ -350,14 +362,14 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id
};
surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => {
surveyQuestionWatcher = $scope.$watch('jobNodeState.promptData.surveyQuestions', () => {
let missingSurveyValue = false;
_.each($scope.promptData.surveyQuestions, (question) => {
_.each($scope.jobNodeState.promptData.surveyQuestions, (question) => {
if (question.required && (Empty(question.model) || question.model === [])) {
missingSurveyValue = true;
}
});
$scope.missingSurveyValue = missingSurveyValue;
$scope.jobNodeState.missingSurveyValue = missingSurveyValue;
}, true);
checkCredentialsForRequiredPasswords();
@ -367,7 +379,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
$scope.nodeFormDataLoaded = true;
});
} else {
$scope.nodeConfig.node.promptData = $scope.promptData = {
$scope.nodeConfig.node.promptData = $scope.jobNodeState.promptData = {
launchConf: launchConf,
launchOptions: launchOptions,
prompts: prompts,
@ -388,39 +400,46 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
}
if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject')) {
$scope.selectedTemplate = $scope.nodeConfig.node.fullUnifiedJobTemplateObject;
const selectedTemplate = $scope.nodeConfig.node.fullUnifiedJobTemplateObject;
if ($scope.selectedTemplate.unified_job_type) {
switch ($scope.selectedTemplate.unified_job_type) {
if (selectedTemplate.unified_job_type) {
switch (selectedTemplate.unified_job_type) {
case "job":
$scope.activeTab = "jobs";
$scope.activeTab = "templates";
$scope.jobNodeState.selectedTemplate = selectedTemplate;
break;
case "project_update":
$scope.activeTab = "project_syncs";
$scope.projectNodeState.selectedTemplate = selectedTemplate;
break;
case "inventory_update":
$scope.activeTab = "inventory_syncs";
$scope.inventoryNodeState.selectedTemplate = selectedTemplate;
break;
}
} else if ($scope.selectedTemplate.type) {
switch ($scope.selectedTemplate.type) {
} else if (selectedTemplate.type) {
switch (selectedTemplate.type) {
case "job_template":
$scope.activeTab = "jobs";
break;
case "workflow_job_template":
$scope.activeTab = "jobs";
$scope.activeTab = "templates";
$scope.jobNodeState.selectedTemplate = selectedTemplate;
break;
case "project":
$scope.activeTab = "project_syncs";
$scope.projectNodeState.selectedTemplate = selectedTemplate;
break;
case "inventory_source":
$scope.activeTab = "inventory_syncs";
$scope.inventoryNodeState.selectedTemplate = selectedTemplate;
break;
}
}
updateSelectedRow();
} else {
$scope.activeTab = "jobs";
$scope.activeTab = "templates";
}
select2ifyDropdowns();
} else {
$scope.jobTags = $scope.nodeConfig.node.originalNodeObject.job_tags ? $scope.nodeConfig.node.originalNodeObject.job_tags.split(',').map((tag) => (tag)) : [];
$scope.skipTags = $scope.nodeConfig.node.originalNodeObject.skip_tags ? $scope.nodeConfig.node.originalNodeObject.skip_tags.split(',').map((tag) => (tag)) : [];
@ -448,120 +467,31 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
};
const templateManuallySelected = (selectedTemplate) => {
if (promptWatcher) {
promptWatcher();
}
if (surveyQuestionWatcher) {
surveyQuestionWatcher();
}
if (credentialsWatcher) {
credentialsWatcher();
}
$scope.promptData = null;
$scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj);
if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") {
let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate();
$q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)])
.then((responses) => {
let launchConf = responses[1].data;
let credentialRequiresPassword = false;
let selectedTemplateInvalid = false;
if (selectedTemplate.type !== "workflow_job_template") {
if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) {
selectedTemplateInvalid = true;
}
if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) {
credentialRequiresPassword = true;
}
}
$scope.credentialRequiresPassword = credentialRequiresPassword;
$scope.selectedTemplateInvalid = selectedTemplateInvalid;
$scope.selectedTemplate = angular.copy(selectedTemplate);
if (!shouldShowPromptButton(launchConf)) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
} else {
$scope.showPromptButton = true;
$scope.promptModalMissingReqFields = false;
if (selectedTemplate.type !== "workflow_job_template") {
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) {
$scope.promptModalMissingReqFields = true;
}
}
if (launchConf.survey_enabled) {
// go out and get the survey questions
jobTemplate.getSurveyQuestions(selectedTemplate.id)
.then((surveyQuestionRes) => {
let processed = PromptService.processSurveyQuestions({
surveyQuestions: surveyQuestionRes.data.spec
});
$scope.missingSurveyValue = processed.missingSurveyValue;
$scope.promptData = {
launchConf: responses[1].data,
launchOptions: responses[0].data,
surveyQuestions: processed.surveyQuestions,
template: selectedTemplate.id,
templateType: selectedTemplate.type,
prompts: PromptService.processPromptValues({
launchConf: responses[1].data,
launchOptions: responses[0].data
}),
};
surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => {
let missingSurveyValue = false;
_.each($scope.promptData.surveyQuestions, (question) => {
if (question.required && (Empty(question.model) || question.model === [])) {
missingSurveyValue = true;
}
});
$scope.missingSurveyValue = missingSurveyValue;
}, true);
watchForPromptChanges();
});
} else {
$scope.promptData = {
launchConf: responses[1].data,
launchOptions: responses[0].data,
template: selectedTemplate.id,
templateType: selectedTemplate.type,
prompts: PromptService.processPromptValues({
launchConf: responses[1].data,
launchOptions: responses[0].data
}),
};
watchForPromptChanges();
}
}
});
} else {
$scope.selectedTemplate = angular.copy(selectedTemplate);
$scope.selectedTemplateInvalid = false;
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
}
};
const setupNodeForm = () => {
$scope.jobNodeState = {
credentialRequiresPassword: false,
missingSurveyValue: false,
promptData: null,
promptModalMissingReqFields: false,
searchTags: [],
selectedTemplate: null,
selectedTemplateInvalid: false,
showPromptButton: false
};
$scope.projectNodeState = {
searchTags: [],
selectedTemplate: null
};
$scope.inventoryNodeState = {
searchTags: [],
selectedTemplate: null,
};
$scope.approvalNodeState = {
name: null,
description: null,
timeoutMinutes: 0,
timeoutSeconds: 0
};
$scope.nodeFormDataLoaded = false;
$scope.wf_maker_template_queryset = {
page_size: '10',
@ -619,123 +549,250 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService
$q.all(listPromises)
.then(() => {
if ($scope.nodeConfig.mode === "edit") {
// Make sure that we have the full unified job template object
if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject) {
// This is a node that we got back from the api with an incomplete
// unified job template so we're going to pull down the whole object
TemplatesService.getUnifiedJobTemplate($scope.nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.id)
.then(({data}) => {
$scope.nodeConfig.node.fullUnifiedJobTemplateObject = data.results[0];
finishConfiguringEdit();
}, (error) => {
ProcessErrors($scope, error.data, error.status, null, {
hdr: 'Error!',
msg: 'Failed to get unified job template. GET returned ' +
'status: ' + error.status
});
});
if ($scope.nodeConfig.node.unifiedJobTemplate && $scope.nodeConfig.node.unifiedJobTemplate.unified_job_type === "workflow_approval") {
$scope.activeTab = "approval";
select2ifyDropdowns();
const timeoutMinutes = Math.floor($scope.nodeConfig.node.unifiedJobTemplate.timeout / 60);
const timeoutSeconds = $scope.nodeConfig.node.unifiedJobTemplate.timeout - timeoutMinutes * 60;
$scope.approvalNodeState = {
name: $scope.nodeConfig.node.unifiedJobTemplate.name,
description: $scope.nodeConfig.node.unifiedJobTemplate.description,
timeoutMinutes,
timeoutSeconds
};
$scope.nodeFormDataLoaded = true;
} else {
finishConfiguringEdit();
// Make sure that we have the full unified job template object
if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject && _.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.unified_job_template')) {
// This is a node that we got back from the api with an incomplete
// unified job template so we're going to pull down the whole object
TemplatesService.getUnifiedJobTemplate($scope.nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.id)
.then(({data}) => {
$scope.nodeConfig.node.fullUnifiedJobTemplateObject = data.results[0];
finishConfiguringEdit();
}, (error) => {
ProcessErrors($scope, error.data, error.status, null, {
hdr: 'Error!',
msg: 'Failed to get unified job template. GET returned ' +
'status: ' + error.status
});
});
} else {
finishConfiguringEdit();
}
}
} else {
finishConfiguringAdd();
$scope.activeTab = "templates";
const alwaysOption = {
label: $scope.strings.get('workflow_maker.ALWAYS'),
value: 'always'
};
const successOption = {
label: $scope.strings.get('workflow_maker.ON_SUCCESS'),
value: 'success'
};
const failureOption = {
label: $scope.strings.get('workflow_maker.ON_FAILURE'),
value: 'failure'
};
$scope.edgeTypeOptions = [alwaysOption];
switch($scope.nodeConfig.newNodeIsRoot) {
case true:
$scope.edgeType = alwaysOption;
break;
case false:
$scope.edgeType = successOption;
$scope.edgeTypeOptions.push(successOption, failureOption);
break;
}
select2ifyDropdowns();
$scope.nodeFormDataLoaded = true;
}
});
};
const formatPopOverDetails = (model) => {
const popOverDetails = {};
popOverDetails.playbook = model.playbook || i18n._('NONE SELECTED');
Object.keys(model.summary_fields).forEach(field => {
if (field === 'project') {
popOverDetails.project = model.summary_fields[field].name || i18n._('NONE SELECTED');
}
if (field === 'inventory') {
popOverDetails.inventory = model.summary_fields[field].name || i18n._('NONE SELECTED');
}
if (field === 'credentials') {
if (model.summary_fields[field].length <= 0) {
popOverDetails.credentials = i18n._('NONE SELECTED');
}
else {
const credentialNames = model.summary_fields[field].map(({name}) => name);
popOverDetails.credentials = credentialNames.join('<br />');
}
}
});
model.popOver = `
<dl>
<dt>${i18n._('INVENTORY')}</dt>
<dd>${$filter('sanitize')(popOverDetails.inventory)}</dd>
</dl>
<dl>
<dt>${i18n._('PROJECT')}</dt>
<dd>${$filter('sanitize')(popOverDetails.project)}</dd>
</dl>
<dl>
<dt>${i18n._('PLAYBOOK')}</dt>
<dd>${$filter('sanitize')(popOverDetails.playbook)}</dd>
</dl>
<dl>
<dt>${i18n._('CREDENTIAL')}</dt>
<dd>${$filter('sanitize')(popOverDetails.credentials)}</dd>
</dl>
`;
$scope.confirmNodeForm = () => {
const nodeFormData = {
edgeType: $scope.edgeType
};
if ($scope.activeTab === "approval") {
const timeout = $scope.approvalNodeState.timeoutMinutes * 60 + $scope.approvalNodeState.timeoutSeconds;
nodeFormData.selectedTemplate = {
name: $scope.approvalNodeState.name,
description: $scope.approvalNodeState.description,
timeout,
unified_job_type: "workflow_approval"
};
} else if($scope.activeTab === "templates") {
nodeFormData.selectedTemplate = $scope.jobNodeState.selectedTemplate;
nodeFormData.promptData = $scope.jobNodeState.promptData;
} else if($scope.activeTab === "project_syncs") {
nodeFormData.selectedTemplate = $scope.projectNodeState.selectedTemplate;
} else if($scope.activeTab === "inventory_syncs") {
nodeFormData.selectedTemplate = $scope.inventoryNodeState.selectedTemplate;
}
$scope.select({ nodeFormData });
};
$scope.openPromptModal = () => {
$scope.promptData.triggerModalOpen = true;
$scope.jobNodeState.promptData.triggerModalOpen = true;
};
$scope.toggle_row = (selectedRow) => {
$scope.selectIsDisabled = () => {
if($scope.activeTab === "templates") {
return !($scope.jobNodeState.selectedTemplate) ||
$scope.jobNodeState.promptModalMissingReqFields ||
$scope.jobNodeState.credentialRequiresPassword ||
$scope.jobNodeState.selectedTemplateInvalid;
} else if($scope.activeTab === "project_syncs") {
return !$scope.projectNodeState.selectedTemplate;
} else if($scope.activeTab === "inventory_syncs") {
return !$scope.inventoryNodeState.selectedTemplate;
} else if ($scope.activeTab === "approval") {
return !($scope.approvalNodeState.name && $scope.approvalNodeState.name !== "") || $scope.workflow_approval.pauseTimeoutMinutes.$error.min || $scope.workflow_approval.pauseTimeoutSeconds.$error.min;
}
};
$scope.selectTemplate = (selectedTemplate) => {
if (!$scope.readOnly) {
templateManuallySelected(selectedRow);
clearWatchers();
$scope.approvalNodeState = {
name: null,
description: null,
timeoutMinutes: 0,
timeoutSeconds: 0
};
$scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj);
if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") {
let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate();
$scope.jobNodeState.promptData = null;
$q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)])
.then((responses) => {
let launchConf = responses[1].data;
let credentialRequiresPassword = false;
let selectedTemplateInvalid = false;
if (selectedTemplate.type !== "workflow_job_template") {
if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) {
selectedTemplateInvalid = true;
}
if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) {
credentialRequiresPassword = true;
}
}
$scope.jobNodeState.credentialRequiresPassword = credentialRequiresPassword;
$scope.jobNodeState.selectedTemplateInvalid = selectedTemplateInvalid;
$scope.jobNodeState.selectedTemplate = angular.copy(selectedTemplate);
updateSelectedRow();
if (!launchConf.survey_enabled &&
!launchConf.ask_inventory_on_launch &&
!launchConf.ask_credential_on_launch &&
!launchConf.ask_verbosity_on_launch &&
!launchConf.ask_job_type_on_launch &&
!launchConf.ask_limit_on_launch &&
!launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch &&
!launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) {
$scope.jobNodeState.showPromptButton = false;
$scope.jobNodeState.promptModalMissingReqFields = false;
} else {
$scope.jobNodeState.showPromptButton = true;
$scope.jobNodeState.promptModalMissingReqFields = false;
if (selectedTemplate.type !== "workflow_job_template") {
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) {
$scope.jobNodeState.promptModalMissingReqFields = true;
}
}
if (launchConf.survey_enabled) {
// go out and get the survey questions
jobTemplate.getSurveyQuestions(selectedTemplate.id)
.then((surveyQuestionRes) => {
let processed = PromptService.processSurveyQuestions({
surveyQuestions: surveyQuestionRes.data.spec
});
$scope.jobNodeState.missingSurveyValue = processed.missingSurveyValue;
$scope.jobNodeState.promptData = {
launchConf: responses[1].data,
launchOptions: responses[0].data,
surveyQuestions: processed.surveyQuestions,
template: selectedTemplate.id,
templateType: selectedTemplate.type,
prompts: PromptService.processPromptValues({
launchConf: responses[1].data,
launchOptions: responses[0].data
}),
};
surveyQuestionWatcher = $scope.$watch('jobNodeState.promptData.surveyQuestions', () => {
let missingSurveyValue = false;
_.each($scope.jobNodeState.promptData.surveyQuestions, (question) => {
if (question.required && (Empty(question.model) || question.model === [])) {
missingSurveyValue = true;
}
});
$scope.jobNodeState.missingSurveyValue = missingSurveyValue;
}, true);
watchForPromptChanges();
});
} else {
$scope.jobNodeState.promptData = {
launchConf: responses[1].data,
launchOptions: responses[0].data,
template: selectedTemplate.id,
templateType: selectedTemplate.type,
prompts: PromptService.processPromptValues({
launchConf: responses[1].data,
launchOptions: responses[0].data
}),
};
watchForPromptChanges();
}
}
});
} else {
if (selectedTemplate.type === "project") {
$scope.projectNodeState.selectedTemplate = angular.copy(selectedTemplate);
} else if (selectedTemplate.type === "inventory_source") {
$scope.inventoryNodeState.selectedTemplate = angular.copy(selectedTemplate);
}
updateSelectedRow();
}
}
};
$scope.$watch('nodeConfig.nodeId', (newNodeId, oldNodeId) => {
if (newNodeId !== oldNodeId) {
clearWatchers();
setupNodeForm();
}
});
$scope.$watchGroup(['wf_maker_templates', 'wf_maker_projects', 'wf_maker_inventory_sources', 'activeTab', 'selectedTemplate.id'], () => {
const unifiedJobTemplateId = _.get($scope, 'selectedTemplate.id') || null;
switch($scope.activeTab) {
case 'jobs':
$scope.wf_maker_templates.forEach((row, i) => {
if (row.type === 'job_template') {
formatPopOverDetails(row);
}
if(row.id === unifiedJobTemplateId) {
$scope.wf_maker_templates[i].checked = 1;
}
else {
$scope.wf_maker_templates[i].checked = 0;
}
});
break;
case 'project_syncs':
$scope.wf_maker_projects.forEach((row, i) => {
if(row.id === unifiedJobTemplateId) {
$scope.wf_maker_projects[i].checked = 1;
}
else {
$scope.wf_maker_projects[i].checked = 0;
}
});
break;
case 'inventory_syncs':
$scope.wf_maker_inventory_sources.forEach((row, i) => {
if(row.id === unifiedJobTemplateId) {
$scope.wf_maker_inventory_sources[i].checked = 1;
}
else {
$scope.wf_maker_inventory_sources[i].checked = 0;
}
});
break;
}
$scope.$watchGroup(['wf_maker_templates', 'wf_maker_projects', 'wf_maker_inventory_sources', 'activeTab'], () => {
updateSelectedRow();
});
setupNodeForm();

View File

@ -1,36 +1,45 @@
<div ng-show="nodeFormDataLoaded">
<div class="WorkflowMaker-formTitle">{{nodeConfig.mode === 'edit' ? nodeConfig.node.fullUnifiedJobTemplateObject.name || nodeConfig.node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_NODE')}}</div>
<div class="Form-tabHolder" ng-show="!readOnly">
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': activeTab === 'jobs'}" ng-click="activeTab = 'jobs'">{{strings.get('workflow_maker.JOBS')}}</div>
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': activeTab === 'project_syncs'}" ng-click="activeTab = 'project_syncs'">{{strings.get('workflow_maker.PROJECT_SYNC')}}</div>
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': activeTab === 'inventory_syncs'}" ng-click="activeTab = 'inventory_syncs'">{{strings.get('workflow_maker.INVENTORY_SYNC')}}</div>
<div class="WorkflowMaker-formTypeDropdown" ng-show="!readOnly">
<select
id="workflow-node-types"
ng-model="activeTab"
class="form-control Form-dropDown"
name="activeTab"
aria-hidden="true"
>
<option value="templates" selected="selected">{{strings.get('workflow_maker.TEMPLATE')}}</option>
<option value="project_syncs" selected="selected">{{strings.get('workflow_maker.PROJECT_SYNC')}}</option>
<option value="inventory_syncs" selected="selected">{{strings.get('workflow_maker.INVENTORY_SYNC')}}</option>
<option value="approval" selected="selected">{{strings.get('workflow_maker.APPROVAL')}}</option>
</select>
</div>
<div class="WorkflowMaker-formLists" ng-show="!readOnly">
<div id="workflow-jobs-list" ng-show="activeTab === 'jobs'">
<div ng-hide="wf_maker_templates.length === 0 && (searchTags | isEmpty)">
<smart-search django-model="wf_maker_templates" base-path="unified_job_templates" iterator="wf_maker_template" dataset="wf_maker_template_dataset" list="templateList" collection="wf_maker_templates" default-params="wf_maker_template_default_params" query-set="wf_maker_template_queryset" search-bar-full-width="true" search-tags="searchTags">
<div id="workflow-jobs-list" ng-show="activeTab === 'templates'">
<div ng-hide="wf_maker_templates.length === 0 && (jobNodeState.searchTags | isEmpty)">
<smart-search django-model="wf_maker_templates" base-path="unified_job_templates" iterator="wf_maker_template" dataset="wf_maker_template_dataset" list="templateList" collection="wf_maker_templates" default-params="wf_maker_template_default_params" query-set="wf_maker_template_queryset" search-bar-full-width="true" search-tags="jobNodeState.searchTags">
</smart-search>
</div>
<div class="row" ng-show="wf_maker_templates.length === 0 && !(searchTags | isEmpty)">
<div class="row" ng-show="wf_maker_templates.length === 0 && !(jobNodeState.searchTags | isEmpty)">
<div class="col-lg-12 List-searchNoResults" translate>No records matched your search.</div>
</div>
<div class="List-noItems" ng-show="wf_maker_templates.length === 0 && (searchTags | isEmpty)" translate>PLEASE ADD ITEMS TO THIS LIST</div>
<div class="List-noItems" ng-show="wf_maker_templates.length === 0 && (jobNodeState.searchTags | isEmpty)" translate>PLEASE ADD ITEMS TO THIS LIST</div>
<div class="list-table-container" ng-show="wf_maker_templates.length > 0">
<div id="templates_table" class="List-table" is-extended="false">
<div class="List-lookupLayout List-tableHeaderRow">
<div></div>
<div class="d-flex h-100">
<div class="col-md-8" base-path="unified_job_templates" collection="wf_maker_templates" dataset="wf_maker_template_dataset" column-sort="" column-field="name" column-iterator="wf_maker_template" column-no-sort="undefined" column-label="Name" column-custom-class="" query-set="wf_maker_template_queryset"></div>
<div class="col-md-8" base-path="unified_job_templates" collection="wf_maker_templates" dataset="wf_maker_template_dataset" column-sort="" column-field="name" column-iterator="wf_maker_template" column-no-sort="undefined" column-label="{{:: strings.get('workflow_maker.NAME') }}" column-custom-class="" query-set="wf_maker_template_queryset"></div>
<div class="List-tableHeader--info col-md-4" base-path="unified_job_templates" collection="wf_maker_templates" dataset="wf_maker_template_dataset" column-sort="" column-field="info" column-iterator="wf_maker_template" column-no-sort="true" column-label="" column-custom-class="" query-set="wf_maker_template_queryset"></div>
</div>
</div>
<div ng-class="[template.success_class, {'List-tableRow--selected' : $stateParams['template_id'] == wf_maker_template.id}, {'List-tableRow--disabled': !wf_maker_template.summary_fields.user_capabilities.edit}]" id="{{ wf_maker_template.id }}" class="List-lookupLayout List-tableRow template_class" disable-row="{{ !wf_maker_template.summary_fields.user_capabilities.edit }}" ng-repeat="wf_maker_template in wf_maker_templates">
<div class="List-centerEnd select-column">
<input type="radio" ng-model="wf_maker_template.checked" ng-value="1" ng-false-value="0" name="check_template_{{wf_maker_template.id}}" ng-click="toggle_row(wf_maker_template)" ng-disabled="!wf_maker_template.summary_fields.user_capabilities.edit">
<input type="radio" ng-model="wf_maker_template.checked" ng-value="1" ng-false-value="0" name="check_template_{{wf_maker_template.id}}" ng-click="selectTemplate(wf_maker_template)" ng-disabled="!wf_maker_template.summary_fields.user_capabilities.edit">
</div>
<div class="d-flex h-100">
<div class="List-tableCell name-column col-md-8" ng-click="toggle_row(wf_maker_template)">
<div class="List-tableCell name-column col-md-8" ng-click="selectTemplate(wf_maker_template)">
{{wf_maker_template.name}}
<span class="at-RowItem-tag" ng-show="wf_maker_template.type === 'workflow_job_template'">
{{:: strings.get('workflow_maker.WORKFLOW') }}
@ -44,29 +53,29 @@
<paginate base-path="unified_job_templates" collection="wf_maker_templates" dataset="wf_maker_template_dataset" iterator="wf_maker_template" query-set="wf_maker_template_queryset" hide-view-per-page="true" max-visible-pages="5"></paginate>
</div>
<div id="workflow-project-sync-list" ng-show="activeTab === 'project_syncs'">
<div ng-hide="wf_maker_projects.length === 0 && (searchTags | isEmpty)">
<smart-search django-model="wf_maker_projects" base-path="projects" iterator="wf_maker_project" dataset="wf_maker_project_dataset" list="projectList" collection="wf_maker_projects" default-params="wf_maker_project_default_params" query-set="wf_maker_project_queryset" search-bar-full-width="true" search-tags="searchTags">
<div ng-hide="wf_maker_projects.length === 0 && (projectNodeState.searchTags | isEmpty)">
<smart-search django-model="wf_maker_projects" base-path="projects" iterator="wf_maker_project" dataset="wf_maker_project_dataset" list="projectList" collection="wf_maker_projects" default-params="wf_maker_project_default_params" query-set="wf_maker_project_queryset" search-bar-full-width="true" search-tags="projectNodeState.searchTags">
</smart-search>
</div>
<div class="row" ng-show="wf_maker_projects.length === 0 && !(searchTags | isEmpty)">
<div class="row" ng-show="wf_maker_projects.length === 0 && !(projectNodeState.searchTags | isEmpty)">
<div class="col-lg-12 List-searchNoResults" translate>No records matched your search.</div>
</div>
<div class="List-noItems" ng-show="wf_maker_projects.length === 0 && (searchTags | isEmpty)">No Projects Have Been Created</div>
<div class="List-noItems" ng-show="wf_maker_projects.length === 0 && (projectNodeState.searchTags | isEmpty)" translate>No Projects Have Been Created</div>
<div class="list-table-container" ng-show="wf_maker_projects.length > 0">
<div id="projects_table" class="List-table" is-extended="false">
<div class="List-lookupLayout List-tableHeaderRow">
<div></div>
<div class="d-flex h-100">
<div base-path="projects" collection="wf_maker_projects" dataset="wf_maker_project_dataset" column-sort="" column-field="name" column-iterator="wf_maker_project" column-no-sort="undefined" column-label="Name" column-custom-class="col-md-12" query-set="wf_maker_project_queryset"></div>
<div base-path="projects" collection="wf_maker_projects" dataset="wf_maker_project_dataset" column-sort="" column-field="name" column-iterator="wf_maker_project" column-no-sort="undefined" column-label="{{:: strings.get('workflow_maker.NAME') }}" column-custom-class="col-md-12" query-set="wf_maker_project_queryset"></div>
</div>
</div>
<div ng-class="[wf_maker_project.success_class, {'List-tableRow--selected' : $stateParams['project_id'] == wf_maker_project.id}]" id="{{ wf_maker_project.id }}" class="List-lookupLayout List-tableRow project_class" ng-repeat="wf_maker_project in wf_maker_projects">
<div class="List-centerEnd select-column">
<input type="radio" ng-model="wf_maker_project.checked" ng-value="1" ng-false-value="0" name="check_project_{{wf_maker_project.id}}" ng-click="toggle_row(wf_maker_project)" ng-disabled="undefined">
<input type="radio" ng-model="wf_maker_project.checked" ng-value="1" ng-false-value="0" name="check_project_{{wf_maker_project.id}}" ng-click="selectTemplate(wf_maker_project)" ng-disabled="undefined">
</div>
<div class="d-flex h-100">
<div class="List-tableCell name-column col-md-12" ng-click="toggle_row(wf_maker_project)">{{ wf_maker_project.name }}</div>
<div class="List-tableCell name-column col-md-12" ng-click="selectTemplate(wf_maker_project)">{{ wf_maker_project.name }}</div>
</div>
</div>
</div>
@ -74,43 +83,83 @@
<paginate base-path="projects" collection="wf_maker_projects" dataset="wf_maker_project_dataset" iterator="wf_maker_project" query-set="wf_maker_project_queryset" hide-view-per-page="true" max-visible-pages="5"></paginate>
</div>
<div id="workflow-inventory-sync-list" ng-show="activeTab === 'inventory_syncs'">
<div ng-hide="wf_maker_inventory_sources.length === 0 && (searchTags | isEmpty)">
<smart-search django-model="wf_maker_inventory_sources" base-path="inventory_sources" iterator="wf_maker_inventory_source" dataset="wf_maker_inventory_source_dataset" list="inventorySourceList" collection="wf_maker_inventory_sources" default-params="wf_maker_inventory_source_default_params" query-set="wf_maker_inventory_source_queryset" search-bar-full-width="true" search-tags="searchTags">
<div ng-hide="wf_maker_inventory_sources.length === 0 && (inventoryNodeState.searchTags | isEmpty)">
<smart-search django-model="wf_maker_inventory_sources" base-path="inventory_sources" iterator="wf_maker_inventory_source" dataset="wf_maker_inventory_source_dataset" list="inventorySourceList" collection="wf_maker_inventory_sources" default-params="wf_maker_inventory_source_default_params" query-set="wf_maker_inventory_source_queryset" search-bar-full-width="true" search-tags="inventoryNodeState.searchTags">
</smart-search>
</div>
<div class="row" ng-show="wf_maker_inventory_sources.length === 0 && !(searchTags | isEmpty)">
<div class="row" ng-show="wf_maker_inventory_sources.length === 0 && !(inventoryNodeState.searchTags | isEmpty)">
<div class="col-lg-12 List-searchNoResults" translate>No records matched your search.</div>
</div>
<div class="List-noItems" ng-show="wf_maker_inventory_sources.length === 0 && (searchTags | isEmpty)">PLEASE ADD ITEMS TO THIS LIST</div>
<div class="List-noItems" ng-show="wf_maker_inventory_sources.length === 0 && (inventoryNodeState.searchTags | isEmpty)" translate>PLEASE ADD ITEMS TO THIS LIST</div>
<div class="list-table-container" ng-show="wf_maker_inventory_sources.length > 0">
<div id="workflow_inventory_sources_table" class="List-table" is-extended="false">
<div class="List-lookupLayout List-tableHeaderRow">
<div></div>
<div class="d-flex h-100">
<div base-path="inventory_sources" collection="wf_maker_inventory_sources" dataset="wf_maker_inventory_source_dataset" column-sort="" column-field="name" column-iterator="wf_maker_inventory_source" column-no-sort="undefined" column-label="Name" column-custom-class="" query-set="wf_maker_inventory_source_queryset"></div>
<div base-path="inventory_sources" collection="wf_maker_inventory_sources" dataset="wf_maker_inventory_source_dataset" column-sort="" column-field="name" column-iterator="wf_maker_inventory_source" column-no-sort="undefined" column-label="{{:: strings.get('workflow_maker.NAME') }}" column-custom-class="" query-set="wf_maker_inventory_source_queryset"></div>
</div>
</div>
<div ng-class="[wf_maker_inventory_source.success_class, {'List-tableRow--selected' : $stateParams['inventory_source_id'] == wf_maker_inventory_source.id}]" id="{{ wf_maker_inventory_source.id }}" class="List-lookupLayout List-tableRow inventory_source_class" ng-repeat="wf_maker_inventory_source in wf_maker_inventory_sources">
<div class="List-centerEnd select-column">
<input type="radio" ng-model="wf_maker_inventory_source.checked" ng-value="1" ng-false-value="0" name="check_inventory_source_{{wf_maker_inventory_source.id}}" ng-click="toggle_row(wf_maker_inventory_source)" ng-disabled="undefined">
<input type="radio" ng-model="wf_maker_inventory_source.checked" ng-value="1" ng-false-value="0" name="check_inventory_source_{{wf_maker_inventory_source.id}}" ng-click="selectTemplate(wf_maker_inventory_source)" ng-disabled="undefined">
</div>
<div class="d-flex h-100">
<div class="List-tableCell name-column col-md-12" ng-click="toggle_row(wf_maker_inventory_source)">{{ wf_maker_inventory_source.name }}</div>
<div class="List-tableCell name-column col-md-12" ng-click="selectTemplate(wf_maker_inventory_source)">{{ wf_maker_inventory_source.name }}</div>
</div>
</div>
</div>
</div>
<paginate base-path="inventory_sources" collection="wf_maker_inventory_sources" dataset="wf_maker_inventory_source_dataset" iterator="wf_maker_inventory_source" query-set="wf_maker_inventory_source_queryset" hide-view-per-page="true" max-visible-pages="5"></paginate>
</div>
<form id="workflow_approval" name="workflow_approval" ng-show="activeTab === 'approval'">
<div class="form-group Form-formGroup Form-formGroup--singleColumn">
<label for="pauseName" class="Form-inputLabelContainer">
<span class="Form-requiredAsterisk">*</span>
<span class="Form-inputLabel" translate>Name</span>
</label>
<div>
<input type="text" ng-model="approvalNodeState.name" name="pauseName" id="workflow_job_template_pauseName" class="form-control Form-textInput" />
</div>
</label>
</div>
<div class="form-group Form-formGroup Form-formGroup--singleColumn">
<label for="pauseDesc" class="Form-inputLabelContainer">
<span class="Form-inputLabel" translate>Description</span>
</label>
<div>
<input type="text" ng-model="approvalNodeState.description" name="pauseDesc" id="workflow_job_template_pauseDesc" class="form-control Form-textInput" />
</div>
</label>
</div>
<div class="form-group Form-formGroup Form-formGroup--singleColumn">
<label class="Form-inputLabelContainer">
<span class="Form-inputLabel" translate>Timeout</span>
<a id="workflow-maker-timeout-popover" href="" aw-pop-over="{{:: strings.get('workflow_maker.TIMEOUT_POPOVER') }}" data-placement="top" data-container="body" over-title="{{:: strings.get('workflow_maker.TIMEOUT') }}" class="help-link">
<i class="fa fa-question-circle"></i>
</a>
</label>
<div>
<span class="WorkflowMaker-timeoutInput">
<input type="number" value="0" min=0 aw-min="0" ng-model="approvalNodeState.timeoutMinutes" name="pauseTimeoutMinutes" id="workflow_job_template_pauseTimeoutMinutes" aw-spinner="pauseTimeoutMinutes" integer />
</span>
<span class="WorkflowMaker-timeoutLabel" translate>min</span>
<span class="WorkflowMaker-timeoutInput WorkflowMaker-timeoutSeconds">
<input class="WorkflowMaker-timeoutInput" type="number" value="0" min=0 aw-min="0" ng-model="approvalNodeState.timeoutSeconds" name="pauseTimeoutSeconds" id="workflow_job_template_pauseTimeoutSeconds" aw-spinner="pauseTimeoutSeconds" integer />
</span>
<span class="WorkflowMaker-timeoutLabel" translate>sec</span>
<div class="error" id="workflow_job_template_pauseTimeout-minmax-error" ng-show="workflow_approval.pauseTimeoutMinutes.$error.min || workflow_approval.pauseTimeoutSeconds.$error.min" translate>Please enter a number greater than or equal to 0.</div>
</div>
</div>
</form>
</div>
<div ng-if="selectedTemplate && selectedTemplateInvalid">
<div ng-if="selectedTemplate && jobNodeState.selectedTemplateInvalid">
<div class="WorkflowMaker-invalidJobTemplateWarning">
<span class="fa fa-warning"></span>
<span>{{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }}</span>
</div>
</div>
<div ng-if="selectedTemplate && credentialRequiresPassword">
<div ng-if="selectedTemplate && jobNodeState.credentialRequiresPassword">
<div class="WorkflowMaker-invalidJobTemplateWarning">
<span class="fa fa-warning"></span>
<span>{{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}</span>
@ -138,29 +187,27 @@
<div
class="WorkflowMaker-readOnlyPromptText"
ng-show="nodeConfig.node.originalNodeObject.job_type !== null ||
(promptData.prompts.credentials.value && promptData.prompts.credentials.value.length > 0) ||
nodeConfig.node.originalNodeObject.inventory !== null ||
nodeConfig.node.originalNodeObject.limit !== null ||
nodeConfig.node.originalNodeObject.verbosity !== null ||
nodeConfig.node.originalNodeObject.job_tags !== null ||
nodeConfig.node.originalNodeObject.skip_tags !== null ||
nodeConfig.node.originalNodeObject.diff_mode !== null ||
nodeConfig.node.originalNodeObject.scm_branch !== null ||
showExtraVars">
(jobNodeState.promptData.prompts.credentials.value && jobNodeState.promptData.prompts.credentials.value.length > 0) ||
nodeConfig.node.originalNodeObject.inventory !== null ||
nodeConfig.node.originalNodeObject.limit !== null ||
nodeConfig.node.originalNodeObject.verbosity !== null ||
nodeConfig.node.originalNodeObject.job_tags !== null ||
nodeConfig.node.originalNodeObject.skip_tags !== null ||
nodeConfig.node.originalNodeObject.diff_mode !== null ||
showExtraVars">
{{:: strings.get('workflow_maker.READ_ONLY_PROMPT_VALUES')}}
</div>
<div
class="WorkflowMaker-readOnlyPromptText"
ng-show="!(nodeConfig.node.originalNodeObject.job_type !== null ||
(promptData.prompts.credentials.value && promptData.prompts.credentials.value.length > 0) ||
nodeConfig.node.originalNodeObject.inventory !== null ||
nodeConfig.node.originalNodeObject.limit !== null ||
nodeConfig.node.originalNodeObject.verbosity !== null ||
nodeConfig.node.originalNodeObject.job_tags !== null ||
nodeConfig.node.originalNodeObject.skip_tags !== null ||
nodeConfig.node.originalNodeObject.diff_mode !== null ||
nodeConfig.node.originalNodeObject.scm_branch !== null ||
showExtraVars)">
(jobNodeState.promptData.prompts.credentials.value && jobNodeState.promptData.prompts.credentials.value.length > 0) ||
nodeConfig.node.originalNodeObject.inventory !== null ||
nodeConfig.node.originalNodeObject.limit !== null ||
nodeConfig.node.originalNodeObject.verbosity !== null ||
nodeConfig.node.originalNodeObject.job_tags !== null ||
nodeConfig.node.originalNodeObject.skip_tags !== null ||
nodeConfig.node.originalNodeObject.diff_mode !== null ||
showExtraVars)">
{{:: strings.get('workflow_maker.READ_ONLY_NO_PROMPT_VALUES')}}
</div>
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.job_type !== null">
@ -217,8 +264,8 @@
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.diff_mode !== null">
<div class="Prompt-previewRowTitle">{{:: strings.get('prompt.SHOW_CHANGES') }}</div>
<div class="Prompt-previewRowValue">
<span ng-if="promptData.prompts.diffMode.value">{{:: strings.get('ON') }}</span>
<span ng-if="!promptData.prompts.diffMode.value">{{:: strings.get('OFF') }}</span>
<span ng-if="jobNodeState.promptData.prompts.diffMode.value">{{:: strings.get('ON') }}</span>
<span ng-if="!jobNodeState.promptData.prompts.diffMode.value">{{:: strings.get('OFF') }}</span>
</div>
</div>
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.scm_branch !== null">
@ -232,13 +279,13 @@
</div>
</div>
</div>
<div ng-show="editNodeHelpMessage && activeTab === 'jobs'" class="WorkflowMaker-formHelp" ng-bind="editNodeHelpMessage"></div>
<div ng-show="editNodeHelpMessage && activeTab === 'templates'" class="WorkflowMaker-formHelp" ng-bind="editNodeHelpMessage"></div>
<br />
<div class="buttons Form-buttons" id="workflow_maker_controls">
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_prompt_btn" ng-show="showPromptButton && activeTab == 'jobs' " ng-click="openPromptModal()"> {{:: strings.get('prompt.PROMPT') }}</button>
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_prompt_btn" ng-show="jobNodeState.showPromptButton && activeTab == 'templates' " ng-click="openPromptModal()"> {{:: strings.get('prompt.PROMPT') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_node_btn" ng-show="!readOnly" ng-click="cancel()"> {{:: strings.get('CANCEL') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_close_node_btn" ng-show="readOnly" ng-click="cancel()"> {{:: strings.get('CLOSE') }}</button>
<button type="button" class="btn btn-sm Form-saveButton" id="workflow_maker_select_node_btn" ng-show="!readOnly" ng-click="select({selectedTemplate, promptData, edgeType})" ng-disabled="!selectedTemplate || promptModalMissingReqFields || credentialRequiresPassword || selectedTemplateInvalid"> {{:: strings.get('workflow_maker.SELECT') }}</button>
<button type="button" class="btn btn-sm Form-saveButton" id="workflow_maker_select_node_btn" ng-show="!readOnly" ng-click="confirmNodeForm()" ng-disabled="selectIsDisabled()"> {{:: strings.get('workflow_maker.SELECT') }}</button>
</div>
<prompt prompt-data="promptData" action-text="{{:: strings.get('prompt.CONFIRM')}}" prevent-creds-with-passwords="preventCredsWithPasswords" read-only-prompts="readOnly"></prompt>
</div>
<prompt prompt-data="jobNodeState.promptData" action-text="{{:: strings.get('prompt.CONFIRM')}}" prevent-creds-with-passwords="preventCredsWithPasswords" read-only-prompts="readOnly"></prompt>
</div>

View File

@ -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;
}
};
}
];

View File

@ -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;
}

View File

@ -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;
}
}
});

View File

@ -68,39 +68,7 @@
<div class="WorkflowLegend-maker">
<div class="WorkflowLegend-maker--left">
<i ng-class="{{ keyClassList }}" class="fa fa-compass Key-menuIcon" ng-click="toggleKey()"></i>
<ul ng-show="showKey" class="Key-list noselect">
<li class="Key-listItem">
<p class="Key-heading">{{strings.get('workflow_maker.KEY')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--success"></div>
<p class="Key-listItemContent">{{strings.get('workflow_maker.ON_SUCCESS')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--fail"></div>
<p class="Key-listItemContent">{{strings.get('workflow_maker.ON_FAILURE')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--always"></div>
<p class="Key-listItemContent">{{strings.get('workflow_maker.ALWAYS')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">P</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.get('workflow_maker.PROJECT_SYNC')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">I</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.get('workflow_maker.INVENTORY_SYNC')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">W</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.get('workflow_maker.WORKFLOW')}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--warning">!</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.get('workflow_maker.WARNING')}}</p>
</li>
</ul>
<workflow-key ng-show="showKey"></workflow-key>
</div>
<div class="WorkflowLegend-maker--right">
<span class="WorkflowMaker-totalJobs">{{strings.get('workflow_maker.TOTAL_NODES')}}</span>
@ -128,7 +96,7 @@
</div>
<div class="WorkflowMaker-contentRight">
<span ng-if="formState.showNodeForm">
<workflow-node-form node-config="nodeConfig" workflow-job-template-obj="workflowJobTemplateObj" select="confirmNodeForm(selectedTemplate, promptData, edgeType)" cancel="cancelNodeForm()" read-only="!workflowJobTemplateObj.summary_fields.user_capabilities.edit"/>
<workflow-node-form node-config="nodeConfig" workflow-job-template-obj="workflowJobTemplateObj" select="confirmNodeForm(nodeFormData)" cancel="cancelNodeForm()" read-only="!workflowJobTemplateObj.summary_fields.user_capabilities.edit"/>
</span>
<span ng-if="formState.showLinkForm">
<workflow-link-form link-config="linkConfig" read-only="!workflowJobTemplateObj.summary_fields.user_capabilities.edit" select="confirmLinkForm(edgeType)" cancel="cancelLinkForm()" unlink="unlink()"/>

View File

@ -327,35 +327,7 @@
<div class="WorkflowLegend-details">
<div class="WorkflowLegend-details--left">
<i ng-class="{{ keyClassList }}" class="fa fa-compass Key-menuIcon" ng-click="toggleKey()"></i>
<ul ng-show="showKey" class="Key-list noselect">
<li class="Key-listItem">
<p class="Key-heading">{{strings.legend.KEY}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--success"></div>
<p class="Key-listItemContent">{{strings.legend.ON_SUCCESS}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--fail"></div>
<p class="Key-listItemContent">{{strings.legend.ON_FAILURE}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--always"></div>
<p class="Key-listItemContent">{{strings.legend.ALWAYS}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">P</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.legend.PROJECT_SYNC}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">I</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.legend.INVENTORY_SYNC}}</p>
</li>
<li class="Key-listItem">
<div class="Key-icon Key-icon--circle Key-icon--default">W</div>
<p class="Key-listItemContent Key-listItemContent--circle">{{strings.legend.WORKFLOW}}</p>
</li>
</ul>
<workflow-key ng-show="showKey"></workflow-key>
</div>
<div class="WorkflowLegend-details--right">
<i ng-class="{'WorkflowMaker-manualControlsIcon--active': showManualControls}" class="fa fa-cog WorkflowMaker-manualControlsIcon" aria-hidden="true" alt="Controls" ng-click="toggleManualControls()"></i>

View File

@ -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: {

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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/'

View File

@ -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.