mirror of
https://github.com/ansible/awx.git
synced 2026-03-27 22:05:07 -02:30
Merge pull request #1536 from chrismeyersfsu/fix-protect_instance_groups
prevent instance group delete if running jobs
This commit is contained in:
18
awx/api/exceptions.py
Normal file
18
awx/api/exceptions.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Copyright (c) 2018 Ansible by Red Hat
|
||||||
|
# All Rights Reserved.
|
||||||
|
|
||||||
|
# Django
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
# Django REST Framework
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveJobConflict(ValidationError):
|
||||||
|
status_code = 409
|
||||||
|
|
||||||
|
def __init__(self, active_jobs):
|
||||||
|
super(ActiveJobConflict, self).__init__({
|
||||||
|
"error": _("Resource is being used by running jobs."),
|
||||||
|
"active_jobs": active_jobs
|
||||||
|
})
|
||||||
@@ -84,6 +84,7 @@ from awx.api.serializers import * # noqa
|
|||||||
from awx.api.metadata import RoleMetadata, JobTypeMetadata
|
from awx.api.metadata import RoleMetadata, JobTypeMetadata
|
||||||
from awx.main.models.unified_jobs import ACTIVE_STATES
|
from awx.main.models.unified_jobs import ACTIVE_STATES
|
||||||
from awx.main.scheduler.tasks import run_job_complete
|
from awx.main.scheduler.tasks import run_job_complete
|
||||||
|
from awx.api.exceptions import ActiveJobConflict
|
||||||
|
|
||||||
logger = logging.getLogger('awx.api.views')
|
logger = logging.getLogger('awx.api.views')
|
||||||
|
|
||||||
@@ -194,6 +195,14 @@ class InstanceGroupMembershipMixin(object):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class RelatedJobsPreventDeleteMixin(object):
|
||||||
|
def perform_destroy(self, obj):
|
||||||
|
active_jobs = obj.get_active_jobs()
|
||||||
|
if len(active_jobs) > 0:
|
||||||
|
raise ActiveJobConflict(active_jobs)
|
||||||
|
return super(RelatedJobsPreventDeleteMixin, self).perform_destroy(obj)
|
||||||
|
|
||||||
|
|
||||||
class ApiRootView(APIView):
|
class ApiRootView(APIView):
|
||||||
|
|
||||||
permission_classes = (AllowAny,)
|
permission_classes = (AllowAny,)
|
||||||
@@ -637,7 +646,7 @@ class InstanceGroupList(ListCreateAPIView):
|
|||||||
serializer_class = InstanceGroupSerializer
|
serializer_class = InstanceGroupSerializer
|
||||||
|
|
||||||
|
|
||||||
class InstanceGroupDetail(RetrieveUpdateDestroyAPIView):
|
class InstanceGroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
view_name = _("Instance Group Detail")
|
view_name = _("Instance Group Detail")
|
||||||
model = InstanceGroup
|
model = InstanceGroup
|
||||||
@@ -922,7 +931,7 @@ class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
|
|||||||
return super(OrganizationList, self).create(request, *args, **kwargs)
|
return super(OrganizationList, self).create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationDetail(RetrieveUpdateDestroyAPIView):
|
class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = Organization
|
model = Organization
|
||||||
serializer_class = OrganizationSerializer
|
serializer_class = OrganizationSerializer
|
||||||
@@ -1230,20 +1239,11 @@ class ProjectList(ListCreateAPIView):
|
|||||||
return projects_qs
|
return projects_qs
|
||||||
|
|
||||||
|
|
||||||
class ProjectDetail(RetrieveUpdateDestroyAPIView):
|
class ProjectDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = Project
|
model = Project
|
||||||
serializer_class = ProjectSerializer
|
serializer_class = ProjectSerializer
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
|
||||||
obj = self.get_object()
|
|
||||||
can_delete = request.user.can_access(Project, 'delete', obj)
|
|
||||||
if not can_delete:
|
|
||||||
raise PermissionDenied(_("Cannot delete project."))
|
|
||||||
for pu in obj.project_updates.filter(status__in=['new', 'pending', 'waiting', 'running']):
|
|
||||||
pu.cancel()
|
|
||||||
return super(ProjectDetail, self).destroy(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectPlaybooks(RetrieveAPIView):
|
class ProjectPlaybooks(RetrieveAPIView):
|
||||||
|
|
||||||
@@ -2038,7 +2038,7 @@ class ControlledByScmMixin(object):
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
class InventoryDetail(ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = Inventory
|
model = Inventory
|
||||||
serializer_class = InventoryDetailSerializer
|
serializer_class = InventoryDetailSerializer
|
||||||
@@ -2493,7 +2493,7 @@ class GroupActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
|
|||||||
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
|
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
|
||||||
|
|
||||||
|
|
||||||
class GroupDetail(ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
class GroupDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = Group
|
model = Group
|
||||||
serializer_class = GroupSerializer
|
serializer_class = GroupSerializer
|
||||||
@@ -2693,20 +2693,11 @@ class InventorySourceList(ListCreateAPIView):
|
|||||||
return methods
|
return methods
|
||||||
|
|
||||||
|
|
||||||
class InventorySourceDetail(RetrieveUpdateDestroyAPIView):
|
class InventorySourceDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = InventorySource
|
model = InventorySource
|
||||||
serializer_class = InventorySourceSerializer
|
serializer_class = InventorySourceSerializer
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
|
||||||
obj = self.get_object()
|
|
||||||
can_delete = request.user.can_access(InventorySource, 'delete', obj)
|
|
||||||
if not can_delete:
|
|
||||||
raise PermissionDenied(_("Cannot delete inventory source."))
|
|
||||||
for pu in obj.inventory_updates.filter(status__in=['new', 'pending', 'waiting', 'running']):
|
|
||||||
pu.cancel()
|
|
||||||
return super(InventorySourceDetail, self).destroy(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class InventorySourceSchedulesList(SubListCreateAPIView):
|
class InventorySourceSchedulesList(SubListCreateAPIView):
|
||||||
|
|
||||||
@@ -2881,7 +2872,7 @@ class JobTemplateList(ListCreateAPIView):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
class JobTemplateDetail(RetrieveUpdateDestroyAPIView):
|
class JobTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = JobTemplate
|
model = JobTemplate
|
||||||
metadata_class = JobTypeMetadata
|
metadata_class = JobTypeMetadata
|
||||||
@@ -3628,7 +3619,7 @@ class WorkflowJobTemplateList(WorkflowsEnforcementMixin, ListCreateAPIView):
|
|||||||
always_allow_superuser = False
|
always_allow_superuser = False
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobTemplateDetail(WorkflowsEnforcementMixin, RetrieveUpdateDestroyAPIView):
|
class WorkflowJobTemplateDetail(RelatedJobsPreventDeleteMixin, WorkflowsEnforcementMixin, RetrieveUpdateDestroyAPIView):
|
||||||
|
|
||||||
model = WorkflowJobTemplate
|
model = WorkflowJobTemplate
|
||||||
serializer_class = WorkflowJobTemplateSerializer
|
serializer_class = WorkflowJobTemplateSerializer
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
# Django REST Framework
|
# Django REST Framework
|
||||||
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError
|
from rest_framework.exceptions import ParseError, PermissionDenied
|
||||||
|
|
||||||
# Django OAuth Toolkit
|
# Django OAuth Toolkit
|
||||||
from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken
|
from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken
|
||||||
@@ -28,14 +28,13 @@ from awx.main.utils import (
|
|||||||
get_licenser,
|
get_licenser,
|
||||||
)
|
)
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
from awx.main.models.unified_jobs import ACTIVE_STATES
|
|
||||||
from awx.main.models.mixins import ResourceMixin
|
from awx.main.models.mixins import ResourceMixin
|
||||||
|
|
||||||
from awx.conf.license import LicenseForbids, feature_enabled
|
from awx.conf.license import LicenseForbids, feature_enabled
|
||||||
|
|
||||||
__all__ = ['get_user_queryset', 'check_user_access', 'check_user_access_with_errors',
|
__all__ = ['get_user_queryset', 'check_user_access', 'check_user_access_with_errors',
|
||||||
'user_accessible_objects', 'consumer_access',
|
'user_accessible_objects', 'consumer_access',
|
||||||
'user_admin_role', 'ActiveJobConflict',]
|
'user_admin_role',]
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.access')
|
logger = logging.getLogger('awx.main.access')
|
||||||
|
|
||||||
@@ -75,16 +74,6 @@ def get_object_from_data(field, Model, data, obj=None):
|
|||||||
raise ParseError(_("Bad data found in related field %s." % field))
|
raise ParseError(_("Bad data found in related field %s." % field))
|
||||||
|
|
||||||
|
|
||||||
class ActiveJobConflict(ValidationError):
|
|
||||||
status_code = 409
|
|
||||||
|
|
||||||
def __init__(self, active_jobs):
|
|
||||||
super(ActiveJobConflict, self).__init__({
|
|
||||||
"conflict": _("Resource is being used by running jobs."),
|
|
||||||
"active_jobs": active_jobs
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def register_access(model_class, access_class):
|
def register_access(model_class, access_class):
|
||||||
access_registry[model_class] = access_class
|
access_registry[model_class] = access_class
|
||||||
|
|
||||||
@@ -473,9 +462,6 @@ class InstanceGroupAccess(BaseAccess):
|
|||||||
def can_change(self, obj, data):
|
def can_change(self, obj, data):
|
||||||
return self.user.is_superuser
|
return self.user.is_superuser
|
||||||
|
|
||||||
def can_delete(self, obj):
|
|
||||||
return self.user.is_superuser
|
|
||||||
|
|
||||||
|
|
||||||
class UserAccess(BaseAccess):
|
class UserAccess(BaseAccess):
|
||||||
'''
|
'''
|
||||||
@@ -658,15 +644,6 @@ class OrganizationAccess(BaseAccess):
|
|||||||
is_change_possible = self.can_change(obj, None)
|
is_change_possible = self.can_change(obj, None)
|
||||||
if not is_change_possible:
|
if not is_change_possible:
|
||||||
return False
|
return False
|
||||||
active_jobs = []
|
|
||||||
active_jobs.extend([dict(type="job", id=o.id)
|
|
||||||
for o in Job.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)])
|
|
||||||
active_jobs.extend([dict(type="project_update", id=o.id)
|
|
||||||
for o in ProjectUpdate.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)])
|
|
||||||
active_jobs.extend([dict(type="inventory_update", id=o.id)
|
|
||||||
for o in InventoryUpdate.objects.filter(inventory_source__inventory__organization=obj, status__in=ACTIVE_STATES)])
|
|
||||||
if len(active_jobs) > 0:
|
|
||||||
raise ActiveJobConflict(active_jobs)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def can_attach(self, obj, sub_obj, relationship, *args, **kwargs):
|
def can_attach(self, obj, sub_obj, relationship, *args, **kwargs):
|
||||||
@@ -749,19 +726,7 @@ class InventoryAccess(BaseAccess):
|
|||||||
return self.user in obj.update_role
|
return self.user in obj.update_role
|
||||||
|
|
||||||
def can_delete(self, obj):
|
def can_delete(self, obj):
|
||||||
is_can_admin = self.can_admin(obj, None)
|
return self.can_admin(obj, None)
|
||||||
if not is_can_admin:
|
|
||||||
return False
|
|
||||||
active_jobs = []
|
|
||||||
active_jobs.extend([dict(type="job", id=o.id)
|
|
||||||
for o in Job.objects.filter(inventory=obj, status__in=ACTIVE_STATES)])
|
|
||||||
active_jobs.extend([dict(type="inventory_update", id=o.id)
|
|
||||||
for o in InventoryUpdate.objects.filter(inventory_source__inventory=obj, status__in=ACTIVE_STATES)])
|
|
||||||
active_jobs.extend([dict(type="ad_hoc_command", id=o.id)
|
|
||||||
for o in AdHocCommand.objects.filter(inventory=obj, status__in=ACTIVE_STATES)])
|
|
||||||
if len(active_jobs) > 0:
|
|
||||||
raise ActiveJobConflict(active_jobs)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def can_run_ad_hoc_commands(self, obj):
|
def can_run_ad_hoc_commands(self, obj):
|
||||||
return self.user in obj.adhoc_role
|
return self.user in obj.adhoc_role
|
||||||
@@ -878,15 +843,7 @@ class GroupAccess(BaseAccess):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_delete(self, obj):
|
def can_delete(self, obj):
|
||||||
is_delete_allowed = bool(obj and self.user in obj.inventory.admin_role)
|
return bool(obj and self.user in obj.inventory.admin_role)
|
||||||
if not is_delete_allowed:
|
|
||||||
return False
|
|
||||||
active_jobs = []
|
|
||||||
active_jobs.extend([dict(type="inventory_update", id=o.id)
|
|
||||||
for o in InventoryUpdate.objects.filter(inventory_source__in=obj.inventory_sources.all(), status__in=ACTIVE_STATES)])
|
|
||||||
if len(active_jobs) > 0:
|
|
||||||
raise ActiveJobConflict(active_jobs)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def can_start(self, obj, validate_license=True):
|
def can_start(self, obj, validate_license=True):
|
||||||
# TODO: Delete for 3.3, only used by v1 serializer
|
# TODO: Delete for 3.3, only used by v1 serializer
|
||||||
@@ -935,9 +892,6 @@ class InventorySourceAccess(BaseAccess):
|
|||||||
if not self.user.is_superuser and \
|
if not self.user.is_superuser and \
|
||||||
not (obj and obj.inventory and self.user.can_access(Inventory, 'admin', obj.inventory, None)):
|
not (obj and obj.inventory and self.user.can_access(Inventory, 'admin', obj.inventory, None)):
|
||||||
return False
|
return False
|
||||||
active_jobs_qs = InventoryUpdate.objects.filter(inventory_source=obj, status__in=ACTIVE_STATES)
|
|
||||||
if active_jobs_qs.exists():
|
|
||||||
raise ActiveJobConflict([dict(type="inventory_update", id=o.id) for o in active_jobs_qs.all()])
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@check_superuser
|
@check_superuser
|
||||||
@@ -1199,23 +1153,13 @@ class ProjectAccess(BaseAccess):
|
|||||||
return False
|
return False
|
||||||
return self.user in obj.admin_role
|
return self.user in obj.admin_role
|
||||||
|
|
||||||
def can_delete(self, obj):
|
|
||||||
is_change_allowed = self.can_change(obj, None)
|
|
||||||
if not is_change_allowed:
|
|
||||||
return False
|
|
||||||
active_jobs = []
|
|
||||||
active_jobs.extend([dict(type="job", id=o.id)
|
|
||||||
for o in Job.objects.filter(project=obj, status__in=ACTIVE_STATES)])
|
|
||||||
active_jobs.extend([dict(type="project_update", id=o.id)
|
|
||||||
for o in ProjectUpdate.objects.filter(project=obj, status__in=ACTIVE_STATES)])
|
|
||||||
if len(active_jobs) > 0:
|
|
||||||
raise ActiveJobConflict(active_jobs)
|
|
||||||
return True
|
|
||||||
|
|
||||||
@check_superuser
|
@check_superuser
|
||||||
def can_start(self, obj, validate_license=True):
|
def can_start(self, obj, validate_license=True):
|
||||||
return obj and self.user in obj.update_role
|
return obj and self.user in obj.update_role
|
||||||
|
|
||||||
|
def can_delete(self, obj):
|
||||||
|
return self.can_change(obj, None)
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdateAccess(BaseAccess):
|
class ProjectUpdateAccess(BaseAccess):
|
||||||
'''
|
'''
|
||||||
@@ -1382,14 +1326,7 @@ class JobTemplateAccess(BaseAccess):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def can_delete(self, obj):
|
def can_delete(self, obj):
|
||||||
is_delete_allowed = self.user.is_superuser or self.user in obj.admin_role
|
return self.user.is_superuser or self.user in obj.admin_role
|
||||||
if not is_delete_allowed:
|
|
||||||
return False
|
|
||||||
active_jobs = [dict(type="job", id=o.id)
|
|
||||||
for o in obj.jobs.filter(status__in=ACTIVE_STATES)]
|
|
||||||
if len(active_jobs) > 0:
|
|
||||||
raise ActiveJobConflict(active_jobs)
|
|
||||||
return True
|
|
||||||
|
|
||||||
@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):
|
||||||
@@ -1890,14 +1827,7 @@ class WorkflowJobTemplateAccess(BaseAccess):
|
|||||||
self.user in obj.admin_role)
|
self.user in obj.admin_role)
|
||||||
|
|
||||||
def can_delete(self, obj):
|
def can_delete(self, obj):
|
||||||
is_delete_allowed = self.user.is_superuser or self.user in obj.admin_role
|
return self.user.is_superuser or self.user in obj.admin_role
|
||||||
if not is_delete_allowed:
|
|
||||||
return False
|
|
||||||
active_jobs = [dict(type="workflow_job", id=o.id)
|
|
||||||
for o in obj.workflow_jobs.filter(status__in=ACTIVE_STATES)]
|
|
||||||
if len(active_jobs) > 0:
|
|
||||||
raise ActiveJobConflict(active_jobs)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobAccess(BaseAccess):
|
class WorkflowJobAccess(BaseAccess):
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ class _AwxTaskError():
|
|||||||
|
|
||||||
AwxTaskError = _AwxTaskError()
|
AwxTaskError = _AwxTaskError()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ from awx.main.fields import JSONField
|
|||||||
from awx.main.models.inventory import InventoryUpdate
|
from awx.main.models.inventory import InventoryUpdate
|
||||||
from awx.main.models.jobs import Job
|
from awx.main.models.jobs import Job
|
||||||
from awx.main.models.projects import ProjectUpdate
|
from awx.main.models.projects import ProjectUpdate
|
||||||
from awx.main.models.unified_jobs import UnifiedJob
|
from awx.main.models.unified_jobs import (
|
||||||
|
UnifiedJob,
|
||||||
|
ACTIVE_STATES
|
||||||
|
)
|
||||||
from awx.main.utils import get_cpu_capacity, get_mem_capacity, get_system_task_capacity
|
from awx.main.utils import get_cpu_capacity, get_mem_capacity, get_system_task_capacity
|
||||||
|
from awx.main.models.mixins import RelatedJobsMixin
|
||||||
|
|
||||||
__all__ = ('Instance', 'InstanceGroup', 'JobOrigin', 'TowerScheduleState',)
|
__all__ = ('Instance', 'InstanceGroup', 'JobOrigin', 'TowerScheduleState',)
|
||||||
|
|
||||||
@@ -110,7 +114,7 @@ class Instance(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceGroup(models.Model):
|
class InstanceGroup(models.Model, RelatedJobsMixin):
|
||||||
"""A model representing a Queue/Group of AWX Instances."""
|
"""A model representing a Queue/Group of AWX Instances."""
|
||||||
objects = InstanceGroupManager()
|
objects = InstanceGroupManager()
|
||||||
|
|
||||||
@@ -152,6 +156,14 @@ class InstanceGroup(models.Model):
|
|||||||
def capacity(self):
|
def capacity(self):
|
||||||
return sum([inst.capacity for inst in self.instances.all()])
|
return sum([inst.capacity for inst in self.instances.all()])
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return UnifiedJob.objects.filter(instance_group=self,
|
||||||
|
status__in=ACTIVE_STATES)
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ from awx.main.managers import HostManager
|
|||||||
from awx.main.models.base import * # noqa
|
from awx.main.models.base import * # noqa
|
||||||
from awx.main.models.events import InventoryUpdateEvent
|
from awx.main.models.events import InventoryUpdateEvent
|
||||||
from awx.main.models.unified_jobs import * # noqa
|
from awx.main.models.unified_jobs import * # noqa
|
||||||
from awx.main.models.mixins import ResourceMixin, TaskManagerInventoryUpdateMixin
|
from awx.main.models.unified_jobs import ACTIVE_STATES
|
||||||
|
from awx.main.models.mixins import (
|
||||||
|
ResourceMixin,
|
||||||
|
TaskManagerInventoryUpdateMixin,
|
||||||
|
RelatedJobsMixin,
|
||||||
|
)
|
||||||
from awx.main.models.notifications import (
|
from awx.main.models.notifications import (
|
||||||
NotificationTemplate,
|
NotificationTemplate,
|
||||||
JobNotificationMixin,
|
JobNotificationMixin,
|
||||||
@@ -47,7 +52,7 @@ __all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate',
|
|||||||
logger = logging.getLogger('awx.main.models.inventory')
|
logger = logging.getLogger('awx.main.models.inventory')
|
||||||
|
|
||||||
|
|
||||||
class Inventory(CommonModelNameNotUnique, ResourceMixin):
|
class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
||||||
'''
|
'''
|
||||||
an inventory source contains lists and hosts.
|
an inventory source contains lists and hosts.
|
||||||
'''
|
'''
|
||||||
@@ -489,6 +494,19 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin):
|
|||||||
self._update_host_smart_inventory_memeberships()
|
self._update_host_smart_inventory_memeberships()
|
||||||
super(Inventory, self).delete(*args, **kwargs)
|
super(Inventory, self).delete(*args, **kwargs)
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return UnifiedJob.objects.non_polymorphic().filter(
|
||||||
|
Q(status__in=ACTIVE_STATES) &
|
||||||
|
(
|
||||||
|
Q(Job___inventory=self) |
|
||||||
|
Q(InventoryUpdate___inventory_source__inventory=self) |
|
||||||
|
Q(AdHocCommand___inventory=self)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SmartInventoryMembership(BaseModel):
|
class SmartInventoryMembership(BaseModel):
|
||||||
'''
|
'''
|
||||||
@@ -690,7 +708,7 @@ class Host(CommonModelNameNotUnique):
|
|||||||
super(Host, self).delete(*args, **kwargs)
|
super(Host, self).delete(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Group(CommonModelNameNotUnique):
|
class Group(CommonModelNameNotUnique, RelatedJobsMixin):
|
||||||
'''
|
'''
|
||||||
A group containing managed hosts. A group or host may belong to multiple
|
A group containing managed hosts. A group or host may belong to multiple
|
||||||
groups.
|
groups.
|
||||||
@@ -942,6 +960,13 @@ class Group(CommonModelNameNotUnique):
|
|||||||
from awx.main.models.ad_hoc_commands import AdHocCommand
|
from awx.main.models.ad_hoc_commands import AdHocCommand
|
||||||
return AdHocCommand.objects.filter(hosts__in=self.all_hosts)
|
return AdHocCommand.objects.filter(hosts__in=self.all_hosts)
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return InventoryUpdate.objects.filter(status__in=ACTIVE_STATES,
|
||||||
|
inventory_source__in=self.inventory_sources.all())
|
||||||
|
|
||||||
|
|
||||||
class InventorySourceOptions(BaseModel):
|
class InventorySourceOptions(BaseModel):
|
||||||
'''
|
'''
|
||||||
@@ -1336,7 +1361,7 @@ class InventorySourceOptions(BaseModel):
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|
||||||
class InventorySource(UnifiedJobTemplate, InventorySourceOptions):
|
class InventorySource(UnifiedJobTemplate, InventorySourceOptions, RelatedJobsMixin):
|
||||||
|
|
||||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'inventory')]
|
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'inventory')]
|
||||||
|
|
||||||
@@ -1555,6 +1580,13 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions):
|
|||||||
raise ValidationError(_("Cannot set source_path if not SCM type."))
|
raise ValidationError(_("Cannot set source_path if not SCM type."))
|
||||||
return self.source_path
|
return self.source_path
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return InventoryUpdate.objects.filter(status__in=ACTIVE_STATES,
|
||||||
|
inventory_source=self)
|
||||||
|
|
||||||
|
|
||||||
class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, TaskManagerInventoryUpdateMixin):
|
class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, TaskManagerInventoryUpdateMixin):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -29,13 +29,21 @@ from awx.api.versioning import reverse
|
|||||||
from awx.main.models.base import * # noqa
|
from awx.main.models.base import * # noqa
|
||||||
from awx.main.models.events import JobEvent, SystemJobEvent
|
from awx.main.models.events import JobEvent, SystemJobEvent
|
||||||
from awx.main.models.unified_jobs import * # noqa
|
from awx.main.models.unified_jobs import * # noqa
|
||||||
|
from awx.main.models.unified_jobs import ACTIVE_STATES
|
||||||
from awx.main.models.notifications import (
|
from awx.main.models.notifications import (
|
||||||
NotificationTemplate,
|
NotificationTemplate,
|
||||||
JobNotificationMixin,
|
JobNotificationMixin,
|
||||||
)
|
)
|
||||||
from awx.main.utils import parse_yaml_or_json
|
from awx.main.utils import parse_yaml_or_json
|
||||||
from awx.main.fields import ImplicitRoleField
|
from awx.main.fields import ImplicitRoleField
|
||||||
from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin, TaskManagerJobMixin, CustomVirtualEnvMixin
|
from awx.main.models.mixins import (
|
||||||
|
ResourceMixin,
|
||||||
|
SurveyJobTemplateMixin,
|
||||||
|
SurveyJobMixin,
|
||||||
|
TaskManagerJobMixin,
|
||||||
|
CustomVirtualEnvMixin,
|
||||||
|
RelatedJobsMixin,
|
||||||
|
)
|
||||||
from awx.main.fields import JSONField, AskForField
|
from awx.main.fields import JSONField, AskForField
|
||||||
|
|
||||||
|
|
||||||
@@ -216,7 +224,7 @@ class JobOptions(BaseModel):
|
|||||||
return needed
|
return needed
|
||||||
|
|
||||||
|
|
||||||
class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin):
|
class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin):
|
||||||
'''
|
'''
|
||||||
A job template is a reusable job definition for applying a project (with
|
A job template is a reusable job definition for applying a project (with
|
||||||
playbook) to an inventory source with a given credential.
|
playbook) to an inventory source with a given credential.
|
||||||
@@ -444,6 +452,12 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
|||||||
organization_notification_templates_for_any=self.project.organization)))
|
organization_notification_templates_for_any=self.project.organization)))
|
||||||
return dict(error=list(error_notification_templates), success=list(success_notification_templates), any=list(any_notification_templates))
|
return dict(error=list(error_notification_templates), success=list(success_notification_templates), any=list(any_notification_templates))
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return Job.objects.filter(status__in=ACTIVE_STATES)
|
||||||
|
|
||||||
|
|
||||||
class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin):
|
class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ from copy import copy, deepcopy
|
|||||||
import six
|
import six
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.auth.models import User # noqa
|
from django.contrib.auth.models import User # noqa
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models.base import prevent_search
|
from awx.main.models.base import prevent_search
|
||||||
@@ -20,6 +22,7 @@ from awx.main.models.rbac import (
|
|||||||
)
|
)
|
||||||
from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices
|
from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices
|
||||||
from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted
|
from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted
|
||||||
|
from awx.main.utils.polymorphic import build_polymorphic_ctypes_map
|
||||||
from awx.main.fields import JSONField, AskForField
|
from awx.main.fields import JSONField, AskForField
|
||||||
|
|
||||||
|
|
||||||
@@ -444,3 +447,28 @@ class CustomVirtualEnvMixin(models.Model):
|
|||||||
if value:
|
if value:
|
||||||
return os.path.join(value, '')
|
return os.path.join(value, '')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RelatedJobsMixin(object):
|
||||||
|
|
||||||
|
'''
|
||||||
|
This method is intended to be overwritten.
|
||||||
|
Called by get_active_jobs()
|
||||||
|
Returns a list of active jobs (i.e. running) associated with the calling
|
||||||
|
resource (self). Expected to return a QuerySet
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
'''
|
||||||
|
Returns [{'id': '1', 'type': 'job'}, {'id': 2, 'type': 'project_update'}, ...]
|
||||||
|
'''
|
||||||
|
def get_active_jobs(self):
|
||||||
|
UnifiedJob = apps.get_model('main', 'UnifiedJob')
|
||||||
|
mapping = build_polymorphic_ctypes_map(UnifiedJob)
|
||||||
|
jobs = self._get_active_jobs()
|
||||||
|
if not isinstance(jobs, QuerySet):
|
||||||
|
raise RuntimeError("Programmer error. Expected _get_active_jobs() to return a QuerySet.")
|
||||||
|
|
||||||
|
return [dict(id=str(t[0]), type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')]
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
# 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
|
||||||
@@ -19,12 +20,16 @@ from awx.main.models.rbac import (
|
|||||||
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||||
ROLE_SINGLETON_SYSTEM_AUDITOR,
|
ROLE_SINGLETON_SYSTEM_AUDITOR,
|
||||||
)
|
)
|
||||||
from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin
|
from awx.main.models.unified_jobs import (
|
||||||
|
UnifiedJob,
|
||||||
|
ACTIVE_STATES,
|
||||||
|
)
|
||||||
|
from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin
|
||||||
|
|
||||||
__all__ = ['Organization', 'Team', 'Profile', 'UserSessionMembership']
|
__all__ = ['Organization', 'Team', 'Profile', 'UserSessionMembership']
|
||||||
|
|
||||||
|
|
||||||
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin):
|
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin):
|
||||||
'''
|
'''
|
||||||
An organization is the basic unit of multi-tenancy divisions
|
An organization is the basic unit of multi-tenancy divisions
|
||||||
'''
|
'''
|
||||||
@@ -74,6 +79,20 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
|
|||||||
def get_absolute_url(self, request=None):
|
def get_absolute_url(self, request=None):
|
||||||
return reverse('api:organization_detail', kwargs={'pk': self.pk}, request=request)
|
return reverse('api:organization_detail', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
project_ids = self.projects.all().values_list('id')
|
||||||
|
return UnifiedJob.objects.non_polymorphic().filter(
|
||||||
|
Q(status__in=ACTIVE_STATES) &
|
||||||
|
(
|
||||||
|
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):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ from awx.main.models.notifications import (
|
|||||||
JobNotificationMixin,
|
JobNotificationMixin,
|
||||||
)
|
)
|
||||||
from awx.main.models.unified_jobs import * # noqa
|
from awx.main.models.unified_jobs import * # noqa
|
||||||
from awx.main.models.mixins import ResourceMixin, TaskManagerProjectUpdateMixin, CustomVirtualEnvMixin
|
from awx.main.models.mixins import (
|
||||||
|
ResourceMixin,
|
||||||
|
TaskManagerProjectUpdateMixin,
|
||||||
|
CustomVirtualEnvMixin,
|
||||||
|
RelatedJobsMixin
|
||||||
|
)
|
||||||
from awx.main.utils import update_scm_url
|
from awx.main.utils import update_scm_url
|
||||||
from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook
|
from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook
|
||||||
from awx.main.fields import ImplicitRoleField
|
from awx.main.fields import ImplicitRoleField
|
||||||
@@ -443,7 +448,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
|||||||
return reverse('api:project_detail', kwargs={'pk': self.pk}, request=request)
|
return reverse('api:project_detail', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManagerProjectUpdateMixin):
|
class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManagerProjectUpdateMixin, RelatedJobsMixin):
|
||||||
'''
|
'''
|
||||||
Internal job for tracking project updates from SCM.
|
Internal job for tracking project updates from SCM.
|
||||||
'''
|
'''
|
||||||
@@ -557,3 +562,16 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
|
|||||||
if not selected_groups:
|
if not selected_groups:
|
||||||
return self.global_instance_groups
|
return self.global_instance_groups
|
||||||
return selected_groups
|
return selected_groups
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return UnifiedJob.objects.non_polymorphic().filter(
|
||||||
|
Q(status__in=ACTIVE_STATES) &
|
||||||
|
(
|
||||||
|
Q(Job___project=self) |
|
||||||
|
Q(ProjectUpdate___project=self)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ from awx.main.models.rbac import (
|
|||||||
ROLE_SINGLETON_SYSTEM_AUDITOR
|
ROLE_SINGLETON_SYSTEM_AUDITOR
|
||||||
)
|
)
|
||||||
from awx.main.fields import ImplicitRoleField
|
from awx.main.fields import ImplicitRoleField
|
||||||
from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin
|
from awx.main.models.mixins import (
|
||||||
|
ResourceMixin,
|
||||||
|
SurveyJobTemplateMixin,
|
||||||
|
SurveyJobMixin,
|
||||||
|
RelatedJobsMixin,
|
||||||
|
)
|
||||||
|
from awx.main.models.unified_jobs import ACTIVE_STATES
|
||||||
from awx.main.models.jobs import LaunchTimeConfig
|
from awx.main.models.jobs import LaunchTimeConfig
|
||||||
from awx.main.models.credential import Credential
|
from awx.main.models.credential import Credential
|
||||||
from awx.main.redact import REPLACE_STR
|
from awx.main.redact import REPLACE_STR
|
||||||
@@ -287,7 +293,7 @@ class WorkflowJobOptions(BaseModel):
|
|||||||
return new_workflow_job
|
return new_workflow_job
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin):
|
class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin):
|
||||||
|
|
||||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
||||||
FIELDS_TO_PRESERVE_AT_COPY = [
|
FIELDS_TO_PRESERVE_AT_COPY = [
|
||||||
@@ -405,6 +411,12 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
|||||||
node_list.append(node.pk)
|
node_list.append(node.pk)
|
||||||
return node_list
|
return node_list
|
||||||
|
|
||||||
|
'''
|
||||||
|
RelatedJobsMixin
|
||||||
|
'''
|
||||||
|
def _get_active_jobs(self):
|
||||||
|
return WorkflowJob.objects.filter(status__in=ACTIVE_STATES)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
|
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
73
awx/main/tests/functional/api/test_instance_group.py
Normal file
73
awx/main/tests/functional/api/test_instance_group.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from awx.api.versioning import reverse
|
||||||
|
from awx.main.models import (
|
||||||
|
InstanceGroup,
|
||||||
|
ProjectUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def instance_group(job_factory):
|
||||||
|
ig = InstanceGroup(name="east")
|
||||||
|
ig.save()
|
||||||
|
return ig
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_job_factory(job_factory, instance_group):
|
||||||
|
def fn(status='running'):
|
||||||
|
j = job_factory()
|
||||||
|
j.status = status
|
||||||
|
j.instance_group = instance_group
|
||||||
|
j.save()
|
||||||
|
return j
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_project_update_factory(instance_group, project):
|
||||||
|
def fn(status='running'):
|
||||||
|
pu = ProjectUpdate(project=project)
|
||||||
|
pu.status = status
|
||||||
|
pu.instance_group = instance_group
|
||||||
|
pu.save()
|
||||||
|
return pu
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def instance_group_jobs_running(instance_group, create_job_factory, create_project_update_factory):
|
||||||
|
jobs_running = [create_job_factory(status='running') for i in xrange(0, 2)]
|
||||||
|
project_updates_running = [create_project_update_factory(status='running') for i in xrange(0, 2)]
|
||||||
|
return jobs_running + project_updates_running
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def instance_group_jobs_successful(instance_group, create_job_factory, create_project_update_factory):
|
||||||
|
jobs_successful = [create_job_factory(status='successful') for i in xrange(0, 2)]
|
||||||
|
project_updates_successful = [create_project_update_factory(status='successful') for i in xrange(0, 2)]
|
||||||
|
return jobs_successful + project_updates_successful
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_delete_instance_group_jobs(delete, instance_group_jobs_successful, instance_group, admin):
|
||||||
|
url = reverse("api:instance_group_detail", kwargs={'pk': instance_group.pk})
|
||||||
|
delete(url, None, admin, expect=204)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_delete_instance_group_jobs_running(delete, instance_group_jobs_running, instance_group_jobs_successful, instance_group, admin):
|
||||||
|
def sort_keys(x):
|
||||||
|
return (x['type'], x['id'])
|
||||||
|
|
||||||
|
url = reverse("api:instance_group_detail", kwargs={'pk': instance_group.pk})
|
||||||
|
response = delete(url, None, admin, expect=409)
|
||||||
|
|
||||||
|
expect_transformed = [dict(id=str(j.id), type=j.model_to_str()) for j in instance_group_jobs_running]
|
||||||
|
response_sorted = sorted(response.data['active_jobs'], key=sort_keys)
|
||||||
|
expect_sorted = sorted(expect_transformed, key=sort_keys)
|
||||||
|
|
||||||
|
assert response.data['error'] == u"Resource is being used by running jobs."
|
||||||
|
assert response_sorted == expect_sorted
|
||||||
|
|
||||||
@@ -14,6 +14,40 @@ from awx.main.models import * # noqa
|
|||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_job_factory(job_factory, project):
|
||||||
|
def fn(status='running'):
|
||||||
|
j = job_factory()
|
||||||
|
j.status = status
|
||||||
|
j.project = project
|
||||||
|
j.save()
|
||||||
|
return j
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_project_update_factory(organization, project):
|
||||||
|
def fn(status='running'):
|
||||||
|
pu = ProjectUpdate(project=project)
|
||||||
|
pu.status = status
|
||||||
|
pu.organization = organization
|
||||||
|
pu.save()
|
||||||
|
return pu
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def organization_jobs_successful(create_job_factory, create_project_update_factory):
|
||||||
|
return [create_job_factory(status='successful') for i in xrange(0, 2)] + \
|
||||||
|
[create_project_update_factory(status='successful') for i in xrange(0, 2)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def organization_jobs_running(create_job_factory, create_project_update_factory):
|
||||||
|
return [create_job_factory(status='running') for i in xrange(0, 2)] + \
|
||||||
|
[create_project_update_factory(status='running') for i in xrange(0, 2)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_organization_list_access_tests(options, head, get, admin, alice):
|
def test_organization_list_access_tests(options, head, get, admin, alice):
|
||||||
options(reverse('api:organization_list'), user=admin, expect=200)
|
options(reverse('api:organization_list'), user=admin, expect=200)
|
||||||
@@ -215,3 +249,27 @@ def test_organization_unset_custom_virtualenv(get, patch, organization, admin, v
|
|||||||
url = reverse('api:organization_detail', kwargs={'pk': organization.id})
|
url = reverse('api:organization_detail', kwargs={'pk': organization.id})
|
||||||
resp = patch(url, {'custom_virtualenv': value}, user=admin, expect=200)
|
resp = patch(url, {'custom_virtualenv': value}, user=admin, expect=200)
|
||||||
assert resp.data['custom_virtualenv'] is None
|
assert resp.data['custom_virtualenv'] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_organization_delete(delete, admin, organization, organization_jobs_successful):
|
||||||
|
url = reverse('api:organization_detail', kwargs={'pk': organization.id})
|
||||||
|
delete(url, None, user=admin, expect=204)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_organization_delete_with_active_jobs(delete, admin, organization, organization_jobs_running):
|
||||||
|
def sort_keys(x):
|
||||||
|
return (x['type'], x['id'])
|
||||||
|
|
||||||
|
url = reverse('api:organization_detail', kwargs={'pk': organization.id})
|
||||||
|
resp = delete(url, None, user=admin, expect=409)
|
||||||
|
|
||||||
|
expect_transformed = [dict(id=str(j.id), type=j.model_to_str()) for j in organization_jobs_running]
|
||||||
|
resp_sorted = sorted(resp.data['active_jobs'], key=sort_keys)
|
||||||
|
expect_sorted = sorted(expect_transformed, key=sort_keys)
|
||||||
|
|
||||||
|
assert resp.data['error'] == u"Resource is being used by running jobs."
|
||||||
|
assert resp_sorted == expect_sorted
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from awx.main.models import (
|
|||||||
Host,
|
Host,
|
||||||
CustomInventoryScript,
|
CustomInventoryScript,
|
||||||
Schedule,
|
Schedule,
|
||||||
AdHocCommand
|
|
||||||
)
|
)
|
||||||
from awx.main.access import (
|
from awx.main.access import (
|
||||||
InventoryAccess,
|
InventoryAccess,
|
||||||
@@ -13,18 +12,9 @@ from awx.main.access import (
|
|||||||
InventoryUpdateAccess,
|
InventoryUpdateAccess,
|
||||||
CustomInventoryScriptAccess,
|
CustomInventoryScriptAccess,
|
||||||
ScheduleAccess,
|
ScheduleAccess,
|
||||||
ActiveJobConflict
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_running_job_protection(inventory, admin_user):
|
|
||||||
AdHocCommand.objects.create(inventory=inventory, status='running')
|
|
||||||
access = InventoryAccess(admin_user)
|
|
||||||
with pytest.raises(ActiveJobConflict):
|
|
||||||
access.can_delete(inventory)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_custom_inv_script_access(organization, user):
|
def test_custom_inv_script_access(organization, user):
|
||||||
u = user('user', False)
|
u = user('user', False)
|
||||||
|
|||||||
12
awx/main/utils/polymorphic.py
Normal file
12
awx/main/utils/polymorphic.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
|
|
||||||
|
def build_polymorphic_ctypes_map(cls):
|
||||||
|
# {'1': 'unified_job', '2': 'Job', '3': 'project_update', ...}
|
||||||
|
mapping = {}
|
||||||
|
for ct in ContentType.objects.filter(app_label='main'):
|
||||||
|
ct_model_class = ct.model_class()
|
||||||
|
if ct_model_class and issubclass(ct_model_class, cls):
|
||||||
|
mapping[ct.id] = ct_model_class._camel_to_underscore(ct_model_class.__name__)
|
||||||
|
return mapping
|
||||||
Reference in New Issue
Block a user