Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions awx/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
UnifiedJobTemplate,
WorkflowApproval,
WorkflowApprovalTemplate,
WorkflowApprovalVote,
WorkflowJob,
WorkflowJobNode,
WorkflowJobTemplate,
Expand Down Expand Up @@ -174,7 +175,7 @@
'workflow_job_template': DEFAULT_SUMMARY_FIELDS,
'workflow_job': DEFAULT_SUMMARY_FIELDS,
'workflow_approval_template': DEFAULT_SUMMARY_FIELDS + ('timeout',),
'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout',),
'workflow_approval': DEFAULT_SUMMARY_FIELDS + ('timeout', 'status'),
'schedule': DEFAULT_SUMMARY_FIELDS + ('next_run',),
'unified_job_template': DEFAULT_SUMMARY_FIELDS + ('unified_job_type',),
# last_job and last_job_host_summary are derived from JobHostSummary in HostSerializer,
Expand Down Expand Up @@ -4016,10 +4017,24 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer):
can_approve_or_deny = serializers.SerializerMethodField()
approval_expiration = serializers.SerializerMethodField()
timed_out = serializers.ReadOnlyField()
approvals_received = serializers.SerializerMethodField()
user_has_voted = serializers.SerializerMethodField()

class Meta:
model = WorkflowApproval
fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out', 'context_message')
fields = (
'*',
'-controller_node',
'-execution_node',
'can_approve_or_deny',
'approval_expiration',
'timed_out',
'context_message',
'required_approvals',
'approvals_received',
'on_timeout',
'user_has_voted',
)

def get_approval_expiration(self, obj):
if obj.status != 'pending' or obj.timeout == 0:
Expand All @@ -4031,13 +4046,23 @@ def get_can_approve_or_deny(self, obj):
allowed = request.user.can_access(WorkflowApproval, 'approve_or_deny', obj)
return allowed is True and obj.status == 'pending'

def get_approvals_received(self, obj):
return obj.approvals_received()

def get_user_has_voted(self, obj):
request = self.context.get('request', None)
if request is None:
return False
return obj.has_vote_from(request.user)

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})
res['votes'] = self.reverse('api:workflow_approval_votes_list', kwargs={'pk': obj.pk})
if obj.approved_or_denied_by:
res['approved_or_denied_by'] = self.reverse('api:user_detail', kwargs={'pk': obj.approved_or_denied_by.pk})
return res
Expand Down Expand Up @@ -4068,7 +4093,7 @@ def get_field_names(self, declared_fields, info):
class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer):
class Meta:
model = WorkflowApprovalTemplate
fields = ('*', 'timeout', 'name', 'context_template')
fields = ('*', 'timeout', 'name', 'context_template', 'required_approvals', 'on_timeout')

def get_related(self, obj):
res = super(WorkflowApprovalTemplateSerializer, self).get_related(obj)
Expand All @@ -4079,6 +4104,33 @@ def get_related(self, obj):
return res


class WorkflowApprovalVoteSerializer(BaseSerializer):
class Meta:
model = WorkflowApprovalVote
fields = (
'*',
'-name',
'-description',
'-modified',
'workflow_approval',
'user',
'vote',
'comment',
'workflow_approval_name',
'workflow_job_id',
'workflow_job_name',
'user_name',
)

def get_related(self, obj):
res = super(WorkflowApprovalVoteSerializer, self).get_related(obj)
if obj.workflow_approval_id:
res['workflow_approval'] = self.reverse('api:workflow_approval_detail', kwargs={'pk': obj.workflow_approval_id})
if obj.user_id:
res['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user_id})
return res


class LaunchConfigurationBaseSerializer(BaseSerializer):
scm_branch = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None, max_length=1024)
job_type = serializers.ChoiceField(allow_blank=True, allow_null=True, required=False, default=None, choices=NEW_JOB_TYPE_CHOICES)
Expand Down Expand Up @@ -4394,7 +4446,7 @@ def build_relational_field(self, field_name, relation_info):
class WorkflowJobTemplateNodeCreateApprovalSerializer(BaseSerializer):
class Meta:
model = WorkflowApprovalTemplate
fields = ('timeout', 'name', 'description', 'context_template')
fields = ('timeout', 'name', 'description', 'context_template', 'required_approvals', 'on_timeout')

def to_representation(self, obj):
return {}
Expand Down
2 changes: 2 additions & 0 deletions awx/api/urls/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
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
from .workflow_approval_vote import urls as workflow_approval_vote_urls
from .analytics import urls as analytics_urls
from .receptor_address import urls as receptor_address_urls

Expand Down Expand Up @@ -153,6 +154,7 @@
path(f'{AWX_ANALYTICS_API_PREFIX}/', include(analytics_urls)),
path('workflow_approval_templates/', include(workflow_approval_template_urls)),
path('workflow_approvals/', include(workflow_approval_urls)),
path('workflow_approval_votes/', include(workflow_approval_vote_urls)),
path('bulk/', BulkView.as_view(), name='bulk'),
path('bulk/host_create/', BulkHostCreateView.as_view(), name='bulk_host_create'),
path('bulk/host_delete/', BulkHostDeleteView.as_view(), name='bulk_host_delete'),
Expand Down
3 changes: 2 additions & 1 deletion awx/api/urls/workflow_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@

from django.urls import path

from awx.api.views import WorkflowApprovalList, WorkflowApprovalDetail, WorkflowApprovalApprove, WorkflowApprovalDeny
from awx.api.views import WorkflowApprovalList, WorkflowApprovalDetail, WorkflowApprovalApprove, WorkflowApprovalDeny, WorkflowApprovalVotesList

urls = [
path('', WorkflowApprovalList.as_view(), name='workflow_approval_list'),
path('<int:pk>/', WorkflowApprovalDetail.as_view(), name='workflow_approval_detail'),
path('<int:pk>/approve/', WorkflowApprovalApprove.as_view(), name='workflow_approval_approve'),
path('<int:pk>/deny/', WorkflowApprovalDeny.as_view(), name='workflow_approval_deny'),
path('<int:pk>/votes/', WorkflowApprovalVotesList.as_view(), name='workflow_approval_votes_list'),
]

__all__ = ['urls']
13 changes: 13 additions & 0 deletions awx/api/urls/workflow_approval_vote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved.

from django.urls import path

from awx.api.views import WorkflowApprovalVoteList, WorkflowApprovalVoteDetail

urls = [
path('', WorkflowApprovalVoteList.as_view(), name='workflow_approval_vote_list'),
path('<int:pk>/', WorkflowApprovalVoteDetail.as_view(), name='workflow_approval_vote_detail'),
]

__all__ = ['urls']
36 changes: 34 additions & 2 deletions awx/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4689,6 +4689,16 @@ class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView):
serializer_class = serializers.WorkflowApprovalSerializer


def _approval_vote_comment(request):
# request.data may be a dict, QueryDict or other mapping depending on the parser
data = request.data
if hasattr(data, 'get'):
comment = data.get('comment', '')
if isinstance(comment, str):
return comment
return ''


class WorkflowApprovalApprove(RetrieveAPIView):
model = models.WorkflowApproval
serializer_class = serializers.WorkflowApprovalViewSerializer
Expand All @@ -4700,7 +4710,9 @@ def post(self, request, *args, **kwargs):
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)
if obj.has_vote_from(request.user):
return Response({"error": _("You have already voted on this workflow step.")}, status=status.HTTP_400_BAD_REQUEST)
obj.approve(request, comment=_approval_vote_comment(request))
return Response(status=status.HTTP_204_NO_CONTENT)


Expand All @@ -4715,5 +4727,25 @@ def post(self, request, *args, **kwargs):
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)
if obj.has_vote_from(request.user):
return Response({"error": _("You have already voted on this workflow step.")}, status=status.HTTP_400_BAD_REQUEST)
obj.deny(request, comment=_approval_vote_comment(request))
return Response(status=status.HTTP_204_NO_CONTENT)


class WorkflowApprovalVotesList(SubListAPIView):
model = models.WorkflowApprovalVote
serializer_class = serializers.WorkflowApprovalVoteSerializer
parent_model = models.WorkflowApproval
relationship = 'votes'
parent_key = 'workflow_approval'


class WorkflowApprovalVoteList(ListAPIView):
model = models.WorkflowApprovalVote
serializer_class = serializers.WorkflowApprovalVoteSerializer


class WorkflowApprovalVoteDetail(RetrieveAPIView):
model = models.WorkflowApprovalVote
serializer_class = serializers.WorkflowApprovalVoteSerializer
1 change: 1 addition & 0 deletions awx/api/views/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def get(self, request, format=None):
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_approval_votes'] = reverse('api:workflow_approval_vote_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)
data['mesh_visualizer'] = reverse('api:mesh_visualizer_view', request=request)
Expand Down
31 changes: 31 additions & 0 deletions awx/main/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
WorkflowJobTemplateNode,
WorkflowApproval,
WorkflowApprovalTemplate,
WorkflowApprovalVote,
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
ROLE_SINGLETON_SYSTEM_AUDITOR,
)
Expand Down Expand Up @@ -2926,6 +2927,7 @@ class WorkflowApprovalAccess(BaseAccess):
prefetch_related = (
'created_by',
'modified_by',
'votes',
)

def can_use(self, obj):
Expand Down Expand Up @@ -2984,6 +2986,35 @@ def filtered_queryset(self):
return self.model.objects.filter(workflowjobtemplatenodes__workflow_job_template__in=WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role'))


class WorkflowApprovalVoteAccess(BaseAccess):
"""
Votes are an immutable audit trail written internally when a user approves
or denies a workflow approval; they can never be created, changed or
deleted through the API.

A user can see votes belonging to approvals they can see. Votes whose
approval has been deleted are only visible to superusers and system
auditors.
"""

model = WorkflowApprovalVote
select_related = ('user', 'workflow_approval')

def filtered_queryset(self):
return self.model.objects.filter(
workflow_approval__unified_job_node__workflow_job__unified_job_template__in=WorkflowJobTemplate.accessible_pk_qs(self.user, 'read_role')
)

def can_add(self, data):
return False

def can_change(self, obj, data):
return False

def can_delete(self, obj):
return False


for cls in BaseAccess.__subclasses__():
access_registry[cls.model] = cls
access_registry[UnpartitionedJobEvent] = UnpartitionedJobEventAccess
Expand Down
Loading