diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3dd5d017c8..e20e191824 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4881,6 +4881,7 @@ 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: diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 88631aa94a..65d246ee5f 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -203,6 +203,7 @@ 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) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 99c76ff77f..17f5da0b7d 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -640,6 +640,10 @@ class WorkflowApprovalTemplate(UnifiedJobTemplate): 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: diff --git a/awx/main/tests/functional/api/test_workflow_node.py b/awx/main/tests/functional/api/test_workflow_node.py index 4402ce3812..4c366bfe19 100644 --- a/awx/main/tests/functional/api/test_workflow_node.py +++ b/awx/main/tests/functional/api/test_workflow_node.py @@ -3,8 +3,13 @@ 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 ( + WorkflowApprovalTemplate, + WorkflowJobTemplate, + WorkflowJobTemplateNode, +) from awx.main.models.credential import Credential @@ -19,13 +24,20 @@ 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.fixture +def approval_node(workflow_job_template, admin_user): + return WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template + ) + + @pytest.mark.django_db def test_node_rejects_unprompted_fields(inventory, project, workflow_job_template, post, admin_user): job_template = JobTemplate.objects.create( @@ -56,6 +68,90 @@ 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): + url = reverse('api:workflow_job_template_node_create_approval', + kwargs={'pk': approval_node.pk, 'version': 'v2'}) + post(url, {'name': '', 'description': 'Approval Node', 'timeout': 0}, + user=admin_user, expect=400) + # Leave off a required param to assert that you get a 400 + approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk) + assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate) is False + + @pytest.mark.parametrize("is_admin, is_org_admin, status", [ + [True, False, 200], + [False, False, 403], + [False, True, 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, 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) + 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 qs2[0].operation == 'create' + assert qs2[1].operation == 'create' + assert qs2[2].operation == 'create' + assert qs2[3].operation == 'create' + assert qs2[4].operation == 'update' + + 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) + workflow_job_template.delete() + get(url, admin_user, expect=404) + + @pytest.mark.django_db class TestExclusiveRelationshipEnforcement(): @pytest.fixture diff --git a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js index 7063f0110a..7081d24db4 100644 --- a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js @@ -97,6 +97,10 @@ export default function BuildAnchor($log, $filter) { 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 + '/'; }