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
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: 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 cd packaging/packer && $(PACKER) build -only $@ $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
# virtualbox # Vagrant box using virtualbox provider
virtualbox-ovf: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box vagrant-virtualbox: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box
packaging/packer/ansible-tower-$(VERSION)-virtualbox.box: packaging/packer/output-virtualbox-iso/centos-7.ovf 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 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: packaging/packer/output-virtualbox-iso/centos-7.ovf:
cd packaging/packer && $(PACKER) build -only virtualbox-iso packer-centos-7.json 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 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: packaging/packer/output-vmware-iso/centos-7.vmx:
cd packaging/packer && $(PACKER) build -only vmware-iso packer-centos-7.json cd packaging/packer && $(PACKER) build -only vmware-iso packer-centos-7.json
vmware-iso: packaging/packer/output-vmware-iso/centos-7.vmx packaging/packer/ansible-tower-$(VERSION)-vmware.box: 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
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 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 # 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 serializer_class = InventorySourceSerializer
elif isinstance(obj, JobTemplate): elif isinstance(obj, JobTemplate):
serializer_class = JobTemplateSerializer serializer_class = JobTemplateSerializer
elif isinstance(obj, SystemJobTemplate):
serializer_class = SystemJobTemplateSerializer
if serializer_class: if serializer_class:
serializer = serializer_class(instance=obj) serializer = serializer_class(instance=obj, context=self.context)
return serializer.to_representation(obj) return serializer.to_representation(obj)
else: else:
return super(UnifiedJobTemplateSerializer, self).to_representation(obj) return super(UnifiedJobTemplateSerializer, self).to_representation(obj)
@@ -590,7 +592,7 @@ class UnifiedJobSerializer(BaseSerializer):
elif isinstance(obj, SystemJob): elif isinstance(obj, SystemJob):
serializer_class = SystemJobSerializer serializer_class = SystemJobSerializer
if serializer_class: if serializer_class:
serializer = serializer_class(instance=obj) serializer = serializer_class(instance=obj, context=self.context)
ret = serializer.to_representation(obj) ret = serializer.to_representation(obj)
else: else:
ret = super(UnifiedJobSerializer, self).to_representation(obj) ret = super(UnifiedJobSerializer, self).to_representation(obj)
@@ -637,7 +639,7 @@ class UnifiedJobListSerializer(UnifiedJobSerializer):
elif isinstance(obj, SystemJob): elif isinstance(obj, SystemJob):
serializer_class = SystemJobListSerializer serializer_class = SystemJobListSerializer
if serializer_class: if serializer_class:
serializer = serializer_class(instance=obj) serializer = serializer_class(instance=obj, context=self.context)
ret = serializer.to_representation(obj) ret = serializer.to_representation(obj)
else: else:
ret = super(UnifiedJobListSerializer, self).to_representation(obj) ret = super(UnifiedJobListSerializer, self).to_representation(obj)
@@ -1285,7 +1287,8 @@ class CustomInventoryScriptSerializer(BaseSerializer):
request = self.context.get('request', None) request = self.context.get('request', None)
if request.user not in obj.admin_role and \ if request.user not in obj.admin_role and \
not request.user.is_superuser 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 ret['script'] = None
return ret return ret
@@ -1713,19 +1716,21 @@ class CredentialSerializerCreate(CredentialSerializer):
attrs.pop(field) attrs.pop(field)
if not owner_fields: if not owner_fields:
raise serializers.ValidationError({"detail": "Missing 'user', 'team', or 'organization'."}) 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) return super(CredentialSerializerCreate, self).validate(attrs)
def create(self, validated_data): def create(self, validated_data):
user = validated_data.pop('user', None) user = validated_data.pop('user', None)
team = validated_data.pop('team', None) team = validated_data.pop('team', None)
if team:
validated_data['organization'] = team.organization
credential = super(CredentialSerializerCreate, self).create(validated_data) credential = super(CredentialSerializerCreate, self).create(validated_data)
if user: if user:
credential.admin_role.members.add(user) credential.admin_role.members.add(user)
if team: 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 return credential
@@ -1823,7 +1828,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer):
class Meta: class Meta:
model = JobTemplate model = JobTemplate
fields = ('*', 'host_config_key', 'ask_variables_on_launch', 'ask_limit_on_launch', 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') 'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'allow_simultaneous')
def get_related(self, obj): def get_related(self, obj):
@@ -1907,6 +1912,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
passwords_needed_to_start = serializers.ReadOnlyField() passwords_needed_to_start = serializers.ReadOnlyField()
ask_variables_on_launch = serializers.ReadOnlyField() ask_variables_on_launch = serializers.ReadOnlyField()
ask_limit_on_launch = serializers.ReadOnlyField() ask_limit_on_launch = serializers.ReadOnlyField()
ask_skip_tags_on_launch = serializers.ReadOnlyField()
ask_tags_on_launch = serializers.ReadOnlyField() ask_tags_on_launch = serializers.ReadOnlyField()
ask_job_type_on_launch = serializers.ReadOnlyField() ask_job_type_on_launch = serializers.ReadOnlyField()
ask_inventory_on_launch = serializers.ReadOnlyField() ask_inventory_on_launch = serializers.ReadOnlyField()
@@ -1915,8 +1921,8 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
class Meta: class Meta:
model = Job model = Job
fields = ('*', 'job_template', 'passwords_needed_to_start', 'ask_variables_on_launch', 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_limit_on_launch', 'ask_tags_on_launch', 'ask_skip_tags_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch') 'ask_job_type_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch')
def get_related(self, obj): def get_related(self, obj):
res = super(JobSerializer, self).get_related(obj) res = super(JobSerializer, self).get_related(obj)
@@ -1977,7 +1983,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
return ret return ret
if 'job_template' in ret and not obj.job_template: if 'job_template' in ret and not obj.job_template:
ret['job_template'] = None 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() ret['extra_vars'] = obj.display_extra_vars()
return ret return ret
@@ -2281,14 +2287,15 @@ class JobLaunchSerializer(BaseSerializer):
fields = ('can_start_without_user_input', 'passwords_needed_to_start', fields = ('can_start_without_user_input', 'passwords_needed_to_start',
'extra_vars', 'limit', 'job_tags', 'skip_tags', 'job_type', 'inventory', 'extra_vars', 'limit', 'job_tags', 'skip_tags', 'job_type', 'inventory',
'credential', 'ask_variables_on_launch', 'ask_tags_on_launch', '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', 'ask_inventory_on_launch', 'ask_credential_on_launch',
'survey_enabled', 'variables_needed_to_start', 'survey_enabled', 'variables_needed_to_start',
'credential_needed_to_start', 'inventory_needed_to_start', 'credential_needed_to_start', 'inventory_needed_to_start',
'job_template_data', 'defaults') 'job_template_data', 'defaults')
read_only_fields = ('ask_variables_on_launch', 'ask_limit_on_launch', read_only_fields = (
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch') 'ask_skip_tags_on_launch', 'ask_job_type_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch')
extra_kwargs = { extra_kwargs = {
'credential': {'write_only': True,}, 'credential': {'write_only': True,},
'limit': {'write_only': True,}, 'limit': {'write_only': True,},
@@ -2675,6 +2682,8 @@ class ActivityStreamSerializer(BaseSerializer):
fval = getattr(thisItem, field, None) fval = getattr(thisItem, field, None)
if fval is not None: if fval is not None:
thisItemDict[field] = fval thisItemDict[field] = fval
if fk == 'group':
thisItemDict['inventory_id'] = getattr(thisItem, 'inventory_id', None)
summary_fields[fk].append(thisItemDict) summary_fields[fk].append(thisItemDict)
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass

View File

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

View File

@@ -1,4 +1,3 @@
# Copyright (c) 2015 Ansible, Inc. # Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
@@ -201,7 +200,7 @@ class ApiV1ConfigView(APIView):
'''Return various sitewide configuration settings.''' '''Return various sitewide configuration settings.'''
license_reader = TaskSerializer() 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']: 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 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()) user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys())
data['user_ldap_fields'] = user_ldap_fields 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( data.update(dict(
project_base_dir = settings.PROJECTS_ROOT, project_base_dir = settings.PROJECTS_ROOT,
project_local_paths = Project.get_local_path_choices(), project_local_paths = Project.get_local_path_choices(),
@@ -880,12 +882,19 @@ class TeamRolesList(SubListCreateAttachDetachAPIView):
data = dict(msg="Role 'id' field is missing.") data = dict(msg="Role 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST) return Response(data, status=status.HTTP_400_BAD_REQUEST)
role = Role.objects.get(pk=sub_id) role = get_object_or_400(Role, pk=sub_id)
content_type = ContentType.objects.get_for_model(Organization) org_content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == content_type: if role.content_type == org_content_type:
data = dict(msg="You cannot assign an Organization role as a child role for a Team.") 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) 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) return super(TeamRolesList, self).post(request, *args, **kwargs)
class TeamObjectRolesList(SubListAPIView): class TeamObjectRolesList(SubListAPIView):
@@ -1209,7 +1218,24 @@ class UserRolesList(SubListCreateAttachDetachAPIView):
return Response(data, status=status.HTTP_400_BAD_REQUEST) return Response(data, status=status.HTTP_400_BAD_REQUEST)
if sub_id == self.request.user.admin_role.pk: 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) return super(UserRolesList, self).post(request, *args, **kwargs)
@@ -1388,8 +1414,8 @@ class TeamCredentialsList(SubListCreateAPIView):
self.check_parent_access(team) self.check_parent_access(team)
visible_creds = Credential.accessible_objects(self.request.user, 'read_role') visible_creds = Credential.accessible_objects(self.request.user, 'read_role')
team_creds = Credential.objects.filter(admin_role__parents=team.member_role) team_creds = Credential.objects.filter(Q(use_role__parents=team.member_role) | Q(admin_role__parents=team.member_role))
return team_creds & visible_creds return (team_creds & visible_creds).distinct()
class OrganizationCredentialList(SubListCreateAPIView): class OrganizationCredentialList(SubListCreateAPIView):
@@ -2975,7 +3001,17 @@ class JobJobTasksList(BaseJobEventsList):
# and these are what we're interested in here. # and these are what we're interested in here.
STARTING_EVENTS = ('playbook_on_task_start', 'playbook_on_setup') 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 # 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 # 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. # make appropriate changes to the task data.
for child_data in data.get(task_start_event.id, []): for child_data in data.get(task_start_event.id, []):
if child_data['event'] == 'runner_on_failed': if child_data['event'] == 'runner_on_failed':
task_data['failed'] = True
task_data['host_count'] += child_data['num'] task_data['host_count'] += child_data['num']
task_data['reported_hosts'] += 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': elif child_data['event'] == 'runner_on_ok':
task_data['host_count'] += child_data['num'] task_data['host_count'] += child_data['num']
task_data['reported_hosts'] += child_data['num'] task_data['reported_hosts'] += child_data['num']
@@ -3625,7 +3664,6 @@ class RoleDetail(RetrieveAPIView):
model = Role model = Role
serializer_class = RoleSerializer serializer_class = RoleSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True new_in_300 = True
@@ -3648,6 +3686,26 @@ class RoleUsersList(SubListCreateAttachDetachAPIView):
if not sub_id: if not sub_id:
data = dict(msg="User 'id' field is missing.") data = dict(msg="User 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST) 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) return super(RoleUsersList, self).post(request, *args, **kwargs)
@@ -3672,13 +3730,20 @@ class RoleTeamsList(SubListAPIView):
data = dict(msg="Team 'id' field is missing.") data = dict(msg="Team 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST) 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']) 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.") 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) 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' action = 'attach'
if request.data.get('disassociate', None): if request.data.get('disassociate', None):
action = 'unattach' action = 'unattach'

View File

@@ -428,8 +428,8 @@ class HostAccess(BaseAccess):
return obj and self.user in obj.inventory.read_role return obj and self.user in obj.inventory.read_role
def can_add(self, data): def can_add(self, data):
if not data or 'inventory' not in data: if not data: # So the browseable API will work
return False return Inventory.accessible_objects(self.user, 'admin_role').exists()
# Checks for admin or change permission on inventory. # Checks for admin or change permission on inventory.
inventory_pk = get_pk_from_dict(data, 'inventory') inventory_pk = get_pk_from_dict(data, 'inventory')
@@ -654,23 +654,14 @@ class CredentialAccess(BaseAccess):
if not obj: if not obj:
return False return False
# Check access to organizations # Cannot change the organization for a credential after it's been created
organization_pk = get_pk_from_dict(data, 'organization') if data and 'organization' in data:
if data and 'organization' in data and organization_pk != getattr(obj, 'organization_id', None): organization_pk = get_pk_from_dict(data, 'organization')
if organization_pk: if (organization_pk and (not obj.organization or organization_pk != obj.organization.id)) \
# admin permission to destination organization is mandatory or (not organization_pk and obj.organization):
new_organization_obj = get_object_or_400(Organization, pk=organization_pk) return False
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
print(self.user in obj.admin_role)
return self.user in obj.admin_role return self.user in obj.admin_role
def can_delete(self, obj): def can_delete(self, obj):
@@ -981,8 +972,11 @@ class JobTemplateAccess(BaseAccess):
field_whitelist = [ field_whitelist = [
'name', 'description', 'forks', 'limit', 'verbosity', 'extra_vars', 'name', 'description', 'forks', 'limit', 'verbosity', 'extra_vars',
'job_tags', 'force_handlers', 'skip_tags', 'ask_variables_on_launch', 'job_tags', 'force_handlers', 'skip_tags', 'ask_variables_on_launch',
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch', 'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_skip_tags_on_launch',
'ask_credential_on_launch', 'survey_enabled' '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(): for k, v in data.items():
@@ -1089,7 +1083,8 @@ class JobAccess(BaseAccess):
def can_delete(self, obj): def can_delete(self, obj):
if obj.inventory is not None and self.user in obj.inventory.organization.admin_role: if obj.inventory is not None and self.user in obj.inventory.organization.admin_role:
return True 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 True
return False 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]) 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) inventory_source_qs = self.user.get_queryset(InventorySource).filter(source__in=CLOUD_INVENTORY_SOURCES)
job_template_qs = self.user.get_queryset(JobTemplate) 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) | qs = qs.filter(Q(Project___in=project_qs) |
Q(InventorySource___in=inventory_source_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( qs = qs.select_related(
'created_by', 'created_by',
'modified_by', 'modified_by',
@@ -1569,21 +1566,22 @@ class ActivityStreamAccess(BaseAccess):
inventory_set = Inventory.accessible_objects(self.user, 'read_role') inventory_set = Inventory.accessible_objects(self.user, 'read_role')
credential_set = Credential.accessible_objects(self.user, 'read_role') credential_set = Credential.accessible_objects(self.user, 'read_role')
organization_set = Organization.accessible_objects(self.user, 'read_role') auditing_orgs = (
admin_of_orgs = Organization.accessible_objects(self.user, 'admin_role') Organization.accessible_objects(self.user, 'admin_role') |
group_set = Group.objects.filter(inventory__in=inventory_set) Organization.accessible_objects(self.user, 'auditor_role')
).distinct().values_list('id', flat=True)
project_set = Project.accessible_objects(self.user, 'read_role') project_set = Project.accessible_objects(self.user, 'read_role')
jt_set = JobTemplate.accessible_objects(self.user, 'read_role') jt_set = JobTemplate.accessible_objects(self.user, 'read_role')
team_set = Team.accessible_objects(self.user, 'read_role') team_set = Team.accessible_objects(self.user, 'read_role')
return qs.filter( return qs.filter(
Q(ad_hoc_command__inventory__in=inventory_set) | 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(user=self.user) |
Q(organization__in=organization_set) | Q(organization__in=auditing_orgs) |
Q(inventory__in=inventory_set) | Q(inventory__in=inventory_set) |
Q(host__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_source__inventory__in=inventory_set) |
Q(inventory_update__inventory_source__inventory__in=inventory_set) | Q(inventory_update__inventory_source__inventory__in=inventory_set) |
Q(credential__in=credential_set) | Q(credential__in=credential_set) |
@@ -1592,10 +1590,10 @@ class ActivityStreamAccess(BaseAccess):
Q(project_update__project__in=project_set) | Q(project_update__project__in=project_set) |
Q(job_template__in=jt_set) | Q(job_template__in=jt_set) |
Q(job__job_template__in=jt_set) | Q(job__job_template__in=jt_set) |
Q(notification_template__organization__in=admin_of_orgs) | Q(notification_template__organization__in=auditing_orgs) |
Q(notification__notification_template__organization__in=admin_of_orgs) | Q(notification__notification_template__organization__in=auditing_orgs) |
Q(label__organization__in=organization_set) | Q(label__organization__in=auditing_orgs) |
Q(role__in=Role.visible_roles(self.user)) Q(role__in=Role.visible_roles(self.user) if auditing_orgs else [])
).distinct() ).distinct()
def can_add(self, data): def can_add(self, data):
@@ -1667,14 +1665,8 @@ class RoleAccess(BaseAccess):
if self.user.is_superuser or self.user.is_system_auditor: if self.user.is_superuser or self.user.is_system_auditor:
return True return True
if obj.object_id: return Role.filter_visible_roles(
sister_roles = Role.objects.filter( self.user, Role.objects.filter(pk=obj.id)).exists()
content_type = obj.content_type,
object_id = obj.object_id
)
else:
sister_roles = obj
return self.user.roles.filter(descendents__in=sister_roles).exists()
def can_add(self, obj, data): def can_add(self, obj, data):
# Unsupported for now # 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( admin_role = ImplicitRoleField(
parent_role=[ parent_role=[
'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
'organization.admin_role',
], ],
) )
use_role = ImplicitRoleField( use_role = ImplicitRoleField(
parent_role=[ parent_role=[
'organization.admin_role',
'admin_role', 'admin_role',
] ]
) )

View File

@@ -6,6 +6,7 @@ import hmac
import json import json
import yaml import yaml
import logging import logging
import time
from urlparse import urljoin from urlparse import urljoin
# Django # Django
@@ -26,7 +27,7 @@ from awx.main.models.unified_jobs import * # noqa
from awx.main.models.notifications import NotificationTemplate from awx.main.models.notifications import NotificationTemplate
from awx.main.utils import decrypt_field, ignore_inventory_computed_fields from awx.main.utils import decrypt_field, ignore_inventory_computed_fields
from awx.main.utils import emit_websocket_notification 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.conf import tower_settings
from awx.main.fields import ImplicitRoleField from awx.main.fields import ImplicitRoleField
from awx.main.models.mixins import ResourceMixin from awx.main.models.mixins import ResourceMixin
@@ -199,6 +200,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
blank=True, blank=True,
default=False, default=False,
) )
ask_skip_tags_on_launch = models.BooleanField(
blank=True,
default=False,
)
ask_job_type_on_launch = models.BooleanField( ask_job_type_on_launch = models.BooleanField(
blank=True, blank=True,
default=False, default=False,
@@ -244,7 +249,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
'playbook', 'credential', 'cloud_credential', 'network_credential', 'forks', 'schedule', 'playbook', 'credential', 'cloud_credential', 'network_credential', 'forks', 'schedule',
'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type', 'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type',
'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled', 'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled',
'labels',] 'labels', 'survey_passwords']
def resource_validation_data(self): def resource_validation_data(self):
''' '''
@@ -418,7 +423,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
extra_vars=self.ask_variables_on_launch, extra_vars=self.ask_variables_on_launch,
limit=self.ask_limit_on_launch, limit=self.ask_limit_on_launch,
job_tags=self.ask_tags_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, job_type=self.ask_job_type_on_launch,
inventory=self.ask_inventory_on_launch, inventory=self.ask_inventory_on_launch,
credential=self.ask_credential_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: if field == 'extra_vars' and self.survey_enabled and self.survey_spec:
# Accept vars defined in the survey and no others # Accept vars defined in the survey and no others
survey_vars = [question['variable'] for question in self.survey_spec.get('spec', [])] 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: if key in survey_vars:
prompted_fields[field][key] = kwargs[field][key] prompted_fields[field][key] = extra_vars[key]
else: else:
ignored_fields[field][key] = kwargs[field][key] ignored_fields[field][key] = extra_vars[key]
else: else:
ignored_fields[field] = kwargs[field] ignored_fields[field] = kwargs[field]
@@ -509,6 +524,11 @@ class Job(UnifiedJob, JobOptions):
editable=False, editable=False,
through='JobHostSummary', through='JobHostSummary',
) )
survey_passwords = JSONField(
blank=True,
default={},
editable=False,
)
@classmethod @classmethod
def _get_parent_field_name(cls): def _get_parent_field_name(cls):
@@ -550,6 +570,12 @@ class Job(UnifiedJob, JobOptions):
return self.job_template.ask_tags_on_launch return self.job_template.ask_tags_on_launch
return False 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 @property
def ask_job_type_on_launch(self): def ask_job_type_on_launch(self):
if self.job_template is not None: if self.job_template is not None:
@@ -670,9 +696,17 @@ class Job(UnifiedJob, JobOptions):
dependencies.append(source.create_inventory_update(launch_type='dependency')) dependencies.append(source.create_inventory_update(launch_type='dependency'))
return dependencies return dependencies
def notification_data(self): def notification_data(self, block=5):
data = super(Job, self).notification_data() data = super(Job, self).notification_data()
all_hosts = {} 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(): for h in self.job_host_summaries.all():
all_hosts[h.host_name] = dict(failed=h.failed, all_hosts[h.host_name] = dict(failed=h.failed,
changed=h.changed, changed=h.changed,
@@ -711,16 +745,12 @@ class Job(UnifiedJob, JobOptions):
''' '''
Hides fields marked as passwords in survey. Hides fields marked as passwords in survey.
''' '''
if self.extra_vars and self.job_template and self.job_template.survey_enabled: if self.survey_passwords:
try: extra_vars = json.loads(self.extra_vars)
extra_vars = json.loads(self.extra_vars) extra_vars.update(self.survey_passwords)
for key in self.job_template.survey_password_variables(): return json.dumps(extra_vars)
if key in extra_vars: else:
extra_vars[key] = REPLACE_STR return self.extra_vars
return json.dumps(extra_vars)
except ValueError:
pass
return self.extra_vars
def _survey_search_and_replace(self, content): def _survey_search_and_replace(self, content):
# Use job template survey spec to identify password fields. # 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.base import * # noqa
from awx.main.models.schedules import Schedule from awx.main.models.schedules import Schedule
from awx.main.utils import decrypt_field, emit_websocket_notification, _inventory_updates 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'] __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() 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: class Meta:
app_label = 'main' app_label = 'main'
unique_together = [('polymorphic_ctype', 'name')] unique_together = [('polymorphic_ctype', 'name')]
@@ -343,6 +346,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
create_kwargs[field_name] = getattr(self, field_name) create_kwargs[field_name] = getattr(self, field_name)
new_kwargs = self._update_unified_job_kwargs(**create_kwargs) new_kwargs = self._update_unified_job_kwargs(**create_kwargs)
unified_job = unified_job_class(**new_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() unified_job.save()
for field_name, src_field_value in m2m_fields.iteritems(): for field_name, src_field_value in m2m_fields.iteritems():
dest_field = getattr(unified_job, field_name) dest_field = getattr(unified_job, field_name)
@@ -367,6 +378,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
PASSWORD_FIELDS = ('start_args',) 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: class Meta:
app_label = 'main' app_label = 'main'

View File

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

View File

@@ -26,14 +26,14 @@ def survey_spec_factory():
return create_survey_spec return create_survey_spec
@pytest.fixture @pytest.fixture
def job_with_secret_key_factory(job_template_factory): def job_template_with_survey_passwords_factory(job_template_factory):
def rf(persisted): def rf(persisted):
"Returns job with linked JT survey with password survey questions" "Returns job with linked JT survey with password survey questions"
objects = job_template_factory('jt', organization='org1', survey=[ objects = job_template_factory('jt', organization='org1', survey=[
{'variable': 'submitter_email', 'type': 'text', 'default': 'foobar@redhat.com'}, {'variable': 'submitter_email', 'type': 'text', 'default': 'foobar@redhat.com'},
{'variable': 'secret_key', 'default': '6kQngg3h8lgiSTvIEb21', 'type': 'password'}, {'variable': 'secret_key', 'default': '6kQngg3h8lgiSTvIEb21', 'type': 'password'},
{'variable': 'SSN', 'type': 'password'}], jobs=[1], persisted=persisted) {'variable': 'SSN', 'type': 'password'}], persisted=persisted)
return objects.jobs[1] return objects.job_template
return rf return rf
@pytest.fixture @pytest.fixture
@@ -43,3 +43,7 @@ def job_with_secret_key_unit(job_with_secret_key_factory):
@pytest.fixture @pytest.fixture
def get_ssh_version(mocker): def get_ssh_version(mocker):
return mocker.patch('awx.main.tasks.get_ssh_version', return_value='OpenSSH_6.9p1, LibreSSL 2.1.8') 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 @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'), { response = post(reverse('api:credential_list'), {
'team': team.id, 'team': team.id,
'organization': organization.id,
'name': 'Some name', 'name': 'Some name',
'username': 'someusername' 'username': 'someusername'
}, org_admin) }, 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 assert response.data['count'] == 1
@pytest.mark.django_db @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'), { response = post(reverse('api:credential_list'), {
'team': team.id, 'team': team.id,
'organization': organization.id,
'name': 'Some name', 'name': 'Some name',
'username': 'someusername' 'username': 'someusername'
}, alice) }, alice)
assert response.status_code == 403 assert response.status_code == 403
@pytest.mark.django_db @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? # Members can't add credentials, only org admins.. for now?
response = post(reverse('api:credential_list'), { response = post(reverse('api:credential_list'), {
'team': team.id, 'team': team.id,
'organization': organization.id,
'name': 'Some name', 'name': 'Some name',
'username': 'someusername' 'username': 'someusername'
}, team_member) }, team_member)
assert response.status_code == 403 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 # organization credentials
@@ -177,6 +312,37 @@ def test_list_created_org_credentials(post, get, organization, org_admin, org_me
assert response.data['count'] == 0 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 # Openstack Credentials
@@ -224,33 +390,3 @@ def test_create_credential_missing_user_team_org_xfail(post, admin):
}, admin) }, admin)
assert response.status_code == 400 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', name='deploy-job-template',
ask_variables_on_launch=on_off, ask_variables_on_launch=on_off,
ask_tags_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_job_type_on_launch=on_off,
ask_inventory_on_launch=on_off, ask_inventory_on_launch=on_off,
ask_limit_on_launch=on_off, ask_limit_on_launch=on_off,
@@ -54,6 +55,7 @@ def job_template_prompts_null(project):
name='deploy-job-template', name='deploy-job-template',
ask_variables_on_launch=True, ask_variables_on_launch=True,
ask_tags_on_launch=True, ask_tags_on_launch=True,
ask_skip_tags_on_launch=True,
ask_job_type_on_launch=True, ask_job_type_on_launch=True,
ask_inventory_on_launch=True, ask_inventory_on_launch=True,
ask_limit_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) 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.django_db
@pytest.mark.job_runtime_vars @pytest.mark.job_runtime_vars
def test_job_accept_prompted_vars_null(runtime_data, job_template_prompts_null, post, rando, mocker): 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 # AWX
from awx.api.serializers import JobTemplateSerializer, JobLaunchSerializer 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.models.projects import ProjectOptions
from awx.main.migrations import _save_password_keys as save_password_keys
# Django # Django
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.apps import apps
@property @property
def project_playbooks(self): def project_playbooks(self):
@@ -103,9 +105,10 @@ def test_edit_nonsenstive(patch, job_template_factory, alice):
'extra_vars': '--', 'extra_vars': '--',
'job_tags': 'sometags', 'job_tags': 'sometags',
'force_handlers': True, 'force_handlers': True,
'skip_tags': True, 'skip_tags': 'thistag,thattag',
'ask_variables_on_launch':True, 'ask_variables_on_launch':True,
'ask_tags_on_launch':True, 'ask_tags_on_launch':True,
'ask_skip_tags_on_launch':True,
'ask_job_type_on_launch':True, 'ask_job_type_on_launch':True,
'ask_inventory_on_launch':True, 'ask_inventory_on_launch':True,
'ask_credential_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() objects.job_template.create_unified_job()
delete_response = delete(reverse('api:job_template_detail', args=[objects.job_template.pk]), user=admin_user) delete_response = delete(reverse('api:job_template_detail', args=[objects.job_template.pk]), user=admin_user)
assert delete_response.status_code == 409 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.django_db
@pytest.mark.survey @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] AS_record = ActivityStream.objects.filter(object1='job').all()[0]
changes_dict = json.loads(AS_record.changes) changes_dict = json.loads(AS_record.changes)
extra_vars = json.loads(changes_dict['extra_vars']) extra_vars = json.loads(changes_dict['extra_vars'])

View File

@@ -160,7 +160,7 @@ def organization(instance):
@pytest.fixture @pytest.fixture
def credential(): 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 @pytest.fixture
def machine_credential(): def machine_credential():
@@ -168,7 +168,7 @@ def machine_credential():
@pytest.fixture @pytest.fixture
def org_credential(organization): 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 @pytest.fixture
def inventory(organization): def inventory(organization):
@@ -206,8 +206,8 @@ def notification(notification_template):
subject='email subject') subject='email subject')
@pytest.fixture @pytest.fixture
def job_with_secret_key(job_with_secret_key_factory): def job_template_with_survey_passwords(job_template_with_survey_passwords_factory):
return job_with_secret_key_factory(persisted=True) return job_template_with_survey_passwords_factory(persisted=True)
@pytest.fixture @pytest.fixture
def admin(user): def admin(user):

View File

@@ -133,29 +133,6 @@ def test_org_credential_access_member(alice, org_credential, credential):
'description': 'New description.', 'description': 'New description.',
'organization': None}) '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 @pytest.mark.django_db
def test_cred_job_template_xfail(user, deploy_jobtemplate): def test_cred_job_template_xfail(user, deploy_jobtemplate):
' Personal credential migration ' ' 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[0].admin_role.members.add(a)
orgs[1].admin_role.members.add(a) orgs[1].admin_role.members.add(a)
access = CredentialAccess(a)
rbac.migrate_credential(apps, None) rbac.migrate_credential(apps, None)
for jt in jts: for jt in jts:
@@ -256,11 +232,6 @@ def test_single_cred_multi_job_template_multi_org(user, organizations, credentia
credential.refresh_from_db() credential.refresh_from_db()
assert jts[0].credential != jts[1].credential 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 @pytest.mark.django_db
def test_cred_inventory_source(user, inventory, credential): 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) access = JobAccess(rando)
assert not access.can_delete(normal_job) 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 @pytest.mark.django_db
def test_inventory_org_admin_delete_allowed(normal_job, org_admin): 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 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 mock
import pytest 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 ( from awx.api.views import (
ApiV1RootView, ApiV1RootView,
TeamRolesList,
JobTemplateLabelList, JobTemplateLabelList,
) )
from awx.main.models import (
User,
Role,
)
@pytest.fixture @pytest.fixture
def mock_response_new(mocker): def mock_response_new(mocker):
m = mocker.patch('awx.api.views.Response.__new__') m = mocker.patch('awx.api.views.Response.__new__')
@@ -71,27 +60,3 @@ class TestJobTemplateLabelList:
super(JobTemplateLabelList, view).unattach(mock_request, None, None) super(JobTemplateLabelList, view).unattach(mock_request, None, None)
assert mixin_unattach.called_with(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 pytest
import json
def test_missing_project_error(job_template_factory): 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 'inventory' in validation_errors
assert 'credential' 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 @pytest.mark.survey
def test_survey_password_list(job_with_secret_key_unit): def test_job_template_survey_password_redaction(job_template_with_survey_passwords_unit):
"""Verify that survey_password_variables method gives a list of survey passwords""" """Tests the JobTemplate model's funciton to redact passwords from
assert job_with_secret_key_unit.job_template.survey_password_variables() == ['secret_key', 'SSN'] 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 import json
from awx.main.tasks import RunJob from awx.main.tasks import RunJob
from awx.main.models import Job
@pytest.fixture @pytest.fixture
@@ -14,9 +15,19 @@ def job(mocker):
'launch_type': 'manual'}) 'launch_type': 'manual'})
@pytest.mark.survey @pytest.mark.survey
def test_job_redacted_extra_vars(job_with_secret_key_unit): def test_job_survey_password_redaction():
"""Verify that this method redacts vars marked as passwords in a survey""" """Tests the Job model's funciton to redact passwords from
assert json.loads(job_with_secret_key_unit.display_extra_vars()) == { 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', 'submitter_email': 'foobar@redhat.com',
'secret_key': '$encrypted$', 'secret_key': '$encrypted$',
'SSN': '$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), 'credential': None, 'cloud_credential': None, 'network_credential': Credential(**options),
'become_enabled': False, 'become_method': None, 'become_username': None, 'become_enabled': False, 'become_method': None, 'become_username': None,
'inventory': mocker.MagicMock(spec=Inventory, id=2), 'force_handlers': False, 'inventory': mocker.MagicMock(spec=Inventory, id=2), 'force_handlers': False,
'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': False, 'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': None,
'start_at_task': False, 'pk': 1, 'launch_type': 'normal', 'job_template':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'} 'created_by': None, 'extra_vars_dict': None, 'project':None, 'playbook': 'test.yml'}
mock_job = mocker.MagicMock(spec=Job, **mock_job_attrs) 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 # http://urllib3.readthedocs.org/en/latest/security.html#disabling-warnings
requests.packages.urllib3.disable_warnings() requests.packages.urllib3.disable_warnings()
class CloudFormsInventory(object): class CloudFormsInventory(object):
def _empty_inventory(self): def _empty_inventory(self):
return {"_meta" : {"hostvars" : {}}} return {"_meta": {"hostvars": {}}}
def __init__(self): def __init__(self):
''' Main execution path ''' ''' Main execution path '''
@@ -43,7 +44,7 @@ class CloudFormsInventory(object):
# This doesn't exist yet and needs to be added # This doesn't exist yet and needs to be added
if self.args.host: if self.args.host:
data2 = { } data2 = {}
print json.dumps(data2, indent=2) print json.dumps(data2, indent=2)
def parse_cli_args(self): 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 = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on CloudForms')
parser.add_argument('--list', action='store_true', default=False, 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', 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() self.args = parser.parse_args()
def read_settings(self): def read_settings(self):
@@ -97,30 +98,47 @@ class CloudFormsInventory(object):
def get_hosts(self): def get_hosts(self):
''' Gets host from CloudForms ''' ''' 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() obj = r.json()
#Remove objects that don't matter # Create groups+hosts based on host data
del obj["count"] for resource in obj.get('resources', []):
del obj["subcount"]
del obj["name"]
#Create a new list to grab VMs with power_state on to add to a new list # Maintain backwards compat by creating `Dynamic_CloudForms` group
#I'm sure there is a cleaner way to do this if 'Dynamic_CloudForms' not in self.inventory:
newlist = [] self.inventory['Dynamic_CloudForms'] = []
getnext = False self.inventory['Dynamic_CloudForms'].append(resource['name'])
for x in obj.items():
for y in x[1]: # Add host to desired groups
for z in y.items(): for key in ('vendor', 'type', 'location'):
if getnext == True: if key in resource:
newlist.append(z[1]) # Create top-level group
getnext = False if key not in self.inventory:
if ( z[0] == "power_state" and z[1] == "on" ): self.inventory[key] = dict(children=[], vars={}, hosts=[])
getnext = True # if resource['name'] not in self.inventory[key]['hosts']:
newdict = {'hosts': newlist} # self.inventory[key]['hosts'].append(resource['name'])
newdict2 = {'Dynamic_CloudForms': newdict}
print json.dumps(newdict2, indent=2) # 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 # Run the script
CloudFormsInventory() CloudFormsInventory()

View File

@@ -697,6 +697,16 @@ SATELLITE6_HOST_FILTER = r'^.+$'
SATELLITE6_EXCLUDE_EMPTY_GROUPS = True SATELLITE6_EXCLUDE_EMPTY_GROUPS = True
SATELLITE6_INSTANCE_ID_VAR = 'foreman.id' 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 -- # -- Activity Stream --
# --------------------- # ---------------------

View File

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

View File

@@ -44,9 +44,11 @@
color: @list-header-txt; color: @list-header-txt;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
white-space: nowrap;
padding-bottom: 25px; padding-bottom: 25px;
min-height: 45px; min-height: 45px;
word-break: break-all;
max-width: 90%;
word-wrap: break-word;
} }
.Form-secondaryTitle{ .Form-secondaryTitle{
@@ -55,7 +57,10 @@
min-height: 40px; 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; height:15px;
color: @default-interface-txt; color: @default-interface-txt;
background-color: @default-list-header-bg; background-color: @default-list-header-bg;
@@ -353,6 +358,11 @@
border-color: transparent transparent @field-dropdown-icon transparent!important; 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{ .select2-dropdown{
border:1px solid @field-border; border:1px solid @field-border;

View File

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

View File

@@ -51,23 +51,6 @@ export default
PaginateInit({ scope: scope, PaginateInit({ scope: scope,
list: list, url: url, pageSize: 5 }); 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); scope.search(list.iterator);
}); });
} }

View File

@@ -21,6 +21,13 @@
key: true, key: true,
label: 'name' 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; display: inline-block;
color: @default-interface-txt; color: @default-interface-txt;
text-transform: uppercase; 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 { .BreadCrumb-item + .BreadCrumb-item:before {

View File

@@ -46,13 +46,13 @@ export function CredentialsList($scope, $rootScope, $location, $log,
Wait('stop'); Wait('stop');
$('#prompt-modal').modal('hide'); $('#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 // Translate the kind value
for (i = 0; i < $scope.credentials.length; i++) { for (i = 0; i < $scope.credentials.length; i++) {
for (j = 0; j < $scope.credential_kind_options.length; j++) { for (j = 0; j < $scope.credential_kind_options_list.length; j++) {
if ($scope.credential_kind_options[j].value === $scope.credentials[i].kind) { if ($scope.credential_kind_options_list[j].value === $scope.credentials[i].kind) {
$scope.credentials[i].kind = $scope.credential_kind_options[j].label; $scope.credentials[i].kind = $scope.credential_kind_options_list[j].label;
break; break;
} }
} }
@@ -82,7 +82,7 @@ export function CredentialsList($scope, $rootScope, $location, $log,
scope: $scope, scope: $scope,
url: defaultUrl, url: defaultUrl,
field: 'kind', field: 'kind',
variable: 'credential_kind_options', variable: 'credential_kind_options_list',
callback: 'choicesReadyCredential' callback: 'choicesReadyCredential'
}); });
@@ -148,7 +148,7 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log,
url; url;
$scope.keyEntered = false; $scope.keyEntered = false;
$scope.permissionsTooltip = 'Please save before assigning permissions';
generator.inject(form, { mode: 'add', related: false, scope: $scope }); generator.inject(form, { mode: 'add', related: false, scope: $scope });
generator.reset(); generator.reset();
@@ -391,6 +391,12 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log,
$scope.removeCredentialLoaded(); $scope.removeCredentialLoaded();
} }
$scope.removeCredentialLoaded = $scope.$on('credentialLoaded', function () { $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; var set;
for (set in relatedSets) { for (set in relatedSets) {
$scope.search(relatedSets[set].iterator); $scope.search(relatedSets[set].iterator);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -228,10 +228,7 @@ export default
column: 2, column: 2,
awPopOver: "<p>Provide a comma separated list of tags.</p>\n" + 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>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. " + "<p>Consult the Ansible documentation for further details on the usage of tags.</p>",
"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",
dataTitle: "Job Tags", dataTitle: "Job Tags",
dataPlacement: "right", dataPlacement: "right",
dataContainer: "body", dataContainer: "body",
@@ -240,6 +237,25 @@ export default
text: 'Prompt on launch' 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: { checkbox_group: {
label: 'Options', label: 'Options',
type: 'checkbox_group', type: 'checkbox_group',
@@ -281,7 +297,7 @@ export default
column: 2, column: 2,
awPopOver: "callback_help", awPopOver: "callback_help",
awPopOverWatch: "callback_help", awPopOverWatch: "callback_help",
dataPlacement: 'right', dataPlacement: 'top',
dataTitle: 'Provisioning Callback URL', dataTitle: 'Provisioning Callback URL',
dataContainer: "body" dataContainer: "body"
}, },

View File

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

View File

@@ -55,9 +55,9 @@ export default
// Submit request to run an adhoc comamand // Submit request to run an adhoc comamand
.factory('AdhocRun', ['$location','$stateParams', 'LaunchJob', .factory('AdhocRun', ['$location','$stateParams', 'LaunchJob',
'PromptForPasswords', 'Rest', 'GetBasePath', 'Alert', 'ProcessErrors', 'PromptForPasswords', 'Rest', 'GetBasePath', 'Alert', 'ProcessErrors',
'Wait', 'Empty', 'CreateLaunchDialog', 'Wait', 'Empty', 'CreateLaunchDialog', '$state',
function ($location, $stateParams, LaunchJob, PromptForPasswords, function ($location, $stateParams, LaunchJob, PromptForPasswords,
Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog) { Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog, $state) {
return function (params) { return function (params) {
var id = params.project_id, var id = params.project_id,
scope = params.scope.$new(), 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) { if (scope.removeStartAdhocRun) {
scope.removeStartAdhocRun(); scope.removeStartAdhocRun();
} }
scope.removeStartAdhocRun = scope.$on('StartAdhocRun', function() { scope.removeStartAdhocRun = scope.$on('StartAdhocRun', function() {
LaunchJob({ var password,
scope: scope, postData={};
url: url, for (password in scope.passwords) {
callback: 'AdhocLaunchFinished' // send to the adhoc postData[scope.passwords[password]] = scope[
// standard out page 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 // start routine only if passwords need to be prompted

View File

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

View File

@@ -165,6 +165,7 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
has_inventory_sources = params.has_inventory_sources, has_inventory_sources = params.has_inventory_sources,
launch_class = '', launch_class = '',
launch_tip = 'Start sync process', launch_tip = 'Start sync process',
schedule_tip = 'Schedule future inventory syncs',
stat, stat_class, status_tip; stat, stat_class, status_tip;
stat = status; stat = status;
@@ -225,7 +226,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
"tooltip": status_tip, "tooltip": status_tip,
"status": stat, "status": stat,
"launch_class": launch_class, "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; scope.ask_tags_on_launch = (data.ask_tags_on_launch) ? true : false;
master.ask_tags_on_launch = scope.ask_tags_on_launch; 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; 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; 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', fld = (params.variable) ? params.variable : 'variables',
pfld = (params.parse_variable) ? params.parse_variable : 'parseType', pfld = (params.parse_variable) ? params.parse_variable : 'parseType',
onReady = params.onReady, onReady = params.onReady,
onChange = params.onChange; onChange = params.onChange,
readOnly = params.readOnly;
function removeField(fld) { function removeField(fld) {
//set our model to the last change in CodeMirror and then destroy CodeMirror //set our model to the last change in CodeMirror and then destroy CodeMirror

View File

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

View File

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

View File

@@ -19,18 +19,4 @@
<li class="BreadCrumb-item" ng-if="currentState !== 'inventoryManage'"></li> <li class="BreadCrumb-item" ng-if="currentState !== 'inventoryManage'"></li>
<div class="InventoryManageBreadCrumb-ncy" ng-if="!licenseMissing" ncy-breadcrumb></div> <div class="InventoryManageBreadCrumb-ncy" ng-if="!licenseMissing" ncy-breadcrumb></div>
</ol> </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> </div>

View File

@@ -100,6 +100,7 @@
// equal to case 'ec2' || 'rax' || 'azure' || 'azure_rm' || 'vmware' || 'satellite6' || 'cloudforms' || 'openstack' // equal to case 'ec2' || 'rax' || 'azure' || 'azure_rm' || 'vmware' || 'satellite6' || 'cloudforms' || 'openstack'
else{ else{
var credentialBasePath = (source === 'ec2') ? GetBasePath('credentials') + '?kind=aws' : GetBasePath('credentials') + (source === '' ? '' : '?kind=' + (source)); 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; CredentialList.basePath = credentialBasePath;
LookUpInit({ LookUpInit({
scope: $scope, scope: $scope,
@@ -122,7 +123,7 @@
$scope.group_by_choices = source === 'ec2' ? $scope.ec2_group_by : null; $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 // 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.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.group_by = null;
$scope.source_regions = null; $scope.source_regions = null;
$scope.credential = null; $scope.credential = null;

View File

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

View File

@@ -151,6 +151,7 @@
{status_tooltip: group_status.tooltip}, {status_tooltip: group_status.tooltip},
{launch_tooltip: group_status.launch_tip}, {launch_tooltip: group_status.launch_tip},
{launch_class: group_status.launch_class}, {launch_class: group_status.launch_class},
{group_schedule_tooltip: group_status.schedule_tip},
{hosts_status_tip: hosts_status.tooltip}, {hosts_status_tip: hosts_status.tooltip},
{hosts_status_class: hosts_status.class}, {hosts_status_class: hosts_status.class},
{source: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.source : null}, {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 { export default {
name: 'inventoryManage', name: 'inventoryManage',
url: '/inventories/:inventory_id/manage?{group:int}{failed}', url: '/inventories/:inventory_id/manage?{group:int}{failed}',
data: {
activityStream: true,
activityStreamTarget: 'inventory',
activityStreamId: 'inventory_id'
},
params:{ params:{
group:{ group:{
array: true array: true

View File

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

View File

@@ -641,7 +641,7 @@ export default
return true; return true;
}); });
//scope.setSearchAll('host'); //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); scope.$emit('LoadPlays', data.related.job_events);
}) })
.error(function(data, status) { .error(function(data, status) {

View File

@@ -154,6 +154,11 @@
<div class="JobDetail-resultRowText">{{ job.job_tags }}</div> <div class="JobDetail-resultRowText">{{ job.job_tags }}</div>
</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"> <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> <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> <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; 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){ if(scope.ask_limit_on_launch && scope.other_prompt_data && scope.other_prompt_data.limit){
job_launch_data.limit = 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 // General catch-all for "other prompts" - used in this link function and to hide the Other Prompts tab when
// it should be hidden // 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.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_inventory = data.defaults && data.defaults.inventory && data.defaults.inventory.id;
$scope.has_default_credential = data.defaults && data.defaults.credential && data.defaults.credential.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 : ""; $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) { if($scope.ask_variables_on_launch) {
$scope.jobLaunchVariables = (data.defaults && data.defaults.extra_vars) ? data.defaults.extra_vars : "---"; $scope.jobLaunchVariables = (data.defaults && data.defaults.extra_vars) ? data.defaults.extra_vars : "---";
$scope.other_prompt_data.parseType = 'yaml'; $scope.other_prompt_data.parseType = 'yaml';

View File

@@ -148,7 +148,15 @@
<span class="Form-inputLabel">Job Tags</span> <span class="Form-inputLabel">Job Tags</span>
</label> </label>
<div> <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>
</div> </div>
</form> </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_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_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_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; 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_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_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_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; 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-tagContainer,
.LabelList-seeMore { .LabelList-seeMoreLess {
display: flex; display: flex;
max-width: 100%; max-width: 100%;
} }
@@ -27,7 +27,7 @@
overflow: hidden; overflow: hidden;
} }
.LabelList-seeMore { .LabelList-seeMoreLess {
color: @default-link; color: @default-link;
margin: 4px 0px; margin: 4px 0px;
text-transform: uppercase; text-transform: uppercase;
@@ -37,7 +37,7 @@
font-size: 11px; font-size: 11px;
} }
.LabelList-seeMore:hover { .LabelList-seeMoreLess:hover {
color: @default-link-hov; 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) { scope.deleteLabel = function(templateId, templateName, labelId, labelName) {
var action = function () { var action = function () {
$('#prompt-modal').modal('hide'); $('#prompt-modal').modal('hide');
@@ -56,13 +63,13 @@ export default
Rest.setUrl(url); Rest.setUrl(url);
Rest.post({"disassociate": true, "id": labelId}) Rest.post({"disassociate": true, "id": labelId})
.success(function () { .success(function () {
scope.search("job_template"); scope.search("job_template", scope.$parent.job_template_page);
Wait('stop'); Wait('stop');
}) })
.error(function (data, status) { .error(function (data, status) {
Wait('stop'); Wait('stop');
ProcessErrors(scope, data, status, null, { hdr: 'Error!', 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; scope.count = null;
} }
}); });
} }
}; };
} }

View File

@@ -8,5 +8,7 @@
<span class="LabelList-name">{{ label.name }}</span> <span class="LabelList-name">{{ label.name }}</span>
</div> </div>
</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> 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)', ngClick: 'submitQuestion($event)',
ngDisabled: true, ngDisabled: true,
'class': 'btn btn-sm Form-saveButton', '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', columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6',
ngClick: "viewJobDetails(all_job)", ngClick: "viewJobDetails(all_job)",
defaultSearchField: true, defaultSearchField: true,
awToolTip: "{{ all_job.name | sanitize }}", searchDefault: true,
dataTipWatch: 'all_job.name',
dataPlacement: 'top'
}, },
type: { type: {
label: 'Type', label: 'Type',

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,8 @@ export default
key: true, key: true,
label: 'Name', label: 'Name',
columnClass: 'col-lg-5 col-md-5 col-sm-9 col-xs-8', 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: { description: {
label: 'Description', label: 'Description',

View File

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

View File

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

View File

@@ -149,6 +149,12 @@ export default
if(field.type === 'number'){ if(field.type === 'number'){
$scope[i] = Number($scope[i]); $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]; return $scope[i];
} }

View File

@@ -223,6 +223,12 @@ export default
if(field.type === 'number'){ if(field.type === 'number'){
$scope[i] = Number($scope[i]); $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]; return $scope[i];
} }

View File

@@ -28,6 +28,7 @@ export default
Wait('stop'); Wait('stop');
if (scope.notification_templates) { if (scope.notification_templates) {
scope.notification_templates.forEach(function(notification_template, i) { scope.notification_templates.forEach(function(notification_template, i) {
setStatus(notification_template);
scope.notification_type_options.forEach(function(type) { scope.notification_type_options.forEach(function(type) {
if (type.value === notification_template.notification_type) { if (type.value === notification_template.notification_type) {
scope.notification_templates[i].notification_type = type.label; scope.notification_templates[i].notification_type = type.label;
@@ -74,78 +75,33 @@ export default
callback: 'choicesReadyNotifierList' callback: 'choicesReadyNotifierList'
}); });
function attachElem(event, html, title) { function setStatus(notification_template) {
var elem = $(event.target).parent(); var html, recent_notifications = notification_template.summary_fields.recent_notifications;
try { if (recent_notifications.length > 0) {
elem.tooltip('hide'); html = "<table class=\"table table-condensed flyout\" style=\"width: 100%\">\n";
elem.popover('destroy'); html += "<thead>\n";
} html += "<tr>";
catch(err) { html += "<th>Status</th>";
//ignore html += "<th>Time</th>";
} html += "</tr>\n";
html += "</thead>\n";
html += "<tbody>\n";
$('.popover').each(function() { recent_notifications.forEach(function(row) {
// remove lingering popover <div>. Seems to be a bug in TB3 RC1 html += "<tr>\n";
$(this).remove(); 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";
$('.tooltip').each( function() { html += "</tr>\n";
// 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!
}); });
$('.popover-content, .popover-title').click(function() { html += "</tbody>\n";
elem.popover('hide'); html += "</table>\n";
}); }
}); else {
elem.popover('show'); 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(){ scope.testNotification = function(){
var name = $filter('sanitize')(this.notification_template.name), var name = $filter('sanitize')(this.notification_template.name),
pending_retries = 10; pending_retries = 10;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -390,46 +390,57 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper'])
// lookup Validate lookup value against API // lookup Validate lookup value against API
// //
.directive('awlookup', ['Rest', function(Rest) { .directive('awlookup', ['Rest', '$timeout', function(Rest, $timeout) {
return { return {
require: 'ngModel', require: 'ngModel',
link: function(scope, elm, attrs, ctrl) { link: function(scope, elm, attrs, ctrl) {
var restTimeout;
ctrl.$parsers.unshift( function(viewValue) { ctrl.$parsers.unshift( function(viewValue) {
if (viewValue !== '' && viewValue !== null) { if (viewValue !== '' && viewValue !== null) {
var url = elm.attr('data-url'); var url = elm.attr('data-url');
url = url.replace(/\:value/, encodeURI(viewValue)); url = url.replace(/\:value/, encodeURI(viewValue));
scope[elm.attr('data-source')] = null; scope[elm.attr('data-source')] = null;
Rest.setUrl(url); if(restTimeout) {
Rest.get().then( function(data) { $timeout.cancel(restTimeout);
var results = data.data.results; }
if (results.length > 0) { restTimeout = $timeout( function(){
scope[elm.attr('data-source')] = results[0].id; 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 // For user lookups the API endpoint doesn't
// have a `name` property, so this is `undefined` // have a `name` property, so this is `undefined`
// which causes the input to clear after typing // which causes the input to clear after typing
// a valid value O_o // a valid value O_o
// //
// Only assign if there is a value, so that we avoid // Only assign if there is a value, so that we avoid
// this situation. // this situation.
// //
// TODO: Evaluate if assigning name on the scope is // TODO: Evaluate if assigning name on the scope is
// even necessary at all. // even necessary at all.
// //
if (!_.isEmpty(results[0].name)) { if (!_.isEmpty(results[0].name)) {
scope[elm.attr('name')] = 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('required', true);
ctrl.$setValidity('awlookup', true); ctrl.$setValidity('awlookup', false);
return viewValue; return undefined;
} });
ctrl.$setValidity('required', true); }, 750);
ctrl.$setValidity('awlookup', false);
return undefined;
});
} }
else { else {
if(restTimeout) {
$timeout.cancel(restTimeout);
}
ctrl.$setValidity('awlookup', true); ctrl.$setValidity('awlookup', true);
scope[elm.attr('data-source')] = null; scope[elm.attr('data-source')] = null;
} }
@@ -477,7 +488,8 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper'])
return { return {
link: function(scope, element, attrs) { link: function(scope, element, attrs) {
var delay = (attrs.delay !== undefined && attrs.delay !== null) ? attrs.delay : ($AnsibleConfig) ? $AnsibleConfig.tooltip_delay : {show: 500, hide: 100}, var delay = (attrs.delay !== undefined && attrs.delay !== null) ? attrs.delay : ($AnsibleConfig) ? $AnsibleConfig.tooltip_delay : {show: 500, hide: 100},
placement; placement,
stateChangeWatcher;
if (attrs.awTipPlacement) { if (attrs.awTipPlacement) {
placement = 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>'; 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( ) { $(element).on('hidden.bs.tooltip', function( ) {
// TB3RC1 is leaving behind tooltip <div> elements. This will remove them // 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 // 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>'; 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) { if (attrs.awPopOverWatch) {
$(element).popover({ $(element).popover({
placement: placement, placement: placement,

View File

@@ -1487,6 +1487,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
"ng-show='is_system_auditor'>Auditor</span>"; "ng-show='is_system_auditor'>Auditor</span>";
html+= "<span class=\"Form-title--is_ldap_user\" "+ html+= "<span class=\"Form-title--is_ldap_user\" "+
"ng-show='ldap_user'>LDAP</span>"; "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>\n";
html += "<div class=\"Form-header--fields\">"; html += "<div class=\"Form-header--fields\">";
@@ -1524,8 +1526,13 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
collection = this.form.related[itm]; collection = this.form.related[itm];
html += `<div id="${itm}_tab"`+ html += `<div id="${itm}_tab"`+
`class="Form-tab"`+ `class="Form-tab"`+
`ng-click="${this.form.related[itm].disabled} || toggleFormTabs($event)"` + `ng-click="${this.form.related[itm].disabled} || toggleFormTabs($event)"`;
`ng-class="{'is-selected': ${itm}Selected ` ; 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){ if(this.form.related[itm].disabled){
html += `, 'Form-tab--disabled' : ${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))) { } else if (field.link || (field.key && (field.link === undefined || field.link))) {
html += "<a href=\"#/" + base + "/{{" + list.iterator + ".id }}\" "; html += "<a href=\"#/" + base + "/{{" + list.iterator + ".id }}\" ";
} else { } else {
html += "<a href=\"\">"; html += "<a href=\"\"";
} }
if (field.awDroppable) { if (field.awDroppable) {
html += Attr(field, 'awDroppable'); html += Attr(field, 'awDroppable');
@@ -394,7 +394,7 @@ angular.module('GeneratorHelpers', [systemStatus.name])
if (field.awPopOver) { if (field.awPopOver) {
html += "aw-pop-over=\"" + field.awPopOver + "\" "; html += "aw-pop-over=\"" + field.awPopOver + "\" ";
html += (field.dataPlacement) ? "data-placement=\"" + field.dataPlacement + "\" " : ""; 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.ngClass) ? Attr(field, 'ngClass') : '';
html += (field.ngEllipsis) ? "data-ng-bind=\"" + list.iterator + "." + fld + "\" data-ellipsis " : ""; 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-odd=\"'List-tableRow--oddRow'\" ";
innerTable += "ng-class-even=\"'List-tableRow--evenRow'\" "; innerTable += "ng-class-even=\"'List-tableRow--evenRow'\" ";
innerTable += "ng-repeat=\"" + list.iterator + " in " + list.name; 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.orderBy) ? " | orderBy:'" + list.orderBy + "'" : "";
innerTable += (list.filterBy) ? " | filter: " + list.filterBy : ""; innerTable += (list.filterBy) ? " | filter: " + list.filterBy : "";
innerTable += "\">\n"; innerTable += "\">\n";

View File

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

View File

@@ -33,11 +33,8 @@ export default
template: '<input type="checkbox" data-multi-select-list-item ng-model="isSelected" ng-change="userInteractionSelect()">', template: '<input type="checkbox" data-multi-select-list-item ng-model="isSelected" ng-change="userInteractionSelect()">',
link: function(scope, element, attrs, multiSelectList) { link: function(scope, element, attrs, multiSelectList) {
var initializeItem = function() { scope.decoratedItem = multiSelectList.registerItem(scope.item);
scope.decoratedItem = multiSelectList.registerItem(scope.item); scope.isSelected = scope.decoratedItem.isSelected ? true : false;
scope.isSelected = scope.item.isSelected ? true : false;
scope.decoratedItem.isSelected = scope.item.isSelected ? true : false;
};
scope.$watch('isSelected', function(value) { scope.$watch('isSelected', function(value) {
if (value === true) { 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.userInteractionSelect = function() {
scope.$emit("selectedOrDeselected", scope.decoratedItem); scope.$emit("selectedOrDeselected", scope.decoratedItem);
}; };
initializeItem();
} }
}; };
}]; }];

View File

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

View File

@@ -37,7 +37,7 @@
<body data-user-agent="{{userAgent}}"> <body data-user-agent="{{userAgent}}">
<main-menu></main-menu> <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> <toast></toast>
<div class="container-fluid" id="content-container"> <div class="container-fluid" id="content-container">

View File

@@ -14,8 +14,11 @@ attempt=0
while [[ $attempt -lt $retry_attempts ]] while [[ $attempt -lt $retry_attempts ]]
do 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}'` 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 then
echo "${status_code} received, encountered problem, halting."
exit 1
else
exit 0 exit 0
fi fi
attempt=$(( attempt + 1 )) attempt=$(( attempt + 1 ))