mirror of
https://github.com/ansible/awx.git
synced 2026-03-03 01:38:50 -03:30
Merge pull request #3174 from jbradberry/org_hosts_limit
[WIP] Org hosts limit Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
@@ -1229,7 +1229,7 @@ class OrganizationSerializer(BaseSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Organization
|
model = Organization
|
||||||
fields = ('*', 'custom_virtualenv',)
|
fields = ('*', 'max_hosts', 'custom_virtualenv',)
|
||||||
|
|
||||||
def get_related(self, obj):
|
def get_related(self, obj):
|
||||||
res = super(OrganizationSerializer, self).get_related(obj)
|
res = super(OrganizationSerializer, self).get_related(obj)
|
||||||
@@ -1265,6 +1265,20 @@ class OrganizationSerializer(BaseSerializer):
|
|||||||
summary_dict['related_field_counts'] = counts_dict[obj.id]
|
summary_dict['related_field_counts'] = counts_dict[obj.id]
|
||||||
return summary_dict
|
return summary_dict
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
obj = self.instance
|
||||||
|
view = self.context['view']
|
||||||
|
|
||||||
|
obj_limit = getattr(obj, 'max_hosts', None)
|
||||||
|
api_limit = attrs.get('max_hosts')
|
||||||
|
|
||||||
|
if not view.request.user.is_superuser:
|
||||||
|
if api_limit is not None and api_limit != obj_limit:
|
||||||
|
# Only allow superusers to edit the max_hosts field
|
||||||
|
raise serializers.ValidationError(_('Cannot change max_hosts.'))
|
||||||
|
|
||||||
|
return super(OrganizationSerializer, self).validate(attrs)
|
||||||
|
|
||||||
|
|
||||||
class ProjectOptionsSerializer(BaseSerializer):
|
class ProjectOptionsSerializer(BaseSerializer):
|
||||||
|
|
||||||
@@ -2202,8 +2216,8 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = InventoryUpdate
|
model = InventoryUpdate
|
||||||
fields = ('*', 'inventory', 'inventory_source', 'license_error', 'source_project_update',
|
fields = ('*', 'inventory', 'inventory_source', 'license_error', 'org_host_limit_error',
|
||||||
'custom_virtualenv', '-controller_node',)
|
'source_project_update', 'custom_virtualenv', '-controller_node',)
|
||||||
|
|
||||||
def get_related(self, obj):
|
def get_related(self, obj):
|
||||||
res = super(InventoryUpdateSerializer, self).get_related(obj)
|
res = super(InventoryUpdateSerializer, self).get_related(obj)
|
||||||
|
|||||||
@@ -330,6 +330,32 @@ class BaseAccess(object):
|
|||||||
elif "features" not in validation_info:
|
elif "features" not in validation_info:
|
||||||
raise LicenseForbids(_("Features not found in active license."))
|
raise LicenseForbids(_("Features not found in active license."))
|
||||||
|
|
||||||
|
def check_org_host_limit(self, data, add_host_name=None):
|
||||||
|
validation_info = get_licenser().validate()
|
||||||
|
if validation_info.get('license_type', 'UNLICENSED') == 'open':
|
||||||
|
return
|
||||||
|
|
||||||
|
inventory = get_object_from_data('inventory', Inventory, data)
|
||||||
|
if inventory is None: # In this case a missing inventory error is launched
|
||||||
|
return # further down the line, so just ignore it.
|
||||||
|
|
||||||
|
org = inventory.organization
|
||||||
|
if org is None or org.max_hosts == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_count = Host.objects.org_active_count(org.id)
|
||||||
|
if active_count > org.max_hosts:
|
||||||
|
raise PermissionDenied(
|
||||||
|
_("Organization host limit of %s has been exceeded, %s hosts active.") %
|
||||||
|
(org.max_hosts, active_count))
|
||||||
|
|
||||||
|
if add_host_name:
|
||||||
|
host_exists = Host.objects.filter(inventory__organization=org.id, name=add_host_name).exists()
|
||||||
|
if not host_exists and active_count == org.max_hosts:
|
||||||
|
raise PermissionDenied(
|
||||||
|
_("Organization host limit of %s would be exceeded, %s hosts active.") %
|
||||||
|
(org.max_hosts, active_count))
|
||||||
|
|
||||||
def get_user_capabilities(self, obj, method_list=[], parent_obj=None, capabilities_cache={}):
|
def get_user_capabilities(self, obj, method_list=[], parent_obj=None, capabilities_cache={}):
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return {}
|
return {}
|
||||||
@@ -360,7 +386,7 @@ class BaseAccess(object):
|
|||||||
user_capabilities[display_method] = self.user.is_superuser
|
user_capabilities[display_method] = self.user.is_superuser
|
||||||
continue
|
continue
|
||||||
elif display_method == 'copy' and isinstance(obj, Project) and obj.scm_type == '':
|
elif display_method == 'copy' and isinstance(obj, Project) and obj.scm_type == '':
|
||||||
# Connot copy manual project without errors
|
# Cannot copy manual project without errors
|
||||||
user_capabilities[display_method] = False
|
user_capabilities[display_method] = False
|
||||||
continue
|
continue
|
||||||
elif display_method in ['start', 'schedule'] and isinstance(obj, Group): # TODO: remove in 3.3
|
elif display_method in ['start', 'schedule'] and isinstance(obj, Group): # TODO: remove in 3.3
|
||||||
@@ -840,6 +866,10 @@ class HostAccess(BaseAccess):
|
|||||||
|
|
||||||
# Check to see if we have enough licenses
|
# Check to see if we have enough licenses
|
||||||
self.check_license(add_host_name=data.get('name', None))
|
self.check_license(add_host_name=data.get('name', None))
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit(data, add_host_name=data.get('name', None))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def can_change(self, obj, data):
|
def can_change(self, obj, data):
|
||||||
@@ -852,6 +882,10 @@ class HostAccess(BaseAccess):
|
|||||||
if data and 'name' in data:
|
if data and 'name' in data:
|
||||||
self.check_license(add_host_name=data['name'])
|
self.check_license(add_host_name=data['name'])
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit({'inventory': obj.inventory},
|
||||||
|
add_host_name=data['name'])
|
||||||
|
|
||||||
# Checks for admin or change permission on inventory, controls whether
|
# Checks for admin or change permission on inventory, controls whether
|
||||||
# the user can edit variable data.
|
# the user can edit variable data.
|
||||||
return obj and self.user in obj.inventory.admin_role
|
return obj and self.user in obj.inventory.admin_role
|
||||||
@@ -1362,6 +1396,10 @@ class JobTemplateAccess(BaseAccess):
|
|||||||
# Check license.
|
# Check license.
|
||||||
if validate_license:
|
if validate_license:
|
||||||
self.check_license()
|
self.check_license()
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit({'inventory': obj.inventory})
|
||||||
|
|
||||||
if obj.survey_enabled:
|
if obj.survey_enabled:
|
||||||
self.check_license(feature='surveys')
|
self.check_license(feature='surveys')
|
||||||
if Instance.objects.active_count() > 1:
|
if Instance.objects.active_count() > 1:
|
||||||
@@ -1520,6 +1558,9 @@ class JobAccess(BaseAccess):
|
|||||||
if validate_license:
|
if validate_license:
|
||||||
self.check_license()
|
self.check_license()
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit({'inventory': obj.inventory})
|
||||||
|
|
||||||
# A super user can relaunch a job
|
# A super user can relaunch a job
|
||||||
if self.user.is_superuser:
|
if self.user.is_superuser:
|
||||||
return True
|
return True
|
||||||
@@ -1886,6 +1927,10 @@ class WorkflowJobTemplateAccess(BaseAccess):
|
|||||||
if validate_license:
|
if validate_license:
|
||||||
# check basic license, node count
|
# check basic license, node count
|
||||||
self.check_license()
|
self.check_license()
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit({'inventory': obj.inventory})
|
||||||
|
|
||||||
# if surveys are added to WFJTs, check license here
|
# if surveys are added to WFJTs, check license here
|
||||||
if obj.survey_enabled:
|
if obj.survey_enabled:
|
||||||
self.check_license(feature='surveys')
|
self.check_license(feature='surveys')
|
||||||
@@ -1957,6 +2002,9 @@ class WorkflowJobAccess(BaseAccess):
|
|||||||
if validate_license:
|
if validate_license:
|
||||||
self.check_license()
|
self.check_license()
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit({'inventory': obj.inventory})
|
||||||
|
|
||||||
if self.user.is_superuser:
|
if self.user.is_superuser:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2033,6 +2081,9 @@ class AdHocCommandAccess(BaseAccess):
|
|||||||
if validate_license:
|
if validate_license:
|
||||||
self.check_license()
|
self.check_license()
|
||||||
|
|
||||||
|
# Check the per-org limit
|
||||||
|
self.check_org_host_limit(data)
|
||||||
|
|
||||||
# If a credential is provided, the user should have use access to it.
|
# If a credential is provided, the user should have use access to it.
|
||||||
if not self.check_related('credential', Credential, data, role_field='use_role'):
|
if not self.check_related('credential', Credential, data, role_field='use_role'):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -904,10 +904,27 @@ class Command(BaseCommand):
|
|||||||
logger.error(LICENSE_MESSAGE % d)
|
logger.error(LICENSE_MESSAGE % d)
|
||||||
raise CommandError('License count exceeded!')
|
raise CommandError('License count exceeded!')
|
||||||
|
|
||||||
|
def check_org_host_limit(self):
|
||||||
|
license_info = get_licenser().validate()
|
||||||
|
if license_info.get('license_type', 'UNLICENSED') == 'open':
|
||||||
|
return
|
||||||
|
|
||||||
|
org = self.inventory.organization
|
||||||
|
if org is None or org.max_hosts == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_count = Host.objects.org_active_count(org.id)
|
||||||
|
if active_count > org.max_hosts:
|
||||||
|
raise CommandError('Host limit for organization exceeded!')
|
||||||
|
|
||||||
def mark_license_failure(self, save=True):
|
def mark_license_failure(self, save=True):
|
||||||
self.inventory_update.license_error = True
|
self.inventory_update.license_error = True
|
||||||
self.inventory_update.save(update_fields=['license_error'])
|
self.inventory_update.save(update_fields=['license_error'])
|
||||||
|
|
||||||
|
def mark_org_limits_failure(self, save=True):
|
||||||
|
self.inventory_update.org_host_limit_error = True
|
||||||
|
self.inventory_update.save(update_fields=['org_host_limit_error'])
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.verbosity = int(options.get('verbosity', 1))
|
self.verbosity = int(options.get('verbosity', 1))
|
||||||
self.set_logging_level()
|
self.set_logging_level()
|
||||||
@@ -961,6 +978,13 @@ class Command(BaseCommand):
|
|||||||
self.mark_license_failure(save=True)
|
self.mark_license_failure(save=True)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check the per-org host limits
|
||||||
|
self.check_org_host_limit()
|
||||||
|
except CommandError as e:
|
||||||
|
self.mark_org_limits_failure(save=True)
|
||||||
|
raise e
|
||||||
|
|
||||||
status, tb, exc = 'error', '', None
|
status, tb, exc = 'error', '', None
|
||||||
try:
|
try:
|
||||||
if settings.SQL_DEBUG:
|
if settings.SQL_DEBUG:
|
||||||
@@ -1032,9 +1056,17 @@ class Command(BaseCommand):
|
|||||||
# If the license is not valid, a CommandError will be thrown,
|
# If the license is not valid, a CommandError will be thrown,
|
||||||
# and inventory update will be marked as invalid.
|
# and inventory update will be marked as invalid.
|
||||||
# with transaction.atomic() will roll back the changes.
|
# with transaction.atomic() will roll back the changes.
|
||||||
|
license_fail = True
|
||||||
self.check_license()
|
self.check_license()
|
||||||
|
|
||||||
|
# Check the per-org host limits
|
||||||
|
license_fail = False
|
||||||
|
self.check_org_host_limit()
|
||||||
except CommandError as e:
|
except CommandError as e:
|
||||||
self.mark_license_failure()
|
if license_fail:
|
||||||
|
self.mark_license_failure()
|
||||||
|
else:
|
||||||
|
self.mark_org_limits_failure()
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
if settings.SQL_DEBUG:
|
if settings.SQL_DEBUG:
|
||||||
@@ -1062,7 +1094,6 @@ class Command(BaseCommand):
|
|||||||
else:
|
else:
|
||||||
tb = traceback.format_exc()
|
tb = traceback.format_exc()
|
||||||
exc = e
|
exc = e
|
||||||
transaction.rollback()
|
|
||||||
|
|
||||||
if self.invoked_from_dispatcher is False:
|
if self.invoked_from_dispatcher is False:
|
||||||
with ignore_inventory_computed_fields():
|
with ignore_inventory_computed_fields():
|
||||||
@@ -1073,7 +1104,8 @@ class Command(BaseCommand):
|
|||||||
self.inventory_source.status = status
|
self.inventory_source.status = status
|
||||||
self.inventory_source.save(update_fields=['status'])
|
self.inventory_source.save(update_fields=['status'])
|
||||||
|
|
||||||
if exc and isinstance(exc, CommandError):
|
if exc:
|
||||||
sys.exit(1)
|
logger.error(str(exc))
|
||||||
elif exc:
|
if isinstance(exc, CommandError):
|
||||||
|
sys.exit(1)
|
||||||
raise exc
|
raise exc
|
||||||
|
|||||||
@@ -29,6 +29,34 @@ class HostManager(models.Manager):
|
|||||||
"""
|
"""
|
||||||
return self.order_by().exclude(inventory_sources__source='tower').values('name').distinct().count()
|
return self.order_by().exclude(inventory_sources__source='tower').values('name').distinct().count()
|
||||||
|
|
||||||
|
def org_active_count(self, org_id):
|
||||||
|
"""Return count of active, unique hosts used by an organization.
|
||||||
|
Construction of query involves:
|
||||||
|
- remove any ordering specified in model's Meta
|
||||||
|
- Exclude hosts sourced from another Tower
|
||||||
|
- Consider only hosts where the canonical inventory is owned by the organization
|
||||||
|
- Restrict the query to only return the name column
|
||||||
|
- Only consider results that are unique
|
||||||
|
- Return the count of this query
|
||||||
|
"""
|
||||||
|
return self.order_by().exclude(
|
||||||
|
inventory_sources__source='tower'
|
||||||
|
).filter(inventory__organization=org_id).values('name').distinct().count()
|
||||||
|
|
||||||
|
def active_counts_by_org(self):
|
||||||
|
"""Return the counts of active, unique hosts for each organization.
|
||||||
|
Construction of query involves:
|
||||||
|
- remove any ordering specified in model's Meta
|
||||||
|
- Exclude hosts sourced from another Tower
|
||||||
|
- Consider only hosts where the canonical inventory is owned by each organization
|
||||||
|
- Restrict the query to only count distinct names
|
||||||
|
- Return the counts
|
||||||
|
"""
|
||||||
|
return self.order_by().exclude(
|
||||||
|
inventory_sources__source='tower'
|
||||||
|
).values('inventory__organization').annotate(
|
||||||
|
inventory__organization__count=models.Count('name', distinct=True))
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""When the parent instance of the host query set has a `kind=smart` and a `host_filter`
|
"""When the parent instance of the host query set has a `kind=smart` and a `host_filter`
|
||||||
set. Use the `host_filter` to generate the queryset for the hosts.
|
set. Use the `host_filter` to generate the queryset for the hosts.
|
||||||
|
|||||||
25
awx/main/migrations/0063_v350_org_host_limits.py
Normal file
25
awx/main/migrations/0063_v350_org_host_limits.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.16 on 2019-02-15 20:03
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('main', '0062_v350_new_playbook_stats'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inventoryupdate',
|
||||||
|
name='org_host_limit_error',
|
||||||
|
field=models.BooleanField(default=False, editable=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='max_hosts',
|
||||||
|
field=models.PositiveIntegerField(blank=True, default=0, help_text='Maximum number of hosts allowed to be managed by this organization.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1653,6 +1653,10 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin,
|
|||||||
default=False,
|
default=False,
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
|
org_host_limit_error = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
source_project_update = models.ForeignKey(
|
source_project_update = models.ForeignKey(
|
||||||
'ProjectUpdate',
|
'ProjectUpdate',
|
||||||
related_name='scm_inventory_updates',
|
related_name='scm_inventory_updates',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
@@ -42,6 +43,12 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
|
|||||||
'InstanceGroup',
|
'InstanceGroup',
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
max_hosts = models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
default=0,
|
||||||
|
help_text=_('Maximum number of hosts allowed to be managed by this organization.'),
|
||||||
|
)
|
||||||
|
|
||||||
admin_role = ImplicitRoleField(
|
admin_role = ImplicitRoleField(
|
||||||
parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -326,6 +326,24 @@ def test_create_inventory_host(post, inventory, alice, role_field, expected_stat
|
|||||||
post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code)
|
post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("hosts,expected_status_code", [
|
||||||
|
(1, 201),
|
||||||
|
(2, 201),
|
||||||
|
(3, 201),
|
||||||
|
])
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_inventory_host_with_limits(post, admin_user, inventory, hosts, expected_status_code):
|
||||||
|
# The per-Organization host limits functionality should be a no-op on AWX.
|
||||||
|
inventory.organization.max_hosts = 2
|
||||||
|
inventory.organization.save()
|
||||||
|
for i in range(hosts):
|
||||||
|
inventory.hosts.create(name="Existing host %i" % i)
|
||||||
|
|
||||||
|
data = {'name': 'New name', 'description': 'Hello world'}
|
||||||
|
post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}),
|
||||||
|
data, admin_user, expect=expected_status_code)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("role_field,expected_status_code", [
|
@pytest.mark.parametrize("role_field,expected_status_code", [
|
||||||
(None, 403),
|
(None, 403),
|
||||||
('admin_role', 201),
|
('admin_role', 201),
|
||||||
@@ -356,6 +374,18 @@ def test_edit_inventory_host(put, host, alice, role_field, expected_status_code)
|
|||||||
put(reverse('api:host_detail', kwargs={'pk': host.id}), data, alice, expect=expected_status_code)
|
put(reverse('api:host_detail', kwargs={'pk': host.id}), data, alice, expect=expected_status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_edit_inventory_host_with_limits(put, host, admin_user):
|
||||||
|
# The per-Organization host limits functionality should be a no-op on AWX.
|
||||||
|
inventory = host.inventory
|
||||||
|
inventory.organization.max_hosts = 1
|
||||||
|
inventory.organization.save()
|
||||||
|
inventory.hosts.create(name='Alternate host')
|
||||||
|
|
||||||
|
data = {'name': 'New name', 'description': 'Hello world'}
|
||||||
|
put(reverse('api:host_detail', kwargs={'pk': host.id}), data, admin_user, expect=200)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("role_field,expected_status_code", [
|
@pytest.mark.parametrize("role_field,expected_status_code", [
|
||||||
(None, 403),
|
(None, 403),
|
||||||
('admin_role', 204),
|
('admin_role', 204),
|
||||||
|
|||||||
@@ -199,6 +199,30 @@ def test_update_organization(get, put, organization, alice, bob):
|
|||||||
put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=bob, expect=403)
|
put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=bob, expect=403)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_update_organization_max_hosts(get, put, organization, admin, alice, bob):
|
||||||
|
# Admin users can get and update max_hosts
|
||||||
|
data = get(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=admin, expect=200).data
|
||||||
|
assert organization.max_hosts == 0
|
||||||
|
data['max_hosts'] = 3
|
||||||
|
put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=admin, expect=200)
|
||||||
|
organization.refresh_from_db()
|
||||||
|
assert organization.max_hosts == 3
|
||||||
|
|
||||||
|
# Organization admins can get the data and can update other fields, but not max_hosts
|
||||||
|
organization.admin_role.members.add(alice)
|
||||||
|
data = get(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=alice, expect=200).data
|
||||||
|
data['max_hosts'] = 5
|
||||||
|
put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=alice, expect=400)
|
||||||
|
organization.refresh_from_db()
|
||||||
|
assert organization.max_hosts == 3
|
||||||
|
|
||||||
|
# Ordinary users shouldn't be able to update either.
|
||||||
|
put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=bob, expect=403)
|
||||||
|
organization.refresh_from_db()
|
||||||
|
assert organization.max_hosts == 3
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True)
|
@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True)
|
||||||
def test_delete_organization(delete, organization, admin):
|
def test_delete_organization(delete, organization, admin):
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ def test_admin_executing_permissions(deploy_jobtemplate, inventory, machine_cred
|
|||||||
assert admin_user.can_access(Credential, 'use', machine_credential)
|
assert admin_user.can_access(Credential, 'use', machine_credential)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.job_permissions
|
||||||
|
def test_admin_executing_permissions_with_limits(deploy_jobtemplate, inventory, user):
|
||||||
|
admin_user = user('admin-user', True)
|
||||||
|
|
||||||
|
inventory.organization.max_hosts = 1
|
||||||
|
inventory.organization.save()
|
||||||
|
inventory.hosts.create(name="Existing host 1")
|
||||||
|
inventory.hosts.create(name="Existing host 2")
|
||||||
|
|
||||||
|
assert admin_user.can_access(JobTemplate, 'start', deploy_jobtemplate)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.job_permissions
|
@pytest.mark.job_permissions
|
||||||
def test_job_template_start_access(deploy_jobtemplate, user):
|
def test_job_template_start_access(deploy_jobtemplate, user):
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ class TestWorkflowJobAccess:
|
|||||||
JobLaunchConfig.objects.create(job=workflow_job)
|
JobLaunchConfig.objects.create(job=workflow_job)
|
||||||
assert WorkflowJobAccess(rando).can_start(workflow_job)
|
assert WorkflowJobAccess(rando).can_start(workflow_job)
|
||||||
|
|
||||||
|
def test_can_start_with_limits(self, workflow_job, inventory, admin_user):
|
||||||
|
inventory.organization.max_hosts = 1
|
||||||
|
inventory.organization.save()
|
||||||
|
inventory.hosts.create(name="Existing host 1")
|
||||||
|
inventory.hosts.create(name="Existing host 2")
|
||||||
|
workflow_job.inventory = inventory
|
||||||
|
workflow_job.save()
|
||||||
|
|
||||||
|
assert WorkflowJobAccess(admin_user).can_start(workflow_job)
|
||||||
|
|
||||||
def test_cannot_relaunch_friends_job(self, wfjt, rando, alice):
|
def test_cannot_relaunch_friends_job(self, wfjt, rando, alice):
|
||||||
workflow_job = wfjt.workflow_jobs.create(name='foo', created_by=alice)
|
workflow_job = wfjt.workflow_jobs.create(name='foo', created_by=alice)
|
||||||
JobLaunchConfig.objects.create(
|
JobLaunchConfig.objects.create(
|
||||||
|
|||||||
@@ -236,6 +236,18 @@ function getLicenseErrorDetails () {
|
|||||||
return { label, value };
|
return { label, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHostLimitErrorDetails () {
|
||||||
|
if (!resource.model.has('org_host_limit_error')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = strings.get('labels.HOST_LIMIT_ERROR');
|
||||||
|
const tooltip = strings.get('tooltips.HOST_LIMIT');
|
||||||
|
const value = resource.model.get('org_host_limit_error');
|
||||||
|
|
||||||
|
return { tooltip, label, value };
|
||||||
|
}
|
||||||
|
|
||||||
function getLaunchedByDetails () {
|
function getLaunchedByDetails () {
|
||||||
const createdBy = resource.model.get('summary_fields.created_by');
|
const createdBy = resource.model.get('summary_fields.created_by');
|
||||||
const jobTemplate = resource.model.get('summary_fields.job_template');
|
const jobTemplate = resource.model.get('summary_fields.job_template');
|
||||||
@@ -804,6 +816,7 @@ function JobDetailsController (
|
|||||||
vm.overwrite = getOverwriteDetails();
|
vm.overwrite = getOverwriteDetails();
|
||||||
vm.overwriteVars = getOverwriteVarsDetails();
|
vm.overwriteVars = getOverwriteVarsDetails();
|
||||||
vm.licenseError = getLicenseErrorDetails();
|
vm.licenseError = getLicenseErrorDetails();
|
||||||
|
vm.hostLimitError = getHostLimitErrorDetails();
|
||||||
|
|
||||||
// Relaunch and Delete Components
|
// Relaunch and Delete Components
|
||||||
vm.job = angular.copy(_.get(resource.model, 'model.GET', {}));
|
vm.job = angular.copy(_.get(resource.model, 'model.GET', {}));
|
||||||
|
|||||||
@@ -81,6 +81,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- HOST LIMIT ERROR DETAIL -->
|
||||||
|
<div class="JobResults-resultRow" ng-show="vm.hostLimitError">
|
||||||
|
<label class="JobResults-resultRowLabel">
|
||||||
|
{{ vm.hostLimitError.label }}
|
||||||
|
<a id="awp-hostLimitError"
|
||||||
|
href=""
|
||||||
|
aw-pop-over="{{ vm.hostLimitError.tooltip }}"
|
||||||
|
data-placement="top"
|
||||||
|
data-container="body"
|
||||||
|
class="help-link"
|
||||||
|
title="{{ vm.hostLimitError.label }}"
|
||||||
|
tabindex="-1">
|
||||||
|
<i class="fa fa-question-circle"></i>
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
<div class="JobResults-resultRowText">
|
||||||
|
{{ vm.hostLimitError.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- START TIME DETAIL -->
|
<!-- START TIME DETAIL -->
|
||||||
<div class="JobResults-resultRow" ng-if="vm.started">
|
<div class="JobResults-resultRow" ng-if="vm.started">
|
||||||
<label class="JobResults-resultRowLabel">{{ vm.started.label }}</label>
|
<label class="JobResults-resultRowLabel">{{ vm.started.label }}</label>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function OutputStrings (BaseString) {
|
|||||||
CREDENTIAL: t.s('View the Credential'),
|
CREDENTIAL: t.s('View the Credential'),
|
||||||
EXPAND_OUTPUT: t.s('Expand Output'),
|
EXPAND_OUTPUT: t.s('Expand Output'),
|
||||||
EXTRA_VARS: t.s('Read-only view of extra variables added to the job template'),
|
EXTRA_VARS: t.s('Read-only view of extra variables added to the job template'),
|
||||||
|
HOST_LIMIT: t.s('When this field is true, the job\'s inventory belongs to an organization that has exceeded it\'s limit of hosts as defined by the system administrator.'),
|
||||||
INVENTORY: t.s('View the Inventory'),
|
INVENTORY: t.s('View the Inventory'),
|
||||||
INVENTORY_SCM: t.s('View the Project'),
|
INVENTORY_SCM: t.s('View the Project'),
|
||||||
INVENTORY_SCM_JOB: t.s('View Project checkout results'),
|
INVENTORY_SCM_JOB: t.s('View Project checkout results'),
|
||||||
@@ -57,6 +58,7 @@ function OutputStrings (BaseString) {
|
|||||||
EXTRA_VARS: t.s('Extra Variables'),
|
EXTRA_VARS: t.s('Extra Variables'),
|
||||||
FINISHED: t.s('Finished'),
|
FINISHED: t.s('Finished'),
|
||||||
FORKS: t.s('Forks'),
|
FORKS: t.s('Forks'),
|
||||||
|
HOST_LIMIT_ERROR: t.s('Host Limit Error'),
|
||||||
INSTANCE_GROUP: t.s('Instance Group'),
|
INSTANCE_GROUP: t.s('Instance Group'),
|
||||||
INVENTORY: t.s('Inventory'),
|
INVENTORY: t.s('Inventory'),
|
||||||
INVENTORY_SCM: t.s('Source Project'),
|
INVENTORY_SCM: t.s('Source Project'),
|
||||||
|
|||||||
@@ -39,93 +39,110 @@ function atLaunchTemplateCtrl (
|
|||||||
if (vm.template.type === 'job_template') {
|
if (vm.template.type === 'job_template') {
|
||||||
const selectedJobTemplate = jobTemplate.create();
|
const selectedJobTemplate = jobTemplate.create();
|
||||||
const preLaunchPromises = [
|
const preLaunchPromises = [
|
||||||
selectedJobTemplate.getLaunch(vm.template.id),
|
selectedJobTemplate.getLaunch(vm.template.id)
|
||||||
selectedJobTemplate.optionsLaunch(vm.template.id),
|
.catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'GET')),
|
||||||
|
selectedJobTemplate.optionsLaunch(vm.template.id)
|
||||||
|
.catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'OPTIONS'))
|
||||||
];
|
];
|
||||||
|
|
||||||
Promise.all(preLaunchPromises)
|
Promise.all(preLaunchPromises)
|
||||||
.then(([launchData, launchOptions]) => {
|
.then(([launchData, launchOptions]) => {
|
||||||
if (selectedJobTemplate.canLaunchWithoutPrompt()) {
|
// If we don't get both of these things then one of the
|
||||||
selectedJobTemplate
|
// promises was rejected
|
||||||
.postLaunch({ id: vm.template.id })
|
if (launchData && launchOptions) {
|
||||||
.then(({ data }) => {
|
if (selectedJobTemplate.canLaunchWithoutPrompt()) {
|
||||||
/* Slice Jobs: Redirect to WF Details page if returned
|
selectedJobTemplate
|
||||||
job type is a WF job */
|
.postLaunch({ id: vm.template.id })
|
||||||
if (data.type === 'workflow_job' && data.workflow_job !== null) {
|
|
||||||
$state.go('workflowResults', { id: data.workflow_job }, { reload: true });
|
|
||||||
} else {
|
|
||||||
$state.go('output', { id: data.job, type: 'playbook' }, { reload: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const promptData = {
|
|
||||||
launchConf: launchData.data,
|
|
||||||
launchOptions: launchOptions.data,
|
|
||||||
template: vm.template.id,
|
|
||||||
templateType: vm.template.type,
|
|
||||||
prompts: PromptService.processPromptValues({
|
|
||||||
launchConf: launchData.data,
|
|
||||||
launchOptions: launchOptions.data
|
|
||||||
}),
|
|
||||||
triggerModalOpen: true
|
|
||||||
};
|
|
||||||
|
|
||||||
if (launchData.data.survey_enabled) {
|
|
||||||
selectedJobTemplate.getSurveyQuestions(vm.template.id)
|
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
const processed = PromptService.processSurveyQuestions({
|
/* Slice Jobs: Redirect to WF Details page if returned
|
||||||
surveyQuestions: data.spec
|
job type is a WF job */
|
||||||
});
|
if (data.type === 'workflow_job' && data.workflow_job !== null) {
|
||||||
promptData.surveyQuestions = processed.surveyQuestions;
|
$state.go('workflowResults', { id: data.workflow_job }, { reload: true });
|
||||||
vm.promptData = promptData;
|
} else {
|
||||||
});
|
$state.go('output', { id: data.job, type: 'playbook' }, { reload: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'POST'));
|
||||||
} else {
|
} else {
|
||||||
vm.promptData = promptData;
|
const promptData = {
|
||||||
|
launchConf: launchData.data,
|
||||||
|
launchOptions: launchOptions.data,
|
||||||
|
template: vm.template.id,
|
||||||
|
templateType: vm.template.type,
|
||||||
|
prompts: PromptService.processPromptValues({
|
||||||
|
launchConf: launchData.data,
|
||||||
|
launchOptions: launchOptions.data
|
||||||
|
}),
|
||||||
|
triggerModalOpen: true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (launchData.data.survey_enabled) {
|
||||||
|
selectedJobTemplate.getSurveyQuestions(vm.template.id)
|
||||||
|
.then(({ data }) => {
|
||||||
|
const processed = PromptService.processSurveyQuestions({
|
||||||
|
surveyQuestions: data.spec
|
||||||
|
});
|
||||||
|
promptData.surveyQuestions = processed.surveyQuestions;
|
||||||
|
vm.promptData = promptData;
|
||||||
|
})
|
||||||
|
.catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/survey_spec`, 'GET'));
|
||||||
|
} else {
|
||||||
|
vm.promptData = promptData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (vm.template.type === 'workflow_job_template') {
|
} else if (vm.template.type === 'workflow_job_template') {
|
||||||
const selectedWorkflowJobTemplate = workflowTemplate.create();
|
const selectedWorkflowJobTemplate = workflowTemplate.create();
|
||||||
const preLaunchPromises = [
|
const preLaunchPromises = [
|
||||||
selectedWorkflowJobTemplate.request('get', vm.template.id),
|
selectedWorkflowJobTemplate.request('get', vm.template.id)
|
||||||
selectedWorkflowJobTemplate.getLaunch(vm.template.id),
|
.catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}`, 'GET')),
|
||||||
selectedWorkflowJobTemplate.optionsLaunch(vm.template.id),
|
selectedWorkflowJobTemplate.getLaunch(vm.template.id)
|
||||||
|
.catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'GET')),
|
||||||
|
selectedWorkflowJobTemplate.optionsLaunch(vm.template.id)
|
||||||
|
.catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'OPTIONS')),
|
||||||
];
|
];
|
||||||
|
|
||||||
Promise.all(preLaunchPromises)
|
Promise.all(preLaunchPromises)
|
||||||
.then(([wfjtData, launchData, launchOptions]) => {
|
.then(([wfjtData, launchData, launchOptions]) => {
|
||||||
if (selectedWorkflowJobTemplate.canLaunchWithoutPrompt()) {
|
// If we don't get all of these things then one of the
|
||||||
selectedWorkflowJobTemplate
|
// promises was rejected
|
||||||
.postLaunch({ id: vm.template.id })
|
if (wfjtData && launchData && launchOptions) {
|
||||||
.then(({ data }) => {
|
if (selectedWorkflowJobTemplate.canLaunchWithoutPrompt()) {
|
||||||
$state.go('workflowResults', { id: data.workflow_job }, { reload: true });
|
selectedWorkflowJobTemplate
|
||||||
});
|
.postLaunch({ id: vm.template.id })
|
||||||
} else {
|
|
||||||
launchData.data.defaults.extra_vars = wfjtData.data.extra_vars;
|
|
||||||
|
|
||||||
const promptData = {
|
|
||||||
launchConf: selectedWorkflowJobTemplate.getLaunchConf(),
|
|
||||||
launchOptions: launchOptions.data,
|
|
||||||
template: vm.template.id,
|
|
||||||
templateType: vm.template.type,
|
|
||||||
prompts: PromptService.processPromptValues({
|
|
||||||
launchConf: selectedWorkflowJobTemplate.getLaunchConf(),
|
|
||||||
launchOptions: launchOptions.data
|
|
||||||
}),
|
|
||||||
triggerModalOpen: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (launchData.data.survey_enabled) {
|
|
||||||
selectedWorkflowJobTemplate.getSurveyQuestions(vm.template.id)
|
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
const processed = PromptService.processSurveyQuestions({
|
$state.go('workflowResults', { id: data.workflow_job }, { reload: true });
|
||||||
surveyQuestions: data.spec
|
})
|
||||||
});
|
.catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'POST'));
|
||||||
promptData.surveyQuestions = processed.surveyQuestions;
|
|
||||||
vm.promptData = promptData;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
vm.promptData = promptData;
|
launchData.data.defaults.extra_vars = wfjtData.data.extra_vars;
|
||||||
|
|
||||||
|
const promptData = {
|
||||||
|
launchConf: selectedWorkflowJobTemplate.getLaunchConf(),
|
||||||
|
launchOptions: launchOptions.data,
|
||||||
|
template: vm.template.id,
|
||||||
|
templateType: vm.template.type,
|
||||||
|
prompts: PromptService.processPromptValues({
|
||||||
|
launchConf: selectedWorkflowJobTemplate.getLaunchConf(),
|
||||||
|
launchOptions: launchOptions.data
|
||||||
|
}),
|
||||||
|
triggerModalOpen: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (launchData.data.survey_enabled) {
|
||||||
|
selectedWorkflowJobTemplate.getSurveyQuestions(vm.template.id)
|
||||||
|
.then(({ data }) => {
|
||||||
|
const processed = PromptService.processSurveyQuestions({
|
||||||
|
surveyQuestions: data.spec
|
||||||
|
});
|
||||||
|
promptData.surveyQuestions = processed.surveyQuestions;
|
||||||
|
vm.promptData = promptData;
|
||||||
|
})
|
||||||
|
.catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/survey_spec`, 'GET'));
|
||||||
|
} else {
|
||||||
|
vm.promptData = promptData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -163,14 +180,14 @@ function atLaunchTemplateCtrl (
|
|||||||
} else {
|
} else {
|
||||||
$state.go('output', { id: launchRes.data.job, type: 'playbook' }, { reload: true });
|
$state.go('output', { id: launchRes.data.job, type: 'playbook' }, { reload: true });
|
||||||
}
|
}
|
||||||
}).catch(createErrorHandler('launch job template', 'POST'));
|
}).catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'POST'));
|
||||||
} else if (vm.promptData.templateType === 'workflow_job_template') {
|
} else if (vm.promptData.templateType === 'workflow_job_template') {
|
||||||
workflowTemplate.create().postLaunch({
|
workflowTemplate.create().postLaunch({
|
||||||
id: vm.promptData.template,
|
id: vm.promptData.template,
|
||||||
launchData: jobLaunchData
|
launchData: jobLaunchData
|
||||||
}).then((launchRes) => {
|
}).then((launchRes) => {
|
||||||
$state.go('workflowResults', { id: launchRes.data.workflow_job }, { reload: true });
|
$state.go('workflowResults', { id: launchRes.data.workflow_job }, { reload: true });
|
||||||
}).catch(createErrorHandler('launch workflow job template', 'POST'));
|
}).catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'POST'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
*************************************************/
|
*************************************************/
|
||||||
|
|
||||||
export default ['$state', '$stateParams', '$scope', 'RelatedHostsFormDefinition', 'ParseTypeChange',
|
export default ['$state', '$stateParams', '$scope', 'RelatedHostsFormDefinition', 'ParseTypeChange',
|
||||||
'GenerateForm', 'HostsService', 'rbacUiControlService', 'GetBasePath', 'ToJSON', 'canAdd',
|
'GenerateForm', 'HostsService', 'GetBasePath', 'ToJSON', 'canAdd',
|
||||||
function($state, $stateParams, $scope, RelatedHostsFormDefinition, ParseTypeChange,
|
function($state, $stateParams, $scope, RelatedHostsFormDefinition, ParseTypeChange,
|
||||||
GenerateForm, HostsService, rbacUiControlService, GetBasePath, ToJSON, canAdd) {
|
GenerateForm, HostsService, GetBasePath, ToJSON, canAdd) {
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
url: function(){
|
url: function(){
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
error: function(data, status) {
|
error: function(data) {
|
||||||
ProcessErrors($rootScope, data.data, status, null, { hdr: 'Error!',
|
ProcessErrors($rootScope, data.data, data.status, null, { hdr: 'Error!',
|
||||||
msg: 'Call to ' + this.url + '. GET returned: ' + status });
|
msg: 'Call to ' + this.url + '. GET returned: ' + data.status });
|
||||||
},
|
},
|
||||||
success: function(data){
|
success: function(data){
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export default ['$scope', '$rootScope', '$location', '$stateParams',
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
function init(){
|
function init(){
|
||||||
// @issue What is this doing, why
|
|
||||||
$scope.$emit("HideOrgListHeader");
|
|
||||||
const virtualEnvs = ConfigData.custom_virtualenvs || [];
|
const virtualEnvs = ConfigData.custom_virtualenvs || [];
|
||||||
$scope.custom_virtualenvs_visible = virtualEnvs.length > 1;
|
$scope.custom_virtualenvs_visible = virtualEnvs.length > 1;
|
||||||
$scope.custom_virtualenvs_options = virtualEnvs.filter(
|
$scope.custom_virtualenvs_options = virtualEnvs.filter(
|
||||||
@@ -43,15 +41,18 @@ export default ['$scope', '$rootScope', '$location', '$stateParams',
|
|||||||
|
|
||||||
// Save
|
// Save
|
||||||
$scope.formSave = function() {
|
$scope.formSave = function() {
|
||||||
|
var fld, params = {};
|
||||||
Wait('start');
|
Wait('start');
|
||||||
|
for (fld in form.fields) {
|
||||||
|
params[fld] = $scope[fld];
|
||||||
|
}
|
||||||
|
if (!params.max_hosts || params.max_hosts === '') {
|
||||||
|
params.max_hosts = 0;
|
||||||
|
}
|
||||||
var url = GetBasePath(base);
|
var url = GetBasePath(base);
|
||||||
url += (base !== 'organizations') ? $stateParams.project_id + '/organizations/' : '';
|
url += (base !== 'organizations') ? $stateParams.project_id + '/organizations/' : '';
|
||||||
Rest.setUrl(url);
|
Rest.setUrl(url);
|
||||||
Rest.post({
|
Rest.post(params)
|
||||||
name: $scope.name,
|
|
||||||
description: $scope.description,
|
|
||||||
custom_virtualenv: $scope.custom_virtualenv
|
|
||||||
})
|
|
||||||
.then(({data}) => {
|
.then(({data}) => {
|
||||||
const organization_id = data.id,
|
const organization_id = data.id,
|
||||||
instance_group_url = data.related.instance_groups;
|
instance_group_url = data.related.instance_groups;
|
||||||
@@ -73,7 +74,7 @@ export default ['$scope', '$rootScope', '$location', '$stateParams',
|
|||||||
let explanation = _.has(data, "name") ? data.name[0] : "";
|
let explanation = _.has(data, "name") ? data.name[0] : "";
|
||||||
ProcessErrors($scope, data, status, OrganizationForm, {
|
ProcessErrors($scope, data, status, OrganizationForm, {
|
||||||
hdr: 'Error!',
|
hdr: 'Error!',
|
||||||
msg: `Failed to save organization. PUT status: ${status}. ${explanation}`
|
msg: `Failed to save organization. POST status: ${status}. ${explanation}`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.$emit("HideOrgListHeader");
|
|
||||||
$scope.instance_groups = InstanceGroupsData;
|
$scope.instance_groups = InstanceGroupsData;
|
||||||
const virtualEnvs = ConfigData.custom_virtualenvs || [];
|
const virtualEnvs = ConfigData.custom_virtualenvs || [];
|
||||||
$scope.custom_virtualenvs_visible = virtualEnvs.length > 1;
|
$scope.custom_virtualenvs_visible = virtualEnvs.length > 1;
|
||||||
@@ -57,7 +56,7 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
|
|||||||
|
|
||||||
$scope.organization_name = data.name;
|
$scope.organization_name = data.name;
|
||||||
for (fld in form.fields) {
|
for (fld in form.fields) {
|
||||||
if (data[fld]) {
|
if (typeof data[fld] !== 'undefined') {
|
||||||
$scope[fld] = data[fld];
|
$scope[fld] = data[fld];
|
||||||
master[fld] = data[fld];
|
master[fld] = data[fld];
|
||||||
}
|
}
|
||||||
@@ -98,6 +97,9 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
|
|||||||
for (fld in form.fields) {
|
for (fld in form.fields) {
|
||||||
params[fld] = $scope[fld];
|
params[fld] = $scope[fld];
|
||||||
}
|
}
|
||||||
|
if (!params.max_hosts || params.max_hosts === '') {
|
||||||
|
params.max_hosts = 0;
|
||||||
|
}
|
||||||
Rest.setUrl(defaultUrl + id + '/');
|
Rest.setUrl(defaultUrl + id + '/');
|
||||||
Rest.put(params)
|
Rest.put(params)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ export default ['NotificationsList', 'i18n',
|
|||||||
dataPlacement: 'right',
|
dataPlacement: 'right',
|
||||||
ngDisabled: '!(organization_obj.summary_fields.user_capabilities.edit || canAdd)',
|
ngDisabled: '!(organization_obj.summary_fields.user_capabilities.edit || canAdd)',
|
||||||
ngShow: 'custom_virtualenvs_visible'
|
ngShow: 'custom_virtualenvs_visible'
|
||||||
|
},
|
||||||
|
max_hosts: {
|
||||||
|
label: i18n._('Max Hosts'),
|
||||||
|
type: 'number',
|
||||||
|
integer: true,
|
||||||
|
min: 0,
|
||||||
|
max: 2147483647,
|
||||||
|
default: 0,
|
||||||
|
spinner: true,
|
||||||
|
dataTitle: i18n._('Max Hosts'),
|
||||||
|
dataPlacement: 'right',
|
||||||
|
dataContainer: 'body',
|
||||||
|
awPopOver: "<p>" + i18n._("The maximum number of hosts allowed to be managed by this organization. Value defaults to 0 which means no limit. Refer to the Ansible documentation for more details.") + "</p>",
|
||||||
|
ngDisabled: '!current_user.is_superuser',
|
||||||
|
ngShow: 'BRAND_NAME === "Tower"'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user