diff --git a/awx/api/filters.py b/awx/api/filters.py index d023b2aa08..f4e65a8d82 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -219,7 +219,7 @@ class FieldLookupBackend(BaseFilterBackend): else: q = Q(**{k:v}) queryset = queryset.filter(q) - queryset = queryset.filter(*args) + queryset = queryset.filter(*args).distinct() return queryset except (FieldError, FieldDoesNotExist, ValueError), e: raise ParseError(e.args[0]) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 0280b0f09f..ce203c533c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1766,7 +1766,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,)), diff --git a/awx/api/views.py b/awx/api/views.py index 98a292b9c5..5ea5d5b6b1 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2212,6 +2212,13 @@ class JobTemplateList(ListCreateAPIView): serializer_class = JobTemplateSerializer always_allow_superuser = False + def post(self, request, *args, **kwargs): + ret = super(JobTemplateList, self).post(request, *args, **kwargs) + if ret.status_code == 201: + job_template = JobTemplate.objects.get(id=ret.data['id']) + job_template.admin_role.members.add(request.user) + return ret + class JobTemplateDetail(RetrieveUpdateDestroyAPIView): model = JobTemplate diff --git a/awx/main/access.py b/awx/main/access.py index cec0b7c2bf..1674e38786 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -773,7 +773,9 @@ class JobTemplateAccess(BaseAccess): inventory_pk = get_pk_from_dict(data, 'inventory') inventory = Inventory.objects.filter(id=inventory_pk) if not inventory.exists() and not data.get('ask_inventory_on_launch', False): - return False # Does this make sense? Maybe should check read access + return False + if inventory.exists() and self.user not in inventory[0].use_role: + return False project_pk = get_pk_from_dict(data, 'project') if 'job_type' in data and data['job_type'] == PERM_INVENTORY_SCAN: @@ -786,10 +788,8 @@ class JobTemplateAccess(BaseAccess): # If the user has admin access to the project (as an org admin), should # be able to proceed without additional checks. project = get_object_or_400(Project, pk=project_pk) - if self.user in project.admin_role: - return True - return self.user in project.admin_role and self.user in inventory.read_role + return self.user in project.use_role def can_start(self, obj, validate_license=True): # Check license. @@ -817,13 +817,62 @@ 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 diff --git a/awx/main/migrations/0017_v300_prompting_migrations.py b/awx/main/migrations/0017_v300_prompting_migrations.py index c5a1df0eb9..6bec778956 100644 --- a/awx/main/migrations/0017_v300_prompting_migrations.py +++ b/awx/main/migrations/0017_v300_prompting_migrations.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +from awx.main.migrations import _rbac as rbac from awx.main.migrations import _ask_for_variables as ask_for_variables from awx.main.migrations import _migration_utils as migration_utils from django.db import migrations @@ -15,4 +16,5 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(migration_utils.set_current_apps_for_migrations), migrations.RunPython(ask_for_variables.migrate_credential), + migrations.RunPython(rbac.rebuild_role_hierarchy), ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index e16b3575d3..7eeb95579d 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -215,7 +215,7 @@ def migrate_inventory(apps, schema_editor): Inventory = apps.get_model('main', 'Inventory') Permission = apps.get_model('main', 'Permission') - def role_from_permission(): + def role_from_permission(perm): if perm.permission_type == 'admin': return inventory.admin_role elif perm.permission_type == 'read': @@ -233,7 +233,7 @@ def migrate_inventory(apps, schema_editor): role = None execrole = None - role = role_from_permission() + role = role_from_permission(perm) if role is None: raise Exception(smart_text(u'Unhandled permission type for inventory: {}'.format( perm.permission_type))) @@ -320,24 +320,30 @@ def migrate_projects(apps, schema_editor): logger.warn(smart_text(u'adding Project({}) admin: {}'.format(project.name, project.created_by.username))) for team in project.deprecated_teams.all(): - team.member_role.children.add(project.use_role) + team.member_role.children.add(project.read_role) logger.info(smart_text(u'adding Team({}) access for Project({})'.format(team.name, project.name))) - if project.organization is not None: - for user in project.organization.deprecated_users.all(): - project.use_role.members.add(user) - logger.info(smart_text(u'adding Organization({}) member access to Project({})'.format(project.organization.name, project.name))) - for perm in Permission.objects.filter(project=project): - # All perms at this level just imply a user or team can read + if perm.permission_type == 'create': + role = project.use_role + else: + role = project.read_role + if perm.team: - perm.team.member_role.children.add(project.use_role) + perm.team.member_role.children.add(role) logger.info(smart_text(u'adding Team({}) access for Project({})'.format(perm.team.name, project.name))) if perm.user: - project.use_role.members.add(perm.user) + role.members.add(perm.user) logger.info(smart_text(u'adding User({}) access for Project({})'.format(perm.user.username, project.name))) + if project.organization is not None: + for user in project.organization.deprecated_users.all(): + if not (project.use_role.members.filter(pk=user.id).exists() or project.admin_role.members.filter(pk=user.id).exists()): + project.read_role.members.add(user) + logger.info(smart_text(u'adding Organization({}) member access to Project({})'.format(project.organization.name, project.name))) + + @log_migration def migrate_job_templates(apps, schema_editor): @@ -403,7 +409,7 @@ def migrate_job_templates(apps, schema_editor): team_create_permissions = set( jt_permission_qs - .filter(permission_type__in=['create'] if jt.job_type == 'check' else ['create']) + .filter(permission_type__in=['create']) .values_list('team__id', flat=True) ) team_run_permissions = set( @@ -413,12 +419,12 @@ def migrate_job_templates(apps, schema_editor): ) user_create_permissions = set( jt_permission_qs - .filter(permission_type__in=['create'] if jt.job_type == 'check' else ['run']) + .filter(permission_type__in=['create']) .values_list('user__id', flat=True) ) user_run_permissions = set( jt_permission_qs - .filter(permission_type__in=['check', 'run'] if jt.job_type == 'check' else ['create']) + .filter(permission_type__in=['check', 'run'] if jt.job_type == 'check' else ['run']) .values_list('user__id', flat=True) ) @@ -446,17 +452,20 @@ def migrate_job_templates(apps, schema_editor): logger.info(smart_text(u'transfering execute access on JobTemplate({}) to Team({})'.format(jt.name, team.name))) for user in User.objects.filter(id__in=user_create_permissions).iterator(): + cred = jt.credential or jt.cloud_credential if (jt.inventory.id in user_inv_permissions[user.id] or any([jt.inventory.id in team_inv_permissions[team.id] for team in user.deprecated_teams.all()])) and \ - ((not jt.credential and not jt.cloud_credential) or - Credential.objects.filter(Q(deprecated_user=user) | Q(deprecated_team__deprecated_users=user), jobtemplates=jt).exists()): + (not cred or cred.deprecated_user == user or + (cred.deprecated_team and cred.deprecated_team.deprecated_users.filter(pk=user.id).exists())): jt.admin_role.members.add(user) logger.info(smart_text(u'transfering admin access on JobTemplate({}) to User({})'.format(jt.name, user.username))) for user in User.objects.filter(id__in=user_run_permissions).iterator(): + cred = jt.credential or jt.cloud_credential + if (jt.inventory.id in user_inv_permissions[user.id] or any([jt.inventory.id in team_inv_permissions[team.id] for team in user.deprecated_teams.all()])) and \ - ((not jt.credential and not jt.cloud_credential) or - Credential.objects.filter(Q(deprecated_user=user) | Q(deprecated_team__deprecated_users=user), jobtemplates=jt).exists()): + (not cred or cred.deprecated_user == user or + (cred.deprecated_team and cred.deprecated_team.deprecated_users.filter(pk=user.id).exists())): jt.execute_role.members.add(user) logger.info(smart_text(u'transfering execute access on JobTemplate({}) to User({})'.format(jt.name, user.username))) @@ -468,8 +477,6 @@ def rebuild_role_hierarchy(apps, schema_editor): start = time() roots = Role.objects \ .all() \ - .exclude(pk__in=Role.parents.through.objects.all() - .values_list('from_role_id', flat=True).distinct()) \ .values_list('id', flat=True) stop = time() logger.info('Found %d roots in %f seconds, rebuilding ancestry map' % (len(roots), stop - start)) 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/tasks.py b/awx/main/tasks.py index 50ba05a2e5..5516181468 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -441,7 +441,12 @@ class BaseTask(Task): if settings.ANSIBLE_USE_VENV: env['VIRTUAL_ENV'] = settings.ANSIBLE_VENV_PATH env['PATH'] = os.path.join(settings.ANSIBLE_VENV_PATH, "bin") + ":" + env['PATH'] - env['PYTHONPATH'] = os.path.join(settings.ANSIBLE_VENV_PATH, "lib/python2.7/site-packages/") + ":" + venv_libdir = os.path.join(settings.ANSIBLE_VENV_PATH, "lib") + env.pop('PYTHONPATH', None) # default to none if no python_ver matches + for python_ver in ["python2.7", "python2.6"]: + if os.path.isdir(os.path.join(venv_libdir, python_ver)): + env['PYTHONPATH'] = os.path.join(venv_libdir, python_ver, "site-packages") + ":" + break if self.should_use_proot(instance, **kwargs): env['PROOT_TMP_DIR'] = tower_settings.AWX_PROOT_BASE_PATH return env diff --git a/awx/main/tests/factories/README.md b/awx/main/tests/factories/README.md new file mode 100644 index 0000000000..c451c02598 --- /dev/null +++ b/awx/main/tests/factories/README.md @@ -0,0 +1,65 @@ +factories +========= + +This is a module for defining stand-alone factories and fixtures. Ideally a fixture will implement a single item. +DO NOT decorate fixtures in this module with the @pytest.fixture. These fixtures are to be combined +with fixture factories and composition using the `conftest.py` convention. Those composed fixtures +will be decorated for usage and discovery. + +Use the fixtures directly in factory methods to build up the desired set of components and relationships. +Each fixture should create exactly one object and should support the option for that object to be persisted +or not. + +A factory should create at a minimum a single object for that factory type. The creation of any +associated objects should be explicit. For example, the `create_organization` factory when given only +a `name` parameter will create an Organization but it will not implicitly create any other objects. + +teams +----- + +There is some special handling for users when adding teams. There is a short hand that allows you to +assign a user to the member\_role of a team using the string notation of `team_name:user_name`. There is +no shortcut for adding a user to the admin\_role of a team. See the roles section for more information +about how to do that. + +roles +----- + +The roles helper allows you pass in roles to a factory. These roles assignments will happen after +the objects are created. Using the roles parameter required that persisted=True (default). + +You can use a string notation of `object_name.role_name:user` OR `object_name.role_name:object_name.child_role` + + obj.parent_role:user # This will make the user a member of parent_role + obj1.role:obj2.role # This will make obj2 a child role of obj1 + + team1.admin_role:joe + team1.admin_role:project1.admin_role + +examples +-------- + + objects = create_organization('test-org') + assert objects.organization.name == 'test-org' + + objects = create_organization('test-org', projects=['test-proj']) + assert objects.projects.test-proj.organization == objects.organization + + objects = create_organization('test-org', persisted=False) + assert not objects.organization.pk + +patterns +-------- + +`mk` functions are single object fixtures. They should create only a single object with the minimum deps. +They should also accept a `persited` flag, if they must be persisted to work, they raise an error if persisted=False + +`generate` and `apply` functions are helpers that build up the various parts of a `create` functions objects. These +should be useful for more than one create function to use and should explicitly accept all of the values needed +to execute. These functions should also be robust and have very speciifc error reporting about constraints and/or +bad values. + +`create` functions compose many of the `mk` and `generate` functions to make different object +factories. These functions when giving the minimum set of arguments should only produce a +single artifact (or the minimum needed for that object). These should be wrapped by discoverable +fixtures in various conftest.py files. diff --git a/awx/main/tests/factories/__init__.py b/awx/main/tests/factories/__init__.py new file mode 100644 index 0000000000..8c8eb326d1 --- /dev/null +++ b/awx/main/tests/factories/__init__.py @@ -0,0 +1,16 @@ +from .tower import ( + create_organization, + create_job_template, + create_notification_template, +) + +from .exc import ( + NotUnique, +) + +__all__ = [ + 'create_organization', + 'create_job_template', + 'create_notification_template', + 'NotUnique', +] diff --git a/awx/main/tests/factories/exc.py b/awx/main/tests/factories/exc.py new file mode 100644 index 0000000000..aa51de5bd3 --- /dev/null +++ b/awx/main/tests/factories/exc.py @@ -0,0 +1,5 @@ +class NotUnique(Exception): + def __init__(self, name, objects): + msg = '{} is not a unique key, found {}={}'.format(name, name, objects[name]) + super(Exception, self).__init__(msg) + diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py new file mode 100644 index 0000000000..7273997ba5 --- /dev/null +++ b/awx/main/tests/factories/fixtures.py @@ -0,0 +1,124 @@ +from django.contrib.auth.models import User + +from awx.main.models import ( + Organization, + Project, + Team, + Instance, + JobTemplate, + NotificationTemplate, + Credential, + Inventory, + Label, +) + +# mk methods should create only a single object of a single type. +# they should also have the option of being persisted or not. +# if the object must be persisted an error should be raised when +# persisted=False +# + +def mk_instance(persisted=True): + if not persisted: + raise RuntimeError('creating an Instance requires persisted=True') + from django.conf import settings + return Instance.objects.get_or_create(uuid=settings.SYSTEM_UUID, primary=True, hostname="instance.example.org") + + +def mk_organization(name, description=None, persisted=True): + description = description or '{}-description'.format(name) + org = Organization(name=name, description=description) + if persisted: + mk_instance(persisted) + org.save() + return org + + +def mk_label(name, organization=None, description=None, persisted=True): + description = description or '{}-description'.format(name) + label = Label(name=name, description=description) + if organization is not None: + label.organization = organization + if persisted: + label.save() + return label + + +def mk_team(name, organization=None, persisted=True): + team = Team(name=name) + if organization is not None: + team.organization = organization + if persisted: + mk_instance(persisted) + team.save() + return team + + +def mk_user(name, is_superuser=False, organization=None, team=None, persisted=True): + user = User(username=name, is_superuser=is_superuser) + if persisted: + user.save() + if organization is not None: + organization.member_role.members.add(user) + if team is not None: + team.member_role.members.add(user) + return user + + +def mk_project(name, organization=None, description=None, persisted=True): + description = description or '{}-description'.format(name) + project = Project(name=name, description=description) + if organization is not None: + project.organization = organization + if persisted: + project.save() + return project + + +def mk_credential(name, cloud=False, kind='ssh', persisted=True): + cred = Credential(name=name, cloud=cloud, kind=kind) + if persisted: + cred.save() + return cred + + +def mk_notification_template(name, notification_type='webhook', configuration=None, organization=None, persisted=True): + nt = NotificationTemplate(name=name) + nt.notification_type = notification_type + nt.notification_configuration = configuration or dict(url="http://localhost", headers={"Test": "Header"}) + + if organization is not None: + nt.organization = organization + if persisted: + nt.save() + return nt + + +def mk_inventory(name, organization=None, persisted=True): + inv = Inventory(name=name) + if organization is not None: + inv.organization = organization + if persisted: + inv.save() + return inv + + +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, playbook='mocked') + + jt.inventory = inventory + if jt.inventory is None: + jt.ask_inventory_on_launch = True + + jt.credential = credential + if jt.credential is None: + jt.ask_credential_on_launch = True + + jt.project = project + + if persisted: + jt.save() + return jt diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py new file mode 100644 index 0000000000..3220f4c78b --- /dev/null +++ b/awx/main/tests/factories/tower.py @@ -0,0 +1,313 @@ +from collections import namedtuple + +from django.contrib.auth.models import User + +from awx.main.models import ( + Organization, + Project, + Team, + NotificationTemplate, + Credential, + Inventory, + Label, +) + +from .fixtures import ( + mk_organization, + mk_team, + mk_user, + mk_job_template, + mk_credential, + mk_inventory, + mk_project, + mk_label, + mk_notification_template, +) + +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 + it is to be used with apply_roles, which expects unique object names. + + roles share a common name e.g. admin_role, member_role. This ensures that the + roles short hand used for mapping Roles and Users in apply_roles will function as desired. + ''' + combined_objects = {} + for o in objects: + if type(o) is dict: + for k,v in o.iteritems(): + if combined_objects.get(k) is not None: + raise NotUnique(k, combined_objects) + combined_objects[k] = v + elif hasattr(o, 'name'): + if combined_objects.get(o.name) is not None: + raise NotUnique(o.name, combined_objects) + combined_objects[o.name] = o + else: + if o is not None: + raise RuntimeError('expected a list of dict or list of list, got a type {}'.format(type(o))) + return combined_objects + +def apply_roles(roles, objects, persisted): + '''apply_roles evaluates a list of Role relationships represented as strings. + The format of this string is 'role:[user|role]'. When a user is provided, they will be + made a member of the role on the LHS. When a role is provided that role will be added to + the children of the role on the LHS. + + This function assumes that objects is a dictionary that contains a unique set of key to value + mappings for all possible "Role objects". See the example below: + + Mapping Users + ------------- + roles = ['org1.admin_role:user1', 'team1.admin_role:user1'] + objects = {'org1': Organization, 'team1': Team, 'user1': User] + + Mapping Roles + ------------- + roles = ['org1.admin_role:team1.admin_role'] + objects = {'org1': Organization, 'team1': Team} + + Invalid Mapping + --------------- + roles = ['org1.admin_role:team1.admin_role'] + objects = {'org1': Organization', 'user1': User} # Exception, no team1 entry + ''' + if roles is None: + return None + + if not persisted: + raise RuntimeError('roles can not be used when persisted=False') + + for role in roles: + obj_role, sep, member_role = role.partition(':') + if not member_role: + raise RuntimeError('you must provide an assignment role, got None') + + obj_str, o_role_str = obj_role.split('.') + member_str, m_sep, m_role_str = member_role.partition('.') + + obj = objects[obj_str] + obj_role = getattr(obj, o_role_str) + + member = objects[member_str] + if m_role_str: + if hasattr(member, m_role_str): + member_role = getattr(member, m_role_str) + obj_role.children.add(member_role) + else: + raise RuntimeError('unable to find {} role for {}'.format(m_role_str, member_str)) + else: + if type(member) is User: + obj_role.members.add(member) + else: + raise RuntimeError('unable to add non-user {} for members list of {}'.format(member_str, obj_str)) + +def generate_users(organization, teams, superuser, persisted, **kwargs): + '''generate_users evaluates a mixed list of User objects and strings. + If a string is encountered a user with that username is created and added to the lookup dict. + If a User object is encountered the User.username is used as a key for the lookup dict. + + A short hand for assigning a user to a team is available in the following format: "team_name:username". + If a string in that format is encounted an attempt to lookup the team by the key team_name from the teams + argumnent is made, a KeyError will be thrown if the team does not exist in the dict. The teams argument should + be a dict of {Team.name:Team} + ''' + users = {} + key = 'superusers' if superuser else 'users' + if key in kwargs and kwargs.get(key) is not None: + for u in kwargs[key]: + if type(u) is User: + users[u.username] = u + else: + p1, sep, p2 = u.partition(':') + if p2: + t = teams[p1] + users[p2] = mk_user(p2, organization=organization, team=t, is_superuser=superuser, persisted=persisted) + else: + users[p1] = mk_user(p1, organization=organization, team=None, is_superuser=superuser, persisted=persisted) + return users + +def generate_teams(organization, persisted, **kwargs): + '''generate_teams evalutes a mixed list of Team objects and strings. + If a string is encountered a team with that string name is created and added to the lookup dict. + If a Team object is encounted the Team.name is used as a key for the lookup dict. + ''' + teams = {} + if 'teams' in kwargs and kwargs.get('teams') is not None: + for t in kwargs['teams']: + if type(t) is Team: + teams[t.name] = t + else: + teams[t] = mk_team(t, organization=organization, persisted=persisted) + return teams + + +class _Mapped(object): + '''_Mapped is a helper class that replaces spaces and dashes + in the name of an object and assigns the object as an attribute + + input: {'my org': Organization} + output: instance.my_org = Organization + ''' + def __init__(self, d): + self.d = d + for k,v in d.items(): + k = k.replace(' ', '_') + k = k.replace('-', '_') + + setattr(self, k.replace(' ','_'), v) + + def all(self): + return self.d.values() + +# create methods are intended to be called directly as needed +# or encapsulated by specific factory fixtures in a conftest +# + +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') + + if 'organization' in kwargs: + org = kwargs['organization'] + if type(org) is not Organization: + org = mk_organization(org, '%s-desc'.format(org), persisted=persisted) + + if 'credential' in kwargs: + cred = kwargs['credential'] + if type(cred) is not Credential: + cred = mk_credential(cred, persisted=persisted) + + if 'project' in kwargs: + proj = kwargs['project'] + if type(proj) is not Project: + proj = mk_project(proj, organization=org, persisted=persisted) + + if 'inventory' in kwargs: + inv = kwargs['inventory'] + if type(inv) is not Inventory: + inv = mk_inventory(inv, organization=org, persisted=persisted) + + jt = mk_job_template(name, project=proj, + inventory=inv, credential=cred, + job_type=job_type, persisted=persisted) + + role_objects = generate_role_objects([org, proj, inv, cred]) + apply_roles(roles, role_objects, persisted) + + return Objects(job_template=jt, + project=proj, + inventory=inv, + credential=cred, + job_type=job_type, + organization=org,) + +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 = {} + + 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: + projects[p.name] = p + else: + projects[p] = mk_project(p, organization=org, persisted=persisted) + + teams = generate_teams(org, persisted, teams=kwargs.get('teams')) + superusers = generate_users(org, teams, True, persisted, superusers=kwargs.get('superusers')) + users = generate_users(org, teams, False, persisted, users=kwargs.get('users')) + + if 'labels' in kwargs: + for l in kwargs['labels']: + if type(l) is Label: + labels[l.name] = l + else: + labels[l] = mk_label(l, organization=org, persisted=persisted) + + if 'notification_templates' in kwargs: + for nt in kwargs['notification_templates']: + if type(nt) is NotificationTemplate: + notification_templates[nt.name] = nt + else: + 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(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), + inventories=_Mapped(inventories)) + +def create_notification_template(name, roles=None, persisted=True, **kwargs): + Objects = generate_objects(["notification_template", + "organization", + "users", + "superusers", + "teams",], kwargs) + + organization = None + + if 'organization' in kwargs: + org = kwargs['organization'] + organization = mk_organization(org, '{}-desc'.format(org), persisted=persisted) + + notification_template = mk_notification_template(name, organization=organization, persisted=persisted) + + teams = generate_teams(organization, persisted, teams=kwargs.get('teams')) + superusers = generate_users(organization, teams, True, persisted, superusers=kwargs.get('superusers')) + users = generate_users(organization, teams, False, persisted, users=kwargs.get('users')) + + role_objects = generate_role_objects([organization, notification_template]) + apply_roles(roles, role_objects, persisted) + return Objects(notification_template=notification_template, + organization=organization, + users=_Mapped(users), + superusers=_Mapped(superusers), + teams=teams) 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_adhoc.py b/awx/main/tests/functional/api/test_adhoc.py new file mode 100644 index 0000000000..43326afcb4 --- /dev/null +++ b/awx/main/tests/functional/api/test_adhoc.py @@ -0,0 +1,148 @@ +import mock # noqa +import pytest + +from django.core.urlresolvers import reverse + + + +""" + def run_test_ad_hoc_command(self, **kwargs): + # Post to list to start a new ad hoc command. + expect = kwargs.pop('expect', 201) + url = kwargs.pop('url', reverse('api:ad_hoc_command_list')) + data = { + 'inventory': self.inventory.pk, + 'credential': self.credential.pk, + 'module_name': 'command', + 'module_args': 'uptime', + } + data.update(kwargs) + for k,v in data.items(): + if v is None: + del data[k] + return self.post(url, data, expect=expect) +""" + +@pytest.fixture +def post_adhoc(post, inventory, machine_credential): + def f(url, data, user, expect=201): + if not url: + url = reverse('api:ad_hoc_command_list') + + if 'module_name' not in data: + data['module_name'] = 'command' + if 'module_args' not in data: + data['module_args'] = 'uptime' + if 'inventory' not in data: + data['inventory'] = inventory.id + if 'credential' not in data: + data['credential'] = machine_credential.id + + for k,v in data.items(): + if v is None: + del data[k] + + return post(url, data, user, expect=expect) + return f + + + +@pytest.mark.django_db +def test_admin_post_ad_hoc_command_list(admin, post_adhoc, inventory, machine_credential): + res = post_adhoc(reverse('api:ad_hoc_command_list'), {}, admin, expect=201) + assert res.data['job_type'] == 'run' + assert res.data['inventory'], inventory.id + assert res.data['credential'] == machine_credential.id + assert res.data['module_name'] == 'command' + assert res.data['module_args'] == 'uptime' + assert res.data['limit'] == '' + assert res.data['forks'] == 0 + assert res.data['verbosity'] == 0 + assert res.data['become_enabled'] is False + + +@pytest.mark.django_db +def test_empty_post_403(admin, post): + post(reverse('api:ad_hoc_command_list'), {}, admin, expect=400) + +@pytest.mark.django_db +def test_empty_put_405(admin, put): + put(reverse('api:ad_hoc_command_list'), {}, admin, expect=405) + +@pytest.mark.django_db +def test_empty_patch_405(admin, patch): + patch(reverse('api:ad_hoc_command_list'), {}, admin, expect=405) + +@pytest.mark.django_db +def test_empty_delete_405(admin, delete): + delete(reverse('api:ad_hoc_command_list'), admin, expect=405) + +@pytest.mark.django_db +def test_user_post_ad_hoc_command_list(alice, post_adhoc, inventory, machine_credential): + inventory.adhoc_role.members.add(alice) + machine_credential.use_role.members.add(alice) + post_adhoc(reverse('api:ad_hoc_command_list'), {}, alice, expect=201) + +@pytest.mark.django_db +def test_user_post_ad_hoc_command_list_xfail(alice, post_adhoc, inventory, machine_credential): + inventory.read_role.members.add(alice) # just read access? no dice. + machine_credential.use_role.members.add(alice) + post_adhoc(reverse('api:ad_hoc_command_list'), {}, alice, expect=403) + +@pytest.mark.django_db +def test_user_post_ad_hoc_command_list_without_creds(alice, post_adhoc, inventory, machine_credential): + inventory.adhoc_role.members.add(alice) + post_adhoc(reverse('api:ad_hoc_command_list'), {}, alice, expect=403) + +@pytest.mark.django_db +def test_user_post_ad_hoc_command_list_without_inventory(alice, post_adhoc, inventory, machine_credential): + machine_credential.use_role.members.add(alice) + post_adhoc(reverse('api:ad_hoc_command_list'), {}, alice, expect=403) + + +@pytest.mark.django_db +def test_admin_post_inventory_ad_hoc_command_list(admin, post_adhoc, inventory): + post_adhoc(reverse('api:inventory_ad_hoc_commands_list', args=(inventory.id,)), {'inventory': None}, admin, expect=201) + post_adhoc(reverse('api:inventory_ad_hoc_commands_list', args=(inventory.id,)), {}, admin, expect=201) + + +@pytest.mark.django_db +def test_get_inventory_ad_hoc_command_list(admin, alice, post_adhoc, get, inventory_factory, machine_credential): + inv1 = inventory_factory('inv1') + inv2 = inventory_factory('inv2') + + post_adhoc(reverse('api:ad_hoc_command_list'), {'inventory': inv1.id}, admin, expect=201) + post_adhoc(reverse('api:ad_hoc_command_list'), {'inventory': inv2.id}, admin, expect=201) + res = get(reverse('api:ad_hoc_command_list'), admin, expect=200) + assert res.data['count'] == 2 + res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv1.id,)), admin, expect=200) + assert res.data['count'] == 1 + res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv2.id,)), admin, expect=200) + assert res.data['count'] == 1 + + 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 + + machine_credential.use_role.members.add(alice) + res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv1.id,)), alice, expect=200) + assert res.data['count'] == 1 + res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv2.id,)), alice, expect=403) + + +@pytest.mark.django_db +def test_bad_data1(admin, post_adhoc): + post_adhoc(reverse('api:ad_hoc_command_list'), {'module_name': 'command', 'module_args': None}, admin, expect=400) + +@pytest.mark.django_db +def test_bad_data2(admin, post_adhoc): + post_adhoc(reverse('api:ad_hoc_command_list'), {'job_type': 'baddata'}, admin, expect=400) + +@pytest.mark.django_db +def test_bad_data3(admin, post_adhoc): + post_adhoc(reverse('api:ad_hoc_command_list'), {'verbosity': -1}, admin, expect=400) + +@pytest.mark.django_db +def test_bad_data4(admin, post_adhoc): + post_adhoc(reverse('api:ad_hoc_command_list'), {'forks': -1}, admin, expect=400) + 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..975eb279b0 --- /dev/null +++ b/awx/main/tests/functional/api/test_job_template.py @@ -0,0 +1,18 @@ +import pytest + +from django.core.urlresolvers import reverse + +@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 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..7f20a099ca --- /dev/null +++ b/awx/main/tests/functional/api/test_job_templates.py @@ -0,0 +1,111 @@ +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.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.playbook = 'mocked.yml' + jt.save() + 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 59d34a2832..081ffca21e 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -38,6 +38,12 @@ from awx.main.models.organization import ( from awx.main.models.notifications import NotificationTemplate +from awx.main.tests.factories import ( + create_organization, + create_job_template, + create_notification_template, +) + ''' Disable all django model signals. ''' @@ -147,18 +153,6 @@ def instance(settings): def organization(instance): return Organization.objects.create(name="test-org", description="test-org-desc") -@pytest.fixture -def organization_factory(instance): - def factory(name): - try: - org = Organization.objects.get(name=name) - except Organization.DoesNotExist: - org = Organization.objects.create(name=name, - description="description for " + name, - ) - return org - return factory - @pytest.fixture def credential(): return Credential.objects.create(kind='aws', name='test-cred') @@ -282,24 +276,9 @@ def permissions(): 'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':True,}, } -@pytest.fixture -def notification_template_factory(organization): - def n(name="test-notification_template"): - try: - notification_template = NotificationTemplate.objects.get(name=name) - except NotificationTemplate.DoesNotExist: - notification_template = NotificationTemplate(name=name, - organization=organization, - notification_type="webhook", - notification_configuration=dict(url="http://localhost", - headers={"Test": "Header"})) - notification_template.save() - return notification_template - return n - @pytest.fixture def post(): - def rf(url, data, user=None, middleware=None, **kwargs): + 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' @@ -311,12 +290,16 @@ def post(): 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 @pytest.fixture def get(): - def rf(url, user=None, middleware=None, **kwargs): + 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' @@ -328,12 +311,16 @@ def get(): 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 @pytest.fixture def put(): - def rf(url, data, user=None, middleware=None, **kwargs): + 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' @@ -345,12 +332,16 @@ def put(): 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 @pytest.fixture def patch(): - def rf(url, data, user=None, middleware=None, **kwargs): + 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' @@ -362,12 +353,16 @@ def patch(): 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 @pytest.fixture def delete(): - def rf(url, user=None, middleware=None, **kwargs): + 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' @@ -379,12 +374,16 @@ def delete(): 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 @pytest.fixture def head(): - def rf(url, user=None, middleware=None, **kwargs): + 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' @@ -396,12 +395,16 @@ def head(): 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 @pytest.fixture def options(): - def rf(url, data, user=None, middleware=None, **kwargs): + 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' @@ -413,6 +416,10 @@ def options(): 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 @@ -474,3 +481,16 @@ def job_template_labels(organization, job_template): job_template.labels.create(name="label-2", organization=organization) return job_template + +@pytest.fixture +def job_template_factory(): + return create_job_template + +@pytest.fixture +def organization_factory(): + return create_organization + +@pytest.fixture +def notification_template_factory(): + return create_notification_template + diff --git a/awx/main/tests/functional/test_fixture_factories.py b/awx/main/tests/functional/test_fixture_factories.py new file mode 100644 index 0000000000..286d375a1a --- /dev/null +++ b/awx/main/tests/functional/test_fixture_factories.py @@ -0,0 +1,85 @@ +import pytest + +from awx.main.tests.factories import NotUnique + +def test_roles_exc_not_persisted(organization_factory): + with pytest.raises(RuntimeError) as exc: + organization_factory('test-org', roles=['test-org.admin_role:user1'], persisted=False) + assert 'persisted=False' in str(exc.value) + + +@pytest.mark.django_db +def test_roles_exc_bad_object(organization_factory): + with pytest.raises(KeyError): + organization_factory('test-org', roles=['test-project.admin_role:user']) + + +@pytest.mark.django_db +def test_roles_exc_not_unique(organization_factory): + with pytest.raises(NotUnique) as exc: + organization_factory('test-org', projects=['foo'], teams=['foo'], roles=['foo.admin_role:user']) + assert 'not a unique key' in str(exc.value) + + +@pytest.mark.django_db +def test_roles_exc_not_assignment(organization_factory): + with pytest.raises(RuntimeError) as exc: + organization_factory('test-org', projects=['foo'], roles=['foo.admin_role']) + assert 'provide an assignment' in str(exc.value) + + +@pytest.mark.django_db +def test_roles_exc_not_found(organization_factory): + with pytest.raises(RuntimeError) as exc: + organization_factory('test-org', users=['user'], projects=['foo'], roles=['foo.admin_role:user.bad_role']) + assert 'unable to find' in str(exc.value) + + +@pytest.mark.django_db +def test_roles_exc_not_user(organization_factory): + with pytest.raises(RuntimeError) as exc: + organization_factory('test-org', projects=['foo'], roles=['foo.admin_role:foo']) + assert 'unable to add non-user' in str(exc.value) + + +@pytest.mark.django_db +def test_org_factory_roles(organization_factory): + objects = organization_factory('org_roles_test', + teams=['team1', 'team2'], + users=['team1:foo', 'bar'], + projects=['baz', 'bang'], + roles=['team2.member_role:foo', + 'team1.admin_role:bar', + 'team1.admin_role:team2.admin_role', + 'baz.admin_role:foo']) + + assert objects.users.bar in objects.teams.team2.admin_role + assert objects.users.foo in objects.projects.baz.admin_role + assert objects.users.foo in objects.teams.team1.member_role + assert objects.teams.team2.admin_role in objects.teams.team1.admin_role.children.all() + + +@pytest.mark.django_db +def test_org_factory(organization_factory): + objects = organization_factory('organization1', + teams=['team1'], + superusers=['superuser'], + users=['admin', 'alice', 'team1:bob'], + projects=['proj1']) + assert hasattr(objects.users, 'admin') + assert hasattr(objects.users, 'alice') + assert hasattr(objects.superusers, 'superuser') + assert objects.users.bob in objects.teams.team1.member_role.members.all() + assert objects.projects.proj1.organization == objects.organization + + +@pytest.mark.django_db +def test_job_template_factory(job_template_factory): + jt_objects = job_template_factory('testJT', organization='org1', + project='proj1', inventory='inventory1', + credential='cred1') + assert jt_objects.job_template.name == 'testJT' + assert jt_objects.project.name == 'proj1' + assert jt_objects.inventory.name == 'inventory1' + assert jt_objects.credential.name == 'cred1' + assert jt_objects.inventory.organization.name == 'org1' diff --git a/awx/main/tests/functional/test_projects.py b/awx/main/tests/functional/test_projects.py index d8bcd5d151..4c32d1dd69 100644 --- a/awx/main/tests/functional/test_projects.py +++ b/awx/main/tests/functional/test_projects.py @@ -1,7 +1,6 @@ import mock # noqa import pytest -from django.db import transaction from django.core.urlresolvers import reverse from awx.main.models import Project @@ -9,62 +8,55 @@ from awx.main.models import Project # # Project listing and visibility tests # +@pytest.fixture +def team_project_list(organization_factory): + objects = organization_factory('org-test', + superusers=['admin'], + users=['team1:alice', 'team2:bob'], + teams=['team1', 'team2'], + projects=['pteam1', 'pteam2', 'pshared'], + roles=['team1.member_role:pteam1.admin_role', + 'team2.member_role:pteam2.admin_role', + 'team1.member_role:pshared.admin_role', + 'team2.member_role:pshared.admin_role']) + return objects + @pytest.mark.django_db -def test_user_project_list(get, project_factory, organization, admin, alice, bob): +def test_user_project_list(get, organization_factory): 'List of projects a user has access to, filtered by projects you can also see' - organization.member_role.members.add(alice, bob) + objects = organization_factory('org1', + projects=['alice project', 'bob project', 'shared project'], + superusers=['admin'], + users=['alice', 'bob'], + roles=['alice project.admin_role:alice', + 'bob project.admin_role:bob', + 'shared project.admin_role:bob', + 'shared project.admin_role:alice']) - alice_project = project_factory('alice project') - alice_project.admin_role.members.add(alice) - - bob_project = project_factory('bob project') - bob_project.admin_role.members.add(bob) - - shared_project = project_factory('shared project') - shared_project.admin_role.members.add(alice) - shared_project.admin_role.members.add(bob) - - # admins can see all projects - assert get(reverse('api:user_projects_list', args=(admin.pk,)), admin).data['count'] == 3 + assert get(reverse('api:user_projects_list', args=(objects.superusers.admin.pk,)), objects.superusers.admin).data['count'] == 3 # admins can see everyones projects - assert get(reverse('api:user_projects_list', args=(alice.pk,)), admin).data['count'] == 2 - assert get(reverse('api:user_projects_list', args=(bob.pk,)), admin).data['count'] == 2 + assert get(reverse('api:user_projects_list', args=(objects.users.alice.pk,)), objects.superusers.admin).data['count'] == 2 + assert get(reverse('api:user_projects_list', args=(objects.users.bob.pk,)), objects.superusers.admin).data['count'] == 2 # users can see their own projects - assert get(reverse('api:user_projects_list', args=(alice.pk,)), alice).data['count'] == 2 + assert get(reverse('api:user_projects_list', args=(objects.users.alice.pk,)), objects.users.alice).data['count'] == 2 # alice should only be able to see the shared project when looking at bobs projects - assert get(reverse('api:user_projects_list', args=(bob.pk,)), alice).data['count'] == 1 + assert get(reverse('api:user_projects_list', args=(objects.users.bob.pk,)), objects.users.alice).data['count'] == 1 # alice should see all projects they can see when viewing an admin - assert get(reverse('api:user_projects_list', args=(admin.pk,)), alice).data['count'] == 2 + assert get(reverse('api:user_projects_list', args=(objects.superusers.admin.pk,)), objects.users.alice).data['count'] == 2 -def setup_test_team_project_list(project_factory, team_factory, admin, alice, bob): - team1 = team_factory('team1') - team2 = team_factory('team2') - - team1_project = project_factory('team1 project') - team1_project.admin_role.parents.add(team1.member_role) - - team2_project = project_factory('team2 project') - team2_project.admin_role.parents.add(team2.member_role) - - shared_project = project_factory('shared project') - shared_project.admin_role.parents.add(team1.member_role) - shared_project.admin_role.parents.add(team2.member_role) - - team1.member_role.members.add(alice) - team2.member_role.members.add(bob) - return team1, team2 - @pytest.mark.django_db -def test_team_project_list(get, project_factory, team_factory, admin, alice, bob): - 'List of projects a team has access to, filtered by projects you can also see' - team1, team2 = setup_test_team_project_list(project_factory, team_factory, admin, alice, bob) +def test_team_project_list(get, team_project_list): + objects = team_project_list + + team1, team2 = objects.teams.team1, objects.teams.team2 + alice, bob, admin = objects.users.alice, objects.users.bob, objects.superusers.admin # admins can see all projects on a team assert get(reverse('api:team_projects_list', args=(team1.pk,)), admin).data['count'] == 2 @@ -78,12 +70,6 @@ def test_team_project_list(get, project_factory, team_factory, admin, alice, bob assert get(reverse('api:team_projects_list', args=(team2.pk,)), alice).data['count'] == 1 team2.read_role.members.remove(alice) - # Test user endpoints first, very similar tests to test_user_project_list - # but permissions are being derived from team membership instead. - with transaction.atomic(): - res = get(reverse('api:user_projects_list', args=(bob.pk,)), alice) - assert res.status_code == 403 - # admins can see all projects assert get(reverse('api:user_projects_list', args=(admin.pk,)), admin).data['count'] == 3 @@ -98,17 +84,11 @@ def test_team_project_list(get, project_factory, team_factory, admin, alice, bob assert get(reverse('api:user_projects_list', args=(admin.pk,)), alice).data['count'] == 2 @pytest.mark.django_db -def test_team_project_list_fail1(get, project_factory, team_factory, admin, alice, bob): - # alice should not be able to see team2 projects because she doesn't have access to team2 - team1, team2 = setup_test_team_project_list(project_factory, team_factory, admin, alice, bob) - res = get(reverse('api:team_projects_list', args=(team2.pk,)), alice) +def test_team_project_list_fail1(get, team_project_list): + objects = team_project_list + res = get(reverse('api:team_projects_list', args=(objects.teams.team2.pk,)), objects.users.alice) assert res.status_code == 403 -@pytest.mark.django_db -def test_team_project_list_fail2(get, project_factory, team_factory, admin, alice, bob): - team1, team2 = setup_test_team_project_list(project_factory, team_factory, admin, alice, bob) - # alice should not be able to see bob - @pytest.mark.parametrize("u,expected_status_code", [ ('rando', 403), ('org_member', 403), diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index f775053ccc..3c7a7b32fc 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -7,8 +7,11 @@ from awx.main.access import ( ) from awx.main.migrations import _rbac as rbac from awx.main.models import Permission +from awx.main.models.jobs import JobTemplate from django.apps import apps +from django.core.urlresolvers import reverse + @pytest.mark.django_db def test_job_template_migration_check(credential, deploy_jobtemplate, check_jobtemplate, user): @@ -155,3 +158,26 @@ def test_job_template_access_superuser(check_license, user, deploy_jobtemplate): # THEN all access checks should pass assert access.can_read(deploy_jobtemplate) assert access.can_add({}) + +@pytest.mark.django_db +@pytest.mark.job_permissions +def test_job_template_creator_access(project, rando, post): + + project.admin_role.members.add(rando) + with mock.patch( + 'awx.main.models.projects.ProjectOptions.playbooks', + new_callable=mock.PropertyMock(return_value=['helloworld.yml'])): + response = post(reverse('api:job_template_list', args=[]), dict( + name='newly-created-jt', + job_type='run', + ask_inventory_on_launch=True, + ask_credential_on_launch=True, + project=project.pk, + playbook='helloworld.yml' + ), rando) + + assert response.status_code == 201 + jt_pk = response.data['id'] + jt_obj = JobTemplate.objects.get(pk=jt_pk) + # Creating a JT should place the creator in the admin role + assert rando in jt_obj.admin_role diff --git a/awx/main/tests/functional/test_rbac_label.py b/awx/main/tests/functional/test_rbac_label.py index ec3c83f314..e425d50908 100644 --- a/awx/main/tests/functional/test_rbac_label.py +++ b/awx/main/tests/functional/test_rbac_label.py @@ -31,20 +31,22 @@ def test_label_access_superuser(label, user): assert access.can_delete(label) @pytest.mark.django_db -def test_label_access_admin(label, user, organization_factory): +def test_label_access_admin(organization_factory): '''can_change because I am an admin of that org''' - a = user('admin', False) - org_no_members = organization_factory("no_members") - org_members = organization_factory("has_members") + no_members = organization_factory("no_members") + members = organization_factory("has_members", + users=['admin'], + labels=['test']) - label.organization.admin_role.members.add(a) - org_members.admin_role.members.add(a) + label = members.labels.test + admin = members.users.admin + members.organization.admin_role.members.add(admin) - access = LabelAccess(user('admin', False)) - assert not access.can_change(label, {'organization': org_no_members.id}) + access = LabelAccess(admin) + assert not access.can_change(label, {'organization': no_members.organization.id}) assert access.can_read(label) assert access.can_change(label, None) - assert access.can_change(label, {'organization': org_members.id}) + assert access.can_change(label, {'organization': members.organization.id}) assert access.can_delete(label) @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_rbac_notifications.py b/awx/main/tests/functional/test_rbac_notifications.py index 467ae8038a..9cabec6fbf 100644 --- a/awx/main/tests/functional/test_rbac_notifications.py +++ b/awx/main/tests/functional/test_rbac_notifications.py @@ -25,35 +25,44 @@ def test_notification_template_get_queryset_orgadmin(notification_template, user assert access.get_queryset().count() == 1 @pytest.mark.django_db -def test_notification_template_access_superuser(notification_template, user, notification_template_factory): - access = NotificationTemplateAccess(user('admin', True)) - assert access.can_read(notification_template) - assert access.can_change(notification_template, None) - assert access.can_delete(notification_template) - nf = notification_template_factory("test-orphaned") +def test_notification_template_access_superuser(notification_template_factory): + nf_objects = notification_template_factory('test-orphaned', organization='test', superusers=['admin']) + admin = nf_objects.superusers.admin + nf = nf_objects.notification_template + + access = NotificationTemplateAccess(admin) + assert access.can_read(nf) + assert access.can_change(nf, None) + assert access.can_delete(nf) + nf.organization = None nf.save() + assert access.can_read(nf) assert access.can_change(nf, None) assert access.can_delete(nf) @pytest.mark.django_db -def test_notification_template_access_admin(notification_template, user, organization_factory, notification_template_factory): - adm = user('admin', False) - other_org = organization_factory('other') - present_org = organization_factory('present') - notification_template.organization.admin_role.members.add(adm) - present_org.admin_role.members.add(adm) +def test_notification_template_access_admin(organization_factory, notification_template_factory): + other_objects = organization_factory('other') + present_objects = organization_factory('present', + users=['admin'], + notification_templates=['test-notification'], + roles=['present.admin_role:admin']) - access = NotificationTemplateAccess(user('admin', False)) + notification_template = present_objects.notification_templates.test_notification + other_org = other_objects.organization + present_org = present_objects.organization + admin = present_objects.users.admin + + access = NotificationTemplateAccess(admin) assert not access.can_change(notification_template, {'organization': other_org.id}) assert access.can_read(notification_template) assert access.can_change(notification_template, None) assert access.can_change(notification_template, {'organization': present_org.id}) assert access.can_delete(notification_template) + nf = notification_template_factory("test-orphaned") - nf.organization = None - nf.save() assert not access.can_read(nf) assert not access.can_change(nf, None) assert not access.can_delete(nf) diff --git a/awx/main/tests/old/ad_hoc.py b/awx/main/tests/old/ad_hoc.py index b05dc6df89..2c81ec71a0 100644 --- a/awx/main/tests/old/ad_hoc.py +++ b/awx/main/tests/old/ad_hoc.py @@ -404,164 +404,6 @@ class AdHocCommandApiTest(BaseAdHocCommandTest): del data[k] return self.post(url, data, expect=expect) - @mock.patch('awx.main.tasks.BaseTask.run_pexpect', side_effect=run_pexpect_mock) - def test_ad_hoc_command_list(self, ignore): - url = reverse('api:ad_hoc_command_list') - - # Retrieve the empty list of ad hoc commands. - qs = AdHocCommand.objects.none() - self.check_get_list(url, 'admin', qs) - self.check_get_list(url, 'normal', qs) - self.check_get_list(url, 'other', qs) - self.check_get_list(url, 'nobody', qs) - self.check_get_list(url, None, qs, expect=401) - - # Start a new ad hoc command. Only admin and normal user (org admin) - # can run commands by default. - with self.current_user('admin'): - response = self.run_test_ad_hoc_command() - self.assertEqual(response['job_type'], 'run') - self.assertEqual(response['inventory'], self.inventory.pk) - self.assertEqual(response['credential'], self.credential.pk) - self.assertEqual(response['module_name'], 'command') - self.assertEqual(response['module_args'], 'uptime') - self.assertEqual(response['limit'], '') - self.assertEqual(response['forks'], 0) - self.assertEqual(response['verbosity'], 0) - self.assertEqual(response['become_enabled'], False) - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - with self.current_user('normal'): - self.run_test_ad_hoc_command() - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - with self.current_user('other'): - self.run_test_ad_hoc_command(expect=403) - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - with self.current_user('nobody'): - self.run_test_ad_hoc_command(expect=403) - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - with self.current_user(None): - self.run_test_ad_hoc_command(expect=401) - self.put(url, {}, expect=401) - self.patch(url, {}, expect=401) - self.delete(url, expect=401) - - # Retrieve the list of ad hoc commands (only admin/normal can see by default). - qs = AdHocCommand.objects.all() - self.assertEqual(qs.count(), 2) - self.check_get_list(url, 'admin', qs) - self.check_get_list(url, 'normal', qs) - qs = AdHocCommand.objects.none() - self.check_get_list(url, 'other', qs) - self.check_get_list(url, 'nobody', qs) - self.check_get_list(url, None, qs, expect=401) - - # Explicitly give other user updater permission on the inventory (still - # not allowed to run ad hoc commands). - user_roles_list_url = reverse('api:user_roles_list', args=(self.other_django_user.pk,)) - with self.current_user('admin'): - response = self.post(user_roles_list_url, {"id": self.inventory.update_role.id}, expect=204) - with self.current_user('other'): - self.run_test_ad_hoc_command(expect=403) - self.check_get_list(url, 'other', qs) - - # Add executor role permissions to other. Fails - # when other user can't read credential. - with self.current_user('admin'): - response = self.post(user_roles_list_url, {"id": self.inventory.execute_role.id}, expect=204) - with self.current_user('other'): - self.run_test_ad_hoc_command(expect=403) - - # Succeeds once other user has a readable credential. Other user can - # only see his own ad hoc command (because of credential permissions). - other_cred = self.create_test_credential(user=self.other_django_user) - with self.current_user('other'): - self.run_test_ad_hoc_command(credential=other_cred.pk) - qs = AdHocCommand.objects.filter(created_by=self.other_django_user) - self.assertEqual(qs.count(), 1) - self.check_get_list(url, 'other', qs) - - # Explicitly give nobody user read permission on the inventory. - nobody_roles_list_url = reverse('api:user_roles_list', args=(self.nobody_django_user.pk,)) - with self.current_user('admin'): - response = self.post(nobody_roles_list_url, {"id": self.inventory.read_role.id}, expect=204) - with self.current_user('nobody'): - self.run_test_ad_hoc_command(credential=other_cred.pk, expect=403) - self.check_get_list(url, 'other', qs) - - # Create a cred for the nobody user, run an ad hoc command as the admin - # user with that cred. Nobody user can still not see the ad hoc command - # without the run_ad_hoc_commands permission flag. - nobody_cred = self.create_test_credential(user=self.nobody_django_user) - with self.current_user('admin'): - self.run_test_ad_hoc_command(credential=nobody_cred.pk) - qs = AdHocCommand.objects.none() - self.check_get_list(url, 'nobody', qs) - - # Give the nobody user the run_ad_hoc_commands flag, and can now see - # the one ad hoc command previously run. - with self.current_user('admin'): - response = self.post(nobody_roles_list_url, {"id": self.inventory.execute_role.id}, expect=204) - qs = AdHocCommand.objects.filter(credential_id=nobody_cred.pk) - self.assertEqual(qs.count(), 1) - self.check_get_list(url, 'nobody', qs) - - # Post without inventory (should fail). - with self.current_user('admin'): - self.run_test_ad_hoc_command(inventory=None, expect=400) - - # Post without credential (should fail). - with self.current_user('admin'): - self.run_test_ad_hoc_command(credential=None, expect=400) - - # Post with empty or unsupported module name (empty defaults to command). - with self.current_user('admin'): - response = self.run_test_ad_hoc_command(module_name=None) - self.assertEqual(response['module_name'], 'command') - with self.current_user('admin'): - response = self.run_test_ad_hoc_command(module_name='') - self.assertEqual(response['module_name'], 'command') - with self.current_user('admin'): - self.run_test_ad_hoc_command(module_name='transcombobulator', expect=400) - - # Post with empty module args for shell/command modules (should fail), - # empty args for other modules ok. - with self.current_user('admin'): - self.run_test_ad_hoc_command(module_args=None, expect=400) - with self.current_user('admin'): - self.run_test_ad_hoc_command(module_name='shell', module_args=None, expect=400) - with self.current_user('admin'): - self.run_test_ad_hoc_command(module_name='shell', module_args='', expect=400) - with self.current_user('admin'): - self.run_test_ad_hoc_command(module_name='ping', module_args=None) - - # Post with invalid values for other parameters. - with self.current_user('admin'): - self.run_test_ad_hoc_command(job_type='something', expect=400) - with self.current_user('admin'): - response = self.run_test_ad_hoc_command(job_type='check') - self.assertEqual(response['job_type'], 'check') - with self.current_user('admin'): - self.run_test_ad_hoc_command(verbosity=-1, expect=400) - with self.current_user('admin'): - self.run_test_ad_hoc_command(forks=-1, expect=400) - with self.current_user('admin'): - response = self.run_test_ad_hoc_command(become_enabled=True) - self.assertEqual(response['become_enabled'], True) - - # Try to run with expired license. - self.create_expired_license_file() - with self.current_user('admin'): - self.run_test_ad_hoc_command(expect=403) - with self.current_user('normal'): - self.run_test_ad_hoc_command(expect=403) @mock.patch('awx.main.tasks.BaseTask.run_pexpect', side_effect=run_pexpect_mock) def test_ad_hoc_command_detail(self, ignore): @@ -953,98 +795,6 @@ class AdHocCommandApiTest(BaseAdHocCommandTest): self.patch(url, {}, expect=401) self.delete(url, expect=401) - @mock.patch('awx.main.tasks.BaseTask.run_pexpect', side_effect=run_pexpect_mock) - def test_inventory_ad_hoc_commands_list(self, ignore): - with self.current_user('admin'): - response = self.run_test_ad_hoc_command() - response = self.run_test_ad_hoc_command(inventory=self.inventory2.pk) - - # Test the ad hoc commands list for an inventory. Should only return - # the ad hoc command(s) run against that inventory. Posting should - # start a new ad hoc command and always set the inventory from the URL. - url = reverse('api:inventory_ad_hoc_commands_list', args=(self.inventory.pk,)) - inventory_url = reverse('api:inventory_detail', args=(self.inventory.pk,)) - with self.current_user('admin'): - response = self.get(url, expect=200) - self.assertEqual(response['count'], 1) - response = self.run_test_ad_hoc_command(url=url, inventory=None, expect=201) - self.assertEqual(response['inventory'], self.inventory.pk) - response = self.run_test_ad_hoc_command(url=url, inventory=self.inventory2.pk, expect=201) - self.assertEqual(response['inventory'], self.inventory.pk) - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - response = self.get(inventory_url, expect=200) - self.assertTrue(response['can_run_ad_hoc_commands']) - with self.current_user('normal'): - response = self.get(url, expect=200) - self.assertEqual(response['count'], 3) - response = self.run_test_ad_hoc_command(url=url, inventory=None, expect=201) - self.assertEqual(response['inventory'], self.inventory.pk) - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - response = self.get(inventory_url, expect=200) - self.assertTrue(response['can_run_ad_hoc_commands']) - with self.current_user('other'): - self.get(url, expect=403) - self.post(url, {}, expect=403) - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - with self.current_user('nobody'): - self.get(url, expect=403) - self.post(url, {}, expect=403) - self.put(url, {}, expect=405) - self.patch(url, {}, expect=405) - self.delete(url, expect=405) - with self.current_user(None): - self.get(url, expect=401) - self.post(url, {}, expect=401) - self.put(url, {}, expect=401) - self.patch(url, {}, expect=401) - self.delete(url, expect=401) - - # Create another unrelated inventory permission with run_ad_hoc_commands - # set; this tests an edge case in the RBAC query where we'll return - # can_run_ad_hoc_commands = True when we shouldn't. - nobody_roles_list_url = reverse('api:user_roles_list', args=(self.nobody_django_user.pk,)) - with self.current_user('admin'): - response = self.post(nobody_roles_list_url, {"id": self.inventory.execute_role.id}, expect=204) - - # Create a credential for the other user and explicitly give other - # user admin permission on the inventory (still not allowed to run ad - # hoc commands; can get the list but can't see any items). - other_cred = self.create_test_credential(user=self.other_django_user) - user_roles_list_url = reverse('api:user_roles_list', args=(self.other_django_user.pk,)) - with self.current_user('admin'): - response = self.post(user_roles_list_url, {"id": self.inventory.update_role.id}, expect=204) - with self.current_user('other'): - response = self.get(url, expect=200) - self.assertEqual(response['count'], 0) - response = self.get(inventory_url, expect=200) - self.assertFalse(response['can_run_ad_hoc_commands']) - self.run_test_ad_hoc_command(url=url, inventory=None, credential=other_cred.pk, expect=403) - - # Update permission to allow other user to run ad hoc commands. Can - # only see his own ad hoc commands (because of credential permission). - with self.current_user('admin'): - response = self.post(user_roles_list_url, {"id": self.inventory.adhoc_role.id}, expect=204) - with self.current_user('other'): - response = self.get(url, expect=200) - self.assertEqual(response['count'], 0) - self.run_test_ad_hoc_command(url=url, inventory=None, credential=other_cred.pk, expect=201) - response = self.get(url, expect=200) - self.assertEqual(response['count'], 1) - response = self.get(inventory_url, expect=200) - self.assertTrue(response['can_run_ad_hoc_commands']) - - # Try to run with expired license. - self.create_expired_license_file() - with self.current_user('admin'): - self.run_test_ad_hoc_command(url=url, expect=403) - with self.current_user('normal'): - self.run_test_ad_hoc_command(url=url, expect=403) def test_host_ad_hoc_commands_list(self): # TODO: Figure out why this test needs pexpect diff --git a/awx/main/tests/old/inventory.py b/awx/main/tests/old/inventory.py index c0fb172b63..2bed467144 100644 --- a/awx/main/tests/old/inventory.py +++ b/awx/main/tests/old/inventory.py @@ -1770,6 +1770,7 @@ class InventoryUpdatesTest(BaseTransactionTest): self.assertFalse(inventory_update.name.endswith(inventory_update.inventory_source.name), inventory_update.name) def test_update_from_rax(self): + self.skipTest('Skipping until we can resolve the CERTIFICATE_VERIFY_FAILED issue: #1706') source_username = getattr(settings, 'TEST_RACKSPACE_USERNAME', '') source_password = getattr(settings, 'TEST_RACKSPACE_API_KEY', '') source_regions = getattr(settings, 'TEST_RACKSPACE_REGIONS', '') diff --git a/awx/main/tests/old/jobs/jobs_monolithic.py b/awx/main/tests/old/jobs/jobs_monolithic.py index de308f52c5..fbb80fd786 100644 --- a/awx/main/tests/old/jobs/jobs_monolithic.py +++ b/awx/main/tests/old/jobs/jobs_monolithic.py @@ -961,7 +961,7 @@ class JobTemplateCallbackTest(BaseJobTestMixin, django.test.LiveServerTestCase): self.assertEqual(jobs_qs.count(), 7) job = jobs_qs[0] self.assertEqual(job.launch_type, 'callback') - self.assertEqual(job.limit, ':&'.join([job_template.limit, host.name])) + self.assertEqual(job.limit, host.name) self.assertEqual(job.hosts.count(), 1) self.assertEqual(job.hosts.all()[0], host) 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..e04e43860d 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -178,7 +178,7 @@ .Form-formGroup--checkbox{ display: flex; - align-items: flex-end; + align-items: flex-start; } .Form-subForm { @@ -324,6 +324,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{ 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..aae513ba14 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', 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..ad8d8c50f1 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -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..a57e73dd25 100644 --- a/awx/ui/client/src/forms/Projects.js +++ b/awx/ui/client/src/forms/Projects.js @@ -174,7 +174,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 +186,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 +197,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 +273,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..febc6b8ddb 100644 --- a/awx/ui/client/src/forms/Users.js +++ b/awx/ui/client/src/forms/Users.js @@ -138,6 +138,7 @@ export default iterator: 'team', open: false, index: false, + suppressEmptyText: true, actions: {}, 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 4c465f91fe..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 @@ -33,8 +33,8 @@ -
- +
+
diff --git a/awx/ui/client/src/job-detail/job-detail.block.less b/awx/ui/client/src/job-detail/job-detail.block.less index 41f209f0c0..6e6f4810da 100644 --- a/awx/ui/client/src/job-detail/job-detail.block.less +++ b/awx/ui/client/src/job-detail/job-detail.block.less @@ -13,6 +13,12 @@ .JobDetail-instructions{ color: @default-interface-txt; margin: 10px 0 10px 0; + + .badge { + background-color: @default-list-header-bg; + color: @default-interface-txt; + padding: 5px 7px; + } } .JobDetail{ .OnePlusOne-container(100%, @breakpoint-md); @@ -151,6 +157,10 @@ background-color: @default-link; border: 1px solid @default-link; color: @default-bg; + + &:hover { + background-color: @default-link-hov; + } } .JobDetail .nvd3.nv-noData{ color: @default-interface-txt; @@ -198,6 +208,12 @@ margin-left: -5px; } +.JobDetails-table--noResults { + tr > td { + border-top: none !important; + } +} + .JobDetail-statusIcon--results{ padding-left: 0px; padding-right: 10px; @@ -215,3 +231,7 @@ width:0px; padding-right: 0px; } + +.JobDetail-leftSide.JobDetail-stdoutActionButton--active { + margin-right: 0px; +} 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 2e927fad2c..33cf23c1d1 100644 --- a/awx/ui/client/src/job-detail/job-detail.controller.js +++ b/awx/ui/client/src/job-detail/job-detail.controller.js @@ -667,21 +667,9 @@ export default scope.lessStatus = false; // close the view more status option - // Detail table height adjusting. First, put page height back to 'normal'. - $('#plays-table-detail').height(80); - //$('#plays-table-detail').mCustomScrollbar("update"); - // $('#tasks-table-detail').height(120); - //$('#tasks-table-detail').mCustomScrollbar("update"); - $('#hosts-table-detail').height(150); - //$('#hosts-table-detail').mCustomScrollbar("update"); + height = $(window).height() - $('#main-menu-container .navbar').outerHeight() - $('#job-detail-container').outerHeight() - 20; - if (height > 15) { - // there's a bunch of white space at the bottom, let's use it - $('#plays-table-detail').height(80 + (height * 0.10)); - $('#tasks-table-detail').height(120 + (height * 0.20)); - $('#hosts-table-detail').height(150 + (height * 0.10)); - } scope.$emit('RefreshCompleted'); }; 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 055496f07b..a353985de0 100644 --- a/awx/ui/client/src/job-detail/job-detail.partial.html +++ b/awx/ui/client/src/job-detail/job-detail.partial.html @@ -189,7 +189,7 @@ -
+
@@ -202,7 +202,7 @@
-
+
@@ -248,7 +248,7 @@
-
{{ play.name }}
+
@@ -261,7 +261,7 @@
-
Tasks
+
- +
@@ -343,7 +343,7 @@
-
Hosts
+
@@ -404,7 +404,7 @@ - + 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 c68e61c1aa..12c1609bbb 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 @@ -72,6 +72,7 @@ jQuery.extend(true, CloudCredentialList, CredentialList); CloudCredentialList.name = 'cloudcredentials'; CloudCredentialList.iterator = 'cloudcredential'; + CloudCredentialList.basePath = '/api/v1/credentials?cloud=true'; SurveyControllerInit({ scope: $scope, 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 d3e81e5ebc..bb05453c5d 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 @@ -108,7 +108,7 @@ export default } }) .error(function (ret,status_code) { - if (status_code == 403) { + if (status_code === 403) { /* user doesn't have access to see the project, no big deal. */ } else { Alert('Missing Playbooks', 'Unable to retrieve the list of playbooks for this project. Choose a different ' + @@ -198,7 +198,7 @@ export default } }) .error(function (data, status) { - if (status == 403) { + if (status === 403) { /* User doesn't have read access to the project, no problem. */ } else { ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to get project ' + $scope.project + @@ -288,6 +288,7 @@ export default jQuery.extend(true, CloudCredentialList, CredentialList); CloudCredentialList.name = 'cloudcredentials'; CloudCredentialList.iterator = 'cloudcredential'; + CloudCredentialList.basePath = '/api/v1/credentials?cloud=true'; LookUpInit({ url: GetBasePath('credentials') + '?cloud=true', scope: $scope, diff --git a/awx/ui/client/src/lists/CompletedJobs.js b/awx/ui/client/src/lists/CompletedJobs.js index 0e3193fbb8..b6c6efc1e4 100644 --- a/awx/ui/client/src/lists/CompletedJobs.js +++ b/awx/ui/client/src/lists/CompletedJobs.js @@ -20,6 +20,7 @@ export default index: false, hover: true, well: false, + emptyListText: 'No completed jobs', fields: { status: { diff --git a/awx/ui/client/src/lists/ScheduledJobs.js b/awx/ui/client/src/lists/ScheduledJobs.js index 07ab0256c0..977808bef7 100644 --- a/awx/ui/client/src/lists/ScheduledJobs.js +++ b/awx/ui/client/src/lists/ScheduledJobs.js @@ -15,6 +15,7 @@ export default index: true, hover: true, well: false, + emptyListText: 'No schedules exist', fields: { status: { diff --git a/awx/ui/client/src/main-menu/main-menu.partial.html b/awx/ui/client/src/main-menu/main-menu.partial.html index 65518a5543..b45d0eb382 100644 --- a/awx/ui/client/src/main-menu/main-menu.partial.html +++ b/awx/ui/client/src/main-menu/main-menu.partial.html @@ -157,11 +157,11 @@ ng-href="/#/portal" ng-if="!licenseMissing" ng-class="{'is-currentRoute' : isCurrentState('portalMode'), 'is-loggedOut' : !$root.current_user.username}" - aw-tool-tip="Portal Mode" + aw-tool-tip="My View" data-placement="bottom" data-trigger="hover" data-container="body"> - 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 7c01cd0b9b..cc39d59469 100644 --- a/awx/ui/client/src/management-jobs/card/card.partial.html +++ b/awx/ui/client/src/management-jobs/card/card.partial.html @@ -1,3 +1,4 @@ +
@@ -7,7 +8,6 @@ {{ mgmtCards.length }}
-
diff --git a/awx/ui/client/src/management-jobs/card/mgmtcards.block.less b/awx/ui/client/src/management-jobs/card/mgmtcards.block.less index 35c74ce147..e569669113 100644 --- a/awx/ui/client/src/management-jobs/card/mgmtcards.block.less +++ b/awx/ui/client/src/management-jobs/card/mgmtcards.block.less @@ -3,7 +3,8 @@ .MgmtCards { display: flex; - flex-wrap: wrap; + flex-flow: row wrap; + justify-content: space-between; } .MgmtCards-card { @@ -11,15 +12,14 @@ padding: 20px; border-radius: 5px; border: 1px solid @default-border; - display: flex; - flex-wrap: wrap; align-items: baseline; margin-top: 20px; + width: 32%; } .MgmtCards-card--selected { padding-left: 16px; - border-left: 5px solid #337AB7; + border-left: 5px solid @default-link; } .MgmtCards-card--promptElements{ @@ -44,7 +44,6 @@ font-weight: bold; color: @default-interface-txt; margin-bottom: 25px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -86,46 +85,19 @@ margin-right: 10px; } -@media (min-width: 1179px) { - .MgmtCards-card { - width: ~"calc(25% - 15px)"; - margin-right: 20px; - } - - .MgmtCards-card:nth-child(4n+4) { - margin-right: 0px; - } -} - -@media (min-width: 901px) and (max-width: 1178px) { - .MgmtCards-card { - width: ~"calc(33% - 11px)"; - margin-right: 20px; - } - - .MgmtCards-card:nth-child(3n+3) { - margin-right: 0px; - } -} - -@media (min-width: 616px) and (max-width: 900px) { - .MgmtCards-card { - width: ~"calc(50% - 10px)"; - margin-right: 20px; - } - - .MgmtCards-card:nth-child(2n+2) { - margin-right: 0px; - } -} - -@media (max-width: 615px) { +@media (max-width: 840px) { .MgmtCards-card { width: 100%; margin-right: 0px; } } +@media (min-width: 840px) and (max-width: 1240px) { + .MgmtCards-card { + width: ~"calc(50% - 10px)"; + } + } + #prompt-for-days-facts, #prompt-for-days { overflow-x: hidden; font-family: "Open Sans"; diff --git a/awx/ui/client/src/notifications/notificationTemplates.form.js b/awx/ui/client/src/notifications/notificationTemplates.form.js index 356efba43b..f3a6c29f3c 100644 --- a/awx/ui/client/src/notifications/notificationTemplates.form.js +++ b/awx/ui/client/src/notifications/notificationTemplates.form.js @@ -141,7 +141,23 @@ export default function() { reqExpression: "channel_required", init: "false" }, - ngShow: "notification_type.value == 'slack' || notification_type.value == 'hipchat'", + ngShow: "notification_type.value == 'slack'", + subForm: 'typeSubForm' + }, + rooms: { + label: 'Destination Channels', + type: 'textarea', + rows: 3, + awPopOver: '

Type an option on each line. The pound symbol (#) is not required.

'+ + '

For example:
engineering
\n #support
\n', + dataTitle: 'Destination Channels', + dataPlacement: 'right', + dataContainer: "body", + awRequiredWhen: { + reqExpression: "room_required", + init: "false" + }, + ngShow: "notification_type.value == 'hipchat'", subForm: 'typeSubForm' }, token: { @@ -243,8 +259,9 @@ export default function() { subForm: 'typeSubForm' }, api_url: { - label: 'API URL (e.g: https://mycompany.hiptchat.com)', + label: 'API URL', type: 'text', + placeholder: 'https://mycompany.hipchat.com', awRequiredWhen: { reqExpression: "hipchat_required", init: "false" @@ -264,11 +281,7 @@ export default function() { }, notify: { label: 'Notify Channel', - type: 'text', - awRequiredWhen: { - reqExpression: "hipchat_required", - init: "false" - }, + type: 'checkbox', ngShow: "notification_type.value == 'hipchat' ", subForm: 'typeSubForm' }, diff --git a/awx/ui/client/src/notifications/notificationTemplates.list.js b/awx/ui/client/src/notifications/notificationTemplates.list.js index 3d90e237b5..bf48ddc38a 100644 --- a/awx/ui/client/src/notifications/notificationTemplates.list.js +++ b/awx/ui/client/src/notifications/notificationTemplates.list.js @@ -13,6 +13,7 @@ export default function(){ iterator: 'notification_template', index: false, hover: false, + emptyListText: 'No notifications exist', fields: { status: { diff --git a/awx/ui/client/src/notifications/notifications.list.js b/awx/ui/client/src/notifications/notifications.list.js index 5520de2210..066732a9a9 100644 --- a/awx/ui/client/src/notifications/notifications.list.js +++ b/awx/ui/client/src/notifications/notifications.list.js @@ -14,6 +14,7 @@ export default function(){ iterator: 'notification', index: false, hover: false, + emptyListText: 'No Notifications exist', basePath: 'notifications', fields: { name: { diff --git a/awx/ui/client/src/notifications/shared/type-change.service.js b/awx/ui/client/src/notifications/shared/type-change.service.js index 1ac44d6f53..b48a4b37f3 100644 --- a/awx/ui/client/src/notifications/shared/type-change.service.js +++ b/awx/ui/client/src/notifications/shared/type-change.service.js @@ -39,7 +39,7 @@ function () { case 'hipchat': obj.tokenLabel = ' Token'; obj.hipchat_required = true; - obj.channel_required = true; + obj.room_required = true; obj.token_required = true; break; case 'twilio': diff --git a/awx/ui/client/src/partials/jobs.html b/awx/ui/client/src/partials/jobs.html index 8b0b5afe89..1c006274fb 100644 --- a/awx/ui/client/src/partials/jobs.html +++ b/awx/ui/client/src/partials/jobs.html @@ -20,7 +20,7 @@

-