summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.dockerignore1
-rw-r--r--CONTRIBUTING.md4
-rw-r--r--Makefile12
-rw-r--r--README.md2
-rw-r--r--awx/conf/settings.py23
-rw-r--r--awx/locale/django.pot9
-rw-r--r--awx/locale/en-us/LC_MESSAGES/django.po9
-rw-r--r--awx/locale/fr/LC_MESSAGES/django.po10
-rw-r--r--awx/main/isolated/manager.py14
-rw-r--r--awx/main/management/commands/inventory_import.py2
-rw-r--r--awx/main/models/credential/__init__.py5
-rw-r--r--awx/main/models/credential/injectors.py5
-rw-r--r--awx/main/models/notifications.py4
-rw-r--r--awx/main/tasks.py4
-rw-r--r--awx/main/tests/functional/models/test_job.py18
-rw-r--r--awx/main/tests/unit/test_tasks.py55
-rw-r--r--awx/playbooks/check_isolated.yml3
-rw-r--r--awx/playbooks/run_isolated.yml4
-rw-r--r--awx/settings/defaults.py19
-rw-r--r--awx/settings/development.py4
-rw-r--r--awx/settings/local_settings.py.docker_compose148
-rw-r--r--awx/settings/local_settings.py.example192
-rw-r--r--awx/settings/production.py13
-rw-r--r--awx/ui_next/CONTRIBUTING.md2
-rw-r--r--awx/ui_next/package-lock.json68
-rw-r--r--awx/ui_next/package.json2
-rw-r--r--awx/ui_next/src/api/models/Jobs.js4
-rw-r--r--awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx2
-rw-r--r--awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx2
-rw-r--r--awx/ui_next/src/components/AddRole/AddResourceRole.jsx22
-rw-r--r--awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx18
-rw-r--r--awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx4
-rw-r--r--awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx14
-rw-r--r--awx/ui_next/src/components/ContentLoading/ContentLoading.jsx21
-rw-r--r--awx/ui_next/src/components/CredentialChip/CredentialChip.jsx9
-rw-r--r--awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx23
-rw-r--r--awx/ui_next/src/components/LoadingSpinner/index.js1
-rw-r--r--awx/ui_next/src/components/Lookup/CredentialLookup.jsx20
-rw-r--r--awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx2
-rw-r--r--awx/ui_next/src/components/Lookup/InventoryLookup.jsx8
-rw-r--r--awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx87
-rw-r--r--awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx12
-rw-r--r--awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx55
-rw-r--r--awx/ui_next/src/components/Lookup/ProjectLookup.jsx8
-rw-r--r--awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx46
-rw-r--r--awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx269
-rw-r--r--awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx12
-rw-r--r--awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx1
-rw-r--r--awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx2
-rw-r--r--awx/ui_next/src/components/SelectableCard/SelectableCard.jsx12
-rw-r--r--awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx11
-rw-r--r--awx/ui_next/src/screens/Credential/Credential.jsx23
-rw-r--r--awx/ui_next/src/screens/Credential/Credential.test.jsx2
-rw-r--r--awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx2
-rw-r--r--awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx23
-rw-r--r--awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json5
-rw-r--r--awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx2
-rw-r--r--awx/ui_next/src/screens/Dashboard/Dashboard.jsx13
-rw-r--r--awx/ui_next/src/screens/Host/Host.jsx37
-rw-r--r--awx/ui_next/src/screens/Host/Host.test.jsx7
-rw-r--r--awx/ui_next/src/screens/Host/data.hostFacts.json10
-rw-r--r--awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx2
-rw-r--r--awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx2
-rw-r--r--awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx2
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx2
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json10
-rw-r--r--awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json2
-rw-r--r--awx/ui_next/src/screens/Job/Job.jsx12
-rw-r--r--awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx13
-rw-r--r--awx/ui_next/src/screens/Job/shared/data.job.json14
-rw-r--r--awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx13
-rw-r--r--awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx2
-rw-r--r--awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx2
-rw-r--r--awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx2
-rw-r--r--awx/ui_next/src/screens/Project/Project.jsx13
-rw-r--r--awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx2
-rw-r--r--awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx44
-rw-r--r--awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx29
-rw-r--r--awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx2
-rw-r--r--awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx19
-rw-r--r--awx/ui_next/src/screens/Project/shared/ProjectForm.jsx4
-rw-r--r--awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx2
-rw-r--r--awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx31
-rw-r--r--awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx8
-rw-r--r--awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx10
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx99
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx154
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx165
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx147
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx186
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js1
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx147
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx177
-rw-r--r--awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js1
-rw-r--r--awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx43
-rw-r--r--awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx182
-rw-r--r--awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx188
-rw-r--r--awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx18
-rw-r--r--awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx263
-rw-r--r--awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx255
-rw-r--r--awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx1
-rw-r--r--awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx3
-rw-r--r--awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx3
-rw-r--r--awx/ui_next/src/screens/Setting/shared/SharedFields.jsx10
-rw-r--r--awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json2
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx1
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx67
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx1
-rw-r--r--awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx46
-rw-r--r--awx/ui_next/src/screens/Template/Survey/SurveyList.jsx134
-rw-r--r--awx/ui_next/src/screens/Template/Template.jsx27
-rw-r--r--awx/ui_next/src/screens/Template/Template.test.jsx16
-rw-r--r--awx/ui_next/src/screens/Template/TemplateSurvey.jsx40
-rw-r--r--awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx95
-rw-r--r--awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx5
-rw-r--r--awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx26
-rw-r--r--awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx2
-rw-r--r--awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx2
-rw-r--r--awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx2
-rw-r--r--awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx8
-rw-r--r--awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json2
-rw-r--r--awx_collection/plugins/modules/tower_project.py2
-rw-r--r--awx_collection/test/awx/test_inventory_source.py6
-rw-r--r--awx_collection/test/awx/test_notification_template.py31
-rw-r--r--awx_collection/tools/roles/template_galaxy/templates/README.md.j22
-rw-r--r--awxkit/awxkit/api/mixins/has_status.py14
-rw-r--r--awxkit/awxkit/api/pages/api.py3
-rw-r--r--awxkit/awxkit/api/pages/base.py7
-rw-r--r--awxkit/awxkit/api/pages/workflow_jobs.py28
-rw-r--r--installer/build.yml2
-rw-r--r--installer/inventory11
-rw-r--r--installer/roles/image_build/files/Dockerfile.sdist22
-rw-r--r--installer/roles/image_build/tasks/main.yml98
-rw-r--r--installer/roles/image_build/templates/Dockerfile.j293
-rw-r--r--installer/roles/image_push/tasks/main.yml3
-rw-r--r--installer/roles/kubernetes/tasks/main.yml14
-rw-r--r--installer/roles/kubernetes/templates/deployment.yml.j216
-rw-r--r--installer/roles/kubernetes/templates/postgresql-persistent.yml.j22
-rw-r--r--installer/roles/kubernetes/templates/postgresql-values.yml.j29
-rw-r--r--installer/roles/local_docker/defaults/main.yml2
-rw-r--r--installer/roles/local_docker/tasks/upgrade_postgres.yml8
-rw-r--r--pytest.ini4
-rw-r--r--requirements/README.md4
-rw-r--r--requirements/requirements.in4
-rw-r--r--requirements/requirements.txt10
-rwxr-xr-xrequirements/updater.sh2
-rw-r--r--tools/docker-compose-cluster.yml2
-rw-r--r--tools/docker-compose.yml2
-rwxr-xr-xtools/scripts/awx-python10
163 files changed, 3350 insertions, 1257 deletions
diff --git a/.dockerignore b/.dockerignore
index f5faf1f0e3..46c83b0467 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,2 +1 @@
-.git
awx/ui/node_modules
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bd3da38b51..e311ecfa1c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,7 +85,7 @@ If you're not using Docker for Mac, or Docker for Windows, you may need, or choo
#### Frontend Development
-See [the ui development documentation](awx/ui/README.md).
+See [the ui development documentation](awx/ui_next/CONTRIBUTING.md).
### Build the environment
@@ -158,7 +158,7 @@ $ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
44251b476f98 gcr.io/ansible-tower-engineering/awx_devel:devel "/entrypoint.sh /bin…" 27 seconds ago Up 23 seconds 0.0.0.0:6899->6899/tcp, 0.0.0.0:7899-7999->7899-7999/tcp, 0.0.0.0:8013->8013/tcp, 0.0.0.0:8043->8043/tcp, 0.0.0.0:8080->8080/tcp, 22/tcp, 0.0.0.0:8888->8888/tcp tools_awx_run_9e820694d57e
40de380e3c2e redis:latest "docker-entrypoint.s…" 28 seconds ago Up 26 seconds
-b66a506d3007 postgres:10 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1
+b66a506d3007 postgres:12 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1
```
**NOTE**
diff --git a/Makefile b/Makefile
index 783547930f..9c7ccd028b 100644
--- a/Makefile
+++ b/Makefile
@@ -19,7 +19,8 @@ PYCURL_SSL_LIBRARY ?= openssl
COMPOSE_TAG ?= $(GIT_BRANCH)
COMPOSE_HOST ?= $(shell hostname)
-VENV_BASE ?= /venv
+VENV_BASE ?= /var/lib/awx/venv/
+COLLECTION_BASE ?= /var/lib/awx/vendor/awx_ansible_collections
SCL_PREFIX ?=
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
@@ -270,7 +271,7 @@ uwsgi: collectstatic
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/awx/bin/activate; \
fi; \
- uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
+ uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
daphne:
@if [ "$(VENV_BASE)" ]; then \
@@ -340,7 +341,7 @@ check: flake8 pep8 # pyflakes pylint
awx-link:
[ -d "/awx_devel/awx.egg-info" ] || python3 /awx_devel/setup.py egg_info_dev
- cp -f /tmp/awx.egg-link /venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link
+ cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
@@ -618,7 +619,10 @@ clean-elk:
docker rm tools_kibana_1
psql-container:
- docker run -it --net tools_default --rm postgres:10 sh -c 'exec psql -h "postgres" -p "5432" -U postgres'
+ docker run -it --net tools_default --rm postgres:12 sh -c 'exec psql -h "postgres" -p "5432" -U postgres'
VERSION:
@echo "awx: $(VERSION)"
+
+Dockerfile: installer/roles/image_build/templates/Dockerfile.j2
+ ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile"
diff --git a/README.md b/README.md
index 23249c35e6..e24e851ce1 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
[![Gated by Zuul](https://zuul-ci.org/gated.svg)](https://ansible.softwarefactory-project.io/zuul/status)
-<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" />
-
AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX.
To install AWX, please view the [Install guide](./INSTALL.md).
diff --git a/awx/conf/settings.py b/awx/conf/settings.py
index d2733ce879..4b18e3d9f6 100644
--- a/awx/conf/settings.py
+++ b/awx/conf/settings.py
@@ -4,6 +4,7 @@ import logging
import sys
import threading
import time
+import os
# Django
from django.conf import LazySettings
@@ -247,6 +248,7 @@ class SettingsWrapper(UserSettingsHolder):
# These values have to be stored via self.__dict__ in this way to get
# around the magic __setattr__ method on this class (which is used to
# store API-assigned settings in the database).
+ self.__dict__['__forks__'] = {}
self.__dict__['default_settings'] = default_settings
self.__dict__['_awx_conf_settings'] = self
self.__dict__['_awx_conf_preload_expires'] = None
@@ -255,6 +257,26 @@ class SettingsWrapper(UserSettingsHolder):
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
self.__dict__['registry'] = registry
+ # record the current pid so we compare it post-fork for
+ # processes like the dispatcher and callback receiver
+ self.__dict__['pid'] = os.getpid()
+
+ def __clean_on_fork__(self):
+ pid = os.getpid()
+ # if the current pid does *not* match the value on self, it means
+ # that value was copied on fork, and we're now in a *forked* process;
+ # the *first* time we enter this code path (on setting access),
+ # forcibly close DB/cache sockets and set a marker so we don't run
+ # this code again _in this process_
+ #
+ if pid != self.__dict__['pid'] and pid not in self.__dict__['__forks__']:
+ self.__dict__['__forks__'][pid] = True
+ # It's important to close these post-fork, because we
+ # don't want the forked processes to inherit the open sockets
+ # for the DB and cache connections (that way lies race conditions)
+ connection.close()
+ django_cache.close()
+
@cached_property
def all_supported_settings(self):
return self.registry.get_registered_settings()
@@ -330,6 +352,7 @@ class SettingsWrapper(UserSettingsHolder):
self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT)
def _get_local(self, name, validate=True):
+ self.__clean_on_fork__()
self._preload_cache()
cache_key = Setting.get_cache_key(name)
try:
diff --git a/awx/locale/django.pot b/awx/locale/django.pot
index 3d2cf41999..e5fbe05390 100644
--- a/awx/locale/django.pot
+++ b/awx/locale/django.pot
@@ -3355,6 +3355,15 @@ msgid ""
msgstr ""
#: awx/main/models/credential/__init__.py:824
+msgid "Region Name"
+msgstr ""
+
+#: awx/main/models/credential/__init__.py:826
+msgid ""
+"For some cloud providers, like OVH, region must be specified."
+msgstr ""
+
+#: awx/main/models/credential/__init__.py:824
#: awx/main/models/credential/__init__.py:1131
#: awx/main/models/credential/__init__.py:1166
msgid "Verify SSL"
diff --git a/awx/locale/en-us/LC_MESSAGES/django.po b/awx/locale/en-us/LC_MESSAGES/django.po
index 3d2cf41999..e5fbe05390 100644
--- a/awx/locale/en-us/LC_MESSAGES/django.po
+++ b/awx/locale/en-us/LC_MESSAGES/django.po
@@ -3355,6 +3355,15 @@ msgid ""
msgstr ""
#: awx/main/models/credential/__init__.py:824
+msgid "Region Name"
+msgstr ""
+
+#: awx/main/models/credential/__init__.py:826
+msgid ""
+"For some cloud providers, like OVH, region must be specified."
+msgstr ""
+
+#: awx/main/models/credential/__init__.py:824
#: awx/main/models/credential/__init__.py:1131
#: awx/main/models/credential/__init__.py:1166
msgid "Verify SSL"
diff --git a/awx/locale/fr/LC_MESSAGES/django.po b/awx/locale/fr/LC_MESSAGES/django.po
index 62c2ba7292..bcb54c548b 100644
--- a/awx/locale/fr/LC_MESSAGES/django.po
+++ b/awx/locale/fr/LC_MESSAGES/django.po
@@ -3294,6 +3294,16 @@ msgid ""
"common scenarios."
msgstr "Les domaines OpenStack définissent les limites administratives. Ils sont nécessaires uniquement pour les URL d’authentification Keystone v3. Voir la documentation Ansible Tower pour les scénarios courants."
+#: awx/main/models/credential/__init__.py:824
+msgid "Region Name"
+msgstr "Nom de la region"
+
+#: awx/main/models/credential/__init__.py:826
+msgid ""
+"For some cloud providers, like OVH, region must be specified."
+msgstr ""
+"Chez certains fournisseurs, comme OVH, vous devez spécifier le nom de la région"
+
#: awx/main/models/credential/__init__.py:812
#: awx/main/models/credential/__init__.py:1110
#: awx/main/models/credential/__init__.py:1144
diff --git a/awx/main/isolated/manager.py b/awx/main/isolated/manager.py
index 1c0978f432..de4783e277 100644
--- a/awx/main/isolated/manager.py
+++ b/awx/main/isolated/manager.py
@@ -7,6 +7,7 @@ import tempfile
import time
import logging
import yaml
+import datetime
from django.conf import settings
import ansible_runner
@@ -123,6 +124,7 @@ class IsolatedManager(object):
dir=private_data_dir
)
params = self.runner_params.copy()
+ params.get('envvars', dict())['ANSIBLE_CALLBACK_WHITELIST'] = 'profile_tasks'
params['playbook'] = playbook
params['private_data_dir'] = iso_dir
if idle_timeout:
@@ -168,7 +170,8 @@ class IsolatedManager(object):
extravars = {
'src': self.private_data_dir,
'dest': settings.AWX_PROOT_BASE_PATH,
- 'ident': self.ident
+ 'ident': self.ident,
+ 'job_id': self.instance.id,
}
if playbook:
extravars['playbook'] = playbook
@@ -204,7 +207,10 @@ class IsolatedManager(object):
:param interval: an interval (in seconds) to wait between status polls
"""
interval = interval if interval is not None else settings.AWX_ISOLATED_CHECK_INTERVAL
- extravars = {'src': self.private_data_dir}
+ extravars = {
+ 'src': self.private_data_dir,
+ 'job_id': self.instance.id
+ }
status = 'failed'
rc = None
last_check = time.time()
@@ -220,9 +226,13 @@ class IsolatedManager(object):
logger.warning('Isolated job {} was manually canceled.'.format(self.instance.id))
logger.debug('Checking on isolated job {} with `check_isolated.yml`.'.format(self.instance.id))
+ time_start = datetime.datetime.now()
runner_obj = self.run_management_playbook('check_isolated.yml',
self.private_data_dir,
extravars=extravars)
+ time_end = datetime.datetime.now()
+ time_diff = time_end - time_start
+ logger.debug('Finished checking on isolated job {} with `check_isolated.yml` took {} seconds.'.format(self.instance.id, time_diff.total_seconds()))
status, rc = runner_obj.status, runner_obj.rc
if self.check_callback is not None and not self.captured_command_artifact:
diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py
index 30529cdf72..a86cc3db48 100644
--- a/awx/main/management/commands/inventory_import.py
+++ b/awx/main/management/commands/inventory_import.py
@@ -133,7 +133,7 @@ class AnsibleInventoryLoader(object):
# NOTE: why do we add "python" to the start of these args?
# the script that runs ansible-inventory specifies a python interpreter
# that makes no sense in light of the fact that we put all the dependencies
- # inside of /venv/ansible, so we override the specified interpreter
+ # inside of /var/lib/awx/venv/ansible, so we override the specified interpreter
# https://github.com/ansible/ansible/issues/50714
bargs = ['python', ansible_inventory_path, '-i', self.source]
bargs.extend(['--playbook-dir', functioning_dir(self.source)])
diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py
index 66db962430..e8a2884083 100644
--- a/awx/main/models/credential/__init__.py
+++ b/awx/main/models/credential/__init__.py
@@ -820,6 +820,11 @@ ManagedCredentialType(
'URLs. Refer to Ansible Tower documentation for '
'common scenarios.')
}, {
+ 'id': 'region',
+ 'label': ugettext_noop('Region Name'),
+ 'type': 'string',
+ 'help_text': ugettext_noop('For some cloud providers, like OVH, region must be specified'),
+ }, {
'id': 'verify_ssl',
'label': ugettext_noop('Verify SSL'),
'type': 'boolean',
diff --git a/awx/main/models/credential/injectors.py b/awx/main/models/credential/injectors.py
index 75d1f17bfe..ef30b91945 100644
--- a/awx/main/models/credential/injectors.py
+++ b/awx/main/models/credential/injectors.py
@@ -82,6 +82,7 @@ def _openstack_data(cred):
if cred.has_input('domain'):
openstack_auth['domain_name'] = cred.get_input('domain', default='')
verify_state = cred.get_input('verify_ssl', default=True)
+
openstack_data = {
'clouds': {
'devstack': {
@@ -90,6 +91,10 @@ def _openstack_data(cred):
},
},
}
+
+ if cred.has_input('project_region_name'):
+ openstack_data['clouds']['devstack']['region_name'] = cred.get_input('project_region_name', default='')
+
return openstack_data
diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py
index 11d97c7690..33562e7fca 100644
--- a/awx/main/models/notifications.py
+++ b/awx/main/models/notifications.py
@@ -12,7 +12,7 @@ from django.core.mail.message import EmailMessage
from django.db import connection
from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import smart_str, force_text
-from jinja2 import sandbox
+from jinja2 import sandbox, ChainableUndefined
from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
# AWX
@@ -429,7 +429,7 @@ class JobNotificationMixin(object):
raise RuntimeError("Define me")
def build_notification_message(self, nt, status):
- env = sandbox.ImmutableSandboxedEnvironment()
+ env = sandbox.ImmutableSandboxedEnvironment(undefined=ChainableUndefined)
from awx.api.serializers import UnifiedJobSerializer
job_serialization = UnifiedJobSerializer(self).to_representation(self)
diff --git a/awx/main/tasks.py b/awx/main/tasks.py
index 3bf67d9e65..1fb7d62cef 100644
--- a/awx/main/tasks.py
+++ b/awx/main/tasks.py
@@ -378,6 +378,7 @@ def gather_analytics():
from awx.conf.models import Setting
from rest_framework.fields import DateTimeField
+ from awx.main.signals import disable_activity_stream
if not settings.INSIGHTS_TRACKING_STATE:
return
if not (settings.AUTOMATION_ANALYTICS_URL and settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD):
@@ -414,7 +415,8 @@ def gather_analytics():
if not _gather_and_ship(incremental_collectors, since=start, until=until):
break
start = until
- settings.AUTOMATION_ANALYTICS_LAST_GATHER = until
+ with disable_activity_stream():
+ settings.AUTOMATION_ANALYTICS_LAST_GATHER = until
if subset:
_gather_and_ship(subset, since=since, until=gather_time)
diff --git a/awx/main/tests/functional/models/test_job.py b/awx/main/tests/functional/models/test_job.py
index ac8912506f..c6c4d2d6e6 100644
--- a/awx/main/tests/functional/models/test_job.py
+++ b/awx/main/tests/functional/models/test_job.py
@@ -16,7 +16,7 @@ def test_awx_virtualenv_from_settings(inventory, project, machine_credential):
)
jt.credentials.add(machine_credential)
job = jt.create_unified_job()
- assert job.ansible_virtualenv_path == '/venv/ansible'
+ assert job.ansible_virtualenv_path == '/var/lib/awx/venv/ansible'
@pytest.mark.django_db
@@ -43,28 +43,28 @@ def test_awx_custom_virtualenv(inventory, project, machine_credential, organizat
jt.credentials.add(machine_credential)
job = jt.create_unified_job()
- job.organization.custom_virtualenv = '/venv/fancy-org'
+ job.organization.custom_virtualenv = '/var/lib/awx/venv/fancy-org'
job.organization.save()
- assert job.ansible_virtualenv_path == '/venv/fancy-org'
+ assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-org'
- job.project.custom_virtualenv = '/venv/fancy-proj'
+ job.project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj'
job.project.save()
- assert job.ansible_virtualenv_path == '/venv/fancy-proj'
+ assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj'
- job.job_template.custom_virtualenv = '/venv/fancy-jt'
+ job.job_template.custom_virtualenv = '/var/lib/awx/venv/fancy-jt'
job.job_template.save()
- assert job.ansible_virtualenv_path == '/venv/fancy-jt'
+ assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-jt'
@pytest.mark.django_db
def test_awx_custom_virtualenv_without_jt(project):
- project.custom_virtualenv = '/venv/fancy-proj'
+ project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj'
project.save()
job = Job(project=project)
job.save()
job = Job.objects.get(pk=job.id)
- assert job.ansible_virtualenv_path == '/venv/fancy-proj'
+ assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj'
@pytest.mark.django_db
diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py
index f94c70c739..166ea95f19 100644
--- a/awx/main/tests/unit/test_tasks.py
+++ b/awx/main/tests/unit/test_tasks.py
@@ -180,7 +180,7 @@ def test_openstack_client_config_generation(mocker, source, expected, private_da
'source_vars_dict': {},
'get_cloud_credential': mocker.Mock(return_value=credential),
'get_extra_credentials': lambda x: [],
- 'ansible_virtualenv_path': '/venv/foo'
+ 'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
})
cloud_config = update.build_private_data(inventory_update, private_data_dir)
cloud_credential = yaml.safe_load(
@@ -224,6 +224,52 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou
'source_vars_dict': {},
'get_cloud_credential': mocker.Mock(return_value=credential),
'get_extra_credentials': lambda x: [],
+ 'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
+ })
+ cloud_config = update.build_private_data(inventory_update, private_data_dir)
+ cloud_credential = yaml.safe_load(
+ cloud_config.get('credentials')[credential]
+ )
+ assert cloud_credential['clouds'] == {
+ 'devstack': {
+ 'auth': {
+ 'auth_url': 'https://keystone.openstack.example.org',
+ 'password': 'secrete',
+ 'project_name': 'demo-project',
+ 'username': 'demo',
+ 'domain_name': 'my-demo-domain',
+ 'project_domain_name': 'project-domain',
+ },
+ 'verify': expected,
+ 'private': True,
+ }
+ }
+
+
+@pytest.mark.parametrize("source,expected", [
+ (None, True), (False, False), (True, True)
+])
+def test_openstack_client_config_generation_with_project_region_name(mocker, source, expected, private_data_dir):
+ update = tasks.RunInventoryUpdate()
+ credential_type = CredentialType.defaults['openstack']()
+ inputs = {
+ 'host': 'https://keystone.openstack.example.org',
+ 'username': 'demo',
+ 'password': 'secrete',
+ 'project': 'demo-project',
+ 'domain': 'my-demo-domain',
+ 'project_domain_name': 'project-domain',
+ 'project_region_name': 'region-name',
+ }
+ if source is not None:
+ inputs['verify_ssl'] = source
+ credential = Credential(pk=1, credential_type=credential_type, inputs=inputs)
+
+ inventory_update = mocker.Mock(**{
+ 'source': 'openstack',
+ 'source_vars_dict': {},
+ 'get_cloud_credential': mocker.Mock(return_value=credential),
+ 'get_extra_credentials': lambda x: [],
'ansible_virtualenv_path': '/venv/foo'
})
cloud_config = update.build_private_data(inventory_update, private_data_dir)
@@ -242,6 +288,7 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou
},
'verify': expected,
'private': True,
+ 'region_name': 'region-name',
}
}
@@ -267,7 +314,7 @@ def test_openstack_client_config_generation_with_private_source_vars(mocker, sou
'source_vars_dict': {'private': source},
'get_cloud_credential': mocker.Mock(return_value=credential),
'get_extra_credentials': lambda x: [],
- 'ansible_virtualenv_path': '/venv/foo'
+ 'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
})
cloud_config = update.build_private_data(inventory_update, private_data_dir)
cloud_credential = yaml.load(
@@ -625,13 +672,13 @@ class TestGenericRun():
def test_invalid_custom_virtualenv(self, patch_Job, private_data_dir):
job = Job(project=Project(), inventory=Inventory())
- job.project.custom_virtualenv = '/venv/missing'
+ job.project.custom_virtualenv = '/var/lib/awx/venv/missing'
task = tasks.RunJob()
with pytest.raises(tasks.InvalidVirtualenvError) as e:
task.build_env(job, private_data_dir)
- assert 'Invalid virtual environment selected: /venv/missing' == str(e.value)
+ assert 'Invalid virtual environment selected: /var/lib/awx/venv/missing' == str(e.value)
class TestAdhocRun(TestJobExecution):
diff --git a/awx/playbooks/check_isolated.yml b/awx/playbooks/check_isolated.yml
index 18b3305846..472b772fbb 100644
--- a/awx/playbooks/check_isolated.yml
+++ b/awx/playbooks/check_isolated.yml
@@ -9,6 +9,9 @@
- ansible.posix
tasks:
+ - name: "Output job the playbook is running for"
+ debug:
+ msg: "Checking on job {{ job_id }}"
- name: Determine if daemon process is alive.
shell: "ansible-runner is-alive {{src}}"
diff --git a/awx/playbooks/run_isolated.yml b/awx/playbooks/run_isolated.yml
index 4e3b7b54ee..76ea42d17c 100644
--- a/awx/playbooks/run_isolated.yml
+++ b/awx/playbooks/run_isolated.yml
@@ -13,6 +13,10 @@
- ansible.posix
tasks:
+ - name: "Output job the playbook is running for"
+ debug:
+ msg: "Checking on job {{ job_id }}"
+
- name: synchronize job environment with isolated host
synchronize:
copy_links: true
diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py
index b9e7ecddb7..05c8a42f20 100644
--- a/awx/settings/defaults.py
+++ b/awx/settings/defaults.py
@@ -116,7 +116,7 @@ LOGIN_URL = '/api/login/'
# Absolute filesystem path to the directory to host projects (with playbooks).
# This directory should not be web-accessible.
-PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
+PROJECTS_ROOT = '/var/lib/awx/projects/'
# Absolute filesystem path to the directory to host collections for
# running inventory imports, isolated playbooks
@@ -125,10 +125,10 @@ AWX_ANSIBLE_COLLECTIONS_PATHS = os.path.join(BASE_DIR, 'vendor', 'awx_ansible_co
# Absolute filesystem path to the directory for job status stdout (default for
# development and tests, default for production defined in production.py). This
# directory should not be web-accessible
-JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_output')
+JOBOUTPUT_ROOT = '/var/lib/awx/job_status/'
# Absolute filesystem path to the directory to store logs
-LOG_ROOT = os.path.join(BASE_DIR)
+LOG_ROOT = '/var/log/tower/'
# The heartbeat file for the tower scheduler
SCHEDULE_METADATA_LOCATION = os.path.join(BASE_DIR, '.tower_cycle')
@@ -932,6 +932,14 @@ LOGGING = {
'backupCount': 5,
'formatter':'simple',
},
+ 'isolated_manager': {
+ 'level': 'WARNING',
+ 'class':'logging.handlers.RotatingFileHandler',
+ 'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
+ 'maxBytes': 1024 * 1024 * 5, # 5 MB
+ 'backupCount': 5,
+ 'formatter':'simple',
+ },
},
'loggers': {
'django': {
@@ -981,6 +989,11 @@ LOGGING = {
'awx.main.wsbroadcast': {
'handlers': ['wsbroadcast'],
},
+ 'awx.isolated.manager': {
+ 'level': 'WARNING',
+ 'handlers': ['console', 'file', 'isolated_manager'],
+ 'propagate': True
+ },
'awx.isolated.manager.playbooks': {
'handlers': ['management_playbooks'],
'propagate': False
diff --git a/awx/settings/development.py b/awx/settings/development.py
index 108767b98c..9846705fa5 100644
--- a/awx/settings/development.py
+++ b/awx/settings/development.py
@@ -148,9 +148,9 @@ include(optional('/etc/tower/settings.py'), scope=locals())
include(optional('/etc/tower/conf.d/*.py'), scope=locals())
# Installed differently in Dockerfile compared to production versions
-AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections'
+AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
-BASE_VENV_PATH = "/venv/"
+BASE_VENV_PATH = "/var/lib/awx/venv/"
ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible")
AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose
index 213f4efe4b..88ef90fd64 100644
--- a/awx/settings/local_settings.py.docker_compose
+++ b/awx/settings/local_settings.py.docker_compose
@@ -48,56 +48,12 @@ if "pytest" in sys.modules:
}
}
-# Absolute filesystem path to the directory to host projects (with playbooks).
-# This directory should NOT be web-accessible.
-PROJECTS_ROOT = '/var/lib/awx/projects/'
-
# Location for cross-development of inventory plugins
-AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections'
-
-# Absolute filesystem path to the directory for job status stdout
-# This directory should not be web-accessible
-JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status')
+AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
# The UUID of the system, for HA.
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
-# Local time zone for this installation. Choices can be found here:
-# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
-# although not all choices may be available on all operating systems.
-# On Unix systems, a value of None will cause Django to use the same
-# timezone as the operating system.
-# If running in a Windows environment this must be set to the same as your
-# system time zone.
-USE_TZ = True
-TIME_ZONE = 'UTC'
-
-# Language code for this installation. All choices can be found here:
-# http://www.i18nguy.com/unicode/language-identifiers.html
-LANGUAGE_CODE = 'en-us'
-
-# SECURITY WARNING: keep the secret key used in production secret!
-# Hardcoded values can leak through source control. Consider loading
-# the secret key from an environment variable or a file instead.
-SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y'
-
-# HTTP headers and meta keys to search to determine remote host name or IP. Add
-# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a
-# reverse proxy.
-REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST']
-
-# If Tower is behind a reverse proxy/load balancer, use this setting to
-# whitelist the proxy IP addresses from which Tower should trust custom
-# REMOTE_HOST_HEADERS header values
-# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST']
-# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101']
-# If this setting is an empty list (the default), the headers specified by
-# REMOTE_HOST_HEADERS will be trusted unconditionally')
-PROXY_IP_WHITELIST = []
-
-# Define additional environment variables to be passed to ansible subprocesses
-#AWX_TASK_ENV['FOO'] = 'BAR'
-
# If set, use -vvv for project updates instead of -v for more output.
# PROJECT_UPDATE_VVV=True
@@ -108,40 +64,6 @@ PROXY_IP_WHITELIST = []
# Enable logging to syslog. Setting level to ERROR captures 500 errors,
# WARNING also logs 4xx responses.
-LOGGING['handlers']['syslog'] = {
- 'level': 'WARNING',
- 'filters': ['require_debug_false'],
- 'class': 'logging.NullHandler',
- 'formatter': 'simple',
-}
-
-LOGGING['loggers']['django.request']['handlers'] = ['console']
-LOGGING['loggers']['rest_framework.request']['handlers'] = ['console']
-LOGGING['loggers']['awx']['handlers'] = ['console', 'external_logger']
-LOGGING['loggers']['awx.main.commands.run_callback_receiver']['handlers'] = [] # propogates to awx
-LOGGING['loggers']['awx.main.tasks']['handlers'] = ['console', 'external_logger']
-LOGGING['loggers']['awx.main.scheduler']['handlers'] = ['console', 'external_logger']
-LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
-LOGGING['loggers']['social']['handlers'] = ['console']
-LOGGING['loggers']['system_tracking_migrations']['handlers'] = ['console']
-LOGGING['loggers']['rbac_migrations']['handlers'] = ['console']
-LOGGING['loggers']['awx.isolated.manager.playbooks']['handlers'] = ['console']
-LOGGING['handlers']['callback_receiver'] = {'class': 'logging.NullHandler'}
-LOGGING['handlers']['fact_receiver'] = {'class': 'logging.NullHandler'}
-LOGGING['handlers']['task_system'] = {'class': 'logging.NullHandler'}
-LOGGING['handlers']['tower_warnings'] = {'class': 'logging.NullHandler'}
-LOGGING['handlers']['rbac_migrations'] = {'class': 'logging.NullHandler'}
-LOGGING['handlers']['system_tracking_migrations'] = {'class': 'logging.NullHandler'}
-LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'}
-
-
-# Enable the following lines to also log to a file.
-#LOGGING['handlers']['file'] = {
-# 'class': 'logging.FileHandler',
-# 'filename': os.path.join(BASE_DIR, 'awx.log'),
-# 'formatter': 'simple',
-#}
-
# Enable the following lines to turn on lots of permissions-related logging.
#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG'
@@ -154,74 +76,6 @@ LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'}
#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG'
-###############################################################################
-# SCM TEST SETTINGS
-###############################################################################
-
-# Define these variables to enable more complete testing of project support for
-# SCM updates. The test repositories listed do not have to contain any valid
-# playbooks.
-
-try:
- path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa'))
- TEST_SSH_KEY_DATA = open(path, 'rb').read()
-except IOError:
- TEST_SSH_KEY_DATA = ''
-
-TEST_GIT_USERNAME = ''
-TEST_GIT_PASSWORD = ''
-TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA
-TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git'
-TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git'
-TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git'
-
-TEST_SVN_USERNAME = ''
-TEST_SVN_PASSWORD = ''
-TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com'
-TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs'
-
-# To test repo access via SSH login to localhost.
-import getpass
-try:
- TEST_SSH_LOOPBACK_USERNAME = getpass.getuser()
-except KeyError:
- TEST_SSH_LOOPBACK_USERNAME = 'root'
-TEST_SSH_LOOPBACK_PASSWORD = ''
-
-###############################################################################
-# INVENTORY IMPORT TEST SETTINGS
-###############################################################################
-
-# Define these variables to enable more complete testing of inventory import
-# from cloud providers.
-
-# EC2 credentials
-TEST_AWS_ACCESS_KEY_ID = ''
-TEST_AWS_SECRET_ACCESS_KEY = ''
-TEST_AWS_REGIONS = 'all'
-# Check IAM STS credentials
-TEST_AWS_SECURITY_TOKEN = ''
-
-# Rackspace credentials
-TEST_RACKSPACE_USERNAME = ''
-TEST_RACKSPACE_API_KEY = ''
-TEST_RACKSPACE_REGIONS = 'all'
-
-# VMware credentials
-TEST_VMWARE_HOST = ''
-TEST_VMWARE_USER = ''
-TEST_VMWARE_PASSWORD = ''
-
-# OpenStack credentials
-TEST_OPENSTACK_HOST = ''
-TEST_OPENSTACK_USER = ''
-TEST_OPENSTACK_PASSWORD = ''
-TEST_OPENSTACK_PROJECT = ''
-
-# Azure credentials.
-TEST_AZURE_USERNAME = ''
-TEST_AZURE_KEY_DATA = ''
-
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
BROADCAST_WEBSOCKET_PORT = 8013
BROADCAST_WEBSOCKET_VERIFY_CERT = False
diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example
deleted file mode 100644
index 59f3bdfa6a..0000000000
--- a/awx/settings/local_settings.py.example
+++ /dev/null
@@ -1,192 +0,0 @@
-# Copyright (c) 2015 Ansible, Inc. (formerly AnsibleWorks, Inc.)
-# All Rights Reserved.
-
-# Local Django settings for AWX project. Rename to "local_settings.py" and
-# edit as needed for your development environment.
-
-# All variables defined in awx/settings/development.py will already be loaded
-# into the global namespace before this file is loaded, to allow for reading
-# and updating the default settings as needed.
-
-###############################################################################
-# MISC PROJECT SETTINGS
-###############################################################################
-
-# Database settings to use PostgreSQL for development.
-DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.postgresql_psycopg2',
- 'NAME': 'awx-dev',
- 'USER': 'awx-dev',
- 'PASSWORD': 'AWXsome1',
- 'HOST': 'localhost',
- 'PORT': '',
- }
-}
-
-# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
-# commented out, Django will create the test_awx-dev database in PostgreSQL to
-# run unit tests.
-if is_testing(sys.argv):
- DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'),
- 'TEST': {
- # Test database cannot be :memory: for tests.
- 'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'),
- },
- }
- }
-
-# AMQP configuration.
-BROKER_URL = 'amqp://guest:guest@localhost:5672'
-
-# Absolute filesystem path to the directory to host projects (with playbooks).
-# This directory should NOT be web-accessible.
-PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
-
-# Absolute filesystem path to the directory for job status stdout
-# This directory should not be web-accessible
-JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status')
-
-# The UUID of the system, for HA.
-SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
-
-# Local time zone for this installation. Choices can be found here:
-# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
-# although not all choices may be available on all operating systems.
-# On Unix systems, a value of None will cause Django to use the same
-# timezone as the operating system.
-# If running in a Windows environment this must be set to the same as your
-# system time zone.
-TIME_ZONE = None
-
-# Language code for this installation. All choices can be found here:
-# http://www.i18nguy.com/unicode/language-identifiers.html
-LANGUAGE_CODE = 'en-us'
-
-# SECURITY WARNING: keep the secret key used in production secret!
-# Hardcoded values can leak through source control. Consider loading
-# the secret key from an environment variable or a file instead.
-SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y'
-
-# HTTP headers and meta keys to search to determine remote host name or IP. Add
-# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a
-# reverse proxy.
-REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST']
-
-# If Tower is behind a reverse proxy/load balancer, use this setting to
-# whitelist the proxy IP addresses from which Tower should trust custom
-# REMOTE_HOST_HEADERS header values
-# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST']
-# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101']
-# If this setting is an empty list (the default), the headers specified by
-# REMOTE_HOST_HEADERS will be trusted unconditionally')
-PROXY_IP_WHITELIST = []
-
-# Define additional environment variables to be passed to ansible subprocesses
-#AWX_TASK_ENV['FOO'] = 'BAR'
-
-# If set, use -vvv for project updates instead of -v for more output.
-# PROJECT_UPDATE_VVV=True
-
-###############################################################################
-# LOGGING SETTINGS
-###############################################################################
-
-# Enable logging to syslog. Setting level to ERROR captures 500 errors,
-# WARNING also logs 4xx responses.
-LOGGING['handlers']['syslog'] = {
- 'level': 'WARNING',
- 'filters': [],
- 'class': 'logging.handlers.SysLogHandler',
- 'address': '/dev/log',
- 'facility': 'local0',
- 'formatter': 'simple',
-}
-
-# Enable the following lines to also log to a file.
-#LOGGING['handlers']['file'] = {
-# 'class': 'logging.FileHandler',
-# 'filename': os.path.join(BASE_DIR, 'awx.log'),
-# 'formatter': 'simple',
-#}
-
-# Enable the following lines to turn on lots of permissions-related logging.
-#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
-#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG'
-#LOGGING['loggers']['awx.main.permissions']['level'] = 'DEBUG'
-
-# Enable the following line to turn on database settings logging.
-#LOGGING['loggers']['awx.conf']['level'] = 'DEBUG'
-
-# Enable the following lines to turn on LDAP auth logging.
-#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
-#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG'
-
-###############################################################################
-# SCM TEST SETTINGS
-###############################################################################
-
-# Define these variables to enable more complete testing of project support for
-# SCM updates. The test repositories listed do not have to contain any valid
-# playbooks.
-
-try:
- path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa'))
- TEST_SSH_KEY_DATA = file(path, 'rb').read()
-except IOError:
- TEST_SSH_KEY_DATA = ''
-
-TEST_GIT_USERNAME = ''
-TEST_GIT_PASSWORD = ''
-TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA
-TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git'
-TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git'
-TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git'
-
-TEST_SVN_USERNAME = ''
-TEST_SVN_PASSWORD = ''
-TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com'
-TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs'
-
-# To test repo access via SSH login to localhost.
-import getpass
-TEST_SSH_LOOPBACK_USERNAME = getpass.getuser()
-TEST_SSH_LOOPBACK_PASSWORD = ''
-
-###############################################################################
-# INVENTORY IMPORT TEST SETTINGS
-###############################################################################
-
-# Define these variables to enable more complete testing of inventory import
-# from cloud providers.
-
-# EC2 credentials
-TEST_AWS_ACCESS_KEY_ID = ''
-TEST_AWS_SECRET_ACCESS_KEY = ''
-TEST_AWS_REGIONS = 'all'
-# Check IAM STS credentials
-TEST_AWS_SECURITY_TOKEN = ''
-
-
-# Rackspace credentials
-TEST_RACKSPACE_USERNAME = ''
-TEST_RACKSPACE_API_KEY = ''
-TEST_RACKSPACE_REGIONS = 'all'
-
-# VMware credentials
-TEST_VMWARE_HOST = ''
-TEST_VMWARE_USER = ''
-TEST_VMWARE_PASSWORD = ''
-
-# OpenStack credentials
-TEST_OPENSTACK_HOST = ''
-TEST_OPENSTACK_USER = ''
-TEST_OPENSTACK_PASSWORD = ''
-TEST_OPENSTACK_PROJECT = ''
-
-# Azure credentials.
-TEST_AZURE_USERNAME = ''
-TEST_AZURE_KEY_DATA = ''
diff --git a/awx/settings/production.py b/awx/settings/production.py
index fb24b7087f..02681265e6 100644
--- a/awx/settings/production.py
+++ b/awx/settings/production.py
@@ -30,10 +30,6 @@ SECRET_KEY = None
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = []
-# Absolute filesystem path to the directory for job status stdout
-# This directory should not be web-accessible
-JOBOUTPUT_ROOT = '/var/lib/awx/job_status/'
-
# The heartbeat file for the tower scheduler
SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle'
@@ -46,15 +42,6 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
AWX_ISOLATED_USERNAME = 'awx'
-LOGGING['handlers']['tower_warnings']['filename'] = '/var/log/tower/tower.log' # noqa
-LOGGING['handlers']['callback_receiver']['filename'] = '/var/log/tower/callback_receiver.log' # noqa
-LOGGING['handlers']['dispatcher']['filename'] = '/var/log/tower/dispatcher.log' # noqa
-LOGGING['handlers']['wsbroadcast']['filename'] = '/var/log/tower/wsbroadcast.log' # noqa
-LOGGING['handlers']['task_system']['filename'] = '/var/log/tower/task_system.log' # noqa
-LOGGING['handlers']['management_playbooks']['filename'] = '/var/log/tower/management_playbooks.log' # noqa
-LOGGING['handlers']['system_tracking_migrations']['filename'] = '/var/log/tower/tower_system_tracking_migrations.log' # noqa
-LOGGING['handlers']['rbac_migrations']['filename'] = '/var/log/tower/tower_rbac_migrations.log' # noqa
-
# Store a snapshot of default settings at this point before loading any
# customizable config files.
DEFAULTS_SNAPSHOT = {}
diff --git a/awx/ui_next/CONTRIBUTING.md b/awx/ui_next/CONTRIBUTING.md
index 575e08e913..c0a3eaefc4 100644
--- a/awx/ui_next/CONTRIBUTING.md
+++ b/awx/ui_next/CONTRIBUTING.md
@@ -57,7 +57,7 @@ The UI is built using [ReactJS](https://reactjs.org/docs/getting-started.html) a
The AWX UI requires the following:
-- Node 10.x LTS
+- Node 14.x LTS
- NPM 6.x LTS
Run the following to install all the dependencies:
diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json
index aa83635125..a64e834f55 100644
--- a/awx/ui_next/package-lock.json
+++ b/awx/ui_next/package-lock.json
@@ -3387,12 +3387,18 @@
"dev": true
},
"axios": {
- "version": "0.18.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz",
- "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==",
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
+ "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"requires": {
- "follow-redirects": "1.5.10",
- "is-buffer": "^2.0.2"
+ "follow-redirects": "^1.10.0"
+ },
+ "dependencies": {
+ "follow-redirects": {
+ "version": "1.13.1",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz",
+ "integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg=="
+ }
}
},
"axobject-query": {
@@ -4195,6 +4201,16 @@
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
"dev": true
},
+ "bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
"bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -5961,6 +5977,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "dev": true,
"requires": {
"ms": "2.0.0"
}
@@ -7911,6 +7928,13 @@
}
}
},
+ "file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "dev": true,
+ "optional": true
+ },
"filesize": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz",
@@ -8110,6 +8134,7 @@
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
+ "dev": true,
"requires": {
"debug": "=3.1.0"
}
@@ -9500,11 +9525,6 @@
"call-bind": "^1.0.0"
}
},
- "is-buffer": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
- "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="
- },
"is-callable": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz",
@@ -10315,7 +10335,11 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true,
- "optional": true
+ "optional": true,
+ "requires": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ }
},
"is-buffer": {
"version": "1.1.6",
@@ -11731,7 +11755,8 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+ "dev": true
},
"multicast-dns": {
"version": "6.2.3",
@@ -11755,6 +11780,13 @@
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
"dev": true
},
+ "nan": {
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
+ "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
+ "dev": true,
+ "optional": true
+ },
"nanomatch": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -17683,7 +17715,11 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true,
- "optional": true
+ "optional": true,
+ "requires": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ }
},
"glob-parent": {
"version": "3.1.0",
@@ -18364,7 +18400,11 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true,
- "optional": true
+ "optional": true,
+ "requires": {
+ "bindings": "^1.5.0",
+ "nan": "^2.12.1"
+ }
},
"glob-parent": {
"version": "3.1.0",
diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json
index 551f0cb543..b052a3183f 100644
--- a/awx/ui_next/package.json
+++ b/awx/ui_next/package.json
@@ -12,7 +12,7 @@
"@patternfly/react-icons": "4.7.22",
"@patternfly/react-table": "^4.19.15",
"ansi-to-html": "^0.6.11",
- "axios": "^0.18.1",
+ "axios": "^0.21.1",
"codemirror": "^5.47.0",
"d3": "^5.12.0",
"dagre": "^0.8.4",
diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js
index 9c43509f9e..fc9bbb2334 100644
--- a/awx/ui_next/src/api/models/Jobs.js
+++ b/awx/ui_next/src/api/models/Jobs.js
@@ -36,6 +36,10 @@ class Jobs extends RelaunchMixin(Base) {
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
}
+ readCredentials(id, type) {
+ return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`);
+ }
+
readDetail(id, type) {
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`);
}
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
index 48fc566e2c..89387b9e8b 100644
--- a/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCommands.jsx
@@ -57,7 +57,7 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) {
fetchData();
}, [fetchData]);
const {
- isloading: isLaunchLoading,
+ isLoading: isLaunchLoading,
error: launchError,
request: launchAdHocCommands,
} = useRequest(
diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
index fa6f931c24..e95f0b05cb 100644
--- a/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
+++ b/awx/ui_next/src/components/AdHocCommands/AdHocCredentialStep.jsx
@@ -58,7 +58,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
return <ContentError error={error} />;
}
if (isLoading) {
- return <ContentLoading error={error} />;
+ return <ContentLoading />;
}
return (
<Form>
diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
index 95cb910295..2f12953afa 100644
--- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
+++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx
@@ -144,7 +144,7 @@ class AddResourceRole extends React.Component {
currentStepId,
maxEnabledStep,
} = this.state;
- const { onClose, roles, i18n } = this.props;
+ const { onClose, roles, i18n, resource } = this.props;
// Object roles can be user only, so we remove them when
// showing role choices for team access
@@ -235,18 +235,24 @@ class AddResourceRole extends React.Component {
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
)}
</div>
+
<SelectableCard
isSelected={selectedResource === 'users'}
label={i18n._(t`Users`)}
dataCy="add-role-users"
+ ariaLabel={i18n._(t`Users`)}
onClick={() => this.handleResourceSelect('users')}
/>
- <SelectableCard
- isSelected={selectedResource === 'teams'}
- label={i18n._(t`Teams`)}
- dataCy="add-role-teams"
- onClick={() => this.handleResourceSelect('teams')}
- />
+ {resource?.type === 'credential' &&
+ !resource?.organization ? null : (
+ <SelectableCard
+ isSelected={selectedResource === 'teams'}
+ label={i18n._(t`Teams`)}
+ dataCy="add-role-teams"
+ ariaLabel={i18n._(t`Teams`)}
+ onClick={() => this.handleResourceSelect('teams')}
+ />
+ )}
</div>
),
enableNext: selectedResource !== null,
@@ -329,10 +335,12 @@ AddResourceRole.propTypes = {
onClose: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
roles: PropTypes.shape(),
+ resource: PropTypes.shape(),
};
AddResourceRole.defaultProps = {
roles: {},
+ resource: {},
};
export { AddResourceRole as _AddResourceRole };
diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx
index 76f5dbb87e..a681999391 100644
--- a/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx
+++ b/awx/ui_next/src/components/AddRole/AddResourceRole.test.jsx
@@ -221,4 +221,22 @@ describe('<_AddResourceRole />', () => {
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
expect(handleSave).toHaveBeenCalled();
});
+
+ test('should not display team as a choice in case credential does not have organization', () => {
+ const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
+ const wrapper = mountWithContexts(
+ <AddResourceRole
+ onClose={() => {}}
+ onSave={() => {}}
+ roles={roles}
+ resource={{ type: 'credential', organization: null }}
+ />,
+ { context: { network: { handleHttpError: () => {} } } }
+ ).find('AddResourceRole');
+ const selectableCardWrapper = wrapper.find('SelectableCard');
+ expect(selectableCardWrapper.length).toBe(1);
+ selectableCardWrapper.first().simulate('click');
+ expect(spy).toHaveBeenCalledWith('users');
+ expect(wrapper.state('selectedResource')).toBe('users');
+ });
});
diff --git a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx
index d9bd7c669d..ced058754a 100644
--- a/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx
+++ b/awx/ui_next/src/components/AnsibleSelect/AnsibleSelect.test.jsx
@@ -6,12 +6,12 @@ const mockData = [
{
key: 'baz',
label: 'Baz',
- value: '/venv/baz/',
+ value: '/var/lib/awx/venv/baz/',
},
{
key: 'default',
label: 'Default',
- value: '/venv/ansible/',
+ value: '/var/lib/awx/venv/ansible/',
},
];
diff --git a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx
index 92a9071332..a07b6feca5 100644
--- a/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx
+++ b/awx/ui_next/src/components/CodeMirrorInput/CodeMirrorInput.jsx
@@ -6,6 +6,7 @@ import 'codemirror/mode/javascript/javascript';
import 'codemirror/mode/yaml/yaml';
import 'codemirror/mode/jinja2/jinja2';
import 'codemirror/lib/codemirror.css';
+import 'codemirror/addon/display/placeholder';
const LINE_HEIGHT = 24;
const PADDING = 12;
@@ -55,6 +56,17 @@ const CodeMirror = styled(ReactCodeMirror)`
background-color: var(--pf-c-form-control--disabled--BackgroundColor);
}
`}
+ ${props =>
+ props.options &&
+ props.options.placeholder &&
+ `
+ .CodeMirror-empty {
+ pre.CodeMirror-placeholder {
+ color: var(--pf-c-form-control--placeholder--Color);
+ height: 100% !important;
+ }
+ }
+ `}
`;
function CodeMirrorInput({
@@ -66,6 +78,7 @@ function CodeMirrorInput({
rows,
fullHeight,
className,
+ placeholder,
}) {
// Workaround for CodeMirror bug: If CodeMirror renders in a modal on the
// modal's initial render, it appears as an empty box due to mis-calculated
@@ -92,6 +105,7 @@ function CodeMirrorInput({
smartIndent: false,
lineNumbers: true,
lineWrapping: true,
+ placeholder,
readOnly,
}}
fullHeight={fullHeight}
diff --git a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
index b1c51a6b8f..9808cc02af 100644
--- a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
+++ b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx
@@ -1,22 +1,25 @@
import React from 'react';
-import { t } from '@lingui/macro';
-import { withI18n } from '@lingui/react';
+
import styled from 'styled-components';
import {
EmptyState as PFEmptyState,
- EmptyStateBody,
+ EmptyStateIcon,
+ Spinner,
} from '@patternfly/react-core';
const EmptyState = styled(PFEmptyState)`
--pf-c-empty-state--m-lg--MaxWidth: none;
+ min-height: 250px;
`;
// TODO: Better loading state - skeleton lines / spinner, etc.
-const ContentLoading = ({ className, i18n }) => (
- <EmptyState variant="full" className={className}>
- <EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
- </EmptyState>
-);
+const ContentLoading = ({ className }) => {
+ return (
+ <EmptyState variant="full" className={className}>
+ <EmptyStateIcon variant="container" component={Spinner} />
+ </EmptyState>
+ );
+};
export { ContentLoading as _ContentLoading };
-export default withI18n()(ContentLoading);
+export default ContentLoading;
diff --git a/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx b/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx
index 11e00ee7ed..7dd5b055b5 100644
--- a/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx
+++ b/awx/ui_next/src/components/CredentialChip/CredentialChip.jsx
@@ -16,10 +16,17 @@ function CredentialChip({ credential, i18n, i18nHash, ...props }) {
type = toTitleCase(credential.kind);
}
+ const buildCredentialName = () => {
+ if (credential.kind === 'vault' && credential.inputs?.vault_id) {
+ return `${credential.name} | ${credential.inputs.vault_id}`;
+ }
+ return `${credential.name}`;
+ };
+
return (
<Chip {...props}>
<strong>{type}: </strong>
- {credential.name}
+ {buildCredentialName()}
</Chip>
);
}
diff --git a/awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx b/awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx
new file mode 100644
index 0000000000..36ed8adf5e
--- /dev/null
+++ b/awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { Spinner } from '@patternfly/react-core';
+import styled from 'styled-components';
+
+const UpdatingContent = styled.div`
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ z-index: 300;
+ width: 100%;
+ height: 100%;
+ & + * {
+ opacity: 0.5;
+ }
+`;
+
+const LoadingSpinner = () => (
+ <UpdatingContent>
+ <Spinner />
+ </UpdatingContent>
+);
+export default LoadingSpinner;
diff --git a/awx/ui_next/src/components/LoadingSpinner/index.js b/awx/ui_next/src/components/LoadingSpinner/index.js
new file mode 100644
index 0000000000..6513c5cb53
--- /dev/null
+++ b/awx/ui_next/src/components/LoadingSpinner/index.js
@@ -0,0 +1 @@
+export { default } from './LoadingSpinner';
diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
index 2b398abcbe..df9964440b 100644
--- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
import {
arrayOf,
bool,
@@ -8,7 +9,6 @@ import {
string,
oneOfType,
} from 'prop-types';
-import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { FormGroup } from '@patternfly/react-core';
@@ -39,13 +39,13 @@ function CredentialLookup({
credentialTypeKind,
credentialTypeNamespace,
value,
- history,
i18n,
tooltip,
isDisabled,
autoPopulate,
multiple,
}) {
+ const history = useHistory();
const autoPopulateLookup = useAutoPopulateLookup(onChange);
const {
result: { count, credentials, relatedSearchableKeys, searchableKeys },
@@ -72,22 +72,28 @@ function CredentialLookup({
...typeNamespaceParams,
})
),
- CredentialsAPI.readOptions,
+ CredentialsAPI.readOptions(),
]);
if (autoPopulate) {
autoPopulateLookup(data.results);
}
+ const searchKeys = Object.keys(
+ actionsResponse.data.actions?.GET || {}
+ ).filter(key => actionsResponse.data.actions?.GET[key].filterable);
+ const item = searchKeys.indexOf('type');
+ if (item) {
+ searchKeys[item] = 'credential_type__kind';
+ }
+
return {
count: data.count,
credentials: data.results,
relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
- searchableKeys: Object.keys(
- actionsResponse.data?.actions?.GET || {}
- ).filter(key => actionsResponse.data?.actions?.GET[key]?.filterable),
+ searchableKeys: searchKeys,
};
}, [
autoPopulate,
@@ -222,4 +228,4 @@ CredentialLookup.defaultProps = {
};
export { CredentialLookup as _CredentialLookup };
-export default withI18n()(withRouter(CredentialLookup));
+export default withI18n()(CredentialLookup);
diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx
index 40d7b87d33..1b4bfb5e59 100644
--- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx
@@ -13,7 +13,7 @@ import useRequest from '../../util/useRequest';
import Lookup from './Lookup';
import LookupErrorMessage from './shared/LookupErrorMessage';
-const QS_CONFIG = getQSConfig('instance_groups', {
+const QS_CONFIG = getQSConfig('instance-groups', {
page: 1,
page_size: 5,
order_by: 'name',
diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx
index 9c42d521de..f55d669880 100644
--- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx
@@ -16,6 +16,7 @@ const QS_CONFIG = getQSConfig('inventory', {
page: 1,
page_size: 5,
order_by: 'name',
+ role_level: 'use_role',
});
function InventoryLookup({
@@ -29,6 +30,7 @@ function InventoryLookup({
fieldId,
promptId,
promptName,
+ isOverrideDisabled,
}) {
const {
result: {
@@ -57,8 +59,10 @@ function InventoryLookup({
searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
- canEdit: Boolean(actionsResponse.data.actions.POST),
+ canEdit:
+ Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
};
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [history.location]),
{
inventories: [],
@@ -195,11 +199,13 @@ InventoryLookup.propTypes = {
value: Inventory,
onChange: func.isRequired,
required: bool,
+ isOverrideDisabled: bool,
};
InventoryLookup.defaultProps = {
value: null,
required: false,
+ isOverrideDisabled: false,
};
export default withI18n()(withRouter(InventoryLookup));
diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx
new file mode 100644
index 0000000000..1c7d13f488
--- /dev/null
+++ b/awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import InventoryLookup from './InventoryLookup';
+import { InventoriesAPI } from '../../api';
+
+jest.mock('../../api');
+
+const mockedInventories = {
+ data: {
+ count: 2,
+ results: [
+ { id: 2, name: 'Bar' },
+ { id: 3, name: 'Baz' },
+ ],
+ },
+};
+
+describe('InventoryLookup', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ InventoriesAPI.read.mockResolvedValue(mockedInventories);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should render successfully and fetch data', async () => {
+ InventoriesAPI.readOptions.mockReturnValue({
+ data: {
+ actions: {
+ GET: {},
+ POST: {},
+ },
+ related_search_fields: [],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />);
+ });
+ wrapper.update();
+ expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('InventoryLookup')).toHaveLength(1);
+ expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
+ });
+
+ test('inventory lookup should be enabled', async () => {
+ InventoriesAPI.readOptions.mockReturnValue({
+ data: {
+ actions: {
+ GET: {},
+ },
+ related_search_fields: [],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <InventoryLookup isOverrideDisabled onChange={() => {}} />
+ );
+ });
+ wrapper.update();
+ expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('InventoryLookup')).toHaveLength(1);
+ expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
+ });
+
+ test('inventory lookup should be disabled', async () => {
+ InventoriesAPI.readOptions.mockReturnValue({
+ data: {
+ actions: {
+ GET: {},
+ },
+ related_search_fields: [],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />);
+ });
+ wrapper.update();
+ expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('InventoryLookup')).toHaveLength(1);
+ expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true);
+ });
+});
diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx
index b31efb35c5..ecc1a268c4 100644
--- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx
@@ -71,6 +71,16 @@ function MultiCredentialsLookup(props) {
loadCredentials(params, selectedType.id),
CredentialsAPI.readOptions(),
]);
+
+ results.map(result => {
+ if (result.kind === 'vault' && result.inputs?.vault_id) {
+ result.label = `${result.name} | ${result.inputs.vault_id}`;
+ return result;
+ }
+ result.label = `${result.name}`;
+ return result;
+ });
+
return {
credentials: results,
credentialsCount: count,
@@ -108,7 +118,6 @@ function MultiCredentialsLookup(props) {
credential={item}
/>
);
-
const isVault = selectedType?.kind === 'vault';
return (
@@ -187,6 +196,7 @@ function MultiCredentialsLookup(props) {
relatedSearchableKeys={relatedSearchableKeys}
multiple={isVault}
header={i18n._(t`Credentials`)}
+ displayKey={isVault ? 'label' : 'name'}
name="credentials"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx
index d0a3738171..a020d56345 100644
--- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx
+++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx
@@ -87,6 +87,23 @@ describe('<MultiCredentialsLookup />', () => {
name: 'Cred 5',
url: 'www.google.com',
},
+
+ {
+ id: 6,
+ credential_type: 5,
+ kind: 'vault',
+ name: 'Cred 6',
+ url: 'www.google.com',
+ inputs: { vault_id: 'vault ID' },
+ },
+ {
+ id: 7,
+ credential_type: 5,
+ kind: 'vault',
+ name: 'Cred 7',
+ url: 'www.google.com',
+ inputs: {},
+ },
],
count: 3,
},
@@ -196,7 +213,13 @@ describe('<MultiCredentialsLookup />', () => {
wrapper.update();
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
expect(wrapper.find('OptionsList').prop('options')).toEqual([
- { id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
+ {
+ id: 1,
+ kind: 'cloud',
+ name: 'New Cred',
+ url: 'www.google.com',
+ label: 'New Cred',
+ },
]);
});
@@ -268,6 +291,36 @@ describe('<MultiCredentialsLookup />', () => {
]);
});
+ test('should properly render vault credential labels', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <MultiCredentialsLookup
+ value={credentials}
+ tooltip="This is credentials look up"
+ onChange={() => {}}
+ onError={() => {}}
+ />
+ );
+ });
+ const searchButton = await waitForElement(
+ wrapper,
+ 'Button[aria-label="Search"]'
+ );
+ await act(async () => {
+ searchButton.invoke('onClick')();
+ });
+ wrapper.update();
+ const typeSelect = wrapper.find('AnsibleSelect');
+ act(() => {
+ typeSelect.invoke('onChange')({}, 500);
+ });
+ wrapper.update();
+ const optionsList = wrapper.find('OptionsList');
+ expect(optionsList.prop('multiple')).toEqual(true);
+ expect(wrapper.find('CheckboxListItem[label="Cred 6 | vault ID"]'));
+ expect(wrapper.find('CheckboxListItem[label="Cred 7"]'));
+ });
+
test('should allow multiple vault credentials with no vault id', async () => {
const onChange = jest.fn();
await act(async () => {
diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx
index dee802c2aa..a02ed40d84 100644
--- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx
@@ -18,6 +18,7 @@ const QS_CONFIG = getQSConfig('project', {
page: 1,
page_size: 5,
order_by: 'name',
+ role_level: 'use_role',
});
function ProjectLookup({
@@ -31,6 +32,7 @@ function ProjectLookup({
value,
onBlur,
history,
+ isOverrideDisabled,
}) {
const autoPopulateLookup = useAutoPopulateLookup(onChange);
const {
@@ -57,8 +59,10 @@ function ProjectLookup({
searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
- canEdit: Boolean(actionsResponse.data.actions.POST),
+ canEdit:
+ Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
};
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPopulate, autoPopulateLookup, history.location.search]),
{
count: 0,
@@ -160,6 +164,7 @@ ProjectLookup.propTypes = {
required: bool,
tooltip: string,
value: Project,
+ isOverrideDisabled: bool,
};
ProjectLookup.defaultProps = {
@@ -170,6 +175,7 @@ ProjectLookup.defaultProps = {
required: false,
tooltip: '',
value: null,
+ isOverrideDisabled: false,
};
export { ProjectLookup as _ProjectLookup };
diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx
index 04ccad63fe..88060c2699 100644
--- a/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx
+++ b/awx/ui_next/src/components/Lookup/ProjectLookup.test.jsx
@@ -7,6 +7,10 @@ import ProjectLookup from './ProjectLookup';
jest.mock('../../api');
describe('<ProjectLookup />', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
test('should auto-select project when only one available and autoPopulate prop is true', async () => {
ProjectsAPI.read.mockReturnValue({
data: {
@@ -48,4 +52,46 @@ describe('<ProjectLookup />', () => {
});
expect(onChange).not.toHaveBeenCalled();
});
+
+ test('project lookup should be enabled', async () => {
+ let wrapper;
+
+ ProjectsAPI.readOptions.mockReturnValue({
+ data: {
+ actions: {
+ GET: {},
+ },
+ related_search_fields: [],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <ProjectLookup isOverrideDisabled onChange={() => {}} />
+ );
+ });
+ wrapper.update();
+ expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('ProjectLookup')).toHaveLength(1);
+ expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
+ });
+
+ test('project lookup should be disabled', async () => {
+ let wrapper;
+
+ ProjectsAPI.readOptions.mockReturnValue({
+ data: {
+ actions: {
+ GET: {},
+ },
+ related_search_fields: [],
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(<ProjectLookup onChange={() => {}} />);
+ });
+ wrapper.update();
+ expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('ProjectLookup')).toHaveLength(1);
+ expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true);
+ });
});
diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx
index 8bd449e851..7f5fe9afdd 100644
--- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx
+++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx
@@ -1,9 +1,10 @@
import React, { Fragment } from 'react';
+
import PropTypes from 'prop-types';
import { DataList } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { withRouter } from 'react-router-dom';
+import { withRouter, useHistory, useLocation } from 'react-router-dom';
import ListHeader from '../ListHeader';
import ContentEmpty from '../ContentEmpty';
@@ -21,167 +22,155 @@ import {
import { QSConfig, SearchColumns, SortColumns } from '../../types';
import PaginatedDataListItem from './PaginatedDataListItem';
-
-class PaginatedDataList extends React.Component {
- constructor(props) {
- super(props);
- this.handleSetPage = this.handleSetPage.bind(this);
- this.handleSetPageSize = this.handleSetPageSize.bind(this);
- this.handleListItemSelect = this.handleListItemSelect.bind(this);
- }
-
- handleListItemSelect = (id = 0) => {
- const { items, onRowClick } = this.props;
+import LoadingSpinner from '../LoadingSpinner';
+
+function PaginatedDataList({
+ items,
+ onRowClick,
+ contentError,
+ hasContentLoading,
+ emptyStateControls,
+ itemCount,
+ qsConfig,
+ renderItem,
+ toolbarSearchColumns,
+ toolbarSearchableKeys,
+ toolbarRelatedSearchableKeys,
+ toolbarSortColumns,
+ pluralizedItemName,
+ showPageSizeOptions,
+ location,
+ i18n,
+ renderToolbar,
+}) {
+ const { search, pathname } = useLocation();
+ const history = useHistory();
+ const handleListItemSelect = (id = 0) => {
const match = items.find(item => item.id === Number(id));
onRowClick(match);
};
- handleSetPage(event, pageNumber) {
- const { history, qsConfig } = this.props;
- const { search } = history.location;
+ const handleSetPage = (event, pageNumber) => {
const oldParams = parseQueryString(qsConfig, search);
- this.pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
- }
+ pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
+ };
- handleSetPageSize(event, pageSize, page) {
- const { history, qsConfig } = this.props;
- const { search } = history.location;
+ const handleSetPageSize = (event, pageSize, page) => {
const oldParams = parseQueryString(qsConfig, search);
- this.pushHistoryState(
- replaceParams(oldParams, { page_size: pageSize, page })
- );
- }
+ pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page }));
+ };
- pushHistoryState(params) {
- const { history, qsConfig } = this.props;
- const { pathname } = history.location;
+ const pushHistoryState = params => {
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
- }
+ };
- render() {
- const {
- contentError,
- hasContentLoading,
- emptyStateControls,
- items,
- itemCount,
- qsConfig,
- renderItem,
- toolbarSearchColumns,
- toolbarSearchableKeys,
- toolbarRelatedSearchableKeys,
- toolbarSortColumns,
- pluralizedItemName,
- showPageSizeOptions,
- location,
- i18n,
- renderToolbar,
- } = this.props;
- const searchColumns = toolbarSearchColumns.length
- ? toolbarSearchColumns
- : [
- {
- name: i18n._(t`Name`),
- key: 'name',
- isDefault: true,
- },
- ];
- const sortColumns = toolbarSortColumns.length
- ? toolbarSortColumns
- : [
- {
- name: i18n._(t`Name`),
- key: 'name',
- },
- ];
- const queryParams = parseQueryString(qsConfig, location.search);
-
- const dataListLabel = i18n._(t`${pluralizedItemName} List`);
- const emptyContentMessage = i18n._(
- t`Please add ${pluralizedItemName} to populate this list `
+ const searchColumns = toolbarSearchColumns.length
+ ? toolbarSearchColumns
+ : [
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ isDefault: true,
+ },
+ ];
+ const sortColumns = toolbarSortColumns.length
+ ? toolbarSortColumns
+ : [
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ },
+ ];
+ const queryParams = parseQueryString(qsConfig, location.search);
+
+ const dataListLabel = i18n._(t`${pluralizedItemName} List`);
+ const emptyContentMessage = i18n._(
+ t`Please add ${pluralizedItemName} to populate this list `
+ );
+ const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
+
+ let Content;
+ if (hasContentLoading && items.length <= 0) {
+ Content = <ContentLoading />;
+ } else if (contentError) {
+ Content = <ContentError error={contentError} />;
+ } else if (items.length <= 0) {
+ Content = (
+ <ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
);
- const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
-
- let Content;
- if (hasContentLoading && items.length <= 0) {
- Content = <ContentLoading />;
- } else if (contentError) {
- Content = <ContentError error={contentError} />;
- } else if (items.length <= 0) {
- Content = (
- <ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
- );
- } else {
- Content = (
+ } else {
+ Content = (
+ <>
+ {hasContentLoading && <LoadingSpinner />}
<DataList
aria-label={dataListLabel}
- onSelectDataListItem={id => this.handleListItemSelect(id)}
+ onSelectDataListItem={id => handleListItemSelect(id)}
>
{items.map(renderItem)}
</DataList>
- );
- }
+ </>
+ );
+ }
- const ToolbarPagination = (
- <Pagination
- isCompact
- dropDirection="down"
+ const ToolbarPagination = (
+ <Pagination
+ isCompact
+ dropDirection="down"
+ itemCount={itemCount}
+ page={queryParams.page || 1}
+ perPage={queryParams.page_size}
+ perPageOptions={
+ showPageSizeOptions
+ ? [
+ { title: '5', value: 5 },
+ { title: '10', value: 10 },
+ { title: '20', value: 20 },
+ { title: '50', value: 50 },
+ ]
+ : []
+ }
+ onSetPage={handleSetPage}
+ onPerPageSelect={handleSetPageSize}
+ />
+ );
+
+ return (
+ <Fragment>
+ <ListHeader
itemCount={itemCount}
- page={queryParams.page || 1}
- perPage={queryParams.page_size}
- perPageOptions={
- showPageSizeOptions
- ? [
- { title: '5', value: 5 },
- { title: '10', value: 10 },
- { title: '20', value: 20 },
- { title: '50', value: 50 },
- ]
- : []
- }
- onSetPage={this.handleSetPage}
- onPerPageSelect={this.handleSetPageSize}
+ renderToolbar={renderToolbar}
+ emptyStateControls={emptyStateControls}
+ searchColumns={searchColumns}
+ sortColumns={sortColumns}
+ searchableKeys={toolbarSearchableKeys}
+ relatedSearchableKeys={toolbarRelatedSearchableKeys}
+ qsConfig={qsConfig}
+ pagination={ToolbarPagination}
/>
- );
-
- return (
- <Fragment>
- <ListHeader
+ {Content}
+ {items.length ? (
+ <Pagination
+ variant="bottom"
itemCount={itemCount}
- renderToolbar={renderToolbar}
- emptyStateControls={emptyStateControls}
- searchColumns={searchColumns}
- sortColumns={sortColumns}
- searchableKeys={toolbarSearchableKeys}
- relatedSearchableKeys={toolbarRelatedSearchableKeys}
- qsConfig={qsConfig}
- pagination={ToolbarPagination}
+ page={queryParams.page || 1}
+ perPage={queryParams.page_size}
+ perPageOptions={
+ showPageSizeOptions
+ ? [
+ { title: '5', value: 5 },
+ { title: '10', value: 10 },
+ { title: '20', value: 20 },
+ { title: '50', value: 50 },
+ ]
+ : []
+ }
+ onSetPage={handleSetPage}
+ onPerPageSelect={handleSetPageSize}
/>
- {Content}
- {items.length ? (
- <Pagination
- variant="bottom"
- itemCount={itemCount}
- page={queryParams.page || 1}
- perPage={queryParams.page_size}
- perPageOptions={
- showPageSizeOptions
- ? [
- { title: '5', value: 5 },
- { title: '10', value: 10 },
- { title: '20', value: 20 },
- { title: '50', value: 50 },
- ]
- : []
- }
- onSetPage={this.handleSetPage}
- onPerPageSelect={this.handleSetPageSize}
- />
- ) : null}
- </Fragment>
- );
- }
+ ) : null}
+ </Fragment>
+ );
}
const Item = PropTypes.shape({
diff --git a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
index 2176bd3b57..9892df34fe 100644
--- a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
+++ b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx
@@ -11,6 +11,7 @@ import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading';
import Pagination from '../Pagination';
import DataListToolbar from '../DataListToolbar';
+import LoadingSpinner from '../LoadingSpinner';
import {
encodeNonDefaultQueryString,
@@ -82,10 +83,13 @@ function PaginatedTable({
);
} else {
Content = (
- <TableComposable aria-label={dataListLabel}>
- {headerRow}
- <Tbody>{items.map(renderRow)}</Tbody>
- </TableComposable>
+ <>
+ {hasContentLoading && <LoadingSpinner />}
+ <TableComposable aria-label={dataListLabel}>
+ {headerRow}
+ <Tbody>{items.map(renderRow)}</Tbody>
+ </TableComposable>
+ </>
);
}
diff --git a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx
index 4568c477e4..b5b1765d45 100644
--- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx
+++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx
@@ -155,6 +155,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
fetchAccessRecords();
}}
roles={resource.summary_fields.object_roles}
+ resource={resource}
/>
)}
{showDeleteModal && (
diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx
index 957006022e..0bac50fdbc 100644
--- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx
+++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx
@@ -62,7 +62,7 @@ function ScheduleList({
scheduleActions.data.actions?.GET || {}
).filter(key => scheduleActions.data.actions?.GET[key].filterable),
};
- }, [location, loadSchedules, loadScheduleOptions]),
+ }, [location.search, loadSchedules, loadScheduleOptions]),
{
schedules: [],
itemCount: 0,
diff --git a/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx
index db9f5008ed..338c089c53 100644
--- a/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx
+++ b/awx/ui_next/src/components/SelectableCard/SelectableCard.jsx
@@ -31,7 +31,14 @@ const Description = styled.p`
font-size: 14px;
`;
-function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
+function SelectableCard({
+ label,
+ description,
+ onClick,
+ isSelected,
+ dataCy,
+ ariaLabel,
+}) {
return (
<SelectableItem
onClick={onClick}
@@ -40,6 +47,7 @@ function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
tabIndex="0"
data-cy={dataCy}
isSelected={isSelected}
+ aria-label={ariaLabel}
>
<Indicator isSelected={isSelected} />
<Contents>
@@ -55,12 +63,14 @@ SelectableCard.propTypes = {
description: PropTypes.string,
onClick: PropTypes.func.isRequired,
isSelected: PropTypes.bool,
+ ariaLabel: PropTypes.string,
};
SelectableCard.defaultProps = {
label: '',
description: '',
isSelected: false,
+ ariaLabel: '',
};
export default SelectableCard;
diff --git a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx
index 5e55cb1611..80d7a8c916 100644
--- a/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx
+++ b/awx/ui_next/src/screens/Application/ApplicationDetails/ApplicationDetails.jsx
@@ -7,7 +7,11 @@ import { Button } from '@patternfly/react-core';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
-import { Detail, DetailList } from '../../../components/DetailList';
+import {
+ Detail,
+ DetailList,
+ UserDateDetail,
+} from '../../../components/DetailList';
import { ApplicationsAPI } from '../../../api';
import DeleteButton from '../../../components/DeleteButton';
import ErrorDetail from '../../../components/ErrorDetail';
@@ -98,6 +102,11 @@ function ApplicationDetails({
value={getClientType(application.client_type)}
dataCy="app-detail-client-type"
/>
+ <UserDateDetail label={i18n._(t`Created`)} date={application.created} />
+ <UserDateDetail
+ label={i18n._(t`Last Modified`)}
+ date={application.modified}
+ />
</DetailList>
<CardActionsRow>
{application.summary_fields.user_capabilities &&
diff --git a/awx/ui_next/src/screens/Credential/Credential.jsx b/awx/ui_next/src/screens/Credential/Credential.jsx
index e06dce231e..42c18a0d41 100644
--- a/awx/ui_next/src/screens/Credential/Credential.jsx
+++ b/awx/ui_next/src/screens/Credential/Credential.jsx
@@ -56,15 +56,12 @@ function Credential({ i18n, setBreadcrumb }) {
id: 99,
},
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
- ];
-
- if (credential && credential.organization) {
- tabsArray.push({
+ {
name: i18n._(t`Access`),
link: `/credentials/${id}/access`,
id: 1,
- });
- }
+ },
+ ];
let showCardHeader = true;
@@ -108,14 +105,12 @@ function Credential({ i18n, setBreadcrumb }) {
<Route key="edit" path="/credentials/:id/edit">
<CredentialEdit credential={credential} />
</Route>,
- credential.organization && (
- <Route key="access" path="/credentials/:id/access">
- <ResourceAccessList
- resource={credential}
- apiModel={CredentialsAPI}
- />
- </Route>
- ),
+ <Route key="access" path="/credentials/:id/access">
+ <ResourceAccessList
+ resource={credential}
+ apiModel={CredentialsAPI}
+ />
+ </Route>,
<Route key="not-found" path="*">
{!hasContentLoading && (
<ContentError isNotFound>
diff --git a/awx/ui_next/src/screens/Credential/Credential.test.jsx b/awx/ui_next/src/screens/Credential/Credential.test.jsx
index 7cb192ac0c..ed3404c1a6 100644
--- a/awx/ui_next/src/screens/Credential/Credential.test.jsx
+++ b/awx/ui_next/src/screens/Credential/Credential.test.jsx
@@ -31,7 +31,7 @@ describe('<Credential />', () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
- await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 2);
+ await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 3);
});
test('initially renders org-based credential succesfully', async () => {
diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx
index 5f8066b904..55190e3a5f 100644
--- a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx
@@ -78,7 +78,7 @@ function CredentialDetail({ i18n, credential }) {
{}
),
};
- }, [credentialId, credential_type]),
+ }, [credentialId, credential_type.id]),
{
fields: [],
managedByTower: true,
diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx
index 26272ee4ad..e4a10ed86c 100644
--- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.jsx
@@ -26,7 +26,13 @@ function CredentialList({ i18n }) {
const location = useLocation();
const {
- result: { credentials, credentialCount, actions },
+ result: {
+ credentials,
+ credentialCount,
+ actions,
+ relatedSearchableKeys,
+ searchableKeys,
+ },
error: contentError,
isLoading,
request: fetchCredentials,
@@ -37,16 +43,29 @@ function CredentialList({ i18n }) {
CredentialsAPI.read(params),
CredentialsAPI.readOptions(),
]);
+ const searchKeys = Object.keys(
+ credActions.data.actions?.GET || {}
+ ).filter(key => credActions.data.actions?.GET[key].filterable);
+ const item = searchKeys.indexOf('type');
+ if (item) {
+ searchKeys[item] = 'credential_type__kind';
+ }
return {
credentials: creds.data.results,
credentialCount: creds.data.count,
actions: credActions.data.actions,
+ relatedSearchableKeys: (
+ credActions?.data?.related_search_fields || []
+ ).map(val => val.slice(0, -8)),
+ searchableKeys: searchKeys,
};
}, [location]),
{
credentials: [],
credentialCount: 0,
actions: {},
+ relatedSearchableKeys: [],
+ searchableKeys: [],
}
);
@@ -102,6 +121,8 @@ function CredentialList({ i18n }) {
itemCount={credentialCount}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
+ toolbarSearchableKeys={searchableKeys}
+ toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
diff --git a/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json b/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json
index b7b3189951..6281f15024 100644
--- a/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json
+++ b/awx/ui_next/src/screens/Credential/shared/data.credentialTypes.json
@@ -276,6 +276,11 @@
"help_text": "OpenStack domains define administrative boundaries. It is only needed for Keystone v3 authentication URLs. Refer to Ansible Tower documentation for common scenarios."
},
{
+ "id": "project_region_name",
+ "label": "Region Name",
+ "type": "string"
+ },
+ {
"id": "verify_ssl",
"label": "Verify SSL",
"type": "boolean",
diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx
index e22112e9ba..ee47d2fdbc 100644
--- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx
+++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx
@@ -18,7 +18,7 @@ import DatalistToolbar from '../../../components/DataListToolbar';
import CredentialTypeListItem from './CredentialTypeListItem';
-const QS_CONFIG = getQSConfig('credential_type', {
+const QS_CONFIG = getQSConfig('credential-type', {
page: 1,
page_size: 20,
managed_by_tower: false,
diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx
index d348ce28f7..f60e049631 100644
--- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx
+++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx
@@ -20,7 +20,7 @@ import useRequest from '../../util/useRequest';
import { DashboardAPI } from '../../api';
import Breadcrumbs from '../../components/Breadcrumbs';
import JobList from '../../components/JobList';
-
+import ContentLoading from '../../components/ContentLoading';
import LineChart from './shared/LineChart';
import Count from './shared/Count';
import DashboardTemplateList from './shared/DashboardTemplateList';
@@ -62,6 +62,7 @@ function Dashboard({ i18n }) {
const [activeTabId, setActiveTabId] = useState(0);
const {
+ isLoading,
result: { jobGraphData, countData },
request: fetchDashboardGraph,
} = useRequest(
@@ -105,7 +106,15 @@ function Dashboard({ i18n }) {
useEffect(() => {
fetchDashboardGraph();
}, [fetchDashboardGraph, periodSelection, jobTypeSelection]);
-
+ if (isLoading) {
+ return (
+ <PageSection>
+ <Card>
+ <ContentLoading />
+ </Card>
+ </PageSection>
+ );
+ }
return (
<Fragment>
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} />
diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx
index 33832e71ee..18a7f80edd 100644
--- a/awx/ui_next/src/screens/Host/Host.jsx
+++ b/awx/ui_next/src/screens/Host/Host.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useCallback, useEffect } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
@@ -20,29 +20,22 @@ import HostDetail from './HostDetail';
import HostEdit from './HostEdit';
import HostGroups from './HostGroups';
import { HostsAPI } from '../../api';
+import useRequest from '../../util/useRequest';
function Host({ i18n, setBreadcrumb }) {
- const [host, setHost] = useState(null);
- const [contentError, setContentError] = useState(null);
- const [hasContentLoading, setHasContentLoading] = useState(true);
-
const location = useLocation();
const match = useRouteMatch('/hosts/:id');
+ const { error, isLoading, result: host, request: fetchHost } = useRequest(
+ useCallback(async () => {
+ const { data } = await HostsAPI.readDetail(match.params.id);
+ setBreadcrumb(data);
+ return data;
+ }, [match.params.id, setBreadcrumb])
+ );
useEffect(() => {
- (async () => {
- setContentError(null);
- try {
- const { data } = await HostsAPI.readDetail(match.params.id);
- setHost(data);
- setBreadcrumb(data);
- } catch (error) {
- setContentError(error);
- } finally {
- setHasContentLoading(false);
- }
- })();
- }, [match.params.id, location, setBreadcrumb]);
+ fetchHost();
+ }, [fetchHost, location]);
const tabsArray = [
{
@@ -77,7 +70,7 @@ function Host({ i18n, setBreadcrumb }) {
},
];
- if (hasContentLoading) {
+ if (isLoading) {
return (
<PageSection>
<Card>
@@ -87,12 +80,12 @@ function Host({ i18n, setBreadcrumb }) {
);
}
- if (contentError) {
+ if (error) {
return (
<PageSection>
<Card>
- <ContentError error={contentError}>
- {contentError?.response?.status === 404 && (
+ <ContentError error={error}>
+ {error?.response?.status === 404 && (
<span>
{i18n._(t`Host not found.`)}{' '}
<Link to="/hosts">{i18n._(t`View all Hosts.`)}</Link>
diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx
index 26862760e9..a875d8777e 100644
--- a/awx/ui_next/src/screens/Host/Host.test.jsx
+++ b/awx/ui_next/src/screens/Host/Host.test.jsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { Route } from 'react-router-dom';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { HostsAPI } from '../../api';
@@ -28,7 +29,11 @@ describe('<Host />', () => {
beforeEach(async () => {
await act(async () => {
- wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />);
+ wrapper = mountWithContexts(
+ <Route path="/hosts/:id/details">
+ <Host setBreadcrumb={() => {}} />
+ </Route>
+ );
});
});
diff --git a/awx/ui_next/src/screens/Host/data.hostFacts.json b/awx/ui_next/src/screens/Host/data.hostFacts.json
index a8427e0003..2507d267e3 100644
--- a/awx/ui_next/src/screens/Host/data.hostFacts.json
+++ b/awx/ui_next/src/screens/Host/data.hostFacts.json
@@ -83,7 +83,7 @@
"PWD": "/tmp/awx_13_r1ffeqze/project",
"HOME": "/var/lib/awx",
"LANG": "\"en-us\"",
- "PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"SHLVL": "4",
"JOB_ID": "13",
"LC_ALL": "en_US.UTF-8",
@@ -96,9 +96,9 @@
"SDB_PORT": "7899",
"MAKEFLAGS": "w",
"MAKELEVEL": "2",
- "PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
+ "PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
"CURRENT_UID": "501",
- "VIRTUAL_ENV": "/venv/ansible",
+ "VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
"INVENTORY_ID": "1",
"MAX_EVENT_RES": "700000",
"PROOT_TMP_DIR": "/tmp",
@@ -106,7 +106,7 @@
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
"AWX_GROUP_QUEUES": "tower",
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
- "ANSIBLE_VENV_PATH": "/venv/ansible",
+ "ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
"RUNNER_OMIT_EVENTS": "False",
"SUPERVISOR_ENABLED": "1",
@@ -119,7 +119,7 @@
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
- "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
+ "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
"ANSIBLE_HOST_KEY_CHECKING": "False",
"RUNNER_ONLY_FAILED_EVENTS": "False",
diff --git a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx
index d63996dde8..937aa15adb 100644
--- a/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/ContainerGroupEdit/ContainerGroupEdit.test.jsx
@@ -123,7 +123,7 @@ describe('<ContainerGroupEdit/>', () => {
});
test('called InstanceGroupsAPI.readOptions', async () => {
- expect(InstanceGroupsAPI.readOptions).toHaveBeenCalledTimes(1);
+ expect(InstanceGroupsAPI.readOptions).toHaveBeenCalled();
});
test('handleCancel returns the user to container group detail', async () => {
diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx
index 5e56d18f73..6b3b43f637 100644
--- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx
+++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx
@@ -18,7 +18,7 @@ import AddDropDownButton from '../../../components/AddDropDownButton';
import InstanceGroupListItem from './InstanceGroupListItem';
-const QS_CONFIG = getQSConfig('instance_group', {
+const QS_CONFIG = getQSConfig('instance-group', {
page: 1,
page_size: 20,
});
diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx
index 1071b91cc3..50a0f13f67 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.test.jsx
@@ -58,7 +58,7 @@ describe('InventorySourceDetail', () => {
assertDetail(wrapper, 'Description', 'mock description');
assertDetail(wrapper, 'Source', 'Sourced from a Project');
assertDetail(wrapper, 'Organization', 'Mock Org');
- assertDetail(wrapper, 'Ansible environment', '/venv/custom');
+ assertDetail(wrapper, 'Ansible environment', '/var/lib/awx/venv/custom');
assertDetail(wrapper, 'Project', 'Mock Project');
assertDetail(wrapper, 'Inventory file', 'foo');
assertDetail(wrapper, 'Verbosity', '2 (Debug)');
diff --git a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx
index f1286e9a2b..2b1cff9115 100644
--- a/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx
+++ b/awx/ui_next/src/screens/Inventory/shared/InventorySourceForm.jsx
@@ -55,7 +55,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
const [venvField] = useField('custom_virtualenv');
const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`),
- value: '/venv/ansible/',
+ value: '/var/lib/awx/venv/ansible/',
key: 'default',
};
diff --git a/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json b/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json
index a8427e0003..2507d267e3 100644
--- a/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json
+++ b/awx/ui_next/src/screens/Inventory/shared/data.hostFacts.json
@@ -83,7 +83,7 @@
"PWD": "/tmp/awx_13_r1ffeqze/project",
"HOME": "/var/lib/awx",
"LANG": "\"en-us\"",
- "PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"SHLVL": "4",
"JOB_ID": "13",
"LC_ALL": "en_US.UTF-8",
@@ -96,9 +96,9 @@
"SDB_PORT": "7899",
"MAKEFLAGS": "w",
"MAKELEVEL": "2",
- "PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
+ "PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
"CURRENT_UID": "501",
- "VIRTUAL_ENV": "/venv/ansible",
+ "VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
"INVENTORY_ID": "1",
"MAX_EVENT_RES": "700000",
"PROOT_TMP_DIR": "/tmp",
@@ -106,7 +106,7 @@
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
"AWX_GROUP_QUEUES": "tower",
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
- "ANSIBLE_VENV_PATH": "/venv/ansible",
+ "ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
"RUNNER_OMIT_EVENTS": "False",
"SUPERVISOR_ENABLED": "1",
@@ -119,7 +119,7 @@
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
- "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
+ "ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
"ANSIBLE_HOST_KEY_CHECKING": "False",
"RUNNER_ONLY_FAILED_EVENTS": "False",
diff --git a/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json b/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json
index ad1e313611..550cb8138e 100644
--- a/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json
+++ b/awx/ui_next/src/screens/Inventory/shared/data.inventory_source.json
@@ -98,7 +98,7 @@
"credential": 8,
"overwrite":true,
"overwrite_vars":true,
- "custom_virtualenv":"/venv/custom",
+ "custom_virtualenv":"/var/lib/awx/venv/custom",
"timeout":0,
"verbosity":2,
"last_job_run":null,
diff --git a/awx/ui_next/src/screens/Job/Job.jsx b/awx/ui_next/src/screens/Job/Job.jsx
index ce62e7338c..479eeb6e49 100644
--- a/awx/ui_next/src/screens/Job/Job.jsx
+++ b/awx/ui_next/src/screens/Job/Job.jsx
@@ -29,10 +29,18 @@ function Job({ i18n, setBreadcrumb }) {
const { isLoading, error, request: fetchJob, result } = useRequest(
useCallback(async () => {
const { data } = await JobsAPI.readDetail(id, type);
+ if (
+ data?.summary_fields?.credentials?.find(cred => cred.kind === 'vault')
+ ) {
+ const {
+ data: { results },
+ } = await JobsAPI.readCredentials(data.id, type);
+
+ data.summary_fields.credentials = results;
+ }
setBreadcrumb(data);
return data;
- }, [id, type, setBreadcrumb]),
- null
+ }, [id, type, setBreadcrumb])
);
useEffect(() => {
diff --git a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
index 9963fbbba8..9255b27af1 100644
--- a/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
+++ b/awx/ui_next/src/screens/Job/JobDetail/JobDetail.jsx
@@ -7,7 +7,11 @@ import { Button, Chip, Label } from '@patternfly/react-core';
import styled from 'styled-components';
import AlertModal from '../../../components/AlertModal';
-import { DetailList, Detail } from '../../../components/DetailList';
+import {
+ DetailList,
+ Detail,
+ UserDateDetail,
+} from '../../../components/DetailList';
import { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup';
import CredentialChip from '../../../components/CredentialChip';
@@ -80,6 +84,7 @@ const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
function JobDetail({ job, i18n }) {
const {
+ created_by,
credential,
credentials,
instance_group: instanceGroup,
@@ -289,6 +294,12 @@ function JobDetail({ job, i18n }) {
}
/>
)}
+ <UserDateDetail
+ label={i18n._(t`Created`)}
+ date={job.created}
+ user={created_by}
+ />
+ <UserDateDetail label={i18n._(t`Last Modified`)} date={job.modified} />
</DetailList>
{job.extra_vars && (
<VariablesInput
diff --git a/awx/ui_next/src/screens/Job/shared/data.job.json b/awx/ui_next/src/screens/Job/shared/data.job.json
index 8bebbbda67..98d071c876 100644
--- a/awx/ui_next/src/screens/Job/shared/data.job.json
+++ b/awx/ui_next/src/screens/Job/shared/data.job.json
@@ -114,7 +114,7 @@
"started": "2019-08-08T19:24:18.329589Z",
"finished": "2019-08-08T19:24:50.119995Z",
"elapsed": 31.79,
- "job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/venv/ansible\", \"/venv/ansible\", \"--ro-bind\", \"/venv/awx\", \"/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
+ "job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/var/lib/awx/venv/ansible\", \"/var/lib/awx/venv/ansible\", \"--ro-bind\", \"/var/lib/awx/venv/awx\", \"/var/lib/awx/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
"job_cwd": "/projects/_6__demo_project",
"job_env": {
"HOSTNAME": "awx",
@@ -123,9 +123,9 @@
"LC_ALL": "en_US.UTF-8",
"SDB_HOST": "0.0.0.0",
"MAKELEVEL": "2",
- "VIRTUAL_ENV": "/venv/ansible",
+ "VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
"MFLAGS": "-w",
- "PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"SUPERVISOR_GROUP_NAME": "tower-processes",
"PWD": "/awx_devel",
"LANG": "\"en-us\"",
@@ -138,7 +138,7 @@
"SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock",
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
"CURRENT_UID": "501",
- "_": "/venv/awx/bin/python3",
+ "_": "/var/lib/awx/venv/awx/bin/python3",
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
"DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199",
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
@@ -147,11 +147,11 @@
"ANSIBLE_HOST_KEY_CHECKING": "False",
"ANSIBLE_INVENTORY_UNPARSED_FAILED": "True",
"ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False",
- "ANSIBLE_VENV_PATH": "/venv/ansible",
+ "ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
"PROOT_TMP_DIR": "/tmp",
"AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw",
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections",
- "PYTHONPATH": "/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
+ "PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
"JOB_ID": "2",
"INVENTORY_ID": "1",
"PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82",
@@ -184,5 +184,5 @@
"play_count": 1,
"task_count": 1
},
- "custom_virtualenv": "/venv/ansible"
+ "custom_virtualenv": "/var/lib/awx/venv/ansible"
}
diff --git a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx
index 5761e737a3..b9becea72f 100644
--- a/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/NotificationTemplate/NotificationTemplateDetail/NotificationTemplateDetail.jsx
@@ -10,6 +10,7 @@ import {
ArrayDetail,
DetailList,
DeletedDetail,
+ UserDateDetail,
} from '../../../components/DetailList';
import CodeDetail from '../../../components/DetailList/CodeDetail';
import DeleteButton from '../../../components/DeleteButton';
@@ -23,6 +24,8 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
const history = useHistory();
const {
+ created,
+ modified,
notification_configuration: configuration,
summary_fields,
messages,
@@ -324,6 +327,16 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
/>
</>
)}
+ <UserDateDetail
+ label={i18n._(t`Created`)}
+ date={created}
+ user={summary_fields?.created_by}
+ />
+ <UserDateDetail
+ label={i18n._(t`Last Modified`)}
+ date={modified}
+ user={summary_fields?.modified_by}
+ />
{hasCustomMessages(messages, typeMessageDefaults) && (
<CustomMessageDetails
messages={messages}
diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx
index ff969b86b5..8fa4e2cbc2 100644
--- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx
+++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx
@@ -153,7 +153,7 @@ describe('<OrganizationAdd />', () => {
.find('FormSelectOption')
.first()
.prop('value')
- ).toEqual('/venv/ansible/');
+ ).toEqual('/var/lib/awx/venv/ansible/');
});
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {
diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx
index c78b178943..094e6ac5b6 100644
--- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx
+++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.jsx
@@ -31,7 +31,7 @@ function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) {
const defaultVenv = {
label: i18n._(t`Use Default Ansible Environment`),
- value: '/venv/ansible/',
+ value: '/var/lib/awx/venv/ansible/',
key: 'default',
};
const { custom_virtualenvs } = useContext(ConfigContext);
diff --git a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx
index 004c7d1577..67cf0a60d6 100644
--- a/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx
+++ b/awx/ui_next/src/screens/Organization/shared/OrganizationForm.test.jsx
@@ -200,7 +200,7 @@ describe('<OrganizationForm />', () => {
.find('FormSelectOption')
.first()
.prop('value')
- ).toEqual('/venv/ansible/');
+ ).toEqual('/var/lib/awx/venv/ansible/');
});
test('onSubmit associates and disassociates instance groups', async () => {
diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx
index f87aee15dc..72341a5de9 100644
--- a/awx/ui_next/src/screens/Project/Project.jsx
+++ b/awx/ui_next/src/screens/Project/Project.jsx
@@ -44,6 +44,19 @@ function Project({ i18n, setBreadcrumb }) {
role_level: 'notification_admin_role',
}),
]);
+
+ if (data.summary_fields.credentials) {
+ const params = {
+ page: 1,
+ page_size: 200,
+ order_by: 'name',
+ };
+ const {
+ data: { results },
+ } = await ProjectsAPI.readCredentials(data.id, params);
+
+ data.summary_fields.credentials = results;
+ }
return {
project: data,
isNotifAdmin: notifAdminRes.data.results.length > 0,
diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
index e6141bebb7..8bc136b889 100644
--- a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.test.jsx
@@ -24,7 +24,7 @@ describe('<ProjectAdd />', () => {
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
allow_override: false,
- custom_virtualenv: '/venv/custom-env',
+ custom_virtualenv: '/var/lib/awx/venv/custom-env',
};
const projectOptionsResolve = {
diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
index 380196d950..4c92c9695e 100644
--- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx
@@ -19,6 +19,7 @@ import CredentialChip from '../../../components/CredentialChip';
import { ProjectsAPI } from '../../../api';
import { toTitleCase } from '../../../util/strings';
import useRequest, { useDismissableError } from '../../../util/useRequest';
+import ProjectSyncButton from '../shared/ProjectSyncButton';
function ProjectDetail({ project, i18n }) {
const {
@@ -148,27 +149,28 @@ function ProjectDetail({ project, i18n }) {
/>
</DetailList>
<CardActionsRow>
- {summary_fields.user_capabilities &&
- summary_fields.user_capabilities.edit && (
- <Button
- aria-label={i18n._(t`edit`)}
- component={Link}
- to={`/projects/${id}/edit`}
- >
- {i18n._(t`Edit`)}
- </Button>
- )}
- {summary_fields.user_capabilities &&
- summary_fields.user_capabilities.delete && (
- <DeleteButton
- name={name}
- modalTitle={i18n._(t`Delete Project`)}
- onConfirm={deleteProject}
- isDisabled={isLoading}
- >
- {i18n._(t`Delete`)}
- </DeleteButton>
- )}
+ {summary_fields.user_capabilities?.edit && (
+ <Button
+ aria-label={i18n._(t`edit`)}
+ component={Link}
+ to={`/projects/${id}/edit`}
+ >
+ {i18n._(t`Edit`)}
+ </Button>
+ )}
+ {summary_fields.user_capabilities?.start && (
+ <ProjectSyncButton projectId={project.id} />
+ )}
+ {summary_fields.user_capabilities?.delete && (
+ <DeleteButton
+ name={name}
+ modalTitle={i18n._(t`Delete Project`)}
+ onConfirm={deleteProject}
+ isDisabled={isLoading}
+ >
+ {i18n._(t`Delete`)}
+ </DeleteButton>
+ )}
</CardActionsRow>
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
{error && (
diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx
index 3139e2b14c..52e45e7d28 100644
--- a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.test.jsx
@@ -9,7 +9,12 @@ import { ProjectsAPI } from '../../../api';
import ProjectDetail from './ProjectDetail';
jest.mock('../../../api');
-
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useRouteMatch: () => ({
+ url: '/projects/1/details',
+ }),
+}));
describe('<ProjectDetail />', () => {
const mockProject = {
id: 1,
@@ -139,13 +144,19 @@ describe('<ProjectDetail />', () => {
);
});
- test('should show edit button for users with edit permission', async () => {
+ test('should show edit and sync button for users with edit permission', async () => {
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
const editButton = await waitForElement(
wrapper,
'ProjectDetail Button[aria-label="edit"]'
);
+
+ const syncButton = await waitForElement(
+ wrapper,
+ 'ProjectDetail Button[aria-label="Sync Project"]'
+ );
expect(editButton.text()).toEqual('Edit');
+ expect(syncButton.text()).toEqual('Sync');
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
});
@@ -166,6 +177,9 @@ describe('<ProjectDetail />', () => {
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
0
);
+ expect(wrapper.find('ProjectDetail Button[aria-label="sync"]').length).toBe(
+ 0
+ );
});
test('edit button should navigate to project edit', () => {
@@ -180,6 +194,17 @@ describe('<ProjectDetail />', () => {
expect(history.location.pathname).toEqual('/projects/1/edit');
});
+ test('sync button should call api to syn project', async () => {
+ ProjectsAPI.readSync.mockResolvedValue({ data: { can_update: true } });
+ const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
+ await act(() =>
+ wrapper
+ .find('ProjectDetail Button[aria-label="Sync Project"]')
+ .prop('onClick')(1)
+ );
+ expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1);
+ });
+
test('expected api calls are made for delete', async () => {
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');
diff --git a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx
index dc1a49eb78..1a62a3f2f0 100644
--- a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.test.jsx
@@ -25,7 +25,7 @@ describe('<ProjectEdit />', () => {
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
allow_override: false,
- custom_virtualenv: '/venv/custom-env',
+ custom_virtualenv: '/var/lib/awx/venv/custom-env',
summary_fields: {
credential: {
id: 100,
diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
index b2539e5f87..dba55552d4 100644
--- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
+++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
@@ -14,7 +14,7 @@ import {
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
-import { PencilAltIcon, SyncIcon } from '@patternfly/react-icons';
+import { PencilAltIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import { formatDateString, timeOfDay } from '../../../util/dates';
import { ProjectsAPI } from '../../../api';
@@ -153,23 +153,10 @@ function ProjectListItem({
aria-labelledby={labelId}
id={labelId}
>
- {project.summary_fields.user_capabilities.start ? (
+ {project.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Sync Project`)} position="top">
- <ProjectSyncButton projectId={project.id}>
- {handleSync => (
- <Button
- isDisabled={isDisabled}
- aria-label={i18n._(t`Sync Project`)}
- variant="plain"
- onClick={handleSync}
- >
- <SyncIcon />
- </Button>
- )}
- </ProjectSyncButton>
+ <ProjectSyncButton projectId={project.id} />
</Tooltip>
- ) : (
- ''
)}
{project.summary_fields.user_capabilities.edit ? (
<Tooltip content={i18n._(t`Edit Project`)} position="top">
diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
index fe0c8b1bb2..c5b454246f 100644
--- a/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
+++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.jsx
@@ -284,11 +284,11 @@ function ProjectFormFields({
data={[
{
label: i18n._(t`Use Default Ansible Environment`),
- value: '/venv/ansible/',
+ value: '/var/lib/awx/venv/ansible/',
key: 'default',
},
...custom_virtualenvs
- .filter(datum => datum !== '/venv/ansible/')
+ .filter(datum => datum !== '/var/lib/awx/venv/ansible/')
.map(datum => ({
label: datum,
value: datum,
diff --git a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
index 03defe391a..7e88bc7f10 100644
--- a/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
+++ b/awx/ui_next/src/screens/Project/shared/ProjectForm.test.jsx
@@ -22,7 +22,7 @@ describe('<ProjectForm />', () => {
scm_update_on_launch: true,
scm_update_cache_timeout: 3,
allow_override: false,
- custom_virtualenv: '/venv/custom-env',
+ custom_virtualenv: '/var/lib/awx/venv/custom-env',
summary_fields: {
credential: {
id: 100,
diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx
index b65aecae68..864142b046 100644
--- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx
+++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx
@@ -1,4 +1,8 @@
import React, { useCallback } from 'react';
+import { useRouteMatch } from 'react-router-dom';
+import { Button } from '@patternfly/react-core';
+import { SyncIcon } from '@patternfly/react-icons';
+
import { number } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@@ -8,28 +12,27 @@ import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import { ProjectsAPI } from '../../../api';
-function ProjectSyncButton({ i18n, children, projectId }) {
+function ProjectSyncButton({ i18n, projectId }) {
+ const match = useRouteMatch();
+
const { request: handleSync, error: syncError } = useRequest(
useCallback(async () => {
- const { data } = await ProjectsAPI.readSync(projectId);
- if (data.can_update) {
- await ProjectsAPI.sync(projectId);
- } else {
- throw new Error(
- i18n._(
- t`You don't have the necessary permissions to sync this project.`
- )
- );
- }
- }, [i18n, projectId]),
+ await ProjectsAPI.sync(projectId);
+ }, [projectId]),
null
);
const { error, dismissError } = useDismissableError(syncError);
-
+ const isDetailsView = match.url.endsWith('/details');
return (
<>
- {children(handleSync)}
+ <Button
+ aria-label={i18n._(t`Sync Project`)}
+ variant={isDetailsView ? 'secondary' : 'plain'}
+ onClick={handleSync}
+ >
+ {match.url.endsWith('/details') ? i18n._(t`Sync`) : <SyncIcon />}
+ </Button>
{error && (
<AlertModal
isOpen={error}
diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx
index 4bc302f09b..9f53dfb339 100644
--- a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx
+++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx
@@ -10,11 +10,6 @@ jest.mock('../../../api');
describe('ProjectSyncButton', () => {
let wrapper;
- ProjectsAPI.readSync.mockResolvedValue({
- data: {
- can_update: true,
- },
- });
const children = handleSync => (
<button type="submit" onClick={() => handleSync()} />
@@ -43,8 +38,7 @@ describe('ProjectSyncButton', () => {
await act(async () => {
button.prop('onClick')();
});
- expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
- await sleep(0);
+
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
});
test('displays error modal after unsuccessful sync', async () => {
diff --git a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx
index d5e6afe101..55bcb74db0 100644
--- a/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/ActivityStream/ActivityStreamDetail/ActivityStreamDetail.jsx
@@ -86,6 +86,7 @@ function ActivityStreamDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/activity_stream/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx
index 889ac19163..aa3528f3a3 100644
--- a/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/AzureAD/AzureADDetail/AzureADDetail.jsx
@@ -78,6 +78,7 @@ function AzureADDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/azure/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
index c134adca9c..03d92e5899 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.jsx
@@ -6,6 +6,8 @@ import { PageSection, Card } from '@patternfly/react-core';
import ContentError from '../../../components/ContentError';
import GitHubDetail from './GitHubDetail';
import GitHubEdit from './GitHubEdit';
+import GitHubOrgEdit from './GitHubOrgEdit';
+import GitHubTeamEdit from './GitHubTeamEdit';
function GitHub({ i18n }) {
const baseURL = '/settings/github';
@@ -29,9 +31,15 @@ function GitHub({ i18n }) {
<Route path={`${baseURL}/:category/details`}>
<GitHubDetail />
</Route>
- <Route path={`${baseURL}/:category/edit`}>
+ <Route path={`${baseURL}/default/edit`}>
<GitHubEdit />
</Route>
+ <Route path={`${baseURL}/organization/edit`}>
+ <GitHubOrgEdit />
+ </Route>
+ <Route path={`${baseURL}/team/edit`}>
+ <GitHubTeamEdit />
+ </Route>
<Route key="not-found" path={`${baseURL}/*`}>
<ContentError isNotFound>
<Link to={`${baseURL}/default/details`}>
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
index 37a1c62a67..68572d6c35 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHub.test.jsx
@@ -5,33 +5,94 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
-import GitHub from './GitHub';
import { SettingsAPI } from '../../../api';
+import { SettingsProvider } from '../../../contexts/Settings';
+import mockAllOptions from '../shared/data.allSettingOptions.json';
+import GitHub from './GitHub';
jest.mock('../../../api/models/Settings');
-SettingsAPI.readCategory.mockResolvedValue({
- data: {},
-});
describe('<GitHub />', () => {
let wrapper;
+ beforeEach(() => {
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github/',
+ SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
+ SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
+ },
+ });
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-org/',
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
+ },
+ });
+ SettingsAPI.readCategory.mockResolvedValueOnce({
+ data: {
+ SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-team/',
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
+ },
+ });
+ });
+
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
- test('should render github details', async () => {
+ test('should render github default details', async () => {
const history = createMemoryHistory({
initialEntries: ['/settings/github/'],
});
await act(async () => {
- wrapper = mountWithContexts(<GitHub />, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHub />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('GitHubDetail').length).toBe(1);
+ expect(wrapper.find('Detail[label="GitHub OAuth2 Key"]').length).toBe(1);
+ });
+
+ test('should redirect to github organization category details', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/settings/github/organization'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHub />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubDetail').length).toBe(1);
+ expect(
+ wrapper.find('Detail[label="GitHub Organization OAuth2 Key"]').length
+ ).toBe(1);
});
test('should render github edit', async () => {
@@ -39,9 +100,14 @@ describe('<GitHub />', () => {
initialEntries: ['/settings/github/default/edit'],
});
await act(async () => {
- wrapper = mountWithContexts(<GitHub />, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHub />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('GitHubEdit').length).toBe(1);
@@ -52,9 +118,14 @@ describe('<GitHub />', () => {
initialEntries: ['/settings/github/foo/bar'],
});
await act(async () => {
- wrapper = mountWithContexts(<GitHub />, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHub />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
});
expect(wrapper.find('ContentError').length).toBe(1);
});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx
index 1fd6da3f0d..5dc76348fb 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubDetail/GitHubDetail.jsx
@@ -114,6 +114,7 @@ function GitHubDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to={`${baseURL}/${category}/edit`}
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx
index 07a6f45015..f1b562043d 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.jsx
@@ -1,25 +1,141 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { Button } from '@patternfly/react-core';
-import { CardBody, CardActionsRow } from '../../../../components/Card';
-
-function GitHubEdit({ i18n }) {
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: formatJson(form.SOCIAL_AUTH_GITHUB_TEAM_MAP),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
return (
<CardBody>
- {i18n._(t`Edit form coming soon :)`)}
- <CardActionsRow>
- <Button
- aria-label={i18n._(t`Cancel`)}
- component={Link}
- to="/settings/github/details"
- >
- {i18n._(t`Cancel`)}
- </Button>
- </CardActionsRow>
+ {isLoading && <ContentLoading />}
+ {!isLoading && error && <ContentError error={error} />}
+ {!isLoading && github && (
+ <Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
+ {formik => (
+ <Form autoComplete="off" onSubmit={formik.handleSubmit}>
+ <FormColumnLayout>
+ <InputField
+ name="SOCIAL_AUTH_GITHUB_KEY"
+ config={github.SOCIAL_AUTH_GITHUB_KEY}
+ />
+ <EncryptedField
+ name="SOCIAL_AUTH_GITHUB_SECRET"
+ config={github.SOCIAL_AUTH_GITHUB_SECRET}
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP"
+ config={github.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP}
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GITHUB_TEAM_MAP"
+ config={github.SOCIAL_AUTH_GITHUB_TEAM_MAP}
+ />
+ {submitError && <FormSubmitError error={submitError} />}
+ </FormColumnLayout>
+ <RevertFormActionGroup
+ onCancel={handleCancel}
+ onSubmit={formik.handleSubmit}
+ onRevert={toggleModal}
+ />
+ {isModalOpen && (
+ <RevertAllAlert
+ onClose={closeModal}
+ onRevertAll={handleRevertAll}
+ />
+ )}
+ </Form>
+ )}
+ </Formik>
+ )}
</CardBody>
);
}
-export default withI18n()(GitHubEdit);
+export default GitHubEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx
index 539932c99a..f864f1f6ca 100644
--- a/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubEdit/GitHubEdit.test.jsx
@@ -1,16 +1,173 @@
import React from 'react';
-import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
import GitHubEdit from './GitHubEdit';
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://foo/complete/github/',
+ SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
+ SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
+ Default: {
+ users: true,
+ },
+ },
+ },
+});
+
describe('<GitHubEdit />', () => {
let wrapper;
- beforeEach(() => {
- wrapper = mountWithContexts(<GitHubEdit />);
- });
+ let history;
+
afterEach(() => {
wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHubEdit />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+
test('initially renders without crashing', () => {
expect(wrapper.find('GitHubEdit').length).toBe(1);
});
+
+ test('should display expected form fields', async () => {
+ expect(wrapper.find('FormGroup[label="GitHub OAuth2 Key"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="GitHub OAuth2 Secret"]').length).toBe(
+ 1
+ );
+ expect(
+ wrapper.find('FormGroup[label="GitHub OAuth2 Organization Map"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub OAuth2 Team Map"]').length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_KEY: '',
+ SOCIAL_AUTH_GITHUB_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#SOCIAL_AUTH_GITHUB_KEY').simulate('change', {
+ target: { value: 'new key', name: 'SOCIAL_AUTH_GITHUB_KEY' },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_KEY: 'new key',
+ SOCIAL_AUTH_GITHUB_SECRET: '',
+ SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github default detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/details');
+ });
+
+ test('should navigate to github default detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/details');
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHubEdit />
+ </SettingsProvider>
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx
new file mode 100644
index 0000000000..6224acb5b7
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.jsx
@@ -0,0 +1,147 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubOrgEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github-org');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/organization/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/organization/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
+ return (
+ <CardBody>
+ {isLoading && <ContentLoading />}
+ {!isLoading && error && <ContentError error={error} />}
+ {!isLoading && github && (
+ <Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
+ {formik => (
+ <Form autoComplete="off" onSubmit={formik.handleSubmit}>
+ <FormColumnLayout>
+ <InputField
+ name="SOCIAL_AUTH_GITHUB_ORG_KEY"
+ config={github.SOCIAL_AUTH_GITHUB_ORG_KEY}
+ />
+ <EncryptedField
+ name="SOCIAL_AUTH_GITHUB_ORG_SECRET"
+ config={github.SOCIAL_AUTH_GITHUB_ORG_SECRET}
+ />
+ <InputField
+ name="SOCIAL_AUTH_GITHUB_ORG_NAME"
+ config={github.SOCIAL_AUTH_GITHUB_ORG_NAME}
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP"
+ config={github.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP}
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP"
+ config={github.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP}
+ />
+ {submitError && <FormSubmitError error={submitError} />}
+ </FormColumnLayout>
+ <RevertFormActionGroup
+ onCancel={handleCancel}
+ onSubmit={formik.handleSubmit}
+ onRevert={toggleModal}
+ />
+ {isModalOpen && (
+ <RevertAllAlert
+ onClose={closeModal}
+ onRevertAll={handleRevertAll}
+ />
+ )}
+ </Form>
+ )}
+ </Formik>
+ )}
+ </CardBody>
+ );
+}
+
+export default GitHubOrgEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx
new file mode 100644
index 0000000000..57396c43d1
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/GitHubOrgEdit.test.jsx
@@ -0,0 +1,186 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import GitHubOrgEdit from './GitHubOrgEdit';
+
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-org/',
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
+ },
+});
+
+describe('<GitHubOrgEdit />', () => {
+ let wrapper;
+ let history;
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/organization/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHubOrgEdit />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders without crashing', () => {
+ expect(wrapper.find('GitHubOrgEdit').length).toBe(1);
+ });
+
+ test('should display expected form fields', async () => {
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization OAuth2 Key"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization OAuth2 Secret"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization Name"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find(
+ 'FormGroup[label="GitHub Organization OAuth2 Organization Map"]'
+ ).length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Organization OAuth2 Team Map"]')
+ .length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: '',
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ORG_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#SOCIAL_AUTH_GITHUB_ORG_NAME').simulate('change', {
+ target: { value: 'new org', name: 'SOCIAL_AUTH_GITHUB_ORG_NAME' },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_ORG_KEY: '',
+ SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
+ SOCIAL_AUTH_GITHUB_ORG_NAME: 'new org',
+ SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github organization detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/organization/details'
+ );
+ });
+
+ test('should navigate to github organization detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/github/organization/details'
+ );
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHubOrgEdit />
+ </SettingsProvider>
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js
new file mode 100644
index 0000000000..1652804b44
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubOrgEdit/index.js
@@ -0,0 +1 @@
+export { default } from './GitHubOrgEdit';
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx
new file mode 100644
index 0000000000..f898539283
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.jsx
@@ -0,0 +1,147 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GitHubTeamEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const { isLoading, error, request: fetchGithub, result: github } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('github-team');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGithub();
+ }, [fetchGithub]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/github/team/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(github).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/github/team/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
+ return (
+ <CardBody>
+ {isLoading && <ContentLoading />}
+ {!isLoading && error && <ContentError error={error} />}
+ {!isLoading && github && (
+ <Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
+ {formik => (
+ <Form autoComplete="off" onSubmit={formik.handleSubmit}>
+ <FormColumnLayout>
+ <InputField
+ name="SOCIAL_AUTH_GITHUB_TEAM_KEY"
+ config={github.SOCIAL_AUTH_GITHUB_TEAM_KEY}
+ />
+ <EncryptedField
+ name="SOCIAL_AUTH_GITHUB_TEAM_SECRET"
+ config={github.SOCIAL_AUTH_GITHUB_TEAM_SECRET}
+ />
+ <InputField
+ name="SOCIAL_AUTH_GITHUB_TEAM_ID"
+ config={github.SOCIAL_AUTH_GITHUB_TEAM_ID}
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP"
+ config={github.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP}
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP"
+ config={github.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP}
+ />
+ {submitError && <FormSubmitError error={submitError} />}
+ </FormColumnLayout>
+ <RevertFormActionGroup
+ onCancel={handleCancel}
+ onSubmit={formik.handleSubmit}
+ onRevert={toggleModal}
+ />
+ {isModalOpen && (
+ <RevertAllAlert
+ onClose={closeModal}
+ onRevertAll={handleRevertAll}
+ />
+ )}
+ </Form>
+ )}
+ </Formik>
+ )}
+ </CardBody>
+ );
+}
+
+export default GitHubTeamEdit;
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx
new file mode 100644
index 0000000000..bbc36f8948
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/GitHubTeamEdit.test.jsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import GitHubTeamEdit from './GitHubTeamEdit';
+
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
+ 'https://towerhost/sso/complete/github-team/',
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
+ },
+});
+
+describe('<GitHubTeamEdit />', () => {
+ let wrapper;
+ let history;
+
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/github/team/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHubTeamEdit />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ });
+
+ test('initially renders without crashing', () => {
+ expect(wrapper.find('GitHubTeamEdit').length).toBe(1);
+ });
+
+ test('should display expected form fields', async () => {
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Key"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Secret"]').length
+ ).toBe(1);
+ expect(wrapper.find('FormGroup[label="GitHub Team ID"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Organization Map"]')
+ .length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="GitHub Team OAuth2 Team Map"]').length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: '',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: '',
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_TEAM_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#SOCIAL_AUTH_GITHUB_TEAM_ID').simulate('change', {
+ target: { value: '12345', name: 'SOCIAL_AUTH_GITHUB_TEAM_ID' },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\ntrue\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
+ SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
+ SOCIAL_AUTH_GITHUB_TEAM_ID: '12345',
+ SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
+ SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {
+ Default: {
+ users: true,
+ },
+ },
+ });
+ });
+
+ test('should navigate to github team detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/team/details');
+ });
+
+ test('should navigate to github team detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/settings/github/team/details');
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GitHubTeamEdit />
+ </SettingsProvider>
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js
new file mode 100644
index 0000000000..ba00e5355b
--- /dev/null
+++ b/awx/ui_next/src/screens/Setting/GitHub/GitHubTeamEdit/index.js
@@ -0,0 +1 @@
+export { default } from './GitHubTeamEdit';
diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx
index 7b7b330aec..4455605098 100644
--- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2.test.jsx
@@ -2,13 +2,28 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
-import GoogleOAuth2 from './GoogleOAuth2';
-
+import { SettingsProvider } from '../../../contexts/Settings';
import { SettingsAPI } from '../../../api';
+import mockAllOptions from '../shared/data.allSettingOptions.json';
+import GoogleOAuth2 from './GoogleOAuth2';
jest.mock('../../../api/models/Settings');
SettingsAPI.readCategory.mockResolvedValue({
- data: {},
+ data: {
+ SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL:
+ 'https://towerhost/sso/complete/google-oauth2/',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [
+ 'example.com',
+ 'example_2.com',
+ ],
+ SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
+ SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
+ Default: {},
+ },
+ SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
+ },
});
describe('<GoogleOAuth2 />', () => {
@@ -24,9 +39,14 @@ describe('<GoogleOAuth2 />', () => {
initialEntries: ['/settings/google_oauth2/details'],
});
await act(async () => {
- wrapper = mountWithContexts(<GoogleOAuth2 />, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GoogleOAuth2 />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
});
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
});
@@ -36,9 +56,14 @@ describe('<GoogleOAuth2 />', () => {
initialEntries: ['/settings/google_oauth2/edit'],
});
await act(async () => {
- wrapper = mountWithContexts(<GoogleOAuth2 />, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GoogleOAuth2 />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
});
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
});
diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx
index f19f87378f..98a241f801 100644
--- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx
+++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Detail/GoogleOAuth2Detail.jsx
@@ -78,6 +78,7 @@ function GoogleOAuth2Detail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/google_oauth2/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx
index 50a546334d..ebc6f3d662 100644
--- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx
+++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.jsx
@@ -1,25 +1,171 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { Button } from '@patternfly/react-core';
-import { CardBody, CardActionsRow } from '../../../../components/Card';
-
-function GoogleOAuth2Edit({ i18n }) {
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import { FormColumnLayout } from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function GoogleOAuth2Edit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+
+ const {
+ isLoading,
+ error,
+ request: fetchGoogleOAuth2,
+ result: googleOAuth2,
+ } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('google-oauth2');
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+ return mergedData;
+ }, [options]),
+ null
+ );
+
+ useEffect(() => {
+ fetchGoogleOAuth2();
+ }, [fetchGoogleOAuth2]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push('/settings/google_oauth2/details');
+ },
+ [history]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ ...form,
+ SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: formatJson(
+ form.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
+ ),
+ SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: formatJson(
+ form.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
+ ),
+ SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: formatJson(
+ form.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
+ ),
+ SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: formatJson(
+ form.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(googleOAuth2).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push('/settings/google_oauth2/details');
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
return (
<CardBody>
- {i18n._(t`Edit form coming soon :)`)}
- <CardActionsRow>
- <Button
- aria-label={i18n._(t`Cancel`)}
- component={Link}
- to="/settings/google_oauth2/details"
+ {isLoading && <ContentLoading />}
+ {!isLoading && error && <ContentError error={error} />}
+ {!isLoading && googleOAuth2 && (
+ <Formik
+ initialValues={initialValues(googleOAuth2)}
+ onSubmit={handleSubmit}
>
- {i18n._(t`Cancel`)}
- </Button>
- </CardActionsRow>
+ {formik => (
+ <Form autoComplete="off" onSubmit={formik.handleSubmit}>
+ <FormColumnLayout>
+ <InputField
+ name="SOCIAL_AUTH_GOOGLE_OAUTH2_KEY"
+ config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY}
+ />
+ <EncryptedField
+ name="SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"
+ config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET}
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS"
+ config={
+ googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
+ }
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS"
+ config={
+ googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
+ }
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP"
+ config={
+ googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
+ }
+ />
+ <ObjectField
+ name="SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP"
+ config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP}
+ />
+ {submitError && <FormSubmitError error={submitError} />}
+ </FormColumnLayout>
+ <RevertFormActionGroup
+ onCancel={handleCancel}
+ onSubmit={formik.handleSubmit}
+ onRevert={toggleModal}
+ />
+ {isModalOpen && (
+ <RevertAllAlert
+ onClose={closeModal}
+ onRevertAll={handleRevertAll}
+ />
+ )}
+ </Form>
+ )}
+ </Formik>
+ )}
</CardBody>
);
}
-export default withI18n()(GoogleOAuth2Edit);
+export default GoogleOAuth2Edit;
diff --git a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx
index 034a0def4e..68a292232c 100644
--- a/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx
+++ b/awx/ui_next/src/screens/Setting/GoogleOAuth2/GoogleOAuth2Edit/GoogleOAuth2Edit.test.jsx
@@ -1,16 +1,196 @@
import React from 'react';
-import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
import GoogleOAuth2Edit from './GoogleOAuth2Edit';
+jest.mock('../../../../api/models/Settings');
+SettingsAPI.updateAll.mockResolvedValue({});
+SettingsAPI.readCategory.mockResolvedValue({
+ data: {
+ SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL:
+ 'https://towerhost/sso/complete/google-oauth2/',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [
+ 'example.com',
+ 'example_2.com',
+ ],
+ SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
+ SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
+ Default: {},
+ },
+ SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
+ },
+});
+
describe('<GoogleOAuth2Edit />', () => {
let wrapper;
- beforeEach(() => {
- wrapper = mountWithContexts(<GoogleOAuth2Edit />);
- });
+ let history;
+
afterEach(() => {
wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/google_oauth2/edit'],
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GoogleOAuth2Edit />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+
test('initially renders without crashing', () => {
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
});
+
+ test('should display expected form fields', async () => {
+ expect(wrapper.find('FormGroup[label="Google OAuth2 Key"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="Google OAuth2 Secret"]').length).toBe(
+ 1
+ );
+ expect(
+ wrapper.find('FormGroup[label="Google OAuth2 Allowed Domains"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="Google OAuth2 Extra Arguments"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="Google OAuth2 Organization Map"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="Google OAuth2 Team Map"]').length
+ ).toBe(1);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: '',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [],
+ SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
+ SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: null,
+ SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: null,
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper
+ .find(
+ 'FormGroup[fieldId="SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#SOCIAL_AUTH_GOOGLE_OAUTH2_KEY').simulate('change', {
+ target: { value: 'new key', name: 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY' },
+ });
+ wrapper
+ .find('CodeMirrorInput#SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP')
+ .invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'new key',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '',
+ SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [],
+ SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
+ SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
+ SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
+ Default: {
+ users: false,
+ },
+ },
+ });
+ });
+
+ test('should navigate to Google OAuth 2.0 detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/google_oauth2/details'
+ );
+ });
+
+ test('should navigate to Google OAuth 2.0 detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual(
+ '/settings/google_oauth2/details'
+ );
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <GoogleOAuth2Edit />
+ </SettingsProvider>
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
});
diff --git a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx
index c25c221518..a29a633a2d 100644
--- a/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/Jobs/JobsDetail/JobsDetail.jsx
@@ -95,6 +95,7 @@ function JobsDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/jobs/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx
index bcc6c60be0..88ca337048 100644
--- a/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx
+++ b/awx/ui_next/src/screens/Setting/LDAP/LDAP.test.jsx
@@ -6,12 +6,13 @@ import {
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SettingsAPI } from '../../../api';
+import { SettingsProvider } from '../../../contexts/Settings';
+import mockAllOptions from '../shared/data.allSettingOptions.json';
+import mockLDAP from '../shared/data.ldapSettings.json';
import LDAP from './LDAP';
jest.mock('../../../api/models/Settings');
-SettingsAPI.readCategory.mockResolvedValue({
- data: {},
-});
+SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP });
describe('<LDAP />', () => {
let wrapper;
@@ -39,9 +40,14 @@ describe('<LDAP />', () => {
initialEntries: ['/settings/ldap/default/edit'],
});
await act(async () => {
- wrapper = mountWithContexts(<LDAP />, {
- context: { router: { history } },
- });
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <LDAP />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('LDAPEdit').length).toBe(1);
diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx
index 120b00fa44..74850dfbe4 100644
--- a/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPDetail/LDAPDetail.jsx
@@ -159,6 +159,7 @@ function LDAPDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to={`${baseURL}/${category}/edit`}
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx
index 084df200ed..af1764299a 100644
--- a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx
+++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.jsx
@@ -1,25 +1,250 @@
-import React from 'react';
-import { Link } from 'react-router-dom';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
-import { Button } from '@patternfly/react-core';
-import { CardBody, CardActionsRow } from '../../../../components/Card';
-
-function LDAPEdit({ i18n }) {
+import React, { useCallback, useEffect } from 'react';
+import { useHistory, useRouteMatch } from 'react-router-dom';
+import { Formik } from 'formik';
+import { Form } from '@patternfly/react-core';
+import { CardBody } from '../../../../components/Card';
+import ContentError from '../../../../components/ContentError';
+import ContentLoading from '../../../../components/ContentLoading';
+import { FormSubmitError } from '../../../../components/FormField';
+import {
+ FormColumnLayout,
+ FormFullWidthLayout,
+} from '../../../../components/FormLayout';
+import { useSettings } from '../../../../contexts/Settings';
+import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
+import {
+ BooleanField,
+ ChoiceField,
+ EncryptedField,
+ InputField,
+ ObjectField,
+} from '../../shared/SharedFields';
+import { formatJson } from '../../shared/settingUtils';
+import useModal from '../../../../util/useModal';
+import useRequest from '../../../../util/useRequest';
+import { SettingsAPI } from '../../../../api';
+
+function filterByPrefix(data, prefix) {
+ return Object.keys(data)
+ .filter(key => key.includes(prefix))
+ .reduce((obj, key) => {
+ obj[key] = data[key];
+ return obj;
+ }, {});
+}
+
+function LDAPEdit() {
+ const history = useHistory();
+ const { isModalOpen, toggleModal, closeModal } = useModal();
+ const { PUT: options } = useSettings();
+ const {
+ params: { category },
+ } = useRouteMatch('/settings/ldap/:category/edit');
+ const ldapCategory =
+ category === 'default' ? 'AUTH_LDAP_' : `AUTH_LDAP_${category}_`;
+
+ const { isLoading, error, request: fetchLDAP, result: ldap } = useRequest(
+ useCallback(async () => {
+ const { data } = await SettingsAPI.readCategory('ldap');
+
+ const mergedData = {};
+ Object.keys(data).forEach(key => {
+ if (!options[key]) {
+ return;
+ }
+ mergedData[key] = options[key];
+ mergedData[key].value = data[key];
+ });
+
+ const allCategories = {
+ AUTH_LDAP_1_: filterByPrefix(mergedData, 'AUTH_LDAP_1_'),
+ AUTH_LDAP_2_: filterByPrefix(mergedData, 'AUTH_LDAP_2_'),
+ AUTH_LDAP_3_: filterByPrefix(mergedData, 'AUTH_LDAP_3_'),
+ AUTH_LDAP_4_: filterByPrefix(mergedData, 'AUTH_LDAP_4_'),
+ AUTH_LDAP_5_: filterByPrefix(mergedData, 'AUTH_LDAP_5_'),
+ AUTH_LDAP_: Object.assign({}, mergedData),
+ };
+ Object.keys({
+ ...allCategories.AUTH_LDAP_1_,
+ ...allCategories.AUTH_LDAP_2_,
+ ...allCategories.AUTH_LDAP_3_,
+ ...allCategories.AUTH_LDAP_4_,
+ ...allCategories.AUTH_LDAP_5_,
+ }).forEach(keyToOmit => {
+ delete allCategories.AUTH_LDAP_[keyToOmit];
+ });
+
+ return allCategories[ldapCategory];
+ }, [options, ldapCategory]),
+ null
+ );
+
+ useEffect(() => {
+ fetchLDAP();
+ }, [fetchLDAP]);
+
+ const { error: submitError, request: submitForm } = useRequest(
+ useCallback(
+ async values => {
+ await SettingsAPI.updateAll(values);
+ history.push(`/settings/ldap/${category}/details`);
+ },
+ [history, category]
+ ),
+ null
+ );
+
+ const handleSubmit = async form => {
+ await submitForm({
+ [`${ldapCategory}BIND_DN`]: form[`${ldapCategory}BIND_DN`],
+ [`${ldapCategory}BIND_PASSWORD`]: form[`${ldapCategory}BIND_PASSWORD`],
+ [`${ldapCategory}DENY_GROUP`]: form[`${ldapCategory}DENY_GROUP`],
+ [`${ldapCategory}GROUP_TYPE`]: form[`${ldapCategory}GROUP_TYPE`],
+ [`${ldapCategory}REQUIRE_GROUP`]: form[`${ldapCategory}REQUIRE_GROUP`],
+ [`${ldapCategory}SERVER_URI`]: form[`${ldapCategory}SERVER_URI`],
+ [`${ldapCategory}START_TLS`]: form[`${ldapCategory}START_TLS`],
+ [`${ldapCategory}USER_DN_TEMPLATE`]: form[
+ `${ldapCategory}USER_DN_TEMPLATE`
+ ],
+ [`${ldapCategory}GROUP_SEARCH`]: formatJson(
+ form[`${ldapCategory}GROUP_SEARCH`]
+ ),
+ [`${ldapCategory}GROUP_TYPE_PARAMS`]: formatJson(
+ form[`${ldapCategory}GROUP_TYPE_PARAMS`]
+ ),
+ [`${ldapCategory}ORGANIZATION_MAP`]: formatJson(
+ form[`${ldapCategory}ORGANIZATION_MAP`]
+ ),
+ [`${ldapCategory}TEAM_MAP`]: formatJson(form[`${ldapCategory}TEAM_MAP`]),
+ [`${ldapCategory}USER_ATTR_MAP`]: formatJson(
+ form[`${ldapCategory}USER_ATTR_MAP`]
+ ),
+ [`${ldapCategory}USER_FLAGS_BY_GROUP`]: formatJson(
+ form[`${ldapCategory}USER_FLAGS_BY_GROUP`]
+ ),
+ [`${ldapCategory}USER_SEARCH`]: formatJson(
+ form[`${ldapCategory}USER_SEARCH`]
+ ),
+ });
+ };
+
+ const handleRevertAll = async () => {
+ const defaultValues = Object.assign(
+ ...Object.entries(ldap).map(([key, value]) => ({
+ [key]: value.default,
+ }))
+ );
+ await submitForm(defaultValues);
+ closeModal();
+ };
+
+ const handleCancel = () => {
+ history.push(`/settings/ldap/${category}/details`);
+ };
+
+ const initialValues = fields =>
+ Object.keys(fields).reduce((acc, key) => {
+ if (fields[key].type === 'list' || fields[key].type === 'nested object') {
+ const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
+ acc[key] = fields[key].value
+ ? JSON.stringify(fields[key].value, null, 2)
+ : emptyDefault;
+ } else {
+ acc[key] = fields[key].value ?? '';
+ }
+ return acc;
+ }, {});
+
return (
<CardBody>
- {i18n._(t`Edit form coming soon :)`)}
- <CardActionsRow>
- <Button
- aria-label={i18n._(t`Cancel`)}
- component={Link}
- to="/settings/ldap/details"
- >
- {i18n._(t`Cancel`)}
- </Button>
- </CardActionsRow>
+ {isLoading && <ContentLoading />}
+ {!isLoading && error && <ContentError error={error} />}
+ {!isLoading && ldap && (
+ <Formik initialValues={initialValues(ldap)} onSubmit={handleSubmit}>
+ {formik => (
+ <Form autoComplete="off" onSubmit={formik.handleSubmit}>
+ <FormColumnLayout>
+ <InputField
+ name={`${ldapCategory}SERVER_URI`}
+ config={ldap[`${ldapCategory}SERVER_URI`]}
+ />
+ <EncryptedField
+ name={`${ldapCategory}BIND_PASSWORD`}
+ config={ldap[`${ldapCategory}BIND_PASSWORD`]}
+ />
+ <ChoiceField
+ name={`${ldapCategory}GROUP_TYPE`}
+ config={ldap[`${ldapCategory}GROUP_TYPE`]}
+ />
+ <BooleanField
+ name={`${ldapCategory}START_TLS`}
+ config={ldap[`${ldapCategory}START_TLS`]}
+ />
+ <FormFullWidthLayout>
+ <InputField
+ name={`${ldapCategory}BIND_DN`}
+ config={ldap[`${ldapCategory}BIND_DN`]}
+ />
+ <InputField
+ name={`${ldapCategory}USER_DN_TEMPLATE`}
+ config={ldap[`${ldapCategory}USER_DN_TEMPLATE`]}
+ />
+ <InputField
+ name={`${ldapCategory}REQUIRE_GROUP`}
+ config={ldap[`${ldapCategory}REQUIRE_GROUP`]}
+ />
+ <InputField
+ name={`${ldapCategory}DENY_GROUP`}
+ config={ldap[`${ldapCategory}DENY_GROUP`]}
+ />
+ </FormFullWidthLayout>
+ <ObjectField
+ name={`${ldapCategory}USER_SEARCH`}
+ config={ldap[`${ldapCategory}USER_SEARCH`]}
+ />
+ <ObjectField
+ name={`${ldapCategory}GROUP_SEARCH`}
+ config={ldap[`${ldapCategory}GROUP_SEARCH`]}
+ />
+ <ObjectField
+ name={`${ldapCategory}USER_ATTR_MAP`}
+ config={ldap[`${ldapCategory}USER_ATTR_MAP`]}
+ />
+ <ObjectField
+ name={`${ldapCategory}GROUP_TYPE_PARAMS`}
+ config={ldap[`${ldapCategory}GROUP_TYPE_PARAMS`]}
+ />
+ <ObjectField
+ name={`${ldapCategory}USER_FLAGS_BY_GROUP`}
+ config={ldap[`${ldapCategory}USER_FLAGS_BY_GROUP`]}
+ />
+ <ObjectField
+ name={`${ldapCategory}ORGANIZATION_MAP`}
+ config={ldap[`${ldapCategory}ORGANIZATION_MAP`]}
+ />
+ <ObjectField
+ name={`${ldapCategory}TEAM_MAP`}
+ config={ldap[`${ldapCategory}TEAM_MAP`]}
+ />
+ {submitError && <FormSubmitError error={submitError} />}
+ </FormColumnLayout>
+ <RevertFormActionGroup
+ onCancel={handleCancel}
+ onSubmit={formik.handleSubmit}
+ onRevert={toggleModal}
+ />
+ {isModalOpen && (
+ <RevertAllAlert
+ onClose={closeModal}
+ onRevertAll={handleRevertAll}
+ />
+ )}
+ </Form>
+ )}
+ </Formik>
+ )}
</CardBody>
);
}
-export default withI18n()(LDAPEdit);
+export default LDAPEdit;
diff --git a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx
index 12ac75a6ed..71f998e341 100644
--- a/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx
+++ b/awx/ui_next/src/screens/Setting/LDAP/LDAPEdit/LDAPEdit.test.jsx
@@ -1,16 +1,265 @@
import React from 'react';
-import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import { useRouteMatch } from 'react-router-dom';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../testUtils/enzymeHelpers';
+import mockAllOptions from '../../shared/data.allSettingOptions.json';
+import mockLDAP from '../../shared/data.ldapSettings.json';
+import { SettingsProvider } from '../../../../contexts/Settings';
+import { SettingsAPI } from '../../../../api';
import LDAPEdit from './LDAPEdit';
+jest.mock('../../../../api/models/Settings');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useRouteMatch: jest.fn(),
+}));
+SettingsAPI.readCategory.mockResolvedValue({ data: mockLDAP });
+
describe('<LDAPEdit />', () => {
let wrapper;
- beforeEach(() => {
- wrapper = mountWithContexts(<LDAPEdit />);
+ let history;
+
+ beforeEach(async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/ldap/default/edit'],
+ });
+ useRouteMatch.mockImplementation(() => ({
+ url: '/settings/ldap/default/edit',
+ path: '/settings/ldap/:category/edit',
+ params: { category: 'default' },
+ }));
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <LDAPEdit />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
+
afterEach(() => {
wrapper.unmount();
+ jest.clearAllMocks();
});
+
test('initially renders without crashing', () => {
expect(wrapper.find('LDAPEdit').length).toBe(1);
});
+
+ test('should display expected form fields', async () => {
+ expect(wrapper.find('FormGroup[label="LDAP Server URI"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="LDAP Bind DN"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="LDAP Bind Password"]').length).toBe(
+ 1
+ );
+ expect(wrapper.find('FormGroup[label="LDAP User Search"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="LDAP User DN Template"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="LDAP User Attribute Map"]').length
+ ).toBe(1);
+ expect(wrapper.find('FormGroup[label="LDAP Group Search"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="LDAP Group Type"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="LDAP Group Type Parameters"]').length
+ ).toBe(1);
+ expect(wrapper.find('FormGroup[label="LDAP Require Group"]').length).toBe(
+ 1
+ );
+ expect(wrapper.find('FormGroup[label="LDAP Deny Group"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="LDAP Start TLS"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="LDAP User Flags By Group"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[label="LDAP Organization Map"]').length
+ ).toBe(1);
+ expect(wrapper.find('FormGroup[label="LDAP Team Map"]').length).toBe(1);
+ expect(
+ wrapper.find('FormGroup[fieldId="AUTH_LDAP_SERVER_URI"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"]').length
+ ).toBe(0);
+ });
+
+ test('should successfully send default values to api on form revert all', async () => {
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
+ await act(async () => {
+ wrapper
+ .find('button[aria-label="Revert all to default"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
+ await act(async () => {
+ wrapper
+ .find('RevertAllAlert button[aria-label="Confirm revert all"]')
+ .invoke('onClick')();
+ });
+ wrapper.update();
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ AUTH_LDAP_BIND_DN: '',
+ AUTH_LDAP_BIND_PASSWORD: '',
+ AUTH_LDAP_CONNECTION_OPTIONS: {
+ OPT_NETWORK_TIMEOUT: 30,
+ OPT_REFERRALS: 0,
+ },
+ AUTH_LDAP_DENY_GROUP: null,
+ AUTH_LDAP_GROUP_SEARCH: [],
+ AUTH_LDAP_GROUP_TYPE: 'MemberDNGroupType',
+ AUTH_LDAP_GROUP_TYPE_PARAMS: {
+ member_attr: 'member',
+ name_attr: 'cn',
+ },
+ AUTH_LDAP_ORGANIZATION_MAP: {},
+ AUTH_LDAP_REQUIRE_GROUP: null,
+ AUTH_LDAP_SERVER_URI: '',
+ AUTH_LDAP_START_TLS: false,
+ AUTH_LDAP_TEAM_MAP: {},
+ AUTH_LDAP_USER_ATTR_MAP: {},
+ AUTH_LDAP_USER_DN_TEMPLATE: null,
+ AUTH_LDAP_USER_FLAGS_BY_GROUP: {},
+ AUTH_LDAP_USER_SEARCH: [],
+ });
+ });
+
+ test('should successfully send request to api on form submission', async () => {
+ act(() => {
+ wrapper
+ .find(
+ 'FormGroup[fieldId="AUTH_LDAP_BIND_PASSWORD"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper
+ .find(
+ 'FormGroup[fieldId="AUTH_LDAP_BIND_DN"] button[aria-label="Revert"]'
+ )
+ .invoke('onClick')();
+ wrapper.find('input#AUTH_LDAP_SERVER_URI').simulate('change', {
+ target: {
+ value: 'ldap://mock.example.com',
+ name: 'AUTH_LDAP_SERVER_URI',
+ },
+ });
+ wrapper.find('CodeMirrorInput#AUTH_LDAP_TEAM_MAP').invoke('onChange')(
+ '{\n"LDAP Sales":{\n"organization":\n"mock org"\n}\n}'
+ );
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
+ AUTH_LDAP_BIND_DN: '',
+ AUTH_LDAP_BIND_PASSWORD: '',
+ AUTH_LDAP_DENY_GROUP: '',
+ AUTH_LDAP_GROUP_SEARCH: [],
+ AUTH_LDAP_GROUP_TYPE: 'MemberDNGroupType',
+ AUTH_LDAP_GROUP_TYPE_PARAMS: { name_attr: 'cn', member_attr: 'member' },
+ AUTH_LDAP_ORGANIZATION_MAP: {},
+ AUTH_LDAP_REQUIRE_GROUP: 'CN=Tower Users,OU=Users,DC=example,DC=com',
+ AUTH_LDAP_SERVER_URI: 'ldap://mock.example.com',
+ AUTH_LDAP_START_TLS: false,
+ AUTH_LDAP_USER_ATTR_MAP: {},
+ AUTH_LDAP_USER_DN_TEMPLATE: 'uid=%(user)s,OU=Users,DC=example,DC=com',
+ AUTH_LDAP_USER_FLAGS_BY_GROUP: {},
+ AUTH_LDAP_USER_SEARCH: [],
+ AUTH_LDAP_TEAM_MAP: {
+ 'LDAP Sales': {
+ organization: 'mock org',
+ },
+ },
+ });
+ });
+
+ test('should navigate to ldap default detail on successful submission', async () => {
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ expect(history.location.pathname).toEqual('/settings/ldap/default/details');
+ });
+
+ test('should navigate to ldap default detail when cancel is clicked', async () => {
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/settings/ldap/default/details');
+ });
+
+ test('should display error message on unsuccessful submission', async () => {
+ const error = {
+ response: {
+ data: { detail: 'An error occurred' },
+ },
+ };
+ SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
+ expect(wrapper.find('FormSubmitError').length).toBe(0);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
+ await act(async () => {
+ wrapper.find('Form').invoke('onSubmit')();
+ });
+ wrapper.update();
+ expect(wrapper.find('FormSubmitError').length).toBe(1);
+ expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display ContentError on throw', async () => {
+ SettingsAPI.readCategory.mockImplementationOnce(() =>
+ Promise.reject(new Error())
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <LDAPEdit />
+ </SettingsProvider>
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('ContentError').length).toBe(1);
+ });
+
+ test('should display ldap category 5 edit form', async () => {
+ history = createMemoryHistory({
+ initialEntries: ['/settings/ldap/5/edit'],
+ });
+ useRouteMatch.mockImplementation(() => ({
+ url: '/settings/ldap/5/edit',
+ path: '/settings/ldap/:category/edit',
+ params: { category: '5' },
+ }));
+ await act(async () => {
+ wrapper = mountWithContexts(
+ <SettingsProvider value={mockAllOptions.actions}>
+ <LDAPEdit />
+ </SettingsProvider>,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(
+ wrapper.find('FormGroup[fieldId="AUTH_LDAP_SERVER_URI"]').length
+ ).toBe(0);
+ expect(
+ wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"]').length
+ ).toBe(1);
+ expect(
+ wrapper.find('FormGroup[fieldId="AUTH_LDAP_5_SERVER_URI"] input').props()
+ .value
+ ).toEqual('ldap://ldap5.example.com');
+ });
});
diff --git a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx
index 233fb40895..05f551c512 100644
--- a/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/License/LicenseDetail/LicenseDetail.jsx
@@ -13,6 +13,7 @@ function LicenseDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/license/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx
index 5ede6a1839..54643f1773 100644
--- a/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/Logging/LoggingDetail/LoggingDetail.jsx
@@ -99,6 +99,7 @@ function LoggingDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/logging/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx
index 074e6ab318..df488c8d3b 100644
--- a/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx
+++ b/awx/ui_next/src/screens/Setting/Logging/LoggingEdit/LoggingEdit.jsx
@@ -246,6 +246,7 @@ function LoggingEdit({ i18n }) {
<div>
<Button
aria-label={i18n._(t`Test logging`)}
+ ouiaId="test-logging-button"
variant="secondary"
type="button"
onClick={handleTest}
diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx
index 146e9d82a7..f92b3e00cf 100644
--- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx
@@ -140,6 +140,7 @@ function MiscSystemDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/miscellaneous_system/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx
index 52b313bbe0..d776f177c7 100644
--- a/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/RADIUS/RADIUSDetail/RADIUSDetail.jsx
@@ -78,6 +78,7 @@ function RADIUSDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/radius/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx
index 0a7fac5806..5d2e54497e 100644
--- a/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/SAML/SAMLDetail/SAMLDetail.jsx
@@ -78,6 +78,7 @@ function SAMLDetail({ i18n }) {
<Button
aria-label={i18n._(t`Edit`)}
component={Link}
+ ouiaId="edit-button"
to="/settings/saml/edit"
>
{i18n._(t`Edit`)}
diff --git a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx
index 6c9f1a85de..094dbf72bd 100644
--- a/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.jsx
@@ -79,6 +79,7 @@ function TACACSDetail({ i18n }) {
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/tacacs/edit"
+ ouiaId="edit-button"
>
{i18n._(t`Edit`)}
</Button>
diff --git a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx
index ef458e5163..e65f176b83 100644
--- a/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx
+++ b/awx/ui_next/src/screens/Setting/UI/UIDetail/UIDetail.jsx
@@ -94,6 +94,7 @@ function UIDetail({ i18n }) {
aria-label={i18n._(t`Edit`)}
component={Link}
to="/settings/ui/edit"
+ ouiaId="edit-button"
>
{i18n._(t`Edit`)}
</Button>
diff --git a/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx
index a1c3903f53..4ec68182fc 100644
--- a/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx
+++ b/awx/ui_next/src/screens/Setting/shared/LoggingTestAlert.jsx
@@ -33,6 +33,7 @@ function LoggingTestAlert({ i18n, successResponse, errorResponse, onClose }) {
{testMessage && (
<Alert
actionClose={<AlertActionCloseButton onClose={onClose} />}
+ ouiaId="logging-test-alert"
title={successResponse ? i18n._(t`Success`) : i18n._(t`Error`)}
variant={successResponse ? 'success' : 'danger'}
>
diff --git a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
index 55b23437b9..46ed00e8d6 100644
--- a/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
+++ b/awx/ui_next/src/screens/Setting/shared/RevertAllAlert.jsx
@@ -11,12 +11,14 @@ function RevertAllAlert({ i18n, onClose, onRevertAll }) {
title={i18n._(t`Revert settings`)}
variant="info"
onClose={onClose}
+ ouiaId="revert-all-modal"
actions={[
<Button
key="revert"
variant="primary"
aria-label={i18n._(t`Confirm revert all`)}
onClick={onRevertAll}
+ ouiaId="confirm-revert-all-button"
>
{i18n._(t`Revert all`)}
</Button>,
@@ -25,6 +27,7 @@ function RevertAllAlert({ i18n, onClose, onRevertAll }) {
variant="secondary"
aria-label={i18n._(t`Cancel revert`)}
onClick={onClose}
+ ouiaId="cancel-revert-all-button"
>
{i18n._(t`Cancel`)}
</Button>,
diff --git a/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx
index 03e80d4eb1..1e707090e2 100644
--- a/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx
+++ b/awx/ui_next/src/screens/Setting/shared/RevertFormActionGroup.jsx
@@ -20,6 +20,7 @@ const RevertFormActionGroup = ({
variant="primary"
type="button"
onClick={onSubmit}
+ ouiaId="save-button"
>
{i18n._(t`Save`)}
</Button>
@@ -28,6 +29,7 @@ const RevertFormActionGroup = ({
variant="secondary"
type="button"
onClick={onRevert}
+ ouiaId="revert-all-button"
>
{i18n._(t`Revert all to default`)}
</Button>
@@ -37,6 +39,7 @@ const RevertFormActionGroup = ({
variant="secondary"
type="button"
onClick={onCancel}
+ ouiaId="cancel-button"
>
{i18n._(t`Cancel`)}
</Button>
diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
index c23a9da17a..13ec5dc11c 100644
--- a/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
+++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.jsx
@@ -17,10 +17,10 @@ import { FormFullWidthLayout } from '../../../components/FormLayout';
import Popover from '../../../components/Popover';
import {
combine,
- required,
- url,
integer,
minMaxValue,
+ required,
+ url,
} from '../../../util/validators';
import RevertButton from './RevertButton';
@@ -51,6 +51,7 @@ const SettingGroup = withI18n()(
isRequired={isRequired}
label={label}
validated={validated}
+ id={fieldId}
labelIcon={
<>
<Popover
@@ -84,6 +85,7 @@ const BooleanField = withI18n()(
>
<Switch
id={name}
+ ouiaId={name}
isChecked={field.value}
isDisabled={disabled}
label={i18n._(t`On`)}
@@ -241,11 +243,13 @@ const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
>
<CodeMirrorInput
{...field}
+ fullHeight
id={name}
+ mode="javascript"
onChange={value => {
helpers.setValue(value);
}}
- mode="javascript"
+ placeholder={JSON.stringify(config?.placeholder, null, 2)}
/>
</SettingGroup>
</FormFullWidthLayout>
diff --git a/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json b/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json
index 161a96b8c5..dce6765491 100644
--- a/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json
+++ b/awx/ui_next/src/screens/Setting/shared/data.ldapSettings.json
@@ -109,7 +109,7 @@
"AUTH_LDAP_4_USER_FLAGS_BY_GROUP": {},
"AUTH_LDAP_4_ORGANIZATION_MAP": {},
"AUTH_LDAP_4_TEAM_MAP": {},
- "AUTH_LDAP_5_SERVER_URI": "",
+ "AUTH_LDAP_5_SERVER_URI": "ldap://ldap5.example.com",
"AUTH_LDAP_5_BIND_DN": "",
"AUTH_LDAP_5_BIND_PASSWORD": "",
"AUTH_LDAP_5_START_TLS": false,
diff --git a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
index e09a4b98f2..562987ba4b 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateAdd/JobTemplateAdd.jsx
@@ -83,6 +83,7 @@ function JobTemplateAdd() {
handleCancel={handleCancel}
handleSubmit={handleSubmit}
submitError={formSubmitError}
+ isOverrideDisabledLookup
/>
</CardBody>
</Card>
diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
index e25032e527..db3e45bc91 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.jsx
@@ -1,4 +1,4 @@
-import React, { Fragment, useState, useEffect, useCallback } from 'react';
+import React, { Fragment, useCallback, useEffect } from 'react';
import { Link, useHistory, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import {
@@ -59,32 +59,31 @@ function JobTemplateDetail({ i18n, template }) {
related: { webhook_receiver },
webhook_key,
} = template;
- const [contentError, setContentError] = useState(null);
- const [hasContentLoading, setHasContentLoading] = useState(false);
- const [instanceGroups, setInstanceGroups] = useState([]);
const { id: templateId } = useParams();
const history = useHistory();
+ const {
+ isLoading: isLoadingInstanceGroups,
+ request: fetchInstanceGroups,
+ error: instanceGroupsError,
+ result: { instanceGroups },
+ } = useRequest(
+ useCallback(async () => {
+ const {
+ data: { results },
+ } = await JobTemplatesAPI.readInstanceGroups(templateId);
+ return { instanceGroups: results };
+ }, [templateId]),
+ { instanceGroups: [] }
+ );
+
useEffect(() => {
- (async () => {
- setContentError(null);
- setHasContentLoading(true);
- try {
- const {
- data: { results = [] },
- } = await JobTemplatesAPI.readInstanceGroups(templateId);
- setInstanceGroups(results);
- } catch (error) {
- setContentError(error);
- } finally {
- setHasContentLoading(false);
- }
- })();
- }, [templateId]);
+ fetchInstanceGroups();
+ }, [fetchInstanceGroups]);
const {
request: deleteJobTemplate,
- isLoading,
+ isLoading: isDeleteLoading,
error: deleteError,
} = useRequest(
useCallback(async () => {
@@ -154,11 +153,11 @@ function JobTemplateDetail({ i18n, template }) {
);
};
- if (contentError) {
- return <ContentError error={contentError} />;
+ if (instanceGroupsError) {
+ return <ContentError error={instanceGroupsError} />;
}
- if (hasContentLoading) {
+ if (isLoadingInstanceGroups || isDeleteLoading) {
return <ContentLoading />;
}
@@ -219,16 +218,6 @@ function JobTemplateDetail({ i18n, template }) {
value={verbosityDetails[0].details}
/>
<Detail label={i18n._(t`Timeout`)} value={timeout || '0'} />
- <UserDateDetail
- label={i18n._(t`Created`)}
- date={created}
- user={summary_fields.created_by}
- />
- <UserDateDetail
- label={i18n._(t`Last Modified`)}
- date={modified}
- user={summary_fields.modified_by}
- />
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
@@ -278,6 +267,16 @@ function JobTemplateDetail({ i18n, template }) {
{renderOptionsField && (
<Detail label={i18n._(t`Options`)} value={renderOptions} />
)}
+ <UserDateDetail
+ label={i18n._(t`Created`)}
+ date={created}
+ user={summary_fields.created_by}
+ />
+ <UserDateDetail
+ label={i18n._(t`Last Modified`)}
+ date={modified}
+ user={summary_fields.modified_by}
+ />
{summary_fields.credentials && summary_fields.credentials.length > 0 && (
<Detail
fullWidth
@@ -389,7 +388,7 @@ function JobTemplateDetail({ i18n, template }) {
name={name}
modalTitle={i18n._(t`Delete Job Template`)}
onConfirm={deleteJobTemplate}
- isDisabled={isLoading}
+ isDisabled={isDeleteLoading}
>
{i18n._(t`Delete`)}
</DeleteButton>
diff --git a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
index 3f147ebaa0..1ed86103ab 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateDetail/JobTemplateDetail.test.jsx
@@ -61,7 +61,6 @@ describe('<JobTemplateDetail />', () => {
});
test('should hide edit button for users without edit permission', async () => {
- JobTemplatesAPI.readInstanceGroups.mockResolvedValue({ data: {} });
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail
diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
index 182c4918f1..213900d40d 100644
--- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
+++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx
@@ -1,22 +1,45 @@
/* eslint react/no-unused-state: 0 */
-import React, { useState } from 'react';
-import { withRouter, Redirect, useHistory } from 'react-router-dom';
-import { CardBody } from '../../../components/Card';
+import React, { useState, useCallback, useEffect } from 'react';
+import { Redirect, useHistory } from 'react-router-dom';
-import { JobTemplatesAPI } from '../../../api';
import { JobTemplate } from '../../../types';
+import { JobTemplatesAPI, ProjectsAPI } from '../../../api';
import { getAddedAndRemoved } from '../../../util/lists';
+import useRequest from '../../../util/useRequest';
import JobTemplateForm from '../shared/JobTemplateForm';
-
import ContentLoading from '../../../components/ContentLoading';
+import { CardBody } from '../../../components/Card';
function JobTemplateEdit({ template }) {
const history = useHistory();
const [formSubmitError, setFormSubmitError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
+ const [isDisabled, setIsDisabled] = useState(false);
const detailsUrl = `/templates/${template.type}/${template.id}/details`;
+ const {
+ request: fetchProject,
+ error: fetchProjectError,
+ isLoading: projectLoading,
+ } = useRequest(
+ useCallback(async () => {
+ await ProjectsAPI.readDetail(template.project);
+ }, [template.project])
+ );
+
+ useEffect(() => {
+ fetchProject();
+ }, [fetchProject]);
+
+ useEffect(() => {
+ if (fetchProjectError) {
+ if (fetchProjectError.response.status === 403) {
+ setIsDisabled(true);
+ }
+ }
+ }, [fetchProjectError]);
+
const handleSubmit = async values => {
const {
labels,
@@ -91,22 +114,21 @@ function JobTemplateEdit({ template }) {
const associateCredentials = added.map(cred =>
JobTemplatesAPI.associateCredentials(template.id, cred.id)
);
- const associatePromise = Promise.all(associateCredentials);
+ const associatePromise = await Promise.all(associateCredentials);
return Promise.all([disassociatePromise, associatePromise]);
};
- const handleCancel = () => {
- history.push(detailsUrl);
- };
+ const handleCancel = () => history.push(detailsUrl);
const canEdit = template?.summary_fields?.user_capabilities?.edit;
if (!canEdit) {
return <Redirect to={detailsUrl} />;
}
- if (isLoading) {
+ if (isLoading || projectLoading) {
return <ContentLoading />;
}
+
return (
<CardBody>
<JobTemplateForm
@@ -114,6 +136,7 @@ function JobTemplateEdit({ template }) {
handleCancel={handleCancel}
handleSubmit={handleSubmit}
submitError={formSubmitError}
+ isOverrideDisabledLookup={!isDisabled}
/>
</CardBody>
);
@@ -122,5 +145,4 @@ function JobTemplateEdit({ template }) {
JobTemplateEdit.propTypes = {
template: JobTemplate.isRequired,
};
-
-export default withRouter(JobTemplateEdit);
+export default JobTemplateEdit;
diff --git a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx
index dd06e0bca9..399efe9922 100644
--- a/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx
+++ b/awx/ui_next/src/screens/Template/Survey/SurveyList.jsx
@@ -85,6 +85,48 @@ function SurveyList({
const end = questions.slice(index + 2);
updateSurvey([...beginning, swapWith, question, ...end]);
};
+ const deleteModal = (
+ <AlertModal
+ variant="danger"
+ title={
+ isAllSelected ? i18n._(t`Delete Survey`) : i18n._(t`Delete Questions`)
+ }
+ isOpen={isDeleteModalOpen}
+ onClose={() => {
+ setIsDeleteModalOpen(false);
+ setSelected([]);
+ }}
+ actions={[
+ <Button
+ key="delete"
+ variant="danger"
+ aria-label={i18n._(t`confirm delete`)}
+ onClick={handleDelete}
+ >
+ {i18n._(t`Delete`)}
+ </Button>,
+ <Button
+ key="cancel"
+ variant="secondary"
+ aria-label={i18n._(t`cancel delete`)}
+ onClick={() => {
+ setIsDeleteModalOpen(false);
+ setSelected([]);
+ }}
+ >
+ {i18n._(t`Cancel`)}
+ </Button>,
+ ]}
+ >
+ <div>{i18n._(t`This action will delete the following:`)}</div>
+ {selected.map(question => (
+ <span key={question.variable}>
+ <strong>{question.question_name}</strong>
+ <br />
+ </span>
+ ))}
+ </AlertModal>
+ );
let content;
if (isLoading) {
@@ -105,6 +147,7 @@ function SurveyList({
canEdit={canEdit}
/>
))}
+ {isDeleteModalOpen && deleteModal}
{isPreviewModalOpen && (
<SurveyPreviewModal
isPreviewModalOpen={isPreviewModalOpen}
@@ -112,7 +155,6 @@ function SurveyList({
questions={questions}
/>
)}
-
<Button
onClick={() => setIsPreviewModalOpen(true)}
variant="primary"
@@ -123,51 +165,8 @@ function SurveyList({
</DataList>
);
}
- if (isDeleteModalOpen) {
- return (
- <AlertModal
- variant="danger"
- title={
- isAllSelected ? i18n._(t`Delete Survey`) : i18n._(t`Delete Questions`)
- }
- isOpen={isDeleteModalOpen}
- onClose={() => {
- setIsDeleteModalOpen(false);
- setSelected([]);
- }}
- actions={[
- <Button
- key="delete"
- variant="danger"
- aria-label={i18n._(t`confirm delete`)}
- onClick={handleDelete}
- >
- {i18n._(t`Delete`)}
- </Button>,
- <Button
- key="cancel"
- variant="secondary"
- aria-label={i18n._(t`cancel delete`)}
- onClick={() => {
- setIsDeleteModalOpen(false);
- setSelected([]);
- }}
- >
- {i18n._(t`Cancel`)}
- </Button>,
- ]}
- >
- <div>{i18n._(t`This action will delete the following:`)}</div>
- {selected.map(question => (
- <span key={question.variable}>
- <strong>{question.question_name}</strong>
- <br />
- </span>
- ))}
- </AlertModal>
- );
- }
- if (!questions || questions?.length <= 0) {
+
+ if ((!questions || questions?.length <= 0) && !isLoading) {
return (
<EmptyState variant="full">
<EmptyStateIcon icon={CubesIcon} />
@@ -193,49 +192,6 @@ function SurveyList({
onToggleDeleteModal={() => setIsDeleteModalOpen(true)}
/>
{content}
- {isDeleteModalOpen && (
- <AlertModal
- variant="danger"
- title={
- isAllSelected
- ? i18n._(t`Delete Survey`)
- : i18n._(t`Delete Questions`)
- }
- isOpen={isDeleteModalOpen}
- onClose={() => {
- setIsDeleteModalOpen(false);
- }}
- actions={[
- <Button
- key="delete"
- variant="danger"
- aria-label={i18n._(t`confirm delete`)}
- onClick={handleDelete}
- >
- {i18n._(t`Delete`)}
- </Button>,
- <Button
- key="cancel"
- variant="secondary"
- aria-label={i18n._(t`cancel delete`)}
- onClick={() => {
- setIsDeleteModalOpen(false);
- }}
- >
- {i18n._(t`Cancel`)}
- </Button>,
- ]}
- >
- <div>{i18n._(t`This action will delete the following:`)}</div>
- <ul>
- {selected.map(question => (
- <li key={question.variable}>
- <strong>{question.question_name}</strong>
- </li>
- ))}
- </ul>
- </AlertModal>
- )}
</>
);
}
diff --git a/awx/ui_next/src/screens/Template/Template.jsx b/awx/ui_next/src/screens/Template/Template.jsx
index 0d9f84288d..50b238b3b7 100644
--- a/awx/ui_next/src/screens/Template/Template.jsx
+++ b/awx/ui_next/src/screens/Template/Template.jsx
@@ -46,8 +46,21 @@ function Template({ i18n, setBreadcrumb }) {
role_level: 'notification_admin_role',
}),
]);
- if (actions?.data?.actions?.PUT) {
- if (data?.webhook_service && data?.related?.webhook_key) {
+ if (data.summary_fields.credentials) {
+ const params = {
+ page: 1,
+ page_size: 200,
+ order_by: 'name',
+ };
+ const {
+ data: { results },
+ } = await JobTemplatesAPI.readCredentials(data.id, params);
+
+ data.summary_fields.credentials = results;
+ }
+
+ if (actions.data.actions.PUT) {
+ if (data.webhook_service && data?.related?.webhook_key) {
const {
data: { webhook_key },
} = await JobTemplatesAPI.readWebhookKey(templateId);
@@ -78,14 +91,14 @@ function Template({ i18n, setBreadcrumb }) {
};
const loadScheduleOptions = useCallback(() => {
- return JobTemplatesAPI.readScheduleOptions(template.id);
- }, [template]);
+ return JobTemplatesAPI.readScheduleOptions(templateId);
+ }, [templateId]);
const loadSchedules = useCallback(
params => {
- return JobTemplatesAPI.readSchedules(template.id, params);
+ return JobTemplatesAPI.readSchedules(templateId, params);
},
- [template]
+ [templateId]
);
const canSeeNotificationsTab = me?.is_system_auditor || isNotifAdmin;
@@ -142,7 +155,7 @@ function Template({ i18n, setBreadcrumb }) {
<PageSection>
<Card>
<ContentError error={contentError}>
- {contentError.response.status === 404 && (
+ {contentError.response?.status === 404 && (
<span>
{i18n._(t`Template not found.`)}{' '}
<Link to="/templates">{i18n._(t`View all Templates.`)}</Link>
diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx
index a38209db75..afb7221f06 100644
--- a/awx/ui_next/src/screens/Template/Template.test.jsx
+++ b/awx/ui_next/src/screens/Template/Template.test.jsx
@@ -28,6 +28,22 @@ describe('<Template />', () => {
actions: { PUT: true },
},
});
+ JobTemplatesAPI.readCredentials.mockResolvedValue({
+ data: {
+ results: [
+ {
+ id: 3,
+ type: 'credential',
+ url: '/api/v2/credentials/3/',
+ name: 'Vault1Id1',
+ inputs: {
+ vault_id: '1',
+ },
+ kind: 'vault',
+ },
+ ],
+ },
+ });
OrganizationsAPI.read.mockResolvedValue({
data: {
count: 1,
diff --git a/awx/ui_next/src/screens/Template/TemplateSurvey.jsx b/awx/ui_next/src/screens/Template/TemplateSurvey.jsx
index 0badeffbf5..37b24cea67 100644
--- a/awx/ui_next/src/screens/Template/TemplateSurvey.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateSurvey.jsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
-import { Switch, Route, useParams, useLocation } from 'react-router-dom';
+import { Switch, Route, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
@@ -12,8 +12,7 @@ import { SurveyList, SurveyQuestionAdd, SurveyQuestionEdit } from './Survey';
function TemplateSurvey({ template, canEdit, i18n }) {
const [surveyEnabled, setSurveyEnabled] = useState(template.survey_enabled);
- const { templateType } = useParams();
- const location = useLocation();
+ const { templateType, id: templateId } = useParams();
const {
result: survey,
@@ -25,30 +24,31 @@ function TemplateSurvey({ template, canEdit, i18n }) {
useCallback(async () => {
const { data } =
templateType === 'workflow_job_template'
- ? await WorkflowJobTemplatesAPI.readSurvey(template.id)
- : await JobTemplatesAPI.readSurvey(template.id);
+ ? await WorkflowJobTemplatesAPI.readSurvey(templateId)
+ : await JobTemplatesAPI.readSurvey(templateId);
return data;
- }, [template.id, templateType])
+ }, [templateId, templateType])
);
useEffect(() => {
fetchSurvey();
- }, [fetchSurvey, location]);
+ }, [fetchSurvey]);
- const { request: updateSurvey, error: updateError } = useRequest(
+ const {
+ request: updateSurvey,
+ error: updateError,
+ isLoading: updateLoading,
+ } = useRequest(
useCallback(
async updatedSurvey => {
if (templateType === 'workflow_job_template') {
- await WorkflowJobTemplatesAPI.updateSurvey(
- template.id,
- updatedSurvey
- );
+ await WorkflowJobTemplatesAPI.updateSurvey(templateId, updatedSurvey);
} else {
- await JobTemplatesAPI.updateSurvey(template.id, updatedSurvey);
+ await JobTemplatesAPI.updateSurvey(templateId, updatedSurvey);
}
setSurvey(updatedSurvey);
},
- [template.id, setSurvey, templateType]
+ [templateId, setSurvey, templateType]
)
);
const updateSurveySpec = spec => {
@@ -61,24 +61,24 @@ function TemplateSurvey({ template, canEdit, i18n }) {
const { request: deleteSurvey, error: deleteError } = useRequest(
useCallback(async () => {
- await JobTemplatesAPI.destroySurvey(template.id);
+ await JobTemplatesAPI.destroySurvey(templateId);
setSurvey(null);
- }, [template.id, setSurvey])
+ }, [templateId, setSurvey])
);
const { request: toggleSurvey, error: toggleError } = useRequest(
useCallback(async () => {
if (templateType === 'workflow_job_template') {
- await WorkflowJobTemplatesAPI.update(template.id, {
+ await WorkflowJobTemplatesAPI.update(templateId, {
survey_enabled: !surveyEnabled,
});
} else {
- await JobTemplatesAPI.update(template.id, {
+ await JobTemplatesAPI.update(templateId, {
survey_enabled: !surveyEnabled,
});
}
setSurveyEnabled(!surveyEnabled);
- }, [template.id, templateType, surveyEnabled])
+ }, [templateId, templateType, surveyEnabled])
);
const { error, dismissError } = useDismissableError(
@@ -109,7 +109,7 @@ function TemplateSurvey({ template, canEdit, i18n }) {
)}
<Route path="/templates/:templateType/:id/survey" exact>
<SurveyList
- isLoading={isLoading}
+ isLoading={isLoading || updateLoading}
survey={survey}
surveyEnabled={surveyEnabled}
toggleSurvey={toggleSurvey}
diff --git a/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx b/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx
index 0d21bc15e0..ae0a5206b2 100644
--- a/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx
+++ b/awx/ui_next/src/screens/Template/TemplateSurvey.test.jsx
@@ -1,11 +1,13 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
+
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import TemplateSurvey from './TemplateSurvey';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
import mockJobTemplateData from './shared/data.job_template.json';
+import mockWorkflowJobTemplateData from './shared/data.workflow_job_template.json';
jest.mock('../../api/models/JobTemplates');
jest.mock('../../api/models/WorkflowJobTemplates');
@@ -27,19 +29,31 @@ describe('<TemplateSurvey />', () => {
test('should fetch survey from API', async () => {
const history = createMemoryHistory({
- initialEntries: ['/templates/job_template/1/survey'],
+ initialEntries: ['/templates/job_template/7/survey'],
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
- <TemplateSurvey template={mockJobTemplateData} />,
+ <Route path="/templates/:templateType/:id/survey">
+ <TemplateSurvey template={mockJobTemplateData} canEdit />
+ </Route>,
{
- context: { router: { history } },
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: {
+ params: { templateType: 'job_template', id: 7 },
+ },
+ },
+ },
+ },
}
);
});
wrapper.update();
- expect(JobTemplatesAPI.readSurvey).toBeCalledWith(7);
+ expect(JobTemplatesAPI.readSurvey).toBeCalledWith('7');
expect(wrapper.find('SurveyList').prop('survey')).toEqual(surveyData);
});
@@ -47,9 +61,27 @@ describe('<TemplateSurvey />', () => {
test('should display error in retrieving survey', async () => {
JobTemplatesAPI.readSurvey.mockRejectedValue(new Error());
let wrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/templates/job_template/7/survey'],
+ });
await act(async () => {
wrapper = mountWithContexts(
- <TemplateSurvey template={{ ...mockJobTemplateData, id: 'a' }} />
+ <Route path="/templates/:templateType/:id/survey">
+ <TemplateSurvey template={{ ...mockJobTemplateData, id: 'a' }} />
+ </Route>,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: {
+ params: { templateType: 'job_template', id: 7 },
+ },
+ },
+ },
+ },
+ }
);
});
@@ -60,14 +92,26 @@ describe('<TemplateSurvey />', () => {
test('should update API with survey changes', async () => {
const history = createMemoryHistory({
- initialEntries: ['/templates/job_template/1/survey'],
+ initialEntries: ['/templates/job_template/7/survey'],
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
- <TemplateSurvey template={mockJobTemplateData} />,
+ <Route path="/templates/:templateType/:id/survey">
+ <TemplateSurvey template={mockJobTemplateData} canEdit />
+ </Route>,
{
- context: { router: { history } },
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: {
+ params: { templateType: 'job_template', id: 7 },
+ },
+ },
+ },
+ },
}
);
});
@@ -79,7 +123,7 @@ describe('<TemplateSurvey />', () => {
{ question_name: 'Bar', type: 'text', default: 'Two', variable: 'bar' },
]);
});
- expect(JobTemplatesAPI.updateSurvey).toHaveBeenCalledWith(7, {
+ expect(JobTemplatesAPI.updateSurvey).toHaveBeenCalledWith('7', {
name: 'Survey',
description: 'description for survey',
spec: [
@@ -91,14 +135,26 @@ describe('<TemplateSurvey />', () => {
test('should toggle jt survery on', async () => {
const history = createMemoryHistory({
- initialEntries: ['/templates/job_template/1/survey'],
+ initialEntries: ['/templates/job_template/7/survey'],
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
- <TemplateSurvey template={mockJobTemplateData} canEdit />,
+ <Route path="/templates/:templateType/:id/survey">
+ <TemplateSurvey template={mockJobTemplateData} canEdit />
+ </Route>,
{
- context: { router: { history } },
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: {
+ params: { templateType: 'job_template', id: 7 },
+ },
+ },
+ },
+ },
}
);
});
@@ -108,12 +164,14 @@ describe('<TemplateSurvey />', () => {
);
wrapper.update();
- expect(JobTemplatesAPI.update).toBeCalledWith(7, { survey_enabled: false });
+ expect(JobTemplatesAPI.update).toBeCalledWith('7', {
+ survey_enabled: false,
+ });
});
test('should toggle wfjt survey on', async () => {
const history = createMemoryHistory({
- initialEntries: ['/templates/workflow_job_template/1/survey'],
+ initialEntries: ['/templates/workflow_job_template/15/survey'],
});
WorkflowJobTemplatesAPI.readSurvey.mockResolvedValueOnce({
@@ -124,7 +182,7 @@ describe('<TemplateSurvey />', () => {
await act(async () => {
wrapper = mountWithContexts(
<Route path="/templates/:templateType/:id/survey">
- <TemplateSurvey template={mockJobTemplateData} canEdit />
+ <TemplateSurvey template={mockWorkflowJobTemplateData} canEdit />
</Route>,
{
context: {
@@ -132,7 +190,9 @@ describe('<TemplateSurvey />', () => {
history,
route: {
location: history.location,
- match: { params: { templateType: 'workflow_job_template' } },
+ match: {
+ params: { templateType: 'workflow_job_template', id: 15 },
+ },
},
},
},
@@ -143,8 +203,9 @@ describe('<TemplateSurvey />', () => {
await act(() =>
wrapper.find('Switch[aria-label="Survey Toggle"]').prop('onChange')()
);
+
wrapper.update();
- expect(WorkflowJobTemplatesAPI.update).toBeCalledWith(7, {
+ expect(WorkflowJobTemplatesAPI.update).toBeCalledWith('15', {
survey_enabled: false,
});
});
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
index 4b997fa9b9..ee22010983 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.jsx
@@ -27,6 +27,7 @@ import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '../../api';
import TemplateSurvey from './TemplateSurvey';
import { Visualizer } from './WorkflowJobTemplateVisualizer';
+import ContentLoading from '../../components/ContentLoading';
function WorkflowJobTemplate({ i18n, setBreadcrumb }) {
const location = useLocation();
@@ -150,6 +151,10 @@ function WorkflowJobTemplate({ i18n, setBreadcrumb }) {
}
const contentError = rolesAndTemplateError;
+
+ if (hasRolesandTemplateLoading) {
+ return <ContentLoading />;
+ }
if (!hasRolesandTemplateLoading && contentError) {
return (
<PageSection>
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
index 2f6b99821a..2a5242b9a0 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
@@ -135,9 +135,6 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
)}
/>
)}
- {renderOptionsField && (
- <Detail label={i18n._(t`Options`)} value={renderOptions} />
- )}
<Detail
label={i18n._(t`Webhook Service`)}
value={toTitleCase(template.webhook_service)}
@@ -162,6 +159,19 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
}
/>
)}
+ {renderOptionsField && (
+ <Detail label={i18n._(t`Options`)} value={renderOptions} />
+ )}
+ <UserDateDetail
+ label={i18n._(t`Created`)}
+ date={created}
+ user={summary_fields.created_by}
+ />
+ <UserDateDetail
+ label={i18n._(t`Modified`)}
+ date={modified}
+ user={summary_fields.modified_by}
+ />
{summary_fields.labels?.results?.length > 0 && (
<Detail
fullWidth
@@ -185,16 +195,6 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
value={extra_vars}
rows={4}
/>
- <UserDateDetail
- label={i18n._(t`Created`)}
- date={created}
- user={summary_fields.created_by}
- />
- <UserDateDetail
- label={i18n._(t`Modified`)}
- date={modified}
- user={summary_fields.modified_by}
- />
</DetailList>
<CardActionsRow>
{summary_fields.user_capabilities &&
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx
index 47e400f716..f5d0ce4dda 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/InventorySourcesList.jsx
@@ -10,7 +10,7 @@ import PaginatedDataList from '../../../../../../components/PaginatedDataList';
import DataListToolbar from '../../../../../../components/DataListToolbar';
import CheckboxListItem from '../../../../../../components/CheckboxListItem';
-const QS_CONFIG = getQSConfig('inventory_sources', {
+const QS_CONFIG = getQSConfig('inventory-sources', {
page: 1,
page_size: 5,
order_by: 'name',
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx
index 3a00f86d64..3de9f280b5 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/JobTemplatesList.jsx
@@ -10,7 +10,7 @@ import PaginatedDataList from '../../../../../../components/PaginatedDataList';
import DataListToolbar from '../../../../../../components/DataListToolbar';
import CheckboxListItem from '../../../../../../components/CheckboxListItem';
-const QS_CONFIG = getQSConfig('job_templates', {
+const QS_CONFIG = getQSConfig('job-templates', {
page: 1,
page_size: 5,
order_by: 'name',
diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx
index f8338ed25f..aac3f669d0 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/WorkflowJobTemplatesList.jsx
@@ -10,7 +10,7 @@ import PaginatedDataList from '../../../../../../components/PaginatedDataList';
import DataListToolbar from '../../../../../../components/DataListToolbar';
import CheckboxListItem from '../../../../../../components/CheckboxListItem';
-const QS_CONFIG = getQSConfig('workflow_job_templates', {
+const QS_CONFIG = getQSConfig('workflow-job-templates', {
page: 1,
page_size: 5,
order_by: 'name',
diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
index 805e201e13..e3bd18ec19 100644
--- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
+++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx
@@ -53,6 +53,7 @@ function JobTemplateForm({
setFieldValue,
submitError,
i18n,
+ isOverrideDisabledLookup,
}) {
const [contentError, setContentError] = useState(false);
const [inventory, setInventory] = useState(
@@ -254,6 +255,7 @@ function JobTemplateForm({
required={!askInventoryOnLaunchField.value}
touched={inventoryMeta.touched}
error={inventoryMeta.error}
+ isOverrideDisabled={isOverrideDisabledLookup}
/>
</FormGroup>
<ProjectLookup
@@ -266,6 +268,7 @@ function JobTemplateForm({
onChange={handleProjectUpdate}
required
autoPopulate={!template?.id}
+ isOverrideDisabled={isOverrideDisabledLookup}
/>
{projectField.value?.allow_override && (
<FieldWithPrompt
@@ -433,7 +436,7 @@ function JobTemplateForm({
min="0"
label={i18n._(t`Timeout`)}
tooltip={i18n._(t`The amount of time (in seconds) to run
- before the task is canceled. Defaults to 0 for no job
+ before the job is canceled. Defaults to 0 for no job
timeout.`)}
/>
<FieldWithPrompt
@@ -623,7 +626,9 @@ JobTemplateForm.propTypes = {
handleCancel: PropTypes.func.isRequired,
handleSubmit: PropTypes.func.isRequired,
submitError: PropTypes.shape({}),
+ isOverrideDisabledLookup: PropTypes.bool,
};
+
JobTemplateForm.defaultProps = {
template: {
name: '',
@@ -641,6 +646,7 @@ JobTemplateForm.defaultProps = {
isNew: true,
},
submitError: null,
+ isOverrideDisabledLookup: false,
};
const FormikApp = withFormik({
diff --git a/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json b/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json
index b120d7c892..ae4e6fb6d4 100644
--- a/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json
+++ b/awx/ui_next/src/screens/Template/shared/data.workflow_job_template.json
@@ -81,7 +81,7 @@
"status": "never updated",
"extra_vars": "",
"organization": null,
- "survey_enabled": false,
+ "survey_enabled": true,
"allow_simultaneous": false,
"ask_variables_on_launch": false,
"inventory": null,
diff --git a/awx_collection/plugins/modules/tower_project.py b/awx_collection/plugins/modules/tower_project.py
index 3e6d6ab442..ca848e7d69 100644
--- a/awx_collection/plugins/modules/tower_project.py
+++ b/awx_collection/plugins/modules/tower_project.py
@@ -153,7 +153,7 @@ EXAMPLES = '''
organization: "test"
scm_update_on_launch: True
scm_update_cache_timeout: 60
- custom_virtualenv: "/var/lib/awx/venv/ansible-2.2"
+ custom_virtualenv: "/var/lib/awx/var/lib/awx/venv/ansible-2.2"
state: present
tower_config_file: "~/tower_cli.cfg"
'''
diff --git a/awx_collection/test/awx/test_inventory_source.py b/awx_collection/test/awx/test_inventory_source.py
index 1bced2eb67..3e65feaddb 100644
--- a/awx_collection/test/awx/test_inventory_source.py
+++ b/awx_collection/test/awx/test_inventory_source.py
@@ -133,10 +133,10 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker, proje
inventory=base_inventory,
source_project=project,
source='scm',
- custom_virtualenv='/venv/foobar/'
+ custom_virtualenv='/var/lib/awx/venv/foobar/'
)
# mock needed due to API behavior, not incorrect client behavior
- with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=['/venv/foobar/']):
+ with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=['/var/lib/awx/venv/foobar/']):
result = run_module('tower_inventory_source', dict(
name='foo',
description='this is the changed description',
@@ -148,7 +148,7 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker, proje
), admin_user)
assert result.pop('changed', None), result
inv_src.refresh_from_db()
- assert inv_src.custom_virtualenv == '/venv/foobar/'
+ assert inv_src.custom_virtualenv == '/var/lib/awx/venv/foobar/'
assert inv_src.description == 'this is the changed description'
diff --git a/awx_collection/test/awx/test_notification_template.py b/awx_collection/test/awx/test_notification_template.py
index 28f7c4ecee..96fbd5e56c 100644
--- a/awx_collection/test/awx/test_notification_template.py
+++ b/awx_collection/test/awx/test_notification_template.py
@@ -3,7 +3,7 @@ __metaclass__ = type
import pytest
-from awx.main.models import NotificationTemplate
+from awx.main.models import NotificationTemplate, Job
def compare_with_encrypted(model_config, param_config):
@@ -109,3 +109,32 @@ def test_deprecated_to_modern_no_op(run_module, admin_user, organization):
), admin_user)
assert not result.get('failed', False), result.get('msg', result)
assert not result.pop('changed', None), result
+
+
+@pytest.mark.django_db
+def test_build_notification_message_undefined(run_module, admin_user, organization):
+ """Job notification templates may encounter undefined values in the context when they are
+ rendered. Make sure that accessing attributes or items of an undefined value returns another
+ instance of Undefined, rather than raising an UndefinedError. This enables the use of expressions
+ like "{{ job.created_by.first_name | default('unknown') }}"."""
+ job = Job.objects.create(name='foobar')
+
+ nt_config = {
+ 'url': 'http://www.example.com/hook',
+ 'headers': {
+ 'X-Custom-Header': 'value123'
+ }
+ }
+ custom_start_template = {'body': '{"started_by": "{{ job.summary_fields.created_by.username | default(\'My Placeholder\') }}"}'}
+ messages = {'started': custom_start_template, 'success': None, 'error': None, 'workflow_approval': None}
+ result = run_module('tower_notification_template', dict(
+ name='foo-notification-template',
+ organization=organization.name,
+ notification_type='webhook',
+ notification_configuration=nt_config,
+ messages=messages,
+ ), admin_user)
+ nt = NotificationTemplate.objects.get(id=result['id'])
+
+ _, body = job.build_notification_message(nt, 'running')
+ assert '{"started_by": "My Placeholder"}' in body
diff --git a/awx_collection/tools/roles/template_galaxy/templates/README.md.j2 b/awx_collection/tools/roles/template_galaxy/templates/README.md.j2
index 8a5743d34f..ed02006c3d 100644
--- a/awx_collection/tools/roles/template_galaxy/templates/README.md.j2
+++ b/awx_collection/tools/roles/template_galaxy/templates/README.md.j2
@@ -31,7 +31,7 @@ with the current AWX version, for example: `awx_collection/awx-awx-9.2.0.tar.gz`
Installing the `tar.gz` involves no special instructions.
{% else %}
-This collection should be installed from [Content Hub][https://cloud.redhat.com/ansible/automation-hub/ansible/tower/]
+This collection should be installed from [Content Hub](https://cloud.redhat.com/ansible/automation-hub/ansible/tower/)
{% endif %}
## Running
diff --git a/awxkit/awxkit/api/mixins/has_status.py b/awxkit/awxkit/api/mixins/has_status.py
index bd76400baa..db14874b6c 100644
--- a/awxkit/awxkit/api/mixins/has_status.py
+++ b/awxkit/awxkit/api/mixins/has_status.py
@@ -47,6 +47,13 @@ class HasStatus(object):
def wait_until_started(self, interval=1, timeout=60):
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
+ def failure_output_details(self):
+ if getattr(self, 'result_stdout', ''):
+ output = bytes_to_str(self.result_stdout)
+ if output:
+ return '\nstdout:\n{}'.format(output)
+ return ''
+
def assert_status(self, status_list, msg=None):
if isinstance(status_list, str):
status_list = [status_list]
@@ -65,10 +72,9 @@ class HasStatus(object):
msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation))
if getattr(self, 'result_traceback', ''):
msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback))
- if getattr(self, 'result_stdout', ''):
- output = bytes_to_str(self.result_stdout)
- if output:
- msg = msg + '\nstdout:\n{}'.format(output)
+
+ msg += self.failure_output_details()
+
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
try:
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
diff --git a/awxkit/awxkit/api/pages/api.py b/awxkit/awxkit/api/pages/api.py
index a4566d3015..3209232352 100644
--- a/awxkit/awxkit/api/pages/api.py
+++ b/awxkit/awxkit/api/pages/api.py
@@ -83,9 +83,6 @@ class ApiV2(base.Base):
if _page.json.get('managed_by_tower'):
log.debug("%s is managed by Tower, skipping.", _page.endpoint)
return None
- # Drop any hosts, groups, or inventories that were pulled in programmatically by an inventory source.
- if _page.json.get('has_inventory_sources'):
- return None
if post_fields is None: # Deprecated endpoint or insufficient permissions
log.error("Object export failed: %s", _page.endpoint)
return None
diff --git a/awxkit/awxkit/api/pages/base.py b/awxkit/awxkit/api/pages/base.py
index e3e64d7db7..f3c3957c9d 100644
--- a/awxkit/awxkit/api/pages/base.py
+++ b/awxkit/awxkit/api/pages/base.py
@@ -25,6 +25,13 @@ class Base(Page):
return self.delete()
except (exc.NoContent, exc.NotFound, exc.Forbidden):
pass
+ except (exc.BadRequest, exc.Conflict) as e:
+ if 'Job has not finished processing events' in e.msg:
+ pass
+ if 'Resource is being used' in e.msg:
+ pass
+ else:
+ raise e
def get_object_role(self, role, by_name=False):
"""Lookup and return a related object role by its role field or name.
diff --git a/awxkit/awxkit/api/pages/workflow_jobs.py b/awxkit/awxkit/api/pages/workflow_jobs.py
index d7fe487030..36afc94460 100644
--- a/awxkit/awxkit/api/pages/workflow_jobs.py
+++ b/awxkit/awxkit/api/pages/workflow_jobs.py
@@ -13,11 +13,35 @@ class WorkflowJob(UnifiedJob):
result = self.related.relaunch.post(payload)
return self.walk(result.url)
+ def failure_output_details(self):
+ """Special implementation of this part of assert_status so that
+ workflow_job.assert_successful() will give a breakdown of failure
+ """
+ node_list = self.related.workflow_nodes.get().results
+
+ msg = '\nNode summary:'
+ for node in node_list:
+ msg += '\n{}: {}'.format(node.id, node.summary_fields.get('job'))
+ for rel in ('failure_nodes', 'always_nodes', 'success_nodes'):
+ val = getattr(node, rel, [])
+ if val:
+ msg += ' {} {}'.format(rel, val)
+
+ msg += '\n\nUnhandled individual job failures:\n'
+ for node in node_list:
+ # nodes without always or failure paths consider failures unhandled
+ if node.job and not (node.failure_nodes or node.always_nodes):
+ job = node.related.job.get()
+ try:
+ job.assert_successful()
+ except Exception as e:
+ msg += str(e)
+
+ return msg
+
@property
def result_stdout(self):
# workflow jobs do not have result_stdout
- # which is problematic for the UnifiedJob.is_successful reliance on
- # related stdout endpoint.
if 'result_stdout' not in self.json:
return 'Unprovided AWX field.'
else:
diff --git a/installer/build.yml b/installer/build.yml
index 8ef6f2b1ce..0bea5821e3 100644
--- a/installer/build.yml
+++ b/installer/build.yml
@@ -1,6 +1,6 @@
---
- name: Build AWX Docker Images
- hosts: all
+ hosts: localhost
gather_facts: true
roles:
- {role: image_build}
diff --git a/installer/inventory b/installer/inventory
index 89d0684a70..d4596f5d96 100644
--- a/installer/inventory
+++ b/installer/inventory
@@ -101,17 +101,6 @@ pg_port=5432
# containerized postgres deployment on OpenShift
# pg_admin_password=postgrespass
-# Use a local distribution build container image for building the AWX package
-# This is helpful if you don't want to bother installing the build-time dependencies as
-# it is taken care of already.
-# NOTE: IMPORTANT: If you are running a mininshift install, using this container might not work
-# if you are using certain drivers like KVM where the source tree can't be mapped
-# into the build container.
-# Thus this setting must be set to False which will trigger a local build. To view the
-# typical dependencies that you might need to install see:
-# installer/image_build/files/Dockerfile.sdist
-# use_container_for_build=true
-
# This will create or update a default admin (superuser) account in AWX, if not provided
# then these default values are used
admin_user=admin
diff --git a/installer/roles/image_build/files/Dockerfile.sdist b/installer/roles/image_build/files/Dockerfile.sdist
deleted file mode 100644
index c4ed45477f..0000000000
--- a/installer/roles/image_build/files/Dockerfile.sdist
+++ /dev/null
@@ -1,22 +0,0 @@
-FROM centos:8
-
-RUN dnf -y update && dnf -y install epel-release && \
- dnf install -y bzip2 \
- gcc-c++ \
- gettext \
- git \
- make \
- nodejs \
- python3 \
- python3-setuptools
-
-# Use the distro provided npm to bootstrap our required version of node
-RUN npm install -g n && n 14.15.1 && dnf remove -y nodejs
-
-RUN mkdir -p /.npm && chmod g+rwx /.npm
-
-ENV PATH=/usr/local/n/versions/node/14.15.1/bin:$PATH
-
-WORKDIR "/awx"
-
-CMD ["make", "sdist"]
diff --git a/installer/roles/image_build/tasks/main.yml b/installer/roles/image_build/tasks/main.yml
index 46add2552c..463e12ec73 100644
--- a/installer/roles/image_build/tasks/main.yml
+++ b/installer/roles/image_build/tasks/main.yml
@@ -7,7 +7,6 @@
- name: Verify awx-logos directory exists for official install
stat:
path: "../../awx-logos"
- delegate_to: localhost
register: logosdir
failed_when: logosdir.stat.isdir is not defined or not logosdir.stat.isdir
when: awx_official|default(false)|bool
@@ -16,79 +15,8 @@
copy:
src: "../../awx-logos/awx/ui/client/assets/"
dest: "../awx/ui_next/public/static/media/"
- delegate_to: localhost
when: awx_official|default(false)|bool
-- name: Set sdist file name
- set_fact:
- awx_sdist_file: "awx-{{ awx_version }}.tar.gz"
-
-- name: AWX Distribution
- debug:
- msg: "{{ awx_sdist_file }}"
-
-- name: Stat distribution file
- stat:
- path: "../dist/{{ awx_sdist_file }}"
- delegate_to: localhost
- register: sdist
-
-- name: Clean distribution
- command: make clean
- args:
- chdir: ..
- ignore_errors: true
- when: not sdist.stat.exists
- delegate_to: localhost
-
-- name: Build sdist builder image
- docker_image:
- build:
- path: "{{ role_path }}/files"
- dockerfile: Dockerfile.sdist
- pull: false
- args:
- http_proxy: "{{ http_proxy | default('') }}"
- https_proxy: "{{ https_proxy | default('') }}"
- no_proxy: "{{ no_proxy | default('') }}"
- name: awx_sdist_builder
- tag: "{{ awx_version }}"
- source: 'build'
- force_source: true
- delegate_to: localhost
- when: use_container_for_build|default(true)|bool
-
-- name: Get current uid
- command: id -u
- register: uid
-
-- name: Build AWX distribution using container
- docker_container:
- env:
- http_proxy: "{{ http_proxy | default('') }}"
- https_proxy: "{{ https_proxy | default('') }}"
- no_proxy: "{{ no_proxy | default('') }}"
- image: "awx_sdist_builder:{{ awx_version }}"
- name: awx_sdist_builder
- state: started
- user: "{{ uid.stdout }}"
- detach: false
- volumes:
- - ../:/awx:Z
- delegate_to: localhost
- when: use_container_for_build|default(true)|bool
-
-- name: Build AWX distribution locally
- command: make sdist
- args:
- chdir: ..
- delegate_to: localhost
- when: not use_container_for_build|default(true)|bool
-
-- name: Set docker build base path
- set_fact:
- docker_base_path: "{{ awx_local_base_config_path|default('/tmp') }}/docker-image"
-
- name: Set awx image name
set_fact:
awx_image: "{{ awx_image|default('awx') }}"
@@ -98,31 +26,11 @@
src: Dockerfile.j2
dest: ../Dockerfile
-- name: Build base awx image
- docker_image:
- build:
- path: ".."
- dockerfile: Dockerfile
- pull: false
- args:
- http_proxy: "{{ http_proxy | default('') }}"
- https_proxy: "{{ https_proxy | default('') }}"
- no_proxy: "{{ no_proxy | default('') }}"
- name: "{{ awx_image }}"
- tag: "{{ awx_version }}"
- source: 'build'
- force_source: true
- delegate_to: localhost
+# Calling Docker directly because docker-py doesnt support BuildKit
+- name: Build AWX image
+ command: docker build -t {{ awx_image }}:{{ awx_version }} ..
- name: Tag awx images as latest
command: "docker tag {{ item }}:{{ awx_version }} {{ item }}:latest"
- delegate_to: localhost
with_items:
- "{{ awx_image }}"
-
-- name: Clean docker base directory
- file:
- path: "{{ docker_base_path }}"
- state: absent
- when: cleanup_docker_base|default(True)|bool
- delegate_to: localhost
diff --git a/installer/roles/image_build/templates/Dockerfile.j2 b/installer/roles/image_build/templates/Dockerfile.j2
index 64417060c7..7572c6219f 100644
--- a/installer/roles/image_build/templates/Dockerfile.j2
+++ b/installer/roles/image_build/templates/Dockerfile.j2
@@ -1,21 +1,19 @@
-{% if build_dev|bool %}
+{% if build_dev|default(False)|bool %}
### This file is generated from
### installer/roles/image_build/templates/Dockerfile.j2
###
### DO NOT EDIT
###
+{% else %}
+ {% set build_dev = False %}
{% endif %}
# Locations - set globally to be used across stages
-ARG VENV_BASE="{% if not build_dev|bool %}/var/lib/awx{% endif %}/venv"
-ARG COLLECTION_BASE="{% if not build_dev|bool %}/var/lib/awx{% endif %}/vendor/awx_ansible_collections"
+ARG COLLECTION_BASE="/var/lib/awx/vendor/awx_ansible_collections"
# Build container
FROM centos:8 as builder
-ARG VENV_BASE
-ARG COLLECTION_BASE
-
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
@@ -23,9 +21,10 @@ ENV LC_ALL en_US.UTF-8
USER root
# Install build dependencies
+RUN dnf -y module enable 'postgresql:12'
RUN dnf -y update && \
dnf -y install epel-release 'dnf-command(config-manager)' && \
- dnf module -y enable 'postgresql:10' && \
+ dnf module -y enable 'postgresql:12' && \
dnf config-manager --set-enabled powertools && \
dnf -y install ansible \
gcc \
@@ -40,7 +39,7 @@ RUN dnf -y update && \
nss \
openldap-devel \
patch \
- @postgresql:10 \
+ @postgresql:12 \
postgresql-devel \
python3-devel \
python3-pip \
@@ -72,16 +71,21 @@ RUN cd /tmp && make requirements_collections
ADD requirements/requirements_dev.txt /tmp/requirements
RUN cd /tmp && make requirements_awx_dev requirements_ansible_dev
{% endif %}
+
{% if not build_dev|bool %}
-COPY dist/{{ awx_sdist_file }} /tmp/{{ awx_sdist_file }}
-RUN mkdir -p -m 755 /var/lib/awx && \
- OFFICIAL=yes /var/lib/awx/venv/awx/bin/pip install /tmp/{{ awx_sdist_file }}
+# Use the distro provided npm to bootstrap our required version of node
+RUN npm install -g n && n 14.15.1 && dnf remove -y nodejs
+
+# Copy source into builder, build sdist, install it into awx venv
+COPY . /tmp/src/
+WORKDIR /tmp/src/
+RUN make sdist && \
+ /var/lib/awx/venv/awx/bin/pip install dist/awx-$(cat VERSION).tar.gz
{% endif %}
# Final container(s)
FROM centos:8
-ARG VENV_BASE
ARG COLLECTION_BASE
ENV LANG en_US.UTF-8
@@ -90,32 +94,11 @@ ENV LC_ALL en_US.UTF-8
USER root
-{% if build_dev|bool %}
-# Install development/test requirements
-RUN dnf -y install \
- gtk3 \
- gettext \
- alsa-lib \
- libX11-xcb \
- libXScrnSaver \
- strace \
- vim \
- nmap-ncat \
- nodejs \
- nss \
- make \
- patch \
- tmux \
- wget \
- diffutils \
- unzip && \
- npm install -g n && n 14.15.1 && dnf remove -y nodejs
-{% endif %}
-
# Install runtime requirements
+RUN dnf -y module enable 'postgresql:12'
RUN dnf -y update && \
dnf -y install epel-release 'dnf-command(config-manager)' && \
- dnf module -y enable 'postgresql:10' && \
+ dnf module -y enable 'postgresql:12' && \
dnf config-manager --set-enabled powertools && \
dnf -y install acl \
ansible \
@@ -126,7 +109,7 @@ RUN dnf -y update && \
krb5-workstation \
libcgroup-tools \
nginx \
- @postgresql:10 \
+ @postgresql:12 \
python3-devel \
python3-libselinux \
python3-pip \
@@ -163,16 +146,40 @@ RUN cd /usr/local/bin && \
curl -L https://github.com/openshift/origin/releases/download/v3.11.0/openshift-origin-client-tools-v3.11.0-0cbc58b-linux-64bit.tar.gz | \
tar -xz --strip-components=1 --wildcards --no-anchored 'oc'
+{% if build_dev|bool %}
+# Install development/test requirements
+RUN dnf --enablerepo=debuginfo -y install \
+ gdb \
+ gtk3 \
+ gettext \
+ alsa-lib \
+ libX11-xcb \
+ libXScrnSaver \
+ strace \
+ vim \
+ nmap-ncat \
+ nodejs \
+ nss \
+ make \
+ patch \
+ python3-debuginfo \
+ socat \
+ tmux \
+ wget \
+ diffutils \
+ unzip && \
+ npm install -g n && n 14.15.1 && dnf remove -y nodejs
+{% endif %}
+
# Copy app from builder
+COPY --from=builder /var/lib/awx /var/lib/awx
+
{%if build_dev|bool %}
-COPY --from=builder /venv /venv
-COPY --from=builder /vendor /vendor
RUN openssl req -nodes -newkey rsa:2048 -keyout /etc/nginx/nginx.key -out /etc/nginx/nginx.csr \
-subj "/C=US/ST=North Carolina/L=Durham/O=Ansible/OU=AWX Development/CN=awx.localhost" && \
openssl x509 -req -days 365 -in /etc/nginx/nginx.csr -signkey /etc/nginx/nginx.key -out /etc/nginx/nginx.crt && \
chmod 640 /etc/nginx/nginx.{csr,key,crt}
{% else %}
-COPY --from=builder /var/lib/awx /var/lib/awx
RUN ln -s /var/lib/awx/venv/awx/bin/awx-manage /usr/bin/awx-manage
{% endif %}
@@ -221,17 +228,17 @@ RUN chmod u+s /usr/bin/bwrap ; \
{% if build_dev|bool %}
RUN for dir in \
- /venv \
- /venv/awx/lib/python3.6 \
+ /var/lib/awx/venv \
+ /var/lib/awx/venv/awx/lib/python3.6 \
/var/lib/awx/projects \
/var/lib/awx/rsyslog \
/var/run/awx-rsyslog \
/.ansible \
- /vendor ; \
+ /var/lib/awx/vendor ; \
do mkdir -m 0775 -p $dir ; chmod g+rw $dir ; chgrp root $dir ; done && \
for file in \
/var/run/nginx.pid \
- /venv/awx/lib/python3.6/site-packages/awx.egg-link ; \
+ /var/lib/awx/venv/awx/lib/python3.6/site-packages/awx.egg-link ; \
do touch $file ; chmod g+rw $file ; done
{% endif %}
diff --git a/installer/roles/image_push/tasks/main.yml b/installer/roles/image_push/tasks/main.yml
index e005af1096..9561af8ac8 100644
--- a/installer/roles/image_push/tasks/main.yml
+++ b/installer/roles/image_push/tasks/main.yml
@@ -6,7 +6,6 @@
password: "{{ docker_registry_password }}"
reauthorize: true
when: docker_registry is defined and docker_registry_password is defined
- delegate_to: localhost
- name: Remove local images to ensure proper push behavior
block:
@@ -15,7 +14,6 @@
name: "{{ docker_registry }}/{{ docker_registry_repository }}/{{ awx_image }}"
tag: "{{ awx_version }}"
state: absent
- delegate_to: localhost
- name: Tag and Push Container Images
block:
@@ -28,7 +26,6 @@
with_items:
- "latest"
- "{{ awx_version }}"
- delegate_to: localhost
- name: Set full image path for Registry
set_fact:
diff --git a/installer/roles/kubernetes/tasks/main.yml b/installer/roles/kubernetes/tasks/main.yml
index 7c23e1bcaf..dc6639b56b 100644
--- a/installer/roles/kubernetes/tasks/main.yml
+++ b/installer/roles/kubernetes/tasks/main.yml
@@ -76,7 +76,7 @@
-e POSTGRESQL_USER={{ pg_username }} \
-e POSTGRESQL_PASSWORD={{ pg_password | quote }} \
-e POSTGRESQL_DATABASE={{ pg_database | quote }} \
- -e POSTGRESQL_VERSION=10 \
+ -e POSTGRESQL_VERSION=12 \
-n {{ kubernetes_namespace }}
register: openshift_pg_activate
no_log: true
@@ -133,9 +133,9 @@
seconds: "{{ postgress_activate_wait }}"
when: openshift_pg_activate.changed or kubernetes_pg_activate.changed
-- name: Check postgres version and upgrade Postgres if necessary
+- name: Check postgres version and upgrade Postgres if necessary (Openshift)
block:
- - name: Check if Postgres 9.6 is being used
+ - name: Check if Postgres 10 is being used
shell: |
POD=$({{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \
get pods -l=name=postgresql --field-selector status.phase=Running -o jsonpath="{.items[0].metadata.name}")
@@ -145,7 +145,7 @@
block:
- name: Set new pg image
shell: |
- IMAGE=registry.redhat.io/rhel-8/postgresql-10
+ IMAGE=registry.redhat.io/rhel-8/postgresql-12
{{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set image dc/postgresql postgresql=$IMAGE
- name: Wait for change to take affect
@@ -162,7 +162,7 @@
- name: Set env var for new pg version
shell: |
- {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_VERSION=10
+ {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} set env dc/postgresql POSTGRESQL_VERSION=12
- name: Wait for Postgres to redeploy
pause:
@@ -185,9 +185,11 @@
- name: Wait for Postgres to redeploy
pause:
seconds: "{{ postgress_activate_wait }}"
- when: "pg_version is success and '9.6' in pg_version.stdout"
+ when: "pg_version is success and '10' in pg_version.stdout"
when:
- pg_hostname is not defined or pg_hostname == ''
+ - postgres_svc_details is defined and postgres_svc_details.rc != 0
+ - openshift_host is defined
- name: Set image names if using custom registry
block:
diff --git a/installer/roles/kubernetes/templates/deployment.yml.j2 b/installer/roles/kubernetes/templates/deployment.yml.j2
index b82c30b069..5902d8d3ae 100644
--- a/installer/roles/kubernetes/templates/deployment.yml.j2
+++ b/installer/roles/kubernetes/templates/deployment.yml.j2
@@ -448,22 +448,6 @@ spec:
- key: environment_sh
path: 'environment.sh'
- - name: {{ kubernetes_deployment_name }}-launch-awx-web
- configMap:
- name: {{ kubernetes_deployment_name }}-launch-awx
- items:
- - key: launch-awx-web
- path: 'launch_awx.sh'
- defaultMode: 0755
-
- - name: {{ kubernetes_deployment_name }}-launch-awx-task
- configMap:
- name: {{ kubernetes_deployment_name }}-launch-awx
- items:
- - key: launch-awx-task
- path: 'launch_awx_task.sh'
- defaultMode: 0755
-
- name: {{ kubernetes_deployment_name }}-supervisor-web-config
configMap:
name: {{ kubernetes_deployment_name }}-supervisor-config
diff --git a/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2 b/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2
index febbc3402a..c688083cbb 100644
--- a/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2
+++ b/installer/roles/kubernetes/templates/postgresql-persistent.yml.j2
@@ -99,7 +99,7 @@ objects:
name: ${DATABASE_SERVICE_NAME}
- name: POSTGRESQL_MAX_CONNECTIONS
value: ${POSTGRESQL_MAX_CONNECTIONS}
- image: registry.redhat.io/rhel8/postgresql-10
+ image: registry.redhat.io/rhel8/postgresql-12
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
diff --git a/installer/roles/kubernetes/templates/postgresql-values.yml.j2 b/installer/roles/kubernetes/templates/postgresql-values.yml.j2
index ea6ba29230..4fca12a7b7 100644
--- a/installer/roles/kubernetes/templates/postgresql-values.yml.j2
+++ b/installer/roles/kubernetes/templates/postgresql-values.yml.j2
@@ -36,11 +36,14 @@ master:
{% endif %}
image:
{% if pg_image_registry is defined %}
+# The default bitnami image from the chart doesn't work on ARM
registry: {{ pg_image_registry }}
{% endif %}
- # The default bitnami image from the chart doesn't work on ARM
- repository: postgres
- tag: '11'
+{% if pg_image_registry is not defined %}
+ registry: docker.io/bitnami
+{% endif %}
+ repository: postgresql
+ tag: '12.5.0'
volumePermissions:
image:
{% if pg_image_registry is defined %}
diff --git a/installer/roles/local_docker/defaults/main.yml b/installer/roles/local_docker/defaults/main.yml
index f8e1304702..03742f5e14 100644
--- a/installer/roles/local_docker/defaults/main.yml
+++ b/installer/roles/local_docker/defaults/main.yml
@@ -4,7 +4,7 @@ dockerhub_version: "{{ lookup('file', playbook_dir + '/../VERSION') }}"
awx_image: "awx"
redis_image: "redis"
-postgresql_version: "10"
+postgresql_version: "12"
postgresql_image: "postgres:{{postgresql_version}}"
compose_start_containers: true
diff --git a/installer/roles/local_docker/tasks/upgrade_postgres.yml b/installer/roles/local_docker/tasks/upgrade_postgres.yml
index ae9801bae6..0a2b3afd33 100644
--- a/installer/roles/local_docker/tasks/upgrade_postgres.yml
+++ b/installer/roles/local_docker/tasks/upgrade_postgres.yml
@@ -31,20 +31,20 @@
- name: Upgrade Postgres
shell: |
docker run --rm \
- -v {{ postgres_data_dir }}/pgdata:/var/lib/postgresql/9.6/data \
-v {{ postgres_data_dir }}/10/data:/var/lib/postgresql/10/data \
+ -v {{ postgres_data_dir }}/12/data:/var/lib/postgresql/12/data \
-e PGUSER={{ pg_username }} -e POSTGRES_INITDB_ARGS="-U {{ pg_username }}" \
- tianon/postgres-upgrade:9.6-to-10 --username={{ pg_username }}
+ tianon/postgres-upgrade:10-to-12 --username={{ pg_username }}
when: upgrade_postgres | bool
- name: Copy old pg_hba.conf
copy:
src: "{{ postgres_data_dir + '/pgdata/pg_hba.conf' }}"
- dest: "{{ postgres_data_dir + '/10/data/' }}"
+ dest: "{{ postgres_data_dir + '/12/data/' }}"
when: upgrade_postgres | bool
- name: Remove old data directory
file:
- path: "{{ postgres_data_dir + '/pgdata' }}"
+ path: "{{ postgres_data_dir + '/10/data' }}"
state: absent
when: compose_start_containers|bool
diff --git a/pytest.ini b/pytest.ini
index ff89dc85f3..fc407b5f17 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,7 +1,7 @@
[pytest]
DJANGO_SETTINGS_MODULE = awx.settings.development
-python_paths = /venv/tower/lib/python3.6/site-packages
-site_dirs = /venv/tower/lib/python3.6/site-packages
+python_paths = /var/lib/awx/venv/tower/lib/python3.6/site-packages
+site_dirs = /var/lib/awx/venv/tower/lib/python3.6/site-packages
python_files = *.py
addopts = --reuse-db --nomigrations --tb=native
markers =
diff --git a/requirements/README.md b/requirements/README.md
index 412ac93d8d..22dfe8d62d 100644
--- a/requirements/README.md
+++ b/requirements/README.md
@@ -140,6 +140,10 @@ The offline installer needs to have functionality confirmed before upgrading the
Versions need to match the versions used in the pip bootstrapping step
in the top-level Makefile.
+### cryptography
+
+The offline installer needs to have functionality confirmed before upgrading these.
+
## Library Notes
### pexpect
diff --git a/requirements/requirements.in b/requirements/requirements.in
index c4466dedc1..f13ccb21b3 100644
--- a/requirements/requirements.in
+++ b/requirements/requirements.in
@@ -2,9 +2,11 @@ aiohttp
ansible-runner>=1.4.6
ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading
asciichartpy
+autobahn>=20.12.3 # CVE-2020-35678
azure-keyvault==1.1.0 # see UPGRADE BLOCKERs
channels
channels-redis>=3.1.0 # https://github.com/django/channels_redis/issues/212
+cryptography<3.0.0
daphne
django==2.2.16 # see UPGRADE BLOCKERs
django-auth-ldap
@@ -25,7 +27,7 @@ djangorestframework>=3.12.1
djangorestframework-yaml
GitPython>=3.1.1 # minimum to fix https://github.com/ansible/awx/issues/6119
irc
-jinja2
+jinja2>=2.11.0 # required for ChainableUndefined
jsonschema
Markdown # used for formatting API help
openshift>=0.11.0 # minimum version to pull in new pyyaml for CVE-2017-18342
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index a9c25d307d..1c968ea264 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -7,7 +7,7 @@ asciichartpy==1.5.25 # via -r /awx_devel/requirements/requirements.in
asgiref==3.2.5 # via channels, channels-redis, daphne
async-timeout==3.0.1 # via aiohttp, aioredis
attrs==19.3.0 # via aiohttp, automat, jsonschema, service-identity, twisted
-autobahn==20.3.1 # via daphne
+autobahn==20.12.3 # via -r /awx_devel/requirements/requirements.in, daphne
automat==20.2.0 # via twisted
azure-common==1.1.25 # via azure-keyvault
azure-keyvault==1.1.0 # via -r /awx_devel/requirements/requirements.in
@@ -19,7 +19,7 @@ channels-redis==3.1.0 # via -r /awx_devel/requirements/requirements.in
channels==2.4.0 # via -r /awx_devel/requirements/requirements.in, channels-redis
chardet==3.0.4 # via aiohttp, requests
constantly==15.1.0 # via twisted
-cryptography==2.8 # via adal, autobahn, azure-keyvault, pyopenssl, service-identity, social-auth-core
+cryptography==2.9.2 # via adal, autobahn, azure-keyvault, pyopenssl, service-identity, social-auth-core
daphne==2.4.1 # via -r /awx_devel/requirements/requirements.in, channels
defusedxml==0.6.0 # via python3-openid, python3-saml, social-auth-core
dictdiffer==0.8.1 # via openshift
@@ -46,7 +46,7 @@ gitdb==4.0.2 # via gitpython
gitpython==3.1.7 # via -r /awx_devel/requirements/requirements.in
google-auth==1.11.3 # via kubernetes
hiredis==1.0.1 # via aioredis
-hyperlink==19.0.0 # via twisted
+hyperlink==20.0.1 # via autobahn, twisted
idna-ssl==1.1.0 # via aiohttp
idna==2.9 # via hyperlink, idna-ssl, requests, twisted, yarl
importlib-metadata==1.5.0 # via importlib-resources, irc, jsonschema
@@ -107,7 +107,7 @@ ruamel.yaml.clib==0.2.0 # via ruamel.yaml
ruamel.yaml==0.16.10 # via openshift
schedule==0.6.0 # via -r /awx_devel/requirements/requirements.in
service-identity==18.1.0 # via twisted
-six==1.14.0 # via ansible-runner, automat, cryptography, django-extensions, django-pglocks, google-auth, isodate, jaraco.collections, jaraco.logging, jaraco.text, jsonschema, kubernetes, openshift, pygerduty, pyopenssl, pyrad, pyrsistent, python-dateutil, slackclient, social-auth-app-django, social-auth-core, tacacs-plus, twilio, txaio, websocket-client
+six==1.14.0 # via ansible-runner, automat, cryptography, django-extensions, django-pglocks, google-auth, isodate, jaraco.collections, jaraco.logging, jaraco.text, jsonschema, kubernetes, openshift, pygerduty, pyopenssl, pyrad, pyrsistent, python-dateutil, slackclient, social-auth-app-django, social-auth-core, tacacs-plus, twilio, websocket-client
slackclient==1.1.2 # via -r /awx_devel/requirements/requirements.in
smmap==3.0.1 # via gitdb
social-auth-app-django==3.1.0 # via -r /awx_devel/requirements/requirements.in
@@ -117,7 +117,7 @@ tacacs_plus==1.0 # via -r /awx_devel/requirements/requirements.in
tempora==2.1.0 # via irc, jaraco.logging
twilio==6.37.0 # via -r /awx_devel/requirements/requirements.in
twisted[tls]==20.3.0 # via -r /awx_devel/requirements/requirements.in, daphne
-txaio==20.1.1 # via autobahn
+txaio==20.12.1 # via autobahn
typing-extensions==3.7.4.1 # via aiohttp
urllib3==1.25.8 # via kubernetes, requests
uwsgi==2.0.18 # via -r /awx_devel/requirements/requirements.in
diff --git a/requirements/updater.sh b/requirements/updater.sh
index 3d93b4d815..c58f1a0f62 100755
--- a/requirements/updater.sh
+++ b/requirements/updater.sh
@@ -21,7 +21,7 @@ _cleanup() {
install_deps() {
pip install pip --upgrade
- pip install pip-tools
+ pip install "pip-tools==5.4.0" # see https://github.com/jazzband/pip-tools/pull/1237
}
generate_requirements_v3() {
diff --git a/tools/docker-compose-cluster.yml b/tools/docker-compose-cluster.yml
index 7aec34d8e4..8532b6e942 100644
--- a/tools/docker-compose-cluster.yml
+++ b/tools/docker-compose-cluster.yml
@@ -96,5 +96,5 @@ services:
- "./redis/redis.conf:/usr/local/etc/redis/redis.conf"
- "./redis/redis_socket_ha_3:/var/run/redis/"
postgres:
- image: postgres:10
+ image: postgres:12
container_name: tools_postgres_1
diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml
index 19ead50661..27261cd1e6 100644
--- a/tools/docker-compose.yml
+++ b/tools/docker-compose.yml
@@ -44,7 +44,7 @@ services:
# Postgres Database Container
postgres:
- image: postgres:10
+ image: postgres:12
container_name: tools_postgres_1
environment:
POSTGRES_HOST_AUTH_METHOD: trust
diff --git a/tools/scripts/awx-python b/tools/scripts/awx-python
index f2116c574c..7b64af02e3 100755
--- a/tools/scripts/awx-python
+++ b/tools/scripts/awx-python
@@ -1,15 +1,7 @@
#!/usr/bin/env bash
-# Enable needed Software Collections, if installed
-for scl in rh-postgresql10; do
- if [ -f /etc/scl/prefixes/$scl ]; then
- if [ -f `cat /etc/scl/prefixes/$scl`/$scl/enable ]; then
- . `cat /etc/scl/prefixes/$scl`/$scl/enable
- fi
- fi
-done
# Enable Tower virtualenv
-for venv_path in /var/lib/awx/venv/awx /venv/awx; do
+for venv_path in /var/lib/awx/venv/awx; do
if [ -f $venv_path/bin/activate ]; then
. $venv_path/bin/activate
fi