diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 9ce7aee670..1487fe17e3 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -87,6 +87,7 @@ SUMMARIZABLE_FK_FIELDS = { 'current_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'), 'inventory_source': ('source', 'last_updated', 'status'), + 'custom_inventory_script': DEFAULT_SUMMARY_FIELDS, 'source_script': ('name', 'description'), 'role': ('id', 'role_field'), 'notification_template': DEFAULT_SUMMARY_FIELDS, @@ -917,6 +918,19 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): args=(obj.last_update.pk,)) return res + def validate(self, attrs): + organization = None + if 'organization' in attrs: + organization = attrs['organization'] + elif self.instance: + organization = self.instance.organization + + view = self.context.get('view', None) + if not organization and not view.request.user.is_superuser: + # Only allow super users to create orgless projects + raise serializers.ValidationError('Organization is missing') + return super(ProjectSerializer, self).validate(attrs) + class ProjectPlaybooksSerializer(ProjectSerializer): @@ -1926,12 +1940,11 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): def to_internal_value(self, data): # When creating a new job and a job template is specified, populate any # fields not provided in data from the job template. - if not self.instance and isinstance(data, dict) and 'job_template' in data: + if not self.instance and isinstance(data, dict) and data.get('job_template', False): try: job_template = JobTemplate.objects.get(pk=data['job_template']) except JobTemplate.DoesNotExist: - self._errors = {'job_template': 'Invalid job template.'} - return + raise serializers.ValidationError({'job_template': 'Invalid job template.'}) data.setdefault('name', job_template.name) data.setdefault('description', job_template.description) data.setdefault('job_type', job_template.job_type) @@ -2427,14 +2440,10 @@ class NotificationTemplateSerializer(BaseSerializer): notification_type = attrs['notification_type'] elif self.instance: notification_type = self.instance.notification_type - if 'organization' in attrs: - organization = attrs['organization'] - elif self.instance: - organization = self.instance.organization + else: + notification_type = None if not notification_type: raise serializers.ValidationError('Missing required fields for Notification Configuration: notification_type') - if not organization: - raise serializers.ValidationError("Missing 'organization' from required fields") notification_class = NotificationTemplate.CLASS_FOR_NOTIFICATION_TYPE[notification_type] missing_fields = [] @@ -2621,7 +2630,11 @@ class ActivityStreamSerializer(BaseSerializer): if getattr(obj, fk).exists(): rel[fk] = [] for thisItem in allm2m: - rel[fk].append(reverse('api:' + fk + '_detail', args=(thisItem.id,))) + if fk == 'custom_inventory_script': + rel[fk].append(reverse('api:inventory_script_detail', args=(thisItem.id,))) + else: + rel[fk].append(reverse('api:' + fk + '_detail', args=(thisItem.id,))) + if fk == 'schedule': rel['unified_job_template'] = thisItem.unified_job_template.get_absolute_url() return rel @@ -2694,8 +2707,7 @@ class TowerSettingsSerializer(BaseSerializer): def to_internal_value(self, data): if data['key'] not in settings.TOWER_SETTINGS_MANIFEST: - self._errors = {'key': 'Key {0} is not a valid settings key.'.format(data['key'])} - return + raise serializers.ValidationError({'key': ['Key {0} is not a valid settings key.'.format(data['key'])]}) ret = super(TowerSettingsSerializer, self).to_internal_value(data) manifest_val = settings.TOWER_SETTINGS_MANIFEST[data['key']] ret['description'] = manifest_val['description'] diff --git a/awx/api/templates/api/job_template_label_list.md b/awx/api/templates/api/job_template_label_list.md new file mode 100644 index 0000000000..76c520eab5 --- /dev/null +++ b/awx/api/templates/api/job_template_label_list.md @@ -0,0 +1,9 @@ +{% include "api/sub_list_create_api_view.md" %} + +Labels not associated with any other resources are deleted. A label can become disassociated with a resource as a result of 3 events. + +1. A label is explicitly diassociated with a related job template +2. A job is deleted with labels +3. A cleanup job deletes a job with labels + +{% include "api/_new_in_awx.md" %} diff --git a/awx/api/templates/api/sub_list_create_api_view.md b/awx/api/templates/api/sub_list_create_api_view.md index 75c5940f4d..74b91b5084 100644 --- a/awx/api/templates/api/sub_list_create_api_view.md +++ b/awx/api/templates/api/sub_list_create_api_view.md @@ -34,7 +34,7 @@ existing {{ model_verbose_name }} with this {{ parent_model_verbose_name }}. Make a POST request to this resource with `id` and `disassociate` fields to remove the {{ model_verbose_name }} from this {{ parent_model_verbose_name }} -without deleting the {{ model_verbose_name }}. +{% if model_verbose_name != "label" %} without deleting the {{ model_verbose_name }}{% endif %}. {% endif %} {% endif %} diff --git a/awx/api/templates/eula.md b/awx/api/templates/eula.md index f07453c04d..83fb3ccb6e 100644 --- a/awx/api/templates/eula.md +++ b/awx/api/templates/eula.md @@ -1,3 +1,19 @@ -TOWER SOFTWARE END USER LICENSE AGREEMENT +ANSIBLE TOWER BY RED HAT END USER LICENSE AGREEMENT -Unless otherwise agreed to, and executed in a definitive agreement, between Ansible, Inc. (“Ansible”) and the individual or entity (“Customer”) signing or electronically accepting these terms of use for the Tower Software (“EULA”), all Tower Software, including any and all versions released or made available by Ansible, shall be subject to the Ansible Software Subscription and Services Agreement found at www.ansible.com/subscription-agreement (“Agreement”). Ansible is not responsible for any additional obligations, conditions or warranties agreed to between Customer and an authorized distributor, or reseller, of the Tower Software. BY DOWNLOADING AND USING THE TOWER SOFTWARE, OR BY CLICKING ON THE “YES” BUTTON OR OTHER BUTTON OR MECHANISM DESIGNED TO ACKNOWLEDGE CONSENT TO THE TERMS OF AN ELECTRONIC COPY OF THIS EULA, THE CUSTOMER HEREBY ACKNOWLEDGES THAT CUSTOMER HAS READ, UNDERSTOOD, AND AGREES TO BE BOUND BY THE TERMS OF THIS EULA AND AGREEMENT, INCLUDING ALL TERMS INCORPORATED HEREIN BY REFERENCE, AND THAT THIS EULA AND AGREEMENT IS EQUIVALENT TO ANY WRITTEN NEGOTIATED AGREEMENT BETWEEN CUSTOMER AND ANSIBLE. THIS EULA AND AGREEMENT IS ENFORCEABLE AGAINST ANY PERSON OR ENTITY THAT USES OR AVAILS ITSELF OF THE TOWER SOFTWARE OR ANY PERSON OR ENTITY THAT USES THE OR AVAILS ITSELF OF THE TOWER SOFTWARE ON ANOTHER PERSON’S OR ENTITY’S BEHALF. +This end user license agreement (“EULA”) governs the use of the Ansible Tower software and any related updates, upgrades, versions, appearance, structure and organization (the “Ansible Tower Software”), regardless of the delivery mechanism. + +1. License Grant. Subject to the terms of this EULA, Red Hat, Inc. and its affiliates (“Red Hat”) grant to you (“You”) a non-transferable, non-exclusive, worldwide, non-sublicensable, limited, revocable license to use the Ansible Tower Software for the term of the associated Red Hat Software Subscription(s) and in a quantity equal to the number of Red Hat Software Subscriptions purchased from Red Hat for the Ansible Tower Software (“License”), each as set forth on the applicable Red Hat ordering document. You acquire only the right to use the Ansible Tower Software and do not acquire any rights of ownership. Red Hat reserves all rights to the Ansible Tower Software not expressly granted to You. This License grant pertains solely to Your use of the Ansible Tower Software and is not intended to limit Your rights under, or grant You rights that supersede, the license terms of any software packages which may be made available with the Ansible Tower Software that are subject to an open source software license. + +2. Intellectual Property Rights. Title to the Ansible Tower Software and each component, copy and modification, including all derivative works whether made by Red Hat, You or on Red Hat's behalf, including those made at Your suggestion and all associated intellectual property rights, are and shall remain the sole and exclusive property of Red Hat and/or it licensors. The License does not authorize You (nor may You allow any third party, specifically non-employees of Yours) to: (a) copy, distribute, reproduce, use or allow third party access to the Ansible Tower Software except as expressly authorized hereunder; (b) decompile, disassemble, reverse engineer, translate, modify, convert or apply any procedure or process to the Ansible Tower Software in order to ascertain, derive, and/or appropriate for any reason or purpose, including the Ansible Tower Software source code or source listings or any trade secret information or process contained in the Ansible Tower Software (except as permitted under applicable law); (c) execute or incorporate other software (except for approved software as appears in the Ansible Tower Software documentation or specifically approved by Red Hat in writing) into Ansible Tower Software, or create a derivative work of any part of the Ansible Tower Software; (d) remove any trademarks, trade names or titles, copyrights legends or any other proprietary marking on the Ansible Tower Software; (e) disclose the results of any benchmarking of the Ansible Tower Software (whether or not obtained with Red Hat’s assistance) to any third party; (f) attempt to circumvent any user limits or other license, timing or use restrictions that are built into, defined or agreed upon, regarding the Ansible Tower Software. You are hereby notified that the Ansible Tower Software may contain time-out devices, counter devices, and/or other devices intended to ensure the limits of the License will not be exceeded (“Limiting Devices”). If the Ansible Tower Software contains Limiting Devices, Red Hat will provide You materials necessary to use the Ansible Tower Software to the extent permitted. You may not tamper with or otherwise take any action to defeat or circumvent a Limiting Device or other control measure, including but not limited to, resetting the unit amount or using false host identification number for the purpose of extending any term of the License. + +3. Evaluation Licenses. Unless You have purchased Ansible Tower Software Subscriptions from Red Hat or an authorized reseller under the terms of a commercial agreement with Red Hat, all use of the Ansible Tower Software shall be limited to testing purposes and not for production use (“Evaluation”). Unless otherwise agreed by Red Hat, Evaluation of the Ansible Tower Software shall be limited to an evaluation environment and the Ansible Tower Software shall not be used to manage any systems or virtual machines on networks being used in the operation of Your business or any other non-evaluation purpose. Unless otherwise agreed by Red Hat, You shall limit all Evaluation use to a single 30 day evaluation period and shall not download or otherwise obtain additional copies of the Ansible Tower Software or license keys for Evaluation. + +4. Limited Warranty. Except as specifically stated in this Section 4, to the maximum extent permitted under applicable law, the Ansible Tower Software and the components are provided and licensed “as is” without warranty of any kind, expressed or implied, including the implied warranties of merchantability, non-infringement or fitness for a particular purpose. Red Hat warrants solely to You that the media on which the Ansible Tower Software may be furnished will be free from defects in materials and manufacture under normal use for a period of thirty (30) days from the date of delivery to You. Red Hat does not warrant that the functions contained in the Ansible Tower Software will meet Your requirements or that the operation of the Ansible Tower Software will be entirely error free, appear precisely as described in the accompanying documentation, or comply with regulatory requirements. + +5. Limitation of Remedies and Liability. To the maximum extent permitted by applicable law, Your exclusive remedy under this EULA is to return any defective media within thirty (30) days of delivery along with a copy of Your payment receipt and Red Hat, at its option, will replace it or refund the money paid by You for the media. To the maximum extent permitted under applicable law, neither Red Hat nor any Red Hat authorized distributor will be liable to You for any incidental or consequential damages, including lost profits or lost savings arising out of the use or inability to use the Ansible Tower Software or any component, even if Red Hat or the authorized distributor has been advised of the possibility of such damages. In no event shall Red Hat's liability or an authorized distributor’s liability exceed the amount that You paid to Red Hat for the Ansible Tower Software during the twelve months preceding the first event giving rise to liability. + +6. Export Control. In accordance with the laws of the United States and other countries, You represent and warrant that You: (a) understand that the Ansible Tower Software and its components may be subject to export controls under the U.S. Commerce Department’s Export Administration Regulations (“EAR”); (b) are not located in any country listed in Country Group E:1 in Supplement No. 1 to part 740 of the EAR; (c) will not export, re-export, or transfer the Ansible Tower Software to any prohibited destination or to any end user who has been prohibited from participating in US export transactions by any federal agency of the US government; (d) will not use or transfer the Ansible Tower Software for use in connection with the design, development or production of nuclear, chemical or biological weapons, or rocket systems, space launch vehicles, or sounding rockets or unmanned air vehicle systems; (e) understand and agree that if you are in the United States and you export or transfer the Ansible Tower Software to eligible end users, you will, to the extent required by EAR Section 740.17 obtain a license for such export or transfer and will submit semi-annual reports to the Commerce Department’s Bureau of Industry and Security, which include the name and address (including country) of each transferee; and (f) understand that countries including the United States may restrict the import, use, or export of encryption products (which may include the Ansible Tower Software) and agree that you shall be solely responsible for compliance with any such import, use, or export restrictions. + +7. General. If any provision of this EULA is held to be unenforceable, that shall not affect the enforceability of the remaining provisions. This agreement shall be governed by the laws of the State of New York and of the United States, without regard to any conflict of laws provisions. The rights and obligations of the parties to this EULA shall not be governed by the United Nations Convention on the International Sale of Goods. + +Copyright © 2015 Red Hat, Inc. All rights reserved. "Red Hat" and “Ansible Tower” are registered trademarks of Red Hat, Inc. All other trademarks are the property of their respective owners. diff --git a/awx/api/views.py b/awx/api/views.py index 4ad0a9cc58..64ea980f66 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -958,6 +958,7 @@ class ProjectList(ListCreateAPIView): 'admin_role', 'use_role', 'update_role', + 'read_role', ) return projects_qs @@ -1401,7 +1402,7 @@ class OrganizationCredentialList(SubListCreateAPIView): user_visible = Credential.accessible_objects(self.request.user, 'read_role').all() org_set = Credential.accessible_objects(organization.admin_role, 'read_role').all() - if self.request.user.is_superuser: + if self.request.user.is_superuser or self.request.user.is_system_auditor: return org_set return org_set & user_visible @@ -1487,7 +1488,7 @@ class InventoryList(ListCreateAPIView): def get_queryset(self): qs = Inventory.accessible_objects(self.request.user, 'read_role') - qs = qs.select_related('admin_role', 'read_role', 'update_role', 'use_role') + qs = qs.select_related('admin_role', 'read_role', 'update_role', 'use_role', 'adhoc_role') return qs class InventoryDetail(RetrieveUpdateDestroyAPIView): @@ -2188,9 +2189,6 @@ class JobTemplateDetail(RetrieveUpdateDestroyAPIView): can_delete = request.user.can_access(JobTemplate, 'delete', obj) if not can_delete: raise PermissionDenied("Cannot delete job template.") - if obj.jobs.filter(status__in=['new', 'pending', 'waiting', 'running']).exists(): - return Response({"error": "Delete not allowed while there are jobs running"}, - status=status.HTTP_405_METHOD_NOT_ALLOWED) return super(JobTemplateDetail, self).destroy(request, *args, **kwargs) @@ -2591,7 +2589,7 @@ class SystemJobTemplateList(ListAPIView): serializer_class = SystemJobTemplateSerializer def get(self, request, *args, **kwargs): - if not request.user.is_superuser: + if not request.user.is_superuser and not request.user.is_system_auditor: raise PermissionDenied("Superuser privileges needed.") return super(SystemJobTemplateList, self).get(request, *args, **kwargs) @@ -3321,7 +3319,7 @@ class SystemJobList(ListCreateAPIView): serializer_class = SystemJobListSerializer def get(self, request, *args, **kwargs): - if not request.user.is_superuser: + if not request.user.is_superuser and not request.user.is_system_auditor: raise PermissionDenied("Superuser privileges needed.") return super(SystemJobList, self).get(request, *args, **kwargs) @@ -3625,8 +3623,6 @@ class RoleList(ListAPIView): new_in_300 = True def get_queryset(self): - if self.request.user.is_superuser: - return Role.objects.all() return Role.visible_roles(self.request.user) @@ -3652,10 +3648,10 @@ class RoleUsersList(SubListCreateAttachDetachAPIView): return role.members.all() def post(self, request, *args, **kwargs): - # Forbid implicit role creation here + # Forbid implicit user creation here sub_id = request.data.get('id', None) if not sub_id: - data = dict(msg="Role 'id' field is missing.") + data = dict(msg="User 'id' field is missing.") return Response(data, status=status.HTTP_400_BAD_REQUEST) return super(RoleUsersList, self).post(request, *args, **kwargs) diff --git a/awx/main/access.py b/awx/main/access.py index e6f692a005..af05c58be6 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -12,11 +12,12 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType # Django REST Framework -from rest_framework.exceptions import ParseError, PermissionDenied +from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError # AWX from awx.main.utils import * # noqa from awx.main.models import * # noqa +from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.models.mixins import ResourceMixin from awx.api.license import LicenseForbids from awx.main.task_engine import TaskSerializer @@ -139,7 +140,7 @@ class BaseAccess(object): self.user = user def get_queryset(self): - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return self.model.objects.all() else: return self.model.objects.none() @@ -221,7 +222,7 @@ class UserAccess(BaseAccess): model = User def get_queryset(self): - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return User.objects.all() if tower_settings.ORG_ADMINS_CAN_SEE_ALL_USERS and \ @@ -310,7 +311,16 @@ class OrganizationAccess(BaseAccess): def can_delete(self, obj): self.check_license(feature='multiple_organizations', check_expiration=False) - return self.can_change(obj, None) + is_change_possible = self.can_change(obj, None) + if not is_change_possible: + return False + active_jobs = [] + active_jobs.extend(Job.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)) + active_jobs.extend(ProjectUpdate.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)) + active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__inventory__organization=obj, status__in=ACTIVE_STATES)) + if len(active_jobs) > 0: + raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + return True class InventoryAccess(BaseAccess): ''' @@ -373,7 +383,15 @@ class InventoryAccess(BaseAccess): return self.user in obj.admin_role def can_delete(self, obj): - return self.can_admin(obj, None) + is_can_admin = self.can_admin(obj, None) + if not is_can_admin: + return False + active_jobs = [] + active_jobs.extend(Job.objects.filter(inventory=obj, status__in=ACTIVE_STATES)) + active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__inventory=obj, status__in=ACTIVE_STATES)) + if len(active_jobs) > 0: + raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + return True def can_run_ad_hoc_commands(self, obj): return self.user in obj.adhoc_role @@ -486,7 +504,14 @@ class GroupAccess(BaseAccess): return True def can_delete(self, obj): - return obj and self.user in obj.inventory.admin_role + is_delete_allowed = bool(obj and self.user in obj.inventory.admin_role) + if not is_delete_allowed: + return False + active_jobs = [] + active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__in=obj.inventory_sources.all(), status__in=ACTIVE_STATES)) + if len(active_jobs) > 0: + raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + return True class InventorySourceAccess(BaseAccess): ''' @@ -589,8 +614,9 @@ class CredentialAccess(BaseAccess): def can_read(self, obj): return self.user in obj.read_role + @check_superuser def can_add(self, data): - if self.user.is_superuser: + if not data: # So the browseable API will work return True user_pk = get_pk_from_dict(data, 'user') if user_pk: @@ -660,6 +686,8 @@ class TeamAccess(BaseAccess): @check_superuser def can_add(self, data): + if not data: # So the browseable API will work + return Organization.accessible_objects(self.user, 'admin_role').exists() org_pk = get_pk_from_dict(data, 'organization') org = get_object_or_400(Organization, pk=org_pk) if self.user in org.admin_role: @@ -715,13 +743,15 @@ class ProjectAccess(BaseAccess): model = Project def get_queryset(self): - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return self.model.objects.all() qs = self.model.accessible_objects(self.user, 'read_role') return qs.select_related('modified_by', 'credential', 'current_job', 'last_job').all() @check_superuser def can_add(self, data): + if not data: # So the browseable API will work + return Organization.accessible_objects(self.user, 'admin_role').exists() organization_pk = get_pk_from_dict(data, 'organization') org = get_object_or_400(Organization, pk=organization_pk) return self.user in org.admin_role @@ -731,7 +761,15 @@ class ProjectAccess(BaseAccess): return self.user in obj.admin_role def can_delete(self, obj): - return self.can_change(obj, None) + is_change_allowed = self.can_change(obj, None) + if not is_change_allowed: + return False + active_jobs = [] + active_jobs.extend(Job.objects.filter(project=obj, status__in=ACTIVE_STATES)) + active_jobs.extend(ProjectUpdate.objects.filter(project=obj, status__in=ACTIVE_STATES)) + if len(active_jobs) > 0: + raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + return True @check_superuser def can_start(self, obj): @@ -747,7 +785,7 @@ class ProjectUpdateAccess(BaseAccess): model = ProjectUpdate def get_queryset(self): - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return self.model.objects.all() qs = ProjectUpdate.objects.distinct() qs = qs.select_related('created_by', 'modified_by', 'project') @@ -783,7 +821,7 @@ class JobTemplateAccess(BaseAccess): model = JobTemplate def get_queryset(self): - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: qs = self.model.objects.all() else: qs = self.model.accessible_objects(self.user, 'read_role') @@ -802,7 +840,7 @@ class JobTemplateAccess(BaseAccess): given action as well as the 'create' deploy permission. Users who are able to create deploy jobs can also run normal and check (dry run) jobs. ''' - if not data or '_method' in data: # So the browseable API will work? + if not data: # So the browseable API will work return True # if reference_obj is provided, determine if it can be coppied @@ -953,7 +991,13 @@ class JobTemplateAccess(BaseAccess): @check_superuser def can_delete(self, obj): - return self.user in obj.admin_role + is_delete_allowed = self.user in obj.admin_role + if not is_delete_allowed: + return False + active_jobs = obj.jobs.filter(status__in=ACTIVE_STATES) + if len(active_jobs) > 0: + raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + return True class JobAccess(BaseAccess): ''' @@ -974,7 +1018,7 @@ class JobAccess(BaseAccess): qs = qs.select_related('created_by', 'modified_by', 'job_template', 'inventory', 'project', 'credential', 'cloud_credential', 'job_template') qs = qs.prefetch_related('unified_job_template') - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs.all() qs_jt = qs.filter( @@ -992,7 +1036,7 @@ class JobAccess(BaseAccess): Q(project__organization__in=org_access_qs)).distinct() def can_add(self, data): - if not data or '_method' in data: # So the browseable API will work? + if not data: # So the browseable API will work return True if not self.user.is_superuser: return False @@ -1073,10 +1117,7 @@ class AdHocCommandAccess(BaseAccess): ''' I can only see/run ad hoc commands when: - I am a superuser. - - I am an org admin and have permission to read the credential. - - I am a normal user with a user/team permission that has at least read - permission on the inventory and the run_ad_hoc_commands flag set, and I - can read the credential. + - I have read access to the inventory ''' model = AdHocCommand @@ -1084,26 +1125,23 @@ class AdHocCommandAccess(BaseAccess): qs = self.model.objects.distinct() qs = qs.select_related('created_by', 'modified_by', 'inventory', 'credential') - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs.all() - credential_ids = set(self.user.get_queryset(Credential).values_list('id', flat=True)) inventory_qs = Inventory.accessible_objects(self.user, 'read_role') - - return qs.filter(credential_id__in=credential_ids, - inventory__in=inventory_qs) + return qs.filter(inventory__in=inventory_qs) def can_add(self, data): - if not data or '_method' in data: # So the browseable API will work? + if not data: # So the browseable API will work return True self.check_license() - # If a credential is provided, the user should have read access to it. + # If a credential is provided, the user should have use access to it. credential_pk = get_pk_from_dict(data, 'credential') if credential_pk: credential = get_object_or_400(Credential, pk=credential_pk) - if self.user not in credential.read_role: + if self.user not in credential.use_role: return False # Check that the user has the run ad hoc command permission on the @@ -1148,7 +1186,7 @@ class AdHocCommandEventAccess(BaseAccess): qs = self.model.objects.distinct() qs = qs.select_related('ad_hoc_command', 'host') - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs.all() ad_hoc_command_qs = self.user.get_queryset(AdHocCommand) host_qs = self.user.get_queryset(Host) @@ -1174,7 +1212,7 @@ class JobHostSummaryAccess(BaseAccess): def get_queryset(self): qs = self.model.objects qs = qs.select_related('job', 'job__job_template', 'host') - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs.all() job_qs = self.user.get_queryset(Job) host_qs = self.user.get_queryset(Host) @@ -1206,7 +1244,7 @@ class JobEventAccess(BaseAccess): event_data__icontains='"ansible_job_id": "', event_data__contains='"module_name": "async_status"') - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs.all() job_qs = self.user.get_queryset(Job) @@ -1319,7 +1357,7 @@ class ScheduleAccess(BaseAccess): qs = self.model.objects.all() qs = qs.select_related('created_by', 'modified_by') qs = qs.prefetch_related('unified_job_template') - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs.all() job_template_qs = self.user.get_queryset(JobTemplate) inventory_source_qs = self.user.get_queryset(InventorySource) @@ -1370,14 +1408,19 @@ class NotificationTemplateAccess(BaseAccess): def get_queryset(self): qs = self.model.objects.all() - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs - return self.model.objects.filter(organization__in=Organization.accessible_objects(self.user, 'admin_role').all()) + return self.model.objects.filter( + Q(organization__in=self.user.admin_of_organizations) | + Q(organization__in=self.user.auditor_of_organizations) + ).distinct() - @check_superuser def can_read(self, obj): + if self.user.is_superuser or self.user.is_system_auditor: + return True if obj.organization is not None: - return self.user in obj.organization.admin_role + if self.user in obj.organization.admin_role or self.user in obj.organization.auditor_role: + return True return False @check_superuser @@ -1414,9 +1457,12 @@ class NotificationAccess(BaseAccess): def get_queryset(self): qs = self.model.objects.all() - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return qs - return self.model.objects.filter(notification_template__organization__in=Organization.accessible_objects(self.user, 'admin_role')) + return self.model.objects.filter( + Q(notification_template__organization__in=self.user.admin_of_organizations) | + Q(notification_template__organization__in=self.user.auditor_of_organizations) + ).distinct() def can_read(self, obj): return self.user.can_access(NotificationTemplate, 'read', obj.notification_template) @@ -1431,7 +1477,7 @@ class LabelAccess(BaseAccess): model = Label def get_queryset(self): - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return self.model.objects.all() return self.model.objects.filter( organization__in=Organization.accessible_objects(self.user, 'read_role') @@ -1443,7 +1489,7 @@ class LabelAccess(BaseAccess): @check_superuser def can_add(self, data): - if not data or '_method' in data: # So the browseable API will work? + if not data: # So the browseable API will work return True org_pk = get_pk_from_dict(data, 'organization') @@ -1494,9 +1540,7 @@ class ActivityStreamAccess(BaseAccess): 'inventory_update', 'credential', 'team', 'project', 'project_update', 'permission', 'job_template', 'job', 'ad_hoc_command', 'notification_template', 'notification', 'label', 'role') - if self.user.is_superuser: - return qs.all() - if self.user in Role.singleton('system_auditor'): + if self.user.is_superuser or self.user.is_system_auditor: return qs.all() inventory_set = Inventory.accessible_objects(self.user, 'read_role') @@ -1544,18 +1588,20 @@ class CustomInventoryScriptAccess(BaseAccess): model = CustomInventoryScript def get_queryset(self): - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return self.model.objects.distinct().all() return self.model.accessible_objects(self.user, 'read_role').all() @check_superuser def can_add(self, data): + if not data: # So the browseable API will work + return Organization.accessible_objects(self.user, 'admin_role').exists() org_pk = get_pk_from_dict(data, 'organization') org = get_object_or_400(Organization, pk=org_pk) return self.user in org.admin_role @check_superuser - def can_admin(self, obj): + def can_admin(self, obj, data=None): return self.user in obj.admin_role @check_superuser @@ -1566,10 +1612,6 @@ class CustomInventoryScriptAccess(BaseAccess): def can_delete(self, obj): return self.can_admin(obj) - @check_superuser - def can_read(self, obj): - return self.user in obj.read_role - class TowerSettingsAccess(BaseAccess): ''' @@ -1598,7 +1640,7 @@ class RoleAccess(BaseAccess): def can_read(self, obj): if not obj: return False - if self.user.is_superuser: + if self.user.is_superuser or self.user.is_system_auditor: return True if obj.object_id: diff --git a/awx/main/migrations/0010_v300_create_system_job_templates.py b/awx/main/migrations/0010_v300_create_system_job_templates.py index 61092ed60b..abf336efaa 100644 --- a/awx/main/migrations/0010_v300_create_system_job_templates.py +++ b/awx/main/migrations/0010_v300_create_system_job_templates.py @@ -24,7 +24,7 @@ def create_system_job_templates(apps, schema_editor): job_type='cleanup_jobs', defaults=dict( name='Cleanup Job Details', - description='Remove job history older than X days', + description='Remove job history', created=now_dt, modified=now_dt, polymorphic_ctype=sjt_ct, @@ -51,7 +51,7 @@ def create_system_job_templates(apps, schema_editor): job_type='cleanup_activitystream', defaults=dict( name='Cleanup Activity Stream', - description='Remove activity stream history older than X days', + description='Remove activity stream history', created=now_dt, modified=now_dt, polymorphic_ctype=sjt_ct, diff --git a/awx/main/migrations/0027_v300_team_migrations.py b/awx/main/migrations/0027_v300_team_migrations.py new file mode 100644 index 0000000000..b53fd8a969 --- /dev/null +++ b/awx/main/migrations/0027_v300_team_migrations.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from awx.main.migrations import _rbac as rbac +from awx.main.migrations import _team_cleanup as team_cleanup +from awx.main.migrations import _migration_utils as migration_utils +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0026_v300_credential_unique'), + ] + + operations = [ + migrations.RunPython(migration_utils.set_current_apps_for_migrations), + migrations.RunPython(team_cleanup.migrate_team), + migrations.RunPython(rbac.rebuild_role_hierarchy), + ] diff --git a/awx/main/migrations/0028_v300_org_team_cascade.py b/awx/main/migrations/0028_v300_org_team_cascade.py new file mode 100644 index 0000000000..80378c5729 --- /dev/null +++ b/awx/main/migrations/0028_v300_org_team_cascade.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0027_v300_team_migrations'), + ] + + operations = [ + migrations.AlterField( + model_name='team', + name='organization', + field=models.ForeignKey(related_name='teams', to='main.Organization'), + preserve_default=False, + ), + ] diff --git a/awx/main/migrations/_old_access.py b/awx/main/migrations/_old_access.py index ce952461ac..da49723a9e 100644 --- a/awx/main/migrations/_old_access.py +++ b/awx/main/migrations/_old_access.py @@ -656,7 +656,7 @@ class TeamAccess(BaseAccess): raise PermissionDenied('Unable to change organization on a team') if self.user.is_superuser: return True - if self.user in obj.organization.deprecated_admins.all(): + if obj.organization and self.user in obj.organization.deprecated_admins.all(): return True return False diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index ee4100431e..4a1115c4b3 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -198,7 +198,8 @@ def migrate_credential(apps, schema_editor): logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at organization level".format(cred.name, cred.kind, cred.host))) if cred.deprecated_team is not None: - cred.deprecated_team.member_role.children.add(cred.admin_role) + cred.deprecated_team.admin_role.children.add(cred.admin_role) + cred.deprecated_team.member_role.children.add(cred.use_role) cred.save() logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at user level".format(cred.name, cred.kind, cred.host))) elif cred.deprecated_user is not None: diff --git a/awx/main/migrations/_team_cleanup.py b/awx/main/migrations/_team_cleanup.py new file mode 100644 index 0000000000..1a937d1f88 --- /dev/null +++ b/awx/main/migrations/_team_cleanup.py @@ -0,0 +1,30 @@ +# Python +import logging +from django.utils.encoding import smart_text + +logger = logging.getLogger(__name__) + +def log_migration(wrapped): + '''setup the logging mechanism for each migration method + as it runs, Django resets this, so we use a decorator + to re-add the handler for each method. + ''' + handler = logging.FileHandler("/tmp/tower_rbac_migrations.log", mode="a", encoding="UTF-8") + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setLevel(logging.DEBUG) + handler.setFormatter(formatter) + + def wrapper(*args, **kwargs): + logger.handlers = [] + logger.addHandler(handler) + return wrapped(*args, **kwargs) + return wrapper + +@log_migration +def migrate_team(apps, schema_editor): + '''If an orphan team exists that is still active, delete it.''' + Team = apps.get_model('main', 'Team') + for team in Team.objects.iterator(): + if team.organization is None: + logger.info(smart_text(u"Deleting orphaned team: {}".format(team.name))) + team.delete() diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index b51e558a8b..8dde9f3b3b 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -309,8 +309,7 @@ class Inventory(CommonModel, ResourceMixin): else: computed_fields.pop(field) if computed_fields: - if len(computed_fields) > 0: - iobj.save(update_fields=computed_fields.keys()) + iobj.save(update_fields=computed_fields.keys()) logger.debug("Finished updating inventory computed fields") @property diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 19a03febee..ac67bf8d67 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -681,10 +681,10 @@ class Job(UnifiedJob, JobOptions): ok=h.ok, processed=h.processed, skipped=h.skipped) - data.update(dict(inventory=self.inventory.name, + data.update(dict(inventory=self.inventory.name if self.inventory else None, project=self.project.name if self.project else None, playbook=self.playbook, - credential=self.credential.name, + credential=self.credential.name if self.credential else None, limit=self.limit, extra_vars=self.extra_vars, hosts=all_hosts)) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 3717171411..5f3dc9d7c9 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -92,8 +92,8 @@ class Team(CommonModelNameNotUnique, ResourceMixin): organization = models.ForeignKey( 'Organization', blank=False, - null=True, - on_delete=models.SET_NULL, + null=False, + on_delete=models.CASCADE, related_name='teams', ) deprecated_projects = models.ManyToManyField( diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 7084cef874..36b2dfe75e 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -39,6 +39,7 @@ __all__ = ['UnifiedJobTemplate', 'UnifiedJob'] logger = logging.getLogger('awx.main.models.unified_jobs') CAN_CANCEL = ('new', 'pending', 'waiting', 'running') +ACTIVE_STATES = CAN_CANCEL class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, NotificationFieldsModel): diff --git a/awx/main/signals.py b/awx/main/signals.py index 6138ee17bd..7389f01763 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -323,6 +323,7 @@ model_serializer_mapping = { Host: HostSerializer, Group: GroupSerializer, InventorySource: InventorySourceSerializer, + CustomInventoryScript: CustomInventoryScriptSerializer, Credential: CredentialSerializer, Team: TeamSerializer, Project: ProjectSerializer, @@ -410,7 +411,10 @@ def activity_stream_associate(sender, instance, **kwargs): for entity_acted in kwargs['pk_set']: obj2 = kwargs['model'] obj2_id = entity_acted - obj2_actual = obj2.objects.get(id=obj2_id) + obj2_actual = obj2.objects.filter(id=obj2_id) + if not obj2_actual.exists(): + continue + obj2_actual = obj2_actual[0] if isinstance(obj2_actual, Role) and obj2_actual.content_object is not None: obj2_actual = obj2_actual.content_object object2 = camelcase_to_underscore(obj2_actual.__class__.__name__) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 04efceb3ae..2325702da6 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -599,7 +599,8 @@ class BaseTask(Task): else: child_procs = main_proc.get_children(recursive=True) for child_proc in child_procs: - os.kill(child_proc.pid, signal.SIGTERM) + os.kill(child_proc.pid, signal.SIGKILL) + os.kill(main_proc.pid, signal.SIGKILL) except TypeError: os.kill(child.pid, signal.SIGKILL) else: diff --git a/awx/main/tests/URI.py b/awx/main/tests/URI.py new file mode 100644 index 0000000000..d04da03436 --- /dev/null +++ b/awx/main/tests/URI.py @@ -0,0 +1,43 @@ +# Helps with test cases. +# Save all components of a uri (i.e. scheme, username, password, etc.) so that +# when we construct a uri string and decompose it, we can verify the decomposition +class URI(object): + DEFAULTS = { + 'scheme' : 'http', + 'username' : 'MYUSERNAME', + 'password' : 'MYPASSWORD', + 'host' : 'host.com', + } + + def __init__(self, description='N/A', scheme=DEFAULTS['scheme'], username=DEFAULTS['username'], password=DEFAULTS['password'], host=DEFAULTS['host']): + self.description = description + self.scheme = scheme + self.username = username + self.password = password + self.host = host + + def get_uri(self): + uri = "%s://" % self.scheme + if self.username: + uri += "%s" % self.username + if self.password: + uri += ":%s" % self.password + if (self.username or self.password) and self.host is not None: + uri += "@%s" % self.host + elif self.host is not None: + uri += "%s" % self.host + return uri + + def get_secret_count(self): + secret_count = 0 + if self.username: + secret_count += 1 + if self.password: + secret_count += 1 + return secret_count + + def __string__(self): + return self.get_uri() + + def __repr__(self): + return self.get_uri() diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py index 6257e26438..cd3754b23f 100644 --- a/awx/main/tests/base.py +++ b/awx/main/tests/base.py @@ -35,6 +35,7 @@ from awx.main.management.commands.run_task_system import run_taskmanager from awx.main.utils import get_ansible_version from awx.main.task_engine import TaskEngager as LicenseWriter from awx.sso.backends import LDAPSettings +from awx.main.tests.URI import URI # noqa TEST_PLAYBOOK = '''- hosts: mygroup gather_facts: false @@ -732,48 +733,3 @@ class BaseJobExecutionTest(QueueStartStopTestMixin, BaseLiveServerTest): ''' Base class for celery task tests. ''' - -# Helps with test cases. -# Save all components of a uri (i.e. scheme, username, password, etc.) so that -# when we construct a uri string and decompose it, we can verify the decomposition -class URI(object): - DEFAULTS = { - 'scheme' : 'http', - 'username' : 'MYUSERNAME', - 'password' : 'MYPASSWORD', - 'host' : 'host.com', - } - - def __init__(self, description='N/A', scheme=DEFAULTS['scheme'], username=DEFAULTS['username'], password=DEFAULTS['password'], host=DEFAULTS['host']): - self.description = description - self.scheme = scheme - self.username = username - self.password = password - self.host = host - - def get_uri(self): - uri = "%s://" % self.scheme - if self.username: - uri += "%s" % self.username - if self.password: - uri += ":%s" % self.password - if (self.username or self.password) and self.host is not None: - uri += "@%s" % self.host - elif self.host is not None: - uri += "%s" % self.host - return uri - - def get_secret_count(self): - secret_count = 0 - if self.username: - secret_count += 1 - if self.password: - secret_count += 1 - return secret_count - - def __string__(self): - return self.get_uri() - - def __repr__(self): - return self.get_uri() - diff --git a/awx/main/tests/functional/api/test_adhoc.py b/awx/main/tests/functional/api/test_adhoc.py index 43326afcb4..e7029b0c79 100644 --- a/awx/main/tests/functional/api/test_adhoc.py +++ b/awx/main/tests/functional/api/test_adhoc.py @@ -122,7 +122,7 @@ def test_get_inventory_ad_hoc_command_list(admin, alice, post_adhoc, get, invent inv1.adhoc_role.members.add(alice) res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv1.id,)), alice, expect=200) - assert res.data['count'] == 0 + assert res.data['count'] == 1 machine_credential.use_role.members.add(alice) res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv1.id,)), alice, expect=200) diff --git a/awx/main/tests/functional/api/test_organizations.py b/awx/main/tests/functional/api/test_organizations.py new file mode 100644 index 0000000000..d141ddd6b5 --- /dev/null +++ b/awx/main/tests/functional/api/test_organizations.py @@ -0,0 +1,190 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Python +import pytest +import mock + + +# Django +from django.core.urlresolvers import reverse + +# AWX +from awx.main.models import * # noqa + + +@pytest.mark.django_db +def test_organization_list_access_tests(options, head, get, admin, alice): + options(reverse('api:organization_list'), user=admin, expect=200) + head(reverse('api:organization_list'), user=admin, expect=200) + get(reverse('api:organization_list'), user=admin, expect=200) + options(reverse('api:organization_list'), user=alice, expect=200) + head(reverse('api:organization_list'), user=alice, expect=200) + get(reverse('api:organization_list'), user=alice, expect=200) + options(reverse('api:organization_list'), user=None, expect=401) + head(reverse('api:organization_list'), user=None, expect=401) + get(reverse('api:organization_list'), user=None, expect=401) + + +@pytest.mark.django_db +def test_organization_access_tests(organization, get, admin, alice, bob): + organization.member_role.members.add(alice) + get(reverse('api:organization_detail', args=(organization.id,)), user=admin, expect=200) + get(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=200) + get(reverse('api:organization_detail', args=(organization.id,)), user=bob, expect=403) + get(reverse('api:organization_detail', args=(organization.id,)), user=None, expect=401) + + +@pytest.mark.django_db +def test_organization_list_integrity(organization, get, admin, alice): + res = get(reverse('api:organization_list'), user=admin) + for field in ['id', 'url', 'name', 'description', 'created']: + assert field in res.data['results'][0] + + +@pytest.mark.django_db +def test_organization_list_visibility(organizations, get, admin, alice): + orgs = organizations(2) + + res = get(reverse('api:organization_list'), user=admin) + assert res.data['count'] == 2 + assert len(res.data['results']) == 2 + + res = get(reverse('api:organization_list'), user=alice) + assert res.data['count'] == 0 + + orgs[1].member_role.members.add(alice) + + res = get(reverse('api:organization_list'), user=alice) + assert res.data['count'] == 1 + assert len(res.data['results']) == 1 + assert res.data['results'][0]['id'] == orgs[1].id + + +@pytest.mark.django_db +def test_organization_project_list(organization, project_factory, get, alice, bob, rando): + prj1 = project_factory('project-one') + project_factory('project-two') + organization.admin_role.members.add(alice) + organization.member_role.members.add(bob) + prj1.use_role.members.add(bob) + assert get(reverse('api:organization_projects_list', args=(organization.id,)), user=alice).data['count'] == 2 + assert get(reverse('api:organization_projects_list', args=(organization.id,)), user=bob).data['count'] == 1 + assert get(reverse('api:organization_projects_list', args=(organization.id,)), user=rando).status_code == 403 + + +@pytest.mark.django_db +def test_organization_user_list(organization, get, admin, alice, bob): + organization.admin_role.members.add(alice) + organization.member_role.members.add(alice) + organization.member_role.members.add(bob) + assert get(reverse('api:organization_users_list', args=(organization.id,)), user=admin).data['count'] == 2 + assert get(reverse('api:organization_users_list', args=(organization.id,)), user=alice).data['count'] == 2 + assert get(reverse('api:organization_users_list', args=(organization.id,)), user=bob).data['count'] == 2 + assert get(reverse('api:organization_admins_list', args=(organization.id,)), user=admin).data['count'] == 1 + assert get(reverse('api:organization_admins_list', args=(organization.id,)), user=alice).data['count'] == 1 + assert get(reverse('api:organization_admins_list', args=(organization.id,)), user=bob).data['count'] == 1 + + +@pytest.mark.django_db +def test_organization_inventory_list(organization, inventory_factory, get, alice, bob, rando): + inv1 = inventory_factory('inventory-one') + inventory_factory('inventory-two') + organization.admin_role.members.add(alice) + organization.member_role.members.add(bob) + inv1.use_role.members.add(bob) + assert get(reverse('api:organization_inventories_list', args=(organization.id,)), user=alice).data['count'] == 2 + assert get(reverse('api:organization_inventories_list', args=(organization.id,)), user=bob).data['count'] == 1 + get(reverse('api:organization_inventories_list', args=(organization.id,)), user=rando, expect=403) + + +@pytest.mark.django_db +@mock.patch('awx.api.views.feature_enabled', lambda feature,bypass_db=None: True) +def test_create_organization(post, admin, alice): + new_org = { + 'name': 'new org', + 'description': 'my description' + } + res = post(reverse('api:organization_list'), new_org, user=admin, expect=201) + assert res.data['name'] == new_org['name'] + res = post(reverse('api:organization_list'), new_org, user=admin, expect=400) + + +@pytest.mark.django_db +@mock.patch('awx.api.views.feature_enabled', lambda feature,bypass_db=None: True) +def test_create_organization_xfail(post, alice): + new_org = { + 'name': 'new org', + 'description': 'my description' + } + post(reverse('api:organization_list'), new_org, user=alice, expect=403) + + +@pytest.mark.django_db +def test_add_user_to_organization(post, organization, alice, bob): + organization.admin_role.members.add(alice) + post(reverse('api:organization_users_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=204) + assert bob in organization.member_role + post(reverse('api:organization_users_list', args=(organization.id,)), {'id': bob.id, 'disassociate': True} , user=alice, expect=204) + assert bob not in organization.member_role + + +@pytest.mark.django_db +def test_add_user_to_organization_xfail(post, organization, alice, bob): + organization.member_role.members.add(alice) + post(reverse('api:organization_users_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=403) + + +@pytest.mark.django_db +def test_add_admin_to_organization(post, organization, alice, bob): + organization.admin_role.members.add(alice) + post(reverse('api:organization_admins_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=204) + assert bob in organization.admin_role + assert bob in organization.member_role + post(reverse('api:organization_admins_list', args=(organization.id,)), {'id': bob.id, 'disassociate': True} , user=alice, expect=204) + assert bob not in organization.admin_role + assert bob not in organization.member_role + + +@pytest.mark.django_db +def test_add_admin_to_organization_xfail(post, organization, alice, bob): + organization.member_role.members.add(alice) + post(reverse('api:organization_admins_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=403) + + +@pytest.mark.django_db +def test_update_organization(get, put, organization, alice, bob): + organization.admin_role.members.add(alice) + data = get(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=200).data + data['description'] = 'hi' + put(reverse('api:organization_detail', args=(organization.id,)), data, user=alice, expect=200) + organization.refresh_from_db() + assert organization.description == 'hi' + data['description'] = 'bye' + put(reverse('api:organization_detail', args=(organization.id,)), data, user=bob, expect=403) + + +@pytest.mark.django_db +@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True) +def test_delete_organization(delete, organization, admin): + delete(reverse('api:organization_detail', args=(organization.id,)), user=admin, expect=204) + + +@pytest.mark.django_db +@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True) +def test_delete_organization2(delete, organization, alice): + organization.admin_role.members.add(alice) + delete(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=204) + + +@pytest.mark.django_db +@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True) +def test_delete_organization_xfail1(delete, organization, alice): + organization.member_role.members.add(alice) + delete(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=403) + + +@pytest.mark.django_db +@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True) +def test_delete_organization_xfail2(delete, organization): + delete(reverse('api:organization_detail', args=(organization.id,)), user=None, expect=401) diff --git a/awx/main/tests/functional/api/test_unified_jobs_view.py b/awx/main/tests/functional/api/test_unified_jobs_view.py index ab9487bc38..ed7034a28e 100644 --- a/awx/main/tests/functional/api/test_unified_jobs_view.py +++ b/awx/main/tests/functional/api/test_unified_jobs_view.py @@ -1,8 +1,83 @@ import pytest -from awx.main.models import UnifiedJob from django.core.urlresolvers import reverse +from awx.main.models import UnifiedJob, ProjectUpdate +from awx.main.tests.base import URI + + +TEST_STDOUTS = [] +uri = URI(scheme="https", username="Dhh3U47nmC26xk9PKscV", password="PXPfWW8YzYrgS@E5NbQ2H@", host="github.ginger.com/theirrepo.git/info/refs") +TEST_STDOUTS.append({ + 'description': 'uri in a plain text document', + 'uri' : uri, + 'text' : 'hello world %s goodbye world' % uri, + 'occurrences' : 1 +}) + +uri = URI(scheme="https", username="applepie@@@", password="thatyouknow@@@@", host="github.ginger.com/theirrepo.git/info/refs") +TEST_STDOUTS.append({ + 'description': 'uri appears twice in a multiline plain text document', + 'uri' : uri, + 'text' : 'hello world %s \n\nyoyo\n\nhello\n%s' % (uri, uri), + 'occurrences' : 2 +}) + + + +@pytest.fixture +def test_cases(project): + ret = [] + for e in TEST_STDOUTS: + e['project'] = ProjectUpdate(project=project) + e['project'].result_stdout_text = e['text'] + e['project'].save() + ret.append(e) + return ret + +@pytest.fixture +def negative_test_cases(job_factory): + ret = [] + for e in TEST_STDOUTS: + e['job'] = job_factory() + e['job'].result_stdout_text = e['text'] + e['job'].save() + ret.append(e) + return ret + + +formats = [ + ('json', 'application/json'), + ('ansi', 'text/plain'), + ('txt', 'text/plain'), + ('html', 'text/html'), +] + +@pytest.mark.parametrize("format,content_type", formats) +@pytest.mark.django_db +def test_project_update_redaction_enabled(get, format, content_type, test_cases, admin): + for test_data in test_cases: + job = test_data['project'] + response = get(reverse("api:project_update_stdout", args=(job.pk,)) + "?format=" + format, user=admin, expect=200, accept=content_type) + assert content_type in response['CONTENT-TYPE'] + assert response.data is not None + content = response.data['content'] if format == 'json' else response.data + assert test_data['uri'].username not in content + assert test_data['uri'].password not in content + assert content.count(test_data['uri'].host) == test_data['occurrences'] + +@pytest.mark.parametrize("format,content_type", formats) +@pytest.mark.django_db +def test_job_redaction_disabled(get, format, content_type, negative_test_cases, admin): + for test_data in negative_test_cases: + job = test_data['job'] + response = get(reverse("api:job_stdout", args=(job.pk,)) + "?format=" + format, user=admin, expect=200, format=format) + content = response.data['content'] if format == 'json' else response.data + assert response.data is not None + assert test_data['uri'].username in content + assert test_data['uri'].password in content + + @pytest.mark.django_db def test_options_fields_choices(instance, options, user): @@ -15,3 +90,4 @@ def test_options_fields_choices(instance, options, user): assert 'choice' == response.data['actions']['GET']['status']['type'] assert UnifiedJob.STATUS_CHOICES == response.data['actions']['GET']['status']['choices'] + diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 076f368631..f970adc2e7 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -38,7 +38,10 @@ from awx.main.models.organization import ( Team, ) -from awx.main.models.notifications import NotificationTemplate +from awx.main.models.notifications import ( + NotificationTemplate, + Notification +) ''' Disable all django model signals. @@ -124,6 +127,12 @@ def project_factory(organization): return prj return factory +@pytest.fixture +def job_factory(job_template, admin): + def factory(job_template=job_template, initial_state='new', created_by=admin): + return job_template.create_job(created_by=created_by, status=initial_state) + return factory + @pytest.fixture def team_factory(organization): def factory(name): @@ -187,6 +196,15 @@ def notification_template(organization): notification_configuration=dict(url="http://localhost", headers={"Test": "Header"})) +@pytest.fixture +def notification(notification_template): + return Notification.objects.create(notification_template=notification_template, + status='successful', + notifications_sent=1, + notification_type='email', + recipients='admin@redhat.com', + subject='email subject') + @pytest.fixture def job_with_secret_key(job_with_secret_key_factory): return job_with_secret_key_factory(persisted=True) @@ -292,17 +310,23 @@ def permissions(): 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':True,}, } -@pytest.fixture -def post(): - def rf(url, data, user=None, middleware=None, expect=None, **kwargs): - view, view_args, view_kwargs = resolve(urlparse(url)[2]) + +def _request(verb): + def rf(url, data_or_user=None, user=None, middleware=None, expect=None, **kwargs): + if type(data_or_user) is User and user is None: + user = data_or_user + elif 'data' not in kwargs: + kwargs['data'] = data_or_user if 'format' not in kwargs: kwargs['format'] = 'json' - request = APIRequestFactory().post(url, data, **kwargs) + + view, view_args, view_kwargs = resolve(urlparse(url)[2]) + request = getattr(APIRequestFactory(), verb)(url, **kwargs) if middleware: middleware.process_request(request) if user: force_authenticate(request, user=user) + response = view(request, *view_args, **view_kwargs) if middleware: middleware.process_response(request, response) @@ -310,134 +334,37 @@ def post(): if response.status_code != expect: print(response.data) assert response.status_code == expect + response.render() return response return rf +@pytest.fixture +def post(): + return _request('post') + @pytest.fixture def get(): - def rf(url, user=None, middleware=None, expect=None, **kwargs): - view, view_args, view_kwargs = resolve(urlparse(url)[2]) - if 'format' not in kwargs: - kwargs['format'] = 'json' - request = APIRequestFactory().get(url, **kwargs) - if middleware: - middleware.process_request(request) - if user: - force_authenticate(request, user=user) - response = view(request, *view_args, **view_kwargs) - if middleware: - middleware.process_response(request, response) - if expect: - if response.status_code != expect: - print(response.data) - assert response.status_code == expect - return response - return rf + return _request('get') @pytest.fixture def put(): - def rf(url, data, user=None, middleware=None, expect=None, **kwargs): - view, view_args, view_kwargs = resolve(urlparse(url)[2]) - if 'format' not in kwargs: - kwargs['format'] = 'json' - request = APIRequestFactory().put(url, data, **kwargs) - if middleware: - middleware.process_request(request) - if user: - force_authenticate(request, user=user) - response = view(request, *view_args, **view_kwargs) - if middleware: - middleware.process_response(request, response) - if expect: - if response.status_code != expect: - print(response.data) - assert response.status_code == expect - return response - return rf + return _request('put') @pytest.fixture def patch(): - def rf(url, data, user=None, middleware=None, expect=None, **kwargs): - view, view_args, view_kwargs = resolve(urlparse(url)[2]) - if 'format' not in kwargs: - kwargs['format'] = 'json' - request = APIRequestFactory().patch(url, data, **kwargs) - if middleware: - middleware.process_request(request) - if user: - force_authenticate(request, user=user) - response = view(request, *view_args, **view_kwargs) - if middleware: - middleware.process_response(request, response) - if expect: - if response.status_code != expect: - print(response.data) - assert response.status_code == expect - return response - return rf + return _request('patch') @pytest.fixture def delete(): - def rf(url, user=None, middleware=None, expect=None, **kwargs): - view, view_args, view_kwargs = resolve(urlparse(url)[2]) - if 'format' not in kwargs: - kwargs['format'] = 'json' - request = APIRequestFactory().delete(url, **kwargs) - if middleware: - middleware.process_request(request) - if user: - force_authenticate(request, user=user) - response = view(request, *view_args, **view_kwargs) - if middleware: - middleware.process_response(request, response) - if expect: - if response.status_code != expect: - print(response.data) - assert response.status_code == expect - return response - return rf + return _request('delete') @pytest.fixture def head(): - def rf(url, user=None, middleware=None, expect=None, **kwargs): - view, view_args, view_kwargs = resolve(urlparse(url)[2]) - if 'format' not in kwargs: - kwargs['format'] = 'json' - request = APIRequestFactory().head(url, **kwargs) - if middleware: - middleware.process_request(request) - if user: - force_authenticate(request, user=user) - response = view(request, *view_args, **view_kwargs) - if middleware: - middleware.process_response(request, response) - if expect: - if response.status_code != expect: - print(response.data) - assert response.status_code == expect - return response - return rf + return _request('head') @pytest.fixture def options(): - def rf(url, data, user=None, middleware=None, expect=None, **kwargs): - view, view_args, view_kwargs = resolve(urlparse(url)[2]) - if 'format' not in kwargs: - kwargs['format'] = 'json' - request = APIRequestFactory().options(url, data, **kwargs) - if middleware: - middleware.process_request(request) - if user: - force_authenticate(request, user=user) - response = view(request, *view_args, **view_kwargs) - if middleware: - middleware.process_response(request, response) - if expect: - if response.status_code != expect: - print(response.data) - assert response.status_code == expect - return response - return rf + return _request('options') diff --git a/awx/main/tests/functional/core/test_licenses.py b/awx/main/tests/functional/core/test_licenses.py new file mode 100644 index 0000000000..37f3c63fa9 --- /dev/null +++ b/awx/main/tests/functional/core/test_licenses.py @@ -0,0 +1,132 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +import json +import mock +import os +import tempfile +import time +import pytest +from datetime import datetime + +from awx.main.models import Host +from awx.main.task_engine import TaskSerializer, TaskEngager + + +@pytest.mark.django_db +def test_license_writer(inventory, admin): + writer = TaskEngager( + company_name='acmecorp', + contact_name='Michael DeHaan', + contact_email='michael@ansibleworks.com', + license_date=25000, # seconds since epoch + instance_count=500) + + data = writer.get_data() + + Host.objects.bulk_create( + [ + Host( + name='host.%d' % n, + inventory=inventory, + created_by=admin, + modified=datetime.now(), + created=datetime.now()) + for n in range(12) + ] + ) + + assert data['instance_count'] == 500 + assert data['contact_name'] == 'Michael DeHaan' + assert data['contact_email'] == 'michael@ansibleworks.com' + assert data['license_date'] == 25000 + assert data['license_key'] == "11bae31f31c6a6cdcb483a278cdbe98bd8ac5761acd7163a50090b0f098b3a13" + + strdata = writer.get_string() + strdata_loaded = json.loads(strdata) + assert strdata_loaded == data + + reader = TaskSerializer() + + vdata = reader.from_string(strdata) + + assert vdata['available_instances'] == 500 + assert vdata['current_instances'] == 12 + assert vdata['free_instances'] == 488 + assert vdata['date_warning'] is True + assert vdata['date_expired'] is True + assert vdata['license_date'] == 25000 + assert vdata['time_remaining'] < 0 + assert vdata['valid_key'] is True + assert vdata['compliant'] is False + assert vdata['subscription_name'] + +@pytest.mark.django_db +def test_expired_licenses(): + reader = TaskSerializer() + writer = TaskEngager( + company_name='Tower', + contact_name='Tower Admin', + contact_email='tower@ansible.com', + license_date=int(time.time() - 3600), + instance_count=100, + trial=True) + strdata = writer.get_string() + vdata = reader.from_string(strdata) + + assert vdata['compliant'] is False + assert vdata['grace_period_remaining'] < 0 + + writer = TaskEngager( + company_name='Tower', + contact_name='Tower Admin', + contact_email='tower@ansible.com', + license_date=int(time.time() - 2592001), + instance_count=100, + trial=False) + strdata = writer.get_string() + vdata = reader.from_string(strdata) + + assert vdata['compliant'] is False + assert vdata['grace_period_remaining'] < 0 + + writer = TaskEngager( + company_name='Tower', + contact_name='Tower Admin', + contact_email='tower@ansible.com', + license_date=int(time.time() - 3600), + instance_count=100, + trial=False) + strdata = writer.get_string() + vdata = reader.from_string(strdata) + + assert vdata['compliant'] is False + assert vdata['grace_period_remaining'] > 0 + +@pytest.mark.django_db +def test_aws_license(): + os.environ['AWX_LICENSE_FILE'] = 'non-existent-license-file.json' + + h, path = tempfile.mkstemp() + with os.fdopen(h, 'w') as f: + json.dump({'instance_count': 100}, f) + + def fetch_ami(_self): + _self.attributes['ami-id'] = 'ami-00000000' + return True + + def fetch_instance(_self): + _self.attributes['instance-id'] = 'i-00000000' + return True + + with mock.patch('awx.main.task_engine.TEMPORARY_TASK_FILE', path): + with mock.patch('awx.main.task_engine.TemporaryTaskEngine.fetch_ami', fetch_ami): + with mock.patch('awx.main.task_engine.TemporaryTaskEngine.fetch_instance', fetch_instance): + reader = TaskSerializer() + license = reader.from_file() + assert license['is_aws'] + assert license['time_remaining'] + assert license['free_instances'] > 0 + assert license['grace_period_remaining'] > 0 + + os.unlink(path) diff --git a/awx/main/tests/functional/test_auth_token_limit.py b/awx/main/tests/functional/test_auth_token_limit.py new file mode 100644 index 0000000000..bbe30320c4 --- /dev/null +++ b/awx/main/tests/functional/test_auth_token_limit.py @@ -0,0 +1,39 @@ +import pytest +from datetime import timedelta + +from django.utils.timezone import now as tz_now +from django.test.utils import override_settings + +from awx.main.models import AuthToken, User + + +@override_settings(AUTH_TOKEN_PER_USER=3) +@pytest.mark.django_db +def test_get_tokens_over_limit(): + now = tz_now() + # Times are relative to now + # (key, created on in seconds , expiration in seconds) + test_data = [ + # a is implicitly expired + ("a", -1000, -10), + # b's are invalid due to session limit of 3 + ("b", -100, 60), + ("bb", -100, 60), + ("c", -90, 70), + ("d", -80, 80), + ("e", -70, 90), + ] + user = User.objects.create_superuser('admin', 'foo@bar.com', 'password') + for key, t_create, t_expire in test_data: + AuthToken.objects.create( + user=user, + key=key, + request_hash='this_is_a_hash', + created=now + timedelta(seconds=t_create), + expires=now + timedelta(seconds=t_expire), + ) + invalid_tokens = AuthToken.get_tokens_over_limit(user, now=now) + invalid_keys = [x.key for x in invalid_tokens] + assert len(invalid_keys) == 2 + assert 'b' in invalid_keys + assert 'bb' in invalid_keys diff --git a/awx/main/tests/functional/test_projects.py b/awx/main/tests/functional/test_projects.py index 4c32d1dd69..40ea659432 100644 --- a/awx/main/tests/functional/test_projects.py +++ b/awx/main/tests/functional/test_projects.py @@ -114,3 +114,19 @@ def test_create_project(post, organization, org_admin, org_member, admin, rando, assert result.status_code == expected_status_code if expected_status_code == 201: assert Project.objects.filter(name='Project', organization=organization).exists() + +@pytest.mark.django_db() +def test_create_project_null_organization(post, organization, admin): + post(reverse('api:project_list'), { 'name': 't', 'organization': None}, admin, expect=201) + +@pytest.mark.django_db() +def test_create_project_null_organization_xfail(post, organization, org_admin): + post(reverse('api:project_list'), { 'name': 't', 'organization': None}, org_admin, expect=400) + +@pytest.mark.django_db() +def test_patch_project_null_organization(patch, organization, project, admin): + patch(reverse('api:project_detail', args=(project.id,)), { 'name': 't', 'organization': organization.id}, admin, expect=200) + +@pytest.mark.django_db() +def test_patch_project_null_organization_xfail(patch, project, org_admin): + patch(reverse('api:project_detail', args=(project.id,)), { 'name': 't', 'organization': None}, org_admin, expect=400) diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index 95a6610a23..3b154d6f42 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -28,8 +28,10 @@ def test_two_teams_same_cred_name(organization_factory): rbac.migrate_credential(apps, None) - assert objects.teams.team1.member_role in cred1.admin_role.parents.all() - assert objects.teams.team2.member_role in cred2.admin_role.parents.all() + assert objects.teams.team1.admin_role in cred1.admin_role.parents.all() + assert objects.teams.team2.admin_role in cred2.admin_role.parents.all() + assert objects.teams.team1.member_role in cred1.use_role.parents.all() + assert objects.teams.team2.member_role in cred2.use_role.parents.all() @pytest.mark.django_db def test_credential_use_role(credential, user, permissions): @@ -53,7 +55,7 @@ def test_credential_migration_team_member(credential, team, user, permissions): rbac.migrate_credential(apps, None) # Admin permissions post migration - assert u in credential.admin_role + assert u in credential.use_role @pytest.mark.django_db def test_credential_migration_team_admin(credential, team, user, permissions): diff --git a/awx/main/tests/functional/test_rbac_notifications.py b/awx/main/tests/functional/test_rbac_notifications.py index 35cbd43814..a9a5e7c5f9 100644 --- a/awx/main/tests/functional/test_rbac_notifications.py +++ b/awx/main/tests/functional/test_rbac_notifications.py @@ -1,6 +1,9 @@ import pytest -from awx.main.access import NotificationTemplateAccess +from awx.main.access import ( + NotificationTemplateAccess, + NotificationAccess +) @pytest.mark.django_db def test_notification_template_get_queryset_orgmember(notification_template, user): @@ -24,6 +27,11 @@ def test_notification_template_get_queryset_orgadmin(notification_template, user notification_template.organization.admin_role.members.add(user('admin', False)) assert access.get_queryset().count() == 1 +@pytest.mark.django_db +def test_notification_template_get_queryset_org_auditor(notification_template, org_auditor): + access = NotificationTemplateAccess(org_auditor) + assert access.get_queryset().count() == 1 + @pytest.mark.django_db def test_notification_template_access_superuser(notification_template_factory): nf_objects = notification_template_factory('test-orphaned', organization='test', superusers=['admin']) @@ -81,3 +89,31 @@ def test_notificaiton_template_orphan_access_org_admin(notification_template, or notification_template.organization = None access = NotificationTemplateAccess(org_admin) assert not access.can_change(notification_template, {'organization': organization.id}) + +@pytest.mark.django_db +def test_notification_access_get_queryset_org_admin(notification, org_admin): + access = NotificationAccess(org_admin) + assert access.get_queryset().count() == 1 + +@pytest.mark.django_db +def test_notification_access_get_queryset_org_auditor(notification, org_auditor): + access = NotificationAccess(org_auditor) + assert access.get_queryset().count() == 1 + +@pytest.mark.django_db +def test_notification_access_system_admin(notification, admin): + access = NotificationAccess(admin) + assert access.can_read(notification) + assert access.can_delete(notification) + +@pytest.mark.django_db +def test_notification_access_org_admin(notification, org_admin): + access = NotificationAccess(org_admin) + assert access.can_read(notification) + assert access.can_delete(notification) + +@pytest.mark.django_db +def test_notification_access_org_auditor(notification, org_auditor): + access = NotificationAccess(org_auditor) + assert access.can_read(notification) + assert not access.can_delete(notification) diff --git a/awx/main/tests/old/api/job_tasks.py b/awx/main/tests/old/api/job_tasks.py index 93861f8d56..2471f9e614 100644 --- a/awx/main/tests/old/api/job_tasks.py +++ b/awx/main/tests/old/api/job_tasks.py @@ -1,6 +1,9 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +import os +import unittest2 as unittest + from django.conf import settings from django.test import LiveServerTestCase from django.test.utils import override_settings @@ -8,6 +11,7 @@ from django.test.utils import override_settings from awx.main.tests.job_base import BaseJobTestMixin +@unittest.skipIf(os.environ.get('SKIP_SLOW_TESTS', False), 'Skipping slow test') @override_settings(CELERY_ALWAYS_EAGER=True, CELERY_EAGER_PROPAGATES_EXCEPTIONS=True, ANSIBLE_TRANSPORT='local') diff --git a/awx/main/tests/old/ha.py b/awx/main/tests/old/ha.py deleted file mode 100644 index 4fd0ae1c40..0000000000 --- a/awx/main/tests/old/ha.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. - -# Python -import mock - -# Django -from django.test import SimpleTestCase - -# AWX -from awx.main.models import * # noqa -from awx.main.ha import * # noqa - -__all__ = ['HAUnitTest',] - -class HAUnitTest(SimpleTestCase): - - @mock.patch('awx.main.models.Instance.objects.count', return_value=2) - def test_multiple_instances(self, ignore): - self.assertTrue(is_ha_environment()) - - @mock.patch('awx.main.models.Instance.objects.count', return_value=1) - def test_db_localhost(self, ignore): - self.assertFalse(is_ha_environment()) - diff --git a/awx/main/tests/old/jobs/job_relaunch.py b/awx/main/tests/old/jobs/job_relaunch.py index 437c6ed099..3a9e050288 100644 --- a/awx/main/tests/old/jobs/job_relaunch.py +++ b/awx/main/tests/old/jobs/job_relaunch.py @@ -4,6 +4,8 @@ # Python from __future__ import absolute_import import json +import os +import unittest2 as unittest # Django from django.core.urlresolvers import reverse @@ -15,6 +17,7 @@ from awx.main.tests.job_base import BaseJobTestMixin __all__ = ['JobRelaunchTest',] +@unittest.skipIf(os.environ.get('SKIP_SLOW_TESTS', False), 'Skipping slow test') class JobRelaunchTest(BaseJobTestMixin, BaseLiveServerTest): def test_job_relaunch(self): diff --git a/awx/main/tests/old/licenses.py b/awx/main/tests/old/licenses.py deleted file mode 100644 index 136c3ce8a5..0000000000 --- a/awx/main/tests/old/licenses.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -import json -import os -import tempfile - -from awx.main.models import Host, Inventory, Organization -from awx.main.tests.base import BaseTest -import awx.main.task_engine -from awx.main.task_engine import * # noqa - -class LicenseTests(BaseTest): - - def setUp(self): - self.start_redis() - self.setup_instances() - super(LicenseTests, self).setUp() - self.setup_users() - u = self.super_django_user - org = Organization.objects.create(name='o1', created_by=u) - org.admin_role.members.add(self.normal_django_user) - self.inventory = Inventory.objects.create(name='hi', organization=org, created_by=u) - Host.objects.create(name='a1', inventory=self.inventory, created_by=u) - Host.objects.create(name='a2', inventory=self.inventory, created_by=u) - Host.objects.create(name='a3', inventory=self.inventory, created_by=u) - Host.objects.create(name='a4', inventory=self.inventory, created_by=u) - Host.objects.create(name='a5', inventory=self.inventory, created_by=u) - Host.objects.create(name='a6', inventory=self.inventory, created_by=u) - Host.objects.create(name='a7', inventory=self.inventory, created_by=u) - Host.objects.create(name='a8', inventory=self.inventory, created_by=u) - Host.objects.create(name='a9', inventory=self.inventory, created_by=u) - Host.objects.create(name='a10', inventory=self.inventory, created_by=u) - Host.objects.create(name='a11', inventory=self.inventory, created_by=u) - Host.objects.create(name='a12', inventory=self.inventory, created_by=u) - self._temp_task_file = awx.main.task_engine.TEMPORARY_TASK_FILE - self._temp_task_fetch_ami = awx.main.task_engine.TemporaryTaskEngine.fetch_ami - self._temp_task_fetch_instance = awx.main.task_engine.TemporaryTaskEngine.fetch_instance - - def tearDown(self): - awx.main.task_engine.TEMPORARY_TASK_FILE = self._temp_task_file - awx.main.task_engine.TemporaryTaskEngine.fetch_ami = self._temp_task_fetch_ami - awx.main.task_engine.TemporaryTaskEngine.fetch_instance = self._temp_task_fetch_instance - super(LicenseTests, self).tearDown() - self.stop_redis() - - def test_license_writer(self): - - writer = TaskEngager( - company_name='acmecorp', - contact_name='Michael DeHaan', - contact_email='michael@ansibleworks.com', - license_date=25000, # seconds since epoch - instance_count=500) - - data = writer.get_data() - - assert data['instance_count'] == 500 - assert data['contact_name'] == 'Michael DeHaan' - assert data['contact_email'] == 'michael@ansibleworks.com' - assert data['license_date'] == 25000 - assert data['license_key'] == "11bae31f31c6a6cdcb483a278cdbe98bd8ac5761acd7163a50090b0f098b3a13" - - strdata = writer.get_string() - strdata_loaded = json.loads(strdata) - assert strdata_loaded == data - - reader = TaskSerializer() - - vdata = reader.from_string(strdata) - - assert vdata['available_instances'] == 500 - assert vdata['current_instances'] == 12 - assert vdata['free_instances'] == 488 - assert vdata['date_warning'] is True - assert vdata['date_expired'] is True - assert vdata['license_date'] == 25000 - assert vdata['time_remaining'] < 0 - assert vdata['valid_key'] is True - assert vdata['compliant'] is False - assert vdata['subscription_name'] - - def test_expired_licenses(self): - reader = TaskSerializer() - writer = TaskEngager( - company_name='Tower', - contact_name='Tower Admin', - contact_email='tower@ansible.com', - license_date=int(time.time() - 3600), - instance_count=100, - trial=True) - strdata = writer.get_string() - vdata = reader.from_string(strdata) - - assert vdata['compliant'] is False - assert vdata['grace_period_remaining'] < 0 - - writer = TaskEngager( - company_name='Tower', - contact_name='Tower Admin', - contact_email='tower@ansible.com', - license_date=int(time.time() - 2592001), - instance_count=100, - trial=False) - strdata = writer.get_string() - vdata = reader.from_string(strdata) - - assert vdata['compliant'] is False - assert vdata['grace_period_remaining'] < 0 - - writer = TaskEngager( - company_name='Tower', - contact_name='Tower Admin', - contact_email='tower@ansible.com', - license_date=int(time.time() - 3600), - instance_count=100, - trial=False) - strdata = writer.get_string() - vdata = reader.from_string(strdata) - - assert vdata['compliant'] is False - assert vdata['grace_period_remaining'] > 0 - - def test_aws_license(self): - os.environ['AWX_LICENSE_FILE'] = 'non-existent-license-file.json' - h, path = tempfile.mkstemp() - self._temp_paths.append(path) - with os.fdopen(h, 'w') as f: - json.dump({'instance_count': 100}, f) - awx.main.task_engine.TEMPORARY_TASK_FILE = path - - def fetch_ami(_self): - _self.attributes['ami-id'] = 'ami-00000000' - return True - - def fetch_instance(_self): - _self.attributes['instance-id'] = 'i-00000000' - return True - - awx.main.task_engine.TemporaryTaskEngine.fetch_ami = fetch_ami - awx.main.task_engine.TemporaryTaskEngine.fetch_instance = fetch_instance - reader = TaskSerializer() - license = reader.from_file() - self.assertTrue(license['is_aws']) - self.assertTrue(license['time_remaining']) - self.assertTrue(license['free_instances'] > 0) - self.assertTrue(license['grace_period_remaining'] > 0) diff --git a/awx/main/tests/old/organizations.py b/awx/main/tests/old/organizations.py deleted file mode 100644 index 136e2603cc..0000000000 --- a/awx/main/tests/old/organizations.py +++ /dev/null @@ -1,413 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python -from datetime import timedelta - -# Django -from django.core.urlresolvers import reverse -from django.test.utils import override_settings -from django.contrib.auth.models import User -from django.utils.timezone import now as tz_now - -# AWX -from awx.main.models import * # noqa -from awx.main.tests.base import BaseTest - -__all__ = ['AuthTokenLimitUnitTest', 'OrganizationsTest'] - -class AuthTokenLimitUnitTest(BaseTest): - - def setUp(self): - self.now = tz_now() - # Times are relative to now - # (key, created on in seconds , expiration in seconds) - self.test_data = [ - # a is implicitly expired - ("a", -1000, -10), - # b's are invalid due to session limit of 3 - ("b", -100, 60), - ("bb", -100, 60), - ("c", -90, 70), - ("d", -80, 80), - ("e", -70, 90), - ] - self.user = User.objects.create_superuser('admin', 'foo@bar.com', 'password') - for key, t_create, t_expire in self.test_data: - AuthToken.objects.create( - user=self.user, - key=key, - request_hash='this_is_a_hash', - created=self.now + timedelta(seconds=t_create), - expires=self.now + timedelta(seconds=t_expire), - ) - super(AuthTokenLimitUnitTest, self).setUp() - - @override_settings(AUTH_TOKEN_PER_USER=3) - def test_get_tokens_over_limit(self): - invalid_tokens = AuthToken.get_tokens_over_limit(self.user, now=self.now) - invalid_keys = [x.key for x in invalid_tokens] - self.assertEqual(len(invalid_keys), 2) - self.assertIn('b', invalid_keys) - self.assertIn('bb', invalid_keys) - -class OrganizationsTest(BaseTest): - - def collection(self): - return reverse('api:organization_list') - - def setUp(self): - super(OrganizationsTest, self).setUp() - self.setup_instances() - # TODO: Test non-enterprise license - self.create_test_license_file() - self.setup_users() - - self.organizations = self.make_organizations(self.super_django_user, 10) - self.projects = self.make_projects(self.normal_django_user, 10) - - # add projects to organizations in a more or less arbitrary way - for project in self.projects[0:2]: - self.organizations[0].projects.add(project) - for project in self.projects[3:8]: - self.organizations[1].projects.add(project) - for project in self.projects[9:10]: - self.organizations[2].projects.add(project) - self.organizations[0].projects.add(self.projects[-1]) - self.organizations[9].projects.add(self.projects[-2]) - - # get the URL for various organization records - self.a_detail_url = "%s%s" % (self.collection(), self.organizations[0].pk) - self.b_detail_url = "%s%s" % (self.collection(), self.organizations[1].pk) - self.c_detail_url = "%s%s" % (self.collection(), self.organizations[2].pk) - - # configuration: - # admin_user is an admin and regular user in all organizations - # other_user is all organizations - # normal_user is a user in organization 0, and an admin of organization 1 - # nobody_user is a user not a member of any organizations - - for x in self.organizations: - x.admin_role.members.add(self.super_django_user) - x.member_role.members.add(self.super_django_user) - x.member_role.members.add(self.other_django_user) - - self.organizations[0].member_role.members.add(self.normal_django_user) - self.organizations[1].admin_role.members.add(self.normal_django_user) - - def test_get_organization_list(self): - url = reverse('api:organization_list') - - # no credentials == 401 - self.options(url, expect=401) - self.head(url, expect=401) - self.get(url, expect=401) - - # wrong credentials == 401 - with self.current_user(self.get_invalid_credentials()): - self.options(url, expect=401) - self.head(url, expect=401) - self.get(url, expect=401) - - # superuser credentials == 200, full list - with self.current_user(self.super_django_user): - self.options(url, expect=200) - self.head(url, expect=200) - response = self.get(url, expect=200) - self.check_pagination_and_size(response, 10, previous=None, next=None) - self.assertEqual(len(response['results']), - Organization.objects.count()) - for field in ['id', 'url', 'name', 'description', 'created']: - self.assertTrue(field in response['results'][0], - 'field %s not in result' % field) - - # check that the related URL functionality works - related = response['results'][0]['related'] - for x in ['projects', 'users', 'admins']: - self.assertTrue(x in related and related[x].endswith("/%s/" % x), "looking for %s in related" % x) - - # normal credentials == 200, get only organizations of which user is a member - with self.current_user(self.normal_django_user): - self.options(url, expect=200) - self.head(url, expect=200) - response = self.get(url, expect=200) - self.check_pagination_and_size(response, 2, previous=None, next=None) - - # no admin rights? get empty list - with self.current_user(self.other_django_user): - response = self.get(url, expect=200) - self.check_pagination_and_size(response, len(self.organizations), previous=None, next=None) - - # not a member of any orgs? get empty list - with self.current_user(self.nobody_django_user): - response = self.get(url, expect=200) - self.check_pagination_and_size(response, 0, previous=None, next=None) - - def test_get_item(self): - - # first get all the URLs - data = self.get(self.collection(), expect=200, auth=self.get_super_credentials()) - urls = [item['url'] for item in data['results']] - - # make sure super user can fetch records - data = self.get(urls[0], expect=200, auth=self.get_super_credentials()) - [self.assertTrue(key in data) for key in ['name', 'description', 'url']] - - # make sure invalid user cannot - data = self.get(urls[0], expect=401, auth=self.get_invalid_credentials()) - - # normal user should be able to get org 0 and org 1 but not org 9 (as he's not a user or admin of it) - data = self.get(urls[0], expect=200, auth=self.get_normal_credentials()) - data = self.get(urls[1], expect=200, auth=self.get_normal_credentials()) - data = self.get(urls[9], expect=403, auth=self.get_normal_credentials()) - - # other user is a member, but not admin, can access org - data = self.get(urls[0], expect=200, auth=self.get_other_credentials()) - - # nobody user is not a member, cannot access org - data = self.get(urls[0], expect=403, auth=self.get_nobody_credentials()) - - def test_get_item_subobjects_projects(self): - - # first get all the orgs - orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials()) - - # find projects attached to the first org - projects0_url = orgs['results'][0]['related']['projects'] - projects1_url = orgs['results'][1]['related']['projects'] - projects9_url = orgs['results'][9]['related']['projects'] - - self.get(projects0_url, expect=401, auth=None) - self.get(projects0_url, expect=401, auth=self.get_invalid_credentials()) - - # normal user is just a member of the first org, so can see all projects under the org - self.get(projects0_url, expect=200, auth=self.get_normal_credentials()) - - # however in the second org, he's an admin and should see all of them - projects1a = self.get(projects1_url, expect=200, auth=self.get_normal_credentials()) - self.assertEquals(projects1a['count'], 5) - - # but the non-admin cannot access the list of projects in the org. He should use /projects/ instead! - self.get(projects1_url, expect=200, auth=self.get_other_credentials()) - - # superuser should be able to read anything - projects9a = self.get(projects9_url, expect=200, auth=self.get_super_credentials()) - self.assertEquals(projects9a['count'], 1) - - # nobody user is not a member of any org, so can't see projects... - self.get(projects0_url, expect=403, auth=self.get_nobody_credentials()) - projects1a = self.get(projects1_url, expect=403, auth=self.get_nobody_credentials()) - - def test_get_item_subobjects_users(self): - - # see if we can list the users added to the organization - orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials()) - org1_users_url = orgs['results'][1]['related']['users'] - org1_users = self.get(org1_users_url, expect=200, auth=self.get_normal_credentials()) - self.assertEquals(org1_users['count'], 2) - org1_users = self.get(org1_users_url, expect=200, auth=self.get_super_credentials()) - self.assertEquals(org1_users['count'], 2) - org1_users = self.get(org1_users_url, expect=200, auth=self.get_other_credentials()) - self.assertEquals(org1_users['count'], 2) - - def test_get_item_subobjects_admins(self): - - # see if we can list the users added to the organization - orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials()) - org1_users_url = orgs['results'][1]['related']['admins'] - org1_users = self.get(org1_users_url, expect=200, auth=self.get_normal_credentials()) - self.assertEquals(org1_users['count'], 2) - org1_users = self.get(org1_users_url, expect=200, auth=self.get_super_credentials()) - self.assertEquals(org1_users['count'], 2) - - def test_get_organization_inventories_list(self): - pass - - def _test_get_item_subobjects_tags(self): - # FIXME: Update to support taggit! - - # put some tags on the org - org1 = Organization.objects.get(pk=2) - tag1 = Tag.objects.create(name='atag') - tag2 = Tag.objects.create(name='btag') - org1.tags.add(tag1) - org1.tags.add(tag2) - - # see if we can list the users added to the organization - orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials()) - org1_tags_url = orgs['results'][1]['related']['tags'] - org1_tags = self.get(org1_tags_url, expect=200, auth=self.get_normal_credentials()) - self.assertEquals(org1_tags['count'], 2) - org1_tags = self.get(org1_tags_url, expect=200, auth=self.get_super_credentials()) - self.assertEquals(org1_tags['count'], 2) - org1_tags = self.get(org1_tags_url, expect=403, auth=self.get_other_credentials()) - - def _test_get_item_subobjects_audit_trail(self): - # FIXME: Update to support whatever audit trail framework is used. - url = '/api/v1/organizations/2/audit_trail/' - self.get(url, expect=200, auth=self.get_normal_credentials()) - # FIXME: verify that some audit trail records are auto-created on save AND post - - def test_post_item(self): - - new_org = dict(name='magic test org', description='8675309') - - # need to be a valid user - self.post(self.collection(), new_org, expect=401, auth=None) - self.post(self.collection(), new_org, expect=401, auth=self.get_invalid_credentials()) - - # only super users can create organizations - self.post(self.collection(), new_org, expect=403, auth=self.get_normal_credentials()) - self.post(self.collection(), new_org, expect=403, auth=self.get_other_credentials()) - data1 = self.post(self.collection(), new_org, expect=201, auth=self.get_super_credentials()) - - # duplicate post results in 400 - response = self.post(self.collection(), new_org, expect=400, auth=self.get_super_credentials()) - self.assertTrue('name' in response, response) - self.assertTrue('Name' in response['name'][0], response) - - # look at what we got back from the post, make sure we added an org - last_org = Organization.objects.order_by('-pk')[0] - self.assertTrue(data1['url'].endswith("/%d/" % last_org.pk)) - - # Test that not even super users can create an organization with a basic license - self.create_basic_license_file() - cant_org = dict(name='silly user org', description='4815162342') - self.post(self.collection(), cant_org, expect=402, auth=self.get_super_credentials()) - - def test_post_item_subobjects_users(self): - - url = reverse('api:organization_users_list', args=(self.organizations[1].pk,)) - users = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(users['count'], 2) - self.post(url, dict(id=self.normal_django_user.pk), expect=204, auth=self.get_normal_credentials()) - users = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(users['count'], 3) - self.post(url, dict(id=self.normal_django_user.pk, disassociate=True), expect=204, auth=self.get_normal_credentials()) - users = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(users['count'], 2) - - # post a completely new user to verify we can add users to the subcollection directly - new_user = dict(username='NewUser9000', password='NewPassword9000') - which_org = Organization.accessible_objects(self.normal_django_user, 'admin_role')[0] - url = reverse('api:organization_users_list', args=(which_org.pk,)) - self.post(url, new_user, expect=201, auth=self.get_normal_credentials()) - - all_users = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(all_users['count'], 3) - - def test_post_item_subobjects_admins(self): - - url = reverse('api:organization_admins_list', args=(self.organizations[1].pk,)) - admins = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(admins['count'], 2) - self.post(url, dict(id=self.other_django_user.pk), expect=204, auth=self.get_normal_credentials()) - admins = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(admins['count'], 3) - self.post(url, dict(id=self.other_django_user.pk, disassociate=1), expect=204, auth=self.get_normal_credentials()) - admins = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(admins['count'], 2) - - def _test_post_item_subobjects_tags(self): - # FIXME: Update to support taggit! - - tag = Tag.objects.create(name='blippy') - url = '/api/v1/organizations/2/tags/' - tags = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(tags['count'], 0) - self.post(url, dict(id=tag.pk), expect=204, auth=self.get_normal_credentials()) - tags = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(tags['count'], 1) - self.assertEqual(tags['results'][0]['id'], tag.pk) - self.post(url, dict(id=tag.pk, disassociate=1), expect=204, auth=self.get_normal_credentials()) - tags = self.get(url, expect=200, auth=self.get_normal_credentials()) - self.assertEqual(tags['count'], 0) - - def _test_post_item_subobjects_audit_trail(self): - # FIXME: Update to support whatever audit trail framework is used. - # audit trails are system things, and no user can post to them. - url = '/api/v1/organizations/2/audit_trail/' - self.post(url, dict(id=1), expect=405, auth=self.get_super_credentials()) - - def test_put_item(self): - - # first get some urls and data to put back to them - urls = self.get_urls(self.collection(), auth=self.get_super_credentials()) - self.get(urls[0], expect=200, auth=self.get_super_credentials()) - data1 = self.get(urls[1], expect=200, auth=self.get_super_credentials()) - - # test that an unauthenticated user cannot do a put - new_data1 = data1.copy() - new_data1['description'] = 'updated description' - self.put(urls[0], new_data1, expect=401, auth=None) - self.put(urls[0], new_data1, expect=401, auth=self.get_invalid_credentials()) - - # user normal is an admin of org 0 and a member of org 1 so should be able to put only org 1 - self.put(urls[0], new_data1, expect=403, auth=self.get_normal_credentials()) - self.put(urls[1], new_data1, expect=200, auth=self.get_normal_credentials()) - - # get back org 1 and see if it changed - get_result = self.get(urls[1], expect=200, auth=self.get_normal_credentials()) - self.assertEquals(get_result['description'], 'updated description') - - # super user can also put even though they aren't added to the org users or admins list - self.put(urls[1], new_data1, expect=200, auth=self.get_super_credentials()) - - # make sure posting to this URL is not supported - self.post(urls[1], new_data1, expect=405, auth=self.get_super_credentials()) - - def test_put_item_subobjects_projects(self): - - # any attempt to put a subobject should be a 405, edit the actual resource or POST with 'disassociate' to delete - # this is against a collection URL anyway, so we really need not repeat this test for other object types - # as a PUT against a collection doesn't make much sense. - - orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials()) - projects0_url = orgs['results'][0]['related']['projects'] - sub_projects = self.get(projects0_url, expect=200, auth=self.get_super_credentials()) - self.assertEquals(sub_projects['count'], 3) - first_sub_project = sub_projects['results'][0] - self.put(projects0_url, first_sub_project, expect=405, auth=self.get_super_credentials()) - - def test_delete_item(self): - - # first get some urls - urls = self.get_urls(self.collection(), auth=self.get_super_credentials()) - urldata1 = self.get(urls[1], auth=self.get_super_credentials()) - - # check authentication -- admins of the org and superusers can delete objects only - self.delete(urls[0], expect=401, auth=None) - self.delete(urls[0], expect=401, auth=self.get_invalid_credentials()) - self.delete(urls[8], expect=403, auth=self.get_normal_credentials()) - self.delete(urls[1], expect=204, auth=self.get_normal_credentials()) - self.delete(urls[0], expect=204, auth=self.get_super_credentials()) - - # check that when we have deleted an object it comes back 404 via GET - self.get(urls[1], expect=404, auth=self.get_normal_credentials()) - assert Organization.objects.filter(pk=urldata1['id']).count() == 0 - - # also check that DELETE on the collection doesn't work - self.delete(self.collection(), expect=405, auth=self.get_super_credentials()) - - # Test that not even super users can delete an organization with a basic license - self.create_basic_license_file() - self.delete(urls[2], expect=402, auth=self.get_super_credentials()) - - def test_invalid_post_data(self): - url = reverse('api:organization_list') - # API should gracefully handle data of an invalid type. - self.post(url, expect=400, data=None, auth=self.get_super_credentials()) - self.post(url, expect=400, data=99, auth=self.get_super_credentials()) - self.post(url, expect=400, data='abcd', auth=self.get_super_credentials()) - self.post(url, expect=400, data=3.14, auth=self.get_super_credentials()) - self.post(url, expect=400, data=True, auth=self.get_super_credentials()) - self.post(url, expect=400, data=[1,2,3], auth=self.get_super_credentials()) - url = reverse('api:organization_users_list', args=(self.organizations[0].pk,)) - self.post(url, expect=400, data=None, auth=self.get_super_credentials()) - self.post(url, expect=400, data=99, auth=self.get_super_credentials()) - self.post(url, expect=400, data='abcd', auth=self.get_super_credentials()) - self.post(url, expect=400, data=3.14, auth=self.get_super_credentials()) - self.post(url, expect=400, data=True, auth=self.get_super_credentials()) - self.post(url, expect=400, data=[1,2,3], auth=self.get_super_credentials()) - -# TODO: tests for tag disassociation diff --git a/awx/main/tests/old/unified_jobs.py b/awx/main/tests/old/unified_jobs.py deleted file mode 100644 index dad9acb3ea..0000000000 --- a/awx/main/tests/old/unified_jobs.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved - -# Python -import mock -from mock import Mock -from StringIO import StringIO -from django.utils.timezone import now - -# Django -from django.test import SimpleTestCase - -# AWX -from awx.main.models import * # noqa - -__all__ = ['UnifiedJobsUnitTest',] - -class UnifiedJobsUnitTest(SimpleTestCase): - - # stdout file present - @mock.patch('os.path.exists', return_value=True) - @mock.patch('codecs.open', return_value='my_file_handler') - def test_result_stdout_raw_handle_file__found(self, exists, open): - unified_job = UnifiedJob() - unified_job.result_stdout_file = 'dummy' - - with mock.patch('os.stat', return_value=Mock(st_size=1)): - result = unified_job.result_stdout_raw_handle() - - self.assertEqual(result, 'my_file_handler') - - # stdout file missing, job finished - @mock.patch('os.path.exists', return_value=False) - def test_result_stdout_raw_handle__missing(self, exists): - unified_job = UnifiedJob() - unified_job.result_stdout_file = 'dummy' - unified_job.finished = now() - - result = unified_job.result_stdout_raw_handle() - - self.assertIsInstance(result, StringIO) - self.assertEqual(result.read(), 'stdout capture is missing') - - # stdout file missing, job not finished - @mock.patch('os.path.exists', return_value=False) - def test_result_stdout_raw_handle__pending(self, exists): - unified_job = UnifiedJob() - unified_job.result_stdout_file = 'dummy' - unified_job.finished = None - - result = unified_job.result_stdout_raw_handle() - - self.assertIsInstance(result, StringIO) - self.assertEqual(result.read(), 'Waiting for results...') - diff --git a/awx/main/tests/old/views.py b/awx/main/tests/old/views.py deleted file mode 100644 index 4ccd6f64ec..0000000000 --- a/awx/main/tests/old/views.py +++ /dev/null @@ -1,113 +0,0 @@ -# Django -from django.core.urlresolvers import reverse - -# Reuse Test code -from awx.main.tests.base import ( - BaseLiveServerTest, - QueueStartStopTestMixin, - URI, -) -from awx.main.models.projects import * # noqa - -__all__ = ['UnifiedJobStdoutRedactedTests'] - - -TEST_STDOUTS = [] -uri = URI(scheme="https", username="Dhh3U47nmC26xk9PKscV", password="PXPfWW8YzYrgS@E5NbQ2H@", host="github.ginger.com/theirrepo.git/info/refs") -TEST_STDOUTS.append({ - 'description': 'uri in a plain text document', - 'uri' : uri, - 'text' : 'hello world %s goodbye world' % uri, - 'occurrences' : 1 -}) - -uri = URI(scheme="https", username="applepie@@@", password="thatyouknow@@@@", host="github.ginger.com/theirrepo.git/info/refs") -TEST_STDOUTS.append({ - 'description': 'uri appears twice in a multiline plain text document', - 'uri' : uri, - 'text' : 'hello world %s \n\nyoyo\n\nhello\n%s' % (uri, uri), - 'occurrences' : 2 -}) - -class UnifiedJobStdoutRedactedTests(BaseLiveServerTest, QueueStartStopTestMixin): - - def setUp(self): - super(UnifiedJobStdoutRedactedTests, self).setUp() - self.setup_instances() - self.setup_users() - self.test_cases = [] - self.negative_test_cases = [] - - proj = self.make_project() - - for e in TEST_STDOUTS: - e['project'] = ProjectUpdate(project=proj) - e['project'].result_stdout_text = e['text'] - e['project'].save() - self.test_cases.append(e) - for d in TEST_STDOUTS: - d['job'] = self.make_job() - d['job'].result_stdout_text = d['text'] - d['job'].save() - self.negative_test_cases.append(d) - - # This is more of a functional test than a unit test. - # should filter out username and password - def check_sensitive_redacted(self, test_data, response): - uri = test_data['uri'] - self.assertIsNotNone(response['content']) - self.check_not_found(response['content'], uri.username, test_data['description']) - self.check_not_found(response['content'], uri.password, test_data['description']) - # Ensure the host didn't get redacted - self.check_found(response['content'], uri.host, test_data['occurrences'], test_data['description']) - - def check_sensitive_not_redacted(self, test_data, response): - uri = test_data['uri'] - self.assertIsNotNone(response['content']) - self.check_found(response['content'], uri.username, description=test_data['description']) - self.check_found(response['content'], uri.password, description=test_data['description']) - - def _get_url_job_stdout(self, job, url_base, format='json'): - formats = { - 'json': 'application/json', - 'ansi': 'text/plain', - 'txt': 'text/plain', - 'html': 'text/html', - } - content_type = formats[format] - project_update_stdout_url = reverse(url_base, args=(job.pk,)) + "?format=" + format - return self.get(project_update_stdout_url, expect=200, auth=self.get_super_credentials(), accept=content_type) - - def _test_redaction_enabled(self, format): - for test_data in self.test_cases: - response = self._get_url_job_stdout(test_data['project'], "api:project_update_stdout", format=format) - self.check_sensitive_redacted(test_data, response) - - def _test_redaction_disabled(self, format): - for test_data in self.negative_test_cases: - response = self._get_url_job_stdout(test_data['job'], "api:job_stdout", format=format) - self.check_sensitive_not_redacted(test_data, response) - - def test_project_update_redaction_enabled_json(self): - self._test_redaction_enabled('json') - - def test_project_update_redaction_enabled_ansi(self): - self._test_redaction_enabled('ansi') - - def test_project_update_redaction_enabled_html(self): - self._test_redaction_enabled('html') - - def test_project_update_redaction_enabled_txt(self): - self._test_redaction_enabled('txt') - - def test_job_redaction_disabled_json(self): - self._test_redaction_disabled('json') - - def test_job_redaction_disabled_ansi(self): - self._test_redaction_disabled('ansi') - - def test_job_redaction_disabled_html(self): - self._test_redaction_disabled('html') - - def test_job_redaction_disabled_txt(self): - self._test_redaction_disabled('txt') diff --git a/awx/main/tests/unit/test_ha.py b/awx/main/tests/unit/test_ha.py new file mode 100644 index 0000000000..07249a67fb --- /dev/null +++ b/awx/main/tests/unit/test_ha.py @@ -0,0 +1,15 @@ +# Copyright (c) 2016 Ansible, Inc. + +# Python +import mock + +# AWX +from awx.main.ha import is_ha_environment + +@mock.patch('awx.main.models.Instance.objects.count', lambda: 2) +def test_multiple_instances(): + assert is_ha_environment() + +@mock.patch('awx.main.models.Instance.objects.count', lambda: 1) +def test_db_localhost(): + assert is_ha_environment() is False diff --git a/awx/main/tests/old/redact.py b/awx/main/tests/unit/test_redact.py similarity index 61% rename from awx/main/tests/old/redact.py rename to awx/main/tests/unit/test_redact.py index 3ce38bc7ad..3535869ee1 100644 --- a/awx/main/tests/old/redact.py +++ b/awx/main/tests/unit/test_redact.py @@ -1,11 +1,8 @@ - import textwrap # AWX from awx.main.redact import UriCleaner -from awx.main.tests.base import BaseTest, URI - -__all__ = ['UriCleanTests'] +from awx.main.tests.URI import URI TEST_URIS = [ URI('no host', scheme='https', username='myusername', password='mypass', host=None), @@ -80,59 +77,58 @@ TEST_CLEARTEXT.append({ 'host_occurrences' : 4 }) -class UriCleanTests(BaseTest): - # should redact sensitive usernames and passwords - def test_uri_scm_simple_redacted(self): - for uri in TEST_URIS: - redacted_str = UriCleaner.remove_sensitive(str(uri)) - if uri.username: - self.check_not_found(redacted_str, uri.username, uri.description) - if uri.password: - self.check_not_found(redacted_str, uri.password, uri.description) - - # should replace secret data with safe string, UriCleaner.REPLACE_STR - def test_uri_scm_simple_replaced(self): - for uri in TEST_URIS: - redacted_str = UriCleaner.remove_sensitive(str(uri)) - self.check_found(redacted_str, UriCleaner.REPLACE_STR, uri.get_secret_count()) - - # should redact multiple uris in text - def test_uri_scm_multiple(self): - cleartext = '' - for uri in TEST_URIS: - cleartext += str(uri) + ' ' - for uri in TEST_URIS: - cleartext += str(uri) + '\n' +# should redact sensitive usernames and passwords +def test_uri_scm_simple_redacted(): + for uri in TEST_URIS: redacted_str = UriCleaner.remove_sensitive(str(uri)) if uri.username: - self.check_not_found(redacted_str, uri.username, uri.description) + assert uri.username not in redacted_str if uri.password: - self.check_not_found(redacted_str, uri.password, uri.description) + assert uri.username not in redacted_str - # should replace multiple secret data with safe string - def test_uri_scm_multiple_replaced(self): - cleartext = '' - find_count = 0 - for uri in TEST_URIS: - cleartext += str(uri) + ' ' - find_count += uri.get_secret_count() +# should replace secret data with safe string, UriCleaner.REPLACE_STR +def test_uri_scm_simple_replaced(): + for uri in TEST_URIS: + redacted_str = UriCleaner.remove_sensitive(str(uri)) + assert redacted_str.count(UriCleaner.REPLACE_STR) == uri.get_secret_count() - for uri in TEST_URIS: - cleartext += str(uri) + '\n' - find_count += uri.get_secret_count() +# should redact multiple uris in text +def test_uri_scm_multiple(): + cleartext = '' + for uri in TEST_URIS: + cleartext += str(uri) + ' ' + for uri in TEST_URIS: + cleartext += str(uri) + '\n' - redacted_str = UriCleaner.remove_sensitive(cleartext) - self.check_found(redacted_str, UriCleaner.REPLACE_STR, find_count) + redacted_str = UriCleaner.remove_sensitive(str(uri)) + if uri.username: + assert uri.username not in redacted_str + if uri.password: + assert uri.username not in redacted_str - # should redact and replace multiple secret data within a complex cleartext blob - def test_uri_scm_cleartext_redact_and_replace(self): - for test_data in TEST_CLEARTEXT: - uri = test_data['uri'] - redacted_str = UriCleaner.remove_sensitive(test_data['text']) - self.check_not_found(redacted_str, uri.username, uri.description) - self.check_not_found(redacted_str, uri.password, uri.description) - # Ensure the host didn't get redacted - self.check_found(redacted_str, uri.host, test_data['host_occurrences'], uri.description) +# should replace multiple secret data with safe string +def test_uri_scm_multiple_replaced(): + cleartext = '' + find_count = 0 + for uri in TEST_URIS: + cleartext += str(uri) + ' ' + find_count += uri.get_secret_count() + for uri in TEST_URIS: + cleartext += str(uri) + '\n' + find_count += uri.get_secret_count() + + redacted_str = UriCleaner.remove_sensitive(cleartext) + assert redacted_str.count(UriCleaner.REPLACE_STR) == find_count + +# should redact and replace multiple secret data within a complex cleartext blob +def test_uri_scm_cleartext_redact_and_replace(): + for test_data in TEST_CLEARTEXT: + uri = test_data['uri'] + redacted_str = UriCleaner.remove_sensitive(test_data['text']) + assert uri.username not in redacted_str + assert uri.password not in redacted_str + # Ensure the host didn't get redacted + assert redacted_str.count(uri.host) == test_data['host_occurrences'] diff --git a/awx/main/tests/unit/test_unified_jobs.py b/awx/main/tests/unit/test_unified_jobs.py new file mode 100644 index 0000000000..edd6978b47 --- /dev/null +++ b/awx/main/tests/unit/test_unified_jobs.py @@ -0,0 +1,48 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved + +# Python +import mock +from mock import Mock +from StringIO import StringIO +from django.utils.timezone import now + +# AWX +from awx.main.models import UnifiedJob + + +# stdout file present +@mock.patch('os.path.exists', return_value=True) +@mock.patch('codecs.open', return_value='my_file_handler') +def test_result_stdout_raw_handle_file__found(exists, open): + unified_job = UnifiedJob() + unified_job.result_stdout_file = 'dummy' + + with mock.patch('os.stat', return_value=Mock(st_size=1)): + result = unified_job.result_stdout_raw_handle() + + assert result == 'my_file_handler' + +# stdout file missing, job finished +@mock.patch('os.path.exists', return_value=False) +def test_result_stdout_raw_handle__missing(exists): + unified_job = UnifiedJob() + unified_job.result_stdout_file = 'dummy' + unified_job.finished = now() + + result = unified_job.result_stdout_raw_handle() + + assert isinstance(result, StringIO) + assert result.read() == 'stdout capture is missing' + +# stdout file missing, job not finished +@mock.patch('os.path.exists', return_value=False) +def test_result_stdout_raw_handle__pending(exists): + unified_job = UnifiedJob() + unified_job.result_stdout_file = 'dummy' + unified_job.finished = None + + result = unified_job.result_stdout_raw_handle() + + assert isinstance(result, StringIO) + assert result.read() == 'Waiting for results...' diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index f7cf9f0c58..2e27a664ea 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -25,6 +25,7 @@ DATABASES = { 'NAME': 'awx-dev', 'USER': 'awx-dev', 'PASSWORD': 'AWXsome1', + 'ATOMIC_REQUESTS': True, 'HOST': 'postgres', 'PORT': '', } diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index f2120c139e..6282ab340e 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -80,6 +80,11 @@ table, tbody { border-top:0px!important; } +.List-tableCell.description-column { + padding-top: 15px; + padding-bottom: 15px; +} + .List-actionButtonCell { padding-top:5px; padding-right: 15px; diff --git a/awx/ui/client/legacy-styles/text-label.less b/awx/ui/client/legacy-styles/text-label.less index 63aee6c9a3..57d50f3f8b 100644 --- a/awx/ui/client/legacy-styles/text-label.less +++ b/awx/ui/client/legacy-styles/text-label.less @@ -1,7 +1,17 @@ -@import "src/shared/text-label.less"; +@import "src/shared/branding/colors.default.less"; .host-disabled-label { &:after { - .include-text-label(#676767; white; "disabled"); + display: inline-block; + content: "disabled"; + border-radius: 3px; + color: @default-icon; + text-transform: uppercase; + font-size: .7em; + font-style: normal; + margin-left: 0.5em; + padding: 0.35em; + padding-bottom: 0.2em; + line-height: 1.1; } } diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html index 725a6e273e..cc2f7ee0c7 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html +++ b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html @@ -105,17 +105,17 @@ diff --git a/awx/ui/client/src/access/roleList.block.less b/awx/ui/client/src/access/roleList.block.less index 9b185148b0..5cacfb1814 100644 --- a/awx/ui/client/src/access/roleList.block.less +++ b/awx/ui/client/src/access/roleList.block.less @@ -53,6 +53,10 @@ cursor: pointer; } +.RoleList-tag--team { + cursor: default; +} + .RoleList-tagDelete { font-size: 13px; color: @default-bg; diff --git a/awx/ui/client/src/access/roleList.partial.html b/awx/ui/client/src/access/roleList.partial.html index f050af8b38..1478c9dc37 100644 --- a/awx/ui/client/src/access/roleList.partial.html +++ b/awx/ui/client/src/access/roleList.partial.html @@ -7,8 +7,10 @@ class="fa fa-times RoleList-tagDelete">
+ ng-class="{'RoleList-tag--deletable': entry.explicit, + 'RoleList-tag--team': entry.team_id}" + aw-tool-tip='{{entry.team_name | sanitize}}' aw-tip-placement='bottom'> {{ entry.name }} - +
diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 8300d7d09c..0698a2fdbb 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -255,6 +255,16 @@ var tower = angular.module('Tower', [ }); }); }] + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); } }). @@ -264,6 +274,16 @@ var tower = angular.module('Tower', [ controller: JobsListController, ncyBreadcrumb: { label: "JOBS" + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); } }). @@ -521,11 +541,12 @@ var tower = angular.module('Tower', [ 'ClearScope', 'Socket', 'LoadConfig', 'Store', 'ShowSocketHelp', 'pendoService', 'Prompt', 'Rest', 'Wait', 'ProcessErrors', '$state', 'GetBasePath', 'ConfigService', - 'FeaturesService', + 'FeaturesService', '$filter', function ($q, $compile, $cookieStore, $rootScope, $log, CheckLicense, $location, Authorization, LoadBasePaths, Timer, ClearScope, Socket, LoadConfig, Store, ShowSocketHelp, pendoService, Prompt, Rest, Wait, - ProcessErrors, $state, GetBasePath, ConfigService, FeaturesService) { + ProcessErrors, $state, GetBasePath, ConfigService, FeaturesService, + $filter) { var sock; $rootScope.addPermission = function (scope) { $compile("")(scope); @@ -563,7 +584,7 @@ var tower = angular.module('Tower', [ if (accessListEntry.team_id) { Prompt({ hdr: `Team access removal`, - body: `
Please confirm that you would like to remove ${entry.name} access from the team ${entry.team_name}. This will affect all members of the team. If you would like to only remove access for this particular user, please remove them from the team.
`, + body: `
Please confirm that you would like to remove ${entry.name} access from the team ${$filter('sanitize')(entry.team_name)}. This will affect all members of the team. If you would like to only remove access for this particular user, please remove them from the team.
`, action: action, actionText: 'REMOVE TEAM ACCESS' }); @@ -859,6 +880,7 @@ var tower = angular.module('Tower', [ $rootScope.$broadcast("EditIndicatorChange", list, id); } else if ($rootScope.addedAnItem) { delete $rootScope.addedAnItem; + $rootScope.$broadcast("RemoveIndicator"); } else { $rootScope.$broadcast("RemoveIndicator"); } @@ -879,6 +901,7 @@ var tower = angular.module('Tower', [ } // If browser refresh, set the user_is_superuser value $rootScope.user_is_superuser = Authorization.getUserInfo('is_superuser'); + $rootScope.user_is_system_auditor = Authorization.getUserInfo('is_system_auditor'); // state the user refreshes we want to open the socket, except if the user is on the login page, which should happen after the user logs in (see the AuthService module for that call to OpenSocket) if(!_.contains($location.$$url, '/login')){ ConfigService.getConfig().then(function(){ diff --git a/awx/ui/client/src/bread-crumb/bread-crumb.directive.js b/awx/ui/client/src/bread-crumb/bread-crumb.directive.js index 1cf035550a..cfc6630412 100644 --- a/awx/ui/client/src/bread-crumb/bread-crumb.directive.js +++ b/awx/ui/client/src/bread-crumb/bread-crumb.directive.js @@ -6,7 +6,7 @@ export default templateUrl: templateUrl('bread-crumb/bread-crumb'), link: function(scope) { - var streamConfig = {}; + var streamConfig = {}, originalRoute; scope.showActivityStreamButton = false; scope.showRefreshButton = false; @@ -30,16 +30,15 @@ export default stateGoParams.id = $state.params[streamConfig.activityStreamId]; } } - + originalRoute = $state.current; $state.go('activityStream', stateGoParams); } // The user is navigating away from the activity stream - take them back from whence they came else { // Pull the previous state out of local storage - var previousState = Store('previous_state'); - if(previousState && !Empty(previousState.name)) { - $state.go(previousState.name, previousState.fromParams); + if(originalRoute) { + $state.go(originalRoute.name, originalRoute.fromParams); } else { // If for some reason something went wrong (like local storage was wiped, etc) take the diff --git a/awx/ui/client/src/controllers/Projects.js b/awx/ui/client/src/controllers/Projects.js index 0fb826906a..1b641ab97a 100644 --- a/awx/ui/client/src/controllers/Projects.js +++ b/awx/ui/client/src/controllers/Projects.js @@ -391,6 +391,10 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l defaultUrl = GetBasePath('projects'), master = {}; + // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); + CredentialList.fields.kind.noSearch = true; + generator.inject(form, { mode: 'add', related: false, scope: $scope }); generator.reset(); @@ -567,6 +571,11 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, id = $stateParams.id, relatedSets = {}; + // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); + CredentialList.fields.kind.noSearch = true; + + SchedulesList.well = false; generator.inject(form, { mode: 'edit', diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.controller.js b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.controller.js index c70d7d39ad..f77764903b 100644 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.controller.js +++ b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.controller.js @@ -22,7 +22,7 @@ $scope.formSave = function(){ var host = { id: $scope.host.id, - variables: $scope.extraVars === '---' || $scope.extraVars === '{}' ? null : $scope.extraVars, + variables: $scope.variables === '---' || $scope.variables === '{}' ? null : $scope.variables, name: $scope.name, description: $scope.description, enabled: $scope.host.enabled @@ -34,15 +34,14 @@ }; var init = function(){ $scope.host = host; - $scope.extraVars = host.variables === '' ? '---' : host.variables; generator.inject(form, {mode: 'edit', related: false, scope: $scope}); - $scope.extraVars = $scope.host.variables === '' ? '---' : $scope.host.variables; $scope.name = host.name; $scope.description = host.description; + $scope.variables = host.variables === '' ? '---' : host.variables; ParseTypeChange({ scope: $scope, field_id: 'host_variables', - variable: 'extraVars', + variable: 'variables', }); }; 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 43970c30b4..bbc57dc275 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', - function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts){ + 'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope', + function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope){ var setJobStatus = function(){ _.forEach($scope.hosts, function(value){ SetStatus({ @@ -38,6 +38,20 @@ export default }); setJobStatus(); }); + var cleanUpStateChangeListener = $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams) { + if (toState.name === "dashboardHosts.edit") { + $scope.rowBeingEdited = toParams.id; + $scope.listBeingEdited = "hosts"; + } + else { + delete $scope.rowBeingEdited; + delete $scope.listBeingEdited; + } + }); + // Remove the listener when the scope is destroyed to avoid a memory leak + $scope.$on('$destroy', function() { + cleanUpStateChangeListener(); + }); var init = function(){ $scope.list = list; $scope.host_active_search = false; @@ -59,6 +73,10 @@ export default iterator: list.iterator }); $scope.hostLoading = false; + if($state.current.name === "dashboardHosts.edit") { + $scope.rowBeingEdited = $state.params.id; + $scope.listBeingEdited = "hosts"; + } }; init(); }]; diff --git a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html index da60bce8cf..9215dc8332 100644 --- a/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html +++ b/awx/ui/client/src/dashboard/lists/job-templates/job-templates-list.partial.html @@ -11,7 +11,7 @@ -
- Title + Name Activity @@ -32,13 +32,15 @@ - - + +
+ + +
diff --git a/awx/ui/client/src/dashboard/lists/jobs/jobs-list.partial.html b/awx/ui/client/src/dashboard/lists/jobs/jobs-list.partial.html index 27be4c729e..313a799ab7 100644 --- a/awx/ui/client/src/dashboard/lists/jobs/jobs-list.partial.html +++ b/awx/ui/client/src/dashboard/lists/jobs/jobs-list.partial.html @@ -10,7 +10,7 @@
- + { + val.status_tip = 'Job ' + val.status + ". Click for details."; + }); + }; + GenerateList.inject(list, { mode: 'edit', id: id, @@ -260,6 +266,7 @@ export default scope.$on('PostRefresh', function(){ JobsControllerInit({ scope: scope, parent_scope: parent_scope }); JobsListUpdate({ scope: scope, parent_scope: parent_scope, list: list }); + buildTooltips(scope.jobs); parent_scope.$emit('listLoaded'); }); @@ -443,7 +450,7 @@ export default return function(params) { var scope = params.scope, id = params.id; - InitiatePlaybookRun({ scope: scope, id: id }); + InitiatePlaybookRun({ scope: scope, id: id, relaunch: true }); }; }]) diff --git a/awx/ui/client/src/helpers/Parse.js b/awx/ui/client/src/helpers/Parse.js index 8057270b4a..c484963162 100644 --- a/awx/ui/client/src/helpers/Parse.js +++ b/awx/ui/client/src/helpers/Parse.js @@ -71,7 +71,7 @@ export default } catch (e) { Alert('Parse Error', 'Failed to parse valid YAML. ' + e.message); - setTimeout( function() { scope.$apply( function() { scope[model] = 'yaml'; createField(); }); }, 500); + setTimeout( function() { scope.$apply( function() { scope[model] = 'yaml'; createField(onReady, onChange, fld); }); }, 500); } } else { @@ -89,7 +89,7 @@ export default } catch (e) { Alert('Parse Error', 'Failed to parse valid JSON. ' + e.message); - setTimeout( function() { scope.$apply( function() { scope[model] = 'json'; createField(); }); }, 500 ); + setTimeout( function() { scope.$apply( function() { scope[model] = 'json'; createField(onReady, onChange, fld); }); }, 500 ); } } }; diff --git a/awx/ui/client/src/helpers/Projects.js b/awx/ui/client/src/helpers/Projects.js index 37f2d5823b..816fd73ba3 100644 --- a/awx/ui/client/src/helpers/Projects.js +++ b/awx/ui/client/src/helpers/Projects.js @@ -70,8 +70,11 @@ export default result = 'Success! Click for details'; break; case 'failed': - case 'missing': result = 'Failed. Click for details'; + break; + case 'missing': + result = 'Missing. 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 096ea2ad31..a5d2b00a01 100644 --- a/awx/ui/client/src/inventories/list/inventory-list.controller.js +++ b/awx/ui/client/src/inventories/list/inventory-list.controller.js @@ -197,7 +197,7 @@ function InventoriesList($scope, $rootScope, $location, $log, ". Click for details\" aw-tip-placement=\"top\">\n"; html += ""; html += ""; + ". Click for details\" aw-tip-placement=\"top\">" + $filter('sanitize')(ellipsis(row.name)) + ""; html += "\n"; }); html += "\n"; @@ -230,16 +230,16 @@ function InventoriesList($scope, $rootScope, $location, $log, data.results.forEach( function(row) { if (row.related.last_update) { html += ""; - html += ""; + html += ``; html += ""; - html += ""; + html += ""; html += "\n"; } else { html += ""; html += ""; html += ""; - html += ""; + html += ""; html += "\n"; } }); diff --git a/awx/ui/client/src/inventories/manage/adhoc/adhoc.form.js b/awx/ui/client/src/inventories/manage/adhoc/adhoc.form.js index 13e74be4d9..20c1aa6941 100644 --- a/awx/ui/client/src/inventories/manage/adhoc/adhoc.form.js +++ b/awx/ui/client/src/inventories/manage/adhoc/adhoc.form.js @@ -44,7 +44,7 @@ export default function() { autocomplete: false }, limit: { - label: 'Host Pattern', + label: 'Limit', type: 'text', addRequired: false, awPopOver: '

The pattern used to target hosts in the ' + @@ -54,7 +54,7 @@ export default function() { 'here.

', - dataTitle: 'Host Pattern', + dataTitle: 'Limit', dataPlacement: 'right', dataContainer: 'body' }, @@ -98,7 +98,7 @@ export default function() { addRequired: true, awPopOver:'

These are the verbosity levels for standard ' + 'out of the command run that are supported.', - dataTitle: 'Module', + dataTitle: 'Verbosity', dataPlacement: 'right', dataContainer: 'body', "default": 1 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 7cc1920daf..a816cacd3a 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 @@ -12,6 +12,10 @@ var generator = GenerateForm, form = GroupForm(); + // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); + CredentialList.fields.kind.noSearch = true; + $scope.formCancel = function(){ $state.go('^'); }; @@ -118,7 +122,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 !== 'manual' && source !== 'custom' ? true : false; + $scope.cloudCredentialRequired = source !== '' && source !== 'custom' ? 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 266eef430a..b008c0f888 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 @@ -13,6 +13,11 @@ GroupManageService, GetChoices, GetBasePath, CreateSelect2, GetSourceTypeOptions, groupData, inventorySourceData){ var generator = GenerateForm, form = GroupForm(); + + // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); + CredentialList.fields.kind.noSearch = true; + $scope.formCancel = function(){ $state.go('^'); }; @@ -117,7 +122,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 !== 'manual' && source.value !== 'custom' ? true : false; + $scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' ? true : false; $scope.group_by = null; $scope.source_regions = null; $scope.credential = null; @@ -247,7 +252,7 @@ {inventory_script: inventorySourceData.source_script} ); if (inventorySourceData.credential){ - GroupManageService.getCredential(inventorySourceData.credential).then(res => $scope.credential_name = res.data.name); + $scope.credential_name = inventorySourceData.summary_fields.credential.name; } $scope = angular.extend($scope, groupData); diff --git a/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js b/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js index 98f2576054..213ff84dbf 100644 --- a/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js +++ b/awx/ui/client/src/inventory-scripts/inventory-scripts.list.js @@ -55,7 +55,7 @@ export default function(){ icon: 'fa-edit', label: 'Edit', "class": 'btn-sm', - awToolTip: 'Edit credential', + awToolTip: 'Edit inventory script', dataPlacement: 'top' }, "delete": { @@ -63,7 +63,7 @@ export default function(){ icon: 'fa-trash', label: 'Delete', "class": 'btn-sm', - awToolTip: 'Delete credential', + awToolTip: 'Delete inventory script', dataPlacement: 'top' } } 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 2edea0a622..55abf51e04 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 @@ -18,93 +18,49 @@ $scope.search = function(){ Wait('start'); - if ($scope.searchStr === undefined){ - return; - } //http://docs.ansible.com/ansible-tower/latest/html/towerapi/intro.html#filtering // SELECT WHERE host_name LIKE str OR WHERE play LIKE str OR WHERE task LIKE str AND host_name NOT "" // selecting non-empty host_name fields prevents us from displaying non-runner events, like playbook_on_task_start - JobDetailService.getRelatedJobEvents($stateParams.id, { - or__host_name__icontains: $scope.searchStr, - or__play__icontains: $scope.searchStr, - or__task__icontains: $scope.searchStr, - not__host_name: "" , - page_size: $scope.pageSize}) - .success(function(res){ - $scope.results = res.results; - Wait('stop'); - }); + var params = { + host_name: $scope.hostName, + }; + if ($scope.searchStr && $scope.searchStr !== ''){ + params.or__play__icontains = $scope.searchStr; + params.or__task__icontains = $scope.searchStr; + } + + switch($scope.activeFilter){ + case 'skipped': + params.event = 'runner_on_skipped'; + break; + case 'unreachable': + params.event = 'runner_on_unreachable'; + break; + case 'ok': + params.event = 'runner_on_ok'; + break; + case 'failed': + params.failed = true; + break; + case 'changed': + params.changed = true; + break; + default: + break; + } + JobDetailService.getRelatedJobEvents($stateParams.id, params) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); }; $scope.filters = ['all', 'changed', 'failed', 'ok', 'unreachable', 'skipped']; - var filter = function(filter){ - Wait('start'); - - if (filter === 'all'){ - return JobDetailService.getRelatedJobEvents($stateParams.id, { - host_name: $stateParams.hostName, - page_size: $scope.pageSize}) - .success(function(res){ - $scope.results = res.results; - Wait('stop'); - }); - } - // handle runner cases - if (filter === 'skipped'){ - return JobDetailService.getRelatedJobEvents($stateParams.id, { - host_name: $stateParams.hostName, - event: 'runner_on_skipped'}) - .success(function(res){ - $scope.results = res.results; - Wait('stop'); - }); - } - if (filter === 'unreachable'){ - return JobDetailService.getRelatedJobEvents($stateParams.id, { - host_name: $stateParams.hostName, - event: 'runner_on_unreachable'}) - .success(function(res){ - $scope.results = res.results; - Wait('stop'); - }); - } - if (filter === 'ok'){ - return JobDetailService.getRelatedJobEvents($stateParams.id, { - host_name: $stateParams.hostName, - event__startswith: 'runner_on_ok' - }) - .success(function(res){ - $scope.results = res.results; - Wait('stop'); - }); - } - // handle convience properties .changed .failed - if (filter === 'changed'){ - return JobDetailService.getRelatedJobEvents($stateParams.id, { - host_name: $stateParams.hostName, - changed: true}) - .success(function(res){ - $scope.results = res.results; - Wait('stop'); - }); - } - if (filter === 'failed'){ - return JobDetailService.getRelatedJobEvents($stateParams.id, { - host_name: $stateParams.hostName, - failed: true, - event__startswith: 'runner_on_failed' - }) - .success(function(res){ - $scope.results = res.results; - Wait('stop'); - }); - } - }; - // watch select2 for changes $('.HostEvents-select').on("select2:select", function () { - filter($('.HostEvents-select').val()); + $scope.activeFilter = $('.HostEvents-select').val(); + $scope.search(); }); var init = function(){ @@ -116,12 +72,9 @@ }); // process the filter if one was passed if ($stateParams.filter){ - Wait('start'); - filter($stateParams.filter).success(function(res){ - $scope.results = res.results; - Wait('stop'); - $('#HostEvents').modal('show'); - }); + $scope.activeFilter = $stateParams.filter; + $scope.search(); + $('#HostEvents').modal('show'); } else{ $scope.results = hosts.data.results; diff --git a/awx/ui/client/src/job-detail/host-summary/host-summary.partial.html b/awx/ui/client/src/job-detail/host-summary/host-summary.partial.html index 669ba5c344..5452990e76 100644 --- a/awx/ui/client/src/job-detail/host-summary/host-summary.partial.html +++ b/awx/ui/client/src/job-detail/host-summary/host-summary.partial.html @@ -1,7 +1,7 @@

-
4 Please select a host below to view a summary of all associated tasks.
-
+
4 Please select a host below to view a summary of all associated tasks.
+
@@ -22,7 +22,7 @@
-
+
TitleName Time
" + ($filter('longDate')(row.finished)).replace(/ /,'
') + "
" + ellipsis(row.name) + "
" + ($filter('longDate')(row.last_updated)).replace(/ /,'
') + "
" + ellipsis(row.summary_fields.group.name) + "" + $filter('sanitize')(ellipsis(row.summary_fields.group.name)) + "
NA" + ellipsis(row.summary_fields.group.name) + "" + $filter('sanitize')(ellipsis(row.summary_fields.group.name)) + "
diff --git a/awx/ui/client/src/job-detail/host-summary/host-summary.route.js b/awx/ui/client/src/job-detail/host-summary/host-summary.route.js index a2de70e5d4..f7722462a0 100644 --- a/awx/ui/client/src/job-detail/host-summary/host-summary.route.js +++ b/awx/ui/client/src/job-detail/host-summary/host-summary.route.js @@ -17,5 +17,15 @@ export default { }, ncyBreadcrumb: { skip: true // Never display this state in breadcrumb. + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); } }; 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 ee40db21ed..02cb21a5f1 100644 --- a/awx/ui/client/src/job-detail/job-detail.controller.js +++ b/awx/ui/client/src/job-detail/job-detail.controller.js @@ -13,13 +13,13 @@ export default [ '$location', '$rootScope', '$filter', '$scope', '$compile', '$state', '$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'GetElapsed', 'JobIsFinished', - 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'InitiatePlaybookRun', 'LoadPlays', 'LoadTasks', + 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'RelaunchPlaybook', 'LoadPlays', 'LoadTasks', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', 'EditSchedule', 'ParseTypeChange', 'JobDetailService', function( $location, $rootScope, $filter, $scope, $compile, $state, $stateParams, $log, ClearScope, GetBasePath, Wait, ProcessErrors, SelectPlay, SelectTask, GetElapsed, JobIsFinished, - SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, InitiatePlaybookRun, LoadPlays, LoadTasks, + SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, RelaunchPlaybook, LoadPlays, LoadTasks, ParseVariableString, GetChoices, fieldChoices, fieldLabels, EditSchedule, ParseTypeChange, JobDetailService ) { @@ -428,7 +428,7 @@ export default var params = { order_by: 'id' }; - if (scope.job.summary_fields.unified_job_template.unified_job_type === 'job'){ + JobDetailService.getJobPlays(scope.job.id, params) .success( function(data) { scope.next_plays = data.next; @@ -514,7 +514,6 @@ export default } scope.$emit('LoadTasks', events_url); }); - } }); @@ -841,7 +840,7 @@ export default }; scope.searchTasks = function() { - var params; + var params = {}; if (scope.search_task_name) { scope.searchTasksEnabled = false; } @@ -866,7 +865,7 @@ export default }; scope.searchHosts = function() { - var params; + var params = {}; if (scope.search_host_name) { scope.searchHostsEnabled = false; } @@ -920,7 +919,7 @@ export default }; scope.relaunchJob = function() { - InitiatePlaybookRun({ + RelaunchPlaybook({ scope: scope, id: scope.job.id }); 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 578e17510f..0be9e4eb6b 100644 --- a/awx/ui/client/src/job-detail/job-detail.partial.html +++ b/awx/ui/client/src/job-detail/job-detail.partial.html @@ -15,7 +15,7 @@
- +
@@ -164,7 +164,7 @@ -
+
@@ -379,7 +379,7 @@ -
+
diff --git a/awx/ui/client/src/job-detail/job-detail.route.js b/awx/ui/client/src/job-detail/job-detail.route.js index dda2722511..bf21fb8e5a 100644 --- a/awx/ui/client/src/job-detail/job-detail.route.js +++ b/awx/ui/client/src/job-detail/job-detail.route.js @@ -30,5 +30,15 @@ export default { }] }, templateUrl: templateUrl('job-detail/job-detail'), - controller: 'JobDetailController' + controller: 'JobDetailController', + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); + } }; diff --git a/awx/ui/client/src/job-detail/job-detail.service.js b/awx/ui/client/src/job-detail/job-detail.service.js index 080339e7dd..e5578349a5 100644 --- a/awx/ui/client/src/job-detail/job-detail.service.js +++ b/awx/ui/client/src/job-detail/job-detail.service.js @@ -39,6 +39,13 @@ export default } catch(err){return;} }, + processsEventTip: function(event, status){ + try{ + var string = `Event ID: ${ event.id }
Status: ${ _.capitalize(status.status)}. Click for details`; + return typeof item === 'object' ? JSON.stringify(string) : string; + } + catch(err){return;} + }, // Generate a helper class for job_event statuses // the stack for which status to display is // unreachable > failed > changed > ok @@ -88,6 +95,7 @@ export default var status = self.processEventStatus(event); var msg = self.processEventMsg(event); var item = self.processEventItem(event); + var tip = self.processsEventTip(event, status); results.push({ id: event.id, status: status.status, @@ -96,6 +104,7 @@ export default task_id: event.parent, name: event.event_data.host, created: event.created, + tip: typeof tip === 'undefined' ? undefined : tip, msg: typeof msg === 'undefined' ? undefined : msg, item: typeof item === 'undefined' ? undefined : item }); diff --git a/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js index 34743b99ea..ed16e745e5 100644 --- a/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js +++ b/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js @@ -9,10 +9,11 @@ export default return function (params) { var scope = params.scope.$new(), id = params.id, + relaunch = params.relaunch || false, system_job = params.system_job || false; scope.job_template_id = id; - var el = $compile( "" )( scope ); + var el = $compile( "" )( scope ); $('#content-container').remove('submit-job').append( el ); }; } diff --git a/awx/ui/client/src/job-submission/job-submission.block.less b/awx/ui/client/src/job-submission/job-submission.block.less index 60272deda5..eed6b797a5 100644 --- a/awx/ui/client/src/job-submission/job-submission.block.less +++ b/awx/ui/client/src/job-submission/job-submission.block.less @@ -103,8 +103,8 @@ } .JobSubmission-step--active { color: @default-bg!important; - background-color: @d7grey!important; - border-color: @d7grey!important; + background-color: @default-icon!important; + border-color: @default-icon!important; cursor: default!important; } .JobSubmission-step--disabled { 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 acefe6aa07..16fc77324e 100644 --- a/awx/ui/client/src/job-submission/job-submission.controller.js +++ b/awx/ui/client/src/job-submission/job-submission.controller.js @@ -128,14 +128,14 @@ export default // This gets things started - goes out and hits the launch endpoint (based on launch/relaunch) and // prepares the form fields, defauts, etc. $scope.init = function() { - $scope.forms = {}; $scope.passwords = {}; - var base = $location.path().replace(/^\//, '').split('/')[0], - isRelaunch = !(base === 'job_templates' || base === 'portal' || base === 'inventories' || base === 'home'); + // As of 3.0, the only place the user can relaunch a + // playbook is on jobTemplates.edit (completed_jobs tab), + // jobs, and jobDetails $states. - if (!isRelaunch) { + if (!$scope.submitJobRelaunch) { launch_url = GetBasePath('job_templates') + $scope.submitJobId + '/launch/'; } else { @@ -187,7 +187,7 @@ export default updateRequiredPasswords(); } - if( (isRelaunch && !$scope.password_needed) || (!isRelaunch && $scope.can_start_without_user_input && !$scope.ask_inventory_on_launch && !$scope.ask_credential_on_launch && !$scope.has_other_prompts && !$scope.survey_enabled)) { + if( ($scope.submitJobRelaunch && !$scope.password_needed) || (!$scope.submitJobRelaunch && $scope.can_start_without_user_input && !$scope.ask_inventory_on_launch && !$scope.ask_credential_on_launch && !$scope.has_other_prompts && !$scope.survey_enabled)) { // The job can be launched if // a) It's a relaunch and no passwords are needed // or @@ -215,7 +215,7 @@ export default $scope.openLaunchModal(); }; - if(isRelaunch) { + if($scope.submitJobRelaunch) { // Go out and get some of the job details like inv, cred, name Rest.setUrl(GetBasePath('jobs') + $scope.submitJobId); Rest.get() @@ -331,6 +331,7 @@ export default var credential_url = GetBasePath('credentials') + '?kind=ssh'; var credList = _.cloneDeep(CredentialList); + credList.basePath = GetBasePath('credentials') + '?kind=ssh'; credList.fields.description.searchable = false; credList.fields.kind.searchable = false; @@ -536,8 +537,9 @@ export default // It shares the same scope with this directive and will // pull the new value of parseType out to determine which // direction to convert the extra vars + $scope.parseType = $scope.other_prompt_data.parseType; - $scope.parseTypeChange(); + $scope.parseTypeChange('parseType', 'jobLaunchVariables'); }; } diff --git a/awx/ui/client/src/job-submission/job-submission.directive.js b/awx/ui/client/src/job-submission/job-submission.directive.js index d31279f00d..bd1c90ff92 100644 --- a/awx/ui/client/src/job-submission/job-submission.directive.js +++ b/awx/ui/client/src/job-submission/job-submission.directive.js @@ -11,7 +11,8 @@ export default [ 'templateUrl', 'CreateDialog', 'Wait', 'CreateSelect2', 'ParseT return { scope: { submitJobId: '=', - submitJobSystem: '=' + submitJobSystem: '=', + submitJobRelaunch: '=' }, templateUrl: templateUrl('job-submission/job-submission'), controller: jobSubmissionController, 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 7abcf5dfca..717aac7a08 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 @@ -34,6 +34,9 @@ base = $location.path().replace(/^\//, '').split('/')[0], context = (base === 'job_templates') ? 'job_template' : 'inv'; + // remove "type" field from search options + CredentialList.fields.kind.noSearch = true; + CallbackHelpInit({ scope: $scope }); $scope.can_edit = true; generator.inject(form, { mode: 'add', related: false, scope: $scope }); @@ -82,6 +85,7 @@ NetworkCredentialList.iterator = 'networkcredential'; NetworkCredentialList.basePath = '/api/v1/credentials?kind=net'; + SurveyControllerInit({ scope: $scope, parent_scope: $scope @@ -375,7 +379,7 @@

`, - 'alert-info', saveCompleted, null, null, + 'alert-danger', saveCompleted, null, null, null, true); } var orgDefer = $q.defer(); diff --git a/awx/ui/client/src/job-templates/add/job-templates-add.route.js b/awx/ui/client/src/job-templates/add/job-templates-add.route.js index d9f9a5c9d3..8218cabc32 100644 --- a/awx/ui/client/src/job-templates/add/job-templates-add.route.js +++ b/awx/ui/client/src/job-templates/add/job-templates-add.route.js @@ -14,5 +14,19 @@ export default { ncyBreadcrumb: { parent: "jobTemplates", label: "CREATE JOB TEMPLATE" + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); + + if($("#survey-modal-dialog").hasClass('ui-dialog-content')) { + $('#survey-modal-dialog').dialog('destroy'); + } } }; 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 ff9766ac55..8a2f433dbc 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 @@ -46,6 +46,9 @@ export default checkSCMStatus, getPlaybooks, callback, choicesCount = 0; + // remove "type" field from search options + CredentialList = _.cloneDeep(CredentialList); + CredentialList.fields.kind.noSearch = true; CallbackHelpInit({ scope: $scope }); @@ -347,20 +350,13 @@ export default ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback }); - if (related_cloud_credential) { - Rest.setUrl(related_cloud_credential); - Rest.get() - .success(function (data) { - $scope.$emit('cloudCredentialReady', data.name); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, {hdr: 'Error!', - msg: 'Failed to related cloud credential. GET returned status: ' + status }); - }); + if($scope.job_template_obj.summary_fields.cloud_credential && related_cloud_credential) { + $scope.$emit('cloudCredentialReady', $scope.job_template_obj.summary_fields.cloud_credential.name); } else { // No existing cloud credential $scope.$emit('cloudCredentialReady', null); } + }); Wait('start'); @@ -446,31 +442,40 @@ export default } }); }; + if($state.params.id !== "null"){ + Rest.setUrl(defaultUrl + $state.params.id + + "/labels"); + Rest.get() + .success(function(data) { + if (data.next) { + getNext(data, data.results, seeMoreResolve); + } else { + seeMoreResolve.resolve(data.results); + } - Rest.setUrl(defaultUrl + $state.params.id + - "/labels"); - Rest.get() - .success(function(data) { - if (data.next) { - getNext(data, data.results, seeMoreResolve); - } else { - seeMoreResolve.resolve(data.results); - } - - seeMoreResolve.promise.then(function (labels) { - $scope.$emit("choicesReady"); - var opts = labels - .map(i => ({id: i.id + "", - test: i.name})); - CreateSelect2({ - element:'#job_templates_labels', - multiple: true, - addNew: true, - opts: opts + seeMoreResolve.promise.then(function (labels) { + $scope.$emit("choicesReady"); + var opts = labels + .map(i => ({id: i.id + "", + test: i.name})); + CreateSelect2({ + element:'#job_templates_labels', + multiple: true, + addNew: true, + opts: opts + }); + Wait("stop"); }); - Wait("stop"); + }).error(function(){ + // job template id is null in this case + $scope.$emit("choicesReady"); }); - }); + } + else { + // job template doesn't exist + $scope.$emit("choicesReady"); + } + }) .error(function (data, status) { ProcessErrors($scope, data, status, form, { diff --git a/awx/ui/client/src/job-templates/edit/job-templates-edit.route.js b/awx/ui/client/src/job-templates/edit/job-templates-edit.route.js index a9d5ff608f..78d383fa42 100644 --- a/awx/ui/client/src/job-templates/edit/job-templates-edit.route.js +++ b/awx/ui/client/src/job-templates/edit/job-templates-edit.route.js @@ -17,5 +17,19 @@ export default { ncyBreadcrumb: { parent: 'jobTemplates', label: "{{name}}" + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); + + if($("#survey-modal-dialog").hasClass('ui-dialog-content')) { + $('#survey-modal-dialog').dialog('destroy'); + } } }; 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 bb1a825273..a5f055bb6e 100644 --- a/awx/ui/client/src/job-templates/labels/labelsList.directive.js +++ b/awx/ui/client/src/job-templates/labels/labelsList.directive.js @@ -74,13 +74,17 @@ export default }); }; - scope.$watch(scope.$parent.list.iterator+".summary_fields.labels.results", function() { + scope.$watchCollection(scope.$parent.list.iterator, function() { // To keep the array of labels fresh, we need to set up a watcher - otherwise, the // array will get set initially and then never be updated as labels are removed if (scope[scope.$parent.list.iterator].summary_fields.labels){ scope.labels = scope[scope.$parent.list.iterator].summary_fields.labels.results; scope.count = scope[scope.$parent.list.iterator].summary_fields.labels.count; } + else{ + scope.labels = null; + scope.count = null; + } }); } }; diff --git a/awx/ui/client/src/job-templates/list/job-templates-list.route.js b/awx/ui/client/src/job-templates/list/job-templates-list.route.js index deeb1982bd..6c646af729 100644 --- a/awx/ui/client/src/job-templates/list/job-templates-list.route.js +++ b/awx/ui/client/src/job-templates/list/job-templates-list.route.js @@ -17,5 +17,15 @@ export default { }, ncyBreadcrumb: { label: "JOB TEMPLATES" + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); } }; diff --git a/awx/ui/client/src/lists/AllJobs.js b/awx/ui/client/src/lists/AllJobs.js index 04bbaad493..ee28221c1d 100644 --- a/awx/ui/client/src/lists/AllJobs.js +++ b/awx/ui/client/src/lists/AllJobs.js @@ -21,7 +21,8 @@ export default label: '', searchLabel: 'Status', columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus', - awToolTip: "Job {{ all_job.status }}. Click for details.", + dataTipWatch: 'all_job.status_tip', + awToolTip: "{{ all_job.status_tip }}", awTipPlacement: "right", dataTitle: "{{ all_job.status_popover_title }}", icon: 'icon-job-{{ all_job.status }}', @@ -48,6 +49,7 @@ export default ngClick: "viewJobDetails(all_job)", defaultSearchField: true, awToolTip: "{{ all_job.name | sanitize }}", + dataTipWatch: 'all_job.name', dataPlacement: 'top' }, type: { diff --git a/awx/ui/client/src/lists/Credentials.js b/awx/ui/client/src/lists/Credentials.js index 8aff2db5e4..580b4b9f5b 100644 --- a/awx/ui/client/src/lists/Credentials.js +++ b/awx/ui/client/src/lists/Credentials.js @@ -45,6 +45,7 @@ export default owners: { label: 'Owners', type: 'owners', + searchable: false, nosort: true, excludeModal: true, columnClass: 'col-md-2 hidden-sm hidden-xs' diff --git a/awx/ui/client/src/lists/InventoryHosts.js b/awx/ui/client/src/lists/InventoryHosts.js index 16c8989832..f0cc803fe5 100644 --- a/awx/ui/client/src/lists/InventoryHosts.js +++ b/awx/ui/client/src/lists/InventoryHosts.js @@ -54,6 +54,10 @@ export default label: 'Disabled?', searchSingleValue: true, searchType: 'boolean', + typeOptions: [ + {label: "Yes", value: false}, + {label: "No", value: true} + ], searchValue: 'false', searchOnly: true }, diff --git a/awx/ui/client/src/lists/PortalJobs.js b/awx/ui/client/src/lists/PortalJobs.js index 49ca8a3ec2..f92497e7a4 100644 --- a/awx/ui/client/src/lists/PortalJobs.js +++ b/awx/ui/client/src/lists/PortalJobs.js @@ -43,6 +43,7 @@ export default searchable: false, filter: "longDate", key: true, + desc: true, columnClass: "col-lg-4 col-md-4 col-sm-3" } }, diff --git a/awx/ui/client/src/lists/ScheduledJobs.js b/awx/ui/client/src/lists/ScheduledJobs.js index 1be131b6ca..be4e7bd22d 100644 --- a/awx/ui/client/src/lists/ScheduledJobs.js +++ b/awx/ui/client/src/lists/ScheduledJobs.js @@ -35,6 +35,7 @@ export default sourceField: 'name', ngClick: "editSchedule(schedule)", awToolTip: "{{ schedule.nameTip | sanitize}}", + dataTipWatch: 'schedule.nameTip', dataPlacement: "top", defaultSearchField: true }, @@ -45,14 +46,17 @@ export default sourceModel: 'unified_job_template', sourceField: 'unified_job_type', ngBind: 'schedule.type_label', - searchField: 'unified_job_template__polymorphic_ctype__name', - searchLable: 'Type', + searchField: 'unified_job_template__polymorphic_ctype__model', + filterBySearchField: true, + searchLabel: 'Type', searchable: true, searchType: 'select', searchOptions: [ - { value: 'inventory source', label: 'Inventory Sync' }, - { value: 'job template', label: 'Playbook Run' }, - { value: 'project', label: 'SCM Update' } + { value: 'inventorysource', label: 'Inventory Sync' }, + { value: 'jobtemplate', label: 'Playbook Run' }, + { value: 'project', label: 'SCM Update' }, + { value: 'systemjobtemplate', label: 'Management Job'} + ] }, next_run: { diff --git a/awx/ui/client/src/login/loginModal/loginModal.controller.js b/awx/ui/client/src/login/loginModal/loginModal.controller.js index c87aafba41..9bd5132ab1 100644 --- a/awx/ui/client/src/login/loginModal/loginModal.controller.js +++ b/awx/ui/client/src/login/loginModal/loginModal.controller.js @@ -137,6 +137,7 @@ export default ['$log', '$cookieStore', '$compile', '$window', '$rootScope', $rootScope.sessionTimer = timer; $rootScope.$emit('OpenSocket'); $rootScope.user_is_superuser = data.results[0].is_superuser; + $rootScope.user_is_system_auditor = data.results[0].is_system_auditor; scope.$emit('AuthorizationGetLicense'); }); }) diff --git a/awx/ui/client/src/management-jobs/card/card.partial.html b/awx/ui/client/src/management-jobs/card/card.partial.html index fc0f2b0c00..ae0d660b50 100644 --- a/awx/ui/client/src/management-jobs/card/card.partial.html +++ b/awx/ui/client/src/management-jobs/card/card.partial.html @@ -18,7 +18,7 @@
`; html += "\n"; html += "\n"; }); @@ -147,7 +147,9 @@ export default }; scope.testNotification = function(){ - var name = $filter('sanitize')(this.notification_template.name); + var name = $filter('sanitize')(this.notification_template.name), + pending_retries = 10; + Rest.setUrl(defaultUrl + this.notification_template.id +'/test/'); Rest.post({}) .then(function (data) { @@ -156,31 +158,7 @@ export default // Using a setTimeout here to wait for the // notification to be processed and for a status // to be returned from the API. - setTimeout(function(){ - var id = data.data.notification, - url = GetBasePath('notifications') + id; - Rest.setUrl(url); - Rest.get() - .then(function (res) { - Wait('stop'); - if(res && res.data && res.data.status && res.data.status === "successful"){ - scope.search(list.iterator); - ngToast.success({ - content: ` ${name}: Notification sent.` - }); - } - else if(res && res.data && res.data.status && res.data.status === "failed"){ - scope.search(list.iterator); - ngToast.danger({ - content: ` ${name}: Notification failed.` - }); - } - else { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. Notification returned status: ' + status }); - } - }); - } , 5000); + retrieveStatus(data.data.notification); } else { ProcessErrors(scope, data, status, null, { hdr: 'Error!', @@ -192,6 +170,40 @@ export default content: ` ${name}: Notification Failed.`, }); }); + + function retrieveStatus(id){ + setTimeout(function(){ + var url = GetBasePath('notifications') + id; + Rest.setUrl(url); + Rest.get() + .then(function (res) { + if(res && res.data && res.data.status && res.data.status === "successful"){ + scope.search(list.iterator); + ngToast.success({ + content: ` ${name}: Notification sent.` + }); + Wait('stop'); + } + else if(res && res.data && res.data.status && res.data.status === "failed"){ + scope.search(list.iterator); + ngToast.danger({ + content: ` ${name}: Notification failed.` + }); + Wait('stop'); + } + else if(res && res.data && res.data.status && res.data.status === "pending" && pending_retries>0){ + pending_retries--; + retrieveStatus(id); + } + else { + Wait('stop'); + ProcessErrors(scope, null, status, null, { hdr: 'Error!', + msg: 'Call to test notifications failed.' }); + } + + }); + } , 5000); + } }; scope.addNotification = function(){ diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js index 13d51cc68f..caa8d7692f 100644 --- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js +++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js @@ -121,6 +121,16 @@ export default [ features: ['FeaturesService', function(FeaturesService) { return FeaturesService.get(); }] + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); } }, { diff --git a/awx/ui/client/src/portal-mode/portal-mode-jobs.controller.js b/awx/ui/client/src/portal-mode/portal-mode-jobs.controller.js index 34669c74e9..f6bd8ff9df 100644 --- a/awx/ui/client/src/portal-mode/portal-mode-jobs.controller.js +++ b/awx/ui/client/src/portal-mode/portal-mode-jobs.controller.js @@ -7,7 +7,7 @@ export function PortalModeJobsController($scope, $rootScope, GetBasePath, GenerateList, PortalJobsList, SearchInit, PaginateInit){ - var list = PortalJobsList, + var list = _.cloneDeep(PortalJobsList), view = GenerateList, // show user jobs by default defaultUrl = GetBasePath('jobs') + '?created_by=' + $rootScope.current_user.id, @@ -23,7 +23,13 @@ export function PortalModeJobsController($scope, $rootScope, GetBasePath, Genera $scope.iterator = list.iterator; $scope.activeFilter = 'user'; - var init = function(sort){ + var init = function(url){ + defaultUrl = url ? url : defaultUrl; + // We need to explicitly set the lists base path so that tag searching will keep the '?created_by' + // query param when it's present. If we don't do this, then tag search will just grab the base + // path for this list (/api/v1/jobs) and lose the created_by filter + list.basePath = defaultUrl; + view.inject(list, { id: 'portal-jobs', mode: 'edit', @@ -44,32 +50,26 @@ export function PortalModeJobsController($scope, $rootScope, GetBasePath, Genera url: defaultUrl, pageSize: pageSize }); - $scope.search (list.iterator); - if(sort) { - // hack to default to descending sort order - $scope.sort('job','finished'); - } - + $scope.search(list.iterator); }; - $scope.filterUser = function(){ $scope.activeFilter = 'user'; defaultUrl = GetBasePath('jobs') + '?created_by=' + $rootScope.current_user.id; - init(); + init(defaultUrl); }; $scope.filterAll = function(){ $scope.activeFilter = 'all'; defaultUrl = GetBasePath('jobs'); - init(); + init(defaultUrl); }; $scope.refresh = function(){ $scope.search(list.iterator); }; - init(true); + init(); } PortalModeJobsController.$inject = ['$scope', '$rootScope', 'GetBasePath', 'generateList', 'PortalJobsList', 'SearchInit', diff --git a/awx/ui/client/src/portal-mode/portal-mode.route.js b/awx/ui/client/src/portal-mode/portal-mode.route.js index c0f7e5f3fb..6982f10620 100644 --- a/awx/ui/client/src/portal-mode/portal-mode.route.js +++ b/awx/ui/client/src/portal-mode/portal-mode.route.js @@ -24,5 +24,15 @@ export default { templateUrl: templateUrl('portal-mode/portal-mode-jobs'), controller: PortalModeJobsController } + }, + onExit: function(){ + // close the job launch modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + // Destroy the dialog + if($("#job-launch-modal").hasClass('ui-dialog-content')) { + $('#job-launch-modal').dialog('destroy'); + } + // Remove the directive from the page (if it's there) + $('#content-container').find('submit-job').remove(); } }; diff --git a/awx/ui/client/src/scheduler/schedulerAdd.controller.js b/awx/ui/client/src/scheduler/schedulerAdd.controller.js index 8f3ae65621..48dcbb764b 100644 --- a/awx/ui/client/src/scheduler/schedulerAdd.controller.js +++ b/awx/ui/client/src/scheduler/schedulerAdd.controller.js @@ -78,11 +78,11 @@ export default ['$compile', '$filter', '$state', '$stateParams', 'AddSchedule', // extra_data field is not manifested in the UI when scheduling a Management Job if ($state.current.name === 'jobTemplateSchedules.add'){ $scope.parseType = 'yaml'; + // grab any existing extra_vars from parent job_template var defaultUrl = GetBasePath('job_templates') + $stateParams.id + '/'; Rest.setUrl(defaultUrl); Rest.get().then(function(res){ - // sanitize - var data = JSON.parse(JSON.stringify(res.data.extra_vars)); + var data = res.data.extra_vars; $scope.extraVars = data === '' ? '---' : data; ParseTypeChange({ scope: $scope, diff --git a/awx/ui/client/src/scheduler/schedulerDatePicker.directive.js b/awx/ui/client/src/scheduler/schedulerDatePicker.directive.js index 7718bdb40a..8eacdefda0 100644 --- a/awx/ui/client/src/scheduler/schedulerDatePicker.directive.js +++ b/awx/ui/client/src/scheduler/schedulerDatePicker.directive.js @@ -36,6 +36,7 @@ export default mustUpdateValue = false; scope.dateValueMoment = moment(newValue, ['MM/DD/YYYY'], moment.locale()); scope.dateValue = scope.dateValueMoment.format('L'); + element.find(".DatePicker").systemTrackingDP('update', scope.dateValue); } }, true); @@ -54,7 +55,7 @@ export default element.find(".DatePicker").addClass("input-prepend date"); element.find(".DatePicker").find(".DatePicker-icon").addClass("add-on"); - $(".date").systemTrackingDP({ + element.find(".DatePicker").systemTrackingDP({ autoclose: true, language: localeKey, format: dateFormat diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 36e94151d3..2687f67ecf 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -1,4 +1,20 @@ -export default ['$compile', '$state', '$stateParams', 'EditSchedule', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', function($compile, $state, $stateParams, EditSchedule, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange) { +export default ['$filter', '$compile', '$state', '$stateParams', 'EditSchedule', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', +function($filter, $compile, $state, $stateParams, EditSchedule, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange) { + + $scope.processSchedulerEndDt = function(){ + // set the schedulerEndDt to be equal to schedulerStartDt + 1 day @ midnight + var dt = new Date($scope.schedulerUTCTime); + // increment date by 1 day + dt.setDate(dt.getDate() + 1); + var month = $filter('schZeroPad')(dt.getMonth() + 1, 2), + day = $filter('schZeroPad')(dt.getDate(), 2); + $scope.$parent.schedulerEndDt = month + '/' + day + '/' + dt.getFullYear(); + }; + // initial end @ midnight values + $scope.schedulerEndHour = "00"; + $scope.schedulerEndMinute = "00"; + $scope.schedulerEndSecond = "00"; + $scope.$on("ScheduleFormCreated", function(e, scope) { $scope.hideForm = false; $scope = angular.extend($scope, scope); diff --git a/awx/ui/client/src/scheduler/schedulerForm.partial.html b/awx/ui/client/src/scheduler/schedulerForm.partial.html index cd8c563b17..e3fffecec7 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/scheduler/schedulerForm.partial.html @@ -630,8 +630,8 @@
- YAML - JSON + YAML + JSON
diff --git a/awx/ui/client/src/search/tagSearch.controller.js b/awx/ui/client/src/search/tagSearch.controller.js index 2a1b8e3311..e50a25670e 100644 --- a/awx/ui/client/src/search/tagSearch.controller.js +++ b/awx/ui/client/src/search/tagSearch.controller.js @@ -1,17 +1,19 @@ -export default ['$scope', 'Refresh', 'tagSearchService', - function($scope, Refresh, tagSearchService) { +export default ['$scope', 'Refresh', 'tagSearchService', '$stateParams', + function($scope, Refresh, tagSearchService, $stateParams) { // JSONify passed field elements that can be searched $scope.list = angular.fromJson($scope.list); // Access config lines from list spec $scope.listConfig = $scope.$parent.list; // Grab options for the left-dropdown of the searchbar - tagSearchService.getSearchTypes($scope.list, $scope.endpoint) - .then(function(searchTypes) { - $scope.searchTypes = searchTypes; + if($stateParams.id !== "null"){ + tagSearchService.getSearchTypes($scope.list, $scope.endpoint) + .then(function(searchTypes) { + $scope.searchTypes = searchTypes; - // currently selected option of the left-dropdown - $scope.currentSearchType = $scope.searchTypes[0]; - }); + // currently selected option of the left-dropdown + $scope.currentSearchType = $scope.searchTypes[0]; + }); + } // shows/hide the search type dropdown $scope.toggleTypeDropdown = function() { diff --git a/awx/ui/client/src/search/tagSearch.service.js b/awx/ui/client/src/search/tagSearch.service.js index fbc87c4e52..fdd808d3ad 100644 --- a/awx/ui/client/src/search/tagSearch.service.js +++ b/awx/ui/client/src/search/tagSearch.service.js @@ -6,7 +6,10 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu var obj = {}; // build the value (key) var value; - if (field.sourceModel && field.sourceField) { + if (field.searchField && field.filterBySearchField === true){ + value = field.searchField; + } + else if (field.sourceModel && field.sourceField) { value = field.sourceModel + '__' + field.sourceField; obj.related = true; } else if (typeof(field.key) === String) { @@ -25,7 +28,7 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu typeOptions = field.searchOptions || []; } else if (field.searchType === 'boolean') { type = 'select'; - typeOptions = [{label: "Yes", value: true}, + typeOptions = field.typeOptions || [{label: "Yes", value: true}, {label: "No", value: false}]; } else { type = 'text'; @@ -81,41 +84,45 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu if (needsRequest.length) { // make the options request to reutrn the typeOptions - Rest.setUrl(needsRequest[0].basePath ? GetBasePath(needsRequest[0].basePath) : basePath); - Rest.options() - .success(function (data) { - try { - var options = data.actions.GET; - needsRequest = needsRequest - .map(function (option) { - option.typeOptions = options[option - .value] - .choices - .map(function(i) { - return { - value: i[0], - label: i[1] - }; - }); - return option; - }); - } - catch(err){ - if (!basePath){ - $log.error('Cannot retrieve OPTIONS because the basePath parameter is not set on the list with the following fieldset: \n', list); + var url = needsRequest[0].basePath ? GetBasePath(needsRequest[0].basePath) : basePath; + if(url.indexOf('null') === 0 ){ + Rest.setUrl(url); + Rest.options() + .success(function (data) { + try { + var options = data.actions.GET; + needsRequest = needsRequest + .map(function (option) { + option.typeOptions = options[option + .value] + .choices + .map(function(i) { + return { + value: i[0], + label: i[1] + }; + }); + return option; + }); } - else { $log.error(err); } - } - Wait("stop"); - defer.resolve(joinOptions()); - }) - .error(function (data, status) { - Wait("stop"); - defer.reject("options request failed"); - ProcessErrors(null, data, status, null, { - hdr: 'Error!', - msg: 'Getting type options failed'}); - }); + catch(err){ + if (!basePath){ + $log.error('Cannot retrieve OPTIONS because the basePath parameter is not set on the list with the following fieldset: \n', list); + } + else { $log.error(err); } + } + Wait("stop"); + defer.resolve(joinOptions()); + }) + .error(function (data, status) { + Wait("stop"); + defer.reject("options request failed"); + ProcessErrors(null, data, status, null, { + hdr: 'Error!', + msg: 'Getting type options failed'}); + }); + } + } else { Wait("stop"); defer.resolve(joinOptions()); @@ -179,7 +186,7 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu this.getTag = function(field, textVal, selectVal) { var tag = _.clone(field); if (tag.type === "text") { - tag.url = tag.value + "__icontains=" + textVal; + tag.url = tag.value + "__icontains=" + encodeURIComponent(textVal); tag.name = textVal; } else if (selectVal.value && typeof selectVal.value === 'string' && selectVal.value.indexOf("=") > 0) { tag.url = selectVal.value; diff --git a/awx/ui/client/src/setup-menu/setup-menu.partial.html b/awx/ui/client/src/setup-menu/setup-menu.partial.html index ae1cdb0ee2..fc9a04d4ed 100644 --- a/awx/ui/client/src/setup-menu/setup-menu.partial.html +++ b/awx/ui/client/src/setup-menu/setup-menu.partial.html @@ -24,7 +24,7 @@ Add passwords, SSH keys, etc. for Tower to use when launching jobs against machines, or when syncing inventories or projects.

- +

Management Jobs

Manage the cleanup of old job history, activity streams, data marked for deletion, and system tracking info. @@ -37,7 +37,7 @@

+ ng-if="orgAdmin || user_is_system_auditor || user_is_superuser">

Notifications

Create templates for sending notifications with Email, HipChat, Slack, and SMS. diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index 5417829724..4b889eeb75 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -1960,7 +1960,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // Row level actions if (collection.fieldActions) { - html += "

"; + html += ""; html += "\n"; } diff --git a/awx/ui/client/src/shared/generator-helpers.js b/awx/ui/client/src/shared/generator-helpers.js index ccddbbc4c4..aebfcb1a31 100644 --- a/awx/ui/client/src/shared/generator-helpers.js +++ b/awx/ui/client/src/shared/generator-helpers.js @@ -137,10 +137,10 @@ angular.module('GeneratorHelpers', [systemStatus.name]) case 'run': case 'rerun': case 'submit': - icon = 'fa-rocket'; + icon = 'icon-launch'; break; case 'launch': - icon = 'fa-rocket'; + icon = 'icon-launch'; break; case 'stream': icon = 'fa-clock-o'; diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index 30c4625a59..9fc63e0969 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -524,7 +524,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate // Row level actions - innerTable += "\n"; + innerTable += "\n"; } innerTable += "\n"; diff --git a/awx/ui/client/src/smart-status/smart-status.block.less b/awx/ui/client/src/smart-status/smart-status.block.less index 92b5244e4f..0757529d45 100644 --- a/awx/ui/client/src/smart-status/smart-status.block.less +++ b/awx/ui/client/src/smart-status/smart-status.block.less @@ -38,6 +38,7 @@ line-height: 22px; } +.SmartStatus-tooltip--successful, .SmartStatus-tooltip--success{ color: @default-succ; padding-left: 5px; diff --git a/awx/ui/client/src/smart-status/smart-status.controller.js b/awx/ui/client/src/smart-status/smart-status.controller.js index 00343eafd6..ff18ba9300 100644 --- a/awx/ui/client/src/smart-status/smart-status.controller.js +++ b/awx/ui/client/src/smart-status/smart-status.controller.js @@ -7,13 +7,13 @@ export default ['$scope', '$filter', function ($scope, $filter) { - var recentJobs = $scope.jobs; - function isFailureState(status) { return status === 'failed' || status === 'error' || status === 'canceled'; } - var sparkData = + function init(){ + var recentJobs = $scope.jobs; + var sparkData = _.sortBy(recentJobs.map(function(job) { var data = {}; @@ -32,11 +32,17 @@ export default ['$scope', '$filter', data.jobId = job.id; data.sortDate = job.finished || "running" + data.jobId; data.finished = $filter('longDate')(job.finished) || job.status+""; + data.status_tip = "JOB ID: " + data.jobId + "
STATUS: " + data.smartStatus + "
FINISHED: " + data.finished; return data; }), "sortDate").reverse(); - $scope.sparkArray = sparkData; + $scope.sparkArray = sparkData; + } + $scope.$watchCollection('jobs', function(){ + init(); + }); + }]; // diff --git a/awx/ui/client/src/smart-status/smart-status.partial.html b/awx/ui/client/src/smart-status/smart-status.partial.html index e100caf913..e8fa03bc56 100644 --- a/awx/ui/client/src/smart-status/smart-status.partial.html +++ b/awx/ui/client/src/smart-status/smart-status.partial.html @@ -1,8 +1,8 @@
- +
diff --git a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html index b4459ba4bf..909eac91a3 100644 --- a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html +++ b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html @@ -8,7 +8,7 @@ RESULTS
- +
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 805200201e..f43d6c4ea5 100644 --- a/awx/ui/client/src/standard-out/standard-out.controller.js +++ b/awx/ui/client/src/standard-out/standard-out.controller.js @@ -39,6 +39,12 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams, } }); + // Unbind $rootScope socket event binding(s) so that they don't get triggered + // in another instance of this controller + $scope.$on('$destroy', function() { + $scope.removeJobStatusChange(); + }); + // Set the parse type so that CodeMirror knows how to display extra params YAML/JSON $scope.parseType = 'yaml'; diff --git a/awx/ui/client/src/widgets/Stream.js b/awx/ui/client/src/widgets/Stream.js index 4a8c6aeb61..bd9b0011a4 100644 --- a/awx/ui/client/src/widgets/Stream.js +++ b/awx/ui/client/src/widgets/Stream.js @@ -32,7 +32,15 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti // try/except pattern asserts that: // if we encounter a case where a UI url can't or shouldn't be generated, just supply the name of the resource try { + // catch-all case to avoid generating urls if a resource has been deleted + // if a resource still exists, it'll be serialized in the activity's summary_fields + if (!activity.summary_fields[resource]){ + throw {name : 'ResourceDeleted', message: 'The referenced resource no longer exists'}; + } switch (resource) { + case 'custom_inventory_script': + url += 'inventory_scripts/' + obj.id + '/'; + break; case 'group': if (activity.operation === 'create' || activity.operation === 'delete'){ // the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey' @@ -47,7 +55,7 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti url += 'home/hosts/' + obj.id; break; case 'job': - url += 'jobs/?id=' + obj.id; + url += 'jobs/' + obj.id; break; case 'inventory': url += 'inventories/' + obj.id + '/'; @@ -192,11 +200,13 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti case 'delete': activity.description += activity.object1 + BuildAnchor(activity.changes, activity.object1, activity); break; - // equivalent to 'create' or 'update' // expected outcome: "operation " - default: + case 'update': activity.description += activity.object1 + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); break; + case 'create': + activity.description += activity.object1 + BuildAnchor(activity.changes, activity.object1, activity); + break; } break; } diff --git a/docs/licenses/cowsay.txt b/docs/licenses/cowsay.txt new file mode 100644 index 0000000000..a58a648c73 --- /dev/null +++ b/docs/licenses/cowsay.txt @@ -0,0 +1,26 @@ +cowsay is licensed under the following MIT license: + +==== +Copyright (c) 2012 Fabio Crisci + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==== + +The original idea of cowsay come from [Tony Monroe](http://www.nog.net/~tony/) - [cowsay](https://github.com/schacon/cowsay) diff --git a/docs/licenses/font-awesome.txt b/docs/licenses/font-awesome.txt new file mode 100644 index 0000000000..0928d89347 --- /dev/null +++ b/docs/licenses/font-awesome.txt @@ -0,0 +1,13 @@ +Font License + +Applies to all desktop and webfont files in the following directory: font-awesome/fonts/. +License: SIL OFL 1.1 +URL: http://scripts.sil.org/OFL + + + +Code License + +Applies to all CSS and LESS files in the following directories: font-awesome/css/, font-awesome/less/, and font-awesome/scss/. +License: MIT License +URL: http://opensource.org/licenses/mit-license.html diff --git a/docs/licenses/ip-associations-python-novaclient-ext b/docs/licenses/ip-associations-python-novaclient-ext new file mode 100644 index 0000000000..b3fc50d03b --- /dev/null +++ b/docs/licenses/ip-associations-python-novaclient-ext @@ -0,0 +1,15 @@ +# As displayed: https://pypi.python.org/pypi/ip_associations_python_novaclient_ext + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/docs/licenses/ip_associations_python_novaclient_ext.txt b/docs/licenses/ip-associations-python-novaclient-ext.txt similarity index 100% rename from docs/licenses/ip_associations_python_novaclient_ext.txt rename to docs/licenses/ip-associations-python-novaclient-ext.txt diff --git a/docs/licenses/irc.txt b/docs/licenses/irc.txt new file mode 100644 index 0000000000..921ae9dd2b --- /dev/null +++ b/docs/licenses/irc.txt @@ -0,0 +1,10 @@ +# As listed on https://pypi.python.org/pypi/irc + +The MIT License (MIT) +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/os_diskconfig_python_novaclient_ext.txt b/docs/licenses/os-diskconfig-python-novaclient-ext.txt similarity index 100% rename from docs/licenses/os_diskconfig_python_novaclient_ext.txt rename to docs/licenses/os-diskconfig-python-novaclient-ext.txt diff --git a/docs/licenses/os_networksv2_python_novaclient_ext.txt b/docs/licenses/os-networksv2-python-novaclient-ext.txt similarity index 100% rename from docs/licenses/os_networksv2_python_novaclient_ext.txt rename to docs/licenses/os-networksv2-python-novaclient-ext.txt diff --git a/docs/licenses/os_virtual_interfacesv2_python_novaclient_ext.txt b/docs/licenses/os-virtual-interfacesv2-python-novaclient-ext.txt similarity index 100% rename from docs/licenses/os_virtual_interfacesv2_python_novaclient_ext.txt rename to docs/licenses/os-virtual-interfacesv2-python-novaclient-ext.txt diff --git a/docs/licenses/python-heatclient.txt b/docs/licenses/python-heatclient.txt new file mode 100644 index 0000000000..68c771a099 --- /dev/null +++ b/docs/licenses/python-heatclient.txt @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/docs/licenses/python-openstackclient.txt b/docs/licenses/python-openstackclient.txt new file mode 100644 index 0000000000..68c771a099 --- /dev/null +++ b/docs/licenses/python-openstackclient.txt @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/docs/licenses/pywinrmkerberos.txt b/docs/licenses/pywinrmkerberos.txt new file mode 100644 index 0000000000..77b1811346 --- /dev/null +++ b/docs/licenses/pywinrmkerberos.txt @@ -0,0 +1,20 @@ +Copyright (c) 2013 Alexey Diyan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/docs/licenses/rax_default_network_flags_python_novaclient_ext.txt b/docs/licenses/rax-default-network-flags-python-novaclient-ext.txt similarity index 100% rename from docs/licenses/rax_default_network_flags_python_novaclient_ext.txt rename to docs/licenses/rax-default-network-flags-python-novaclient-ext.txt diff --git a/docs/licenses/rax_scheduled_images_python_novaclient_ext.txt b/docs/licenses/rax-scheduled-images-python-novaclient-ext.txt similarity index 100% rename from docs/licenses/rax_scheduled_images_python_novaclient_ext.txt rename to docs/licenses/rax-scheduled-images-python-novaclient-ext.txt diff --git a/docs/licenses/requestsexceptions.txt b/docs/licenses/requestsexceptions.txt new file mode 100644 index 0000000000..37a3165011 --- /dev/null +++ b/docs/licenses/requestsexceptions.txt @@ -0,0 +1,14 @@ +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/docs/licenses/total-ordering.txt b/docs/licenses/total-ordering.txt new file mode 100644 index 0000000000..16c40f4f4f --- /dev/null +++ b/docs/licenses/total-ordering.txt @@ -0,0 +1,28 @@ +Copyright (c) 2012, Konsta Vesterinen + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* The names of the contributors may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/tools/audit-api-license.sh b/tools/audit-api-license.sh new file mode 100755 index 0000000000..6cf93f4022 --- /dev/null +++ b/tools/audit-api-license.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +echo "---------- requirements.txt --------------" +for each in `cat requirements/requirements.txt| awk -F= '{print $1}' | tr -d "[]"` +do + if [ ! -f docs/licenses/$each.txt ]; then + echo No license for $each + fi +done +echo "---------- end requirements.txt --------------" + + +echo "---------- requirements_ansible.txt --------------" +for each in `cat requirements/requirements_ansible.txt| awk -F= '{print $1}' | tr -d "[]"` +do + if [ ! -f docs/licenses/$each.txt ]; then + echo No license for $each + fi +done +echo "---------- end requirements_ansible.txt --------------"
- {{ result.name }}{{ result.name }} + {{ result.name }}{{ result.name }} {{ result.item }} {{ result.msg }}" + ($filter('longDate')(row.created)).replace(/ /,'
') + "
"; + html += "
"; for (act in collection.fieldActions) { fAction = collection.fieldActions[act]; html += ""; } - html += "
"; + innerTable += "
"; for (field_action in list.fieldActions) { if (field_action !== 'columnClass') { @@ -573,7 +573,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate } } } - innerTable += "