From 9024a514a62e1fa9132558566cea74168579804b Mon Sep 17 00:00:00 2001 From: beeankha Date: Wed, 3 Jul 2019 14:46:59 -0400 Subject: [PATCH] Add API endpoints for workflow approvals --- awx/api/serializers.py | 71 ++++++++++++-- awx/api/urls/urls.py | 5 + awx/api/urls/workflow_approval.py | 21 +++++ awx/api/urls/workflow_approval_template.py | 28 ++++++ awx/api/views/__init__.py | 94 +++++++++++++++++++ awx/api/views/root.py | 2 + awx/main/access.py | 46 +++++++++ .../migrations/0082_v360_workflowapproval.py | 7 +- awx/main/models/__init__.py | 3 +- awx/main/models/workflow.py | 38 +++++++- .../functional/models/test_unified_job.py | 6 +- 11 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 awx/api/urls/workflow_approval.py create mode 100644 awx/api/urls/workflow_approval_template.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5f38158cda..977c076d51 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -50,16 +50,16 @@ from awx.main.constants import ( CENSOR_VALUE, ) from awx.main.models import ( - ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, - CredentialInputSource, CredentialType, CustomInventoryScript, - Group, Host, Instance, InstanceGroup, Inventory, InventorySource, - InventoryUpdate, InventoryUpdateEvent, Job, JobEvent, JobHostSummary, - JobLaunchConfig, JobNotificationMixin, JobTemplate, Label, Notification, - NotificationTemplate, OAuth2AccessToken, OAuth2Application, Organization, - Project, ProjectUpdate, ProjectUpdateEvent, RefreshToken, Role, Schedule, - StdoutMaxBytesExceeded, SystemJob, SystemJobEvent, SystemJobTemplate, - Team, UnifiedJob, UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, - WorkflowJobTemplate, WorkflowJobTemplateNode + ActivityStream, AdHocCommand, AdHocCommandEvent, Credential, CredentialInputSource, + CredentialType, CustomInventoryScript, Group, Host, Instance, + InstanceGroup, Inventory, InventorySource, InventoryUpdate, + InventoryUpdateEvent, Job, JobEvent, JobHostSummary, JobLaunchConfig, + JobNotificationMixin, JobTemplate, Label, Notification, NotificationTemplate, + OAuth2AccessToken, OAuth2Application, Organization, Project, + ProjectUpdate, ProjectUpdateEvent, RefreshToken, Role, Schedule, + SystemJob, SystemJobEvent, SystemJobTemplate, Team, UnifiedJob, + UnifiedJobTemplate, WorkflowApproval, WorkflowApprovalTemplate, WorkflowJob, + WorkflowJobNode, WorkflowJobTemplate, WorkflowJobTemplateNode, StdoutMaxBytesExceeded ) from awx.main.models.base import VERBOSITY_CHOICES, NEW_JOB_TYPE_CHOICES from awx.main.models.rbac import ( @@ -681,6 +681,8 @@ class UnifiedJobTemplateSerializer(BaseSerializer): serializer_class = SystemJobTemplateSerializer elif isinstance(obj, WorkflowJobTemplate): serializer_class = WorkflowJobTemplateSerializer + elif isinstance(obj, WorkflowApprovalTemplate): + serializer_class = WorkflowApprovalTemplateSerializer return serializer_class def to_representation(self, obj): @@ -782,6 +784,8 @@ class UnifiedJobSerializer(BaseSerializer): serializer_class = SystemJobSerializer elif isinstance(obj, WorkflowJob): serializer_class = WorkflowJobSerializer + elif isinstance(obj, WorkflowApproval): + serializer_class = WorkflowApprovalSerializer return serializer_class def to_representation(self, obj): @@ -838,6 +842,8 @@ class UnifiedJobListSerializer(UnifiedJobSerializer): serializer_class = SystemJobListSerializer elif isinstance(obj, WorkflowJob): serializer_class = WorkflowJobListSerializer + elif isinstance(obj, WorkflowApproval): + serializer_class = WorkflowApprovalListSerializer return serializer_class def to_representation(self, obj): @@ -3395,6 +3401,51 @@ class WorkflowJobCancelSerializer(WorkflowJobSerializer): fields = ('can_cancel',) +class WorkflowApprovalSerializer(UnifiedJobSerializer): + + class Meta: + model = WorkflowApproval + fields = ('*', 'result_stdout', '-controller_node', '-execution_node',) + + def get_related(self, obj): + res = super(WorkflowApprovalSerializer, self).get_related(obj) + + if obj.workflow_approval_template: + res['workflow_approval_template'] = self.reverse('api:workflow_approval_template_detail', + kwargs={'pk': obj.workflow_approval_template.pk}) + res['notifications'] = self.reverse('api:workflow_approval_notifications_list', kwargs={'pk': obj.pk}) + return res + + def get_result_stdout(self, obj): + return obj.result_stdout + + +class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer): + + class Meta: + fields = ('*', '-execution_node', '-controller_node',) + + +class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): + + class Meta: + model = WorkflowApprovalTemplate + fields = ('*',) + + def get_related(self, obj): + res = super(WorkflowApprovalTemplateSerializer, self).get_related(obj) + if 'last_job' in res: + del res['last_job'] + + res.update(dict( + jobs = self.reverse('api:workflow_approval_template_jobs_list', kwargs={'pk': obj.pk}), + notification_templates_started = self.reverse('api:workflow_approval_template_notification_templates_started_list', kwargs={'pk': obj.pk}), + notification_templates_success = self.reverse('api:workflow_approval_template_notification_templates_success_list', kwargs={'pk': obj.pk}), + notification_templates_error = self.reverse('api:workflow_approval_template_notification_templates_error_list', kwargs={'pk': obj.pk}), + )) + return res + + class LaunchConfigurationBaseSerializer(BaseSerializer): scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) job_type = serializers.ChoiceField(allow_blank=True, allow_null=True, required=False, default=None, diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 31eb6b78d0..97819b5ce9 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -71,6 +71,8 @@ from .instance import urls as instance_urls from .instance_group import urls as instance_group_urls from .oauth2 import urls as oauth2_urls from .oauth2_root import urls as oauth2_root_urls +from .workflow_approval_template import urls as workflow_approval_template_urls +from .workflow_approval import urls as workflow_approval_urls v2_urls = [ @@ -131,8 +133,11 @@ v2_urls = [ url(r'^unified_job_templates/$', UnifiedJobTemplateList.as_view(), name='unified_job_template_list'), url(r'^unified_jobs/$', UnifiedJobList.as_view(), name='unified_job_list'), url(r'^activity_stream/', include(activity_stream_urls)), + url(r'^workflow_approval_templates/', include(workflow_approval_template_urls)), + url(r'^workflow_approval/', include(workflow_approval_urls)), ] + app_name = 'api' urlpatterns = [ url(r'^$', ApiRootView.as_view(), name='api_root_view'), diff --git a/awx/api/urls/workflow_approval.py b/awx/api/urls/workflow_approval.py new file mode 100644 index 0000000000..b66fe751c7 --- /dev/null +++ b/awx/api/urls/workflow_approval.py @@ -0,0 +1,21 @@ +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + +from django.conf.urls import url + +from awx.api.views import ( + WorkflowApprovalList, + WorkflowApprovalDetail, + WorkflowApprovalNotificationsList, +) + + +urls = [ + url(r'^$', WorkflowApprovalList.as_view(), name='workflow_approval_list'), + url(r'^(?P[0-9]+)/$', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'), + url(r'^(?P[0-9]+)/approve/$', WorkflowApprovalDetail.as_view(), name='approved_workflow'), + url(r'^(?P[0-9]+)/reject/$', WorkflowApprovalDetail.as_view(), name='rejected_workflow'), + url(r'^(?P[0-9]+)/notifications/$', WorkflowApprovalNotificationsList.as_view(), name='workflow_approval_notifications_list'), +] + +__all__ = ['urls'] diff --git a/awx/api/urls/workflow_approval_template.py b/awx/api/urls/workflow_approval_template.py new file mode 100644 index 0000000000..e0955f0904 --- /dev/null +++ b/awx/api/urls/workflow_approval_template.py @@ -0,0 +1,28 @@ +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + +from django.conf.urls import url + +from awx.api.views import ( + WorfklowApprovalTemplateList, + WorfklowApprovalTemplateDetail, + WorkflowApprovalTemplateJobsList, + WorkflowApprovalTemplateNotificationTemplatesErrorList, + WorkflowApprovalTemplateNotificationTemplatesStartedList, + WorkflowApprovalTemplateNotificationTemplatesSuccessList, +) + + +urls = [ + url(r'^$', WorfklowApprovalTemplateList.as_view(), name='workflow_approval_template_list'), + url(r'^(?P[0-9]+)/$', WorfklowApprovalTemplateDetail.as_view(), name='workflow_approval_template_detail'), + url(r'^(?P[0-9]+)/approvals/$', WorkflowApprovalTemplateJobsList.as_view(), name='workflow_approval_template_jobs_list'), + url(r'^(?P[0-9]+)/notification_templates_started/$', WorkflowApprovalTemplateNotificationTemplatesStartedList.as_view(), + name='workflow_approval_template_notification_templates_started_list'), + url(r'^(?P[0-9]+)/notification_templates_error/$', WorkflowApprovalTemplateNotificationTemplatesErrorList.as_view(), + name='workflow_approval_template_notification_templates_error_list'), + url(r'^(?P[0-9]+)/notification_templates_success/$', WorkflowApprovalTemplateNotificationTemplatesSuccessList.as_view(), + name='workflow_approval_template_notification_templates_success_list'), +] + +__all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index a6601ad607..0c8e52a988 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4405,3 +4405,97 @@ for attr, value in list(locals().items()): name = camelcase_to_underscore(attr) view = value.as_view() setattr(this_module, name, view) + + +class WorfklowApprovalTemplateList(ListAPIView): + + model = models.WorkflowApprovalTemplate + serializer_class = serializers.WorkflowApprovalTemplateSerializer + + def get(self, request, *args, **kwargs): + if not request.user.is_superuser and not request.user.is_system_auditor: + raise PermissionDenied(_("Superuser privileges needed.")) + return super(WorfklowApprovalTemplateList, self).get(request, *args, **kwargs) + + +class WorfklowApprovalTemplateDetail(RetrieveAPIView): + + model = models.WorkflowApprovalTemplate + serializer_class = serializers.WorkflowApprovalTemplateSerializer + + +class WorkflowApprovalTemplateNotificationTemplatesAnyList(SubListCreateAttachDetachAPIView): + + model = models.NotificationTemplate + serializer_class = serializers.NotificationTemplateSerializer + parent_model = models.WorkflowApprovalTemplate + + +class WorkflowApprovalTemplateNotificationTemplatesStartedList(WorkflowApprovalTemplateNotificationTemplatesAnyList): + + relationship = 'notification_templates_started' + + +class WorkflowApprovalTemplateNotificationTemplatesErrorList(WorkflowApprovalTemplateNotificationTemplatesAnyList): + + relationship = 'notification_templates_error' + + +class WorkflowApprovalTemplateNotificationTemplatesSuccessList(WorkflowApprovalTemplateNotificationTemplatesAnyList): + + relationship = 'notification_templates_success' + + +class WorkflowApprovalTemplateLaunch(GenericAPIView): + + model = models.WorkflowApprovalTemplate + obj_permission_type = 'start' + serializer_class = serializers.EmptySerializer + + def get(self, request, *args, **kwargs): + return Response({}) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + new_job = obj.create_unified_job(extra_vars=request.data.get('extra_vars', {})) + new_job.signal_start() + data = OrderedDict() + data['workflow_approval'] = new_job.id + data.update(serializers.WorkflowApprovalSerializer(new_job, context=self.get_serializer_context()).to_representation(new_job)) + headers = {'Location': new_job.get_absolute_url(request)} + return Response(data, status=status.HTTP_201_CREATED, headers=headers) + + +class WorkflowApprovalTemplateJobsList(SubListAPIView): + + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalListSerializer + parent_model = models.WorkflowApprovalTemplate + relationship = 'approvals' + parent_key = 'workflow_approval_template' + + +class WorkflowApprovalList(ListCreateAPIView): + + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalListSerializer + + def get(self, request, *args, **kwargs): + if not request.user.is_superuser and not request.user.is_system_auditor: + raise PermissionDenied(_("Superuser privileges needed.")) + return super(WorkflowApprovalList, self).get(request, *args, **kwargs) + + +class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): + + model = models.WorkflowApproval + serializer_class = serializers.WorkflowApprovalSerializer + + +class WorkflowApprovalNotificationsList(SubListAPIView): + + model = models.Notification + serializer_class = serializers.NotificationSerializer + parent_model = models.WorkflowApproval + relationship = 'notifications' + search_fields = ('subject', 'notification_type', 'body',) diff --git a/awx/api/views/root.py b/awx/api/views/root.py index 50e7ada0d6..bf85b4866e 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -124,6 +124,8 @@ class ApiVersionRootView(APIView): data['activity_stream'] = reverse('api:activity_stream_list', request=request) data['workflow_job_templates'] = reverse('api:workflow_job_template_list', request=request) data['workflow_jobs'] = reverse('api:workflow_job_list', request=request) + data['workflow_approval_templates'] = reverse('api:workflow_approval_template_list', request=request) + data['workflow_approval'] = reverse('api:workflow_approval_list', request=request) data['workflow_job_template_nodes'] = reverse('api:workflow_job_template_node_list', request=request) data['workflow_job_nodes'] = reverse('api:workflow_job_node_list', request=request) return Response(data) diff --git a/awx/main/access.py b/awx/main/access.py index 78aaa2f5d2..f732ae00c9 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -37,6 +37,7 @@ from awx.main.models import ( ProjectUpdateEvent, Role, Schedule, SystemJob, SystemJobEvent, SystemJobTemplate, Team, UnifiedJob, UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, WorkflowJobTemplate, WorkflowJobTemplateNode, + WorkflowApproval, WorkflowApprovalTemplate, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR ) from awx.main.models.mixins import ResourceMixin @@ -2769,5 +2770,50 @@ class RoleAccess(BaseAccess): return False +class WorkflowApprovalAccess(BaseAccess): + ''' + I can approve workflows when: + - I'm authenticated + I can create when: + - I'm a superuser: + ''' + + model = WorkflowApproval + prefetch_related = ('created_by', 'modified_by',) + + def can_read(self, obj): + return True + + def can_use(self, obj): + return True + + def filtered_queryset(self): + return self.model.objects.all() + + def can_start(self, obj, validate_license=True): + return False + + +class WorkflowApprovalTemplateAccess(BaseAccess): + ''' + I can approve workflows when: + - I'm authenticated + I can create when: + - I'm a superuser: + ''' + + model = WorkflowApprovalTemplate + prefetch_related = ('created_by', 'modified_by',) + + def can_read(self, obj): + return True + + def can_use(self, obj): + return True + + def filtered_queryset(self): + return self.model.objects.all() + + for cls in BaseAccess.__subclasses__(): access_registry[cls.model] = cls diff --git a/awx/main/migrations/0082_v360_workflowapproval.py b/awx/main/migrations/0082_v360_workflowapproval.py index 7e8b03bd88..80a188c274 100644 --- a/awx/main/migrations/0082_v360_workflowapproval.py +++ b/awx/main/migrations/0082_v360_workflowapproval.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.20 on 2019-07-01 19:41 +# Generated by Django 1.11.20 on 2019-07-03 14:38 from __future__ import unicode_literals from django.db import migrations, models @@ -33,4 +33,9 @@ class Migration(migrations.Migration): }, bases=('main.unifiedjobtemplate',), ), + migrations.AddField( + model_name='workflowapproval', + name='workflow_approval_template', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approvals', to='main.WorkflowApprovalTemplate'), + ), ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index d0ed13ef54..a85653e112 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -55,7 +55,7 @@ from awx.main.models.notifications import ( # noqa from awx.main.models.label import Label # noqa from awx.main.models.workflow import ( # noqa WorkflowJob, WorkflowJobNode, WorkflowJobOptions, WorkflowJobTemplate, - WorkflowJobTemplateNode, + WorkflowJobTemplateNode, WorkflowApproval, WorkflowApprovalTemplate, ) from awx.main.models.channels import ChannelGroup # noqa from awx.api.versioning import reverse @@ -212,4 +212,3 @@ prevent_search(RefreshToken._meta.get_field('token')) prevent_search(OAuth2Application._meta.get_field('client_secret')) prevent_search(OAuth2Application._meta.get_field('client_id')) prevent_search(Grant._meta.get_field('code')) - diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 26cc1424df..3975b4dbf1 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -34,6 +34,7 @@ from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemp from awx.main.models.credential import Credential from awx.main.redact import REPLACE_STR from awx.main.fields import JSONField +from awx.main.utils import schedule_task_manager from copy import copy from urllib.parse import urljoin @@ -604,7 +605,6 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio class WorkflowApprovalTemplate(UnifiedJobTemplate): - class Meta: app_label = 'main' @@ -616,8 +616,42 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): def _get_unified_job_field_names(cls): return ['name', 'description'] + def get_absolute_url(self, request=None): + return reverse('api:workflow_approval_template_detail', kwargs={'pk': self.pk}, request=request) + class WorkflowApproval(UnifiedJob): - class Meta: app_label = 'main' + + workflow_approval_template = models.ForeignKey( + 'WorkflowApprovalTemplate', + related_name='approvals', + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + ) + + @classmethod + def _get_unified_job_template_class(cls): + return WorkflowApprovalTemplate + + def get_absolute_url(self, request=None): + return reverse('api:workflow_approval_detail', kwargs={'pk': self.pk}, request=request) + + @property + def event_class(self): + return None + + def approve(self, request=None): + self.status = 'successful' + self.save() + schedule_task_manager() + return reverse('api:approved_workflow', kwargs={'pk': self.pk}, request=request) + + def reject(self, request=None): + self.status = 'failed' + self.save() + schedule_task_manager() + return reverse('api:rejected_workflow', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py index 402e2ac1f6..0db76aac4c 100644 --- a/awx/main/tests/functional/models/test_unified_job.py +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -10,7 +10,7 @@ from django.contrib.contenttypes.models import ContentType # AWX from awx.main.models import ( UnifiedJobTemplate, Job, JobTemplate, WorkflowJobTemplate, - Project, WorkflowJob, Schedule, + WorkflowApprovalTemplate, Project, WorkflowJob, Schedule, Credential ) @@ -20,7 +20,9 @@ def test_subclass_types(rando): assert set(UnifiedJobTemplate._submodels_with_roles()) == set([ ContentType.objects.get_for_model(JobTemplate).id, ContentType.objects.get_for_model(Project).id, - ContentType.objects.get_for_model(WorkflowJobTemplate).id + ContentType.objects.get_for_model(WorkflowJobTemplate).id, + ContentType.objects.get_for_model(WorkflowApprovalTemplate).id + ])