Merge pull request #4657 from beeankha/wf_approval_notification

[WIP] Notification Support for Workflow Approval Nodes

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2019-09-30 14:54:59 +00:00 committed by GitHub
commit 505dcf9dd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 213 additions and 20 deletions

View File

@ -489,9 +489,12 @@ class SubListAPIView(ParentMixin, ListAPIView):
parent = self.get_parent_object()
self.check_parent_access(parent)
qs = self.request.user.get_queryset(self.model).distinct()
sublist_qs = getattrd(parent, self.relationship).distinct()
sublist_qs = self.get_sublist_queryset(parent)
return qs & sublist_qs
def get_sublist_queryset(self, parent):
return getattrd(parent, self.relationship).distinct()
class DestroyAPIView(generics.DestroyAPIView):

View File

@ -1261,6 +1261,7 @@ class OrganizationSerializer(BaseSerializer):
notification_templates_started = self.reverse('api:organization_notification_templates_started_list', kwargs={'pk': obj.pk}),
notification_templates_success = self.reverse('api:organization_notification_templates_success_list', kwargs={'pk': obj.pk}),
notification_templates_error = self.reverse('api:organization_notification_templates_error_list', kwargs={'pk': obj.pk}),
notification_templates_approvals = self.reverse('api:organization_notification_templates_approvals_list', kwargs={'pk': obj.pk}),
object_roles = self.reverse('api:organization_object_roles_list', kwargs={'pk': obj.pk}),
access_list = self.reverse('api:organization_access_list', kwargs={'pk': obj.pk}),
instance_groups = self.reverse('api:organization_instance_groups_list', kwargs={'pk': obj.pk}),
@ -3335,6 +3336,7 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
notification_templates_started = self.reverse('api:workflow_job_template_notification_templates_started_list', kwargs={'pk': obj.pk}),
notification_templates_success = self.reverse('api:workflow_job_template_notification_templates_success_list', kwargs={'pk': obj.pk}),
notification_templates_error = self.reverse('api:workflow_job_template_notification_templates_error_list', kwargs={'pk': obj.pk}),
notification_templates_approvals = self.reverse('api:workflow_job_template_notification_templates_approvals_list', kwargs={'pk': obj.pk}),
access_list = self.reverse('api:workflow_job_template_access_list', kwargs={'pk': obj.pk}),
object_roles = self.reverse('api:workflow_job_template_object_roles_list', kwargs={'pk': obj.pk}),
survey_spec = self.reverse('api:workflow_job_template_survey_spec', kwargs={'pk': obj.pk}),
@ -3490,7 +3492,7 @@ class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer):
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}),))
res.update(jobs = self.reverse('api:workflow_approval_template_jobs_list', kwargs={'pk': obj.pk}))
return res

View File

@ -18,6 +18,7 @@ from awx.api.views import (
OrganizationNotificationTemplatesErrorList,
OrganizationNotificationTemplatesStartedList,
OrganizationNotificationTemplatesSuccessList,
OrganizationNotificationTemplatesApprovalList,
OrganizationInstanceGroupsList,
OrganizationObjectRolesList,
OrganizationAccessList,
@ -43,6 +44,8 @@ urls = [
name='organization_notification_templates_error_list'),
url(r'^(?P<pk>[0-9]+)/notification_templates_success/$', OrganizationNotificationTemplatesSuccessList.as_view(),
name='organization_notification_templates_success_list'),
url(r'^(?P<pk>[0-9]+)/notification_templates_approvals/$', OrganizationNotificationTemplatesApprovalList.as_view(),
name='organization_notification_templates_approvals_list'),
url(r'^(?P<pk>[0-9]+)/instance_groups/$', OrganizationInstanceGroupsList.as_view(), name='organization_instance_groups_list'),
url(r'^(?P<pk>[0-9]+)/object_roles/$', OrganizationObjectRolesList.as_view(), name='organization_object_roles_list'),
url(r'^(?P<pk>[0-9]+)/access_list/$', OrganizationAccessList.as_view(), name='organization_access_list'),

View File

@ -16,6 +16,7 @@ from awx.api.views import (
WorkflowJobTemplateNotificationTemplatesErrorList,
WorkflowJobTemplateNotificationTemplatesStartedList,
WorkflowJobTemplateNotificationTemplatesSuccessList,
WorkflowJobTemplateNotificationTemplatesApprovalList,
WorkflowJobTemplateAccessList,
WorkflowJobTemplateObjectRolesList,
WorkflowJobTemplateLabelList,
@ -38,6 +39,8 @@ urls = [
name='workflow_job_template_notification_templates_error_list'),
url(r'^(?P<pk>[0-9]+)/notification_templates_success/$', WorkflowJobTemplateNotificationTemplatesSuccessList.as_view(),
name='workflow_job_template_notification_templates_success_list'),
url(r'^(?P<pk>[0-9]+)/notification_templates_approvals/$', WorkflowJobTemplateNotificationTemplatesApprovalList.as_view(),
name='workflow_job_template_notification_templates_approvals_list'),
url(r'^(?P<pk>[0-9]+)/access_list/$', WorkflowJobTemplateAccessList.as_view(), name='workflow_job_template_access_list'),
url(r'^(?P<pk>[0-9]+)/object_roles/$', WorkflowJobTemplateObjectRolesList.as_view(), name='workflow_job_template_object_roles_list'),
url(r'^(?P<pk>[0-9]+)/labels/$', WorkflowJobTemplateLabelList.as_view(), name='workflow_job_template_label_list'),

View File

@ -119,6 +119,7 @@ from awx.api.views.organization import ( # noqa
OrganizationNotificationTemplatesErrorList,
OrganizationNotificationTemplatesStartedList,
OrganizationNotificationTemplatesSuccessList,
OrganizationNotificationTemplatesApprovalList,
OrganizationInstanceGroupsList,
OrganizationAccessList,
OrganizationObjectRolesList,
@ -3288,6 +3289,11 @@ class WorkflowJobTemplateNotificationTemplatesSuccessList(WorkflowJobTemplateNot
relationship = 'notification_templates_success'
class WorkflowJobTemplateNotificationTemplatesApprovalList(WorkflowJobTemplateNotificationTemplatesAnyList):
relationship = 'notification_templates_approvals'
class WorkflowJobTemplateAccessList(ResourceAccessList):
model = models.User # needs to be User for AccessLists's
@ -3373,6 +3379,11 @@ class WorkflowJobNotificationsList(SubListAPIView):
relationship = 'notifications'
search_fields = ('subject', 'notification_type', 'body',)
def get_sublist_queryset(self, parent):
return self.model.objects.filter(Q(unifiedjob_notifications=parent) |
Q(unifiedjob_notifications__unified_job_node__workflow_job=parent,
unifiedjob_notifications__workflowapproval__isnull=False)).distinct()
class WorkflowJobActivityStreamList(SubListAPIView):

View File

@ -195,6 +195,11 @@ class OrganizationNotificationTemplatesSuccessList(OrganizationNotificationTempl
relationship = 'notification_templates_success'
class OrganizationNotificationTemplatesApprovalList(OrganizationNotificationTemplatesAnyList):
relationship = 'notification_templates_approvals'
class OrganizationInstanceGroupsList(SubListAttachDetachAPIView):
model = InstanceGroup

View File

@ -0,0 +1,28 @@
# Generated by Django 2.2.4 on 2019-09-11 13:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0090_v360_WFJT_prompts'),
]
operations = [
migrations.AddField(
model_name='organization',
name='notification_templates_approvals',
field=models.ManyToManyField(blank=True, related_name='organization_notification_templates_for_approvals', to='main.NotificationTemplate'),
),
migrations.AddField(
model_name='workflowjobtemplate',
name='notification_templates_approvals',
field=models.ManyToManyField(blank=True, related_name='workflowjobtemplate_notification_templates_for_approvals', to='main.NotificationTemplate'),
),
migrations.AlterField(
model_name='workflowjobnode',
name='do_not_run',
field=models.BooleanField(default=False, help_text='Indicates that a job will not be created when True. Workflow runtime semantics will mark this True if the node is in a path that will decidedly not be ran. A value of False means the node may not run.'),
),
]

View File

@ -51,6 +51,11 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
default=0,
help_text=_('Maximum number of hosts allowed to be managed by this organization.'),
)
notification_templates_approvals = models.ManyToManyField(
"NotificationTemplate",
blank=True,
related_name='%(class)s_notification_templates_for_approvals'
)
admin_role = ImplicitRoleField(
parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,

View File

@ -3,9 +3,11 @@
# Python
import logging
from copy import copy
from urllib.parse import urljoin
# Django
from django.db import models
from django.db import connection, models
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ObjectDoesNotExist
@ -38,9 +40,6 @@ 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', 'WorkflowApprovalTemplate', 'WorkflowApproval']
@ -196,7 +195,7 @@ class WorkflowJobNode(WorkflowNodeBase):
)
do_not_run = models.BooleanField(
default=False,
help_text=_("Indidcates that a job will not be created when True. Workflow runtime "
help_text=_("Indicates that a job will not be created when True. Workflow runtime "
"semantics will mark this True if the node is in a path that will "
"decidedly not be ran. A value of False means the node may not run."),
)
@ -388,6 +387,12 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
blank=True,
default=False,
)
notification_templates_approvals = models.ManyToManyField(
"NotificationTemplate",
blank=True,
related_name='%(class)s_notification_templates_for_approvals'
)
admin_role = ImplicitRoleField(parent_role=[
'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
'organization.workflow_admin_role'
@ -441,9 +446,16 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
.filter(unifiedjobtemplate_notification_templates_for_started__in=[self]))
success_notification_templates = list(base_notification_templates
.filter(unifiedjobtemplate_notification_templates_for_success__in=[self]))
approval_notification_templates = list(base_notification_templates
.filter(workflowjobtemplate_notification_templates_for_approvals__in=[self]))
# Get Organization NotificationTemplates
if self.organization is not None:
approval_notification_templates = set(approval_notification_templates + list(base_notification_templates.filter(
organization_notification_templates_for_approvals=self.organization)))
return dict(error=list(error_notification_templates),
started=list(started_notification_templates),
success=list(success_notification_templates))
success=list(success_notification_templates),
approvals=list(approval_notification_templates))
def create_unified_job(self, **kwargs):
workflow_job = super(WorkflowJobTemplate, self).create_unified_job(**kwargs)
@ -649,7 +661,7 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate):
return self.workflowjobtemplatenodes.first().workflow_job_template
class WorkflowApproval(UnifiedJob):
class WorkflowApproval(UnifiedJob, JobNotificationMixin):
class Meta:
app_label = 'main'
@ -689,6 +701,7 @@ class WorkflowApproval(UnifiedJob):
def approve(self, request=None):
self.status = 'successful'
self.save()
self.send_approval_notification('approved')
self.websocket_emit_status(self.status)
schedule_task_manager()
return reverse('api:workflow_approval_approve', kwargs={'pk': self.pk}, request=request)
@ -696,10 +709,53 @@ class WorkflowApproval(UnifiedJob):
def deny(self, request=None):
self.status = 'failed'
self.save()
self.send_approval_notification('denied')
self.websocket_emit_status(self.status)
schedule_task_manager()
return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request)
def signal_start(self, **kwargs):
can_start = super(WorkflowApproval, self).signal_start(**kwargs)
self.send_approval_notification('running')
return can_start
def send_approval_notification(self, approval_status):
from awx.main.tasks import send_notifications # avoid circular import
if self.workflow_job_template is None:
return
for nt in self.workflow_job_template.notification_templates["approvals"]:
try:
(notification_subject, notification_body) = self.build_approval_notification_message(nt, approval_status)
except Exception:
raise NotImplementedError("build_approval_notification_message() does not exist")
# Use kwargs to force late-binding
# https://stackoverflow.com/a/3431699/10669572
def send_it(local_nt=nt, local_subject=notification_subject, local_body=notification_body):
def _func():
send_notifications.delay([local_nt.generate_notification(local_subject, local_body).id],
job_id=self.id)
return _func
connection.on_commit(send_it())
def build_approval_notification_message(self, nt, approval_status):
subject = []
workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id))
subject.append(('The approval node "{}"').format(self.workflow_approval_template.name))
if approval_status == 'running':
subject.append(('needs review. This node can be viewed at: {}').format(workflow_url))
if approval_status == 'approved':
subject.append(('was approved. {}').format(workflow_url))
if approval_status == 'timed_out':
subject.append(('has timed out. {}').format(workflow_url))
elif approval_status == 'denied':
subject.append(('was denied. {}').format(workflow_url))
subject = " ".join(subject)
body = self.notification_data()
body['body'] = subject
return subject, body
@property
def workflow_job_template(self):
try:

View File

@ -533,6 +533,7 @@ class TaskManager():
logger.warn(timeout_message)
task.timed_out = True
task.status = 'failed'
task.send_approval_notification('timed_out')
task.websocket_emit_status(task.status)
task.job_explanation = timeout_message
task.save(update_fields=['status', 'job_explanation', 'timed_out'])

View File

@ -323,7 +323,7 @@ def send_notifications(notification_list, job_id=None):
notification.status = "successful"
notification.notifications_sent = sent
except Exception as e:
logger.error("Send Notification Failed {}".format(e))
logger.exception("Send Notification Failed {}".format(e))
notification.status = "failed"
notification.error = smart_str(e)
update_fields.append('error')

View File

@ -135,3 +135,45 @@ def test_search_on_notification_configuration_is_prevented(get, admin):
response = get(url, {'notification_configuration__regex': 'ABCDEF'}, admin)
assert response.status_code == 403
assert response.data == {"detail": "Filtering on notification_configuration is not allowed."}
@pytest.mark.django_db
def test_get_wfjt_approval_notification(get, admin, workflow_job_template):
url = reverse('api:workflow_job_template_notification_templates_approvals_list', kwargs={'pk': workflow_job_template.pk})
response = get(url, admin)
assert response.status_code == 200
assert len(response.data['results']) == 0
@pytest.mark.django_db
def test_post_wfjt_approval_notification(get, post, admin, notification_template, workflow_job_template):
url = reverse('api:workflow_job_template_notification_templates_approvals_list', kwargs={'pk': workflow_job_template.pk})
response = post(url,
dict(id=notification_template.id,
associate=True),
admin)
assert response.status_code == 204
response = get(url, admin)
assert response.status_code == 200
assert len(response.data['results']) == 1
@pytest.mark.django_db
def test_get_org_approval_notification(get, admin, organization):
url = reverse('api:organization_notification_templates_approvals_list', kwargs={'pk': organization.pk})
response = get(url, admin)
assert response.status_code == 200
assert len(response.data['results']) == 0
@pytest.mark.django_db
def test_post_org_approval_notification(get, post, admin, notification_template, organization):
url = reverse('api:organization_notification_templates_approvals_list', kwargs={'pk': organization.pk})
response = post(url,
dict(id=notification_template.id,
associate=True),
admin)
assert response.status_code == 204
response = get(url, admin)
assert response.status_code == 200
assert len(response.data['results']) == 1

View File

@ -25,15 +25,29 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
name: {
key: true,
label: i18n._('Name'),
columnClass: 'col-md-4 col-sm-9 col-xs-9',
linkTo: '/#/notification_templates/{{notification.id}}'
columnClass: 'col-sm-9 col-xs-9',
linkTo: '/#/notification_templates/{{notification.id}}',
columnNgClass: "{'col-lg-4 col-md-2': showApprovalColumn, 'col-lg-5 col-md-3': !showApprovalColumn}"
},
notification_type: {
label: i18n._('Type'),
searchType: 'select',
searchOptions: [],
excludeModal: true,
columnClass: 'd-none d-sm-flex col-md-4 col-sm-3'
columnClass: 'd-none d-sm-flex col-lg-4 col-md-2 col-sm-3',
},
notification_templates_approvals: {
label: i18n._('Approval'),
columnClass: 'd-none d-md-flex justify-content-start col-lg-1 col-md-2',
flag: 'notification_templates_approvals',
type: "toggle",
ngClick: "toggleNotification($event, notification.id, 'notification_templates_approvals')",
ngDisabled: "!sufficientRoleForNotifToggle",
awToolTip: "{{ schedule.play_tip }}",
dataTipWatch: "schedule.play_tip",
dataPlacement: "right",
nosort: true,
ngIf: "showApprovalColumn"
},
notification_templates_started: {
label: i18n._("Start"),
@ -45,7 +59,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
dataTipWatch: "schedule.play_tip",
dataPlacement: "right",
nosort: true,
columnClass: 'd-none d-md-flex justify-content-start col-md-1'
columnClass: 'd-none d-md-flex justify-content-start col-lg-1 col-md-2'
},
notification_templates_success: {
label: i18n._('Success'),
@ -57,11 +71,11 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
dataTipWatch: "schedule.play_tip",
dataPlacement: "right",
nosort: true,
columnClass: 'd-none d-md-flex justify-content-start col-md-1'
columnClass: 'd-none d-md-flex justify-content-start col-lg-1 col-md-2'
},
notification_templates_error: {
label: i18n._('Failure'),
columnClass: 'd-none d-md-flex justify-content-start col-md-1 NotifierList-lastColumn',
columnClass: 'd-none d-md-flex justify-content-start col-lg-1 col-md-2 NotifierList-lastColumn',
flag: 'notification_templates_error',
type: "toggle",
ngClick: "toggleNotification($event, notification.id, 'notification_templates_error')",

View File

@ -22,6 +22,10 @@ export default ['Wait', 'GetBasePath', 'ProcessErrors', 'Rest', 'GetChoices',
url = params.url,
id = params.id;
if ($state.includes('templates.editWorkflowJobTemplate') || $state.includes('organizations.edit')) {
scope.showApprovalColumn = true;
}
scope.addNotificationTemplate = function() {
var org_id;
if($stateParams.hasOwnProperty('project_id')){
@ -51,6 +55,10 @@ export default ['Wait', 'GetBasePath', 'ProcessErrors', 'Rest', 'GetChoices',
scope.relatednotificationsRemove = scope.$on('relatednotifications', function () {
var columns = ['/notification_templates_started/', '/notification_templates_success/', '/notification_templates_error/'];
if ($state.includes('templates.editWorkflowJobTemplate') || $state.includes('organizations.edit')) {
columns.push('/notification_templates_approvals');
}
GetChoices({
scope: scope,
url: GetBasePath('notifications'),
@ -64,9 +72,17 @@ export default ['Wait', 'GetBasePath', 'ProcessErrors', 'Rest', 'GetChoices',
Rest.setUrl(notifier_url);
Rest.get()
.then(function(response) {
let checkForSuccessOrError = response.config.url.indexOf('success') > 0 ? "notification_templates_success" : "notification_templates_error";
let type;
let type = response.config.url.indexOf('started') > 0 ? "notification_templates_started" : checkForSuccessOrError;
if (response.config.url.indexOf('started') > 0) {
type = "notification_templates_started";
} else if (response.config.url.indexOf('success') > 0) {
type = "notification_templates_success";
} else if (response.config.url.indexOf('error') > 0) {
type = "notification_templates_error";
} else if (response.config.url.indexOf('approvals') > 0) {
type = "notification_templates_approvals";
}
if (response.data.results) {
_.forEach(response.data.results, function(result){

View File

@ -1,4 +1,4 @@
<div id="{{columnIterator}}-{{columnField}}-header" class="List-tableHeader list-header {{columnCustomClass}}" ng-click="columnNoSort !== 'true' && toggleColumnOrderBy()" ng-class="{'list-header-noSort' : columnNoSort === 'true'}" >
<div id="{{columnIterator}}-{{columnField}}-header" class="List-tableHeader list-header {{columnCustomClass}}" ng-click="columnNoSort !== 'true' && toggleColumnOrderBy()" >
{{columnLabel | translate}}
<i ng-if="columnNoSort !== 'true'" class="fa columnSortIcon" ng-class="orderByIcon()">
</div>

View File

@ -527,8 +527,9 @@ angular.module('GeneratorHelpers', [systemStatus.name])
} else if (field.type === 'template') {
html = Template(field);
} else if (field.type === 'toggle') {
const ngIf = field.ngIf ? `ng-if="${field.ngIf}"` : '';
html += `
<div class="atSwitch-listTableCell ${field}-column ${field['class']} ${field.columnClass}">
<div class="atSwitch-listTableCell ${field['class']} ${field.columnClass}" ${ngIf}>
<at-switch on-toggle="${field.ngClick}" switch-on="${"flag" in field} ? ${list.iterator}.${field.flag} : ${list.iterator}.enabled" switch-disabled="${"ngDisabled" in field} ? ${field.ngDisabled} : false" tooltip-string="${field.awToolTip}" tooltip-placement="${field.dataPlacement ? field.dataPlacement : 'right'}" tooltip-watch="${field.dataTipWatch}"></at-switch>
</div>
`;

View File

@ -546,7 +546,9 @@ export default ['$compile', 'Attr', 'Icon',
for (fld in list.fields) {
if (options.mode !== 'lookup' || (options.mode === 'lookup' && (fld === 'name' || _.has(list.fields[fld], 'includeModal')))){
let customClass = list.fields[fld].columnClass || '';
const ngIf = list.fields[fld].ngIf ? `ng-if="${list.fields[fld].ngIf}"` : '';
html += `<div
${ngIf}
base-path="${list.basePath || list.name}"
collection="${list.name}"
dataset="${list.iterator}_dataset"
@ -556,6 +558,7 @@ export default ['$compile', 'Attr', 'Icon',
column-no-sort="${list.fields[fld].nosort}"
column-label="${list.fields[fld].label}"
column-custom-class="${customClass}"
ng-class="${list.fields[fld].columnNgClass || `{'list-header-noSort': ${list.fields[fld].nosort ? true : false}}`}"
query-set="${list.iterator}_queryset">
</div>`;
}