diff --git a/.gitignore b/.gitignore index ca9dd12298..57e2baf042 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ tower/tower_warnings.log celerybeat-schedule awx/ui/static awx/ui/build_test +awx/ui/client/languages # Tower setup playbook testing setup/test/roles/postgresql @@ -112,3 +113,4 @@ local/ awx/lib/.deps_built awx/lib/site-packages venv/* +use_dev_supervisor.txt diff --git a/Makefile b/Makefile index 6d55edc698..836f96da9c 100644 --- a/Makefile +++ b/Makefile @@ -378,6 +378,12 @@ server: server_noattach servercc: server_noattach tmux -2 -CC attach-session -t tower +supervisor: + @if [ "$(VENV_BASE)" ]; then \ + . $(VENV_BASE)/tower/bin/activate; \ + fi; \ + supervisord --configuration /supervisor.conf --pidfile=/tmp/supervisor_pid + # Alternate approach to tmux to run all development tasks specified in # Procfile. https://youtu.be/OPMgaibszjk honcho: diff --git a/awx/api/filters.py b/awx/api/filters.py index fbbbba2d05..e5c9c39264 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -9,9 +9,11 @@ from django.core.exceptions import FieldError, ValidationError from django.db import models from django.db.models import Q from django.db.models.fields import FieldDoesNotExist -from django.db.models.fields.related import ForeignObjectRel +from django.db.models.fields.related import ForeignObjectRel, ManyToManyField, ForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey from django.utils.encoding import force_text +from django.utils.translation import ugettext_lazy as _ # Django REST Framework from rest_framework.exceptions import ParseError, PermissionDenied @@ -88,8 +90,8 @@ class FieldLookupBackend(BaseFilterBackend): # those lookups combined with request.user.get_queryset(Model) to make # sure user cannot query using objects he could not view. new_parts = [] - for n, name in enumerate(parts[:-1]): + for name in parts[:-1]: # HACK: Make project and inventory source filtering by old field names work for backwards compatibility. if model._meta.object_name in ('Project', 'InventorySource'): name = { @@ -99,15 +101,28 @@ class FieldLookupBackend(BaseFilterBackend): 'last_updated': 'last_job_run', }.get(name, name) - new_parts.append(name) + if name == 'type' and 'polymorphic_ctype' in model._meta.get_all_field_names(): + name = 'polymorphic_ctype' + new_parts.append('polymorphic_ctype__model') + else: + new_parts.append(name) - if name in getattr(model, 'PASSWORD_FIELDS', ()): - raise PermissionDenied('Filtering on password fields is not allowed.') + raise PermissionDenied(_('Filtering on password fields is not allowed.')) elif name == 'pk': field = model._meta.pk else: - field = model._meta.get_field_by_name(name)[0] + name_alt = name.replace("_", "") + if name_alt in model._meta.fields_map.keys(): + field = model._meta.fields_map[name_alt] + new_parts.pop() + new_parts.append(name_alt) + else: + field = model._meta.get_field_by_name(name)[0] + if isinstance(field, ForeignObjectRel) and getattr(field.field, '__prevent_search__', False): + raise PermissionDenied(_('Filtering on %s is not allowed.' % name)) + elif getattr(field, '__prevent_search__', False): + raise PermissionDenied(_('Filtering on %s is not allowed.' % name)) model = getattr(field, 'related_model', None) or field.model if parts: @@ -127,14 +142,20 @@ class FieldLookupBackend(BaseFilterBackend): return to_python_boolean(value, allow_none=True) elif isinstance(field, models.BooleanField): return to_python_boolean(value) - elif isinstance(field, ForeignObjectRel): + elif isinstance(field, (ForeignObjectRel, ManyToManyField, GenericForeignKey, ForeignKey)): return self.to_python_related(value) else: return field.to_python(value) def value_to_python(self, model, lookup, value): field, new_lookup = self.get_field_from_lookup(model, lookup) - if new_lookup.endswith('__isnull'): + + # Type names are stored without underscores internally, but are presented and + # and serialized over the API containing underscores so we remove `_` + # for polymorphic_ctype__model lookups. + if new_lookup.startswith('polymorphic_ctype__model'): + value = value.replace('_','') + elif new_lookup.endswith('__isnull'): value = to_python_boolean(value) elif new_lookup.endswith('__in'): items = [] diff --git a/awx/api/generics.py b/awx/api/generics.py index 5e81ee7bdb..fca4ce3582 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -9,6 +9,7 @@ import time # Django from django.conf import settings from django.db import connection +from django.db.models.fields import FieldDoesNotExist from django.http import QueryDict from django.shortcuts import get_object_or_404 from django.template.loader import render_to_string @@ -26,6 +27,7 @@ from rest_framework import status from rest_framework import views # AWX +from awx.api.filters import FieldLookupBackend from awx.main.models import * # noqa from awx.main.utils import * # noqa from awx.api.serializers import ResourceAccessListElementSerializer @@ -41,6 +43,7 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView', 'DeleteLastUnattachLabelMixin',] logger = logging.getLogger('awx.api.generics') +analytics_logger = logging.getLogger('awx.analytics.performance') def get_view_name(cls, suffix=None): @@ -117,6 +120,8 @@ class APIView(views.APIView): q_times = [float(q['time']) for q in connection.queries[queries_before:]] response['X-API-Query-Count'] = len(q_times) response['X-API-Query-Time'] = '%0.3fs' % sum(q_times) + + analytics_logger.info("api response", extra=dict(python_objects=dict(request=request, response=response))) return response def get_authenticate_header(self, request): @@ -274,22 +279,48 @@ class ListAPIView(generics.ListAPIView, GenericAPIView): @property def related_search_fields(self): - fields = [] + def skip_related_name(name): + return ( + name is None or name.endswith('_role') or name.startswith('_') or + name.startswith('deprecated_') or name.endswith('_set') or + name == 'polymorphic_ctype') + + fields = set([]) for field in self.model._meta.fields: - if field.name.endswith('_role'): + if skip_related_name(field.name): continue if getattr(field, 'related_model', None): - fields.append('{}__search'.format(field.name)) + fields.add('{}__search'.format(field.name)) for rel in self.model._meta.related_objects: - name = rel.get_accessor_name() - if name.endswith('_set'): + name = rel.related_model._meta.verbose_name.replace(" ", "_") + if skip_related_name(name): + continue + fields.add('{}__search'.format(name)) + m2m_rel = [] + m2m_rel += self.model._meta.local_many_to_many + if issubclass(self.model, UnifiedJobTemplate) and self.model != UnifiedJobTemplate: + m2m_rel += UnifiedJobTemplate._meta.local_many_to_many + if issubclass(self.model, UnifiedJob) and self.model != UnifiedJob: + m2m_rel += UnifiedJob._meta.local_many_to_many + for relationship in m2m_rel: + if skip_related_name(relationship.name): continue - fields.append('{}__search'.format(name)) - for relationship in self.model._meta.local_many_to_many: if relationship.related_model._meta.app_label != 'main': continue - fields.append('{}__search'.format(relationship.name)) - return fields + fields.add('{}__search'.format(relationship.name)) + fields = list(fields) + + allowed_fields = [] + for field in fields: + try: + FieldLookupBackend().get_field_from_lookup(self.model, field) + except PermissionDenied: + pass + except FieldDoesNotExist: + allowed_fields.append(field) + else: + allowed_fields.append(field) + return allowed_fields class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView): diff --git a/awx/api/metadata.py b/awx/api/metadata.py index 37f00fdbac..232ec059f8 100644 --- a/awx/api/metadata.py +++ b/awx/api/metadata.py @@ -67,7 +67,10 @@ class Metadata(metadata.SimpleMetadata): # Indicate if a field has a default value. # FIXME: Still isn't showing all default values? try: - field_info['default'] = field.get_default() + default = field.get_default() + if field.field_name == 'TOWER_URL_BASE' and default == 'https://towerhost': + default = '{}://{}'.format(self.request.scheme, self.request.get_host()) + field_info['default'] = default except serializers.SkipField: pass @@ -120,19 +123,20 @@ class Metadata(metadata.SimpleMetadata): actions = {} for method in {'GET', 'PUT', 'POST'} & set(view.allowed_methods): view.request = clone_request(request, method) + obj = None try: # Test global permissions if hasattr(view, 'check_permissions'): view.check_permissions(view.request) # Test object permissions if method == 'PUT' and hasattr(view, 'get_object'): - view.get_object() + obj = view.get_object() except (exceptions.APIException, PermissionDenied, Http404): continue else: # If user has appropriate permissions for the view, include # appropriate metadata about the fields that should be supplied. - serializer = view.get_serializer() + serializer = view.get_serializer(instance=obj) actions[method] = self.get_serializer_info(serializer) finally: view.request = request @@ -167,6 +171,10 @@ class Metadata(metadata.SimpleMetadata): return actions def determine_metadata(self, request, view): + # store request on self so we can use it to generate field defaults + # (such as TOWER_URL_BASE) + self.request = request + metadata = super(Metadata, self).determine_metadata(request, view) # Add version number in which view was added to Tower. diff --git a/awx/api/serializers.py b/awx/api/serializers.py index cdb30e113d..7c6698ccdd 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -42,7 +42,9 @@ from awx.main.constants import SCHEDULEABLE_PROVIDERS from awx.main.models import * # noqa from awx.main.access import get_user_capabilities from awx.main.fields import ImplicitRoleField -from awx.main.utils import get_type_for_model, get_model_for_type, build_url, timestamp_apiformat, camelcase_to_underscore, getattrd +from awx.main.utils import ( + get_type_for_model, get_model_for_type, build_url, timestamp_apiformat, + camelcase_to_underscore, getattrd, parse_yaml_or_json) from awx.main.validators import vars_validate_or_raise from awx.conf.license import feature_enabled @@ -1307,10 +1309,7 @@ class BaseVariableDataSerializer(BaseSerializer): if obj is None: return {} ret = super(BaseVariableDataSerializer, self).to_representation(obj) - try: - return json.loads(ret.get('variables', '') or '{}') - except ValueError: - return yaml.safe_load(ret.get('variables', '')) + return parse_yaml_or_json(ret.get('variables', '') or '{}') def to_internal_value(self, data): data = {'variables': json.dumps(data)} @@ -1622,8 +1621,11 @@ class ResourceAccessListElementSerializer(UserSerializer): role_dict['user_capabilities'] = {'unattach': False} return { 'role': role_dict, 'descendant_roles': get_roles_on_resource(obj, role)} - def format_team_role_perm(team_role, permissive_role_ids): + def format_team_role_perm(naive_team_role, permissive_role_ids): ret = [] + team_role = naive_team_role + if naive_team_role.role_field == 'admin_role': + team_role = naive_team_role.content_object.member_role for role in team_role.children.filter(id__in=permissive_role_ids).all(): role_dict = { 'id': role.id, @@ -1682,11 +1684,11 @@ class ResourceAccessListElementSerializer(UserSerializer): ret['summary_fields']['direct_access'] \ = [format_role_perm(r) for r in direct_access_roles.distinct()] \ - + [y for x in (format_team_role_perm(r, direct_permissive_role_ids) for r in direct_team_roles.distinct()) for y in x] + + [y for x in (format_team_role_perm(r, direct_permissive_role_ids) for r in direct_team_roles.distinct()) for y in x] \ + + [y for x in (format_team_role_perm(r, all_permissive_role_ids) for r in indirect_team_roles.distinct()) for y in x] ret['summary_fields']['indirect_access'] \ - = [format_role_perm(r) for r in indirect_access_roles.distinct()] \ - + [y for x in (format_team_role_perm(r, all_permissive_role_ids) for r in indirect_team_roles.distinct()) for y in x] + = [format_role_perm(r) for r in indirect_access_roles.distinct()] return ret diff --git a/awx/api/views.py b/awx/api/views.py index 56d5e7d789..e69dc57fd4 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -22,7 +22,7 @@ from django.contrib.auth.models import User, AnonymousUser from django.core.cache import cache from django.core.urlresolvers import reverse from django.core.exceptions import FieldError -from django.db.models import Q, Count +from django.db.models import Q, Count, F from django.db import IntegrityError, transaction, connection from django.shortcuts import get_object_or_404 from django.utils.encoding import smart_text, force_text @@ -518,7 +518,7 @@ class AuthView(APIView): def get(self, request): data = OrderedDict() err_backend, err_message = request.session.get('social_auth_error', (None, None)) - auth_backends = load_backends(settings.AUTHENTICATION_BACKENDS).items() + auth_backends = load_backends(settings.AUTHENTICATION_BACKENDS, force_load=True).items() # Return auth backends in consistent order: Google, GitHub, SAML. auth_backends.sort(key=lambda x: 'g' if x[0] == 'google-oauth2' else x[0]) for name, backend in auth_backends: @@ -646,15 +646,16 @@ class OrganizationCountsMixin(object): self.request.user, 'read_role').values('organization').annotate( Count('organization')).order_by('organization') - JT_reference = 'project__organization' - db_results['job_templates'] = JobTemplate.accessible_objects( - self.request.user, 'read_role').exclude(job_type='scan').values(JT_reference).annotate( - Count(JT_reference)).order_by(JT_reference) + JT_project_reference = 'project__organization' + JT_inventory_reference = 'inventory__organization' + db_results['job_templates_project'] = JobTemplate.accessible_objects( + self.request.user, 'read_role').exclude( + project__organization=F(JT_inventory_reference)).values(JT_project_reference).annotate( + Count(JT_project_reference)).order_by(JT_project_reference) - JT_scan_reference = 'inventory__organization' - db_results['job_templates_scan'] = JobTemplate.accessible_objects( - self.request.user, 'read_role').filter(job_type='scan').values(JT_scan_reference).annotate( - Count(JT_scan_reference)).order_by(JT_scan_reference) + db_results['job_templates_inventory'] = JobTemplate.accessible_objects( + self.request.user, 'read_role').values(JT_inventory_reference).annotate( + Count(JT_inventory_reference)).order_by(JT_inventory_reference) db_results['projects'] = project_qs\ .values('organization').annotate(Count('organization')).order_by('organization') @@ -672,16 +673,16 @@ class OrganizationCountsMixin(object): 'inventories': 0, 'teams': 0, 'users': 0, 'job_templates': 0, 'admins': 0, 'projects': 0} - for res in db_results: - if res == 'job_templates': - org_reference = JT_reference - elif res == 'job_templates_scan': - org_reference = JT_scan_reference + for res, count_qs in db_results.items(): + if res == 'job_templates_project': + org_reference = JT_project_reference + elif res == 'job_templates_inventory': + org_reference = JT_inventory_reference elif res == 'users': org_reference = 'id' else: org_reference = 'organization' - for entry in db_results[res]: + for entry in count_qs: org_id = entry[org_reference] if org_id in count_context: if res == 'users': @@ -690,11 +691,13 @@ class OrganizationCountsMixin(object): continue count_context[org_id][res] = entry['%s__count' % org_reference] - # Combine the counts for job templates with scan job templates + # Combine the counts for job templates by project and inventory for org in org_id_list: org_id = org['id'] - if 'job_templates_scan' in count_context[org_id]: - count_context[org_id]['job_templates'] += count_context[org_id].pop('job_templates_scan') + count_context[org_id]['job_templates'] = 0 + for related_path in ['job_templates_project', 'job_templates_inventory']: + if related_path in count_context[org_id]: + count_context[org_id]['job_templates'] += count_context[org_id].pop(related_path) full_context['related_field_counts'] = count_context @@ -1865,6 +1868,16 @@ class GroupChildrenList(EnforceParentRelationshipMixin, SubListCreateAttachDetac relationship = 'children' enforce_parent_relationship = 'inventory' + def unattach(self, request, *args, **kwargs): + sub_id = request.data.get('id', None) + if sub_id is not None: + return super(GroupChildrenList, self).unattach(request, *args, **kwargs) + parent = self.get_parent_object() + if not request.user.can_access(self.model, 'delete', parent): + raise PermissionDenied() + parent.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + class GroupPotentialChildrenList(SubListAPIView): @@ -2484,7 +2497,7 @@ class JobTemplateSurveySpec(GenericAPIView): return Response(dict(error=_("'required' missing from survey question %s.") % str(idx)), status=status.HTTP_400_BAD_REQUEST) if survey_item["type"] == "password": - if "default" in survey_item and survey_item["default"].startswith('$encrypted$'): + if survey_item.get("default") and survey_item["default"].startswith('$encrypted$'): old_spec = obj.survey_spec for old_item in old_spec['spec']: if old_item['variable'] == survey_item['variable']: @@ -3039,6 +3052,9 @@ class WorkflowJobTemplateWorkflowNodesList(WorkflowsEnforcementMixin, SubListCre data[fd] = None return super(WorkflowJobTemplateWorkflowNodesList, self).update_raw_data(data) + def get_queryset(self): + return super(WorkflowJobTemplateWorkflowNodesList, self).get_queryset().order_by('id') + class WorkflowJobTemplateJobsList(WorkflowsEnforcementMixin, SubListAPIView): @@ -3149,6 +3165,9 @@ class WorkflowJobWorkflowNodesList(WorkflowsEnforcementMixin, SubListAPIView): parent_key = 'workflow_job' new_in_310 = True + def get_queryset(self): + return super(WorkflowJobWorkflowNodesList, self).get_queryset().order_by('id') + class WorkflowJobCancel(WorkflowsEnforcementMixin, RetrieveAPIView): diff --git a/awx/conf/apps.py b/awx/conf/apps.py index 9ae459fb35..a70d21326c 100644 --- a/awx/conf/apps.py +++ b/awx/conf/apps.py @@ -2,7 +2,7 @@ from django.apps import AppConfig # from django.core import checks from django.utils.translation import ugettext_lazy as _ -from django.utils.log import configure_logging +from awx.main.utils.handlers import configure_external_logger from django.conf import settings @@ -15,10 +15,4 @@ class ConfConfig(AppConfig): self.module.autodiscover() from .settings import SettingsWrapper SettingsWrapper.initialize() - if settings.LOG_AGGREGATOR_ENABLED: - LOGGING_DICT = settings.LOGGING - LOGGING_DICT['handlers']['http_receiver']['class'] = 'awx.main.utils.handlers.HTTPSHandler' - if 'awx' in settings.LOG_AGGREGATOR_LOGGERS: - if 'http_receiver' not in LOGGING_DICT['loggers']['awx']['handlers']: - LOGGING_DICT['loggers']['awx']['handlers'] += ['http_receiver'] - configure_logging(settings.LOGGING_CONFIG, LOGGING_DICT) + configure_external_logger(settings) diff --git a/awx/conf/fields.py b/awx/conf/fields.py index 13d80ae937..f8d012a3aa 100644 --- a/awx/conf/fields.py +++ b/awx/conf/fields.py @@ -19,6 +19,18 @@ logger = logging.getLogger('awx.conf.fields') # appropriate Python type to be used in settings. +class CharField(CharField): + + def to_representation(self, value): + # django_rest_frameworks' default CharField implementation casts `None` + # to a string `"None"`: + # + # https://github.com/tomchristie/django-rest-framework/blob/cbad236f6d817d992873cd4df6527d46ab243ed1/rest_framework/fields.py#L761 + if value is None: + return None + return super(CharField, self).to_representation(value) + + class StringListField(ListField): child = CharField() diff --git a/awx/conf/models.py b/awx/conf/models.py index bc6cfb3dfc..5c26e17c54 100644 --- a/awx/conf/models.py +++ b/awx/conf/models.py @@ -8,7 +8,7 @@ import json from django.db import models # Tower -from awx.main.models.base import CreatedModifiedModel +from awx.main.models.base import CreatedModifiedModel, prevent_search from awx.main.fields import JSONField from awx.main.utils import encrypt_field from awx.conf import settings_registry @@ -24,14 +24,14 @@ class Setting(CreatedModifiedModel): value = JSONField( null=True, ) - user = models.ForeignKey( + user = prevent_search(models.ForeignKey( 'auth.User', related_name='settings', default=None, null=True, editable=False, on_delete=models.CASCADE, - ) + )) def __unicode__(self): try: diff --git a/awx/conf/settings.py b/awx/conf/settings.py index 8b1c0786a1..616ed1fcd3 100644 --- a/awx/conf/settings.py +++ b/awx/conf/settings.py @@ -6,6 +6,8 @@ import sys import threading import time +import six + # Django from django.conf import settings, UserSettingsHolder from django.core.cache import cache as django_cache @@ -17,6 +19,7 @@ from rest_framework.fields import empty, SkipField # Tower from awx.main.utils import encrypt_field, decrypt_field +from awx.main.utils.db import get_tower_migration_version from awx.conf import settings_registry from awx.conf.models import Setting @@ -57,7 +60,10 @@ def _log_database_error(): try: yield except (ProgrammingError, OperationalError) as e: - logger.warning('Database settings are not available, using defaults (%s)', e, exc_info=True) + if get_tower_migration_version() < '310': + logger.info('Using default settings until version 3.1 migration.') + else: + logger.warning('Database settings are not available, using defaults (%s)', e, exc_info=True) finally: pass @@ -88,7 +94,17 @@ class EncryptedCacheProxy(object): def get(self, key, **kwargs): value = self.cache.get(key, **kwargs) - return self._handle_encryption(self.decrypter, key, value) + value = self._handle_encryption(self.decrypter, key, value) + + # python-memcached auto-encodes unicode on cache set in python2 + # https://github.com/linsomniac/python-memcached/issues/79 + # https://github.com/linsomniac/python-memcached/blob/288c159720eebcdf667727a859ef341f1e908308/memcache.py#L961 + if six.PY2 and isinstance(value, six.binary_type): + try: + six.text_type(value) + except UnicodeDecodeError: + value = value.decode('utf-8') + return value def set(self, key, value, **kwargs): self.cache.set( diff --git a/awx/conf/tests/unit/test_settings.py b/awx/conf/tests/unit/test_settings.py index 5cd5d0e012..f7f1540108 100644 --- a/awx/conf/tests/unit/test_settings.py +++ b/awx/conf/tests/unit/test_settings.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + # Copyright (c) 2017 Ansible, Inc. # All Rights Reserved. @@ -9,10 +11,10 @@ from django.conf import LazySettings from django.core.cache.backends.locmem import LocMemCache from django.core.exceptions import ImproperlyConfigured from django.utils.translation import ugettext_lazy as _ -from rest_framework import fields import pytest +import six -from awx.conf import models +from awx.conf import models, fields from awx.conf.settings import SettingsWrapper, EncryptedCacheProxy, SETTING_CACHE_NOTSET from awx.conf.registry import SettingsRegistry @@ -61,6 +63,15 @@ def test_unregistered_setting(settings): assert settings.cache.get('DEBUG') is None +def test_cached_settings_unicode_is_auto_decoded(settings): + # https://github.com/linsomniac/python-memcached/issues/79 + # https://github.com/linsomniac/python-memcached/blob/288c159720eebcdf667727a859ef341f1e908308/memcache.py#L961 + + value = six.u('Iñtërnâtiônàlizætiøn').encode('utf-8') # this simulates what python-memcached does on cache.set() + settings.cache.set('DEBUG', value) + assert settings.cache.get('DEBUG') == six.u('Iñtërnâtiônàlizætiøn') + + def test_read_only_setting(settings): settings.registry.register( 'AWX_READ_ONLY', @@ -240,6 +251,31 @@ def test_setting_from_db(settings, mocker): assert settings.cache.get('AWX_SOME_SETTING') == 'FROM_DB' +@pytest.mark.parametrize('encrypted', (True, False)) +def test_setting_from_db_with_unicode(settings, mocker, encrypted): + settings.registry.register( + 'AWX_SOME_SETTING', + field_class=fields.CharField, + category=_('System'), + category_slug='system', + default='DEFAULT', + encrypted=encrypted + ) + # this simulates a bug in python-memcached; see https://github.com/linsomniac/python-memcached/issues/79 + value = six.u('Iñtërnâtiônàlizætiøn').encode('utf-8') + + setting_from_db = mocker.Mock(key='AWX_SOME_SETTING', value=value) + mocks = mocker.Mock(**{ + 'order_by.return_value': mocker.Mock(**{ + '__iter__': lambda self: iter([setting_from_db]), + 'first.return_value': setting_from_db + }), + }) + with mocker.patch('awx.conf.models.Setting.objects.filter', return_value=mocks): + assert settings.AWX_SOME_SETTING == six.u('Iñtërnâtiônàlizætiøn') + assert settings.cache.get('AWX_SOME_SETTING') == six.u('Iñtërnâtiônàlizætiøn') + + @pytest.mark.defined_in_file(AWX_SOME_SETTING='DEFAULT') def test_read_only_setting_assignment(settings): "read-only settings cannot be overwritten" @@ -330,6 +366,31 @@ def test_read_only_setting_deletion(settings): assert settings.AWX_SOME_SETTING == 'DEFAULT' +def test_charfield_properly_sets_none(settings, mocker): + "see: https://github.com/ansible/ansible-tower/issues/5322" + settings.registry.register( + 'AWX_SOME_SETTING', + field_class=fields.CharField, + category=_('System'), + category_slug='system', + allow_null=True + ) + + setting_list = mocker.Mock(**{'order_by.return_value.first.return_value': None}) + with apply_patches([ + mocker.patch('awx.conf.models.Setting.objects.filter', + return_value=setting_list), + mocker.patch('awx.conf.models.Setting.objects.create', mocker.Mock()) + ]): + settings.AWX_SOME_SETTING = None + + models.Setting.objects.create.assert_called_with( + key='AWX_SOME_SETTING', + user=None, + value=None + ) + + def test_settings_use_an_encrypted_cache(settings): settings.registry.register( 'AWX_ENCRYPTED', diff --git a/awx/locale/django.pot b/awx/locale/django.pot index 56771e6f92..9a47e8d755 100644 --- a/awx/locale/django.pot +++ b/awx/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-01-31 20:58+0000\n" +"POT-Creation-Date: 2017-02-09 14:32+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -70,39 +70,39 @@ msgstr "" msgid "\"id\" is required to disassociate" msgstr "" -#: awx/api/metadata.py:50 +#: awx/api/metadata.py:51 msgid "Database ID for this {}." msgstr "" -#: awx/api/metadata.py:51 +#: awx/api/metadata.py:52 msgid "Name of this {}." msgstr "" -#: awx/api/metadata.py:52 +#: awx/api/metadata.py:53 msgid "Optional description of this {}." msgstr "" -#: awx/api/metadata.py:53 +#: awx/api/metadata.py:54 msgid "Data type for this {}." msgstr "" -#: awx/api/metadata.py:54 +#: awx/api/metadata.py:55 msgid "URL for this {}." msgstr "" -#: awx/api/metadata.py:55 +#: awx/api/metadata.py:56 msgid "Data structure with URLs of related resources." msgstr "" -#: awx/api/metadata.py:56 +#: awx/api/metadata.py:57 msgid "Data structure with name/description for related resources." msgstr "" -#: awx/api/metadata.py:57 +#: awx/api/metadata.py:58 msgid "Timestamp when this {} was created." msgstr "" -#: awx/api/metadata.py:58 +#: awx/api/metadata.py:59 msgid "Timestamp when this {} was last modified." msgstr "" @@ -111,616 +111,620 @@ msgstr "" msgid "JSON parse error - %s" msgstr "" -#: awx/api/serializers.py:250 +#: awx/api/serializers.py:251 msgid "Playbook Run" msgstr "" -#: awx/api/serializers.py:251 +#: awx/api/serializers.py:252 msgid "Command" msgstr "" -#: awx/api/serializers.py:252 +#: awx/api/serializers.py:253 msgid "SCM Update" msgstr "" -#: awx/api/serializers.py:253 +#: awx/api/serializers.py:254 msgid "Inventory Sync" msgstr "" -#: awx/api/serializers.py:254 +#: awx/api/serializers.py:255 msgid "Management Job" msgstr "" -#: awx/api/serializers.py:255 +#: awx/api/serializers.py:256 msgid "Workflow Job" msgstr "" -#: awx/api/serializers.py:256 +#: awx/api/serializers.py:257 msgid "Workflow Template" msgstr "" -#: awx/api/serializers.py:658 awx/api/serializers.py:716 awx/api/views.py:3819 +#: awx/api/serializers.py:653 awx/api/serializers.py:711 awx/api/views.py:3842 #, python-format msgid "" "Standard Output too large to display (%(text_size)d bytes), only download " "supported for sizes over %(supported_size)d bytes" msgstr "" -#: awx/api/serializers.py:731 +#: awx/api/serializers.py:726 msgid "Write-only field used to change the password." msgstr "" -#: awx/api/serializers.py:733 +#: awx/api/serializers.py:728 msgid "Set if the account is managed by an external service" msgstr "" -#: awx/api/serializers.py:757 +#: awx/api/serializers.py:752 msgid "Password required for new User." msgstr "" -#: awx/api/serializers.py:841 +#: awx/api/serializers.py:836 #, python-format msgid "Unable to change %s on user managed by LDAP." msgstr "" -#: awx/api/serializers.py:1002 +#: awx/api/serializers.py:997 msgid "Organization is missing" msgstr "" -#: awx/api/serializers.py:1006 +#: awx/api/serializers.py:1001 msgid "Update options must be set to false for manual projects." msgstr "" -#: awx/api/serializers.py:1012 +#: awx/api/serializers.py:1007 msgid "Array of playbooks available within this project." msgstr "" -#: awx/api/serializers.py:1194 +#: awx/api/serializers.py:1189 #, python-format msgid "Invalid port specification: %s" msgstr "" -#: awx/api/serializers.py:1222 awx/main/validators.py:193 +#: awx/api/serializers.py:1217 awx/main/validators.py:193 msgid "Must be valid JSON or YAML." msgstr "" -#: awx/api/serializers.py:1279 +#: awx/api/serializers.py:1274 msgid "Invalid group name." msgstr "" -#: awx/api/serializers.py:1354 +#: awx/api/serializers.py:1349 msgid "" "Script must begin with a hashbang sequence: i.e.... #!/usr/bin/env python" msgstr "" -#: awx/api/serializers.py:1407 +#: awx/api/serializers.py:1402 msgid "If 'source' is 'custom', 'source_script' must be provided." msgstr "" -#: awx/api/serializers.py:1411 +#: awx/api/serializers.py:1406 msgid "" "The 'source_script' does not belong to the same organization as the " "inventory." msgstr "" -#: awx/api/serializers.py:1413 +#: awx/api/serializers.py:1408 msgid "'source_script' doesn't exist." msgstr "" -#: awx/api/serializers.py:1772 +#: awx/api/serializers.py:1770 msgid "" "Write-only field used to add user to owner role. If provided, do not give " "either team or organization. Only valid for creation." msgstr "" -#: awx/api/serializers.py:1777 +#: awx/api/serializers.py:1775 msgid "" "Write-only field used to add team to owner role. If provided, do not give " "either user or organization. Only valid for creation." msgstr "" -#: awx/api/serializers.py:1782 +#: awx/api/serializers.py:1780 msgid "" "Inherit permissions from organization roles. If provided on creation, do not " "give either user or team." msgstr "" -#: awx/api/serializers.py:1798 +#: awx/api/serializers.py:1796 msgid "Missing 'user', 'team', or 'organization'." msgstr "" -#: awx/api/serializers.py:1811 +#: awx/api/serializers.py:1809 msgid "" "Credential organization must be set and match before assigning to a team" msgstr "" -#: awx/api/serializers.py:1903 +#: awx/api/serializers.py:1905 msgid "This field is required." msgstr "" -#: awx/api/serializers.py:1905 awx/api/serializers.py:1907 +#: awx/api/serializers.py:1907 awx/api/serializers.py:1909 msgid "Playbook not found for project." msgstr "" -#: awx/api/serializers.py:1909 +#: awx/api/serializers.py:1911 msgid "Must select playbook for project." msgstr "" -#: awx/api/serializers.py:1975 +#: awx/api/serializers.py:1977 msgid "Must either set a default value or ask to prompt on launch." msgstr "" -#: awx/api/serializers.py:1978 awx/main/models/jobs.py:278 +#: awx/api/serializers.py:1980 awx/main/models/jobs.py:277 msgid "Scan jobs must be assigned a fixed inventory." msgstr "" -#: awx/api/serializers.py:1980 awx/main/models/jobs.py:281 +#: awx/api/serializers.py:1982 awx/main/models/jobs.py:280 msgid "Job types 'run' and 'check' must have assigned a project." msgstr "" -#: awx/api/serializers.py:1987 +#: awx/api/serializers.py:1989 msgid "Survey Enabled cannot be used with scan jobs." msgstr "" -#: awx/api/serializers.py:2047 +#: awx/api/serializers.py:2049 msgid "Invalid job template." msgstr "" -#: awx/api/serializers.py:2132 +#: awx/api/serializers.py:2134 msgid "Credential not found or deleted." msgstr "" -#: awx/api/serializers.py:2134 +#: awx/api/serializers.py:2136 msgid "Job Template Project is missing or undefined." msgstr "" -#: awx/api/serializers.py:2136 +#: awx/api/serializers.py:2138 msgid "Job Template Inventory is missing or undefined." msgstr "" -#: awx/api/serializers.py:2421 +#: awx/api/serializers.py:2423 #, python-format msgid "%(job_type)s is not a valid job type. The choices are %(choices)s." msgstr "" -#: awx/api/serializers.py:2426 +#: awx/api/serializers.py:2428 msgid "Workflow job template is missing during creation." msgstr "" -#: awx/api/serializers.py:2431 +#: awx/api/serializers.py:2433 #, python-format msgid "Cannot nest a %s inside a WorkflowJobTemplate" msgstr "" -#: awx/api/serializers.py:2669 +#: awx/api/serializers.py:2671 #, python-format msgid "Job Template '%s' is missing or undefined." msgstr "" -#: awx/api/serializers.py:2695 +#: awx/api/serializers.py:2697 msgid "Must be a valid JSON or YAML dictionary." msgstr "" -#: awx/api/serializers.py:2837 +#: awx/api/serializers.py:2839 msgid "" "Missing required fields for Notification Configuration: notification_type" msgstr "" -#: awx/api/serializers.py:2860 +#: awx/api/serializers.py:2862 msgid "No values specified for field '{}'" msgstr "" -#: awx/api/serializers.py:2865 +#: awx/api/serializers.py:2867 msgid "Missing required fields for Notification Configuration: {}." msgstr "" -#: awx/api/serializers.py:2868 +#: awx/api/serializers.py:2870 msgid "Configuration field '{}' incorrect type, expected {}." msgstr "" -#: awx/api/serializers.py:2921 +#: awx/api/serializers.py:2923 msgid "Inventory Source must be a cloud resource." msgstr "" -#: awx/api/serializers.py:2923 +#: awx/api/serializers.py:2925 msgid "Manual Project can not have a schedule set." msgstr "" -#: awx/api/serializers.py:2945 +#: awx/api/serializers.py:2947 msgid "DTSTART required in rrule. Value should match: DTSTART:YYYYMMDDTHHMMSSZ" msgstr "" -#: awx/api/serializers.py:2947 +#: awx/api/serializers.py:2949 msgid "Multiple DTSTART is not supported." msgstr "" -#: awx/api/serializers.py:2949 +#: awx/api/serializers.py:2951 msgid "RRULE require in rrule." msgstr "" -#: awx/api/serializers.py:2951 +#: awx/api/serializers.py:2953 msgid "Multiple RRULE is not supported." msgstr "" -#: awx/api/serializers.py:2953 +#: awx/api/serializers.py:2955 msgid "INTERVAL required in rrule." msgstr "" -#: awx/api/serializers.py:2955 +#: awx/api/serializers.py:2957 msgid "TZID is not supported." msgstr "" -#: awx/api/serializers.py:2957 +#: awx/api/serializers.py:2959 msgid "SECONDLY is not supported." msgstr "" -#: awx/api/serializers.py:2959 +#: awx/api/serializers.py:2961 msgid "Multiple BYMONTHDAYs not supported." msgstr "" -#: awx/api/serializers.py:2961 +#: awx/api/serializers.py:2963 msgid "Multiple BYMONTHs not supported." msgstr "" -#: awx/api/serializers.py:2963 +#: awx/api/serializers.py:2965 msgid "BYDAY with numeric prefix not supported." msgstr "" -#: awx/api/serializers.py:2965 +#: awx/api/serializers.py:2967 msgid "BYYEARDAY not supported." msgstr "" -#: awx/api/serializers.py:2967 +#: awx/api/serializers.py:2969 msgid "BYWEEKNO not supported." msgstr "" -#: awx/api/serializers.py:2971 +#: awx/api/serializers.py:2973 msgid "COUNT > 999 is unsupported." msgstr "" -#: awx/api/serializers.py:2975 +#: awx/api/serializers.py:2977 msgid "rrule parsing failed validation." msgstr "" -#: awx/api/serializers.py:2997 +#: awx/api/serializers.py:3012 msgid "" "A summary of the new and changed values when an object is created, updated, " "or deleted" msgstr "" -#: awx/api/serializers.py:2999 +#: awx/api/serializers.py:3014 msgid "" "For create, update, and delete events this is the object type that was " "affected. For associate and disassociate events this is the object type " "associated or disassociated with object2." msgstr "" -#: awx/api/serializers.py:3002 +#: awx/api/serializers.py:3017 msgid "" "Unpopulated for create, update, and delete events. For associate and " "disassociate events this is the object type that object1 is being associated " "with." msgstr "" -#: awx/api/serializers.py:3005 +#: awx/api/serializers.py:3020 msgid "The action taken with respect to the given object(s)." msgstr "" -#: awx/api/serializers.py:3112 +#: awx/api/serializers.py:3123 msgid "Unable to login with provided credentials." msgstr "" -#: awx/api/serializers.py:3114 +#: awx/api/serializers.py:3125 msgid "Must include \"username\" and \"password\"." msgstr "" -#: awx/api/views.py:101 +#: awx/api/views.py:102 msgid "Your license does not allow use of the activity stream." msgstr "" -#: awx/api/views.py:111 +#: awx/api/views.py:112 msgid "Your license does not permit use of system tracking." msgstr "" -#: awx/api/views.py:121 +#: awx/api/views.py:122 msgid "Your license does not allow use of workflows." msgstr "" -#: awx/api/views.py:129 awx/templates/rest_framework/api.html:28 +#: awx/api/views.py:130 awx/templates/rest_framework/api.html:28 msgid "REST API" msgstr "" -#: awx/api/views.py:136 awx/templates/rest_framework/api.html:4 +#: awx/api/views.py:137 awx/templates/rest_framework/api.html:4 msgid "Ansible Tower REST API" msgstr "" -#: awx/api/views.py:152 +#: awx/api/views.py:153 msgid "Version 1" msgstr "" -#: awx/api/views.py:203 +#: awx/api/views.py:204 msgid "Ping" msgstr "" -#: awx/api/views.py:232 awx/conf/apps.py:12 +#: awx/api/views.py:233 awx/conf/apps.py:12 msgid "Configuration" msgstr "" -#: awx/api/views.py:285 +#: awx/api/views.py:286 msgid "Invalid license data" msgstr "" -#: awx/api/views.py:287 +#: awx/api/views.py:288 msgid "Missing 'eula_accepted' property" msgstr "" -#: awx/api/views.py:291 +#: awx/api/views.py:292 msgid "'eula_accepted' value is invalid" msgstr "" -#: awx/api/views.py:294 +#: awx/api/views.py:295 msgid "'eula_accepted' must be True" msgstr "" -#: awx/api/views.py:301 +#: awx/api/views.py:302 msgid "Invalid JSON" msgstr "" -#: awx/api/views.py:309 +#: awx/api/views.py:310 msgid "Invalid License" msgstr "" -#: awx/api/views.py:319 +#: awx/api/views.py:320 msgid "Invalid license" msgstr "" -#: awx/api/views.py:327 +#: awx/api/views.py:328 #, python-format msgid "Failed to remove license (%s)" msgstr "" -#: awx/api/views.py:332 +#: awx/api/views.py:333 msgid "Dashboard" msgstr "" -#: awx/api/views.py:438 +#: awx/api/views.py:439 msgid "Dashboard Jobs Graphs" msgstr "" -#: awx/api/views.py:474 +#: awx/api/views.py:475 #, python-format msgid "Unknown period \"%s\"" msgstr "" -#: awx/api/views.py:488 +#: awx/api/views.py:489 msgid "Schedules" msgstr "" -#: awx/api/views.py:507 +#: awx/api/views.py:508 msgid "Schedule Jobs List" msgstr "" -#: awx/api/views.py:717 +#: awx/api/views.py:727 msgid "Your Tower license only permits a single organization to exist." msgstr "" -#: awx/api/views.py:942 awx/api/views.py:1301 +#: awx/api/views.py:952 awx/api/views.py:1311 msgid "Role 'id' field is missing." msgstr "" -#: awx/api/views.py:948 awx/api/views.py:4106 +#: awx/api/views.py:958 awx/api/views.py:4129 msgid "You cannot assign an Organization role as a child role for a Team." msgstr "" -#: awx/api/views.py:952 awx/api/views.py:4120 +#: awx/api/views.py:962 awx/api/views.py:4143 msgid "You cannot grant system-level permissions to a team." msgstr "" -#: awx/api/views.py:959 awx/api/views.py:4112 +#: awx/api/views.py:969 awx/api/views.py:4135 msgid "" "You cannot grant credential access to a team when the Organization field " "isn't set, or belongs to a different organization" msgstr "" -#: awx/api/views.py:1049 +#: awx/api/views.py:1059 msgid "Cannot delete project." msgstr "" -#: awx/api/views.py:1078 +#: awx/api/views.py:1088 msgid "Project Schedules" msgstr "" -#: awx/api/views.py:1182 awx/api/views.py:2273 awx/api/views.py:3286 +#: awx/api/views.py:1192 awx/api/views.py:2285 awx/api/views.py:3298 msgid "Cannot delete job resource when associated workflow job is running." msgstr "" -#: awx/api/views.py:1259 +#: awx/api/views.py:1269 msgid "Me" msgstr "" -#: awx/api/views.py:1305 awx/api/views.py:4061 +#: awx/api/views.py:1315 awx/api/views.py:4084 msgid "You may not perform any action with your own admin_role." msgstr "" -#: awx/api/views.py:1311 awx/api/views.py:4065 +#: awx/api/views.py:1321 awx/api/views.py:4088 msgid "You may not change the membership of a users admin_role" msgstr "" -#: awx/api/views.py:1316 awx/api/views.py:4070 +#: awx/api/views.py:1326 awx/api/views.py:4093 msgid "" "You cannot grant credential access to a user not in the credentials' " "organization" msgstr "" -#: awx/api/views.py:1320 awx/api/views.py:4074 +#: awx/api/views.py:1330 awx/api/views.py:4097 msgid "You cannot grant private credential access to another user" msgstr "" -#: awx/api/views.py:1418 +#: awx/api/views.py:1428 #, python-format msgid "Cannot change %s." msgstr "" -#: awx/api/views.py:1424 +#: awx/api/views.py:1434 msgid "Cannot delete user." msgstr "" -#: awx/api/views.py:1572 +#: awx/api/views.py:1583 msgid "Cannot delete inventory script." msgstr "" -#: awx/api/views.py:1808 +#: awx/api/views.py:1820 msgid "Fact not found." msgstr "" -#: awx/api/views.py:2128 +#: awx/api/views.py:2140 msgid "Inventory Source List" msgstr "" -#: awx/api/views.py:2156 +#: awx/api/views.py:2168 msgid "Cannot delete inventory source." msgstr "" -#: awx/api/views.py:2164 +#: awx/api/views.py:2176 msgid "Inventory Source Schedules" msgstr "" -#: awx/api/views.py:2194 +#: awx/api/views.py:2206 msgid "Notification Templates can only be assigned when source is one of {}." msgstr "" -#: awx/api/views.py:2405 +#: awx/api/views.py:2414 msgid "Job Template Schedules" msgstr "" -#: awx/api/views.py:2425 awx/api/views.py:2441 +#: awx/api/views.py:2434 awx/api/views.py:2450 msgid "Your license does not allow adding surveys." msgstr "" -#: awx/api/views.py:2448 +#: awx/api/views.py:2457 msgid "'name' missing from survey spec." msgstr "" -#: awx/api/views.py:2450 +#: awx/api/views.py:2459 msgid "'description' missing from survey spec." msgstr "" -#: awx/api/views.py:2452 +#: awx/api/views.py:2461 msgid "'spec' missing from survey spec." msgstr "" -#: awx/api/views.py:2454 +#: awx/api/views.py:2463 msgid "'spec' must be a list of items." msgstr "" -#: awx/api/views.py:2456 +#: awx/api/views.py:2465 msgid "'spec' doesn't contain any items." msgstr "" -#: awx/api/views.py:2462 +#: awx/api/views.py:2471 #, python-format msgid "Survey question %s is not a json object." msgstr "" -#: awx/api/views.py:2464 +#: awx/api/views.py:2473 #, python-format msgid "'type' missing from survey question %s." msgstr "" -#: awx/api/views.py:2466 +#: awx/api/views.py:2475 #, python-format msgid "'question_name' missing from survey question %s." msgstr "" -#: awx/api/views.py:2468 +#: awx/api/views.py:2477 #, python-format msgid "'variable' missing from survey question %s." msgstr "" -#: awx/api/views.py:2470 +#: awx/api/views.py:2479 #, python-format msgid "'variable' '%(item)s' duplicated in survey question %(survey)s." msgstr "" -#: awx/api/views.py:2475 +#: awx/api/views.py:2484 #, python-format msgid "'required' missing from survey question %s." msgstr "" -#: awx/api/views.py:2686 +#: awx/api/views.py:2569 +msgid "Maximum number of labels for {} reached." +msgstr "" + +#: awx/api/views.py:2698 msgid "No matching host could be found!" msgstr "" -#: awx/api/views.py:2689 +#: awx/api/views.py:2701 msgid "Multiple hosts matched the request!" msgstr "" -#: awx/api/views.py:2694 +#: awx/api/views.py:2706 msgid "Cannot start automatically, user input required!" msgstr "" -#: awx/api/views.py:2701 +#: awx/api/views.py:2713 msgid "Host callback job already pending." msgstr "" -#: awx/api/views.py:2714 +#: awx/api/views.py:2726 msgid "Error starting job!" msgstr "" -#: awx/api/views.py:3043 +#: awx/api/views.py:3055 msgid "Workflow Job Template Schedules" msgstr "" -#: awx/api/views.py:3185 awx/api/views.py:3728 +#: awx/api/views.py:3197 awx/api/views.py:3745 msgid "Superuser privileges needed." msgstr "" -#: awx/api/views.py:3217 +#: awx/api/views.py:3229 msgid "System Job Template Schedules" msgstr "" -#: awx/api/views.py:3409 +#: awx/api/views.py:3421 msgid "Job Host Summaries List" msgstr "" -#: awx/api/views.py:3451 +#: awx/api/views.py:3468 msgid "Job Event Children List" msgstr "" -#: awx/api/views.py:3460 +#: awx/api/views.py:3477 msgid "Job Event Hosts List" msgstr "" -#: awx/api/views.py:3469 +#: awx/api/views.py:3486 msgid "Job Events List" msgstr "" -#: awx/api/views.py:3682 +#: awx/api/views.py:3699 msgid "Ad Hoc Command Events List" msgstr "" -#: awx/api/views.py:3874 +#: awx/api/views.py:3897 msgid "Error generating stdout download file: {}" msgstr "" -#: awx/api/views.py:3887 +#: awx/api/views.py:3910 #, python-format msgid "Error generating stdout download file: %s" msgstr "" -#: awx/api/views.py:3932 +#: awx/api/views.py:3955 msgid "Delete not allowed while there are pending notifications" msgstr "" -#: awx/api/views.py:3939 +#: awx/api/views.py:3962 msgid "Notification Template Test" msgstr "" -#: awx/api/views.py:4055 +#: awx/api/views.py:4078 msgid "User 'id' field is missing." msgstr "" -#: awx/api/views.py:4098 +#: awx/api/views.py:4121 msgid "Team 'id' field is missing." msgstr "" @@ -920,6 +924,10 @@ msgstr "" msgid "User-Defaults" msgstr "" +#: awx/conf/registry.py:133 +msgid "This value has been set manually in a settings file." +msgstr "" + #: awx/conf/tests/unit/test_registry.py:46 #: awx/conf/tests/unit/test_registry.py:56 #: awx/conf/tests/unit/test_registry.py:72 @@ -940,18 +948,25 @@ msgstr "" #: awx/conf/tests/unit/test_registry.py:245 #: awx/conf/tests/unit/test_registry.py:288 #: awx/conf/tests/unit/test_registry.py:306 -#: awx/conf/tests/unit/test_settings.py:67 -#: awx/conf/tests/unit/test_settings.py:81 -#: awx/conf/tests/unit/test_settings.py:97 -#: awx/conf/tests/unit/test_settings.py:110 -#: awx/conf/tests/unit/test_settings.py:127 -#: awx/conf/tests/unit/test_settings.py:143 +#: awx/conf/tests/unit/test_settings.py:68 +#: awx/conf/tests/unit/test_settings.py:86 +#: awx/conf/tests/unit/test_settings.py:101 +#: awx/conf/tests/unit/test_settings.py:116 +#: awx/conf/tests/unit/test_settings.py:132 +#: awx/conf/tests/unit/test_settings.py:145 #: awx/conf/tests/unit/test_settings.py:162 -#: awx/conf/tests/unit/test_settings.py:183 -#: awx/conf/tests/unit/test_settings.py:197 -#: awx/conf/tests/unit/test_settings.py:221 -#: awx/conf/tests/unit/test_settings.py:241 -#: awx/conf/tests/unit/test_settings.py:258 awx/main/conf.py:19 +#: awx/conf/tests/unit/test_settings.py:178 +#: awx/conf/tests/unit/test_settings.py:189 +#: awx/conf/tests/unit/test_settings.py:205 +#: awx/conf/tests/unit/test_settings.py:226 +#: awx/conf/tests/unit/test_settings.py:249 +#: awx/conf/tests/unit/test_settings.py:263 +#: awx/conf/tests/unit/test_settings.py:287 +#: awx/conf/tests/unit/test_settings.py:307 +#: awx/conf/tests/unit/test_settings.py:324 +#: awx/conf/tests/unit/test_settings.py:337 +#: awx/conf/tests/unit/test_settings.py:351 +#: awx/conf/tests/unit/test_settings.py:387 awx/main/conf.py:19 #: awx/main/conf.py:29 awx/main/conf.py:39 awx/main/conf.py:48 #: awx/main/conf.py:60 awx/main/conf.py:78 awx/main/conf.py:103 msgid "System" @@ -973,72 +988,72 @@ msgstr "" msgid "Setting Detail" msgstr "" -#: awx/main/access.py:255 +#: awx/main/access.py:266 #, python-format msgid "Bad data found in related field %s." msgstr "" -#: awx/main/access.py:296 +#: awx/main/access.py:307 msgid "License is missing." msgstr "" -#: awx/main/access.py:298 +#: awx/main/access.py:309 msgid "License has expired." msgstr "" -#: awx/main/access.py:306 +#: awx/main/access.py:317 #, python-format msgid "License count of %s instances has been reached." msgstr "" -#: awx/main/access.py:308 +#: awx/main/access.py:319 #, python-format msgid "License count of %s instances has been exceeded." msgstr "" -#: awx/main/access.py:310 +#: awx/main/access.py:321 msgid "Host count exceeds available instances." msgstr "" -#: awx/main/access.py:314 +#: awx/main/access.py:325 #, python-format msgid "Feature %s is not enabled in the active license." msgstr "" -#: awx/main/access.py:316 +#: awx/main/access.py:327 msgid "Features not found in active license." msgstr "" -#: awx/main/access.py:514 awx/main/access.py:581 awx/main/access.py:706 -#: awx/main/access.py:969 awx/main/access.py:1208 awx/main/access.py:1605 +#: awx/main/access.py:525 awx/main/access.py:592 awx/main/access.py:717 +#: awx/main/access.py:987 awx/main/access.py:1222 awx/main/access.py:1619 msgid "Resource is being used by running jobs" msgstr "" -#: awx/main/access.py:625 +#: awx/main/access.py:636 msgid "Unable to change inventory on a host." msgstr "" -#: awx/main/access.py:642 awx/main/access.py:687 +#: awx/main/access.py:653 awx/main/access.py:698 msgid "Cannot associate two items from different inventories." msgstr "" -#: awx/main/access.py:675 +#: awx/main/access.py:686 msgid "Unable to change inventory on a group." msgstr "" -#: awx/main/access.py:889 +#: awx/main/access.py:907 msgid "Unable to change organization on a team." msgstr "" -#: awx/main/access.py:902 +#: awx/main/access.py:920 msgid "The {} role cannot be assigned to a team" msgstr "" -#: awx/main/access.py:904 +#: awx/main/access.py:922 msgid "The admin_role for a User cannot be assigned to a team" msgstr "" -#: awx/main/access.py:1678 +#: awx/main/access.py:1692 msgid "" "You do not have permission to the workflow job resources required for " "relaunch." @@ -1258,8 +1273,8 @@ msgid "Hostname/IP where external logs will be sent to." msgstr "" #: awx/main/conf.py:236 awx/main/conf.py:245 awx/main/conf.py:255 -#: awx/main/conf.py:264 awx/main/conf.py:274 awx/main/conf.py:288 -#: awx/main/conf.py:300 awx/main/conf.py:309 +#: awx/main/conf.py:264 awx/main/conf.py:275 awx/main/conf.py:290 +#: awx/main/conf.py:302 awx/main/conf.py:311 msgid "Logging" msgstr "" @@ -1287,20 +1302,20 @@ msgstr "" msgid "Username for external log aggregator (if required)." msgstr "" -#: awx/main/conf.py:272 +#: awx/main/conf.py:273 msgid "Logging Aggregator Password/Token" msgstr "" -#: awx/main/conf.py:273 +#: awx/main/conf.py:274 msgid "" "Password or authentication token for external log aggregator (if required)." msgstr "" -#: awx/main/conf.py:281 +#: awx/main/conf.py:283 msgid "Loggers to send data to the log aggregator from" msgstr "" -#: awx/main/conf.py:282 +#: awx/main/conf.py:284 msgid "" "List of loggers that will send HTTP logs to the collector, these can include " "any or all of: \n" @@ -1310,11 +1325,11 @@ msgid "" "system_tracking - facts gathered from scan jobs." msgstr "" -#: awx/main/conf.py:295 +#: awx/main/conf.py:297 msgid "Log System Tracking Facts Individually" msgstr "" -#: awx/main/conf.py:296 +#: awx/main/conf.py:298 msgid "" "If set, system tracking facts will be sent for each package, service, " "orother item found in a scan, allowing for greater search query granularity. " @@ -1322,11 +1337,11 @@ msgid "" "efficiency in fact processing." msgstr "" -#: awx/main/conf.py:307 +#: awx/main/conf.py:309 msgid "Enable External Logging" msgstr "" -#: awx/main/conf.py:308 +#: awx/main/conf.py:310 msgid "Enable sending logs to external log aggregator." msgstr "" @@ -1354,7 +1369,7 @@ msgstr "" msgid "No valid inventory." msgstr "" -#: awx/main/models/ad_hoc_commands.py:103 awx/main/models/jobs.py:161 +#: awx/main/models/ad_hoc_commands.py:103 awx/main/models/jobs.py:160 msgid "You must provide a machine / SSH credential." msgstr "" @@ -1372,43 +1387,43 @@ msgstr "" msgid "No argument passed to %s module." msgstr "" -#: awx/main/models/ad_hoc_commands.py:222 awx/main/models/jobs.py:766 +#: awx/main/models/ad_hoc_commands.py:222 awx/main/models/jobs.py:752 msgid "Host Failed" msgstr "" -#: awx/main/models/ad_hoc_commands.py:223 awx/main/models/jobs.py:767 +#: awx/main/models/ad_hoc_commands.py:223 awx/main/models/jobs.py:753 msgid "Host OK" msgstr "" -#: awx/main/models/ad_hoc_commands.py:224 awx/main/models/jobs.py:770 +#: awx/main/models/ad_hoc_commands.py:224 awx/main/models/jobs.py:756 msgid "Host Unreachable" msgstr "" -#: awx/main/models/ad_hoc_commands.py:229 awx/main/models/jobs.py:769 +#: awx/main/models/ad_hoc_commands.py:229 awx/main/models/jobs.py:755 msgid "Host Skipped" msgstr "" -#: awx/main/models/ad_hoc_commands.py:239 awx/main/models/jobs.py:797 +#: awx/main/models/ad_hoc_commands.py:239 awx/main/models/jobs.py:783 msgid "Debug" msgstr "" -#: awx/main/models/ad_hoc_commands.py:240 awx/main/models/jobs.py:798 +#: awx/main/models/ad_hoc_commands.py:240 awx/main/models/jobs.py:784 msgid "Verbose" msgstr "" -#: awx/main/models/ad_hoc_commands.py:241 awx/main/models/jobs.py:799 +#: awx/main/models/ad_hoc_commands.py:241 awx/main/models/jobs.py:785 msgid "Deprecated" msgstr "" -#: awx/main/models/ad_hoc_commands.py:242 awx/main/models/jobs.py:800 +#: awx/main/models/ad_hoc_commands.py:242 awx/main/models/jobs.py:786 msgid "Warning" msgstr "" -#: awx/main/models/ad_hoc_commands.py:243 awx/main/models/jobs.py:801 +#: awx/main/models/ad_hoc_commands.py:243 awx/main/models/jobs.py:787 msgid "System Warning" msgstr "" -#: awx/main/models/ad_hoc_commands.py:244 awx/main/models/jobs.py:802 +#: awx/main/models/ad_hoc_commands.py:244 awx/main/models/jobs.py:788 #: awx/main/models/unified_jobs.py:64 msgid "Error" msgstr "" @@ -1817,7 +1832,7 @@ msgid "Inventory source(s) that created or modified this group." msgstr "" #: awx/main/models/inventory.py:706 awx/main/models/projects.py:42 -#: awx/main/models/unified_jobs.py:402 +#: awx/main/models/unified_jobs.py:411 msgid "Manual" msgstr "" @@ -1939,143 +1954,143 @@ msgstr "" msgid "Organization owning this inventory script" msgstr "" -#: awx/main/models/jobs.py:169 +#: awx/main/models/jobs.py:168 msgid "You must provide a network credential." msgstr "" -#: awx/main/models/jobs.py:177 +#: awx/main/models/jobs.py:176 msgid "" "Must provide a credential for a cloud provider, such as Amazon Web Services " "or Rackspace." msgstr "" -#: awx/main/models/jobs.py:269 +#: awx/main/models/jobs.py:268 msgid "Job Template must provide 'inventory' or allow prompting for it." msgstr "" -#: awx/main/models/jobs.py:273 +#: awx/main/models/jobs.py:272 msgid "Job Template must provide 'credential' or allow prompting for it." msgstr "" -#: awx/main/models/jobs.py:362 +#: awx/main/models/jobs.py:370 msgid "Cannot override job_type to or from a scan job." msgstr "" -#: awx/main/models/jobs.py:365 +#: awx/main/models/jobs.py:373 msgid "Inventory cannot be changed at runtime for scan jobs." msgstr "" -#: awx/main/models/jobs.py:431 awx/main/models/projects.py:243 +#: awx/main/models/jobs.py:439 awx/main/models/projects.py:243 msgid "SCM Revision" msgstr "" -#: awx/main/models/jobs.py:432 +#: awx/main/models/jobs.py:440 msgid "The SCM Revision from the Project used for this job, if available" msgstr "" -#: awx/main/models/jobs.py:440 +#: awx/main/models/jobs.py:448 msgid "" "The SCM Refresh task used to make sure the playbooks were available for the " "job run" msgstr "" -#: awx/main/models/jobs.py:665 +#: awx/main/models/jobs.py:651 msgid "job host summaries" msgstr "" -#: awx/main/models/jobs.py:768 +#: awx/main/models/jobs.py:754 msgid "Host Failure" msgstr "" -#: awx/main/models/jobs.py:771 awx/main/models/jobs.py:785 +#: awx/main/models/jobs.py:757 awx/main/models/jobs.py:771 msgid "No Hosts Remaining" msgstr "" -#: awx/main/models/jobs.py:772 +#: awx/main/models/jobs.py:758 msgid "Host Polling" msgstr "" -#: awx/main/models/jobs.py:773 +#: awx/main/models/jobs.py:759 msgid "Host Async OK" msgstr "" -#: awx/main/models/jobs.py:774 +#: awx/main/models/jobs.py:760 msgid "Host Async Failure" msgstr "" -#: awx/main/models/jobs.py:775 +#: awx/main/models/jobs.py:761 msgid "Item OK" msgstr "" -#: awx/main/models/jobs.py:776 +#: awx/main/models/jobs.py:762 msgid "Item Failed" msgstr "" -#: awx/main/models/jobs.py:777 +#: awx/main/models/jobs.py:763 msgid "Item Skipped" msgstr "" -#: awx/main/models/jobs.py:778 +#: awx/main/models/jobs.py:764 msgid "Host Retry" msgstr "" -#: awx/main/models/jobs.py:780 +#: awx/main/models/jobs.py:766 msgid "File Difference" msgstr "" -#: awx/main/models/jobs.py:781 +#: awx/main/models/jobs.py:767 msgid "Playbook Started" msgstr "" -#: awx/main/models/jobs.py:782 +#: awx/main/models/jobs.py:768 msgid "Running Handlers" msgstr "" -#: awx/main/models/jobs.py:783 +#: awx/main/models/jobs.py:769 msgid "Including File" msgstr "" -#: awx/main/models/jobs.py:784 +#: awx/main/models/jobs.py:770 msgid "No Hosts Matched" msgstr "" -#: awx/main/models/jobs.py:786 +#: awx/main/models/jobs.py:772 msgid "Task Started" msgstr "" -#: awx/main/models/jobs.py:788 +#: awx/main/models/jobs.py:774 msgid "Variables Prompted" msgstr "" -#: awx/main/models/jobs.py:789 +#: awx/main/models/jobs.py:775 msgid "Gathering Facts" msgstr "" -#: awx/main/models/jobs.py:790 +#: awx/main/models/jobs.py:776 msgid "internal: on Import for Host" msgstr "" -#: awx/main/models/jobs.py:791 +#: awx/main/models/jobs.py:777 msgid "internal: on Not Import for Host" msgstr "" -#: awx/main/models/jobs.py:792 +#: awx/main/models/jobs.py:778 msgid "Play Started" msgstr "" -#: awx/main/models/jobs.py:793 +#: awx/main/models/jobs.py:779 msgid "Playbook Complete" msgstr "" -#: awx/main/models/jobs.py:1203 +#: awx/main/models/jobs.py:1189 msgid "Remove jobs older than a certain number of days" msgstr "" -#: awx/main/models/jobs.py:1204 +#: awx/main/models/jobs.py:1190 msgid "Remove activity stream entries older than a certain number of days" msgstr "" -#: awx/main/models/jobs.py:1205 +#: awx/main/models/jobs.py:1191 msgid "Purge and/or reduce the granularity of system tracking data" msgstr "" @@ -2243,99 +2258,99 @@ msgstr "" msgid "List of playbooks found in the project" msgstr "" -#: awx/main/models/rbac.py:36 +#: awx/main/models/rbac.py:37 msgid "System Administrator" msgstr "" -#: awx/main/models/rbac.py:37 +#: awx/main/models/rbac.py:38 msgid "System Auditor" msgstr "" -#: awx/main/models/rbac.py:38 +#: awx/main/models/rbac.py:39 msgid "Ad Hoc" msgstr "" -#: awx/main/models/rbac.py:39 +#: awx/main/models/rbac.py:40 msgid "Admin" msgstr "" -#: awx/main/models/rbac.py:40 +#: awx/main/models/rbac.py:41 msgid "Auditor" msgstr "" -#: awx/main/models/rbac.py:41 +#: awx/main/models/rbac.py:42 msgid "Execute" msgstr "" -#: awx/main/models/rbac.py:42 +#: awx/main/models/rbac.py:43 msgid "Member" msgstr "" -#: awx/main/models/rbac.py:43 +#: awx/main/models/rbac.py:44 msgid "Read" msgstr "" -#: awx/main/models/rbac.py:44 +#: awx/main/models/rbac.py:45 msgid "Update" msgstr "" -#: awx/main/models/rbac.py:45 +#: awx/main/models/rbac.py:46 msgid "Use" msgstr "" -#: awx/main/models/rbac.py:49 +#: awx/main/models/rbac.py:50 msgid "Can manage all aspects of the system" msgstr "" -#: awx/main/models/rbac.py:50 +#: awx/main/models/rbac.py:51 msgid "Can view all settings on the system" msgstr "" -#: awx/main/models/rbac.py:51 -msgid "May run ad hoc commands on an inventory" -msgstr "" - #: awx/main/models/rbac.py:52 -#, python-format -msgid "Can manage all aspects of the %s" +msgid "May run ad hoc commands on an inventory" msgstr "" #: awx/main/models/rbac.py:53 #, python-format -msgid "Can view all settings for the %s" +msgid "Can manage all aspects of the %s" msgstr "" #: awx/main/models/rbac.py:54 #, python-format -msgid "May run the %s" +msgid "Can view all settings for the %s" msgstr "" #: awx/main/models/rbac.py:55 #, python-format -msgid "User is a member of the %s" +msgid "May run the %s" msgstr "" #: awx/main/models/rbac.py:56 #, python-format -msgid "May view settings for the %s" +msgid "User is a member of the %s" msgstr "" #: awx/main/models/rbac.py:57 +#, python-format +msgid "May view settings for the %s" +msgstr "" + +#: awx/main/models/rbac.py:58 msgid "" "May update project or inventory or group using the configured source update " "system" msgstr "" -#: awx/main/models/rbac.py:58 +#: awx/main/models/rbac.py:59 #, python-format msgid "Can use the %s in a job template" msgstr "" -#: awx/main/models/rbac.py:122 +#: awx/main/models/rbac.py:123 msgid "roles" msgstr "" -#: awx/main/models/rbac.py:438 +#: awx/main/models/rbac.py:435 msgid "role_ancestors" msgstr "" @@ -2398,47 +2413,47 @@ msgstr "" msgid "Updating" msgstr "" -#: awx/main/models/unified_jobs.py:403 +#: awx/main/models/unified_jobs.py:412 msgid "Relaunch" msgstr "" -#: awx/main/models/unified_jobs.py:404 +#: awx/main/models/unified_jobs.py:413 msgid "Callback" msgstr "" -#: awx/main/models/unified_jobs.py:405 +#: awx/main/models/unified_jobs.py:414 msgid "Scheduled" msgstr "" -#: awx/main/models/unified_jobs.py:406 +#: awx/main/models/unified_jobs.py:415 msgid "Dependency" msgstr "" -#: awx/main/models/unified_jobs.py:407 +#: awx/main/models/unified_jobs.py:416 msgid "Workflow" msgstr "" -#: awx/main/models/unified_jobs.py:408 +#: awx/main/models/unified_jobs.py:417 msgid "Sync" msgstr "" -#: awx/main/models/unified_jobs.py:454 +#: awx/main/models/unified_jobs.py:463 msgid "The Tower node the job executed on." msgstr "" -#: awx/main/models/unified_jobs.py:480 +#: awx/main/models/unified_jobs.py:489 msgid "The date and time the job was queued for starting." msgstr "" -#: awx/main/models/unified_jobs.py:486 +#: awx/main/models/unified_jobs.py:495 msgid "The date and time the job finished execution." msgstr "" -#: awx/main/models/unified_jobs.py:492 +#: awx/main/models/unified_jobs.py:501 msgid "Elapsed time in seconds that the job ran." msgstr "" -#: awx/main/models/unified_jobs.py:514 +#: awx/main/models/unified_jobs.py:523 msgid "" "A status field to indicate the state of the job if it wasn't able to run and " "capture stdout" @@ -2482,12 +2497,18 @@ msgstr "" msgid "Error sending notification webhook: {}" msgstr "" -#: awx/main/scheduler/__init__.py:130 +#: awx/main/scheduler/__init__.py:127 msgid "" "Job spawned from workflow could not start because it was not in the right " "state or required manual credentials" msgstr "" +#: awx/main/scheduler/__init__.py:131 +msgid "" +"Job spawned from workflow could not start because it was missing a related " +"resource such as project or inventory" +msgstr "" + #: awx/main/tasks.py:180 msgid "Ansible Tower host usage over 90%" msgstr "" @@ -2505,38 +2526,38 @@ msgstr "" msgid "Unable to convert \"%s\" to boolean" msgstr "" -#: awx/main/utils/common.py:243 +#: awx/main/utils/common.py:245 #, python-format msgid "Unsupported SCM type \"%s\"" msgstr "" -#: awx/main/utils/common.py:250 awx/main/utils/common.py:262 -#: awx/main/utils/common.py:281 +#: awx/main/utils/common.py:252 awx/main/utils/common.py:264 +#: awx/main/utils/common.py:283 #, python-format msgid "Invalid %s URL" msgstr "" -#: awx/main/utils/common.py:252 awx/main/utils/common.py:290 +#: awx/main/utils/common.py:254 awx/main/utils/common.py:292 #, python-format msgid "Unsupported %s URL" msgstr "" -#: awx/main/utils/common.py:292 +#: awx/main/utils/common.py:294 #, python-format msgid "Unsupported host \"%s\" for file:// URL" msgstr "" -#: awx/main/utils/common.py:294 +#: awx/main/utils/common.py:296 #, python-format msgid "Host is required for %s URL" msgstr "" -#: awx/main/utils/common.py:312 +#: awx/main/utils/common.py:314 #, python-format msgid "Username must be \"git\" for SSH access to %s." msgstr "" -#: awx/main/utils/common.py:318 +#: awx/main/utils/common.py:320 #, python-format msgid "Username must be \"hg\" for SSH access to %s." msgstr "" @@ -2647,195 +2668,195 @@ msgstr "" msgid "A server error has occurred." msgstr "" -#: awx/settings/defaults.py:624 +#: awx/settings/defaults.py:625 msgid "Chicago" msgstr "" -#: awx/settings/defaults.py:625 +#: awx/settings/defaults.py:626 msgid "Dallas/Ft. Worth" msgstr "" -#: awx/settings/defaults.py:626 +#: awx/settings/defaults.py:627 msgid "Northern Virginia" msgstr "" -#: awx/settings/defaults.py:627 +#: awx/settings/defaults.py:628 msgid "London" msgstr "" -#: awx/settings/defaults.py:628 +#: awx/settings/defaults.py:629 msgid "Sydney" msgstr "" -#: awx/settings/defaults.py:629 +#: awx/settings/defaults.py:630 msgid "Hong Kong" msgstr "" -#: awx/settings/defaults.py:656 +#: awx/settings/defaults.py:657 msgid "US East (Northern Virginia)" msgstr "" -#: awx/settings/defaults.py:657 +#: awx/settings/defaults.py:658 msgid "US East (Ohio)" msgstr "" -#: awx/settings/defaults.py:658 +#: awx/settings/defaults.py:659 msgid "US West (Oregon)" msgstr "" -#: awx/settings/defaults.py:659 +#: awx/settings/defaults.py:660 msgid "US West (Northern California)" msgstr "" -#: awx/settings/defaults.py:660 +#: awx/settings/defaults.py:661 msgid "Canada (Central)" msgstr "" -#: awx/settings/defaults.py:661 +#: awx/settings/defaults.py:662 msgid "EU (Frankfurt)" msgstr "" -#: awx/settings/defaults.py:662 +#: awx/settings/defaults.py:663 msgid "EU (Ireland)" msgstr "" -#: awx/settings/defaults.py:663 +#: awx/settings/defaults.py:664 msgid "EU (London)" msgstr "" -#: awx/settings/defaults.py:664 +#: awx/settings/defaults.py:665 msgid "Asia Pacific (Singapore)" msgstr "" -#: awx/settings/defaults.py:665 +#: awx/settings/defaults.py:666 msgid "Asia Pacific (Sydney)" msgstr "" -#: awx/settings/defaults.py:666 +#: awx/settings/defaults.py:667 msgid "Asia Pacific (Tokyo)" msgstr "" -#: awx/settings/defaults.py:667 +#: awx/settings/defaults.py:668 msgid "Asia Pacific (Seoul)" msgstr "" -#: awx/settings/defaults.py:668 +#: awx/settings/defaults.py:669 msgid "Asia Pacific (Mumbai)" msgstr "" -#: awx/settings/defaults.py:669 +#: awx/settings/defaults.py:670 msgid "South America (Sao Paulo)" msgstr "" -#: awx/settings/defaults.py:670 +#: awx/settings/defaults.py:671 msgid "US West (GovCloud)" msgstr "" -#: awx/settings/defaults.py:671 +#: awx/settings/defaults.py:672 msgid "China (Beijing)" msgstr "" -#: awx/settings/defaults.py:720 +#: awx/settings/defaults.py:721 msgid "US East (B)" msgstr "" -#: awx/settings/defaults.py:721 +#: awx/settings/defaults.py:722 msgid "US East (C)" msgstr "" -#: awx/settings/defaults.py:722 +#: awx/settings/defaults.py:723 msgid "US East (D)" msgstr "" -#: awx/settings/defaults.py:723 +#: awx/settings/defaults.py:724 msgid "US Central (A)" msgstr "" -#: awx/settings/defaults.py:724 +#: awx/settings/defaults.py:725 msgid "US Central (B)" msgstr "" -#: awx/settings/defaults.py:725 +#: awx/settings/defaults.py:726 msgid "US Central (C)" msgstr "" -#: awx/settings/defaults.py:726 +#: awx/settings/defaults.py:727 msgid "US Central (F)" msgstr "" -#: awx/settings/defaults.py:727 +#: awx/settings/defaults.py:728 msgid "Europe West (B)" msgstr "" -#: awx/settings/defaults.py:728 +#: awx/settings/defaults.py:729 msgid "Europe West (C)" msgstr "" -#: awx/settings/defaults.py:729 +#: awx/settings/defaults.py:730 msgid "Europe West (D)" msgstr "" -#: awx/settings/defaults.py:730 +#: awx/settings/defaults.py:731 msgid "Asia East (A)" msgstr "" -#: awx/settings/defaults.py:731 +#: awx/settings/defaults.py:732 msgid "Asia East (B)" msgstr "" -#: awx/settings/defaults.py:732 +#: awx/settings/defaults.py:733 msgid "Asia East (C)" msgstr "" -#: awx/settings/defaults.py:756 +#: awx/settings/defaults.py:757 msgid "US Central" msgstr "" -#: awx/settings/defaults.py:757 +#: awx/settings/defaults.py:758 msgid "US East" msgstr "" -#: awx/settings/defaults.py:758 +#: awx/settings/defaults.py:759 msgid "US East 2" msgstr "" -#: awx/settings/defaults.py:759 +#: awx/settings/defaults.py:760 msgid "US North Central" msgstr "" -#: awx/settings/defaults.py:760 +#: awx/settings/defaults.py:761 msgid "US South Central" msgstr "" -#: awx/settings/defaults.py:761 +#: awx/settings/defaults.py:762 msgid "US West" msgstr "" -#: awx/settings/defaults.py:762 +#: awx/settings/defaults.py:763 msgid "Europe North" msgstr "" -#: awx/settings/defaults.py:763 +#: awx/settings/defaults.py:764 msgid "Europe West" msgstr "" -#: awx/settings/defaults.py:764 +#: awx/settings/defaults.py:765 msgid "Asia Pacific East" msgstr "" -#: awx/settings/defaults.py:765 +#: awx/settings/defaults.py:766 msgid "Asia Pacific Southeast" msgstr "" -#: awx/settings/defaults.py:766 +#: awx/settings/defaults.py:767 msgid "Japan East" msgstr "" -#: awx/settings/defaults.py:767 +#: awx/settings/defaults.py:768 msgid "Japan West" msgstr "" -#: awx/settings/defaults.py:768 +#: awx/settings/defaults.py:769 msgid "Brazil South" msgstr "" @@ -2952,10 +2973,10 @@ msgid "" msgstr "" #: awx/sso/conf.py:181 awx/sso/conf.py:199 awx/sso/conf.py:211 -#: awx/sso/conf.py:223 awx/sso/conf.py:239 awx/sso/conf.py:258 -#: awx/sso/conf.py:280 awx/sso/conf.py:296 awx/sso/conf.py:315 -#: awx/sso/conf.py:332 awx/sso/conf.py:349 awx/sso/conf.py:365 -#: awx/sso/conf.py:382 awx/sso/conf.py:420 awx/sso/conf.py:461 +#: awx/sso/conf.py:223 awx/sso/conf.py:239 awx/sso/conf.py:259 +#: awx/sso/conf.py:281 awx/sso/conf.py:297 awx/sso/conf.py:316 +#: awx/sso/conf.py:333 awx/sso/conf.py:350 awx/sso/conf.py:366 +#: awx/sso/conf.py:383 awx/sso/conf.py:421 awx/sso/conf.py:462 msgid "LDAP" msgstr "" @@ -3000,11 +3021,11 @@ msgid "" "values that can be set." msgstr "" -#: awx/sso/conf.py:251 +#: awx/sso/conf.py:252 msgid "LDAP User Search" msgstr "" -#: awx/sso/conf.py:252 +#: awx/sso/conf.py:253 msgid "" "LDAP search query to find users. Any user that matches the given pattern " "will be able to login to Tower. The user should also be mapped into an " @@ -3013,11 +3034,11 @@ msgid "" "possible. See python-ldap documentation as linked at the top of this section." msgstr "" -#: awx/sso/conf.py:274 +#: awx/sso/conf.py:275 msgid "LDAP User DN Template" msgstr "" -#: awx/sso/conf.py:275 +#: awx/sso/conf.py:276 msgid "" "Alternative to user search, if user DNs are all of the same format. This " "approach will be more efficient for user lookups than searching if it is " @@ -3025,11 +3046,11 @@ msgid "" "will be used instead of AUTH_LDAP_USER_SEARCH." msgstr "" -#: awx/sso/conf.py:290 +#: awx/sso/conf.py:291 msgid "LDAP User Attribute Map" msgstr "" -#: awx/sso/conf.py:291 +#: awx/sso/conf.py:292 msgid "" "Mapping of LDAP user schema to Tower API user attributes (key is user " "attribute name, value is LDAP attribute name). The default setting is valid " @@ -3037,54 +3058,54 @@ msgid "" "change the values (not the keys) of the dictionary/hash-table." msgstr "" -#: awx/sso/conf.py:310 +#: awx/sso/conf.py:311 msgid "LDAP Group Search" msgstr "" -#: awx/sso/conf.py:311 +#: awx/sso/conf.py:312 msgid "" "Users in Tower are mapped to organizations based on their membership in LDAP " "groups. This setting defines the LDAP search query to find groups. Note that " "this, unlike the user search above, does not support LDAPSearchUnion." msgstr "" -#: awx/sso/conf.py:328 +#: awx/sso/conf.py:329 msgid "LDAP Group Type" msgstr "" -#: awx/sso/conf.py:329 +#: awx/sso/conf.py:330 msgid "" "The group type may need to be changed based on the type of the LDAP server. " "Values are listed at: http://pythonhosted.org/django-auth-ldap/groups." "html#types-of-groups" msgstr "" -#: awx/sso/conf.py:344 +#: awx/sso/conf.py:345 msgid "LDAP Require Group" msgstr "" -#: awx/sso/conf.py:345 +#: awx/sso/conf.py:346 msgid "" "Group DN required to login. If specified, user must be a member of this " "group to login via LDAP. If not set, everyone in LDAP that matches the user " "search will be able to login via Tower. Only one require group is supported." msgstr "" -#: awx/sso/conf.py:361 +#: awx/sso/conf.py:362 msgid "LDAP Deny Group" msgstr "" -#: awx/sso/conf.py:362 +#: awx/sso/conf.py:363 msgid "" "Group DN denied from login. If specified, user will not be allowed to login " "if a member of this group. Only one deny group is supported." msgstr "" -#: awx/sso/conf.py:375 +#: awx/sso/conf.py:376 msgid "LDAP User Flags By Group" msgstr "" -#: awx/sso/conf.py:376 +#: awx/sso/conf.py:377 msgid "" "User profile flags updated from group membership (key is user attribute " "name, value is group DN). These are boolean fields that are matched based " @@ -3093,11 +3114,11 @@ msgid "" "false at login time based on current LDAP settings." msgstr "" -#: awx/sso/conf.py:394 +#: awx/sso/conf.py:395 msgid "LDAP Organization Map" msgstr "" -#: awx/sso/conf.py:395 +#: awx/sso/conf.py:396 msgid "" "Mapping between organization admins/users and LDAP groups. This controls " "what users are placed into what Tower organizations relative to their LDAP " @@ -3124,11 +3145,11 @@ msgid "" "remove_admins." msgstr "" -#: awx/sso/conf.py:443 +#: awx/sso/conf.py:444 msgid "LDAP Team Map" msgstr "" -#: awx/sso/conf.py:444 +#: awx/sso/conf.py:445 msgid "" "Mapping between team members (users) and LDAP groups. Keys are team names " "(will be created if not present). Values are dictionaries of options for " @@ -3147,88 +3168,88 @@ msgid "" "of the given groups will be removed from the team." msgstr "" -#: awx/sso/conf.py:487 +#: awx/sso/conf.py:488 msgid "RADIUS Server" msgstr "" -#: awx/sso/conf.py:488 +#: awx/sso/conf.py:489 msgid "" "Hostname/IP of RADIUS server. RADIUS authentication will be disabled if this " "setting is empty." msgstr "" -#: awx/sso/conf.py:490 awx/sso/conf.py:504 awx/sso/conf.py:516 +#: awx/sso/conf.py:491 awx/sso/conf.py:505 awx/sso/conf.py:517 msgid "RADIUS" msgstr "" -#: awx/sso/conf.py:502 +#: awx/sso/conf.py:503 msgid "RADIUS Port" msgstr "" -#: awx/sso/conf.py:503 +#: awx/sso/conf.py:504 msgid "Port of RADIUS server." msgstr "" -#: awx/sso/conf.py:514 +#: awx/sso/conf.py:515 msgid "RADIUS Secret" msgstr "" -#: awx/sso/conf.py:515 +#: awx/sso/conf.py:516 msgid "Shared secret for authenticating to RADIUS server." msgstr "" -#: awx/sso/conf.py:531 +#: awx/sso/conf.py:532 msgid "Google OAuth2 Callback URL" msgstr "" -#: awx/sso/conf.py:532 +#: awx/sso/conf.py:533 msgid "" "Create a project at https://console.developers.google.com/ to obtain an " "OAuth2 key and secret for a web application. Ensure that the Google+ API is " "enabled. Provide this URL as the callback URL for your application." msgstr "" -#: awx/sso/conf.py:536 awx/sso/conf.py:547 awx/sso/conf.py:558 -#: awx/sso/conf.py:571 awx/sso/conf.py:585 awx/sso/conf.py:597 -#: awx/sso/conf.py:609 +#: awx/sso/conf.py:537 awx/sso/conf.py:548 awx/sso/conf.py:559 +#: awx/sso/conf.py:572 awx/sso/conf.py:586 awx/sso/conf.py:598 +#: awx/sso/conf.py:610 msgid "Google OAuth2" msgstr "" -#: awx/sso/conf.py:545 +#: awx/sso/conf.py:546 msgid "Google OAuth2 Key" msgstr "" -#: awx/sso/conf.py:546 +#: awx/sso/conf.py:547 msgid "" "The OAuth2 key from your web application at https://console.developers." "google.com/." msgstr "" -#: awx/sso/conf.py:556 +#: awx/sso/conf.py:557 msgid "Google OAuth2 Secret" msgstr "" -#: awx/sso/conf.py:557 +#: awx/sso/conf.py:558 msgid "" "The OAuth2 secret from your web application at https://console.developers." "google.com/." msgstr "" -#: awx/sso/conf.py:568 +#: awx/sso/conf.py:569 msgid "Google OAuth2 Whitelisted Domains" msgstr "" -#: awx/sso/conf.py:569 +#: awx/sso/conf.py:570 msgid "" "Update this setting to restrict the domains who are allowed to login using " "Google OAuth2." msgstr "" -#: awx/sso/conf.py:580 +#: awx/sso/conf.py:581 msgid "Google OAuth2 Extra Arguments" msgstr "" -#: awx/sso/conf.py:581 +#: awx/sso/conf.py:582 msgid "" "Extra arguments for Google OAuth2 login. When only allowing a single domain " "to authenticate, set to `{\"hd\": \"yourdomain.com\"}` and Google will not " @@ -3236,60 +3257,60 @@ msgid "" "Google accounts." msgstr "" -#: awx/sso/conf.py:595 +#: awx/sso/conf.py:596 msgid "Google OAuth2 Organization Map" msgstr "" -#: awx/sso/conf.py:607 +#: awx/sso/conf.py:608 msgid "Google OAuth2 Team Map" msgstr "" -#: awx/sso/conf.py:623 +#: awx/sso/conf.py:624 msgid "GitHub OAuth2 Callback URL" msgstr "" -#: awx/sso/conf.py:624 +#: awx/sso/conf.py:625 msgid "" "Create a developer application at https://github.com/settings/developers to " "obtain an OAuth2 key (Client ID) and secret (Client Secret). Provide this " "URL as the callback URL for your application." msgstr "" -#: awx/sso/conf.py:628 awx/sso/conf.py:639 awx/sso/conf.py:649 -#: awx/sso/conf.py:661 awx/sso/conf.py:673 +#: awx/sso/conf.py:629 awx/sso/conf.py:640 awx/sso/conf.py:650 +#: awx/sso/conf.py:662 awx/sso/conf.py:674 msgid "GitHub OAuth2" msgstr "" -#: awx/sso/conf.py:637 +#: awx/sso/conf.py:638 msgid "GitHub OAuth2 Key" msgstr "" -#: awx/sso/conf.py:638 +#: awx/sso/conf.py:639 msgid "The OAuth2 key (Client ID) from your GitHub developer application." msgstr "" -#: awx/sso/conf.py:647 +#: awx/sso/conf.py:648 msgid "GitHub OAuth2 Secret" msgstr "" -#: awx/sso/conf.py:648 +#: awx/sso/conf.py:649 msgid "" "The OAuth2 secret (Client Secret) from your GitHub developer application." msgstr "" -#: awx/sso/conf.py:659 +#: awx/sso/conf.py:660 msgid "GitHub OAuth2 Organization Map" msgstr "" -#: awx/sso/conf.py:671 +#: awx/sso/conf.py:672 msgid "GitHub OAuth2 Team Map" msgstr "" -#: awx/sso/conf.py:687 +#: awx/sso/conf.py:688 msgid "GitHub Organization OAuth2 Callback URL" msgstr "" -#: awx/sso/conf.py:688 awx/sso/conf.py:763 +#: awx/sso/conf.py:689 awx/sso/conf.py:764 msgid "" "Create an organization-owned application at https://github.com/organizations/" "/settings/applications and obtain an OAuth2 key (Client ID) and " @@ -3297,86 +3318,86 @@ msgid "" "application." msgstr "" -#: awx/sso/conf.py:692 awx/sso/conf.py:703 awx/sso/conf.py:713 -#: awx/sso/conf.py:725 awx/sso/conf.py:736 awx/sso/conf.py:748 +#: awx/sso/conf.py:693 awx/sso/conf.py:704 awx/sso/conf.py:714 +#: awx/sso/conf.py:726 awx/sso/conf.py:737 awx/sso/conf.py:749 msgid "GitHub Organization OAuth2" msgstr "" -#: awx/sso/conf.py:701 +#: awx/sso/conf.py:702 msgid "GitHub Organization OAuth2 Key" msgstr "" -#: awx/sso/conf.py:702 awx/sso/conf.py:777 +#: awx/sso/conf.py:703 awx/sso/conf.py:778 msgid "The OAuth2 key (Client ID) from your GitHub organization application." msgstr "" -#: awx/sso/conf.py:711 +#: awx/sso/conf.py:712 msgid "GitHub Organization OAuth2 Secret" msgstr "" -#: awx/sso/conf.py:712 awx/sso/conf.py:787 +#: awx/sso/conf.py:713 awx/sso/conf.py:788 msgid "" "The OAuth2 secret (Client Secret) from your GitHub organization application." msgstr "" -#: awx/sso/conf.py:722 +#: awx/sso/conf.py:723 msgid "GitHub Organization Name" msgstr "" -#: awx/sso/conf.py:723 +#: awx/sso/conf.py:724 msgid "" "The name of your GitHub organization, as used in your organization's URL: " "https://github.com//." msgstr "" -#: awx/sso/conf.py:734 +#: awx/sso/conf.py:735 msgid "GitHub Organization OAuth2 Organization Map" msgstr "" -#: awx/sso/conf.py:746 +#: awx/sso/conf.py:747 msgid "GitHub Organization OAuth2 Team Map" msgstr "" -#: awx/sso/conf.py:762 +#: awx/sso/conf.py:763 msgid "GitHub Team OAuth2 Callback URL" msgstr "" -#: awx/sso/conf.py:767 awx/sso/conf.py:778 awx/sso/conf.py:788 -#: awx/sso/conf.py:800 awx/sso/conf.py:811 awx/sso/conf.py:823 +#: awx/sso/conf.py:768 awx/sso/conf.py:779 awx/sso/conf.py:789 +#: awx/sso/conf.py:801 awx/sso/conf.py:812 awx/sso/conf.py:824 msgid "GitHub Team OAuth2" msgstr "" -#: awx/sso/conf.py:776 +#: awx/sso/conf.py:777 msgid "GitHub Team OAuth2 Key" msgstr "" -#: awx/sso/conf.py:786 +#: awx/sso/conf.py:787 msgid "GitHub Team OAuth2 Secret" msgstr "" -#: awx/sso/conf.py:797 +#: awx/sso/conf.py:798 msgid "GitHub Team ID" msgstr "" -#: awx/sso/conf.py:798 +#: awx/sso/conf.py:799 msgid "" "Find the numeric team ID using the Github API: http://fabian-kostadinov." "github.io/2015/01/16/how-to-find-a-github-team-id/." msgstr "" -#: awx/sso/conf.py:809 +#: awx/sso/conf.py:810 msgid "GitHub Team OAuth2 Organization Map" msgstr "" -#: awx/sso/conf.py:821 +#: awx/sso/conf.py:822 msgid "GitHub Team OAuth2 Team Map" msgstr "" -#: awx/sso/conf.py:837 +#: awx/sso/conf.py:838 msgid "Azure AD OAuth2 Callback URL" msgstr "" -#: awx/sso/conf.py:838 +#: awx/sso/conf.py:839 msgid "" "Register an Azure AD application as described by https://msdn.microsoft.com/" "en-us/library/azure/dn132599.aspx and obtain an OAuth2 key (Client ID) and " @@ -3384,118 +3405,118 @@ msgid "" "application." msgstr "" -#: awx/sso/conf.py:842 awx/sso/conf.py:853 awx/sso/conf.py:863 -#: awx/sso/conf.py:875 awx/sso/conf.py:887 +#: awx/sso/conf.py:843 awx/sso/conf.py:854 awx/sso/conf.py:864 +#: awx/sso/conf.py:876 awx/sso/conf.py:888 msgid "Azure AD OAuth2" msgstr "" -#: awx/sso/conf.py:851 +#: awx/sso/conf.py:852 msgid "Azure AD OAuth2 Key" msgstr "" -#: awx/sso/conf.py:852 +#: awx/sso/conf.py:853 msgid "The OAuth2 key (Client ID) from your Azure AD application." msgstr "" -#: awx/sso/conf.py:861 +#: awx/sso/conf.py:862 msgid "Azure AD OAuth2 Secret" msgstr "" -#: awx/sso/conf.py:862 +#: awx/sso/conf.py:863 msgid "The OAuth2 secret (Client Secret) from your Azure AD application." msgstr "" -#: awx/sso/conf.py:873 +#: awx/sso/conf.py:874 msgid "Azure AD OAuth2 Organization Map" msgstr "" -#: awx/sso/conf.py:885 +#: awx/sso/conf.py:886 msgid "Azure AD OAuth2 Team Map" msgstr "" -#: awx/sso/conf.py:906 +#: awx/sso/conf.py:907 msgid "SAML Service Provider Callback URL" msgstr "" -#: awx/sso/conf.py:907 +#: awx/sso/conf.py:908 msgid "" "Register Tower as a service provider (SP) with each identity provider (IdP) " "you have configured. Provide your SP Entity ID and this callback URL for " "your application." msgstr "" -#: awx/sso/conf.py:910 awx/sso/conf.py:924 awx/sso/conf.py:937 -#: awx/sso/conf.py:951 awx/sso/conf.py:965 awx/sso/conf.py:983 -#: awx/sso/conf.py:1005 awx/sso/conf.py:1024 awx/sso/conf.py:1044 -#: awx/sso/conf.py:1078 awx/sso/conf.py:1091 +#: awx/sso/conf.py:911 awx/sso/conf.py:925 awx/sso/conf.py:938 +#: awx/sso/conf.py:952 awx/sso/conf.py:966 awx/sso/conf.py:984 +#: awx/sso/conf.py:1006 awx/sso/conf.py:1025 awx/sso/conf.py:1045 +#: awx/sso/conf.py:1079 awx/sso/conf.py:1092 msgid "SAML" msgstr "" -#: awx/sso/conf.py:921 +#: awx/sso/conf.py:922 msgid "SAML Service Provider Metadata URL" msgstr "" -#: awx/sso/conf.py:922 +#: awx/sso/conf.py:923 msgid "" "If your identity provider (IdP) allows uploading an XML metadata file, you " "can download one from this URL." msgstr "" -#: awx/sso/conf.py:934 +#: awx/sso/conf.py:935 msgid "SAML Service Provider Entity ID" msgstr "" -#: awx/sso/conf.py:935 +#: awx/sso/conf.py:936 msgid "" "The application-defined unique identifier used as the audience of the SAML " "service provider (SP) configuration." msgstr "" -#: awx/sso/conf.py:948 +#: awx/sso/conf.py:949 msgid "SAML Service Provider Public Certificate" msgstr "" -#: awx/sso/conf.py:949 +#: awx/sso/conf.py:950 msgid "" "Create a keypair for Tower to use as a service provider (SP) and include the " "certificate content here." msgstr "" -#: awx/sso/conf.py:962 +#: awx/sso/conf.py:963 msgid "SAML Service Provider Private Key" msgstr "" -#: awx/sso/conf.py:963 +#: awx/sso/conf.py:964 msgid "" "Create a keypair for Tower to use as a service provider (SP) and include the " "private key content here." msgstr "" -#: awx/sso/conf.py:981 +#: awx/sso/conf.py:982 msgid "SAML Service Provider Organization Info" msgstr "" -#: awx/sso/conf.py:982 +#: awx/sso/conf.py:983 msgid "Configure this setting with information about your app." msgstr "" -#: awx/sso/conf.py:1003 +#: awx/sso/conf.py:1004 msgid "SAML Service Provider Technical Contact" msgstr "" -#: awx/sso/conf.py:1004 awx/sso/conf.py:1023 +#: awx/sso/conf.py:1005 awx/sso/conf.py:1024 msgid "Configure this setting with your contact information." msgstr "" -#: awx/sso/conf.py:1022 +#: awx/sso/conf.py:1023 msgid "SAML Service Provider Support Contact" msgstr "" -#: awx/sso/conf.py:1037 +#: awx/sso/conf.py:1038 msgid "SAML Enabled Identity Providers" msgstr "" -#: awx/sso/conf.py:1038 +#: awx/sso/conf.py:1039 msgid "" "Configure the Entity ID, SSO URL and certificate for each identity provider " "(IdP) in use. Multiple SAML IdPs are supported. Some IdPs may provide user " @@ -3504,11 +3525,11 @@ msgid "" "Attribute names may be overridden for each IdP." msgstr "" -#: awx/sso/conf.py:1076 +#: awx/sso/conf.py:1077 msgid "SAML Organization Map" msgstr "" -#: awx/sso/conf.py:1089 +#: awx/sso/conf.py:1090 msgid "SAML Team Map" msgstr "" @@ -3731,7 +3752,7 @@ msgstr "" #: awx/ui/conf.py:49 msgid "" "To set up a custom logo, provide a file that you create. For the custom logo " -"to look its best, use a `.png` file with a transparent background. GIF, PNG " +"to look its best, use a .png file with a transparent background. GIF, PNG " "and JPEG formats are supported." msgstr "" diff --git a/awx/main/access.py b/awx/main/access.py index c18b4cfd6f..20becf2cbe 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -343,6 +343,9 @@ class BaseAccess(object): if validation_errors: user_capabilities[display_method] = False continue + elif display_method == 'copy' and isinstance(obj, WorkflowJobTemplate) and obj.organization_id is None: + user_capabilities[display_method] = self.user.is_superuser + continue elif display_method in ['start', 'schedule'] and isinstance(obj, Group): if obj.inventory_source and not obj.inventory_source._can_update(): user_capabilities[display_method] = False @@ -355,6 +358,9 @@ class BaseAccess(object): # Grab the answer from the cache, if available if hasattr(obj, 'capabilities_cache') and display_method in obj.capabilities_cache: user_capabilities[display_method] = obj.capabilities_cache[display_method] + if self.user.is_superuser and not user_capabilities[display_method]: + # Cache override for models with bad orphaned state + user_capabilities[display_method] = True continue # Aliases for going form UI language to API language @@ -1223,6 +1229,13 @@ class JobTemplateAccess(BaseAccess): "active_jobs": active_jobs}) return True + @check_superuser + def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): + if isinstance(sub_obj, NotificationTemplate): + return self.check_related('organization', Organization, {}, obj=sub_obj, mandatory=True) + return super(JobTemplateAccess, self).can_attach( + obj, sub_obj, relationship, data, skip_sub_obj_read_check=skip_sub_obj_read_check) + class JobAccess(BaseAccess): ''' @@ -1952,13 +1965,12 @@ class ScheduleAccess(BaseAccess): qs = qs.prefetch_related('unified_job_template') if self.user.is_superuser or self.user.is_system_auditor: return qs.all() - job_template_qs = self.user.get_queryset(JobTemplate) - inventory_source_qs = self.user.get_queryset(InventorySource) - project_qs = self.user.get_queryset(Project) - unified_qs = UnifiedJobTemplate.objects.filter(jobtemplate__in=job_template_qs) | \ - UnifiedJobTemplate.objects.filter(Q(project__in=project_qs)) | \ - UnifiedJobTemplate.objects.filter(Q(inventorysource__in=inventory_source_qs)) - return qs.filter(unified_job_template__in=unified_qs) + + unified_pk_qs = UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role') + inv_src_qs = InventorySource.objects.filter(inventory_id=Inventory._accessible_pk_qs(Inventory, self.user, 'read_role')) + return qs.filter( + Q(unified_job_template_id__in=unified_pk_qs) | + Q(unified_job_template_id__in=inv_src_qs.values_list('pk', flat=True))) @check_superuser def can_read(self, obj): diff --git a/awx/main/conf.py b/awx/main/conf.py index d82e766607..bbdf159ae6 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -111,6 +111,7 @@ register( help_text=_('List of modules allowed to be used by ad-hoc jobs.'), category=_('Jobs'), category_slug='jobs', + required=False, ) register( @@ -258,7 +259,8 @@ register( register( 'LOG_AGGREGATOR_USERNAME', field_class=fields.CharField, - allow_null=True, + allow_blank=True, + default='', label=_('Logging Aggregator Username'), help_text=_('Username for external log aggregator (if required).'), category=_('Logging'), @@ -268,7 +270,8 @@ register( register( 'LOG_AGGREGATOR_PASSWORD', field_class=fields.CharField, - allow_null=True, + allow_blank=True, + default='', encrypted=True, label=_('Logging Aggregator Password/Token'), help_text=_('Password or authentication token for external log aggregator (if required).'), @@ -311,3 +314,13 @@ register( category=_('Logging'), category_slug='logging', ) +register( + 'LOG_AGGREGATOR_TOWER_UUID', + field_class=fields.CharField, + allow_blank=True, + label=_('Cluster-wide Tower unique identifier.'), + help_text=_('Useful to uniquely identify Tower instances.'), + category=_('Logging'), + category_slug='logging', + default=None, +) diff --git a/awx/main/consumers.py b/awx/main/consumers.py index 6b7edcfecb..c42f16ef21 100644 --- a/awx/main/consumers.py +++ b/awx/main/consumers.py @@ -63,7 +63,7 @@ def ws_receive(message): if 'groups' in data: discard_groups(message) groups = data['groups'] - current_groups = message.channel_session.pop('groups') if 'groups' in message.channel_session else [] + current_groups = set(message.channel_session.pop('groups') if 'groups' in message.channel_session else []) for group_name,v in groups.items(): if type(v) is list: for oid in v: @@ -74,12 +74,12 @@ def ws_receive(message): if not user_access.get_queryset().filter(pk=oid).exists(): message.reply_channel.send({"text": json.dumps({"error": "access denied to channel {0} for resource id {1}".format(group_name, oid)})}) continue - current_groups.append(name) + current_groups.add(name) Group(name).add(message.reply_channel) else: - current_groups.append(group_name) + current_groups.add(group_name) Group(group_name).add(message.reply_channel) - message.channel_session['groups'] = current_groups + message.channel_session['groups'] = list(current_groups) def emit_channel_notification(group, payload): diff --git a/awx/main/management/commands/cleanup_jobs.py b/awx/main/management/commands/cleanup_jobs.py index 3b26767bc0..cb03e4e9d6 100644 --- a/awx/main/management/commands/cleanup_jobs.py +++ b/awx/main/management/commands/cleanup_jobs.py @@ -12,7 +12,17 @@ from django.db import transaction from django.utils.timezone import now # AWX -from awx.main.models import Job, AdHocCommand, ProjectUpdate, InventoryUpdate, SystemJob, WorkflowJob, Notification +from awx.main.models import ( + Job, AdHocCommand, ProjectUpdate, InventoryUpdate, + SystemJob, WorkflowJob, Notification +) +from awx.main.signals import ( # noqa + emit_update_inventory_on_created_or_deleted, + emit_update_inventory_computed_fields, + disable_activity_stream, + disable_computed_fields +) +from django.db.models.signals import post_save, post_delete, m2m_changed # noqa class Command(NoArgsCommand): @@ -237,10 +247,11 @@ class Command(NoArgsCommand): models_to_cleanup.add(m) if not models_to_cleanup: models_to_cleanup.update(model_names) - for m in model_names: - if m in models_to_cleanup: - skipped, deleted = getattr(self, 'cleanup_%s' % m)() - if self.dry_run: - self.logger.log(99, '%s: %d would be deleted, %d would be skipped.', m.replace('_', ' '), deleted, skipped) - else: - self.logger.log(99, '%s: %d deleted, %d skipped.', m.replace('_', ' '), deleted, skipped) + with disable_activity_stream(), disable_computed_fields(): + for m in model_names: + if m in models_to_cleanup: + skipped, deleted = getattr(self, 'cleanup_%s' % m)() + if self.dry_run: + self.logger.log(99, '%s: %d would be deleted, %d would be skipped.', m.replace('_', ' '), deleted, skipped) + else: + self.logger.log(99, '%s: %d deleted, %d skipped.', m.replace('_', ' '), deleted, skipped) diff --git a/awx/main/management/commands/deprovision_node.py b/awx/main/management/commands/deprovision_node.py index 251816703e..8412b5bd86 100644 --- a/awx/main/management/commands/deprovision_node.py +++ b/awx/main/management/commands/deprovision_node.py @@ -1,6 +1,7 @@ # Copyright (c) 2016 Ansible, Inc. # All Rights Reserved +import subprocess from django.core.management.base import BaseCommand, CommandError from optparse import make_option from awx.main.models import Instance @@ -22,7 +23,11 @@ class Command(BaseCommand): instance = Instance.objects.filter(hostname=options.get('name')) if instance.exists(): instance.delete() - print('Successfully removed') + result = subprocess.Popen("rabbitmqctl forget_cluster_node rabbitmq@{}".format(options.get('name')), shell=True).wait() + if result != 0: + print("Node deprovisioning may have failed when attempting to remove the RabbitMQ instance from the cluster") + else: + print('Successfully deprovisioned {}'.format(options.get('name'))) else: print('No instance found matching name {}'.format(options.get('name'))) diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index e984e41bf4..c262b23024 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -70,8 +70,11 @@ class CallbackBrokerWorker(ConsumerMixin): callbacks=[self.process_task])] def process_task(self, body, message): - if "uuid" in body: - queue = UUID(body['uuid']).int % settings.JOB_EVENT_WORKERS + if "uuid" in body and body['uuid']: + try: + queue = UUID(body['uuid']).int % settings.JOB_EVENT_WORKERS + except Exception: + queue = self.total_messages % settings.JOB_EVENT_WORKERS else: queue = self.total_messages % settings.JOB_EVENT_WORKERS self.write_queue_worker(queue, body) diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index d7b65d8107..2f4d02a7b9 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -42,7 +42,7 @@ _PythonSerializer.handle_m2m_field = _new_handle_m2m_field # Add custom methods to User model for permissions checks. -from django.contrib.auth.models import User # noqa +from django.contrib.auth.models import User # noqa from awx.main.access import * # noqa @@ -128,3 +128,6 @@ activity_stream_registrar.connect(User) activity_stream_registrar.connect(WorkflowJobTemplate) activity_stream_registrar.connect(WorkflowJobTemplateNode) activity_stream_registrar.connect(WorkflowJob) + +# prevent API filtering on certain Django-supplied sensitive fields +prevent_search(User._meta.get_field('password')) diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index 057924eda7..3636aa8e0a 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -83,10 +83,10 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin): editable=False, through='AdHocCommandEvent', ) - extra_vars = models.TextField( + extra_vars = prevent_search(models.TextField( blank=True, default='', - ) + )) extra_vars_dict = VarsDictProperty('extra_vars', True) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index bdee496bad..81e00f92c6 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -23,7 +23,7 @@ from crum import get_current_user # Ansible Tower from awx.main.utils import encrypt_field -__all__ = ['VarsDictProperty', 'BaseModel', 'CreatedModifiedModel', +__all__ = ['prevent_search', 'VarsDictProperty', 'BaseModel', 'CreatedModifiedModel', 'PasswordFieldsModel', 'PrimordialModel', 'CommonModel', 'CommonModelNameNotUnique', 'NotificationFieldsModel', 'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ', @@ -343,3 +343,21 @@ class NotificationFieldsModel(BaseModel): blank=True, related_name='%(class)s_notification_templates_for_any' ) + + + +def prevent_search(relation): + """ + Used to mark a model field or relation as "restricted from filtering" + e.g., + + class AuthToken(BaseModel): + user = prevent_search(models.ForeignKey(...)) + sensitive_data = prevent_search(models.CharField(...)) + + The flag set by this function is used by + `awx.api.filters.FieldLookupBackend` to blacklist fields and relations that + should not be searchable/filterable via search query params + """ + setattr(relation, '__prevent_search__', True) + return relation diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index a7f77e87c2..3342c8b750 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -345,6 +345,9 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): if self.has_encrypted_ssh_key_data and not self.ssh_key_unlock: raise ValidationError(_('SSH key unlock must be set when SSH key ' 'is encrypted.')) + if not self.has_encrypted_ssh_key_data and self.ssh_key_unlock: + raise ValidationError(_('SSH key unlock should not be set when ' + 'SSH key is not encrypted.')) return self.ssh_key_unlock def clean(self): diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index b01d44802c..387277c5e9 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -23,6 +23,7 @@ from awx.main.fields import AutoOneToOneField, ImplicitRoleField from awx.main.managers import HostManager from awx.main.models.base import * # noqa from awx.main.models.unified_jobs import * # noqa +from awx.main.models.jobs import Job from awx.main.models.mixins import ResourceMixin from awx.main.models.notifications import ( NotificationTemplate, @@ -1276,6 +1277,12 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin): def get_notification_friendly_name(self): return "Inventory Update" + def cancel(self): + res = super(InventoryUpdate, self).cancel() + if res: + map(lambda x: x.cancel(), Job.objects.filter(dependent_jobs__in=[self.id])) + return res + class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin): @@ -1284,11 +1291,11 @@ class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin): unique_together = [('name', 'organization')] ordering = ('name',) - script = models.TextField( + script = prevent_search(models.TextField( blank=True, default='', help_text=_('Inventory script contents'), - ) + )) organization = models.ForeignKey( 'Organization', related_name='custom_inventory_scripts', diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 19a853a45f..00a68c69ca 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -117,10 +117,10 @@ class JobOptions(BaseModel): blank=True, default=0, ) - extra_vars = models.TextField( + extra_vars = prevent_search(models.TextField( blank=True, default='', - ) + )) job_tags = models.CharField( max_length=1024, blank=True, @@ -1252,10 +1252,10 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin): on_delete=models.SET_NULL, ) - extra_vars = models.TextField( + extra_vars = prevent_search(models.TextField( blank=True, default='', - ) + )) extra_vars_dict = VarsDictProperty('extra_vars', True) diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index c755de9f0a..3ae26eaf71 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User # noqa # AWX +from awx.main.models.base import prevent_search from awx.main.models.rbac import ( Role, RoleAncestorEntry, get_roles_on_resource ) @@ -86,10 +87,10 @@ class SurveyJobTemplateMixin(models.Model): survey_enabled = models.BooleanField( default=False, ) - survey_spec = JSONField( + survey_spec = prevent_search(JSONField( blank=True, default={}, - ) + )) def survey_password_variables(self): vars = [] @@ -215,11 +216,11 @@ class SurveyJobMixin(models.Model): class Meta: abstract = True - survey_passwords = JSONField( + survey_passwords = prevent_search(JSONField( blank=True, default={}, editable=False, - ) + )) def display_extra_vars(self): ''' diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 8ba92b3782..31b96aa8dd 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -75,7 +75,7 @@ class NotificationTemplate(CommonModel): setattr(self, '_saved_{}_{}'.format("config", field), value) self.notification_configuration[field] = '' else: - encrypted = encrypt_field(self, 'notification_configuration', subfield=field) + encrypted = encrypt_field(self, 'notification_configuration', subfield=field, skip_utf8=True) self.notification_configuration[field] = encrypted if 'notification_configuration' not in update_fields: update_fields.append('notification_configuration') diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index c2fe3b1c4f..99023c86e1 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -220,12 +220,13 @@ class AuthToken(BaseModel): app_label = 'main' key = models.CharField(max_length=40, primary_key=True) - user = models.ForeignKey('auth.User', related_name='auth_tokens', - on_delete=models.CASCADE) + user = prevent_search(models.ForeignKey('auth.User', + related_name='auth_tokens', on_delete=models.CASCADE)) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) expires = models.DateTimeField(default=tz_now) - request_hash = models.CharField(max_length=40, blank=True, default='') + request_hash = prevent_search(models.CharField(max_length=40, blank=True, + default='')) reason = models.CharField( max_length=1024, blank=True, diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 7da004eb7e..f17b2b4c55 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -174,6 +174,13 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio return [] return ['project', 'inventorysource', 'systemjobtemplate'] + @classmethod + def _submodels_with_roles(cls): + ujt_classes = [c for c in cls.__subclasses__() + if c._meta.model_name not in ['inventorysource', 'systemjobtemplate']] + ct_dict = ContentType.objects.get_for_models(*ujt_classes) + return [ct.id for ct in ct_dict.values()] + @classmethod def accessible_pk_qs(cls, accessor, role_field): ''' @@ -184,12 +191,8 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio # do not use this if in a subclass if cls != UnifiedJobTemplate: return super(UnifiedJobTemplate, cls).accessible_pk_qs(accessor, role_field) - ujt_names = [c.__name__.lower() for c in cls.__subclasses__() - if c.__name__.lower() not in ['inventorysource', 'systemjobtemplate']] - subclass_content_types = list(ContentType.objects.filter( - model__in=ujt_names).values_list('id', flat=True)) - - return ResourceMixin._accessible_pk_qs(cls, accessor, role_field, content_types=subclass_content_types) + return ResourceMixin._accessible_pk_qs( + cls, accessor, role_field, content_types=cls._submodels_with_roles()) def _perform_unique_checks(self, unique_checks): # Handle the list of unique fields returned above. Replace with an @@ -500,33 +503,33 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique editable=False, help_text=_("Elapsed time in seconds that the job ran."), ) - job_args = models.TextField( + job_args = prevent_search(models.TextField( blank=True, default='', editable=False, - ) + )) job_cwd = models.CharField( max_length=1024, blank=True, default='', editable=False, ) - job_env = JSONField( + job_env = prevent_search(JSONField( blank=True, default={}, editable=False, - ) + )) job_explanation = models.TextField( blank=True, default='', editable=False, help_text=_("A status field to indicate the state of the job if it wasn't able to run and capture stdout"), ) - start_args = models.TextField( + start_args = prevent_search(models.TextField( blank=True, default='', editable=False, - ) + )) result_stdout_text = models.TextField( blank=True, default='', diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index a6a1ccfe81..874822e013 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -11,7 +11,7 @@ from django.core.urlresolvers import reverse #from django import settings as tower_settings # AWX -from awx.main.models import UnifiedJobTemplate, UnifiedJob +from awx.main.models import prevent_search, UnifiedJobTemplate, UnifiedJob from awx.main.models.notifications import ( NotificationTemplate, JobNotificationMixin @@ -280,10 +280,10 @@ class WorkflowJobOptions(BaseModel): class Meta: abstract = True - extra_vars = models.TextField( + extra_vars = prevent_search(models.TextField( blank=True, default='', - ) + )) extra_vars_dict = VarsDictProperty('extra_vars', True) diff --git a/awx/main/notifications/hipchat_backend.py b/awx/main/notifications/hipchat_backend.py index 586754bd92..b286439954 100644 --- a/awx/main/notifications/hipchat_backend.py +++ b/awx/main/notifications/hipchat_backend.py @@ -37,6 +37,7 @@ class HipChatBackend(TowerBaseEmailBackend): for rcp in m.recipients(): r = requests.post("{}/v2/room/{}/notification".format(self.api_url, rcp), params={"auth_token": self.token}, + verify=False, json={"color": self.color, "message": m.subject, "notify": self.notify, diff --git a/awx/main/scheduler/__init__.py b/awx/main/scheduler/__init__.py index 7446785a7b..a48ca3ad23 100644 --- a/awx/main/scheduler/__init__.py +++ b/awx/main/scheduler/__init__.py @@ -251,6 +251,18 @@ class TaskManager(): dep.save() inventory_task = InventoryUpdateDict.get_partial(dep.id) + + ''' + Update internal datastructures with the newly created inventory update + ''' + # Should be only 1 inventory update. The one for the job (task) + latest_inventory_updates = self.get_latest_inventory_update_tasks([task]) + self.process_latest_inventory_updates(latest_inventory_updates) + + inventory_sources = self.get_inventory_source_tasks([task]) + self.process_inventory_sources(inventory_sources) + + self.graph.add_job(inventory_task) return inventory_task @@ -271,9 +283,15 @@ class TaskManager(): def capture_chain_failure_dependencies(self, task, dependencies): for dep in dependencies: - dep_obj = task.get_full() + dep_obj = dep.get_full() dep_obj.dependent_jobs.add(task['id']) dep_obj.save() + ''' + if not 'dependent_jobs__id' in task.data: + task.data['dependent_jobs__id'] = [dep_obj.data['id']] + else: + task.data['dependent_jobs__id'].append(dep_obj.data['id']) + ''' def generate_dependencies(self, task): dependencies = [] @@ -291,6 +309,9 @@ class TaskManager(): ''' inventory_sources_already_updated = task.get_inventory_sources_already_updated() + ''' + get_inventory_sources() only return update on launch sources + ''' for inventory_source_task in self.graph.get_inventory_sources(task['inventory_id']): if inventory_source_task['id'] in inventory_sources_already_updated: continue @@ -346,10 +367,14 @@ class TaskManager(): for task in all_running_sorted_tasks: if (task['celery_task_id'] not in active_tasks and not hasattr(settings, 'IGNORE_CELERY_INSPECTOR')): - # NOTE: Pull status again and make sure it didn't finish in - # the meantime? # TODO: try catch the getting of the job. The job COULD have been deleted task_obj = task.get_full() + # Ensure job did not finish running between the time we get the + # list of task id's from celery and now. + # Note: This is an actual fix, not a reduction in the time + # window that this can happen. + if task_obj.status != 'running': + continue task_obj.status = 'failed' task_obj.job_explanation += ' '.join(( 'Task was marked as running in Tower but was not present in', diff --git a/awx/main/scheduler/dependency_graph.py b/awx/main/scheduler/dependency_graph.py index 846a194b27..a94a158335 100644 --- a/awx/main/scheduler/dependency_graph.py +++ b/awx/main/scheduler/dependency_graph.py @@ -83,6 +83,11 @@ class DependencyGraph(object): ''' def should_update_related_project(self, job): now = self.get_now() + + # Already processed dependencies for this job + if job.data['dependent_jobs__id'] is not None: + return False + latest_project_update = self.data[self.LATEST_PROJECT_UPDATES].get(job['project_id'], None) if not latest_project_update: return True @@ -113,21 +118,15 @@ class DependencyGraph(object): def should_update_related_inventory_source(self, job, inventory_source_id): now = self.get_now() + + # Already processed dependencies for this job + if job.data['dependent_jobs__id'] is not None: + return False + latest_inventory_update = self.data[self.LATEST_INVENTORY_UPDATES].get(inventory_source_id, None) if not latest_inventory_update: return True - ''' - This is a bit of fuzzy logic. - If the latest inventory update has a created time == job_created_time-2 - then consider the inventory update found. This is so we don't enter an infinite loop - of updating the project when cache timeout is 0. - ''' - if latest_inventory_update['inventory_source__update_cache_timeout'] == 0 and \ - latest_inventory_update['launch_type'] == 'dependency' and \ - latest_inventory_update['created'] == job['created'] - timedelta(seconds=2): - return False - ''' Normal, expected, cache timeout logic ''' diff --git a/awx/main/signals.py b/awx/main/signals.py index ceda8899b1..20dcd2dcd6 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -180,8 +180,16 @@ def rbac_activity_stream(instance, sender, **kwargs): elif sender.__name__ == 'Role_parents': role = kwargs['model'].objects.filter(pk__in=kwargs['pk_set']).first() # don't record implicit creation / parents - if role is not None and role.content_type is not None: - parent = role.content_type.name + "." + role.role_field + if role is not None: + if role.content_type is None: + if role.is_singleton(): + parent = 'singleton:' + role.singleton_name + else: + # Ill-defined role, may need additional logic in the + # case of future expansions of the RBAC system + parent = str(role.role_field) + else: + parent = role.content_type.name + "." + role.role_field # Get the list of implicit parents that were defined at the class level. # We have to take this list from the class property to avoid including parents # that may have been added since the creation of the ImplicitRoleField @@ -210,18 +218,24 @@ def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs): l.delete() -post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) -post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) -post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Group) -post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Group) -m2m_changed.connect(emit_update_inventory_computed_fields, sender=Group.hosts.through) -m2m_changed.connect(emit_update_inventory_computed_fields, sender=Group.parents.through) -m2m_changed.connect(emit_update_inventory_computed_fields, sender=Host.inventory_sources.through) -m2m_changed.connect(emit_update_inventory_computed_fields, sender=Group.inventory_sources.through) -post_save.connect(emit_update_inventory_on_created_or_deleted, sender=InventorySource) -post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=InventorySource) -post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Job) -post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job) +def connect_computed_field_signals(): + post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) + post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) + post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Group) + post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Group) + m2m_changed.connect(emit_update_inventory_computed_fields, sender=Group.hosts.through) + m2m_changed.connect(emit_update_inventory_computed_fields, sender=Group.parents.through) + m2m_changed.connect(emit_update_inventory_computed_fields, sender=Host.inventory_sources.through) + m2m_changed.connect(emit_update_inventory_computed_fields, sender=Group.inventory_sources.through) + post_save.connect(emit_update_inventory_on_created_or_deleted, sender=InventorySource) + post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=InventorySource) + post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Job) + post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job) + + +connect_computed_field_signals() + + post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) @@ -340,6 +354,24 @@ def disable_activity_stream(): activity_stream_enabled.enabled = previous_value +@contextlib.contextmanager +def disable_computed_fields(): + post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=Host) + post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=Host) + post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=Group) + post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=Group) + m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Group.hosts.through) + m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Group.parents.through) + m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Host.inventory_sources.through) + m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Group.inventory_sources.through) + post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=InventorySource) + post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=InventorySource) + post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=Job) + post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=Job) + yield + connect_computed_field_signals() + + model_serializer_mapping = { Organization: OrganizationSerializer, Inventory: InventorySerializer, diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 194cc3c837..584e6ce0bb 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -32,8 +32,7 @@ import pexpect # Celery from celery import Task, task -from celery.signals import celeryd_init, worker_ready -from celery import current_app +from celery.signals import celeryd_init, worker_process_init # Django from django.conf import settings @@ -54,6 +53,8 @@ from awx.main.task_engine import TaskEnhancer from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, check_proot_installed, build_proot_temp_dir, wrap_args_with_proot, get_system_task_capacity, OutputEventFilter, parse_yaml_or_json) +from awx.main.utils.reload import restart_local_services +from awx.main.utils.handlers import configure_external_logger from awx.main.consumers import emit_channel_notification __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', @@ -86,41 +87,10 @@ def celery_startup(conf=None, **kwargs): logger.error("Failed to rebuild schedule {}: {}".format(sch, e)) -def _setup_tower_logger(): - global logger - from django.utils.log import configure_logging - LOGGING_DICT = settings.LOGGING - if settings.LOG_AGGREGATOR_ENABLED: - LOGGING_DICT['handlers']['http_receiver']['class'] = 'awx.main.utils.handlers.HTTPSHandler' - LOGGING_DICT['handlers']['http_receiver']['async'] = False - if 'awx' in settings.LOG_AGGREGATOR_LOGGERS: - if 'http_receiver' not in LOGGING_DICT['loggers']['awx']['handlers']: - LOGGING_DICT['loggers']['awx']['handlers'] += ['http_receiver'] - configure_logging(settings.LOGGING_CONFIG, LOGGING_DICT) - logger = logging.getLogger('awx.main.tasks') - - -@worker_ready.connect +@worker_process_init.connect def task_set_logger_pre_run(*args, **kwargs): cache.close() - if settings.LOG_AGGREGATOR_ENABLED: - _setup_tower_logger() - logger.debug('Custom Tower logger configured for worker process.') - - -def _uwsgi_reload(): - # http://uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html#available-commands - logger.warn('Initiating uWSGI chain reload of server') - TRIGGER_CHAIN_RELOAD = 'c' - with open('/var/lib/awx/awxfifo', 'w') as awxfifo: - awxfifo.write(TRIGGER_CHAIN_RELOAD) - - -def _reset_celery_logging(): - # Worker logger reloaded, now send signal to restart pool - app = current_app._get_current_object() - app.control.broadcast('pool_restart', arguments={'reload': True}, - destination=['celery@{}'.format(settings.CLUSTER_HOST_ID)], reply=False) + configure_external_logger(settings, async_flag=False, is_startup=False) def _clear_cache_keys(set_of_keys): @@ -136,8 +106,7 @@ def process_cache_changes(cache_keys): _clear_cache_keys(set_of_keys) for setting_key in set_of_keys: if setting_key.startswith('LOG_AGGREGATOR_'): - _uwsgi_reload() - _reset_celery_logging() + restart_local_services(['uwsgi', 'celery', 'beat', 'callback', 'fact']) break @@ -864,6 +833,7 @@ class RunJob(BaseTask): env['INVENTORY_ID'] = str(job.inventory.pk) if job.project: env['PROJECT_REVISION'] = job.project.scm_revision + env['ANSIBLE_RETRY_FILES_ENABLED'] = "False" env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_path env['ANSIBLE_STDOUT_CALLBACK'] = 'tower_display' env['REST_API_URL'] = settings.INTERNAL_API_URL @@ -1159,6 +1129,7 @@ class RunProjectUpdate(BaseTask): ''' Return SSH private key data needed for this project update. ''' + handle, self.revision_path = tempfile.mkstemp() private_data = {} if project_update.credential: credential = project_update.credential @@ -1247,9 +1218,9 @@ class RunProjectUpdate(BaseTask): 'scm_url': scm_url, 'scm_branch': scm_branch, 'scm_clean': project_update.scm_clean, - 'scm_delete_on_update': project_update.scm_delete_on_update, + 'scm_delete_on_update': project_update.scm_delete_on_update if project_update.job_type == 'check' else False, 'scm_full_checkout': True if project_update.job_type == 'run' else False, - 'scm_revision_output': '/tmp/_{}_syncrev'.format(project_update.id) # TODO: TempFile + 'scm_revision_output': self.revision_path }) args.extend(['-e', json.dumps(extra_vars)]) args.append('project_update.yml') @@ -1335,7 +1306,7 @@ class RunProjectUpdate(BaseTask): def post_run_hook(self, instance, status, **kwargs): if instance.job_type == 'check' and status not in ('failed', 'canceled',): p = instance.project - fd = open('/tmp/_{}_syncrev'.format(instance.id), 'r') + fd = open(self.revision_path, 'r') lines = fd.readlines() if lines: p.scm_revision = lines[0].strip() @@ -1343,6 +1314,10 @@ class RunProjectUpdate(BaseTask): p.save() else: logger.error("Could not find scm revision in check") + try: + os.remove(self.revision_path) + except Exception, e: + logger.error("Failed removing revision tmp file: {}".format(e)) class RunInventoryUpdate(BaseTask): diff --git a/awx/main/tests/functional/api/test_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index 500667e107..f08fd75d01 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -181,6 +181,29 @@ def test_scan_JT_counted(resourced_organization, user, get): assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict +@pytest.mark.django_db +def test_JT_not_double_counted(resourced_organization, user, get): + admin_user = user('admin', True) + # Add a scan job template to the org + resourced_organization.projects.all()[0].jobtemplates.create( + job_type='run', + inventory=resourced_organization.inventories.all()[0], + project=resourced_organization.projects.all()[0], + name='double-linked-job-template') + counts_dict = COUNTS_PRIMES + counts_dict['job_templates'] += 1 + + # Test list view + list_response = get(reverse('api:organization_list', args=[]), admin_user) + assert list_response.status_code == 200 + assert list_response.data['results'][0]['summary_fields']['related_field_counts'] == counts_dict + + # Test detail view + detail_response = get(reverse('api:organization_detail', args=[resourced_organization.pk]), admin_user) + assert detail_response.status_code == 200 + assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict + + @pytest.mark.django_db def test_JT_associated_with_project(organizations, project, user, get): # Check that adding a project to an organization gets the project's JT diff --git a/awx/main/tests/functional/api/test_resource_access_lists.py b/awx/main/tests/functional/api/test_resource_access_lists.py index 433c14a9c4..96806d4d72 100644 --- a/awx/main/tests/functional/api/test_resource_access_lists.py +++ b/awx/main/tests/functional/api/test_resource_access_lists.py @@ -7,53 +7,52 @@ from awx.main.models import Role @pytest.mark.django_db def test_indirect_access_list(get, organization, project, team_factory, user, admin): project_admin = user('project_admin') - org_admin_team_member = user('org_admin_team_member') project_admin_team_member = user('project_admin_team_member') - org_admin_team = team_factory('org-admin-team') + team_admin = user('team_admin') + project_admin_team = team_factory('project-admin-team') project.admin_role.members.add(project_admin) - org_admin_team.member_role.members.add(org_admin_team_member) - org_admin_team.member_role.children.add(organization.admin_role) project_admin_team.member_role.members.add(project_admin_team_member) project_admin_team.member_role.children.add(project.admin_role) + project_admin_team.admin_role.members.add(team_admin) + result = get(reverse('api:project_access_list', args=(project.id,)), admin) assert result.status_code == 200 # Result should be: # project_admin should have direct access, # project_team_admin should have "direct" access through being a team member -> project admin, - # org_admin_team_member should have indirect access through being a team member -> org admin -> project admin, + # team_admin should have direct access the same as the project_team_admin, # admin should have access through system admin -> org admin -> project admin assert result.data['count'] == 4 project_admin_res = [r for r in result.data['results'] if r['id'] == project_admin.id][0] - org_admin_team_member_res = [r for r in result.data['results'] if r['id'] == org_admin_team_member.id][0] + team_admin_res = [r for r in result.data['results'] if r['id'] == team_admin.id][0] project_admin_team_member_res = [r for r in result.data['results'] if r['id'] == project_admin_team_member.id][0] admin_res = [r for r in result.data['results'] if r['id'] == admin.id][0] assert len(project_admin_res['summary_fields']['direct_access']) == 1 assert len(project_admin_res['summary_fields']['indirect_access']) == 0 - assert len(org_admin_team_member_res['summary_fields']['direct_access']) == 0 - assert len(org_admin_team_member_res['summary_fields']['indirect_access']) == 1 + assert len(team_admin_res['summary_fields']['direct_access']) == 1 + assert len(team_admin_res['summary_fields']['indirect_access']) == 0 assert len(admin_res['summary_fields']['direct_access']) == 0 assert len(admin_res['summary_fields']['indirect_access']) == 1 project_admin_entry = project_admin_res['summary_fields']['direct_access'][0]['role'] assert project_admin_entry['id'] == project.admin_role.id + # assure that results for team admin are the same as for team member + team_admin_entry = team_admin_res['summary_fields']['direct_access'][0]['role'] + assert team_admin_entry['id'] == project.admin_role.id + assert team_admin_entry['name'] == 'Admin' project_admin_team_member_entry = project_admin_team_member_res['summary_fields']['direct_access'][0]['role'] assert project_admin_team_member_entry['id'] == project.admin_role.id assert project_admin_team_member_entry['team_id'] == project_admin_team.id assert project_admin_team_member_entry['team_name'] == project_admin_team.name - org_admin_team_member_entry = org_admin_team_member_res['summary_fields']['indirect_access'][0]['role'] - assert org_admin_team_member_entry['id'] == organization.admin_role.id - assert org_admin_team_member_entry['team_id'] == org_admin_team.id - assert org_admin_team_member_entry['team_name'] == org_admin_team.name - admin_entry = admin_res['summary_fields']['indirect_access'][0]['role'] assert admin_entry['name'] == Role.singleton('system_administrator').name diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index 5753841bd2..6322f354e7 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -30,6 +30,7 @@ def test_license_cannot_be_removed_via_system_settings(mock_no_license_file, get url = reverse('api:setting_singleton_detail', args=('system',)) response = get(url, user=admin, expect=200) assert not response.data['LICENSE'] + Setting.objects.create(key='TOWER_URL_BASE', value='https://towerhost') Setting.objects.create(key='LICENSE', value=enterprise_license) response = get(url, user=admin, expect=200) assert response.data['LICENSE'] @@ -44,6 +45,13 @@ def test_license_cannot_be_removed_via_system_settings(mock_no_license_file, get assert response.data['LICENSE'] +@pytest.mark.django_db +def test_url_base_defaults_to_request(options, admin): + # If TOWER_URL_BASE is not set, default to the Tower request hostname + resp = options(reverse('api:setting_singleton_detail', args=('system',)), user=admin, expect=200) + assert resp.data['actions']['PUT']['TOWER_URL_BASE']['default'] == 'http://testserver' + + @pytest.mark.django_db def test_jobs_settings(get, put, patch, delete, admin): url = reverse('api:setting_singleton_detail', args=('jobs',)) diff --git a/awx/main/tests/functional/api/test_unified_job_template.py b/awx/main/tests/functional/api/test_unified_job_template.py new file mode 100644 index 0000000000..695bd51d23 --- /dev/null +++ b/awx/main/tests/functional/api/test_unified_job_template.py @@ -0,0 +1,11 @@ +import pytest + +from django.core.urlresolvers import reverse + + +@pytest.mark.django_db +def test_aliased_forward_reverse_field_searches(instance, options, get, admin): + url = reverse('api:unified_job_template_list') + response = options(url, None, admin) + assert 'job_template__search' in response.data['related_search_fields'] + get(reverse("api:unified_job_template_list") + "?job_template__search=anything", user=admin, expect=200) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 165dfed0d6..3d79ca4c4c 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -56,15 +56,6 @@ def clear_cache(): cache.clear() -@pytest.fixture(scope="session", autouse=False) -def disable_signals(): - ''' - Disable all django model signals. - ''' - mocked = mock.patch('django.dispatch.Signal.send', autospec=True) - mocked.start() - - @pytest.fixture(scope="session", autouse=True) def celery_memory_broker(): ''' diff --git a/awx/main/tests/functional/models/test_activity_stream.py b/awx/main/tests/functional/models/test_activity_stream.py new file mode 100644 index 0000000000..b6e63a4377 --- /dev/null +++ b/awx/main/tests/functional/models/test_activity_stream.py @@ -0,0 +1,13 @@ +import pytest + +# AWX models +from awx.main.models.organization import Organization +from awx.main.models import ActivityStream + + + +@pytest.mark.django_db +def test_activity_stream_create_entries(): + Organization.objects.create(name='test-organization2') + assert ActivityStream.objects.filter(organization__isnull=False).count() == 1 + diff --git a/awx/main/tests/functional/models/test_context_managers.py b/awx/main/tests/functional/models/test_context_managers.py new file mode 100644 index 0000000000..61aad54ad4 --- /dev/null +++ b/awx/main/tests/functional/models/test_context_managers.py @@ -0,0 +1,47 @@ +import pytest + +# AWX context managers for testing +from awx.main.models.rbac import batch_role_ancestor_rebuilding +from awx.main.signals import ( + disable_activity_stream, + disable_computed_fields, + update_inventory_computed_fields +) + +# AWX models +from awx.main.models.organization import Organization +from awx.main.models import ActivityStream, Job + + +@pytest.mark.django_db +def test_rbac_batch_rebuilding(rando, organization): + with batch_role_ancestor_rebuilding(): + organization.admin_role.members.add(rando) + inventory = organization.inventories.create(name='test-inventory') + assert rando not in inventory.admin_role + assert rando in inventory.admin_role + + +@pytest.mark.django_db +def test_disable_activity_stream(): + with disable_activity_stream(): + Organization.objects.create(name='test-organization') + assert ActivityStream.objects.filter(organization__isnull=False).count() == 0 + + +@pytest.mark.django_db +class TestComputedFields: + + def test_computed_fields_normal_use(self, mocker, inventory): + job = Job.objects.create(name='fake-job', inventory=inventory) + with mocker.patch.object(update_inventory_computed_fields, 'delay'): + job.delete() + update_inventory_computed_fields.delay.assert_called_once_with(inventory.id, True) + + def test_disable_computed_fields(self, mocker, inventory): + job = Job.objects.create(name='fake-job', inventory=inventory) + with disable_computed_fields(): + with mocker.patch.object(update_inventory_computed_fields, 'delay'): + job.delete() + update_inventory_computed_fields.delay.assert_not_called() + diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py index 870f9f034a..4d19e4191e 100644 --- a/awx/main/tests/functional/models/test_unified_job.py +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -1,5 +1,20 @@ import pytest +# Django +from django.contrib.contenttypes.models import ContentType + +# AWX +from awx.main.models import UnifiedJobTemplate, JobTemplate, WorkflowJobTemplate, Project + + +@pytest.mark.django_db +def test_subclass_types(rando): + assert set(UnifiedJobTemplate._submodels_with_roles()) == set([ + ContentType.objects.get_for_model(JobTemplate).id, + ContentType.objects.get_for_model(Project).id, + ContentType.objects.get_for_model(WorkflowJobTemplate).id + ]) + class TestCreateUnifiedJob: ''' diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index b68003f049..6f26cacc54 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -5,13 +5,15 @@ from awx.main.models import ( Permission, Host, CustomInventoryScript, + Schedule ) from awx.main.access import ( InventoryAccess, InventorySourceAccess, HostAccess, InventoryUpdateAccess, - CustomInventoryScriptAccess + CustomInventoryScriptAccess, + ScheduleAccess ) from django.apps import apps @@ -277,3 +279,14 @@ def test_inventory_source_credential_check(rando, inventory_source, credential): inventory_source.group.inventory.admin_role.members.add(rando) access = InventorySourceAccess(rando) assert not access.can_change(inventory_source, {'credential': credential}) + + +@pytest.mark.django_db +def test_inventory_source_org_admin_schedule_access(org_admin, inventory_source): + schedule = Schedule.objects.create( + unified_job_template=inventory_source, + rrule='DTSTART:20151117T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1') + access = ScheduleAccess(org_admin) + assert access.get_queryset() + assert access.can_read(schedule) + assert access.can_change(schedule, {'rrule': 'DTSTART:20151117T050000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=2'}) diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index f3868daeb7..ef615a09d6 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -227,11 +227,19 @@ def test_job_template_access_org_admin(jt_objects, rando): @pytest.mark.django_db -def test_orphan_JT_readable_by_system_auditor(job_template, system_auditor): - assert system_auditor.is_system_auditor - assert job_template.project is None - access = JobTemplateAccess(system_auditor) - assert access.can_read(job_template) +class TestOrphanJobTemplate: + + def test_orphan_JT_readable_by_system_auditor(self, job_template, system_auditor): + assert system_auditor.is_system_auditor + assert job_template.project is None + access = JobTemplateAccess(system_auditor) + assert access.can_read(job_template) + + def test_system_admin_orphan_capabilities(self, job_template, admin_user): + job_template.capabilities_cache = {'edit': False} + access = JobTemplateAccess(admin_user) + capabilities = access.get_user_capabilities(job_template, method_list=['edit']) + assert capabilities['edit'] @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 05f19740fe..80255da0d1 100644 --- a/awx/main/tests/functional/test_rbac_notifications.py +++ b/awx/main/tests/functional/test_rbac_notifications.py @@ -2,7 +2,8 @@ import pytest from awx.main.access import ( NotificationTemplateAccess, - NotificationAccess + NotificationAccess, + JobTemplateAccess ) @@ -119,6 +120,15 @@ def test_notification_access_system_admin(notification, admin): assert access.can_delete(notification) +@pytest.mark.django_db +def test_system_auditor_JT_attach(system_auditor, job_template, notification_template): + job_template.admin_role.members.add(system_auditor) + access = JobTemplateAccess(system_auditor) + assert not access.can_attach( + job_template, notification_template, 'notification_templates_success', + {'id': notification_template.id}) + + @pytest.mark.django_db def test_notification_access_org_admin(notification, org_admin): access = NotificationAccess(org_admin) diff --git a/awx/main/tests/functional/utils/__init__.py b/awx/main/tests/functional/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/tests/unit/api/test_filters.py b/awx/main/tests/unit/api/test_filters.py index 65356958bb..6570ada6f7 100644 --- a/awx/main/tests/unit/api/test_filters.py +++ b/awx/main/tests/unit/api/test_filters.py @@ -2,21 +2,25 @@ import pytest from rest_framework.exceptions import PermissionDenied from awx.api.filters import FieldLookupBackend -from awx.main.models import Credential, JobTemplate +from awx.main.models import (AdHocCommand, AuthToken, CustomInventoryScript, + Credential, Job, JobTemplate, SystemJob, + UnifiedJob, User, WorkflowJob, + WorkflowJobTemplate, WorkflowJobOptions) +from awx.main.models.jobs import JobOptions @pytest.mark.parametrize(u"empty_value", [u'', '']) def test_empty_in(empty_value): field_lookup = FieldLookupBackend() with pytest.raises(ValueError) as excinfo: - field_lookup.value_to_python(JobTemplate, 'project__in', empty_value) + field_lookup.value_to_python(JobTemplate, 'project__name__in', empty_value) assert 'empty value for __in' in str(excinfo.value) @pytest.mark.parametrize(u"valid_value", [u'foo', u'foo,']) def test_valid_in(valid_value): field_lookup = FieldLookupBackend() - value, new_lookup = field_lookup.value_to_python(JobTemplate, 'project__in', valid_value) + value, new_lookup = field_lookup.value_to_python(JobTemplate, 'project__name__in', valid_value) assert 'foo' in value @@ -38,3 +42,28 @@ def test_filter_on_related_password_field(password_field, lookup_suffix): with pytest.raises(PermissionDenied) as excinfo: field, new_lookup = field_lookup.get_field_from_lookup(JobTemplate, lookup) assert 'not allowed' in str(excinfo.value) + + +@pytest.mark.parametrize('model, query', [ + (AuthToken, 'request_hash__icontains'), + (User, 'password__icontains'), + (User, 'auth_tokens__key__icontains'), + (User, 'settings__value__icontains'), + (UnifiedJob, 'job_args__icontains'), + (UnifiedJob, 'job_env__icontains'), + (UnifiedJob, 'start_args__icontains'), + (AdHocCommand, 'extra_vars__icontains'), + (JobOptions, 'extra_vars__icontains'), + (SystemJob, 'extra_vars__icontains'), + (WorkflowJobOptions, 'extra_vars__icontains'), + (Job, 'survey_passwords__icontains'), + (WorkflowJob, 'survey_passwords__icontains'), + (JobTemplate, 'survey_spec__icontains'), + (WorkflowJobTemplate, 'survey_spec__icontains'), + (CustomInventoryScript, 'script__icontains') +]) +def test_filter_sensitive_fields_and_relations(model, query): + field_lookup = FieldLookupBackend() + with pytest.raises(PermissionDenied) as excinfo: + field, new_lookup = field_lookup.get_field_from_lookup(model, query) + assert 'not allowed' in str(excinfo.value) diff --git a/awx/main/tests/unit/scheduler/conftest.py b/awx/main/tests/unit/scheduler/conftest.py index 40e221d0cc..8f3c5f913e 100644 --- a/awx/main/tests/unit/scheduler/conftest.py +++ b/awx/main/tests/unit/scheduler/conftest.py @@ -223,7 +223,8 @@ def job_factory(epoch): 'celery_task_id': '', 'project__scm_update_on_launch': project__scm_update_on_launch, 'inventory__inventory_sources': inventory__inventory_sources, - 'forks': 5 + 'forks': 5, + 'dependent_jobs__id': None, }) return fn diff --git a/awx/main/tests/unit/scheduler/test_dependency_graph.py b/awx/main/tests/unit/scheduler/test_dependency_graph.py index c1c92411ea..ca74ec9bf7 100644 --- a/awx/main/tests/unit/scheduler/test_dependency_graph.py +++ b/awx/main/tests/unit/scheduler/test_dependency_graph.py @@ -17,8 +17,10 @@ def graph(): @pytest.fixture -def job(): - return dict(project_id=1) +def job(job_factory): + j = job_factory() + j.project_id = 1 + return j @pytest.fixture @@ -36,13 +38,11 @@ def unsuccessful_last_project(graph, job): @pytest.fixture -def last_dependent_project(graph): +def last_dependent_project(graph, job): now = tz_now() - job = { - 'project_id': 1, - 'created': now, - } + job['project_id'] = 1 + job['created'] = now pu = ProjectUpdateDict(dict(id=1, project_id=1, status='waiting', project__scm_update_cache_timeout=0, launch_type='dependency', @@ -57,10 +57,8 @@ def last_dependent_project(graph): def timedout_project_update(graph, job): now = tz_now() - job = { - 'project_id': 1, - 'created': now, - } + job['project_id'] = 1 + job['created'] = now pu = ProjectUpdateDict(dict(id=1, project_id=1, status='successful', project__scm_update_cache_timeout=10, launch_type='dependency', @@ -76,10 +74,8 @@ def timedout_project_update(graph, job): def not_timedout_project_update(graph, job): now = tz_now() - job = { - 'project_id': 1, - 'created': now, - } + job['project_id'] = 1 + job['created'] = now pu = ProjectUpdateDict(dict(id=1, project_id=1, status='successful', project__scm_update_cache_timeout=3600, launch_type='dependency', diff --git a/awx/main/tests/unit/utils/__init__.py b/awx/main/tests/unit/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/tests/unit/common/test_common.py b/awx/main/tests/unit/utils/common/test_common.py similarity index 51% rename from awx/main/tests/unit/common/test_common.py rename to awx/main/tests/unit/utils/common/test_common.py index a152d69c12..6542d64cf0 100644 --- a/awx/main/tests/unit/common/test_common.py +++ b/awx/main/tests/unit/utils/common/test_common.py @@ -1,24 +1,45 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Ansible, Inc. +# All Rights Reserved. + from awx.conf.models import Setting from awx.main.utils import common def test_encrypt_field(): field = Setting(pk=123, value='ANSIBLE') - encrypted = common.encrypt_field(field, 'value') + encrypted = field.value = common.encrypt_field(field, 'value') assert encrypted == '$encrypted$AES$Ey83gcmMuBBT1OEq2lepnw==' assert common.decrypt_field(field, 'value') == 'ANSIBLE' def test_encrypt_field_without_pk(): field = Setting(value='ANSIBLE') - encrypted = common.encrypt_field(field, 'value') + encrypted = field.value = common.encrypt_field(field, 'value') assert encrypted == '$encrypted$AES$8uIzEoGyY6QJwoTWbMFGhw==' assert common.decrypt_field(field, 'value') == 'ANSIBLE' +def test_encrypt_field_with_unicode_string(): + value = u'Iñtërnâtiônàlizætiøn' + field = Setting(value=value) + encrypted = field.value = common.encrypt_field(field, 'value') + assert encrypted == '$encrypted$UTF8$AES$AESQbqOefpYcLC7x8yZ2aWG4FlXlS66JgavLbDp/DSM=' + assert common.decrypt_field(field, 'value') == value + + +def test_encrypt_field_force_disable_unicode(): + value = u"NothingSpecial" + field = Setting(value=value) + encrypted = field.value = common.encrypt_field(field, 'value', skip_utf8=True) + assert "UTF8" not in encrypted + assert common.decrypt_field(field, 'value') == value + + def test_encrypt_subfield(): field = Setting(value={'name': 'ANSIBLE'}) - encrypted = common.encrypt_field(field, 'value', subfield='name') + encrypted = field.value = common.encrypt_field(field, 'value', subfield='name') assert encrypted == '$encrypted$AES$8uIzEoGyY6QJwoTWbMFGhw==' assert common.decrypt_field(field, 'value', subfield='name') == 'ANSIBLE' diff --git a/awx/main/tests/unit/utils/test_handlers.py b/awx/main/tests/unit/utils/test_handlers.py new file mode 100644 index 0000000000..3de3b2e7b7 --- /dev/null +++ b/awx/main/tests/unit/utils/test_handlers.py @@ -0,0 +1,234 @@ +import base64 +import json +import logging + +from django.conf import LazySettings +import pytest +import requests +from requests_futures.sessions import FuturesSession + +from awx.main.utils.handlers import BaseHTTPSHandler as HTTPSHandler, PARAM_NAMES +from awx.main.utils.formatters import LogstashFormatter + + +@pytest.fixture() +def dummy_log_record(): + return logging.LogRecord( + 'awx', # logger name + 20, # loglevel INFO + './awx/some/module.py', # pathname + 100, # lineno + 'User joe logged in', # msg + tuple(), # args, + None # exc_info + ) + + +@pytest.fixture() +def ok200_adapter(): + class OK200Adapter(requests.adapters.HTTPAdapter): + requests = [] + + def send(self, request, **kwargs): + self.requests.append(request) + resp = requests.models.Response() + resp.status_code = 200 + resp.raw = '200 OK' + resp.request = request + return resp + + return OK200Adapter() + + +def test_https_logging_handler_requests_sync_implementation(): + handler = HTTPSHandler(async=False) + assert not isinstance(handler.session, FuturesSession) + assert isinstance(handler.session, requests.Session) + + +def test_https_logging_handler_requests_async_implementation(): + handler = HTTPSHandler(async=True) + assert isinstance(handler.session, FuturesSession) + + +@pytest.mark.parametrize('param', PARAM_NAMES.keys()) +def test_https_logging_handler_defaults(param): + handler = HTTPSHandler() + assert hasattr(handler, param) and getattr(handler, param) is None + + +@pytest.mark.parametrize('param', PARAM_NAMES.keys()) +def test_https_logging_handler_kwargs(param): + handler = HTTPSHandler(**{param: 'EXAMPLE'}) + assert hasattr(handler, param) and getattr(handler, param) == 'EXAMPLE' + + +@pytest.mark.parametrize('param, django_settings_name', PARAM_NAMES.items()) +def test_https_logging_handler_from_django_settings(param, django_settings_name): + settings = LazySettings() + settings.configure(**{ + django_settings_name: 'EXAMPLE' + }) + handler = HTTPSHandler.from_django_settings(settings) + assert hasattr(handler, param) and getattr(handler, param) == 'EXAMPLE' + + +def test_https_logging_handler_logstash_auth_info(): + handler = HTTPSHandler(message_type='logstash', username='bob', password='ansible') + handler.add_auth_information() + assert isinstance(handler.session.auth, requests.auth.HTTPBasicAuth) + assert handler.session.auth.username == 'bob' + assert handler.session.auth.password == 'ansible' + + +def test_https_logging_handler_splunk_auth_info(): + handler = HTTPSHandler(message_type='splunk', password='ansible') + handler.add_auth_information() + assert handler.session.headers['Authorization'] == 'Splunk ansible' + assert handler.session.headers['Content-Type'] == 'application/json' + + +@pytest.mark.parametrize('host, port, normalized', [ + ('localhost', None, 'http://localhost'), + ('localhost', 80, 'http://localhost'), + ('localhost', 8080, 'http://localhost:8080'), + ('http://localhost', None, 'http://localhost'), + ('http://localhost', 80, 'http://localhost'), + ('http://localhost', 8080, 'http://localhost:8080'), + ('https://localhost', 443, 'https://localhost:443') +]) +def test_https_logging_handler_http_host_format(host, port, normalized): + handler = HTTPSHandler(host=host, port=port) + assert handler.get_http_host() == normalized + + +@pytest.mark.parametrize('params, logger_name, expected', [ + ({'enabled_flag': False}, 'awx.main', True), # skip all records if enabled_flag = False + ({'host': '', 'enabled_flag': True}, 'awx.main', True), # skip all records if the host is undefined + ({'host': '127.0.0.1', 'enabled_flag': True}, 'awx.main', False), + ({'host': '127.0.0.1', 'enabled_flag': True, 'enabled_loggers': ['abc']}, 'awx.analytics.xyz', True), + ({'host': '127.0.0.1', 'enabled_flag': True, 'enabled_loggers': ['xyz']}, 'awx.analytics.xyz', False), +]) +def test_https_logging_handler_skip_log(params, logger_name, expected): + handler = HTTPSHandler(**params) + assert handler.skip_log(logger_name) is expected + + +@pytest.mark.parametrize('message_type, async', [ + ('logstash', False), + ('logstash', True), + ('splunk', False), + ('splunk', True), +]) +def test_https_logging_handler_emit(ok200_adapter, dummy_log_record, + message_type, async): + handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True, + message_type=message_type, + enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'], + async=async) + handler.setFormatter(LogstashFormatter()) + handler.session.mount('http://', ok200_adapter) + async_futures = handler.emit(dummy_log_record) + [future.result() for future in async_futures] + + assert len(ok200_adapter.requests) == 1 + request = ok200_adapter.requests[0] + assert request.url == 'http://127.0.0.1/' + assert request.method == 'POST' + body = json.loads(request.body) + + if message_type == 'logstash': + # A username + password weren't used, so this header should be missing + assert 'Authorization' not in request.headers + + if message_type == 'splunk': + # splunk messages are nested under the 'event' key + body = body['event'] + assert request.headers['Authorization'] == 'Splunk None' + + assert body['level'] == 'INFO' + assert body['logger_name'] == 'awx' + assert body['message'] == 'User joe logged in' + + +@pytest.mark.parametrize('async', (True, False)) +def test_https_logging_handler_emit_logstash_with_creds(ok200_adapter, + dummy_log_record, async): + handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True, + username='user', password='pass', + message_type='logstash', + enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'], + async=async) + handler.setFormatter(LogstashFormatter()) + handler.session.mount('http://', ok200_adapter) + async_futures = handler.emit(dummy_log_record) + [future.result() for future in async_futures] + + assert len(ok200_adapter.requests) == 1 + request = ok200_adapter.requests[0] + assert request.headers['Authorization'] == 'Basic %s' % base64.b64encode("user:pass") + + +@pytest.mark.parametrize('async', (True, False)) +def test_https_logging_handler_emit_splunk_with_creds(ok200_adapter, + dummy_log_record, async): + handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True, + password='pass', message_type='splunk', + enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'], + async=async) + handler.setFormatter(LogstashFormatter()) + handler.session.mount('http://', ok200_adapter) + async_futures = handler.emit(dummy_log_record) + [future.result() for future in async_futures] + + assert len(ok200_adapter.requests) == 1 + request = ok200_adapter.requests[0] + assert request.headers['Authorization'] == 'Splunk pass' + + +def test_https_logging_handler_emit_one_record_per_fact(ok200_adapter): + handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True, + message_type='logstash', indv_facts=True, + enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking']) + handler.setFormatter(LogstashFormatter()) + handler.session.mount('http://', ok200_adapter) + record = logging.LogRecord( + 'awx.analytics.system_tracking', # logger name + 20, # loglevel INFO + './awx/some/module.py', # pathname + 100, # lineno + None, # msg + tuple(), # args, + None # exc_info + ) + record.module_name = 'packages' + record.facts_data = [{ + "name": "ansible", + "version": "2.2.1.0" + }, { + "name": "ansible-tower", + "version": "3.1.0" + }] + async_futures = handler.emit(record) + [future.result() for future in async_futures] + + assert len(ok200_adapter.requests) == 2 + requests = sorted(ok200_adapter.requests, key=lambda request: json.loads(request.body)['version']) + + request = requests[0] + assert request.url == 'http://127.0.0.1/' + assert request.method == 'POST' + body = json.loads(request.body) + assert body['level'] == 'INFO' + assert body['logger_name'] == 'awx.analytics.system_tracking' + assert body['name'] == 'ansible' + assert body['version'] == '2.2.1.0' + + request = requests[1] + assert request.url == 'http://127.0.0.1/' + assert request.method == 'POST' + body = json.loads(request.body) + assert body['level'] == 'INFO' + assert body['logger_name'] == 'awx.analytics.system_tracking' + assert body['name'] == 'ansible-tower' + assert body['version'] == '3.1.0' diff --git a/awx/main/tests/unit/utils/test_reload.py b/awx/main/tests/unit/utils/test_reload.py new file mode 100644 index 0000000000..d1f3291753 --- /dev/null +++ b/awx/main/tests/unit/utils/test_reload.py @@ -0,0 +1,38 @@ +# awx.main.utils.reload +from awx.main.utils import reload + + +def test_produce_supervisor_command(mocker): + with mocker.patch.object(reload.subprocess, 'Popen'): + reload._supervisor_service_restart(['beat', 'callback', 'fact']) + reload.subprocess.Popen.assert_called_once_with( + ['supervisorctl', 'restart', 'tower-processes:receiver', 'tower-processes:factcacher']) + + +def test_routing_of_service_restarts_works(mocker): + ''' + This tests that the parent restart method will call the appropriate + service restart methods, depending on which services are given in args + ''' + with mocker.patch.object(reload, '_uwsgi_reload'),\ + mocker.patch.object(reload, '_reset_celery_thread_pool'),\ + mocker.patch.object(reload, '_supervisor_service_restart'): + reload.restart_local_services(['uwsgi', 'celery', 'flower', 'daphne']) + reload._uwsgi_reload.assert_called_once_with() + reload._reset_celery_thread_pool.assert_called_once_with() + reload._supervisor_service_restart.assert_called_once_with(['flower', 'daphne']) + + + +def test_routing_of_service_restarts_diables(mocker): + ''' + Test that methods are not called if not in the args + ''' + with mocker.patch.object(reload, '_uwsgi_reload'),\ + mocker.patch.object(reload, '_reset_celery_thread_pool'),\ + mocker.patch.object(reload, '_supervisor_service_restart'): + reload.restart_local_services(['flower']) + reload._uwsgi_reload.assert_not_called() + reload._reset_celery_thread_pool.assert_not_called() + reload._supervisor_service_restart.assert_called_once_with(['flower']) + diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 5842b78db9..49d92b5f9c 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -21,6 +21,8 @@ import tempfile # Decorator from decorator import decorator +import six + # Django from django.utils.translation import ugettext_lazy as _ from django.db.models import ManyToManyField @@ -181,7 +183,7 @@ def get_encryption_key(field_name, pk=None): return h.digest()[:16] -def encrypt_field(instance, field_name, ask=False, subfield=None): +def encrypt_field(instance, field_name, ask=False, subfield=None, skip_utf8=False): ''' Return content of the given instance and field name encrypted. ''' @@ -190,6 +192,10 @@ def encrypt_field(instance, field_name, ask=False, subfield=None): value = value[subfield] if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'): return value + if skip_utf8: + utf8 = False + else: + utf8 = type(value) == six.text_type value = smart_str(value) key = get_encryption_key(field_name, getattr(instance, 'pk', None)) cipher = AES.new(key, AES.MODE_ECB) @@ -197,17 +203,31 @@ def encrypt_field(instance, field_name, ask=False, subfield=None): value += '\x00' encrypted = cipher.encrypt(value) b64data = base64.b64encode(encrypted) - return '$encrypted$%s$%s' % ('AES', b64data) + tokens = ['$encrypted', 'AES', b64data] + if utf8: + # If the value to encrypt is utf-8, we need to add a marker so we + # know to decode the data when it's decrypted later + tokens.insert(1, 'UTF8') + return '$'.join(tokens) def decrypt_value(encryption_key, value): - algo, b64data = value[len('$encrypted$'):].split('$', 1) + raw_data = value[len('$encrypted$'):] + # If the encrypted string contains a UTF8 marker, discard it + utf8 = raw_data.startswith('UTF8$') + if utf8: + raw_data = raw_data[len('UTF8$'):] + algo, b64data = raw_data.split('$', 1) if algo != 'AES': raise ValueError('unsupported algorithm: %s' % algo) encrypted = base64.b64decode(b64data) cipher = AES.new(encryption_key, AES.MODE_ECB) value = cipher.decrypt(encrypted) - return value.rstrip('\x00') + value = value.rstrip('\x00') + # If the encrypted string contained a UTF8 marker, decode the data + if utf8: + value = value.decode('utf-8') + return value def decrypt_field(instance, field_name, subfield=None): diff --git a/awx/main/utils/db.py b/awx/main/utils/db.py new file mode 100644 index 0000000000..f9c625a7a1 --- /dev/null +++ b/awx/main/utils/db.py @@ -0,0 +1,22 @@ +# Copyright (c) 2017 Ansible by Red Hat +# All Rights Reserved. + +# Django database +from django.db.migrations.loader import MigrationLoader +from django.db import connection + +# Python +import re + + +def get_tower_migration_version(): + loader = MigrationLoader(connection, ignore_no_migrations=True) + v = '000' + for app_name, migration_name in loader.applied_migrations: + if app_name == 'main': + version_captures = re.findall('^[0-9]{4}_v([0-9]{3})_', migration_name) + if len(version_captures) == 1: + migration_version = version_captures[0] + if migration_version > v: + v = migration_version + return v diff --git a/awx/main/utils/formatters.py b/awx/main/utils/formatters.py index 94f9b5c0f0..68d0917985 100644 --- a/awx/main/utils/formatters.py +++ b/awx/main/utils/formatters.py @@ -2,7 +2,6 @@ # All Rights Reserved. from logstash.formatter import LogstashFormatterVersion1 -from django.conf import settings from copy import copy import json import time @@ -10,8 +9,11 @@ import time class LogstashFormatter(LogstashFormatterVersion1): def __init__(self, **kwargs): + settings_module = kwargs.pop('settings_module', None) ret = super(LogstashFormatter, self).__init__(**kwargs) - self.host_id = settings.CLUSTER_HOST_ID + if settings_module: + self.host_id = settings_module.CLUSTER_HOST_ID + self.tower_uuid = settings_module.LOG_AGGREGATOR_TOWER_UUID return ret def reformat_data_for_log(self, raw_data, kind=None): @@ -56,6 +58,21 @@ class LogstashFormatter(LogstashFormatterVersion1): adict[name] = subdict return adict + def convert_to_type(t, val): + if t is float: + val = val[:-1] if val.endswith('s') else val + try: + return float(val) + except ValueError: + return val + elif t is int: + try: + return int(val) + except ValueError: + return val + elif t is str: + return val + if kind == 'job_events': data.update(data.get('event_data', {})) for fd in data: @@ -81,12 +98,35 @@ class LogstashFormatter(LogstashFormatterVersion1): else: data_for_log['facts'] = data data_for_log['module_name'] = module_name + elif kind == 'performance': + request = raw_data['python_objects']['request'] + response = raw_data['python_objects']['response'] + + # Note: All of the below keys may not be in the response "dict" + # For example, X-API-Query-Time and X-API-Query-Count will only + # exist if SQL_DEBUG is turned on in settings. + headers = [ + (float, 'X-API-Time'), # may end with an 's' "0.33s" + (int, 'X-API-Query-Count'), + (float, 'X-API-Query-Time'), # may also end with an 's' + (str, 'X-API-Node'), + ] + data_for_log['x_api'] = {k: convert_to_type(t, response[k]) for (t, k) in headers if k in response} + + data_for_log['request'] = { + 'method': request.method, + 'path': request.path, + 'path_info': request.path_info, + 'query_string': request.META['QUERY_STRING'], + 'data': request.data, + } + return data_for_log def get_extra_fields(self, record): fields = super(LogstashFormatter, self).get_extra_fields(record) if record.name.startswith('awx.analytics'): - log_kind = record.name.split('.')[-1] + log_kind = record.name[len('awx.analytics.'):] fields = self.reformat_data_for_log(fields, kind=log_kind) return fields @@ -104,9 +144,13 @@ class LogstashFormatter(LogstashFormatterVersion1): # Extra Fields 'level': record.levelname, 'logger_name': record.name, - 'cluster_host_id': self.host_id } + if getattr(self, 'tower_uuid', None): + message['tower_uuid'] = self.tower_uuid + if getattr(self, 'host_id', None): + message['cluster_host_id'] = self.host_id + # Add extra fields message.update(self.get_extra_fields(record)) diff --git a/awx/main/utils/handlers.py b/awx/main/utils/handlers.py index 71176cbb1a..fe2fb87228 100644 --- a/awx/main/utils/handlers.py +++ b/awx/main/utils/handlers.py @@ -12,9 +12,11 @@ import traceback from requests_futures.sessions import FuturesSession -# custom -from django.conf import settings as django_settings -from django.utils.log import NullHandler +# AWX +from awx.main.utils.formatters import LogstashFormatter + + +__all__ = ['HTTPSNullHandler', 'BaseHTTPSHandler', 'configure_external_logger'] # AWX external logging handler, generally designed to be used # with the accompanying LogstashHandler, derives from python-logstash library @@ -38,31 +40,32 @@ def unused_callback(sess, resp): pass -class HTTPSNullHandler(NullHandler): +class HTTPSNullHandler(logging.NullHandler): "Placeholder null handler to allow loading without database access" - def __init__(self, host, **kwargs): + def __init__(self, *args, **kwargs): return super(HTTPSNullHandler, self).__init__() -class HTTPSHandler(logging.Handler): +class BaseHTTPSHandler(logging.Handler): def __init__(self, fqdn=False, **kwargs): - super(HTTPSHandler, self).__init__() + super(BaseHTTPSHandler, self).__init__() self.fqdn = fqdn self.async = kwargs.get('async', True) for fd in PARAM_NAMES: - # settings values take precedence over the input params - settings_name = PARAM_NAMES[fd] - settings_val = getattr(django_settings, settings_name, None) - if settings_val: - setattr(self, fd, settings_val) - elif fd in kwargs: - setattr(self, fd, kwargs[fd]) - else: - setattr(self, fd, None) - self.session = FuturesSession() + setattr(self, fd, kwargs.get(fd, None)) + if self.async: + self.session = FuturesSession() + else: + self.session = requests.Session() self.add_auth_information() + @classmethod + def from_django_settings(cls, settings, *args, **kwargs): + for param, django_setting_name in PARAM_NAMES.items(): + kwargs[param] = getattr(settings, django_setting_name, None) + return cls(*args, **kwargs) + def get_full_message(self, record): if record.exc_info: return '\n'.join(traceback.format_exception(*record.exc_info)) @@ -85,7 +88,7 @@ class HTTPSHandler(logging.Handler): self.session.headers.update(headers) def get_http_host(self): - host = self.host + host = self.host or '' if not host.startswith('http'): host = 'http://%s' % self.host if self.port != 80 and self.port is not None: @@ -113,14 +116,25 @@ class HTTPSHandler(logging.Handler): if not logger_name.startswith('awx.analytics'): # Tower log emission is only turned off by enablement setting return False - return self.enabled_loggers is None or logger_name.split('.')[-1] not in self.enabled_loggers + return self.enabled_loggers is None or logger_name[len('awx.analytics.'):] not in self.enabled_loggers def emit(self, record): + """ + Emit a log record. Returns a list of zero or more + ``concurrent.futures.Future`` objects. + + When ``self.async`` is True, the list will contain one + Future object for each HTTP request made. When ``self.async`` is + False, the list will be empty. + + See: + https://docs.python.org/3/library/concurrent.futures.html#future-objects + http://pythonhosted.org/futures/ + """ if self.skip_log(record.name): - return + return [] try: payload = self.format(record) - host = self.get_http_host() # Special action for System Tracking, queue up multiple log messages if self.indv_facts: @@ -129,18 +143,56 @@ class HTTPSHandler(logging.Handler): module_name = payload_data['module_name'] if module_name in ['services', 'packages', 'files']: facts_dict = payload_data.pop(module_name) + async_futures = [] for key in facts_dict: fact_payload = copy(payload_data) fact_payload.update(facts_dict[key]) - self.session.post(host, **self.get_post_kwargs(fact_payload)) - return + if self.async: + async_futures.append(self._send(fact_payload)) + else: + self._send(fact_payload) + return async_futures if self.async: - self.session.post(host, **self.get_post_kwargs(payload)) - else: - requests.post(host, auth=requests.auth.HTTPBasicAuth(self.username, self.password), **self.get_post_kwargs(payload)) + return [self._send(payload)] + + self._send(payload) + return [] except (KeyboardInterrupt, SystemExit): raise except: self.handleError(record) + def _send(self, payload): + return self.session.post(self.get_http_host(), + **self.get_post_kwargs(payload)) + + +def add_or_remove_logger(address, instance): + specific_logger = logging.getLogger(address) + for i, handler in enumerate(specific_logger.handlers): + if isinstance(handler, (HTTPSNullHandler, BaseHTTPSHandler)): + specific_logger.handlers[i] = instance or HTTPSNullHandler() + break + else: + if instance is not None: + specific_logger.handlers.append(instance) + + +def configure_external_logger(settings_module, async_flag=True, is_startup=True): + + is_enabled = settings_module.LOG_AGGREGATOR_ENABLED + if is_startup and (not is_enabled): + # Pass-through if external logging not being used + return + + instance = None + if is_enabled: + instance = BaseHTTPSHandler.from_django_settings(settings_module, async=async_flag) + instance.setFormatter(LogstashFormatter(settings_module=settings_module)) + awx_logger_instance = instance + if is_enabled and 'awx' not in settings_module.LOG_AGGREGATOR_LOGGERS: + awx_logger_instance = None + + add_or_remove_logger('awx.analytics', instance) + add_or_remove_logger('awx', awx_logger_instance) diff --git a/awx/main/utils/reload.py b/awx/main/utils/reload.py new file mode 100644 index 0000000000..729a33a703 --- /dev/null +++ b/awx/main/utils/reload.py @@ -0,0 +1,68 @@ +# Copyright (c) 2017 Ansible Tower by Red Hat +# All Rights Reserved. + +# Python +import subprocess +import logging + +# Django +from django.conf import settings + +# Celery +from celery import current_app + +logger = logging.getLogger('awx.main.utils.reload') + + +def _uwsgi_reload(): + # http://uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html#available-commands + logger.warn('Initiating uWSGI chain reload of server') + TRIGGER_CHAIN_RELOAD = 'c' + with open(settings.UWSGI_FIFO_LOCATION, 'w') as awxfifo: + awxfifo.write(TRIGGER_CHAIN_RELOAD) + + +def _reset_celery_thread_pool(): + # Send signal to restart thread pool + app = current_app._get_current_object() + app.control.broadcast('pool_restart', arguments={'reload': True}, + destination=['celery@{}'.format(settings.CLUSTER_HOST_ID)], reply=False) + + +def _supervisor_service_restart(service_internal_names): + ''' + Service internal name options: + - beat - celery - callback - channels - uwsgi - daphne + - fact - nginx + example use pattern of supervisorctl: + # supervisorctl restart tower-processes:receiver tower-processes:factcacher + ''' + group_name = 'tower-processes' + args = ['supervisorctl'] + if settings.DEBUG: + args.extend(['-c', '/supervisor.conf']) + programs = [] + name_translation_dict = settings.SERVICE_NAME_DICT + for n in service_internal_names: + if n in name_translation_dict: + programs.append('{}:{}'.format(group_name, name_translation_dict[n])) + args.extend(['restart']) + args.extend(programs) + logger.debug('Issuing command to restart services, args={}'.format(args)) + subprocess.Popen(args) + + +def restart_local_services(service_internal_names): + logger.warn('Restarting services {} on this node in response to user action'.format(service_internal_names)) + if 'uwsgi' in service_internal_names: + _uwsgi_reload() + service_internal_names.remove('uwsgi') + restart_celery = False + if 'celery' in service_internal_names: + restart_celery = True + service_internal_names.remove('celery') + _supervisor_service_restart(service_internal_names) + if restart_celery: + # Celery restarted last because this probably includes current process + _reset_celery_thread_pool() + diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index cbe05ae83b..090e939914 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -431,7 +431,8 @@ CELERY_ROUTES = {'awx.main.tasks.run_job': {'queue': 'jobs', CELERYBEAT_SCHEDULE = { 'tower_scheduler': { 'task': 'awx.main.tasks.tower_periodic_scheduler', - 'schedule': timedelta(seconds=30) + 'schedule': timedelta(seconds=30), + 'options': {'expires': 20,} }, 'admin_checks': { 'task': 'awx.main.tasks.run_administrative_checks', @@ -443,7 +444,8 @@ CELERYBEAT_SCHEDULE = { }, 'cluster_heartbeat': { 'task': 'awx.main.tasks.cluster_node_heartbeat', - 'schedule': timedelta(seconds=60) + 'schedule': timedelta(seconds=60), + 'options': {'expires': 50,} }, 'purge_stdout_files': { 'task': 'awx.main.tasks.purge_old_stdout_files', @@ -451,11 +453,13 @@ CELERYBEAT_SCHEDULE = { }, 'task_manager': { 'task': 'awx.main.scheduler.tasks.run_task_manager', - 'schedule': timedelta(seconds=20) + 'schedule': timedelta(seconds=20), + 'options': {'expires': 20,} }, 'task_fail_inconsistent_running_jobs': { 'task': 'awx.main.scheduler.tasks.run_fail_inconsistent_running_jobs', - 'schedule': timedelta(seconds=30) + 'schedule': timedelta(seconds=30), + 'options': {'expires': 20,} }, } @@ -893,16 +897,16 @@ LOGGING = { 'formatter': 'simple', }, 'null': { - 'class': 'django.utils.log.NullHandler', + 'class': 'logging.NullHandler', }, 'file': { - 'class': 'django.utils.log.NullHandler', + 'class': 'logging.NullHandler', 'formatter': 'simple', }, 'syslog': { 'level': 'WARNING', 'filters': ['require_debug_false'], - 'class': 'django.utils.log.NullHandler', + 'class': 'logging.NullHandler', 'formatter': 'simple', }, 'http_receiver': { diff --git a/awx/settings/development.py b/awx/settings/development.py index 1326c12814..0a0bc748f2 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -112,3 +112,15 @@ except ImportError: CLUSTER_HOST_ID = socket.gethostname() CELERY_ROUTES['awx.main.tasks.cluster_node_heartbeat'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} +# Supervisor service name dictionary used for programatic restart +SERVICE_NAME_DICT = { + "celery": "celeryd", + "callback": "receiver", + "runworker": "channels", + "uwsgi": "uwsgi", + "daphne": "daphne", + "fact": "factcacher", + "nginx": "nginx"} +# Used for sending commands in automatic restart +UWSGI_FIFO_LOCATION = '/awxfifo' + diff --git a/awx/settings/production.py b/awx/settings/production.py index f056a4ea31..19afcab9c9 100644 --- a/awx/settings/production.py +++ b/awx/settings/production.py @@ -57,6 +57,18 @@ LOGGING['handlers']['fact_receiver']['filename'] = '/var/log/tower/fact_receiver LOGGING['handlers']['system_tracking_migrations']['filename'] = '/var/log/tower/tower_system_tracking_migrations.log' LOGGING['handlers']['rbac_migrations']['filename'] = '/var/log/tower/tower_rbac_migrations.log' +# Supervisor service name dictionary used for programatic restart +SERVICE_NAME_DICT = { + "beat": "awx-celeryd-beat", + "celery": "awx-celeryd", + "callback": "awx-callback-receiver", + "channels": "awx-channels-worker", + "uwsgi": "awx-uwsgi", + "daphne": "awx-daphne", + "fact": "awx-fact-cache-receiver"} +# Used for sending commands in automatic restart +UWSGI_FIFO_LOCATION = '/var/lib/awx/awxfifo' + # Store a snapshot of default settings at this point before loading any # customizable config files. DEFAULTS_SNAPSHOT = {} diff --git a/awx/sso/middleware.py b/awx/sso/middleware.py index 012bcefd55..c678ff08f3 100644 --- a/awx/sso/middleware.py +++ b/awx/sso/middleware.py @@ -23,6 +23,10 @@ from awx.main.models import AuthToken class SocialAuthMiddleware(SocialAuthExceptionMiddleware): + def process_view(self, request, callback, callback_args, callback_kwargs): + if request.path.startswith('/sso/login/'): + request.session['social_auth_last_backend'] = callback_kwargs['backend'] + def process_request(self, request): token_key = request.COOKIES.get('token', '') token_key = urllib.quote(urllib.unquote(token_key).strip('"')) @@ -57,6 +61,7 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware): if auth_token and request.user and request.user.is_authenticated(): request.session.pop('social_auth_error', None) + request.session.pop('social_auth_last_backend', None) def process_exception(self, request, exception): strategy = getattr(request, 'social_strategy', None) @@ -66,6 +71,12 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware): if isinstance(exception, SocialAuthBaseException) or request.path.startswith('/sso/'): backend = getattr(request, 'backend', None) backend_name = getattr(backend, 'name', 'unknown-backend') + + message = self.get_message(request, exception) + if request.session.get('social_auth_last_backend') != backend_name: + backend_name = request.session.get('social_auth_last_backend') + message = request.GET.get('error_description', message) + full_backend_name = backend_name try: idp_name = strategy.request_data()['RelayState'] @@ -73,7 +84,6 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware): except KeyError: pass - message = self.get_message(request, exception) social_logger.error(message) url = self.get_redirect_uri(request, exception) diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index c5f2e91933..4cc306cb36 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -921,7 +921,7 @@ input[type="checkbox"].checkbox-no-label { /* Display list actions next to search widget */ .list-actions { text-align: right; - margin-bottom: 20px; + margin-bottom: -34px; .fa-lg { vertical-align: -8%; @@ -1939,10 +1939,16 @@ tr td button i { padding-right: 15px; } + + +} + +// lists.less uses 600px as the breakpoint, doing same for consistency +@media (max-width: 600px) { .list-actions { text-align: left; + margin-bottom: 20px; } - } .nvtooltip { @@ -2241,6 +2247,10 @@ html input[disabled] { cursor: not-allowed; } +.CodeMirror { + font-family: Monaco, Menlo, Consolas, "Courier New", monospace; +} + .CodeMirror--disabled .CodeMirror.cm-s-default, .CodeMirror--disabled .CodeMirror-line { background-color: #f6f6f6; diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less index 570a096c7e..5da836f921 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -245,13 +245,13 @@ .Form-textArea{ border-radius: 5px; color: @field-input-text; - background-color: @field-secondary-bg!important; + background-color: @field-secondary-bg; width:100%!important; } .Form-textInput{ height: 30px; - background-color: @field-secondary-bg!important; + background-color: @field-secondary-bg; border-radius: 5px; border:1px solid @field-border; color: @field-input-text; diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index eb0a50529b..8807fc5f93 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -153,10 +153,13 @@ table, tbody { .List-actionHolder { justify-content: flex-end; display: flex; + // margin-bottom: 20px; + // float: right; } .List-actions { display: flex; + margin-bottom: -32px; } .List-auxAction { @@ -275,6 +278,7 @@ table, tbody { } .List-noItems { + margin-top: 52px; display: flex; align-items: center; justify-content: center; @@ -287,6 +291,9 @@ table, tbody { text-transform: uppercase; } +.modal-body > .List-noItems { + margin-top: 0px; +} .List-editButton--selected { background-color: @list-actn-bg-hov !important; color: @list-actn-icn-hov; @@ -419,7 +426,51 @@ table, tbody { flex: 1 0 auto; margin-top: 12px; } + .List-actions { + margin-bottom: 20px; + } .List-well { margin-top: 20px; } + .List-action:not(.ng-hide) ~ .List-action:not(.ng-hide) { + margin-left: 0; + } } + +.InventoryManage-container, .modal-body { + .List-header { + flex-direction: column; + align-items: stretch; + } + .List-actionHolder { + justify-content: flex-start; + align-items: center; + flex: 1 0 auto; + margin-top: 12px; + } + .List-actions { + margin-bottom: 20px; + } + .List-well { + margin-top: 20px; + } + .List-action:not(.ng-hide) ~ .List-action:not(.ng-hide) { + margin-left: 0; + } +} + +// Inventory Manage exceptions +.InventoryManage-container { + .List-actionHolder { + justify-content: flex-end; + margin-top: -52px; + } + .List-action button { + margin-left: 12px; + } + .SmartSearch-searchTermContainer { + width: 100%; + } +} + + diff --git a/awx/ui/client/legacy-styles/stdout.less b/awx/ui/client/legacy-styles/stdout.less index 70f83fa31f..822c10e759 100644 --- a/awx/ui/client/legacy-styles/stdout.less +++ b/awx/ui/client/legacy-styles/stdout.less @@ -46,7 +46,7 @@ .ansi3 { font-weight: italic; } .ansi4 { text-decoration: underline; } .ansi9 { text-decoration: line-through; } -.ansi30 { color: @default-stdout-txt; } +.ansi30 { color: @default-data-txt; } .ansi31 { color: @default-err; } .ansi1.ansi31 { color: @default-unreachable; diff --git a/awx/ui/client/src/access/add-rbac-resource/rbac-resource.controller.js b/awx/ui/client/src/access/add-rbac-resource/rbac-resource.controller.js index e40589a3f6..6e41696faa 100644 --- a/awx/ui/client/src/access/add-rbac-resource/rbac-resource.controller.js +++ b/awx/ui/client/src/access/add-rbac-resource/rbac-resource.controller.js @@ -62,13 +62,13 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr user.username; } - if (item.isSelected) { + if (value.isSelected) { if (item.type === 'user') { item.name = buildName(item); } scope.allSelected.push(item); } else { - scope.allSelected = _.remove(scope.allSelected, { id: item.id }); + _.remove(scope.allSelected, { id: item.id }); } }); diff --git a/awx/ui/client/src/access/add-rbac-resource/rbac-resource.partial.html b/awx/ui/client/src/access/add-rbac-resource/rbac-resource.partial.html index c9ca9c5e51..cc2c3afbba 100644 --- a/awx/ui/client/src/access/add-rbac-resource/rbac-resource.partial.html +++ b/awx/ui/client/src/access/add-rbac-resource/rbac-resource.partial.html @@ -44,7 +44,7 @@ -
+
@@ -62,7 +62,7 @@ Please assign roles to the selected users/teams
+ ng-click="toggleKeyPane()" translate> Key
@@ -104,13 +104,13 @@
diff --git a/awx/ui/client/src/access/add-rbac-user-team/rbac-user-team.controller.js b/awx/ui/client/src/access/add-rbac-user-team/rbac-user-team.controller.js index edbd5eaf6f..a97bd0218c 100644 --- a/awx/ui/client/src/access/add-rbac-user-team/rbac-user-team.controller.js +++ b/awx/ui/client/src/access/add-rbac-user-team/rbac-user-team.controller.js @@ -127,7 +127,7 @@ function(rootScope, scope, $state, i18n, CreateSelect2, GetBasePath, Rest, $q, W let resourceType = scope.currentTab(), item = value.value; - if (item.isSelected) { + if (value.isSelected) { scope.selected[resourceType][item.id] = item; scope.selected[resourceType][item.id].roles = []; aggregateKey(item, resourceType); diff --git a/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js b/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js index 9769df3506..39b083f06c 100644 --- a/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js +++ b/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js @@ -9,12 +9,6 @@ return { name: 'users', iterator: 'user', - defaultSearchParams: function(term){ - return {or__username__icontains: term, - or__first_name__icontains: term, - or__last_name__icontains: term - }; - }, title: false, listTitleBadge: false, multiSelect: true, diff --git a/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js b/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js index b775dedaca..8f3d924ec2 100644 --- a/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js +++ b/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js @@ -65,6 +65,7 @@ export default ['addPermissionsTeamsList', 'addPermissionsUsersList', 'TemplateL description: list.fields.description }; list.fields.name.columnClass = 'col-md-6 col-sm-6 col-xs-11'; + list.fields.name.ngHref = '#/templates/job_template/{{job_template.id}}'; list.fields.description.columnClass = 'col-md-5 col-sm-5 hidden-xs'; break; @@ -77,6 +78,7 @@ export default ['addPermissionsTeamsList', 'addPermissionsUsersList', 'TemplateL description: list.fields.description }; list.fields.name.columnClass = 'col-md-6 col-sm-6 col-xs-11'; + list.fields.name.ngHref = '#/templates/workflow_job_template/{{workflow_template.id}}'; list.fields.description.columnClass = 'col-md-5 col-sm-5 hidden-xs'; break; case 'Users': @@ -119,10 +121,39 @@ export default ['addPermissionsTeamsList', 'addPermissionsUsersList', 'TemplateL scope.$watch(list.name, function(){ _.forEach(scope[`${list.name}`], isSelected); + optionsRequestDataProcessing(); }); + scope.$on(`${list.iterator}_options`, function(event, data){ + scope.options = data.data.actions.GET; + optionsRequestDataProcessing(); + }); + + // iterate over the list and add fields like type label, after the + // OPTIONS request returns, or the list is sorted/paginated/searched + function optionsRequestDataProcessing(){ + if(scope.list.name === 'projects'){ + if (scope[list.name] !== undefined) { + scope[list.name].forEach(function(item, item_idx) { + var itm = scope[list.name][item_idx]; + + // Set the item type label + if (list.fields.scm_type && scope.options && + scope.options.hasOwnProperty('scm_type')) { + scope.options.scm_type.choices.forEach(function(choice) { + if (choice[0] === item.scm_type) { + itm.type_label = choice[1]; + } + }); + } + + }); + } + } + } + function isSelected(item){ - if(_.find(scope.allSelected, {id: item.id})){ + if(_.find(scope.allSelected, {id: item.id, type: item.type})){ item.isSelected = true; } return item; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 610dcc31c2..aade185211 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -382,8 +382,11 @@ var tower = angular.module('Tower', [ Authorization.restoreUserInfo(); //user must have hit browser refresh } if (next && (next.name !== "signIn" && next.name !== "signOut" && next.name !== "license")) { - // if not headed to /login or /logout, then check the license - CheckLicense.test(event); + if($rootScope.configReady === true){ + // if not headed to /login or /logout, then check the license + CheckLicense.test(event); + } + } } activateTab(); diff --git a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js index 07c5cc8a0b..f3cb9605e6 100644 --- a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js +++ b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js @@ -60,9 +60,11 @@ export default [ } var activeForm = function() { + if(!$scope.$parent[formTracker.currentFormName()].$dirty) { authVm.activeAuthForm = authVm.dropdownValue; formTracker.setCurrentAuth(authVm.activeAuthForm); + startCodeMirrors(); } else { var msg = i18n._('You have unsaved changes. Would you like to proceed without saving?'); var title = i18n._('Warning: Unsaved Changes'); @@ -115,28 +117,36 @@ export default [ var authForms = [{ formDef: configurationAzureForm, - id: 'auth-azure-form' + id: 'auth-azure-form', + name: 'azure' }, { formDef: configurationGithubForm, - id: 'auth-github-form' + id: 'auth-github-form', + name: 'github' }, { formDef: configurationGithubOrgForm, - id: 'auth-github-org-form' + id: 'auth-github-org-form', + name: 'github_org' }, { formDef: configurationGithubTeamForm, - id: 'auth-github-team-form' + id: 'auth-github-team-form', + name: 'github_team' }, { formDef: configurationGoogleForm, - id: 'auth-google-form' + id: 'auth-google-form', + name: 'google_oauth' }, { formDef: configurationLdapForm, - id: 'auth-ldap-form' + id: 'auth-ldap-form', + name: 'ldap' }, { formDef: configurationRadiusForm, - id: 'auth-radius-form' + id: 'auth-radius-form', + name: 'radius' }, { formDef: configurationSamlForm, - id: 'auth-saml-form' + id: 'auth-saml-form', + name: 'saml' }, ]; var forms = _.pluck(authForms, 'formDef'); @@ -161,9 +171,42 @@ export default [ form.buttons.save.disabled = $rootScope.user_is_system_auditor; }); + function startCodeMirrors(key){ + var form = _.find(authForms, function(f){ + return f.name === $scope.authVm.activeAuthForm; + }); + + if(!key){ + // Attach codemirror to fields that need it + _.each(form.formDef.fields, function(field) { + // Codemirror balks at empty values so give it one + if($scope.$parent[field.name] === null && field.codeMirror) { + $scope.$parent[field.name] = '{}'; + } + if(field.codeMirror) { + createIt(field.name); + } + }); + } + else if(key){ + createIt(key); + } + + function createIt(name){ + ParseTypeChange({ + scope: $scope.$parent, + variable: name, + parse_variable: 'parseType', + field_id: form.formDef.name + '_' + name + }); + $scope.parseTypeChange('parseType', name); + } + } + function addFieldInfo(form, key) { _.extend(form.fields[key], { - awPopOver: $scope.$parent.configDataResolve[key].help_text, + awPopOver: ($scope.$parent.configDataResolve[key].defined_in_file) ? + null: $scope.$parent.configDataResolve[key].help_text, label: $scope.$parent.configDataResolve[key].label, name: key, toggleSource: key, @@ -186,40 +229,23 @@ export default [ id: form.id, mode: 'edit', scope: $scope.$parent, - related: true + related: true, + noPanel: true }); }); // Flag to avoid re-rendering and breaking Select2 dropdowns on tab switching var dropdownRendered = false; - $scope.$on('populated', function() { - // Attach codemirror to fields that need it - _.each(authForms, function(form) { - _.each(form.formDef.fields, function(field) { - // Codemirror balks at empty values so give it one - if($scope.$parent[field.name] === null && field.codeMirror) { - $scope.$parent[field.name] = '{}'; - } - if(field.codeMirror) { - ParseTypeChange({ - scope: $scope.$parent, - variable: field.name, - parse_variable: 'parseType', - field_id: form.formDef.name + '_' + field.name, - readonly: true, - }); - } - }); - }); - // Create Select2 fields - var opts = []; + + function populateLDAPGroupType(flag){ if($scope.$parent.AUTH_LDAP_GROUP_TYPE !== null) { - opts.push({ - id: $scope.$parent.AUTH_LDAP_GROUP_TYPE, - text: $scope.$parent.AUTH_LDAP_GROUP_TYPE - }); + $scope.$parent.AUTH_LDAP_GROUP_TYPE = _.find($scope.$parent.AUTH_LDAP_GROUP_TYPE_options, { value: $scope.$parent.AUTH_LDAP_GROUP_TYPE }); + } + + if(flag !== undefined){ + dropdownRendered = flag; } if(!dropdownRendered) { @@ -228,15 +254,21 @@ export default [ element: '#configuration_ldap_template_AUTH_LDAP_GROUP_TYPE', multiple: false, placeholder: i18n._('Select group types'), - opts: opts }); - // Fix for bug where adding selected opts causes form to be $dirty and triggering modal - // TODO Find better solution for this bug - $timeout(function(){ - $scope.$parent.configuration_ldap_template_form.$setPristine(); - }, 1000); } + } + $scope.$on('AUTH_LDAP_GROUP_TYPE_populated', function(e, data, flag) { + populateLDAPGroupType(flag); + }); + + $scope.$on('codeMirror_populated', function(e, key) { + startCodeMirrors(key); + }); + + $scope.$on('populated', function() { + startCodeMirrors(); + populateLDAPGroupType(false); }); angular.extend(authVm, { diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js index ad1f7cb6d8..1a972f0aee 100644 --- a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js @@ -24,11 +24,15 @@ export default ['i18n', function(i18n) { reset: 'SOCIAL_AUTH_SAML_SP_ENTITY_ID' }, SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: { - type: 'text', + type: 'textarea', + rows: 6, + elementClass: 'Form-monospace', reset: 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT' }, SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: { - type: 'sensitive', + type: 'textarea', + rows: 6, + elementClass: 'Form-monospace', hasShowInputButton: true, reset: 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY' }, diff --git a/awx/ui/client/src/configuration/configuration.block.less b/awx/ui/client/src/configuration/configuration.block.less index b5a7d203cf..2ce7d4e4b9 100644 --- a/awx/ui/client/src/configuration/configuration.block.less +++ b/awx/ui/client/src/configuration/configuration.block.less @@ -147,6 +147,8 @@ textarea[disabled="disabled"] + div[id*="-container"]{ //Needed to show the not-allowed cursor over a Codemirror instance .Form-formGroup--disabled { cursor: not-allowed; + position: relative; + display: inline-block; // Filepicker and toggle disabling .Form-filePicker--pickerButton, .Form-filePicker--textBox, @@ -155,4 +157,46 @@ textarea[disabled="disabled"] + div[id*="-container"]{ cursor: not-allowed; } + // Adding explanatory tooltips for disabled fields + // Borrows styling from .popover + .Form-tooltip--disabled { + visibility: hidden; + background-color: @default-interface-txt; + color: @default-bg; + text-align: center; + border-radius: 6px; + + position: absolute; + z-index: 1; + width: 200px; + bottom: 110%; + left: 50%; + margin-left: -100px; + + background-clip: padding-box; + border: 1px solid rgba(0,0,0,.2); + -webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2); + box-shadow: 0 5px 10px rgba(0,0,0,.2); + white-space: normal; + + padding: 9px 14px; + font-size: 12px; + font-weight: bold; + } + + &:hover .Form-tooltip--disabled { + visibility: visible; + } + + .Form-tooltip--disabled::after { + content: " "; + position: absolute; + top: 100%; + left: 50%; + margin-left: -11px; + border-width: 11px; + border-style: solid; + border-color: @default-interface-txt transparent transparent transparent; + } + } diff --git a/awx/ui/client/src/configuration/configuration.controller.js b/awx/ui/client/src/configuration/configuration.controller.js index f9319300a9..dab604b744 100644 --- a/awx/ui/client/src/configuration/configuration.controller.js +++ b/awx/ui/client/src/configuration/configuration.controller.js @@ -7,7 +7,7 @@ export default [ '$scope', '$rootScope', '$state', '$stateParams', '$timeout', '$q', 'Alert', 'ClearScope', 'ConfigurationService', 'ConfigurationUtils', 'CreateDialog', 'CreateSelect2', 'i18n', 'ParseTypeChange', 'ProcessErrors', 'Store', - 'Wait', 'configDataResolve', + 'Wait', 'configDataResolve', 'ToJSON', //Form definitions 'configurationAzureForm', 'configurationGithubForm', @@ -25,7 +25,7 @@ export default [ function( $scope, $rootScope, $state, $stateParams, $timeout, $q, Alert, ClearScope, ConfigurationService, ConfigurationUtils, CreateDialog, CreateSelect2, i18n, ParseTypeChange, ProcessErrors, Store, - Wait, configDataResolve, + Wait, configDataResolve, ToJSON, //Form definitions configurationAzureForm, configurationGithubForm, @@ -71,7 +71,7 @@ export default [ // we want the options w/o a space, and // the ConfigurationUtils.arrayToList() // does a string.split(', ') w/ an extra space - // behind the comma. + // behind the comma. if(key === "AD_HOC_COMMANDS"){ $scope[key] = data[key].toString(); } @@ -295,9 +295,20 @@ export default [ ConfigurationService.patchConfiguration(payload) .then(function() { $scope[key] = $scope.configDataResolve[key].default; - if(key === "AD_HOC_COMMANDS"){ - $scope.AD_HOC_COMMANDS = $scope.AD_HOC_COMMANDS.toString(); - $scope.$broadcast('adhoc_populated', null, false); + if($scope[key + '_field'].type === "select"){ + // We need to re-instantiate the Select2 element + // after resetting the value. Example: + $scope.$broadcast(key+'_populated', null, false); + } + else if($scope[key + '_field'].reset === "CUSTOM_LOGO"){ + $scope.$broadcast(key+'_reverted'); + } + else if($scope[key + '_field'].type === "textarea" && _.isArray($scope.configDataResolve[key].default)){ + $scope[key] = ConfigurationUtils.arrayToList($scope[key], key); + } + else if($scope[key + '_field'].hasOwnProperty('codeMirror')){ + $scope[key] = '{}'; + $scope.$broadcast('codeMirror_populated', key); } loginUpdate(); }) @@ -353,7 +364,12 @@ export default [ payload[key] = _.map($scope[key], 'value').join(','); } } else { - payload[key] = $scope[key].value; + if(multiselectDropdowns.indexOf(key) !== -1) { + // Default AD_HOC_COMMANDS to an empty list + payload[key] = $scope[key].value || []; + } else { + payload[key] = $scope[key].value; + } } } else if($scope.configDataResolve[key].type === 'list' && $scope[key] !== null) { // Parse lists @@ -363,7 +379,9 @@ export default [ if($scope[key] === '') { payload[key] = {}; } else { - payload[key] = JSON.parse($scope[key]); + // payload[key] = JSON.parse($scope[key]); + payload[key] = ToJSON($scope.parseType, + $scope[key]); } } else { @@ -431,6 +449,7 @@ export default [ .then(function() { populateFromApi(); $scope[formTracker.currentFormName()].$setPristine(); + $scope.$broadcast('CUSTOM_LOGO_reverted'); }) .catch(function(error) { ProcessErrors($scope, error, status, formDefs[formTracker.getCurrent()], diff --git a/awx/ui/client/src/configuration/jobs-form/configuration-jobs.controller.js b/awx/ui/client/src/configuration/jobs-form/configuration-jobs.controller.js index af0572481b..2aca32628b 100644 --- a/awx/ui/client/src/configuration/jobs-form/configuration-jobs.controller.js +++ b/awx/ui/client/src/configuration/jobs-form/configuration-jobs.controller.js @@ -49,7 +49,8 @@ export default [ function addFieldInfo(form, key) { _.extend(form.fields[key], { - awPopOver: $scope.$parent.configDataResolve[key].help_text, + awPopOver: ($scope.$parent.configDataResolve[key].defined_in_file) ? + null: $scope.$parent.configDataResolve[key].help_text, label: $scope.$parent.configDataResolve[key].label, name: key, toggleSource: key, @@ -67,7 +68,8 @@ export default [ id: 'configure-jobs-form', mode: 'edit', scope: $scope.$parent, - related: false + related: false, + noPanel: true }); // Flag to avoid re-rendering and breaking Select2 dropdowns on tab switching @@ -75,6 +77,7 @@ export default [ function populateAdhocCommand(flag){ + $scope.$parent.AD_HOC_COMMANDS = $scope.$parent.AD_HOC_COMMANDS.toString(); var ad_hoc_commands = $scope.$parent.AD_HOC_COMMANDS.split(','); $scope.$parent.AD_HOC_COMMANDS = _.map(ad_hoc_commands, (item) => _.find($scope.$parent.AD_HOC_COMMANDS_options, { value: item })); @@ -92,12 +95,12 @@ export default [ } } - $scope.$on('adhoc_populated', function(e, data, flag) { + $scope.$on('AD_HOC_COMMANDS_populated', function(e, data, flag) { populateAdhocCommand(flag); }); - $scope.$on('populated', function(e, data, flag) { - populateAdhocCommand(flag); + $scope.$on('populated', function() { + populateAdhocCommand(false); }); // Fix for bug where adding selected opts causes form to be $dirty and triggering modal diff --git a/awx/ui/client/src/configuration/system-form/configuration-system.controller.js b/awx/ui/client/src/configuration/system-form/configuration-system.controller.js index 865145115b..17b1f8edfb 100644 --- a/awx/ui/client/src/configuration/system-form/configuration-system.controller.js +++ b/awx/ui/client/src/configuration/system-form/configuration-system.controller.js @@ -122,7 +122,8 @@ export default [ function addFieldInfo(form, key) { _.extend(form.fields[key], { - awPopOver: $scope.$parent.configDataResolve[key].help_text, + awPopOver: ($scope.$parent.configDataResolve[key].defined_in_file) ? + null: $scope.$parent.configDataResolve[key].help_text, label: $scope.$parent.configDataResolve[key].label, name: key, toggleSource: key, @@ -144,35 +145,40 @@ export default [ id: form.id, mode: 'edit', scope: $scope.$parent, - related: true + related: true, + noPanel: true }); }); var dropdownRendered = false; $scope.$on('populated', function() { + populateLogAggregator(false); + }); - var opts = []; + $scope.$on('LOG_AGGREGATOR_TYPE_populated', function(e, data, flag) { + populateLogAggregator(flag); + }); + + function populateLogAggregator(flag){ if($scope.$parent.LOG_AGGREGATOR_TYPE !== null) { - _.each(ConfigurationUtils.listToArray($scope.$parent.LOG_AGGREGATOR_TYPE), function(type) { - opts.push({ - id: type, - text: type - }); - }); + $scope.$parent.LOG_AGGREGATOR_TYPE = _.find($scope.$parent.LOG_AGGREGATOR_TYPE_options, { value: $scope.$parent.LOG_AGGREGATOR_TYPE }); + } + + if(flag !== undefined){ + dropdownRendered = flag; } if(!dropdownRendered) { dropdownRendered = true; CreateSelect2({ element: '#configuration_logging_template_LOG_AGGREGATOR_TYPE', - multiple: true, + multiple: false, placeholder: i18n._('Select types'), - opts: opts }); + $scope.$parent.configuration_logging_template_form.LOG_AGGREGATOR_TYPE.$setPristine(); } - - }); + } // Fix for bug where adding selected opts causes form to be $dirty and triggering modal // TODO Find better solution for this bug diff --git a/awx/ui/client/src/configuration/system-form/sub-forms/system-logging.form.js b/awx/ui/client/src/configuration/system-form/sub-forms/system-logging.form.js index f2ed9f54e3..9669d8074a 100644 --- a/awx/ui/client/src/configuration/system-form/sub-forms/system-logging.form.js +++ b/awx/ui/client/src/configuration/system-form/sub-forms/system-logging.form.js @@ -23,7 +23,6 @@ type: 'select', reset: 'LOG_AGGREGATOR_TYPE', ngOptions: 'type.label for type in LOG_AGGREGATOR_TYPE_options track by type.value', - multiSelect: true }, LOG_AGGREGATOR_USERNAME: { type: 'text', diff --git a/awx/ui/client/src/configuration/ui-form/configuration-ui.controller.js b/awx/ui/client/src/configuration/ui-form/configuration-ui.controller.js index 43ad4df2dc..000b265b93 100644 --- a/awx/ui/client/src/configuration/ui-form/configuration-ui.controller.js +++ b/awx/ui/client/src/configuration/ui-form/configuration-ui.controller.js @@ -52,7 +52,8 @@ function addFieldInfo(form, key) { _.extend(form.fields[key], { - awPopOver: $scope.$parent.configDataResolve[key].help_text, + awPopOver: ($scope.$parent.configDataResolve[key].defined_in_file) ? + null: $scope.$parent.configDataResolve[key].help_text, label: $scope.$parent.configDataResolve[key].label, name: key, toggleSource: key, @@ -70,30 +71,38 @@ id: 'configure-ui-form', mode: 'edit', scope: $scope.$parent, - related: true + related: true, + noPanel: true }); // Flag to avoid re-rendering and breaking Select2 dropdowns on tab switching var dropdownRendered = false; - $scope.$on('populated', function(){ + function populatePendoTrackingState(flag){ + if($scope.$parent.PENDO_TRACKING_STATE !== null) { + $scope.$parent.PENDO_TRACKING_STATE = _.find($scope.$parent.PENDO_TRACKING_STATE_options, { value: $scope.$parent.PENDO_TRACKING_STATE }); + } + + if(flag !== undefined){ + dropdownRendered = flag; + } + if(!dropdownRendered) { dropdownRendered = true; CreateSelect2({ element: '#configuration_ui_template_PENDO_TRACKING_STATE', multiple: false, - placeholder: i18n._('Select commands'), - opts: [{ - id: $scope.$parent.PENDO_TRACKING_STATE, - text: $scope.$parent.PENDO_TRACKING_STATE - }] + placeholder: i18n._('Select commands') }); - // Fix for bug where adding selected opts causes form to be $dirty and triggering modal - // TODO Find better solution for this bug - $timeout(function(){ - $scope.$parent.configuration_ui_template_form.$setPristine(); - }, 1000); } + } + + $scope.$on('PENDO_TRACKING_STATE_populated', function(e, data, flag) { + populatePendoTrackingState(flag); + }); + + $scope.$on('populated', function(){ + populatePendoTrackingState(false); }); angular.extend(uiVm, { diff --git a/awx/ui/client/src/forms/Credentials.js b/awx/ui/client/src/forms/Credentials.js index caff5de792..1ff9f5e6d4 100644 --- a/awx/ui/client/src/forms/Credentials.js +++ b/awx/ui/client/src/forms/Credentials.js @@ -431,7 +431,7 @@ export default awToolTip: '{{permissionsTooltip}}', dataTipWatch: 'permissionsTooltip', awToolTipTabEnabledInEditMode: true, - dataPlacement: 'top', + dataPlacement: 'right', basePath: 'api/v1/credentials/{{$stateParams.credential_id}}/access_list/', search: { order_by: 'username' diff --git a/awx/ui/client/src/forms/Groups.js b/awx/ui/client/src/forms/Groups.js index bf1050e922..cc9d6f081e 100644 --- a/awx/ui/client/src/forms/Groups.js +++ b/awx/ui/client/src/forms/Groups.js @@ -24,6 +24,7 @@ export default // form generator inspects the current state name to determine whether or not to set an active (.is-selected) class on a form tab // this setting is optional on most forms, except where the form's edit state name is not parentStateName.edit activeEditState: 'inventoryManage.editGroup', + detailsClick: "$state.go('inventoryManage.editGroup')", well: false, fields: { name: { diff --git a/awx/ui/client/src/forms/Hosts.js b/awx/ui/client/src/forms/Hosts.js index f52a640e83..ef9c7d62d0 100644 --- a/awx/ui/client/src/forms/Hosts.js +++ b/awx/ui/client/src/forms/Hosts.js @@ -12,9 +12,10 @@ export default angular.module('HostFormDefinition', []) - .value('HostForm', { + .factory('HostForm', ['i18n', function(i18n) { + return { - addTitle: 'Create Host', + addTitle: i18n._('Create Host'), editTitle: '{{ host.name }}', name: 'host', basePath: 'hosts', @@ -27,46 +28,54 @@ export default class: 'Form-header-field', ngClick: 'toggleHostEnabled(host)', type: 'toggle', - awToolTip: "

Indicates if a host is available and should be included in running jobs.

For hosts that " + - "are part of an external inventory, this flag cannot be changed. It will be set by the inventory sync process.

", - dataTitle: 'Host Enabled', + awToolTip: "

" + + i18n._("Indicates if a host is available and should be included in running jobs.") + + "

" + + i18n._("For hosts that are part of an external" + + " inventory, this flag cannot be changed. It will be" + + " set by the inventory sync process.") + + "

", + dataTitle: i18n._('Host Enabled'), + ngDisabled: 'host.has_inventory_sources' } }, fields: { name: { - label: 'Host Name', + label: i18n._('Host Name'), type: 'text', required: true, - awPopOver: "

Provide a host name, ip address, or ip address:port. Examples include:

" + + awPopOver: "

" + + i18n._("Provide a host name, ip address, or ip address:port. Examples include:") + + "

" + "
myserver.domain.com
" + "127.0.0.1
" + "10.1.0.140:25
" + "server.example.com:25" + "
", - dataTitle: 'Host Name', + dataTitle: i18n._('Host Name'), dataPlacement: 'right', dataContainer: 'body', ngDisabled: '!(host.summary_fields.user_capabilities.edit || canAdd)' }, description: { - label: 'Description', + label: i18n._('Description'), ngDisabled: '!(host.summary_fields.user_capabilities.edit || canAdd)', type: 'text' }, variables: { - label: 'Variables', + label: i18n._('Variables'), type: 'textarea', rows: 6, class: 'Form-formGroup--fullWidth', "default": "---", - awPopOver: "

Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.

" + + awPopOver: "

" + i18n._("Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "

" + "JSON:
\n" + "
{
 \"somevar\": \"somevalue\",
 \"password\": \"magic\"
}
\n" + "YAML:
\n" + "
---
somevar: somevalue
password: magic
\n" + - '

View JSON examples at www.json.org

' + - '

View YAML examples at docs.ansible.com

', - dataTitle: 'Host Variables', + '

' + i18n.sprintf(i18n._('View JSON examples at %s'), 'www.json.org') + '

' + + '

' + i18n.sprintf(i18n._('View YAML examples at %s'), 'docs.ansible.com') + '

', + dataTitle: i18n._('Host Variables'), dataPlacement: 'right', dataContainer: 'body' }, @@ -92,4 +101,5 @@ export default ngShow: '(host.summary_fields.user_capabilities.edit || canAdd)' } }, - }); + }; + }]); diff --git a/awx/ui/client/src/forms/Organizations.js b/awx/ui/client/src/forms/Organizations.js index 8e67cb20cf..bfd17b8cea 100644 --- a/awx/ui/client/src/forms/Organizations.js +++ b/awx/ui/client/src/forms/Organizations.js @@ -54,6 +54,7 @@ export default related: { users: { + name: 'users', dataPlacement: 'top', awToolTip: i18n._('Please save before adding users'), basePath: 'api/v1/organizations/{{$stateParams.organization_id}}/access_list/', diff --git a/awx/ui/client/src/forms/Teams.js b/awx/ui/client/src/forms/Teams.js index 6f31ad5481..a4252cb0a9 100644 --- a/awx/ui/client/src/forms/Teams.js +++ b/awx/ui/client/src/forms/Teams.js @@ -65,6 +65,7 @@ export default related: { users: { + name: 'users', dataPlacement: 'top', awToolTip: i18n._('Please save before adding users'), basePath: 'api/v1/teams/{{$stateParams.team_id}}/access_list/', diff --git a/awx/ui/client/src/forms/Users.js b/awx/ui/client/src/forms/Users.js index 59df6f3fd0..118857f895 100644 --- a/awx/ui/client/src/forms/Users.js +++ b/awx/ui/client/src/forms/Users.js @@ -119,6 +119,7 @@ export default related: { organizations: { + name: 'organizations', awToolTip: i18n._('Please save before assigning to organizations'), basePath: 'api/v1/users/{{$stateParams.user_id}}/organizations', emptyListText: i18n._('Please add user to an Organization.'), @@ -146,6 +147,7 @@ export default //hideOnSuperuser: true // RBAC defunct }, teams: { + name: 'teams', awToolTip: i18n._('Please save before assigning to teams'), basePath: 'api/v1/users/{{$stateParams.user_id}}/teams', search: { diff --git a/awx/ui/client/src/forms/WorkflowMaker.js b/awx/ui/client/src/forms/WorkflowMaker.js index 9ed3c69b65..a911e85678 100644 --- a/awx/ui/client/src/forms/WorkflowMaker.js +++ b/awx/ui/client/src/forms/WorkflowMaker.js @@ -34,7 +34,7 @@ export default label: i18n._('Type'), type: 'radio_group', ngShow: 'selectedTemplate && edgeFlags.showTypeOptions', - ngDisabled: '!canAddWorkflowJobTemplate', + ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)', options: [ { label: i18n._('On Success'), @@ -70,7 +70,7 @@ export default dataPlacement: 'right', dataContainer: "body", ngShow: "selectedTemplate.ask_credential_on_launch", - ngDisabled: '!canAddWorkflowJobTemplate', + ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)', awRequiredWhen: { reqExpression: 'selectedTemplate && selectedTemplate.ask_credential_on_launch' } @@ -90,7 +90,7 @@ export default dataPlacement: 'right', dataContainer: "body", ngShow: "selectedTemplate.ask_inventory_on_launch", - ngDisabled: '!canAddWorkflowJobTemplate', + ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)', awRequiredWhen: { reqExpression: 'selectedTemplate && selectedTemplate.ask_inventory_on_launch' } @@ -111,7 +111,7 @@ export default dataPlacement: 'right', dataContainer: "body", ngShow: "selectedTemplate.ask_job_type_on_launch", - ngDisabled: '!canAddWorkflowJobTemplate', + ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)', awRequiredWhen: { reqExpression: 'selectedTemplate && selectedTemplate.ask_job_type_on_launch' } @@ -128,7 +128,7 @@ export default dataPlacement: 'right', dataContainer: "body", ngShow: "selectedTemplate.ask_limit_on_launch", - ngDisabled: '!canAddWorkflowJobTemplate' + ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' }, job_tags: { label: i18n._('Job Tags'), @@ -143,7 +143,7 @@ export default dataPlacement: "right", dataContainer: "body", ngShow: "selectedTemplate.ask_tags_on_launch", - ngDisabled: '!canAddWorkflowJobTemplate' + ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' }, skip_tags: { label: i18n._('Skip Tags'), @@ -158,22 +158,22 @@ export default dataPlacement: "right", dataContainer: "body", ngShow: "selectedTemplate.ask_skip_tags_on_launch", - ngDisabled: '!canAddWorkflowJobTemplate' + ngDisabled: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' } }, buttons: { cancel: { ngClick: 'cancelNodeForm()', - ngShow: 'canAddWorkflowJobTemplate' + ngShow: '(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' }, close: { ngClick: 'cancelNodeForm()', - ngShow: '!canAddWorkflowJobTemplate' + ngShow: '!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' }, select: { ngClick: 'saveNodeForm()', ngDisabled: "workflow_maker_form.$invalid || !selectedTemplate", - ngShow: 'canAddWorkflowJobTemplate' + ngShow: '(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' } } };}]) diff --git a/awx/ui/client/src/helpers/Adhoc.js b/awx/ui/client/src/helpers/Adhoc.js index a192e41b5d..d29e591ce5 100644 --- a/awx/ui/client/src/helpers/Adhoc.js +++ b/awx/ui/client/src/helpers/Adhoc.js @@ -104,7 +104,9 @@ export default Rest.post(postData) .success(function (data) { Wait('stop'); - $state.go('adHocJobStdout', {id: data.id}); + if($location.path().replace(/^\//, '').split('/')[0] !== 'jobs') { + $state.go('adHocJobStdout', {id: data.id}); + } }) .error(function (data, status) { ProcessErrors(scope, data, status, { diff --git a/awx/ui/client/src/helpers/Groups.js b/awx/ui/client/src/helpers/Groups.js index bbc8007a06..8e7dcfc7ad 100644 --- a/awx/ui/client/src/helpers/Groups.js +++ b/awx/ui/client/src/helpers/Groups.js @@ -78,7 +78,7 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name scope.removeSourceReady = scope.$on('SourceReady', function(e, source) { // Get the ID from the correct summary field - var update_id = (source.current_update) ? source.summary_fields.current_update.id : source.summary_fields.last_update.id; + var update_id = (source.summary_fields.current_update) ? source.summary_fields.current_update.id : source.summary_fields.last_update.id; $state.go('inventorySyncStdout', {id: update_id}); diff --git a/awx/ui/client/src/helpers/JobSubmission.js b/awx/ui/client/src/helpers/JobSubmission.js index 9f5163e1ae..c4fd62434d 100644 --- a/awx/ui/client/src/helpers/JobSubmission.js +++ b/awx/ui/client/src/helpers/JobSubmission.js @@ -46,6 +46,7 @@ function($compile, CreateDialog, Wait, ParseTypeChange) { label: "Launch", onClick: function() { scope.$emit(callback); + $('#password-modal').dialog('close'); }, icon: "fa-check", "class": "btn btn-primary", diff --git a/awx/ui/client/src/helpers/JobTemplates.js b/awx/ui/client/src/helpers/JobTemplates.js index 921d5b1e37..35d80e78f6 100644 --- a/awx/ui/client/src/helpers/JobTemplates.js +++ b/awx/ui/client/src/helpers/JobTemplates.js @@ -155,8 +155,7 @@ angular.module('JobTemplatesHelper', ['Utilities']) scope.can_edit = data.summary_fields.user_capabilities.edit; - - if (scope.project === "" && scope.playbook === "") { + if (scope.job_type.value === "scan" && (!scope.project || scope.project === "") && (!scope.playbook || scope.playbook === "")) { scope.resetProjectToDefault(); } diff --git a/awx/ui/client/src/helpers/Jobs.js b/awx/ui/client/src/helpers/Jobs.js index 13a0198bb7..ff5cdfead1 100644 --- a/awx/ui/client/src/helpers/Jobs.js +++ b/awx/ui/client/src/helpers/Jobs.js @@ -88,21 +88,17 @@ export default // Set the item type label if (list.fields.type) { - parent_scope.type_choices.every(function(choice) { + parent_scope.type_choices.forEach(function(choice) { if (choice.value === item.type) { itm.type_label = choice.label; - return false; } - return true; }); } // Set the job status label - parent_scope.status_choices.every(function(status) { + parent_scope.status_choices.forEach(function(status) { if (status.value === item.status) { itm.status_label = status.label; - return false; } - return true; }); if (list.name === 'completed_jobs' || list.name === 'running_jobs') { diff --git a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-edit.controller.js b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-edit.controller.js index 3edbe6de90..2426a2bed8 100644 --- a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-edit.controller.js +++ b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-edit.controller.js @@ -12,6 +12,9 @@ $state.go('^', null, {reload: true}); }; $scope.toggleHostEnabled = function(){ + if ($scope.host.has_inventory_sources){ + return; + } $scope.host.enabled = !$scope.host.enabled; }; $scope.toggleEnabled = function(){ diff --git a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-list.controller.js b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-list.controller.js index 3ebf7dd370..b4ce108167 100644 --- a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-list.controller.js +++ b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts-list.controller.js @@ -43,6 +43,9 @@ export default ['$scope', '$state', '$stateParams', 'GetBasePath', 'DashboardHos }; $scope.toggleHostEnabled = function(host) { + if (host.has_inventory_sources){ + return; + } DashboardHostService.setHostStatus(host, !host.enabled) .then(function(res) { var index = _.findIndex($scope.hosts, function(o) { diff --git a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.form.js b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.form.js index 659ccdbe44..1344786990 100644 --- a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.form.js +++ b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.form.js @@ -4,7 +4,7 @@ * All Rights Reserved *************************************************/ -export default function(){ +export default ['i18n', function(i18n){ return { editTitle: '{{host.name}}', name: 'host', @@ -19,48 +19,55 @@ export default function(){ class: 'Form-header-field', ngClick: 'toggleHostEnabled()', type: 'toggle', - - awToolTip: "

Indicates if a host is available and should be included in running jobs.

For hosts that " + - "are part of an external inventory, this flag cannot be changed. It will be set by the inventory sync process.

", - dataTitle: 'Host Enabled' + awToolTip: "

" + + i18n._("Indicates if a host is available and should be included in running jobs.") + + "

" + + i18n._("For hosts that are part of an external inventory, this" + + " flag cannot be changed. It will be set by the inventory" + + " sync process.") + + "

", + dataTitle: i18n._('Host Enabled'), + ngDisabled: 'host.has_inventory_sources' } }, fields: { name: { - label: 'Host Name', + label: i18n._('Host Name'), type: 'text', value: '{{name}}', - awPopOver: "

Provide a host name, ip address, or ip address:port. Examples include:

" + + awPopOver: "

" + + i18n._("Provide a host name, ip address, or ip address:port. Examples include:") + + "

" + "
myserver.domain.com
" + "127.0.0.1
" + "10.1.0.140:25
" + "server.example.com:25" + "
", - dataTitle: 'Host Name', + dataTitle: i18n._('Host Name'), dataPlacement: 'right', dataContainer: 'body' }, description: { - label: 'Description', + label: i18n._('Description'), type: 'text', }, variables: { - label: 'Variables', + label: i18n._('Variables'), type: 'textarea', rows: 6, class: 'modal-input-xlarge Form-textArea Form-formGroup--fullWidth', - dataTitle: 'Host Variables', + dataTitle: i18n._('Host Variables'), dataPlacement: 'right', dataContainer: 'body', default: '---', - awPopOver: "

Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.

" + + awPopOver: "

" + i18n._("Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two.") + "

" + "JSON:
\n" + "
{
 \"somevar\": \"somevalue\",
 \"password\": \"magic\"
}
\n" + "YAML:
\n" + "
---
somevar: somevalue
password: magic
\n" + - '

View JSON examples at www.json.org

' + - '

View YAML examples at docs.ansible.com

', + '

' + i18n.sprintf(i18n._('View JSON examples at %s'), 'www.json.org') + '

' + + '

' + i18n.sprintf(i18n._('View YAML examples at %s'), 'docs.ansible.com') + '

', } }, buttons: { @@ -73,4 +80,4 @@ export default function(){ } } }; -} +}]; diff --git a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.list.js b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.list.js index dd4531c98e..b9162dccac 100644 --- a/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.list.js +++ b/awx/ui/client/src/home/dashboard/hosts/dashboard-hosts.list.js @@ -10,8 +10,8 @@ export default [ 'i18n', function(i18n){ name: 'hosts', iterator: 'host', selectTitle: i18n._('Add Existing Hosts'), - editTitle: 'Hosts', - listTitle: 'Hosts', + editTitle: i18n._('Hosts'), + listTitle: i18n._('Hosts'), index: false, hover: true, well: true, @@ -33,7 +33,7 @@ export default [ 'i18n', function(i18n){ }, name: { key: true, - label: 'Name', + label: i18n._('Name'), columnClass: 'col-lg-5 col-md-5 col-sm-5 col-xs-8 ellipsis List-staticColumnAdjacent', ngClick: 'editHost(host.id)' }, @@ -52,6 +52,7 @@ export default [ 'i18n', function(i18n){ nosort: true, awToolTip: "

" + i18n._("Indicates if a host is available and should be included in running jobs.") + "

" + i18n._("For hosts that are part of an external inventory, this flag cannot be changed. It will be set by the inventory sync process.") + "

", dataTitle: i18n._('Host Enabled'), + ngDisabled: 'host.has_inventory_sources' } }, diff --git a/awx/ui/client/src/home/dashboard/lists/dashboard-list.block.less b/awx/ui/client/src/home/dashboard/lists/dashboard-list.block.less index 7937e0906c..a33f7b3ec2 100644 --- a/awx/ui/client/src/home/dashboard/lists/dashboard-list.block.less +++ b/awx/ui/client/src/home/dashboard/lists/dashboard-list.block.less @@ -86,6 +86,18 @@ color: @default-err; } +.DashboardList-status--failed{ + color: @default-err; + margin-top: 10px; + margin-bottom: 10px; + padding: 0px; + margin-right: 5px; +} + +.DashboardList-status--failed:before { + content: "\f06a"; +} + .DashboardList-nameCell { padding-left: 15px; width: 100%; diff --git a/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.partial.html b/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.partial.html index 846b268c29..20817d40e6 100644 --- a/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.partial.html +++ b/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.partial.html @@ -30,7 +30,7 @@ - +
diff --git a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js index fd59f4496e..be3d78c8bb 100644 --- a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js +++ b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js @@ -29,7 +29,7 @@ export default // detailsUrl, status, name, time scope.jobs = _.map(list, function(job){ return { - detailsUrl: job.url.replace("api/v1", "#"), + detailsUrl: job.type && job.type === 'workflow_job' ? job.url.replace("api/v1/workflow_jobs", "#/workflows") : job.url.replace("api/v1", "#"), status: job.status, name: job.name, id: job.id, diff --git a/awx/ui/client/src/i18n.js b/awx/ui/client/src/i18n.js index b37d05a8c0..2b24a09822 100644 --- a/awx/ui/client/src/i18n.js +++ b/awx/ui/client/src/i18n.js @@ -19,8 +19,10 @@ export default .factory('I18NInit', ['$window', 'gettextCatalog', function ($window, gettextCatalog) { return function() { - var langInfo = $window.navigator.language || - $window.navigator.userLanguage; + var langInfo = ($window.navigator.languages || [])[0] || + $window.navigator.language || + $window.navigator.userLanguage || + ''; var langUrl = langInfo.replace('-', '_'); //gettextCatalog.debug = true; gettextCatalog.setCurrentLanguage(langInfo); diff --git a/awx/ui/client/src/inventories/main.js b/awx/ui/client/src/inventories/main.js index 1c8a7654d1..5abd9c99eb 100644 --- a/awx/ui/client/src/inventories/main.js +++ b/awx/ui/client/src/inventories/main.js @@ -103,7 +103,7 @@ angular.module('inventory', [ mode: 'edit' }); html = generateList.wrapPanel(html); - return generateList.insertFormView() + html; + return "
" + generateList.insertFormView() + html + "
"; }, controller: 'schedulerListController' } diff --git a/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.block.less b/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.block.less index 3a1db43548..c87b1b3f52 100644 --- a/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.block.less +++ b/awx/ui/client/src/inventories/manage/breadcrumbs/breadcrumbs.block.less @@ -12,7 +12,7 @@ .InventoryManageBreadCrumbs{ position: relative; height: auto; - top: -40px; + top: -36px; .BreadCrumb-list{ margin-bottom: 0px; } diff --git a/awx/ui/client/src/inventories/manage/copy-move/copy-move.route.js b/awx/ui/client/src/inventories/manage/copy-move/copy-move.route.js index c80d9b0591..deff0cf0ef 100644 --- a/awx/ui/client/src/inventories/manage/copy-move/copy-move.route.js +++ b/awx/ui/client/src/inventories/manage/copy-move/copy-move.route.js @@ -67,7 +67,7 @@ var copyMoveHostRoute = { resolve: { Dataset: ['CopyMoveGroupList', 'QuerySet', '$stateParams', 'GetBasePath', function(list, qs, $stateParams, GetBasePath) { - let path = GetBasePath('inventory') + $stateParams.inventory_id + '/hosts/'; + let path = GetBasePath('inventory') + $stateParams.inventory_id + '/groups/'; return qs.search(path, $stateParams.copy_search); } ], @@ -83,7 +83,7 @@ var copyMoveHostRoute = { 'copyMoveList@inventoryManage.copyMoveHost': { templateProvider: function(CopyMoveGroupList, generateList, $stateParams, GetBasePath) { let list = CopyMoveGroupList; - list.basePath = GetBasePath('inventory') + $stateParams.inventory_id + '/hosts/'; + list.basePath = GetBasePath('inventory') + $stateParams.inventory_id + '/groups/'; let html = generateList.build({ list: CopyMoveGroupList, mode: 'lookup', diff --git a/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js b/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js index 503baaf584..7042800ee2 100644 --- a/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js +++ b/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js @@ -34,6 +34,9 @@ export default ['$state', '$stateParams', '$scope', 'HostForm', 'ParseTypeChange $state.go('^'); }; $scope.toggleHostEnabled = function() { + if ($scope.host.has_inventory_sources){ + return; + } $scope.host.enabled = !$scope.host.enabled; }; $scope.formSave = function(){ diff --git a/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js b/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js index ec825bf510..47ac724664 100644 --- a/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js +++ b/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js @@ -63,6 +63,9 @@ $state.go('^'); }; $scope.toggleHostEnabled = function(){ + if ($scope.host.has_inventory_sources){ + return; + } $scope.host.enabled = !$scope.host.enabled; }; $scope.formSave = function(){ diff --git a/awx/ui/client/src/inventories/manage/inventory-manage.block.less b/awx/ui/client/src/inventories/manage/inventory-manage.block.less index 4bf257dffe..caaf039058 100644 --- a/awx/ui/client/src/inventories/manage/inventory-manage.block.less +++ b/awx/ui/client/src/inventories/manage/inventory-manage.block.less @@ -1,3 +1,3 @@ .InventoryManage-container{ - margin-top: -40px; -} \ No newline at end of file + margin-top: -36px; +} diff --git a/awx/ui/client/src/inventory-scripts/inventory-scripts.form.js b/awx/ui/client/src/inventory-scripts/inventory-scripts.form.js index 72b965bd54..05ab6b42ca 100644 --- a/awx/ui/client/src/inventory-scripts/inventory-scripts.form.js +++ b/awx/ui/client/src/inventory-scripts/inventory-scripts.form.js @@ -57,6 +57,7 @@ export default ['i18n', function(i18n) { required: true, awDropFile: true, ngDisabled: '!(inventory_script_obj.summary_fields.user_capabilities.edit || canAdd)', + ngTrim: false, rows: 10, awPopOver: "

" + i18n._("Drag and drop your custom inventory script file here or create one in the field to import your custom inventory.") + " " + "

" + i18n.sprintf(i18n._("Script must begin with a hashbang sequence: i.e.... %s"), "#!/usr/bin/env python") + "

", @@ -77,7 +78,7 @@ export default ['i18n', function(i18n) { }, save: { ngClick: 'formSave()', //$scope.function to call on click, optional - ngDisabled: 'inventory_script_form.$pristine || inventory_script_form.$invalid', //Disable when $pristine or $invalid, optional + ngDisabled: 'inventory_script_form.$invalid', //Disable when $invalid, optional ngShow: '(inventory_script_obj.summary_fields.user_capabilities.edit || canAdd)' } } diff --git a/awx/ui/client/src/job-detail/host-event/host-event-details.partial.html b/awx/ui/client/src/job-detail/host-event/host-event-details.partial.html index 60cd44a9b7..c287788f19 100644 --- a/awx/ui/client/src/job-detail/host-event/host-event-details.partial.html +++ b/awx/ui/client/src/job-detail/host-event/host-event-details.partial.html @@ -20,7 +20,7 @@
CREATED - {{event.created || "No result found"}} + {{(event.created | longDate) || "No result found"}}
PLAY @@ -43,4 +43,3 @@ {{value}}
- diff --git a/awx/ui/client/src/job-results/event-queue.service.js b/awx/ui/client/src/job-results/event-queue.service.js index 02c99ff9a5..6982b71e7b 100644 --- a/awx/ui/client/src/job-results/event-queue.service.js +++ b/awx/ui/client/src/job-results/event-queue.service.js @@ -52,10 +52,14 @@ export default ['jobResultsService', 'parseStdoutService', function(jobResultsSe }, // populates the event queue populate: function(event) { - val.queue[event.counter] = val.munge(event); + if (event) { + val.queue[event.counter] = val.munge(event); - if (!val.queue[event.counter].processed) { - return val.munge(event); + if (!val.queue[event.counter].processed) { + return val.munge(event); + } else { + return {}; + } } else { return {}; } diff --git a/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html b/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html index 6087115e45..cf9dc67ac4 100644 --- a/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html +++ b/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html @@ -17,7 +17,11 @@
CREATED - {{event.created || "No result found"}} + {{(event.created | longDate) || "No result found"}} +
+
+ ID + {{event.id || "No result found"}}
PLAY @@ -29,13 +33,18 @@
MODULE - {{event.event_data.res.invocation.module_name || "No result found"}} + {{module_name}}
+ - +
diff --git a/awx/ui/client/src/job-results/host-event/host-event-stderr.partial.html b/awx/ui/client/src/job-results/host-event/host-event-stderr.partial.html new file mode 100644 index 0000000000..0a9e84a137 --- /dev/null +++ b/awx/ui/client/src/job-results/host-event/host-event-stderr.partial.html @@ -0,0 +1,12 @@ +
+
+
+
1
+
+
+ 2 +
+ +
+
+
diff --git a/awx/ui/client/src/job-results/host-event/host-event.block.less b/awx/ui/client/src/job-results/host-event/host-event.block.less index 6ae8f66c27..e5797643b3 100644 --- a/awx/ui/client/src/job-results/host-event/host-event.block.less +++ b/awx/ui/client/src/job-results/host-event/host-event.block.less @@ -159,6 +159,7 @@ border: 1px solid #ccc; font-style: normal; background-color: @default-no-items-bord; + font-family: Monaco, Menlo, Consolas, "Courier New", monospace; } .HostEvent-numberColumnPreload { @@ -187,7 +188,8 @@ } .HostEvent-stdoutColumn{ + white-space: pre; + overflow-y: scroll; margin-left: 46px; padding-top: 4px; - font-family: monospace; } diff --git a/awx/ui/client/src/job-results/host-event/host-event.controller.js b/awx/ui/client/src/job-results/host-event/host-event.controller.js index 707ccde385..6015dd3a63 100644 --- a/awx/ui/client/src/job-results/host-event/host-event.controller.js +++ b/awx/ui/client/src/job-results/host-event/host-event.controller.js @@ -6,8 +6,8 @@ export default - ['$stateParams', '$scope', '$state', 'Wait', 'JobDetailService', 'hostEvent', 'hostResults', 'parseStdoutService', - function($stateParams, $scope, $state, Wait, JobDetailService, hostEvent, hostResults, parseStdoutService){ + ['$stateParams', '$scope', '$state', 'Wait', 'JobDetailService', 'hostEvent', 'hostResults', + function($stateParams, $scope, $state, Wait, JobDetailService, hostEvent, hostResults){ $scope.processEventStatus = JobDetailService.processEventStatus; $scope.hostResults = []; @@ -18,7 +18,7 @@ else {return true;} }; $scope.isStdOut = function(){ - if ($state.current.name === 'jobDetails.host-event.stdout' || $state.current.name === 'jobDetaisl.histe-event.stderr'){ + if ($state.current.name === 'jobDetail.host-event.stdout' || $state.current.name === 'jobDetail.host-event.stderr'){ return 'StandardOut-preContainer StandardOut-preContent'; } }; @@ -48,16 +48,27 @@ hostEvent.event_name = hostEvent.event; $scope.event = _.cloneDeep(hostEvent); $scope.hostResults = hostResults; - $scope.json = JobDetailService.processJson(hostEvent); - // grab standard out & standard error if present, and remove from the results displayed in the details panel - if (hostEvent.stdout){ - $scope.stdout = parseStdoutService.prettify(hostEvent.stdout); - delete $scope.event.stdout; + // grab standard out & standard error if present from the host + // event's "res" object, for things like Ansible modules + try{ + $scope.module_name = hostEvent.event_data.res.invocation.module_name || hostEvent.event_data.task_action || "No result found"; + $scope.stdout = hostEvent.event_data.res.stdout; + $scope.stderr = hostEvent.event_data.res.stderr; + $scope.json = hostEvent.event_data.res; } - if (hostEvent.stderr){ - $scope.stderr = hostEvent.stderr; - delete $scope.event.stderr; + catch(err){ + // do nothing, no stdout/stderr for this module + } + if($scope.module_name === "debug" && + hostEvent.event_data.res.hasOwnProperty('result') && + hostEvent.event_data.res.result.hasOwnProperty('stdout')){ + $scope.stdout = hostEvent.event_data.res.result.stdout; + } + if($scope.module_name === "yum" && + hostEvent.event_data.res.hasOwnProperty('results') && + _.isArray(hostEvent.event_data.res.results)){ + $scope.stdout = hostEvent.event_data.res.results[0]; } // instantiate Codemirror // try/catch pattern prevents the abstract-state controller from complaining about element being null diff --git a/awx/ui/client/src/job-results/host-event/host-event.route.js b/awx/ui/client/src/job-results/host-event/host-event.route.js index 6dad68c5d0..23d5fe2451 100644 --- a/awx/ui/client/src/job-results/host-event/host-event.route.js +++ b/awx/ui/client/src/job-results/host-event/host-event.route.js @@ -51,7 +51,7 @@ var hostEventStderr = { name: 'jobDetail.host-event.stderr', url: '/stderr', controller: 'HostEventController', - templateUrl: templateUrl('job-results/host-event/host-event-stdout') + templateUrl: templateUrl('job-results/host-event/host-event-stderr') }; diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less index 7ff93d17ee..15697e8e03 100644 --- a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less +++ b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less @@ -3,7 +3,7 @@ @breakpoint-md: 1200px; .JobResultsStdOut { - height: 100%; + height: auto; width: 100%; display: flex; flex-direction: column; @@ -178,7 +178,7 @@ color: @default-err; } -.JobResultsStdOut-stdoutColumn { +.JobResultsStdOut-stdoutColumn--clickable { cursor: pointer; } diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html index c283d578f7..88ee156a4e 100644 --- a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html +++ b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html @@ -35,7 +35,7 @@
+ ng-show="tooManyEvents || tooManyPastEvents || showLegacyJobErrorMessage">
@@ -43,6 +43,8 @@ ng-show="tooManyEvents">The standard output is too large to display. Please specify additional filters to narrow the standard out.
Too much previous output to display. Showing running standard output.
+
Job details are not available for this job. Please download to view standard out.
diff --git a/awx/ui/client/src/job-results/job-results.block.less b/awx/ui/client/src/job-results/job-results.block.less index be8e330693..33a449c67f 100644 --- a/awx/ui/client/src/job-results/job-results.block.less +++ b/awx/ui/client/src/job-results/job-results.block.less @@ -16,7 +16,12 @@ .JobResults-leftSide { .OnePlusTwo-left--panel(100%, @breakpoint-md); + max-width: 33%; height: ~"calc(100vh - 177px)"; + + @media screen and (max-width: @breakpoint-md) { + max-width: 100%; + } } .JobResults-rightSide { @@ -175,8 +180,15 @@ smart-search { job-results-standard-out { flex: 1; + flex-basis: auto; + height: ~"calc(100% - 800px)"; display: flex } +@media screen and (max-width: @breakpoint-md) { + job-results-standard-out { + height: auto; + } +} .JobResults-extraVarsHelp { margin-left: 10px; diff --git a/awx/ui/client/src/job-results/job-results.controller.js b/awx/ui/client/src/job-results/job-results.controller.js index d19e7a919d..373eeafda4 100644 --- a/awx/ui/client/src/job-results/job-results.controller.js +++ b/awx/ui/client/src/job-results/job-results.controller.js @@ -1,5 +1,5 @@ -export default ['jobData', 'jobDataOptions', 'jobLabels', 'jobFinished', 'count', '$scope', 'ParseTypeChange', 'ParseVariableString', 'jobResultsService', 'eventQueue', '$compile', '$log', 'Dataset', '$q', 'Rest', '$state', 'QuerySet', '$rootScope', 'moment', '$stateParams', 'i18n', 'fieldChoices', 'fieldLabels', 'workflowResultsService', -function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile, $log, Dataset, $q, Rest, $state, QuerySet, $rootScope, moment, $stateParams, i18n, fieldChoices, fieldLabels, workflowResultsService) { +export default ['jobData', 'jobDataOptions', 'jobLabels', 'jobFinished', 'count', '$scope', 'ParseTypeChange', 'ParseVariableString', 'jobResultsService', 'eventQueue', '$compile', '$log', 'Dataset', '$q', 'Rest', '$state', 'QuerySet', '$rootScope', 'moment', '$stateParams', 'i18n', 'fieldChoices', 'fieldLabels', 'workflowResultsService', 'statusSocket', +function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile, $log, Dataset, $q, Rest, $state, QuerySet, $rootScope, moment, $stateParams, i18n, fieldChoices, fieldLabels, workflowResultsService, statusSocket) { var toDestroy = []; var cancelRequests = false; var runTimeElapsedTimer = null; @@ -298,6 +298,14 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy $scope.job.finished = mungedEvent.finishedTime; $scope.jobFinished = true; $scope.followTooltip = "Jump to last line of standard out."; + if ($scope.followEngaged) { + if (!$scope.followScroll) { + $scope.followScroll = function() { + $log.error("follow scroll undefined, standard out directive not loaded yet?"); + }; + } + $scope.followScroll(); + } } if (change === 'countFinished') { @@ -365,25 +373,30 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy var putAfter; var isDup = false; - $(".header_" + putIn + ",." + putIn) - .each((i, v) => { - if (angular.element(v).scope() - .event.start_line < mungedEvent - .start_line) { - putAfter = v; - } else if (angular.element(v).scope() - .event.start_line === mungedEvent - .start_line) { - isDup = true; - return false; - } else if (angular.element(v).scope() - .event.start_line > mungedEvent - .start_line) { - return false; - } else { - appendToBottom(mungedEvent); - } - }); + + if ($(".header_" + putIn + ",." + putIn).length === 0) { + appendToBottom(mungedEvent); + } else { + $(".header_" + putIn + ",." + putIn) + .each((i, v) => { + if (angular.element(v).scope() + .event.start_line < mungedEvent + .start_line) { + putAfter = v; + } else if (angular.element(v).scope() + .event.start_line === mungedEvent + .start_line) { + isDup = true; + return false; + } else if (angular.element(v).scope() + .event.start_line > mungedEvent + .start_line) { + return false; + } else { + appendToBottom(mungedEvent); + } + }); + } if (!isDup) { $(putAfter).after($compile(mungedEvent @@ -407,17 +420,6 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy // container $(".JobResultsStdOut-followAnchor") .appendTo(".JobResultsStdOut-stdoutContainer"); - - // if follow is engaged, - // scroll down to the followAnchor - if ($scope.followEngaged) { - if (!$scope.followScroll) { - $scope.followScroll = function() { - $log.error("follow scroll undefined, standard out directive not loaded yet?"); - }; - } - $scope.followScroll(); - } } }); @@ -435,13 +437,35 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy $scope.playCount = 0; $scope.taskCount = 0; + + // used to show a message to just download for old jobs + // remove in 3.2.0 + $scope.isOld = 0; + $scope.showLegacyJobErrorMessage = false; + + toDestroy.push($scope.$watch('isOld', function (val) { + if (val >= 2) { + $scope.showLegacyJobErrorMessage = true; + } + })); + // get header and recap lines var skeletonPlayCount = 0; var skeletonTaskCount = 0; var getSkeleton = function(url) { jobResultsService.getEvents(url) .then(events => { + // old job check: if the job is complete, there is result stdout, and + // there are no job events, it's an old job + if ($scope.jobFinished) { + $scope.showLegacyJobErrorMessage = $scope.job.result_stdout.length && + !events.results.length; + } + events.results.forEach(event => { + if (event.start_line === 0 && event.end_line === 0) { + $scope.isOld++; + } // get the name in the same format as the data // coming over the websocket event.event_name = event.event; @@ -586,10 +610,25 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy var buffer = []; var processBuffer = function() { - buffer.forEach((event, i) => { - processEvent(event); + var follow = function() { + // if follow is engaged, + // scroll down to the followAnchor + if ($scope.followEngaged) { + if (!$scope.followScroll) { + $scope.followScroll = function() { + $log.error("follow scroll undefined, standard out directive not loaded yet?"); + }; + } + $scope.followScroll(); + } + }; + + for (let i = 0; i < 4; i++) { + processEvent(buffer[i]); buffer.splice(i, 1); - }); + } + + follow(); }; var bufferInterval; @@ -628,10 +667,17 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy $scope.taskCount++; } buffer.push(data); - processEvent(data); }); })); + // get previously set up socket messages from resolve + if (statusSocket && statusSocket[0] && statusSocket[0].job_status) { + $scope.job_status = statusSocket[0].job_status; + } + if ($scope.job_status === "running" && !$scope.job.elapsed) { + runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateJobElapsedTimer); + } + // Processing of job-status messages from the websocket toDestroy.push($scope.$on(`ws-jobs`, function(e, data) { if (parseInt(data.unified_job_id, 10) === @@ -639,15 +685,15 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy // controller is defined, so set the job_status $scope.job_status = data.status; if (data.status === "running") { - runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateJobElapsedTimer); + if (!runTimeElapsedTimer) { + runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateJobElapsedTimer); + } } else if (data.status === "successful" || data.status === "failed" || data.status === "error" || data.status === "canceled") { workflowResultsService.destroyTimer(runTimeElapsedTimer); - if (bufferInterval) { - clearInterval(bufferInterval); - } + // When the fob is finished retrieve the job data to // correct anything that was out of sync from the job run jobResultsService.getJobData($scope.job.id).then(function(data){ @@ -669,7 +715,14 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy } })); + if (statusSocket && statusSocket[1]) { + statusSocket[1](); + } + $scope.$on('$destroy', function(){ + if (statusSocket && statusSocket[1]) { + statusSocket[1](); + } $( ".JobResultsStdOut-aLineOfStdOut" ).remove(); cancelRequests = true; eventQueue.initialize(); diff --git a/awx/ui/client/src/job-results/job-results.route.js b/awx/ui/client/src/job-results/job-results.route.js index 755735ddc8..4ea38a97ba 100644 --- a/awx/ui/client/src/job-results/job-results.route.js +++ b/awx/ui/client/src/job-results/job-results.route.js @@ -36,6 +36,16 @@ export default { } }, resolve: { + statusSocket: ['$rootScope', '$stateParams', function($rootScope, $stateParams) { + var preScope = {}; + var eventOn = $rootScope.$on(`ws-jobs`, function(e, data) { + if (parseInt(data.unified_job_id, 10) === + parseInt($stateParams.id,10)) { + preScope.job_status = data.status; + } + }); + return [preScope, eventOn]; + }], // the GET for the particular job jobData: ['Rest', 'GetBasePath', '$stateParams', '$q', '$state', 'Alert', 'jobResultsService', function(Rest, GetBasePath, $stateParams, $q, $state, Alert, jobResultsService) { return jobResultsService.getJobData($stateParams.id); diff --git a/awx/ui/client/src/job-results/job-results.service.js b/awx/ui/client/src/job-results/job-results.service.js index 7c3d36aab9..cb51dc8864 100644 --- a/awx/ui/client/src/job-results/job-results.service.js +++ b/awx/ui/client/src/job-results/job-results.service.js @@ -41,32 +41,31 @@ function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybo } }); - // use the hosts data populate above to get the count - var count = { - ok : _.filter(hosts, function(o){ - return !o.failures && !o.changed && o.ok > 0; - }), - skipped : _.filter(hosts, function(o){ - return o.skipped > 0; - }), - unreachable : _.filter(hosts, function(o){ - return o.dark > 0; - }), - failures : _.filter(hosts, function(o){ - return o.failures > 0; - }), - changed : _.filter(hosts, function(o){ - return o.changed > 0; - }) + var total_hosts_by_state = { + ok: 0, + skipped: 0, + unreachable: 0, + failures: 0, + changed: 0 }; - // turn the count into an actual count, rather than a list of host - // names - Object.keys(count).forEach(key => { - count[key] = count[key].length; + // each host belongs in at most *one* of these states depending on + // the state of its tasks + _.each(hosts, function(host) { + if (host.dark > 0){ + total_hosts_by_state.unreachable++; + } else if (host.failures > 0){ + total_hosts_by_state.failures++; + } else if (host.changed > 0){ + total_hosts_by_state.changed++; + } else if (host.ok > 0){ + total_hosts_by_state.ok++; + } else if (host.skipped > 0){ + total_hosts_by_state.skipped++; + } }); - return count; + return total_hosts_by_state; }, // rest call to grab previously complete job_events getEvents: function(url) { diff --git a/awx/ui/client/src/job-results/parse-stdout.service.js b/awx/ui/client/src/job-results/parse-stdout.service.js index b8830d07a9..03852eb185 100644 --- a/awx/ui/client/src/job-results/parse-stdout.service.js +++ b/awx/ui/client/src/job-results/parse-stdout.service.js @@ -28,6 +28,10 @@ export default ['$log', 'moment', function($log, moment){ // ansi classes line = line.replace(/\[1;im/g, ''); + line = line.replace(/\[0;30m/g, ''); + line = line.replace(/\[1;30m/g, ''); + line = line.replace(/\[1;31m/g, ''); + line = line.replace(/\[0;31m/g, ''); line = line.replace(/\[1;31m/g, ''); line = line.replace(/\[0;31m/g, ''); line = line.replace(/\[0;32m/g, ''); @@ -36,6 +40,7 @@ export default ['$log', 'moment', function($log, moment){ line = line.replace(/\[0;33m/g, ''); line = line.replace(/\[0;34m/g, ''); line = line.replace(/\[0;35m/g, ''); + line = line.replace(/\[1;35m/g, ''); line = line.replace(/\[0;36m/g, ''); line = line.replace(/()\s/g, '$1'); @@ -47,6 +52,8 @@ export default ['$log', 'moment', function($log, moment){ line = line.replace(/u001b/g, ''); // ansi classes + line = line.replace(/\[0;30m/g, ''); + line = line.replace(/\[1;30m/g, ''); line = line.replace(/\[1;31m/g, ''); line = line.replace(/\[0;31m/g, ''); line = line.replace(/\[0;32m/g, ''); @@ -70,7 +77,7 @@ export default ['$log', 'moment', function($log, moment){ return `"`; } else{ - return ` JobResultsStdOut-stdoutColumn--clickable" ui-sref="jobDetail.host-event.stdout({eventId: ${event.id}, taskUuid: '${event.event_data.task_uuid}' })" aw-tool-tip="Event ID: ${event.id}
Status: ${event.event_display}
Click for details" data-placement="top"`; + return ` JobResultsStdOut-stdoutColumn--clickable" ui-sref="jobDetail.host-event.json({eventId: ${event.id}, taskUuid: '${event.event_data.task_uuid}' })" aw-tool-tip="Event ID: ${event.id}
Status: ${event.event_display}
Click for details" data-placement="top"`; } }, @@ -198,6 +205,45 @@ export default ['$log', 'moment', function($log, moment){ return emptySpan; } }, + distributeColors: function(lines) { + var colorCode; + return lines.map(line => { + + if (colorCode) { + line = colorCode + line; + } + + if (line.indexOf("[0m") === -1) { + if (line.indexOf("[1;31m") > -1) { + colorCode = "[1;31m"; + } else if (line.indexOf("[1;30m") > -1) { + colorCode = "[1;30m"; + } else if (line.indexOf("[0;31m") > -1) { + colorCode = "[0;31m"; + } else if (line.indexOf("[0;32m=") > -1) { + colorCode = "[0;32m="; + } else if (line.indexOf("[0;32m1") > -1) { + colorCode = "[0;32m1"; + } else if (line.indexOf("[0;32m") > -1) { + colorCode = "[0;32m"; + } else if (line.indexOf("[0;33m") > -1) { + colorCode = "[0;33m"; + } else if (line.indexOf("[0;34m") > -1) { + colorCode = "[0;34m"; + } else if (line.indexOf("[0;35m") > -1) { + colorCode = "[0;35m"; + } else if (line.indexOf("[1;35m") > -1) { + colorCode = "[1;35m"; + } else if (line.indexOf("[0;36m") > -1) { + colorCode = "[0;36m"; + } + } else { + colorCode = null; + } + + return line; + }); + }, getLineArr: function(event) { let lineNums = _.range(event.start_line + 1, event.end_line + 1); @@ -219,6 +265,8 @@ export default ['$log', 'moment', function($log, moment){ } } + lines = this.distributeColors(lines); + // hack around no-carriage return issues if (lineNums.length === lines.length) { return _.zip(lineNums, lines); diff --git a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js index d49b4e521d..3cfb80e7a3 100644 --- a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js +++ b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js @@ -130,8 +130,12 @@ export default if(_.has(data, 'job')) { goToJobDetails('jobDetail'); - } - else if(data.type && data.type === 'workflow_job') { + } else if(base === 'jobs'){ + if(scope.clearDialog) { + scope.clearDialog(); + } + return; + } else if(data.type && data.type === 'workflow_job') { job = data.id; goToJobDetails('workflowResults'); } diff --git a/awx/ui/client/src/jobs/jobs-list.controller.js b/awx/ui/client/src/jobs/jobs-list.controller.js index d590cf7e17..ac1ef684cd 100644 --- a/awx/ui/client/src/jobs/jobs-list.controller.js +++ b/awx/ui/client/src/jobs/jobs-list.controller.js @@ -58,12 +58,10 @@ // Set the item type label if (list.fields.type && $scope.options && $scope.options.hasOwnProperty('type')) { - $scope.options.type.choices.every(function(choice) { + $scope.options.type.choices.forEach(function(choice) { if (choice[0] === item.type) { itm.type_label = choice[1]; - return false; } - return true; }); } buildTooltips(itm); diff --git a/awx/ui/client/src/license/license.controller.js b/awx/ui/client/src/license/license.controller.js index 195178dd28..eee73a91c1 100644 --- a/awx/ui/client/src/license/license.controller.js +++ b/awx/ui/client/src/license/license.controller.js @@ -41,6 +41,7 @@ export default $scope.fileName = N_("No file selected."); $scope.title = $rootScope.licenseMissing ? ("Tower " + i18n._("License")) : i18n._("License Management"); Wait('start'); + ConfigService.delete(); ConfigService.getConfig().then(function(config){ $scope.license = config; $scope.license.version = config.version.split('-')[0]; diff --git a/awx/ui/client/src/lists/ScheduledJobs.js b/awx/ui/client/src/lists/ScheduledJobs.js index 736ad61e4e..8c016cfb9f 100644 --- a/awx/ui/client/src/lists/ScheduledJobs.js +++ b/awx/ui/client/src/lists/ScheduledJobs.js @@ -46,7 +46,8 @@ export default columnClass: "col-lg-2 col-md-2 hidden-sm hidden-xs", sourceModel: 'unified_job_template', sourceField: 'unified_job_type', - ngBind: 'schedule.type_label' + ngBind: 'schedule.type_label', + searchField: 'unified_job_template__polymorphic_ctype__model' }, next_run: { label: i18n._('Next Run'), diff --git a/awx/ui/client/src/lists/Users.js b/awx/ui/client/src/lists/Users.js index bfff119616..fb84286bfb 100644 --- a/awx/ui/client/src/lists/Users.js +++ b/awx/ui/client/src/lists/Users.js @@ -15,12 +15,6 @@ export default search: { order_by: 'username' }, - defaultSearchParams: function(term){ - return {or__username__icontains: term, - or__first_name__icontains: term, - or__last_name__icontains: term - }; - }, iterator: 'user', selectTitle: i18n._('Add Users'), editTitle: i18n._('Users'), diff --git a/awx/ui/client/src/login/authenticationServices/authentication.service.js b/awx/ui/client/src/login/authenticationServices/authentication.service.js index b6064e8211..9771bc46c5 100644 --- a/awx/ui/client/src/login/authenticationServices/authentication.service.js +++ b/awx/ui/client/src/login/authenticationServices/authentication.service.js @@ -61,7 +61,10 @@ export default deleteToken: function () { return $http({ method: 'DELETE', - url: GetBasePath('authtoken') + url: GetBasePath('authtoken'), + headers: { + 'Authorization': 'Token ' + this.getToken() + } }); }, diff --git a/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html index e4302004df..581e34d0c2 100644 --- a/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html @@ -529,7 +529,7 @@
- +
@@ -545,8 +545,8 @@
- +
@@ -562,13 +562,13 @@
+ ng-show="(scheduler_form.$invalid || !schedulerIsValid) && scheduler_form.$dirty">

The scheduler options are invalid or incomplete.

+ ng-show="!scheduler_form.$invalid && schedulerIsValid"> @@ -642,7 +642,7 @@ id="project_save_btn" ng-click="saveSchedule()" ng-show="(schedule_obj.summary_fields.user_capabilities.edit || canAdd)" - ng-disabled="!schedulerIsValid"> Save + ng-disabled="scheduler_form.$invalid || !schedulerIsValid"> Save
diff --git a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js index d3aec68845..867c0ad914 100644 --- a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js +++ b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js @@ -50,14 +50,12 @@ // Set the item type label if (list.fields.notification_type && $scope.options && $scope.options.hasOwnProperty('notification_type')) { - $scope.options.notification_type.choices.every(function(choice) { + $scope.options.notification_type.choices.forEach(function(choice) { if (choice[0] === item.notification_type) { itm.type_label = choice[1]; var recent_notifications = itm.summary_fields.recent_notifications; itm.status = recent_notifications && recent_notifications.length > 0 ? recent_notifications[0].status : "none"; - return false; } - return true; }); } setStatus(itm); diff --git a/awx/ui/client/src/organizations/linkout/addUsers/addUsers.controller.js b/awx/ui/client/src/organizations/linkout/addUsers/addUsers.controller.js index e576ac5fd2..0a59ddbaef 100644 --- a/awx/ui/client/src/organizations/linkout/addUsers/addUsers.controller.js +++ b/awx/ui/client/src/organizations/linkout/addUsers/addUsers.controller.js @@ -11,10 +11,10 @@ * Controller for handling permissions adding */ -export default ['$scope', '$rootScope', 'ProcessErrors', 'GetBasePath', -'SelectionInit', 'templateUrl', '$state', 'Rest', '$q', 'Wait', '$window', -function($scope, $rootScope, ProcessErrors, GetBasePath, - SelectionInit, templateUrl, $state, Rest, $q, Wait, $window) { +export default ['$scope', '$rootScope', 'ProcessErrors', 'GetBasePath', 'generateList', +'SelectionInit', 'templateUrl', '$state', 'Rest', '$q', 'Wait', '$window', 'QuerySet', 'UserList', +function($scope, $rootScope, ProcessErrors, GetBasePath, generateList, + SelectionInit, templateUrl, $state, Rest, $q, Wait, $window, qs, UserList) { $scope.$on("linkLists", function() { if ($state.current.name.split(".")[1] === "users") { @@ -26,16 +26,59 @@ function($scope, $rootScope, ProcessErrors, GetBasePath, init(); function init(){ - // search init - $scope.list = $scope.$parent.add_user_list; - $scope.add_user_dataset = $scope.$parent.add_user_dataset; - $scope.add_users = $scope.$parent.add_user_dataset.results; + $scope.add_user_default_params = { + order_by: 'username', + page_size: 5 + }; + + $scope.add_user_queryset = { + order_by: 'username', + page_size: 5 + }; + + let list = _.cloneDeep(UserList); + list.basePath = 'users'; + list.iterator = 'add_user'; + list.name = 'add_users'; + list.multiSelect = true; + list.fields.username.ngClick = 'linkoutUser(add_user.id)'; + delete list.actions; + delete list.fieldActions; + + // Fire off the initial search + qs.search(GetBasePath('users'), $scope.add_user_default_params) + .then(function(res) { + $scope.add_user_dataset = res.data; + $scope.add_users = $scope.add_user_dataset.results; + + let html = generateList.build({ + list: list, + mode: 'edit', + title: false + }); + + $scope.list = list; + + $scope.compileList(html); + + $scope.$watchCollection('add_users', function () { + if($scope.selectedItems) { + // Loop across the users and see if any of them should be "checked" + $scope.add_users.forEach(function(row, i) { + if (_.includes($scope.selectedItems, row.id)) { + $scope.add_users[i].isSelected = true; + } + }); + } + }); + + }); $scope.selectedItems = []; $scope.$on('selectedOrDeselected', function(e, value) { let item = value.value; - if (item.isSelected) { + if (value.isSelected) { $scope.selectedItems.push(item.id); } else { diff --git a/awx/ui/client/src/organizations/linkout/addUsers/addUsers.directive.js b/awx/ui/client/src/organizations/linkout/addUsers/addUsers.directive.js index f146149b13..65c721be17 100644 --- a/awx/ui/client/src/organizations/linkout/addUsers/addUsers.directive.js +++ b/awx/ui/client/src/organizations/linkout/addUsers/addUsers.directive.js @@ -7,7 +7,7 @@ /* jshint unused: vars */ import addUsers from './addUsers.controller'; export default - ['Wait', 'templateUrl', '$state', '$view', function(Wait, templateUrl, $state, $view) { + ['Wait', 'templateUrl', '$state', '$view', '$compile', function(Wait, templateUrl, $state, $view, $compile) { return { restrict: 'E', scope: { @@ -48,6 +48,10 @@ export default scope.closeModal(); }); + scope.compileList = function(html) { + $('#add-users-list').append($compile(html)(scope)); + }; + Wait('stop'); window.scrollTo(0,0); diff --git a/awx/ui/client/src/organizations/linkout/addUsers/addUsers.partial.html b/awx/ui/client/src/organizations/linkout/addUsers/addUsers.partial.html index a8a6cbbb81..b7e24e3940 100644 --- a/awx/ui/client/src/organizations/linkout/addUsers/addUsers.partial.html +++ b/awx/ui/client/src/organizations/linkout/addUsers/addUsers.partial.html @@ -15,8 +15,7 @@ -
-
+
+
+
+ + +
+
What would you like to name the copy of job template ?
-
Please enter a name for this job template copy.
+
Please enter a name for this job template copy.
diff --git a/awx/ui/client/src/partials/organizations-job-template-smart-status.html b/awx/ui/client/src/partials/organizations-job-template-smart-status.html index 1c45c5fe36..d4f8d860b3 100644 --- a/awx/ui/client/src/partials/organizations-job-template-smart-status.html +++ b/awx/ui/client/src/partials/organizations-job-template-smart-status.html @@ -1 +1 @@ - + diff --git a/awx/ui/client/src/partials/scan-job-template-smart-status.html b/awx/ui/client/src/partials/scan-job-template-smart-status.html index cb5787fed1..67d1ef287e 100644 --- a/awx/ui/client/src/partials/scan-job-template-smart-status.html +++ b/awx/ui/client/src/partials/scan-job-template-smart-status.html @@ -1 +1 @@ - + diff --git a/awx/ui/client/src/partials/subhome.html b/awx/ui/client/src/partials/subhome.html index 626c7e4b23..035553e815 100644 --- a/awx/ui/client/src/partials/subhome.html +++ b/awx/ui/client/src/partials/subhome.html @@ -4,11 +4,11 @@ diff --git a/awx/ui/client/src/partials/survey-maker-modal.html b/awx/ui/client/src/partials/survey-maker-modal.html index 7ee3974129..b8f56cadcc 100644 --- a/awx/ui/client/src/partials/survey-maker-modal.html +++ b/awx/ui/client/src/partials/survey-maker-modal.html @@ -10,12 +10,12 @@ @@ -23,8 +23,8 @@
{{name || "New Job Template"}}
SURVEY
-
ON
-
OFF
+ +
@@ -40,9 +40,9 @@
-
PREVIEW
+
PREVIEW
-
PLEASE ADD A SURVEY PROMPT ON THE LEFT.
+
PLEASE ADD A SURVEY PROMPT ON THE LEFT.
  • @@ -73,17 +73,17 @@
-
  • +
  • Drop question here to reorder
  • - - - - + + + +
    diff --git a/awx/ui/client/src/scheduler/scheduleToggle.block.less b/awx/ui/client/src/scheduler/scheduleToggle.block.less index 9733274bfe..8beac5b45c 100644 --- a/awx/ui/client/src/scheduler/scheduleToggle.block.less +++ b/awx/ui/client/src/scheduler/scheduleToggle.block.less @@ -11,12 +11,21 @@ cursor: pointer; display: flex; height: 18px; + + &.ScheduleToggle--disabled { + cursor: not-allowed; + border-color: @b7grey !important; + .ScheduleToggle-switch { + background-color: @b7grey !important; + cursor: not-allowed; + } + } } .ScheduleToggle-switch { color: @default-interface-txt; background-color: @default-bg; - margin-left: 4px; + margin-left: 7px; border-left: 1px solid @default-icon; margin-right: 0px; text-align: center; @@ -28,6 +37,7 @@ margin-top: 0px; border-top: 0px; border-bottom: 0px; + border-right: 0px; } .ScheduleToggle.is-on { diff --git a/awx/ui/client/src/shared/Modal.js b/awx/ui/client/src/shared/Modal.js index e37defa900..e6890ac367 100644 --- a/awx/ui/client/src/shared/Modal.js +++ b/awx/ui/client/src/shared/Modal.js @@ -46,7 +46,7 @@ angular.module('ModalDialog', ['Utilities', 'ParseHelper']) return function(params) { - var scope = params.scope, + let scope = params.scope, buttonSet = params.buttons, width = params.width || 500, height = params.height || 600, diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index 3bb9c02de0..b4e5b31dc8 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -192,7 +192,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) } if (status === 403) { msg = 'The API responded with a 403 Access Denied error. '; - if (data.detail) { + if (data && data.detail) { msg += 'Detail: ' + data.detail; } else { msg += 'Please contact your system administrator.'; @@ -203,7 +203,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) } else if (status === 410) { Alert('Deleted Object', 'The requested object was previously deleted and can no longer be accessed.'); } else if ((status === 'Token is expired') || (status === 401 && data.detail && data.detail === 'Token is expired') || - (status === 401 && data.detail && data.detail === 'Invalid token')) { + (status === 401 && data && data.detail && data.detail === 'Invalid token')) { if ($rootScope.sessionTimer) { $rootScope.sessionTimer.expireSession('idle'); } diff --git a/awx/ui/client/src/shared/directives.js b/awx/ui/client/src/shared/directives.js index ad8a44b374..1dcfb7ac62 100644 --- a/awx/ui/client/src/shared/directives.js +++ b/awx/ui/client/src/shared/directives.js @@ -159,6 +159,7 @@ function(ConfigurationUtils, i18n, $rootScope) { var filePickerText = angular.element(document.getElementById('filePickerText')); var filePickerError = angular.element(document.getElementById('filePickerError')); var filePickerButton = angular.element(document.getElementById('filePickerButton')); + var filePicker = angular.element(document.getElementById('filePicker')); scope.imagePresent = global.$AnsibleConfig.custom_logo || false; scope.imageData = $rootScope.custom_logo; @@ -168,12 +169,18 @@ function(ConfigurationUtils, i18n, $rootScope) { scope.imageData = $rootScope.custom_logo; }); - scope.update = function(e) { - if(scope.$parent[fieldKey]) { + scope.$on(fieldKey+'_reverted', function(e) { + scope.update(e, true); + }); + + scope.update = function(e, flag) { + if(scope.$parent[fieldKey] || flag ) { e.preventDefault(); scope.$parent[fieldKey] = ''; filePickerButton.html(browseText); filePickerText.val(''); + filePicker.context.value = ""; + scope.imagePresent = false; } else { // Nothing exists so open file picker diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index 6d2809181e..600675f341 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -545,11 +545,12 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += " " + field.columnClass; html += "\">
    " + i18n._("ON") + "
    "; + html += "' class='ScheduleToggle-switch' ng-click='" + field.ngClick + "'>" + i18n._("OFF") + ""; } return html; }, @@ -678,7 +679,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat if(field.reset && !field.disabled) { var resetValue = "'" + field.reset+ "'"; - var resetMessage = i18n._('Reset'); + var resetMessage = i18n._('Revert'); html+= `${resetMessage}`; } @@ -693,6 +694,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += " " + field.columnClass; html += "\">
    ON
    ${definedInFileMessage}` : ``; + // toggle switches if(field.type === 'toggleSwitch') { html += label(); html += `
    -
    ON
    -
    OFF
    + data-placement="top"> + +
    `; } @@ -1010,6 +1016,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += (field.ngDisabled) ? this.attr(field, 'ngDisabled'): ""; html += (field.required) ? "required " : ""; html += (field.ngRequired) ? "ng-required=\"" + field.ngRequired +"\"" : ""; + html += (field.ngTrim !== undefined) ? "ng-trim=\"" + field.ngTrim +"\"" : ""; html += (field.readonly || field.showonly) ? "readonly " : ""; html += (field.awDropFile) ? "aw-drop-file " : ""; if(field.awRequiredWhen) { @@ -1852,6 +1859,12 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat width = "col-lg-8 col-md-8 col-sm-8 col-xs-12"; } + if(actionButtons.length>0){ + html += `
    + ${actionButtons} +
    `; + } + // smart-search directive html += `
    `; - if(actionButtons.length>0){ - html += `
    - ${actionButtons} -
    `; - } + //html += "
    "; // Message for when a search returns no results. This should only get shown after a search is executed with no results. diff --git a/awx/ui/client/src/shared/generator-helpers.js b/awx/ui/client/src/shared/generator-helpers.js index 7942c02163..171a72ffa0 100644 --- a/awx/ui/client/src/shared/generator-helpers.js +++ b/awx/ui/client/src/shared/generator-helpers.js @@ -449,8 +449,8 @@ angular.module('GeneratorHelpers', [systemStatus.name]) }; }]) -.factory('Column', ['Attr', 'Icon', 'DropDown', 'Badge', 'BadgeCount', 'BuildLink', 'Template', - function (Attr, Icon, DropDown, Badge, BadgeCount, BuildLink, Template) { +.factory('Column', ['i18n', 'Attr', 'Icon', 'DropDown', 'Badge', 'BadgeCount', 'BuildLink', 'Template', + function (i18n, Attr, Icon, DropDown, Badge, BadgeCount, BuildLink, Template) { return function (params) { var list = params.list, fld = params.fld, @@ -507,18 +507,19 @@ angular.module('GeneratorHelpers', [systemStatus.name]) } else if (field.type === 'toggle') { html += "
    "; + html += "' class='ScheduleToggle-switch' ng-click='" + field.ngClick + "'>" + i18n._("OFF") + "
    "; } else { html += ""; + // Don't display an empty
    if there is no listTitle + if ((options.title !== false && list.title !== false) && list.listTitle !== undefined) { + html += "
    "; html += "
    "; - if (list.listTitle && options.listTitle !== false) { - html += "
    " + list.listTitle + "
    "; // We want to show the list title badge by default and only hide it when the list config specifically passes a false flag list.listTitleBadge = (typeof list.listTitleBadge === 'boolean' && list.listTitleBadge === false) ? false : true; if (list.listTitleBadge) { html += `{{ ${list.iterator}_dataset.count }}`; } - } - html += "
    "; if (options.cancelButton === true) { html += "
    "; @@ -155,27 +152,10 @@ export default ['$location', '$compile', '$rootScope', 'Attr', 'Icon', html += ""; html += "
    \n"; } - html += "
    "; - html += "
    "; - html += `
    `; - - for (action in list.actions) { - list.actions[action] = _.defaults(list.actions[action], { dataPlacement: "top" }); - } - - html += "
    "; - if (list.toolbarAuxAction) { - html += "
    "; - html += list.toolbarAuxAction; - html += "
    "; - } - html += "\n
    "; - html += "
    "; html += "
    "; } } - if (options.mode === 'edit' && list.editInstructions) { html += "
    \n"; html += "\n"; @@ -191,6 +171,22 @@ export default ['$location', '$compile', '$rootScope', 'Attr', 'Icon', if (options.mode !== 'lookup' && (list.well === undefined || list.well)) { html += `
    `; + // List actions + html += "
    "; + html += "
    "; + html += `
    `; + + for (action in list.actions) { + list.actions[action] = _.defaults(list.actions[action], { dataPlacement: "top" }); + } + + html += "
    "; + if (list.toolbarAuxAction) { + html += `
    ${list.toolbarAuxAction}
    `; + } + html += "\n
    "; + html += "
    "; + // End list actions } html += (list.searchRowActions) ? "
    " : ""; @@ -497,7 +493,7 @@ export default ['$location', '$compile', '$rootScope', 'Attr', 'Icon', collection="${list.name}" dataset="${list.iterator}_dataset" column-sort - column-field="${fld}" + column-field="${list.fields[fld].searchField || fld}" column-iterator="${list.iterator}" column-no-sort="${list.fields[fld].nosort}" column-label="${list.fields[fld].label}" diff --git a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js index a9eeff647e..aa8397e904 100644 --- a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js +++ b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js @@ -30,7 +30,7 @@ export default item: '=item' }, require: '^multiSelectList', - template: '', + template: '', link: function(scope, element, attrs, multiSelectList) { scope.decoratedItem = multiSelectList.registerItem(scope.item); diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js index 445c07b7a0..7326a797e8 100644 --- a/awx/ui/client/src/shared/paginate/paginate.controller.js +++ b/awx/ui/client/src/shared/paginate/paginate.controller.js @@ -7,9 +7,21 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q $scope.pageSize = pageSize; function init() { - $scope.pageRange = calcPageRange($scope.current(), $scope.last()); - $scope.dataRange = calcDataRange(); + + let updatePaginationVariables = function() { + $scope.current = calcCurrent(); + $scope.last = calcLast(); + $scope.pageRange = calcPageRange($scope.current, $scope.last); + $scope.dataRange = calcDataRange(); + }; + + updatePaginationVariables(); + + $scope.$watch('collection', function(){ + updatePaginationVariables(); + }); } + $scope.dataCount = function() { return $filter('number')($scope.dataset.count); }; @@ -48,22 +60,22 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q $scope.dataset = res.data; $scope.collection = res.data.results; }); - $scope.pageRange = calcPageRange($scope.current(), $scope.last()); + $scope.pageRange = calcPageRange($scope.current, $scope.last); $scope.dataRange = calcDataRange(); }; - $scope.current = function() { + function calcLast() { + return Math.ceil($scope.dataset.count / pageSize); + } + + function calcCurrent() { if($scope.querySet) { return parseInt($scope.querySet.page || '1'); } else { return parseInt($stateParams[`${$scope.iterator}_search`].page || '1'); } - }; - - $scope.last = function() { - return Math.ceil($scope.dataset.count / pageSize); - }; + } function calcPageRange(current, last) { let result = [], @@ -95,12 +107,12 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q } function calcDataRange() { - if ($scope.current() === 1 && $scope.dataset.count < parseInt(pageSize)) { + if ($scope.current === 1 && $scope.dataset.count < parseInt(pageSize)) { return `1 - ${$scope.dataset.count}`; - } else if ($scope.current() === 1) { + } else if ($scope.current === 1) { return `1 - ${pageSize}`; } else { - let floor = (($scope.current() - 1) * parseInt(pageSize)) + 1; + let floor = (($scope.current - 1) * parseInt(pageSize)) + 1; let ceil = floor + parseInt(pageSize) < $scope.dataset.count ? floor + parseInt(pageSize) : $scope.dataset.count; return `${floor} - ${ceil}`; } diff --git a/awx/ui/client/src/shared/paginate/paginate.partial.html b/awx/ui/client/src/shared/paginate/paginate.partial.html index b00cc97fa1..7f8dc186c9 100644 --- a/awx/ui/client/src/shared/paginate/paginate.partial.html +++ b/awx/ui/client/src/shared/paginate/paginate.partial.html @@ -1,38 +1,38 @@
    -
    +
    Page - {{current()}} of - {{last()}} + {{current}} of + {{last}}
    diff --git a/awx/ui/client/src/shared/smart-search/django-search-model.class.js b/awx/ui/client/src/shared/smart-search/django-search-model.class.js index 5271a38a30..1866d41ff2 100644 --- a/awx/ui/client/src/shared/smart-search/django-search-model.class.js +++ b/awx/ui/client/src/shared/smart-search/django-search-model.class.js @@ -1,28 +1,3 @@ -// Ignored fields are not surfaced in the UI's search key -let isIgnored = function(key, value) { - let ignored = [ - 'type', - 'url', - 'related', - 'summary_fields', - 'object_roles', - 'activity_stream', - 'update', - 'teams', - 'users', - 'owner_teams', - 'owner_users', - 'access_list', - 'notification_templates_error', - 'notification_templates_success', - 'ad_hoc_command_events', - 'fact_versions', - 'variable_data', - 'playbooks' - ]; - return ignored.indexOf(key) > -1 || value.type === 'field'; -}; - export default class DjangoSearchModel { /* @@ -36,21 +11,40 @@ class DjangoSearchModel { } @@property related ['field' ...] */ - constructor(name, endpoint, baseFields, relations) { - let base = {}; + constructor(name, baseFields, relatedSearchFields) { + function trimRelated(relatedSearchField){ + return relatedSearchField.replace(/\__search$/, ""); + } this.name = name; - this.related = _.reject(relations, isIgnored); + this.searchExamples = []; + this.related = _.uniq(_.map(relatedSearchFields, trimRelated)); + // Remove "object" type fields from this list + for (var key in baseFields) { + if (baseFields.hasOwnProperty(key)) { + if (baseFields[key].type === 'object'){ + delete baseFields[key]; + } + } + } + delete baseFields.url; + this.base = baseFields; + if(baseFields.id) { + this.searchExamples.push("id:>10"); + } + // Loop across the base fields and try find one of type = string and one of type = datetime + let stringFound = false, + dateTimeFound = false; + _.forEach(baseFields, (value, key) => { - if (!isIgnored(key, value)) { - base[key] = value; + if(!stringFound && value.type === 'string') { + this.searchExamples.push(key + ":foobar"); + stringFound = true; + } + if(!dateTimeFound && value.type === 'datetime') { + this.searchExamples.push(key + ":>=\"2000-01-01T00:00:00Z\""); + this.searchExamples.push(key + ":<2000-01-01"); + dateTimeFound = true; } }); - this.base = base; - } - - fields() { - let result = this.base; - result.related = this.related; - return result; } } diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index 877222da11..57bc43aae6 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -1,42 +1,31 @@ -export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', 'SmartSearchService', - function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory, SmartSearchService) { +export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', 'SmartSearchService', + function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, SmartSearchService) { return { // kick off building a model for a specific endpoint // this is usually a list's basePath // unified_jobs is the exception, where we need to fetch many subclass OPTIONS and summary_fields - initFieldset(path, name, relations) { - // get or set $cachFactory.Cache object with id '$http' - let defer = $q.defer(), - cache = $cacheFactory.get('$http') || $cacheFactory('$http'); - defer.resolve(this.getCommonModelOptions(path, name, relations, cache)); + initFieldset(path, name) { + let defer = $q.defer(); + defer.resolve(this.getCommonModelOptions(path, name)); return defer.promise; }, - getCommonModelOptions(path, name, relations, cache) { + getCommonModelOptions(path, name) { let resolve, base, defer = $q.defer(); - // grab a single model from the cache, if present - if (cache.get(path)) { - defer.resolve({ - models: { - [name] : new DjangoSearchModel(name, path, cache.get(path), relations) - }, - options: cache.get(path) - }); - } else { - this.url = path; - resolve = this.options(path) - .then((res) => { - base = res.data.actions.GET; - defer.resolve({ - models: { - [name]: new DjangoSearchModel(name, path, base, relations) - }, - options: res - }); + this.url = path; + resolve = this.options(path) + .then((res) => { + base = res.data.actions.GET; + let relatedSearchFields = res.data.related_search_fields; + defer.resolve({ + models: { + [name]: new DjangoSearchModel(name, base, relatedSearchFields) + }, + options: res }); - } + }); return defer.promise; }, @@ -76,7 +65,7 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear let concated = ''; angular.forEach(value, function(item){ if(item && typeof item === 'string') { - item = item.replace(/"|'/g, ""); + item = decodeURIComponent(item).replace(/"|'/g, ""); } concated += `${key}=${item}&`; }); @@ -84,7 +73,7 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear } else { if(value && typeof value === 'string') { - value = value.replace(/"|'/g, ""); + value = decodeURIComponent(value).replace(/"|'/g, ""); } return `${key}=${value}&`; } @@ -139,7 +128,7 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear } } - return {[paramString] : valueString}; + return {[paramString] : encodeURIComponent(valueString)}; }, // decodes a django queryset param into a ui smart-search tag or set of tags decodeParam(value, key){ @@ -175,7 +164,10 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear decodedParam = '<=' + decodedParam; split = split.splice(0, split.length-1); } - return exclude ? `-${split.join('.')}:${decodedParam}` : `${split.join('.')}:${decodedParam}`; + + let uriDecodedParam = decodeURIComponent(decodedParam); + + return exclude ? `-${split.join('.')}:${uriDecodedParam}` : `${split.join('.')}:${uriDecodedParam}`; } }; @@ -264,13 +256,13 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear this.error(response.data, response.status); - return response; + throw response; }.bind(this)); }, error(data, status) { - ProcessErrors($rootScope, data, status, null, { + ProcessErrors($rootScope, null, status, null, { hdr: 'Error!', - msg: 'Call to ' + this.url + '. GET returned: ' + status + msg: "Invalid search term entered." }); } }; diff --git a/awx/ui/client/src/shared/smart-search/smart-search.block.less b/awx/ui/client/src/shared/smart-search/smart-search.block.less index fee822d7c7..3163ec945b 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.block.less +++ b/awx/ui/client/src/shared/smart-search/smart-search.block.less @@ -35,7 +35,7 @@ .SmartSearch-searchTermContainer { flex: initial; - width: ~"calc(100% - 100px)"; + width: 50%; border: 1px solid @d7grey; border-radius: 4px; display: flex; @@ -75,6 +75,7 @@ cursor: pointer; border-top-right-radius: 5px; border-bottom-right-radius: 5px; + z-index: 1; } .SmartSearch-searchButton:hover { @@ -158,7 +159,8 @@ padding-top: 5px; } .SmartSearch-keyToggle { - margin-left: auto; + margin-right: auto; + margin-left: 12px; text-transform: uppercase; background-color: @default-bg; border-radius: 5px; @@ -184,7 +186,7 @@ } .SmartSearch-keyPane { - max-height: 200px; + max-height: 215px; overflow: auto; margin: 0px 0px 20px 0px; font-size: 12px; @@ -233,3 +235,12 @@ padding: 0px 5px; margin-right: 5px; } + + +// Additional modal specific styles +.modal-body, #add-permissions-modal, +.JobResults { + .SmartSearch-searchTermContainer { + width: 100%; + } +} diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js index 22a91ec61a..d3b1d216f1 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -1,7 +1,7 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', 'QuerySet', 'SmartSearchService', 'i18n', function($stateParams, $scope, $state, QuerySet, GetBasePath, qs, SmartSearchService, i18n) { - let path, relations, + let path, defaults, queryset, stateChangeSuccessListener; @@ -12,7 +12,9 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' else { // steps through the current tree of $state configurations, grabs default search params defaults = _.find($state.$current.path, (step) => { - return step.params.hasOwnProperty(`${$scope.iterator}_search`); + if(step && step.params && step.params.hasOwnProperty(`${$scope.iterator}_search`)){ + return step.params.hasOwnProperty(`${$scope.iterator}_search`); + } }).params[`${$scope.iterator}_search`].config.value; } @@ -28,9 +30,8 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' function init() { path = GetBasePath($scope.basePath) || $scope.basePath; - relations = getRelationshipFields($scope.dataset.results); $scope.searchTags = stripDefaultParams($state.params[`${$scope.iterator}_search`]); - qs.initFieldset(path, $scope.djangoModel, relations).then((data) => { + qs.initFieldset(path, $scope.djangoModel).then((data) => { $scope.models = data.models; $scope.options = data.options.data; $scope.$emit(`${$scope.list.iterator}_options`, data.options); @@ -107,14 +108,6 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' return _(strippedCopy).map(qs.decodeParam).flatten().value(); } - // searchable relationships - function getRelationshipFields(dataset) { - let flat = _(dataset).map((value) => { - return _.keys(value.related); - }).flatten().uniq().value(); - return flat; - } - function setDefaults(term) { if ($scope.list.defaultSearchParams) { return $scope.list.defaultSearchParams(term); @@ -148,9 +141,25 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' // remove tag, merge new queryset, $state.go $scope.remove = function(index) { - let tagToRemove = $scope.searchTags.splice(index, 1)[0]; - let termParts = SmartSearchService.splitTermIntoParts(tagToRemove); - let removed; + let tagToRemove = $scope.searchTags.splice(index, 1)[0], + termParts = SmartSearchService.splitTermIntoParts(tagToRemove), + removed; + + let removeFromQuerySet = function(set) { + _.each(removed, (value, key) => { + if (Array.isArray(set[key])){ + _.remove(set[key], (item) => item === value); + // If the array is now empty, remove that key + if(set[key].length === 0) { + delete set[key]; + } + } + else { + delete set[key]; + } + }); + }; + if (termParts.length === 1) { removed = setDefaults(tagToRemove); } @@ -159,31 +168,38 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' let encodeParams = { term: tagToRemove }; - if(_.has($scope.options.actions.GET, root)) { - if($scope.options.actions.GET[root].type && $scope.options.actions.GET[root].type === 'field') { + if($scope.models[$scope.list.name]) { + if(_.has($scope.models[$scope.list.name].base, root)) { + if($scope.models[$scope.list.name].base[root].type && $scope.models[$scope.list.name].base[root].type === 'field') { + encodeParams.relatedSearchTerm = true; + } + else { + encodeParams.searchTerm = true; + } + removed = qs.encodeParam(encodeParams); + } + else if(_.contains($scope.models[$scope.list.name].related, root)) { encodeParams.relatedSearchTerm = true; + removed = qs.encodeParam(encodeParams); } else { - encodeParams.searchTerm = true; - } - } - removed = qs.encodeParam(encodeParams); - } - _.each(removed, (value, key) => { - if (Array.isArray(queryset[key])){ - _.remove(queryset[key], (item) => item === value); - // If the array is now empty, remove that key - if(queryset[key].length === 0) { - delete queryset[key]; + removed = setDefaults(termParts[termParts.length-1]); } } else { - delete queryset[key]; + removed = setDefaults(termParts[termParts.length-1]); } - }); + } + removeFromQuerySet(queryset); if(!$scope.querySet) { $state.go('.', { - [$scope.iterator + '_search']: queryset }, {notify: false}); + [$scope.iterator + '_search']: queryset }, {notify: false}).then(function(){ + // ISSUE: for some reason deleting a tag from a list in a modal does not + // remove the param from $stateParams. Here we'll manually check to make sure + // that that happened and remove it if it didn't. + + removeFromQuerySet($stateParams[`${$scope.iterator}_search`]); + }); } qs.search(path, queryset).then((res) => { if($scope.querySet) { @@ -227,14 +243,21 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' } else { // Figure out if this is a search term let root = termParts[0].split(".")[0].replace(/^-/, ''); - if(_.has($scope.options.actions.GET, root)) { - if($scope.options.actions.GET[root].type && $scope.options.actions.GET[root].type === 'field') { + if(_.has($scope.models[$scope.list.name].base, root)) { + if($scope.models[$scope.list.name].base[root].type && $scope.models[$scope.list.name].base[root].type === 'field') { params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true}), combineSameSearches); } else { params = _.merge(params, qs.encodeParam({term: term, searchTerm: true}), combineSameSearches); } } + // The related fields need to also be checked for related searches. + // The related fields for the search are retrieved from the API + // options endpoint, and are stored in the $scope.model. FYI, the + // Django search model is what sets the related fields on the model. + else if(_.contains($scope.models[$scope.list.name].related, root)) { + params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true}), combineSameSearches); + } // Its not a search term or a related search term - treat it as a string else { params = _.merge(params, setDefaults(term), combineSameSearches); @@ -243,7 +266,6 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' } }); - params.page = '1'; queryset = _.merge(queryset, params, (objectValue, sourceValue, key, object) => { if (object[key] && object[key] !== sourceValue){ if(_.isArray(object[key])) { @@ -262,12 +284,20 @@ export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', ' return undefined; } }); + + // Go back to the first page after a new search + delete queryset.page; + // https://ui-router.github.io/docs/latest/interfaces/params.paramdeclaration.html#dynamic // This transition will not reload controllers/resolves/views // but will register new $stateParams[$scope.iterator + '_search'] terms if(!$scope.querySet) { $state.go('.', { - [$scope.iterator + '_search']: queryset }, {notify: false}); + [$scope.iterator + '_search']: queryset }, {notify: false}).then(function(){ + // ISSUE: same as above in $scope.remove. For some reason deleting the page + // from the queryset works for all lists except lists in modals. + delete $stateParams[$scope.iterator + '_search'].page; + }); } qs.search(path, queryset).then((res) => { if($scope.querySet) { diff --git a/awx/ui/client/src/shared/smart-search/smart-search.partial.html b/awx/ui/client/src/shared/smart-search/smart-search.partial.html index b19b011698..9f589a9d1d 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.partial.html +++ b/awx/ui/client/src/shared/smart-search/smart-search.partial.html @@ -15,7 +15,6 @@ Key
    -
    @@ -39,19 +38,17 @@
    EXAMPLES:
    - - - +
    FIELDS: {{ key }},
    -
    +
    RELATED FIELDS: {{ relation }},
    - ADDITIONAL INFORMATION: For additional information on advanced search search syntax please see the Ansible Tower documentation. + ADDITIONAL INFORMATION: For additional information on advanced search search syntax please see the Ansible Tower documentation.
    diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index f6f0bf1950..6a3e530401 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -272,6 +272,9 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto dynamic: true } }, + ncyBreadcrumb:{ + skip:true + }, views: { [`modal@${formStateDefinition.name}`]: { template: `` @@ -342,6 +345,9 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto template: `` } }, + ncyBreadcrumb:{ + skip:true + }, resolve: { usersDataset: ['addPermissionsUsersList', 'QuerySet', '$stateParams', 'GetBasePath', function(list, qs, $stateParams, GetBasePath) { @@ -511,6 +517,9 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto template: `` } }, + ncyBreadcrumb:{ + skip:true + }, resolve: { usersDataset: ['addPermissionsUsersList', 'QuerySet', '$stateParams', 'GetBasePath', function(list, qs, $stateParams, GetBasePath) { @@ -646,6 +655,32 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto generateLookupNodes: function(form, formStateDefinition) { function buildFieldDefinition(field) { + + // Some lookup modals require some additional default params, + // namely organization and inventory_script. If these params + // aren't set as default params out of the gate, then smart + // search will think they need to be set as search tags. + var params; + if(field.sourceModel === "organization"){ + params = { + page_size: '5', + role_level: 'admin_role' + }; + } + else if(field.sourceModel === "inventory_script"){ + params = { + page_size: '5', + role_level: 'admin_role', + organization: null + }; + } + else { + params = { + page_size: '5', + role_level: 'use_role' + }; + } + let state = $stateExtender.buildDefinition({ searchPrefix: field.sourceModel, //squashSearchUrl: true, @issue enable @@ -658,10 +693,7 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto }, params: { [field.sourceModel + '_search']: { - value: { - page_size: '5', - role_level: 'use_role' - } + value: params } }, ncyBreadcrumb: { diff --git a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.partial.html b/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.partial.html index eff50b6290..c2baf24ee9 100644 --- a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.partial.html +++ b/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.partial.html @@ -9,8 +9,8 @@
    - - + +
    diff --git a/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html b/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html index 0f398643b9..451f10c5e8 100644 --- a/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html +++ b/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html @@ -9,8 +9,8 @@
    - - + +
    diff --git a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.partial.html b/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.partial.html index 5ec35cc675..bb7a6c1e64 100644 --- a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.partial.html +++ b/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.partial.html @@ -8,8 +8,8 @@ RESULTS
    - - + +
    diff --git a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html index 62d7b6045d..458b2c6dd2 100644 --- a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html +++ b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html @@ -9,8 +9,8 @@
    - - + +
    @@ -18,7 +18,7 @@
    NAME
    - + {{ project_name }}
    PROJECT
    diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js index 8da8a4c034..59f1ccd310 100644 --- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js +++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js @@ -266,6 +266,7 @@ export default var dft; master = masterObject; + getPlaybooks($scope.project); dft = ($scope.host_config_key === "" || $scope.host_config_key === null) ? false : true; diff --git a/awx/ui/client/src/templates/list/templates-list.controller.js b/awx/ui/client/src/templates/list/templates-list.controller.js index d3e07c6fda..5fc6695ca2 100644 --- a/awx/ui/client/src/templates/list/templates-list.controller.js +++ b/awx/ui/client/src/templates/list/templates-list.controller.js @@ -59,12 +59,10 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', 'Rest', // Set the item type label if (list.fields.type && $scope.options.hasOwnProperty('type')) { - $scope.options.type.choices.every(function(choice) { + $scope.options.type.choices.forEach(function(choice) { if (choice[0] === item.type) { itm.type_label = choice[1]; - return false; } - return true; }); } }); diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index 887e362e02..e5f2d0aab3 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -102,8 +102,9 @@ angular.module('templates', [surveyMaker.name, templatesList.name, jobTemplatesA job_template_search: { value: { page_size: '5', - type: 'job_template', - order_by: 'name' + order_by: 'name', + inventory__isnull: false, + credential__isnull: false }, squash: true, dynamic: true @@ -451,6 +452,7 @@ angular.module('templates', [surveyMaker.name, templatesList.name, jobTemplatesA WorkflowMakerJobTemplateList: ['TemplateList', (TemplateList) => { let list = _.cloneDeep(TemplateList); + delete list.actions; delete list.fields.type; delete list.fields.description; delete list.fields.smart_status; @@ -459,6 +461,7 @@ angular.module('templates', [surveyMaker.name, templatesList.name, jobTemplatesA list.fields.name.columnClass = "col-md-8"; list.iterator = 'job_template'; list.name = 'job_templates'; + list.basePath = "job_templates"; list.fields.info = { ngInclude: "'/static/partials/job-template-details.html'", type: 'template', diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index 4399e6504a..bdb280e963 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -87,6 +87,7 @@ .WorkflowChart-detailsLink { fill: @default-link; cursor: pointer; + font-size: 10px; } .WorkflowChart-incompleteIcon { color: @default-warning; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 642dc26971..e7379fe19c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -4,13 +4,14 @@ * All Rights Reserved *************************************************/ -export default [ '$state','moment', - function($state, moment) { +export default [ '$state','moment', '$timeout', '$window', + function($state, moment, $timeout, $window) { return { scope: { treeData: '=', canAddWorkflowJobTemplate: '=', + workflowJobTemplateObj: '=', addNode: '&', editNode: '&', deleteNode: '&', @@ -21,38 +22,89 @@ export default [ '$state','moment', link: function(scope, element) { let margin = {top: 20, right: 20, bottom: 20, left: 20}, - width = 950, - height = 550, i = 0, nodeW = 120, nodeH = 60, rootW = 60, - rootH = 40; + rootH = 40, + startNodeOffsetY = scope.mode === 'details' ? 17 : 10, + verticalSpaceBetweenNodes = 20, + windowHeight, + windowWidth, + tree, + line, + zoomObj, + baseSvg, + svgGroup; - let tree = d3.layout.tree() - .size([height, width]); + scope.dimensionsSet = false; - let line = d3.svg.line() - .x(function(d){return d.x;}) - .y(function(d){return d.y;}); + $timeout(function(){ + let dimensions = calcAvailableScreenSpace(); - let zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); + windowHeight = dimensions.height; + windowWidth = dimensions.width; - let baseSvg = d3.select(element[0]).append("svg") - .attr("width", width - margin.right - margin.left) - .attr("height", height - margin.top - margin.bottom) - .attr("class", "WorkflowChart-svg") - .call(zoomObj - .on("zoom", naturalZoom) - ); + $('.WorkflowMaker-chart').css("height", windowHeight); + $('.WorkflowMaker-chart').css("width", windowWidth); - let svgGroup = baseSvg.append("g") - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + scope.dimensionsSet = true; + + init(); + }); + + function init() { + tree = d3.layout.tree() + .nodeSize([nodeH + verticalSpaceBetweenNodes,nodeW]) + .separation(function(a, b) { + // This should tighten up some of the other nodes so there's not so much wasted space + return a.parent === b.parent ? 1 : 1.25; + }); + + line = d3.svg.line() + .x(function(d){return d.x;}) + .y(function(d){return d.y;}); + + zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); + + baseSvg = d3.select(element[0]).append("svg") + .attr("class", "WorkflowChart-svg") + .call(zoomObj + .on("zoom", naturalZoom) + ); + + svgGroup = baseSvg.append("g") + .attr("transform", "translate(" + margin.left + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); + } + + function calcAvailableScreenSpace() { + let dimensions = {}; + + if(scope.mode !== 'details') { + // This is the workflow editor + dimensions.height = $('.WorkflowMaker-contentLeft').outerHeight() - $('.WorkflowLegend-maker').outerHeight(); + dimensions.width = $('#workflow-modal-dialog').width() - $('.WorkflowMaker-contentRight').outerWidth(); + } + else { + // This is the workflow details view + let panel = $('.WorkflowResults-rightSide').children('.Panel')[0]; + let panelWidth = $(panel).width(); + let panelHeight = $(panel).height(); + let headerHeight = $('.StandardOut-panelHeader').outerHeight(); + let legendHeight = $('.WorkflowLegend-details').outerHeight(); + let proposedHeight = panelHeight - headerHeight - legendHeight - 40; + + dimensions.height = proposedHeight > 200 ? proposedHeight : 200; + dimensions.width = panelWidth; + } + + return dimensions; + } function lineData(d){ let sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + nodeW; - let sourceY = d.source.isStartNode ? d.source.x + 10 + rootH / 2 : d.source.x + nodeH / 2; + let sourceY = d.source.isStartNode ? d.source.x + startNodeOffsetY + rootH / 2 : d.source.x + nodeH / 2; let targetX = d.target.y; let targetY = d.target.x + nodeH / 2; @@ -108,7 +160,7 @@ export default [ '$state','moment', let scale = d3.event.scale, translation = d3.event.translate; - translation = [translation[0] + (margin.left*scale), translation[1] + (margin.top*scale)]; + translation = [translation[0] + (margin.left*scale), translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); @@ -122,12 +174,12 @@ export default [ '$state','moment', let scale = zoom / 100, translation = zoomObj.translate(), origZoom = zoomObj.scale(), - unscaledOffsetX = (translation[0] + ((width*origZoom) - width)/2)/origZoom, - unscaledOffsetY = (translation[1] + ((height*origZoom) - height)/2)/origZoom, - translateX = unscaledOffsetX*scale - ((scale*width)-width)/2, - translateY = unscaledOffsetY*scale - ((scale*height)-height)/2; + unscaledOffsetX = (translation[0] + ((windowWidth*origZoom) - windowWidth)/2)/origZoom, + unscaledOffsetY = (translation[1] + ((windowHeight*origZoom) - windowHeight)/2)/origZoom, + translateX = unscaledOffsetX*scale - ((scale*windowWidth)-windowWidth)/2, + translateY = unscaledOffsetY*scale - ((scale*windowHeight)-windowHeight)/2; - svgGroup.attr("transform", "translate(" + [translateX + (margin.left*scale), translateY + (margin.top*scale)] + ")scale(" + scale + ")"); + svgGroup.attr("transform", "translate(" + [translateX + (margin.left*scale), translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); zoomObj.scale(scale); zoomObj.translate([translateX, translateY]); } @@ -145,577 +197,590 @@ export default [ '$state','moment', translateX = translateCoords[0]; translateY = direction === 'up' ? translateCoords[1] - distance : translateCoords[1] + distance; } - svgGroup.attr("transform", "translate(" + translateX + "," + translateY + ")scale(" + scale + ")"); + svgGroup.attr("transform", "translate(" + translateX + "," + (translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)) + ")scale(" + scale + ")"); zoomObj.translate([translateX, translateY]); } function resetZoomAndPan() { - svgGroup.attr("transform", "translate(" + margin.left + "," + margin.top + ")scale(" + 1 + ")"); + svgGroup.attr("transform", "translate(" + margin.left + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); // Update the zoomObj zoomObj.scale(1); zoomObj.translate([0,0]); } function update() { - // Declare the nodes - let nodes = tree.nodes(scope.treeData), - links = tree.links(nodes); - let node = svgGroup.selectAll("g.node") - .data(nodes, function(d) { - d.y = d.depth * 180; - return d.id || (d.id = ++i); - }); + let userCanAddEdit = (scope.workflowJobTemplateObjt && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate; + if(scope.dimensionsSet) { + // Declare the nodes + let nodes = tree.nodes(scope.treeData), + links = tree.links(nodes); + let node = svgGroup.selectAll("g.node") + .data(nodes, function(d) { + d.y = d.depth * 180; + return d.id || (d.id = ++i); + }); - let nodeEnter = node.enter().append("g") - .attr("class", "node") - .attr("id", function(d){return "node-" + d.id;}) - .attr("parent", function(d){return d.parent ? d.parent.id : null;}) - .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); + let nodeEnter = node.enter().append("g") + .attr("class", "node") + .attr("id", function(d){return "node-" + d.id;}) + .attr("parent", function(d){return d.parent ? d.parent.id : null;}) + .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); - nodeEnter.each(function(d) { - let thisNode = d3.select(this); - if(d.isStartNode && scope.mode === 'details') { - // Overwrite the default root height and width and replace it with a small blue square - rootW = 25; - rootH = 25; - thisNode.append("rect") - .attr("width", rootW) - .attr("height", rootH) - .attr("y", 10) - .attr("rx", 5) - .attr("ry", 5) - .attr("fill", "#337ab7") - .attr("class", "WorkflowChart-rootNode"); - } - else if(d.isStartNode && scope.mode !== 'details') { - thisNode.append("rect") - .attr("width", rootW) - .attr("height", rootH) - .attr("y", 10) - .attr("rx", 5) - .attr("ry", 5) - .attr("fill", "#5cb85c") - .attr("class", "WorkflowChart-rootNode") - .call(add_node); - thisNode.append("text") - .attr("x", 13) - .attr("y", 30) - .attr("dy", ".35em") - .attr("class", "WorkflowChart-startText") - .text(function () { return "START"; }) - .call(add_node); - } - else { - thisNode.append("rect") - .attr("width", nodeW) - .attr("height", nodeH) - .attr("rx", 5) - .attr("ry", 5) - .attr('stroke', function(d) { - if(d.job && d.job.status) { - if(d.job.status === "successful"){ - return "#5cb85c"; - } - else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { - return "#d9534f"; + nodeEnter.each(function(d) { + let thisNode = d3.select(this); + if(d.isStartNode && scope.mode === 'details') { + // Overwrite the default root height and width and replace it with a small blue square + rootW = 25; + rootH = 25; + thisNode.append("rect") + .attr("width", rootW) + .attr("height", rootH) + .attr("y", startNodeOffsetY) + .attr("rx", 5) + .attr("ry", 5) + .attr("fill", "#337ab7") + .attr("class", "WorkflowChart-rootNode"); + } + else if(d.isStartNode && scope.mode !== 'details') { + thisNode.append("rect") + .attr("width", rootW) + .attr("height", rootH) + //.attr("y", (windowHeight-margin.top-margin.bottom)/2 - rootH) + .attr("y", 10) + .attr("rx", 5) + .attr("ry", 5) + .attr("fill", "#5cb85c") + .attr("class", "WorkflowChart-rootNode") + .call(add_node); + thisNode.append("text") + .attr("x", 13) + //.attr("y", (windowHeight-margin.top-margin.bottom)/2 - rootH + rootH/2) + .attr("y", 30) + .attr("dy", ".35em") + .attr("class", "WorkflowChart-startText") + .text(function () { return "START"; }) + .call(add_node); + } + else { + thisNode.append("rect") + .attr("width", nodeW) + .attr("height", nodeH) + .attr("rx", 5) + .attr("ry", 5) + .attr('stroke', function(d) { + if(d.job && d.job.status) { + if(d.job.status === "successful"){ + return "#5cb85c"; + } + else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { + return "#d9534f"; + } + else { + return "#D7D7D7"; + } } else { return "#D7D7D7"; } + }) + .attr('stroke-width', "2px") + .attr("class", function(d) { + return d.placeholder ? "rect placeholder" : "rect"; + }); + + thisNode.append("path") + .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) + .attr("class", "WorkflowChart-activeNode") + .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); + + thisNode.append("text") + .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) + .attr("dy", ".35em") + .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) + .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") + .text(function (d) { + return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; + }).each(wrap); + + thisNode.append("foreignObject") + .attr("x", 43) + .attr("y", 45) + .style("font-size","0.7em") + .attr("class", "WorkflowChart-conflictText") + .html(function () { + return "\uf06a EDGE CONFLICT"; + }) + .style("display", function(d) { return (d.edgeConflict && !d.placeholder) ? null : "none"; }); + + thisNode.append("foreignObject") + .attr("x", 17) + .attr("y", 22) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-defaultText WorkflowChart-incompleteText") + .html(function () { + return "\uf06a INCOMPLETE"; + }) + .style("display", function(d) { return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); + + thisNode.append("circle") + .attr("cy", nodeH) + .attr("r", 10) + .attr("class", "WorkflowChart-nodeTypeCircle") + .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); + + thisNode.append("text") + .attr("y", nodeH) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-nodeTypeLetter") + .text(function (d) { + return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + }) + .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); + + thisNode.append("rect") + .attr("width", nodeW) + .attr("height", nodeH) + .attr("class", "transparentRect") + .call(edit_node) + .on("mouseover", function(d) { + if(!d.isStartNode) { + d3.select("#node-" + d.id) + .classed("hovering", true); + } + }) + .on("mouseout", function(d){ + if(!d.isStartNode) { + d3.select("#node-" + d.id) + .classed("hovering", false); + } + }); + thisNode.append("text") + .attr("x", nodeW - 45) + .attr("y", nodeH - 10) + .attr("dy", ".35em") + .attr("class", "WorkflowChart-detailsLink") + .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }) + .text(function () { + return "DETAILS"; + }) + .call(details); + thisNode.append("circle") + .attr("id", function(d){return "node-" + d.id + "-add";}) + .attr("cx", nodeW) + .attr("r", 10) + .attr("class", "addCircle nodeCircle") + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) + .call(add_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", false); + }); + thisNode.append("path") + .attr("class", "nodeAddCross WorkflowChart-hoverPath") + .style("fill", "white") + .attr("transform", function() { return "translate(" + nodeW + "," + 0 + ")"; }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) + .call(add_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", false); + }); + thisNode.append("circle") + .attr("id", function(d){return "node-" + d.id + "-remove";}) + .attr("cx", nodeW) + .attr("cy", nodeH) + .attr("r", 10) + .attr("class", "removeCircle") + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(remove_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); + thisNode.append("path") + .attr("class", "nodeRemoveCross WorkflowChart-hoverPath") + .style("fill", "white") + .attr("transform", function() { return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(remove_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); + + thisNode.append("circle") + .attr("class", function(d) { + + let statusClass = "WorkflowChart-nodeStatus "; + + if(d.job){ + switch(d.job.status) { + case "pending": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "waiting": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "running": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "successful": + statusClass += "workflowChart-nodeStatus--success"; + break; + case "failed": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "error": + statusClass += "workflowChart-nodeStatus--failed"; + break; + } + } + + return statusClass; + }) + .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .attr("cy", 10) + .attr("cx", 10) + .attr("r", 6); + + thisNode.append("foreignObject") + .attr("x", 5) + .attr("y", 43) + .style("font-size","0.7em") + .attr("class", "WorkflowChart-elapsed") + .html(function (d) { + if(d.job && d.job.elapsed) { + let elapsedMs = d.job.elapsed * 1000; + let elapsedMoment = moment.duration(elapsedMs); + let paddedElapsedMoment = Math.floor(elapsedMoment.asHours()) < 10 ? "0" + Math.floor(elapsedMoment.asHours()) : Math.floor(elapsedMoment.asHours()); + let elapsedString = paddedElapsedMoment + moment.utc(elapsedMs).format(":mm:ss"); + return "
    " + elapsedString + "
    "; + } + else { + return ""; + } + }) + .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + } + }); + + node.exit().remove(); + + let link = svgGroup.selectAll("g.link") + .data(links, function(d) { + return d.source.id + "-" + d.target.id; + }); + + let linkEnter = link.enter().append("g") + .attr("class", "link") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + + // Add entering links in the parent’s old position. + linkEnter.insert("path", "g") + .attr("class", function(d) { + return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + }) + .attr("d", lineData) + .attr('stroke', function(d) { + if(d.target.edgeType) { + if(d.target.edgeType === "failure") { + return "#d9534f"; + } + else if(d.target.edgeType === "success") { + return "#5cb85c"; + } + else if(d.target.edgeType === "always"){ + return "#337ab7"; + } + } + else { + return "#D7D7D7"; + } + }); + + linkEnter.append("circle") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) + .attr("cx", function(d) { + return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; + }) + .attr("cy", function(d) { + return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; + }) + .attr("r", 10) + .attr("class", "addCircle linkCircle") + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(add_node_between) + .on("mouseover", function(d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", true); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", false); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", false); + }); + + linkEnter.append("path") + .attr("class", "linkCross") + .style("fill", "white") + .attr("transform", function(d) { + let translate; + if(d.source.isStartNode) { + translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; + } + else { + translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; + } + return translate; + }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(add_node_between) + .on("mouseover", function(d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", true); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", false); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", false); + }); + + link.exit().remove(); + + // Transition nodes and links to their new positions. + let t = baseSvg.transition(); + + t.selectAll(".nodeCircle") + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); + + t.selectAll(".nodeAddCross") + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); + + t.selectAll(".removeCircle") + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); + + t.selectAll(".nodeRemoveCross") + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); + + t.selectAll(".linkPath") + .attr("class", function(d) { + return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + }) + .attr("d", lineData) + .attr('stroke', function(d) { + if(d.target.edgeType) { + if(d.target.edgeType === "failure") { + return "#d9534f"; + } + else if(d.target.edgeType === "success") { + return "#5cb85c"; + } + else if(d.target.edgeType === "always"){ + return "#337ab7"; + } } else { return "#D7D7D7"; } - }) - .attr('stroke-width', "2px") - .attr("class", function(d) { - return d.placeholder ? "rect placeholder" : "rect"; }); - thisNode.append("path") - .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) - .attr("class", "WorkflowChart-activeNode") - .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); - - thisNode.append("text") - .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) - .attr("dy", ".35em") - .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) - .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") - .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; - }).each(wrap); - - thisNode.append("foreignObject") - .attr("x", 43) - .attr("y", 45) - .style("font-size","0.7em") - .attr("class", "WorkflowChart-conflictText") - .html(function () { - return "\uf06a EDGE CONFLICT"; - }) - .style("display", function(d) { return (d.edgeConflict && !d.placeholder) ? null : "none"; }); - - thisNode.append("foreignObject") - .attr("x", 17) - .attr("y", 22) - .attr("dy", ".35em") - .attr("text-anchor", "middle") - .attr("class", "WorkflowChart-defaultText WorkflowChart-incompleteText") - .html(function () { - return "\uf06a INCOMPLETE"; - }) - .style("display", function(d) { return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); - - thisNode.append("circle") - .attr("cy", nodeH) - .attr("r", 10) - .attr("class", "WorkflowChart-nodeTypeCircle") - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); - - thisNode.append("text") - .attr("y", nodeH) - .attr("dy", ".35em") - .attr("text-anchor", "middle") - .attr("class", "WorkflowChart-nodeTypeLetter") - .text(function (d) { - return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); - }) - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); - - thisNode.append("rect") - .attr("width", nodeW) - .attr("height", nodeH) - .attr("class", "transparentRect") - .call(edit_node) - .on("mouseover", function(d) { - if(!d.isStartNode) { - d3.select("#node-" + d.id) - .classed("hovering", true); - } - }) - .on("mouseout", function(d){ - if(!d.isStartNode) { - d3.select("#node-" + d.id) - .classed("hovering", false); - } - }); - thisNode.append("text") - .attr("x", nodeW - 50) - .attr("y", nodeH - 10) - .attr("dy", ".35em") - .attr("class", "WorkflowChart-detailsLink") - .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }) - .text(function () { - return "DETAILS"; - }) - .call(details); - thisNode.append("circle") - .attr("id", function(d){return "node-" + d.id + "-add";}) - .attr("cx", nodeW) - .attr("r", 10) - .attr("class", "addCircle nodeCircle") - .style("display", function(d) { return d.placeholder || scope.canAddWorkflowJobTemplate === false ? "none" : null; }) - .call(add_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); - }); - thisNode.append("path") - .attr("class", "nodeAddCross WorkflowChart-hoverPath") - .style("fill", "white") - .attr("transform", function() { return "translate(" + nodeW + "," + 0 + ")"; }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function(d) { return d.placeholder || scope.canAddWorkflowJobTemplate === false ? "none" : null; }) - .call(add_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); - }); - thisNode.append("circle") - .attr("id", function(d){return "node-" + d.id + "-remove";}) - .attr("cx", nodeW) - .attr("cy", nodeH) - .attr("r", 10) - .attr("class", "removeCircle") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }) - .call(remove_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", false); - }); - thisNode.append("path") - .attr("class", "nodeRemoveCross WorkflowChart-hoverPath") - .style("fill", "white") - .attr("transform", function() { return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function(d) { return (d.canDelete === false || d.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }) - .call(remove_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", false); - }); - - thisNode.append("circle") - .attr("class", function(d) { - - let statusClass = "WorkflowChart-nodeStatus "; - - if(d.job){ - switch(d.job.status) { - case "pending": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "waiting": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "running": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "successful": - statusClass += "workflowChart-nodeStatus--success"; - break; - case "failed": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "error": - statusClass += "workflowChart-nodeStatus--failed"; - break; - } - } - - return statusClass; - }) - .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) - .attr("cy", 10) - .attr("cx", 10) - .attr("r", 6); - - thisNode.append("foreignObject") - .attr("x", 5) - .attr("y", 43) - .style("font-size","0.7em") - .attr("class", "WorkflowChart-elapsed") - .html(function (d) { - if(d.job && d.job.elapsed) { - let elapsedMs = d.job.elapsed * 1000; - let elapsedMoment = moment.duration(elapsedMs); - let paddedElapsedMoment = Math.floor(elapsedMoment.asHours()) < 10 ? "0" + Math.floor(elapsedMoment.asHours()) : Math.floor(elapsedMoment.asHours()); - let elapsedString = paddedElapsedMoment + moment.utc(elapsedMs).format(":mm:ss"); - return "
    " + elapsedString + "
    "; - } - else { - return ""; - } - }) - .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); - } - }); - - node.exit().remove(); - - let link = svgGroup.selectAll("g.link") - .data(links, function(d) { - return d.source.id + "-" + d.target.id; - }); - - let linkEnter = link.enter().append("g") - .attr("class", "link") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); - - // Add entering links in the parent’s old position. - linkEnter.insert("path", "g") - .attr("class", function(d) { - return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; - }) - .attr("d", lineData) - .attr('stroke', function(d) { - if(d.target.edgeType) { - if(d.target.edgeType === "failure") { - return "#d9534f"; - } - else if(d.target.edgeType === "success") { - return "#5cb85c"; - } - else if(d.target.edgeType === "always"){ - return "#337ab7"; - } - } - else { - return "#D7D7D7"; - } - }); - - linkEnter.append("circle") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + 10 + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }) - .attr("r", 10) - .attr("class", "addCircle linkCircle") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }) - .call(add_node_between) - .on("mouseover", function(d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", true); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", false); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", false); - }); - - linkEnter.append("path") - .attr("class", "linkCross") - .style("fill", "white") - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + 10 + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }) - .call(add_node_between) - .on("mouseover", function(d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", true); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", false); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", false); - }); - - link.exit().remove(); - - // Transition nodes and links to their new positions. - let t = baseSvg.transition(); - - t.selectAll(".nodeCircle") - .style("display", function(d) { return d.placeholder || scope.canAddWorkflowJobTemplate === false ? "none" : null; }); - - t.selectAll(".nodeAddCross") - .style("display", function(d) { return d.placeholder || scope.canAddWorkflowJobTemplate === false ? "none" : null; }); - - t.selectAll(".removeCircle") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }); - - t.selectAll(".nodeRemoveCross") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }); - - t.selectAll(".linkPath") - .attr("class", function(d) { - return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + t.selectAll(".linkCircle") + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .attr("cx", function(d) { + return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; }) - .attr("d", lineData) + .attr("cy", function(d) { + return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; + }); + + t.selectAll(".linkCross") + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .attr("transform", function(d) { + let translate; + if(d.source.isStartNode) { + translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; + } + else { + translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; + } + return translate; + }); + + t.selectAll(".rect") .attr('stroke', function(d) { - if(d.target.edgeType) { - if(d.target.edgeType === "failure") { - return "#d9534f"; - } - else if(d.target.edgeType === "success") { + if(d.job && d.job.status) { + if(d.job.status === "successful"){ return "#5cb85c"; } - else if(d.target.edgeType === "always"){ - return "#337ab7"; + else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { + return "#d9534f"; + } + else { + return "#D7D7D7"; } } else { return "#D7D7D7"; } + }) + .attr("class", function(d) { + return d.placeholder ? "rect placeholder" : "rect"; + }); + + t.selectAll(".node") + .attr("parent", function(d){return d.parent ? d.parent.id : null;}) + .attr("transform", function(d) {d.px = d.x; d.py = d.y; return "translate(" + d.y + "," + d.x + ")"; }); + + t.selectAll(".WorkflowChart-nodeTypeCircle") + .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" ) ? null : "none"; }); + + t.selectAll(".WorkflowChart-nodeTypeLetter") + .text(function (d) { + return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + }) + .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); + + t.selectAll(".WorkflowChart-nodeStatus") + .attr("class", function(d) { + + let statusClass = "WorkflowChart-nodeStatus "; + + if(d.job){ + switch(d.job.status) { + case "pending": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "waiting": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "running": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "successful": + statusClass += "workflowChart-nodeStatus--success"; + break; + case "failed": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "error": + statusClass += "workflowChart-nodeStatus--failed"; + break; + } + } + + return statusClass; + }) + .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .transition() + .duration(0) + .attr("r", 6) + .each(function(d) { + if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { + // Pulse the circle + var circle = d3.select(this); + (function repeat() { + circle = circle.transition() + .duration(2000) + .attr("r", 6) + .transition() + .duration(2000) + .attr("r", 0) + .ease('sine') + .each("end", repeat); + })(); + } }); - t.selectAll(".linkCircle") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }) - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + 10 + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }); + t.selectAll(".WorkflowChart-nameText") + .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) + .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) + .text(function (d) { + return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : ""; + }); - t.selectAll(".linkCross") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || scope.canAddWorkflowJobTemplate === false) ? "none" : null; }) - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + 10 + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }); + t.selectAll(".WorkflowChart-detailsLink") + .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }); - t.selectAll(".rect") - .attr('stroke', function(d) { - if(d.job && d.job.status) { - if(d.job.status === "successful"){ - return "#5cb85c"; - } - else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { - return "#d9534f"; - } - else { - return "#D7D7D7"; - } - } - else { - return "#D7D7D7"; - } - }) - .attr("class", function(d) { - return d.placeholder ? "rect placeholder" : "rect"; - }); + t.selectAll(".WorkflowChart-incompleteText") + .style("display", function(d){ return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); - t.selectAll(".node") - .attr("parent", function(d){return d.parent ? d.parent.id : null;}) - .attr("transform", function(d) {d.px = d.x; d.py = d.y; return "translate(" + d.y + "," + d.x + ")"; }); + t.selectAll(".WorkflowChart-conflictText") + .style("display", function(d) { return (d.edgeConflict && !d.placeholder) ? null : "none"; }); - t.selectAll(".WorkflowChart-nodeTypeCircle") - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" ) ? null : "none"; }); + t.selectAll(".WorkflowChart-activeNode") + .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); - t.selectAll(".WorkflowChart-nodeTypeLetter") - .text(function (d) { - return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); - }) - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); - - t.selectAll(".WorkflowChart-nodeStatus") - .attr("class", function(d) { - - let statusClass = "WorkflowChart-nodeStatus "; - - if(d.job){ - switch(d.job.status) { - case "pending": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "waiting": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "running": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "successful": - statusClass += "workflowChart-nodeStatus--success"; - break; - case "failed": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "error": - statusClass += "workflowChart-nodeStatus--failed"; - break; - } - } - - return statusClass; - }) - .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) - .transition() - .duration(0) - .attr("r", 6) - .each(function(d) { - if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { - // Pulse the circle - var circle = d3.select(this); - (function repeat() { - circle = circle.transition() - .duration(2000) - .attr("r", 6) - .transition() - .duration(2000) - .attr("r", 0) - .ease('sine') - .each("end", repeat); - })(); + t.selectAll(".WorkflowChart-elapsed") + .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + } + else if(!scope.watchDimensionsSet){ + scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ + if(scope.dimensionsSet) { + scope.watchDimensionsSet(); + scope.watchDimensionsSet = null; + update(); } }); - - t.selectAll(".WorkflowChart-nameText") - .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) - .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) - .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : ""; - }); - - t.selectAll(".WorkflowChart-detailsLink") - .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }); - - t.selectAll(".WorkflowChart-incompleteText") - .style("display", function(d){ return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); - - t.selectAll(".WorkflowChart-conflictText") - .style("display", function(d) { return (d.edgeConflict && !d.placeholder) ? null : "none"; }); - - t.selectAll(".WorkflowChart-activeNode") - .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); - - t.selectAll(".WorkflowChart-elapsed") - .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); - + } } function add_node() { this.on("click", function(d) { - if(scope.canAddWorkflowJobTemplate !== false) { + if((scope.workflowJobTemplateObjt && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { scope.addNode({ parent: d, betweenTwoNodes: false @@ -726,7 +791,7 @@ export default [ '$state','moment', function add_node_between() { this.on("click", function(d) { - if(scope.canAddWorkflowJobTemplate !== false) { + if((scope.workflowJobTemplateObjt && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { scope.addNode({ parent: d, betweenTwoNodes: true @@ -737,7 +802,7 @@ export default [ '$state','moment', function remove_node() { this.on("click", function(d) { - if(scope.canAddWorkflowJobTemplate !== false) { + if((scope.workflowJobTemplateObjt && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { scope.deleteNode({ nodeToDelete: d }); @@ -809,6 +874,37 @@ export default [ '$state','moment', } }); + function onResize(){ + let dimensions = calcAvailableScreenSpace(); + + $('.WorkflowMaker-chart').css("width", dimensions.width); + $('.WorkflowMaker-chart').css("height", dimensions.height); + } + + function cleanUpResize() { + angular.element($window).off('resize', onResize); + } + + if(scope.mode === 'details') { + angular.element($window).on('resize', onResize); + scope.$on('$destroy', cleanUpResize); + + scope.$on('workflowDetailsResized', function(){ + $('.WorkflowMaker-chart').hide(); + $timeout(function(){ + onResize(); + $('.WorkflowMaker-chart').show(); + }); + }); + } + else { + scope.$on('workflowMakerModalResized', function(){ + let dimensions = calcAvailableScreenSpace(); + + $('.WorkflowMaker-chart').css("width", dimensions.width); + $('.WorkflowMaker-chart').css("height", dimensions.height); + }); + } } }; }]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index aa61efa0b7..33b53ac366 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -124,6 +124,9 @@ } .WorkflowMaker-formLists { margin-bottom: 20px; + .SmartSearch-searchTermContainer { + width: 100%; + } } .WorkflowMaker-formTitle { display: flex; @@ -243,3 +246,6 @@ display: flex; border: 1px solid @default-list-header-bg; } +.WorkflowMaker-formTab { + margin-right: 10px; +} diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index e4cbfcd134..7fcf27cc6c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -888,6 +888,10 @@ export default ['$scope', 'WorkflowService', 'generateList', 'TemplateList', 'Pr }); }; + $scope.$on('WorkflowDialogReady', function(){ + $scope.modalOpen = true; + }); + init(); } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.directive.js index 54c1e526fd..bfac9c1469 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.directive.js @@ -6,8 +6,8 @@ import workflowMakerController from './workflow-maker.controller'; -export default ['templateUrl', 'CreateDialog', 'Wait', '$state', - function(templateUrl, CreateDialog, Wait, $state) { +export default ['templateUrl', 'CreateDialog', 'Wait', '$state', '$window', + function(templateUrl, CreateDialog, Wait, $state, $window) { return { scope: { workflowJobTemplateObj: '=', @@ -17,11 +17,17 @@ export default ['templateUrl', 'CreateDialog', 'Wait', '$state', templateUrl: templateUrl('templates/workflows/workflow-maker/workflow-maker'), controller: workflowMakerController, link: function(scope) { + + let availableHeight = $(window).height(), + availableWidth = $(window).width(), + minimumWidth = 1300, + minimumHeight = 550; + CreateDialog({ id: 'workflow-modal-dialog', scope: scope, - width: 1400, - height: 720, + width: availableWidth > minimumWidth ? availableWidth : minimumWidth, + height: availableHeight > minimumHeight ? availableHeight : minimumHeight, draggable: false, dialogClass: 'SurveyMaker-dialog', position: ['center', 20], @@ -34,6 +40,10 @@ export default ['templateUrl', 'CreateDialog', 'Wait', '$state', // Let the modal height be variable based on the content // and set a uniform padding $('#workflow-modal-dialog').css({ 'padding': '20px' }); + $('#workflow-modal-dialog').parent('.ui-dialog').height(availableHeight > minimumHeight ? availableHeight : minimumHeight); + $('#workflow-modal-dialog').parent('.ui-dialog').width(availableWidth > minimumWidth ? availableWidth : minimumWidth); + $('#workflow-modal-dialog').outerHeight(availableHeight > minimumHeight ? availableHeight : minimumHeight); + $('#workflow-modal-dialog').outerWidth(availableWidth > minimumWidth ? availableWidth : minimumWidth); }, _allowInteraction: function(e) { @@ -55,6 +65,24 @@ export default ['templateUrl', 'CreateDialog', 'Wait', '$state', $state.go('^'); }; + + function onResize(){ + availableHeight = $(window).height(); + availableWidth = $(window).width(); + $('#workflow-modal-dialog').parent('.ui-dialog').height(availableHeight > minimumHeight ? availableHeight : minimumHeight); + $('#workflow-modal-dialog').parent('.ui-dialog').width(availableWidth > minimumWidth ? availableWidth : minimumWidth); + $('#workflow-modal-dialog').outerHeight(availableHeight > minimumHeight ? availableHeight : minimumHeight); + $('#workflow-modal-dialog').outerWidth(availableWidth > minimumWidth ? availableWidth : minimumWidth); + + scope.$broadcast('workflowMakerModalResized'); + } + + function cleanUpResize() { + angular.element($window).off('resize', onResize); + } + + angular.element($window).on('resize', onResize); + scope.$on('$destroy', cleanUpResize); } }; } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 099d03ae10..ea4e514b72 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -64,16 +64,16 @@
    - +
    {{(workflowMakerFormConfig.nodeMode === 'edit' && nodeBeingEdited) ? ((nodeBeingEdited.unifiedJobTemplate && nodeBeingEdited.unifiedJobTemplate.name) ? nodeBeingEdited.unifiedJobTemplate.name : "EDIT TEMPLATE") : "ADD A TEMPLATE"}}
    -
    JOBS
    -
    PROJECT SYNC
    -
    INVENTORY SYNC
    +
    JOBS
    +
    PROJECT SYNC
    +
    INVENTORY SYNC
    @@ -86,6 +86,6 @@
    - +
    diff --git a/awx/ui/client/src/widgets/Stream.js b/awx/ui/client/src/widgets/Stream.js index a41b14b462..64315e7953 100644 --- a/awx/ui/client/src/widgets/Stream.js +++ b/awx/ui/client/src/widgets/Stream.js @@ -80,6 +80,12 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti break; case 'role': throw {name : 'NotImplementedError', message : 'role object management is not consolidated to a single UI view'}; + case 'job_template': + url += `templates/job_template/${obj.id}`; + break; + case 'workflow_job_template': + url += `templates/workflow_job_template/${obj.id}`; + break; default: url += resource + 's/' + obj.id + '/'; } @@ -283,18 +289,29 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti }); }; - scope.activities.forEach(function(activity, i) { - // build activity.user - if (scope.activities[i].summary_fields.actor) { - scope.activities[i].user = "" + - scope.activities[i].summary_fields.actor.username + ""; - } else { - scope.activities[i].user = 'system'; - } - // build description column / action text - BuildDescription(scope.activities[i]); + if(scope.activities && scope.activities.length > 0) { + buildUserAndDescription(); + } + scope.$watch('activities', function(){ + // Watch for future update to scope.activities (like page change, column sort, search, etc) + buildUserAndDescription(); }); + + function buildUserAndDescription(){ + scope.activities.forEach(function(activity, i) { + // build activity.user + if (scope.activities[i].summary_fields.actor) { + scope.activities[i].user = "" + + scope.activities[i].summary_fields.actor.username + ""; + } else { + scope.activities[i].user = 'system'; + } + // build description column / action text + BuildDescription(scope.activities[i]); + + }); + } }; } ]); diff --git a/awx/ui/client/src/workflow-results/workflow-results.block.less b/awx/ui/client/src/workflow-results/workflow-results.block.less index 0173ed8eb8..51b8d739ed 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.block.less +++ b/awx/ui/client/src/workflow-results/workflow-results.block.less @@ -18,11 +18,17 @@ .WorkflowResults-leftSide { .OnePlusTwo-left--panel(100%, @breakpoint-md); height: ~"calc(100vh - 177px)"; + min-height: 350px; + + .Panel { + overflow: scroll; + } } .WorkflowResults-rightSide { .OnePlusTwo-right--panel(100%, @breakpoint-md); height: ~"calc(100vh - 177px)"; + min-height: 350px; @media (max-width: @breakpoint-md - 1px) { padding-right: 15px; diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index be6a67c0ca..cd4ae42a20 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -88,6 +88,11 @@ export default ['workflowData', runTimeElapsedTimer = workflowResultsService.createOneSecondTimer($scope.workflow.started, updateWorkflowJobElapsedTimer); } + if(workflowData.summary_fields && workflowData.summary_fields.workflow_job_template && + workflowData.summary_fields.workflow_job_template.id){ + $scope.workflow_job_template_link = `/#/templates/workflow_job_template/${$scope.workflow.summary_fields.workflow_job_template.id}`; + } + // stdout full screen toggle tooltip text $scope.toggleStdoutFullscreenTooltip = i18n._("Expand Output"); @@ -125,6 +130,9 @@ export default ['workflowData', } $scope.toggleStdoutFullscreen = function() { + + $scope.$broadcast('workflowDetailsResized'); + $scope.stdoutFullScreen = !$scope.stdoutFullScreen; if ($scope.stdoutFullScreen === true) { diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html index 5d0bbf87f9..b73c1f609c 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.partial.html +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -98,6 +98,21 @@
    + + +
    diff --git a/awx/ui/client/src/workflow-results/workflow-results.route.js b/awx/ui/client/src/workflow-results/workflow-results.route.js index cbf8778b34..168f0b8329 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.route.js +++ b/awx/ui/client/src/workflow-results/workflow-results.route.js @@ -49,7 +49,7 @@ export default { // flashing as rest data comes in. Provides the list of workflow nodes workflowNodes: ['workflowData', 'Rest', '$q', function(workflowData, Rest, $q) { var defer = $q.defer(); - Rest.setUrl(workflowData.related.workflow_nodes); + Rest.setUrl(workflowData.related.workflow_nodes + '?order_by=id'); Rest.get() .success(function(data) { if(data.next) { diff --git a/awx/ui/client/src/workflow-results/workflow-results.service.js b/awx/ui/client/src/workflow-results/workflow-results.service.js index abe39b14e6..f6cf877596 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.service.js +++ b/awx/ui/client/src/workflow-results/workflow-results.service.js @@ -107,7 +107,7 @@ export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErr } }); }, - actionText: 'CANCEL' + actionText: 'PROCEED' }); }, relaunchJob: function(scope) { diff --git a/awx/ui/conf.py b/awx/ui/conf.py index a8f8a42766..00f1e043d8 100644 --- a/awx/ui/conf.py +++ b/awx/ui/conf.py @@ -47,7 +47,7 @@ register( default='', label=_('Custom Logo'), help_text=_('To set up a custom logo, provide a file that you create. For ' - 'the custom logo to look its best, use a `.png` file with a ' + 'the custom logo to look its best, use a .png file with a ' 'transparent background. GIF, PNG and JPEG formats are supported.'), placeholder='data:image/gif;base64,R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=', category=_('UI'), diff --git a/awx/ui/karma.conf.js b/awx/ui/karma.conf.js index 4d7d4c8363..1a22f476b2 100644 --- a/awx/ui/karma.conf.js +++ b/awx/ui/karma.conf.js @@ -20,7 +20,6 @@ module.exports = function(config) { './client/src/app.js', './node_modules/angular-mocks/angular-mocks.js', { pattern: './tests/**/*-test.js' }, - { pattern: './tests/**/*.json', included: false}, 'client/src/**/*.html' ], preprocessors: { diff --git a/awx/ui/npm-shrinkwrap.json b/awx/ui/npm-shrinkwrap.json index faec63cc41..2f1127a0cb 100644 --- a/awx/ui/npm-shrinkwrap.json +++ b/awx/ui/npm-shrinkwrap.json @@ -1391,9 +1391,9 @@ "dev": true }, "d3": { - "version": "3.5.17", - "from": "d3@>=3.3.13 <4.0.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz" + "version": "3.3.13", + "from": "d3@3.3.13", + "resolved": "https://registry.npmjs.org/d3/-/d3-3.3.13.tgz" }, "dashdash": { "version": "1.14.1", diff --git a/awx/ui/package.json b/awx/ui/package.json index 7095b616a4..3dbf77e777 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -94,7 +94,7 @@ "bootstrap-datepicker": "^1.4.0", "codemirror": "^5.17.0", "components-font-awesome": "^4.6.1", - "d3": "^3.3.13", + "d3": "3.3.13", "javascript-detect-element-resize": "^0.5.3", "jquery": "2.2.4", "jquery-ui": "1.10.5", diff --git a/awx/ui/po/ansible-tower-ui.pot b/awx/ui/po/ansible-tower-ui.pot index fd770dd0ff..4c87ddb4d1 100644 --- a/awx/ui/po/ansible-tower-ui.pot +++ b/awx/ui/po/ansible-tower-ui.pot @@ -8,13 +8,13 @@ msgstr "" msgid "%s or %s" msgstr "" -#: client/src/controllers/Projects.js:437 -#: client/src/controllers/Projects.js:719 +#: client/src/controllers/Projects.js:439 +#: client/src/controllers/Projects.js:721 msgid "%sNote:%s Mercurial does not support password authentication for SSH. Do not put the username and key in the URL. If using Bitbucket and SSH, do not supply your Bitbucket username." msgstr "" -#: client/src/controllers/Projects.js:424 -#: client/src/controllers/Projects.js:706 +#: client/src/controllers/Projects.js:426 +#: client/src/controllers/Projects.js:708 msgid "%sNote:%s When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, do not enter a username (other than git). Additionally, GitHub and Bitbucket do not support password authentication when using SSH. GIT read only protocol (git://) does not use username or password information." msgstr "" @@ -52,9 +52,9 @@ msgstr "" #: client/src/forms/Credentials.js:450 #: client/src/forms/JobTemplates.js:430 -#: client/src/forms/Organizations.js:76 +#: client/src/forms/Organizations.js:75 #: client/src/forms/Projects.js:238 -#: client/src/forms/Teams.js:85 +#: client/src/forms/Teams.js:86 #: client/src/forms/Workflows.js:128 #: client/src/inventory-scripts/inventory-scripts.list.js:45 #: client/src/lists/Credentials.js:60 @@ -63,7 +63,7 @@ msgstr "" #: client/src/lists/Schedules.js:70 #: client/src/lists/Teams.js:50 #: client/src/lists/Templates.js:62 -#: client/src/lists/Users.js:58 +#: client/src/lists/Users.js:52 #: client/src/notifications/notificationTemplates.list.js:52 msgid "ADD" msgstr "" @@ -72,8 +72,8 @@ msgstr "" msgid "ADD NOTIFICATION TEMPLATE" msgstr "" -#: client/src/forms/Teams.js:157 -#: client/src/forms/Users.js:213 +#: client/src/forms/Teams.js:158 +#: client/src/forms/Users.js:215 msgid "ADD PERMISSIONS" msgstr "" @@ -81,7 +81,7 @@ msgstr "" msgid "ADDITIONAL INFORMATION:" msgstr "" -#: client/src/organizations/linkout/organizations-linkout.route.js:363 +#: client/src/organizations/linkout/organizations-linkout.route.js:325 msgid "ADMINS" msgstr "" @@ -122,7 +122,7 @@ msgid "Action" msgstr "" #: client/src/dashboard/lists/job-templates/job-templates-list.partial.html:20 -#: client/src/shared/list-generator/list-generator.factory.js:546 +#: client/src/shared/list-generator/list-generator.factory.js:547 msgid "Actions" msgstr "" @@ -142,8 +142,8 @@ msgid "Activity Stream" msgstr "" #: client/src/forms/Inventories.js:105 -#: client/src/forms/Organizations.js:73 -#: client/src/forms/Teams.js:82 +#: client/src/forms/Organizations.js:72 +#: client/src/forms/Teams.js:83 #: client/src/forms/Workflows.js:125 #: client/src/organizations/linkout/addUsers/addUsers.partial.html:8 msgid "Add" @@ -183,16 +183,20 @@ msgstr "" msgid "Add Team" msgstr "" -#: client/src/forms/Teams.js:83 +#: client/src/forms/Teams.js:84 msgid "Add User" msgstr "" -#: client/src/lists/Users.js:25 +#: client/src/lists/Users.js:19 #: client/src/shared/stateDefinitions.factory.js:342 #: client/src/shared/stateDefinitions.factory.js:511 msgid "Add Users" msgstr "" +#: client/src/forms/Organizations.js:73 +msgid "Add Users to this organization." +msgstr "" + #: client/src/lists/Schedules.js:68 msgid "Add a new schedule" msgstr "" @@ -200,7 +204,6 @@ msgstr "" #: client/src/forms/Credentials.js:448 #: client/src/forms/Inventories.js:107 #: client/src/forms/JobTemplates.js:428 -#: client/src/forms/Organizations.js:74 #: client/src/forms/Projects.js:236 #: client/src/forms/Workflows.js:126 msgid "Add a permission" @@ -247,11 +250,11 @@ msgstr "" msgid "Always" msgstr "" -#: client/src/controllers/Projects.js:260 +#: client/src/controllers/Projects.js:262 msgid "An SCM update does not appear to be running for project: %s. Click the %sRefresh%s button to view the latest status." msgstr "" -#: client/src/controllers/Credentials.js:105 +#: client/src/controllers/Credentials.js:104 msgid "Are you sure you want to delete the credential below?" msgstr "" @@ -267,7 +270,7 @@ msgstr "" msgid "Are you sure you want to delete the organization below?" msgstr "" -#: client/src/controllers/Projects.js:202 +#: client/src/controllers/Projects.js:204 msgid "Are you sure you want to delete the project below?" msgstr "" @@ -275,8 +278,8 @@ msgstr "" msgid "Are you sure you want to delete the user below?" msgstr "" -#: client/src/controllers/Credentials.js:579 -#: client/src/controllers/Projects.js:687 +#: client/src/controllers/Credentials.js:578 +#: client/src/controllers/Projects.js:689 msgid "Are you sure you want to remove the %s below from %s?" msgstr "" @@ -352,14 +355,14 @@ msgstr "" msgid "CREATE %s" msgstr "" -#: client/src/inventories/main.js:110 -#: client/src/scheduler/main.js:176 -#: client/src/scheduler/main.js:253 -#: client/src/scheduler/main.js:90 +#: client/src/inventories/main.js:117 +#: client/src/scheduler/main.js:190 +#: client/src/scheduler/main.js:274 +#: client/src/scheduler/main.js:97 msgid "CREATE SCHEDULE" msgstr "" -#: client/src/management-jobs/scheduler/main.js:74 +#: client/src/management-jobs/scheduler/main.js:81 msgid "CREATE SCHEDULED JOB" msgstr "" @@ -368,7 +371,7 @@ msgstr "" msgid "CREDENTIAL" msgstr "" -#: client/src/app.js:319 +#: client/src/app.js:320 #: client/src/helpers/ActivityStream.js:29 msgid "CREDENTIALS" msgstr "" @@ -381,21 +384,21 @@ msgstr "" msgid "Cache Timeout%s (seconds)%s" msgstr "" -#: client/src/controllers/Projects.js:193 +#: client/src/controllers/Projects.js:195 #: client/src/controllers/Users.js:95 msgid "Call to %s failed. DELETE returned status:" msgstr "" -#: client/src/controllers/Projects.js:241 -#: client/src/controllers/Projects.js:257 +#: client/src/controllers/Projects.js:243 +#: client/src/controllers/Projects.js:259 msgid "Call to %s failed. GET status:" msgstr "" -#: client/src/controllers/Projects.js:681 +#: client/src/controllers/Projects.js:683 msgid "Call to %s failed. POST returned status:" msgstr "" -#: client/src/controllers/Projects.js:220 +#: client/src/controllers/Projects.js:222 msgid "Call to %s failed. POST status:" msgstr "" @@ -403,7 +406,7 @@ msgstr "" msgid "Call to %s failed. Return status: %d" msgstr "" -#: client/src/controllers/Projects.js:266 +#: client/src/controllers/Projects.js:268 msgid "Call to get project failed. GET status:" msgstr "" @@ -416,7 +419,7 @@ msgstr "" msgid "Cancel" msgstr "" -#: client/src/controllers/Projects.js:236 +#: client/src/controllers/Projects.js:238 msgid "Cancel Not Allowed" msgstr "" @@ -428,7 +431,7 @@ msgstr "" msgid "Cancel the job" msgstr "" -#: client/src/controllers/Projects.js:82 +#: client/src/controllers/Projects.js:84 #: client/src/helpers/Projects.js:79 msgid "Canceled. Click for details" msgstr "" @@ -503,7 +506,7 @@ msgstr "" #: client/src/job-results/job-results.controller.js:205 #: client/src/standard-out/standard-out.controller.js:233 -#: client/src/workflow-results/workflow-results.controller.js:131 +#: client/src/workflow-results/workflow-results.controller.js:136 msgid "Collapse Output" msgstr "" @@ -559,7 +562,7 @@ msgstr "" msgid "Create Credential" msgstr "" -#: client/src/lists/Users.js:52 +#: client/src/lists/Users.js:46 msgid "Create New" msgstr "" @@ -596,7 +599,7 @@ msgstr "" msgid "Create a new template" msgstr "" -#: client/src/lists/Users.js:56 +#: client/src/lists/Users.js:50 msgid "Create a new user" msgstr "" @@ -629,26 +632,26 @@ msgstr "" msgid "Credentials" msgstr "" -#: client/src/controllers/Credentials.js:349 +#: client/src/controllers/Credentials.js:348 msgid "Credentials are only shared within an organization. Assign credentials to an organization to delegate credential permissions. The organization cannot be edited after credentials are assigned." msgstr "" +#: client/src/shared/directives.js:135 +msgid "Current Image:" +msgstr "" + #: client/src/inventory-scripts/inventory-scripts.form.js:53 #: client/src/inventory-scripts/inventory-scripts.form.js:63 msgid "Custom Script" msgstr "" -#: client/src/shared/directives.js:135 -msgid "Custom logo has been uploaded" -msgstr "" - -#: client/src/app.js:410 +#: client/src/app.js:411 msgid "DASHBOARD" msgstr "" -#: client/src/controllers/Credentials.js:107 -#: client/src/controllers/Credentials.js:581 -#: client/src/controllers/Projects.js:689 +#: client/src/controllers/Credentials.js:106 +#: client/src/controllers/Credentials.js:580 +#: client/src/controllers/Projects.js:691 #: client/src/controllers/Users.js:104 #: client/src/notifications/notification-templates-list/list.controller.js:189 #: client/src/organizations/list/organizations-list.controller.js:168 @@ -660,10 +663,10 @@ msgstr "" msgid "DETAILS" msgstr "" -#: client/src/controllers/Credentials.js:104 -#: client/src/controllers/Credentials.js:578 -#: client/src/controllers/Projects.js:201 -#: client/src/controllers/Projects.js:686 +#: client/src/controllers/Credentials.js:103 +#: client/src/controllers/Credentials.js:577 +#: client/src/controllers/Projects.js:203 +#: client/src/controllers/Projects.js:688 #: client/src/controllers/Users.js:101 #: client/src/helpers/Jobs.js:165 #: client/src/inventory-scripts/inventory-scripts.list.js:74 @@ -672,7 +675,7 @@ msgstr "" #: client/src/lists/Schedules.js:92 #: client/src/lists/Teams.js:77 #: client/src/lists/Templates.js:124 -#: client/src/lists/Users.js:87 +#: client/src/lists/Users.js:81 #: client/src/notifications/notification-templates-list/list.controller.js:186 #: client/src/notifications/notificationTemplates.list.js:89 #: client/src/organizations/list/organizations-list.controller.js:165 @@ -730,7 +733,7 @@ msgstr "" msgid "Delete the schedule" msgstr "" -#: client/src/lists/Users.js:91 +#: client/src/lists/Users.js:85 msgid "Delete user" msgstr "" @@ -744,8 +747,8 @@ msgstr "" #: client/src/forms/Organizations.js:33 #: client/src/forms/Projects.js:38 #: client/src/forms/Teams.js:34 -#: client/src/forms/Users.js:143 -#: client/src/forms/Users.js:168 +#: client/src/forms/Users.js:144 +#: client/src/forms/Users.js:170 #: client/src/forms/Workflows.js:41 #: client/src/inventory-scripts/inventory-scripts.form.js:35 #: client/src/inventory-scripts/inventory-scripts.list.js:25 @@ -786,11 +789,11 @@ msgstr "" msgid "Discard changes" msgstr "" -#: client/src/forms/Teams.js:146 +#: client/src/forms/Teams.js:147 msgid "Dissasociate permission from team" msgstr "" -#: client/src/forms/Users.js:222 +#: client/src/forms/Users.js:224 msgid "Dissasociate permission from user" msgstr "" @@ -812,7 +815,7 @@ msgstr "" msgid "Drag and drop your custom inventory script file here or create one in the field to import your custom inventory." msgstr "" -#: client/src/management-jobs/scheduler/main.js:88 +#: client/src/management-jobs/scheduler/main.js:95 msgid "EDIT SCHEDULED JOB" msgstr "" @@ -842,7 +845,7 @@ msgstr "" #: client/src/lists/Schedules.js:77 #: client/src/lists/Teams.js:60 #: client/src/lists/Templates.js:107 -#: client/src/lists/Users.js:68 +#: client/src/lists/Users.js:62 #: client/src/notifications/notificationTemplates.list.js:63 #: client/src/notifications/notificationTemplates.list.js:72 msgid "Edit" @@ -894,7 +897,7 @@ msgstr "" msgid "Edit template" msgstr "" -#: client/src/workflow-results/workflow-results.partial.html:109 +#: client/src/workflow-results/workflow-results.partial.html:124 msgid "Edit the User" msgstr "" @@ -906,11 +909,11 @@ msgstr "" msgid "Edit the schedule" msgstr "" -#: client/src/lists/Users.js:72 +#: client/src/lists/Users.js:66 msgid "Edit user" msgstr "" -#: client/src/controllers/Projects.js:236 +#: client/src/controllers/Projects.js:238 msgid "Either you do not have access or the SCM update process completed. Click the %sRefresh%s button to view the latest status." msgstr "" @@ -975,16 +978,16 @@ msgstr "" #: client/src/configuration/configuration.controller.js:385 #: client/src/configuration/configuration.controller.js:419 #: client/src/configuration/configuration.controller.js:438 -#: client/src/controllers/Projects.js:171 -#: client/src/controllers/Projects.js:192 -#: client/src/controllers/Projects.js:220 -#: client/src/controllers/Projects.js:241 -#: client/src/controllers/Projects.js:256 -#: client/src/controllers/Projects.js:265 -#: client/src/controllers/Projects.js:403 -#: client/src/controllers/Projects.js:597 -#: client/src/controllers/Projects.js:663 -#: client/src/controllers/Projects.js:681 +#: client/src/controllers/Projects.js:173 +#: client/src/controllers/Projects.js:194 +#: client/src/controllers/Projects.js:222 +#: client/src/controllers/Projects.js:243 +#: client/src/controllers/Projects.js:258 +#: client/src/controllers/Projects.js:267 +#: client/src/controllers/Projects.js:405 +#: client/src/controllers/Projects.js:599 +#: client/src/controllers/Projects.js:665 +#: client/src/controllers/Projects.js:683 #: client/src/controllers/Users.js:182 #: client/src/controllers/Users.js:271 #: client/src/controllers/Users.js:339 @@ -1023,18 +1026,18 @@ msgstr "" msgid "Events" msgstr "" -#: client/src/controllers/Projects.js:421 -#: client/src/controllers/Projects.js:704 +#: client/src/controllers/Projects.js:423 +#: client/src/controllers/Projects.js:706 msgid "Example URLs for GIT SCM include:" msgstr "" -#: client/src/controllers/Projects.js:434 -#: client/src/controllers/Projects.js:716 +#: client/src/controllers/Projects.js:436 +#: client/src/controllers/Projects.js:718 msgid "Example URLs for Mercurial SCM include:" msgstr "" -#: client/src/controllers/Projects.js:429 -#: client/src/controllers/Projects.js:711 +#: client/src/controllers/Projects.js:431 +#: client/src/controllers/Projects.js:713 msgid "Example URLs for Subversion SCM include:" msgstr "" @@ -1042,8 +1045,8 @@ msgstr "" #: client/src/job-results/job-results.controller.js:207 #: client/src/standard-out/standard-out.controller.js:235 #: client/src/standard-out/standard-out.controller.js:25 -#: client/src/workflow-results/workflow-results.controller.js:133 -#: client/src/workflow-results/workflow-results.controller.js:92 +#: client/src/workflow-results/workflow-results.controller.js:138 +#: client/src/workflow-results/workflow-results.controller.js:97 msgid "Expand Output" msgstr "" @@ -1097,7 +1100,7 @@ msgstr "" msgid "Failed to create new Credential. POST status:" msgstr "" -#: client/src/controllers/Projects.js:404 +#: client/src/controllers/Projects.js:406 msgid "Failed to create new project. POST returned status:" msgstr "" @@ -1109,7 +1112,7 @@ msgstr "" msgid "Failed to retrieve job template extra variables." msgstr "" -#: client/src/controllers/Projects.js:598 +#: client/src/controllers/Projects.js:600 msgid "Failed to retrieve project: %s. GET status:" msgstr "" @@ -1130,7 +1133,7 @@ msgstr "" msgid "Failed to update Credential. PUT status:" msgstr "" -#: client/src/controllers/Projects.js:663 +#: client/src/controllers/Projects.js:665 msgid "Failed to update project: %s. PUT status:" msgstr "" @@ -1160,9 +1163,9 @@ msgstr "" msgid "Finished" msgstr "" -#: client/src/access/rbac-multiselect/permissionsUsers.list.js:27 +#: client/src/access/rbac-multiselect/permissionsUsers.list.js:21 #: client/src/forms/Users.js:28 -#: client/src/lists/Users.js:41 +#: client/src/lists/Users.js:35 msgid "First Name" msgstr "" @@ -1228,8 +1231,8 @@ msgstr "" msgid "Google OAuth2" msgstr "" -#: client/src/forms/Teams.js:155 -#: client/src/forms/Users.js:211 +#: client/src/forms/Teams.js:156 +#: client/src/forms/Users.js:213 msgid "Grant Permission" msgstr "" @@ -1310,10 +1313,10 @@ msgid "INITIATED BY" msgstr "" #: client/src/helpers/ActivityStream.js:26 -#: client/src/inventories/main.js:291 +#: client/src/inventories/main.js:298 #: client/src/main-menu/main-menu.partial.html:104 #: client/src/main-menu/main-menu.partial.html:27 -#: client/src/organizations/linkout/organizations-linkout.route.js:178 +#: client/src/organizations/linkout/organizations-linkout.route.js:143 msgid "INVENTORIES" msgstr "" @@ -1445,13 +1448,13 @@ msgstr "" msgid "JOB TEMPLATE" msgstr "" -#: client/src/organizations/linkout/organizations-linkout.route.js:287 +#: client/src/organizations/linkout/organizations-linkout.route.js:252 msgid "JOB TEMPLATES" msgstr "" -#: client/src/app.js:430 #: client/src/dashboard/graphs/job-status/job-status-graph.directive.js:113 #: client/src/helpers/ActivityStream.js:44 +#: client/src/jobs/jobs.route.js:15 #: client/src/main-menu/main-menu.partial.html:122 #: client/src/main-menu/main-menu.partial.html:43 msgid "JOBS" @@ -1477,6 +1480,7 @@ msgstr "" #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:97 #: client/src/lists/PortalJobTemplates.js:15 #: client/src/lists/PortalJobTemplates.js:16 +#: client/src/organizations/linkout/organizations-linkout.route.js:264 msgid "Job Templates" msgstr "" @@ -1491,9 +1495,9 @@ msgstr "" #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:28 #: client/src/configuration/configuration.partial.html:16 +#: client/src/jobs/jobs.partial.html:7 #: client/src/lists/PortalJobs.js:15 #: client/src/lists/PortalJobs.js:19 -#: client/src/partials/jobs.html:7 msgid "Jobs" msgstr "" @@ -1536,9 +1540,9 @@ msgstr "" msgid "Labels" msgstr "" -#: client/src/access/rbac-multiselect/permissionsUsers.list.js:31 +#: client/src/access/rbac-multiselect/permissionsUsers.list.js:25 #: client/src/forms/Users.js:35 -#: client/src/lists/Users.js:45 +#: client/src/lists/Users.js:39 msgid "Last Name" msgstr "" @@ -1661,12 +1665,12 @@ msgstr "" msgid "Management Jobs" msgstr "" -#: client/src/controllers/Projects.js:91 +#: client/src/controllers/Projects.js:93 msgid "Manual projects do not require a schedule" msgstr "" -#: client/src/controllers/Projects.js:587 -#: client/src/controllers/Projects.js:90 +#: client/src/controllers/Projects.js:589 +#: client/src/controllers/Projects.js:92 msgid "Manual projects do not require an SCM update" msgstr "" @@ -1739,11 +1743,11 @@ msgstr "" #: client/src/forms/LogViewerStatus.js:23 #: client/src/forms/Organizations.js:26 #: client/src/forms/Projects.js:31 -#: client/src/forms/Teams.js:124 +#: client/src/forms/Teams.js:125 #: client/src/forms/Teams.js:27 -#: client/src/forms/Users.js:140 -#: client/src/forms/Users.js:165 -#: client/src/forms/Users.js:191 +#: client/src/forms/Users.js:141 +#: client/src/forms/Users.js:167 +#: client/src/forms/Users.js:193 #: client/src/forms/Workflows.js:34 #: client/src/inventory-scripts/inventory-scripts.form.js:28 #: client/src/inventory-scripts/inventory-scripts.list.js:20 @@ -1829,7 +1833,7 @@ msgstr "" msgid "No Credentials Have Been Created" msgstr "" -#: client/src/controllers/Projects.js:161 +#: client/src/controllers/Projects.js:163 msgid "No SCM Configuration" msgstr "" @@ -1841,11 +1845,11 @@ msgstr "" msgid "No Teams exist" msgstr "" -#: client/src/controllers/Projects.js:152 +#: client/src/controllers/Projects.js:154 msgid "No Updates Available" msgstr "" -#: client/src/access/rbac-multiselect/permissionsUsers.list.js:24 +#: client/src/access/rbac-multiselect/permissionsUsers.list.js:18 msgid "No Users exist" msgstr "" @@ -1881,8 +1885,8 @@ msgstr "" msgid "No matching tasks" msgstr "" -#: client/src/forms/Teams.js:121 -#: client/src/forms/Users.js:188 +#: client/src/forms/Teams.js:122 +#: client/src/forms/Users.js:190 msgid "No permissions have been granted" msgstr "" @@ -1902,7 +1906,7 @@ msgstr "" msgid "Normal User" msgstr "" -#: client/src/controllers/Projects.js:93 +#: client/src/controllers/Projects.js:95 msgid "Not configured for SCM" msgstr "" @@ -1996,7 +2000,7 @@ msgid "Organization" msgstr "" #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:30 -#: client/src/forms/Users.js:130 +#: client/src/forms/Users.js:131 #: client/src/setup-menu/setup-menu.partial.html:4 msgid "Organizations" msgstr "" @@ -2027,19 +2031,19 @@ msgstr "" msgid "PROJECT" msgstr "" -#: client/src/app.js:295 +#: client/src/app.js:296 #: client/src/helpers/ActivityStream.js:23 #: client/src/main-menu/main-menu.partial.html:19 #: client/src/main-menu/main-menu.partial.html:95 -#: client/src/organizations/linkout/organizations-linkout.route.js:229 +#: client/src/organizations/linkout/organizations-linkout.route.js:194 msgid "PROJECTS" msgstr "" #: client/src/shared/paginate/paginate.partial.html:33 msgid "" "Page\n" -" {{current()}} of\n" -" {{last()}}" +" {{current}} of\n" +" {{last}}" msgstr "" #: client/src/notifications/notificationTemplates.form.js:240 @@ -2109,7 +2113,7 @@ msgstr "" msgid "Period" msgstr "" -#: client/src/controllers/Projects.js:324 +#: client/src/controllers/Projects.js:326 #: client/src/controllers/Users.js:141 #: client/src/templates/job_templates/add-job-template/job-template-add.controller.js:26 msgid "Permission Error" @@ -2118,10 +2122,9 @@ msgstr "" #: client/src/forms/Credentials.js:440 #: client/src/forms/Inventories.js:96 #: client/src/forms/JobTemplates.js:419 -#: client/src/forms/Organizations.js:65 #: client/src/forms/Projects.js:228 -#: client/src/forms/Teams.js:117 -#: client/src/forms/Users.js:184 +#: client/src/forms/Teams.js:118 +#: client/src/forms/Users.js:186 #: client/src/forms/Workflows.js:117 msgid "Permissions" msgstr "" @@ -2148,7 +2151,7 @@ msgstr "" msgid "Plays" msgstr "" -#: client/src/forms/Users.js:124 +#: client/src/forms/Users.js:125 msgid "Please add user to an Organization." msgstr "" @@ -2208,26 +2211,26 @@ msgstr "" msgid "Please save before adding notifications" msgstr "" -#: client/src/forms/Teams.js:69 +#: client/src/forms/Organizations.js:59 +#: client/src/forms/Teams.js:70 msgid "Please save before adding users" msgstr "" -#: client/src/controllers/Credentials.js:161 +#: client/src/controllers/Credentials.js:160 #: client/src/forms/Inventories.js:92 #: client/src/forms/JobTemplates.js:412 -#: client/src/forms/Organizations.js:58 #: client/src/forms/Projects.js:220 -#: client/src/forms/Teams.js:113 +#: client/src/forms/Teams.js:114 #: client/src/forms/Workflows.js:110 msgid "Please save before assigning permissions" msgstr "" -#: client/src/forms/Users.js:122 -#: client/src/forms/Users.js:180 +#: client/src/forms/Users.js:123 +#: client/src/forms/Users.js:182 msgid "Please save before assigning to organizations" msgstr "" -#: client/src/forms/Users.js:149 +#: client/src/forms/Users.js:151 msgid "Please save before assigning to teams" msgstr "" @@ -2345,7 +2348,7 @@ msgstr "" msgid "Project Sync Failures" msgstr "" -#: client/src/controllers/Projects.js:172 +#: client/src/controllers/Projects.js:174 msgid "Project lookup failed. GET returned:" msgstr "" @@ -2409,11 +2412,11 @@ msgstr "" msgid "RECENTLY USED TEMPLATES" msgstr "" +#: client/src/jobs/jobs.partial.html:15 #: client/src/lists/JobEvents.js:89 #: client/src/lists/Projects.js:70 #: client/src/lists/Schedules.js:63 #: client/src/lists/Streams.js:57 -#: client/src/partials/jobs.html:15 #: client/src/portal-mode/portal-mode-jobs.partial.html:12 msgid "REFRESH" msgstr "" @@ -2451,7 +2454,7 @@ msgstr "" msgid "RUN COMMAND" msgstr "" -#: client/src/workflow-results/workflow-results.partial.html:140 +#: client/src/workflow-results/workflow-results.partial.html:155 msgid "Read only view of extra variables added to the workflow." msgstr "" @@ -2479,8 +2482,8 @@ msgid "Relaunch using the same parameters" msgstr "" #: client/src/access/add-rbac-user-team/rbac-selected-list.directive.js:37 -#: client/src/forms/Teams.js:142 -#: client/src/forms/Users.js:219 +#: client/src/forms/Teams.js:143 +#: client/src/forms/Users.js:221 msgid "Remove" msgstr "" @@ -2535,18 +2538,18 @@ msgstr "" msgid "Revision" msgstr "" -#: client/src/controllers/Projects.js:697 +#: client/src/controllers/Projects.js:699 msgid "Revision #" msgstr "" #: client/src/forms/Credentials.js:462 #: client/src/forms/EventsViewer.js:36 #: client/src/forms/Inventories.js:121 -#: client/src/forms/Organizations.js:89 +#: client/src/forms/Organizations.js:88 #: client/src/forms/Projects.js:250 -#: client/src/forms/Teams.js:135 -#: client/src/forms/Teams.js:98 -#: client/src/forms/Users.js:202 +#: client/src/forms/Teams.js:136 +#: client/src/forms/Teams.js:99 +#: client/src/forms/Users.js:204 #: client/src/forms/Workflows.js:141 msgid "Role" msgstr "" @@ -2559,20 +2562,20 @@ msgstr "" msgid "SAML" msgstr "" -#: client/src/scheduler/main.js:293 +#: client/src/scheduler/main.js:314 msgid "SCHEDULED" msgstr "" #: client/src/helpers/ActivityStream.js:50 #: client/src/inventories/main.js:59 #: client/src/management-jobs/scheduler/main.js:26 -#: client/src/scheduler/main.js:122 -#: client/src/scheduler/main.js:205 +#: client/src/scheduler/main.js:129 +#: client/src/scheduler/main.js:219 #: client/src/scheduler/main.js:36 msgid "SCHEDULES" msgstr "" -#: client/src/controllers/Projects.js:697 +#: client/src/controllers/Projects.js:699 msgid "SCM Branch" msgstr "" @@ -2602,7 +2605,7 @@ msgstr "" msgid "SCM Update" msgstr "" -#: client/src/controllers/Projects.js:216 +#: client/src/controllers/Projects.js:218 msgid "SCM Update Cancel" msgstr "" @@ -2610,8 +2613,8 @@ msgstr "" msgid "SCM Update Options" msgstr "" -#: client/src/controllers/Projects.js:583 -#: client/src/controllers/Projects.js:86 +#: client/src/controllers/Projects.js:585 +#: client/src/controllers/Projects.js:88 msgid "SCM update currently running" msgstr "" @@ -2628,7 +2631,7 @@ msgstr "" msgid "SIGN IN WITH" msgstr "" -#: client/src/app.js:499 +#: client/src/app.js:453 msgid "SOCKETS" msgstr "" @@ -2710,7 +2713,7 @@ msgstr "" msgid "Schedule Management Job" msgstr "" -#: client/src/controllers/Projects.js:78 +#: client/src/controllers/Projects.js:80 msgid "Schedule future SCM updates" msgstr "" @@ -2723,12 +2726,12 @@ msgid "Scheduled Jobs" msgstr "" #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:32 -#: client/src/inventories/main.js:93 +#: client/src/inventories/main.js:100 +#: client/src/jobs/jobs.partial.html:10 #: client/src/management-jobs/scheduler/main.js:32 -#: client/src/partials/jobs.html:10 -#: client/src/scheduler/main.js:153 -#: client/src/scheduler/main.js:236 -#: client/src/scheduler/main.js:67 +#: client/src/scheduler/main.js:167 +#: client/src/scheduler/main.js:257 +#: client/src/scheduler/main.js:74 msgid "Schedules" msgstr "" @@ -2907,8 +2910,8 @@ msgstr "" msgid "Start a job using this template" msgstr "" -#: client/src/controllers/Projects.js:580 -#: client/src/controllers/Projects.js:77 +#: client/src/controllers/Projects.js:582 +#: client/src/controllers/Projects.js:79 msgid "Start an SCM update" msgstr "" @@ -2982,9 +2985,9 @@ msgstr "" msgid "System auditors have read-only permissions in this section." msgstr "" -#: client/src/app.js:343 +#: client/src/app.js:344 #: client/src/helpers/ActivityStream.js:35 -#: client/src/organizations/linkout/organizations-linkout.route.js:132 +#: client/src/organizations/linkout/organizations-linkout.route.js:97 msgid "TEAMS" msgstr "" @@ -3018,7 +3021,6 @@ msgstr "" #: client/src/forms/Credentials.js:468 #: client/src/forms/Inventories.js:127 -#: client/src/forms/Organizations.js:95 #: client/src/forms/Projects.js:256 #: client/src/forms/Workflows.js:147 msgid "Team Roles" @@ -3026,7 +3028,7 @@ msgstr "" #: client/src/access/add-rbac-resource/rbac-resource.partial.html:40 #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:33 -#: client/src/forms/Users.js:156 +#: client/src/forms/Users.js:158 #: client/src/lists/Teams.js:16 #: client/src/lists/Teams.js:17 #: client/src/setup-menu/setup-menu.partial.html:16 @@ -3063,7 +3065,7 @@ msgstr "" msgid "The Project ID is the GCE assigned identification. It is constructed as two words followed by a three digit number. Such as:" msgstr "" -#: client/src/controllers/Projects.js:733 +#: client/src/controllers/Projects.js:735 msgid "The SCM update process is running." msgstr "" @@ -3100,7 +3102,7 @@ msgstr "" msgid "The project value" msgstr "" -#: client/src/controllers/Projects.js:161 +#: client/src/controllers/Projects.js:163 msgid "The selected project is not configured for SCM. To configure for SCM, edit the project and provide SCM settings, and then run an update." msgstr "" @@ -3124,7 +3126,7 @@ msgstr "" msgid "There are no jobs to display at this time" msgstr "" -#: client/src/controllers/Projects.js:152 +#: client/src/controllers/Projects.js:154 msgid "There is no SCM update information available for this project. An update has not yet been completed. If you have not already done so, start an update for this project." msgstr "" @@ -3153,7 +3155,7 @@ msgstr "" msgid "This must be of the form %s." msgstr "" -#: client/src/forms/Users.js:161 +#: client/src/forms/Users.js:163 msgid "This user is not a member of any teams" msgstr "" @@ -3209,8 +3211,8 @@ msgstr "" #: client/src/forms/Credentials.js:61 #: client/src/forms/Credentials.js:85 -#: client/src/forms/Teams.js:130 -#: client/src/forms/Users.js:197 +#: client/src/forms/Teams.js:131 +#: client/src/forms/Users.js:199 #: client/src/forms/WorkflowMaker.js:34 #: client/src/lists/AllJobs.js:61 #: client/src/lists/CompletedJobs.js:50 @@ -3240,8 +3242,8 @@ msgstr "" msgid "Type an option on each line. The pound symbol (#) is not required." msgstr "" -#: client/src/controllers/Projects.js:442 -#: client/src/controllers/Projects.js:724 +#: client/src/controllers/Projects.js:444 +#: client/src/controllers/Projects.js:726 msgid "URL popover text" msgstr "" @@ -3249,17 +3251,17 @@ msgstr "" msgid "USERNAME" msgstr "" -#: client/src/app.js:367 +#: client/src/app.js:368 #: client/src/helpers/ActivityStream.js:32 -#: client/src/organizations/linkout/organizations-linkout.route.js:59 +#: client/src/organizations/linkout/organizations-linkout.route.js:41 msgid "USERS" msgstr "" -#: client/src/controllers/Projects.js:260 +#: client/src/controllers/Projects.js:262 msgid "Update Not Found" msgstr "" -#: client/src/controllers/Projects.js:733 +#: client/src/controllers/Projects.js:735 msgid "Update in Progress" msgstr "" @@ -3285,9 +3287,9 @@ msgstr "" #: client/src/forms/Credentials.js:457 #: client/src/forms/Inventories.js:116 -#: client/src/forms/Organizations.js:84 +#: client/src/forms/Organizations.js:83 #: client/src/forms/Projects.js:245 -#: client/src/forms/Teams.js:93 +#: client/src/forms/Teams.js:94 #: client/src/forms/Workflows.js:136 msgid "User" msgstr "" @@ -3300,7 +3302,7 @@ msgstr "" msgid "User Type" msgstr "" -#: client/src/access/rbac-multiselect/permissionsUsers.list.js:36 +#: client/src/access/rbac-multiselect/permissionsUsers.list.js:30 #: client/src/forms/Users.js:49 #: client/src/helpers/Credentials.js:118 #: client/src/helpers/Credentials.js:225 @@ -3308,7 +3310,7 @@ msgstr "" #: client/src/helpers/Credentials.js:32 #: client/src/helpers/Credentials.js:56 #: client/src/helpers/Credentials.js:89 -#: client/src/lists/Users.js:37 +#: client/src/lists/Users.js:31 #: client/src/notifications/notificationTemplates.form.js:67 msgid "Username" msgstr "" @@ -3319,9 +3321,10 @@ msgstr "" #: client/src/access/add-rbac-resource/rbac-resource.partial.html:35 #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:35 -#: client/src/forms/Teams.js:75 -#: client/src/lists/Users.js:26 -#: client/src/lists/Users.js:27 +#: client/src/forms/Organizations.js:65 +#: client/src/forms/Teams.js:76 +#: client/src/lists/Users.js:20 +#: client/src/lists/Users.js:21 #: client/src/setup-menu/setup-menu.partial.html:10 msgid "Users" msgstr "" @@ -3372,7 +3375,7 @@ msgstr "" #: client/src/lists/Streams.js:66 #: client/src/lists/Teams.js:69 #: client/src/lists/Templates.js:116 -#: client/src/lists/Users.js:78 +#: client/src/lists/Users.js:72 #: client/src/notifications/notificationTemplates.list.js:80 msgid "View" msgstr "" @@ -3456,10 +3459,11 @@ msgstr "" msgid "View the schedule" msgstr "" -#: client/src/lists/Users.js:81 +#: client/src/lists/Users.js:75 msgid "View user" msgstr "" +#: client/src/lists/AllJobs.js:51 #: client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html:25 #: client/src/standard-out/scm-update/standard-out-scm-update.partial.html:25 msgid "View workflow results" @@ -3512,7 +3516,7 @@ msgstr "" msgid "You can create a job template here." msgstr "" -#: client/src/controllers/Projects.js:508 +#: client/src/controllers/Projects.js:510 msgid "You do not have access to view this property" msgstr "" @@ -3520,7 +3524,7 @@ msgstr "" msgid "You do not have permission to add a job template." msgstr "" -#: client/src/controllers/Projects.js:324 +#: client/src/controllers/Projects.js:326 msgid "You do not have permission to add a project." msgstr "" @@ -3555,7 +3559,7 @@ msgstr "" msgid "Your password must contain one of the following characters: %s" msgstr "" -#: client/src/controllers/Projects.js:216 +#: client/src/controllers/Projects.js:218 msgid "Your request to cancel the update was submitted to the task manager." msgstr "" diff --git a/awx/ui/po/fr.po b/awx/ui/po/fr.po index 3f5cafc3bd..f7355b266a 100644 --- a/awx/ui/po/fr.po +++ b/awx/ui/po/fr.po @@ -25,9 +25,9 @@ msgid "" "put the username and key in the URL. If using Bitbucket and SSH, do not " "supply your Bitbucket username." msgstr "" -"%sRemarque%s : Mercurial ne prend pas en charge l'authentification par mot " -"de passe pour SSH. N'entrez ni le nom d'utilisateur, ni la clé dans l'URL. " -"Si vous utilisez Bitbucket et SSH, ne saisissez pas votre nom d'utilisateur " +"%sRemarque%s : Mercurial ne prend pas en charge l’authentification par mot " +"de passe pour SSH. N’entrez ni le nom d’utilisateur, ni la clé dans l’URL. " +"Si vous utilisez Bitbucket et SSH, ne saisissez pas votre nom d’utilisateur " "Bitbucket." #: client/src/controllers/Projects.js:424 @@ -39,10 +39,10 @@ msgid "" "only protocol (git://) does not use username or password information." msgstr "" "%sRemarque%s : Si vous utilisez le protocole SSH pour GitHub ou Bitbucket, " -"entrez uniquement une clé SSH sans nom d'utilisateur (autre que git). De " -"plus, GitHub et Bitbucket ne prennent pas en charge l'authentification par " +"entrez uniquement une clé SSH sans nom d’utilisateur (autre que git). De " +"plus, GitHub et Bitbucket ne prennent pas en charge l’authentification par " "mot de passe lorsque SSH est utilisé. Le protocole GIT en lecture seule (git:" -"//) n'utilise pas les informations de nom d'utilisateur ou de mot de passe." +"//) n’utilise pas les informations de nom d’utilisateur ou de mot de passe." #: client/src/forms/Credentials.js:288 msgid "(defaults to %s)" @@ -74,7 +74,7 @@ msgstr "ACTION" #: client/src/activity-stream/activitystream.route.js:27 msgid "ACTIVITY STREAM" -msgstr "FLUX D'ACTIVITÉ" +msgstr "FLUX D’ACTIVITÉ" #: client/src/forms/Credentials.js:450 #: client/src/forms/JobTemplates.js:430 @@ -121,7 +121,7 @@ msgstr "Clé API" #: client/src/notifications/notificationTemplates.form.js:251 msgid "API Service/Integration Key" -msgstr "Service API/Clé d'intégration" +msgstr "Service API/Clé d’intégration" #: client/src/notifications/shared/type-change.service.js:52 msgid "API Token" @@ -133,7 +133,7 @@ msgstr "Tower" #: client/src/forms/Credentials.js:92 msgid "Access Key" -msgstr "Clé d'accès" +msgstr "Clé d’accès" #: client/src/notifications/notificationTemplates.form.js:229 msgid "Account SID" @@ -159,13 +159,13 @@ msgstr "Activité" #: client/src/forms/ActivityDetail.js:25 msgid "Activity Detail" -msgstr "Détails sur l'activité" +msgstr "Détails sur l’activité" #: client/src/configuration/system-form/configuration-system.controller.js:81 #: client/src/lists/Streams.js:16 #: client/src/lists/Streams.js:17 msgid "Activity Stream" -msgstr "Flux d'activité" +msgstr "Flux d’activité" #: client/src/forms/Inventories.js:105 #: client/src/forms/Organizations.js:73 @@ -177,7 +177,7 @@ msgstr "Ajouter" #: client/src/lists/Credentials.js:17 msgid "Add Credentials" -msgstr "Ajouter des informations d'identification" +msgstr "Ajouter des informations d’identification" #: client/src/dashboard/hosts/dashboard-hosts.list.js:12 msgid "Add Existing Hosts" @@ -239,7 +239,7 @@ msgid "" msgstr "" "Ajouter des mots de passe, des clés SSH, etc. pour Tower afin de les " "utiliser lors du lancement de tâches sur des machines ou durant la " -"synchronisation d'inventaires ou de projets." +"synchronisation d’inventaires ou de projets." #: client/src/shared/form-generator.js:1462 msgid "Admin" @@ -268,13 +268,13 @@ msgstr "Toutes les tâches" #: client/src/forms/JobTemplates.js:303 #: client/src/forms/JobTemplates.js:310 msgid "Allow Provisioning Callbacks" -msgstr "Autoriser les rappels d'exécution de Tower job_template" +msgstr "Autoriser les rappels d’exécution de Tower job_template" #: client/src/setup-menu/setup-menu.partial.html:11 msgid "Allow others to sign into Tower and own the content they create." msgstr "" "Autoriser les autres à se connecter à Tower et à devenir propriétaire du " -"contenu qu'ils créent." +"contenu qu’ils créent." #: client/src/forms/WorkflowMaker.js:50 msgid "Always" @@ -285,13 +285,13 @@ msgid "" "An SCM update does not appear to be running for project: %s. Click the " "%sRefresh%s button to view the latest status." msgstr "" -"Une mise à jour SCM ne semble pas s'exécuter pour le projet : %s. Cliquez " -"sur le bouton %sActualiser%s pour voir l'état le plus récent." +"Une mise à jour SCM ne semble pas s’exécuter pour le projet : %s. Cliquez " +"sur le bouton %sActualiser%s pour voir l’état le plus récent." #: client/src/controllers/Credentials.js:105 msgid "Are you sure you want to delete the credential below?" msgstr "" -"Voulez-vous vraiment supprimer les informations d'identification ci-" +"Voulez-vous vraiment supprimer les informations d’identification ci-" "dessous ?" #: client/src/helpers/Jobs.js:231 @@ -304,7 +304,7 @@ msgstr "Voulez-vous vraiment supprimer le modèle de notification ci-dessous ?" #: client/src/organizations/list/organizations-list.controller.js:166 msgid "Are you sure you want to delete the organization below?" -msgstr "Voulez-vous vraiment supprimer l'organisation ci-dessous ?" +msgstr "Voulez-vous vraiment supprimer l’organisation ci-dessous ?" #: client/src/controllers/Projects.js:202 msgid "Are you sure you want to delete the project below?" @@ -312,7 +312,7 @@ msgstr "Voulez-vous vraiment supprimer le projet ci-dessous ?" #: client/src/controllers/Users.js:102 msgid "Are you sure you want to delete the user below?" -msgstr "Voulez-vous vraiment supprimer l'utilisateur ci-dessous ?" +msgstr "Voulez-vous vraiment supprimer l’utilisateur ci-dessous ?" #: client/src/controllers/Credentials.js:579 #: client/src/controllers/Projects.js:687 @@ -328,7 +328,7 @@ msgstr "Arguments" #: client/src/forms/Credentials.js:312 #: client/src/forms/Credentials.js:398 msgid "Ask at runtime?" -msgstr "Demander durant l'éxecution ?" +msgstr "Demander durant l’éxecution ?" #: client/src/shared/form-generator.js:1464 msgid "Auditor" @@ -344,10 +344,10 @@ msgid "" "usernames, passwords, and authorize information. Network credentials are " "used when submitting jobs to run playbooks against network devices." msgstr "" -"Authentification pour l'accès aux périphériques réseau. Il peut s'agir de " -"clés SSH, de noms d'utilisateur, de mots de passe et d'informations " -"d'autorisation. Les informations d'identification réseau sont utilisées au " -"cours de l'envoi de tâches afin d'exécuter des playbooks sur des " +"Authentification pour l’accès aux périphériques réseau. Il peut s’agir de " +"clés SSH, de noms d’utilisateur, de mots de passe et d’informations " +"d’autorisation. Les informations d’identification réseau sont utilisées au " +"cours de l’envoi de tâches afin d’exécuter des playbooks sur des " "périphériques réseau." #: client/src/forms/Credentials.js:69 @@ -356,10 +356,10 @@ msgid "" "usernames, passwords, and sudo information. Machine credentials are used " "when submitting jobs to run playbooks against remote hosts." msgstr "" -"Authentification pour l'accès aux machines distantes. Il peut s'agir de clés " -"SSH, de noms d'utilisateur, de mots de passe et d'informations sudo. Les " -"informations d'identification de machine sont utilisées au cours de l'envoi " -"de tâches afin d'exécuter des playbooks sur des hôtes distants." +"Authentification pour l’accès aux machines distantes. Il peut s’agir de clés " +"SSH, de noms d’utilisateur, de mots de passe et d’informations sudo. Les " +"informations d’identification de machine sont utilisées au cours de l’envoi " +"de tâches afin d’exécuter des playbooks sur des hôtes distants." #: client/src/forms/Credentials.js:344 msgid "Authorize" @@ -367,7 +367,7 @@ msgstr "Autoriser" #: client/src/forms/Credentials.js:352 msgid "Authorize Password" -msgstr "Mot de passe d'autorisation" +msgstr "Mot de passe d’autorisation" #: client/src/configuration/auth-form/configuration-auth.controller.js:101 msgid "Azure AD" @@ -392,7 +392,7 @@ msgstr "" #: client/src/forms/JobTemplates.js:297 msgid "Become Privilege Escalation" -msgstr "Activer l'élévation des privilèges" +msgstr "Activer l’élévation des privilèges" #: client/src/license/license.partial.html:104 msgid "Browse" @@ -429,46 +429,46 @@ msgstr "CRÉER UNE TÂCHE PROGRAMMÉE" #: client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html:80 #: client/src/standard-out/scm-update/standard-out-scm-update.partial.html:82 msgid "CREDENTIAL" -msgstr "INFORMATIONS D'IDENTIFICATION" +msgstr "INFORMATIONS D’IDENTIFICATION" #: client/src/app.js:319 #: client/src/helpers/ActivityStream.js:29 msgid "CREDENTIALS" -msgstr "INFORMATIONS D'IDENTIFICATION" +msgstr "INFORMATIONS D’IDENTIFICATION" #: client/src/forms/Projects.js:194 msgid "Cache Timeout" -msgstr "Expiration du délai d'attente du cache" +msgstr "Expiration du délai d’attente du cache" #: client/src/forms/Projects.js:183 msgid "Cache Timeout%s (seconds)%s" -msgstr "Expiration du délai d'attente du cache%s (secondes)%s" +msgstr "Expiration du délai d’attente du cache%s (secondes)%s" #: client/src/controllers/Projects.js:193 #: client/src/controllers/Users.js:95 msgid "Call to %s failed. DELETE returned status:" -msgstr "Échec de l'appel de %s. État DELETE renvoyé :" +msgstr "Échec de l’appel de %s. État DELETE renvoyé :" #: client/src/controllers/Projects.js:241 #: client/src/controllers/Projects.js:257 msgid "Call to %s failed. GET status:" -msgstr "Échec de l'appel de %s. État GET :" +msgstr "Échec de l’appel de %s. État GET :" #: client/src/controllers/Projects.js:681 msgid "Call to %s failed. POST returned status:" -msgstr "Échec de l'appel de %s. État POST renvoyé :" +msgstr "Échec de l’appel de %s. État POST renvoyé :" #: client/src/controllers/Projects.js:220 msgid "Call to %s failed. POST status:" -msgstr "Échec de l'appel de %s. État POST :" +msgstr "Échec de l’appel de %s. État POST :" #: client/src/management-jobs/card/card.controller.js:30 msgid "Call to %s failed. Return status: %d" -msgstr "Échec de l'appel de %s. État du renvoie : %d" +msgstr "Échec de l’appel de %s. État du renvoie : %d" #: client/src/controllers/Projects.js:266 msgid "Call to get project failed. GET status:" -msgstr "Échec de l'appel du projet en GET. État GET :" +msgstr "Échec de l’appel du projet en GET. État GET :" #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:171 #: client/src/configuration/configuration.controller.js:449 @@ -503,7 +503,7 @@ msgstr "Impossible de rechercher les tâches en cours" #: client/src/forms/Projects.js:82 msgid "Change %s under \"Configure Tower\" to change this location." -msgstr "Modifiez %s sous \"Configurer Tower\" pour changer d'emplacement." +msgstr "Modifiez %s sous \"Configurer Tower\" pour changer d’emplacement." #: client/src/forms/ActivityDetail.js:43 msgid "Changes" @@ -523,7 +523,7 @@ msgid "" "submit." msgstr "" "Choisissez votre fichier de licence, acceptez le Contrat de licence de " -"l'utilisateur final et validez." +"l’utilisateur final et validez." #: client/src/forms/Projects.js:151 msgid "Clean" @@ -573,7 +573,7 @@ msgstr "Fermer" #: client/src/forms/JobTemplates.js:180 #: client/src/job-detail/job-detail.partial.html:124 msgid "Cloud Credential" -msgstr "Informations d'identification cloud" +msgstr "Informations d’identification cloud" #: client/src/helpers/Credentials.js:160 #: client/src/helpers/Credentials.js:296 @@ -588,7 +588,7 @@ msgstr "Tout réduire" #: client/src/notifications/notificationTemplates.form.js:298 msgid "Color can be one of %s." -msgstr "La couleur peut être l'une des %s." +msgstr "La couleur peut être l’une des %s." #: client/src/lists/CompletedJobs.js:18 msgid "Completed Jobs" @@ -622,14 +622,14 @@ msgstr "Confirmer la réinitialisation usine" msgid "" "Consult the Ansible documentation for further details on the usage of tags." msgstr "" -"Consultez la documentation d'Ansible pour en savoir plus sur l'utilisation " +"Consultez la documentation d’Ansible pour en savoir plus sur l’utilisation " "des balises." #: client/src/forms/JobTemplates.js:245 msgid "" "Control the level of output ansible will produce as the playbook executes." msgstr "" -"Contrôlez le niveau de sortie qu'Ansible génère lors de l'exécution du " +"Contrôlez le niveau de sortie qu’Ansible génère lors de l’exécution du " "playbook." #: client/src/lists/Templates.js:99 @@ -642,7 +642,7 @@ msgstr "Copier le modèle" #: client/src/forms/Credentials.js:18 msgid "Create Credential" -msgstr "Créer des informations d'identification" +msgstr "Créer des informations d’identification" #: client/src/lists/Users.js:52 msgid "Create New" @@ -650,7 +650,7 @@ msgstr "Créer" #: client/src/lists/Credentials.js:58 msgid "Create a new credential" -msgstr "Créer de nouvelles informations d'identification" +msgstr "Créer de nouvelles informations d’identification" #: client/src/inventory-scripts/inventory-scripts.list.js:43 msgid "Create a new custom inventory" @@ -689,7 +689,7 @@ msgstr "Créer un utilisateur" msgid "Create and edit scripts to dynamically load hosts from any source." msgstr "" "Créez et modifiez des scripts pour charger dynamiquement des hôtes à partir " -"de n'importe quelle source." +"de n’importe quelle source." #: client/src/setup-menu/setup-menu.partial.html:42 msgid "" @@ -709,7 +709,7 @@ msgstr "Créé le" #: client/src/forms/WorkflowMaker.js:69 #: client/src/standard-out/adhoc/standard-out-adhoc.partial.html:67 msgid "Credential" -msgstr "Information d'identification" +msgstr "Information d’identification" #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:123 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:57 @@ -718,7 +718,7 @@ msgstr "Information d'identification" #: client/src/lists/Credentials.js:19 #: client/src/setup-menu/setup-menu.partial.html:22 msgid "Credentials" -msgstr "Informations d'identification" +msgstr "Informations d’identification" #: client/src/controllers/Credentials.js:349 msgid "" @@ -726,9 +726,9 @@ msgid "" "organization to delegate credential permissions. The organization cannot be " "edited after credentials are assigned." msgstr "" -"Les informations d'identification ne peuvent être partagées que dans " -"l'organisation. Allouer les informations d'identification à une organisation " -"pour déléguer les permissions d'informations d'identification." +"Les informations d’identification ne peuvent être partagées que dans " +"l’organisation. Allouer les informations d’identification à une organisation " +"pour déléguer les permissions d’informations d’identification." #: client/src/inventory-scripts/inventory-scripts.form.js:53 #: client/src/inventory-scripts/inventory-scripts.form.js:63 @@ -780,15 +780,15 @@ msgstr "Supprimer" #: client/src/lists/Credentials.js:93 msgid "Delete credential" -msgstr "Supprimer les informations d'identification" +msgstr "Supprimer les informations d’identification" #: client/src/lists/Inventories.js:94 msgid "Delete inventory" -msgstr "Supprimer l'inventaire" +msgstr "Supprimer l’inventaire" #: client/src/inventory-scripts/inventory-scripts.list.js:76 msgid "Delete inventory script" -msgstr "Supprimer le script d'inventaire" +msgstr "Supprimer le script d’inventaire" #: client/src/notifications/notificationTemplates.list.js:91 msgid "Delete notification" @@ -804,7 +804,7 @@ msgstr "Supprimer la programmation" #: client/src/lists/Teams.js:81 msgid "Delete team" -msgstr "Supprimer l'équipe" +msgstr "Supprimer l’équipe" #: client/src/lists/Templates.js:127 msgid "Delete template" @@ -832,15 +832,15 @@ msgstr "Supprimer la planification" #: client/src/lists/Users.js:91 msgid "Delete user" -msgstr "Supprimer l'utilisateur" +msgstr "Supprimer l’utilisateur" #: client/src/forms/Projects.js:163 msgid "" "Depending on the size of the repository this may significantly increase the " "amount of time required to complete an update." msgstr "" -"Selon la taille du référentiel, cette opération risque d'augmenter " -"considérablement le délai d'exécution de la mise à jour." +"Selon la taille du référentiel, cette opération risque d’augmenter " +"considérablement le délai d’exécution de la mise à jour." #: client/src/forms/Credentials.js:41 #: client/src/forms/Inventories.js:37 @@ -892,11 +892,11 @@ msgstr "Ignorer les modifications" #: client/src/forms/Teams.js:146 msgid "Dissasociate permission from team" -msgstr "Dissocier la permission de l'équipe" +msgstr "Dissocier la permission de l’équipe" #: client/src/forms/Users.js:222 msgid "Dissasociate permission from user" -msgstr "Dissocier la permission de l'utilisateur" +msgstr "Dissocier la permission de l’utilisateur" #: client/src/forms/Credentials.js:385 #: client/src/helpers/Credentials.js:134 @@ -917,7 +917,7 @@ msgid "" "Drag and drop your custom inventory script file here or create one in the " "field to import your custom inventory." msgstr "" -"Faites glisser votre script d'inventaire personnalisé et déposez-le ici ou " +"Faites glisser votre script d’inventaire personnalisé et déposez-le ici ou " "créez-en un dans le champ pour importer votre inventaire personnalisé." #: client/src/management-jobs/scheduler/main.js:88 @@ -944,7 +944,7 @@ msgid "" "Each time a job runs using this project, perform an update to the local " "repository prior to starting the job." msgstr "" -"Chaque fois qu'une tâche s'exécute avec ce projet, réalisez une mise à jour " +"Chaque fois qu’une tâche s’exécute avec ce projet, réalisez une mise à jour " "dans le référentiel local avant de lancer la tâche." #: client/src/dashboard/hosts/dashboard-hosts.list.js:62 @@ -971,24 +971,24 @@ msgid "Edit Survey" msgstr "Modifier le questionnaire" #: client/src/setup-menu/setup-menu.partial.html:54 -msgid "Edit Tower's configuration." +msgid "Edit Tower’s configuration." msgstr "Modifier la configuration de Tower." #: client/src/lists/Credentials.js:74 msgid "Edit credential" -msgstr "Modifier les informations d'identification" +msgstr "Modifier les informations d’identification" #: client/src/dashboard/hosts/dashboard-hosts.list.js:65 msgid "Edit host" -msgstr "Modifier l'hôte" +msgstr "Modifier l’hôte" #: client/src/lists/Inventories.js:80 msgid "Edit inventory" -msgstr "Modifier l'inventaire" +msgstr "Modifier l’inventaire" #: client/src/inventory-scripts/inventory-scripts.list.js:59 msgid "Edit inventory script" -msgstr "Modifier le script d'inventaire" +msgstr "Modifier le script d’inventaire" #: client/src/notifications/notificationTemplates.list.js:74 msgid "Edit notification" @@ -1000,7 +1000,7 @@ msgstr "Modifier la programmation" #: client/src/lists/Teams.js:64 msgid "Edit team" -msgstr "Modifier l'équipe" +msgstr "Modifier l’équipe" #: client/src/lists/Templates.js:109 msgid "Edit template" @@ -1008,7 +1008,7 @@ msgstr "Modifier le modèle" #: client/src/workflow-results/workflow-results.partial.html:109 msgid "Edit the User" -msgstr "Modifier l'Utilisateur" +msgstr "Modifier l’Utilisateur" #: client/src/lists/Projects.js:103 msgid "Edit the project" @@ -1020,15 +1020,15 @@ msgstr "Modifier la planification" #: client/src/lists/Users.js:72 msgid "Edit user" -msgstr "Modifier l'utilisateur" +msgstr "Modifier l’utilisateur" #: client/src/controllers/Projects.js:236 msgid "" "Either you do not have access or the SCM update process completed. Click the " "%sRefresh%s button to view the latest status." msgstr "" -"Vous n'avez pas accès, ou la mise à jour SCM est terminée. Cliquez sur le " -"bouton %sActualiser%s pour voir l'état le plus récent." +"Vous n’avez pas accès, ou la mise à jour SCM est terminée. Cliquez sur le " +"bouton %sActualiser%s pour voir l’état le plus récent." #: client/src/forms/EventsViewer.js:81 #: client/src/forms/LogViewerStatus.js:50 @@ -1050,21 +1050,21 @@ msgstr "Activer les tâches parallèles" #: client/src/forms/JobTemplates.js:292 msgid "Enable Privilege Escalation" -msgstr "Activer l'élévation des privilèges" +msgstr "Activer l’élévation des privilèges" #: client/src/forms/JobTemplates.js:307 msgid "" "Enables creation of a provisioning callback URL. Using the URL a host can " "contact Tower and request a configuration update using this job template." msgstr "" -"Active la création d'une URL de rappels d'exécution de Tower job_template. " +"Active la création d’une URL de rappels d’exécution de Tower job_template. " "Avec cette URL, un hôte peut contacter Tower et demander une mise à jour de " -"la configuration à l'aide de ce modèle de tâche." +"la configuration à l’aide de ce modèle de tâche." #: client/src/helpers/Credentials.js:414 msgid "Encrypted credentials are not supported." msgstr "" -"Les informations d'identification chiffrées ne sont pas prises en charge." +"Les informations d’identification chiffrées ne sont pas prises en charge." #: client/src/forms/EventsViewer.js:77 msgid "End" @@ -1072,14 +1072,14 @@ msgstr "Fin" #: client/src/license/license.partial.html:108 msgid "End User License Agreement" -msgstr "Contrat de licence de l'utilisateur final" +msgstr "Contrat de licence de l’utilisateur final" #: client/src/forms/Inventories.js:60 msgid "" "Enter inventory variables using either JSON or YAML syntax. Use the radio " "button to toggle between the two." msgstr "" -"Entrez les variables d'inventaire avec la syntaxe JSON ou YAML. Utilisez le " +"Entrez les variables d’inventaire avec la syntaxe JSON ou YAML. Utilisez le " "bouton radio pour basculer entre les deux." #: client/src/helpers/Credentials.js:161 @@ -1088,7 +1088,7 @@ msgid "" "Enter the URL for the virtual machine which %scorresponds to your CloudForm " "instance. %sFor example, %s" msgstr "" -"Veuillez saisir l'URL de la machine virtuelle qui correspond à votre " +"Veuillez saisir l’URL de la machine virtuelle qui correspond à votre " "instance de CloudForm. %sPar exemple, %s" #: client/src/helpers/Credentials.js:151 @@ -1097,7 +1097,7 @@ msgid "" "Enter the URL which corresponds to your %sRed Hat Satellite 6 server. %sFor " "example, %s" msgstr "" -"Veuillez saisir l'URL qui correspond à votre serveur %sRed Hat Satellite 6. " +"Veuillez saisir l’URL qui correspond à votre serveur %sRed Hat Satellite 6. " "%sPar exemple, %s" #: client/src/helpers/Credentials.js:129 @@ -1105,7 +1105,7 @@ msgstr "" msgid "" "Enter the hostname or IP address which corresponds to your VMware vCenter." msgstr "" -"Entrez le nom d'hôte ou l'adresse IP qui correspond à votre VMware vCenter." +"Entrez le nom d’hôte ou l’adresse IP qui correspond à votre VMware vCenter." #: client/src/configuration/configuration.controller.js:307 #: client/src/configuration/configuration.controller.js:385 @@ -1153,7 +1153,7 @@ msgstr "Événement" #: client/src/widgets/Stream.js:216 msgid "Event summary not available" -msgstr "Récapitulatif de l'événement non disponible" +msgstr "Récapitulatif de l’événement non disponible" #: client/src/lists/JobEvents.js:31 msgid "Events" @@ -1162,17 +1162,17 @@ msgstr "Événements" #: client/src/controllers/Projects.js:421 #: client/src/controllers/Projects.js:704 msgid "Example URLs for GIT SCM include:" -msgstr "Exemples d'URL pour le SCM GIT :" +msgstr "Exemples d’URL pour le SCM GIT :" #: client/src/controllers/Projects.js:434 #: client/src/controllers/Projects.js:716 msgid "Example URLs for Mercurial SCM include:" -msgstr "Exemples d'URL pour le SCM Mercurial :" +msgstr "Exemples d’URL pour le SCM Mercurial :" #: client/src/controllers/Projects.js:429 #: client/src/controllers/Projects.js:711 msgid "Example URLs for Subversion SCM include:" -msgstr "Exemples d'URL pour le SCM Subversion :" +msgstr "Exemples d’URL pour le SCM Subversion :" #: client/src/job-results/job-results.controller.js:11 #: client/src/job-results/job-results.controller.js:207 @@ -1227,11 +1227,11 @@ msgstr "Échec des hôtes" #: client/src/controllers/Users.js:182 msgid "Failed to add new user. POST returned status:" -msgstr "L'ajout de l'utilisateur a échoué. État POST renvoyé :" +msgstr "L’ajout de l’utilisateur a échoué. État POST renvoyé :" #: client/src/helpers/Credentials.js:419 msgid "Failed to create new Credential. POST status:" -msgstr "La création des informations d'identification a échoué. État POST :" +msgstr "La création des informations d’identification a échoué. État POST :" #: client/src/controllers/Projects.js:404 msgid "Failed to create new project. POST returned status:" @@ -1239,12 +1239,12 @@ msgstr "La création du projet a échoué. État POST renvoyé :" #: client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js:120 msgid "Failed to get third-party login types. Returned status:" -msgstr "L'obtention des types de connexion tiers a échoué. État renvoyé :" +msgstr "L’obtention des types de connexion tiers a échoué. État renvoyé :" #: client/src/job-submission/job-submission-factories/launchjob.factory.js:188 msgid "Failed to retrieve job template extra variables." msgstr "" -"N'a pas pu extraire les variables supplémentaires du modèle de la tâche." +"N’a pas pu extraire les variables supplémentaires du modèle de la tâche." #: client/src/controllers/Projects.js:598 msgid "Failed to retrieve project: %s. GET status:" @@ -1253,21 +1253,21 @@ msgstr "La récupération du projet a échoué : %s. État GET :" #: client/src/controllers/Users.js:272 #: client/src/controllers/Users.js:340 msgid "Failed to retrieve user: %s. GET status:" -msgstr "La récupération de l'utilisateur a échoué : %s. État GET :" +msgstr "La récupération de l’utilisateur a échoué : %s. État GET :" #: client/src/configuration/configuration.controller.js:386 msgid "Failed to save settings. Returned status:" -msgstr "L'enregistrement des paramètres a échoué. État renvoyé :" +msgstr "L’enregistrement des paramètres a échoué. État renvoyé :" #: client/src/configuration/configuration.controller.js:420 msgid "Failed to save toggle settings. Returned status:" msgstr "" -"L'enregistrement des paramètres d'activation/désactivation a échoué. État " +"L’enregistrement des paramètres d’activation/désactivation a échoué. État " "renvoyé :" #: client/src/helpers/Credentials.js:435 msgid "Failed to update Credential. PUT status:" -msgstr "La mise à jour des informations d'identification a échoué. État PUT :" +msgstr "La mise à jour des informations d’identification a échoué. État PUT :" #: client/src/controllers/Projects.js:663 msgid "Failed to update project: %s. PUT status:" @@ -1278,11 +1278,11 @@ msgstr "La mise à jour du projet a échoué : %s. État PUT :" #: client/src/management-jobs/card/card.controller.js:241 msgid "Failed updating job %s with variables. POST returned: %d" msgstr "" -"N'a pas pu mettre à jour la tâche %s avec les variables. Retour POST : %d" +"N’a pas pu mettre à jour la tâche %s avec les variables. Retour POST : %d" #: client/src/helpers/Projects.js:73 msgid "Failed. Click for details" -msgstr "Échec. Cliquer pour obtenir davantage d'informations" +msgstr "Échec. Cliquer pour obtenir davantage d’informations" #: client/src/notifications/notifications.list.js:48 msgid "Failure" @@ -1337,7 +1337,7 @@ msgid "" "For hosts that are part of an external inventory, this flag cannot be " "changed. It will be set by the inventory sync process." msgstr "" -"Pour les hôtes qui font partie d'un inventaire externe, ce marqueur ne peut " +"Pour les hôtes qui font partie d’un inventaire externe, ce marqueur ne peut " "pas être modifié. Il sera défini par le processus de synchronisation des " "inventaires." @@ -1347,7 +1347,7 @@ msgid "" "For more information and examples see %sthe Patterns topic at docs.ansible." "com%s." msgstr "" -"Pour obtenir plus d'informations et voir des exemples, reportez-vous à la " +"Pour obtenir plus d’informations et voir des exemples, reportez-vous à la " "rubrique %sPatterns sur docs.ansible.com%s." #: client/src/forms/JobTemplates.js:203 @@ -1391,7 +1391,7 @@ msgid "" "Group all of your content to manage permissions across departments in your " "company." msgstr "" -"Regroupez l'ensemble du contenu pour gérer les permissions entre les " +"Regroupez l’ensemble du contenu pour gérer les permissions entre les " "différents services de votre entreprise." #: client/src/dashboard/hosts/main.js:55 @@ -1405,7 +1405,7 @@ msgstr "En-têtes HTTP" #: client/src/bread-crumb/bread-crumb.directive.js:111 msgid "Hide Activity Stream" -msgstr "Cacher le flux d'activité" +msgstr "Cacher le flux d’activité" #: client/src/forms/Credentials.js:140 #: client/src/forms/EventsViewer.js:20 @@ -1417,12 +1417,12 @@ msgstr "Hôte" #: client/src/helpers/Credentials.js:132 #: client/src/helpers/Credentials.js:268 msgid "Host (Authentication URL)" -msgstr "Hôte (URL d'authentification)" +msgstr "Hôte (URL d’authentification)" #: client/src/forms/JobTemplates.js:341 #: client/src/forms/JobTemplates.js:350 msgid "Host Config Key" -msgstr "Clé de configuration de l'hôte" +msgstr "Clé de configuration de l’hôte" #: client/src/dashboard/hosts/dashboard-hosts.list.js:54 msgid "Host Enabled" @@ -1430,11 +1430,11 @@ msgstr "Hôte activé" #: client/src/job-detail/job-detail.partial.html:269 msgid "Host Status" -msgstr "Statut de l'hôte" +msgstr "Statut de l’hôte" #: client/src/lists/JobEvents.js:37 msgid "Host Summary" -msgstr "Récapitulatif de l'hôte" +msgstr "Récapitulatif de l’hôte" #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:25 #: client/src/dashboard/counts/dashboard-counts.directive.js:39 @@ -1456,7 +1456,7 @@ msgstr "Hôtes utilisés" #: client/src/license/license.partial.html:116 msgid "I agree to the End User License Agreement" -msgstr "J'accepte le Contrat de licence de l'utilisateur final" +msgstr "J’accepte le Contrat de licence de l’utilisateur final" #: client/src/forms/EventsViewer.js:28 msgid "ID" @@ -1476,12 +1476,12 @@ msgstr "INVENTAIRES" #: client/src/inventory-scripts/inventory-scripts.form.js:23 msgid "INVENTORY SCRIPT" -msgstr "SCRIPT D'INVENTAIRE" +msgstr "SCRIPT D’INVENTAIRE" #: client/src/helpers/ActivityStream.js:47 #: client/src/inventory-scripts/main.js:66 msgid "INVENTORY SCRIPTS" -msgstr "SCRIPTS D'INVENTAIRE" +msgstr "SCRIPTS D’INVENTAIRE" #: client/src/notifications/notificationTemplates.form.js:360 msgid "IRC Nick" @@ -1518,13 +1518,13 @@ msgid "" "If enabled, run this playbook as an administrator. This is the equivalent of " "passing the %s option to the %s command." msgstr "" -"Si cette option est activée, exécutez ce playbook en tant qu'administrateur. " -"Cette opération revient à transmettre l'option %s à la commande %s." +"Si cette option est activée, exécutez ce playbook en tant qu’administrateur. " +"Cette opération revient à transmettre l’option %s à la commande %s." #: client/src/forms/JobTemplates.js:319 msgid "If enabled, simultaneous runs of this job template will be allowed." msgstr "" -"Si activé, il sera possible d'avoir des exécutions de ce modèle de tâche en " +"Si activé, il sera possible d’avoir des exécutions de ce modèle de tâche en " "simultané." #: client/src/forms/Credentials.js:54 @@ -1534,11 +1534,11 @@ msgid "" "can assign an organization so that roles for the credential can be assigned " "to users and teams in that organization." msgstr "" -"Si aucune organisation n'est renseignée, les informations d'identification " -"ne peuvent être utilisées que par l'utilisateur qui les crée. Les " -"administrateurs d'organisation et les administrateurs système peuvent " +"Si aucune organisation n’est renseignée, les informations d’identification " +"ne peuvent être utilisées que par l’utilisateur qui les crée. Les " +"administrateurs d’organisation et les administrateurs système peuvent " "assigner une organisation pour que les rôles liés aux informations " -"d'identification puissent être associés aux utilisateurs et aux équipes de " +"d’identification puissent être associés aux utilisateurs et aux équipes de " "cette organisation." #: client/src/license/license.partial.html:70 @@ -1553,7 +1553,7 @@ msgid "" "Indicates if a host is available and should be included in running jobs." msgstr "" "Indique si un hôte est disponible et doit être ajouté aux tâches en cours " -"d'exécution." +"d’exécution." #: client/src/forms/ActivityDetail.js:33 #: client/src/lists/Streams.js:36 @@ -1566,7 +1566,7 @@ msgid "" "problems." msgstr "" "À la place, %s vérifie la syntaxe du playbook, teste la configuration de " -"l'environnement et signale les problèmes." +"l’environnement et signale les problèmes." #: client/src/license/license.partial.html:11 msgid "Invalid License" @@ -1579,7 +1579,7 @@ msgstr "Format de fichier non valide. Chargez un fichier JSON valide." #: client/src/login/loginModal/loginModal.partial.html:34 msgid "Invalid username and/or password. Please try again." -msgstr "Nom d'utilisateur et/ou mot de passe non valide. Veuillez réessayer." +msgstr "Nom d’utilisateur et/ou mot de passe non valide. Veuillez réessayer." #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:116 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:51 @@ -1604,7 +1604,7 @@ msgstr "Inventaire" #: client/src/inventory-scripts/inventory-scripts.list.js:12 #: client/src/setup-menu/setup-menu.partial.html:34 msgid "Inventory Scripts" -msgstr "Scripts d'inventaire" +msgstr "Scripts d’inventaire" #: client/src/dashboard/graphs/dashboard-graphs.partial.html:46 msgid "Inventory Sync" @@ -1616,7 +1616,7 @@ msgstr "Erreurs de synchronisation des inventaires" #: client/src/forms/Inventories.js:67 msgid "Inventory Variables" -msgstr "Variables d'inventaire" +msgstr "Variables d’inventaire" #: client/src/forms/EventsViewer.js:49 #: client/src/job-detail/job-detail.partial.html:350 @@ -1645,7 +1645,7 @@ msgstr "TÂCHES" #: client/src/lists/JobEvents.js:15 msgid "Job Events" -msgstr "Événements d'une tâche" +msgstr "Événements d’une tâche" #: client/src/forms/JobTemplates.js:252 #: client/src/forms/JobTemplates.js:260 @@ -1796,7 +1796,7 @@ msgid "" "automatically update in real-time." msgstr "" "Événements en direct : connecté. Les pages contenant des informations sur " -"l'état de la tâche seront automatiquement mises à jour en temps réel." +"l’état de la tâche seront automatiquement mises à jour en temps réel." #: client/src/shared/socket/socket.service.js:176 msgid "Live events: error connecting to the Tower server." @@ -1835,14 +1835,14 @@ msgstr "Machine" #: client/src/forms/JobTemplates.js:139 #: client/src/job-detail/job-detail.partial.html:117 msgid "Machine Credential" -msgstr "Informations d'identification de la machine" +msgstr "Informations d’identification de la machine" #: client/src/setup-menu/setup-menu.partial.html:29 msgid "" "Manage the cleanup of old job history, activity streams, data marked for " "deletion, and system tracking info." msgstr "" -"Gérez le nettoyage de l'historique des anciennes tâches, des flux " +"Gérez le nettoyage de l’historique des anciennes tâches, des flux " "d’activité, des données marquées pour suppression et des informations de " "suivi du système." @@ -1882,7 +1882,7 @@ msgstr "Système divers" #: client/src/helpers/Projects.js:76 msgid "Missing. Click for details" -msgstr "Manquant. Cliquer pour obtenir davantage d'informations" +msgstr "Manquant. Cliquer pour obtenir davantage d’informations" #: client/src/forms/EventsViewer.js:53 msgid "Module" @@ -1912,7 +1912,7 @@ msgstr "AUCUN HÔTE DÉTECTÉ" #: client/src/login/loginModal/loginModal.partial.html:89 msgid "NOTICE" -msgstr "ENVOI D'INFOS" +msgstr "ENVOI D’INFOS" #: client/src/notifications/notificationTemplates.form.js:21 msgid "NOTIFICATION TEMPLATE" @@ -1971,15 +1971,15 @@ msgstr "Réseau" #: client/src/forms/JobTemplates.js:197 #: client/src/job-detail/job-detail.partial.html:131 msgid "Network Credential" -msgstr "Informations d'identification réseau" +msgstr "Informations d’identification réseau" #: client/src/forms/JobTemplates.js:196 msgid "" "Network credentials are used by Ansible networking modules to connect to and " "manage networking devices." msgstr "" -"Les informations d'identification sont utilisées par les modules de mise en " -"réseau d'Ansible pour connecter et gérer les périphériques réseau." +"Les informations d’identification sont utilisées par les modules de mise en " +"réseau d’Ansible pour connecter et gérer les périphériques réseau." #: client/src/inventory-scripts/inventory-scripts.form.js:16 msgid "New Custom Inventory" @@ -2019,7 +2019,7 @@ msgstr "Nouveau modèle de tâche Workflow" #: client/src/controllers/Users.js:174 msgid "New user successfully created!" -msgstr "Création de l'utilisateur réussie" +msgstr "Création de l’utilisateur réussie" #: client/src/lists/ScheduledJobs.js:52 #: client/src/lists/Schedules.js:45 @@ -2028,7 +2028,7 @@ msgstr "Exécution suivante" #: client/src/lists/Credentials.js:24 msgid "No Credentials Have Been Created" -msgstr "Informations d'identification non créées" +msgstr "Informations d’identification non créées" #: client/src/controllers/Projects.js:161 msgid "No SCM Configuration" @@ -2036,7 +2036,7 @@ msgstr "Aucune configuration SCM" #: client/src/helpers/Projects.js:58 msgid "No SCM updates have run for this project" -msgstr "Aucune mise à jour SCM n'a été exécutée pour ce projet" +msgstr "Aucune mise à jour SCM n’a été exécutée pour ce projet" #: client/src/access/rbac-multiselect/permissionsTeams.list.js:18 msgid "No Teams exist" @@ -2064,7 +2064,7 @@ msgstr "Aucun modèle de tâche utilisé récemment." #: client/src/lists/AllJobs.js:20 msgid "No jobs have yet run." -msgstr "Aucune tâche exécutée pour l'instant." +msgstr "Aucune tâche exécutée pour l’instant." #: client/src/dashboard/lists/jobs/jobs-list.partial.html:46 msgid "No jobs were recently run." @@ -2072,7 +2072,7 @@ msgstr "Aucune tâche récemment exécutée." #: client/src/job-detail/job-detail.partial.html:374 msgid "No matching host events" -msgstr "Aucun événement d'hôte correspondant" +msgstr "Aucun événement d’hôte correspondant" #: client/src/job-detail/job-detail.partial.html:307 msgid "No matching hosts." @@ -2158,11 +2158,11 @@ msgstr "REMPLACER VARS" #: client/src/forms/WorkflowMaker.js:45 msgid "On Failure" -msgstr "Lors d'un échec" +msgstr "Lors d’un échec" #: client/src/forms/WorkflowMaker.js:40 msgid "On Success" -msgstr "Lors d'une réussite" +msgstr "Lors d’une réussite" #: client/src/forms/Credentials.js:380 msgid "" @@ -2170,7 +2170,7 @@ msgid "" "Keystone v3 authentication URLs. Common scenarios include:" msgstr "" "Les domaines OpenStack définissent les limites administratives. Ils sont " -"nécessaires seulement pour les URL d'authentification Keystone v3. Les " +"nécessaires seulement pour les URL d’authentification Keystone v3. Les " "scénarios courants incluent :" #: client/src/forms/JobTemplates.js:362 @@ -2182,7 +2182,7 @@ msgid "" msgstr "" "Libellés facultatifs décrivant ce modèle de tâche, par exemple 'dev' ou " "'test'. Les libellés peuvent être utilisés pour regrouper et filtrer les " -"modèles de tâche et les tâches terminées dans l'affichage de Tower." +"modèles de tâche et les tâches terminées dans l’affichage de Tower." #: client/src/forms/JobTemplates.js:288 #: client/src/notifications/notificationTemplates.form.js:395 @@ -2269,7 +2269,7 @@ msgid "" "JSON." msgstr "" "Transmettez des variables de ligne de commande supplémentaires au playbook. " -"Il s'agit du paramètre de ligne de commande %s ou %s pour %s. Entrez des " +"Il s’agit du paramètre de ligne de commande %s ou %s pour %s. Entrez des " "paires clé/valeur avec la syntaxe YAML ou JSON." #: client/src/forms/Credentials.js:227 @@ -2314,7 +2314,7 @@ msgid "" "Paste the contents of the PEM file associated with the service account email." "" msgstr "" -"Collez le contenu du fichier PEM associé à l'adresse électronique du compte " +"Collez le contenu du fichier PEM associé à l’adresse électronique du compte " "de service." #: client/src/helpers/Credentials.js:115 @@ -2392,11 +2392,11 @@ msgstr "Veuillez allouer des rôles aux utilisateurs / équipes sélectionnées" #: client/src/license/license.partial.html:84 msgid "" -"Please click the button below to visit Ansible's website to get a Tower " +"Please click the button below to visit Ansible’s website to get a Tower " "license key." msgstr "" -"Cliquez sur le bouton ci-dessous pour visiter le site Web d'Ansible afin " -"d'obtenir une clé de licence Tower." +"Cliquez sur le bouton ci-dessous pour visiter le site Web d’Ansible afin " +"d’obtenir une clé de licence Tower." #: client/src/shared/form-generator.js:836 #: client/src/shared/form-generator.js:950 @@ -2404,7 +2404,7 @@ msgid "" "Please enter a URL that begins with ssh, http or https. The URL may not " "contain the '@' character." msgstr "" -"Veuillez saisir une URL commençant par ssh, http ou https. L'URL ne doit pas " +"Veuillez saisir une URL commençant par ssh, http ou https. L’URL ne doit pas " "contenir le caractère '@'." #: client/src/shared/form-generator.js:1188 @@ -2425,7 +2425,7 @@ msgstr "Entrez un mot de passe." #: client/src/login/loginModal/loginModal.partial.html:58 msgid "Please enter a username." -msgstr "Entrez un nom d'utilisateur." +msgstr "Entrez un nom d’utilisateur." #: client/src/shared/form-generator.js:826 #: client/src/shared/form-generator.js:940 @@ -2444,11 +2444,11 @@ msgstr "Veuillez enregistrer et exécuter une tâche à afficher" #: client/src/notifications/notifications.list.js:15 msgid "Please save before adding notifications" -msgstr "Veuillez enregistrer avant d'ajouter des notifications" +msgstr "Veuillez enregistrer avant d’ajouter des notifications" #: client/src/forms/Teams.js:69 msgid "Please save before adding users" -msgstr "Veuillez enregistrer avant d'ajouter des utilisateurs" +msgstr "Veuillez enregistrer avant d’ajouter des utilisateurs" #: client/src/controllers/Credentials.js:161 #: client/src/forms/Inventories.js:92 @@ -2458,16 +2458,16 @@ msgstr "Veuillez enregistrer avant d'ajouter des utilisateurs" #: client/src/forms/Teams.js:113 #: client/src/forms/Workflows.js:110 msgid "Please save before assigning permissions" -msgstr "Veuillez enregistrer avant d'attribuer des permissions" +msgstr "Veuillez enregistrer avant d’attribuer des permissions" #: client/src/forms/Users.js:122 #: client/src/forms/Users.js:180 msgid "Please save before assigning to organizations" -msgstr "Veuillez enregistrer avant l'attribution à des organisations" +msgstr "Veuillez enregistrer avant l’attribution à des organisations" #: client/src/forms/Users.js:149 msgid "Please save before assigning to teams" -msgstr "Veuillez enregistrer avant l'attribution à des équipes" +msgstr "Veuillez enregistrer avant l’attribution à des équipes" #: client/src/forms/Workflows.js:186 msgid "Please save before defining the workflow graph" @@ -2484,14 +2484,14 @@ msgstr "Veuillez sélectionner des utilisateurs dans la liste ci-dessous." #: client/src/forms/WorkflowMaker.js:65 msgid "Please select a Credential." -msgstr "Sélectionnez des informations d'identification." +msgstr "Sélectionnez des informations d’identification." #: client/src/forms/JobTemplates.js:153 msgid "" "Please select a Machine Credential or check the Prompt on launch option." msgstr "" -"Sélectionnez les informations d'identification de la machine ou cochez " -"l'option Me le demander au lancement." +"Sélectionnez les informations d’identification de la machine ou cochez " +"l’option Me le demander au lancement." #: client/src/shared/form-generator.js:1223 msgid "Please select a number between" @@ -2516,7 +2516,7 @@ msgstr "Sélectionnez une valeur." #: client/src/forms/JobTemplates.js:84 msgid "Please select an Inventory or check the Prompt on launch option." msgstr "" -"Sélectionnez un inventaire ou cochez l'option Me le demander au lancement." +"Sélectionnez un inventaire ou cochez l’option Me le demander au lancement." #: client/src/forms/WorkflowMaker.js:86 msgid "Please select an Inventory." @@ -2552,12 +2552,12 @@ msgstr "Élévation des privilèges" #: client/src/helpers/Credentials.js:227 #: client/src/helpers/Credentials.js:91 msgid "Privilege Escalation Password" -msgstr "Mot de passe pour l'élévation des privilèges" +msgstr "Mot de passe pour l’élévation des privilèges" #: client/src/helpers/Credentials.js:226 #: client/src/helpers/Credentials.js:90 msgid "Privilege Escalation Username" -msgstr "Nom d'utilisateur pour l'élévation des privilèges" +msgstr "Nom d’utilisateur pour l’élévation des privilèges" #: client/src/forms/JobTemplates.js:116 #: client/src/forms/JobTemplates.js:99 @@ -2591,7 +2591,7 @@ msgstr "Erreurs de synchronisation du projet" #: client/src/controllers/Projects.js:172 msgid "Project lookup failed. GET returned:" -msgstr "La recherche de projet n'a pas abouti. GET renvoyé :" +msgstr "La recherche de projet n’a pas abouti. GET renvoyé :" #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:109 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:46 @@ -2626,18 +2626,18 @@ msgid "" "managed or affected by the playbook. Multiple patterns can be separated by " "%s %s or %s" msgstr "" -"Entrez un modèle d'hôte pour limiter davantage la liste des hôtes qui seront " +"Entrez un modèle d’hôte pour limiter davantage la liste des hôtes qui seront " "gérés ou attribués par le playbook. Plusieurs modèles peuvent être séparés " "par des %s %s ou des %s" #: client/src/forms/JobTemplates.js:328 #: client/src/forms/JobTemplates.js:336 msgid "Provisioning Callback URL" -msgstr "URL de rappel d'exécution de Tower job_template" +msgstr "URL de rappel d’exécution de Tower job_template" #: client/src/helpers/Projects.js:63 msgid "Queued. Click for details" -msgstr "En file d'attente. Cliquer pour obtenir davantage d'informations" +msgstr "En file d’attente. Cliquer pour obtenir davantage d’informations" #: client/src/configuration/auth-form/configuration-auth.controller.js:107 msgid "RADIUS" @@ -2739,7 +2739,7 @@ msgstr "Supprimer" #: client/src/forms/Projects.js:153 msgid "Remove any local modifications prior to performing an update." msgstr "" -"Supprimez toutes les modifications locales avant d'effectuer une mise à jour." +"Supprimez toutes les modifications locales avant d’effectuer une mise à jour." "" #: client/src/management-jobs/scheduler/schedulerForm.partial.html:149 @@ -2807,7 +2807,7 @@ msgstr "Rôle" #: client/src/helpers/Projects.js:67 msgid "Running! Click for details" -msgstr "En cours d'exécution. Cliquer pour obtenir davantage d'informations" +msgstr "En cours d’exécution. Cliquer pour obtenir davantage d’informations" #: client/src/configuration/auth-form/configuration-auth.controller.js:108 msgid "SAML" @@ -2836,7 +2836,7 @@ msgstr "Nettoyage SCM" #: client/src/forms/Projects.js:130 msgid "SCM Credential" -msgstr "Information d'identification SCM" +msgstr "Information d’identification SCM" #: client/src/forms/Projects.js:165 msgid "SCM Delete" @@ -3006,8 +3006,8 @@ msgid "" "Management (IAM) users." msgstr "" "Le service de jeton de sécurité (STS) est un service Web qui permet de " -"demander des informations d'identification provisoires avec des privilèges " -"limités pour les utilisateurs d'AWS Identity and Access Management (IAM)." +"demander des informations d’identification provisoires avec des privilèges " +"limités pour les utilisateurs d’AWS Identity and Access Management (IAM)." #: client/src/shared/form-generator.js:1703 msgid "Select" @@ -3047,16 +3047,16 @@ msgid "" "hosts. Choose the credential containing the username and SSH key or " "password that Ansible will need to log into the remote hosts." msgstr "" -"Sélectionnez les informations d'identification que la tâche doit utiliser " -"lors de l'accès à des hôtes distants. Choisissez les informations " -"d'identification contenant le nom d'utilisateur et la clé SSH ou le mot de " +"Sélectionnez les informations d’identification que la tâche doit utiliser " +"lors de l’accès à des hôtes distants. Choisissez les informations " +"d’identification contenant le nom d’utilisateur et la clé SSH ou le mot de " "passe dont Ansible aura besoin pour se connecter aux hôtes distants." #: client/src/forms/JobTemplates.js:86 #: client/src/forms/WorkflowMaker.js:88 msgid "Select the inventory containing the hosts you want this job to manage." msgstr "" -"Sélectionnez l'inventaire contenant les hôtes que vous souhaitez gérer." +"Sélectionnez l’inventaire contenant les hôtes que vous souhaitez gérer." #: client/src/forms/JobTemplates.js:132 msgid "Select the playbook to be executed by this job." @@ -3078,14 +3078,14 @@ msgid "" "the access credentials to the running playbook, allowing provisioning into " "the cloud without manually passing parameters to the included modules." msgstr "" -"La sélection d'informations identification cloud facultatives dans le modèle " -"de tâche transmettra les informations d'identification d'accès au playbook " -"en cours d'exécution, ce qui permet une authentification dans le cloud sans " +"La sélection d’informations identification cloud facultatives dans le modèle " +"de tâche transmettra les informations d’identification d’accès au playbook " +"en cours d’exécution, ce qui permet une authentification dans le cloud sans " "transmettre manuellement les paramètres aux modules inclus." #: client/src/notifications/notificationTemplates.form.js:86 msgid "Sender Email" -msgstr "Adresse électronique de l'expéditeur" +msgstr "Adresse électronique de l’expéditeur" #: client/src/helpers/Credentials.js:234 #: client/src/helpers/Credentials.js:98 @@ -3096,22 +3096,22 @@ msgstr "Adresse électronique du compte de service" #: client/src/forms/WorkflowMaker.js:108 msgid "" "Setting the type to %s will execute the playbook and store any scanned " -"facts for use with Tower's System Tracking feature." +"facts for use with Tower’s System Tracking feature." msgstr "" "La définition du type sur %s exécute le playbook et stocke tous les faits " "scannés à utiliser avec la fonctionnalité de suivi System Tracking de Tower." #: client/src/forms/JobTemplates.js:57 msgid "Setting the type to %s will not execute the playbook." -msgstr "La définition du type sur %s n'exécute pas le playbook." +msgstr "La définition du type sur %s n’exécute pas le playbook." #: client/src/forms/WorkflowMaker.js:106 msgid "" "Setting the type to %s will not execute the playbook. Instead, %s will check " "playbook syntax, test environment setup and report problems." msgstr "" -"La définition du type sur %s n'exécute pas le playbook. À la place, %s " -"vérifie la syntaxe du playbook, teste la configuration de l'environnement et " +"La définition du type sur %s n’exécute pas le playbook. À la place, %s " +"vérifie la syntaxe du playbook, teste la configuration de l’environnement et " "signale les problèmes." #: client/src/main-menu/main-menu.partial.html:147 @@ -3152,7 +3152,7 @@ msgid "" "specific parts of a play or task." msgstr "" "Les balises de saut sont utiles si votre playbook est important et que vous " -"souhaitez ignorer certaines parties d'une scène ou d'une tâche." +"souhaitez ignorer certaines parties d’une scène ou d’une tâche." #: client/src/forms/Credentials.js:76 msgid "Source Control" @@ -3187,7 +3187,7 @@ msgid "" "Split up your organization to associate content and control permissions for " "groups." msgstr "" -"Divisez votre organisation afin d'associer du contenu et des permissions de " +"Divisez votre organisation afin d’associer du contenu et des permissions de " "contrôle pour les groupes." #: client/src/partials/logviewer.html:5 @@ -3243,7 +3243,7 @@ msgstr "Valider" #: client/src/helpers/Jobs.js:230 msgid "Submit the request to cancel?" -msgstr "Demander l'annulation de la demande ?" +msgstr "Demander l’annulation de la demande ?" #: client/src/license/license.partial.html:27 msgid "Subscription" @@ -3252,12 +3252,12 @@ msgstr "Abonnement" #: client/src/forms/Credentials.js:152 #: client/src/forms/Credentials.js:163 msgid "Subscription ID" -msgstr "ID d'abonnement" +msgstr "ID d’abonnement" #: client/src/forms/Credentials.js:162 msgid "Subscription ID is an Azure construct, which is mapped to a username." msgstr "" -"L'ID d'abonnement est une construction Azure mappée à un nom d'utilisateur." +"L’ID d’abonnement est une construction Azure mappée à un nom d’utilisateur." #: client/src/notifications/notifications.list.js:38 msgid "Success" @@ -3265,7 +3265,7 @@ msgstr "Réussite" #: client/src/helpers/Projects.js:70 msgid "Success! Click for details" -msgstr "Réussi. Cliquer pour obtenir davantage d'informations" +msgstr "Réussi. Cliquer pour obtenir davantage d’informations" #: client/src/dashboard/graphs/dashboard-graphs.partial.html:77 msgid "Successful" @@ -3286,7 +3286,7 @@ msgstr "Auditeur système" #: client/src/configuration/configuration.partial.html:3 msgid "System auditors have read-only permissions in this section." msgstr "" -"Les auditeurs de système n'ont que des permissions lecture-seule sur cette " +"Les auditeurs de système n’ont que des permissions lecture-seule sur cette " "section." #: client/src/app.js:343 @@ -3313,7 +3313,7 @@ msgid "" "specific part of a play or task." msgstr "" "Les balises sont utiles si votre playbook est important et que vous " -"souhaitez exécuter une partie donnée d'une scène ou d'une tâche." +"souhaitez exécuter une partie donnée d’une scène ou d’une tâche." #: client/src/notifications/notificationTemplates.form.js:316 msgid "Target URL" @@ -3333,7 +3333,7 @@ msgstr "Tâches" #: client/src/forms/Projects.js:256 #: client/src/forms/Workflows.js:147 msgid "Team Roles" -msgstr "Rôles d'équipe" +msgstr "Rôles d’équipe" #: client/src/access/add-rbac-resource/rbac-resource.partial.html:40 #: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:33 @@ -3368,7 +3368,7 @@ msgstr "Notification test" #: client/src/shared/form-generator.js:1420 msgid "That value was not found. Please enter or select a valid value." msgstr "" -"Cette valeur n'a pas été trouvée. Veuillez entrer ou sélectionner une valeur " +"Cette valeur n’a pas été trouvée. Veuillez entrer ou sélectionner une valeur " "valide." #: client/src/helpers/Credentials.js:106 @@ -3377,17 +3377,17 @@ msgid "" "The Project ID is the GCE assigned identification. It is constructed as two " "words followed by a three digit number. Such as:" msgstr "" -"L'ID du projet est l'identifiant attribué par GCE. Il se compose de deux " -"mots suivis d'un nombre à trois chiffres. Exemple :" +"L’ID du projet est l’identifiant attribué par GCE. Il se compose de deux " +"mots suivis d’un nombre à trois chiffres. Exemple :" #: client/src/controllers/Projects.js:733 msgid "The SCM update process is running." -msgstr "Le processus de mise à jour SCM est en cours d'exécution." +msgstr "Le processus de mise à jour SCM est en cours d’exécution." #: client/src/standard-out/adhoc/standard-out-adhoc.partial.html:70 msgid "The credential used to run this command." msgstr "" -"Les informations d'identification utilisées pour exécuter cette commande." +"Les informations d’identification utilisées pour exécuter cette commande." #: client/src/forms/Credentials.js:191 msgid "" @@ -3399,15 +3399,15 @@ msgstr "" #: client/src/helpers/Credentials.js:142 #: client/src/helpers/Credentials.js:278 msgid "The host to authenticate with." -msgstr "Hôte avec lequel s'authentifier." +msgstr "Hôte avec lequel s’authentifier." #: client/src/helpers/Credentials.js:75 msgid "The host value" -msgstr "Valeur de l'hôte" +msgstr "Valeur de l’hôte" #: client/src/standard-out/adhoc/standard-out-adhoc.partial.html:61 msgid "The inventory this command ran on." -msgstr "L'inventaire sur lequel cette commande a été exécutée." +msgstr "L’inventaire sur lequel cette commande a été exécutée." #: client/src/forms/JobTemplates.js:212 msgid "" @@ -3415,7 +3415,7 @@ msgid "" "playbook. 0 signifies the default value from the %sansible configuration " "file%s." msgstr "" -"Nombre de processus parallèles ou simultanés à utiliser lors de l'exécution " +"Nombre de processus parallèles ou simultanés à utiliser lors de l’exécution " "du playbook. 0 indique la valeur par défaut du %sfichier de configuration " "ansible%s." @@ -3433,17 +3433,17 @@ msgid "" "The selected project is not configured for SCM. To configure for SCM, edit " "the project and provide SCM settings, and then run an update." msgstr "" -"Le projet sélectionné n'est pas configuré pour SCM. Afin de le configurer " +"Le projet sélectionné n’est pas configuré pour SCM. Afin de le configurer " "pour SCM, modifiez le projet et définissez les paramètres SCM, puis lancez " "une mise à jour." #: client/src/management-jobs/scheduler/schedulerForm.partial.html:124 msgid "The time must be in HH24:MM:SS format." -msgstr "L'heure doit être sous le format suivant HH24:MM:SS" +msgstr "L’heure doit être sous le format suivant HH24:MM:SS" #: client/src/standard-out/adhoc/standard-out-adhoc.partial.html:79 msgid "The user who ran this command." -msgstr "L'utilisateur qui a exécuté cette commande." +msgstr "L’utilisateur qui a exécuté cette commande." #: client/src/lists/Streams.js:19 msgid "There are no events to display at this time" @@ -3463,33 +3463,33 @@ msgid "" "not yet been completed. If you have not already done so, start an update " "for this project." msgstr "" -"Aucune information de mise à jour SCM n'est disponible pour ce projet. Une " -"mise à jour n'est pas encore terminée. Si vous n'avez pas encore lancé une " +"Aucune information de mise à jour SCM n’est disponible pour ce projet. Une " +"mise à jour n’est pas encore terminée. Si vous n’avez pas encore lancé une " "mise à jour pour ce projet, faites-le." #: client/src/configuration/configuration.controller.js:308 msgid "There was an error resetting value. Returned status:" msgstr "" -"Une erreur s'est produite lors de la réinitialisation de la valeur. État " +"Une erreur s’est produite lors de la réinitialisation de la valeur. État " "renvoyé :" #: client/src/configuration/configuration.controller.js:439 msgid "There was an error resetting values. Returned status:" msgstr "" -"Une erreur s'est produite lors de la réinitialisation des valeurs. État " +"Une erreur s’est produite lors de la réinitialisation des valeurs. État " "renvoyé :" #: client/src/management-jobs/scheduler/schedulerForm.partial.html:168 msgid "This is not a valid number." -msgstr "Le nombre n'est pas valide." +msgstr "Le nombre n’est pas valide." #: client/src/helpers/Credentials.js:139 #: client/src/helpers/Credentials.js:275 msgid "" "This is the tenant name. This value is usually the same as the username." msgstr "" -"Il s'agit du nom du client. Cette valeur est habituellement la même que " -"celle du nom d'utilisateur." +"Il s’agit du nom du client. Cette valeur est habituellement la même que " +"celle du nom d’utilisateur." #: client/src/notifications/notifications.list.js:21 msgid "" @@ -3505,7 +3505,7 @@ msgstr "Elle doit se présenter au format %s." #: client/src/forms/Users.js:161 msgid "This user is not a member of any teams" -msgstr "Cet utilisateur n'est pas membre d'une équipe" +msgstr "Cet utilisateur n’est pas membre d’une équipe" #: client/src/shared/form-generator.js:831 #: client/src/shared/form-generator.js:945 @@ -3541,10 +3541,10 @@ msgid "" "update. If it is older than Cache Timeout, it is not considered current, and " "a new project update will be performed." msgstr "" -"Délai en secondes à prévoir pour qu'un projet soit actualisé. Durant " -"l'exécution des tâches et les rappels, le système de tâches évalue " -"l'horodatage de la dernière mise à jour du projet. Si elle est plus ancienne " -"que le délai d'expiration du cache, elle n'est pas considérée comme " +"Délai en secondes à prévoir pour qu’un projet soit actualisé. Durant " +"l’exécution des tâches et les rappels, le système de tâches évalue " +"l’horodatage de la dernière mise à jour du projet. Si elle est plus ancienne " +"que le délai d’expiration du cache, elle n’est pas considérée comme " "actualisée, et une nouvelle mise à jour du projet sera effectuée." #: client/src/forms/EventsViewer.js:74 @@ -3558,8 +3558,8 @@ msgid "" "To learn more about the IAM STS Token, refer to the %sAmazon documentation%s." "" msgstr "" -"Pour en savoir plus sur le token STS d'IAM, reportez-vous à la documentation " -"d'%sAmazon%s." +"Pour en savoir plus sur le token STS d’IAM, reportez-vous à la documentation " +"d’%sAmazon%s." #: client/src/job-detail/job-detail.partial.html:416 msgid "Toggle Output" @@ -3567,7 +3567,7 @@ msgstr "Basculer Sortie" #: client/src/shared/form-generator.js:856 msgid "Toggle the display of plaintext." -msgstr "Bascule l'affichage du texte en clair." +msgstr "Bascule l’affichage du texte en clair." #: client/src/notifications/shared/type-change.service.js:34 #: client/src/notifications/shared/type-change.service.js:40 @@ -3610,17 +3610,17 @@ msgstr "Tapez une option sur chaque ligne." #: client/src/notifications/notificationTemplates.form.js:374 msgid "Type an option on each line. The pound symbol (#) is not required." msgstr "" -"Tapez une option sur chaque ligne. Le symbole dièse (#) n'est pas nécessaire." +"Tapez une option sur chaque ligne. Le symbole dièse (#) n’est pas nécessaire." "" #: client/src/controllers/Projects.js:442 #: client/src/controllers/Projects.js:724 msgid "URL popover text" -msgstr "Texte popover de l'URL" +msgstr "Texte popover de l’URL" #: client/src/login/loginModal/loginModal.partial.html:49 msgid "USERNAME" -msgstr "NOM D'UTILISATEUR" +msgstr "NOM D’UTILISATEUR" #: client/src/app.js:367 #: client/src/helpers/ActivityStream.js:32 @@ -3660,7 +3660,7 @@ msgid "" msgstr "" "Utilisé pour vérifier et synchroniser les référentiels de playbooks avec un " "SCM à distance tel que Git, Subversion (svn) ou Mercurial (hg). Ces " -"informations d'identification sont utilisées par les Projets." +"informations d’identification sont utilisées par les Projets." #: client/src/forms/Credentials.js:457 #: client/src/forms/Inventories.js:116 @@ -3677,7 +3677,7 @@ msgstr "Interface utilisateur" #: client/src/forms/Users.js:94 msgid "User Type" -msgstr "Type d'utilisateur" +msgstr "Type d’utilisateur" #: client/src/access/rbac-multiselect/permissionsUsers.list.js:36 #: client/src/forms/Users.js:49 @@ -3690,7 +3690,7 @@ msgstr "Type d'utilisateur" #: client/src/lists/Users.js:37 #: client/src/notifications/notificationTemplates.form.js:67 msgid "Username" -msgstr "Nom d'utilisateur" +msgstr "Nom d’utilisateur" #: client/src/forms/Credentials.js:81 msgid "" @@ -3698,9 +3698,9 @@ msgid "" "cloud or infrastructure provider. These are used for dynamic inventory " "sources and for cloud provisioning and deployment in playbook runs." msgstr "" -"Noms d'utilisateur, mots de passe et clés d'accès pour s'authentifier auprès " -"du fournisseur de cloud ou d'infrastructure spécifié. Ceux-ci sont utilisés " -"pour les sources d'inventaire dynamique et pour l'authentification de " +"Noms d’utilisateur, mots de passe et clés d’accès pour s’authentifier auprès " +"du fournisseur de cloud ou d’infrastructure spécifié. Ceux-ci sont utilisés " +"pour les sources d’inventaire dynamique et pour l’authentification de " "services dans le cloud et leur déploiement dans les playbooks." #: client/src/access/add-rbac-resource/rbac-resource.partial.html:35 @@ -3767,7 +3767,7 @@ msgstr "Afficher" #: client/src/bread-crumb/bread-crumb.directive.js:111 msgid "View Activity Stream" -msgstr "Afficher le flux d'activité" +msgstr "Afficher le flux d’activité" #: client/src/main-menu/main-menu.partial.html:173 msgid "View Documentation" @@ -3797,24 +3797,24 @@ msgstr "Affichez et modifiez vos informations de licence." #: client/src/lists/Credentials.js:83 msgid "View credential" -msgstr "Afficher les informations d'identification" +msgstr "Afficher les informations d’identification" #: client/src/lists/JobEvents.js:100 #: client/src/lists/Streams.js:70 msgid "View event details" -msgstr "Afficher les détails de l'événement" +msgstr "Afficher les détails de l’événement" #: client/src/setup-menu/setup-menu.partial.html:60 msgid "View information about this version of Ansible Tower." -msgstr "Afficher les informations sur cette version d'Ansible Tower." +msgstr "Afficher les informations sur cette version d’Ansible Tower." #: client/src/lists/Inventories.js:87 msgid "View inventory" -msgstr "Afficher l'inventaire" +msgstr "Afficher l’inventaire" #: client/src/inventory-scripts/inventory-scripts.list.js:67 msgid "View inventory script" -msgstr "Afficher le script d'inventaire" +msgstr "Afficher le script d’inventaire" #: client/src/notifications/notificationTemplates.list.js:82 msgid "View notification" @@ -3826,7 +3826,7 @@ msgstr "Afficher le calendrier" #: client/src/lists/Teams.js:72 msgid "View team" -msgstr "Afficher l'équipe" +msgstr "Afficher l’équipe" #: client/src/lists/Templates.js:118 msgid "View template" @@ -3846,7 +3846,7 @@ msgstr "Afficher le calendrier" #: client/src/lists/Users.js:81 msgid "View user" -msgstr "Afficher l'utilisateur" +msgstr "Afficher l’utilisateur" #: client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html:25 #: client/src/standard-out/scm-update/standard-out-scm-update.partial.html:25 @@ -3914,19 +3914,19 @@ msgstr "" #: client/src/controllers/Projects.js:508 msgid "You do not have access to view this property" -msgstr "Vous n'avez pas d'accès pour afficher cette propriété" +msgstr "Vous n’avez pas d’accès pour afficher cette propriété" #: client/src/templates/job_templates/add-job-template/job-template-add.controller.js:26 msgid "You do not have permission to add a job template." -msgstr "Vous n'êtes pas autorisé à ajouter un modèle de tâche." +msgstr "Vous n’êtes pas autorisé à ajouter un modèle de tâche." #: client/src/controllers/Projects.js:324 msgid "You do not have permission to add a project." -msgstr "Vous n'êtes pas autorisé à ajouter un projet." +msgstr "Vous n’êtes pas autorisé à ajouter un projet." #: client/src/controllers/Users.js:141 msgid "You do not have permission to add a user." -msgstr "Vous n'êtes pas autorisé à ajouter un utilisateur." +msgstr "Vous n’êtes pas autorisé à ajouter un utilisateur." #: client/src/configuration/auth-form/configuration-auth.controller.js:67 #: client/src/configuration/configuration.controller.js:178 @@ -3936,7 +3936,7 @@ msgid "" "You have unsaved changes. Would you like to proceed without " "saving?" msgstr "" -"Des modifications n'ont pas été enregistrées. Voulez-vous continuer " +"Des modifications n’ont pas été enregistrées. Voulez-vous continuer " "sans les enregistrer ?" #: client/src/shared/form-generator.js:957 @@ -3957,18 +3957,18 @@ msgstr "Votre mot de passe doit contenir une lettre majuscule." #: client/src/shared/form-generator.js:977 msgid "Your password must contain one of the following characters: %s" -msgstr "Votre mot de passe doit contenir l'un des caractères suivants : %s" +msgstr "Votre mot de passe doit contenir l’un des caractères suivants : %s" #: client/src/controllers/Projects.js:216 msgid "Your request to cancel the update was submitted to the task manager." msgstr "" -"Votre demande d'annulation de la mise à jour a été envoyée au gestionnaire " +"Votre demande d’annulation de la mise à jour a été envoyée au gestionnaire " "de tâches." #: client/src/login/loginModal/loginModal.partial.html:22 msgid "Your session timed out due to inactivity. Please sign in." msgstr "" -"Votre session a expiré en raison d'un temps d'inactivité. Veuillez vous " +"Votre session a expiré en raison d’un temps d’inactivité. Veuillez vous " "connecter." #: client/src/shared/form-generator.js:1223 diff --git a/awx/ui/tests/spec/job-results/job-results.controller-test.js b/awx/ui/tests/spec/job-results/job-results.controller-test.js index f0b7f3ecea..e9a1629143 100644 --- a/awx/ui/tests/spec/job-results/job-results.controller-test.js +++ b/awx/ui/tests/spec/job-results/job-results.controller-test.js @@ -5,8 +5,12 @@ describe('Controller: jobResultsController', () => { // Setup let jobResultsController; - let jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile, eventResolve, populateResolve, $rScope, q, $log, Dataset, Rest, $state, QuerySet, i18n,fieldChoices, fieldLabels, $interval, workflowResultsService; + let jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile, eventResolve, populateResolve, $rScope, q, $log, Dataset, Rest, $state, QuerySet, i18n,fieldChoices, fieldLabels, $interval, workflowResultsService, statusSocket; + statusSocket = function() { + var fn = function() {}; + return fn; + } jobData = { related: {} }; @@ -70,6 +74,8 @@ describe('Controller: jobResultsController', () => { return jasmine.createSpyObj('workflowResultsService', ['createOneSecondTimer', 'destroyTimer']); }); + $provide.value('statusSocket', statusSocket); + $provide.value('jobData', jobData); $provide.value('jobDataOptions', jobDataOptions); $provide.value('jobLabels', jobLabels); @@ -90,7 +96,7 @@ describe('Controller: jobResultsController', () => { }; let injectVals = () => { - angular.mock.inject((_jobData_, _jobDataOptions_, _jobLabels_, _jobFinished_, _count_, _ParseTypeChange_, _ParseVariableString_, _jobResultsService_, _eventQueue_, _$compile_, $rootScope, $controller, $q, $httpBackend, _$log_, _Dataset_, _Rest_, _$state_, _QuerySet_, _$interval_, _workflowResultsService_) => { + angular.mock.inject((_jobData_, _jobDataOptions_, _jobLabels_, _jobFinished_, _count_, _ParseTypeChange_, _ParseVariableString_, _jobResultsService_, _eventQueue_, _$compile_, $rootScope, $controller, $q, $httpBackend, _$log_, _Dataset_, _Rest_, _$state_, _QuerySet_, _$interval_, _workflowResultsService_, _statusSocket_) => { // when you call $scope.$apply() (which you need to do to // to get inside of .then blocks to test), something is // causing a request for all static files. @@ -127,6 +133,7 @@ describe('Controller: jobResultsController', () => { QuerySet = _QuerySet_; $interval = _$interval_; workflowResultsService = _workflowResultsService_; + statusSocket = _statusSocket_; jobResultsService.getEvents.and .returnValue(eventResolve); @@ -157,7 +164,8 @@ describe('Controller: jobResultsController', () => { Dataset: Dataset, Rest: Rest, $state: $state, - QuerySet: QuerySet + QuerySet: QuerySet, + statusSocket: statusSocket }); }); }; diff --git a/awx/ui/tests/spec/job-results/job-results.service-test.js b/awx/ui/tests/spec/job-results/job-results.service-test.js new file mode 100644 index 0000000000..85cc0e526f --- /dev/null +++ b/awx/ui/tests/spec/job-results/job-results.service-test.js @@ -0,0 +1,50 @@ +'use strict'; + +describe('jobResultsService', () => { + let jobResultsService; + + beforeEach(angular.mock.module('Tower')); + + beforeEach(angular.mock.inject(( _jobResultsService_) => { + jobResultsService = _jobResultsService_; + })); + + describe('getCountsFromStatsEvent()', () => { + it('properly counts hosts based on task state', () => { + let event_data = { + "skipped": { + "skipped-host": 5 // this host skipped all 5 tasks + }, + "ok": { + "ok-host": 5, // this host was ok on all 5 tasks + "changed-host": 4 // this host had 4 ok tasks, had 1 changed task + }, + "changed": { + "changed-host": 1 + }, + "failures": { + "failed-host": 1 // this host had a failed task + }, + "dark": { + "unreachable-host": 1 // this host was unreachable + }, + "processed": { + "ok-host": 1, + "changed-host": 1, + "skipped-host": 1, + "failed-host": 1, + "unreachable-host": 1 + }, + "playbook_uuid": "c23d8872-c92a-4e96-9f78-abe6fef38f33", + "playbook": "some_playbook.yml", + }; + expect(jobResultsService.getCountsFromStatsEvent(event_data)).toEqual({ + 'ok': 1, + 'skipped': 1, + 'unreachable': 1, + 'failures': 1, + 'changed': 1 + }); + }); + }); +}); diff --git a/awx/ui/tests/spec/license/license.controller-test.js b/awx/ui/tests/spec/license/license.controller-test.js index 903e281963..060fcd5b58 100644 --- a/awx/ui/tests/spec/license/license.controller-test.js +++ b/awx/ui/tests/spec/license/license.controller-test.js @@ -44,6 +44,7 @@ describe('Controller: LicenseController', () => { // Suites it('should GET a config object on initialization', ()=>{ + expect(ConfigService.delete).toHaveBeenCalled(); expect(ConfigService.getConfig).toHaveBeenCalled(); }); diff --git a/awx/ui/tests/spec/smart-search/queryset.service-test.js b/awx/ui/tests/spec/smart-search/queryset.service-test.js index 3d49440920..6fa8dbcab5 100644 --- a/awx/ui/tests/spec/smart-search/queryset.service-test.js +++ b/awx/ui/tests/spec/smart-search/queryset.service-test.js @@ -39,8 +39,8 @@ describe('Service: QuerySet', () => { it('should encode parameters properly', () =>{ expect(QuerySet.encodeParam({term: "name:foo", searchTerm: true})).toEqual({"name__icontains_DEFAULT" : "foo"}); expect(QuerySet.encodeParam({term: "-name:foo", searchTerm: true})).toEqual({"not__name__icontains_DEFAULT" : "foo"}); - expect(QuerySet.encodeParam({term: "name:'foo bar'", searchTerm: true})).toEqual({"name__icontains_DEFAULT" : "'foo bar'"}); - expect(QuerySet.encodeParam({term: "-name:'foo bar'", searchTerm: true})).toEqual({"not__name__icontains_DEFAULT" : "'foo bar'"}); + expect(QuerySet.encodeParam({term: "name:'foo bar'", searchTerm: true})).toEqual({"name__icontains_DEFAULT" : "'foo%20bar'"}); + expect(QuerySet.encodeParam({term: "-name:'foo bar'", searchTerm: true})).toEqual({"not__name__icontains_DEFAULT" : "'foo%20bar'"}); expect(QuerySet.encodeParam({term: "organization:foo", relatedSearchTerm: true})).toEqual({"organization__search_DEFAULT" : "foo"}); expect(QuerySet.encodeParam({term: "-organization:foo", relatedSearchTerm: true})).toEqual({"not__organization__search_DEFAULT" : "foo"}); expect(QuerySet.encodeParam({term: "organization.name:foo", relatedSearchTerm: true})).toEqual({"organization__name" : "foo"}); diff --git a/docs/licenses/amqp-1.4.5.tar.gz b/docs/licenses/amqp-1.4.5.tar.gz deleted file mode 100644 index 2c6d6f1147..0000000000 Binary files a/docs/licenses/amqp-1.4.5.tar.gz and /dev/null differ diff --git a/docs/licenses/amqp-1.4.9.tar.gz b/docs/licenses/amqp-1.4.9.tar.gz new file mode 100644 index 0000000000..9533d58491 Binary files /dev/null and b/docs/licenses/amqp-1.4.9.tar.gz differ diff --git a/docs/licenses/psycopg2-2.6.1.tar.gz b/docs/licenses/psycopg2-2.6.1.tar.gz deleted file mode 100644 index b825a43c13..0000000000 Binary files a/docs/licenses/psycopg2-2.6.1.tar.gz and /dev/null differ diff --git a/docs/licenses/psycopg2-2.6.2.tar.gz b/docs/licenses/psycopg2-2.6.2.tar.gz new file mode 100644 index 0000000000..7b407bdaa9 Binary files /dev/null and b/docs/licenses/psycopg2-2.6.2.tar.gz differ diff --git a/docs/logging_integration.md b/docs/logging_integration.md index f913122e04..cca7434db4 100644 --- a/docs/logging_integration.md +++ b/docs/logging_integration.md @@ -26,7 +26,7 @@ from the API. These data loggers are the following. These loggers only use log-level of INFO. -Additionally, the standard Tower logs should be deliverable through this +Additionally, the standard Tower logs are be deliverable through this same mechanism. It should be obvious to the user how to enable to disable each of these 5 sources of data without manipulating a complex dictionary in their local settings file, as well as adjust the log-level consumed @@ -34,16 +34,19 @@ from the standard Tower logs. ## Supported Services -Currently committed to support: +Committed to support: - Splunk - Elastic Stack / ELK Stack / Elastic Cloud -Under consideration for testing: +Have tested: - - Sumo Logic - - Datadog + - Sumologic - Loggly + +Considered, but have not tested: + + - Datadog - Red Hat Common Logging via logstash connector ### Elastic Search Instructions @@ -64,8 +67,8 @@ make docker-compose-elk make docker-compose-cluster-elk ``` -Kibana is the visualization service, and it can be accessed in a web browser -by going to `{server address}:5601`. +For more instructions on getting started with the environment this stands +up, also refer to instructions in `/tools/elastic/README.md`. If you were to start from scratch, standing up your own version the elastic stack, then the only change you should need is to add the following lines @@ -149,6 +152,8 @@ the job model. In addition to the common fields, this will contain a `msg` field with the log message. Errors contain a separate `traceback` field. +These logs can be enabled or disabled in CTiT by adding or removing +it to the setting `LOG_AGGREGATOR_LOGGERS`. # Configuring Inside of Tower @@ -158,10 +163,12 @@ supported services: - Host - Port - - some kind of token - - enabling sending logs, and selecting which loggers to send - - use fully qualified domain name (fqdn) or not - - flag to use HTTPS or not + - The type of service, allowing service-specific customizations + - Optional username for the connection, used by certain services + - Some kind of token or password + - A flag to indicate how system tracking records will be sent + - Selecting which loggers to send + - Enabling sending logs Some settings for the log handler will not be exposed to the user via this mechanism. In particular, threading (enabled), and connection type diff --git a/requirements/requirements.in b/requirements/requirements.in index 723319e86f..b0b5164b25 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -4,7 +4,8 @@ -e git+https://github.com/ansible/dm.xmlsec.binding.git@master#egg=dm.xmlsec.binding -e git+https://github.com/chrismeyersfsu/pyrax@tower#egg=pyrax apache-libcloud==1.3.0 -asgi-amqp==0.3.1 +appdirs==1.4.2 +asgi-amqp==0.4.0 azure==2.0.0rc6 backports.ssl-match-hostname==3.5.0.1 boto==2.45.0 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index d413f74101..2489dc5b36 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -13,8 +13,8 @@ adal==0.4.3 # via msrestazure amqp==1.4.9 # via kombu anyjson==0.3.3 # via kombu apache-libcloud==1.3.0 -appdirs==1.4.0 # via os-client-config, python-ironicclient, rply -asgi-amqp==0.3.1 +appdirs==1.4.2 +asgi-amqp==0.4.0 asgiref==1.0.0 # via asgi-amqp, channels, daphne attrs==16.3.0 # via service-identity autobahn==0.17.0 # via daphne diff --git a/setup.py b/setup.py index d7b2e925b0..334b0e78d5 100755 --- a/setup.py +++ b/setup.py @@ -117,7 +117,9 @@ setup( }, data_files = proc_data_files([ ("%s" % homedir, ["config/wsgi.py", - "awx/static/favicon.ico"]), + "awx/static/favicon.ico", + "awx/locale/*/LC_MESSAGES/*.po", + "awx/locale/*/LC_MESSAGES/*.mo"]), ("%s" % siteconfig, ["config/awx-nginx.conf"]), # ("%s" % webconfig, ["config/uwsgi_params"]), ("%s" % sharedir, ["tools/scripts/request_tower_configuration.sh","tools/scripts/request_tower_configuration.ps1"]), diff --git a/tools/data_generators/rbac_dummy_data_generator.py b/tools/data_generators/rbac_dummy_data_generator.py index b7bc2c7720..0e906a9e47 100755 --- a/tools/data_generators/rbac_dummy_data_generator.py +++ b/tools/data_generators/rbac_dummy_data_generator.py @@ -35,16 +35,14 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "awx.settings.development") # no django.setup() # noqa -from django.contrib.auth.models import User # noqa from django.db import transaction # noqa # awx from awx.main.models import * # noqa from awx.main.signals import ( # noqa - emit_update_inventory_on_created_or_deleted, - emit_update_inventory_computed_fields + disable_activity_stream, + disable_computed_fields ) -from django.db.models.signals import post_save, post_delete, m2m_changed # noqa option_list = [ @@ -194,32 +192,13 @@ def mock_computed_fields(self, **kwargs): PrimordialModel.save = mock_save -sigstat = [] -sigstat.append(post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=Host)) -sigstat.append(post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=Host)) -sigstat.append(post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=Group)) -sigstat.append(post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=Group)) -sigstat.append(m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Group.hosts.through)) -sigstat.append(m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Group.parents.through)) -sigstat.append(m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Host.inventory_sources.through)) -sigstat.append(m2m_changed.disconnect(emit_update_inventory_computed_fields, sender=Group.inventory_sources.through)) -sigstat.append(post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=InventorySource)) -sigstat.append(post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=InventorySource)) -sigstat.append(post_save.disconnect(emit_update_inventory_on_created_or_deleted, sender=Job)) -sigstat.append(post_delete.disconnect(emit_update_inventory_on_created_or_deleted, sender=Job)) - -print ' status of signal disconnects ' -print ' (True means successful disconnect)' -print str(sigstat) - - startTime = datetime.now() try: with transaction.atomic(): - with batch_role_ancestor_rebuilding(): + with batch_role_ancestor_rebuilding(), disable_computed_fields(): admin, created = User.objects.get_or_create(username = 'admin', is_superuser=True) if created: admin.is_superuser = True @@ -713,7 +692,7 @@ try: if j == n / MAX_BULK_CREATE: # on final pass, create the remainder n_subgroup = n % MAX_BULK_CREATE - sys.stdout.write('\r Creating %d job events for job %d, subgroup: %d' % (n, job.id, j)) + sys.stdout.write('\r Creating %d job events for job %d, subgroup: %d' % (n, job.id, j + 1)) sys.stdout.flush() JobEvent.objects.bulk_create([ JobEvent( diff --git a/tools/docker-compose-cluster.yml b/tools/docker-compose-cluster.yml index 788d7d4af6..5f8807c485 100644 --- a/tools/docker-compose-cluster.yml +++ b/tools/docker-compose-cluster.yml @@ -15,6 +15,7 @@ services: - "5555:5555" - "15672:15672" tower_1: + privileged: true image: gcr.io/ansible-tower-engineering/tower_devel:${TAG} hostname: tower_1 environment: @@ -26,6 +27,7 @@ services: - "../:/tower_devel" tower_2: + privileged: true image: gcr.io/ansible-tower-engineering/tower_devel:${TAG} hostname: tower_2 environment: @@ -36,6 +38,7 @@ services: volumes: - "../:/tower_devel" tower_3: + privileged: true image: gcr.io/ansible-tower-engineering/tower_devel:${TAG} hostname: tower_3 environment: diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index edcddcbe43..890e198b64 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -32,6 +32,8 @@ services: image: postgres:9.4.1 memcached: image: memcached:alpine + ports: + - "11211:11211" rabbitmq: image: rabbitmq:3-management ports: diff --git a/tools/docker-compose/Dockerfile b/tools/docker-compose/Dockerfile index 19b699ab36..4a78226a3a 100644 --- a/tools/docker-compose/Dockerfile +++ b/tools/docker-compose/Dockerfile @@ -19,6 +19,7 @@ RUN mkdir -p /etc/tower RUN mkdir -p /data/db ADD tools/docker-compose/license /etc/tower/license RUN pip2 install honcho +RUN pip2 install supervisor RUN curl -LO https://github.com/Yelp/dumb-init/releases/download/v1.1.3/dumb-init_1.1.3_amd64 && chmod +x ./dumb-init_1.1.3_amd64 && mv ./dumb-init_1.1.3_amd64 /usr/bin/dumb-init ADD tools/docker-compose/ansible-tower.egg-link /tmp/ansible-tower.egg-link ADD tools/docker-compose/tower-manage /usr/local/bin/tower-manage diff --git a/tools/docker-compose/start_development.sh b/tools/docker-compose/start_development.sh index ee94888431..9814a9344c 100755 --- a/tools/docker-compose/start_development.sh +++ b/tools/docker-compose/start_development.sh @@ -25,6 +25,7 @@ fi cp -nR /tmp/ansible_tower.egg-info /tower_devel/ || true cp /tmp/ansible-tower.egg-link /venv/tower/lib/python2.7/site-packages/ansible-tower.egg-link +yes | cp -rf /tower_devel/tools/docker-compose/supervisor.conf /supervisor.conf # Tower bootstrapping make version_file @@ -35,4 +36,9 @@ mkdir -p /tower_devel/awx/public/static mkdir -p /tower_devel/awx/ui/static # Start the service -make honcho + +if [ -f "/tower_devel/tools/docker-compose/use_dev_supervisor.txt" ]; then + make supervisor +else + make honcho +fi diff --git a/tools/docker-compose/supervisor.conf b/tools/docker-compose/supervisor.conf new file mode 100644 index 0000000000..aab7d8aeb7 --- /dev/null +++ b/tools/docker-compose/supervisor.conf @@ -0,0 +1,81 @@ +[supervisord] +umask = 022 +minfds = 4096 +nodaemon=true + +[program:celeryd] +command = python manage.py celeryd -l DEBUG -B --autoreload --autoscale=20,3 --schedule=/celerybeat-schedule -Q projects,jobs,default,scheduler,broadcast_all,%(ENV_HOSTNAME)s -n celery@%(ENV_HOSTNAME)s +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[program:receiver] +command = python manage.py run_callback_receiver +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[program:runworker] +command = python manage.py runworker --only-channels websocket.* +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[program:uwsgi] +command = make uwsgi +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[program:daphne] +command = daphne -b 0.0.0.0 -p 8051 awx.asgi:channel_layer +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[program:factcacher] +command = python manage.py run_fact_cache_receiver +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[program:nginx] +command = nginx -g "daemon off;" +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[program:flower] +command = make flower +autostart = true +autorestart = true +redirect_stderr=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 + +[group:tower-processes] +programs=celeryd,receiver,runworker,uwsgi,daphne,factcacher,nginx,flower +priority=5 + +[unix_http_server] +file=/tmp/supervisor.sock + +[supervisorctl] +serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface diff --git a/tools/elastic/README.md b/tools/elastic/README.md index b98b0b34b6..c872831eaf 100644 --- a/tools/elastic/README.md +++ b/tools/elastic/README.md @@ -1,8 +1,7 @@ # Docker ELK / Elastic Stack Development Tools These are tools to run a containerized version of ELK stack, comprising -of Logstash, Elastic Search, and Kibana. There are also cases where -only a subset of these are needed to run. +of Logstash, Elastic Search, and Kibana. A copy of the license is in `docs/licenses/docker-elk.txt` @@ -12,12 +11,10 @@ Due to complex requirements from the elastic search container upstream, there is a prerequisite to get the containers running. The docker _host_ machine must have the `max_map_count` variable increased. For a developer using docker-machine with something like VirtualBox of VMWare, this can be -done by getting bash in the running Docker machine. Example: +done by getting via bash in the running Docker machine. Example: ```bash -$ docker-machine ssh default -docker@default:~$ sudo sysctl -w vm.max_map_count=262144 -vm.max_map_count = 262144 +docker-machine ssh default sudo sysctl -w vm.max_map_count=262144 ``` After this, the containers can be started up with commands like: @@ -32,6 +29,37 @@ make docker-compose-cluster-elk These are ran from the root folder of the ansible-tower repository. +Kibana is the visualization service, and it can be accessed in a web browser +by going to `{server address}:5601`. + + +### Authentication + +The default logstash configuration makes use of basic auth, so a username +and password is needed in the configuration, in addition to the other +parameters. The following settings are supported: + +``` +{ + "LOG_AGGREGATOR_HOST": "logstash", + "LOG_AGGREGATOR_PORT": 8085, + "LOG_AGGREGATOR_TYPE": "logstash", + "LOG_AGGREGATOR_USERNAME": "awx_logger", + "LOG_AGGREGATOR_PASSWORD": "workflows", + "LOG_AGGREGATOR_LOGGERS": [ + "awx", + "activity_stream", + "job_events", + "system_tracking" + ], + "LOG_AGGREGATOR_INDIVIDUAL_FACTS": false, + "LOG_AGGREGATOR_ENABLED": true +} +``` + +These can be entered via Configure-Tower-in-Tower by making a POST to +`/api/v1/settings/logging/`. + ### Connecting Logstash to 3rd Party Receivers In order to send these logs to an external consumer of logstash format diff --git a/tools/scripts/manage_translations.py b/tools/scripts/manage_translations.py index 3611b0d4de..4f6ce0a4c5 100755 --- a/tools/scripts/manage_translations.py +++ b/tools/scripts/manage_translations.py @@ -24,11 +24,11 @@ # to update django.pot file, run: # $ python tools/scripts/manage_translations.py update # -# to update both pot files, run: +# to update both pot files locally, run: # $ python tools/scripts/manage_translations.py update --both # -# to push both pot files (update also), run: -# $ python tools/scripts/manage_translations.py push --both +# to push both pot files (update also) and ja translations, run: +# $ python tools/scripts/manage_translations.py push --both --lang ja # # to pull both translations for Japanese and French, run: # $ python tools/scripts/manage_translations.py pull --both --lang ja,fr @@ -128,17 +128,21 @@ def push(lang=None, both=None): (1) for angularjs - project_type should be gettext - {locale}.po format (2) for django - project_type should be podir - {locale}/{filename}.po format (3) only required languages should be kept enabled + This will update/overwrite PO file with translations found for input lang(s) + [!] POT and PO must remain in sync as messages would overwrite as per POT file """ - - command = "zanata push --project-config %(config)s --push-type both --force --disable-ssl-cert" + command = "zanata push --project-config %(config)s --push-type both --force --lang %(lang)s --disable-ssl-cert" + lang = lang[0] if lang and len(lang) > 0 else 'en-us' if both: - p = Popen(command % {'config': ZNTA_CONFIG_FRONTEND_TRANS}, stdout=PIPE, stderr=PIPE, shell=True) + p = Popen(command % {'config': ZNTA_CONFIG_FRONTEND_TRANS, 'lang': lang}, + stdout=PIPE, stderr=PIPE, shell=True) output, errors = p.communicate() if _handle_response(output, errors): _print_zanata_project_url(ZNTA_CONFIG_FRONTEND_TRANS) - p = Popen(command % {'config': ZNTA_CONFIG_BACKEND_TRANS}, stdout=PIPE, stderr=PIPE, shell=True) + p = Popen(command % {'config': ZNTA_CONFIG_BACKEND_TRANS, 'lang': lang}, + stdout=PIPE, stderr=PIPE, shell=True) output, errors = p.communicate() if _handle_response(output, errors): _print_zanata_project_url(ZNTA_CONFIG_BACKEND_TRANS)