mirror of
https://github.com/ansible/awx.git
synced 2026-03-29 06:45:09 -02:30
Initial (editable) pass of adding JT.organization
This is the old version of this feature from 2019 this allows setting the organization in the data sent to the API when creating a JT, and exposes the field in the UI as well Subsequent commit changes the field from editable to read-only, but as of this commit, the machinery is not hooked up to infer it from project
This commit is contained in:
@@ -548,6 +548,15 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView):
|
|||||||
})
|
})
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
if hasattr(self, 'parent_key'):
|
||||||
|
# Prefer this filtering because ForeignKey allows us more assumptions
|
||||||
|
parent = self.get_parent_object()
|
||||||
|
self.check_parent_access(parent)
|
||||||
|
qs = self.request.user.get_queryset(self.model)
|
||||||
|
return qs.filter(**{self.parent_key: parent})
|
||||||
|
return super(SubListCreateAPIView, self).get_queryset()
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
# If the object ID was not specified, it probably doesn't exist in the
|
# If the object ID was not specified, it probably doesn't exist in the
|
||||||
# DB yet. We want to see if we can create it. The URL may choose to
|
# DB yet. We want to see if we can create it. The URL may choose to
|
||||||
|
|||||||
@@ -642,7 +642,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
|
|||||||
_capabilities_prefetch = [
|
_capabilities_prefetch = [
|
||||||
'admin', 'execute',
|
'admin', 'execute',
|
||||||
{'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use',
|
{'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use',
|
||||||
'workflowjobtemplate.organization.workflow_admin']}
|
'organization.workflow_admin']}
|
||||||
]
|
]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -700,6 +700,18 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
|
|||||||
else:
|
else:
|
||||||
return super(UnifiedJobTemplateSerializer, self).to_representation(obj)
|
return super(UnifiedJobTemplateSerializer, self).to_representation(obj)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if 'organization' in self.fields:
|
||||||
|
# Do not allow setting template organization to null
|
||||||
|
# otherwise be as non-restrictive as possible for PATCH or PUT, even with orphans
|
||||||
|
# does not correspond with any REST framework field construct
|
||||||
|
if self.instance is None and attrs.get('organization', None) is None:
|
||||||
|
raise serializers.ValidationError({'organization': _('Organization required for new object.')})
|
||||||
|
if self.instance and self.instance.organization_id and attrs.get('organization', 'blank') is None:
|
||||||
|
raise serializers.ValidationError({'organization': _('Organization can not be set to null.')})
|
||||||
|
|
||||||
|
return super(UnifiedJobTemplateSerializer, self).validate(attrs)
|
||||||
|
|
||||||
|
|
||||||
class UnifiedJobSerializer(BaseSerializer):
|
class UnifiedJobSerializer(BaseSerializer):
|
||||||
show_capabilities = ['start', 'delete']
|
show_capabilities = ['start', 'delete']
|
||||||
@@ -1387,12 +1399,6 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
|
|||||||
def get_field_from_model_or_attrs(fd):
|
def get_field_from_model_or_attrs(fd):
|
||||||
return attrs.get(fd, self.instance and getattr(self.instance, fd) or None)
|
return attrs.get(fd, self.instance and getattr(self.instance, fd) or None)
|
||||||
|
|
||||||
organization = None
|
|
||||||
if 'organization' in attrs:
|
|
||||||
organization = attrs['organization']
|
|
||||||
elif self.instance:
|
|
||||||
organization = self.instance.organization
|
|
||||||
|
|
||||||
if 'allow_override' in attrs and self.instance:
|
if 'allow_override' in attrs and self.instance:
|
||||||
# case where user is turning off this project setting
|
# case where user is turning off this project setting
|
||||||
if self.instance.allow_override and not attrs['allow_override']:
|
if self.instance.allow_override and not attrs['allow_override']:
|
||||||
@@ -1408,11 +1414,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
|
|||||||
' '.join([str(pk) for pk in used_by])
|
' '.join([str(pk) for pk in used_by])
|
||||||
)})
|
)})
|
||||||
|
|
||||||
view = self.context.get('view', None)
|
if get_field_from_model_or_attrs('scm_type') == '':
|
||||||
if not organization and not view.request.user.is_superuser:
|
|
||||||
# Only allow super users to create orgless projects
|
|
||||||
raise serializers.ValidationError(_('Organization is missing'))
|
|
||||||
elif get_field_from_model_or_attrs('scm_type') == '':
|
|
||||||
for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_clean'):
|
for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_clean'):
|
||||||
if get_field_from_model_or_attrs(fd):
|
if get_field_from_model_or_attrs(fd):
|
||||||
raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')})
|
raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')})
|
||||||
@@ -2738,7 +2740,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer):
|
|||||||
fields = ('*', 'job_type', 'inventory', 'project', 'playbook', 'scm_branch',
|
fields = ('*', 'job_type', 'inventory', 'project', 'playbook', 'scm_branch',
|
||||||
'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags',
|
'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags',
|
||||||
'force_handlers', 'skip_tags', 'start_at_task', 'timeout',
|
'force_handlers', 'skip_tags', 'start_at_task', 'timeout',
|
||||||
'use_fact_cache',)
|
'use_fact_cache', 'organization',)
|
||||||
|
|
||||||
def get_related(self, obj):
|
def get_related(self, obj):
|
||||||
res = super(JobOptionsSerializer, self).get_related(obj)
|
res = super(JobOptionsSerializer, self).get_related(obj)
|
||||||
@@ -2753,6 +2755,8 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer):
|
|||||||
res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk})
|
res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk})
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
setattr(obj, 'project', None)
|
setattr(obj, 'project', None)
|
||||||
|
if obj.organization_id:
|
||||||
|
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id})
|
||||||
if isinstance(obj, UnifiedJobTemplate):
|
if isinstance(obj, UnifiedJobTemplate):
|
||||||
res['extra_credentials'] = self.reverse(
|
res['extra_credentials'] = self.reverse(
|
||||||
'api:job_template_extra_credentials_list',
|
'api:job_template_extra_credentials_list',
|
||||||
@@ -2899,6 +2903,8 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
|
|||||||
)
|
)
|
||||||
if obj.host_config_key:
|
if obj.host_config_key:
|
||||||
res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk})
|
res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk})
|
||||||
|
if obj.organization_id:
|
||||||
|
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id})
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from awx.api.views import (
|
|||||||
OrganizationAdminsList,
|
OrganizationAdminsList,
|
||||||
OrganizationInventoriesList,
|
OrganizationInventoriesList,
|
||||||
OrganizationProjectsList,
|
OrganizationProjectsList,
|
||||||
|
OrganizationJobTemplatesList,
|
||||||
OrganizationWorkflowJobTemplatesList,
|
OrganizationWorkflowJobTemplatesList,
|
||||||
OrganizationTeamsList,
|
OrganizationTeamsList,
|
||||||
OrganizationCredentialList,
|
OrganizationCredentialList,
|
||||||
@@ -33,6 +34,7 @@ urls = [
|
|||||||
url(r'^(?P<pk>[0-9]+)/admins/$', OrganizationAdminsList.as_view(), name='organization_admins_list'),
|
url(r'^(?P<pk>[0-9]+)/admins/$', OrganizationAdminsList.as_view(), name='organization_admins_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/inventories/$', OrganizationInventoriesList.as_view(), name='organization_inventories_list'),
|
url(r'^(?P<pk>[0-9]+)/inventories/$', OrganizationInventoriesList.as_view(), name='organization_inventories_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/projects/$', OrganizationProjectsList.as_view(), name='organization_projects_list'),
|
url(r'^(?P<pk>[0-9]+)/projects/$', OrganizationProjectsList.as_view(), name='organization_projects_list'),
|
||||||
|
url(r'^(?P<pk>[0-9]+)/job_templates/$', OrganizationJobTemplatesList.as_view(), name='organization_job_templates_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/workflow_job_templates/$', OrganizationWorkflowJobTemplatesList.as_view(), name='organization_workflow_job_templates_list'),
|
url(r'^(?P<pk>[0-9]+)/workflow_job_templates/$', OrganizationWorkflowJobTemplatesList.as_view(), name='organization_workflow_job_templates_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/teams/$', OrganizationTeamsList.as_view(), name='organization_teams_list'),
|
url(r'^(?P<pk>[0-9]+)/teams/$', OrganizationTeamsList.as_view(), name='organization_teams_list'),
|
||||||
url(r'^(?P<pk>[0-9]+)/credentials/$', OrganizationCredentialList.as_view(), name='organization_credential_list'),
|
url(r'^(?P<pk>[0-9]+)/credentials/$', OrganizationCredentialList.as_view(), name='organization_credential_list'),
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ from awx.api.views.organization import ( # noqa
|
|||||||
OrganizationUsersList,
|
OrganizationUsersList,
|
||||||
OrganizationAdminsList,
|
OrganizationAdminsList,
|
||||||
OrganizationProjectsList,
|
OrganizationProjectsList,
|
||||||
|
OrganizationJobTemplatesList,
|
||||||
OrganizationWorkflowJobTemplatesList,
|
OrganizationWorkflowJobTemplatesList,
|
||||||
OrganizationTeamsList,
|
OrganizationTeamsList,
|
||||||
OrganizationActivityStreamList,
|
OrganizationActivityStreamList,
|
||||||
|
|||||||
@@ -4,10 +4,7 @@
|
|||||||
import dateutil
|
import dateutil
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.db.models import (
|
from django.db.models import Count
|
||||||
Count,
|
|
||||||
F,
|
|
||||||
)
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@@ -175,28 +172,18 @@ class OrganizationCountsMixin(object):
|
|||||||
|
|
||||||
inv_qs = Inventory.accessible_objects(self.request.user, 'read_role')
|
inv_qs = Inventory.accessible_objects(self.request.user, 'read_role')
|
||||||
project_qs = Project.accessible_objects(self.request.user, 'read_role')
|
project_qs = Project.accessible_objects(self.request.user, 'read_role')
|
||||||
|
jt_qs = JobTemplate.accessible_objects(self.request.user, 'read_role')
|
||||||
|
|
||||||
# Produce counts of Foreign Key relationships
|
# Produce counts of Foreign Key relationships
|
||||||
db_results['inventories'] = inv_qs\
|
db_results['inventories'] = inv_qs.values('organization').annotate(Count('organization')).order_by('organization')
|
||||||
.values('organization').annotate(Count('organization')).order_by('organization')
|
|
||||||
|
|
||||||
db_results['teams'] = Team.accessible_objects(
|
db_results['teams'] = Team.accessible_objects(
|
||||||
self.request.user, 'read_role').values('organization').annotate(
|
self.request.user, 'read_role').values('organization').annotate(
|
||||||
Count('organization')).order_by('organization')
|
Count('organization')).order_by('organization')
|
||||||
|
|
||||||
JT_project_reference = 'project__organization'
|
db_results['job_templates'] = jt_qs.values('organization').annotate(Count('organization')).order_by('organization')
|
||||||
JT_inventory_reference = 'inventory__organization'
|
|
||||||
db_results['job_templates_project'] = JobTemplate.accessible_objects(
|
|
||||||
self.request.user, 'read_role').exclude(
|
|
||||||
project__organization=F(JT_inventory_reference)).values(JT_project_reference).annotate(
|
|
||||||
Count(JT_project_reference)).order_by(JT_project_reference)
|
|
||||||
|
|
||||||
db_results['job_templates_inventory'] = JobTemplate.accessible_objects(
|
db_results['projects'] = project_qs.values('organization').annotate(Count('organization')).order_by('organization')
|
||||||
self.request.user, 'read_role').values(JT_inventory_reference).annotate(
|
|
||||||
Count(JT_inventory_reference)).order_by(JT_inventory_reference)
|
|
||||||
|
|
||||||
db_results['projects'] = project_qs\
|
|
||||||
.values('organization').annotate(Count('organization')).order_by('organization')
|
|
||||||
|
|
||||||
# Other members and admins of organization are always viewable
|
# Other members and admins of organization are always viewable
|
||||||
db_results['users'] = org_qs.annotate(
|
db_results['users'] = org_qs.annotate(
|
||||||
@@ -212,11 +199,7 @@ class OrganizationCountsMixin(object):
|
|||||||
'admins': 0, 'projects': 0}
|
'admins': 0, 'projects': 0}
|
||||||
|
|
||||||
for res, count_qs in db_results.items():
|
for res, count_qs in db_results.items():
|
||||||
if res == 'job_templates_project':
|
if res == 'users':
|
||||||
org_reference = JT_project_reference
|
|
||||||
elif res == 'job_templates_inventory':
|
|
||||||
org_reference = JT_inventory_reference
|
|
||||||
elif res == 'users':
|
|
||||||
org_reference = 'id'
|
org_reference = 'id'
|
||||||
else:
|
else:
|
||||||
org_reference = 'organization'
|
org_reference = 'organization'
|
||||||
@@ -229,14 +212,6 @@ class OrganizationCountsMixin(object):
|
|||||||
continue
|
continue
|
||||||
count_context[org_id][res] = entry['%s__count' % org_reference]
|
count_context[org_id][res] = entry['%s__count' % org_reference]
|
||||||
|
|
||||||
# Combine the counts for job templates by project and inventory
|
|
||||||
for org in org_id_list:
|
|
||||||
org_id = org['id']
|
|
||||||
count_context[org_id]['job_templates'] = 0
|
|
||||||
for related_path in ['job_templates_project', 'job_templates_inventory']:
|
|
||||||
if related_path in count_context[org_id]:
|
|
||||||
count_context[org_id]['job_templates'] += count_context[org_id].pop(related_path)
|
|
||||||
|
|
||||||
full_context['related_field_counts'] = count_context
|
full_context['related_field_counts'] = count_context
|
||||||
|
|
||||||
return full_context
|
return full_context
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from awx.main.models import (
|
|||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
Team,
|
Team,
|
||||||
InstanceGroup,
|
InstanceGroup
|
||||||
)
|
)
|
||||||
from awx.api.generics import (
|
from awx.api.generics import (
|
||||||
ListCreateAPIView,
|
ListCreateAPIView,
|
||||||
@@ -28,6 +28,7 @@ from awx.api.generics import (
|
|||||||
SubListAPIView,
|
SubListAPIView,
|
||||||
SubListCreateAttachDetachAPIView,
|
SubListCreateAttachDetachAPIView,
|
||||||
SubListAttachDetachAPIView,
|
SubListAttachDetachAPIView,
|
||||||
|
SubListCreateAPIView,
|
||||||
ResourceAccessList,
|
ResourceAccessList,
|
||||||
BaseUsersList,
|
BaseUsersList,
|
||||||
)
|
)
|
||||||
@@ -35,14 +36,13 @@ from awx.api.generics import (
|
|||||||
from awx.api.serializers import (
|
from awx.api.serializers import (
|
||||||
OrganizationSerializer,
|
OrganizationSerializer,
|
||||||
InventorySerializer,
|
InventorySerializer,
|
||||||
ProjectSerializer,
|
|
||||||
UserSerializer,
|
UserSerializer,
|
||||||
TeamSerializer,
|
TeamSerializer,
|
||||||
ActivityStreamSerializer,
|
ActivityStreamSerializer,
|
||||||
RoleSerializer,
|
RoleSerializer,
|
||||||
NotificationTemplateSerializer,
|
NotificationTemplateSerializer,
|
||||||
WorkflowJobTemplateSerializer,
|
|
||||||
InstanceGroupSerializer,
|
InstanceGroupSerializer,
|
||||||
|
ProjectSerializer, JobTemplateSerializer, WorkflowJobTemplateSerializer
|
||||||
)
|
)
|
||||||
from awx.api.views.mixin import (
|
from awx.api.views.mixin import (
|
||||||
RelatedJobsPreventDeleteMixin,
|
RelatedJobsPreventDeleteMixin,
|
||||||
@@ -94,7 +94,7 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI
|
|||||||
org_counts['projects'] = Project.accessible_objects(**access_kwargs).filter(
|
org_counts['projects'] = Project.accessible_objects(**access_kwargs).filter(
|
||||||
organization__id=org_id).count()
|
organization__id=org_id).count()
|
||||||
org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter(
|
org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter(
|
||||||
project__organization__id=org_id).count()
|
organization__id=org_id).count()
|
||||||
|
|
||||||
full_context['related_field_counts'] = {}
|
full_context['related_field_counts'] = {}
|
||||||
full_context['related_field_counts'][org_id] = org_counts
|
full_context['related_field_counts'][org_id] = org_counts
|
||||||
@@ -128,21 +128,27 @@ class OrganizationAdminsList(BaseUsersList):
|
|||||||
ordering = ('username',)
|
ordering = ('username',)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationProjectsList(SubListCreateAttachDetachAPIView):
|
class OrganizationProjectsList(SubListCreateAPIView):
|
||||||
|
|
||||||
model = Project
|
model = Project
|
||||||
serializer_class = ProjectSerializer
|
serializer_class = ProjectSerializer
|
||||||
parent_model = Organization
|
parent_model = Organization
|
||||||
relationship = 'projects'
|
|
||||||
parent_key = 'organization'
|
parent_key = 'organization'
|
||||||
|
|
||||||
|
|
||||||
class OrganizationWorkflowJobTemplatesList(SubListCreateAttachDetachAPIView):
|
class OrganizationJobTemplatesList(SubListCreateAPIView):
|
||||||
|
|
||||||
|
model = JobTemplate
|
||||||
|
serializer_class = JobTemplateSerializer
|
||||||
|
parent_model = Organization
|
||||||
|
parent_key = 'organization'
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView):
|
||||||
|
|
||||||
model = WorkflowJobTemplate
|
model = WorkflowJobTemplate
|
||||||
serializer_class = WorkflowJobTemplateSerializer
|
serializer_class = WorkflowJobTemplateSerializer
|
||||||
parent_model = Organization
|
parent_model = Organization
|
||||||
relationship = 'workflows'
|
|
||||||
parent_key = 'organization'
|
parent_key = 'organization'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1411,7 +1411,7 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
model = JobTemplate
|
model = JobTemplate
|
||||||
select_related = ('created_by', 'modified_by', 'inventory', 'project',
|
select_related = ('created_by', 'modified_by', 'inventory', 'project', 'organization',
|
||||||
'next_schedule',)
|
'next_schedule',)
|
||||||
prefetch_related = (
|
prefetch_related = (
|
||||||
'instance_groups',
|
'instance_groups',
|
||||||
@@ -1435,9 +1435,7 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
|||||||
Users who are able to create deploy jobs can also run normal and check (dry run) jobs.
|
Users who are able to create deploy jobs can also run normal and check (dry run) jobs.
|
||||||
'''
|
'''
|
||||||
if not data: # So the browseable API will work
|
if not data: # So the browseable API will work
|
||||||
return (
|
return Organization.accessible_objects(self.user, 'job_template_admin_role').exists()
|
||||||
Project.accessible_objects(self.user, 'use_role').exists() or
|
|
||||||
Inventory.accessible_objects(self.user, 'use_role').exists())
|
|
||||||
|
|
||||||
# if reference_obj is provided, determine if it can be copied
|
# if reference_obj is provided, determine if it can be copied
|
||||||
reference_obj = data.get('reference_obj', None)
|
reference_obj = data.get('reference_obj', None)
|
||||||
@@ -1467,6 +1465,10 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
|||||||
if self.user not in inventory.use_role:
|
if self.user not in inventory.use_role:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
organization = get_value(Organization, 'organization')
|
||||||
|
if (not organization) or (self.user not in organization.job_template_admin_role):
|
||||||
|
return False
|
||||||
|
|
||||||
project = get_value(Project, 'project')
|
project = get_value(Project, 'project')
|
||||||
# If the user has admin access to the project (as an org admin), should
|
# If the user has admin access to the project (as an org admin), should
|
||||||
# be able to proceed without additional checks.
|
# be able to proceed without additional checks.
|
||||||
@@ -1504,22 +1506,31 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
|||||||
return self.user in obj.execute_role
|
return self.user in obj.execute_role
|
||||||
|
|
||||||
def can_change(self, obj, data):
|
def can_change(self, obj, data):
|
||||||
data_for_change = data
|
|
||||||
if self.user not in obj.admin_role and not self.user.is_superuser:
|
if self.user not in obj.admin_role and not self.user.is_superuser:
|
||||||
return False
|
return False
|
||||||
if data is not None:
|
if data is None:
|
||||||
data = dict(data)
|
return True
|
||||||
|
|
||||||
if self.changes_are_non_sensitive(obj, data):
|
# standard type of check for organization - cannot change the value
|
||||||
if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']:
|
# unless posessing the respective job_template_admin_role, otherwise non-blocking
|
||||||
self.check_license(feature='surveys')
|
if not self.check_related('organization', Organization, data, obj=obj, role_field='job_template_admin_role'):
|
||||||
return True
|
return False
|
||||||
|
|
||||||
for required_field in ('inventory', 'project'):
|
data = dict(data)
|
||||||
required_obj = getattr(obj, required_field, None)
|
|
||||||
if required_field not in data_for_change and required_obj is not None:
|
if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']:
|
||||||
data_for_change[required_field] = required_obj.pk
|
self.check_license(feature='surveys')
|
||||||
return self.can_read(obj) and (self.can_add(data_for_change) if data is not None else True)
|
|
||||||
|
if self.changes_are_non_sensitive(obj, data):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for required_field, cls in (('inventory', Inventory), ('project', Project)):
|
||||||
|
is_mandatory = True
|
||||||
|
if not getattr(obj, '{}_id'.format(required_field)):
|
||||||
|
is_mandatory = False
|
||||||
|
if not self.check_related(required_field, cls, data, obj=obj, role_field='use_role', mandatory=is_mandatory):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def changes_are_non_sensitive(self, obj, data):
|
def changes_are_non_sensitive(self, obj, data):
|
||||||
'''
|
'''
|
||||||
@@ -1554,9 +1565,9 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
|||||||
@check_superuser
|
@check_superuser
|
||||||
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False):
|
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False):
|
||||||
if relationship == "instance_groups":
|
if relationship == "instance_groups":
|
||||||
if not obj.project.organization:
|
if not obj.organization:
|
||||||
return False
|
return False
|
||||||
return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.project.organization.admin_role
|
return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.organization.admin_role
|
||||||
if relationship == 'credentials' and isinstance(sub_obj, Credential):
|
if relationship == 'credentials' and isinstance(sub_obj, Credential):
|
||||||
return self.user in obj.admin_role and self.user in sub_obj.use_role
|
return self.user in obj.admin_role and self.user in sub_obj.use_role
|
||||||
return super(JobTemplateAccess, self).can_attach(
|
return super(JobTemplateAccess, self).can_attach(
|
||||||
@@ -1587,6 +1598,7 @@ class JobAccess(BaseAccess):
|
|||||||
select_related = ('created_by', 'modified_by', 'job_template', 'inventory',
|
select_related = ('created_by', 'modified_by', 'job_template', 'inventory',
|
||||||
'project', 'project_update',)
|
'project', 'project_update',)
|
||||||
prefetch_related = (
|
prefetch_related = (
|
||||||
|
'organization',
|
||||||
'unified_job_template',
|
'unified_job_template',
|
||||||
'instance_group',
|
'instance_group',
|
||||||
'credentials__credential_type',
|
'credentials__credential_type',
|
||||||
@@ -1607,42 +1619,19 @@ class JobAccess(BaseAccess):
|
|||||||
|
|
||||||
return qs.filter(
|
return qs.filter(
|
||||||
Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) |
|
Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) |
|
||||||
Q(inventory__organization__in=org_access_qs) |
|
Q(organization__in=org_access_qs)).distinct()
|
||||||
Q(project__organization__in=org_access_qs)).distinct()
|
|
||||||
|
|
||||||
def related_orgs(self, obj):
|
|
||||||
orgs = []
|
|
||||||
if obj.inventory and obj.inventory.organization:
|
|
||||||
orgs.append(obj.inventory.organization)
|
|
||||||
if obj.project and obj.project.organization and obj.project.organization not in orgs:
|
|
||||||
orgs.append(obj.project.organization)
|
|
||||||
return orgs
|
|
||||||
|
|
||||||
def org_access(self, obj, role_types=['admin_role']):
|
|
||||||
orgs = self.related_orgs(obj)
|
|
||||||
for org in orgs:
|
|
||||||
for role_type in role_types:
|
|
||||||
role = getattr(org, role_type)
|
|
||||||
if self.user in role:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def can_add(self, data, validate_license=True):
|
def can_add(self, data, validate_license=True):
|
||||||
if validate_license:
|
raise NotImplementedError('Direct job creation not possible in v2 API')
|
||||||
self.check_license()
|
|
||||||
|
|
||||||
if not data: # So the browseable API will work
|
|
||||||
return True
|
|
||||||
return self.user.is_superuser
|
|
||||||
|
|
||||||
def can_change(self, obj, data):
|
def can_change(self, obj, data):
|
||||||
return (obj.status == 'new' and
|
raise NotImplementedError('Direct job editing not supported in v2 API')
|
||||||
self.can_read(obj) and
|
|
||||||
self.can_add(data, validate_license=False))
|
|
||||||
|
|
||||||
@check_superuser
|
@check_superuser
|
||||||
def can_delete(self, obj):
|
def can_delete(self, obj):
|
||||||
return self.org_access(obj)
|
if not obj.organization:
|
||||||
|
return False
|
||||||
|
return self.user in obj.organization.admin_role
|
||||||
|
|
||||||
def can_start(self, obj, validate_license=True):
|
def can_start(self, obj, validate_license=True):
|
||||||
if validate_license:
|
if validate_license:
|
||||||
@@ -1662,6 +1651,7 @@ class JobAccess(BaseAccess):
|
|||||||
except JobLaunchConfig.DoesNotExist:
|
except JobLaunchConfig.DoesNotExist:
|
||||||
config = None
|
config = None
|
||||||
|
|
||||||
|
# Standard permissions model (1)
|
||||||
if obj.job_template and (self.user not in obj.job_template.execute_role):
|
if obj.job_template and (self.user not in obj.job_template.execute_role):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1676,24 +1666,15 @@ class JobAccess(BaseAccess):
|
|||||||
if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
|
if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role
|
# Standard permissions model (2)
|
||||||
project_access = obj.project is None or self.user in obj.project.admin_role
|
if obj.organization and self.user in obj.organization.execute_role:
|
||||||
credential_access = all([self.user in cred.use_role for cred in obj.credentials.all()])
|
# Respect organization ownership of orphaned jobs
|
||||||
|
return True
|
||||||
|
elif not (obj.job_template or obj.organization):
|
||||||
|
if self.save_messages:
|
||||||
|
self.messages['detail'] = _('Job has been orphaned from its job template and organization.')
|
||||||
|
|
||||||
# job can be relaunched if user could make an equivalent JT
|
return False
|
||||||
ret = org_access and credential_access and project_access
|
|
||||||
if not ret and self.save_messages and not self.messages:
|
|
||||||
if not obj.job_template:
|
|
||||||
pretext = _('Job has been orphaned from its job template.')
|
|
||||||
elif config is None:
|
|
||||||
pretext = _('Job was launched with unknown prompted fields.')
|
|
||||||
else:
|
|
||||||
pretext = _('Job was launched with prompted fields.')
|
|
||||||
if credential_access:
|
|
||||||
self.messages['detail'] = '{} {}'.format(pretext, _(' Organization level permissions required.'))
|
|
||||||
else:
|
|
||||||
self.messages['detail'] = '{} {}'.format(pretext, _(' You do not have permission to related resources.'))
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def get_method_capability(self, method, obj, parent_obj):
|
def get_method_capability(self, method, obj, parent_obj):
|
||||||
if method == 'start':
|
if method == 'start':
|
||||||
@@ -1706,10 +1687,16 @@ class JobAccess(BaseAccess):
|
|||||||
def can_cancel(self, obj):
|
def can_cancel(self, obj):
|
||||||
if not obj.can_cancel:
|
if not obj.can_cancel:
|
||||||
return False
|
return False
|
||||||
# Delete access allows org admins to stop running jobs
|
# Users may always cancel their own jobs
|
||||||
if self.user == obj.created_by or self.can_delete(obj):
|
if self.user == obj.created_by:
|
||||||
return True
|
return True
|
||||||
return obj.job_template is not None and self.user in obj.job_template.admin_role
|
# Users with direct admin to JT may cancel jobs started by anyone
|
||||||
|
if obj.job_template and self.user in obj.job_template.admin_role:
|
||||||
|
return True
|
||||||
|
# If orphaned, allow org JT admins to stop running jobs
|
||||||
|
if not obj.job_template and obj.organization and self.user in obj.organization.job_template_admin_role:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class SystemJobTemplateAccess(BaseAccess):
|
class SystemJobTemplateAccess(BaseAccess):
|
||||||
@@ -1944,11 +1931,11 @@ class WorkflowJobNodeAccess(BaseAccess):
|
|||||||
# TODO: notification attachments?
|
# TODO: notification attachments?
|
||||||
class WorkflowJobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
class WorkflowJobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
||||||
'''
|
'''
|
||||||
I can only see/manage Workflow Job Templates if I'm a super user
|
I can see/manage Workflow Job Templates based on object roles
|
||||||
'''
|
'''
|
||||||
|
|
||||||
model = WorkflowJobTemplate
|
model = WorkflowJobTemplate
|
||||||
select_related = ('created_by', 'modified_by', 'next_schedule',
|
select_related = ('created_by', 'modified_by', 'organization', 'next_schedule',
|
||||||
'admin_role', 'execute_role', 'read_role',)
|
'admin_role', 'execute_role', 'read_role',)
|
||||||
|
|
||||||
def filtered_queryset(self):
|
def filtered_queryset(self):
|
||||||
@@ -2038,7 +2025,7 @@ class WorkflowJobAccess(BaseAccess):
|
|||||||
I can also cancel it if I started it
|
I can also cancel it if I started it
|
||||||
'''
|
'''
|
||||||
model = WorkflowJob
|
model = WorkflowJob
|
||||||
select_related = ('created_by', 'modified_by',)
|
select_related = ('created_by', 'modified_by', 'organization',)
|
||||||
|
|
||||||
def filtered_queryset(self):
|
def filtered_queryset(self):
|
||||||
return WorkflowJob.objects.filter(
|
return WorkflowJob.objects.filter(
|
||||||
@@ -2332,6 +2319,7 @@ class UnifiedJobTemplateAccess(BaseAccess):
|
|||||||
prefetch_related = (
|
prefetch_related = (
|
||||||
'last_job',
|
'last_job',
|
||||||
'current_job',
|
'current_job',
|
||||||
|
'organization',
|
||||||
'credentials__credential_type',
|
'credentials__credential_type',
|
||||||
Prefetch('labels', queryset=Label.objects.all().order_by('name')),
|
Prefetch('labels', queryset=Label.objects.all().order_by('name')),
|
||||||
)
|
)
|
||||||
@@ -2371,6 +2359,7 @@ class UnifiedJobAccess(BaseAccess):
|
|||||||
prefetch_related = (
|
prefetch_related = (
|
||||||
'created_by',
|
'created_by',
|
||||||
'modified_by',
|
'modified_by',
|
||||||
|
'organization',
|
||||||
'unified_job_node__workflow_job',
|
'unified_job_node__workflow_job',
|
||||||
'unified_job_template',
|
'unified_job_template',
|
||||||
'instance_group',
|
'instance_group',
|
||||||
@@ -2401,8 +2390,7 @@ class UnifiedJobAccess(BaseAccess):
|
|||||||
Q(unified_job_template_id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) |
|
Q(unified_job_template_id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) |
|
||||||
Q(inventoryupdate__inventory_source__inventory__id__in=inv_pk_qs) |
|
Q(inventoryupdate__inventory_source__inventory__id__in=inv_pk_qs) |
|
||||||
Q(adhoccommand__inventory__id__in=inv_pk_qs) |
|
Q(adhoccommand__inventory__id__in=inv_pk_qs) |
|
||||||
Q(job__inventory__organization__in=org_auditor_qs) |
|
Q(organization__in=org_auditor_qs)
|
||||||
Q(job__project__organization__in=org_auditor_qs)
|
|
||||||
)
|
)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ from awx.main import utils
|
|||||||
|
|
||||||
__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField',
|
__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField',
|
||||||
'SmartFilterField', 'OrderedManyToManyField',
|
'SmartFilterField', 'OrderedManyToManyField',
|
||||||
'update_role_parentage_for_instance', 'is_implicit_parent']
|
'update_role_parentage_for_instance',
|
||||||
|
'is_implicit_parent']
|
||||||
|
|
||||||
|
|
||||||
# Provide a (better) custom error message for enum jsonschema validation
|
# Provide a (better) custom error message for enum jsonschema validation
|
||||||
@@ -140,8 +141,9 @@ def resolve_role_field(obj, field):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
if len(field_components) == 1:
|
if len(field_components) == 1:
|
||||||
role_cls = str(utils.get_current_apps().get_model('main', 'Role'))
|
# use extremely generous duck typing to accomidate all possible forms
|
||||||
if not str(type(obj)) == role_cls:
|
# of the model that may be used during various migrations
|
||||||
|
if obj._meta.model_name != 'role' or obj._meta.app_label != 'main':
|
||||||
raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj))))
|
raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj))))
|
||||||
ret.append(obj.id)
|
ret.append(obj.id)
|
||||||
else:
|
else:
|
||||||
@@ -197,18 +199,30 @@ def update_role_parentage_for_instance(instance):
|
|||||||
updates the parents listing for all the roles
|
updates the parents listing for all the roles
|
||||||
of a given instance if they have changed
|
of a given instance if they have changed
|
||||||
'''
|
'''
|
||||||
|
changed_ct = 0
|
||||||
for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'):
|
for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'):
|
||||||
|
changed = False
|
||||||
cur_role = getattr(instance, implicit_role_field.name)
|
cur_role = getattr(instance, implicit_role_field.name)
|
||||||
original_parents = set(json.loads(cur_role.implicit_parents))
|
original_parents = set(json.loads(cur_role.implicit_parents))
|
||||||
new_parents = implicit_role_field._resolve_parent_roles(instance)
|
new_parents = implicit_role_field._resolve_parent_roles(instance)
|
||||||
cur_role.parents.remove(*list(original_parents - new_parents))
|
removals = original_parents - new_parents
|
||||||
cur_role.parents.add(*list(new_parents - original_parents))
|
if removals:
|
||||||
|
changed = True
|
||||||
|
cur_role.parents.remove(*list(removals))
|
||||||
|
additions = new_parents - original_parents
|
||||||
|
if additions:
|
||||||
|
changed = True
|
||||||
|
cur_role.parents.add(*list(additions))
|
||||||
new_parents_list = list(new_parents)
|
new_parents_list = list(new_parents)
|
||||||
new_parents_list.sort()
|
new_parents_list.sort()
|
||||||
new_parents_json = json.dumps(new_parents_list)
|
new_parents_json = json.dumps(new_parents_list)
|
||||||
if cur_role.implicit_parents != new_parents_json:
|
if cur_role.implicit_parents != new_parents_json:
|
||||||
|
changed = True
|
||||||
cur_role.implicit_parents = new_parents_json
|
cur_role.implicit_parents = new_parents_json
|
||||||
cur_role.save()
|
cur_role.save()
|
||||||
|
if changed:
|
||||||
|
changed_ct += 1
|
||||||
|
return changed_ct
|
||||||
|
|
||||||
|
|
||||||
class ImplicitRoleDescriptor(ForwardManyToOneDescriptor):
|
class ImplicitRoleDescriptor(ForwardManyToOneDescriptor):
|
||||||
@@ -256,20 +270,18 @@ class ImplicitRoleField(models.ForeignKey):
|
|||||||
field_names = [field_names]
|
field_names = [field_names]
|
||||||
|
|
||||||
for field_name in field_names:
|
for field_name in field_names:
|
||||||
# Handle the OR syntax for role parents
|
|
||||||
if type(field_name) == tuple:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if type(field_name) == bytes:
|
|
||||||
field_name = field_name.decode('utf-8')
|
|
||||||
|
|
||||||
if field_name.startswith('singleton:'):
|
if field_name.startswith('singleton:'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
field_name, sep, field_attr = field_name.partition('.')
|
field_name, sep, field_attr = field_name.partition('.')
|
||||||
field = getattr(cls, field_name)
|
# Non existent fields will occur if ever a parent model is
|
||||||
|
# moved inside a migration, needed for job_template_organization_field
|
||||||
|
# migration in particular
|
||||||
|
# consistency is assured by unit test awx.main.tests.functional
|
||||||
|
field = getattr(cls, field_name, None)
|
||||||
|
|
||||||
if type(field) is ReverseManyToOneDescriptor or \
|
if field and type(field) is ReverseManyToOneDescriptor or \
|
||||||
type(field) is ManyToManyDescriptor:
|
type(field) is ManyToManyDescriptor:
|
||||||
|
|
||||||
if '.' in field_attr:
|
if '.' in field_attr:
|
||||||
|
|||||||
@@ -192,21 +192,41 @@ class URLModificationMiddleware(MiddlewareMixin):
|
|||||||
)
|
)
|
||||||
super().__init__(get_response)
|
super().__init__(get_response)
|
||||||
|
|
||||||
def _named_url_to_pk(self, node, named_url):
|
@staticmethod
|
||||||
kwargs = {}
|
def _hijack_for_old_jt_name(node, kwargs, named_url):
|
||||||
if not node.populate_named_url_query_kwargs(kwargs, named_url):
|
try:
|
||||||
return named_url
|
int(named_url)
|
||||||
return str(get_object_or_404(node.model, **kwargs).pk)
|
return False
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
JobTemplate = node.model
|
||||||
|
name = urllib.parse.unquote(named_url)
|
||||||
|
return JobTemplate.objects.filter(name=name).order_by('organization__created').first()
|
||||||
|
|
||||||
def _convert_named_url(self, url_path):
|
@classmethod
|
||||||
|
def _named_url_to_pk(cls, node, resource, named_url):
|
||||||
|
kwargs = {}
|
||||||
|
if node.populate_named_url_query_kwargs(kwargs, named_url):
|
||||||
|
return str(get_object_or_404(node.model, **kwargs).pk)
|
||||||
|
if resource == 'job_templates' and '++' not in named_url:
|
||||||
|
# special case for deprecated job template case
|
||||||
|
# will not raise a 404 on its own
|
||||||
|
jt = cls._hijack_for_old_jt_name(node, kwargs, named_url)
|
||||||
|
if jt:
|
||||||
|
return str(jt.pk)
|
||||||
|
return named_url
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _convert_named_url(cls, url_path):
|
||||||
url_units = url_path.split('/')
|
url_units = url_path.split('/')
|
||||||
# If the identifier is an empty string, it is always invalid.
|
# If the identifier is an empty string, it is always invalid.
|
||||||
if len(url_units) < 6 or url_units[1] != 'api' or url_units[2] not in ['v2'] or not url_units[4]:
|
if len(url_units) < 6 or url_units[1] != 'api' or url_units[2] not in ['v2'] or not url_units[4]:
|
||||||
return url_path
|
return url_path
|
||||||
resource = url_units[3]
|
resource = url_units[3]
|
||||||
if resource in settings.NAMED_URL_MAPPINGS:
|
if resource in settings.NAMED_URL_MAPPINGS:
|
||||||
url_units[4] = self._named_url_to_pk(settings.NAMED_URL_GRAPH[settings.NAMED_URL_MAPPINGS[resource]],
|
url_units[4] = cls._named_url_to_pk(
|
||||||
url_units[4])
|
settings.NAMED_URL_GRAPH[settings.NAMED_URL_MAPPINGS[resource]],
|
||||||
|
resource, url_units[4])
|
||||||
return '/'.join(url_units)
|
return '/'.join(url_units)
|
||||||
|
|
||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Generated by Django 2.2.4 on 2019-08-07 19:56
|
||||||
|
|
||||||
|
import awx.main.utils.polymorphic
|
||||||
|
import awx.main.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from awx.main.migrations._rbac import rebuild_role_parentage, migrate_ujt_organization, migrate_ujt_organization_backward
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0084_v360_token_description'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# backwards parents and ancestors caching
|
||||||
|
migrations.RunPython(migrations.RunPython.noop, rebuild_role_parentage),
|
||||||
|
# add new organization field for JT and all other unified jobs
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unifiedjob',
|
||||||
|
name='tmp_organization',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='The organization used to determine access to this unified job.', null=True, on_delete=awx.main.utils.polymorphic.SET_NULL, related_name='unifiedjobs', to='main.Organization'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unifiedjobtemplate',
|
||||||
|
name='tmp_organization',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='The organization used to determine access to this template.', null=True, on_delete=awx.main.utils.polymorphic.SET_NULL, related_name='unifiedjobtemplates', to='main.Organization'),
|
||||||
|
),
|
||||||
|
# while new and old fields exist, copy the organization fields
|
||||||
|
migrations.RunPython(migrate_ujt_organization, migrate_ujt_organization_backward),
|
||||||
|
# with data saved, remove old fields
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='project',
|
||||||
|
name='organization',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='workflowjobtemplate',
|
||||||
|
name='organization',
|
||||||
|
),
|
||||||
|
# now, without safely rename the new field without conflicts from old field
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='unifiedjobtemplate',
|
||||||
|
old_name='tmp_organization',
|
||||||
|
new_name='organization',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='unifiedjob',
|
||||||
|
old_name='tmp_organization',
|
||||||
|
new_name='organization',
|
||||||
|
),
|
||||||
|
# parentage of job template roles has genuinely changed at this point
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobtemplate',
|
||||||
|
name='admin_role',
|
||||||
|
field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.job_template_admin_role'], related_name='+', to='main.Role'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobtemplate',
|
||||||
|
name='execute_role',
|
||||||
|
field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['admin_role', 'organization.execute_role'], related_name='+', to='main.Role'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='jobtemplate',
|
||||||
|
name='read_role',
|
||||||
|
field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role'], related_name='+', to='main.Role'),
|
||||||
|
),
|
||||||
|
# Re-compute the role parents and ancestors caching
|
||||||
|
# this may be a no-op because field post_save hooks from migrate_jt_organization
|
||||||
|
migrations.RunPython(rebuild_role_parentage, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
|
from django.db.models import Subquery, OuterRef
|
||||||
|
|
||||||
|
from awx.main.fields import update_role_parentage_for_instance
|
||||||
from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding
|
from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding
|
||||||
|
|
||||||
logger = logging.getLogger('rbac_migrations')
|
logger = logging.getLogger('rbac_migrations')
|
||||||
@@ -10,11 +13,11 @@ def create_roles(apps, schema_editor):
|
|||||||
'''
|
'''
|
||||||
Implicit role creation happens in our post_save hook for all of our
|
Implicit role creation happens in our post_save hook for all of our
|
||||||
resources. Here we iterate through all of our resource types and call
|
resources. Here we iterate through all of our resource types and call
|
||||||
.save() to ensure all that happens for every object in the system before we
|
.save() to ensure all that happens for every object in the system.
|
||||||
get busy with the actual migration work.
|
|
||||||
|
|
||||||
This gets run after migrate_users, which does role creation for users a
|
This can be used whenever new roles are introduced in a migration to
|
||||||
little differently.
|
create those roles for pre-existing objects that did not previously
|
||||||
|
have them created via signals.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
models = [
|
models = [
|
||||||
@@ -35,7 +38,118 @@ def create_roles(apps, schema_editor):
|
|||||||
obj.save()
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all_user_roles(apps, schema_editor):
|
||||||
|
ContentType = apps.get_model('contenttypes', "ContentType")
|
||||||
|
Role = apps.get_model('main', "Role")
|
||||||
|
User = apps.get_model('auth', "User")
|
||||||
|
user_content_type = ContentType.objects.get_for_model(User)
|
||||||
|
for role in Role.objects.filter(content_type=user_content_type).iterator():
|
||||||
|
role.delete()
|
||||||
|
|
||||||
|
|
||||||
|
UNIFIED_ORG_LOOKUPS = {
|
||||||
|
# Job Templates had an implicit organization via their project
|
||||||
|
'jobtemplate': 'project',
|
||||||
|
# Inventory Sources had an implicit organization via their inventory
|
||||||
|
'inventorysource': 'inventory',
|
||||||
|
# Projects had an explicit organization in their subclass table
|
||||||
|
'project': None,
|
||||||
|
# Workflow JTs also had an explicit organization in their subclass table
|
||||||
|
'workflowjobtemplate': None,
|
||||||
|
# Jobs inherited project from job templates as a convenience field
|
||||||
|
'job': 'project',
|
||||||
|
# Inventory Sources had an convenience field of inventory
|
||||||
|
'inventoryupdate': 'inventory',
|
||||||
|
# Project Updates did not have a direct organization field, obtained it from project
|
||||||
|
'projectupdate': 'project',
|
||||||
|
# Workflow Jobs are handled same as project updates
|
||||||
|
# Sliced jobs are a special case, but old data is not given special treatment for simplicity
|
||||||
|
'workflowjob': 'workflow_job_template',
|
||||||
|
# AdHocCommands do not have a template, but still migrate them
|
||||||
|
'adhoccommand': 'inventory'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def implicit_org_subquery(UnifiedClass, cls, backward=False):
|
||||||
|
"""Returns a subquery that returns the so-called organization for objects
|
||||||
|
in the class in question, before migration to the explicit unified org field.
|
||||||
|
In some cases, this can still be applied post-migration.
|
||||||
|
"""
|
||||||
|
if cls._meta.model_name not in UNIFIED_ORG_LOOKUPS:
|
||||||
|
return None
|
||||||
|
cls_name = cls._meta.model_name
|
||||||
|
source_field = UNIFIED_ORG_LOOKUPS[cls_name]
|
||||||
|
|
||||||
|
unified_field = UnifiedClass._meta.get_field(cls_name)
|
||||||
|
unified_ptr = unified_field.remote_field.name
|
||||||
|
if backward:
|
||||||
|
qs = UnifiedClass.objects.filter(**{cls_name: OuterRef('id')}).order_by().values_list('tmp_organization')[:1]
|
||||||
|
elif source_field is None:
|
||||||
|
qs = cls.objects.filter(**{unified_ptr: OuterRef('id')}).order_by().values_list('organization')[:1]
|
||||||
|
else:
|
||||||
|
intermediary_field = cls._meta.get_field(source_field)
|
||||||
|
intermediary_model = intermediary_field.related_model
|
||||||
|
intermediary_reverse_rel = intermediary_field.remote_field.name
|
||||||
|
qs = intermediary_model.objects.filter(**{
|
||||||
|
# this filter leverages the fact that the Unified models have same pk as subclasses.
|
||||||
|
# For instance... filters projects used in job template, where that job template
|
||||||
|
# has same id same as UJT from the outer reference (which it does)
|
||||||
|
intermediary_reverse_rel: OuterRef('id')}
|
||||||
|
).order_by().values_list('organization')[:1]
|
||||||
|
return Subquery(qs)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_unified_organization(apps, unified_cls_name, backward=False):
|
||||||
|
"""Given a unified base model (either UJT or UJ)
|
||||||
|
and a dict org_field_mapping which gives related model to get org from
|
||||||
|
saves organization for those objects to the temporary migration
|
||||||
|
variable tmp_organization on the unified model
|
||||||
|
(optimized method)
|
||||||
|
"""
|
||||||
|
start = time()
|
||||||
|
UnifiedClass = apps.get_model('main', unified_cls_name)
|
||||||
|
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||||
|
|
||||||
|
for cls in UnifiedClass.__subclasses__():
|
||||||
|
cls_name = cls._meta.model_name
|
||||||
|
if backward and UNIFIED_ORG_LOOKUPS.get(cls_name, 'not-found') is not None:
|
||||||
|
logger.debug('Not reverse migrating {}, existing data should remain valid'.format(cls_name))
|
||||||
|
continue
|
||||||
|
logger.debug('Migrating {} to new organization field'.format(cls_name))
|
||||||
|
|
||||||
|
sub_qs = implicit_org_subquery(UnifiedClass, cls, backward=backward)
|
||||||
|
if sub_qs is None:
|
||||||
|
logger.debug('Class {} has no organization migration'.format(cls_name))
|
||||||
|
continue
|
||||||
|
|
||||||
|
this_ct = ContentType.objects.get_for_model(cls)
|
||||||
|
if backward:
|
||||||
|
r = cls.objects.order_by().update(organization=sub_qs)
|
||||||
|
else:
|
||||||
|
r = UnifiedClass.objects.order_by().filter(polymorphic_ctype=this_ct).update(tmp_organization=sub_qs)
|
||||||
|
if r:
|
||||||
|
logger.info('Organization migration on {} affected {} rows.'.format(cls_name, r))
|
||||||
|
logger.info('Unified organization migration completed in %f seconds' % (time() - start))
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_ujt_organization(apps, schema_editor):
|
||||||
|
'''Move organization field to UJT and UJ models'''
|
||||||
|
_migrate_unified_organization(apps, 'UnifiedJobTemplate')
|
||||||
|
_migrate_unified_organization(apps, 'UnifiedJob')
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_ujt_organization_backward(apps, schema_editor):
|
||||||
|
'''Move organization field from UJT and UJ models back to their original places'''
|
||||||
|
_migrate_unified_organization(apps, 'UnifiedJobTemplate', backward=True)
|
||||||
|
_migrate_unified_organization(apps, 'UnifiedJob', backward=True)
|
||||||
|
|
||||||
|
|
||||||
def rebuild_role_hierarchy(apps, schema_editor):
|
def rebuild_role_hierarchy(apps, schema_editor):
|
||||||
|
'''
|
||||||
|
This should be called in any migration when ownerships are changed.
|
||||||
|
Ex. I remove a user from the admin_role of a credential.
|
||||||
|
Ancestors are cached from parents for performance, this re-computes ancestors.
|
||||||
|
'''
|
||||||
logger.info('Computing role roots..')
|
logger.info('Computing role roots..')
|
||||||
start = time()
|
start = time()
|
||||||
roots = Role.objects \
|
roots = Role.objects \
|
||||||
@@ -46,14 +160,57 @@ def rebuild_role_hierarchy(apps, schema_editor):
|
|||||||
start = time()
|
start = time()
|
||||||
Role.rebuild_role_ancestor_list(roots, [])
|
Role.rebuild_role_ancestor_list(roots, [])
|
||||||
stop = time()
|
stop = time()
|
||||||
logger.info('Rebuild completed in %f seconds' % (stop - start))
|
logger.info('Rebuild ancestors completed in %f seconds' % (stop - start))
|
||||||
logger.info('Done.')
|
logger.info('Done.')
|
||||||
|
|
||||||
|
|
||||||
def delete_all_user_roles(apps, schema_editor):
|
def rebuild_role_parentage(apps, schema_editor):
|
||||||
ContentType = apps.get_model('contenttypes', "ContentType")
|
'''
|
||||||
|
This should be called in any migration when any parent_role entry
|
||||||
|
is modified so that the cached parent fields will be updated. Ex:
|
||||||
|
foo_role = ImplicitRoleField(
|
||||||
|
parent_role=['bar_role'] # change to parent_role=['admin_role']
|
||||||
|
)
|
||||||
|
|
||||||
|
This is like rebuild_role_hierarchy, but that method updates ancestors,
|
||||||
|
whereas this method updates parents.
|
||||||
|
'''
|
||||||
|
start = time()
|
||||||
|
seen_models = set()
|
||||||
|
updated_ct = 0
|
||||||
|
model_ct = 0
|
||||||
|
noop_ct = 0
|
||||||
Role = apps.get_model('main', "Role")
|
Role = apps.get_model('main', "Role")
|
||||||
User = apps.get_model('auth', "User")
|
for role in Role.objects.iterator():
|
||||||
user_content_type = ContentType.objects.get_for_model(User)
|
if not role.object_id:
|
||||||
for role in Role.objects.filter(content_type=user_content_type).iterator():
|
noop_ct += 1
|
||||||
role.delete()
|
continue
|
||||||
|
model_tuple = (role.content_type_id, role.object_id)
|
||||||
|
if model_tuple in seen_models:
|
||||||
|
continue
|
||||||
|
seen_models.add(model_tuple)
|
||||||
|
|
||||||
|
# The GenericForeignKey does not work right in migrations
|
||||||
|
# with the usage as role.content_object
|
||||||
|
# so we do the lookup ourselves with current migration models
|
||||||
|
ct = role.content_type
|
||||||
|
app = ct.app_label
|
||||||
|
ct_model = apps.get_model(app, ct.model)
|
||||||
|
content_object = ct_model.objects.get(pk=role.object_id)
|
||||||
|
|
||||||
|
updated = update_role_parentage_for_instance(content_object)
|
||||||
|
if updated:
|
||||||
|
model_ct += 1
|
||||||
|
logger.debug('Updated parents of {} roles of {}'.format(updated, content_object))
|
||||||
|
else:
|
||||||
|
noop_ct += 1
|
||||||
|
updated_ct += updated
|
||||||
|
|
||||||
|
logger.debug('No changes to role parents for {} roles'.format(noop_ct))
|
||||||
|
if updated_ct:
|
||||||
|
logger.info('Updated parentage for {} roles of {} resources'.format(updated_ct, model_ct))
|
||||||
|
|
||||||
|
logger.info('Rebuild parentage completed in %f seconds' % (time() - start))
|
||||||
|
|
||||||
|
if updated_ct:
|
||||||
|
rebuild_role_hierarchy(apps, schema_editor)
|
||||||
|
|||||||
@@ -426,9 +426,9 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
|||||||
'''
|
'''
|
||||||
def _get_related_jobs(self):
|
def _get_related_jobs(self):
|
||||||
return UnifiedJob.objects.non_polymorphic().filter(
|
return UnifiedJob.objects.non_polymorphic().filter(
|
||||||
Q(Job___inventory=self) |
|
Q(job__inventory=self) |
|
||||||
Q(InventoryUpdate___inventory_source__inventory=self) |
|
Q(inventoryupdate__inventory=self) |
|
||||||
Q(AdHocCommand___inventory=self)
|
Q(adhoccommand__inventory=self)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -808,8 +808,8 @@ class Group(CommonModelNameNotUnique, RelatedJobsMixin):
|
|||||||
'''
|
'''
|
||||||
def _get_related_jobs(self):
|
def _get_related_jobs(self):
|
||||||
return UnifiedJob.objects.non_polymorphic().filter(
|
return UnifiedJob.objects.non_polymorphic().filter(
|
||||||
Q(Job___inventory=self.inventory) |
|
Q(job__inventory=self.inventory) |
|
||||||
Q(InventoryUpdate___inventory_source__groups=self)
|
Q(inventoryupdate__inventory_source__groups=self)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1277,10 +1277,14 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_field_names(cls):
|
def _get_unified_job_field_names(cls):
|
||||||
return set(f.name for f in InventorySourceOptions._meta.fields) | set(
|
return set(f.name for f in InventorySourceOptions._meta.fields) | set(
|
||||||
['name', 'description', 'credentials', 'inventory']
|
['name', 'description', 'organization', 'credentials', 'inventory']
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
# if this is a new object, inherit organization from its inventory
|
||||||
|
if not self.pk and self.inventory and self.inventory.organization_id and not self.organization_id:
|
||||||
|
self.organization_id = self.inventory.organization_id
|
||||||
|
|
||||||
# If update_fields has been specified, add our field names to it,
|
# If update_fields has been specified, add our field names to it,
|
||||||
# if it hasn't been specified, then we're just doing a normal save.
|
# if it hasn't been specified, then we're just doing a normal save.
|
||||||
update_fields = kwargs.get('update_fields', [])
|
update_fields = kwargs.get('update_fields', [])
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
'labels', 'instance_groups', 'credentials', 'survey_spec'
|
'labels', 'instance_groups', 'credentials', 'survey_spec'
|
||||||
]
|
]
|
||||||
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
|
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
|
||||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')]
|
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
@@ -262,13 +262,17 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
)
|
)
|
||||||
|
|
||||||
admin_role = ImplicitRoleField(
|
admin_role = ImplicitRoleField(
|
||||||
parent_role=['project.organization.job_template_admin_role', 'inventory.organization.job_template_admin_role']
|
parent_role=['organization.job_template_admin_role']
|
||||||
)
|
)
|
||||||
execute_role = ImplicitRoleField(
|
execute_role = ImplicitRoleField(
|
||||||
parent_role=['admin_role', 'project.organization.execute_role', 'inventory.organization.execute_role'],
|
parent_role=['admin_role', 'organization.execute_role'],
|
||||||
)
|
)
|
||||||
read_role = ImplicitRoleField(
|
read_role = ImplicitRoleField(
|
||||||
parent_role=['project.organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role'],
|
parent_role=[
|
||||||
|
'organization.auditor_role',
|
||||||
|
'inventory.organization.auditor_role', # partial support for old inheritance via inventory
|
||||||
|
'execute_role', 'admin_role'
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -279,7 +283,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_field_names(cls):
|
def _get_unified_job_field_names(cls):
|
||||||
return set(f.name for f in JobOptions._meta.fields) | set(
|
return set(f.name for f in JobOptions._meta.fields) | set(
|
||||||
['name', 'description', 'survey_passwords', 'labels', 'credentials',
|
['name', 'description', 'organization', 'survey_passwords', 'labels', 'credentials',
|
||||||
'job_slice_number', 'job_slice_count']
|
'job_slice_number', 'job_slice_count']
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -479,13 +483,13 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
success_notification_templates = list(base_notification_templates.filter(
|
success_notification_templates = list(base_notification_templates.filter(
|
||||||
unifiedjobtemplate_notification_templates_for_success__in=[self, self.project]))
|
unifiedjobtemplate_notification_templates_for_success__in=[self, self.project]))
|
||||||
# Get Organization NotificationTemplates
|
# Get Organization NotificationTemplates
|
||||||
if self.project is not None and self.project.organization is not None:
|
if self.organization is not None:
|
||||||
error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter(
|
error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter(
|
||||||
organization_notification_templates_for_errors=self.project.organization)))
|
organization_notification_templates_for_errors=self.organization)))
|
||||||
started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter(
|
started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter(
|
||||||
organization_notification_templates_for_started=self.project.organization)))
|
organization_notification_templates_for_started=self.organization)))
|
||||||
success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter(
|
success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter(
|
||||||
organization_notification_templates_for_success=self.project.organization)))
|
organization_notification_templates_for_success=self.organization)))
|
||||||
return dict(error=list(error_notification_templates),
|
return dict(error=list(error_notification_templates),
|
||||||
started=list(started_notification_templates),
|
started=list(started_notification_templates),
|
||||||
success=list(success_notification_templates))
|
success=list(success_notification_templates))
|
||||||
@@ -588,7 +592,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
for virtualenv in (
|
for virtualenv in (
|
||||||
self.job_template.custom_virtualenv if self.job_template else None,
|
self.job_template.custom_virtualenv if self.job_template else None,
|
||||||
self.project.custom_virtualenv,
|
self.project.custom_virtualenv,
|
||||||
self.project.organization.custom_virtualenv if self.project.organization else None
|
self.organization.custom_virtualenv if self.organization else None
|
||||||
):
|
):
|
||||||
if virtualenv:
|
if virtualenv:
|
||||||
return virtualenv
|
return virtualenv
|
||||||
@@ -741,8 +745,8 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def preferred_instance_groups(self):
|
def preferred_instance_groups(self):
|
||||||
if self.project is not None and self.project.organization is not None:
|
if self.organization is not None:
|
||||||
organization_groups = [x for x in self.project.organization.instance_groups.all()]
|
organization_groups = [x for x in self.organization.instance_groups.all()]
|
||||||
else:
|
else:
|
||||||
organization_groups = []
|
organization_groups = []
|
||||||
if self.inventory is not None:
|
if self.inventory is not None:
|
||||||
@@ -1144,7 +1148,7 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_field_names(cls):
|
def _get_unified_job_field_names(cls):
|
||||||
return ['name', 'description', 'job_type', 'extra_vars']
|
return ['name', 'description', 'organization', 'job_type', 'extra_vars']
|
||||||
|
|
||||||
def get_absolute_url(self, request=None):
|
def get_absolute_url(self, request=None):
|
||||||
return reverse('api:system_job_template_detail', kwargs={'pk': self.pk}, request=request)
|
return reverse('api:system_job_template_detail', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.sessions.models import Session
|
from django.contrib.sessions.models import Session
|
||||||
from django.utils.timezone import now as tz_now
|
from django.utils.timezone import now as tz_now
|
||||||
@@ -106,12 +105,7 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
|
|||||||
RelatedJobsMixin
|
RelatedJobsMixin
|
||||||
'''
|
'''
|
||||||
def _get_related_jobs(self):
|
def _get_related_jobs(self):
|
||||||
project_ids = self.projects.all().values_list('id')
|
return UnifiedJob.objects.non_polymorphic().filter(organization=self)
|
||||||
return UnifiedJob.objects.non_polymorphic().filter(
|
|
||||||
Q(Job___project__in=project_ids) |
|
|
||||||
Q(ProjectUpdate___project__in=project_ids) |
|
|
||||||
Q(InventoryUpdate___inventory_source__inventory__organization=self)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Team(CommonModelNameNotUnique, ResourceMixin):
|
class Team(CommonModelNameNotUnique, ResourceMixin):
|
||||||
|
|||||||
@@ -254,13 +254,6 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
|
|
||||||
organization = models.ForeignKey(
|
|
||||||
'Organization',
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='projects',
|
|
||||||
)
|
|
||||||
scm_update_on_launch = models.BooleanField(
|
scm_update_on_launch = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
help_text=_('Update the project when a job is launched that uses the project.'),
|
help_text=_('Update the project when a job is launched that uses the project.'),
|
||||||
@@ -329,7 +322,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_field_names(cls):
|
def _get_unified_job_field_names(cls):
|
||||||
return set(f.name for f in ProjectOptions._meta.fields) | set(
|
return set(f.name for f in ProjectOptions._meta.fields) | set(
|
||||||
['name', 'description']
|
['name', 'description', 'organization']
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@@ -450,8 +443,8 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
'''
|
'''
|
||||||
def _get_related_jobs(self):
|
def _get_related_jobs(self):
|
||||||
return UnifiedJob.objects.non_polymorphic().filter(
|
return UnifiedJob.objects.non_polymorphic().filter(
|
||||||
models.Q(Job___project=self) |
|
models.Q(job__project=self) |
|
||||||
models.Q(ProjectUpdate___project=self)
|
models.Q(projectupdate__project=self)
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -157,6 +157,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
|
|||||||
default='ok',
|
default='ok',
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
'Organization',
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=polymorphic.SET_NULL,
|
||||||
|
related_name='%(class)ss',
|
||||||
|
help_text=_('The organization used to determine access to this template.'),
|
||||||
|
)
|
||||||
credentials = models.ManyToManyField(
|
credentials = models.ManyToManyField(
|
||||||
'Credential',
|
'Credential',
|
||||||
related_name='%(class)ss',
|
related_name='%(class)ss',
|
||||||
@@ -700,6 +708,14 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
|||||||
on_delete=polymorphic.SET_NULL,
|
on_delete=polymorphic.SET_NULL,
|
||||||
help_text=_('The Rampart/Instance group the job was run under'),
|
help_text=_('The Rampart/Instance group the job was run under'),
|
||||||
)
|
)
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
'Organization',
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=polymorphic.SET_NULL,
|
||||||
|
related_name='%(class)ss',
|
||||||
|
help_text=_('The organization used to determine access to this unified job.'),
|
||||||
|
)
|
||||||
credentials = models.ManyToManyField(
|
credentials = models.ManyToManyField(
|
||||||
'Credential',
|
'Credential',
|
||||||
related_name='%(class)ss',
|
related_name='%(class)ss',
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_field_names(cls):
|
def _get_unified_job_field_names(cls):
|
||||||
r = set(f.name for f in WorkflowJobOptions._meta.fields) | set(
|
r = set(f.name for f in WorkflowJobOptions._meta.fields) | set(
|
||||||
['name', 'description', 'survey_passwords', 'labels', 'limit', 'scm_branch']
|
['name', 'description', 'organization', 'survey_passwords', 'labels', 'limit', 'scm_branch']
|
||||||
)
|
)
|
||||||
r.remove('char_prompts') # needed due to copying launch config to launch config
|
r.remove('char_prompts') # needed due to copying launch config to launch config
|
||||||
return r
|
return r
|
||||||
@@ -382,13 +382,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
|||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
|
|
||||||
organization = models.ForeignKey(
|
|
||||||
'Organization',
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name='workflows',
|
|
||||||
)
|
|
||||||
ask_inventory_on_launch = AskForField(
|
ask_inventory_on_launch = AskForField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ def connect_computed_field_signals():
|
|||||||
|
|
||||||
connect_computed_field_signals()
|
connect_computed_field_signals()
|
||||||
|
|
||||||
post_save.connect(save_related_job_templates, sender=Project)
|
|
||||||
post_save.connect(save_related_job_templates, sender=Inventory)
|
post_save.connect(save_related_job_templates, sender=Inventory)
|
||||||
m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through)
|
m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through)
|
||||||
m2m_changed.connect(rbac_activity_stream, Role.members.through)
|
m2m_changed.connect(rbac_activity_stream, Role.members.through)
|
||||||
|
|||||||
@@ -159,7 +159,8 @@ def mk_job_template(name, job_type='run',
|
|||||||
extra_vars = json.dumps(extra_vars)
|
extra_vars = json.dumps(extra_vars)
|
||||||
|
|
||||||
jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars,
|
jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars,
|
||||||
webhook_service=webhook_service, playbook='helloworld.yml')
|
webhook_service=webhook_service, playbook='helloworld.yml',
|
||||||
|
organization=organization)
|
||||||
|
|
||||||
jt.inventory = inventory
|
jt.inventory = inventory
|
||||||
if jt.inventory is None:
|
if jt.inventory is None:
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ def create_job_template(name, roles=None, persisted=True, webhook_service='', **
|
|||||||
jt = mk_job_template(name, project=proj, inventory=inv, credential=cred,
|
jt = mk_job_template(name, project=proj, inventory=inv, credential=cred,
|
||||||
network_credential=net_cred, cloud_credential=cloud_cred,
|
network_credential=net_cred, cloud_credential=cloud_cred,
|
||||||
job_type=job_type, spec=spec, extra_vars=extra_vars,
|
job_type=job_type, spec=spec, extra_vars=extra_vars,
|
||||||
persisted=persisted, webhook_service=webhook_service)
|
persisted=persisted, webhook_service=webhook_service, organization=org)
|
||||||
|
|
||||||
if 'jobs' in kwargs:
|
if 'jobs' in kwargs:
|
||||||
for i in kwargs['jobs']:
|
for i in kwargs['jobs']:
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ def test_multi_vault_preserved_on_put(get, put, admin_user, job_template, vault_
|
|||||||
job_template.credentials.add(vault_credential, vault2)
|
job_template.credentials.add(vault_credential, vault2)
|
||||||
assert job_template.credentials.count() == 2 # sanity check
|
assert job_template.credentials.count() == 2 # sanity check
|
||||||
r = get(job_template.get_absolute_url(), admin_user, expect=200)
|
r = get(job_template.get_absolute_url(), admin_user, expect=200)
|
||||||
|
r.data.pop('organization') # so that it passes validation
|
||||||
# should be a no-op PUT request
|
# should be a no-op PUT request
|
||||||
put(
|
put(
|
||||||
job_template.get_absolute_url(),
|
job_template.get_absolute_url(),
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ def test_extra_credentials(get, organization_factory, job_template_factory, cred
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_job_relaunch_permission_denied_response(
|
def test_job_relaunch_permission_denied_response(
|
||||||
post, get, inventory, project, credential, net_credential, machine_credential):
|
post, get, inventory, project, credential, net_credential, machine_credential):
|
||||||
|
jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project, ask_credential_on_launch=True)
|
||||||
|
jt.credentials.add(machine_credential)
|
||||||
|
jt_user = User.objects.create(username='jobtemplateuser')
|
||||||
|
jt.execute_role.members.add(jt_user)
|
||||||
|
with impersonate(jt_user):
|
||||||
|
job = jt.create_unified_job()
|
||||||
|
|
||||||
|
# User capability is shown for this
|
||||||
|
r = get(job.get_absolute_url(), jt_user, expect=200)
|
||||||
|
assert r.data['summary_fields']['user_capabilities']['start']
|
||||||
|
|
||||||
|
# Job has prompted extra_credential, launch denied w/ message
|
||||||
|
job.launch_config.credentials.add(net_credential)
|
||||||
|
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
|
||||||
|
assert 'launched with prompted fields which you do not have access to' in r.data['detail']
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_job_relaunch_prompts_not_accepted_response(
|
||||||
|
post, get, inventory, project, credential, net_credential, machine_credential):
|
||||||
jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project)
|
jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project)
|
||||||
jt.credentials.add(machine_credential)
|
jt.credentials.add(machine_credential)
|
||||||
jt_user = User.objects.create(username='jobtemplateuser')
|
jt_user = User.objects.create(username='jobtemplateuser')
|
||||||
@@ -53,8 +73,7 @@ def test_job_relaunch_permission_denied_response(
|
|||||||
# Job has prompted extra_credential, launch denied w/ message
|
# Job has prompted extra_credential, launch denied w/ message
|
||||||
job.launch_config.credentials.add(net_credential)
|
job.launch_config.credentials.add(net_credential)
|
||||||
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
|
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
|
||||||
assert 'launched with prompted fields' in r.data['detail']
|
assert 'no longer accepts the prompts provided for this job' in r.data['detail']
|
||||||
assert 'do not have permission' in r.data['detail']
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -201,7 +220,8 @@ def test_block_unprocessed_events(delete, admin_user, mocker):
|
|||||||
def test_block_related_unprocessed_events(mocker, organization, project, delete, admin_user):
|
def test_block_related_unprocessed_events(mocker, organization, project, delete, admin_user):
|
||||||
job_template = JobTemplate.objects.create(
|
job_template = JobTemplate.objects.create(
|
||||||
project=project,
|
project=project,
|
||||||
playbook='helloworld.yml'
|
playbook='helloworld.yml',
|
||||||
|
organization=organization
|
||||||
)
|
)
|
||||||
time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500")
|
time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500")
|
||||||
Job.objects.create(
|
Job.objects.create(
|
||||||
@@ -209,7 +229,8 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete,
|
|||||||
status='finished',
|
status='finished',
|
||||||
finished=time_of_finish,
|
finished=time_of_finish,
|
||||||
job_template=job_template,
|
job_template=job_template,
|
||||||
project=project
|
project=project,
|
||||||
|
organization=organization
|
||||||
)
|
)
|
||||||
view = RelatedJobsPreventDeleteMixin()
|
view = RelatedJobsPreventDeleteMixin()
|
||||||
time_of_request = time_of_finish + relativedelta(seconds=2)
|
time_of_request = time_of_finish + relativedelta(seconds=2)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
# AWX
|
# AWX
|
||||||
from awx.api.serializers import JobTemplateSerializer
|
from awx.api.serializers import JobTemplateSerializer
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate
|
from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization
|
||||||
from awx.main.migrations import _save_password_keys as save_password_keys
|
from awx.main.migrations import _save_password_keys as save_password_keys
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
@@ -30,16 +30,55 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje
|
|||||||
project.use_role.members.add(alice)
|
project.use_role.members.add(alice)
|
||||||
if grant_inventory:
|
if grant_inventory:
|
||||||
inventory.use_role.members.add(alice)
|
inventory.use_role.members.add(alice)
|
||||||
|
project.organization.job_template_admin_role.members.add(alice)
|
||||||
|
|
||||||
r = post(reverse('api:job_template_list'), {
|
r = post(reverse('api:job_template_list'), {
|
||||||
'name': 'Some name',
|
'name': 'Some name',
|
||||||
'project': project.id,
|
'project': project.id,
|
||||||
'inventory': inventory.id,
|
'inventory': inventory.id,
|
||||||
'playbook': 'helloworld.yml',
|
'playbook': 'helloworld.yml',
|
||||||
|
'organization': project.organization_id
|
||||||
}, alice)
|
}, alice)
|
||||||
assert r.status_code == expect
|
assert r.status_code == expect
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_creation_uniqueness_rules(post, project, inventory, admin_user):
|
||||||
|
orgA = Organization.objects.create(name='orga')
|
||||||
|
orgB = Organization.objects.create(name='orgb')
|
||||||
|
create_data = {
|
||||||
|
'name': 'this_unique_name',
|
||||||
|
'project': project.pk,
|
||||||
|
'inventory': inventory.pk,
|
||||||
|
'playbook': 'helloworld.yml',
|
||||||
|
'organization': orgA.pk
|
||||||
|
}
|
||||||
|
post(
|
||||||
|
url=reverse('api:job_template_list'),
|
||||||
|
data=create_data,
|
||||||
|
user=admin_user,
|
||||||
|
expect=201
|
||||||
|
)
|
||||||
|
r = post(
|
||||||
|
url=reverse('api:job_template_list'),
|
||||||
|
data=create_data,
|
||||||
|
user=admin_user,
|
||||||
|
expect=400
|
||||||
|
)
|
||||||
|
msg = str(r.data['__all__'][0])
|
||||||
|
assert "JobTemplate with this (" in msg
|
||||||
|
assert ") combination already exists" in msg
|
||||||
|
|
||||||
|
# can create JT with same name, only if it is in different org
|
||||||
|
create_data['organization'] = orgB.pk
|
||||||
|
post(
|
||||||
|
url=reverse('api:job_template_list'),
|
||||||
|
data=create_data,
|
||||||
|
user=admin_user,
|
||||||
|
expect=201
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws):
|
def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws):
|
||||||
objs = organization_factory("org", superusers=['admin'])
|
objs = organization_factory("org", superusers=['admin'])
|
||||||
@@ -524,13 +563,14 @@ def test_callback_disallowed_null_inventory(project):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_job_template_branch_error(project, inventory, post, admin_user):
|
def test_job_template_branch_error(project, inventory, organization, post, admin_user):
|
||||||
r = post(
|
r = post(
|
||||||
url=reverse('api:job_template_list'),
|
url=reverse('api:job_template_list'),
|
||||||
data={
|
data={
|
||||||
"name": "fooo",
|
"name": "fooo",
|
||||||
"inventory": inventory.pk,
|
"inventory": inventory.pk,
|
||||||
"project": project.pk,
|
"project": project.pk,
|
||||||
|
"organization": organization.pk,
|
||||||
"playbook": "helloworld.yml",
|
"playbook": "helloworld.yml",
|
||||||
"scm_branch": "foobar"
|
"scm_branch": "foobar"
|
||||||
},
|
},
|
||||||
@@ -541,13 +581,14 @@ def test_job_template_branch_error(project, inventory, post, admin_user):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_job_template_branch_prompt_error(project, inventory, post, admin_user):
|
def test_job_template_branch_prompt_error(project, inventory, post, organization, admin_user):
|
||||||
r = post(
|
r = post(
|
||||||
url=reverse('api:job_template_list'),
|
url=reverse('api:job_template_list'),
|
||||||
data={
|
data={
|
||||||
"name": "fooo",
|
"name": "fooo",
|
||||||
"inventory": inventory.pk,
|
"inventory": inventory.pk,
|
||||||
"project": project.pk,
|
"project": project.pk,
|
||||||
|
"organization": organization.pk,
|
||||||
"playbook": "helloworld.yml",
|
"playbook": "helloworld.yml",
|
||||||
"ask_scm_branch_on_launch": True
|
"ask_scm_branch_on_launch": True
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import pytest
|
|||||||
|
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
|
|
||||||
|
from awx.main.models import Project
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def organization_resource_creator(organization, user):
|
def organization_resource_creator(organization, user):
|
||||||
@@ -19,21 +21,26 @@ def organization_resource_creator(organization, user):
|
|||||||
for i in range(inventories):
|
for i in range(inventories):
|
||||||
inventory = organization.inventories.create(name="associated-inv %s" % i)
|
inventory = organization.inventories.create(name="associated-inv %s" % i)
|
||||||
for i in range(projects):
|
for i in range(projects):
|
||||||
organization.projects.create(name="test-proj %s" % i,
|
Project.objects.create(
|
||||||
description="test-proj-desc")
|
name="test-proj %s" % i,
|
||||||
|
description="test-proj-desc",
|
||||||
|
organization=organization
|
||||||
|
)
|
||||||
# Mix up the inventories and projects used by the job templates
|
# Mix up the inventories and projects used by the job templates
|
||||||
i_proj = 0
|
i_proj = 0
|
||||||
i_inv = 0
|
i_inv = 0
|
||||||
for i in range(job_templates):
|
for i in range(job_templates):
|
||||||
project = organization.projects.all()[i_proj]
|
project = Project.objects.filter(organization=organization)[i_proj]
|
||||||
|
# project = organization.projects.all()[i_proj]
|
||||||
inventory = organization.inventories.all()[i_inv]
|
inventory = organization.inventories.all()[i_inv]
|
||||||
project.jobtemplates.create(name="test-jt %s" % i,
|
project.jobtemplates.create(name="test-jt %s" % i,
|
||||||
description="test-job-template-desc",
|
description="test-job-template-desc",
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
playbook="test_playbook.yml")
|
playbook="test_playbook.yml",
|
||||||
|
organization=organization)
|
||||||
i_proj += 1
|
i_proj += 1
|
||||||
i_inv += 1
|
i_inv += 1
|
||||||
if i_proj >= organization.projects.count():
|
if i_proj >= Project.objects.filter(organization=organization).count():
|
||||||
i_proj = 0
|
i_proj = 0
|
||||||
if i_inv >= organization.inventories.count():
|
if i_inv >= organization.inventories.count():
|
||||||
i_inv = 0
|
i_inv = 0
|
||||||
@@ -179,12 +186,14 @@ def test_scan_JT_counted(resourced_organization, user, get):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_JT_not_double_counted(resourced_organization, user, get):
|
def test_JT_not_double_counted(resourced_organization, user, get):
|
||||||
admin_user = user('admin', True)
|
admin_user = user('admin', True)
|
||||||
|
proj = Project.objects.filter(organization=resourced_organization).all()[0]
|
||||||
# Add a run job template to the org
|
# Add a run job template to the org
|
||||||
resourced_organization.projects.all()[0].jobtemplates.create(
|
proj.jobtemplates.create(
|
||||||
job_type='run',
|
job_type='run',
|
||||||
inventory=resourced_organization.inventories.all()[0],
|
inventory=resourced_organization.inventories.all()[0],
|
||||||
project=resourced_organization.projects.all()[0],
|
project=proj,
|
||||||
name='double-linked-job-template')
|
name='double-linked-job-template',
|
||||||
|
organization=resourced_organization)
|
||||||
counts_dict = COUNTS_PRIMES
|
counts_dict = COUNTS_PRIMES
|
||||||
counts_dict['job_templates'] += 1
|
counts_dict['job_templates'] += 1
|
||||||
|
|
||||||
@@ -197,38 +206,3 @@ def test_JT_not_double_counted(resourced_organization, user, get):
|
|||||||
detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
|
detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
|
||||||
assert detail_response.status_code == 200
|
assert detail_response.status_code == 200
|
||||||
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict
|
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_JT_associated_with_project(organizations, project, user, get):
|
|
||||||
# Check that adding a project to an organization gets the project's JT
|
|
||||||
# included in the organization's JT count
|
|
||||||
external_admin = user('admin', True)
|
|
||||||
two_orgs = organizations(2)
|
|
||||||
organization = two_orgs[0]
|
|
||||||
other_org = two_orgs[1]
|
|
||||||
|
|
||||||
unrelated_inv = other_org.inventories.create(name='not-in-organization')
|
|
||||||
organization.projects.add(project)
|
|
||||||
project.jobtemplates.create(name="test-jt",
|
|
||||||
description="test-job-template-desc",
|
|
||||||
inventory=unrelated_inv,
|
|
||||||
playbook="test_playbook.yml")
|
|
||||||
|
|
||||||
response = get(reverse('api:organization_list'), external_admin)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
org_id = organization.id
|
|
||||||
counts = {}
|
|
||||||
for org_json in response.data['results']:
|
|
||||||
working_id = org_json['id']
|
|
||||||
counts[working_id] = org_json['summary_fields']['related_field_counts']
|
|
||||||
|
|
||||||
assert counts[org_id] == {
|
|
||||||
'users': 0,
|
|
||||||
'admins': 0,
|
|
||||||
'job_templates': 1,
|
|
||||||
'projects': 1,
|
|
||||||
'inventories': 0,
|
|
||||||
'teams': 0
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class TestJobTemplateCopyEdit:
|
|||||||
def jt_copy_edit(self, job_template_factory, project):
|
def jt_copy_edit(self, job_template_factory, project):
|
||||||
objects = job_template_factory(
|
objects = job_template_factory(
|
||||||
'copy-edit-job-template',
|
'copy-edit-job-template',
|
||||||
project=project)
|
project=project, organization=project.organization)
|
||||||
return objects.job_template
|
return objects.job_template
|
||||||
|
|
||||||
def fake_context(self, user):
|
def fake_context(self, user):
|
||||||
@@ -129,9 +129,8 @@ class TestJobTemplateCopyEdit:
|
|||||||
|
|
||||||
# random user given JT and project admin abilities
|
# random user given JT and project admin abilities
|
||||||
jt_copy_edit.admin_role.members.add(rando)
|
jt_copy_edit.admin_role.members.add(rando)
|
||||||
jt_copy_edit.save()
|
|
||||||
jt_copy_edit.project.admin_role.members.add(rando)
|
jt_copy_edit.project.admin_role.members.add(rando)
|
||||||
jt_copy_edit.project.save()
|
jt_copy_edit.organization.job_template_admin_role.members.add(rando)
|
||||||
|
|
||||||
serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(rando))
|
serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(rando))
|
||||||
response = serializer.to_representation(jt_copy_edit)
|
response = serializer.to_representation(jt_copy_edit)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
|
from awx.main import models
|
||||||
|
from awx.main.utils import get_type_for_model
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -9,3 +11,111 @@ def test_aliased_forward_reverse_field_searches(instance, options, get, admin):
|
|||||||
response = options(url, None, admin)
|
response = options(url, None, admin)
|
||||||
assert 'job_template__search' in response.data['related_search_fields']
|
assert 'job_template__search' in response.data['related_search_fields']
|
||||||
get(reverse("api:unified_job_template_list") + "?job_template__search=anything", user=admin, expect=200)
|
get(reverse("api:unified_job_template_list") + "?job_template__search=anything", user=admin, expect=200)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize('model', (
|
||||||
|
'Project',
|
||||||
|
'JobTemplate',
|
||||||
|
'WorkflowJobTemplate'
|
||||||
|
))
|
||||||
|
class TestUnifiedOrganization:
|
||||||
|
|
||||||
|
def data_for_model(self, model, orm_style=False):
|
||||||
|
data = {
|
||||||
|
'name': 'foo',
|
||||||
|
'organization': None
|
||||||
|
}
|
||||||
|
if model == 'JobTemplate':
|
||||||
|
proj = models.Project.objects.create(
|
||||||
|
name="test-proj",
|
||||||
|
playbook_files=['helloworld.yml']
|
||||||
|
)
|
||||||
|
if orm_style:
|
||||||
|
data['project_id'] = proj.id
|
||||||
|
else:
|
||||||
|
data['project'] = proj.id
|
||||||
|
data['playbook'] = 'helloworld.yml'
|
||||||
|
data['ask_inventory_on_launch'] = True
|
||||||
|
return data
|
||||||
|
|
||||||
|
def test_organization_required_on_creation(self, model, admin_user, post):
|
||||||
|
cls = getattr(models, model)
|
||||||
|
data = self.data_for_model(model)
|
||||||
|
r = post(
|
||||||
|
url=reverse('api:{}_list'.format(get_type_for_model(cls))),
|
||||||
|
data=data,
|
||||||
|
user=admin_user,
|
||||||
|
expect=400
|
||||||
|
)
|
||||||
|
assert 'organization' in r.data
|
||||||
|
assert 'required for new object' in r.data['organization'][0]
|
||||||
|
# Surprising behavior - not providing the key can often give
|
||||||
|
# different behavior from giving it as null on create
|
||||||
|
data.pop('organization')
|
||||||
|
r = post(
|
||||||
|
url=reverse('api:{}_list'.format(get_type_for_model(cls))),
|
||||||
|
data=data,
|
||||||
|
user=admin_user,
|
||||||
|
expect=400
|
||||||
|
)
|
||||||
|
assert 'organization' in r.data
|
||||||
|
assert 'required' in r.data['organization'][0]
|
||||||
|
|
||||||
|
def test_organization_blank_on_edit_of_orphan(self, model, admin_user, patch):
|
||||||
|
cls = getattr(models, model)
|
||||||
|
data = self.data_for_model(model, orm_style=True)
|
||||||
|
obj = cls.objects.create(**data)
|
||||||
|
patch(
|
||||||
|
url=obj.get_absolute_url(),
|
||||||
|
data={'name': 'foooooo'},
|
||||||
|
user=admin_user,
|
||||||
|
expect=200
|
||||||
|
)
|
||||||
|
obj.refresh_from_db()
|
||||||
|
assert obj.name == 'foooooo'
|
||||||
|
|
||||||
|
def test_organization_blank_on_edit_of_orphan_as_nonsuperuser(self, model, rando, patch):
|
||||||
|
"""Test case reflects historical bug where ordinary users got weird error
|
||||||
|
message when editing an orphaned project
|
||||||
|
"""
|
||||||
|
cls = getattr(models, model)
|
||||||
|
data = self.data_for_model(model, orm_style=True)
|
||||||
|
obj = cls.objects.create(**data)
|
||||||
|
if model == 'JobTemplate':
|
||||||
|
obj.project.admin_role.members.add(rando)
|
||||||
|
obj.admin_role.members.add(rando)
|
||||||
|
patch(
|
||||||
|
url=obj.get_absolute_url(),
|
||||||
|
data={'name': 'foooooo'},
|
||||||
|
user=rando,
|
||||||
|
expect=200
|
||||||
|
)
|
||||||
|
obj.refresh_from_db()
|
||||||
|
assert obj.name == 'foooooo'
|
||||||
|
|
||||||
|
def test_organization_blank_on_edit_of_normal(self, model, admin_user, patch, organization):
|
||||||
|
cls = getattr(models, model)
|
||||||
|
data = self.data_for_model(model, orm_style=True)
|
||||||
|
data['organization'] = organization
|
||||||
|
obj = cls.objects.create(**data)
|
||||||
|
patch(
|
||||||
|
url=obj.get_absolute_url(),
|
||||||
|
data={'name': 'foooooo'},
|
||||||
|
user=admin_user,
|
||||||
|
expect=200
|
||||||
|
)
|
||||||
|
obj.refresh_from_db()
|
||||||
|
assert obj.name == 'foooooo'
|
||||||
|
|
||||||
|
def test_organization_cannot_change_to_null(self, model, admin_user, patch, organization):
|
||||||
|
cls = getattr(models, model)
|
||||||
|
data = self.data_for_model(model, orm_style=True)
|
||||||
|
data['organization'] = organization
|
||||||
|
obj = cls.objects.create(**data)
|
||||||
|
patch(
|
||||||
|
url=obj.get_absolute_url(),
|
||||||
|
data={'organization': None},
|
||||||
|
user=admin_user,
|
||||||
|
expect=400
|
||||||
|
)
|
||||||
|
|||||||
@@ -75,24 +75,26 @@ def user():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def check_jobtemplate(project, inventory, credential):
|
def check_jobtemplate(project, inventory, credential, organization):
|
||||||
jt = JobTemplate.objects.create(
|
jt = JobTemplate.objects.create(
|
||||||
job_type='check',
|
job_type='check',
|
||||||
project=project,
|
project=project,
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
name='check-job-template'
|
name='check-job-template',
|
||||||
|
organization=organization
|
||||||
)
|
)
|
||||||
jt.credentials.add(credential)
|
jt.credentials.add(credential)
|
||||||
return jt
|
return jt
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def deploy_jobtemplate(project, inventory, credential):
|
def deploy_jobtemplate(project, inventory, credential, organization):
|
||||||
jt = JobTemplate.objects.create(
|
jt = JobTemplate.objects.create(
|
||||||
job_type='run',
|
job_type='run',
|
||||||
project=project,
|
project=project,
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
name='deploy-job-template'
|
name='deploy-job-template',
|
||||||
|
organization=organization
|
||||||
)
|
)
|
||||||
jt.credentials.add(credential)
|
jt.credentials.add(credential)
|
||||||
return jt
|
return jt
|
||||||
@@ -180,8 +182,8 @@ def project_factory(organization):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def job_factory(job_template, admin):
|
def job_factory(jt_linked, admin):
|
||||||
def factory(job_template=job_template, initial_state='new', created_by=admin):
|
def factory(job_template=jt_linked, initial_state='new', created_by=admin):
|
||||||
return job_template.create_unified_job(_eager_fields={
|
return job_template.create_unified_job(_eager_fields={
|
||||||
'status': initial_state, 'created_by': created_by})
|
'status': initial_state, 'created_by': created_by})
|
||||||
return factory
|
return factory
|
||||||
@@ -701,11 +703,8 @@ def ad_hoc_command_factory(inventory, machine_credential, admin):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def job_template(organization):
|
def job_template():
|
||||||
jt = JobTemplate(name='test-job_template')
|
return JobTemplate.objects.create(name='test-job_template')
|
||||||
jt.save()
|
|
||||||
|
|
||||||
return jt
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -717,20 +716,16 @@ def job_template_labels(organization, job_template):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def jt_linked(job_template_factory, credential, net_credential, vault_credential):
|
def jt_linked(organization, project, inventory, machine_credential, credential, net_credential, vault_credential):
|
||||||
'''
|
'''
|
||||||
A job template with a reasonably complete set of related objects to
|
A job template with a reasonably complete set of related objects to
|
||||||
test RBAC and other functionality affected by related objects
|
test RBAC and other functionality affected by related objects
|
||||||
'''
|
'''
|
||||||
objects = job_template_factory(
|
jt = JobTemplate.objects.create(
|
||||||
'testJT', organization='org1', project='proj1', inventory='inventory1',
|
project=project, inventory=inventory, playbook='helloworld.yml',
|
||||||
credential='cred1')
|
organization=organization
|
||||||
jt = objects.job_template
|
)
|
||||||
jt.credentials.add(vault_credential)
|
jt.credentials.add(machine_credential, vault_credential, credential, net_credential)
|
||||||
jt.save()
|
|
||||||
# Add AWS cloud credential and network credential
|
|
||||||
jt.credentials.add(credential)
|
|
||||||
jt.credentials.add(net_credential)
|
|
||||||
return jt
|
return jt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from awx.main.models import (
|
|||||||
CredentialType,
|
CredentialType,
|
||||||
Inventory,
|
Inventory,
|
||||||
InventorySource,
|
InventorySource,
|
||||||
|
Project,
|
||||||
User
|
User
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -99,8 +100,8 @@ class TestRolesAssociationEntries:
|
|||||||
).count() == 1, 'In loop %s' % i
|
).count() == 1, 'In loop %s' % i
|
||||||
|
|
||||||
def test_model_associations_are_recorded(self, organization):
|
def test_model_associations_are_recorded(self, organization):
|
||||||
proj1 = organization.projects.create(name='proj1')
|
proj1 = Project.objects.create(name='proj1', organization=organization)
|
||||||
proj2 = organization.projects.create(name='proj2')
|
proj2 = Project.objects.create(name='proj2', organization=organization)
|
||||||
proj2.use_role.parents.add(proj1.admin_role)
|
proj2.use_role.parents.add(proj1.admin_role)
|
||||||
assert ActivityStream.objects.filter(role=proj1.admin_role, project=proj2).count() == 1
|
assert ActivityStream.objects.filter(role=proj1.admin_role, project=proj2).count() == 1
|
||||||
|
|
||||||
|
|||||||
@@ -29,18 +29,19 @@ def test_prevent_slicing():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_awx_custom_virtualenv(inventory, project, machine_credential):
|
def test_awx_custom_virtualenv(inventory, project, machine_credential, organization):
|
||||||
jt = JobTemplate.objects.create(
|
jt = JobTemplate.objects.create(
|
||||||
name='my-jt',
|
name='my-jt',
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
project=project,
|
project=project,
|
||||||
playbook='helloworld.yml'
|
playbook='helloworld.yml',
|
||||||
|
organization=organization
|
||||||
)
|
)
|
||||||
jt.credentials.add(machine_credential)
|
jt.credentials.add(machine_credential)
|
||||||
job = jt.create_unified_job()
|
job = jt.create_unified_job()
|
||||||
|
|
||||||
job.project.organization.custom_virtualenv = '/venv/fancy-org'
|
job.organization.custom_virtualenv = '/venv/fancy-org'
|
||||||
job.project.organization.save()
|
job.organization.save()
|
||||||
assert job.ansible_virtualenv_path == '/venv/fancy-org'
|
assert job.ansible_virtualenv_path == '/venv/fancy-org'
|
||||||
|
|
||||||
job.project.custom_virtualenv = '/venv/fancy-proj'
|
job.project.custom_virtualenv = '/venv/fancy-proj'
|
||||||
|
|||||||
@@ -39,3 +39,9 @@ def test_foreign_key_change_changes_modified_by(project, organization):
|
|||||||
assert project._get_fields_snapshot()['organization_id'] == organization.id
|
assert project._get_fields_snapshot()['organization_id'] == organization.id
|
||||||
project.organization = Organization(name='foo', pk=41)
|
project.organization = Organization(name='foo', pk=41)
|
||||||
assert project._get_fields_snapshot()['organization_id'] == 41
|
assert project._get_fields_snapshot()['organization_id'] == 41
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_project_related_jobs(project):
|
||||||
|
update = project.create_unified_job()
|
||||||
|
assert update.id in [u.id for u in project._get_related_jobs()]
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ from awx.main.tasks import deep_copy_model_obj
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_job_template_copy(post, get, project, inventory, machine_credential, vault_credential,
|
def test_job_template_copy(post, get, project, inventory, organization, machine_credential, vault_credential,
|
||||||
credential, alice, job_template_with_survey_passwords, admin):
|
credential, alice, job_template_with_survey_passwords, admin):
|
||||||
job_template_with_survey_passwords.project = project
|
job_template_with_survey_passwords.project = project
|
||||||
job_template_with_survey_passwords.inventory = inventory
|
job_template_with_survey_passwords.inventory = inventory
|
||||||
|
job_template_with_survey_passwords.organization = organization
|
||||||
job_template_with_survey_passwords.save()
|
job_template_with_survey_passwords.save()
|
||||||
job_template_with_survey_passwords.credentials.add(credential)
|
job_template_with_survey_passwords.credentials.add(credential)
|
||||||
job_template_with_survey_passwords.credentials.add(machine_credential)
|
job_template_with_survey_passwords.credentials.add(machine_credential)
|
||||||
@@ -22,6 +23,7 @@ def test_job_template_copy(post, get, project, inventory, machine_credential, va
|
|||||||
job_template_with_survey_passwords.admin_role.members.add(alice)
|
job_template_with_survey_passwords.admin_role.members.add(alice)
|
||||||
project.admin_role.members.add(alice)
|
project.admin_role.members.add(alice)
|
||||||
inventory.admin_role.members.add(alice)
|
inventory.admin_role.members.add(alice)
|
||||||
|
organization.job_template_admin_role.members.add(alice)
|
||||||
assert get(
|
assert get(
|
||||||
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
|
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
|
||||||
alice, expect=200
|
alice, expect=200
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate
|
from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate, Organization
|
||||||
from awx.main.models.ha import Instance, InstanceGroup
|
from awx.main.models.ha import Instance, InstanceGroup
|
||||||
from awx.main.tasks import apply_cluster_membership_policies
|
from awx.main.tasks import apply_cluster_membership_policies
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
@@ -253,7 +253,7 @@ def test_inherited_instance_group_membership(instance_group_factory, default_ins
|
|||||||
j.inventory = inventory
|
j.inventory = inventory
|
||||||
ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()])
|
ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()])
|
||||||
ig_inv = instance_group_factory("basicB", [default_instance_group.instances.first()])
|
ig_inv = instance_group_factory("basicB", [default_instance_group.instances.first()])
|
||||||
j.project.organization.instance_groups.add(ig_org)
|
j.organization.instance_groups.add(ig_org)
|
||||||
j.inventory.instance_groups.add(ig_inv)
|
j.inventory.instance_groups.add(ig_inv)
|
||||||
assert ig_org in j.preferred_instance_groups
|
assert ig_org in j.preferred_instance_groups
|
||||||
assert ig_inv in j.preferred_instance_groups
|
assert ig_inv in j.preferred_instance_groups
|
||||||
@@ -320,13 +320,14 @@ class TestInstanceGroupOrdering:
|
|||||||
assert pu.preferred_instance_groups == [ig_tmp, ig_org]
|
assert pu.preferred_instance_groups == [ig_tmp, ig_org]
|
||||||
|
|
||||||
def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group):
|
def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group):
|
||||||
jt = JobTemplate.objects.create(inventory=inventory, project=project)
|
org = Organization.objects.create(name='foo')
|
||||||
job = Job.objects.create(inventory=inventory, job_template=jt, project=project)
|
jt = JobTemplate.objects.create(inventory=inventory, project=project, organization=org)
|
||||||
|
job = Job.objects.create(inventory=inventory, job_template=jt, project=project, organization=org)
|
||||||
assert job.preferred_instance_groups == [default_instance_group]
|
assert job.preferred_instance_groups == [default_instance_group]
|
||||||
ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()])
|
ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()])
|
||||||
ig_inv = instance_group_factory("InvIstGrp", [default_instance_group.instances.first()])
|
ig_inv = instance_group_factory("InvIstGrp", [default_instance_group.instances.first()])
|
||||||
ig_tmp = instance_group_factory("TmpIstGrp", [default_instance_group.instances.first()])
|
ig_tmp = instance_group_factory("TmpIstGrp", [default_instance_group.instances.first()])
|
||||||
project.organization.instance_groups.add(ig_org)
|
jt.organization.instance_groups.add(ig_org)
|
||||||
inventory.instance_groups.add(ig_inv)
|
inventory.instance_groups.add(ig_inv)
|
||||||
assert job.preferred_instance_groups == [ig_inv, ig_org]
|
assert job.preferred_instance_groups == [ig_inv, ig_org]
|
||||||
job.job_template.instance_groups.add(ig_tmp)
|
job.job_template.instance_groups.add(ig_tmp)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
@@ -58,10 +59,25 @@ def test_organization(get, admin_user):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_job_template(get, admin_user):
|
def test_job_template(get, admin_user):
|
||||||
test_jt = JobTemplate.objects.create(name='test_jt')
|
test_org = Organization.objects.create(name='test_org')
|
||||||
|
test_jt = JobTemplate.objects.create(name='test_jt', organization=test_org)
|
||||||
url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk})
|
url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk})
|
||||||
response = get(url, user=admin_user, expect=200)
|
response = get(url, user=admin_user, expect=200)
|
||||||
assert response.data['related']['named_url'].endswith('/test_jt/')
|
assert response.data['related']['named_url'].endswith('/test_jt++test_org/')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_job_template_old_way(get, admin_user, mocker):
|
||||||
|
test_org = Organization.objects.create(name='test_org')
|
||||||
|
test_jt = JobTemplate.objects.create(name='test_jt ♥', organization=test_org)
|
||||||
|
url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk})
|
||||||
|
|
||||||
|
response = get(url, user=admin_user, expect=200)
|
||||||
|
new_url = response.data['related']['named_url']
|
||||||
|
old_url = '/'.join([url.rsplit('/', 2)[0], test_jt.name, ''])
|
||||||
|
|
||||||
|
assert URLModificationMiddleware._convert_named_url(new_url) == url
|
||||||
|
assert URLModificationMiddleware._convert_named_url(old_url) == url
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -221,26 +221,6 @@ def test_project_credential_protection(post, put, project, organization, scm_cre
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
|
||||||
def test_create_project_null_organization(post, organization, admin):
|
|
||||||
post(reverse('api:project_list'), { 'name': 't', 'organization': None}, admin, expect=201)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
|
||||||
def test_create_project_null_organization_xfail(post, organization, org_admin):
|
|
||||||
post(reverse('api:project_list'), { 'name': 't', 'organization': None}, org_admin, expect=403)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
|
||||||
def test_patch_project_null_organization(patch, organization, project, admin):
|
|
||||||
patch(reverse('api:project_detail', kwargs={'pk':project.id,}), { 'name': 't', 'organization': organization.id}, admin, expect=200)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
|
||||||
def test_patch_project_null_organization_xfail(patch, project, org_admin):
|
|
||||||
patch(reverse('api:project_detail', kwargs={'pk':project.id,}), { 'name': 't', 'organization': None}, org_admin, expect=400)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_cannot_schedule_manual_project(manual_project, admin_user, post):
|
def test_cannot_schedule_manual_project(manual_project, admin_user, post):
|
||||||
response = post(
|
response = post(
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ def normal_job(deploy_jobtemplate):
|
|||||||
return Job.objects.create(
|
return Job.objects.create(
|
||||||
job_template=deploy_jobtemplate,
|
job_template=deploy_jobtemplate,
|
||||||
project=deploy_jobtemplate.project,
|
project=deploy_jobtemplate.project,
|
||||||
inventory=deploy_jobtemplate.inventory
|
inventory=deploy_jobtemplate.inventory,
|
||||||
|
organization=deploy_jobtemplate.organization
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ def test_slice_job(slice_job_factory, rando):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestJobRelaunchAccess:
|
class TestJobRelaunchAccess:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def job_no_prompts(self, machine_credential, inventory):
|
def job_no_prompts(self, machine_credential, inventory, organization):
|
||||||
jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory)
|
jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory, organization=organization)
|
||||||
jt.credentials.add(machine_credential)
|
jt.credentials.add(machine_credential)
|
||||||
return jt.create_unified_job()
|
return jt.create_unified_job()
|
||||||
|
|
||||||
@@ -119,6 +119,13 @@ class TestJobRelaunchAccess:
|
|||||||
job_no_prompts.job_template.execute_role.members.add(rando)
|
job_no_prompts.job_template.execute_role.members.add(rando)
|
||||||
assert rando.can_access(Job, 'start', job_no_prompts)
|
assert rando.can_access(Job, 'start', job_no_prompts)
|
||||||
|
|
||||||
|
def test_orphan_relaunch_via_organization(self, job_no_prompts, rando, organization):
|
||||||
|
"JT for job has been deleted, relevant organization roles will allow management"
|
||||||
|
organization.execute_role.members.add(rando)
|
||||||
|
job_no_prompts.job_template.delete()
|
||||||
|
job_no_prompts.job_template = None # Django should do this for us, but it does not
|
||||||
|
assert rando.can_access(Job, 'start', job_no_prompts)
|
||||||
|
|
||||||
def test_no_relaunch_without_prompted_fields_access(self, job_with_prompts, rando):
|
def test_no_relaunch_without_prompted_fields_access(self, job_with_prompts, rando):
|
||||||
"Has JT execute_role but no use_role on inventory & credential - deny relaunch"
|
"Has JT execute_role but no use_role on inventory & credential - deny relaunch"
|
||||||
job_with_prompts.job_template.execute_role.members.add(rando)
|
job_with_prompts.job_template.execute_role.members.add(rando)
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ def test_job_template_access_superuser(check_license, user, deploy_jobtemplate):
|
|||||||
assert access.can_add({})
|
assert access.can_add({})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestImplicitAccess:
|
||||||
|
def test_org_execute(self, jt_linked, rando):
|
||||||
|
assert rando not in jt_linked.execute_role
|
||||||
|
jt_linked.organization.execute_role.members.add(rando)
|
||||||
|
assert rando in jt_linked.execute_role
|
||||||
|
|
||||||
|
def test_org_admin(self, jt_linked, rando):
|
||||||
|
assert rando not in jt_linked.execute_role
|
||||||
|
jt_linked.organization.job_template_admin_role.members.add(rando)
|
||||||
|
assert rando in jt_linked.execute_role
|
||||||
|
|
||||||
|
def test_org_auditor(self, jt_linked, rando):
|
||||||
|
assert rando not in jt_linked.read_role
|
||||||
|
jt_linked.organization.auditor_role.members.add(rando)
|
||||||
|
assert rando in jt_linked.read_role
|
||||||
|
|
||||||
|
def test_deprecated_inventory_read(self, jt_linked, rando):
|
||||||
|
assert rando not in jt_linked.read_role
|
||||||
|
jt_linked.inventory.organization.execute_role.members.add(rando)
|
||||||
|
assert rando in jt_linked.read_role
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_job_template_access_read_level(jt_linked, rando):
|
def test_job_template_access_read_level(jt_linked, rando):
|
||||||
ssh_cred = jt_linked.machine_credential
|
ssh_cred = jt_linked.machine_credential
|
||||||
@@ -45,22 +68,21 @@ def test_job_template_access_read_level(jt_linked, rando):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_job_template_access_use_level(jt_linked, rando):
|
def test_job_template_access_use_level(jt_linked, rando):
|
||||||
ssh_cred = jt_linked.machine_credential
|
|
||||||
vault_cred = jt_linked.vault_credentials[0]
|
|
||||||
|
|
||||||
access = JobTemplateAccess(rando)
|
access = JobTemplateAccess(rando)
|
||||||
jt_linked.project.use_role.members.add(rando)
|
jt_linked.project.use_role.members.add(rando)
|
||||||
jt_linked.inventory.use_role.members.add(rando)
|
jt_linked.inventory.use_role.members.add(rando)
|
||||||
ssh_cred.use_role.members.add(rando)
|
jt_linked.organization.job_template_admin_role.members.add(rando)
|
||||||
vault_cred.use_role.members.add(rando)
|
|
||||||
|
|
||||||
proj_pk = jt_linked.project.pk
|
proj_pk = jt_linked.project.pk
|
||||||
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk))
|
org_pk = jt_linked.organization_id
|
||||||
assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk))
|
|
||||||
assert access.can_add(dict(vault_credential=vault_cred.pk, project=proj_pk))
|
assert access.can_change(jt_linked, {'job_type': 'check', 'project': proj_pk})
|
||||||
|
assert access.can_change(jt_linked, {'job_type': 'check', 'inventory': None})
|
||||||
|
|
||||||
for cred in jt_linked.credentials.all():
|
for cred in jt_linked.credentials.all():
|
||||||
assert not access.can_unattach(jt_linked, cred, 'credentials', {})
|
assert access.can_unattach(jt_linked, cred, 'credentials', {})
|
||||||
|
|
||||||
|
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk, organization=org_pk))
|
||||||
|
assert access.can_add(dict(project=proj_pk, organization=org_pk))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -69,22 +91,21 @@ def test_job_template_access_admin(role_names, jt_linked, rando):
|
|||||||
ssh_cred = jt_linked.machine_credential
|
ssh_cred = jt_linked.machine_credential
|
||||||
|
|
||||||
access = JobTemplateAccess(rando)
|
access = JobTemplateAccess(rando)
|
||||||
# Appoint this user as admin of the organization
|
|
||||||
#jt_linked.inventory.organization.admin_role.members.add(rando)
|
|
||||||
assert not access.can_read(jt_linked)
|
assert not access.can_read(jt_linked)
|
||||||
assert not access.can_delete(jt_linked)
|
assert not access.can_delete(jt_linked)
|
||||||
|
|
||||||
for role_name in role_names:
|
# Appoint this user as admin of the organization
|
||||||
role = getattr(jt_linked.inventory.organization, role_name)
|
jt_linked.organization.admin_role.members.add(rando)
|
||||||
role.members.add(rando)
|
org_pk = jt_linked.organization.id
|
||||||
|
|
||||||
# Assign organization permission in the same way the create view does
|
# Assign organization permission in the same way the create view does
|
||||||
organization = jt_linked.inventory.organization
|
organization = jt_linked.inventory.organization
|
||||||
ssh_cred.admin_role.parents.add(organization.admin_role)
|
ssh_cred.admin_role.parents.add(organization.admin_role)
|
||||||
|
|
||||||
proj_pk = jt_linked.project.pk
|
proj_pk = jt_linked.project.pk
|
||||||
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk))
|
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk, organization=org_pk))
|
||||||
assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk))
|
assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk, organization=org_pk))
|
||||||
|
|
||||||
for cred in jt_linked.credentials.all():
|
for cred in jt_linked.credentials.all():
|
||||||
assert access.can_unattach(jt_linked, cred, 'credentials', {})
|
assert access.can_unattach(jt_linked, cred, 'credentials', {})
|
||||||
@@ -148,26 +169,46 @@ class TestOrphanJobTemplate:
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.job_permissions
|
@pytest.mark.job_permissions
|
||||||
def test_job_template_creator_access(project, rando, post):
|
def test_job_template_creator_access(project, organization, rando, post):
|
||||||
|
project.use_role.members.add(rando)
|
||||||
|
organization.job_template_admin_role.members.add(rando)
|
||||||
|
response = post(url=reverse('api:job_template_list'), data=dict(
|
||||||
|
name='newly-created-jt',
|
||||||
|
ask_inventory_on_launch=True,
|
||||||
|
project=project.pk,
|
||||||
|
organization=organization.id,
|
||||||
|
playbook='helloworld.yml'
|
||||||
|
), user=rando, expect=201)
|
||||||
|
|
||||||
project.admin_role.members.add(rando)
|
|
||||||
with mock.patch(
|
|
||||||
'awx.main.models.projects.ProjectOptions.playbooks',
|
|
||||||
new_callable=mock.PropertyMock(return_value=['helloworld.yml'])):
|
|
||||||
response = post(reverse('api:job_template_list'), dict(
|
|
||||||
name='newly-created-jt',
|
|
||||||
job_type='run',
|
|
||||||
ask_inventory_on_launch=True,
|
|
||||||
ask_credential_on_launch=True,
|
|
||||||
project=project.pk,
|
|
||||||
playbook='helloworld.yml'
|
|
||||||
), rando)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
jt_pk = response.data['id']
|
jt_pk = response.data['id']
|
||||||
jt_obj = JobTemplate.objects.get(pk=jt_pk)
|
jt_obj = JobTemplate.objects.get(pk=jt_pk)
|
||||||
# Creating a JT should place the creator in the admin role
|
# Creating a JT should place the creator in the admin role
|
||||||
assert rando in jt_obj.admin_role
|
assert rando in jt_obj.admin_role.members.all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.job_permissions
|
||||||
|
@pytest.mark.parametrize('lacking', ['project', 'inventory', 'organization'])
|
||||||
|
def test_job_template_insufficient_creator_permissions(lacking, project, inventory, organization, rando, post):
|
||||||
|
if lacking != 'project':
|
||||||
|
project.use_role.members.add(rando)
|
||||||
|
else:
|
||||||
|
project.read_role.members.add(rando)
|
||||||
|
if lacking != 'organization':
|
||||||
|
organization.job_template_admin_role.members.add(rando)
|
||||||
|
else:
|
||||||
|
organization.member_role.members.add(rando)
|
||||||
|
if lacking != 'inventory':
|
||||||
|
inventory.use_role.members.add(rando)
|
||||||
|
else:
|
||||||
|
inventory.read_role.members.add(rando)
|
||||||
|
post(url=reverse('api:job_template_list'), data=dict(
|
||||||
|
name='newly-created-jt',
|
||||||
|
inventory=inventory.id,
|
||||||
|
project=project.pk,
|
||||||
|
organization=organization.id,
|
||||||
|
playbook='helloworld.yml'
|
||||||
|
), user=rando, expect=403)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -239,7 +280,7 @@ class TestJobTemplateSchedules:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_jt_org_ownership_change(user, jt_linked):
|
def test_jt_org_ownership_change(user, jt_linked):
|
||||||
admin1 = user('admin1')
|
admin1 = user('admin1')
|
||||||
org1 = jt_linked.project.organization
|
org1 = jt_linked.organization
|
||||||
org1.admin_role.members.add(admin1)
|
org1.admin_role.members.add(admin1)
|
||||||
a1_access = JobTemplateAccess(admin1)
|
a1_access = JobTemplateAccess(admin1)
|
||||||
|
|
||||||
@@ -254,10 +295,8 @@ def test_jt_org_ownership_change(user, jt_linked):
|
|||||||
assert not a2_access.can_read(jt_linked)
|
assert not a2_access.can_read(jt_linked)
|
||||||
|
|
||||||
|
|
||||||
jt_linked.project.organization = org2
|
jt_linked.organization = org2
|
||||||
jt_linked.project.save()
|
jt_linked.save()
|
||||||
jt_linked.inventory.organization = org2
|
|
||||||
jt_linked.inventory.save()
|
|
||||||
|
|
||||||
assert a2_access.can_read(jt_linked)
|
assert a2_access.can_read(jt_linked)
|
||||||
assert not a1_access.can_read(jt_linked)
|
assert not a1_access.can_read(jt_linked)
|
||||||
|
|||||||
64
awx/main/tests/functional/test_rbac_migration.py
Normal file
64
awx/main/tests/functional/test_rbac_migration.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from awx.main.migrations import _rbac as rbac
|
||||||
|
from awx.main.models import (
|
||||||
|
UnifiedJobTemplate,
|
||||||
|
InventorySource, Inventory,
|
||||||
|
JobTemplate, Project,
|
||||||
|
Organization
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_implied_organization_subquery_inventory():
|
||||||
|
orgs = []
|
||||||
|
for i in range(3):
|
||||||
|
orgs.append(Organization.objects.create(name='foo{}'.format(i)))
|
||||||
|
orgs.append(orgs[0])
|
||||||
|
for i in range(4):
|
||||||
|
org = orgs[i]
|
||||||
|
if i == 2:
|
||||||
|
inventory = Inventory.objects.create(name='foo{}'.format(i))
|
||||||
|
else:
|
||||||
|
inventory = Inventory.objects.create(name='foo{}'.format(i), organization=org)
|
||||||
|
inv_src = InventorySource.objects.create(name='foo{}'.format(i), inventory=inventory)
|
||||||
|
sources = UnifiedJobTemplate.objects.annotate(
|
||||||
|
test_field=rbac.implicit_org_subquery(UnifiedJobTemplate, InventorySource)
|
||||||
|
)
|
||||||
|
for inv_src in sources:
|
||||||
|
assert inv_src.test_field == inv_src.inventory.organization_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_implied_organization_subquery_job_template():
|
||||||
|
jts = []
|
||||||
|
for i in range(5):
|
||||||
|
if i <= 3:
|
||||||
|
org = Organization.objects.create(name='foo{}'.format(i))
|
||||||
|
else:
|
||||||
|
org = None
|
||||||
|
if i <= 4:
|
||||||
|
proj = Project.objects.create(
|
||||||
|
name='foo{}'.format(i),
|
||||||
|
organization=org
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
proj = None
|
||||||
|
jts.append(JobTemplate.objects.create(
|
||||||
|
name='foo{}'.format(i),
|
||||||
|
project=proj
|
||||||
|
))
|
||||||
|
# test case of sharing same org
|
||||||
|
jts[2].project.organization = jts[3].project.organization
|
||||||
|
jts[2].save()
|
||||||
|
ujts = UnifiedJobTemplate.objects.annotate(
|
||||||
|
test_field=rbac.implicit_org_subquery(UnifiedJobTemplate, JobTemplate)
|
||||||
|
)
|
||||||
|
for jt in ujts:
|
||||||
|
if not isinstance(jt, JobTemplate): # some are projects
|
||||||
|
assert jt.test_field is None
|
||||||
|
else:
|
||||||
|
if jt.project is None:
|
||||||
|
assert jt.test_field is None
|
||||||
|
else:
|
||||||
|
assert jt.test_field == jt.project.organization_id
|
||||||
@@ -62,10 +62,11 @@ class TestWorkflowJobTemplateAccess:
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestWorkflowJobTemplateNodeAccess:
|
class TestWorkflowJobTemplateNodeAccess:
|
||||||
|
|
||||||
def test_no_jt_access_to_edit(self, wfjt_node, org_admin):
|
def test_no_jt_access_to_edit(self, wfjt_node, rando):
|
||||||
# without access to the related job template, admin to the WFJT can
|
# without access to the related job template, admin to the WFJT can
|
||||||
# not change the prompted parameters
|
# not change the prompted parameters
|
||||||
access = WorkflowJobTemplateNodeAccess(org_admin)
|
wfjt_node.workflow_job_template.admin_role.members.add(rando)
|
||||||
|
access = WorkflowJobTemplateNodeAccess(rando)
|
||||||
assert not access.can_change(wfjt_node, {'job_type': 'check'})
|
assert not access.can_change(wfjt_node, {'job_type': 'check'})
|
||||||
|
|
||||||
def test_node_edit_allowed(self, wfjt_node, org_admin):
|
def test_node_edit_allowed(self, wfjt_node, org_admin):
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def job_template(mocker):
|
|||||||
mock_jt.host_config_key = '9283920492'
|
mock_jt.host_config_key = '9283920492'
|
||||||
mock_jt.validation_errors = mock_JT_resource_data
|
mock_jt.validation_errors = mock_JT_resource_data
|
||||||
mock_jt.webhook_service = ''
|
mock_jt.webhook_service = ''
|
||||||
|
mock_jt.organization_id = None
|
||||||
return mock_jt
|
return mock_jt
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ def test_cancel_job_explanation(unified_job):
|
|||||||
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'status', 'job_explanation'])
|
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'status', 'job_explanation'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_organization_copy_to_jobs():
|
||||||
|
'''
|
||||||
|
All unified job types should infer their organization from their template organization
|
||||||
|
'''
|
||||||
|
for cls in UnifiedJobTemplate.__subclasses__():
|
||||||
|
assert 'organization' in cls._get_unified_job_field_names()
|
||||||
|
|
||||||
|
|
||||||
def test_log_representation():
|
def test_log_representation():
|
||||||
'''
|
'''
|
||||||
Common representation used inside of log messages
|
Common representation used inside of log messages
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ def job_template_with_ids(job_template_factory):
|
|||||||
'testJT', project=proj, inventory=inv, credential=credential,
|
'testJT', project=proj, inventory=inv, credential=credential,
|
||||||
cloud_credential=cloud_cred, network_credential=net_cred,
|
cloud_credential=cloud_cred, network_credential=net_cred,
|
||||||
persisted=False)
|
persisted=False)
|
||||||
return jt_objects.job_template
|
jt = jt_objects.job_template
|
||||||
|
jt.organization = Organization(id=1, pk=1, name='fooOrg')
|
||||||
|
return jt
|
||||||
|
|
||||||
|
|
||||||
def test_superuser(mocker):
|
def test_superuser(mocker):
|
||||||
@@ -180,21 +182,24 @@ def test_jt_existing_values_are_nonsensitive(job_template_with_ids, user_unit):
|
|||||||
def test_change_jt_sensitive_data(job_template_with_ids, mocker, user_unit):
|
def test_change_jt_sensitive_data(job_template_with_ids, mocker, user_unit):
|
||||||
"""Assure that can_add is called with all ForeignKeys."""
|
"""Assure that can_add is called with all ForeignKeys."""
|
||||||
|
|
||||||
job_template_with_ids.admin_role = Role()
|
class RoleReturnsTrue(Role):
|
||||||
|
def __contains__(self, accessor):
|
||||||
|
return True
|
||||||
|
|
||||||
|
job_template_with_ids.admin_role = RoleReturnsTrue()
|
||||||
|
job_template_with_ids.organization.job_template_admin_role = RoleReturnsTrue()
|
||||||
|
|
||||||
|
inv2 = Inventory()
|
||||||
|
inv2.use_role = RoleReturnsTrue()
|
||||||
|
data = {'inventory': inv2}
|
||||||
|
|
||||||
data = {'inventory': job_template_with_ids.inventory.id + 1}
|
|
||||||
access = JobTemplateAccess(user_unit)
|
access = JobTemplateAccess(user_unit)
|
||||||
|
|
||||||
mock_add = mock.MagicMock(return_value=False)
|
assert not access.changes_are_non_sensitive(job_template_with_ids, data)
|
||||||
with mock.patch('awx.main.models.rbac.Role.__contains__', return_value=True):
|
|
||||||
with mocker.patch('awx.main.access.JobTemplateAccess.can_add', mock_add):
|
|
||||||
with mocker.patch('awx.main.access.JobTemplateAccess.can_read', return_value=True):
|
|
||||||
assert not access.can_change(job_template_with_ids, data)
|
|
||||||
|
|
||||||
mock_add.assert_called_once_with({
|
job_template_with_ids.inventory.use_role = RoleReturnsTrue()
|
||||||
'inventory': data['inventory'],
|
job_template_with_ids.project.use_role = RoleReturnsTrue()
|
||||||
'project': job_template_with_ids.project.id
|
assert access.can_change(job_template_with_ids, data)
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def mock_raise_none(self, add_host=False, feature=None, check_expiration=True):
|
def mock_raise_none(self, add_host=False, feature=None, check_expiration=True):
|
||||||
|
|||||||
@@ -2,10 +2,17 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.apps import apps
|
||||||
|
from django.db.models.fields.related import ForeignKey
|
||||||
|
from django.db.models.fields.related_descriptors import (
|
||||||
|
ReverseManyToOneDescriptor,
|
||||||
|
ForwardManyToOneDescriptor
|
||||||
|
)
|
||||||
|
|
||||||
from rest_framework.serializers import ValidationError as DRFValidationError
|
from rest_framework.serializers import ValidationError as DRFValidationError
|
||||||
|
|
||||||
from awx.main.models import Credential, CredentialType, BaseModel
|
from awx.main.models import Credential, CredentialType, BaseModel
|
||||||
from awx.main.fields import JSONSchemaField
|
from awx.main.fields import JSONSchemaField, ImplicitRoleField, ImplicitRoleDescriptor
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('schema, given, message', [
|
@pytest.mark.parametrize('schema, given, message', [
|
||||||
@@ -194,3 +201,57 @@ def test_credential_creation_validation_failure(inputs):
|
|||||||
with pytest.raises(Exception) as e:
|
with pytest.raises(Exception) as e:
|
||||||
field.validate(inputs, cred)
|
field.validate(inputs, cred)
|
||||||
assert e.type in (ValidationError, DRFValidationError)
|
assert e.type in (ValidationError, DRFValidationError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_implicit_role_field_parents():
|
||||||
|
"""This assures that every ImplicitRoleField only references parents
|
||||||
|
which are relationships that actually exist
|
||||||
|
"""
|
||||||
|
app_models = apps.get_app_config('main').get_models()
|
||||||
|
for cls in app_models:
|
||||||
|
for field in cls._meta.get_fields():
|
||||||
|
if not isinstance(field, ImplicitRoleField):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not field.parent_role:
|
||||||
|
continue
|
||||||
|
|
||||||
|
field_names = field.parent_role
|
||||||
|
if type(field_names) is not list:
|
||||||
|
field_names = [field_names]
|
||||||
|
|
||||||
|
for field_name in field_names:
|
||||||
|
# this type of specification appears to have been considered
|
||||||
|
# at some point, but does not exist in the app and would
|
||||||
|
# need support and tests built out for it
|
||||||
|
assert not isinstance(field_name, tuple)
|
||||||
|
# also used to be a thing before py3 upgrade
|
||||||
|
assert not isinstance(field_name, bytes)
|
||||||
|
# this is always coherent
|
||||||
|
if field_name.startswith('singleton:'):
|
||||||
|
continue
|
||||||
|
# separate out parent role syntax
|
||||||
|
field_name, sep, field_attr = field_name.partition('.')
|
||||||
|
# now make primary assertion, that specified paths exist
|
||||||
|
assert hasattr(cls, field_name)
|
||||||
|
|
||||||
|
# inspect in greater depth
|
||||||
|
second_field = cls._meta.get_field(field_name)
|
||||||
|
second_field_descriptor = getattr(cls, field_name)
|
||||||
|
# all supported linkage types
|
||||||
|
assert isinstance(second_field_descriptor, (
|
||||||
|
ReverseManyToOneDescriptor, # not currently used
|
||||||
|
ImplicitRoleDescriptor,
|
||||||
|
ForwardManyToOneDescriptor
|
||||||
|
))
|
||||||
|
# only these links are supported
|
||||||
|
if field_attr:
|
||||||
|
if isinstance(second_field_descriptor, ReverseManyToOneDescriptor):
|
||||||
|
assert type(second_field) is ForeignKey
|
||||||
|
rel_model = cls._meta.get_field(field_name).related_model
|
||||||
|
third_field = getattr(rel_model, field_attr)
|
||||||
|
# expecting for related_model.foo_role, test role field type
|
||||||
|
assert isinstance(third_field, ImplicitRoleDescriptor)
|
||||||
|
else:
|
||||||
|
# expecting simple format of foo_role
|
||||||
|
assert type(second_field) is ImplicitRoleField
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ function(NotificationsList, i18n) {
|
|||||||
column: 1,
|
column: 1,
|
||||||
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
|
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
|
||||||
},
|
},
|
||||||
|
organization: {
|
||||||
|
label: i18n._('Organization'),
|
||||||
|
type: 'lookup',
|
||||||
|
list: 'OrganizationList',
|
||||||
|
sourceModel: 'organization',
|
||||||
|
basePath: 'organizations',
|
||||||
|
sourceField: 'name',
|
||||||
|
dataTitle: i18n._('Organization'),
|
||||||
|
dataContainer: 'body',
|
||||||
|
dataPlacement: 'right',
|
||||||
|
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)',
|
||||||
|
awLookupWhen: '(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
|
||||||
|
},
|
||||||
job_type: {
|
job_type: {
|
||||||
label: i18n._('Job Type'),
|
label: i18n._('Job Type'),
|
||||||
type: 'select',
|
type: 'select',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from awxkit.utils import (
|
|||||||
suppress,
|
suppress,
|
||||||
update_payload,
|
update_payload,
|
||||||
PseudoNamespace)
|
PseudoNamespace)
|
||||||
from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate
|
from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate, Organization
|
||||||
from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter
|
from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter
|
||||||
from awxkit.api.resources import resources
|
from awxkit.api.resources import resources
|
||||||
import awxkit.exceptions as exc
|
import awxkit.exceptions as exc
|
||||||
@@ -23,7 +23,7 @@ class JobTemplate(
|
|||||||
HasSurvey,
|
HasSurvey,
|
||||||
UnifiedJobTemplate):
|
UnifiedJobTemplate):
|
||||||
|
|
||||||
optional_dependencies = [Inventory, Credential, Project]
|
optional_dependencies = [Organization, Inventory, Credential, Project]
|
||||||
|
|
||||||
def launch(self, payload={}):
|
def launch(self, payload={}):
|
||||||
"""Launch the job_template using related->launch endpoint."""
|
"""Launch the job_template using related->launch endpoint."""
|
||||||
@@ -129,6 +129,7 @@ class JobTemplate(
|
|||||||
playbook='ping.yml',
|
playbook='ping.yml',
|
||||||
credential=Credential,
|
credential=Credential,
|
||||||
inventory=Inventory,
|
inventory=Inventory,
|
||||||
|
organization=Organization,
|
||||||
project=None,
|
project=None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
if not project:
|
if not project:
|
||||||
@@ -148,12 +149,18 @@ class JobTemplate(
|
|||||||
project = self.ds.project if project else None
|
project = self.ds.project if project else None
|
||||||
inventory = self.ds.inventory if inventory else None
|
inventory = self.ds.inventory if inventory else None
|
||||||
credential = self.ds.credential if credential else None
|
credential = self.ds.credential if credential else None
|
||||||
|
# if the created project has an organization, and the parameters
|
||||||
|
# specified no organization, then borrow the one from the project
|
||||||
|
if hasattr(project.ds, 'organization') and organization is Organization:
|
||||||
|
self.ds.organization = project.ds.organization
|
||||||
|
organization = self.ds.organization
|
||||||
|
|
||||||
payload = self.payload(
|
payload = self.payload(
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
job_type=job_type,
|
job_type=job_type,
|
||||||
playbook=playbook,
|
playbook=playbook,
|
||||||
|
organization=organization,
|
||||||
credential=credential,
|
credential=credential,
|
||||||
inventory=inventory,
|
inventory=inventory,
|
||||||
project=project,
|
project=project,
|
||||||
@@ -169,11 +176,12 @@ class JobTemplate(
|
|||||||
playbook='ping.yml',
|
playbook='ping.yml',
|
||||||
credential=Credential,
|
credential=Credential,
|
||||||
inventory=Inventory,
|
inventory=Inventory,
|
||||||
|
organization=Organization,
|
||||||
project=None,
|
project=None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
payload, credential = self.create_payload(name=name, description=description, job_type=job_type,
|
payload, credential = self.create_payload(name=name, description=description, job_type=job_type,
|
||||||
playbook=playbook, credential=credential, inventory=inventory,
|
playbook=playbook, credential=credential, inventory=inventory,
|
||||||
project=project, **kwargs)
|
project=project, organization=organization, **kwargs)
|
||||||
ret = self.update_identity(
|
ret = self.update_identity(
|
||||||
JobTemplates(
|
JobTemplates(
|
||||||
self.connection).post(payload))
|
self.connection).post(payload))
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from . import page
|
|||||||
|
|
||||||
class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate):
|
class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate):
|
||||||
|
|
||||||
optional_dependencies = [Organization]
|
dependencies = [Organization]
|
||||||
|
|
||||||
def launch(self, payload={}):
|
def launch(self, payload={}):
|
||||||
"""Launch using related->launch endpoint."""
|
"""Launch using related->launch endpoint."""
|
||||||
@@ -71,14 +71,14 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
|
|||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def create_payload(self, name='', description='', organization=None, **kwargs):
|
def create_payload(self, name='', description='', organization=Organization, **kwargs):
|
||||||
self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
|
self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
|
||||||
organization = self.ds.organization if organization else None
|
organization = self.ds.organization if organization else None
|
||||||
payload = self.payload(name=name, description=description, organization=organization, **kwargs)
|
payload = self.payload(name=name, description=description, organization=organization, **kwargs)
|
||||||
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
|
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def create(self, name='', description='', organization=None, **kwargs):
|
def create(self, name='', description='', organization=Organization, **kwargs):
|
||||||
payload = self.create_payload(name=name, description=description, organization=organization, **kwargs)
|
payload = self.create_payload(name=name, description=description, organization=organization, **kwargs)
|
||||||
return self.update_identity(WorkflowJobTemplates(self.connection).post(payload))
|
return self.update_identity(WorkflowJobTemplates(self.connection).post(payload))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user