diff --git a/Makefile b/Makefile index 574db49c58..8076ba2faf 100644 --- a/Makefile +++ b/Makefile @@ -689,30 +689,24 @@ reprepro: deb-build/$(DEB_NVRA).deb reprepro/conf amazon-ebs: cd packaging/packer && $(PACKER) build -only $@ $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json -# virtualbox -virtualbox-ovf: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box +# Vagrant box using virtualbox provider +vagrant-virtualbox: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box packaging/packer/ansible-tower-$(VERSION)-virtualbox.box: packaging/packer/output-virtualbox-iso/centos-7.ovf cd packaging/packer && $(PACKER) build -only virtualbox-ovf $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json -packaging/packer/output-virtualbox-iso/centos-6.ovf: - cd packaging/packer && $(PACKER) build packer-centos-6.json - packaging/packer/output-virtualbox-iso/centos-7.ovf: cd packaging/packer && $(PACKER) build -only virtualbox-iso packer-centos-7.json -# virtualbox-iso: packaging/packer/output-virtualbox-iso/centos-6.ovf virtualbox-iso: packaging/packer/output-virtualbox-iso/centos-7.ovf -# vmware +# Vagrant box using VMware provider +vagrant-vmware: packaging/packer/ansible-tower-$(VERSION)-vmware.box + packaging/packer/output-vmware-iso/centos-7.vmx: cd packaging/packer && $(PACKER) build -only vmware-iso packer-centos-7.json -vmware-iso: packaging/packer/output-vmware-iso/centos-7.vmx - -vmware-vmx: packaging/packer/ansible-tower-$(VERSION)-vmx/ansible-tower-$(VERSION).vmx - -packaging/packer/ansible-tower-$(VERSION)-vmx/ansible-tower-$(VERSION).vmx: packaging/packer/output-vmware-iso/centos-7.vmx +packaging/packer/ansible-tower-$(VERSION)-vmware.box: packaging/packer/output-vmware-iso/centos-7.vmx cd packaging/packer && $(PACKER) build -only vmware-vmx $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json # TODO - figure out how to build the front-end and python requirements with diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a46788362b..2d2e38a8f5 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -526,8 +526,10 @@ class UnifiedJobTemplateSerializer(BaseSerializer): serializer_class = InventorySourceSerializer elif isinstance(obj, JobTemplate): serializer_class = JobTemplateSerializer + elif isinstance(obj, SystemJobTemplate): + serializer_class = SystemJobTemplateSerializer if serializer_class: - serializer = serializer_class(instance=obj) + serializer = serializer_class(instance=obj, context=self.context) return serializer.to_representation(obj) else: return super(UnifiedJobTemplateSerializer, self).to_representation(obj) @@ -590,7 +592,7 @@ class UnifiedJobSerializer(BaseSerializer): elif isinstance(obj, SystemJob): serializer_class = SystemJobSerializer if serializer_class: - serializer = serializer_class(instance=obj) + serializer = serializer_class(instance=obj, context=self.context) ret = serializer.to_representation(obj) else: ret = super(UnifiedJobSerializer, self).to_representation(obj) @@ -637,7 +639,7 @@ class UnifiedJobListSerializer(UnifiedJobSerializer): elif isinstance(obj, SystemJob): serializer_class = SystemJobListSerializer if serializer_class: - serializer = serializer_class(instance=obj) + serializer = serializer_class(instance=obj, context=self.context) ret = serializer.to_representation(obj) else: ret = super(UnifiedJobListSerializer, self).to_representation(obj) @@ -1285,7 +1287,8 @@ class CustomInventoryScriptSerializer(BaseSerializer): request = self.context.get('request', None) if request.user not in obj.admin_role and \ not request.user.is_superuser and \ - not request.user.is_system_auditor: + not request.user.is_system_auditor and \ + not (obj.organization is not None and request.user in obj.organization.auditor_role): ret['script'] = None return ret @@ -1713,19 +1716,21 @@ class CredentialSerializerCreate(CredentialSerializer): attrs.pop(field) if not owner_fields: raise serializers.ValidationError({"detail": "Missing 'user', 'team', or 'organization'."}) - elif len(owner_fields) > 1: - raise serializers.ValidationError({"detail": "Expecting exactly one of 'user', 'team', or 'organization'."}) - return super(CredentialSerializerCreate, self).validate(attrs) def create(self, validated_data): user = validated_data.pop('user', None) team = validated_data.pop('team', None) + if team: + validated_data['organization'] = team.organization credential = super(CredentialSerializerCreate, self).create(validated_data) if user: credential.admin_role.members.add(user) if team: - credential.admin_role.parents.add(team.member_role) + if not credential.organization or team.organization.id != credential.organization.id: + raise serializers.ValidationError({"detail": "Credential organization must be set and match before assigning to a team"}) + credential.admin_role.parents.add(team.admin_role) + credential.use_role.parents.add(team.member_role) return credential @@ -1823,7 +1828,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): class Meta: model = JobTemplate fields = ('*', 'host_config_key', 'ask_variables_on_launch', 'ask_limit_on_launch', - 'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch', + 'ask_tags_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'allow_simultaneous') def get_related(self, obj): @@ -1907,6 +1912,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): passwords_needed_to_start = serializers.ReadOnlyField() ask_variables_on_launch = serializers.ReadOnlyField() ask_limit_on_launch = serializers.ReadOnlyField() + ask_skip_tags_on_launch = serializers.ReadOnlyField() ask_tags_on_launch = serializers.ReadOnlyField() ask_job_type_on_launch = serializers.ReadOnlyField() ask_inventory_on_launch = serializers.ReadOnlyField() @@ -1915,8 +1921,8 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): class Meta: model = Job fields = ('*', 'job_template', 'passwords_needed_to_start', 'ask_variables_on_launch', - 'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_job_type_on_launch', - 'ask_inventory_on_launch', 'ask_credential_on_launch') + 'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_skip_tags_on_launch', + 'ask_job_type_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch') def get_related(self, obj): res = super(JobSerializer, self).get_related(obj) @@ -1977,7 +1983,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): return ret if 'job_template' in ret and not obj.job_template: ret['job_template'] = None - if obj.job_template and obj.job_template.survey_enabled and 'extra_vars' in ret: + if 'extra_vars' in ret: ret['extra_vars'] = obj.display_extra_vars() return ret @@ -2281,14 +2287,15 @@ class JobLaunchSerializer(BaseSerializer): fields = ('can_start_without_user_input', 'passwords_needed_to_start', 'extra_vars', 'limit', 'job_tags', 'skip_tags', 'job_type', 'inventory', 'credential', 'ask_variables_on_launch', 'ask_tags_on_launch', - 'ask_job_type_on_launch', 'ask_limit_on_launch', + 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_limit_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled', 'variables_needed_to_start', 'credential_needed_to_start', 'inventory_needed_to_start', 'job_template_data', 'defaults') - read_only_fields = ('ask_variables_on_launch', 'ask_limit_on_launch', - 'ask_tags_on_launch', 'ask_job_type_on_launch', - 'ask_inventory_on_launch', 'ask_credential_on_launch') + read_only_fields = ( + 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch', + 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', + 'ask_inventory_on_launch', 'ask_credential_on_launch') extra_kwargs = { 'credential': {'write_only': True,}, 'limit': {'write_only': True,}, @@ -2675,6 +2682,8 @@ class ActivityStreamSerializer(BaseSerializer): fval = getattr(thisItem, field, None) if fval is not None: thisItemDict[field] = fval + if fk == 'group': + thisItemDict['inventory_id'] = getattr(thisItem, 'inventory_id', None) summary_fields[fk].append(thisItemDict) except ObjectDoesNotExist: pass diff --git a/awx/api/templates/api/job_template_launch.md b/awx/api/templates/api/job_template_launch.md index 0c17c3d842..10c2c4288e 100644 --- a/awx/api/templates/api/job_template_launch.md +++ b/awx/api/templates/api/job_template_launch.md @@ -8,6 +8,8 @@ The response will include the following fields: configured to prompt for variables upon launch (boolean, read-only) * `ask_tags_on_launch`: Flag indicating whether the job_template is configured to prompt for tags upon launch (boolean, read-only) +* `ask_skip_tags_on_launch`: Flag indicating whether the job_template is + configured to prompt for skip_tags upon launch (boolean, read-only) * `ask_job_type_on_launch`: Flag indicating whether the job_template is configured to prompt for job_type upon launch (boolean, read-only) * `ask_limit_on_launch`: Flag indicating whether the job_template is diff --git a/awx/api/views.py b/awx/api/views.py index 042a123fbe..39db4cd23c 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1,4 +1,3 @@ - # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. @@ -201,7 +200,7 @@ class ApiV1ConfigView(APIView): '''Return various sitewide configuration settings.''' license_reader = TaskSerializer() - license_data = license_reader.from_database(show_key=request.user.is_superuser) + license_data = license_reader.from_database(show_key=request.user.is_superuser or request.user.is_system_auditor) if license_data and 'features' in license_data and 'activity_streams' in license_data['features']: license_data['features']['activity_streams'] &= tower_settings.ACTIVITY_STREAM_ENABLED @@ -225,7 +224,10 @@ class ApiV1ConfigView(APIView): user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys()) data['user_ldap_fields'] = user_ldap_fields - if request.user.is_superuser or Organization.accessible_objects(request.user, 'admin_role').exists(): + if request.user.is_superuser \ + or request.user.is_system_auditor \ + or Organization.accessible_objects(request.user, 'admin_role').exists() \ + or Organization.accessible_objects(request.user, 'auditor_role').exists(): data.update(dict( project_base_dir = settings.PROJECTS_ROOT, project_local_paths = Project.get_local_path_choices(), @@ -880,12 +882,19 @@ class TeamRolesList(SubListCreateAttachDetachAPIView): data = dict(msg="Role 'id' field is missing.") return Response(data, status=status.HTTP_400_BAD_REQUEST) - role = Role.objects.get(pk=sub_id) - content_type = ContentType.objects.get_for_model(Organization) - if role.content_type == content_type: + role = get_object_or_400(Role, pk=sub_id) + org_content_type = ContentType.objects.get_for_model(Organization) + if role.content_type == org_content_type: data = dict(msg="You cannot assign an Organization role as a child role for a Team.") return Response(data, status=status.HTTP_400_BAD_REQUEST) + team = get_object_or_404(Team, pk=self.kwargs['pk']) + credential_content_type = ContentType.objects.get_for_model(Credential) + if role.content_type == credential_content_type: + if not role.content_object.organization or role.content_object.organization.id != team.organization.id: + data = dict(msg="You cannot grant credential access to a team when the Organization field isn't set, or belongs to a different organization") + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return super(TeamRolesList, self).post(request, *args, **kwargs) class TeamObjectRolesList(SubListAPIView): @@ -1209,7 +1218,24 @@ class UserRolesList(SubListCreateAttachDetachAPIView): return Response(data, status=status.HTTP_400_BAD_REQUEST) if sub_id == self.request.user.admin_role.pk: - raise PermissionDenied('You may not remove your own admin_role.') + raise PermissionDenied('You may not perform any action with your own admin_role.') + + user = get_object_or_400(User, pk=self.kwargs['pk']) + role = get_object_or_400(Role, pk=sub_id) + user_content_type = ContentType.objects.get_for_model(User) + if role.content_type == user_content_type: + raise PermissionDenied('You may not change the membership of a users admin_role') + + credential_content_type = ContentType.objects.get_for_model(Credential) + if role.content_type == credential_content_type: + if role.content_object.organization and user not in role.content_object.organization.member_role: + data = dict(msg="You cannot grant credential access to a user not in the credentials' organization") + return Response(data, status=status.HTTP_400_BAD_REQUEST) + + if not role.content_object.organization and not request.user.is_superuser: + data = dict(msg="You cannot grant private credential access to another user") + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return super(UserRolesList, self).post(request, *args, **kwargs) @@ -1388,8 +1414,8 @@ class TeamCredentialsList(SubListCreateAPIView): self.check_parent_access(team) visible_creds = Credential.accessible_objects(self.request.user, 'read_role') - team_creds = Credential.objects.filter(admin_role__parents=team.member_role) - return team_creds & visible_creds + team_creds = Credential.objects.filter(Q(use_role__parents=team.member_role) | Q(admin_role__parents=team.member_role)) + return (team_creds & visible_creds).distinct() class OrganizationCredentialList(SubListCreateAPIView): @@ -2975,7 +3001,17 @@ class JobJobTasksList(BaseJobEventsList): # and these are what we're interested in here. STARTING_EVENTS = ('playbook_on_task_start', 'playbook_on_setup') - queryset = JobEvent.start_event_queryset(parent_task, STARTING_EVENTS) + # We need to pull information about each start event. + # + # This is super tricky, because this table has a one-to-many + # relationship with itself (parent-child), and we're getting + # information for an arbitrary number of children. This means we + # need stats on grandchildren, sorted by child. + queryset = (JobEvent.objects.filter(parent__parent=parent_task, + parent__event__in=STARTING_EVENTS) + .values('parent__id', 'event', 'changed', 'failed') + .annotate(num=Count('event')) + .order_by('parent__id')) # The data above will come back in a list, but we are going to # want to access it based on the parent id, so map it into a @@ -3034,10 +3070,13 @@ class JobJobTasksList(BaseJobEventsList): # make appropriate changes to the task data. for child_data in data.get(task_start_event.id, []): if child_data['event'] == 'runner_on_failed': - task_data['failed'] = True task_data['host_count'] += child_data['num'] task_data['reported_hosts'] += child_data['num'] - task_data['failed_count'] += child_data['num'] + if child_data['failed']: + task_data['failed'] = True + task_data['failed_count'] += child_data['num'] + else: + task_data['skipped_count'] += child_data['num'] elif child_data['event'] == 'runner_on_ok': task_data['host_count'] += child_data['num'] task_data['reported_hosts'] += child_data['num'] @@ -3625,7 +3664,6 @@ class RoleDetail(RetrieveAPIView): model = Role serializer_class = RoleSerializer - permission_classes = (IsAuthenticated,) new_in_300 = True @@ -3648,6 +3686,26 @@ class RoleUsersList(SubListCreateAttachDetachAPIView): if not sub_id: data = dict(msg="User 'id' field is missing.") return Response(data, status=status.HTTP_400_BAD_REQUEST) + + user = get_object_or_400(User, pk=sub_id) + role = self.get_parent_object() + if role == self.request.user.admin_role: + raise PermissionDenied('You may not perform any action with your own admin_role.') + + user_content_type = ContentType.objects.get_for_model(User) + if role.content_type == user_content_type: + raise PermissionDenied('You may not change the membership of a users admin_role') + + credential_content_type = ContentType.objects.get_for_model(Credential) + if role.content_type == credential_content_type: + if role.content_object.organization and user not in role.content_object.organization.member_role: + data = dict(msg="You cannot grant credential access to a user not in the credentials' organization") + return Response(data, status=status.HTTP_400_BAD_REQUEST) + + if not role.content_object.organization and not request.user.is_superuser: + data = dict(msg="You cannot grant private credential access to another user") + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return super(RoleUsersList, self).post(request, *args, **kwargs) @@ -3672,13 +3730,20 @@ class RoleTeamsList(SubListAPIView): data = dict(msg="Team 'id' field is missing.") return Response(data, status=status.HTTP_400_BAD_REQUEST) + team = get_object_or_400(Team, pk=sub_id) role = Role.objects.get(pk=self.kwargs['pk']) - content_type = ContentType.objects.get_for_model(Organization) - if role.content_type == content_type: + + organization_content_type = ContentType.objects.get_for_model(Organization) + if role.content_type == organization_content_type: data = dict(msg="You cannot assign an Organization role as a child role for a Team.") return Response(data, status=status.HTTP_400_BAD_REQUEST) - team = Team.objects.get(pk=sub_id) + credential_content_type = ContentType.objects.get_for_model(Credential) + if role.content_type == credential_content_type: + if not role.content_object.organization or role.content_object.organization.id != team.organization.id: + data = dict(msg="You cannot grant credential access to a team when the Organization field isn't set, or belongs to a different organization") + return Response(data, status=status.HTTP_400_BAD_REQUEST) + action = 'attach' if request.data.get('disassociate', None): action = 'unattach' diff --git a/awx/main/access.py b/awx/main/access.py index d8fe5b245b..5fa3b76274 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -428,8 +428,8 @@ class HostAccess(BaseAccess): return obj and self.user in obj.inventory.read_role def can_add(self, data): - if not data or 'inventory' not in data: - return False + if not data: # So the browseable API will work + return Inventory.accessible_objects(self.user, 'admin_role').exists() # Checks for admin or change permission on inventory. inventory_pk = get_pk_from_dict(data, 'inventory') @@ -654,23 +654,14 @@ class CredentialAccess(BaseAccess): if not obj: return False - # Check access to organizations - organization_pk = get_pk_from_dict(data, 'organization') - if data and 'organization' in data and organization_pk != getattr(obj, 'organization_id', None): - if organization_pk: - # admin permission to destination organization is mandatory - new_organization_obj = get_object_or_400(Organization, pk=organization_pk) - if self.user not in new_organization_obj.admin_role: - return False - # admin permission to existing organization is also mandatory - if obj.organization: - if self.user not in obj.organization.admin_role: - return False - - if obj.organization: - if self.user in obj.organization.admin_role: - return True + # Cannot change the organization for a credential after it's been created + if data and 'organization' in data: + organization_pk = get_pk_from_dict(data, 'organization') + if (organization_pk and (not obj.organization or organization_pk != obj.organization.id)) \ + or (not organization_pk and obj.organization): + return False + print(self.user in obj.admin_role) return self.user in obj.admin_role def can_delete(self, obj): @@ -981,8 +972,11 @@ class JobTemplateAccess(BaseAccess): field_whitelist = [ 'name', 'description', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', 'skip_tags', 'ask_variables_on_launch', - 'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch', - 'ask_credential_on_launch', 'survey_enabled' + 'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_skip_tags_on_launch', + 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled', + + # These fields are ignored, but it is convenient for QA to allow clients to post them + 'last_job_run', 'created', 'modified', ] for k, v in data.items(): @@ -1089,7 +1083,8 @@ class JobAccess(BaseAccess): def can_delete(self, obj): if obj.inventory is not None and self.user in obj.inventory.organization.admin_role: return True - if obj.project is not None and self.user in obj.project.organization.admin_role: + if (obj.project is not None and obj.project.organization is not None and + self.user in obj.project.organization.admin_role): return True return False @@ -1299,9 +1294,11 @@ class UnifiedJobTemplateAccess(BaseAccess): project_qs = self.user.get_queryset(Project).filter(scm_type__in=[s[0] for s in Project.SCM_TYPE_CHOICES]) inventory_source_qs = self.user.get_queryset(InventorySource).filter(source__in=CLOUD_INVENTORY_SOURCES) job_template_qs = self.user.get_queryset(JobTemplate) + system_job_template_qs = self.user.get_queryset(SystemJobTemplate) qs = qs.filter(Q(Project___in=project_qs) | Q(InventorySource___in=inventory_source_qs) | - Q(JobTemplate___in=job_template_qs)) + Q(JobTemplate___in=job_template_qs) | + Q(systemjobtemplate__in=system_job_template_qs)) qs = qs.select_related( 'created_by', 'modified_by', @@ -1569,21 +1566,22 @@ class ActivityStreamAccess(BaseAccess): inventory_set = Inventory.accessible_objects(self.user, 'read_role') credential_set = Credential.accessible_objects(self.user, 'read_role') - organization_set = Organization.accessible_objects(self.user, 'read_role') - admin_of_orgs = Organization.accessible_objects(self.user, 'admin_role') - group_set = Group.objects.filter(inventory__in=inventory_set) + auditing_orgs = ( + Organization.accessible_objects(self.user, 'admin_role') | + Organization.accessible_objects(self.user, 'auditor_role') + ).distinct().values_list('id', flat=True) project_set = Project.accessible_objects(self.user, 'read_role') jt_set = JobTemplate.accessible_objects(self.user, 'read_role') team_set = Team.accessible_objects(self.user, 'read_role') return qs.filter( Q(ad_hoc_command__inventory__in=inventory_set) | - Q(user__in=organization_set.values('member_role__members')) | + Q(user__in=auditing_orgs.values('member_role__members')) | Q(user=self.user) | - Q(organization__in=organization_set) | + Q(organization__in=auditing_orgs) | Q(inventory__in=inventory_set) | Q(host__inventory__in=inventory_set) | - Q(group__in=group_set) | + Q(group__inventory__in=inventory_set) | Q(inventory_source__inventory__in=inventory_set) | Q(inventory_update__inventory_source__inventory__in=inventory_set) | Q(credential__in=credential_set) | @@ -1592,10 +1590,10 @@ class ActivityStreamAccess(BaseAccess): Q(project_update__project__in=project_set) | Q(job_template__in=jt_set) | Q(job__job_template__in=jt_set) | - Q(notification_template__organization__in=admin_of_orgs) | - Q(notification__notification_template__organization__in=admin_of_orgs) | - Q(label__organization__in=organization_set) | - Q(role__in=Role.visible_roles(self.user)) + Q(notification_template__organization__in=auditing_orgs) | + Q(notification__notification_template__organization__in=auditing_orgs) | + Q(label__organization__in=auditing_orgs) | + Q(role__in=Role.visible_roles(self.user) if auditing_orgs else []) ).distinct() def can_add(self, data): @@ -1667,14 +1665,8 @@ class RoleAccess(BaseAccess): if self.user.is_superuser or self.user.is_system_auditor: return True - if obj.object_id: - sister_roles = Role.objects.filter( - content_type = obj.content_type, - object_id = obj.object_id - ) - else: - sister_roles = obj - return self.user.roles.filter(descendents__in=sister_roles).exists() + return Role.filter_visible_roles( + self.user, Role.objects.filter(pk=obj.id)).exists() def can_add(self, obj, data): # Unsupported for now diff --git a/awx/main/migrations/0029_v302_add_ask_skip_tags.py b/awx/main/migrations/0029_v302_add_ask_skip_tags.py new file mode 100644 index 0000000000..0aa5192c33 --- /dev/null +++ b/awx/main/migrations/0029_v302_add_ask_skip_tags.py @@ -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), + ), + ] diff --git a/awx/main/migrations/0030_v302_job_survey_passwords.py b/awx/main/migrations/0030_v302_job_survey_passwords.py new file mode 100644 index 0000000000..fa6c2cd3fe --- /dev/null +++ b/awx/main/migrations/0030_v302_job_survey_passwords.py @@ -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), + ), + ] diff --git a/awx/main/migrations/0031_v302_migrate_survey_passwords.py b/awx/main/migrations/0031_v302_migrate_survey_passwords.py new file mode 100644 index 0000000000..5eac01b853 --- /dev/null +++ b/awx/main/migrations/0031_v302_migrate_survey_passwords.py @@ -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), + ] diff --git a/awx/main/migrations/0032_v302_credential_permissions_update.py b/awx/main/migrations/0032_v302_credential_permissions_update.py new file mode 100644 index 0000000000..a961be6dcf --- /dev/null +++ b/awx/main/migrations/0032_v302_credential_permissions_update.py @@ -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), + ] diff --git a/awx/main/migrations/_save_password_keys.py b/awx/main/migrations/_save_password_keys.py new file mode 100644 index 0000000000..a5a231a92f --- /dev/null +++ b/awx/main/migrations/_save_password_keys.py @@ -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() diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 1bd11ec68e..3188e10083 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -215,11 +215,11 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): admin_role = ImplicitRoleField( parent_role=[ 'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, + 'organization.admin_role', ], ) use_role = ImplicitRoleField( parent_role=[ - 'organization.admin_role', 'admin_role', ] ) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index ff8cb6702d..c233269ce9 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -6,6 +6,7 @@ import hmac import json import yaml import logging +import time from urlparse import urljoin # Django @@ -26,7 +27,7 @@ from awx.main.models.unified_jobs import * # noqa from awx.main.models.notifications import NotificationTemplate from awx.main.utils import decrypt_field, ignore_inventory_computed_fields from awx.main.utils import emit_websocket_notification -from awx.main.redact import PlainTextCleaner, REPLACE_STR +from awx.main.redact import PlainTextCleaner from awx.main.conf import tower_settings from awx.main.fields import ImplicitRoleField from awx.main.models.mixins import ResourceMixin @@ -199,6 +200,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): blank=True, default=False, ) + ask_skip_tags_on_launch = models.BooleanField( + blank=True, + default=False, + ) ask_job_type_on_launch = models.BooleanField( blank=True, default=False, @@ -244,7 +249,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): 'playbook', 'credential', 'cloud_credential', 'network_credential', 'forks', 'schedule', 'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type', 'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled', - 'labels',] + 'labels', 'survey_passwords'] def resource_validation_data(self): ''' @@ -418,7 +423,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): extra_vars=self.ask_variables_on_launch, limit=self.ask_limit_on_launch, job_tags=self.ask_tags_on_launch, - skip_tags=self.ask_tags_on_launch, + skip_tags=self.ask_skip_tags_on_launch, job_type=self.ask_job_type_on_launch, inventory=self.ask_inventory_on_launch, credential=self.ask_credential_on_launch @@ -442,11 +447,21 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): if field == 'extra_vars' and self.survey_enabled and self.survey_spec: # Accept vars defined in the survey and no others survey_vars = [question['variable'] for question in self.survey_spec.get('spec', [])] - for key in kwargs[field]: + extra_vars = kwargs[field] + if isinstance(extra_vars, basestring): + try: + extra_vars = json.loads(extra_vars) + except (ValueError, TypeError): + try: + extra_vars = yaml.safe_load(extra_vars) + assert isinstance(extra_vars, dict) + except (yaml.YAMLError, TypeError, AttributeError, AssertionError): + extra_vars = {} + for key in extra_vars: if key in survey_vars: - prompted_fields[field][key] = kwargs[field][key] + prompted_fields[field][key] = extra_vars[key] else: - ignored_fields[field][key] = kwargs[field][key] + ignored_fields[field][key] = extra_vars[key] else: ignored_fields[field] = kwargs[field] @@ -509,6 +524,11 @@ class Job(UnifiedJob, JobOptions): editable=False, through='JobHostSummary', ) + survey_passwords = JSONField( + blank=True, + default={}, + editable=False, + ) @classmethod def _get_parent_field_name(cls): @@ -550,6 +570,12 @@ class Job(UnifiedJob, JobOptions): return self.job_template.ask_tags_on_launch return False + @property + def ask_skip_tags_on_launch(self): + if self.job_template is not None: + return self.job_template.ask_skip_tags_on_launch + return False + @property def ask_job_type_on_launch(self): if self.job_template is not None: @@ -670,9 +696,17 @@ class Job(UnifiedJob, JobOptions): dependencies.append(source.create_inventory_update(launch_type='dependency')) return dependencies - def notification_data(self): + def notification_data(self, block=5): data = super(Job, self).notification_data() all_hosts = {} + # NOTE: Probably related to job event slowness, remove at some point -matburt + if block: + summaries = self.job_host_summaries.all() + while block > 0 and not len(summaries): + time.sleep(1) + block -= 1 + else: + summaries = self.job_host_summaries.all() for h in self.job_host_summaries.all(): all_hosts[h.host_name] = dict(failed=h.failed, changed=h.changed, @@ -711,16 +745,12 @@ class Job(UnifiedJob, JobOptions): ''' Hides fields marked as passwords in survey. ''' - if self.extra_vars and self.job_template and self.job_template.survey_enabled: - try: - extra_vars = json.loads(self.extra_vars) - for key in self.job_template.survey_password_variables(): - if key in extra_vars: - extra_vars[key] = REPLACE_STR - return json.dumps(extra_vars) - except ValueError: - pass - return self.extra_vars + if self.survey_passwords: + extra_vars = json.loads(self.extra_vars) + extra_vars.update(self.survey_passwords) + return json.dumps(extra_vars) + else: + return self.extra_vars def _survey_search_and_replace(self, content): # Use job template survey spec to identify password fields. diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index f7106eb7ab..f39ee35c4c 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -32,7 +32,7 @@ from djcelery.models import TaskMeta from awx.main.models.base import * # noqa from awx.main.models.schedules import Schedule from awx.main.utils import decrypt_field, emit_websocket_notification, _inventory_updates -from awx.main.redact import UriCleaner +from awx.main.redact import UriCleaner, REPLACE_STR __all__ = ['UnifiedJobTemplate', 'UnifiedJob'] @@ -81,6 +81,9 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio ALL_STATUS_CHOICES = OrderedDict(PROJECT_STATUS_CHOICES + INVENTORY_SOURCE_STATUS_CHOICES + JOB_TEMPLATE_STATUS_CHOICES + DEPRECATED_STATUS_CHOICES).items() + # NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229 + _base_manager = models.Manager() + class Meta: app_label = 'main' unique_together = [('polymorphic_ctype', 'name')] @@ -343,6 +346,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio create_kwargs[field_name] = getattr(self, field_name) new_kwargs = self._update_unified_job_kwargs(**create_kwargs) unified_job = unified_job_class(**new_kwargs) + # For JobTemplate-based jobs with surveys, save list for perma-redaction + if (hasattr(self, 'survey_spec') and getattr(self, 'survey_enabled', False) and + not getattr(unified_job, 'survey_passwords', False)): + password_list = self.survey_password_variables() + hide_password_dict = {} + for password in password_list: + hide_password_dict[password] = REPLACE_STR + unified_job.survey_passwords = hide_password_dict unified_job.save() for field_name, src_field_value in m2m_fields.iteritems(): dest_field = getattr(unified_job, field_name) @@ -367,6 +378,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique PASSWORD_FIELDS = ('start_args',) + # NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229 + _base_manager = models.Manager() + class Meta: app_label = 'main' diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 17268515b5..877ed4b2d2 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -214,15 +214,18 @@ def handle_work_success(self, result, task_actual): friendly_name = "System Job" else: return - notification_body = instance.notification_data() - notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name, - task_actual['id'], - smart_str(instance_name), - notification_body['url']) - notification_body['friendly_name'] = friendly_name - send_notifications.delay([n.generate_notification(notification_subject, notification_body).id - for n in set(notification_templates.get('success', []) + notification_templates.get('any', []))], - job_id=task_actual['id']) + + all_notification_templates = set(notification_templates.get('success', []) + notification_templates.get('any', [])) + if len(all_notification_templates): + notification_body = instance.notification_data() + notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name, + task_actual['id'], + smart_str(instance_name), + notification_body['url']) + notification_body['friendly_name'] = friendly_name + send_notifications.delay([n.generate_notification(notification_subject, notification_body).id + for n in all_notification_templates], + job_id=task_actual['id']) @task(bind=True) def handle_work_error(self, task_id, subtasks=None): @@ -277,15 +280,18 @@ def handle_work_error(self, task_id, subtasks=None): (first_task_type, first_task_name, first_task_id) instance.save() instance.socketio_emit_status("failed") - notification_body = first_task.notification_data() - notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name, - first_task_id, - smart_str(first_task_name), - notification_body['url']) - notification_body['friendly_name'] = first_task_friendly_name - send_notifications.delay([n.generate_notification(notification_subject, notification_body).id - for n in set(notification_templates.get('error', []) + notification_templates.get('any', []))], - job_id=first_task_id) + + all_notification_templates = set(notification_templates.get('error', []) + notification_templates.get('any', [])) + if len(all_notification_templates): + notification_body = first_task.notification_data() + notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name, + first_task_id, + smart_str(first_task_name), + notification_body['url']) + notification_body['friendly_name'] = first_task_friendly_name + send_notifications.delay([n.generate_notification(notification_subject, notification_body).id + for n in all_notification_templates], + job_id=first_task_id) @task() diff --git a/awx/main/tests/conftest.py b/awx/main/tests/conftest.py index aff5859813..035b627922 100644 --- a/awx/main/tests/conftest.py +++ b/awx/main/tests/conftest.py @@ -26,14 +26,14 @@ def survey_spec_factory(): return create_survey_spec @pytest.fixture -def job_with_secret_key_factory(job_template_factory): +def job_template_with_survey_passwords_factory(job_template_factory): def rf(persisted): "Returns job with linked JT survey with password survey questions" objects = job_template_factory('jt', organization='org1', survey=[ {'variable': 'submitter_email', 'type': 'text', 'default': 'foobar@redhat.com'}, {'variable': 'secret_key', 'default': '6kQngg3h8lgiSTvIEb21', 'type': 'password'}, - {'variable': 'SSN', 'type': 'password'}], jobs=[1], persisted=persisted) - return objects.jobs[1] + {'variable': 'SSN', 'type': 'password'}], persisted=persisted) + return objects.job_template return rf @pytest.fixture @@ -43,3 +43,7 @@ def job_with_secret_key_unit(job_with_secret_key_factory): @pytest.fixture def get_ssh_version(mocker): return mocker.patch('awx.main.tasks.get_ssh_version', return_value='OpenSSH_6.9p1, LibreSSL 2.1.8') + +@pytest.fixture +def job_template_with_survey_passwords_unit(job_template_with_survey_passwords_factory): + return job_template_with_survey_passwords_factory(persisted=False) diff --git a/awx/main/tests/functional/api/test_credential.py b/awx/main/tests/functional/api/test_credential.py index eb6391ba05..3c79e62e33 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -68,9 +68,10 @@ def test_create_user_credential_via_user_credentials_list_xfail(post, alice, bob # @pytest.mark.django_db -def test_create_team_credential(post, get, team, org_admin, team_member): +def test_create_team_credential(post, get, team, organization, org_admin, team_member): response = post(reverse('api:credential_list'), { 'team': team.id, + 'organization': organization.id, 'name': 'Some name', 'username': 'someusername' }, org_admin) @@ -94,25 +95,159 @@ def test_create_team_credential_via_team_credentials_list(post, get, team, org_a assert response.data['count'] == 1 @pytest.mark.django_db -def test_create_team_credential_by_urelated_user_xfail(post, team, alice, team_member): +def test_create_team_credential_by_urelated_user_xfail(post, team, organization, alice, team_member): response = post(reverse('api:credential_list'), { 'team': team.id, + 'organization': organization.id, 'name': 'Some name', 'username': 'someusername' }, alice) assert response.status_code == 403 @pytest.mark.django_db -def test_create_team_credential_by_team_member_xfail(post, team, alice, team_member): +def test_create_team_credential_by_team_member_xfail(post, team, organization, alice, team_member): # Members can't add credentials, only org admins.. for now? response = post(reverse('api:credential_list'), { 'team': team.id, + 'organization': organization.id, 'name': 'Some name', 'username': 'someusername' }, team_member) assert response.status_code == 403 +# +# Permission granting +# + +@pytest.mark.django_db +def test_grant_org_credential_to_org_user_through_role_users(post, credential, organization, org_admin, org_member): + credential.organization = organization + credential.save() + response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), { + 'id': org_member.id + }, org_admin) + assert response.status_code == 204 + +@pytest.mark.django_db +def test_grant_org_credential_to_org_user_through_user_roles(post, credential, organization, org_admin, org_member): + credential.organization = organization + credential.save() + response = post(reverse('api:user_roles_list', args=(org_member.id,)), { + 'id': credential.use_role.id + }, org_admin) + assert response.status_code == 204 + +@pytest.mark.django_db +def test_grant_org_credential_to_non_org_user_through_role_users(post, credential, organization, org_admin, alice): + credential.organization = organization + credential.save() + response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), { + 'id': alice.id + }, org_admin) + assert response.status_code == 400 + +@pytest.mark.django_db +def test_grant_org_credential_to_non_org_user_through_user_roles(post, credential, organization, org_admin, alice): + credential.organization = organization + credential.save() + response = post(reverse('api:user_roles_list', args=(alice.id,)), { + 'id': credential.use_role.id + }, org_admin) + assert response.status_code == 400 + +@pytest.mark.django_db +def test_grant_private_credential_to_user_through_role_users(post, credential, alice, bob): + # normal users can't do this + credential.admin_role.members.add(alice) + response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), { + 'id': bob.id + }, alice) + assert response.status_code == 400 + +@pytest.mark.django_db +def test_grant_private_credential_to_org_user_through_role_users(post, credential, org_admin, org_member): + # org admins can't either + credential.admin_role.members.add(org_admin) + response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), { + 'id': org_member.id + }, org_admin) + assert response.status_code == 400 + +@pytest.mark.django_db +def test_sa_grant_private_credential_to_user_through_role_users(post, credential, admin, bob): + # but system admins can + response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), { + 'id': bob.id + }, admin) + assert response.status_code == 204 + +@pytest.mark.django_db +def test_grant_private_credential_to_user_through_user_roles(post, credential, alice, bob): + # normal users can't do this + credential.admin_role.members.add(alice) + response = post(reverse('api:user_roles_list', args=(bob.id,)), { + 'id': credential.use_role.id + }, alice) + assert response.status_code == 400 + +@pytest.mark.django_db +def test_grant_private_credential_to_org_user_through_user_roles(post, credential, org_admin, org_member): + # org admins can't either + credential.admin_role.members.add(org_admin) + response = post(reverse('api:user_roles_list', args=(org_member.id,)), { + 'id': credential.use_role.id + }, org_admin) + assert response.status_code == 400 + +@pytest.mark.django_db +def test_sa_grant_private_credential_to_user_through_user_roles(post, credential, admin, bob): + # but system admins can + response = post(reverse('api:user_roles_list', args=(bob.id,)), { + 'id': credential.use_role.id + }, admin) + assert response.status_code == 204 + +@pytest.mark.django_db +def test_grant_org_credential_to_team_through_role_teams(post, credential, organization, org_admin, org_auditor, team): + assert org_auditor not in credential.read_role + credential.organization = organization + credential.save() + response = post(reverse('api:role_teams_list', args=(credential.use_role.id,)), { + 'id': team.id + }, org_admin) + assert response.status_code == 204 + assert org_auditor in credential.read_role + +@pytest.mark.django_db +def test_grant_org_credential_to_team_through_team_roles(post, credential, organization, org_admin, org_auditor, team): + assert org_auditor not in credential.read_role + credential.organization = organization + credential.save() + response = post(reverse('api:team_roles_list', args=(team.id,)), { + 'id': credential.use_role.id + }, org_admin) + assert response.status_code == 204 + assert org_auditor in credential.read_role + +@pytest.mark.django_db +def test_sa_grant_private_credential_to_team_through_role_teams(post, credential, admin, team): + # not even a system admin can grant a private cred to a team though + response = post(reverse('api:role_teams_list', args=(credential.use_role.id,)), { + 'id': team.id + }, admin) + assert response.status_code == 400 + +@pytest.mark.django_db +def test_sa_grant_private_credential_to_team_through_team_roles(post, credential, admin, team): + # not even a system admin can grant a private cred to a team though + response = post(reverse('api:role_teams_list', args=(team.id,)), { + 'id': credential.use_role.id + }, admin) + assert response.status_code == 400 + + + # # organization credentials @@ -177,6 +312,37 @@ def test_list_created_org_credentials(post, get, organization, org_admin, org_me assert response.data['count'] == 0 +@pytest.mark.django_db +def test_cant_change_organization(patch, credential, organization, org_admin): + credential.organization = organization + credential.save() + + response = patch(reverse('api:credential_detail', args=(organization.id,)), { + 'name': 'Some new name', + }, org_admin) + assert response.status_code == 200 + + response = patch(reverse('api:credential_detail', args=(organization.id,)), { + 'name': 'Some new name2', + 'organization': organization.id, # fine for it to be the same + }, org_admin) + assert response.status_code == 200 + + response = patch(reverse('api:credential_detail', args=(organization.id,)), { + 'name': 'Some new name3', + 'organization': None + }, org_admin) + assert response.status_code == 403 + +@pytest.mark.django_db +def test_cant_add_organization(patch, credential, organization, org_admin): + assert credential.organization is None + response = patch(reverse('api:credential_detail', args=(organization.id,)), { + 'name': 'Some new name', + 'organization': organization.id + }, org_admin) + assert response.status_code == 403 + # # Openstack Credentials @@ -224,33 +390,3 @@ def test_create_credential_missing_user_team_org_xfail(post, admin): }, admin) assert response.status_code == 400 -@pytest.mark.django_db -def test_create_credential_with_user_and_org_xfail(post, organization, admin): - # Can only specify one of user, team, or organization - response = post(reverse('api:credential_list'), { - 'name': 'Some name', - 'username': 'someusername', - 'user': admin.id, - 'organization': organization.id, - }, admin) - assert response.status_code == 400 - -@pytest.mark.django_db -def test_create_credential_with_team_and_org_xfail(post, organization, team, admin): - response = post(reverse('api:credential_list'), { - 'name': 'Some name', - 'username': 'someusername', - 'organization': organization.id, - 'team': team.id, - }, admin) - assert response.status_code == 400 - -@pytest.mark.django_db -def test_create_credential_with_user_and_team_xfail(post, team, admin): - response = post(reverse('api:credential_list'), { - 'name': 'Some name', - 'username': 'someusername', - 'user': admin.id, - 'team': team.id, - }, admin) - assert response.status_code == 400 diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index 46aeadb6d0..af7d133c4c 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -37,6 +37,7 @@ def job_template_prompts(project, inventory, machine_credential): name='deploy-job-template', ask_variables_on_launch=on_off, ask_tags_on_launch=on_off, + ask_skip_tags_on_launch=on_off, ask_job_type_on_launch=on_off, ask_inventory_on_launch=on_off, ask_limit_on_launch=on_off, @@ -54,6 +55,7 @@ def job_template_prompts_null(project): name='deploy-job-template', ask_variables_on_launch=True, ask_tags_on_launch=True, + ask_skip_tags_on_launch=True, ask_job_type_on_launch=True, ask_inventory_on_launch=True, ask_limit_on_launch=True, @@ -113,6 +115,20 @@ def test_job_accept_prompted_vars(runtime_data, job_template_prompts, post, admi mock_job.signal_start.assert_called_once_with(**runtime_data) +@pytest.mark.django_db +@pytest.mark.job_runtime_vars +def test_job_accept_null_tags(job_template_prompts, post, admin_user, mocker): + job_template = job_template_prompts(True) + + mock_job = mocker.MagicMock(spec=Job, id=968) + + with mocker.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.create_unified_job', return_value=mock_job): + with mocker.patch('awx.api.serializers.JobSerializer.to_representation'): + post(reverse('api:job_template_launch', args=[job_template.pk]), + {'job_tags': '', 'skip_tags': ''}, admin_user, expect=201) + + mock_job.signal_start.assert_called_once_with(job_tags='', skip_tags='') + @pytest.mark.django_db @pytest.mark.job_runtime_vars def test_job_accept_prompted_vars_null(runtime_data, job_template_prompts_null, post, rando, mocker): diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index cab2e53731..88437a0037 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -3,12 +3,14 @@ import mock # AWX from awx.api.serializers import JobTemplateSerializer, JobLaunchSerializer -from awx.main.models.jobs import JobTemplate +from awx.main.models.jobs import JobTemplate, Job from awx.main.models.projects import ProjectOptions +from awx.main.migrations import _save_password_keys as save_password_keys # Django from django.test.client import RequestFactory from django.core.urlresolvers import reverse +from django.apps import apps @property def project_playbooks(self): @@ -103,9 +105,10 @@ def test_edit_nonsenstive(patch, job_template_factory, alice): 'extra_vars': '--', 'job_tags': 'sometags', 'force_handlers': True, - 'skip_tags': True, + 'skip_tags': 'thistag,thattag', 'ask_variables_on_launch':True, 'ask_tags_on_launch':True, + 'ask_skip_tags_on_launch':True, 'ask_job_type_on_launch':True, 'ask_inventory_on_launch':True, 'ask_credential_on_launch': True, @@ -347,3 +350,20 @@ def test_disallow_template_delete_on_running_job(job_template_factory, delete, a objects.job_template.create_unified_job() delete_response = delete(reverse('api:job_template_detail', args=[objects.job_template.pk]), user=admin_user) assert delete_response.status_code == 409 + +@pytest.mark.django_db +def test_save_survey_passwords_to_job(job_template_with_survey_passwords): + """Test that when a new job is created, the survey_passwords field is + given all of the passwords that exist in the JT survey""" + job = job_template_with_survey_passwords.create_unified_job() + assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'} + +@pytest.mark.django_db +def test_save_survey_passwords_on_migration(job_template_with_survey_passwords): + """Test that when upgrading to 3.0.2, the jobs connected to a JT that has + a survey with passwords in it, the survey passwords get saved to the + job survey_passwords field.""" + Job.objects.create(job_template=job_template_with_survey_passwords) + save_password_keys.migrate_survey_passwords(apps, None) + job = job_template_with_survey_passwords.jobs.all()[0] + assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'} diff --git a/awx/main/tests/functional/api/test_survey_spec.py b/awx/main/tests/functional/api/test_survey_spec.py index dc7071fc11..d6cc512847 100644 --- a/awx/main/tests/functional/api/test_survey_spec.py +++ b/awx/main/tests/functional/api/test_survey_spec.py @@ -193,7 +193,8 @@ def test_launch_with_non_empty_survey_spec_no_license(job_template_factory, post @pytest.mark.django_db @pytest.mark.survey -def test_redact_survey_passwords_in_activity_stream(job_with_secret_key): +def test_redact_survey_passwords_in_activity_stream(job_template_with_survey_passwords): + job_template_with_survey_passwords.create_unified_job() AS_record = ActivityStream.objects.filter(object1='job').all()[0] changes_dict = json.loads(AS_record.changes) extra_vars = json.loads(changes_dict['extra_vars']) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index f970adc2e7..e5e1222a39 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -160,7 +160,7 @@ def organization(instance): @pytest.fixture def credential(): - return Credential.objects.create(kind='aws', name='test-cred') + return Credential.objects.create(kind='aws', name='test-cred', username='something', password='secret') @pytest.fixture def machine_credential(): @@ -168,7 +168,7 @@ def machine_credential(): @pytest.fixture def org_credential(organization): - return Credential.objects.create(kind='aws', name='test-cred', organization=organization) + return Credential.objects.create(kind='aws', name='test-cred', username='something', password='secret', organization=organization) @pytest.fixture def inventory(organization): @@ -206,8 +206,8 @@ def notification(notification_template): subject='email subject') @pytest.fixture -def job_with_secret_key(job_with_secret_key_factory): - return job_with_secret_key_factory(persisted=True) +def job_template_with_survey_passwords(job_template_with_survey_passwords_factory): + return job_template_with_survey_passwords_factory(persisted=True) @pytest.fixture def admin(user): diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index 3b154d6f42..8cac236fee 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -133,29 +133,6 @@ def test_org_credential_access_member(alice, org_credential, credential): 'description': 'New description.', 'organization': None}) -@pytest.mark.django_db -def test_credential_access_org_permissions( - org_admin, org_member, organization, org_credential, credential): - credential.admin_role.members.add(org_admin) - credential.admin_role.members.add(org_member) - org_credential.admin_role.members.add(org_member) - - access = CredentialAccess(org_admin) - member_access = CredentialAccess(org_member) - - # Org admin can move their own credential into their org - assert access.can_change(credential, {'organization': organization.pk}) - # Org member can not - assert not member_access.can_change(credential, { - 'organization': organization.pk}) - - # Org admin can remove a credential from their org - assert access.can_change(org_credential, {'organization': None}) - # Org member can not - assert not member_access.can_change(org_credential, {'organization': None}) - assert not member_access.can_change(org_credential, { - 'user': org_member.pk, 'organization': None}) - @pytest.mark.django_db def test_cred_job_template_xfail(user, deploy_jobtemplate): ' Personal credential migration ' @@ -248,7 +225,6 @@ def test_single_cred_multi_job_template_multi_org(user, organizations, credentia orgs[0].admin_role.members.add(a) orgs[1].admin_role.members.add(a) - access = CredentialAccess(a) rbac.migrate_credential(apps, None) for jt in jts: @@ -256,11 +232,6 @@ def test_single_cred_multi_job_template_multi_org(user, organizations, credentia credential.refresh_from_db() assert jts[0].credential != jts[1].credential - assert access.can_change(jts[0].credential, {'organization': org.pk}) - assert access.can_change(jts[1].credential, {'organization': org.pk}) - - orgs[0].admin_role.members.remove(a) - assert not access.can_change(jts[0].credential, {'organization': org.pk}) @pytest.mark.django_db def test_cred_inventory_source(user, inventory, credential): diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py index f1688b7046..febade67eb 100644 --- a/awx/main/tests/functional/test_rbac_job.py +++ b/awx/main/tests/functional/test_rbac_job.py @@ -92,6 +92,12 @@ def test_null_related_delete_denied(normal_job, rando): access = JobAccess(rando) assert not access.can_delete(normal_job) +@pytest.mark.django_db +def test_delete_job_with_orphan_proj(normal_job, rando): + normal_job.project.organization = None + access = JobAccess(rando) + assert not access.can_delete(normal_job) + @pytest.mark.django_db def test_inventory_org_admin_delete_allowed(normal_job, org_admin): normal_job.project = None # do this so we test job->inventory->org->admin connection diff --git a/awx/main/tests/unit/api/test_roles.py b/awx/main/tests/unit/api/test_roles.py new file mode 100644 index 0000000000..2dd6b57675 --- /dev/null +++ b/awx/main/tests/unit/api/test_roles.py @@ -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 diff --git a/awx/main/tests/unit/api/test_views.py b/awx/main/tests/unit/api/test_views.py index a03ef7adae..6a97831f02 100644 --- a/awx/main/tests/unit/api/test_views.py +++ b/awx/main/tests/unit/api/test_views.py @@ -1,22 +1,11 @@ import mock import pytest -from rest_framework.test import APIRequestFactory -from rest_framework.test import force_authenticate - -from django.contrib.contenttypes.models import ContentType - from awx.api.views import ( ApiV1RootView, - TeamRolesList, JobTemplateLabelList, ) -from awx.main.models import ( - User, - Role, -) - @pytest.fixture def mock_response_new(mocker): m = mocker.patch('awx.api.views.Response.__new__') @@ -68,30 +57,6 @@ class TestJobTemplateLabelList: with mock.patch('awx.api.generics.DeleteLastUnattachLabelMixin.unattach') as mixin_unattach: view = JobTemplateLabelList() mock_request = mock.MagicMock() - + super(JobTemplateLabelList, view).unattach(mock_request, None, None) assert mixin_unattach.called_with(mock_request, None, None) - -@pytest.mark.parametrize("url", ["/team/1/roles", "/role/1/teams"]) -def test_team_roles_list_post_org_roles(url): - with mock.patch('awx.api.views.Role.objects.get') as role_get, \ - mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get: - - role_mock = mock.MagicMock(spec=Role) - content_type_mock = mock.MagicMock(spec=ContentType) - role_mock.content_type = content_type_mock - role_get.return_value = role_mock - ct_get.return_value = content_type_mock - - factory = APIRequestFactory() - view = TeamRolesList.as_view() - - request = factory.post(url, {'id':1}, format="json") - force_authenticate(request, User(username="root", is_superuser=True)) - - response = view(request) - response.render() - - assert response.status_code == 400 - assert 'cannot assign' in response.content - diff --git a/awx/main/tests/unit/models/test_job_template_unit.py b/awx/main/tests/unit/models/test_job_template_unit.py index a25cce6f6c..a156d1e920 100644 --- a/awx/main/tests/unit/models/test_job_template_unit.py +++ b/awx/main/tests/unit/models/test_job_template_unit.py @@ -1,4 +1,5 @@ import pytest +import json def test_missing_project_error(job_template_factory): @@ -34,7 +35,18 @@ def test_inventory_credential_contradictions(job_template_factory): assert 'inventory' in validation_errors assert 'credential' in validation_errors +def test_survey_answers_as_string(job_template_factory): + objects = job_template_factory( + 'job-template-with-survey', + survey=['var1'], + persisted=False) + jt = objects.job_template + user_extra_vars = json.dumps({'var1': 'asdf'}) + accepted, ignored = jt._accept_or_ignore_job_kwargs(extra_vars=user_extra_vars) + assert 'var1' in accepted['extra_vars'] + @pytest.mark.survey -def test_survey_password_list(job_with_secret_key_unit): - """Verify that survey_password_variables method gives a list of survey passwords""" - assert job_with_secret_key_unit.job_template.survey_password_variables() == ['secret_key', 'SSN'] +def test_job_template_survey_password_redaction(job_template_with_survey_passwords_unit): + """Tests the JobTemplate model's funciton to redact passwords from + extra_vars - used when creating a new job""" + assert job_template_with_survey_passwords_unit.survey_password_variables() == ['secret_key', 'SSN'] diff --git a/awx/main/tests/unit/models/test_job_unit.py b/awx/main/tests/unit/models/test_job_unit.py index a1791c59d5..1b66681dcf 100644 --- a/awx/main/tests/unit/models/test_job_unit.py +++ b/awx/main/tests/unit/models/test_job_unit.py @@ -2,6 +2,7 @@ import pytest import json from awx.main.tasks import RunJob +from awx.main.models import Job @pytest.fixture @@ -14,9 +15,19 @@ def job(mocker): 'launch_type': 'manual'}) @pytest.mark.survey -def test_job_redacted_extra_vars(job_with_secret_key_unit): - """Verify that this method redacts vars marked as passwords in a survey""" - assert json.loads(job_with_secret_key_unit.display_extra_vars()) == { +def test_job_survey_password_redaction(): + """Tests the Job model's funciton to redact passwords from + extra_vars - used when displaying job information""" + job = Job( + name="test-job-with-passwords", + extra_vars=json.dumps({ + 'submitter_email': 'foobar@redhat.com', + 'secret_key': '6kQngg3h8lgiSTvIEb21', + 'SSN': '123-45-6789'}), + survey_passwords={ + 'secret_key': '$encrypted$', + 'SSN': '$encrypted$'}) + assert json.loads(job.display_extra_vars()) == { 'submitter_email': 'foobar@redhat.com', 'secret_key': '$encrypted$', 'SSN': '$encrypted$'} diff --git a/awx/main/tests/unit/test_network_credential.py b/awx/main/tests/unit/test_network_credential.py index 9ca8c6693a..8ef5c4cb5e 100644 --- a/awx/main/tests/unit/test_network_credential.py +++ b/awx/main/tests/unit/test_network_credential.py @@ -64,8 +64,8 @@ def test_net_cred_ssh_agent(mocker, get_ssh_version): 'credential': None, 'cloud_credential': None, 'network_credential': Credential(**options), 'become_enabled': False, 'become_method': None, 'become_username': None, 'inventory': mocker.MagicMock(spec=Inventory, id=2), 'force_handlers': False, - 'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': False, - 'start_at_task': False, 'pk': 1, 'launch_type': 'normal', 'job_template':None, + 'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': None, + 'start_at_task': None, 'pk': 1, 'launch_type': 'normal', 'job_template':None, 'created_by': None, 'extra_vars_dict': None, 'project':None, 'playbook': 'test.yml'} mock_job = mocker.MagicMock(spec=Job, **mock_job_attrs) diff --git a/awx/plugins/inventory/cloudforms.py b/awx/plugins/inventory/cloudforms.py index 3de81d0bd2..8d9854974f 100755 --- a/awx/plugins/inventory/cloudforms.py +++ b/awx/plugins/inventory/cloudforms.py @@ -18,10 +18,11 @@ import json # http://urllib3.readthedocs.org/en/latest/security.html#disabling-warnings requests.packages.urllib3.disable_warnings() + class CloudFormsInventory(object): def _empty_inventory(self): - return {"_meta" : {"hostvars" : {}}} + return {"_meta": {"hostvars": {}}} def __init__(self): ''' Main execution path ''' @@ -43,7 +44,7 @@ class CloudFormsInventory(object): # This doesn't exist yet and needs to be added if self.args.host: - data2 = { } + data2 = {} print json.dumps(data2, indent=2) def parse_cli_args(self): @@ -51,9 +52,9 @@ class CloudFormsInventory(object): parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on CloudForms') parser.add_argument('--list', action='store_true', default=False, - help='List instances (default: False)') + help='List instances (default: False)') parser.add_argument('--host', action='store', - help='Get all the variables about a specific instance') + help='Get all the variables about a specific instance') self.args = parser.parse_args() def read_settings(self): @@ -97,30 +98,47 @@ class CloudFormsInventory(object): def get_hosts(self): ''' Gets host from CloudForms ''' - r = requests.get("https://" + self.cloudforms_hostname + "/api/vms?expand=resources&attributes=name,power_state", auth=(self.cloudforms_username,self.cloudforms_password), verify=False) - + r = requests.get("https://{0}/api/vms?expand=resources&attributes=all".format(self.cloudforms_hostname), + auth=(self.cloudforms_username, self.cloudforms_password), verify=False) obj = r.json() - #Remove objects that don't matter - del obj["count"] - del obj["subcount"] - del obj["name"] + # Create groups+hosts based on host data + for resource in obj.get('resources', []): - #Create a new list to grab VMs with power_state on to add to a new list - #I'm sure there is a cleaner way to do this - newlist = [] - getnext = False - for x in obj.items(): - for y in x[1]: - for z in y.items(): - if getnext == True: - newlist.append(z[1]) - getnext = False - if ( z[0] == "power_state" and z[1] == "on" ): - getnext = True - newdict = {'hosts': newlist} - newdict2 = {'Dynamic_CloudForms': newdict} - print json.dumps(newdict2, indent=2) + # Maintain backwards compat by creating `Dynamic_CloudForms` group + if 'Dynamic_CloudForms' not in self.inventory: + self.inventory['Dynamic_CloudForms'] = [] + self.inventory['Dynamic_CloudForms'].append(resource['name']) + + # Add host to desired groups + for key in ('vendor', 'type', 'location'): + if key in resource: + # Create top-level group + if key not in self.inventory: + self.inventory[key] = dict(children=[], vars={}, hosts=[]) + # if resource['name'] not in self.inventory[key]['hosts']: + # self.inventory[key]['hosts'].append(resource['name']) + + # Create sub-group + if resource[key] not in self.inventory: + self.inventory[resource[key]] = dict(children=[], vars={}, hosts=[]) + # self.inventory[resource[key]]['hosts'].append(resource['name']) + + # Add sub-group, as a child of top-level + if resource[key] not in self.inventory[key]['children']: + self.inventory[key]['children'].append(resource[key]) + + # Add host to sub-group + if resource['name'] not in self.inventory[resource[key]]: + self.inventory[resource[key]]['hosts'].append(resource['name']) + + # Delete 'actions' key + del resource['actions'] + + # Add _meta hostvars + self.inventory['_meta']['hostvars'][resource['name']] = resource + + print json.dumps(self.inventory, indent=2) # Run the script CloudFormsInventory() diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 1098d709db..d17368649a 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -697,6 +697,16 @@ SATELLITE6_HOST_FILTER = r'^.+$' SATELLITE6_EXCLUDE_EMPTY_GROUPS = True SATELLITE6_INSTANCE_ID_VAR = 'foreman.id' +# --------------------- +# ----- CloudForms ----- +# --------------------- +CLOUDFORMS_ENABLED_VAR = 'power_state' +CLOUDFORMS_ENABLED_VALUE = 'on' +CLOUDFORMS_GROUP_FILTER = r'^.+$' +CLOUDFORMS_HOST_FILTER = r'^.+$' +CLOUDFORMS_EXCLUDE_EMPTY_GROUPS = True +CLOUDFORMS_INSTANCE_ID_VAR = 'id' + # --------------------- # -- Activity Stream -- # --------------------- diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index 1c58aa38f8..dfa53e0d25 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -80,12 +80,6 @@ a.red-txt:active { text-overflow: ellipsis; } -.name-column { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - blockquote { font-size: 14px; } diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less index 3a29e162ba..b36f1bdd5b 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -44,9 +44,11 @@ color: @list-header-txt; font-size: 14px; font-weight: bold; - white-space: nowrap; padding-bottom: 25px; min-height: 45px; + word-break: break-all; + max-width: 90%; + word-wrap: break-word; } .Form-secondaryTitle{ @@ -55,7 +57,10 @@ min-height: 40px; } -.Form-title--is_superuser, .Form-title--is_system_auditor, .Form-title--is_ldap_user{ +.Form-title--is_superuser, +.Form-title--is_system_auditor, +.Form-title--is_ldap_user, +.Form-title--is_external_account{ height:15px; color: @default-interface-txt; background-color: @default-list-header-bg; @@ -353,6 +358,11 @@ border-color: transparent transparent @field-dropdown-icon transparent!important; } +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single { + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + .select2-dropdown{ border:1px solid @field-border; diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index 6d1dfd7263..098f936ab1 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -78,6 +78,7 @@ table, tbody { padding-left: 15px; padding-right: 15px; border-top:0px!important; + word-wrap: break-word; } .List-tableCell.description-column { @@ -383,6 +384,7 @@ table, tbody { .List-action--showTooltipOnDisabled { display: inline-block; + cursor: not-allowed; } .List-action--showTooltipOnDisabled .btn[disabled] { diff --git a/awx/ui/client/src/access/addPermissions/addPermissionsList/addPermissionsList.directive.js b/awx/ui/client/src/access/addPermissions/addPermissionsList/addPermissionsList.directive.js index 6722fac853..392fe50522 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissionsList/addPermissionsList.directive.js +++ b/awx/ui/client/src/access/addPermissions/addPermissionsList/addPermissionsList.directive.js @@ -51,23 +51,6 @@ export default PaginateInit({ scope: scope, list: list, url: url, pageSize: 5 }); - if (scope.removePostRefresh) { - scope.removePostRefresh(); - } - scope.removePostRefresh = scope.$on('PostRefresh', function () { - if(scope.allSelected && scope.allSelected.length > 0) { - // We need to check to see if any of the selected items are now in our list! - for(var i=0; i 0) ? true : false; + $scope.external_account = data.external_account; $scope.user_type = $scope.user_type_options[0]; $scope.is_system_auditor = false; diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js index bbc57dc275..9009efe609 100644 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js +++ b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js @@ -6,8 +6,8 @@ export default ['$scope', '$state', '$stateParams', 'PageRangeSetup', 'GetBasePath', 'DashboardHostsList', - 'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope', - function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope){ + 'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope', 'SearchInit', + function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope, SearchInit){ var setJobStatus = function(){ _.forEach($scope.hosts, function(value){ SetStatus({ @@ -59,6 +59,12 @@ export default $scope.hosts = hosts.results; setJobStatus(); generator.inject(list, {mode: 'edit', scope: $scope}); + SearchInit({ + scope: $scope, + set: 'hosts', + list: list, + url: defaultUrl + }); PaginateInit({ scope: $scope, list: list, @@ -77,6 +83,7 @@ export default $scope.rowBeingEdited = $state.params.id; $scope.listBeingEdited = "hosts"; } + $scope.search(list.iterator); }; init(); }]; diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js b/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js index 169ab31c94..e34a051588 100644 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js +++ b/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js @@ -31,6 +31,7 @@ export default function(){ awTipPlacement: 'right', dataPlacement: 'right', awPopOver: '{{ host.job_status_html }}', + dataTitle: '{{host.job_status_title}}', ngClick:'viewHost(host.id)', columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus' }, diff --git a/awx/ui/client/src/dashboard/lists/dashboard-list.block.less b/awx/ui/client/src/dashboard/lists/dashboard-list.block.less index 589f6c292d..102188034d 100644 --- a/awx/ui/client/src/dashboard/lists/dashboard-list.block.less +++ b/awx/ui/client/src/dashboard/lists/dashboard-list.block.less @@ -101,10 +101,8 @@ .DashboardList-nameCell { padding-left: 15px; - text-overflow: ellipsis; - overflow:hidden; - white-space: nowrap; width: 100%; + word-wrap: break-word; } .DashboardList-nameContainer { diff --git a/awx/ui/client/src/forms/Credentials.js b/awx/ui/client/src/forms/Credentials.js index fe61186cce..d503f53905 100644 --- a/awx/ui/client/src/forms/Credentials.js +++ b/awx/ui/client/src/forms/Credentials.js @@ -404,7 +404,9 @@ export default related: { permissions: { - awToolTip: 'Please save before assigning permissions', + disabled: 'disablePermissionAssignment', + awToolTip: '{{permissionsTooltip}}', + dataTipWatch: 'permissionsTooltip', dataPlacement: 'top', basePath: 'credentials/:id/access_list/', type: 'collection', @@ -452,7 +454,7 @@ export default return { permissions: { iterator: 'permission', - url: urls.access_list + url: urls.access_list, } }; } diff --git a/awx/ui/client/src/forms/Inventories.js b/awx/ui/client/src/forms/Inventories.js index dd9fd44e51..6467bf28d3 100644 --- a/awx/ui/client/src/forms/Inventories.js +++ b/awx/ui/client/src/forms/Inventories.js @@ -81,7 +81,7 @@ export default permissions: { awToolTip: 'Please save before assigning permissions', dataPlacement: 'top', - basePath: 'projects/:id/access_list/', + basePath: 'inventories/:id/access_list/', type: 'collection', title: 'Permissions', iterator: 'permission', diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index 90c92f9f57..5d56907c88 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -228,10 +228,7 @@ export default column: 2, awPopOver: "

Provide a comma separated list of tags.

\n" + "

Tags are useful when you have a large playbook, and you want to run a specific part of a play or task.

" + - "

For example, you might have a task consisting of a long list of actions. Tag values can be assigned to each action. " + - "Suppose the actions have been assigned tag values of "configuration", "packages" and "install".

" + - "

If you just want to run the "configuration" and "packages" actions, you would enter the following here " + - "in the Job Tags field:

\n
configuration,packages
\n", + "

Consult the Ansible documentation for further details on the usage of tags.

", dataTitle: "Job Tags", dataPlacement: "right", dataContainer: "body", @@ -240,6 +237,25 @@ export default text: 'Prompt on launch' } }, + skip_tags: { + label: 'Skip Tags', + type: 'textarea', + rows: 5, + addRequired: false, + editRequired: false, + 'elementClass': 'Form-textInput', + column: 2, + awPopOver: "

Provide a comma separated list of tags.

\n" + + "

Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task.

" + + "

Consult the Ansible documentation for further details on the usage of tags.

", + dataTitle: "Skip Tags", + dataPlacement: "right", + dataContainer: "body", + subCheckbox: { + variable: 'ask_skip_tags_on_launch', + text: 'Prompt on launch' + } + }, checkbox_group: { label: 'Options', type: 'checkbox_group', @@ -281,7 +297,7 @@ export default column: 2, awPopOver: "callback_help", awPopOverWatch: "callback_help", - dataPlacement: 'right', + dataPlacement: 'top', dataTitle: 'Provisioning Callback URL', dataContainer: "body" }, diff --git a/awx/ui/client/src/forms/Users.js b/awx/ui/client/src/forms/Users.js index 6ce19602b4..ddcda6e296 100644 --- a/awx/ui/client/src/forms/Users.js +++ b/awx/ui/client/src/forms/Users.js @@ -46,7 +46,7 @@ export default label: 'Username', type: 'text', awRequiredWhen: { - reqExpression: "not_ldap_user", + reqExpression: "not_ldap_user && external_account === null", init: true }, autocomplete: false @@ -69,7 +69,7 @@ export default label: 'Password', type: 'sensitive', hasShowInputButton: true, - ngShow: 'ldap_user == false && socialAuthUser === false', + ngShow: 'ldap_user == false && socialAuthUser === false && external_account === null', addRequired: true, editRequired: false, ngChange: "clearPWConfirm('password_confirm')", @@ -80,7 +80,7 @@ export default label: 'Confirm Password', type: 'sensitive', hasShowInputButton: true, - ngShow: 'ldap_user == false && socialAuthUser === false', + ngShow: 'ldap_user == false && socialAuthUser === false && external_account === null', addRequired: true, editRequired: false, awPassMatch: true, diff --git a/awx/ui/client/src/helpers/Adhoc.js b/awx/ui/client/src/helpers/Adhoc.js index 6754a60611..04ff4b2189 100644 --- a/awx/ui/client/src/helpers/Adhoc.js +++ b/awx/ui/client/src/helpers/Adhoc.js @@ -55,9 +55,9 @@ export default // Submit request to run an adhoc comamand .factory('AdhocRun', ['$location','$stateParams', 'LaunchJob', 'PromptForPasswords', 'Rest', 'GetBasePath', 'Alert', 'ProcessErrors', - 'Wait', 'Empty', 'CreateLaunchDialog', + 'Wait', 'Empty', 'CreateLaunchDialog', '$state', function ($location, $stateParams, LaunchJob, PromptForPasswords, - Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog) { + Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog, $state) { return function (params) { var id = params.project_id, scope = params.scope.$new(), @@ -87,25 +87,31 @@ export default }); }); - if (scope.removeAdhocLaunchFinished) { - scope.removeAdhocLaunchFinished(); - } - scope.removeAdhocLaunchFinished = scope.$on('AdhocLaunchFinished', - function(e, data) { - $location.path('/ad_hoc_commands/' + data.id); - }); - if (scope.removeStartAdhocRun) { scope.removeStartAdhocRun(); } scope.removeStartAdhocRun = scope.$on('StartAdhocRun', function() { - LaunchJob({ - scope: scope, - url: url, - callback: 'AdhocLaunchFinished' // send to the adhoc - // standard out page - }); + var password, + postData={}; + for (password in scope.passwords) { + postData[scope.passwords[password]] = scope[ + scope.passwords[password] + ]; + } + // Re-launch the adhoc job + Rest.setUrl(url); + Rest.post(postData) + .success(function (data) { + Wait('stop'); + $state.go('adHocJobStdout', {id: data.id}); + }) + .error(function (data, status) { + ProcessErrors(scope, data, status, { + hdr: 'Error!', + msg: 'Failed to launch adhoc command. POST ' + + 'returned status: ' + status }); + }); }); // start routine only if passwords need to be prompted diff --git a/awx/ui/client/src/helpers/Credentials.js b/awx/ui/client/src/helpers/Credentials.js index bd0551b206..0a81b3eee9 100644 --- a/awx/ui/client/src/helpers/Credentials.js +++ b/awx/ui/client/src/helpers/Credentials.js @@ -161,7 +161,7 @@ angular.module('CredentialsHelper', ['Utilities']) break; case 'net': scope.username_required = true; - scope.password_required = true; + scope.password_required = false; scope.passwordLabel = 'Password'; scope.sshKeyDataLabel = 'SSH Key'; break; diff --git a/awx/ui/client/src/helpers/Groups.js b/awx/ui/client/src/helpers/Groups.js index fb8ff47f40..a1b0f44041 100644 --- a/awx/ui/client/src/helpers/Groups.js +++ b/awx/ui/client/src/helpers/Groups.js @@ -165,6 +165,7 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name has_inventory_sources = params.has_inventory_sources, launch_class = '', launch_tip = 'Start sync process', + schedule_tip = 'Schedule future inventory syncs', stat, stat_class, status_tip; stat = status; @@ -225,7 +226,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name "tooltip": status_tip, "status": stat, "launch_class": launch_class, - "launch_tip": launch_tip + "launch_tip": launch_tip, + "schedule_tip": schedule_tip }; }; } diff --git a/awx/ui/client/src/helpers/JobTemplates.js b/awx/ui/client/src/helpers/JobTemplates.js index 2947c05546..dba72869a4 100644 --- a/awx/ui/client/src/helpers/JobTemplates.js +++ b/awx/ui/client/src/helpers/JobTemplates.js @@ -132,6 +132,9 @@ angular.module('JobTemplatesHelper', ['Utilities']) scope.ask_tags_on_launch = (data.ask_tags_on_launch) ? true : false; master.ask_tags_on_launch = scope.ask_tags_on_launch; + scope.ask_skip_tags_on_launch = (data.ask_skip_tags_on_launch) ? true : false; + master.ask_skip_tags_on_launch = scope.ask_skip_tags_on_launch; + scope.ask_job_type_on_launch = (data.ask_job_type_on_launch) ? true : false; master.ask_job_type_on_launch = scope.ask_job_type_on_launch; diff --git a/awx/ui/client/src/helpers/Parse.js b/awx/ui/client/src/helpers/Parse.js index 67f6e5eb26..5e76bc670d 100644 --- a/awx/ui/client/src/helpers/Parse.js +++ b/awx/ui/client/src/helpers/Parse.js @@ -34,7 +34,8 @@ export default fld = (params.variable) ? params.variable : 'variables', pfld = (params.parse_variable) ? params.parse_variable : 'parseType', onReady = params.onReady, - onChange = params.onChange; + onChange = params.onChange, + readOnly = params.readOnly; function removeField(fld) { //set our model to the last change in CodeMirror and then destroy CodeMirror diff --git a/awx/ui/client/src/helpers/Projects.js b/awx/ui/client/src/helpers/Projects.js index 816fd73ba3..e284bc07d1 100644 --- a/awx/ui/client/src/helpers/Projects.js +++ b/awx/ui/client/src/helpers/Projects.js @@ -75,6 +75,9 @@ export default case 'missing': result = 'Missing. Click for details'; break; + case 'canceled': + result = 'Canceled. Click for details'; + break; } return result; }; diff --git a/awx/ui/client/src/inventories/list/inventory-list.controller.js b/awx/ui/client/src/inventories/list/inventory-list.controller.js index a5d2b00a01..54ed987ec6 100644 --- a/awx/ui/client/src/inventories/list/inventory-list.controller.js +++ b/awx/ui/client/src/inventories/list/inventory-list.controller.js @@ -50,16 +50,9 @@ function InventoriesList($scope, $rootScope, $location, $log, "aw-pop-over": html, "data-popover-title": title, "data-placement": "right" }); + elem.removeAttr('ng-click'); $compile(elem)($scope); - elem.on('shown.bs.popover', function() { - $('.popover').each(function() { - $compile($(this))($scope); //make nested directives work! - }); - $('.popover-content, .popover-title').click(function() { - elem.popover('hide'); - }); - }); - elem.popover('show'); + $scope.triggerPopover(event); } view.inject(InventoryList, { mode: mode, scope: $scope }); @@ -250,44 +243,62 @@ function InventoriesList($scope, $rootScope, $location, $log, }); $scope.showGroupSummary = function(event, id) { - var inventory; - if (!Empty(id)) { - inventory = Find({ list: $scope.inventories, key: 'id', val: id }); - if (inventory.syncStatus !== 'na') { - Wait('start'); - Rest.setUrl(inventory.related.inventory_sources + '?or__source=ec2&or__source=rax&order_by=-last_job_run&page_size=5'); - Rest.get() - .success(function(data) { - $scope.$emit('GroupSummaryReady', event, inventory, data); - }) - .error(function(data, status) { - ProcessErrors( $scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + inventory.related.inventory_sources + ' failed. GET returned status: ' + status + try{ + var elem = $(event.target).parent(); + // if the popover is visible already, then exit the function here + if(elem.data()['bs.popover'].tip().hasClass('in')){ + return; + } + } + catch(err){ + var inventory; + if (!Empty(id)) { + inventory = Find({ list: $scope.inventories, key: 'id', val: id }); + if (inventory.syncStatus !== 'na') { + Wait('start'); + Rest.setUrl(inventory.related.inventory_sources + '?or__source=ec2&or__source=rax&order_by=-last_job_run&page_size=5'); + Rest.get() + .success(function(data) { + $scope.$emit('GroupSummaryReady', event, inventory, data); + }) + .error(function(data, status) { + ProcessErrors( $scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + inventory.related.inventory_sources + ' failed. GET returned status: ' + status + }); }); - }); + } } } }; $scope.showHostSummary = function(event, id) { - var url, inventory; - if (!Empty(id)) { - inventory = Find({ list: $scope.inventories, key: 'id', val: id }); - if (inventory.total_hosts > 0) { - Wait('start'); - url = GetBasePath('jobs') + "?type=job&inventory=" + id + "&failed="; - url += (inventory.has_active_failures) ? 'true' : "false"; - url += "&order_by=-finished&page_size=5"; - Rest.setUrl(url); - Rest.get() - .success( function(data) { - $scope.$emit('HostSummaryReady', event, data); - }) - .error( function(data, status) { - ProcessErrors( $scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. GET returned: ' + status + try{ + var elem = $(event.target).parent(); + // if the popover is visible already, then exit the function here + if(elem.data()['bs.popover'].tip().hasClass('in')){ + return; + } + } + catch(err){ + var url, inventory; + if (!Empty(id)) { + inventory = Find({ list: $scope.inventories, key: 'id', val: id }); + if (inventory.total_hosts > 0) { + Wait('start'); + url = GetBasePath('jobs') + "?type=job&inventory=" + id + "&failed="; + url += (inventory.has_active_failures) ? 'true' : "false"; + url += "&order_by=-finished&page_size=5"; + Rest.setUrl(url); + Rest.get() + .success( function(data) { + $scope.$emit('HostSummaryReady', event, data); + }) + .error( function(data, status) { + ProcessErrors( $scope, data, status, null, { hdr: 'Error!', + msg: 'Call to ' + url + ' failed. GET returned: ' + status + }); }); - }); + } } } }; diff --git a/awx/ui/client/src/inventories/manage/adhoc/adhoc.controller.js b/awx/ui/client/src/inventories/manage/adhoc/adhoc.controller.js index 7f67d81936..0281a9b0f2 100644 --- a/awx/ui/client/src/inventories/manage/adhoc/adhoc.controller.js +++ b/awx/ui/client/src/inventories/manage/adhoc/adhoc.controller.js @@ -242,7 +242,7 @@ function adhocController($q, $scope, $location, $stateParams, Rest.post(data) .success(function (data) { Wait('stop'); - $location.path("/ad_hoc_commands/" + data.id); + $state.go('adHocJobStdout', {id: data.id}); }) .error(function (data, status) { ProcessErrors($scope, data, status, adhocForm, { diff --git a/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.partial.html b/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.partial.html index aeebf7a8fa..702a85379e 100644 --- a/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.partial.html +++ b/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.partial.html @@ -19,18 +19,4 @@
- diff --git a/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js index a816cacd3a..d1272f508b 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js @@ -100,6 +100,7 @@ // equal to case 'ec2' || 'rax' || 'azure' || 'azure_rm' || 'vmware' || 'satellite6' || 'cloudforms' || 'openstack' else{ var credentialBasePath = (source === 'ec2') ? GetBasePath('credentials') + '?kind=aws' : GetBasePath('credentials') + (source === '' ? '' : '?kind=' + (source)); + $scope.cloudCredentialRequired = source !== '' && source !== 'custom' && source !== 'ec2' ? true : false; CredentialList.basePath = credentialBasePath; LookUpInit({ scope: $scope, @@ -122,7 +123,7 @@ $scope.group_by_choices = source === 'ec2' ? $scope.ec2_group_by : null; // azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint $scope.source_region_choices = source === 'azure_rm' ? $scope.azure_regions : $scope[source + '_regions']; - $scope.cloudCredentialRequired = source !== '' && source !== 'custom' ? true : false; + $scope.cloudCredentialRequired = source !== '' && source !== 'custom' && source !== 'ec2' ? true : false; $scope.group_by = null; $scope.source_regions = null; $scope.credential = null; diff --git a/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js index b008c0f888..941789f39d 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js @@ -100,6 +100,7 @@ else{ var credentialBasePath = (source.value === 'ec2') ? GetBasePath('credentials') + '?kind=aws' : GetBasePath('credentials') + (source.value === '' ? '' : '?kind=' + (source.value)); CredentialList.basePath = credentialBasePath; + $scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' && source.value !== 'ec2' ? true : false; LookUpInit({ scope: $scope, url: credentialBasePath, @@ -122,7 +123,7 @@ // reset fields // azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint $scope.source_region_choices = source.value === 'azure_rm' ? $scope.azure_regions : $scope[source.value + '_regions']; - $scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' ? true : false; + $scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' && source.value !== 'ec2' ? true : false; $scope.group_by = null; $scope.source_regions = null; $scope.credential = null; diff --git a/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js index 9fe6d9809a..ca56eeddd4 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js @@ -151,6 +151,7 @@ {status_tooltip: group_status.tooltip}, {launch_tooltip: group_status.launch_tip}, {launch_class: group_status.launch_class}, + {group_schedule_tooltip: group_status.schedule_tip}, {hosts_status_tip: hosts_status.tooltip}, {hosts_status_class: hosts_status.class}, {source: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.source : null}, diff --git a/awx/ui/client/src/inventories/manage/inventory-manage.route.js b/awx/ui/client/src/inventories/manage/inventory-manage.route.js index 317f584930..719ce3bf15 100644 --- a/awx/ui/client/src/inventories/manage/inventory-manage.route.js +++ b/awx/ui/client/src/inventories/manage/inventory-manage.route.js @@ -13,11 +13,6 @@ import GroupsListController from './groups/groups-list.controller'; export default { name: 'inventoryManage', url: '/inventories/:inventory_id/manage?{group:int}{failed}', - data: { - activityStream: true, - activityStreamTarget: 'inventory', - activityStreamId: 'inventory_id' - }, params:{ group:{ array: true diff --git a/awx/ui/client/src/job-detail/host-events/host-events.controller.js b/awx/ui/client/src/job-detail/host-events/host-events.controller.js index f56111bdb6..be1510f0ac 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.controller.js +++ b/awx/ui/client/src/job-detail/host-events/host-events.controller.js @@ -38,11 +38,13 @@ break; case 'ok': params.event = 'runner_on_ok'; + params.changed = 'false'; break; case 'failed': - params.failed = true; + params.event = 'runner_on_failed'; break; case 'changed': + params.event = 'runner_on_ok'; params.changed = true; break; default: diff --git a/awx/ui/client/src/job-detail/job-detail.controller.js b/awx/ui/client/src/job-detail/job-detail.controller.js index 02cb21a5f1..9181b21b20 100644 --- a/awx/ui/client/src/job-detail/job-detail.controller.js +++ b/awx/ui/client/src/job-detail/job-detail.controller.js @@ -641,7 +641,7 @@ export default return true; }); //scope.setSearchAll('host'); - ParseTypeChange({ scope: scope, field_id: 'pre-formatted-variables' }); + ParseTypeChange({ scope: scope, field_id: 'pre-formatted-variables', readOnly: true }); scope.$emit('LoadPlays', data.related.job_events); }) .error(function(data, status) { diff --git a/awx/ui/client/src/job-detail/job-detail.partial.html b/awx/ui/client/src/job-detail/job-detail.partial.html index 0be9e4eb6b..37bf4de249 100644 --- a/awx/ui/client/src/job-detail/job-detail.partial.html +++ b/awx/ui/client/src/job-detail/job-detail.partial.html @@ -154,6 +154,11 @@
{{ job.job_tags }}
+
+ +
{{ job.skip_tags }}
+
+
diff --git a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js index 74e57cf149..3968eaf484 100644 --- a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js +++ b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js @@ -42,10 +42,14 @@ export default } - if(scope.ask_tags_on_launch && scope.other_prompt_data && scope.other_prompt_data.job_tags){ + if(scope.ask_tags_on_launch && scope.other_prompt_data && typeof scope.other_prompt_data.job_tags === 'string'){ job_launch_data.job_tags = scope.other_prompt_data.job_tags; } + if(scope.ask_skip_tags_on_launch && scope.other_prompt_data && typeof scope.other_prompt_data.skip_tags === 'string'){ + job_launch_data.skip_tags = scope.other_prompt_data.skip_tags; + } + if(scope.ask_limit_on_launch && scope.other_prompt_data && scope.other_prompt_data.limit){ job_launch_data.limit = scope.other_prompt_data.limit; } diff --git a/awx/ui/client/src/job-submission/job-submission.controller.js b/awx/ui/client/src/job-submission/job-submission.controller.js index 16fc77324e..008c34990f 100644 --- a/awx/ui/client/src/job-submission/job-submission.controller.js +++ b/awx/ui/client/src/job-submission/job-submission.controller.js @@ -153,7 +153,7 @@ export default // General catch-all for "other prompts" - used in this link function and to hide the Other Prompts tab when // it should be hidden - $scope.has_other_prompts = (data.ask_job_type_on_launch || data.ask_limit_on_launch || data.ask_tags_on_launch || data.ask_variables_on_launch) ? true : false; + $scope.has_other_prompts = (data.ask_job_type_on_launch || data.ask_limit_on_launch || data.ask_tags_on_launch || data.ask_skip_tags_on_launch || data.ask_variables_on_launch) ? true : false; $scope.password_needed = data.passwords_needed_to_start && data.passwords_needed_to_start.length > 0; $scope.has_default_inventory = data.defaults && data.defaults.inventory && data.defaults.inventory.id; $scope.has_default_credential = data.defaults && data.defaults.credential && data.defaults.credential.id; @@ -172,6 +172,10 @@ export default $scope.other_prompt_data.job_tags = (data.defaults && data.defaults.job_tags) ? data.defaults.job_tags : ""; } + if($scope.ask_skip_tags_on_launch) { + $scope.other_prompt_data.skip_tags = (data.defaults && data.defaults.skip_tags) ? data.defaults.skip_tags : ""; + } + if($scope.ask_variables_on_launch) { $scope.jobLaunchVariables = (data.defaults && data.defaults.extra_vars) ? data.defaults.extra_vars : "---"; $scope.other_prompt_data.parseType = 'yaml'; diff --git a/awx/ui/client/src/job-submission/job-submission.partial.html b/awx/ui/client/src/job-submission/job-submission.partial.html index 3f69234d91..1c1d398586 100644 --- a/awx/ui/client/src/job-submission/job-submission.partial.html +++ b/awx/ui/client/src/job-submission/job-submission.partial.html @@ -148,7 +148,15 @@ Job Tags
- + +
+
+
+ +
+
diff --git a/awx/ui/client/src/job-templates/add/job-templates-add.controller.js b/awx/ui/client/src/job-templates/add/job-templates-add.controller.js index 717aac7a08..39a9377c0a 100644 --- a/awx/ui/client/src/job-templates/add/job-templates-add.controller.js +++ b/awx/ui/client/src/job-templates/add/job-templates-add.controller.js @@ -518,6 +518,7 @@ } data.ask_tags_on_launch = $scope.ask_tags_on_launch ? $scope.ask_tags_on_launch : false; + data.ask_skip_tags_on_launch = $scope.ask_skip_tags_on_launch ? $scope.ask_skip_tags_on_launch : false; data.ask_limit_on_launch = $scope.ask_limit_on_launch ? $scope.ask_limit_on_launch : false; data.ask_job_type_on_launch = $scope.ask_job_type_on_launch ? $scope.ask_job_type_on_launch : false; data.ask_inventory_on_launch = $scope.ask_inventory_on_launch ? $scope.ask_inventory_on_launch : false; diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js index 8a2f433dbc..c30412504a 100644 --- a/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.controller.js @@ -640,6 +640,7 @@ export default } data.ask_tags_on_launch = $scope.ask_tags_on_launch ? $scope.ask_tags_on_launch : false; + data.ask_skip_tags_on_launch = $scope.ask_skip_tags_on_launch ? $scope.ask_skip_tags_on_launch : false; data.ask_limit_on_launch = $scope.ask_limit_on_launch ? $scope.ask_limit_on_launch : false; data.ask_job_type_on_launch = $scope.ask_job_type_on_launch ? $scope.ask_job_type_on_launch : false; data.ask_inventory_on_launch = $scope.ask_inventory_on_launch ? $scope.ask_inventory_on_launch : false; diff --git a/awx/ui/client/src/job-templates/labels/labelsList.block.less b/awx/ui/client/src/job-templates/labels/labelsList.block.less index 5c8fc20e3f..9eab78e389 100644 --- a/awx/ui/client/src/job-templates/labels/labelsList.block.less +++ b/awx/ui/client/src/job-templates/labels/labelsList.block.less @@ -8,7 +8,7 @@ } .LabelList-tagContainer, -.LabelList-seeMore { +.LabelList-seeMoreLess { display: flex; max-width: 100%; } @@ -27,7 +27,7 @@ overflow: hidden; } -.LabelList-seeMore { +.LabelList-seeMoreLess { color: @default-link; margin: 4px 0px; text-transform: uppercase; @@ -37,7 +37,7 @@ font-size: 11px; } -.LabelList-seeMore:hover { +.LabelList-seeMoreLess:hover { color: @default-link-hov; } diff --git a/awx/ui/client/src/job-templates/labels/labelsList.directive.js b/awx/ui/client/src/job-templates/labels/labelsList.directive.js index a5f055bb6e..212bd8bb64 100644 --- a/awx/ui/client/src/job-templates/labels/labelsList.directive.js +++ b/awx/ui/client/src/job-templates/labels/labelsList.directive.js @@ -47,6 +47,13 @@ export default }); }; + scope.seeLess = function() { + // Trim the labels array back down to 10 items + scope.labels = scope.labels.slice(0, 10); + // Re-set the seeMoreInteractive flag so that the "See More" will be displayed + scope.seeMoreInactive = true; + }; + scope.deleteLabel = function(templateId, templateName, labelId, labelName) { var action = function () { $('#prompt-modal').modal('hide'); @@ -56,13 +63,13 @@ export default Rest.setUrl(url); Rest.post({"disassociate": true, "id": labelId}) .success(function () { - scope.search("job_template"); + scope.search("job_template", scope.$parent.job_template_page); Wait('stop'); }) .error(function (data, status) { Wait('stop'); ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Could not disacssociate label from JT. Call to ' + url + ' failed. DELETE returned status: ' + status }); + msg: 'Could not disassociate label from JT. Call to ' + url + ' failed. DELETE returned status: ' + status }); }); }; @@ -86,6 +93,7 @@ export default scope.count = null; } }); + } }; } diff --git a/awx/ui/client/src/job-templates/labels/labelsList.partial.html b/awx/ui/client/src/job-templates/labels/labelsList.partial.html index 0bcc5f795a..b14ec6deb9 100644 --- a/awx/ui/client/src/job-templates/labels/labelsList.partial.html +++ b/awx/ui/client/src/job-templates/labels/labelsList.partial.html @@ -8,5 +8,7 @@ {{ label.name }} -
View More
+
View Less
diff --git a/awx/ui/client/src/job-templates/survey-maker/shared/question-definition.form.js b/awx/ui/client/src/job-templates/survey-maker/shared/question-definition.form.js index 56a3903f69..685476432a 100644 --- a/awx/ui/client/src/job-templates/survey-maker/shared/question-definition.form.js +++ b/awx/ui/client/src/job-templates/survey-maker/shared/question-definition.form.js @@ -314,7 +314,7 @@ export default ngClick: 'submitQuestion($event)', ngDisabled: true, 'class': 'btn btn-sm Form-saveButton', - label: '{{editQuestionIndex === null ? "ADD" : "UPDATE"}}' + label: '{{editQuestionIndex === null ? "+ ADD" : "UPDATE"}}' } } diff --git a/awx/ui/client/src/lists/AllJobs.js b/awx/ui/client/src/lists/AllJobs.js index ee28221c1d..1e3bb3629b 100644 --- a/awx/ui/client/src/lists/AllJobs.js +++ b/awx/ui/client/src/lists/AllJobs.js @@ -48,9 +48,7 @@ export default columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6', ngClick: "viewJobDetails(all_job)", defaultSearchField: true, - awToolTip: "{{ all_job.name | sanitize }}", - dataTipWatch: 'all_job.name', - dataPlacement: 'top' + searchDefault: true, }, type: { label: 'Type', diff --git a/awx/ui/client/src/lists/CompletedJobs.js b/awx/ui/client/src/lists/CompletedJobs.js index ca0a24e454..23e4f325a8 100644 --- a/awx/ui/client/src/lists/CompletedJobs.js +++ b/awx/ui/client/src/lists/CompletedJobs.js @@ -35,6 +35,7 @@ export default ngClick:"viewJobDetails(completed_job)", searchable: true, searchType: 'select', + defaultSearchField: true, nosort: true, searchOptions: [ { label: "Success", value: "successful" }, @@ -54,8 +55,8 @@ export default name: { label: 'Name', columnClass: 'col-lg-4 col-md-4 col-sm-4 col-xs-6', + searchable: false, ngClick: "viewJobDetails(completed_job)", - defaultSearchField: true, awToolTip: "{{ completed_job.name | sanitize }}", dataPlacement: 'top' }, @@ -64,7 +65,7 @@ export default ngBind: 'completed_job.type_label', link: false, columnClass: "col-lg-2 col-md-2 hidden-sm hidden-xs", - searchable: true, + searchable: false, searchType: 'select', searchOptions: [] // populated via GetChoices() in controller }, diff --git a/awx/ui/client/src/lists/InventoryGroups.js b/awx/ui/client/src/lists/InventoryGroups.js index 4628388e90..c7f1a661e5 100644 --- a/awx/ui/client/src/lists/InventoryGroups.js +++ b/awx/ui/client/src/lists/InventoryGroups.js @@ -19,6 +19,7 @@ export default hover: true, 'class': 'table-no-border', multiSelect: true, + trackBy: 'group.id', fields: { sync_status: { diff --git a/awx/ui/client/src/lists/InventoryHosts.js b/awx/ui/client/src/lists/InventoryHosts.js index f0cc803fe5..1c01a77021 100644 --- a/awx/ui/client/src/lists/InventoryHosts.js +++ b/awx/ui/client/src/lists/InventoryHosts.js @@ -20,6 +20,7 @@ export default hasChildren: true, 'class': 'table-no-border', multiSelect: true, + trackBy: 'host.id', fields: { active_failures: { diff --git a/awx/ui/client/src/lists/PortalJobTemplates.js b/awx/ui/client/src/lists/PortalJobTemplates.js index fab6588937..dac5d942f2 100644 --- a/awx/ui/client/src/lists/PortalJobTemplates.js +++ b/awx/ui/client/src/lists/PortalJobTemplates.js @@ -22,7 +22,8 @@ export default key: true, label: 'Name', columnClass: 'col-lg-5 col-md-5 col-sm-9 col-xs-8', - linkTo: '/#/job_templates/{{job_template.id}}' + linkTo: '/#/job_templates/{{job_template.id}}', + searchDefault: true }, description: { label: 'Description', diff --git a/awx/ui/client/src/lists/PortalJobs.js b/awx/ui/client/src/lists/PortalJobs.js index f92497e7a4..bc8fd40fa8 100644 --- a/awx/ui/client/src/lists/PortalJobs.js +++ b/awx/ui/client/src/lists/PortalJobs.js @@ -35,7 +35,8 @@ export default label: 'Name', columnClass: 'col-lg-4 col-md-4 col-sm-4 col-xs-6 List-staticColumnAdjacent', defaultSearchField: true, - linkTo: '/#/jobs/{{job.id}}' + linkTo: '/#/jobs/{{job.id}}', + searchDefault: true }, finished: { label: 'Finished', diff --git a/awx/ui/client/src/lists/Projects.js b/awx/ui/client/src/lists/Projects.js index 43f9983339..a209a9ae1d 100644 --- a/awx/ui/client/src/lists/Projects.js +++ b/awx/ui/client/src/lists/Projects.js @@ -37,6 +37,7 @@ export default }, name: { key: true, + searchDefault: true, label: 'Name', columnClass: "col-lg-4 col-md-4 col-sm-5 col-xs-7 List-staticColumnAdjacent", modalColumnClass: 'col-md-8' diff --git a/awx/ui/client/src/notifications/add/add.controller.js b/awx/ui/client/src/notifications/add/add.controller.js index 148940e8a1..0b58f2e257 100644 --- a/awx/ui/client/src/notifications/add/add.controller.js +++ b/awx/ui/client/src/notifications/add/add.controller.js @@ -149,6 +149,12 @@ export default if(field.type === 'number'){ $scope[i] = Number($scope[i]); } + if(field.name === "username" && $scope.notification_type.value === "email" && value === null){ + $scope[i] = ""; + } + if(field.type === 'sensitive' && value === null){ + $scope[i] = ""; + } return $scope[i]; } diff --git a/awx/ui/client/src/notifications/edit/edit.controller.js b/awx/ui/client/src/notifications/edit/edit.controller.js index 4e0bc35772..44c50a71ce 100644 --- a/awx/ui/client/src/notifications/edit/edit.controller.js +++ b/awx/ui/client/src/notifications/edit/edit.controller.js @@ -223,6 +223,12 @@ export default if(field.type === 'number'){ $scope[i] = Number($scope[i]); } + if(field.name === "username" && $scope.notification_type.value === "email" && value === null){ + $scope[i] = ""; + } + if(field.type === 'sensitive' && value === null){ + $scope[i] = ""; + } return $scope[i]; } diff --git a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js index a84e4c96da..04265f1181 100644 --- a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js +++ b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js @@ -28,6 +28,7 @@ export default Wait('stop'); if (scope.notification_templates) { scope.notification_templates.forEach(function(notification_template, i) { + setStatus(notification_template); scope.notification_type_options.forEach(function(type) { if (type.value === notification_template.notification_type) { scope.notification_templates[i].notification_type = type.label; @@ -74,78 +75,33 @@ export default callback: 'choicesReadyNotifierList' }); - function attachElem(event, html, title) { - var elem = $(event.target).parent(); - try { - elem.tooltip('hide'); - elem.popover('destroy'); - } - catch(err) { - //ignore - } + function setStatus(notification_template) { + var html, recent_notifications = notification_template.summary_fields.recent_notifications; + if (recent_notifications.length > 0) { + html = "\n"; + html += "\n"; + html += ""; + html += ""; + html += ""; + html += "\n"; + html += "\n"; + html += "\n"; - $('.popover').each(function() { - // remove lingering popover
. Seems to be a bug in TB3 RC1 - $(this).remove(); - }); - $('.tooltip').each( function() { - // close any lingering tool tipss - $(this).hide(); - }); - elem.attr({ - "aw-pop-over": html, - "data-popover-title": title, - "data-placement": "right" }); - $compile(elem)(scope); - elem.on('shown.bs.popover', function() { - $('.popover').each(function() { - $compile($(this))(scope); //make nested directives work! + recent_notifications.forEach(function(row) { + html += "
\n"; + html += ``; + html += "\n"; + html += "\n"; }); - $('.popover-content, .popover-title').click(function() { - elem.popover('hide'); - }); - }); - elem.popover('show'); + html += "\n"; + html += "
StatusTime
" + ($filter('longDate')(row.created)).replace(/ /,'
') + "
\n"; + } + else { + html = "

No recent notifications.

\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 = "\n"; - html += "\n"; - html += ""; - html += ""; - html += ""; - html += "\n"; - html += "\n"; - html += "\n"; - - recent_notifications.forEach(function(row) { - html += "\n"; - html += ``; - html += "\n"; - html += "\n"; - }); - html += "\n"; - html += "
StatusTime
" + ($filter('longDate')(row.created)).replace(/ /,'
') + "
\n"; - } - else { - html = "

No recent notifications.

\n"; - } - attachElem(event, html, title); - } - }, 100); - }; - scope.testNotification = function(){ var name = $filter('sanitize')(this.notification_template.name), pending_retries = 10; diff --git a/awx/ui/client/src/notifications/notificationTemplates.form.js b/awx/ui/client/src/notifications/notificationTemplates.form.js index d748ab96e4..cd0ff9d945 100644 --- a/awx/ui/client/src/notifications/notificationTemplates.form.js +++ b/awx/ui/client/src/notifications/notificationTemplates.form.js @@ -59,10 +59,6 @@ export default function() { username: { label: 'Username', type: 'text', - awRequiredWhen: { - reqExpression: "email_required", - init: "false" - }, ngShow: "notification_type.value == 'email' ", subForm: 'typeSubForm' }, diff --git a/awx/ui/client/src/notifications/notificationTemplates.list.js b/awx/ui/client/src/notifications/notificationTemplates.list.js index 7f3780f959..06eb25615f 100644 --- a/awx/ui/client/src/notifications/notificationTemplates.list.js +++ b/awx/ui/client/src/notifications/notificationTemplates.list.js @@ -19,17 +19,14 @@ export default function(){ fields: { status: { label: '', - columnClass: 'List-staticColumn--smallStatus', + iconOnly: true, searchable: false, nosort: true, - ngClick: "null", - iconOnly: true, - excludeModal: true, - icons: [{ - icon: "{{ 'icon-job-' + notification_template.status }}", - ngClick: "showSummary($event, notification_template.id)", - ngClass: "" - }] + icon: 'icon-job-{{ notification_template.status }}', + awPopOver: '{{ notification_template.template_status_html }}', + dataTitle: "Recent Notifications", + dataPlacement: 'right', + columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus' }, name: { key: true, diff --git a/awx/ui/client/src/notifications/shared/toggle-notification.factory.js b/awx/ui/client/src/notifications/shared/toggle-notification.factory.js index 2bbe8e2ff5..3a530606a4 100644 --- a/awx/ui/client/src/notifications/shared/toggle-notification.factory.js +++ b/awx/ui/client/src/notifications/shared/toggle-notification.factory.js @@ -36,6 +36,8 @@ export default ['Wait', 'GetBasePath', 'ProcessErrors', 'Rest', disassociate: 1 }; } + // Show the working spinner + Wait('start'); Rest.setUrl(url); Rest.post(params) .success( function(data) { @@ -43,9 +45,8 @@ export default ['Wait', 'GetBasePath', 'ProcessErrors', 'Rest', scope.$emit(callback, data.id); notifier[column] = !notifier[column]; } - else { - Wait('stop'); - } + // Hide the working spinner + Wait('stop'); }) .error( function(data, status) { ProcessErrors(scope, data, status, null, { hdr: 'Error!', diff --git a/awx/ui/client/src/notifications/shared/type-change.service.js b/awx/ui/client/src/notifications/shared/type-change.service.js index e7d63e51f5..0827e88b34 100644 --- a/awx/ui/client/src/notifications/shared/type-change.service.js +++ b/awx/ui/client/src/notifications/shared/type-change.service.js @@ -28,7 +28,7 @@ function () { obj.passwordLabel = ' Password'; obj.email_required = true; obj.port_required = true; - obj.password_required = true; + obj.password_required = false; break; case 'slack': obj.tokenLabel =' Token'; diff --git a/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js b/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js index 406a1d1b78..a718b423c2 100644 --- a/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js +++ b/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js @@ -334,11 +334,8 @@ export default ['$scope', '$rootScope', '$location', '$log', $scope.editSchedules = function(id) { var project = Find({ list: $scope.projects, key: 'id', val: id }); - if (project.scm_type === "Manual" || Empty(project.scm_type)) { - // Nothing to do - } - else { - $location.path('/projects/' + id + '/schedules'); + if (!(project.scm_type === "Manual" || Empty(project.scm_type)) && !(project.status === 'updating' || project.status === 'running' || project.status === 'pending')) { + $state.go('projectSchedules', {id: id}); } }; diff --git a/awx/ui/client/src/search/tagSearch.service.js b/awx/ui/client/src/search/tagSearch.service.js index 4e5e6ae3ec..228bcfeac7 100644 --- a/awx/ui/client/src/search/tagSearch.service.js +++ b/awx/ui/client/src/search/tagSearch.service.js @@ -34,6 +34,10 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu type = 'text'; } + if (field.searchDefault) { + obj.default = true; + } + obj.id = id; obj.value = value; obj.label = label; @@ -76,10 +80,13 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu passThrough = partitionedOptions[1]; var joinOptions = function() { - return _.sortBy(_ + var options = _.sortBy(_ .flatten([needsRequest, passThrough]), function(opt) { return opt.id; }); + + // put default first + return _.flatten(_.partition(options, opt => opt.default)); }; if (needsRequest.length) { diff --git a/awx/ui/client/src/shared/directives.js b/awx/ui/client/src/shared/directives.js index 52cbe156b9..289e04b58d 100644 --- a/awx/ui/client/src/shared/directives.js +++ b/awx/ui/client/src/shared/directives.js @@ -390,46 +390,57 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) // lookup Validate lookup value against API // - .directive('awlookup', ['Rest', function(Rest) { + .directive('awlookup', ['Rest', '$timeout', function(Rest, $timeout) { return { require: 'ngModel', link: function(scope, elm, attrs, ctrl) { + + var restTimeout; + ctrl.$parsers.unshift( function(viewValue) { if (viewValue !== '' && viewValue !== null) { var url = elm.attr('data-url'); url = url.replace(/\:value/, encodeURI(viewValue)); scope[elm.attr('data-source')] = null; - Rest.setUrl(url); - Rest.get().then( function(data) { - var results = data.data.results; - if (results.length > 0) { - scope[elm.attr('data-source')] = results[0].id; + if(restTimeout) { + $timeout.cancel(restTimeout); + } + restTimeout = $timeout( function(){ + Rest.setUrl(url); + Rest.get().then( function(data) { + var results = data.data.results; + if (results.length > 0) { + scope[elm.attr('data-source')] = results[0].id; - // For user lookups the API endpoint doesn't - // have a `name` property, so this is `undefined` - // which causes the input to clear after typing - // a valid value O_o - // - // Only assign if there is a value, so that we avoid - // this situation. - // - // TODO: Evaluate if assigning name on the scope is - // even necessary at all. - // - if (!_.isEmpty(results[0].name)) { - scope[elm.attr('name')] = results[0].name; + // For user lookups the API endpoint doesn't + // have a `name` property, so this is `undefined` + // which causes the input to clear after typing + // a valid value O_o + // + // Only assign if there is a value, so that we avoid + // this situation. + // + // TODO: Evaluate if assigning name on the scope is + // even necessary at all. + // + if (!_.isEmpty(results[0].name)) { + scope[elm.attr('name')] = results[0].name; + } + + ctrl.$setValidity('required', true); + ctrl.$setValidity('awlookup', true); + return viewValue; } - ctrl.$setValidity('required', true); - ctrl.$setValidity('awlookup', true); - return viewValue; - } - ctrl.$setValidity('required', true); - ctrl.$setValidity('awlookup', false); - return undefined; - }); + ctrl.$setValidity('awlookup', false); + return undefined; + }); + }, 750); } else { + if(restTimeout) { + $timeout.cancel(restTimeout); + } ctrl.$setValidity('awlookup', true); scope[elm.attr('data-source')] = null; } @@ -477,7 +488,8 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) return { link: function(scope, element, attrs) { var delay = (attrs.delay !== undefined && attrs.delay !== null) ? attrs.delay : ($AnsibleConfig) ? $AnsibleConfig.tooltip_delay : {show: 500, hide: 100}, - placement; + placement, + stateChangeWatcher; if (attrs.awTipPlacement) { placement = attrs.awTipPlacement; } @@ -493,6 +505,22 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) template = ''; } + // This block helps clean up tooltips that may get orphaned by a click event + $(element).on('mouseenter', function() { + if(stateChangeWatcher) { + // Un-bind - we don't want a bunch of listeners firing + stateChangeWatcher(); + } + stateChangeWatcher = scope.$on('$stateChangeStart', function() { + // Go ahead and force the tooltip setTimeout to expire (if it hasn't already fired) + $(element).tooltip('hide'); + // Clean up any existing tooltips including this one + $('.tooltip').each(function() { + $(this).remove(); + }); + }); + }); + $(element).on('hidden.bs.tooltip', function( ) { // TB3RC1 is leaving behind tooltip
elements. This will remove them // after a tooltip fades away. If not, they lay overtop of other elements and @@ -548,6 +576,10 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) template = ''; } + scope.triggerPopover = function(e){ + showPopover(e); + }; + if (attrs.awPopOverWatch) { $(element).popover({ placement: placement, diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index 4c8382db38..66c4f750b8 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -1487,6 +1487,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat "ng-show='is_system_auditor'>Auditor"; html+= "LDAP"; + html+= "{{external_account}}"; } html += "
\n"; html += "
"; @@ -1524,8 +1526,13 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat collection = this.form.related[itm]; html += `
"; + html += "\n"; diff --git a/awx/ui/client/src/shared/multi-select-list/multi-select-list.controller.js b/awx/ui/client/src/shared/multi-select-list/multi-select-list.controller.js index 1957afa992..1d7044af28 100644 --- a/awx/ui/client/src/shared/multi-select-list/multi-select-list.controller.js +++ b/awx/ui/client/src/shared/multi-select-list/multi-select-list.controller.js @@ -63,9 +63,16 @@ export default ['$scope', * {@link multiSelectList.controller:multiSelectList#decorateItem `decorateItem`} */ this.registerItem = function(item) { - var decoratedItem = this.decorateItem(item); - $scope.items = $scope.items.concat(decoratedItem); - return decoratedItem; + var foundItem = _.find($scope.items, function(existingItem) { return existingItem.id === item.id; }); + + if(foundItem) { + return foundItem; + } + else { + var decoratedItem = this.decorateItem(item); + $scope.items = $scope.items.concat(decoratedItem); + return decoratedItem; + } }; /** @@ -99,6 +106,7 @@ export default ['$scope', this.decorateItem = function(item) { return { isSelected: false, + id: item.id, value: item }; }; @@ -129,11 +137,11 @@ export default ['$scope', * Triggers {@link multiSelectList.selectionChanged `multiSelectList.selectionChanged`} */ this.deselectAll = function() { - $scope.items.forEach(function(item) { - item.isSelected = false; - }); - $scope.selection.isExtended = false; - rebuildSelections(); + $scope.items.forEach(function(item) { + item.isSelected = false; + }); + $scope.selection.isExtended = false; + rebuildSelections(); }; diff --git a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js index 09ede3e244..4867c5e07c 100644 --- a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js +++ b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js @@ -33,11 +33,8 @@ export default template: '', link: function(scope, element, attrs, multiSelectList) { - var initializeItem = function() { - scope.decoratedItem = multiSelectList.registerItem(scope.item); - scope.isSelected = scope.item.isSelected ? true : false; - scope.decoratedItem.isSelected = scope.item.isSelected ? true : false; - }; + scope.decoratedItem = multiSelectList.registerItem(scope.item); + scope.isSelected = scope.decoratedItem.isSelected ? true : false; scope.$watch('isSelected', function(value) { if (value === true) { @@ -47,23 +44,10 @@ export default } }); - scope.$watch('item', function() { - // This is necessary for page changes where $scope.item gets updated via ng-repeat - // but this link function never gets triggered (and scope.decoratedItem) never - // gets updated. - initializeItem(); - }); - - scope.$on('$destroy', function() { - multiSelectList.deregisterItem(scope.decoratedItem); - }); - scope.userInteractionSelect = function() { scope.$emit("selectedOrDeselected", scope.decoratedItem); }; - initializeItem(); - } }; }]; diff --git a/awx/ui/client/src/standard-out/standard-out.controller.js b/awx/ui/client/src/standard-out/standard-out.controller.js index f43d6c4ea5..acdd5815a2 100644 --- a/awx/ui/client/src/standard-out/standard-out.controller.js +++ b/awx/ui/client/src/standard-out/standard-out.controller.js @@ -74,6 +74,7 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams, $scope.limit = data.limit; $scope.verbosity = data.verbosity; $scope.job_tags = data.job_tags; + $scope.job.module_name = data.module_name; if (data.extra_vars) { $scope.variables = ParseVariableString(data.extra_vars); } diff --git a/awx/ui/templates/ui/index.html b/awx/ui/templates/ui/index.html index 180d90b85f..d5777c9619 100644 --- a/awx/ui/templates/ui/index.html +++ b/awx/ui/templates/ui/index.html @@ -37,7 +37,7 @@ -
+
diff --git a/tools/scripts/request_tower_configuration.sh b/tools/scripts/request_tower_configuration.sh index 0e569ac5fd..4b3b731772 100644 --- a/tools/scripts/request_tower_configuration.sh +++ b/tools/scripts/request_tower_configuration.sh @@ -14,8 +14,11 @@ attempt=0 while [[ $attempt -lt $retry_attempts ]] do status_code=`curl -s -i --data "host_config_key=$2" http://$1/api/v1/job_templates/$3/callback/ | head -n 1 | awk '{print $2}'` - if [[ $status_code == 202 ]] + if [[ $status_code -ge 300 ]] then + echo "${status_code} received, encountered problem, halting." + exit 1 + else exit 0 fi attempt=$(( attempt + 1 ))