Merge branch 'release_3.0.2' into devel

* release_3.0.2: (126 commits)
  Disable permissions tab in Credential > Edit form if Credential is private (#3288)
  Tweaked the popover text for job and skip tags on JT add/edit
  Workaround a cascade setnull polymorphic issue
  flake8
  Fixed old test expectations
  Made it so the credential organization field can't be changed
  Skip some unit tests
  Fixed org auditor visibility of team credentials
  Fix sosreport
  fix credential kind options for list
  interpret any code below 300 as success
  bail when status code is over 300
  Make CloudForms inventory_script work
  Use no_log when handling passwords
  Prevent ignored task from being displayed as failing.
  making ec2 cred optional on group->edit
  making ec2 credential optional for ec2 inventory
  Revert "Fix to ensure org auditors can see team credentials"
  Fixed team credential list to work with corrected permissions
  Making the username and password fields optional
  ...
This commit is contained in:
Matthew Jones 2016-08-18 22:52:55 -04:00
commit 8893f8278a
96 changed files with 1151 additions and 570 deletions

View File

@ -689,30 +689,24 @@ reprepro: deb-build/$(DEB_NVRA).deb reprepro/conf
amazon-ebs:
cd packaging/packer && $(PACKER) build -only $@ $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
# virtualbox
virtualbox-ovf: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box
# Vagrant box using virtualbox provider
vagrant-virtualbox: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box
packaging/packer/ansible-tower-$(VERSION)-virtualbox.box: packaging/packer/output-virtualbox-iso/centos-7.ovf
cd packaging/packer && $(PACKER) build -only virtualbox-ovf $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
packaging/packer/output-virtualbox-iso/centos-6.ovf:
cd packaging/packer && $(PACKER) build packer-centos-6.json
packaging/packer/output-virtualbox-iso/centos-7.ovf:
cd packaging/packer && $(PACKER) build -only virtualbox-iso packer-centos-7.json
# virtualbox-iso: packaging/packer/output-virtualbox-iso/centos-6.ovf
virtualbox-iso: packaging/packer/output-virtualbox-iso/centos-7.ovf
# vmware
# Vagrant box using VMware provider
vagrant-vmware: packaging/packer/ansible-tower-$(VERSION)-vmware.box
packaging/packer/output-vmware-iso/centos-7.vmx:
cd packaging/packer && $(PACKER) build -only vmware-iso packer-centos-7.json
vmware-iso: packaging/packer/output-vmware-iso/centos-7.vmx
vmware-vmx: packaging/packer/ansible-tower-$(VERSION)-vmx/ansible-tower-$(VERSION).vmx
packaging/packer/ansible-tower-$(VERSION)-vmx/ansible-tower-$(VERSION).vmx: packaging/packer/output-vmware-iso/centos-7.vmx
packaging/packer/ansible-tower-$(VERSION)-vmware.box: packaging/packer/output-vmware-iso/centos-7.vmx
cd packaging/packer && $(PACKER) build -only vmware-vmx $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
# TODO - figure out how to build the front-end and python requirements with

View File

@ -526,8 +526,10 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
serializer_class = InventorySourceSerializer
elif isinstance(obj, JobTemplate):
serializer_class = JobTemplateSerializer
elif isinstance(obj, SystemJobTemplate):
serializer_class = SystemJobTemplateSerializer
if serializer_class:
serializer = serializer_class(instance=obj)
serializer = serializer_class(instance=obj, context=self.context)
return serializer.to_representation(obj)
else:
return super(UnifiedJobTemplateSerializer, self).to_representation(obj)
@ -590,7 +592,7 @@ class UnifiedJobSerializer(BaseSerializer):
elif isinstance(obj, SystemJob):
serializer_class = SystemJobSerializer
if serializer_class:
serializer = serializer_class(instance=obj)
serializer = serializer_class(instance=obj, context=self.context)
ret = serializer.to_representation(obj)
else:
ret = super(UnifiedJobSerializer, self).to_representation(obj)
@ -637,7 +639,7 @@ class UnifiedJobListSerializer(UnifiedJobSerializer):
elif isinstance(obj, SystemJob):
serializer_class = SystemJobListSerializer
if serializer_class:
serializer = serializer_class(instance=obj)
serializer = serializer_class(instance=obj, context=self.context)
ret = serializer.to_representation(obj)
else:
ret = super(UnifiedJobListSerializer, self).to_representation(obj)
@ -1285,7 +1287,8 @@ class CustomInventoryScriptSerializer(BaseSerializer):
request = self.context.get('request', None)
if request.user not in obj.admin_role and \
not request.user.is_superuser and \
not request.user.is_system_auditor:
not request.user.is_system_auditor and \
not (obj.organization is not None and request.user in obj.organization.auditor_role):
ret['script'] = None
return ret
@ -1713,19 +1716,21 @@ class CredentialSerializerCreate(CredentialSerializer):
attrs.pop(field)
if not owner_fields:
raise serializers.ValidationError({"detail": "Missing 'user', 'team', or 'organization'."})
elif len(owner_fields) > 1:
raise serializers.ValidationError({"detail": "Expecting exactly one of 'user', 'team', or 'organization'."})
return super(CredentialSerializerCreate, self).validate(attrs)
def create(self, validated_data):
user = validated_data.pop('user', None)
team = validated_data.pop('team', None)
if team:
validated_data['organization'] = team.organization
credential = super(CredentialSerializerCreate, self).create(validated_data)
if user:
credential.admin_role.members.add(user)
if team:
credential.admin_role.parents.add(team.member_role)
if not credential.organization or team.organization.id != credential.organization.id:
raise serializers.ValidationError({"detail": "Credential organization must be set and match before assigning to a team"})
credential.admin_role.parents.add(team.admin_role)
credential.use_role.parents.add(team.member_role)
return credential
@ -1823,7 +1828,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer):
class Meta:
model = JobTemplate
fields = ('*', 'host_config_key', 'ask_variables_on_launch', 'ask_limit_on_launch',
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch',
'ask_tags_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch',
'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'allow_simultaneous')
def get_related(self, obj):
@ -1907,6 +1912,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
passwords_needed_to_start = serializers.ReadOnlyField()
ask_variables_on_launch = serializers.ReadOnlyField()
ask_limit_on_launch = serializers.ReadOnlyField()
ask_skip_tags_on_launch = serializers.ReadOnlyField()
ask_tags_on_launch = serializers.ReadOnlyField()
ask_job_type_on_launch = serializers.ReadOnlyField()
ask_inventory_on_launch = serializers.ReadOnlyField()
@ -1915,8 +1921,8 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
class Meta:
model = Job
fields = ('*', 'job_template', 'passwords_needed_to_start', 'ask_variables_on_launch',
'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_job_type_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch')
'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_skip_tags_on_launch',
'ask_job_type_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch')
def get_related(self, obj):
res = super(JobSerializer, self).get_related(obj)
@ -1977,7 +1983,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
return ret
if 'job_template' in ret and not obj.job_template:
ret['job_template'] = None
if obj.job_template and obj.job_template.survey_enabled and 'extra_vars' in ret:
if 'extra_vars' in ret:
ret['extra_vars'] = obj.display_extra_vars()
return ret
@ -2281,14 +2287,15 @@ class JobLaunchSerializer(BaseSerializer):
fields = ('can_start_without_user_input', 'passwords_needed_to_start',
'extra_vars', 'limit', 'job_tags', 'skip_tags', 'job_type', 'inventory',
'credential', 'ask_variables_on_launch', 'ask_tags_on_launch',
'ask_job_type_on_launch', 'ask_limit_on_launch',
'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_limit_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch',
'survey_enabled', 'variables_needed_to_start',
'credential_needed_to_start', 'inventory_needed_to_start',
'job_template_data', 'defaults')
read_only_fields = ('ask_variables_on_launch', 'ask_limit_on_launch',
'ask_tags_on_launch', 'ask_job_type_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch')
read_only_fields = (
'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch',
'ask_skip_tags_on_launch', 'ask_job_type_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch')
extra_kwargs = {
'credential': {'write_only': True,},
'limit': {'write_only': True,},
@ -2675,6 +2682,8 @@ class ActivityStreamSerializer(BaseSerializer):
fval = getattr(thisItem, field, None)
if fval is not None:
thisItemDict[field] = fval
if fk == 'group':
thisItemDict['inventory_id'] = getattr(thisItem, 'inventory_id', None)
summary_fields[fk].append(thisItemDict)
except ObjectDoesNotExist:
pass

View File

@ -8,6 +8,8 @@ The response will include the following fields:
configured to prompt for variables upon launch (boolean, read-only)
* `ask_tags_on_launch`: Flag indicating whether the job_template is
configured to prompt for tags upon launch (boolean, read-only)
* `ask_skip_tags_on_launch`: Flag indicating whether the job_template is
configured to prompt for skip_tags upon launch (boolean, read-only)
* `ask_job_type_on_launch`: Flag indicating whether the job_template is
configured to prompt for job_type upon launch (boolean, read-only)
* `ask_limit_on_launch`: Flag indicating whether the job_template is

View File

@ -1,4 +1,3 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
@ -201,7 +200,7 @@ class ApiV1ConfigView(APIView):
'''Return various sitewide configuration settings.'''
license_reader = TaskSerializer()
license_data = license_reader.from_database(show_key=request.user.is_superuser)
license_data = license_reader.from_database(show_key=request.user.is_superuser or request.user.is_system_auditor)
if license_data and 'features' in license_data and 'activity_streams' in license_data['features']:
license_data['features']['activity_streams'] &= tower_settings.ACTIVITY_STREAM_ENABLED
@ -225,7 +224,10 @@ class ApiV1ConfigView(APIView):
user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys())
data['user_ldap_fields'] = user_ldap_fields
if request.user.is_superuser or Organization.accessible_objects(request.user, 'admin_role').exists():
if request.user.is_superuser \
or request.user.is_system_auditor \
or Organization.accessible_objects(request.user, 'admin_role').exists() \
or Organization.accessible_objects(request.user, 'auditor_role').exists():
data.update(dict(
project_base_dir = settings.PROJECTS_ROOT,
project_local_paths = Project.get_local_path_choices(),
@ -880,12 +882,19 @@ class TeamRolesList(SubListCreateAttachDetachAPIView):
data = dict(msg="Role 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
role = Role.objects.get(pk=sub_id)
content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == content_type:
role = get_object_or_400(Role, pk=sub_id)
org_content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == org_content_type:
data = dict(msg="You cannot assign an Organization role as a child role for a Team.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
team = get_object_or_404(Team, pk=self.kwargs['pk'])
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if not role.content_object.organization or role.content_object.organization.id != team.organization.id:
data = dict(msg="You cannot grant credential access to a team when the Organization field isn't set, or belongs to a different organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(TeamRolesList, self).post(request, *args, **kwargs)
class TeamObjectRolesList(SubListAPIView):
@ -1209,7 +1218,24 @@ class UserRolesList(SubListCreateAttachDetachAPIView):
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if sub_id == self.request.user.admin_role.pk:
raise PermissionDenied('You may not remove your own admin_role.')
raise PermissionDenied('You may not perform any action with your own admin_role.')
user = get_object_or_400(User, pk=self.kwargs['pk'])
role = get_object_or_400(Role, pk=sub_id)
user_content_type = ContentType.objects.get_for_model(User)
if role.content_type == user_content_type:
raise PermissionDenied('You may not change the membership of a users admin_role')
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if role.content_object.organization and user not in role.content_object.organization.member_role:
data = dict(msg="You cannot grant credential access to a user not in the credentials' organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if not role.content_object.organization and not request.user.is_superuser:
data = dict(msg="You cannot grant private credential access to another user")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(UserRolesList, self).post(request, *args, **kwargs)
@ -1388,8 +1414,8 @@ class TeamCredentialsList(SubListCreateAPIView):
self.check_parent_access(team)
visible_creds = Credential.accessible_objects(self.request.user, 'read_role')
team_creds = Credential.objects.filter(admin_role__parents=team.member_role)
return team_creds & visible_creds
team_creds = Credential.objects.filter(Q(use_role__parents=team.member_role) | Q(admin_role__parents=team.member_role))
return (team_creds & visible_creds).distinct()
class OrganizationCredentialList(SubListCreateAPIView):
@ -2975,7 +3001,17 @@ class JobJobTasksList(BaseJobEventsList):
# and these are what we're interested in here.
STARTING_EVENTS = ('playbook_on_task_start', 'playbook_on_setup')
queryset = JobEvent.start_event_queryset(parent_task, STARTING_EVENTS)
# We need to pull information about each start event.
#
# This is super tricky, because this table has a one-to-many
# relationship with itself (parent-child), and we're getting
# information for an arbitrary number of children. This means we
# need stats on grandchildren, sorted by child.
queryset = (JobEvent.objects.filter(parent__parent=parent_task,
parent__event__in=STARTING_EVENTS)
.values('parent__id', 'event', 'changed', 'failed')
.annotate(num=Count('event'))
.order_by('parent__id'))
# The data above will come back in a list, but we are going to
# want to access it based on the parent id, so map it into a
@ -3034,10 +3070,13 @@ class JobJobTasksList(BaseJobEventsList):
# make appropriate changes to the task data.
for child_data in data.get(task_start_event.id, []):
if child_data['event'] == 'runner_on_failed':
task_data['failed'] = True
task_data['host_count'] += child_data['num']
task_data['reported_hosts'] += child_data['num']
task_data['failed_count'] += child_data['num']
if child_data['failed']:
task_data['failed'] = True
task_data['failed_count'] += child_data['num']
else:
task_data['skipped_count'] += child_data['num']
elif child_data['event'] == 'runner_on_ok':
task_data['host_count'] += child_data['num']
task_data['reported_hosts'] += child_data['num']
@ -3625,7 +3664,6 @@ class RoleDetail(RetrieveAPIView):
model = Role
serializer_class = RoleSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True
@ -3648,6 +3686,26 @@ class RoleUsersList(SubListCreateAttachDetachAPIView):
if not sub_id:
data = dict(msg="User 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
user = get_object_or_400(User, pk=sub_id)
role = self.get_parent_object()
if role == self.request.user.admin_role:
raise PermissionDenied('You may not perform any action with your own admin_role.')
user_content_type = ContentType.objects.get_for_model(User)
if role.content_type == user_content_type:
raise PermissionDenied('You may not change the membership of a users admin_role')
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if role.content_object.organization and user not in role.content_object.organization.member_role:
data = dict(msg="You cannot grant credential access to a user not in the credentials' organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if not role.content_object.organization and not request.user.is_superuser:
data = dict(msg="You cannot grant private credential access to another user")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(RoleUsersList, self).post(request, *args, **kwargs)
@ -3672,13 +3730,20 @@ class RoleTeamsList(SubListAPIView):
data = dict(msg="Team 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
team = get_object_or_400(Team, pk=sub_id)
role = Role.objects.get(pk=self.kwargs['pk'])
content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == content_type:
organization_content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == organization_content_type:
data = dict(msg="You cannot assign an Organization role as a child role for a Team.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
team = Team.objects.get(pk=sub_id)
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if not role.content_object.organization or role.content_object.organization.id != team.organization.id:
data = dict(msg="You cannot grant credential access to a team when the Organization field isn't set, or belongs to a different organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
action = 'attach'
if request.data.get('disassociate', None):
action = 'unattach'

View File

@ -428,8 +428,8 @@ class HostAccess(BaseAccess):
return obj and self.user in obj.inventory.read_role
def can_add(self, data):
if not data or 'inventory' not in data:
return False
if not data: # So the browseable API will work
return Inventory.accessible_objects(self.user, 'admin_role').exists()
# Checks for admin or change permission on inventory.
inventory_pk = get_pk_from_dict(data, 'inventory')
@ -654,23 +654,14 @@ class CredentialAccess(BaseAccess):
if not obj:
return False
# Check access to organizations
organization_pk = get_pk_from_dict(data, 'organization')
if data and 'organization' in data and organization_pk != getattr(obj, 'organization_id', None):
if organization_pk:
# admin permission to destination organization is mandatory
new_organization_obj = get_object_or_400(Organization, pk=organization_pk)
if self.user not in new_organization_obj.admin_role:
return False
# admin permission to existing organization is also mandatory
if obj.organization:
if self.user not in obj.organization.admin_role:
return False
if obj.organization:
if self.user in obj.organization.admin_role:
return True
# Cannot change the organization for a credential after it's been created
if data and 'organization' in data:
organization_pk = get_pk_from_dict(data, 'organization')
if (organization_pk and (not obj.organization or organization_pk != obj.organization.id)) \
or (not organization_pk and obj.organization):
return False
print(self.user in obj.admin_role)
return self.user in obj.admin_role
def can_delete(self, obj):
@ -981,8 +972,11 @@ class JobTemplateAccess(BaseAccess):
field_whitelist = [
'name', 'description', 'forks', 'limit', 'verbosity', 'extra_vars',
'job_tags', 'force_handlers', 'skip_tags', 'ask_variables_on_launch',
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch',
'ask_credential_on_launch', 'survey_enabled'
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_skip_tags_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled',
# These fields are ignored, but it is convenient for QA to allow clients to post them
'last_job_run', 'created', 'modified',
]
for k, v in data.items():
@ -1089,7 +1083,8 @@ class JobAccess(BaseAccess):
def can_delete(self, obj):
if obj.inventory is not None and self.user in obj.inventory.organization.admin_role:
return True
if obj.project is not None and self.user in obj.project.organization.admin_role:
if (obj.project is not None and obj.project.organization is not None and
self.user in obj.project.organization.admin_role):
return True
return False
@ -1299,9 +1294,11 @@ class UnifiedJobTemplateAccess(BaseAccess):
project_qs = self.user.get_queryset(Project).filter(scm_type__in=[s[0] for s in Project.SCM_TYPE_CHOICES])
inventory_source_qs = self.user.get_queryset(InventorySource).filter(source__in=CLOUD_INVENTORY_SOURCES)
job_template_qs = self.user.get_queryset(JobTemplate)
system_job_template_qs = self.user.get_queryset(SystemJobTemplate)
qs = qs.filter(Q(Project___in=project_qs) |
Q(InventorySource___in=inventory_source_qs) |
Q(JobTemplate___in=job_template_qs))
Q(JobTemplate___in=job_template_qs) |
Q(systemjobtemplate__in=system_job_template_qs))
qs = qs.select_related(
'created_by',
'modified_by',
@ -1569,21 +1566,22 @@ class ActivityStreamAccess(BaseAccess):
inventory_set = Inventory.accessible_objects(self.user, 'read_role')
credential_set = Credential.accessible_objects(self.user, 'read_role')
organization_set = Organization.accessible_objects(self.user, 'read_role')
admin_of_orgs = Organization.accessible_objects(self.user, 'admin_role')
group_set = Group.objects.filter(inventory__in=inventory_set)
auditing_orgs = (
Organization.accessible_objects(self.user, 'admin_role') |
Organization.accessible_objects(self.user, 'auditor_role')
).distinct().values_list('id', flat=True)
project_set = Project.accessible_objects(self.user, 'read_role')
jt_set = JobTemplate.accessible_objects(self.user, 'read_role')
team_set = Team.accessible_objects(self.user, 'read_role')
return qs.filter(
Q(ad_hoc_command__inventory__in=inventory_set) |
Q(user__in=organization_set.values('member_role__members')) |
Q(user__in=auditing_orgs.values('member_role__members')) |
Q(user=self.user) |
Q(organization__in=organization_set) |
Q(organization__in=auditing_orgs) |
Q(inventory__in=inventory_set) |
Q(host__inventory__in=inventory_set) |
Q(group__in=group_set) |
Q(group__inventory__in=inventory_set) |
Q(inventory_source__inventory__in=inventory_set) |
Q(inventory_update__inventory_source__inventory__in=inventory_set) |
Q(credential__in=credential_set) |
@ -1592,10 +1590,10 @@ class ActivityStreamAccess(BaseAccess):
Q(project_update__project__in=project_set) |
Q(job_template__in=jt_set) |
Q(job__job_template__in=jt_set) |
Q(notification_template__organization__in=admin_of_orgs) |
Q(notification__notification_template__organization__in=admin_of_orgs) |
Q(label__organization__in=organization_set) |
Q(role__in=Role.visible_roles(self.user))
Q(notification_template__organization__in=auditing_orgs) |
Q(notification__notification_template__organization__in=auditing_orgs) |
Q(label__organization__in=auditing_orgs) |
Q(role__in=Role.visible_roles(self.user) if auditing_orgs else [])
).distinct()
def can_add(self, data):
@ -1667,14 +1665,8 @@ class RoleAccess(BaseAccess):
if self.user.is_superuser or self.user.is_system_auditor:
return True
if obj.object_id:
sister_roles = Role.objects.filter(
content_type = obj.content_type,
object_id = obj.object_id
)
else:
sister_roles = obj
return self.user.roles.filter(descendents__in=sister_roles).exists()
return Role.filter_visible_roles(
self.user, Role.objects.filter(pk=obj.id)).exists()
def can_add(self, obj, data):
# Unsupported for now

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import awx.main.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0028_v300_org_team_cascade'),
]
operations = [
migrations.AddField(
model_name='jobtemplate',
name='ask_skip_tags_on_launch',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0029_v302_add_ask_skip_tags'),
]
operations = [
migrations.AddField(
model_name='job',
name='survey_passwords',
field=jsonfield.fields.JSONField(default={}, editable=False, blank=True),
),
]

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from awx.main.migrations import _save_password_keys
from awx.main.migrations import _migration_utils as migration_utils
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0030_v302_job_survey_passwords'),
]
operations = [
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
migrations.RunPython(_save_password_keys.migrate_survey_passwords),
]

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
from awx.main.migrations import _rbac as rbac
from awx.main.migrations import _migration_utils as migration_utils
import awx.main.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0031_v302_migrate_survey_passwords'),
]
operations = [
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
migrations.AlterField(
model_name='credential',
name='admin_role',
field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_administrator', b'organization.admin_role'], to='main.Role', null=b'True'),
),
migrations.AlterField(
model_name='credential',
name='use_role',
field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'),
),
migrations.RunPython(rbac.rebuild_role_hierarchy),
]

View File

@ -0,0 +1,27 @@
def survey_password_variables(survey_spec):
vars = []
# Get variables that are type password
if 'spec' not in survey_spec:
return vars
for survey_element in survey_spec['spec']:
if 'type' in survey_element and survey_element['type'] == 'password':
vars.append(survey_element['variable'])
return vars
def migrate_survey_passwords(apps, schema_editor):
'''Take the output of the Job Template password list for all that
have a survey enabled, and then save it into the job model.
'''
Job = apps.get_model('main', 'Job')
for job in Job.objects.iterator():
if not job.job_template:
continue
jt = job.job_template
if jt.survey_spec is not None and jt.survey_enabled:
password_list = survey_password_variables(jt.survey_spec)
hide_password_dict = {}
for password in password_list:
hide_password_dict[password] = "$encrypted$"
job.survey_passwords = hide_password_dict
job.save()

View File

@ -215,11 +215,11 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
admin_role = ImplicitRoleField(
parent_role=[
'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
'organization.admin_role',
],
)
use_role = ImplicitRoleField(
parent_role=[
'organization.admin_role',
'admin_role',
]
)

View File

@ -6,6 +6,7 @@ import hmac
import json
import yaml
import logging
import time
from urlparse import urljoin
# Django
@ -26,7 +27,7 @@ from awx.main.models.unified_jobs import * # noqa
from awx.main.models.notifications import NotificationTemplate
from awx.main.utils import decrypt_field, ignore_inventory_computed_fields
from awx.main.utils import emit_websocket_notification
from awx.main.redact import PlainTextCleaner, REPLACE_STR
from awx.main.redact import PlainTextCleaner
from awx.main.conf import tower_settings
from awx.main.fields import ImplicitRoleField
from awx.main.models.mixins import ResourceMixin
@ -199,6 +200,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
blank=True,
default=False,
)
ask_skip_tags_on_launch = models.BooleanField(
blank=True,
default=False,
)
ask_job_type_on_launch = models.BooleanField(
blank=True,
default=False,
@ -244,7 +249,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
'playbook', 'credential', 'cloud_credential', 'network_credential', 'forks', 'schedule',
'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type',
'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled',
'labels',]
'labels', 'survey_passwords']
def resource_validation_data(self):
'''
@ -418,7 +423,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
extra_vars=self.ask_variables_on_launch,
limit=self.ask_limit_on_launch,
job_tags=self.ask_tags_on_launch,
skip_tags=self.ask_tags_on_launch,
skip_tags=self.ask_skip_tags_on_launch,
job_type=self.ask_job_type_on_launch,
inventory=self.ask_inventory_on_launch,
credential=self.ask_credential_on_launch
@ -442,11 +447,21 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
if field == 'extra_vars' and self.survey_enabled and self.survey_spec:
# Accept vars defined in the survey and no others
survey_vars = [question['variable'] for question in self.survey_spec.get('spec', [])]
for key in kwargs[field]:
extra_vars = kwargs[field]
if isinstance(extra_vars, basestring):
try:
extra_vars = json.loads(extra_vars)
except (ValueError, TypeError):
try:
extra_vars = yaml.safe_load(extra_vars)
assert isinstance(extra_vars, dict)
except (yaml.YAMLError, TypeError, AttributeError, AssertionError):
extra_vars = {}
for key in extra_vars:
if key in survey_vars:
prompted_fields[field][key] = kwargs[field][key]
prompted_fields[field][key] = extra_vars[key]
else:
ignored_fields[field][key] = kwargs[field][key]
ignored_fields[field][key] = extra_vars[key]
else:
ignored_fields[field] = kwargs[field]
@ -509,6 +524,11 @@ class Job(UnifiedJob, JobOptions):
editable=False,
through='JobHostSummary',
)
survey_passwords = JSONField(
blank=True,
default={},
editable=False,
)
@classmethod
def _get_parent_field_name(cls):
@ -550,6 +570,12 @@ class Job(UnifiedJob, JobOptions):
return self.job_template.ask_tags_on_launch
return False
@property
def ask_skip_tags_on_launch(self):
if self.job_template is not None:
return self.job_template.ask_skip_tags_on_launch
return False
@property
def ask_job_type_on_launch(self):
if self.job_template is not None:
@ -670,9 +696,17 @@ class Job(UnifiedJob, JobOptions):
dependencies.append(source.create_inventory_update(launch_type='dependency'))
return dependencies
def notification_data(self):
def notification_data(self, block=5):
data = super(Job, self).notification_data()
all_hosts = {}
# NOTE: Probably related to job event slowness, remove at some point -matburt
if block:
summaries = self.job_host_summaries.all()
while block > 0 and not len(summaries):
time.sleep(1)
block -= 1
else:
summaries = self.job_host_summaries.all()
for h in self.job_host_summaries.all():
all_hosts[h.host_name] = dict(failed=h.failed,
changed=h.changed,
@ -711,16 +745,12 @@ class Job(UnifiedJob, JobOptions):
'''
Hides fields marked as passwords in survey.
'''
if self.extra_vars and self.job_template and self.job_template.survey_enabled:
try:
extra_vars = json.loads(self.extra_vars)
for key in self.job_template.survey_password_variables():
if key in extra_vars:
extra_vars[key] = REPLACE_STR
return json.dumps(extra_vars)
except ValueError:
pass
return self.extra_vars
if self.survey_passwords:
extra_vars = json.loads(self.extra_vars)
extra_vars.update(self.survey_passwords)
return json.dumps(extra_vars)
else:
return self.extra_vars
def _survey_search_and_replace(self, content):
# Use job template survey spec to identify password fields.

View File

@ -32,7 +32,7 @@ from djcelery.models import TaskMeta
from awx.main.models.base import * # noqa
from awx.main.models.schedules import Schedule
from awx.main.utils import decrypt_field, emit_websocket_notification, _inventory_updates
from awx.main.redact import UriCleaner
from awx.main.redact import UriCleaner, REPLACE_STR
__all__ = ['UnifiedJobTemplate', 'UnifiedJob']
@ -81,6 +81,9 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
ALL_STATUS_CHOICES = OrderedDict(PROJECT_STATUS_CHOICES + INVENTORY_SOURCE_STATUS_CHOICES + JOB_TEMPLATE_STATUS_CHOICES + DEPRECATED_STATUS_CHOICES).items()
# NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229
_base_manager = models.Manager()
class Meta:
app_label = 'main'
unique_together = [('polymorphic_ctype', 'name')]
@ -343,6 +346,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
create_kwargs[field_name] = getattr(self, field_name)
new_kwargs = self._update_unified_job_kwargs(**create_kwargs)
unified_job = unified_job_class(**new_kwargs)
# For JobTemplate-based jobs with surveys, save list for perma-redaction
if (hasattr(self, 'survey_spec') and getattr(self, 'survey_enabled', False) and
not getattr(unified_job, 'survey_passwords', False)):
password_list = self.survey_password_variables()
hide_password_dict = {}
for password in password_list:
hide_password_dict[password] = REPLACE_STR
unified_job.survey_passwords = hide_password_dict
unified_job.save()
for field_name, src_field_value in m2m_fields.iteritems():
dest_field = getattr(unified_job, field_name)
@ -367,6 +378,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
PASSWORD_FIELDS = ('start_args',)
# NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229
_base_manager = models.Manager()
class Meta:
app_label = 'main'

View File

@ -214,15 +214,18 @@ def handle_work_success(self, result, task_actual):
friendly_name = "System Job"
else:
return
notification_body = instance.notification_data()
notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name,
task_actual['id'],
smart_str(instance_name),
notification_body['url'])
notification_body['friendly_name'] = friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in set(notification_templates.get('success', []) + notification_templates.get('any', []))],
job_id=task_actual['id'])
all_notification_templates = set(notification_templates.get('success', []) + notification_templates.get('any', []))
if len(all_notification_templates):
notification_body = instance.notification_data()
notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name,
task_actual['id'],
smart_str(instance_name),
notification_body['url'])
notification_body['friendly_name'] = friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in all_notification_templates],
job_id=task_actual['id'])
@task(bind=True)
def handle_work_error(self, task_id, subtasks=None):
@ -277,15 +280,18 @@ def handle_work_error(self, task_id, subtasks=None):
(first_task_type, first_task_name, first_task_id)
instance.save()
instance.socketio_emit_status("failed")
notification_body = first_task.notification_data()
notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name,
first_task_id,
smart_str(first_task_name),
notification_body['url'])
notification_body['friendly_name'] = first_task_friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in set(notification_templates.get('error', []) + notification_templates.get('any', []))],
job_id=first_task_id)
all_notification_templates = set(notification_templates.get('error', []) + notification_templates.get('any', []))
if len(all_notification_templates):
notification_body = first_task.notification_data()
notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name,
first_task_id,
smart_str(first_task_name),
notification_body['url'])
notification_body['friendly_name'] = first_task_friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in all_notification_templates],
job_id=first_task_id)
@task()

View File

@ -26,14 +26,14 @@ def survey_spec_factory():
return create_survey_spec
@pytest.fixture
def job_with_secret_key_factory(job_template_factory):
def job_template_with_survey_passwords_factory(job_template_factory):
def rf(persisted):
"Returns job with linked JT survey with password survey questions"
objects = job_template_factory('jt', organization='org1', survey=[
{'variable': 'submitter_email', 'type': 'text', 'default': 'foobar@redhat.com'},
{'variable': 'secret_key', 'default': '6kQngg3h8lgiSTvIEb21', 'type': 'password'},
{'variable': 'SSN', 'type': 'password'}], jobs=[1], persisted=persisted)
return objects.jobs[1]
{'variable': 'SSN', 'type': 'password'}], persisted=persisted)
return objects.job_template
return rf
@pytest.fixture
@ -43,3 +43,7 @@ def job_with_secret_key_unit(job_with_secret_key_factory):
@pytest.fixture
def get_ssh_version(mocker):
return mocker.patch('awx.main.tasks.get_ssh_version', return_value='OpenSSH_6.9p1, LibreSSL 2.1.8')
@pytest.fixture
def job_template_with_survey_passwords_unit(job_template_with_survey_passwords_factory):
return job_template_with_survey_passwords_factory(persisted=False)

View File

@ -68,9 +68,10 @@ def test_create_user_credential_via_user_credentials_list_xfail(post, alice, bob
#
@pytest.mark.django_db
def test_create_team_credential(post, get, team, org_admin, team_member):
def test_create_team_credential(post, get, team, organization, org_admin, team_member):
response = post(reverse('api:credential_list'), {
'team': team.id,
'organization': organization.id,
'name': 'Some name',
'username': 'someusername'
}, org_admin)
@ -94,25 +95,159 @@ def test_create_team_credential_via_team_credentials_list(post, get, team, org_a
assert response.data['count'] == 1
@pytest.mark.django_db
def test_create_team_credential_by_urelated_user_xfail(post, team, alice, team_member):
def test_create_team_credential_by_urelated_user_xfail(post, team, organization, alice, team_member):
response = post(reverse('api:credential_list'), {
'team': team.id,
'organization': organization.id,
'name': 'Some name',
'username': 'someusername'
}, alice)
assert response.status_code == 403
@pytest.mark.django_db
def test_create_team_credential_by_team_member_xfail(post, team, alice, team_member):
def test_create_team_credential_by_team_member_xfail(post, team, organization, alice, team_member):
# Members can't add credentials, only org admins.. for now?
response = post(reverse('api:credential_list'), {
'team': team.id,
'organization': organization.id,
'name': 'Some name',
'username': 'someusername'
}, team_member)
assert response.status_code == 403
#
# Permission granting
#
@pytest.mark.django_db
def test_grant_org_credential_to_org_user_through_role_users(post, credential, organization, org_admin, org_member):
credential.organization = organization
credential.save()
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': org_member.id
}, org_admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_org_credential_to_org_user_through_user_roles(post, credential, organization, org_admin, org_member):
credential.organization = organization
credential.save()
response = post(reverse('api:user_roles_list', args=(org_member.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_org_credential_to_non_org_user_through_role_users(post, credential, organization, org_admin, alice):
credential.organization = organization
credential.save()
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': alice.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_org_credential_to_non_org_user_through_user_roles(post, credential, organization, org_admin, alice):
credential.organization = organization
credential.save()
response = post(reverse('api:user_roles_list', args=(alice.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_private_credential_to_user_through_role_users(post, credential, alice, bob):
# normal users can't do this
credential.admin_role.members.add(alice)
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': bob.id
}, alice)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_private_credential_to_org_user_through_role_users(post, credential, org_admin, org_member):
# org admins can't either
credential.admin_role.members.add(org_admin)
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': org_member.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_sa_grant_private_credential_to_user_through_role_users(post, credential, admin, bob):
# but system admins can
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': bob.id
}, admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_private_credential_to_user_through_user_roles(post, credential, alice, bob):
# normal users can't do this
credential.admin_role.members.add(alice)
response = post(reverse('api:user_roles_list', args=(bob.id,)), {
'id': credential.use_role.id
}, alice)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_private_credential_to_org_user_through_user_roles(post, credential, org_admin, org_member):
# org admins can't either
credential.admin_role.members.add(org_admin)
response = post(reverse('api:user_roles_list', args=(org_member.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_sa_grant_private_credential_to_user_through_user_roles(post, credential, admin, bob):
# but system admins can
response = post(reverse('api:user_roles_list', args=(bob.id,)), {
'id': credential.use_role.id
}, admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_org_credential_to_team_through_role_teams(post, credential, organization, org_admin, org_auditor, team):
assert org_auditor not in credential.read_role
credential.organization = organization
credential.save()
response = post(reverse('api:role_teams_list', args=(credential.use_role.id,)), {
'id': team.id
}, org_admin)
assert response.status_code == 204
assert org_auditor in credential.read_role
@pytest.mark.django_db
def test_grant_org_credential_to_team_through_team_roles(post, credential, organization, org_admin, org_auditor, team):
assert org_auditor not in credential.read_role
credential.organization = organization
credential.save()
response = post(reverse('api:team_roles_list', args=(team.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 204
assert org_auditor in credential.read_role
@pytest.mark.django_db
def test_sa_grant_private_credential_to_team_through_role_teams(post, credential, admin, team):
# not even a system admin can grant a private cred to a team though
response = post(reverse('api:role_teams_list', args=(credential.use_role.id,)), {
'id': team.id
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_sa_grant_private_credential_to_team_through_team_roles(post, credential, admin, team):
# not even a system admin can grant a private cred to a team though
response = post(reverse('api:role_teams_list', args=(team.id,)), {
'id': credential.use_role.id
}, admin)
assert response.status_code == 400
#
# organization credentials
@ -177,6 +312,37 @@ def test_list_created_org_credentials(post, get, organization, org_admin, org_me
assert response.data['count'] == 0
@pytest.mark.django_db
def test_cant_change_organization(patch, credential, organization, org_admin):
credential.organization = organization
credential.save()
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name',
}, org_admin)
assert response.status_code == 200
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name2',
'organization': organization.id, # fine for it to be the same
}, org_admin)
assert response.status_code == 200
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name3',
'organization': None
}, org_admin)
assert response.status_code == 403
@pytest.mark.django_db
def test_cant_add_organization(patch, credential, organization, org_admin):
assert credential.organization is None
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name',
'organization': organization.id
}, org_admin)
assert response.status_code == 403
#
# Openstack Credentials
@ -224,33 +390,3 @@ def test_create_credential_missing_user_team_org_xfail(post, admin):
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_create_credential_with_user_and_org_xfail(post, organization, admin):
# Can only specify one of user, team, or organization
response = post(reverse('api:credential_list'), {
'name': 'Some name',
'username': 'someusername',
'user': admin.id,
'organization': organization.id,
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_create_credential_with_team_and_org_xfail(post, organization, team, admin):
response = post(reverse('api:credential_list'), {
'name': 'Some name',
'username': 'someusername',
'organization': organization.id,
'team': team.id,
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_create_credential_with_user_and_team_xfail(post, team, admin):
response = post(reverse('api:credential_list'), {
'name': 'Some name',
'username': 'someusername',
'user': admin.id,
'team': team.id,
}, admin)
assert response.status_code == 400

View File

@ -37,6 +37,7 @@ def job_template_prompts(project, inventory, machine_credential):
name='deploy-job-template',
ask_variables_on_launch=on_off,
ask_tags_on_launch=on_off,
ask_skip_tags_on_launch=on_off,
ask_job_type_on_launch=on_off,
ask_inventory_on_launch=on_off,
ask_limit_on_launch=on_off,
@ -54,6 +55,7 @@ def job_template_prompts_null(project):
name='deploy-job-template',
ask_variables_on_launch=True,
ask_tags_on_launch=True,
ask_skip_tags_on_launch=True,
ask_job_type_on_launch=True,
ask_inventory_on_launch=True,
ask_limit_on_launch=True,
@ -113,6 +115,20 @@ def test_job_accept_prompted_vars(runtime_data, job_template_prompts, post, admi
mock_job.signal_start.assert_called_once_with(**runtime_data)
@pytest.mark.django_db
@pytest.mark.job_runtime_vars
def test_job_accept_null_tags(job_template_prompts, post, admin_user, mocker):
job_template = job_template_prompts(True)
mock_job = mocker.MagicMock(spec=Job, id=968)
with mocker.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.create_unified_job', return_value=mock_job):
with mocker.patch('awx.api.serializers.JobSerializer.to_representation'):
post(reverse('api:job_template_launch', args=[job_template.pk]),
{'job_tags': '', 'skip_tags': ''}, admin_user, expect=201)
mock_job.signal_start.assert_called_once_with(job_tags='', skip_tags='')
@pytest.mark.django_db
@pytest.mark.job_runtime_vars
def test_job_accept_prompted_vars_null(runtime_data, job_template_prompts_null, post, rando, mocker):

View File

@ -3,12 +3,14 @@ import mock
# AWX
from awx.api.serializers import JobTemplateSerializer, JobLaunchSerializer
from awx.main.models.jobs import JobTemplate
from awx.main.models.jobs import JobTemplate, Job
from awx.main.models.projects import ProjectOptions
from awx.main.migrations import _save_password_keys as save_password_keys
# Django
from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
from django.apps import apps
@property
def project_playbooks(self):
@ -103,9 +105,10 @@ def test_edit_nonsenstive(patch, job_template_factory, alice):
'extra_vars': '--',
'job_tags': 'sometags',
'force_handlers': True,
'skip_tags': True,
'skip_tags': 'thistag,thattag',
'ask_variables_on_launch':True,
'ask_tags_on_launch':True,
'ask_skip_tags_on_launch':True,
'ask_job_type_on_launch':True,
'ask_inventory_on_launch':True,
'ask_credential_on_launch': True,
@ -347,3 +350,20 @@ def test_disallow_template_delete_on_running_job(job_template_factory, delete, a
objects.job_template.create_unified_job()
delete_response = delete(reverse('api:job_template_detail', args=[objects.job_template.pk]), user=admin_user)
assert delete_response.status_code == 409
@pytest.mark.django_db
def test_save_survey_passwords_to_job(job_template_with_survey_passwords):
"""Test that when a new job is created, the survey_passwords field is
given all of the passwords that exist in the JT survey"""
job = job_template_with_survey_passwords.create_unified_job()
assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'}
@pytest.mark.django_db
def test_save_survey_passwords_on_migration(job_template_with_survey_passwords):
"""Test that when upgrading to 3.0.2, the jobs connected to a JT that has
a survey with passwords in it, the survey passwords get saved to the
job survey_passwords field."""
Job.objects.create(job_template=job_template_with_survey_passwords)
save_password_keys.migrate_survey_passwords(apps, None)
job = job_template_with_survey_passwords.jobs.all()[0]
assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'}

View File

@ -193,7 +193,8 @@ def test_launch_with_non_empty_survey_spec_no_license(job_template_factory, post
@pytest.mark.django_db
@pytest.mark.survey
def test_redact_survey_passwords_in_activity_stream(job_with_secret_key):
def test_redact_survey_passwords_in_activity_stream(job_template_with_survey_passwords):
job_template_with_survey_passwords.create_unified_job()
AS_record = ActivityStream.objects.filter(object1='job').all()[0]
changes_dict = json.loads(AS_record.changes)
extra_vars = json.loads(changes_dict['extra_vars'])

View File

@ -160,7 +160,7 @@ def organization(instance):
@pytest.fixture
def credential():
return Credential.objects.create(kind='aws', name='test-cred')
return Credential.objects.create(kind='aws', name='test-cred', username='something', password='secret')
@pytest.fixture
def machine_credential():
@ -168,7 +168,7 @@ def machine_credential():
@pytest.fixture
def org_credential(organization):
return Credential.objects.create(kind='aws', name='test-cred', organization=organization)
return Credential.objects.create(kind='aws', name='test-cred', username='something', password='secret', organization=organization)
@pytest.fixture
def inventory(organization):
@ -206,8 +206,8 @@ def notification(notification_template):
subject='email subject')
@pytest.fixture
def job_with_secret_key(job_with_secret_key_factory):
return job_with_secret_key_factory(persisted=True)
def job_template_with_survey_passwords(job_template_with_survey_passwords_factory):
return job_template_with_survey_passwords_factory(persisted=True)
@pytest.fixture
def admin(user):

View File

@ -133,29 +133,6 @@ def test_org_credential_access_member(alice, org_credential, credential):
'description': 'New description.',
'organization': None})
@pytest.mark.django_db
def test_credential_access_org_permissions(
org_admin, org_member, organization, org_credential, credential):
credential.admin_role.members.add(org_admin)
credential.admin_role.members.add(org_member)
org_credential.admin_role.members.add(org_member)
access = CredentialAccess(org_admin)
member_access = CredentialAccess(org_member)
# Org admin can move their own credential into their org
assert access.can_change(credential, {'organization': organization.pk})
# Org member can not
assert not member_access.can_change(credential, {
'organization': organization.pk})
# Org admin can remove a credential from their org
assert access.can_change(org_credential, {'organization': None})
# Org member can not
assert not member_access.can_change(org_credential, {'organization': None})
assert not member_access.can_change(org_credential, {
'user': org_member.pk, 'organization': None})
@pytest.mark.django_db
def test_cred_job_template_xfail(user, deploy_jobtemplate):
' Personal credential migration '
@ -248,7 +225,6 @@ def test_single_cred_multi_job_template_multi_org(user, organizations, credentia
orgs[0].admin_role.members.add(a)
orgs[1].admin_role.members.add(a)
access = CredentialAccess(a)
rbac.migrate_credential(apps, None)
for jt in jts:
@ -256,11 +232,6 @@ def test_single_cred_multi_job_template_multi_org(user, organizations, credentia
credential.refresh_from_db()
assert jts[0].credential != jts[1].credential
assert access.can_change(jts[0].credential, {'organization': org.pk})
assert access.can_change(jts[1].credential, {'organization': org.pk})
orgs[0].admin_role.members.remove(a)
assert not access.can_change(jts[0].credential, {'organization': org.pk})
@pytest.mark.django_db
def test_cred_inventory_source(user, inventory, credential):

View File

@ -92,6 +92,12 @@ def test_null_related_delete_denied(normal_job, rando):
access = JobAccess(rando)
assert not access.can_delete(normal_job)
@pytest.mark.django_db
def test_delete_job_with_orphan_proj(normal_job, rando):
normal_job.project.organization = None
access = JobAccess(rando)
assert not access.can_delete(normal_job)
@pytest.mark.django_db
def test_inventory_org_admin_delete_allowed(normal_job, org_admin):
normal_job.project = None # do this so we test job->inventory->org->admin connection

View File

@ -0,0 +1,102 @@
import mock
from mock import PropertyMock
import pytest
from rest_framework.test import APIRequestFactory
from rest_framework.test import force_authenticate
from django.contrib.contenttypes.models import ContentType
from awx.api.views import (
RoleUsersList,
UserRolesList,
TeamRolesList,
)
from awx.main.models import (
User,
Role,
)
@pytest.mark.skip(reason="Seeing pk error, suspect weirdness in mocking requests")
@pytest.mark.parametrize("pk, err", [
(111, "not change the membership"),
(1, "may not perform"),
])
def test_user_roles_list_user_admin_role(pk, err):
with mock.patch('awx.api.views.get_object_or_400') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role, id=1, pk=1)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
with mock.patch('awx.api.views.User.admin_role', new_callable=PropertyMock, return_value=role_mock):
factory = APIRequestFactory()
view = UserRolesList.as_view()
user = User(username="root", is_superuser=True)
request = factory.post("/user/1/roles", {'id':pk}, format="json")
force_authenticate(request, user)
response = view(request)
response.render()
assert response.status_code == 403
assert err in response.content
@pytest.mark.skip(reason="db access or mocking needed for new tests in role assignment code")
@pytest.mark.parametrize("admin_role, err", [
(True, "may not perform"),
(False, "not change the membership"),
])
def test_role_users_list_other_user_admin_role(admin_role, err):
with mock.patch('awx.api.views.RoleUsersList.get_parent_object') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role, id=1)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
user_admin_role = role_mock if admin_role else None
with mock.patch('awx.api.views.User.admin_role', new_callable=PropertyMock, return_value=user_admin_role):
factory = APIRequestFactory()
view = RoleUsersList.as_view()
user = User(username="root", is_superuser=True, pk=1, id=1)
request = factory.post("/role/1/users", {'id':1}, format="json")
force_authenticate(request, user)
response = view(request)
response.render()
assert response.status_code == 403
assert err in response.content
def test_team_roles_list_post_org_roles():
with mock.patch('awx.api.views.get_object_or_400') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
factory = APIRequestFactory()
view = TeamRolesList.as_view()
request = factory.post("/team/1/roles", {'id':1}, format="json")
force_authenticate(request, User(username="root", is_superuser=True))
response = view(request)
response.render()
assert response.status_code == 400
assert 'cannot assign' in response.content

View File

@ -1,22 +1,11 @@
import mock
import pytest
from rest_framework.test import APIRequestFactory
from rest_framework.test import force_authenticate
from django.contrib.contenttypes.models import ContentType
from awx.api.views import (
ApiV1RootView,
TeamRolesList,
JobTemplateLabelList,
)
from awx.main.models import (
User,
Role,
)
@pytest.fixture
def mock_response_new(mocker):
m = mocker.patch('awx.api.views.Response.__new__')
@ -68,30 +57,6 @@ class TestJobTemplateLabelList:
with mock.patch('awx.api.generics.DeleteLastUnattachLabelMixin.unattach') as mixin_unattach:
view = JobTemplateLabelList()
mock_request = mock.MagicMock()
super(JobTemplateLabelList, view).unattach(mock_request, None, None)
assert mixin_unattach.called_with(mock_request, None, None)
@pytest.mark.parametrize("url", ["/team/1/roles", "/role/1/teams"])
def test_team_roles_list_post_org_roles(url):
with mock.patch('awx.api.views.Role.objects.get') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
factory = APIRequestFactory()
view = TeamRolesList.as_view()
request = factory.post(url, {'id':1}, format="json")
force_authenticate(request, User(username="root", is_superuser=True))
response = view(request)
response.render()
assert response.status_code == 400
assert 'cannot assign' in response.content

View File

@ -1,4 +1,5 @@
import pytest
import json
def test_missing_project_error(job_template_factory):
@ -34,7 +35,18 @@ def test_inventory_credential_contradictions(job_template_factory):
assert 'inventory' in validation_errors
assert 'credential' in validation_errors
def test_survey_answers_as_string(job_template_factory):
objects = job_template_factory(
'job-template-with-survey',
survey=['var1'],
persisted=False)
jt = objects.job_template
user_extra_vars = json.dumps({'var1': 'asdf'})
accepted, ignored = jt._accept_or_ignore_job_kwargs(extra_vars=user_extra_vars)
assert 'var1' in accepted['extra_vars']
@pytest.mark.survey
def test_survey_password_list(job_with_secret_key_unit):
"""Verify that survey_password_variables method gives a list of survey passwords"""
assert job_with_secret_key_unit.job_template.survey_password_variables() == ['secret_key', 'SSN']
def test_job_template_survey_password_redaction(job_template_with_survey_passwords_unit):
"""Tests the JobTemplate model's funciton to redact passwords from
extra_vars - used when creating a new job"""
assert job_template_with_survey_passwords_unit.survey_password_variables() == ['secret_key', 'SSN']

View File

@ -2,6 +2,7 @@ import pytest
import json
from awx.main.tasks import RunJob
from awx.main.models import Job
@pytest.fixture
@ -14,9 +15,19 @@ def job(mocker):
'launch_type': 'manual'})
@pytest.mark.survey
def test_job_redacted_extra_vars(job_with_secret_key_unit):
"""Verify that this method redacts vars marked as passwords in a survey"""
assert json.loads(job_with_secret_key_unit.display_extra_vars()) == {
def test_job_survey_password_redaction():
"""Tests the Job model's funciton to redact passwords from
extra_vars - used when displaying job information"""
job = Job(
name="test-job-with-passwords",
extra_vars=json.dumps({
'submitter_email': 'foobar@redhat.com',
'secret_key': '6kQngg3h8lgiSTvIEb21',
'SSN': '123-45-6789'}),
survey_passwords={
'secret_key': '$encrypted$',
'SSN': '$encrypted$'})
assert json.loads(job.display_extra_vars()) == {
'submitter_email': 'foobar@redhat.com',
'secret_key': '$encrypted$',
'SSN': '$encrypted$'}

View File

@ -64,8 +64,8 @@ def test_net_cred_ssh_agent(mocker, get_ssh_version):
'credential': None, 'cloud_credential': None, 'network_credential': Credential(**options),
'become_enabled': False, 'become_method': None, 'become_username': None,
'inventory': mocker.MagicMock(spec=Inventory, id=2), 'force_handlers': False,
'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': False,
'start_at_task': False, 'pk': 1, 'launch_type': 'normal', 'job_template':None,
'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': None,
'start_at_task': None, 'pk': 1, 'launch_type': 'normal', 'job_template':None,
'created_by': None, 'extra_vars_dict': None, 'project':None, 'playbook': 'test.yml'}
mock_job = mocker.MagicMock(spec=Job, **mock_job_attrs)

View File

@ -18,10 +18,11 @@ import json
# http://urllib3.readthedocs.org/en/latest/security.html#disabling-warnings
requests.packages.urllib3.disable_warnings()
class CloudFormsInventory(object):
def _empty_inventory(self):
return {"_meta" : {"hostvars" : {}}}
return {"_meta": {"hostvars": {}}}
def __init__(self):
''' Main execution path '''
@ -43,7 +44,7 @@ class CloudFormsInventory(object):
# This doesn't exist yet and needs to be added
if self.args.host:
data2 = { }
data2 = {}
print json.dumps(data2, indent=2)
def parse_cli_args(self):
@ -51,9 +52,9 @@ class CloudFormsInventory(object):
parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on CloudForms')
parser.add_argument('--list', action='store_true', default=False,
help='List instances (default: False)')
help='List instances (default: False)')
parser.add_argument('--host', action='store',
help='Get all the variables about a specific instance')
help='Get all the variables about a specific instance')
self.args = parser.parse_args()
def read_settings(self):
@ -97,30 +98,47 @@ class CloudFormsInventory(object):
def get_hosts(self):
''' Gets host from CloudForms '''
r = requests.get("https://" + self.cloudforms_hostname + "/api/vms?expand=resources&attributes=name,power_state", auth=(self.cloudforms_username,self.cloudforms_password), verify=False)
r = requests.get("https://{0}/api/vms?expand=resources&attributes=all".format(self.cloudforms_hostname),
auth=(self.cloudforms_username, self.cloudforms_password), verify=False)
obj = r.json()
#Remove objects that don't matter
del obj["count"]
del obj["subcount"]
del obj["name"]
# Create groups+hosts based on host data
for resource in obj.get('resources', []):
#Create a new list to grab VMs with power_state on to add to a new list
#I'm sure there is a cleaner way to do this
newlist = []
getnext = False
for x in obj.items():
for y in x[1]:
for z in y.items():
if getnext == True:
newlist.append(z[1])
getnext = False
if ( z[0] == "power_state" and z[1] == "on" ):
getnext = True
newdict = {'hosts': newlist}
newdict2 = {'Dynamic_CloudForms': newdict}
print json.dumps(newdict2, indent=2)
# Maintain backwards compat by creating `Dynamic_CloudForms` group
if 'Dynamic_CloudForms' not in self.inventory:
self.inventory['Dynamic_CloudForms'] = []
self.inventory['Dynamic_CloudForms'].append(resource['name'])
# Add host to desired groups
for key in ('vendor', 'type', 'location'):
if key in resource:
# Create top-level group
if key not in self.inventory:
self.inventory[key] = dict(children=[], vars={}, hosts=[])
# if resource['name'] not in self.inventory[key]['hosts']:
# self.inventory[key]['hosts'].append(resource['name'])
# Create sub-group
if resource[key] not in self.inventory:
self.inventory[resource[key]] = dict(children=[], vars={}, hosts=[])
# self.inventory[resource[key]]['hosts'].append(resource['name'])
# Add sub-group, as a child of top-level
if resource[key] not in self.inventory[key]['children']:
self.inventory[key]['children'].append(resource[key])
# Add host to sub-group
if resource['name'] not in self.inventory[resource[key]]:
self.inventory[resource[key]]['hosts'].append(resource['name'])
# Delete 'actions' key
del resource['actions']
# Add _meta hostvars
self.inventory['_meta']['hostvars'][resource['name']] = resource
print json.dumps(self.inventory, indent=2)
# Run the script
CloudFormsInventory()

View File

@ -697,6 +697,16 @@ SATELLITE6_HOST_FILTER = r'^.+$'
SATELLITE6_EXCLUDE_EMPTY_GROUPS = True
SATELLITE6_INSTANCE_ID_VAR = 'foreman.id'
# ---------------------
# ----- CloudForms -----
# ---------------------
CLOUDFORMS_ENABLED_VAR = 'power_state'
CLOUDFORMS_ENABLED_VALUE = 'on'
CLOUDFORMS_GROUP_FILTER = r'^.+$'
CLOUDFORMS_HOST_FILTER = r'^.+$'
CLOUDFORMS_EXCLUDE_EMPTY_GROUPS = True
CLOUDFORMS_INSTANCE_ID_VAR = 'id'
# ---------------------
# -- Activity Stream --
# ---------------------

View File

@ -80,12 +80,6 @@ a.red-txt:active {
text-overflow: ellipsis;
}
.name-column {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
blockquote {
font-size: 14px;
}

View File

@ -44,9 +44,11 @@
color: @list-header-txt;
font-size: 14px;
font-weight: bold;
white-space: nowrap;
padding-bottom: 25px;
min-height: 45px;
word-break: break-all;
max-width: 90%;
word-wrap: break-word;
}
.Form-secondaryTitle{
@ -55,7 +57,10 @@
min-height: 40px;
}
.Form-title--is_superuser, .Form-title--is_system_auditor, .Form-title--is_ldap_user{
.Form-title--is_superuser,
.Form-title--is_system_auditor,
.Form-title--is_ldap_user,
.Form-title--is_external_account{
height:15px;
color: @default-interface-txt;
background-color: @default-list-header-bg;
@ -353,6 +358,11 @@
border-color: transparent transparent @field-dropdown-icon transparent!important;
}
.select2-container--default.select2-container--open.select2-container--below .select2-selection--single {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.select2-dropdown{
border:1px solid @field-border;

View File

@ -78,6 +78,7 @@ table, tbody {
padding-left: 15px;
padding-right: 15px;
border-top:0px!important;
word-wrap: break-word;
}
.List-tableCell.description-column {
@ -383,6 +384,7 @@ table, tbody {
.List-action--showTooltipOnDisabled {
display: inline-block;
cursor: not-allowed;
}
.List-action--showTooltipOnDisabled .btn[disabled] {

View File

@ -51,23 +51,6 @@ export default
PaginateInit({ scope: scope,
list: list, url: url, pageSize: 5 });
if (scope.removePostRefresh) {
scope.removePostRefresh();
}
scope.removePostRefresh = scope.$on('PostRefresh', function () {
if(scope.allSelected && scope.allSelected.length > 0) {
// We need to check to see if any of the selected items are now in our list!
for(var i=0; i<scope.allSelected.length; i++) {
for(var j=0; j<scope[set].length; j++) {
if(scope.allSelected[i].id === scope[set][j].id && scope.allSelected[i].type === scope[set][j].type) {
// If so, let's go ahead and mark it as selected so that select-list-item knows to check the box
scope[set][j].isSelected = true;
}
}
}
}
});
scope.search(list.iterator);
});
}

View File

@ -21,6 +21,13 @@
key: true,
label: 'name'
},
organization: {
label: 'organization',
ngBind: 'team.summary_fields.organization.name',
sourceModel: 'organization',
sourceField: 'name',
searchable: true
}
}
};

View File

@ -66,6 +66,12 @@
display: inline-block;
color: @default-interface-txt;
text-transform: uppercase;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: bottom;
}
.BreadCrumb-item + .BreadCrumb-item:before {

View File

@ -46,13 +46,13 @@ export function CredentialsList($scope, $rootScope, $location, $log,
Wait('stop');
$('#prompt-modal').modal('hide');
list.fields.kind.searchOptions = $scope.credential_kind_options;
list.fields.kind.searchOptions = $scope.credential_kind_options_list;
// Translate the kind value
for (i = 0; i < $scope.credentials.length; i++) {
for (j = 0; j < $scope.credential_kind_options.length; j++) {
if ($scope.credential_kind_options[j].value === $scope.credentials[i].kind) {
$scope.credentials[i].kind = $scope.credential_kind_options[j].label;
for (j = 0; j < $scope.credential_kind_options_list.length; j++) {
if ($scope.credential_kind_options_list[j].value === $scope.credentials[i].kind) {
$scope.credentials[i].kind = $scope.credential_kind_options_list[j].label;
break;
}
}
@ -82,7 +82,7 @@ export function CredentialsList($scope, $rootScope, $location, $log,
scope: $scope,
url: defaultUrl,
field: 'kind',
variable: 'credential_kind_options',
variable: 'credential_kind_options_list',
callback: 'choicesReadyCredential'
});
@ -148,7 +148,7 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log,
url;
$scope.keyEntered = false;
$scope.permissionsTooltip = 'Please save before assigning permissions';
generator.inject(form, { mode: 'add', related: false, scope: $scope });
generator.reset();
@ -391,6 +391,12 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log,
$scope.removeCredentialLoaded();
}
$scope.removeCredentialLoaded = $scope.$on('credentialLoaded', function () {
// if the credential is assigned to an organization, allow permission delegation
// do NOT use $scope.organization in a view directive to determine if a credential is associated with an org
$scope.disablePermissionAssignment = typeof($scope.organization) === 'number' ? false : true;
if ($scope.disablePermissionAssignment){
$scope.permissionsTooltip = 'Credentials are only shared within an organization. Assign credentials to an organization to delegate credential permissions. The organization cannot be edited after credentials are assigned.';
}
var set;
for (set in relatedSets) {
$scope.search(relatedSets[set].iterator);

View File

@ -20,7 +20,8 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa
var jobs_scope, scheduled_scope,
choicesCount = 0,
listCount = 0,
api_complete = false;
api_complete = false,
scheduledJobsList = _.cloneDeep(ScheduledJobsList);
$scope.jobsSelected = true;
@ -66,22 +67,23 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa
scope: jobs_scope,
list: AllJobsList,
id: 'active-jobs',
pageSize: 20,
url: GetBasePath('unified_jobs') + '?status__in=pending,waiting,running,completed,failed,successful,error,canceled,new&order_by=-finished',
pageSize: 20,
searchParams: search_params,
spinner: false
});
scheduled_scope = $scope.$new(true);
scheduledJobsList.basePath = GetBasePath('schedules') + '?next_run__isnull=false';
LoadSchedulesScope({
parent_scope: $scope,
scope: scheduled_scope,
list: ScheduledJobsList,
list: scheduledJobsList,
pageSize: 20,
id: 'scheduled-jobs-tab',
searchSize: 'col-lg-4 col-md-4 col-sm-4 col-xs-12',
url: GetBasePath('schedules') + '?next_run__isnull=false'
url: scheduledJobsList.basePath
});
$scope.refreshJobs = function() {

View File

@ -358,11 +358,8 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams,
$scope.editSchedules = function(id) {
var project = Find({ list: $scope.projects, key: 'id', val: id });
if (project.scm_type === "Manual" || Empty(project.scm_type)) {
// Nothing to do
}
else {
$location.path('/projects/' + id + '/schedules');
if (!(project.scm_type === "Manual" || Empty(project.scm_type)) && !(project.status === 'updating' || project.status === 'running' || project.status === 'pending')) {
$state.go('projectSchedules', {id: id});
}
};
}

View File

@ -162,6 +162,7 @@ export function UsersAdd($scope, $rootScope, $compile, $location, $log,
$scope.not_ldap_user = !$scope.ldap_user;
$scope.ldap_dn = null;
$scope.socialAuthUser = false;
$scope.external_account = null;
generator.reset();
@ -334,6 +335,7 @@ export function UsersEdit($scope, $rootScope, $location,
$scope.not_ldap_user = !$scope.ldap_user;
master.ldap_user = $scope.ldap_user;
$scope.socialAuthUser = (data.auth.length > 0) ? true : false;
$scope.external_account = data.external_account;
$scope.user_type = $scope.user_type_options[0];
$scope.is_system_auditor = false;

View File

@ -6,8 +6,8 @@
export default
['$scope', '$state', '$stateParams', 'PageRangeSetup', 'GetBasePath', 'DashboardHostsList',
'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope',
function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope){
'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope', 'SearchInit',
function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope, SearchInit){
var setJobStatus = function(){
_.forEach($scope.hosts, function(value){
SetStatus({
@ -59,6 +59,12 @@ export default
$scope.hosts = hosts.results;
setJobStatus();
generator.inject(list, {mode: 'edit', scope: $scope});
SearchInit({
scope: $scope,
set: 'hosts',
list: list,
url: defaultUrl
});
PaginateInit({
scope: $scope,
list: list,
@ -77,6 +83,7 @@ export default
$scope.rowBeingEdited = $state.params.id;
$scope.listBeingEdited = "hosts";
}
$scope.search(list.iterator);
};
init();
}];

View File

@ -31,6 +31,7 @@ export default function(){
awTipPlacement: 'right',
dataPlacement: 'right',
awPopOver: '{{ host.job_status_html }}',
dataTitle: '{{host.job_status_title}}',
ngClick:'viewHost(host.id)',
columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus'
},

View File

@ -101,10 +101,8 @@
.DashboardList-nameCell {
padding-left: 15px;
text-overflow: ellipsis;
overflow:hidden;
white-space: nowrap;
width: 100%;
word-wrap: break-word;
}
.DashboardList-nameContainer {

View File

@ -404,7 +404,9 @@ export default
related: {
permissions: {
awToolTip: 'Please save before assigning permissions',
disabled: 'disablePermissionAssignment',
awToolTip: '{{permissionsTooltip}}',
dataTipWatch: 'permissionsTooltip',
dataPlacement: 'top',
basePath: 'credentials/:id/access_list/',
type: 'collection',
@ -452,7 +454,7 @@ export default
return {
permissions: {
iterator: 'permission',
url: urls.access_list
url: urls.access_list,
}
};
}

View File

@ -81,7 +81,7 @@ export default
permissions: {
awToolTip: 'Please save before assigning permissions',
dataPlacement: 'top',
basePath: 'projects/:id/access_list/',
basePath: 'inventories/:id/access_list/',
type: 'collection',
title: 'Permissions',
iterator: 'permission',

View File

@ -228,10 +228,7 @@ export default
column: 2,
awPopOver: "<p>Provide a comma separated list of tags.</p>\n" +
"<p>Tags are useful when you have a large playbook, and you want to run a specific part of a play or task.</p>" +
"<p>For example, you might have a task consisting of a long list of actions. Tag values can be assigned to each action. " +
"Suppose the actions have been assigned tag values of &quot;configuration&quot;, &quot;packages&quot; and &quot;install&quot;.</p>" +
"<p>If you just want to run the &quot;configuration&quot; and &quot;packages&quot; actions, you would enter the following here " +
"in the Job Tags field:</p>\n<blockquote>configuration,packages</blockquote>\n",
"<p>Consult the Ansible documentation for further details on the usage of tags.</p>",
dataTitle: "Job Tags",
dataPlacement: "right",
dataContainer: "body",
@ -240,6 +237,25 @@ export default
text: 'Prompt on launch'
}
},
skip_tags: {
label: 'Skip Tags',
type: 'textarea',
rows: 5,
addRequired: false,
editRequired: false,
'elementClass': 'Form-textInput',
column: 2,
awPopOver: "<p>Provide a comma separated list of tags.</p>\n" +
"<p>Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task.</p>" +
"<p>Consult the Ansible documentation for further details on the usage of tags.</p>",
dataTitle: "Skip Tags",
dataPlacement: "right",
dataContainer: "body",
subCheckbox: {
variable: 'ask_skip_tags_on_launch',
text: 'Prompt on launch'
}
},
checkbox_group: {
label: 'Options',
type: 'checkbox_group',
@ -281,7 +297,7 @@ export default
column: 2,
awPopOver: "callback_help",
awPopOverWatch: "callback_help",
dataPlacement: 'right',
dataPlacement: 'top',
dataTitle: 'Provisioning Callback URL',
dataContainer: "body"
},

View File

@ -46,7 +46,7 @@ export default
label: 'Username',
type: 'text',
awRequiredWhen: {
reqExpression: "not_ldap_user",
reqExpression: "not_ldap_user && external_account === null",
init: true
},
autocomplete: false
@ -69,7 +69,7 @@ export default
label: 'Password',
type: 'sensitive',
hasShowInputButton: true,
ngShow: 'ldap_user == false && socialAuthUser === false',
ngShow: 'ldap_user == false && socialAuthUser === false && external_account === null',
addRequired: true,
editRequired: false,
ngChange: "clearPWConfirm('password_confirm')",
@ -80,7 +80,7 @@ export default
label: 'Confirm Password',
type: 'sensitive',
hasShowInputButton: true,
ngShow: 'ldap_user == false && socialAuthUser === false',
ngShow: 'ldap_user == false && socialAuthUser === false && external_account === null',
addRequired: true,
editRequired: false,
awPassMatch: true,

View File

@ -55,9 +55,9 @@ export default
// Submit request to run an adhoc comamand
.factory('AdhocRun', ['$location','$stateParams', 'LaunchJob',
'PromptForPasswords', 'Rest', 'GetBasePath', 'Alert', 'ProcessErrors',
'Wait', 'Empty', 'CreateLaunchDialog',
'Wait', 'Empty', 'CreateLaunchDialog', '$state',
function ($location, $stateParams, LaunchJob, PromptForPasswords,
Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog) {
Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog, $state) {
return function (params) {
var id = params.project_id,
scope = params.scope.$new(),
@ -87,25 +87,31 @@ export default
});
});
if (scope.removeAdhocLaunchFinished) {
scope.removeAdhocLaunchFinished();
}
scope.removeAdhocLaunchFinished = scope.$on('AdhocLaunchFinished',
function(e, data) {
$location.path('/ad_hoc_commands/' + data.id);
});
if (scope.removeStartAdhocRun) {
scope.removeStartAdhocRun();
}
scope.removeStartAdhocRun = scope.$on('StartAdhocRun', function() {
LaunchJob({
scope: scope,
url: url,
callback: 'AdhocLaunchFinished' // send to the adhoc
// standard out page
});
var password,
postData={};
for (password in scope.passwords) {
postData[scope.passwords[password]] = scope[
scope.passwords[password]
];
}
// Re-launch the adhoc job
Rest.setUrl(url);
Rest.post(postData)
.success(function (data) {
Wait('stop');
$state.go('adHocJobStdout', {id: data.id});
})
.error(function (data, status) {
ProcessErrors(scope, data, status, {
hdr: 'Error!',
msg: 'Failed to launch adhoc command. POST ' +
'returned status: ' + status });
});
});
// start routine only if passwords need to be prompted

View File

@ -161,7 +161,7 @@ angular.module('CredentialsHelper', ['Utilities'])
break;
case 'net':
scope.username_required = true;
scope.password_required = true;
scope.password_required = false;
scope.passwordLabel = 'Password';
scope.sshKeyDataLabel = 'SSH Key';
break;

View File

@ -165,6 +165,7 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
has_inventory_sources = params.has_inventory_sources,
launch_class = '',
launch_tip = 'Start sync process',
schedule_tip = 'Schedule future inventory syncs',
stat, stat_class, status_tip;
stat = status;
@ -225,7 +226,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
"tooltip": status_tip,
"status": stat,
"launch_class": launch_class,
"launch_tip": launch_tip
"launch_tip": launch_tip,
"schedule_tip": schedule_tip
};
};
}

View File

@ -132,6 +132,9 @@ angular.module('JobTemplatesHelper', ['Utilities'])
scope.ask_tags_on_launch = (data.ask_tags_on_launch) ? true : false;
master.ask_tags_on_launch = scope.ask_tags_on_launch;
scope.ask_skip_tags_on_launch = (data.ask_skip_tags_on_launch) ? true : false;
master.ask_skip_tags_on_launch = scope.ask_skip_tags_on_launch;
scope.ask_job_type_on_launch = (data.ask_job_type_on_launch) ? true : false;
master.ask_job_type_on_launch = scope.ask_job_type_on_launch;

View File

@ -34,7 +34,8 @@ export default
fld = (params.variable) ? params.variable : 'variables',
pfld = (params.parse_variable) ? params.parse_variable : 'parseType',
onReady = params.onReady,
onChange = params.onChange;
onChange = params.onChange,
readOnly = params.readOnly;
function removeField(fld) {
//set our model to the last change in CodeMirror and then destroy CodeMirror

View File

@ -75,6 +75,9 @@ export default
case 'missing':
result = 'Missing. Click for details';
break;
case 'canceled':
result = 'Canceled. Click for details';
break;
}
return result;
};

View File

@ -50,16 +50,9 @@ function InventoriesList($scope, $rootScope, $location, $log,
"aw-pop-over": html,
"data-popover-title": title,
"data-placement": "right" });
elem.removeAttr('ng-click');
$compile(elem)($scope);
elem.on('shown.bs.popover', function() {
$('.popover').each(function() {
$compile($(this))($scope); //make nested directives work!
});
$('.popover-content, .popover-title').click(function() {
elem.popover('hide');
});
});
elem.popover('show');
$scope.triggerPopover(event);
}
view.inject(InventoryList, { mode: mode, scope: $scope });
@ -250,44 +243,62 @@ function InventoriesList($scope, $rootScope, $location, $log,
});
$scope.showGroupSummary = function(event, id) {
var inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.syncStatus !== 'na') {
Wait('start');
Rest.setUrl(inventory.related.inventory_sources + '?or__source=ec2&or__source=rax&order_by=-last_job_run&page_size=5');
Rest.get()
.success(function(data) {
$scope.$emit('GroupSummaryReady', event, inventory, data);
})
.error(function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + inventory.related.inventory_sources + ' failed. GET returned status: ' + status
try{
var elem = $(event.target).parent();
// if the popover is visible already, then exit the function here
if(elem.data()['bs.popover'].tip().hasClass('in')){
return;
}
}
catch(err){
var inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.syncStatus !== 'na') {
Wait('start');
Rest.setUrl(inventory.related.inventory_sources + '?or__source=ec2&or__source=rax&order_by=-last_job_run&page_size=5');
Rest.get()
.success(function(data) {
$scope.$emit('GroupSummaryReady', event, inventory, data);
})
.error(function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + inventory.related.inventory_sources + ' failed. GET returned status: ' + status
});
});
});
}
}
}
};
$scope.showHostSummary = function(event, id) {
var url, inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.total_hosts > 0) {
Wait('start');
url = GetBasePath('jobs') + "?type=job&inventory=" + id + "&failed=";
url += (inventory.has_active_failures) ? 'true' : "false";
url += "&order_by=-finished&page_size=5";
Rest.setUrl(url);
Rest.get()
.success( function(data) {
$scope.$emit('HostSummaryReady', event, data);
})
.error( function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. GET returned: ' + status
try{
var elem = $(event.target).parent();
// if the popover is visible already, then exit the function here
if(elem.data()['bs.popover'].tip().hasClass('in')){
return;
}
}
catch(err){
var url, inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.total_hosts > 0) {
Wait('start');
url = GetBasePath('jobs') + "?type=job&inventory=" + id + "&failed=";
url += (inventory.has_active_failures) ? 'true' : "false";
url += "&order_by=-finished&page_size=5";
Rest.setUrl(url);
Rest.get()
.success( function(data) {
$scope.$emit('HostSummaryReady', event, data);
})
.error( function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. GET returned: ' + status
});
});
});
}
}
}
};

View File

@ -242,7 +242,7 @@ function adhocController($q, $scope, $location, $stateParams,
Rest.post(data)
.success(function (data) {
Wait('stop');
$location.path("/ad_hoc_commands/" + data.id);
$state.go('adHocJobStdout', {id: data.id});
})
.error(function (data, status) {
ProcessErrors($scope, data, status, adhocForm, {

View File

@ -19,18 +19,4 @@
<li class="BreadCrumb-item" ng-if="currentState !== 'inventoryManage'"></li>
<div class="InventoryManageBreadCrumb-ncy" ng-if="!licenseMissing" ncy-breadcrumb></div>
</ol>
<div class="BreadCrumb-menuLink"
id="bread_crumb_activity_stream"
aw-tool-tip="View Activity Stream"
data-placement="left"
data-trigger="hover"
data-container="body"
ng-class="{'BreadCrumb-menuLinkActive' : activityStreamActive}"
ng-if="showActivityStreamButton"
ng-hide= "licenseMissing"
ng-click="openActivityStream()">
<i class="BreadCrumb-menuLinkImage icon-activity-stream"
alt="Activity Stream">
</i>
</div>
</div>

View File

@ -100,6 +100,7 @@
// equal to case 'ec2' || 'rax' || 'azure' || 'azure_rm' || 'vmware' || 'satellite6' || 'cloudforms' || 'openstack'
else{
var credentialBasePath = (source === 'ec2') ? GetBasePath('credentials') + '?kind=aws' : GetBasePath('credentials') + (source === '' ? '' : '?kind=' + (source));
$scope.cloudCredentialRequired = source !== '' && source !== 'custom' && source !== 'ec2' ? true : false;
CredentialList.basePath = credentialBasePath;
LookUpInit({
scope: $scope,
@ -122,7 +123,7 @@
$scope.group_by_choices = source === 'ec2' ? $scope.ec2_group_by : null;
// azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint
$scope.source_region_choices = source === 'azure_rm' ? $scope.azure_regions : $scope[source + '_regions'];
$scope.cloudCredentialRequired = source !== '' && source !== 'custom' ? true : false;
$scope.cloudCredentialRequired = source !== '' && source !== 'custom' && source !== 'ec2' ? true : false;
$scope.group_by = null;
$scope.source_regions = null;
$scope.credential = null;

View File

@ -100,6 +100,7 @@
else{
var credentialBasePath = (source.value === 'ec2') ? GetBasePath('credentials') + '?kind=aws' : GetBasePath('credentials') + (source.value === '' ? '' : '?kind=' + (source.value));
CredentialList.basePath = credentialBasePath;
$scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' && source.value !== 'ec2' ? true : false;
LookUpInit({
scope: $scope,
url: credentialBasePath,
@ -122,7 +123,7 @@
// reset fields
// azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint
$scope.source_region_choices = source.value === 'azure_rm' ? $scope.azure_regions : $scope[source.value + '_regions'];
$scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' ? true : false;
$scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' && source.value !== 'ec2' ? true : false;
$scope.group_by = null;
$scope.source_regions = null;
$scope.credential = null;

View File

@ -151,6 +151,7 @@
{status_tooltip: group_status.tooltip},
{launch_tooltip: group_status.launch_tip},
{launch_class: group_status.launch_class},
{group_schedule_tooltip: group_status.schedule_tip},
{hosts_status_tip: hosts_status.tooltip},
{hosts_status_class: hosts_status.class},
{source: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.source : null},

View File

@ -13,11 +13,6 @@ import GroupsListController from './groups/groups-list.controller';
export default {
name: 'inventoryManage',
url: '/inventories/:inventory_id/manage?{group:int}{failed}',
data: {
activityStream: true,
activityStreamTarget: 'inventory',
activityStreamId: 'inventory_id'
},
params:{
group:{
array: true

View File

@ -38,11 +38,13 @@
break;
case 'ok':
params.event = 'runner_on_ok';
params.changed = 'false';
break;
case 'failed':
params.failed = true;
params.event = 'runner_on_failed';
break;
case 'changed':
params.event = 'runner_on_ok';
params.changed = true;
break;
default:

View File

@ -641,7 +641,7 @@ export default
return true;
});
//scope.setSearchAll('host');
ParseTypeChange({ scope: scope, field_id: 'pre-formatted-variables' });
ParseTypeChange({ scope: scope, field_id: 'pre-formatted-variables', readOnly: true });
scope.$emit('LoadPlays', data.related.job_events);
})
.error(function(data, status) {

View File

@ -154,6 +154,11 @@
<div class="JobDetail-resultRowText">{{ job.job_tags }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.skip_tags">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Skip Tags</label>
<div class="JobDetail-resultRowText">{{ job.skip_tags }}</div>
</div>
<div class="form-group JobDetail-resultRow JobDetail-resultRow--variables toggle-show" ng-show="variables">
<label class="JobDetail-resultRowLabel JobDetail-extraVarsLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Extra Variables</label>
<textarea rows="6" ng-model="variables" name="variables" class="JobDetail-extraVars" id="pre-formatted-variables"></textarea>

View File

@ -42,10 +42,14 @@ export default
}
if(scope.ask_tags_on_launch && scope.other_prompt_data && scope.other_prompt_data.job_tags){
if(scope.ask_tags_on_launch && scope.other_prompt_data && typeof scope.other_prompt_data.job_tags === 'string'){
job_launch_data.job_tags = scope.other_prompt_data.job_tags;
}
if(scope.ask_skip_tags_on_launch && scope.other_prompt_data && typeof scope.other_prompt_data.skip_tags === 'string'){
job_launch_data.skip_tags = scope.other_prompt_data.skip_tags;
}
if(scope.ask_limit_on_launch && scope.other_prompt_data && scope.other_prompt_data.limit){
job_launch_data.limit = scope.other_prompt_data.limit;
}

View File

@ -153,7 +153,7 @@ export default
// General catch-all for "other prompts" - used in this link function and to hide the Other Prompts tab when
// it should be hidden
$scope.has_other_prompts = (data.ask_job_type_on_launch || data.ask_limit_on_launch || data.ask_tags_on_launch || data.ask_variables_on_launch) ? true : false;
$scope.has_other_prompts = (data.ask_job_type_on_launch || data.ask_limit_on_launch || data.ask_tags_on_launch || data.ask_skip_tags_on_launch || data.ask_variables_on_launch) ? true : false;
$scope.password_needed = data.passwords_needed_to_start && data.passwords_needed_to_start.length > 0;
$scope.has_default_inventory = data.defaults && data.defaults.inventory && data.defaults.inventory.id;
$scope.has_default_credential = data.defaults && data.defaults.credential && data.defaults.credential.id;
@ -172,6 +172,10 @@ export default
$scope.other_prompt_data.job_tags = (data.defaults && data.defaults.job_tags) ? data.defaults.job_tags : "";
}
if($scope.ask_skip_tags_on_launch) {
$scope.other_prompt_data.skip_tags = (data.defaults && data.defaults.skip_tags) ? data.defaults.skip_tags : "";
}
if($scope.ask_variables_on_launch) {
$scope.jobLaunchVariables = (data.defaults && data.defaults.extra_vars) ? data.defaults.extra_vars : "---";
$scope.other_prompt_data.parseType = 'yaml';

View File

@ -148,7 +148,15 @@
<span class="Form-inputLabel">Job Tags</span>
</label>
<div>
<textarea rows="1" ng-model="other_prompt_data.job_tags" name="tags" class="form-control Form-textArea Form-textInput"></textarea>
<textarea rows="5" ng-model="other_prompt_data.job_tags" name="tags" class="form-control Form-textArea Form-textInput"></textarea>
</div>
</div>
<div class="form-group Form-formGroup Form-formGroup--singleColumn" ng-if="ask_skip_tags_on_launch">
<label for="skip_tags">
<span class="Form-inputLabel">Skip Tags</span>
</label>
<div>
<textarea rows="5" ng-model="other_prompt_data.skip_tags" name="skip_tags" class="form-control Form-textArea Form-textInput"></textarea>
</div>
</div>
</form>

View File

@ -518,6 +518,7 @@
}
data.ask_tags_on_launch = $scope.ask_tags_on_launch ? $scope.ask_tags_on_launch : false;
data.ask_skip_tags_on_launch = $scope.ask_skip_tags_on_launch ? $scope.ask_skip_tags_on_launch : false;
data.ask_limit_on_launch = $scope.ask_limit_on_launch ? $scope.ask_limit_on_launch : false;
data.ask_job_type_on_launch = $scope.ask_job_type_on_launch ? $scope.ask_job_type_on_launch : false;
data.ask_inventory_on_launch = $scope.ask_inventory_on_launch ? $scope.ask_inventory_on_launch : false;

View File

@ -640,6 +640,7 @@ export default
}
data.ask_tags_on_launch = $scope.ask_tags_on_launch ? $scope.ask_tags_on_launch : false;
data.ask_skip_tags_on_launch = $scope.ask_skip_tags_on_launch ? $scope.ask_skip_tags_on_launch : false;
data.ask_limit_on_launch = $scope.ask_limit_on_launch ? $scope.ask_limit_on_launch : false;
data.ask_job_type_on_launch = $scope.ask_job_type_on_launch ? $scope.ask_job_type_on_launch : false;
data.ask_inventory_on_launch = $scope.ask_inventory_on_launch ? $scope.ask_inventory_on_launch : false;

View File

@ -8,7 +8,7 @@
}
.LabelList-tagContainer,
.LabelList-seeMore {
.LabelList-seeMoreLess {
display: flex;
max-width: 100%;
}
@ -27,7 +27,7 @@
overflow: hidden;
}
.LabelList-seeMore {
.LabelList-seeMoreLess {
color: @default-link;
margin: 4px 0px;
text-transform: uppercase;
@ -37,7 +37,7 @@
font-size: 11px;
}
.LabelList-seeMore:hover {
.LabelList-seeMoreLess:hover {
color: @default-link-hov;
}

View File

@ -47,6 +47,13 @@ export default
});
};
scope.seeLess = function() {
// Trim the labels array back down to 10 items
scope.labels = scope.labels.slice(0, 10);
// Re-set the seeMoreInteractive flag so that the "See More" will be displayed
scope.seeMoreInactive = true;
};
scope.deleteLabel = function(templateId, templateName, labelId, labelName) {
var action = function () {
$('#prompt-modal').modal('hide');
@ -56,13 +63,13 @@ export default
Rest.setUrl(url);
Rest.post({"disassociate": true, "id": labelId})
.success(function () {
scope.search("job_template");
scope.search("job_template", scope.$parent.job_template_page);
Wait('stop');
})
.error(function (data, status) {
Wait('stop');
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Could not disacssociate label from JT. Call to ' + url + ' failed. DELETE returned status: ' + status });
msg: 'Could not disassociate label from JT. Call to ' + url + ' failed. DELETE returned status: ' + status });
});
};
@ -86,6 +93,7 @@ export default
scope.count = null;
}
});
}
};
}

View File

@ -8,5 +8,7 @@
<span class="LabelList-name">{{ label.name }}</span>
</div>
</div>
<div class="LabelList-seeMore" ng-show="count > 10 && seeMoreInactive"
<div class="LabelList-seeMoreLess" ng-show="count > 10 && seeMoreInactive"
ng-click="seeMore()">View More</div>
<div class="LabelList-seeMoreLess" ng-show="count > 10 && !seeMoreInactive"
ng-click="seeLess()">View Less</div>

View File

@ -314,7 +314,7 @@ export default
ngClick: 'submitQuestion($event)',
ngDisabled: true,
'class': 'btn btn-sm Form-saveButton',
label: '{{editQuestionIndex === null ? "ADD" : "UPDATE"}}'
label: '{{editQuestionIndex === null ? "+ ADD" : "UPDATE"}}'
}
}

View File

@ -48,9 +48,7 @@ export default
columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6',
ngClick: "viewJobDetails(all_job)",
defaultSearchField: true,
awToolTip: "{{ all_job.name | sanitize }}",
dataTipWatch: 'all_job.name',
dataPlacement: 'top'
searchDefault: true,
},
type: {
label: 'Type',

View File

@ -35,6 +35,7 @@ export default
ngClick:"viewJobDetails(completed_job)",
searchable: true,
searchType: 'select',
defaultSearchField: true,
nosort: true,
searchOptions: [
{ label: "Success", value: "successful" },
@ -54,8 +55,8 @@ export default
name: {
label: 'Name',
columnClass: 'col-lg-4 col-md-4 col-sm-4 col-xs-6',
searchable: false,
ngClick: "viewJobDetails(completed_job)",
defaultSearchField: true,
awToolTip: "{{ completed_job.name | sanitize }}",
dataPlacement: 'top'
},
@ -64,7 +65,7 @@ export default
ngBind: 'completed_job.type_label',
link: false,
columnClass: "col-lg-2 col-md-2 hidden-sm hidden-xs",
searchable: true,
searchable: false,
searchType: 'select',
searchOptions: [] // populated via GetChoices() in controller
},

View File

@ -19,6 +19,7 @@ export default
hover: true,
'class': 'table-no-border',
multiSelect: true,
trackBy: 'group.id',
fields: {
sync_status: {

View File

@ -20,6 +20,7 @@ export default
hasChildren: true,
'class': 'table-no-border',
multiSelect: true,
trackBy: 'host.id',
fields: {
active_failures: {

View File

@ -22,7 +22,8 @@ export default
key: true,
label: 'Name',
columnClass: 'col-lg-5 col-md-5 col-sm-9 col-xs-8',
linkTo: '/#/job_templates/{{job_template.id}}'
linkTo: '/#/job_templates/{{job_template.id}}',
searchDefault: true
},
description: {
label: 'Description',

View File

@ -35,7 +35,8 @@ export default
label: 'Name',
columnClass: 'col-lg-4 col-md-4 col-sm-4 col-xs-6 List-staticColumnAdjacent',
defaultSearchField: true,
linkTo: '/#/jobs/{{job.id}}'
linkTo: '/#/jobs/{{job.id}}',
searchDefault: true
},
finished: {
label: 'Finished',

View File

@ -37,6 +37,7 @@ export default
},
name: {
key: true,
searchDefault: true,
label: 'Name',
columnClass: "col-lg-4 col-md-4 col-sm-5 col-xs-7 List-staticColumnAdjacent",
modalColumnClass: 'col-md-8'

View File

@ -149,6 +149,12 @@ export default
if(field.type === 'number'){
$scope[i] = Number($scope[i]);
}
if(field.name === "username" && $scope.notification_type.value === "email" && value === null){
$scope[i] = "";
}
if(field.type === 'sensitive' && value === null){
$scope[i] = "";
}
return $scope[i];
}

View File

@ -223,6 +223,12 @@ export default
if(field.type === 'number'){
$scope[i] = Number($scope[i]);
}
if(field.name === "username" && $scope.notification_type.value === "email" && value === null){
$scope[i] = "";
}
if(field.type === 'sensitive' && value === null){
$scope[i] = "";
}
return $scope[i];
}

View File

@ -28,6 +28,7 @@ export default
Wait('stop');
if (scope.notification_templates) {
scope.notification_templates.forEach(function(notification_template, i) {
setStatus(notification_template);
scope.notification_type_options.forEach(function(type) {
if (type.value === notification_template.notification_type) {
scope.notification_templates[i].notification_type = type.label;
@ -74,78 +75,33 @@ export default
callback: 'choicesReadyNotifierList'
});
function attachElem(event, html, title) {
var elem = $(event.target).parent();
try {
elem.tooltip('hide');
elem.popover('destroy');
}
catch(err) {
//ignore
}
function setStatus(notification_template) {
var html, recent_notifications = notification_template.summary_fields.recent_notifications;
if (recent_notifications.length > 0) {
html = "<table class=\"table table-condensed flyout\" style=\"width: 100%\">\n";
html += "<thead>\n";
html += "<tr>";
html += "<th>Status</th>";
html += "<th>Time</th>";
html += "</tr>\n";
html += "</thead>\n";
html += "<tbody>\n";
$('.popover').each(function() {
// remove lingering popover <div>. Seems to be a bug in TB3 RC1
$(this).remove();
});
$('.tooltip').each( function() {
// close any lingering tool tipss
$(this).hide();
});
elem.attr({
"aw-pop-over": html,
"data-popover-title": title,
"data-placement": "right" });
$compile(elem)(scope);
elem.on('shown.bs.popover', function() {
$('.popover').each(function() {
$compile($(this))(scope); //make nested directives work!
recent_notifications.forEach(function(row) {
html += "<tr>\n";
html += `<td><i class=\"SmartStatus-tooltip--${row.status} fa icon-job-${row.status}"></i></td>`;
html += "<td>" + ($filter('longDate')(row.created)).replace(/ /,'<br />') + "</td>\n";
html += "</tr>\n";
});
$('.popover-content, .popover-title').click(function() {
elem.popover('hide');
});
});
elem.popover('show');
html += "</tbody>\n";
html += "</table>\n";
}
else {
html = "<p>No recent notifications.</p>\n";
}
notification_template.template_status_html = html;
}
scope.showSummary = function(event, id) {
setTimeout(function(){
if (!Empty(id)) {
var recent_notifications,
html, title = "Recent Notifications";
scope.notification_templates.forEach(function(notification_template){
if(notification_template.id === id){
recent_notifications = notification_template.summary_fields.recent_notifications;
}
});
if (recent_notifications.length > 0) {
html = "<table class=\"table table-condensed flyout\" style=\"width: 100%\">\n";
html += "<thead>\n";
html += "<tr>";
html += "<th>Status</th>";
html += "<th>Time</th>";
html += "</tr>\n";
html += "</thead>\n";
html += "<tbody>\n";
recent_notifications.forEach(function(row) {
html += "<tr>\n";
html += `<td><i class=\"SmartStatus-tooltip--${row.status} fa icon-job-${row.status}"></i></td>`;
html += "<td>" + ($filter('longDate')(row.created)).replace(/ /,'<br />') + "</td>\n";
html += "</tr>\n";
});
html += "</tbody>\n";
html += "</table>\n";
}
else {
html = "<p>No recent notifications.</p>\n";
}
attachElem(event, html, title);
}
}, 100);
};
scope.testNotification = function(){
var name = $filter('sanitize')(this.notification_template.name),
pending_retries = 10;

View File

@ -59,10 +59,6 @@ export default function() {
username: {
label: 'Username',
type: 'text',
awRequiredWhen: {
reqExpression: "email_required",
init: "false"
},
ngShow: "notification_type.value == 'email' ",
subForm: 'typeSubForm'
},

View File

@ -19,17 +19,14 @@ export default function(){
fields: {
status: {
label: '',
columnClass: 'List-staticColumn--smallStatus',
iconOnly: true,
searchable: false,
nosort: true,
ngClick: "null",
iconOnly: true,
excludeModal: true,
icons: [{
icon: "{{ 'icon-job-' + notification_template.status }}",
ngClick: "showSummary($event, notification_template.id)",
ngClass: ""
}]
icon: 'icon-job-{{ notification_template.status }}',
awPopOver: '{{ notification_template.template_status_html }}',
dataTitle: "Recent Notifications",
dataPlacement: 'right',
columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus'
},
name: {
key: true,

View File

@ -36,6 +36,8 @@ export default ['Wait', 'GetBasePath', 'ProcessErrors', 'Rest',
disassociate: 1
};
}
// Show the working spinner
Wait('start');
Rest.setUrl(url);
Rest.post(params)
.success( function(data) {
@ -43,9 +45,8 @@ export default ['Wait', 'GetBasePath', 'ProcessErrors', 'Rest',
scope.$emit(callback, data.id);
notifier[column] = !notifier[column];
}
else {
Wait('stop');
}
// Hide the working spinner
Wait('stop');
})
.error( function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',

View File

@ -28,7 +28,7 @@ function () {
obj.passwordLabel = ' Password';
obj.email_required = true;
obj.port_required = true;
obj.password_required = true;
obj.password_required = false;
break;
case 'slack':
obj.tokenLabel =' Token';

View File

@ -334,11 +334,8 @@ export default ['$scope', '$rootScope', '$location', '$log',
$scope.editSchedules = function(id) {
var project = Find({ list: $scope.projects, key: 'id', val: id });
if (project.scm_type === "Manual" || Empty(project.scm_type)) {
// Nothing to do
}
else {
$location.path('/projects/' + id + '/schedules');
if (!(project.scm_type === "Manual" || Empty(project.scm_type)) && !(project.status === 'updating' || project.status === 'running' || project.status === 'pending')) {
$state.go('projectSchedules', {id: id});
}
};

View File

@ -34,6 +34,10 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu
type = 'text';
}
if (field.searchDefault) {
obj.default = true;
}
obj.id = id;
obj.value = value;
obj.label = label;
@ -76,10 +80,13 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu
passThrough = partitionedOptions[1];
var joinOptions = function() {
return _.sortBy(_
var options = _.sortBy(_
.flatten([needsRequest, passThrough]), function(opt) {
return opt.id;
});
// put default first
return _.flatten(_.partition(options, opt => opt.default));
};
if (needsRequest.length) {

View File

@ -390,46 +390,57 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper'])
// lookup Validate lookup value against API
//
.directive('awlookup', ['Rest', function(Rest) {
.directive('awlookup', ['Rest', '$timeout', function(Rest, $timeout) {
return {
require: 'ngModel',
link: function(scope, elm, attrs, ctrl) {
var restTimeout;
ctrl.$parsers.unshift( function(viewValue) {
if (viewValue !== '' && viewValue !== null) {
var url = elm.attr('data-url');
url = url.replace(/\:value/, encodeURI(viewValue));
scope[elm.attr('data-source')] = null;
Rest.setUrl(url);
Rest.get().then( function(data) {
var results = data.data.results;
if (results.length > 0) {
scope[elm.attr('data-source')] = results[0].id;
if(restTimeout) {
$timeout.cancel(restTimeout);
}
restTimeout = $timeout( function(){
Rest.setUrl(url);
Rest.get().then( function(data) {
var results = data.data.results;
if (results.length > 0) {
scope[elm.attr('data-source')] = results[0].id;
// For user lookups the API endpoint doesn't
// have a `name` property, so this is `undefined`
// which causes the input to clear after typing
// a valid value O_o
//
// Only assign if there is a value, so that we avoid
// this situation.
//
// TODO: Evaluate if assigning name on the scope is
// even necessary at all.
//
if (!_.isEmpty(results[0].name)) {
scope[elm.attr('name')] = results[0].name;
// For user lookups the API endpoint doesn't
// have a `name` property, so this is `undefined`
// which causes the input to clear after typing
// a valid value O_o
//
// Only assign if there is a value, so that we avoid
// this situation.
//
// TODO: Evaluate if assigning name on the scope is
// even necessary at all.
//
if (!_.isEmpty(results[0].name)) {
scope[elm.attr('name')] = results[0].name;
}
ctrl.$setValidity('required', true);
ctrl.$setValidity('awlookup', true);
return viewValue;
}
ctrl.$setValidity('required', true);
ctrl.$setValidity('awlookup', true);
return viewValue;
}
ctrl.$setValidity('required', true);
ctrl.$setValidity('awlookup', false);
return undefined;
});
ctrl.$setValidity('awlookup', false);
return undefined;
});
}, 750);
}
else {
if(restTimeout) {
$timeout.cancel(restTimeout);
}
ctrl.$setValidity('awlookup', true);
scope[elm.attr('data-source')] = null;
}
@ -477,7 +488,8 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper'])
return {
link: function(scope, element, attrs) {
var delay = (attrs.delay !== undefined && attrs.delay !== null) ? attrs.delay : ($AnsibleConfig) ? $AnsibleConfig.tooltip_delay : {show: 500, hide: 100},
placement;
placement,
stateChangeWatcher;
if (attrs.awTipPlacement) {
placement = attrs.awTipPlacement;
}
@ -493,6 +505,22 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper'])
template = '<div class="tooltip Tooltip" role="tooltip"><div class="tooltip-arrow Tooltip-arrow"></div><div class="tooltip-inner Tooltip-inner"></div></div>';
}
// This block helps clean up tooltips that may get orphaned by a click event
$(element).on('mouseenter', function() {
if(stateChangeWatcher) {
// Un-bind - we don't want a bunch of listeners firing
stateChangeWatcher();
}
stateChangeWatcher = scope.$on('$stateChangeStart', function() {
// Go ahead and force the tooltip setTimeout to expire (if it hasn't already fired)
$(element).tooltip('hide');
// Clean up any existing tooltips including this one
$('.tooltip').each(function() {
$(this).remove();
});
});
});
$(element).on('hidden.bs.tooltip', function( ) {
// TB3RC1 is leaving behind tooltip <div> elements. This will remove them
// after a tooltip fades away. If not, they lay overtop of other elements and
@ -548,6 +576,10 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper'])
template = '<div id="' + element[0].id + '_popover_container" class="popover" role="tooltip"><div class="arrow"></div><h3 id="' + element[0].id + '_popover_title" class="popover-title"></h3><div id="' + element[0].id + '_popover_content" class="popover-content"></div></div>';
}
scope.triggerPopover = function(e){
showPopover(e);
};
if (attrs.awPopOverWatch) {
$(element).popover({
placement: placement,

View File

@ -1487,6 +1487,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
"ng-show='is_system_auditor'>Auditor</span>";
html+= "<span class=\"Form-title--is_ldap_user\" "+
"ng-show='ldap_user'>LDAP</span>";
html+= "<span class=\"Form-title--is_external_account\" "+
"ng-show='external_account'>{{external_account}}</span>";
}
html += "</div>\n";
html += "<div class=\"Form-header--fields\">";
@ -1524,8 +1526,13 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
collection = this.form.related[itm];
html += `<div id="${itm}_tab"`+
`class="Form-tab"`+
`ng-click="${this.form.related[itm].disabled} || toggleFormTabs($event)"` +
`ng-class="{'is-selected': ${itm}Selected ` ;
`ng-click="${this.form.related[itm].disabled} || toggleFormTabs($event)"`;
if (collection.awToolTip){
html += `aw-tool-tip="${collection.awToolTip}"` +
`aw-tip-placement="${collection.dataPlacement}"` +
`data-tip-watch="${collection.dataTipWatch}"`;
}
html += `ng-class="{'is-selected': ${itm}Selected ` ;
if(this.form.related[itm].disabled){
html += `, 'Form-tab--disabled' : ${this.form.related[itm].disabled }`;
}

View File

@ -371,7 +371,7 @@ angular.module('GeneratorHelpers', [systemStatus.name])
} else if (field.link || (field.key && (field.link === undefined || field.link))) {
html += "<a href=\"#/" + base + "/{{" + list.iterator + ".id }}\" ";
} else {
html += "<a href=\"\">";
html += "<a href=\"\"";
}
if (field.awDroppable) {
html += Attr(field, 'awDroppable');
@ -394,7 +394,7 @@ angular.module('GeneratorHelpers', [systemStatus.name])
if (field.awPopOver) {
html += "aw-pop-over=\"" + field.awPopOver + "\" ";
html += (field.dataPlacement) ? "data-placement=\"" + field.dataPlacement + "\" " : "";
html += (field.dataTitle) ? "data-title=\"" + field.dataTitle + "\" " : "";
html += (field.dataTitle) ? "over-title=\"" + field.dataTitle + "\" " : "";
}
html += (field.ngClass) ? Attr(field, 'ngClass') : '';
html += (field.ngEllipsis) ? "data-ng-bind=\"" + list.iterator + "." + fld + "\" data-ellipsis " : "";

View File

@ -466,7 +466,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate
innerTable += "ng-class-odd=\"'List-tableRow--oddRow'\" ";
innerTable += "ng-class-even=\"'List-tableRow--evenRow'\" ";
innerTable += "ng-repeat=\"" + list.iterator + " in " + list.name;
innerTable += (list.trackBy) ? " track by " + list.trackBy : " track by $index";
innerTable += (list.trackBy) ? " track by " + list.trackBy : "";
innerTable += (list.orderBy) ? " | orderBy:'" + list.orderBy + "'" : "";
innerTable += (list.filterBy) ? " | filter: " + list.filterBy : "";
innerTable += "\">\n";

View File

@ -63,9 +63,16 @@ export default ['$scope',
* {@link multiSelectList.controller:multiSelectList#decorateItem `decorateItem`}
*/
this.registerItem = function(item) {
var decoratedItem = this.decorateItem(item);
$scope.items = $scope.items.concat(decoratedItem);
return decoratedItem;
var foundItem = _.find($scope.items, function(existingItem) { return existingItem.id === item.id; });
if(foundItem) {
return foundItem;
}
else {
var decoratedItem = this.decorateItem(item);
$scope.items = $scope.items.concat(decoratedItem);
return decoratedItem;
}
};
/**
@ -99,6 +106,7 @@ export default ['$scope',
this.decorateItem = function(item) {
return {
isSelected: false,
id: item.id,
value: item
};
};
@ -129,11 +137,11 @@ export default ['$scope',
* Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`}
*/
this.deselectAll = function() {
$scope.items.forEach(function(item) {
item.isSelected = false;
});
$scope.selection.isExtended = false;
rebuildSelections();
$scope.items.forEach(function(item) {
item.isSelected = false;
});
$scope.selection.isExtended = false;
rebuildSelections();
};

View File

@ -33,11 +33,8 @@ export default
template: '<input type="checkbox" data-multi-select-list-item ng-model="isSelected" ng-change="userInteractionSelect()">',
link: function(scope, element, attrs, multiSelectList) {
var initializeItem = function() {
scope.decoratedItem = multiSelectList.registerItem(scope.item);
scope.isSelected = scope.item.isSelected ? true : false;
scope.decoratedItem.isSelected = scope.item.isSelected ? true : false;
};
scope.decoratedItem = multiSelectList.registerItem(scope.item);
scope.isSelected = scope.decoratedItem.isSelected ? true : false;
scope.$watch('isSelected', function(value) {
if (value === true) {
@ -47,23 +44,10 @@ export default
}
});
scope.$watch('item', function() {
// This is necessary for page changes where $scope.item gets updated via ng-repeat
// but this link function never gets triggered (and scope.decoratedItem) never
// gets updated.
initializeItem();
});
scope.$on('$destroy', function() {
multiSelectList.deregisterItem(scope.decoratedItem);
});
scope.userInteractionSelect = function() {
scope.$emit("selectedOrDeselected", scope.decoratedItem);
};
initializeItem();
}
};
}];

View File

@ -74,6 +74,7 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams,
$scope.limit = data.limit;
$scope.verbosity = data.verbosity;
$scope.job_tags = data.job_tags;
$scope.job.module_name = data.module_name;
if (data.extra_vars) {
$scope.variables = ParseVariableString(data.extra_vars);
}

View File

@ -37,7 +37,7 @@
<body data-user-agent="{{userAgent}}">
<main-menu></main-menu>
<bread-crumb ng-if="!includesCurrentState('inventoryManage')"></bread-crumb><div ui-view="groupBreadcrumbs" ng-if="includesCurrentState('inventoryManage')"></div>
<bread-crumb ng-show="!includesCurrentState('inventoryManage')"></bread-crumb><div ui-view="groupBreadcrumbs" ng-show="includesCurrentState('inventoryManage')"></div>
<toast></toast>
<div class="container-fluid" id="content-container">

View File

@ -14,8 +14,11 @@ attempt=0
while [[ $attempt -lt $retry_attempts ]]
do
status_code=`curl -s -i --data "host_config_key=$2" http://$1/api/v1/job_templates/$3/callback/ | head -n 1 | awk '{print $2}'`
if [[ $status_code == 202 ]]
if [[ $status_code -ge 300 ]]
then
echo "${status_code} received, encountered problem, halting."
exit 1
else
exit 0
fi
attempt=$(( attempt + 1 ))