diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 0280b0f09f..5efaed8d56 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -23,6 +23,7 @@ from django.db import models # from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import force_text from django.utils.text import capfirst +from django.forms.models import model_to_dict # Django REST Framework from rest_framework.exceptions import ValidationError @@ -1766,7 +1767,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): notification_templates_any = reverse('api:job_template_notification_templates_any_list', args=(obj.pk,)), notification_templates_success = reverse('api:job_template_notification_templates_success_list', args=(obj.pk,)), notification_templates_error = reverse('api:job_template_notification_templates_error_list', args=(obj.pk,)), - access_list = reverse('api:job_template_access_list', args=(obj.pk,)), + access_list = reverse('api:job_template_access_list', args=(obj.pk,)), survey_spec = reverse('api:job_template_survey_spec', args=(obj.pk,)), labels = reverse('api:job_template_label_list', args=(obj.pk,)), roles = reverse('api:job_template_roles_list', args=(obj.pk,)), @@ -1783,19 +1784,21 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): if obj.survey_spec is not None and ('name' in obj.survey_spec and 'description' in obj.survey_spec): d['survey'] = dict(title=obj.survey_spec['name'], description=obj.survey_spec['description']) request = self.context.get('request', None) - if request is not None and request.user is not None and obj.inventory is not None and obj.project is not None: - d['can_copy'] = request.user.can_access(JobTemplate, 'add', - {'inventory': obj.inventory.pk, - 'project': obj.project.pk}) - d['can_edit'] = request.user.can_access(JobTemplate, 'change', obj, - {'inventory': obj.inventory.pk, - 'project': obj.project.pk}) - elif request is not None and request.user is not None and request.user.is_superuser: - d['can_copy'] = True - d['can_edit'] = True - else: + + # Check for conditions that would create a validation error if coppied + validation_errors, resources_needed_to_start = obj.resource_validation_data() + + if request is None or request.user is None: d['can_copy'] = False d['can_edit'] = False + elif request.user.is_superuser: + d['can_copy'] = not validation_errors + d['can_edit'] = True + else: + jt_data = model_to_dict(obj) + d['can_copy'] = (not validation_errors) and request.user.can_access(JobTemplate, 'add', jt_data) + d['can_edit'] = request.user.can_access(JobTemplate, 'change', obj, jt_data) + d['recent_jobs'] = self._recent_jobs(obj) return d @@ -2259,12 +2262,14 @@ class JobLaunchSerializer(BaseSerializer): obj = self.context.get('obj') data = self.context.get('data') + for field in obj.resources_needed_to_start: + if not (field in attrs and obj._ask_for_vars_dict().get(field, False)): + errors[field] = "Job Template '%s' is missing or undefined." % field + if (not obj.ask_credential_on_launch) or (not attrs.get('credential', None)): credential = obj.credential else: credential = attrs.get('credential', None) - if not credential: - errors['credential'] = 'Credential not provided' # fill passwords dict with request data passwords if credential and credential.passwords_needed: @@ -2295,11 +2300,6 @@ class JobLaunchSerializer(BaseSerializer): if validation_errors: errors['variables_needed_to_start'] = validation_errors - if obj.job_type != PERM_INVENTORY_SCAN and (obj.project is None): - errors['project'] = 'Job Template Project is missing or undefined.' - if (obj.inventory is None) and not attrs.get('inventory', None): - errors['inventory'] = 'Job Template Inventory is missing or undefined.' - # Special prohibited cases for scan jobs if 'job_type' in data and obj.ask_job_type_on_launch: if ((obj.job_type == PERM_INVENTORY_SCAN and not data['job_type'] == PERM_INVENTORY_SCAN) or diff --git a/awx/main/access.py b/awx/main/access.py index 7e2289df42..fd240fac53 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -817,17 +817,76 @@ class JobTemplateAccess(BaseAccess): if self.user not in obj.admin_role: return False if data is not None: - data_for_change = dict(data) + data = dict(data) + + if self.changes_are_non_sensitive(obj, data): + if 'job_type' in data and obj.job_type != data['job_type'] and data['job_type'] == PERM_INVENTORY_SCAN: + self.check_license(feature='system_tracking') + + if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']: + self.check_license(feature='surveys') + return True + for required_field in ('credential', 'cloud_credential', 'inventory', 'project'): required_obj = getattr(obj, required_field, None) if required_field not in data_for_change and required_obj is not None: data_for_change[required_field] = required_obj.pk return self.can_read(obj) and self.can_add(data_for_change) + def changes_are_non_sensitive(self, obj, data): + ''' + Returne true if the changes being made are considered nonsensitive, and + thus can be made by a job template administrator which may not have access + to the any inventory, project, or credentials associated with the template. + ''' + # We are white listing fields that can + field_whitelist = [ + 'name', 'description', 'forks', 'limit', 'verbosity', 'extra_vars', + 'job_tags', 'force_handlers', 'skip_tags', 'ask_variables_on_launch', + 'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch', + 'ask_credential_on_launch', 'survey_enabled' + ] + + for k, v in data.items(): + if hasattr(obj, k) and getattr(obj, k) != v: + if k not in field_whitelist: + return False + return True + + def can_update_sensitive_fields(self, obj, data): + project_id = data.get('project', obj.project.id if obj.project else None) + inventory_id = data.get('inventory', obj.inventory.id if obj.inventory else None) + credential_id = data.get('credential', obj.credential.id if obj.credential else None) + cloud_credential_id = data.get('cloud_credential', obj.cloud_credential.id if obj.cloud_credential else None) + network_credential_id = data.get('network_credential', obj.network_credential.id if obj.network_credential else None) + + if project_id and self.user not in Project.objects.get(pk=project_id).use_role: + return False + if inventory_id and self.user not in Inventory.objects.get(pk=inventory_id).use_role: + return False + if credential_id and self.user not in Credential.objects.get(pk=credential_id).use_role: + return False + if cloud_credential_id and self.user not in Credential.objects.get(pk=cloud_credential_id).use_role: + return False + if network_credential_id and self.user not in Credential.objects.get(pk=network_credential_id).use_role: + return False + + return True + def can_delete(self, obj): return self.user in obj.admin_role class JobAccess(BaseAccess): + ''' + I can see jobs when: + - I am a superuser. + - I can see its job template + - I am an admin or auditor of the organization which contains its inventory + - I am an admin or auditor of the organization which contains its project + I can delete jobs when: + - I am an admin of the organization which contains its inventory + - I am an admin of the organization which contains its project + ''' model = Job @@ -839,10 +898,20 @@ class JobAccess(BaseAccess): if self.user.is_superuser: return qs.all() - return qs.filter( + qs_jt = qs.filter( job_template__in=JobTemplate.accessible_objects(self.user, 'read_role') ) + org_access_qs = Organization.objects.filter( + Q(admin_role__members=self.user) | Q(auditor_role__members=self.user)) + if not org_access_qs.exists(): + return qs_jt + + return qs.filter( + Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) | + Q(inventory__organization__in=org_access_qs) | + 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? return True @@ -871,7 +940,11 @@ class JobAccess(BaseAccess): @check_superuser def can_delete(self, obj): - return self.user in obj.inventory.admin_role + if obj.inventory is not None and self.user in obj.inventory.organization.admin_role: + return True + if obj.project is not None and self.user in obj.project.organization.admin_role: + return True + return False def can_start(self, obj): self.check_license() diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 7e5f7fc905..97ec93b874 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -242,15 +242,44 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): 'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled', 'labels',] + def resource_validation_data(self): + ''' + Process consistency errors and need-for-launch related fields. + ''' + resources_needed_to_start = [] + validation_errors = {} + + # Inventory and Credential related checks + if self.inventory is None: + resources_needed_to_start.append('inventory') + if not self.ask_inventory_on_launch: + validation_errors['inventory'] = ["Job Template must provide 'inventory' or allow prompting for it.",] + if self.credential is None: + resources_needed_to_start.append('credential') + if not self.ask_credential_on_launch: + validation_errors['credential'] = ["Job Template must provide 'credential' or allow prompting for it.",] + + # Job type dependent checks + if self.job_type == 'scan': + if self.inventory is None or self.ask_inventory_on_launch: + validation_errors['inventory'] = ["Scan jobs must be assigned a fixed inventory.",] + elif self.project is None: + resources_needed_to_start.append('project') + validation_errors['project'] = ["Job types 'run' and 'check' must have assigned a project.",] + + return (validation_errors, resources_needed_to_start) + def clean(self): - if self.job_type == 'scan' and (self.inventory is None or self.ask_inventory_on_launch): - raise ValidationError({"inventory": ["Scan jobs must be assigned a fixed inventory.",]}) - if (not self.ask_inventory_on_launch) and self.inventory is None: - raise ValidationError({"inventory": ["Job Template must provide 'inventory' or allow prompting for it.",]}) - if (not self.ask_credential_on_launch) and self.credential is None: - raise ValidationError({"credential": ["Job Template must provide 'credential' or allow prompting for it.",]}) + validation_errors, resources_needed_to_start = self.resource_validation_data() + if validation_errors: + raise ValidationError(validation_errors) return super(JobTemplate, self).clean() + @property + def resources_needed_to_start(self): + validation_errors, resources_needed_to_start = self.resource_validation_data() + return resources_needed_to_start + def create_job(self, **kwargs): ''' Create a new job based on this template. @@ -265,9 +294,9 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): Return whether job template can be used to start a new job without requiring any user input. ''' - return bool(self.credential and not len(self.passwords_needed_to_start) and - not len(self.variables_needed_to_start) and - self.inventory) + return (not self.resources_needed_to_start and + not self.passwords_needed_to_start and + not self.variables_needed_to_start) @property def variables_needed_to_start(self): diff --git a/awx/main/signals.py b/awx/main/signals.py index 64402953a0..86dff57df2 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -379,11 +379,16 @@ def activity_stream_associate(sender, instance, **kwargs): obj1 = instance object1=camelcase_to_underscore(obj1.__class__.__name__) obj_rel = sender.__module__ + "." + sender.__name__ + for entity_acted in kwargs['pk_set']: obj2 = kwargs['model'] obj2_id = entity_acted obj2_actual = obj2.objects.get(id=obj2_id) - object2 = camelcase_to_underscore(obj2.__name__) + 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__) + else: + object2 = camelcase_to_underscore(obj2.__name__) # Skip recording any inventory source, or system job template changes here. if isinstance(obj1, InventorySource) or isinstance(obj2_actual, InventorySource): continue @@ -409,7 +414,7 @@ def activity_stream_associate(sender, instance, **kwargs): # If the m2m is from the User side we need to # set the content_object of the Role for our entry. if type(instance) == User and role.content_object is not None: - getattr(activity_entry, role.content_type.name).add(role.content_object) + getattr(activity_entry, role.content_type.name.replace(' ', '_')).add(role.content_object) activity_entry.role.add(role) activity_entry.object_relationship_type = obj_rel diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index 91ead460db..7273997ba5 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -107,7 +107,7 @@ def mk_job_template(name, job_type='run', organization=None, inventory=None, credential=None, persisted=True, project=None): - jt = JobTemplate(name=name, job_type=job_type) + jt = JobTemplate(name=name, job_type=job_type, playbook='mocked') jt.inventory = inventory if jt.inventory is None: diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py index 2a9d493efe..3220f4c78b 100644 --- a/awx/main/tests/factories/tower.py +++ b/awx/main/tests/factories/tower.py @@ -27,6 +27,17 @@ from .fixtures import ( from .exc import NotUnique +def generate_objects(artifacts, kwargs): + '''generate_objects takes a list of artifacts that are supported by + a create function and compares it to the kwargs passed in to the create + function. If a kwarg is found that is not in the artifacts list a RuntimeError + is raised. + ''' + for k in kwargs.keys(): + if k not in artifacts: + raise RuntimeError('{} is not a valid argument'.format(k)) + return namedtuple("Objects", ",".join(artifacts)) + def generate_role_objects(objects): '''generate_role_objects assembles a dictionary of all possible objects by name. It will raise an exception if any of the objects share a name due to the fact that @@ -167,15 +178,19 @@ class _Mapped(object): # or encapsulated by specific factory fixtures in a conftest # -def create_job_template(name, **kwargs): - Objects = namedtuple("Objects", "job_template, inventory, project, credential, job_type") +def create_job_template(name, roles=None, persisted=True, **kwargs): + Objects = generate_objects(["job_template", + "organization", + "inventory", + "project", + "credential", + "job_type",], kwargs) org = None proj = None inv = None cred = None job_type = kwargs.get('job_type', 'run') - persisted = kwargs.get('persisted', True) if 'organization' in kwargs: org = kwargs['organization'] @@ -202,24 +217,38 @@ def create_job_template(name, **kwargs): job_type=job_type, persisted=persisted) role_objects = generate_role_objects([org, proj, inv, cred]) - apply_roles(kwargs.get('roles'), role_objects, persisted) + apply_roles(roles, role_objects, persisted) return Objects(job_template=jt, project=proj, inventory=inv, credential=cred, - job_type=job_type) + job_type=job_type, + organization=org,) -def create_organization(name, **kwargs): - Objects = namedtuple("Objects", "organization,teams,users,superusers,projects,labels,notification_templates") +def create_organization(name, roles=None, persisted=True, **kwargs): + Objects = generate_objects(["organization", + "teams", "users", + "superusers", + "projects", + "labels", + "notification_templates", + "inventories",], kwargs) projects = {} + inventories = {} labels = {} notification_templates = {} - persisted = kwargs.get('persisted', True) org = mk_organization(name, '%s-desc'.format(name), persisted=persisted) + if 'inventories' in kwargs: + for i in kwargs['inventories']: + if type(i) is Inventory: + inventories[i.name] = i + else: + inventories[i] = mk_inventory(i, organization=org, persisted=persisted) + if 'projects' in kwargs: for p in kwargs['projects']: if type(p) is Project: @@ -246,20 +275,24 @@ def create_organization(name, **kwargs): notification_templates[nt] = mk_notification_template(nt, organization=org, persisted=persisted) role_objects = generate_role_objects([org, superusers, users, teams, projects, labels, notification_templates]) - apply_roles(kwargs.get('roles'), role_objects, persisted) + apply_roles(roles, role_objects, persisted) return Objects(organization=org, superusers=_Mapped(superusers), users=_Mapped(users), teams=_Mapped(teams), projects=_Mapped(projects), labels=_Mapped(labels), - notification_templates=_Mapped(notification_templates)) + notification_templates=_Mapped(notification_templates), + inventories=_Mapped(inventories)) -def create_notification_template(name, **kwargs): - Objects = namedtuple("Objects", "notification_template,organization,users,superusers,teams") +def create_notification_template(name, roles=None, persisted=True, **kwargs): + Objects = generate_objects(["notification_template", + "organization", + "users", + "superusers", + "teams",], kwargs) organization = None - persisted = kwargs.get('persisted', True) if 'organization' in kwargs: org = kwargs['organization'] @@ -272,7 +305,7 @@ def create_notification_template(name, **kwargs): users = generate_users(organization, teams, False, persisted, users=kwargs.get('users')) role_objects = generate_role_objects([organization, notification_template]) - apply_roles(kwargs.get('roles'), role_objects, persisted) + apply_roles(roles, role_objects, persisted) return Objects(notification_template=notification_template, organization=organization, users=_Mapped(users), diff --git a/awx/main/tests/functional/api/test_activity_streams.py b/awx/main/tests/functional/api/test_activity_streams.py index 43e809afb9..39990064d7 100644 --- a/awx/main/tests/functional/api/test_activity_streams.py +++ b/awx/main/tests/functional/api/test_activity_streams.py @@ -131,3 +131,24 @@ def test_stream_queryset_hides_shows_items( assert queryset.filter(host__pk=host.pk, operation='create').count() == 1 assert queryset.filter(team__pk=team.pk, operation='create').count() == 1 assert queryset.filter(notification_template__pk=notification_template.pk, operation='create').count() == 1 + +@pytest.mark.django_db +@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled) +def test_stream_user_direct_role_updates(get, post, organization_factory): + objects = organization_factory('test_org', + superusers=['admin'], + users=['test'], + inventories=['inv1']) + + url = reverse('api:user_roles_list', args=(objects.users.test.pk,)) + post(url, dict(id=objects.inventories.inv1.read_role.pk), objects.superusers.admin) + + activity_stream = ActivityStream.objects.filter( + inventory__pk=objects.inventories.inv1.pk, + user__pk=objects.users.test.pk, + role__pk=objects.inventories.inv1.read_role.pk).first() + url = reverse('api:activity_stream_detail', args=(activity_stream.pk,)) + response = get(url, objects.users.test) + + assert response.data['object1'] == 'user' + assert response.data['object2'] == 'inventory' diff --git a/awx/main/tests/functional/api/test_job_runtime_params.py b/awx/main/tests/functional/api/test_job_runtime_params.py index cfda95213a..176271f714 100644 --- a/awx/main/tests/functional/api/test_job_runtime_params.py +++ b/awx/main/tests/functional/api/test_job_runtime_params.py @@ -178,7 +178,7 @@ def test_job_launch_fails_without_inventory(deploy_jobtemplate, post, user): args=[deploy_jobtemplate.pk]), {}, user('admin', True)) assert response.status_code == 400 - assert response.data['inventory'] == ['Job Template Inventory is missing or undefined.'] + assert response.data['inventory'] == ["Job Template 'inventory' is missing or undefined."] @pytest.mark.django_db @pytest.mark.job_runtime_vars diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py new file mode 100644 index 0000000000..1b971fe0d4 --- /dev/null +++ b/awx/main/tests/functional/api/test_job_template.py @@ -0,0 +1,179 @@ +import pytest +import mock + +# AWX +from awx.api.serializers import JobTemplateSerializer +from awx.main.models.jobs import JobTemplate +from awx.main.models.projects import ProjectOptions + +# Django +from django.test.client import RequestFactory +from django.core.urlresolvers import reverse + + +@pytest.fixture +def jt_copy_edit(job_template_factory, project): + objects = job_template_factory( + 'copy-edit-job-template', + project=project) + return objects.job_template + +@property +def project_playbooks(self): + return ['mocked', 'mocked.yml', 'alt-mocked.yml'] + +@pytest.mark.django_db +def test_job_template_role_user(post, organization_factory, job_template_factory): + objects = organization_factory("org", + superusers=['admin'], + users=['test']) + + jt_objects = job_template_factory("jt", + organization=objects.organization, + inventory='test_inv', + project='test_proj') + + url = reverse('api:user_roles_list', args=(objects.users.test.pk,)) + response = post(url, dict(id=jt_objects.job_template.execute_role.pk), objects.superusers.admin) + assert response.status_code == 204 + +# Test protection against limited set of validation problems + +@pytest.mark.django_db +def test_bad_data_copy_edit(admin_user, project): + """ + If a required resource (inventory here) was deleted, copying not allowed + because doing so would caues a validation error + """ + + jt_res = JobTemplate.objects.create( + job_type='run', + project=project, + inventory=None, ask_inventory_on_launch=False, # not allowed + credential=None, ask_credential_on_launch=True, + name='deploy-job-template' + ) + serializer = JobTemplateSerializer(jt_res) + request = RequestFactory().get('/api/v1/job_templates/12/') + request.user = admin_user + serializer.context['request'] = request + response = serializer.to_representation(jt_res) + assert not response['summary_fields']['can_copy'] + assert response['summary_fields']['can_edit'] + +# Tests for correspondence between view info and actual access + +@pytest.mark.django_db +def test_admin_copy_edit(jt_copy_edit, admin_user): + "Absent a validation error, system admins can do everything" + + # Serializer can_copy/can_edit fields + serializer = JobTemplateSerializer(jt_copy_edit) + request = RequestFactory().get('/api/v1/job_templates/12/') + request.user = admin_user + serializer.context['request'] = request + response = serializer.to_representation(jt_copy_edit) + assert response['summary_fields']['can_copy'] + assert response['summary_fields']['can_edit'] + +@pytest.mark.django_db +def test_org_admin_copy_edit(jt_copy_edit, org_admin): + "Organization admins SHOULD be able to copy a JT firmly in their org" + + # Serializer can_copy/can_edit fields + serializer = JobTemplateSerializer(jt_copy_edit) + request = RequestFactory().get('/api/v1/job_templates/12/') + request.user = org_admin + serializer.context['request'] = request + response = serializer.to_representation(jt_copy_edit) + assert response['summary_fields']['can_copy'] + assert response['summary_fields']['can_edit'] + +@pytest.mark.django_db +def test_org_admin_foreign_cred_no_copy_edit(jt_copy_edit, org_admin, machine_credential): + """ + Organization admins without access to the 3 related resources: + SHOULD NOT be able to copy JT + SHOULD NOT be able to edit that job template + """ + + # Attach credential to JT that org admin can not use + jt_copy_edit.credential = machine_credential + jt_copy_edit.save() + + # Serializer can_copy/can_edit fields + serializer = JobTemplateSerializer(jt_copy_edit) + request = RequestFactory().get('/api/v1/job_templates/12/') + request.user = org_admin + serializer.context['request'] = request + response = serializer.to_representation(jt_copy_edit) + assert not response['summary_fields']['can_copy'] + assert not response['summary_fields']['can_edit'] + +@pytest.mark.django_db +def test_jt_admin_copy_edit(jt_copy_edit, rando): + "JT admins wihout access to associated resources SHOULD NOT be able to copy" + + # random user given JT admin access only + jt_copy_edit.admin_role.members.add(rando) + jt_copy_edit.save() + + # Serializer can_copy/can_edit fields + serializer = JobTemplateSerializer(jt_copy_edit) + request = RequestFactory().get('/api/v1/job_templates/12/') + request.user = rando + serializer.context['request'] = request + response = serializer.to_representation(jt_copy_edit) + assert not response['summary_fields']['can_copy'] + assert not response['summary_fields']['can_edit'] + +@pytest.mark.django_db +def test_proj_jt_admin_copy_edit(jt_copy_edit, rando): + "JT admins with access to associated resources SHOULD be able to copy" + + # random user given JT and project admin abilities + jt_copy_edit.admin_role.members.add(rando) + jt_copy_edit.save() + jt_copy_edit.project.admin_role.members.add(rando) + jt_copy_edit.project.save() + + # Serializer can_copy/can_edit fields + serializer = JobTemplateSerializer(jt_copy_edit) + request = RequestFactory().get('/api/v1/job_templates/12/') + request.user = rando + serializer.context['request'] = request + response = serializer.to_representation(jt_copy_edit) + assert response['summary_fields']['can_copy'] + assert response['summary_fields']['can_edit'] + +# Functional tests - create new JT with all returned fields, as the UI does + +@pytest.mark.django_db +@mock.patch.object(ProjectOptions, "playbooks", project_playbooks) +def test_org_admin_copy_edit_functional(jt_copy_edit, org_admin, get, post): + get_response = get(reverse('api:job_template_detail', args=[jt_copy_edit.pk]), user=org_admin) + assert get_response.status_code == 200 + assert get_response.data['summary_fields']['can_copy'] + + post_data = get_response.data + post_data['name'] = '%s @ 12:19:47 pm' % post_data['name'] + post_response = post(reverse('api:job_template_list', args=[]), user=org_admin, data=post_data) + assert post_response.status_code == 201 + assert post_response.data['name'] == 'copy-edit-job-template @ 12:19:47 pm' + +@pytest.mark.django_db +@mock.patch.object(ProjectOptions, "playbooks", project_playbooks) +def test_jt_admin_copy_edit_functional(jt_copy_edit, rando, get, post): + + # Grant random user JT admin access only + jt_copy_edit.admin_role.members.add(rando) + jt_copy_edit.save() + + get_response = get(reverse('api:job_template_detail', args=[jt_copy_edit.pk]), user=rando) + assert get_response.status_code == 200 + assert not get_response.data['summary_fields']['can_copy'] + + post_data = get_response.data + post_data['name'] = '%s @ 12:19:47 pm' % post_data['name'] + post_response = post(reverse('api:job_template_list', args=[]), user=rando, data=post_data) + assert post_response.status_code == 403 diff --git a/awx/main/tests/functional/api/test_job_templates.py b/awx/main/tests/functional/api/test_job_templates.py new file mode 100644 index 0000000000..3c582ca2eb --- /dev/null +++ b/awx/main/tests/functional/api/test_job_templates.py @@ -0,0 +1,109 @@ +import mock # noqa +import pytest +from awx.main.models.projects import ProjectOptions + + +from django.core.urlresolvers import reverse + +@property +def project_playbooks(self): + return ['mocked', 'mocked.yml', 'alt-mocked.yml'] + +@pytest.mark.django_db +@mock.patch.object(ProjectOptions, "playbooks", project_playbooks) +@pytest.mark.parametrize( + "grant_project, grant_credential, grant_inventory, expect", [ + (True, True, True, 201), + (True, True, False, 403), + (True, False, True, 403), + (False, True, True, 403), + ] +) +def test_create(post, project, machine_credential, inventory, alice, grant_project, grant_credential, grant_inventory, expect): + if grant_project: + project.use_role.members.add(alice) + if grant_credential: + machine_credential.use_role.members.add(alice) + if grant_inventory: + inventory.use_role.members.add(alice) + + post(reverse('api:job_template_list'), { + 'name': 'Some name', + 'project': project.id, + 'credential': machine_credential.id, + 'inventory': inventory.id, + 'playbook': 'mocked.yml', + }, alice, expect=expect) + +@pytest.mark.django_db +@mock.patch.object(ProjectOptions, "playbooks", project_playbooks) +@pytest.mark.parametrize( + "grant_project, grant_credential, grant_inventory, expect", [ + (True, True, True, 200), + (True, True, False, 403), + (True, False, True, 403), + (False, True, True, 403), + ] +) +def test_edit_sensitive_fields(patch, job_template_factory, alice, grant_project, grant_credential, grant_inventory, expect): + objs = job_template_factory('jt', organization='org1', project='prj', inventory='inv', credential='cred') + objs.job_template.admin_role.members.add(alice) + + if grant_project: + objs.project.use_role.members.add(alice) + if grant_credential: + objs.credential.use_role.members.add(alice) + if grant_inventory: + objs.inventory.use_role.members.add(alice) + + patch(reverse('api:job_template_detail', args=(objs.job_template.id,)), { + 'name': 'Some name', + 'project': objs.project.id, + 'credential': objs.credential.id, + 'inventory': objs.inventory.id, + 'playbook': 'alt-mocked.yml', + }, alice, expect=expect) + +@pytest.mark.django_db +@mock.patch.object(ProjectOptions, "playbooks", project_playbooks) +def test_edit_playbook(patch, job_template_factory, alice): + objs = job_template_factory('jt', organization='org1', project='prj', inventory='inv', credential='cred') + objs.job_template.admin_role.members.add(alice) + objs.project.use_role.members.add(alice) + objs.credential.use_role.members.add(alice) + objs.inventory.use_role.members.add(alice) + + patch(reverse('api:job_template_detail', args=(objs.job_template.id,)), { + 'playbook': 'alt-mocked.yml', + }, alice, expect=200) + + objs.inventory.use_role.members.remove(alice) + patch(reverse('api:job_template_detail', args=(objs.job_template.id,)), { + 'playbook': 'mocked.yml', + }, alice, expect=403) + +@pytest.mark.django_db +@mock.patch.object(ProjectOptions, "playbooks", project_playbooks) +def test_edit_nonsenstive(patch, job_template_factory, alice): + objs = job_template_factory('jt', organization='org1', project='prj', inventory='inv', credential='cred') + jt = objs.job_template + jt.admin_role.members.add(alice) + + res = patch(reverse('api:job_template_detail', args=(jt.id,)), { + 'name': 'updated', + 'description': 'bar', + 'forks': 14, + 'limit': 'something', + 'verbosity': 5, + 'extra_vars': '--', + 'job_tags': 'sometags', + 'force_handlers': True, + 'skip_tags': True, + 'ask_variables_on_launch':True, + 'ask_tags_on_launch':True, + 'ask_job_type_on_launch':True, + 'ask_inventory_on_launch':True, + 'ask_credential_on_launch': True, + }, alice, expect=200) + print(res.data) + assert res.data['name'] == 'updated' diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 081ffca21e..94223ceb58 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -215,6 +215,13 @@ def org_admin(user, organization): organization.member_role.members.add(ret) return ret +@pytest.fixture +def org_auditor(user, organization): + ret = user('org-auditor', False) + organization.auditor_role.members.add(ret) + organization.member_role.members.add(ret) + return ret + @pytest.fixture def org_member(user, organization): ret = user('org-member', False) diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py new file mode 100644 index 0000000000..f54d661095 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_job.py @@ -0,0 +1,72 @@ +import pytest + +from awx.main.access import JobAccess +from awx.main.models import Job + + +@pytest.fixture +def normal_job(deploy_jobtemplate): + return Job.objects.create( + job_template=deploy_jobtemplate, + project=deploy_jobtemplate.project, + inventory=deploy_jobtemplate.inventory + ) + +# Read permissions testing +@pytest.mark.django_db +def test_superuser_sees_orphans(normal_job, admin_user): + normal_job.job_template = None + access = JobAccess(admin_user) + assert access.can_read(normal_job) + +@pytest.mark.django_db +def test_org_member_does_not_see_orphans(normal_job, org_member, project): + normal_job.job_template = None + # Check that privledged access to project still does not grant access + project.admin_role.members.add(org_member) + access = JobAccess(org_member) + assert not access.can_read(normal_job) + +@pytest.mark.django_db +def test_org_admin_sees_orphans(normal_job, org_admin): + normal_job.job_template = None + access = JobAccess(org_admin) + assert access.can_read(normal_job) + +@pytest.mark.django_db +def test_org_auditor_sees_orphans(normal_job, org_auditor): + normal_job.job_template = None + access = JobAccess(org_auditor) + assert access.can_read(normal_job) + +# Delete permissions testing +@pytest.mark.django_db +def test_JT_admin_delete_denied(normal_job, rando): + normal_job.job_template.admin_role.members.add(rando) + access = JobAccess(rando) + assert not access.can_delete(normal_job) + +@pytest.mark.django_db +def test_inventory_admin_delete_denied(normal_job, rando): + normal_job.job_template.inventory.admin_role.members.add(rando) + access = JobAccess(rando) + assert not access.can_delete(normal_job) + +@pytest.mark.django_db +def test_null_related_delete_denied(normal_job, rando): + normal_job.project = None + normal_job.inventory = None + access = JobAccess(rando) + assert not access.can_delete(normal_job) + +@pytest.mark.django_db +def test_inventory_org_admin_delete_allowed(normal_job, org_admin): + normal_job.project = None # do this so we test job->inventory->org->admin connection + access = JobAccess(org_admin) + assert access.can_delete(normal_job) + +@pytest.mark.django_db +def test_project_org_admin_delete_allowed(normal_job, org_admin): + normal_job.inventory = None # do this so we test job->project->org->admin connection + access = JobAccess(org_admin) + assert access.can_delete(normal_job) diff --git a/awx/main/tests/unit/api/test_serializers.py b/awx/main/tests/unit/api/test_serializers.py index 4f98892e8c..0b2f41d930 100644 --- a/awx/main/tests/unit/api/test_serializers.py +++ b/awx/main/tests/unit/api/test_serializers.py @@ -9,9 +9,14 @@ from awx.main.models import Label, Job #DRF from rest_framework import serializers +def mock_JT_resource_data(): + return ({}, []) + @pytest.fixture def job_template(mocker): - return mocker.MagicMock(pk=5) + mock_jt = mocker.MagicMock(pk=5) + mock_jt.resource_validation_data = mock_JT_resource_data + return mock_jt @pytest.fixture def job(mocker, job_template): diff --git a/awx/main/tests/unit/models/test_job_template_unit.py b/awx/main/tests/unit/models/test_job_template_unit.py new file mode 100644 index 0000000000..df45c753b6 --- /dev/null +++ b/awx/main/tests/unit/models/test_job_template_unit.py @@ -0,0 +1,35 @@ +from awx.main.tests.factories import create_job_template + + +def test_missing_project_error(): + objects = create_job_template( + 'missing-project-jt', + organization='org1', + inventory='inventory1', + credential='cred1', + persisted=False) + obj = objects.job_template + assert 'project' in obj.resources_needed_to_start + validation_errors, resources_needed_to_start = obj.resource_validation_data() + assert 'project' in validation_errors + +def test_inventory_credential_need_to_start(): + objects = create_job_template( + 'job-template-few-resources', + project='project1', + persisted=False) + obj = objects.job_template + assert 'inventory' in obj.resources_needed_to_start + assert 'credential' in obj.resources_needed_to_start + +def test_inventory_credential_contradictions(): + objects = create_job_template( + 'job-template-paradox', + project='project1', + persisted=False) + obj = objects.job_template + obj.ask_inventory_on_launch = False + obj.ask_credential_on_launch = False + validation_errors, resources_needed_to_start = obj.resource_validation_data() + assert 'inventory' in validation_errors + assert 'credential' in validation_errors diff --git a/awx/static/api/api.css b/awx/static/api/api.css index 078799e6a5..c4965ccc13 100644 --- a/awx/static/api/api.css +++ b/awx/static/api/api.css @@ -37,13 +37,13 @@ body .navbar { border-color: #E8E8E8; } body .navbar .navbar-brand { - color: #848992; + color: #707070; padding: 0; font-size: 14px; } body .navbar .navbar-brand:focus, body .navbar .navbar-brand:hover { - color: #848992; + color: #707070; } body .navbar .navbar-brand img { display: inline-block; @@ -60,7 +60,7 @@ body .navbar .navbar-brand > span { body .navbar .navbar-title { float: left; height: 50px; - color: #848992; + color: #707070; padding: 0; font-size: 14px; display: none; @@ -74,19 +74,19 @@ body.show-title .navbar .navbar-title { display: inline-block; } body .navbar .navbar-nav > li > a { - color: #848992; + color: #707070; display: flex; justify-content: center; } body .navbar .navbar-nav > li > a:focus, body .navbar .navbar-nav > li > a:hover { - color: #848992; + color: #707070; } body .navbar .navbar-nav > li > a > span.glyphicon { font-size: 20px; padding-right: 5px; padding-left: 5px; - color: #B7B7B7; + color: #848992; } body .page-header { @@ -110,7 +110,7 @@ body .description .hide-description span.glyphicon { font-size: 20px; } body .description .hide-description:hover span.glyphicon { - color: #B7B7B7; + color: #848992; } body ul.breadcrumb, body .description, @@ -167,7 +167,7 @@ body .form-actions button { body .form-horizontal .control-label { text-transform: uppercase; font-weight: normal; - color: #848992; + color: #707070; } body textarea.form-control { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; @@ -182,22 +182,22 @@ body .description pre { } body .tooltip.bottom .tooltip-arrow { - border-bottom-color: #848992; + border-bottom-color: #707070; } body .tooltip.top .tooltip-arrow { - border-top-color: #848992; + border-top-color: #707070; } body .tooltip.left .tooltip-arrow { - border-left-color: #848992; + border-left-color: #707070; } body .tooltip.right .tooltip-arrow { - border-right-color: #848992; + border-right-color: #707070; } body .tooltip.in { opacity: 1; } body .tooltip-inner { - background-color: #848992; + background-color: #707070; } body .btn { @@ -205,7 +205,7 @@ body .btn { } .btn-primary { background-color: #FFFFFF; - color: #848992; + color: #707070; border: 1px solid #E8E8E8; } .btn-primary:hover, @@ -224,7 +224,7 @@ body .btn { .open>.dropdown-toggle.btn-primary:hover, .open>.dropdown-toggle.btn-primary { background-color: #FAFAFA; - color: #848992; + color: #707070; border: 1px solid #E8E8E8; } @@ -283,7 +283,7 @@ body #footer { overflow: hidden; margin-bottom: 0; height: 40px; - color: #848992; + color: #707070; } body #footer .footer-logo { text-align: left; @@ -302,7 +302,7 @@ body #footer .footer-copyright { padding-top: 10px; } body #footer .footer-copyright a { - color: #848992; + color: #707070; } @media screen and (min-width: 768px) { @@ -329,7 +329,7 @@ body #footer .footer-copyright a { border-color: #E8E8E8; } body .navbar .navbar-toggle .icon-bar { - background-color: #B7B7B7; + background-color: #848992; } body .navbar .tooltip { visibility: hidden; diff --git a/awx/static/favicon.ico b/awx/static/favicon.ico index 31b759caf9..5003cda615 100644 Binary files a/awx/static/favicon.ico and b/awx/static/favicon.ico differ diff --git a/awx/ui/client/assets/favicon.ico b/awx/ui/client/assets/favicon.ico index 31b759caf9..5003cda615 100644 Binary files a/awx/ui/client/assets/favicon.ico and b/awx/ui/client/assets/favicon.ico differ diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index 0d0bfc9928..862bdc8b1d 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -962,6 +962,10 @@ input[type="checkbox"].checkbox-no-label { .checkbox-inline, .radio-inline { margin-right: 10px; } + + .checkbox-inline.stack-inline { + display: block; + } } .checkbox-options { diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less index a54c0c6aed..5f69f25d47 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -178,7 +178,14 @@ .Form-formGroup--checkbox{ display: flex; - align-items: flex-end; + align-items: flex-start; +} + +.Form-textUneditable { + .Form-textInput { + border: none; + padding: 0; + } } .Form-subForm { @@ -324,6 +331,12 @@ .select2-dropdown{ border:1px solid @field-border; + +} + +.select2-container--open .select2-dropdown--below { + margin-top: -1px; + border-top: 1px solid @field-border; } .Form-dropDown:focus{ @@ -427,6 +440,10 @@ input[type='radio']:checked:before { outline:none; } +.Form-inputLabelContainer { + width: 100%; + display: block !important; +} .Form-inputLabel{ text-transform: uppercase; color: @default-interface-txt; @@ -437,6 +454,16 @@ input[type='radio']:checked:before { .noselect; } +.Form-labelAction { + text-transform: uppercase; + font-weight: normal; + font-size: 0.8em; + padding-left:5px; + float: right; + margin-top: 3px; + .noselect; +} + .Form-buttons{ height: 30px; display: flex; diff --git a/awx/ui/client/legacy-styles/job-details.less b/awx/ui/client/legacy-styles/job-details.less index d4a21a9421..6f19e70fe8 100644 --- a/awx/ui/client/legacy-styles/job-details.less +++ b/awx/ui/client/legacy-styles/job-details.less @@ -214,7 +214,7 @@ } #job-detail-container { - + .well { overflow: hidden; } @@ -276,6 +276,8 @@ overflow-x: hidden; overflow-y: auto; background-color: @white; + min-height: 40px; + .row { border-top: 1px solid @grey; } @@ -318,7 +320,7 @@ #play-section { .table-detail { - height: 150px; + min-height: 40px; } } @@ -421,7 +423,6 @@ table-layout: fixed; } #hosts-table-detail { - height: 150px; background-color: @white; } #hosts-table-detail table { diff --git a/awx/ui/client/lib/lrInfiniteScroll/.bower.json b/awx/ui/client/lib/lrInfiniteScroll/.bower.json new file mode 100644 index 0000000000..7e27fd82a2 --- /dev/null +++ b/awx/ui/client/lib/lrInfiniteScroll/.bower.json @@ -0,0 +1,32 @@ +{ + "name": "lrInfiniteScroll", + "main": "lrInfiniteScroll.js", + "version": "1.0.0", + "homepage": "https://github.com/lorenzofox3/lrInfiniteScroll", + "authors": [ + "lorenzofox3 " + ], + "description": "angular directive to handle element scroll", + "keywords": [ + "angular", + "scroll", + "inifinite" + ], + "license": "MIT", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "_release": "1.0.0", + "_resolution": { + "type": "version", + "tag": "1.0.0", + "commit": "c833e9d8ff56d6c66e2a21ed7f27ad840f159a8b" + }, + "_source": "https://github.com/lorenzofox3/lrInfiniteScroll.git", + "_target": "~1.0.0", + "_originalSource": "lrInfiniteScroll" +} \ No newline at end of file diff --git a/awx/ui/client/lib/lrInfiniteScroll/index.js b/awx/ui/client/lib/lrInfiniteScroll/index.js new file mode 100644 index 0000000000..62f33e97b4 --- /dev/null +++ b/awx/ui/client/lib/lrInfiniteScroll/index.js @@ -0,0 +1,2 @@ +require('./lrInfiniteScroll'); +module.exports = 'lrInfiniteScroll'; diff --git a/awx/ui/client/lib/lrInfiniteScroll/lrInfiniteScroll.js b/awx/ui/client/lib/lrInfiniteScroll/lrInfiniteScroll.js index 3268a94081..ac29895c2b 100644 --- a/awx/ui/client/lib/lrInfiniteScroll/lrInfiniteScroll.js +++ b/awx/ui/client/lib/lrInfiniteScroll/lrInfiniteScroll.js @@ -2,13 +2,12 @@ 'use strict'; var module = ng.module('lrInfiniteScroll', []); - module.directive('lrInfiniteScroll', ['$log', '$timeout', function ($log, timeout) { + module.directive('lrInfiniteScroll', ['$timeout', function (timeout) { return{ link: function (scope, element, attr) { var lengthThreshold = attr.scrollThreshold || 50, timeThreshold = attr.timeThreshold || 400, - direction = attr.direction || 'down', handler = scope.$eval(attr.lrInfiniteScroll), promise = null, lastRemaining = 9999; @@ -20,14 +19,14 @@ handler = ng.noop; } - $log.debug('lrInfiniteScroll: ' + attr.lrInfiniteScroll); - element.bind('scroll', function () { - var remaining = (direction === 'down') ? element[0].scrollHeight - (element[0].clientHeight + element[0].scrollTop) : element[0].scrollTop; - // if we have reached the threshold and we scroll down - if ((direction === 'down' && remaining < lengthThreshold && (remaining - lastRemaining) < 0) || - direction === 'up' && remaining < lengthThreshold) { - //if there is already a timer running which has not expired yet we have to cancel it and restart the timer + var + remaining = element[0].scrollHeight - (element[0].clientHeight + element[0].scrollTop); + + //if we have reached the threshold and we scroll down + if (remaining < lengthThreshold && (remaining - lastRemaining) < 0) { + + //if there is already a timer running which has no expired yet we have to cancel it and restart the timer if (promise !== null) { timeout.cancel(promise); } diff --git a/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsTeams.list.js b/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsTeams.list.js index dc30bfbaf5..61b1c3de46 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsTeams.list.js +++ b/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsTeams.list.js @@ -15,13 +15,13 @@ multiSelectExtended: true, index: false, hover: true, - + emptyListText : 'No Teams exist', fields: { name: { key: true, label: 'name' }, - }, + } }; } diff --git a/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js b/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js index ced865e944..c08c45e352 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js +++ b/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js @@ -16,6 +16,7 @@ multiSelectExtended: true, index: false, hover: true, + emptyListText : 'No Users exist', fields: { first_name: { diff --git a/awx/ui/client/src/access/roleList.directive.js b/awx/ui/client/src/access/roleList.directive.js index 996a5a51aa..7bdd1b29d4 100644 --- a/awx/ui/client/src/access/roleList.directive.js +++ b/awx/ui/client/src/access/roleList.directive.js @@ -23,7 +23,7 @@ export default return i.role; })) .filter((role) => { - return !!attrs.teamRoleList == !!role.team_id; + return Boolean(attrs.teamRoleList) === Boolean(role.team_id); }) .sort((a, b) => { if (a.name diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 1fbd283e79..086700b6b7 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -79,6 +79,7 @@ __deferLoadIfEnabled(); var tower = angular.module('Tower', [ //'ngAnimate', + 'lrInfiniteScroll', 'ngSanitize', 'ngCookies', about.name, @@ -269,7 +270,7 @@ var tower = angular.module('Tower', [ }). state('projects', { - url: '/projects', + url: '/projects?{status}', templateUrl: urlPrefix + 'partials/projects.html', controller: ProjectsList, data: { @@ -297,6 +298,10 @@ var tower = angular.module('Tower', [ controller: ProjectsEdit, data: { activityStreamId: 'id' + }, + ncyBreadcrumb: { + parent: 'projects', + label: 'EDIT PROJECT' } }). state('projectOrganizations', { @@ -340,6 +345,10 @@ var tower = angular.module('Tower', [ controller: TeamsEdit, data: { activityStreamId: 'team_id' + }, + ncyBreadcrumb: { + parent: "teams", + label: "EDIT TEAM" } }). 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 bfb11e42cc..180bce2ab9 100644 --- a/awx/ui/client/src/bread-crumb/bread-crumb.directive.js +++ b/awx/ui/client/src/bread-crumb/bread-crumb.directive.js @@ -74,7 +74,7 @@ export default var licenseInfo = FeaturesService.getLicenseInfo(); scope.licenseType = licenseInfo ? licenseInfo.license_type : null; if (!licenseInfo) { - console.warn("License info not loaded correctly"); + console.warn("License info not loaded correctly"); // jshint ignore:line } }) .catch(function (response) { diff --git a/awx/ui/client/src/controllers/Projects.js b/awx/ui/client/src/controllers/Projects.js index 033328f838..089fda112a 100644 --- a/awx/ui/client/src/controllers/Projects.js +++ b/awx/ui/client/src/controllers/Projects.js @@ -22,7 +22,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, Wait('start'); var list = ProjectList, - defaultUrl = GetBasePath('projects'), + defaultUrl = GetBasePath('projects') + ($stateParams.status ? '?status=' + $stateParams.status : ''), view = GenerateList, base = $location.path().replace(/^\//, '').split('/')[0], mode = (base === 'projects') ? 'edit' : 'select', @@ -495,6 +495,35 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l $scope.scmRequired = ($scope.scm_type.value !== 'manual') ? true : false; $scope.scmBranchLabel = ($scope.scm_type.value === 'svn') ? 'Revision #' : 'SCM Branch'; } + + // Dynamically update popover values + if($scope.scm_type.value) { + switch ($scope.scm_type.value) { + case 'git': + $scope.urlPopover = '

Example URLs for GIT SCM include:

' + + '

Note: When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' + + 'do not enter a username (other than git). Additionally, GitHub and Bitbucket do not support password authentication when using ' + + 'SSH. GIT read only protocol (git://) does not use username or password information.'; + break; + case 'svn': + $scope.urlPopover = '

Example URLs for Subversion SCM include:

' + + ''; + break; + case 'hg': + $scope.urlPopover = '

Example URLs for Mercurial SCM include:

' + + '' + + '

Note: Mercurial does not support password authentication for SSH. ' + + 'Do not put the username and key in the URL. ' + + 'If using Bitbucket and SSH, do not supply your Bitbucket username.'; + break; + default: + $scope.urlPopover = '

URL popover text'; + } + } + }; $scope.formCancel = function () { diff --git a/awx/ui/client/src/controllers/Users.js b/awx/ui/client/src/controllers/Users.js index 09bde96e87..bd1a8ecfc5 100644 --- a/awx/ui/client/src/controllers/Users.js +++ b/awx/ui/client/src/controllers/Users.js @@ -162,7 +162,7 @@ export function UsersAdd($scope, $rootScope, $compile, $location, $log, generator.reset(); $scope.user_type_options = user_type_options; - $scope.user_type = user_type_options[0] + $scope.user_type = user_type_options[0]; $scope.$watch('user_type', user_type_sync($scope)); CreateSelect2({ @@ -271,7 +271,7 @@ export function UsersEdit($scope, $rootScope, $location, generator.reset(); $scope.user_type_options = user_type_options; - $scope.user_type = user_type_options[0] + $scope.user_type = user_type_options[0]; $scope.$watch('user_type', user_type_sync($scope)); var setScopeFields = function(data){ diff --git a/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js b/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js index 868742154a..6d6f2934f8 100644 --- a/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js +++ b/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js @@ -49,7 +49,7 @@ export default label: "Inventories", }, { - url: "/#/inventories/?inventory_sources_with_failures", + url: "/#/inventories?status=sync-failed", number: scope.data.inventories.inventory_failed, label: "Inventory Sync Failures", isFailureCount: true @@ -60,7 +60,7 @@ export default label: "Projects" }, { - url: "/#/projects/?status=failed", + url: "/#/projects?status=failed", number: scope.data.projects.failed, label: "Project Sync Failures", isFailureCount: true diff --git a/awx/ui/client/src/dashboard/graphs/dashboard-graphs.block.less b/awx/ui/client/src/dashboard/graphs/dashboard-graphs.block.less index 8abd237d0d..e2674b8c6f 100644 --- a/awx/ui/client/src/dashboard/graphs/dashboard-graphs.block.less +++ b/awx/ui/client/src/dashboard/graphs/dashboard-graphs.block.less @@ -118,6 +118,7 @@ top: auto; box-shadow: none; text-transform: uppercase; + cursor: pointer; } .DashboardGraphs-periodDropdown, diff --git a/awx/ui/client/src/forms/Credentials.js b/awx/ui/client/src/forms/Credentials.js index 655b28e416..b31c3bed68 100644 --- a/awx/ui/client/src/forms/Credentials.js +++ b/awx/ui/client/src/forms/Credentials.js @@ -246,7 +246,7 @@ export default rows: 10, awPopOver: "SSH key description", awPopOverWatch: "key_description", - dataTitle: 'Help', + dataTitle: 'Private Key', dataPlacement: 'right', dataContainer: "body", subForm: "credentialSubForm" diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index cc82057048..7a9065af2b 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -81,6 +81,11 @@ export default }, project: { label: 'Project', + labelAction: { + label: 'RESET', + ngClick: 'resetProjectToDefault()', + 'class': "{{!(job_type.value === 'scan' && project_name !== 'Default') ? 'hidden' : ''}}", + }, type: 'lookup', sourceModel: 'project', sourceField: 'name', @@ -99,6 +104,7 @@ export default label: 'Playbook', type:'select', ngOptions: 'book for book in playbook_options track by book', + ngDisabled: "job_type.value === 'scan' && project_name === 'Default'", id: 'playbook-select', awRequiredWhen: { reqExpression: "playbookrequired", @@ -110,12 +116,6 @@ export default dataPlacement: 'right', dataContainer: "body", }, - default_scan: { - type: 'custom', - column: 1, - ngShow: 'job_type.value === "scan" && project_name !== "Default"', - control: 'Reset to default project and playbook' - }, credential: { label: 'Machine Credential', type: 'lookup', @@ -204,7 +204,7 @@ export default }, job_tags: { label: 'Job Tags', - type: 'textarea', + type: 'text', rows: 1, addRequired: false, editRequired: false, diff --git a/awx/ui/client/src/forms/Projects.js b/awx/ui/client/src/forms/Projects.js index 7b01537ff6..24f666c0d5 100644 --- a/awx/ui/client/src/forms/Projects.js +++ b/awx/ui/client/src/forms/Projects.js @@ -64,11 +64,10 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) ngChange: 'scmChange()', addRequired: true, editRequired: true, - hasSubForm: true + hasSubForm: true, }, missing_path_alert: { type: 'alertblock', - "class": 'alert-info', ngShow: "showMissingPlaybooksAlert && scm_type.value == 'manual'", alertTxt: '

WARNING: There are no available playbook directories in {{ base_dir }}. ' + 'Either that directory is empty, or all of the contents are already assigned to other projects. ' + @@ -79,7 +78,7 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) base_dir: { label: 'Project Base Path', type: 'text', - //"class": 'col-lg-6', + class: 'Form-textUneditable', showonly: true, ngShow: "scm_type.value == 'manual' " , awPopOver: '

Base path used for locating playbooks. Directories found inside this path will be listed in the playbook directory drop-down. ' + @@ -115,30 +114,12 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) init: false }, subForm: 'sourceSubForm', - helpCollapse: [{ - hdr: 'GIT URLs', - content: '

Example URLs for GIT SCM include:

' + - '

Note: When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' + - 'do not enter a username (other than git). Additionally, GitHub and Bitbucket do not support password authentication when using ' + - 'SSH. GIT read only protocol (git://) does not use username or password information.', - show: "scm_type.value == 'git'" - }, { - hdr: 'SVN URLs', - content: '

Example URLs for Subversion SCM include:

' + - '', - show: "scm_type.value == 'svn'" - }, { - hdr: 'Mercurial URLs', - content: '

Example URLs for Mercurial SCM include:

' + - '' + - '

Note: Mercurial does not support password authentication for SSH. ' + - 'Do not put the username and key in the URL. ' + - 'If using Bitbucket and SSH, do not supply your Bitbucket username.', - show: "scm_type.value == 'hg'" - }], + hideSubForm: "scm_type.value === 'manual'", + awPopOverWatch: "urlPopover", + awPopOver: "set in controllers/projects", + dataTitle: 'SCM URL', + dataContainer: 'body', + dataPlacement: 'right' }, scm_branch: { labelBind: "scmBranchLabel", @@ -174,7 +155,7 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) dataTitle: 'SCM Clean', dataContainer: 'body', dataPlacement: 'right', - labelClass: 'checkbox-options' + labelClass: 'checkbox-options stack-inline' }, { name: 'scm_delete_on_update', label: 'Delete on Update', @@ -186,7 +167,7 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) dataTitle: 'SCM Delete', dataContainer: 'body', dataPlacement: 'right', - labelClass: 'checkbox-options' + labelClass: 'checkbox-options stack-inline' }, { name: 'scm_update_on_launch', label: 'Update on Launch', @@ -197,7 +178,7 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) dataTitle: 'SCM Update', dataContainer: 'body', dataPlacement: 'right', - labelClass: 'checkbox-options' + labelClass: 'checkbox-options stack-inline' }] }, scm_update_cache_timeout: { @@ -273,7 +254,7 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition']) } }, notifications: { - include: "NotificationsList" + include: "NotificationsList", } }, diff --git a/awx/ui/client/src/forms/Teams.js b/awx/ui/client/src/forms/Teams.js index 7992cc38f2..3bea1a81cb 100644 --- a/awx/ui/client/src/forms/Teams.js +++ b/awx/ui/client/src/forms/Teams.js @@ -132,9 +132,10 @@ export default "delete": { label: 'Remove', ngClick: 'deletePermissionFromTeam(team_id, team_obj.name, role.name, role.summary_fields.resource_name, role.related.teams)', - class: "List-actionButton--delete", + 'class': "List-actionButton--delete", iconClass: 'fa fa-times', - awToolTip: 'Dissasociate permission from team' + awToolTip: 'Dissasociate permission from team', + dataPlacement: 'top' } }, hideOnSuperuser: true diff --git a/awx/ui/client/src/forms/Users.js b/awx/ui/client/src/forms/Users.js index 9ae8cde69f..f1e6aada99 100644 --- a/awx/ui/client/src/forms/Users.js +++ b/awx/ui/client/src/forms/Users.js @@ -138,7 +138,6 @@ export default iterator: 'team', open: false, index: false, - actions: {}, fields: { diff --git a/awx/ui/client/src/helpers/JobDetail.js b/awx/ui/client/src/helpers/JobDetail.js index df42b50328..f6c0ef60cc 100644 --- a/awx/ui/client/src/helpers/JobDetail.js +++ b/awx/ui/client/src/helpers/JobDetail.js @@ -784,7 +784,6 @@ export default url, play; scope.tasks = []; - if (scope.selectedPlay) { url = scope.job.url + 'job_tasks/?event_id=' + scope.selectedPlay; url += (scope.search_task_name) ? '&task__icontains=' + scope.search_task_name : ''; @@ -912,16 +911,25 @@ export default scope.tasks[idx].taskActiveClass = ''; } }); - params = { - parent: scope.selectedTask, - event__startswith: 'runner', - page_size: scope.hostResultsMaxRows, - order: 'host_name,counter', - }; - JobDetailService.getRelatedJobEvents(scope.job.id, params).success(function(res){ - scope.hostResults = JobDetailService.processHostEvents(res.results); + if (scope.selectedTask !== null){ + params = { + parent: scope.selectedTask, + event__startswith: 'runner', + page_size: scope.hostResultsMaxRows, + order: 'host_name,counter', + }; + if (scope.search_host_status === 'failed'){ + params.failed = true; + } + JobDetailService.getRelatedJobEvents(scope.job.id, params).success(function(res){ + scope.hostResults = JobDetailService.processHostEvents(res.results); + scope.hostResultsLoading = false; + }); + } + else{ + scope.hostResults = []; scope.hostResultsLoading = false; - }); + } }; }]) @@ -934,7 +942,7 @@ export default graph_data.push({ label: 'OK', value: count.ok.length, - color: '#60D66F' + color: '#5CB85C' }); } if (count.changed.length > 0) { @@ -979,18 +987,19 @@ export default job_detail_chart = nv.models.pieChart() .margin({bottom: 15}) .x(function(d) { - return d.label +': '+ Math.round((d.value/total)*100) + "%"; + return d.label +': '+ Math.floor((d.value/total)*100) + "%"; }) .y(function(d) { return d.value; }) - .showLabels(true) - .showLegend(false) + .showLabels(false) + .showLegend(true) .growOnHover(false) .labelThreshold(0.01) .tooltipContent(function(x, y) { return '

'+x+'

'+ '

' + Math.floor(y.replace(',','')) + ' HOSTS ' + '

'; }) .color(colors); - + job_detail_chart.legend.rightAlign(false); + job_detail_chart.legend.margin({top: 5, right: 450, left:0, bottom: 0}); d3.select(element.find('svg')[0]) .datum(dataset) .transition().duration(350) @@ -1000,19 +1009,15 @@ export default "font-style": "normal", "font-weight":400, "src": "url(/static/assets/OpenSans-Regular.ttf)", - "width": 500, + "width": 600, "height": 300, + "color": '#848992' }); - - d3.select(element.find(".nv-label text")[0]) - .attr("class", "HostSummary-graph--successful") + d3.select(element.find(".nv-noData")[0]) .style({ - "font-family": 'Open Sans', - "font-size": "16px", - "text-transform" : "uppercase", - "fill" : colors[0], - "src": "url(/static/assets/OpenSans-Regular.ttf)" + "text-anchor": 'start' }); + /* d3.select(element.find(".nv-label text")[1]) .attr("class", "HostSummary-graph--changed") .style({ @@ -1040,6 +1045,7 @@ export default "fill" : colors[3], "src": "url(/static/assets/OpenSans-Regular.ttf)" }); + */ return job_detail_chart; }; }]) diff --git a/awx/ui/client/src/helpers/JobTemplates.js b/awx/ui/client/src/helpers/JobTemplates.js index 90317a41a8..80242ccecd 100644 --- a/awx/ui/client/src/helpers/JobTemplates.js +++ b/awx/ui/client/src/helpers/JobTemplates.js @@ -177,7 +177,7 @@ angular.module('JobTemplatesHelper', ['Utilities']) }); - if(scope.project === "" && scope.playbook === ""){ + if (scope.project === "" && scope.playbook === "") { scope.toggleScanInfo(); } diff --git a/awx/ui/client/src/helpers/Schedules.js b/awx/ui/client/src/helpers/Schedules.js index ae0c8649c0..72761011a7 100644 --- a/awx/ui/client/src/helpers/Schedules.js +++ b/awx/ui/client/src/helpers/Schedules.js @@ -20,7 +20,7 @@ export default angular.module('SchedulesHelper', [ 'Utilities', 'RestServices', 'SchedulesHelper', 'SearchHelper', 'PaginationHelpers', listGenerator.name, 'ModalDialog', 'GeneratorHelpers']) - .factory('EditSchedule', ['SchedulerInit', '$rootScope', 'Wait', 'Rest', + .factory('EditSchedule', ['SchedulerInit', '$rootScope', 'Wait', 'Rest', 'ProcessErrors', 'GetBasePath', 'SchedulePost', '$state', function(SchedulerInit, $rootScope, Wait, Rest, ProcessErrors, GetBasePath, SchedulePost, $state) { @@ -176,7 +176,7 @@ export default }; }]) - .factory('AddSchedule', ['$location', '$rootScope', '$stateParams', + .factory('AddSchedule', ['$location', '$rootScope', '$stateParams', 'SchedulerInit', 'Wait', 'GetBasePath', 'Empty', 'SchedulePost', '$state', 'Rest', 'ProcessErrors', function($location, $rootScope, $stateParams, SchedulerInit, Wait, GetBasePath, Empty, SchedulePost, $state, Rest, @@ -295,8 +295,7 @@ export default }]) .factory('SchedulePost', ['Rest', 'ProcessErrors', 'RRuleToAPI', 'Wait', - 'ToJSON', - function(Rest, ProcessErrors, RRuleToAPI, Wait, ToJSON) { + function(Rest, ProcessErrors, RRuleToAPI, Wait) { return function(params) { var scope = params.scope, url = params.url, @@ -326,8 +325,8 @@ export default schedule.extra_data = JSON.stringify(extra_vars); } else if(scope.extraVars){ - schedule.extra_data = scope.parseType === 'yaml' ? - (scope.extraVars === '---' ? "" : jsyaml.safeLoad(scope.extraVars)) : scope.extraVars; + schedule.extra_data = scope.parseType === 'yaml' ? + (scope.extraVars === '---' ? "" : jsyaml.safeLoad(scope.extraVars)) : scope.extraVars; } Rest.setUrl(url); if (mode === 'add') { 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 947b1c0341..f38e99239f 100644 --- a/awx/ui/client/src/inventories/list/inventory-list.controller.js +++ b/awx/ui/client/src/inventories/list/inventory-list.controller.js @@ -17,7 +17,7 @@ function InventoriesList($scope, $rootScope, $location, $log, Find, Empty, $state) { var list = InventoryList, - defaultUrl = GetBasePath('inventory'), + defaultUrl = GetBasePath('inventory') + ($stateParams.status === 'sync-failed' ? '?not__inventory_sources_with_failures=0' : ''), view = generateList, paths = $location.path().replace(/^\//, '').split('/'), mode = (paths[0] === 'inventories') ? 'edit' : 'select'; diff --git a/awx/ui/client/src/inventories/list/inventory-list.route.js b/awx/ui/client/src/inventories/list/inventory-list.route.js index 65709dd512..af1cc1853b 100644 --- a/awx/ui/client/src/inventories/list/inventory-list.route.js +++ b/awx/ui/client/src/inventories/list/inventory-list.route.js @@ -9,7 +9,7 @@ import InventoriesList from './inventory-list.controller'; export default { name: 'inventories', - route: '/inventories', + route: '/inventories?{status}', templateUrl: templateUrl('inventories/inventories'), controller: InventoriesList, data: { diff --git a/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js index b5969304bc..965b3e67af 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-list.controller.js @@ -80,7 +80,7 @@ group_id: id }); }; - $scope.showFailedHosts = function(x, y, z){ + $scope.showFailedHosts = function() { $state.go('inventoryManage', {failed: true}, {reload: true}); }; $scope.scheduleGroup = function(id) { @@ -91,7 +91,7 @@ $scope.$parent.groupsSelected = selection.length > 0 ? true : false; $scope.$parent.groupsSelectedItems = selection.selectedItems; }); - $scope.$on('PostRefresh', () =>{ + $scope.$on('PostRefresh', () => { $scope.groups.forEach( (group, index) => { var group_status, hosts_status; group_status = GetSyncStatusMsg({ diff --git a/awx/ui/client/src/inventories/manage/hosts/main.js b/awx/ui/client/src/inventories/manage/hosts/main.js index 8b71ce162a..0d6aeedd1c 100644 --- a/awx/ui/client/src/inventories/manage/hosts/main.js +++ b/awx/ui/client/src/inventories/manage/hosts/main.js @@ -8,7 +8,7 @@ import {ManageHostsAdd, ManageHostsEdit} from './hosts.route'; export default angular.module('manageHosts', []) - .run(['$stateExtender', '$state', function($stateExtender, $state){ + .run(['$stateExtender', function($stateExtender){ $stateExtender.addState(ManageHostsAdd); $stateExtender.addState(ManageHostsEdit); }]); diff --git a/awx/ui/client/src/inventory-scripts/edit/edit.controller.js b/awx/ui/client/src/inventory-scripts/edit/edit.controller.js index a9515d60a5..c2cb27cf0c 100644 --- a/awx/ui/client/src/inventory-scripts/edit/edit.controller.js +++ b/awx/ui/client/src/inventory-scripts/edit/edit.controller.js @@ -61,7 +61,7 @@ export default data.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField]; } } - $scope.canEdit = data['script'] !== null; + $scope.canEdit = data.script !== null; if (!$scope.canEdit) { $scope.script = "Script contents hidden"; } 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 546772ffc0..7c95a8b42d 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,6 +1,6 @@
-
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.
@@ -13,7 +13,7 @@
-