diff --git a/Makefile b/Makefile index de64580092..2ff93989dd 100644 --- a/Makefile +++ b/Makefile @@ -172,7 +172,7 @@ endif .DEFAULT_GOAL := build -.PHONY: clean clean-tmp rebase push requirements requirements_dev \ +.PHONY: clean clean-tmp clean-venv rebase push requirements requirements_dev \ requirements_jenkins \ develop refresh adduser migrate dbchange dbshell runserver celeryd \ receiver test test_unit test_coverage coverage_html test_jenkins dev_build \ @@ -182,8 +182,8 @@ endif websocket-proxy browser-sync browser-sync-reload brocolli-watcher \ devjs minjs testjs_ci \ deb deb-src debian debsign pbuilder reprepro setup_tarball \ - virtualbox-ovf virtualbox-centos-7 virtualbox-centos-6 \ - clean-bundle setup_bundle_tarball + vagrant-virtualbox virtualbox-centos-7 virtualbox-centos-6 \ + vagrant-vmware clean-bundle setup_bundle_tarball # Remove setup build files clean-tar: @@ -229,8 +229,11 @@ clean-build-test: clean-tmp: rm -rf tmp/ +clean-venv: + rm -rf venv/ + # Remove temporary build files, compiled Python files. -clean: clean-rpm clean-deb clean-grunt clean-ui clean-static clean-build-test clean-tar clean-packer clean-bundle +clean: clean-rpm clean-deb clean-grunt clean-ui clean-static clean-build-test clean-tar clean-packer clean-bundle clean-venv rm -rf awx/lib/site-packages rm -rf awx/lib/.deps_built rm -rf dist/* @@ -448,13 +451,22 @@ check: flake8 pep8 # pyflakes pylint TEST_DIRS=awx/main/tests # Run all API unit tests. test: + @if [ "$(VENV_BASE)" ]; then \ + . $(VENV_BASE)/tower/bin/activate; \ + fi; \ py.test $(TEST_DIRS) test_unit: + @if [ "$(VENV_BASE)" ]; then \ + . $(VENV_BASE)/tower/bin/activate; \ + fi; \ py.test awx/main/tests/unit # Run all API unit tests with coverage enabled. test_coverage: + @if [ "$(VENV_BASE)" ]; then \ + . $(VENV_BASE)/tower/bin/activate; \ + fi; \ py.test --create-db --cov=awx --cov-report=xml --junitxml=./reports/junit.xml $(TEST_DIRS) # Output test coverage as HTML (into htmlcov directory). @@ -848,30 +860,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/__init__.py b/awx/__init__.py index 81659056aa..bf3f75255c 100644 --- a/awx/__init__.py +++ b/awx/__init__.py @@ -5,7 +5,7 @@ import os import sys import warnings -__version__ = '3.0.1' +__version__ = '3.0.2' __all__ = ['__version__'] diff --git a/awx/api/permissions.py b/awx/api/permissions.py index 6e1320e2d8..285441421d 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -19,7 +19,7 @@ from awx.main.utils import get_object_or_400 logger = logging.getLogger('awx.api.permissions') __all__ = ['ModelAccessPermission', 'JobTemplateCallbackPermission', - 'TaskPermission', 'ProjectUpdatePermission'] + 'TaskPermission', 'ProjectUpdatePermission', 'UserPermission'] class ModelAccessPermission(permissions.BasePermission): ''' @@ -202,3 +202,10 @@ class ProjectUpdatePermission(ModelAccessPermission): def check_post_permissions(self, request, view, obj=None): project = get_object_or_400(view.model, pk=view.kwargs['pk']) return check_user_access(request.user, view.model, 'start', project) + + +class UserPermission(ModelAccessPermission): + def check_post_permissions(self, request, view, obj=None): + if request.user.is_superuser: + return True + raise PermissionDenied() diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 679d23aeee..2eb4e3d7a1 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 @@ -2280,14 +2286,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,}, @@ -2674,6 +2681,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 7d1a855ed7..4496122bf4 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -201,7 +201,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 +225,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(), @@ -876,12 +879,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): @@ -1143,6 +1153,7 @@ class UserList(ListCreateAPIView): model = User serializer_class = UserSerializer + permission_classes = (UserPermission,) def post(self, request, *args, **kwargs): ret = super(UserList, self).post( request, *args, **kwargs) @@ -1205,7 +1216,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) @@ -1291,7 +1319,7 @@ class UserDetail(RetrieveUpdateDestroyAPIView): can_admin = request.user.can_access(User, 'admin', obj, request.data) su_only_edit_fields = ('is_superuser', 'is_system_auditor') - admin_only_edit_fields = ('last_name', 'first_name', 'username', 'is_active') + admin_only_edit_fields = ('username', 'is_active') fields_to_check = () if not request.user.is_superuser: @@ -1384,8 +1412,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): @@ -3623,7 +3651,6 @@ class RoleDetail(RetrieveAPIView): model = Role serializer_class = RoleSerializer - permission_classes = (IsAuthenticated,) new_in_300 = True @@ -3646,6 +3673,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) @@ -3670,13 +3717,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/fields.py b/awx/main/fields.py index 92ed69672f..e95dbc1ee7 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -54,10 +54,6 @@ class AutoOneToOneField(models.OneToOneField): AutoSingleRelatedObjectDescriptor(related)) - - - - def resolve_role_field(obj, field): ret = [] @@ -71,8 +67,8 @@ def resolve_role_field(obj, field): return [] if len(field_components) == 1: - Role_ = get_current_apps().get_model('main', 'Role') - if type(obj) is not Role_: + role_cls = str(get_current_apps().get_model('main', 'Role')) + if not str(type(obj)) == role_cls: raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj)))) ret.append(obj.id) else: diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 21031f23cc..4ae521cd5c 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -22,6 +22,7 @@ import yaml from django.conf import settings from django.core.management.base import NoArgsCommand, CommandError from django.db import connection, transaction +from django.utils.encoding import smart_text # AWX from awx.main.models import * # noqa @@ -606,7 +607,7 @@ class Command(NoArgsCommand): break instance_id = from_dict.get(key, default) from_dict = instance_id - return instance_id + return smart_text(instance_id) def _get_enabled(self, from_dict, default=None): ''' 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..2587588e6d --- /dev/null +++ b/awx/main/migrations/0032_v302_credential_permissions_update.py @@ -0,0 +1,30 @@ +# -*- 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.infer_credential_org_from_team), + migrations.RunPython(rbac.rebuild_role_hierarchy), + ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index b60ac65691..80ecc69ebc 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -2,7 +2,9 @@ import logging from time import time from django.utils.encoding import smart_text +from django.db import transaction from django.db.models import Q +from django.db.utils import IntegrityError from collections import defaultdict from awx.main.utils import getattrd @@ -490,3 +492,11 @@ def rebuild_role_hierarchy(apps, schema_editor): logger.info('Done.') +def infer_credential_org_from_team(apps, schema_editor): + Credential = apps.get_model('main', "Credential") + for cred in Credential.objects.exclude(deprecated_team__isnull=True): + try: + with transaction.atomic(): + _update_credential_parents(cred.deprecated_team.organization, cred) + except IntegrityError: + logger.info("Organization<{}> credential for old Team<{}> credential already created".format(cred.deprecated_team.organization.pk, cred.pk)) 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 ac67bf8d67..bbf53b86ce 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..c99f043e1a 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() @@ -1313,9 +1319,14 @@ class RunInventoryUpdate(BaseTask): credential = inventory_update.credential if credential: - cp.set(section, 'hostname', credential.host) + cp.set(section, 'url', credential.host) cp.set(section, 'username', credential.username) cp.set(section, 'password', decrypt_field(credential, 'password')) + cp.set(section, 'ssl_verify', "false") + + section = 'cache' + cp.add_section(section) + cp.set(section, 'max_age', "0") elif inventory_update.source == 'azure_rm': section = 'azure' diff --git a/awx/main/tests/conftest.py b/awx/main/tests/conftest.py index 470f43e661..1f21905fb9 100644 --- a/awx/main/tests/conftest.py +++ b/awx/main/tests/conftest.py @@ -26,16 +26,16 @@ 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 -def job_with_secret_key_unit(job_with_secret_key_factory): - return job_with_secret_key_factory(persisted=False) +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..f1e7a2b1dd 100644 --- a/awx/main/tests/functional/api/test_credential.py +++ b/awx/main/tests/functional/api/test_credential.py @@ -68,7 +68,7 @@ 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, 'name': 'Some name', @@ -80,6 +80,9 @@ def test_create_team_credential(post, get, team, org_admin, team_member): assert response.status_code == 200 assert response.data['count'] == 1 + # Assure that credential's organization is implictly set to team's org + assert response.data['results'][0]['summary_fields']['organization']['id'] == team.organization.id + @pytest.mark.django_db def test_create_team_credential_via_team_credentials_list(post, get, team, org_admin, team_member): response = post(reverse('api:team_credentials_list', args=(team.pk,)), { @@ -94,25 +97,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 +314,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 +392,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..ae68f036d8 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -54,21 +54,40 @@ def test_credential_migration_team_member(credential, team, user, permissions): rbac.migrate_credential(apps, None) - # Admin permissions post migration + # User permissions post migration assert u in credential.use_role + assert u not in credential.admin_role @pytest.mark.django_db def test_credential_migration_team_admin(credential, team, user, permissions): u = user('user', False) - team.member_role.members.add(u) + team.admin_role.members.add(u) credential.deprecated_team = team credential.save() assert u not in credential.use_role - # Usage permissions post migration + # Admin permissions post migration rbac.migrate_credential(apps, None) - assert u in credential.use_role + assert u in credential.admin_role + +@pytest.mark.django_db +def test_credential_migration_org_auditor(credential, team, org_auditor): + # Team's organization is the org_auditor's org + credential.deprecated_team = team + credential.save() + + # No permissions pre-migration (this happens automatically so we patch this) + team.admin_role.children.remove(credential.admin_role) + team.member_role.children.remove(credential.use_role) + assert org_auditor not in credential.read_role + + rbac.migrate_credential(apps, None) + rbac.infer_credential_org_from_team(apps, None) + + # Read permissions post migration + assert org_auditor not in credential.use_role + assert org_auditor in credential.read_role def test_credential_access_superuser(): u = User(username='admin', is_superuser=True) @@ -133,29 +152,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 +244,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 +251,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/old/users.py b/awx/main/tests/old/users.py index de364ff161..df2d5e19bc 100644 --- a/awx/main/tests/old/users.py +++ b/awx/main/tests/old/users.py @@ -192,8 +192,12 @@ class UsersTest(BaseTest): self.post(url, expect=403, data=new_user, auth=self.get_other_credentials()) self.post(url, expect=201, data=new_user, auth=self.get_super_credentials()) self.post(url, expect=400, data=new_user, auth=self.get_super_credentials()) - self.post(url, expect=201, data=new_user2, auth=self.get_normal_credentials()) - self.post(url, expect=400, data=new_user2, auth=self.get_normal_credentials()) + # org admin cannot create orphaned users + self.post(url, expect=403, data=new_user2, auth=self.get_normal_credentials()) + # org admin can create org users + org_url = reverse('api:organization_users_list', args=(self.organizations[0].pk,)) + self.post(org_url, expect=201, data=new_user2, auth=self.get_normal_credentials()) + self.post(org_url, expect=400, data=new_user2, auth=self.get_normal_credentials()) # Normal user cannot add users after his org is marked inactive. self.organizations[0].delete() new_user3 = dict(username='blippy3') @@ -325,9 +329,9 @@ class UsersTest(BaseTest): detail_url = reverse('api:user_detail', args=(self.other_django_user.pk,)) data = self.get(detail_url, expect=200, auth=self.get_other_credentials()) - # can't change first_name, last_name, etc + # can change first_name, last_name, etc data['last_name'] = "NewLastName" - self.put(detail_url, data, expect=403, auth=self.get_other_credentials()) + self.put(detail_url, data, expect=200, auth=self.get_other_credentials()) # can't change username data['username'] = 'newUsername' @@ -367,23 +371,20 @@ class UsersTest(BaseTest): url = reverse('api:user_list') data = dict(username='username', password='password') data2 = dict(username='username2', password='password2') - data = self.post(url, expect=201, data=data, auth=self.get_normal_credentials()) + # but a regular user cannot create users + self.post(url, expect=403, data=data2, auth=self.get_other_credentials()) + # org admins cannot create orphaned users + self.post(url, expect=403, data=data2, auth=self.get_normal_credentials()) + + # a super user can create new users + self.post(url, expect=201, data=data, auth=self.get_super_credentials()) # verify that the login works... self.get(url, expect=200, auth=('username', 'password')) - # but a regular user cannot - data = self.post(url, expect=403, data=data2, auth=self.get_other_credentials()) - - # a super user can also create new users - data = self.post(url, expect=201, data=data2, auth=self.get_super_credentials()) - - # verify that the login works - self.get(url, expect=200, auth=('username2', 'password2')) - # verify that if you post a user with a pk, you do not alter that user's password info mod = dict(id=self.super_django_user.pk, username='change', password='change') - data = self.post(url, expect=201, data=mod, auth=self.get_super_credentials()) + self.post(url, expect=201, data=mod, auth=self.get_super_credentials()) orig = User.objects.get(pk=self.super_django_user.pk) self.assertTrue(orig.username != 'change') 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 90f9959cfe..676a0c7f1f 100644 --- a/awx/main/tests/unit/test_network_credential.py +++ b/awx/main/tests/unit/test_network_credential.py @@ -41,8 +41,8 @@ def test_net_cred_ssh_agent(mocker, options): '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..65d95853d5 100755 --- a/awx/plugins/inventory/cloudforms.py +++ b/awx/plugins/inventory/cloudforms.py @@ -1,126 +1,462 @@ #!/usr/bin/python +# vim: set fileencoding=utf-8 : +# +# Copyright (C) 2016 Guido Günther +# +# This script is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with it. If not, see . +# +# This is loosely based on the foreman inventory script +# -- Josh Preston +# -''' -CloudForms external inventory script -================================================== -Generates inventory that Ansible can understand by making API request to CloudForms. -Modeled after https://raw.githubusercontent.com/ansible/ansible/stable-1.9/plugins/inventory/ec2.py -jlabocki redhat.com or @jameslabocki on twitter -''' - -import os +from __future__ import print_function import argparse import ConfigParser +import os +import re +from time import time import requests -import json +from requests.auth import HTTPBasicAuth +import warnings + +try: + import json +except ImportError: + import simplejson as json -# This disables warnings and is not a good idea, but hey, this is a demo -# 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" : {}}} - def __init__(self): - ''' Main execution path ''' + """ + Main execution path + """ + self.inventory = dict() # A list of groups and the hosts in that group + self.hosts = dict() # Details about hosts in the inventory - # Inventory grouped by instance IDs, tags, security groups, regions, - # and availability zones - self.inventory = self._empty_inventory() - - # Index of hostname (address) to instance ID - self.index = {} - - # Read CLI arguments - self.read_settings() + # Parse CLI arguments self.parse_cli_args() - # Get Hosts - if self.args.list: - self.get_hosts() + # Read settings + self.read_settings() - # This doesn't exist yet and needs to be added + # Cache + if self.args.refresh_cache or not self.is_cache_valid(): + self.update_cache() + else: + self.load_inventory_from_cache() + self.load_hosts_from_cache() + + data_to_print = "" + + # Data to print if self.args.host: - data2 = { } - print json.dumps(data2, indent=2) + if self.args.debug: + print("Fetching host [%s]" % self.args.host) + data_to_print += self.get_host_info(self.args.host) + else: + self.inventory['_meta'] = {'hostvars': {}} + for hostname in self.hosts: + self.inventory['_meta']['hostvars'][hostname] = { + 'cloudforms': self.hosts[hostname], + } + # include the ansible_ssh_host in the top level + if 'ansible_ssh_host' in self.hosts[hostname]: + self.inventory['_meta']['hostvars'][hostname]['ansible_ssh_host'] = self.hosts[hostname]['ansible_ssh_host'] - def parse_cli_args(self): - ''' Command line argument processing ''' + data_to_print += self.json_format_dict(self.inventory, self.args.pretty) - 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)') - parser.add_argument('--host', action='store', - help='Get all the variables about a specific instance') - self.args = parser.parse_args() + print(data_to_print) + + def is_cache_valid(self): + """ + Determines if the cache files have expired, or if it is still valid + """ + if self.args.debug: + print("Determining if cache [%s] is still valid (< %s seconds old)" % (self.cache_path_hosts, self.cache_max_age)) + + if os.path.isfile(self.cache_path_hosts): + mod_time = os.path.getmtime(self.cache_path_hosts) + current_time = time() + if (mod_time + self.cache_max_age) > current_time: + if os.path.isfile(self.cache_path_inventory): + if self.args.debug: + print("Cache is still valid!") + return True + + if self.args.debug: + print("Cache is stale or does not exist.") + + return False def read_settings(self): - ''' Reads the settings from the cloudforms.ini file ''' - + """ + Reads the settings from the cloudforms.ini file + """ config = ConfigParser.SafeConfigParser() config_paths = [ - os.path.join(os.path.dirname(os.path.realpath(__file__)), 'cloudforms.ini'), - "/opt/rh/cloudforms.ini", + os.path.dirname(os.path.realpath(__file__)) + '/cloudforms.ini', + "/etc/ansible/cloudforms.ini", ] env_value = os.environ.get('CLOUDFORMS_INI_PATH') if env_value is not None: config_paths.append(os.path.expanduser(os.path.expandvars(env_value))) + if self.args.debug: + for config_path in config_paths: + print("Reading from configuration file [%s]" % config_path) + config.read(config_paths) - # Version - if config.has_option('cloudforms', 'version'): - self.cloudforms_version = config.get('cloudforms', 'version') + # CloudForms API related + if config.has_option('cloudforms', 'url'): + self.cloudforms_url = config.get('cloudforms', 'url') else: - self.cloudforms_version = "none" + self.cloudforms_url = None - # CloudForms Endpoint - if config.has_option('cloudforms', 'hostname'): - self.cloudforms_hostname = config.get('cloudforms', 'hostname') - else: - self.cloudforms_hostname = None + if not self.cloudforms_url: + warnings.warn("No url specified, expected something like 'https://cfme.example.com'") - # CloudForms Username if config.has_option('cloudforms', 'username'): self.cloudforms_username = config.get('cloudforms', 'username') else: - self.cloudforms_username = "none" + self.cloudforms_username = None + + if not self.cloudforms_username: + warnings.warn("No username specified, you need to specify a CloudForms username.") - # CloudForms Password if config.has_option('cloudforms', 'password'): - self.cloudforms_password = config.get('cloudforms', 'password') + self.cloudforms_pw = config.get('cloudforms', 'password') else: - self.cloudforms_password = "none" + self.cloudforms_pw = None - 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) + if not self.cloudforms_pw: + warnings.warn("No password specified, you need to specify a password for the CloudForms user.") - obj = r.json() + if config.has_option('cloudforms', 'ssl_verify'): + self.cloudforms_ssl_verify = config.getboolean('cloudforms', 'ssl_verify') + else: + self.cloudforms_ssl_verify = True - #Remove objects that don't matter - del obj["count"] - del obj["subcount"] - del obj["name"] + if config.has_option('cloudforms', 'version'): + self.cloudforms_version = config.get('cloudforms', 'version') + else: + self.cloudforms_version = None - #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) + if config.has_option('cloudforms', 'limit'): + self.cloudforms_limit = config.getint('cloudforms', 'limit') + else: + self.cloudforms_limit = 100 + + if config.has_option('cloudforms', 'purge_actions'): + self.cloudforms_purge_actions = config.getboolean('cloudforms', 'purge_actions') + else: + self.cloudforms_purge_actions = True + + if config.has_option('cloudforms', 'clean_group_keys'): + self.cloudforms_clean_group_keys = config.getboolean('cloudforms', 'clean_group_keys') + else: + self.cloudforms_clean_group_keys = True + + if config.has_option('cloudforms', 'nest_tags'): + self.cloudforms_nest_tags = config.getboolean('cloudforms', 'nest_tags') + else: + self.cloudforms_nest_tags = False + + # Ansible related + try: + group_patterns = config.get('ansible', 'group_patterns') + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + group_patterns = "[]" + + self.group_patterns = eval(group_patterns) + + # Cache related + try: + cache_path = os.path.expanduser(config.get('cache', 'path')) + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + cache_path = '.' + (script, ext) = os.path.splitext(os.path.basename(__file__)) + self.cache_path_hosts = cache_path + "/%s.hosts" % script + self.cache_path_inventory = cache_path + "/%s.inventory" % script + self.cache_max_age = config.getint('cache', 'max_age') + + if self.args.debug: + print("CloudForms settings:") + print("cloudforms_url = %s" % self.cloudforms_url) + print("cloudforms_username = %s" % self.cloudforms_username) + print("cloudforms_pw = %s" % self.cloudforms_pw) + print("cloudforms_ssl_verify = %s" % self.cloudforms_ssl_verify) + print("cloudforms_version = %s" % self.cloudforms_version) + print("cloudforms_limit = %s" % self.cloudforms_limit) + print("cloudforms_purge_actions = %s" % self.cloudforms_purge_actions) + print("Cache settings:") + print("cache_max_age = %s" % self.cache_max_age) + print("cache_path_hosts = %s" % self.cache_path_hosts) + print("cache_path_inventory = %s" % self.cache_path_inventory) + + def parse_cli_args(self): + """ + Command line argument processing + """ + parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on CloudForms managed VMs') + parser.add_argument('--list', action='store_true', default=True, help='List instances (default: True)') + parser.add_argument('--host', action='store', help='Get all the variables about a specific instance') + parser.add_argument('--pretty', action='store_true', default=False, help='Pretty print JSON output (default: False)') + parser.add_argument('--refresh-cache', action='store_true', default=False, + help='Force refresh of cache by making API requests to CloudForms (default: False - use cache files)') + parser.add_argument('--debug', action='store_true', default=False, help='Show debug output while running (default: False)') + self.args = parser.parse_args() + + def _get_json(self, url): + """ + Make a request and return the JSON + """ + results = [] + + ret = requests.get(url, + auth=HTTPBasicAuth(self.cloudforms_username, self.cloudforms_pw), + verify=self.cloudforms_ssl_verify) + + ret.raise_for_status() + + try: + results = json.loads(ret.text) + except ValueError: + warnings.warn("Unexpected response from {0} ({1}): {2}".format(self.cloudforms_url, ret.status_code, ret.reason)) + results = {} + + if self.args.debug: + print("=======================================================================") + print("=======================================================================") + print("=======================================================================") + print(ret.text) + print("=======================================================================") + print("=======================================================================") + print("=======================================================================") + + return results + + def _get_hosts(self): + """ + Get all hosts by paging through the results + """ + limit = self.cloudforms_limit + + page = 0 + last_page = False + + results = [] + + while not last_page: + offset = page * limit + ret = self._get_json("%s/api/vms?offset=%s&limit=%s&expand=resources,tags,hosts,&attributes=ipaddresses" % (self.cloudforms_url, offset, limit)) + results += ret['resources'] + if ret['subcount'] < limit: + last_page = True + page += 1 + + return results + + def update_cache(self): + """ + Make calls to cloudforms and save the output in a cache + """ + self.groups = dict() + self.hosts = dict() + + if self.args.debug: + print("Updating cache...") + + for host in self._get_hosts(): + # Ignore VMs that are not powered on + if host['power_state'] != 'on': + if self.args.debug: + print("Skipping %s because power_state = %s" % (host['name'], host['power_state'])) + continue + + # purge actions + if self.cloudforms_purge_actions and 'actions' in host: + del host['actions'] + + # Create ansible groups for tags + if 'tags' in host: + + # Create top-level group + if 'tags' not in self.inventory: + self.inventory['tags'] = dict(children=[], vars={}, hosts=[]) + + if not self.cloudforms_nest_tags: + # don't expand tags, just use them in a safe way + for group in host['tags']: + # Add sub-group, as a child of top-level + safe_key = self.to_safe(group['name']) + if safe_key: + if self.args.debug: + print("Adding sub-group '%s' to parent 'tags'" % safe_key) + + if safe_key not in self.inventory['tags']['children']: + self.push(self.inventory['tags'], 'children', safe_key) + + self.push(self.inventory, safe_key, host['name']) + + if self.args.debug: + print("Found tag [%s] for host which will be mapped to [%s]" % (group['name'], safe_key)) + else: + # expand the tags into nested groups / sub-groups + # Create nested groups for tags + safe_parent_tag_name = 'tags' + for tag in host['tags']: + tag_hierarchy = tag['name'][1:].split('/') + + if self.args.debug: + print("Working on list %s" % tag_hierarchy) + + for tag_name in tag_hierarchy: + if self.args.debug: + print("Working on tag_name = %s" % tag_name) + + safe_tag_name = self.to_safe(tag_name) + if self.args.debug: + print("Using sanitized name %s" % safe_tag_name) + + # Create sub-group + if safe_tag_name not in self.inventory: + self.inventory[safe_tag_name] = dict(children=[], vars={}, hosts=[]) + + # Add sub-group, as a child of top-level + if safe_parent_tag_name: + if self.args.debug: + print("Adding sub-group '%s' to parent '%s'" % (safe_tag_name, safe_parent_tag_name)) + + if safe_tag_name not in self.inventory[safe_parent_tag_name]['children']: + self.push(self.inventory[safe_parent_tag_name], 'children', safe_tag_name) + + # Make sure the next one uses this one as it's parent + safe_parent_tag_name = safe_tag_name + + # Add the host to the last tag + self.push(self.inventory[safe_parent_tag_name], 'hosts', host['name']) + + # Set ansible_ssh_host to the first available ip address + if 'ipaddresses' in host and host['ipaddresses'] and isinstance(host['ipaddresses'], list): + host['ansible_ssh_host'] = host['ipaddresses'][0] + + # Create additional groups + for key in ('location', 'type', 'vendor'): + safe_key = self.to_safe(host[key]) + + # Create top-level group + if key not in self.inventory: + self.inventory[key] = dict(children=[], vars={}, hosts=[]) + + # Create sub-group + if safe_key not in self.inventory: + self.inventory[safe_key] = dict(children=[], vars={}, hosts=[]) + + # Add sub-group, as a child of top-level + if safe_key not in self.inventory[key]['children']: + self.push(self.inventory[key], 'children', safe_key) + + if key in host: + # Add host to sub-group + self.push(self.inventory[safe_key], 'hosts', host['name']) + + self.hosts[host['name']] = host + self.push(self.inventory, 'all', host['name']) + + if self.args.debug: + print("Saving cached data") + + self.write_to_cache(self.hosts, self.cache_path_hosts) + self.write_to_cache(self.inventory, self.cache_path_inventory) + + def get_host_info(self, host): + """ + Get variables about a specific host + """ + if not self.hosts or len(self.hosts) == 0: + # Need to load cache from cache + self.load_hosts_from_cache() + + if host not in self.hosts: + if self.args.debug: + print("[%s] not found in cache." % host) + + # try updating the cache + self.update_cache() + + if host not in self.hosts: + if self.args.debug: + print("[%s] does not exist after cache update." % host) + # host might not exist anymore + return self.json_format_dict({}, self.args.pretty) + + return self.json_format_dict(self.hosts[host], self.args.pretty) + + def push(self, d, k, v): + """ + Safely puts a new entry onto an array. + """ + if k in d: + d[k].append(v) + else: + d[k] = [v] + + def load_inventory_from_cache(self): + """ + Reads the inventory from the cache file sets self.inventory + """ + cache = open(self.cache_path_inventory, 'r') + json_inventory = cache.read() + self.inventory = json.loads(json_inventory) + + def load_hosts_from_cache(self): + """ + Reads the cache from the cache file sets self.hosts + """ + cache = open(self.cache_path_hosts, 'r') + json_cache = cache.read() + self.hosts = json.loads(json_cache) + + def write_to_cache(self, data, filename): + """ + Writes data in JSON format to a file + """ + json_data = self.json_format_dict(data, True) + cache = open(filename, 'w') + cache.write(json_data) + cache.close() + + def to_safe(self, word): + """ + Converts 'bad' characters in a string to underscores so they can be used as Ansible groups + """ + if self.cloudforms_clean_group_keys: + regex = "[^A-Za-z0-9\_]" + return re.sub(regex, "_", word.replace(" ", "")) + else: + return word + + def json_format_dict(self, data, pretty=False): + """ + Converts a dict to a JSON object and dumps it as a formatted string + """ + if pretty: + return json.dumps(data, sort_keys=True, indent=2) + else: + return json.dumps(data) -# Run the script CloudFormsInventory() + diff --git a/awx/plugins/inventory/foreman.py b/awx/plugins/inventory/foreman.py index ce057690df..ddcb912fd5 100755 --- a/awx/plugins/inventory/foreman.py +++ b/awx/plugins/inventory/foreman.py @@ -1,8 +1,6 @@ -#!/usr/bin/python +#!/usr/bin/env python # vim: set fileencoding=utf-8 : # -# NOTE FOR TOWER: change foreman_ to sattelite_ for the group prefix -# # Copyright (C) 2016 Guido Günther # # This script is free software: you can redistribute it and/or modify @@ -41,6 +39,7 @@ class ForemanInventory(object): self.inventory = dict() # A list of groups and the hosts in that group self.cache = dict() # Details about hosts in the inventory self.params = dict() # Params of each host + self.facts = dict() # Facts of each host self.hostgroups = dict() # host groups # Read settings and parse CLI arguments @@ -55,6 +54,7 @@ class ForemanInventory(object): else: self.load_inventory_from_cache() self.load_params_from_cache() + self.load_facts_from_cache() self.load_cache_from_cache() data_to_print = "" @@ -69,6 +69,9 @@ class ForemanInventory(object): 'foreman': self.cache[hostname], 'foreman_params': self.params[hostname], } + if self.want_facts: + self.inventory['_meta']['hostvars'][hostname]['foreman_facts'] = self.facts[hostname] + data_to_print += self.json_format_dict(self.inventory, True) print(data_to_print) @@ -81,7 +84,8 @@ class ForemanInventory(object): current_time = time() if (mod_time + self.cache_max_age) > current_time: if (os.path.isfile(self.cache_path_inventory) and - os.path.isfile(self.cache_path_params)): + os.path.isfile(self.cache_path_params) and + os.path.isfile(self.cache_path_facts)): return True return False @@ -114,6 +118,16 @@ class ForemanInventory(object): self.group_patterns = eval(group_patterns) + try: + self.group_prefix = config.get('ansible', 'group_prefix') + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + self.group_prefix = "foreman_" + + try: + self.want_facts = config.getboolean('ansible', 'want_facts') + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + self.want_facts = True + # Cache related try: cache_path = os.path.expanduser(config.get('cache', 'path')) @@ -123,6 +137,7 @@ class ForemanInventory(object): self.cache_path_cache = cache_path + "/%s.cache" % script self.cache_path_inventory = cache_path + "/%s.index" % script self.cache_path_params = cache_path + "/%s.params" % script + self.cache_path_facts = cache_path + "/%s.facts" % script self.cache_max_age = config.getint('cache', 'max_age') def parse_cli_args(self): @@ -135,7 +150,7 @@ class ForemanInventory(object): help='Force refresh of cache by making API requests to foreman (default: False - use cache files)') self.args = parser.parse_args() - def _get_json(self, url): + def _get_json(self, url, ignore_errors=None): page = 1 results = [] while True: @@ -143,10 +158,14 @@ class ForemanInventory(object): auth=HTTPBasicAuth(self.foreman_user, self.foreman_pw), verify=self.foreman_ssl_verify, params={'page': page, 'per_page': 250}) + if ignore_errors and ret.status_code in ignore_errors: + break ret.raise_for_status() json = ret.json() if not json.has_key('results'): return json + if type(json['results']) == type({}): + return json['results'] results = results + json['results'] if len(results) >= json['total']: break @@ -162,38 +181,44 @@ class ForemanInventory(object): self.hostgroups[hid] = self._get_json(url) return self.hostgroups[hid] - def _get_params_by_id(self, hid): - url = "%s/api/v2/hosts/%s/parameters" % (self.foreman_url, hid) + def _get_all_params_by_id(self, hid): + url = "%s/api/v2/hosts/%s" % (self.foreman_url, hid) + ret = self._get_json(url, [404]) + if ret == []: ret = {} + return ret.get('all_parameters', {}) + + def _get_facts_by_id(self, hid): + url = "%s/api/v2/hosts/%s/facts" % (self.foreman_url, hid) return self._get_json(url) def _resolve_params(self, host): """ - Resolve all host group params of the host using the top level - hostgroup and the ancestry. + Fetch host params and convert to dict """ - hostgroup_id = host['hostgroup_id'] - paramgroups = [] params = {} - if hostgroup_id: - hostgroup = self._get_hostgroup_by_id(hostgroup_id) - ancestry_path = hostgroup.get('ancestry', '') - ancestry = ancestry_path.split('/') if ancestry_path is not None else [] - - # Append top level hostgroup last to overwrite lower levels - # values - ancestry.append(hostgroup_id) - paramgroups = [self._get_hostgroup_by_id(hostgroup_id)['parameters'] - for hostgroup_id in ancestry] - - paramgroups += [self._get_params_by_id(host['id'])] - for paramgroup in paramgroups: - for param in paramgroup: - name = param['name'] - params[name] = param['value'] + for param in self._get_all_params_by_id(host['id']): + name = param['name'] + params[name] = param['value'] return params + def _get_facts(self, host): + """ + Fetch all host facts of the host + """ + if not self.want_facts: + return {} + + ret = self._get_facts_by_id(host['id']) + if len(ret.values()) == 0: + facts = {} + elif len(ret.values()) == 1: + facts = ret.values()[0] + else: + raise ValueError("More than one set of facts returned for '%s'" % host) + return facts + def update_cache(self): """Make calls to foreman and save the output in a cache""" @@ -203,11 +228,17 @@ class ForemanInventory(object): for host in self._get_hosts(): dns_name = host['name'] - # Create ansible groups for hostgroup, location and organization - for group in ['hostgroup', 'location', 'organization']: + # Create ansible groups for hostgroup, environment, location and organization + for group in ['hostgroup', 'environment', 'location', 'organization']: val = host.get('%s_name' % group) if val: - safe_key = self.to_safe('satellite_%s_%s' % (group, val.lower())) + safe_key = self.to_safe('%s%s_%s' % (self.group_prefix, group, val.lower())) + self.push(self.inventory, safe_key, dns_name) + + for group in ['lifecycle_environment', 'content_view']: + val = host.get('content_facet_attributes', {}).get('%s_name' % group) + if val: + safe_key = self.to_safe('%s%s_%s' % (self.group_prefix, group, val.lower())) self.push(self.inventory, safe_key, dns_name) params = self._resolve_params(host) @@ -231,11 +262,13 @@ class ForemanInventory(object): self.cache[dns_name] = host self.params[dns_name] = params + self.facts[dns_name] = self._get_facts(host) self.push(self.inventory, 'all', dns_name) self.write_to_cache(self.cache, self.cache_path_cache) self.write_to_cache(self.inventory, self.cache_path_inventory) self.write_to_cache(self.params, self.cache_path_params) + self.write_to_cache(self.facts, self.cache_path_facts) def get_host_info(self): """ Get variables about a specific host """ @@ -274,6 +307,14 @@ class ForemanInventory(object): json_params = cache.read() self.params = json.loads(json_params) + def load_facts_from_cache(self): + """ Reads the index from the cache file sets self.index """ + if not self.want_facts: + return + cache = open(self.cache_path_facts, 'r') + json_facts = cache.read() + self.facts = json.loads(json_facts) + def load_cache_from_cache(self): """ Reads the cache from the cache file sets self.cache """ @@ -301,4 +342,7 @@ class ForemanInventory(object): else: return json.dumps(data) -ForemanInventory() +if __name__ == '__main__': + ForemanInventory() + + diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index aa8f69866b..2998d15bb7 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -682,6 +682,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/sso/__init__.py b/awx/sso/__init__.py index e484e62be1..347aedfeee 100644 --- a/awx/sso/__init__.py +++ b/awx/sso/__init__.py @@ -1,2 +1,21 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. + +# Python +import threading + +# Monkeypatch xmlsec.initialize() to only run once (https://github.com/ansible/ansible-tower/issues/3241). +xmlsec_init_lock = threading.Lock() +xmlsec_initialized = False + +import dm.xmlsec.binding +original_xmlsec_initialize = dm.xmlsec.binding.initialize + +def xmlsec_initialize(*args, **kwargs): + global xmlsec_init_lock, xmlsec_initialized, original_xmlsec_initialize + with xmlsec_init_lock: + if not xmlsec_initialized: + original_xmlsec_initialize(*args, **kwargs) + xmlsec_initialized = True + +dm.xmlsec.binding.initialize = xmlsec_initialize diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index f5b6f42c9a..f201b7352a 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 b77d3738a2..9fa16dc0fb 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 6282ab340e..cd486376f9 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/lib/angular-codemirror/.bower.json b/awx/ui/client/lib/angular-codemirror/.bower.json index 194a61b513..c88cb405db 100644 --- a/awx/ui/client/lib/angular-codemirror/.bower.json +++ b/awx/ui/client/lib/angular-codemirror/.bower.json @@ -1,6 +1,6 @@ { "name": "angular-codemirror", - "version": "1.0.2", + "version": "1.0.3", "dependencies": { "angular": "latest", "angular-route": "latest", @@ -13,14 +13,13 @@ "codemirror": "latest" }, "homepage": "https://github.com/chouseknecht/angular-codemirror", - "_release": "1.0.2", + "_release": "1.0.3", "_resolution": { "type": "version", - "tag": "v1.0.2", - "commit": "94b7aac548b036f4fbd94e56129ed9574e472616" + "tag": "1.0.3", + "commit": "b94dc86fde8f60a50b324054806d29d742177d21" }, - "_source": "git://github.com/chouseknecht/angular-codemirror.git", - "_target": "~1.0.2", - "_originalSource": "angular-codemirror", - "_direct": true + "_source": "https://github.com/chouseknecht/angular-codemirror.git", + "_target": "~1.0.3", + "_originalSource": "angular-codemirror" } \ No newline at end of file diff --git a/awx/ui/client/lib/angular-codemirror/bower.json b/awx/ui/client/lib/angular-codemirror/bower.json index 943b83b3e7..df7644686c 100644 --- a/awx/ui/client/lib/angular-codemirror/bower.json +++ b/awx/ui/client/lib/angular-codemirror/bower.json @@ -1,6 +1,6 @@ { "name": "angular-codemirror", - "version": "0.0.3", + "version": "1.0.2", "dependencies": { "angular": "latest", "angular-route": "latest", diff --git a/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.css b/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.css index a81ff6dff4..f233cc2941 100644 --- a/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.css +++ b/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.css @@ -1,6 +1,6 @@ /********************************************** * AngularCodeMirror.css - * + * * CodeMirror.css overrides * * Copyright (c) 2014 Chris Houseknecht @@ -30,14 +30,14 @@ .CodeMirror { height: auto; } - + .CodeMirror-activeline-background { background-color: #f7f7f7; } + - -/* Modal dialog overrides to make jqueryui dialog blend in with Twitter. +/* Modal dialog overrides to make jqueryui dialog blend in with Twitter. Why? Twitter's modal is not draggable or resizable, which is not very useful for a code editor */ @@ -71,7 +71,7 @@ border-color: #ffffff; color: #A9A9A9; } - + .ui-dialog .ui-resizable-se { right: 5px; bottom: 5px; @@ -108,3 +108,4 @@ .CodeMirror-lint-tooltip { z-index: 2060; } + diff --git a/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.js b/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.js index e4b9e5e02e..2ef0ad4fe7 100644 --- a/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.js +++ b/awx/ui/client/lib/angular-codemirror/lib/AngularCodeMirror.js @@ -30,7 +30,7 @@ angular.module('AngularCodeMirrorModule', []) .factory('AngularCodeMirror', [ function() { - return function() { + return function(readOnly) { var fn = function() { this.myCodeMirror = null; @@ -43,7 +43,6 @@ angular.module('AngularCodeMirrorModule', []) model = params.model, mode = params.mode, onReady = params.onReady, - onChange = params.onChange, height = 0; self.element = $(element); @@ -69,6 +68,15 @@ angular.module('AngularCodeMirrorModule', []) // Initialize CodeMirror self.modes[mode].value = scope[model]; + + // if readOnly is passed to AngularCodeMirror, set the + // options for all modes to be readOnly + if (readOnly) { + Object.keys(self.modes).forEach(function(val) { + self.modes[val].readOnly = true; + }); + } + self.myCodeMirror = CodeMirror(document.getElementById('cm-' + model + '-container'), self.modes[mode]); // Adjust the height @@ -85,14 +93,7 @@ angular.module('AngularCodeMirrorModule', []) // Update the model on change self.myCodeMirror.on('change', function() { - setTimeout(function() { - scope.$apply(function(){ - scope[model] = self.myCodeMirror.getValue(); - if (onChange) { - onChange(); - } - }); - }, 500); + setTimeout(function() { scope.$apply(function(){ scope[model] = self.myCodeMirror.getValue(); }); }, 500); }); }; diff --git a/awx/ui/client/lib/angular-scheduler/lib/angular-scheduler.js b/awx/ui/client/lib/angular-scheduler/lib/angular-scheduler.js index dfe3b7f8f8..6ef130f553 100644 --- a/awx/ui/client/lib/angular-scheduler/lib/angular-scheduler.js +++ b/awx/ui/client/lib/angular-scheduler/lib/angular-scheduler.js @@ -1,5 +1,5 @@ /*************************************************************************** - * angular-scheruler.js + * angular-scheduler.js * * Copyright (c) 2014 Ansible, Inc. * @@ -13,16 +13,32 @@ /* global RRule */ -'use strict'; - - -angular.module('underscore',[]) - .factory('_', [ function() { - return window._; - }]); - - -angular.module('AngularScheduler', ['underscore']) +(function(root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['lodash', 'angular', 'jquery', 'jquery-ui', 'moment'], factory); + } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require('lodash'), require('angular'), require('jquery'), require('jquery-ui'), require('moment')); + } else { + // Browser globals (root is window) + root.returnExports = factory(root._, root.angular, root.$, root.$.ui, root.moment); + } +}(this, function(_, angular, $, ui, moment) { + return angular.module('AngularScheduler', []) + .filter('schedulerDate', ['moment', function(moment) { + return function(input) { + var date; + if(input === null){ + return ""; + }else { + date = moment(input.split(" ")[0]); + return date.format('l') + input.slice(input.indexOf(" ")); + } + }; + }]) .constant('AngularScheduler.partials', '/lib/') .constant('AngularScheduler.useTimezone', false) @@ -30,189 +46,181 @@ angular.module('AngularScheduler', ['underscore']) // Initialize supporting scope variables and functions. Returns a scheduler object with getString(), // setString() and inject() methods. - .factory('SchedulerInit', ['$log', '$filter', '$timezones', 'LoadLookupValues', 'SetDefaults', 'CreateObject', '_', - 'AngularScheduler.useTimezone', 'AngularScheduler.showUTCField', 'InRange', - function($log, $filter, $timezones, LoadLookupValues, SetDefaults, CreateObject, _, useTimezone, showUTCField, InRange) { - return function(params) { + .factory('SchedulerInit', ['$log', '$filter', '$timezones', 'LoadLookupValues', 'SetDefaults', 'CreateObject', + 'AngularScheduler.useTimezone', 'AngularScheduler.showUTCField', 'InRange', + function($log, $filter, $timezones, LoadLookupValues, SetDefaults, CreateObject, useTimezone, showUTCField, InRange) { + return function(params) { - var scope = params.scope, - requireFutureStartTime = params.requireFutureStartTime || false; + var scope = params.scope, + requireFutureStartTime = params.requireFutureStartTime || false; - scope.schedulerShowTimeZone = useTimezone; - scope.schedulerShowUTCStartTime = showUTCField; + scope.schedulerShowTimeZone = useTimezone; + scope.schedulerShowUTCStartTime = showUTCField; - scope.setDefaults = function() { - if (useTimezone) { - scope.current_timezone = $timezones.getLocal(); - if ($.isEmptyObject(scope.current_timezone) || !scope.current_timezone.name) { - $log.error('Failed to find local timezone. Defaulting to America/New_York.'); - scope.current_timezone = { name: 'America/New_York' }; - } - // Set the to the browser's local timezone + scope.schedulerTimeZone = _.find(scope.timeZones, function(x) { + return x.name === scope.current_timezone.name; + }); + } + LoadLookupValues(scope); + SetDefaults(scope); + scope.scheduleTimeChange(); + scope.scheduleRepeatChange(); + }; + + scope.scheduleTimeChange = function(callback) { + if (scope.schedulerStartDt === "" || scope.schedulerStartDt === null || scope.schedulerStartDt === undefined) { + scope.startDateError("Provide a valid start date and time"); + scope.schedulerUTCTime = ''; + } else if (!(InRange(scope.schedulerStartHour, 0, 23, 2) && InRange(scope.schedulerStartMinute, 0, 59, 2) && InRange(scope.schedulerStartSecond, 0, 59, 2))) { + scope.scheduler_startTime_error = true; + } else { + if (useTimezone) { + scope.resetStartDate(); + try { + var dateStr = scope.schedulerStartDt.replace(/(\d{2})\/(\d{2})\/(\d{4})/, function(match, p1, p2, p3) { return p3 + '-' + p1 + '-' + p2; }); - dateStr += 'T' + $filter('schZeroPad')(scope.schedulerStartHour, 2) + ':' + $filter('schZeroPad')(scope.schedulerStartMinute, 2) + ':' + - $filter('schZeroPad')(scope.schedulerStartSecond, 2) + '.000Z'; - scope.schedulerUTCTime = $filter('schDateStrFix')($timezones.toUTC(dateStr, scope.schedulerTimeZone.name).toISOString()); - scope.scheduler_form_schedulerStartDt_error = false; + dateStr += 'T' + $filter('schZeroPad')(scope.schedulerStartHour, 2) + ':' + $filter('schZeroPad')(scope.schedulerStartMinute, 2) + ':' + + $filter('schZeroPad')(scope.schedulerStartSecond, 2) + '.000Z'; + scope.schedulerUTCTime = $filter('schDateStrFix')($timezones.toUTC(dateStr, scope.schedulerTimeZone.name).toISOString()); + scope.scheduler_form_schedulerStartDt_error = false; + scope.scheduler_startTime_error = false; + } catch (e) { + scope.startDateError("Provide a valid start date and time"); + } + } else { scope.scheduler_startTime_error = false; - } - catch(e) { - scope.startDateError("Provide a valid start date and time"); + scope.scheduler_form_schedulerStartDt_error = false; + scope.schedulerUTCTime = $filter('schDateStrFix')(scope.schedulerStartDt + 'T' + scope.schedulerStartHour + ':' + scope.schedulerStartMinute + + ':' + scope.schedulerStartSecond + '.000Z'); } } - else { - scope.scheduler_startTime_error = false; - scope.scheduler_form_schedulerStartDt_error = false; - scope.schedulerUTCTime = $filter('schDateStrFix')(scope.schedulerStartDt + 'T' + scope.schedulerStartHour + ':' + scope.schedulerStartMinute + - ':' + scope.schedulerStartSecond + '.000Z'); + if (callback) { + callback(); } - } - if (callback){ - callback(); - } - }; + }; - // change the utc time with the new start date - scope.$watch('schedulerStartDt', function() { - scope.scheduleTimeChange(scope.processSchedulerEndDt); - }); + // change the utc time with the new start date + scope.$watch('schedulerStartDt', function() { + scope.scheduleTimeChange(scope.processSchedulerEndDt); + }); - scope.resetError = function(variable) { - scope[variable] = false; - }; + scope.resetError = function(variable) { + scope[variable] = false; + }; - scope.scheduleRepeatChange = function() { - // reset the week buttons and scope values to be empty - // when the schedule repeat is changed to week - if (scope.schedulerFrequency.name === "Week") { - scope.weekDays = []; - delete scope.weekDaySUClass; - delete scope.weekDayMOClass; - delete scope.weekDayTUClass; - delete scope.weekDayWEClass; - delete scope.weekDayTHClass; - delete scope.weekDayFRClass; - delete scope.weekDaySAClass; - } - if (scope.schedulerFrequency && scope.schedulerFrequency.value !== '' && scope.schedulerFrequency.value !== 'none') { - scope.schedulerInterval = 1; - scope.schedulerShowInterval = true; - scope.schedulerIntervalLabel = scope.schedulerFrequency.intervalLabel; - } - else { - scope.schedulerShowInterval = false; - scope.schedulerEnd = scope.endOptions[0]; - } - scope.sheduler_frequency_error = false; - scope.$emit("updateSchedulerSelects"); - }; - - scope.showCalendar = function(fld) { - $('#' + fld).focus(); - }; - - scope.monthlyRepeatChange = function() { - if (scope.monthlyRepeatOption !== 'day') { - $('#monthDay').spinner('disable'); - } - else { - $('#monthDay').spinner('enable'); - } - }; - - scope.yearlyRepeatChange = function() { - if (scope.yearlyRepeatOption !== 'month') { - $('#yearlyRepeatDay').spinner('disable'); - } - else { - $('#yearlyRepeatDay').spinner('enable'); - } - }; - - scope.setWeekday = function(event, day) { - // Add or remove day when user clicks checkbox button - var i = scope.weekDays.indexOf(day); - if (i >= 0) { - scope.weekDays.splice(i,1); - } - else { - scope.weekDays.push(day); - } - $(event.target).blur(); - scope.scheduler_weekDays_error = false; - }; - - scope.startDateError = function(msg) { - if (scope.scheduler_form) { - if (scope.scheduler_form.schedulerStartDt) { - scope.scheduler_form_schedulerStartDt_error = msg; - scope.scheduler_form.schedulerStartDt.$pristine = false; - scope.scheduler_form.schedulerStartDt.$dirty = true; + scope.scheduleRepeatChange = function() { + // reset the week buttons and scope values to be empty + // when the schedule repeat is changed to week + if (scope.schedulerFrequency.name === "Week") { + scope.weekDays = []; + delete scope.weekDaySUClass; + delete scope.weekDayMOClass; + delete scope.weekDayTUClass; + delete scope.weekDayWEClass; + delete scope.weekDayTHClass; + delete scope.weekDayFRClass; + delete scope.weekDaySAClass; } - $('#schedulerStartDt').removeClass('ng-pristine').removeClass('ng-valid').removeClass('ng-valid-custom-error') - .addClass('ng-dirty').addClass('ng-invalid').addClass('ng-invalid-custom-error'); - } - }; - - scope.resetStartDate = function() { - if (scope.scheduler_form) { - scope.scheduler_form_schedulerStartDt_error = ''; - if (scope.scheduler_form.schedulerStartDt) { - scope.scheduler_form.schedulerStartDt.$setValidity('custom-error', true); - scope.scheduler_form.schedulerStartDt.$setPristine(); + if (scope.schedulerFrequency && scope.schedulerFrequency.value !== '' && scope.schedulerFrequency.value !== 'none') { + scope.schedulerInterval = 1; + scope.schedulerShowInterval = true; + scope.schedulerIntervalLabel = scope.schedulerFrequency.intervalLabel; + } else { + scope.schedulerShowInterval = false; + scope.schedulerEnd = scope.endOptions[0]; } + scope.sheduler_frequency_error = false; + scope.$emit("updateSchedulerSelects"); + }; + + scope.showCalendar = function(fld) { + $('#' + fld).focus(); + }; + + scope.monthlyRepeatChange = function() { + if (scope.monthlyRepeatOption !== 'day') { + $('#monthDay').spinner('disable'); + } else { + $('#monthDay').spinner('enable'); + } + }; + + scope.yearlyRepeatChange = function() { + if (scope.yearlyRepeatOption !== 'month') { + $('#yearlyRepeatDay').spinner('disable'); + } else { + $('#yearlyRepeatDay').spinner('enable'); + } + }; + + scope.setWeekday = function(event, day) { + // Add or remove day when user clicks checkbox button + var i = scope.weekDays.indexOf(day); + if (i >= 0) { + scope.weekDays.splice(i, 1); + } else { + scope.weekDays.push(day); + } + $(event.target).blur(); + scope.scheduler_weekDays_error = false; + }; + + scope.startDateError = function(msg) { + if (scope.scheduler_form) { + if (scope.scheduler_form.schedulerStartDt) { + scope.scheduler_form_schedulerStartDt_error = msg; + scope.scheduler_form.schedulerStartDt.$pristine = false; + scope.scheduler_form.schedulerStartDt.$dirty = true; + } + $('#schedulerStartDt').removeClass('ng-pristine').removeClass('ng-valid').removeClass('ng-valid-custom-error') + .addClass('ng-dirty').addClass('ng-invalid').addClass('ng-invalid-custom-error'); + } + }; + + scope.resetStartDate = function() { + if (scope.scheduler_form) { + scope.scheduler_form_schedulerStartDt_error = ''; + if (scope.scheduler_form.schedulerStartDt) { + scope.scheduler_form.schedulerStartDt.$setValidity('custom-error', true); + scope.scheduler_form.schedulerStartDt.$setPristine(); + } + } + }; + + scope.schedulerEndChange = function(key, value) { + scope[key] = $filter('schZeroPad')(parseInt(value), 2); + }; + + // When timezones become available, use to set defaults + if (scope.removeZonesReady) { + scope.removeZonesReady(); } + scope.removeZonesReady = scope.$on('zonesReady', function() { + scope.timeZones = JSON.parse(localStorage.zones); + scope.setDefaults(); + }); + + if (useTimezone) { + // Build list of timezone element options - $timezones.getZoneList(scope); - } - else { - scope.setDefaults(); - } - - return CreateObject(scope, requireFutureStartTime); - - }; - }]) + } + ]) /** Return an AngularScheduler object we can use to get the RRule result from user input, check if @@ -220,280 +228,272 @@ angular.module('AngularScheduler', ['underscore']) scheduler widget */ .factory('CreateObject', ['AngularScheduler.useTimezone', '$filter', 'GetRule', 'Inject', 'InjectDetail', 'SetDefaults', '$timezones', 'SetRule', 'InRange', - function(useTimezone, $filter, GetRule, Inject, InjectDetail, SetDefaults, $timezones, SetRule, InRange) { - return function(scope, requireFutureST) { - var fn = function() { + function(useTimezone, $filter, GetRule, Inject, InjectDetail, SetDefaults, $timezones, SetRule, InRange) { + return function(scope, requireFutureST) { + var fn = function() { - this.scope = scope; - this.useTimezone = useTimezone; - this.requireFutureStartTime = requireFutureST; + this.scope = scope; + this.useTimezone = useTimezone; + this.requireFutureStartTime = requireFutureST; - // Evaluate user intput and build options for passing to rrule - this.getOptions = function() { - var options = {}; - options.startDate = this.scope.schedulerUTCTime; - options.frequency = this.scope.schedulerFrequency.value; - options.interval = parseInt(this.scope.schedulerInterval); - if (this.scope.schedulerEnd.value === 'after') { - options.occurrenceCount = this.scope.schedulerOccurrenceCount; - } - if (this.scope.schedulerEnd.value === 'on') { - options.endDate = scope.schedulerEndDt.replace(/(\d{2})\/(\d{2})\/(\d{4})/, function(match, p1, p2, p3) { + // Evaluate user intput and build options for passing to rrule + this.getOptions = function() { + var options = {}; + options.startDate = this.scope.schedulerUTCTime; + options.frequency = this.scope.schedulerFrequency.value; + options.interval = parseInt(this.scope.schedulerInterval); + if (this.scope.schedulerEnd.value === 'after') { + options.occurrenceCount = this.scope.schedulerOccurrenceCount; + } + if (this.scope.schedulerEnd.value === 'on') { + options.endDate = scope.schedulerEndDt.replace(/(\d{2})\/(\d{2})\/(\d{4})/, function(match, p1, p2, p3) { return p3 + '-' + p1 + '-' + p2; }) + 'T' + - $filter('schZeroPad')(this.scope.schedulerEndHour,2) + ':' + - $filter('schZeroPad')(this.scope.schedulerEndMinute,2) + ':' + - $filter('schZeroPad')(this.scope.schedulerEndSecond,2)+ 'Z'; - } - if (this.scope.schedulerFrequency.value === 'weekly') { - options.weekDays = this.scope.weekDays; - } - else if (this.scope.schedulerFrequency.value === 'yearly') { - if (this.scope.yearlyRepeatOption === 'month') { - options.month = this.scope.yearlyMonth.value; - options.monthDay = this.scope.yearlyMonthDay; + $filter('schZeroPad')(this.scope.schedulerEndHour, 2) + ':' + + $filter('schZeroPad')(this.scope.schedulerEndMinute, 2) + ':' + + $filter('schZeroPad')(this.scope.schedulerEndSecond, 2) + 'Z'; } - else { - options.setOccurrence = this.scope.yearlyOccurrence.value; - options.weekDays = this.scope.yearlyWeekDay.value; - options.month = this.scope.yearlyOtherMonth.value; - } - } - else if (this.scope.schedulerFrequency.value === 'monthly') { - if (this.scope.monthlyRepeatOption === 'day') { - options.monthDay = this.scope.monthDay; - } - else { - options.setOccurrence = this.scope.monthlyOccurrence.value; - options.weekDays = this.scope.monthlyWeekDay.value; - } - } - return options; - }; - - // Clear custom field errors - this.clearErrors = function() { - this.scope.scheduler_weekDays_error = false; - this.scope.scheduler_endDt_error = false; - this.scope.resetStartDate(); - this.scope.scheduler_endDt_error = false; - this.scope.scheduler_interval_error = false; - this.scope.scheduler_occurrenceCount_error = false; - this.scope.scheduler_monthDay_error = false; - this.scope.scheduler_yearlyMonthDay_error = false; - - if (this.scope.scheduler_form && this.scope.scheduler_form.schedulerEndDt) { - this.scope.scheduler_form.schedulerEndDt.$setValidity('custom-error', true); - this.scope.scheduler_form.schedulerEndDt.$setPristine(); - this.scope.scheduler_form.$setPristine(); - } - }; - - // Set values for detail page - this.setDetails = function() { - var rrule = this.getRRule(), - scope = this.scope; - if (rrule) { - scope.rrule_nlp_description = rrule.toText(); - scope.dateChoice = 'local'; - scope.occurrence_list = []; - rrule.all(function(date, i){ - var local, dt; - if (i < 10) { - if (useTimezone) { - dt = $timezones.align(date, scope.schedulerTimeZone.name); - local = $filter('schZeroPad')(dt.getMonth() + 1,2) + '/' + - $filter('schZeroPad')(dt.getDate(),2) + '/' + dt.getFullYear() + ' ' + - $filter('schZeroPad')(dt.getHours(),2) + ':' + - $filter('schZeroPad')(dt.getMinutes(),2) + ':' + - $filter('schZeroPad')(dt.getSeconds(),2) + ' ' + - dt.getTimezoneAbbreviation(); - } - else { - local = $filter('date')(date, 'MM/dd/yyyy HH:mm:ss Z'); - } - scope.occurrence_list.push({ utc: $filter('schDateStrFix')(date.toISOString()), local: local }); - return true; + if (this.scope.schedulerFrequency.value === 'weekly') { + options.weekDays = this.scope.weekDays; + } else if (this.scope.schedulerFrequency.value === 'yearly') { + if (this.scope.yearlyRepeatOption === 'month') { + options.month = this.scope.yearlyMonth.value; + options.monthDay = this.scope.yearlyMonthDay; + } else { + options.setOccurrence = this.scope.yearlyOccurrence.value; + options.weekDays = this.scope.yearlyWeekDay.value; + options.month = this.scope.yearlyOtherMonth.value; } - return false; - }); - scope.rrule_nlp_description = rrule.toText().replace(/^RRule error.*$/,'Natural language description not available'); - scope.rrule = rrule.toString(); - } - }; + } else if (this.scope.schedulerFrequency.value === 'monthly') { + if (this.scope.monthlyRepeatOption === 'day') { + options.monthDay = this.scope.monthDay; + } else { + options.setOccurrence = this.scope.monthlyOccurrence.value; + options.weekDays = this.scope.monthlyWeekDay.value; + } + } + return options; + }; - // Check the input form for errors - this.isValid = function() { - var startDt, now, dateStr, adjNow, timeNow, timeFuture, validity = true; - this.clearErrors(); + // Clear custom field errors + this.clearErrors = function() { + this.scope.scheduler_weekDays_error = false; + this.scope.scheduler_endDt_error = false; + this.scope.resetStartDate(); + this.scope.scheduler_endDt_error = false; + this.scope.scheduler_interval_error = false; + this.scope.scheduler_occurrenceCount_error = false; + this.scope.scheduler_monthDay_error = false; + this.scope.scheduler_yearlyMonthDay_error = false; - if (this.scope.schedulerFrequency.value !== 'none' && !InRange(this.scope.schedulerInterval, 1, 999, 3)) { - this.scope.scheduler_interval_error = true; - validity = false; - } + if (this.scope.scheduler_form && this.scope.scheduler_form.schedulerEndDt) { + this.scope.scheduler_form.schedulerEndDt.$setValidity('custom-error', true); + this.scope.scheduler_form.schedulerEndDt.$setPristine(); + this.scope.scheduler_form.$setPristine(); + } + }; - if (this.scope.schedulerEnd.value === 'after' && !InRange(this.scope.schedulerOccurrenceCount, 1, 999, 3)) { - this.scope.scheduler_occurrenceCount_error = true; - validity = false; - } + // Set values for detail page + this.setDetails = function() { + var rrule = this.getRRule(), + scope = this.scope; + if (rrule) { + scope.rrule_nlp_description = rrule.toText(); + scope.dateChoice = 'local'; + scope.occurrence_list = []; + rrule.all(function(date, i) { + var local, dt; + if (i < 10) { + if (useTimezone) { + dt = $timezones.align(date, scope.schedulerTimeZone.name); + local = $filter('schZeroPad')(dt.getMonth() + 1, 2) + '/' + + $filter('schZeroPad')(dt.getDate(), 2) + '/' + dt.getFullYear() + ' ' + + $filter('schZeroPad')(dt.getHours(), 2) + ':' + + $filter('schZeroPad')(dt.getMinutes(), 2) + ':' + + $filter('schZeroPad')(dt.getSeconds(), 2) + ' ' + + dt.getTimezoneAbbreviation(); + } else { + local = $filter('date')(date, 'MM/dd/yyyy HH:mm:ss Z'); + } + scope.occurrence_list.push({ utc: $filter('schedulerDate')($filter('schDateStrFix')(date.toISOString())), local: $filter('schedulerDate')(local) }); + return true; + } + return false; + }); + scope.rrule_nlp_description = rrule.toText().replace(/^RRule error.*$/, 'Natural language description not available'); + scope.rrule = rrule.toString(); + } + }; - if (this.scope.schedulerFrequency.value === 'weekly' && this.scope.weekDays.length === 0) { - this.scope.scheduler_weekDays_error = true; - validity = false; - } + // Check the input form for errors + this.isValid = function() { + var startDt, now, dateStr, adjNow, timeNow, timeFuture, validity = true; + this.clearErrors(); - if (this.scope.schedulerFrequency.value === 'monthly' && this.scope.monthlyRepeatOption === 'day' && !InRange(this.scope.monthDay, 1, 31, 99)) { - this.scope.scheduler_monthDay_error = true; - validity = false; - } - - if (this.scope.schedulerFrequency.value === 'yearly' && this.scope.yearlyRepeatOption === 'month' && !InRange(this.scope.yearlyMonthDay, 1, 31, 99)) { - this.scope.scheduler_yearlyMonthDay_error = true; - validity = false; - } - if ( !(InRange(scope.schedulerStartHour, 0, 23, 2) && InRange(scope.schedulerStartMinute, 0, 59, 2) && InRange(scope.schedulerStartSecond, 0, 59, 2)) ) { - this.scope.scheduler_startTime_error = true; - validity = false; - } - if (!this.scope.scheduler_form.schedulerName.$valid) { - // Make sure schedulerName requird error shows up - this.scope.scheduler_form.schedulerName.$dirty = true; - $('#schedulerName').addClass('ng-dirty'); - validity = false; - } - if (this.scope.schedulerEnd.value === 'on') { - if (!/^\d{2}\/\d{2}\/\d{4}$/.test(this.scope.schedulerEndDt)) { - this.scope.scheduler_form.schedulerEndDt.$pristine = false; - this.scope.scheduler_form.schedulerEndDt.$dirty = true; - $('#schedulerEndDt').removeClass('ng-pristine').removeClass('ng-valid').removeClass('ng-valid-custom-error') - .addClass('ng-dirty').addClass('ng-invalid').addClass('ng-invalid-custom-error'); - this.scope.scheduler_endDt_error = true; + if (this.scope.schedulerFrequency.value !== 'none' && !InRange(this.scope.schedulerInterval, 1, 999, 3)) { + this.scope.scheduler_interval_error = true; validity = false; } - } - if (this.scope.schedulerUTCTime) { - try { - startDt = new Date(this.scope.schedulerUTCTime); - if (!isNaN(startDt)) { - timeFuture = startDt.getTime(); - now = new Date(); - if (this.useTimezone) { - dateStr = now.getFullYear() + '-' + - $filter('schZeroPad')(now.getMonth() + 1, 2)+ '-' + - $filter('schZeroPad')(now.getDate(),2) + 'T' + - $filter('schZeroPad')(now.getHours(),2) + ':' + - $filter('schZeroPad')(now.getMinutes(),2) + ':' + - $filter('schZeroPad')(now.getSeconds(),2) + '.000Z'; - adjNow = $timezones.toUTC(dateStr, this.scope.schedulerTimeZone.name); //Adjust to the selected TZ - timeNow = adjNow.getTime(); - } - else { - timeNow = now.getTime(); - } - if (this.requireFutureStartTime && timeNow >= timeFuture) { - this.scope.startDateError("Start time must be in the future"); - validity = false; - } - } - else { - this.scope.startDateError("Invalid start time"); + + if (this.scope.schedulerEnd.value === 'after' && !InRange(this.scope.schedulerOccurrenceCount, 1, 999, 3)) { + this.scope.scheduler_occurrenceCount_error = true; + validity = false; + } + + if (this.scope.schedulerFrequency.value === 'weekly' && this.scope.weekDays.length === 0) { + this.scope.scheduler_weekDays_error = true; + validity = false; + } + + if (this.scope.schedulerFrequency.value === 'monthly' && this.scope.monthlyRepeatOption === 'day' && !InRange(this.scope.monthDay, 1, 31, 99)) { + this.scope.scheduler_monthDay_error = true; + validity = false; + } + + if (this.scope.schedulerFrequency.value === 'yearly' && this.scope.yearlyRepeatOption === 'month' && !InRange(this.scope.yearlyMonthDay, 1, 31, 99)) { + this.scope.scheduler_yearlyMonthDay_error = true; + validity = false; + } + if (!(InRange(scope.schedulerStartHour, 0, 23, 2) && InRange(scope.schedulerStartMinute, 0, 59, 2) && InRange(scope.schedulerStartSecond, 0, 59, 2))) { + this.scope.scheduler_startTime_error = true; + validity = false; + } + if (!this.scope.scheduler_form.schedulerName.$valid) { + // Make sure schedulerName requird error shows up + this.scope.scheduler_form.schedulerName.$dirty = true; + $('#schedulerName').addClass('ng-dirty'); + validity = false; + } + if (this.scope.schedulerEnd.value === 'on') { + if (!/^\d{2}\/\d{2}\/\d{4}$/.test(this.scope.schedulerEndDt)) { + this.scope.scheduler_form.schedulerEndDt.$pristine = false; + this.scope.scheduler_form.schedulerEndDt.$dirty = true; + $('#schedulerEndDt').removeClass('ng-pristine').removeClass('ng-valid').removeClass('ng-valid-custom-error') + .addClass('ng-dirty').addClass('ng-invalid').addClass('ng-invalid-custom-error'); + this.scope.scheduler_endDt_error = true; validity = false; } } - catch(e) { - this.scope.startDateError("Invalid start time"); + if (this.scope.schedulerUTCTime) { + try { + startDt = new Date(this.scope.schedulerUTCTime); + if (!isNaN(startDt)) { + timeFuture = startDt.getTime(); + now = new Date(); + if (this.useTimezone) { + dateStr = now.getFullYear() + '-' + + $filter('schZeroPad')(now.getMonth() + 1, 2) + '-' + + $filter('schZeroPad')(now.getDate(), 2) + 'T' + + $filter('schZeroPad')(now.getHours(), 2) + ':' + + $filter('schZeroPad')(now.getMinutes(), 2) + ':' + + $filter('schZeroPad')(now.getSeconds(), 2) + '.000Z'; + adjNow = $timezones.toUTC(dateStr, this.scope.schedulerTimeZone.name); //Adjust to the selected TZ + timeNow = adjNow.getTime(); + } else { + timeNow = now.getTime(); + } + if (this.requireFutureStartTime && timeNow >= timeFuture) { + this.scope.startDateError("Start time must be in the future"); + validity = false; + } + } else { + this.scope.startDateError("Invalid start time"); + validity = false; + } + } catch (e) { + this.scope.startDateError("Invalid start time"); + validity = false; + } + } else { + this.scope.startDateError("Provide a start time"); validity = false; } - } - else { - this.scope.startDateError("Provide a start time"); - validity = false; - } - scope.schedulerIsValid = validity; - if (validity) { - this.setDetails(); - } + scope.schedulerIsValid = validity; + if (validity) { + this.setDetails(); + } - return validity; - }; + return validity; + }; - var that = this; + var that = this; - that.scope.$on("loadSchedulerDetailPane", function() { - that.isValid(); - }); + that.scope.$on("loadSchedulerDetailPane", function() { + that.isValid(); + }); - // Returns an rrule object - this.getRRule = function() { - var options = this.getOptions(); - return GetRule(options); - }; + // Returns an rrule object + this.getRRule = function() { + var options = this.getOptions(); + return GetRule(options); + }; - // Return object containing schedule name, string representation of rrule per iCalendar RFC, - // and options used to create rrule - this.getValue = function() { - var rule = this.getRRule(), - options = this.getOptions(); - return { - name: scope.schedulerName, - rrule: rule.toString(), - options: options + // Return object containing schedule name, string representation of rrule per iCalendar RFC, + // and options used to create rrule + this.getValue = function() { + var rule = this.getRRule(), + options = this.getOptions(); + return { + name: scope.schedulerName, + rrule: rule.toString(), + options: options + }; + }; + + this.setRRule = function(rule) { + this.clear(); + return SetRule(rule, this.scope); + }; + + this.setName = function(name) { + this.scope.schedulerName = name; + }; + + // Read in the HTML partial, compile and inject it into the DOM. + // Pass in the target element's id attribute value or an angular.element() + // object. + this.inject = function(element, showButtons) { + return Inject({ scope: this.scope, target: element, buttons: showButtons }); + }; + + this.injectDetail = function(element, showRRule) { + return InjectDetail({ scope: this.scope, target: element, showRRule: showRRule }); + }; + + // Clear the form, returning all elements to a default state + this.clear = function() { + this.clearErrors(); + if (this.scope.scheduler_form && this.scope.scheduler_form.schedulerName) { + this.scope.scheduler_form.schedulerName.$setPristine(); + } + this.scope.setDefaults(); + }; + + // Get the user's local timezone + this.getUserTimezone = function() { + return $timezones.getLocal(); + }; + + // futureStartTime setter/getter + this.setRequireFutureStartTime = function(opt) { + this.requireFutureStartTime = opt; + }; + + this.getRequireFutureStartTime = function() { + return this.requireFutureStartTime; + }; + + this.setShowRRule = function(opt) { + scope.showRRule = opt; }; }; - - this.setRRule = function(rule) { - this.clear(); - return SetRule(rule, this.scope); - }; - - this.setName = function(name) { - this.scope.schedulerName = name; - }; - - // Read in the HTML partial, compile and inject it into the DOM. - // Pass in the target element's id attribute value or an angular.element() - // object. - this.inject = function(element, showButtons) { - return Inject({ scope: this.scope, target: element, buttons: showButtons }); - }; - - this.injectDetail = function(element, showRRule) { - return InjectDetail({ scope: this.scope, target: element, showRRule: showRRule }); - }; - - // Clear the form, returning all elements to a default state - this.clear = function() { - this.clearErrors(); - if (this.scope.scheduler_form && this.scope.scheduler_form.schedulerName) { - this.scope.scheduler_form.schedulerName.$setPristine(); - } - this.scope.setDefaults(); - }; - - // Get the user's local timezone - this.getUserTimezone = function() { - return $timezones.getLocal(); - }; - - // futureStartTime setter/getter - this.setRequireFutureStartTime = function(opt) { - this.requireFutureStartTime = opt; - }; - - this.getRequireFutureStartTime = function() { - return this.requireFutureStartTime; - }; - - this.setShowRRule = function(opt) { - scope.showRRule = opt; - }; + return new fn(); }; - return new fn(); - }; - }]) + } + ]) - .factory('InRange', [ function() { + .factory('InRange', [function() { return function(x, min, max, length) { var rx = new RegExp("\\d{1," + length + "}"); if (!rx.test(x)) { @@ -526,11 +526,11 @@ angular.module('AngularScheduler', ['underscore']) }); $http({ method: 'GET', url: scheduler_partial + 'angular-scheduler.html' }) - .success( function(data) { + .success(function(data) { scope.$emit('htmlReady', data); }) - .error( function(data, status) { - throw('Error reading ' + scheduler_partial + 'angular-scheduler.html. ' + status); + .error(function(data, status) { + throw ('Error reading ' + scheduler_partial + 'angular-scheduler.html. ' + status); //$log.error('Error calling ' + scheduler_partial + '. ' + status); }); }; @@ -555,11 +555,11 @@ angular.module('AngularScheduler', ['underscore']) }); $http({ method: 'GET', url: scheduler_partial + 'angular-scheduler-detail.html' }) - .success( function(data) { + .success(function(data) { scope.$emit('htmlDetailReady', data); }) - .error( function(data, status) { - throw('Error reading ' + scheduler_partial + 'angular-scheduler-detail.html. ' + status); + .error(function(data, status) { + throw ('Error reading ' + scheduler_partial + 'angular-scheduler-detail.html. ' + status); //$log.error('Error calling ' + scheduler_partial + '. ' + status); }); }; @@ -570,26 +570,25 @@ angular.module('AngularScheduler', ['underscore']) // Convert user inputs to an rrule. Returns rrule object using https://github.com/jkbr/rrule // **list of 'valid values' found below in LoadLookupValues - var startDate = params.startDate, // date object or string in yyyy-MM-ddTHH:mm:ss.sssZ format - frequency = params.frequency, // string, optional, valid value from frequencyOptions - interval = params.interval, // integer, optional - occurrenceCount = params.occurrenceCount, //integer, optional - endDate = params.endDate, // date object or string in yyyy-MM-dd format, optional - // ignored if occurrenceCount provided - month = params.month, // integer, optional, valid value from months - monthDay = params.monthDay, // integer, optional, between 1 and 31 - weekDays = params.weekDays, // integer, optional, valid value from weekdays + var startDate = params.startDate, // date object or string in yyyy-MM-ddTHH:mm:ss.sssZ format + frequency = params.frequency, // string, optional, valid value from frequencyOptions + interval = params.interval, // integer, optional + occurrenceCount = params.occurrenceCount, //integer, optional + endDate = params.endDate, // date object or string in yyyy-MM-dd format, optional + // ignored if occurrenceCount provided + month = params.month, // integer, optional, valid value from months + monthDay = params.monthDay, // integer, optional, between 1 and 31 + weekDays = params.weekDays, // integer, optional, valid value from weekdays setOccurrence = params.setOccurrence, // integer, optional, valid value from occurrences - options = {}, i; + options = {}, + i; if (angular.isDate(startDate)) { options.dtstart = startDate; - } - else { + } else { try { options.dtstart = new Date(startDate); - } - catch(e) { + } catch (e) { $log.error('Date conversion failed. Attempted to convert ' + startDate + ' to Date. ' + e.message); } } @@ -604,7 +603,7 @@ angular.module('AngularScheduler', ['underscore']) if (weekDays && angular.isArray(weekDays)) { options.byweekday = []; - for (i=0; i < weekDays.length; i++) { + for (i = 0; i < weekDays.length; i++) { options.byweekday.push(RRule[weekDays[i].toUpperCase()]); } } @@ -623,22 +622,18 @@ angular.module('AngularScheduler', ['underscore']) if (occurrenceCount) { options.count = occurrenceCount; - } - else if (endDate) { + } else if (endDate) { if (angular.isDate(endDate)) { options.until = endDate; - } - else { + } else { try { options.until = new Date(endDate); - } - catch(e) { + } catch (e) { $log.error('Date conversion failed. Attempted to convert ' + endDate + ' to Date. ' + e.message); } } } - } - else { + } else { // We only want to run 1x options.freq = RRule.DAILY; options.interval = 1; @@ -648,250 +643,240 @@ angular.module('AngularScheduler', ['underscore']) }; }]) - .factory('SetRule', ['AngularScheduler.useTimezone', '_', '$log', '$timezones', '$filter', - function(useTimezone, _, $log, $timezones, $filter) { - return function(rule, scope) { - var set, result = '', i, - setStartDate = false; + .factory('SetRule', ['AngularScheduler.useTimezone', '$log', '$timezones', '$filter', + function(useTimezone, $log, $timezones, $filter) { + return function(rule, scope) { + var set, result = '', + i, + setStartDate = false; - // Search the set of RRule keys for a particular key, returning its value - function getValue(set, key) { - var pair = _.find(set, function(x) { - var k = x.split(/=/)[0].toUpperCase(); - return (k === key); - }); - if (pair) { - return pair.split(/=/)[1].toUpperCase(); - } - return null; - } - - function toWeekDays(days) { - var darray = days.toLowerCase().split(/,/), - match = _.find(scope.weekdays, function(x) { - var warray = (angular.isArray(x.value)) ? x.value : [x.value], - diffA = _.difference(warray, darray), - diffB = _.difference(darray, warray); - return (diffA.length === 0 && diffB.length === 0); + // Search the set of RRule keys for a particular key, returning its value + function getValue(set, key) { + var pair = _.find(set, function(x) { + var k = x.split(/=/)[0].toUpperCase(); + return (k === key); }); - return match; - } - - function setValue(pair, set) { - var key = pair.split(/=/)[0].toUpperCase(), - value = pair.split(/=/)[1], - days, l, j, dt, month, day, timeString; - - if (key === 'NAME') { - //name is not actually part of RRule, but we can handle it just the same - scope.schedulerName = value; + if (pair) { + return pair.split(/=/)[1].toUpperCase(); + } + return null; } - if (key === 'FREQ') { - l = value.toLowerCase(); - scope.schedulerFrequency = _.find(scope.frequencyOptions, function(opt) { - scope.schedulerIntervalLabel = opt.intervalLabel; - return opt.value === l; - }); - if (!scope.schedulerFrequency || !scope.schedulerFrequency.name) { - result = 'FREQ not found in list of valid options'; - } + function toWeekDays(days) { + var darray = days.toLowerCase().split(/,/), + match = _.find(scope.weekdays, function(x) { + var warray = (angular.isArray(x.value)) ? x.value : [x.value], + diffA = _.difference(warray, darray), + diffB = _.difference(darray, warray); + return (diffA.length === 0 && diffB.length === 0); + }); + return match; } - if (key === 'INTERVAL') { - if (parseInt(value,10)) { - scope.schedulerInterval = parseInt(value,10); - scope.schedulerShowInterval = true; + + function setValue(pair, set) { + var key = pair.split(/=/)[0].toUpperCase(), + value = pair.split(/=/)[1], + days, l, j, dt, month, day, timeString; + + if (key === 'NAME') { + //name is not actually part of RRule, but we can handle it just the same + scope.schedulerName = value; } - else { - result = 'INTERVAL must contain an integer > 0'; + + if (key === 'FREQ') { + l = value.toLowerCase(); + scope.schedulerFrequency = _.find(scope.frequencyOptions, function(opt) { + scope.schedulerIntervalLabel = opt.intervalLabel; + return opt.value === l; + }); + if (!scope.schedulerFrequency || !scope.schedulerFrequency.name) { + result = 'FREQ not found in list of valid options'; + } } - } - if (key === 'BYDAY') { - if (getValue(set, 'FREQ') === 'WEEKLY') { - days = value.split(/,/); - scope.weekDays = []; - for (j=0; j < days.length; j++) { - if (_.contains(['SU','MO','TU','WE','TH','FR','SA'], days[j])) { - scope.weekDays.push(days[j].toLowerCase()); - scope['weekDay' + days[j].toUpperCase() + 'Class'] = 'active'; //activate related button + if (key === 'INTERVAL') { + if (parseInt(value, 10)) { + scope.schedulerInterval = parseInt(value, 10); + scope.schedulerShowInterval = true; + } else { + result = 'INTERVAL must contain an integer > 0'; + } + } + if (key === 'BYDAY') { + if (getValue(set, 'FREQ') === 'WEEKLY') { + days = value.split(/,/); + scope.weekDays = []; + for (j = 0; j < days.length; j++) { + if (_.contains(['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'], days[j])) { + scope.weekDays.push(days[j].toLowerCase()); + scope['weekDay' + days[j].toUpperCase() + 'Class'] = 'active'; //activate related button + } else { + result = 'BYDAY contains unrecognized day value(s)'; + } } - else { + } else if (getValue(set, 'FREQ') === 'MONTHLY') { + scope.monthlyRepeatOption = 'other'; + scope.monthlyWeekDay = toWeekDays(value); + if (!scope.monthlyWeekDay) { + result = 'BYDAY contains unrecognized day value(s)'; + } + } else { + scope.yearlyRepeatOption = 'other'; + scope.yearlyWeekDay = toWeekDays(value); + if (!scope.yearlyWeekDay) { result = 'BYDAY contains unrecognized day value(s)'; } } } - else if (getValue(set, 'FREQ') === 'MONTHLY') { - scope.monthlyRepeatOption = 'other'; - scope.monthlyWeekDay = toWeekDays(value); - if (!scope.monthlyWeekDay) { - result = 'BYDAY contains unrecognized day value(s)'; + if (key === 'BYMONTHDAY') { + if (parseInt(value, 10) && parseInt(value, 10) > 0 && parseInt(value, 10) < 32) { + scope.monthDay = parseInt(value, 10); + scope.monhthlyRepeatOption = 'day'; + } else { + result = 'BYMONTHDAY must contain an integer between 1 and 31'; } } - else { - scope.yearlyRepeatOption = 'other'; - scope.yearlyWeekDay = toWeekDays(value); - if (!scope.yearlyWeekDay) { - result = 'BYDAY contains unrecognized day value(s)'; + if (key === 'DTSTART') { + // The form has been reset to the local zone + setStartDate = true; + if (/\d{8}T\d{6}.*Z/.test(value)) { + // date may come in without separators. add them so new Date constructor will work + value = value.replace(/(\d{4})(\d{2})(\d{2}T)(\d{2})(\d{2})(\d{2}.*$)/, + function(match, p1, p2, p3, p4, p5, p6) { + return p1 + '-' + p2 + '-' + p3 + p4 + ':' + p5 + ':' + p6.substr(0, 2) + 'Z'; + }); } - } - } - if (key === 'BYMONTHDAY') { - if (parseInt(value,10) && parseInt(value,10) > 0 && parseInt(value,10) < 32) { - scope.monthDay = parseInt(value,10); - scope.monhthlyRepeatOption = 'day'; - } - else { - result = 'BYMONTHDAY must contain an integer between 1 and 31'; - } - } - if (key === 'DTSTART') { - // The form has been reset to the local zone - setStartDate = true; - if (/\d{8}T\d{6}.*Z/.test(value)) { - // date may come in without separators. add them so new Date constructor will work - value = value.replace(/(\d{4})(\d{2})(\d{2}T)(\d{2})(\d{2})(\d{2}.*$)/, - function(match, p1, p2, p3, p4,p5,p6) { - return p1 + '-' + p2 + '-' + p3 + p4 + ':' + p5 + ':' + p6.substr(0,2) + 'Z'; - }); - } - if (useTimezone) { - dt = new Date(value); // date adjusted to local zone automatically - month = $filter('schZeroPad')(dt.getMonth() + 1, 2); - day = $filter('schZeroPad')(dt.getDate(), 2); - scope.schedulerStartDt = month + '/' + day + '/' + dt.getFullYear(); - scope.schedulerStartHour = $filter('schZeroPad')(dt.getHours(),2); - scope.schedulerStartMinute = $filter('schZeroPad')(dt.getMinutes(),2); - scope.schedulerStartSecond = $filter('schZeroPad')(dt.getSeconds(),2); - scope.scheduleTimeChange(); // calc UTC - } - else { - // expects inbound dates to be in ISO format: 2014-04-02T00:00:00.000Z - scope.schedulerStartDt = value.replace(/T.*$/,'').replace(/(\d{4})-(\d{2})-(\d{2})/, function(match, p1, p2, p3) { + if (useTimezone) { + dt = new Date(value); // date adjusted to local zone automatically + month = $filter('schZeroPad')(dt.getMonth() + 1, 2); + day = $filter('schZeroPad')(dt.getDate(), 2); + scope.schedulerStartDt = month + '/' + day + '/' + dt.getFullYear(); + scope.schedulerStartHour = $filter('schZeroPad')(dt.getHours(), 2); + scope.schedulerStartMinute = $filter('schZeroPad')(dt.getMinutes(), 2); + scope.schedulerStartSecond = $filter('schZeroPad')(dt.getSeconds(), 2); + scope.scheduleTimeChange(); // calc UTC + } else { + // expects inbound dates to be in ISO format: 2014-04-02T00:00:00.000Z + scope.schedulerStartDt = value.replace(/T.*$/, '').replace(/(\d{4})-(\d{2})-(\d{2})/, function(match, p1, p2, p3) { return p2 + '/' + p3 + '/' + p1; }); - timeString = value.replace(/^.*T/,''); - scope.schedulerStartHour = $filter('schZeroPad')(timeString.substr(0,2),2); - scope.schedulerStartMinute = $filter('schZeroPad')(timeString.substr(3,2),2); - scope.schedulerStartSecond = $filter('schZeroPad')(timeString.substr(6,2),2); - } - scope.scheduleTimeChange(); - } - if (key === 'BYSETPOS') { - if (getValue(set, 'FREQ') === 'YEARLY') { - scope.yearlRepeatOption = 'other'; - scope.yearlyOccurrence = _.find(scope.occurrences, function(x) { - return (x.value === parseInt(value,10)); - }); - if (!scope.yearlyOccurrence || !scope.yearlyOccurrence.name) { - result = 'BYSETPOS was not in the set of 1,2,3,4,-1'; + timeString = value.replace(/^.*T/, ''); + scope.schedulerStartHour = $filter('schZeroPad')(timeString.substr(0, 2), 2); + scope.schedulerStartMinute = $filter('schZeroPad')(timeString.substr(3, 2), 2); + scope.schedulerStartSecond = $filter('schZeroPad')(timeString.substr(6, 2), 2); } + scope.scheduleTimeChange(); } - else { - scope.monthlyOccurrence = _.find(scope.occurrences, function(x) { - return (x.value === parseInt(value,10)); - }); - if (!scope.monthlyOccurrence || !scope.monthlyOccurrence.name) { - result = 'BYSETPOS was not in the set of 1,2,3,4,-1'; - } - } - } - - if (key === 'COUNT') { - if (parseInt(value,10)) { - scope.schedulerEnd = scope.endOptions[1]; - scope.schedulerOccurrenceCount = parseInt(value,10); - } - else { - result = "COUNT must be a valid integer > 0"; - } - } - - if (key === 'UNTIL') { - if (/\d{8}T\d{6}.*Z/.test(value)) { - // date may come in without separators. add them so new Date constructor will work - value = value.replace(/(\d{4})(\d{2})(\d{2}T)(\d{2})(\d{2})(\d{2}.*$)/, - function(match, p1, p2, p3, p4,p5,p6) { - return p1 + '-' + p2 + '-' + p3 + p4 + ':' + p5 + ':' + p6.substr(0,2) + 'Z'; + if (key === 'BYSETPOS') { + if (getValue(set, 'FREQ') === 'YEARLY') { + scope.yearlRepeatOption = 'other'; + scope.yearlyOccurrence = _.find(scope.occurrences, function(x) { + return (x.value === parseInt(value, 10)); }); + if (!scope.yearlyOccurrence || !scope.yearlyOccurrence.name) { + result = 'BYSETPOS was not in the set of 1,2,3,4,-1'; + } + } else { + scope.monthlyOccurrence = _.find(scope.occurrences, function(x) { + return (x.value === parseInt(value, 10)); + }); + if (!scope.monthlyOccurrence || !scope.monthlyOccurrence.name) { + result = 'BYSETPOS was not in the set of 1,2,3,4,-1'; + } + } } - scope.schedulerEnd = scope.endOptions[2]; - scope.schedulerEndDt = value.replace(/T.*$/,'').replace(/(\d{4})-(\d{2})-(\d{2})/, function(match, p1, p2, p3) { + + if (key === 'COUNT') { + if (parseInt(value, 10)) { + scope.schedulerEnd = scope.endOptions[1]; + scope.schedulerOccurrenceCount = parseInt(value, 10); + } else { + result = "COUNT must be a valid integer > 0"; + } + } + + if (key === 'UNTIL') { + if (/\d{8}T\d{6}.*Z/.test(value)) { + // date may come in without separators. add them so new Date constructor will work + value = value.replace(/(\d{4})(\d{2})(\d{2}T)(\d{2})(\d{2})(\d{2}.*$)/, + function(match, p1, p2, p3, p4, p5, p6) { + return p1 + '-' + p2 + '-' + p3 + p4 + ':' + p5 + ':' + p6.substr(0, 2) + 'Z'; + }); + } + scope.schedulerEnd = scope.endOptions[2]; + scope.schedulerEndDt = value.replace(/T.*$/, '').replace(/(\d{4})-(\d{2})-(\d{2})/, function(match, p1, p2, p3) { return p2 + '/' + p3 + '/' + p1; }); - timeString = value.replace(/^.*T/,''); - scope.schedulerEndHour = $filter('schZeroPad')(timeString.substr(0,2),2); - scope.schedulerEndMinute = $filter('schZeroPad')(timeString.substr(3,2),2); - scope.schedulerEndSecond = $filter('schZeroPad')(timeString.substr(6,2),2); - } + timeString = value.replace(/^.*T/, ''); + scope.schedulerEndHour = $filter('schZeroPad')(timeString.substr(0, 2), 2); + scope.schedulerEndMinute = $filter('schZeroPad')(timeString.substr(3, 2), 2); + scope.schedulerEndSecond = $filter('schZeroPad')(timeString.substr(6, 2), 2); + } - if (key === 'BYMONTH') { - if (getValue(set, 'FREQ') === 'YEARLY' && getValue(set, 'BYDAY')) { - scope.yearlRepeatOption = 'other'; - scope.yearlyOtherMonth = _.find(scope.months, function(x) { - return x.value === parseInt(value,10); - }); - if (!scope.yearlyOtherMonth || !scope.yearlyOtherMonth.name) { - result = 'BYMONTH must be an integer between 1 and 12'; + if (key === 'BYMONTH') { + if (getValue(set, 'FREQ') === 'YEARLY' && getValue(set, 'BYDAY')) { + scope.yearlRepeatOption = 'other'; + scope.yearlyOtherMonth = _.find(scope.months, function(x) { + return x.value === parseInt(value, 10); + }); + if (!scope.yearlyOtherMonth || !scope.yearlyOtherMonth.name) { + result = 'BYMONTH must be an integer between 1 and 12'; + } + } else { + scope.yearlyOption = 'month'; + scope.yearlyMonth = _.find(scope.months, function(x) { + return x.value === parseInt(value, 10); + }); + if (!scope.yearlyMonth || !scope.yearlyMonth.name) { + result = 'BYMONTH must be an integer between 1 and 12'; + } } } - else { - scope.yearlyOption = 'month'; - scope.yearlyMonth = _.find(scope.months, function(x) { - return x.value === parseInt(value,10); - }); - if (!scope.yearlyMonth || !scope.yearlyMonth.name) { - result = 'BYMONTH must be an integer between 1 and 12'; + + if (key === 'BYMONTHDAY') { + if (parseInt(value, 10)) { + scope.yearlyMonthDay = parseInt(value, 10); + } else { + result = 'BYMONTHDAY must be an integer between 1 and 31'; } } } - if (key === 'BYMONTHDAY') { - if (parseInt(value,10)) { - scope.yearlyMonthDay = parseInt(value,10); + function isValid() { + // Check what was put into scope vars, and see if anything is + // missing or not quite right. + if (scope.schedulerFrequency.name === 'weekly' && scope.weekDays.length === 0) { + result = 'Frequency is weekly, but BYDAYS value is missing.'; } - else { - result = 'BYMONTHDAY must be an integer between 1 and 31'; + if (!setStartDate) { + result = 'Warning: start date was not provided'; } } - } - function isValid() { - // Check what was put into scope vars, and see if anything is - // missing or not quite right. - if (scope.schedulerFrequency.name === 'weekly' && scope.weekDays.length === 0) { - result = 'Frequency is weekly, but BYDAYS value is missing.'; - } - if (!setStartDate) { - result = 'Warning: start date was not provided'; - } - } - - if (rule) { - set = rule.split(/;/); - if (angular.isArray(set)) { - for (i=0; i < set.length; i++) { - setValue(set[i], set); - if (result) { - break; + if (rule) { + set = rule.split(/;/); + if (angular.isArray(set)) { + for (i = 0; i < set.length; i++) { + setValue(set[i], set); + if (result) { + break; + } } + if (!result) { + isValid(); + } + } else { + result = 'No rule entered. Provide a valid RRule string.'; } - if (!result) { - isValid(); - } - } - else { + } else { result = 'No rule entered. Provide a valid RRule string.'; } - } - else { - result = 'No rule entered. Provide a valid RRule string.'; - } - if (result) { - $log.error(result); - } - return result; - }; - }]) + if (result) { + $log.error(result); + } + return result; + }; + } + ]) .factory('SetDefaults', ['$filter', function($filter) { return function(scope) { @@ -938,7 +923,7 @@ angular.module('AngularScheduler', ['underscore']) }; }]) - .factory('LoadLookupValues', [ function() { + .factory('LoadLookupValues', [function() { return function(scope) { scope.frequencyOptions = [ @@ -997,15 +982,15 @@ angular.module('AngularScheduler', ['underscore']) }]) // $filter('schZeroPad')(n, pad) -- or -- {{ n | afZeroPad:pad }} - .filter('schZeroPad', [ function() { - return function (n, pad) { - var str = (Math.pow(10,pad) + '').replace(/^1/,'') + (n + '').trim(); + .filter('schZeroPad', [function() { + return function(n, pad) { + var str = (Math.pow(10, pad) + '').replace(/^1/, '') + (n + '').trim(); return str.substr(str.length - pad); }; }]) // $filter('schdateStrFix')(s) where s is a date string in ISO format: yyyy-mm-ddTHH:MM:SS.sssZ. Returns string in format: mm/dd/yyyy HH:MM:SS UTC - .filter('schDateStrFix', [ function() { + .filter('schDateStrFix', [function() { return function(dateStr) { return dateStr.replace(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}).*Z/, function(match, yy, mm, dd, hh, mi, ss) { return mm + '/' + dd + '/' + yy + ' ' + hh + ':' + mi + ':' + ss + ' UTC'; @@ -1013,7 +998,7 @@ angular.module('AngularScheduler', ['underscore']) }; }]) - .directive('schTooltip', [ function() { + .directive('schTooltip', [function() { return { link: function(scope, element, attrs) { var placement = (attrs.placement) ? attrs.placement : 'top'; @@ -1028,30 +1013,30 @@ angular.module('AngularScheduler', ['underscore']) }; }]) - .directive('schDatePicker', [ function() { + .directive('schDatePicker', [function() { return { require: 'ngModel', link: function(scope, element, attrs) { - var options = {}, - variable = attrs.ngModel, - defaultDate = new Date(); - options.dateFormat = attrs.dateFormat || 'mm/dd/yy'; - options.defaultDate = scope[variable]; - options.minDate = (attrs.minToday) ? defaultDate : null; - options.maxDate = (attrs.maxDate) ? new Date(attrs('maxDate')) : null; - options.changeMonth = (attrs.changeMonth === "false") ? false : true; - options.changeYear = (attrs.changeYear === "false") ? false : true; - options.beforeShow = function() { - setTimeout(function(){ - $('.ui-datepicker').css('z-index', 9999); - }, 100); - }; - $(element).datepicker(options); - } + var options = {}, + variable = attrs.ngModel, + defaultDate = new Date(); + options.dateFormat = attrs.dateFormat || 'mm/dd/yy'; + options.defaultDate = scope[variable]; + options.minDate = (attrs.minToday) ? defaultDate : null; + options.maxDate = (attrs.maxDate) ? new Date(attrs('maxDate')) : null; + options.changeMonth = (attrs.changeMonth === "false") ? false : true; + options.changeYear = (attrs.changeYear === "false") ? false : true; + options.beforeShow = function() { + setTimeout(function() { + $('.ui-datepicker').css('z-index', 9999); + }, 100); + }; + $(element).datepicker(options); + } }; }]) - // Custom directives + // Custom directives .directive('schSpinner', ['$filter', function($filter) { return { require: 'ngModel', @@ -1061,7 +1046,7 @@ angular.module('AngularScheduler', ['underscore']) zeroPad = attr.zeroPad, min = attr.min || 1, max = attr.max || 999; - $(element).spinner({ + element.spinner({ min: min, max: max, stop: function() { @@ -1069,10 +1054,9 @@ angular.module('AngularScheduler', ['underscore']) setTimeout(function() { scope.$apply(function() { if (zeroPad) { - scope[attr.ngModel] = $filter('schZeroPad')($(element).spinner('value'),zeroPad); - } - else { - scope[attr.ngModel] = $(element).spinner('value'); + scope[attr.ngModel] = $filter('schZeroPad')(element.spinner('value'), zeroPad); + } else { + scope[attr.ngModel] = element.spinner('value'); } if (attr.ngChange) { scope.$eval(attr.ngChange); @@ -1090,9 +1074,11 @@ angular.module('AngularScheduler', ['underscore']) } }); - $(element).on("click", function () { + $(element).on("click", function() { $(element).select(); }); } }; }]); + +})); diff --git a/awx/ui/client/lib/ngToast/.bower.json b/awx/ui/client/lib/ngtoast/.bower.json similarity index 91% rename from awx/ui/client/lib/ngToast/.bower.json rename to awx/ui/client/lib/ngtoast/.bower.json index 446800a71d..27c51cca55 100644 --- a/awx/ui/client/lib/ngToast/.bower.json +++ b/awx/ui/client/lib/ngtoast/.bower.json @@ -52,8 +52,7 @@ "tag": "2.0.0", "commit": "8a1951c54a956c33964c99b338f3a4830e652689" }, - "_source": "git://github.com/tameraydin/ngToast.git", + "_source": "https://github.com/tameraydin/ngToast.git", "_target": "~2.0.0", - "_originalSource": "ngtoast", - "_direct": true + "_originalSource": "ngtoast" } \ No newline at end of file diff --git a/awx/ui/client/lib/ngToast/README.md b/awx/ui/client/lib/ngtoast/README.md similarity index 100% rename from awx/ui/client/lib/ngToast/README.md rename to awx/ui/client/lib/ngtoast/README.md diff --git a/awx/ui/client/lib/ngToast/bower.json b/awx/ui/client/lib/ngtoast/bower.json similarity index 100% rename from awx/ui/client/lib/ngToast/bower.json rename to awx/ui/client/lib/ngtoast/bower.json diff --git a/awx/ui/client/lib/ngToast/dist/ngToast-animations.css b/awx/ui/client/lib/ngtoast/dist/ngToast-animations.css similarity index 100% rename from awx/ui/client/lib/ngToast/dist/ngToast-animations.css rename to awx/ui/client/lib/ngtoast/dist/ngToast-animations.css diff --git a/awx/ui/client/lib/ngToast/dist/ngToast-animations.min.css b/awx/ui/client/lib/ngtoast/dist/ngToast-animations.min.css similarity index 100% rename from awx/ui/client/lib/ngToast/dist/ngToast-animations.min.css rename to awx/ui/client/lib/ngtoast/dist/ngToast-animations.min.css diff --git a/awx/ui/client/lib/ngToast/dist/ngToast.css b/awx/ui/client/lib/ngtoast/dist/ngToast.css similarity index 100% rename from awx/ui/client/lib/ngToast/dist/ngToast.css rename to awx/ui/client/lib/ngtoast/dist/ngToast.css diff --git a/awx/ui/client/lib/ngToast/dist/ngToast.js b/awx/ui/client/lib/ngtoast/dist/ngToast.js similarity index 100% rename from awx/ui/client/lib/ngToast/dist/ngToast.js rename to awx/ui/client/lib/ngtoast/dist/ngToast.js diff --git a/awx/ui/client/lib/ngToast/dist/ngToast.min.css b/awx/ui/client/lib/ngtoast/dist/ngToast.min.css similarity index 100% rename from awx/ui/client/lib/ngToast/dist/ngToast.min.css rename to awx/ui/client/lib/ngtoast/dist/ngToast.min.css diff --git a/awx/ui/client/lib/ngToast/dist/ngToast.min.js b/awx/ui/client/lib/ngtoast/dist/ngToast.min.js similarity index 100% rename from awx/ui/client/lib/ngToast/dist/ngToast.min.js rename to awx/ui/client/lib/ngtoast/dist/ngToast.min.js 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 c484963162..a81f8c8194 100644 --- a/awx/ui/client/src/helpers/Parse.js +++ b/awx/ui/client/src/helpers/Parse.js @@ -25,7 +25,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 @@ -35,8 +36,7 @@ export default function createField(onChange, onReady, fld) { //hide the textarea and show a fresh CodeMirror with the current mode (json or yaml) - - scope[fld + 'codeMirror'] = AngularCodeMirror(); + scope[fld + 'codeMirror'] = AngularCodeMirror(readOnly); scope[fld + 'codeMirror'].addModes($AnsibleConfig.variable_edit_modes); scope[fld + 'codeMirror'].showTextArea({ scope: scope, 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 d851bd545e..2693ee99ee 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/management-jobs/scheduler/schedulerForm.partial.html b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html index 0592a0cfeb..b619ead47a 100644 --- a/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html @@ -38,9 +38,6 @@
@@ -487,9 +484,6 @@
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/scheduler/schedulerForm.partial.html b/awx/ui/client/src/scheduler/schedulerForm.partial.html index e3fffecec7..fb13937b64 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/scheduler/schedulerForm.partial.html @@ -38,9 +38,6 @@
@@ -469,9 +466,6 @@
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/Utilities.js b/awx/ui/client/src/shared/Utilities.js index f7ccaf0be4..bdb7ddf03c 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -134,6 +134,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) }); $(document).bind('keydown', function (e) { if (e.keyCode === 27 || e.keyCode === 13) { + e.preventDefault(); $('#alert-modal2').modal('hide'); } }); @@ -161,6 +162,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) }); $(document).bind('keydown', function (e) { if (e.keyCode === 27 || e.keyCode === 13) { + e.preventDefault(); $('#alert-modal').modal('hide'); } }); 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/client/tests/multi-select-list/multi-select-list.directive-test.js b/awx/ui/client/tests/multi-select-list/multi-select-list.directive-test.js index ad0cf8c7c9..e9077a274b 100644 --- a/awx/ui/client/tests/multi-select-list/multi-select-list.directive-test.js +++ b/awx/ui/client/tests/multi-select-list/multi-select-list.directive-test.js @@ -135,9 +135,9 @@ describeModule(mod.name) context('selectionChanged event', function() { it('triggers with selections set to all the items', function() { - var item1 = controller.registerItem({ name: 'blah' }); - var item2 = controller.registerItem({ name: 'diddy' }); - var item3 = controller.registerItem({ name: 'doo' }); + var item1 = controller.registerItem({ isSelected: false, id: 1, name: 'blah' }); + var item2 = controller.registerItem({ isSelected: false, id: 2, name: 'diddy' }); + var item3 = controller.registerItem({ isSelected: false, id: 3, name: 'doo' }); var spy = sinon.spy(); $scope.$on('multiSelectList.selectionChanged', spy); @@ -167,9 +167,9 @@ describeModule(mod.name) it('tracks extended selection state', function() { var spy = sinon.spy(); - var item1 = controller.registerItem({ name: 'blah' }); - var item2 = controller.registerItem({ name: 'diddy' }); - var item3 = controller.registerItem({ name: 'doo' }); + var item1 = controller.registerItem({ isSelected: false, id: 1, name: 'blah' }); + var item2 = controller.registerItem({ isSelected: false, id: 2, name: 'diddy' }); + var item3 = controller.registerItem({ isSelected: false, id: 3, name: 'doo' }); var allItems = _.pluck([item1, item2, item3], 'value'); controller.selectAll(); @@ -196,4 +196,3 @@ describeModule(mod.name) }); }); }); - diff --git a/awx/ui/templates/ui/index.html b/awx/ui/templates/ui/index.html index 9cc995e395..b4311edd26 100644 --- a/awx/ui/templates/ui/index.html +++ b/awx/ui/templates/ui/index.html @@ -15,7 +15,7 @@ - + @@ -40,7 +40,7 @@ -
+
diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt index d986b1dc89..3e7cf4ae8c 100644 --- a/requirements/requirements_dev.txt +++ b/requirements/requirements_dev.txt @@ -5,7 +5,7 @@ unittest2 pep8 flake8 pyflakes==1.0.0 # Pinned until PR merges https://gitlab.com/pycqa/flake8/merge_requests/56 -pytest +pytest==2.9.2 pytest-cov pytest-django pytest-pythonpath diff --git a/requirements/requirements_jenkins.txt b/requirements/requirements_jenkins.txt index ff3fda270f..287a714939 100644 --- a/requirements/requirements_jenkins.txt +++ b/requirements/requirements_jenkins.txt @@ -6,7 +6,7 @@ pylint flake8 distribute==0.7.3 unittest2 -pytest +pytest==2.9.2 pytest-cov pytest-django pytest-pythonpath 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 ))