Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Corey
26a947ed31 Adds functionality to add multiple rrules to a schedule and save the form 2023-01-09 09:27:47 -05:00
87 changed files with 1206 additions and 1754 deletions

View File

@@ -38,13 +38,9 @@ jobs:
- name: Build collection and publish to galaxy - name: Build collection and publish to galaxy
run: | run: |
COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection COLLECTION_TEMPLATE_VERSION=true COLLECTION_NAMESPACE=${{ env.collection_namespace }} make build_collection
if [ "$(curl --head -sw '%{http_code}' https://galaxy.ansible.com/download/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz | tail -1)" == "302" ] ; then \
echo "Galaxy release already done"; \
else \
ansible-galaxy collection publish \ ansible-galaxy collection publish \
--token=${{ secrets.GALAXY_TOKEN }} \ --token=${{ secrets.GALAXY_TOKEN }} \
awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz; \ awx_collection_build/${{ env.collection_namespace }}-awx-${{ github.event.release.tag_name }}.tar.gz
fi
- name: Set official pypi info - name: Set official pypi info
run: echo pypi_repo=pypi >> $GITHUB_ENV run: echo pypi_repo=pypi >> $GITHUB_ENV
@@ -56,7 +52,6 @@ jobs:
- name: Build awxkit and upload to pypi - name: Build awxkit and upload to pypi
run: | run: |
git reset --hard
cd awxkit && python3 setup.py bdist_wheel cd awxkit && python3 setup.py bdist_wheel
twine upload \ twine upload \
-r ${{ env.pypi_repo }} \ -r ${{ env.pypi_repo }} \
@@ -79,6 +74,4 @@ jobs:
docker tag ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} quay.io/${{ github.repository }}:latest docker tag ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} quay.io/${{ github.repository }}:latest
docker push quay.io/${{ github.repository }}:${{ github.event.release.tag_name }} docker push quay.io/${{ github.repository }}:${{ github.event.release.tag_name }}
docker push quay.io/${{ github.repository }}:latest docker push quay.io/${{ github.repository }}:latest
docker pull ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}
docker tag ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }} quay.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}
docker push quay.io/${{ github.repository_owner }}/awx-ee:${{ github.event.release.tag_name }}

View File

@@ -84,20 +84,6 @@ jobs:
-e push=yes \ -e push=yes \
-e awx_official=yes -e awx_official=yes
- name: Log in to GHCR
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Log in to Quay
run: |
echo ${{ secrets.QUAY_TOKEN }} | docker login quay.io -u ${{ secrets.QUAY_USER }} --password-stdin
- name: tag awx-ee:latest with version input
run: |
docker pull quay.io/ansible/awx-ee:latest
docker tag quay.io/ansible/awx-ee:latest ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
docker push ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
- name: Build and stage awx-operator - name: Build and stage awx-operator
working-directory: awx-operator working-directory: awx-operator
run: | run: |
@@ -117,7 +103,6 @@ jobs:
env: env:
AWX_TEST_IMAGE: ${{ github.repository }} AWX_TEST_IMAGE: ${{ github.repository }}
AWX_TEST_VERSION: ${{ github.event.inputs.version }} AWX_TEST_VERSION: ${{ github.event.inputs.version }}
AWX_EE_TEST_IMAGE: ghcr.io/${{ github.repository_owner }}/awx-ee:${{ github.event.inputs.version }}
- name: Create draft release for AWX - name: Create draft release for AWX
working-directory: awx working-directory: awx

View File

@@ -96,15 +96,6 @@ register(
category=_('Authentication'), category=_('Authentication'),
category_slug='authentication', category_slug='authentication',
) )
register(
'ALLOW_METRICS_FOR_ANONYMOUS_USERS',
field_class=fields.BooleanField,
default=False,
label=_('Allow anonymous users to poll metrics'),
help_text=_('If true, anonymous users are allowed to poll metrics.'),
category=_('Authentication'),
category_slug='authentication',
)
def authentication_validate(serializer, attrs): def authentication_validate(serializer, attrs):

View File

@@ -5,11 +5,9 @@
import logging import logging
# Django # Django
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Django REST Framework # Django REST Framework
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -33,14 +31,9 @@ class MetricsView(APIView):
renderer_classes = [renderers.PlainTextRenderer, renderers.PrometheusJSONRenderer, renderers.BrowsableAPIRenderer] renderer_classes = [renderers.PlainTextRenderer, renderers.PrometheusJSONRenderer, renderers.BrowsableAPIRenderer]
def initialize_request(self, request, *args, **kwargs):
if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS:
self.permission_classes = (AllowAny,)
return super(APIView, self).initialize_request(request, *args, **kwargs)
def get(self, request): def get(self, request):
'''Show Metrics Details''' '''Show Metrics Details'''
if settings.ALLOW_METRICS_FOR_ANONYMOUS_USERS or request.user.is_superuser or request.user.is_system_auditor: if request.user.is_superuser or request.user.is_system_auditor:
metrics_to_show = '' metrics_to_show = ''
if not request.query_params.get('subsystemonly', "0") == "1": if not request.query_params.get('subsystemonly', "0") == "1":
metrics_to_show += metrics().decode('UTF-8') metrics_to_show += metrics().decode('UTF-8')

View File

@@ -1,7 +1,6 @@
import copy import copy
import os import os
import pathlib import pathlib
import time
from urllib.parse import urljoin from urllib.parse import urljoin
from .plugin import CredentialPlugin, CertFiles, raise_for_status from .plugin import CredentialPlugin, CertFiles, raise_for_status
@@ -248,15 +247,7 @@ def kv_backend(**kwargs):
request_url = urljoin(url, '/'.join(['v1'] + path_segments)).rstrip('/') request_url = urljoin(url, '/'.join(['v1'] + path_segments)).rstrip('/')
with CertFiles(cacert) as cert: with CertFiles(cacert) as cert:
request_kwargs['verify'] = cert request_kwargs['verify'] = cert
request_retries = 0
while request_retries < 5:
response = sess.get(request_url, **request_kwargs) response = sess.get(request_url, **request_kwargs)
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
if response.status_code == 412:
request_retries += 1
time.sleep(1)
else:
break
raise_for_status(response) raise_for_status(response)
json = response.json() json = response.json()
@@ -298,15 +289,8 @@ def ssh_backend(**kwargs):
with CertFiles(cacert) as cert: with CertFiles(cacert) as cert:
request_kwargs['verify'] = cert request_kwargs['verify'] = cert
request_retries = 0
while request_retries < 5:
resp = sess.post(request_url, **request_kwargs) resp = sess.post(request_url, **request_kwargs)
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
if resp.status_code == 412:
request_retries += 1
time.sleep(1)
else:
break
raise_for_status(resp) raise_for_status(resp)
return resp.json()['data']['signed_key'] return resp.json()['data']['signed_key']

View File

@@ -3,12 +3,14 @@ import logging
import os import os
import signal import signal
import time import time
import traceback
import datetime import datetime
from django.conf import settings from django.conf import settings
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now as tz_now from django.utils.timezone import now as tz_now
from django.db import transaction, connection as django_connection from django.db import DatabaseError, OperationalError, transaction, connection as django_connection
from django.db.utils import InterfaceError, InternalError
from django_guid import set_guid from django_guid import set_guid
import psutil import psutil
@@ -62,7 +64,6 @@ class CallbackBrokerWorker(BaseWorker):
""" """
MAX_RETRIES = 2 MAX_RETRIES = 2
INDIVIDUAL_EVENT_RETRIES = 3
last_stats = time.time() last_stats = time.time()
last_flush = time.time() last_flush = time.time()
total = 0 total = 0
@@ -163,48 +164,38 @@ class CallbackBrokerWorker(BaseWorker):
else: # only calculate the seconds if the created time already has been set else: # only calculate the seconds if the created time already has been set
metrics_total_job_event_processing_seconds += e.modified - e.created metrics_total_job_event_processing_seconds += e.modified - e.created
metrics_duration_to_save = time.perf_counter() metrics_duration_to_save = time.perf_counter()
saved_events = []
try: try:
cls.objects.bulk_create(events) cls.objects.bulk_create(events)
metrics_bulk_events_saved += len(events) metrics_bulk_events_saved += len(events)
saved_events = events
self.buff[cls] = []
except Exception as exc: except Exception as exc:
# If the database is flaking, let ensure_connection throw a general exception logger.warning(f'Error in events bulk_create, will try indiviually up to 5 errors, error {str(exc)}')
# will be caught by the outer loop, which goes into a proper sleep and retry loop
django_connection.ensure_connection()
logger.warning(f'Error in events bulk_create, will try indiviually, error: {str(exc)}')
# if an exception occurs, we should re-attempt to save the # if an exception occurs, we should re-attempt to save the
# events one-by-one, because something in the list is # events one-by-one, because something in the list is
# broken/stale # broken/stale
consecutive_errors = 0
events_saved = 0
metrics_events_batch_save_errors += 1 metrics_events_batch_save_errors += 1
for e in events.copy(): for e in events:
try: try:
e.save() e.save()
metrics_singular_events_saved += 1 events_saved += 1
events.remove(e) consecutive_errors = 0
saved_events.append(e) # Importantly, remove successfully saved events from the buffer
except Exception as exc_indv: except Exception as exc_indv:
retry_count = getattr(e, '_retry_count', 0) + 1 consecutive_errors += 1
e._retry_count = retry_count logger.info(f'Database Error Saving individual Job Event, error {str(exc_indv)}')
if consecutive_errors >= 5:
# special sanitization logic for postgres treatment of NUL 0x00 char raise
if (retry_count == 1) and isinstance(exc_indv, ValueError) and ("\x00" in e.stdout): metrics_singular_events_saved += events_saved
e.stdout = e.stdout.replace("\x00", "") if events_saved == 0:
raise
if retry_count >= self.INDIVIDUAL_EVENT_RETRIES:
logger.error(f'Hit max retries ({retry_count}) saving individual Event error: {str(exc_indv)}\ndata:\n{e.__dict__}')
events.remove(e)
else:
logger.info(f'Database Error Saving individual Event uuid={e.uuid} try={retry_count}, error: {str(exc_indv)}')
metrics_duration_to_save = time.perf_counter() - metrics_duration_to_save metrics_duration_to_save = time.perf_counter() - metrics_duration_to_save
for e in saved_events: for e in events:
if not getattr(e, '_skip_websocket_message', False): if not getattr(e, '_skip_websocket_message', False):
metrics_events_broadcast += 1 metrics_events_broadcast += 1
emit_event_detail(e) emit_event_detail(e)
if getattr(e, '_notification_trigger_event', False): if getattr(e, '_notification_trigger_event', False):
job_stats_wrapup(getattr(e, e.JOB_REFERENCE), event=e) job_stats_wrapup(getattr(e, e.JOB_REFERENCE), event=e)
self.buff = {}
self.last_flush = time.time() self.last_flush = time.time()
# only update metrics if we saved events # only update metrics if we saved events
if (metrics_bulk_events_saved + metrics_singular_events_saved) > 0: if (metrics_bulk_events_saved + metrics_singular_events_saved) > 0:
@@ -276,16 +267,20 @@ class CallbackBrokerWorker(BaseWorker):
try: try:
self.flush(force=flush) self.flush(force=flush)
break break
except Exception as exc: except (OperationalError, InterfaceError, InternalError) as exc:
# Aside form bugs, exceptions here are assumed to be due to database flake
if retries >= self.MAX_RETRIES: if retries >= self.MAX_RETRIES:
logger.exception('Worker could not re-establish database connectivity, giving up on one or more events.') logger.exception('Worker could not re-establish database connectivity, giving up on one or more events.')
self.buff = {}
return return
delay = 60 * retries delay = 60 * retries
logger.warning(f'Database Error Flushing Job Events, retry #{retries + 1} in {delay} seconds: {str(exc)}') logger.warning(f'Database Error Flushing Job Events, retry #{retries + 1} in {delay} seconds: {str(exc)}')
django_connection.close() django_connection.close()
time.sleep(delay) time.sleep(delay)
retries += 1 retries += 1
except Exception: except DatabaseError:
logger.exception(f'Callback Task Processor Raised Unexpected Exception processing event data:\n{body}') logger.exception('Database Error Flushing Job Events')
django_connection.close()
break
except Exception as exc:
tb = traceback.format_exc()
logger.error('Callback Task Processor Raised Exception: %r', exc)
logger.error('Detail: {}'.format(tb))

View File

@@ -390,7 +390,6 @@ class BaseTask(object):
logger.error("I/O error({0}) while trying to open lock file [{1}]: {2}".format(e.errno, lock_path, e.strerror)) logger.error("I/O error({0}) while trying to open lock file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
raise raise
emitted_lockfile_log = False
start_time = time.time() start_time = time.time()
while True: while True:
try: try:
@@ -402,9 +401,6 @@ class BaseTask(object):
logger.error("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror)) logger.error("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
raise raise
else: else:
if not emitted_lockfile_log:
logger.info(f"exception acquiring lock {lock_path}: {e}")
emitted_lockfile_log = True
time.sleep(1.0) time.sleep(1.0)
self.instance.refresh_from_db(fields=['cancel_flag']) self.instance.refresh_from_db(fields=['cancel_flag'])
if self.instance.cancel_flag or signal_callback(): if self.instance.cancel_flag or signal_callback():

View File

@@ -1,15 +1,7 @@
import pytest import pytest
import time
from unittest import mock
from uuid import uuid4
from django.test import TransactionTestCase
from awx.main.dispatch.worker.callback import job_stats_wrapup, CallbackBrokerWorker
from awx.main.dispatch.worker.callback import job_stats_wrapup
from awx.main.models.jobs import Job from awx.main.models.jobs import Job
from awx.main.models.inventory import InventoryUpdate, InventorySource
from awx.main.models.events import InventoryUpdateEvent
@pytest.mark.django_db @pytest.mark.django_db
@@ -32,108 +24,3 @@ def test_wrapup_does_send_notifications(mocker):
job.refresh_from_db() job.refresh_from_db()
assert job.host_status_counts == {} assert job.host_status_counts == {}
mock.assert_called_once_with('succeeded') mock.assert_called_once_with('succeeded')
class FakeRedis:
def keys(self, *args, **kwargs):
return []
def set(self):
pass
def get(self):
return None
@classmethod
def from_url(cls, *args, **kwargs):
return cls()
def pipeline(self):
return self
class TestCallbackBrokerWorker(TransactionTestCase):
@pytest.fixture(autouse=True)
def turn_off_websockets(self):
with mock.patch('awx.main.dispatch.worker.callback.emit_event_detail', lambda *a, **kw: None):
yield
def get_worker(self):
with mock.patch('redis.Redis', new=FakeRedis): # turn off redis stuff
return CallbackBrokerWorker()
def event_create_kwargs(self):
inventory_update = InventoryUpdate.objects.create(source='file', inventory_source=InventorySource.objects.create(source='file'))
return dict(inventory_update=inventory_update, created=inventory_update.created)
def test_flush_with_valid_event(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events}
worker.flush()
assert worker.buff.get(InventoryUpdateEvent, []) == []
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
def test_flush_with_invalid_event(self):
worker = self.get_worker()
kwargs = self.event_create_kwargs()
events = [
InventoryUpdateEvent(uuid=str(uuid4()), stdout='good1', **kwargs),
InventoryUpdateEvent(uuid=str(uuid4()), stdout='bad', counter=-2, **kwargs),
InventoryUpdateEvent(uuid=str(uuid4()), stdout='good2', **kwargs),
]
worker.buff = {InventoryUpdateEvent: events.copy()}
worker.flush()
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
assert InventoryUpdateEvent.objects.filter(uuid=events[1].uuid).count() == 0
assert InventoryUpdateEvent.objects.filter(uuid=events[2].uuid).count() == 1
assert worker.buff == {InventoryUpdateEvent: [events[1]]}
def test_duplicate_key_not_saved_twice(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events.copy()}
worker.flush()
# put current saved event in buffer (error case)
worker.buff = {InventoryUpdateEvent: [InventoryUpdateEvent.objects.get(uuid=events[0].uuid)]}
worker.last_flush = time.time() - 2.0
# here, the bulk_create will fail with UNIQUE constraint violation, but individual saves should resolve it
worker.flush()
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 1
assert worker.buff.get(InventoryUpdateEvent, []) == []
def test_give_up_on_bad_event(self):
worker = self.get_worker()
events = [InventoryUpdateEvent(uuid=str(uuid4()), counter=-2, **self.event_create_kwargs())]
worker.buff = {InventoryUpdateEvent: events.copy()}
for i in range(5):
worker.last_flush = time.time() - 2.0
worker.flush()
# Could not save, should be logged, and buffer should be cleared
assert worker.buff.get(InventoryUpdateEvent, []) == []
assert InventoryUpdateEvent.objects.filter(uuid=events[0].uuid).count() == 0 # sanity
def test_postgres_invalid_NUL_char(self):
# In postgres, text fields reject NUL character, 0x00
# tests use sqlite3 which will not raise an error
# but we can still test that it is sanitized before saving
worker = self.get_worker()
kwargs = self.event_create_kwargs()
events = [InventoryUpdateEvent(uuid=str(uuid4()), stdout="\x00", **kwargs)]
assert "\x00" in events[0].stdout # sanity
worker.buff = {InventoryUpdateEvent: events.copy()}
with mock.patch.object(InventoryUpdateEvent.objects, 'bulk_create', side_effect=ValueError):
with mock.patch.object(events[0], 'save', side_effect=ValueError):
worker.flush()
assert "\x00" not in events[0].stdout
worker.last_flush = time.time() - 2.0
worker.flush()
event = InventoryUpdateEvent.objects.get(uuid=events[0].uuid)
assert "\x00" not in event.stdout

View File

@@ -103,10 +103,6 @@ ColorHandler = logging.StreamHandler
if settings.COLOR_LOGS is True: if settings.COLOR_LOGS is True:
try: try:
from logutils.colorize import ColorizingStreamHandler from logutils.colorize import ColorizingStreamHandler
import colorama
colorama.deinit()
colorama.init(wrap=False, convert=False, strip=False)
class ColorHandler(ColorizingStreamHandler): class ColorHandler(ColorizingStreamHandler):
def colorize(self, line, record): def colorize(self, line, record):

View File

@@ -418,9 +418,6 @@ AUTH_BASIC_ENABLED = True
# when trying to access a UI page that requries authentication. # when trying to access a UI page that requries authentication.
LOGIN_REDIRECT_OVERRIDE = '' LOGIN_REDIRECT_OVERRIDE = ''
# Note: This setting may be overridden by database settings.
ALLOW_METRICS_FOR_ANONYMOUS_USERS = False
DEVSERVER_DEFAULT_ADDR = '0.0.0.0' DEVSERVER_DEFAULT_ADDR = '0.0.0.0'
DEVSERVER_DEFAULT_PORT = '8013' DEVSERVER_DEFAULT_PORT = '8013'

View File

@@ -452,10 +452,7 @@ def on_populate_user(sender, **kwargs):
remove = bool(team_opts.get('remove', True)) remove = bool(team_opts.get('remove', True))
state = _update_m2m_from_groups(ldap_user, users_opts, remove) state = _update_m2m_from_groups(ldap_user, users_opts, remove)
if state is not None: if state is not None:
organization = team_opts['organization'] desired_team_states[team_name] = {'member_role': state}
if organization not in desired_team_states:
desired_team_states[organization] = {}
desired_team_states[organization][team_name] = {'member_role': state}
# Check if user.profile is available, otherwise force user.save() # Check if user.profile is available, otherwise force user.save()
try: try:
@@ -476,28 +473,16 @@ def on_populate_user(sender, **kwargs):
def reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, source): def reconcile_users_org_team_mappings(user, desired_org_states, desired_team_states, source):
#
# Arguments:
# user - a user object
# desired_org_states: { '<org_name>': { '<role>': <boolean> or None } }
# desired_team_states: { '<org_name>': { '<team name>': { '<role>': <boolean> or None } } }
# source - a text label indicating the "authentication adapter" for debug messages
#
# This function will load the users existing roles and then based on the deisred states modify the users roles
# True indicates the user needs to be a member of the role
# False indicates the user should not be a member of the role
# None means this function should not change the users membership of a role
#
from awx.main.models import Organization, Team from awx.main.models import Organization, Team
content_types = [] content_types = []
reconcile_items = [] reconcile_items = []
if desired_org_states: if desired_org_states:
content_types.append(ContentType.objects.get_for_model(Organization)) content_types.append(ContentType.objects.get_for_model(Organization))
reconcile_items.append(('organization', desired_org_states)) reconcile_items.append(('organization', desired_org_states, Organization))
if desired_team_states: if desired_team_states:
content_types.append(ContentType.objects.get_for_model(Team)) content_types.append(ContentType.objects.get_for_model(Team))
reconcile_items.append(('team', desired_team_states)) reconcile_items.append(('team', desired_team_states, Team))
if not content_types: if not content_types:
# If both desired states were empty we can simply return because there is nothing to reconcile # If both desired states were empty we can simply return because there is nothing to reconcile
@@ -506,39 +491,24 @@ def reconcile_users_org_team_mappings(user, desired_org_states, desired_team_sta
# users_roles is a flat set of IDs # users_roles is a flat set of IDs
users_roles = set(user.roles.filter(content_type__in=content_types).values_list('pk', flat=True)) users_roles = set(user.roles.filter(content_type__in=content_types).values_list('pk', flat=True))
for object_type, desired_states in reconcile_items: for object_type, desired_states, model in reconcile_items:
# Get all of the roles in the desired states for efficient DB extraction
roles = [] roles = []
# Get a set of named tuples for the org/team name plus all of the roles we got above
if object_type == 'organization':
for sub_dict in desired_states.values(): for sub_dict in desired_states.values():
for role_name in sub_dict: for role_name in sub_dict:
if sub_dict[role_name] is None: if sub_dict[role_name] is None:
continue continue
if role_name not in roles: if role_name not in roles:
roles.append(role_name) roles.append(role_name)
model_roles = Organization.objects.filter(name__in=desired_states.keys()).values_list('name', *roles, named=True)
else:
team_names = []
for teams_dict in desired_states.values():
team_names.extend(teams_dict.keys())
for sub_dict in teams_dict.values():
for role_name in sub_dict:
if sub_dict[role_name] is None:
continue
if role_name not in roles:
roles.append(role_name)
model_roles = Team.objects.filter(name__in=team_names).values_list('name', 'organization__name', *roles, named=True)
# Get a set of named tuples for the org/team name plus all of the roles we got above
model_roles = model.objects.filter(name__in=desired_states.keys()).values_list('name', *roles, named=True)
for row in model_roles: for row in model_roles:
for role_name in roles: for role_name in roles:
if object_type == 'organization':
desired_state = desired_states.get(row.name, {}) desired_state = desired_states.get(row.name, {})
else: if desired_state[role_name] is None:
desired_state = desired_states.get(row.organization__name, {}).get(row.name, {})
if desired_state.get(role_name, None) is None:
# The mapping was not defined for this [org/team]/role so we can just pass # The mapping was not defined for this [org/team]/role so we can just pass
continue pass
# If somehow the auth adapter knows about an items role but that role is not defined in the DB we are going to print a pretty error # If somehow the auth adapter knows about an items role but that role is not defined in the DB we are going to print a pretty error
# This is your classic safety net that we should never hit; but here you are reading this comment... good luck and Godspeed. # This is your classic safety net that we should never hit; but here you are reading this comment... good luck and Godspeed.

View File

@@ -34,14 +34,8 @@ const QS_CONFIG = getQSConfig('template', {
order_by: 'name', order_by: 'name',
}); });
const resources = { function RelatedTemplateList({ searchParams, projectName = null }) {
projects: 'project', const { id: projectId } = useParams();
inventories: 'inventory',
credentials: 'credentials',
};
function RelatedTemplateList({ searchParams, resourceName = null }) {
const { id } = useParams();
const location = useLocation(); const location = useLocation();
const { addToast, Toast, toastProps } = useToast(); const { addToast, Toast, toastProps } = useToast();
@@ -135,19 +129,12 @@ function RelatedTemplateList({ searchParams, resourceName = null }) {
actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
let linkTo = ''; let linkTo = '';
if (resourceName) {
const queryString = { if (projectName) {
resource_id: id, const qs = encodeQueryString({
resource_name: resourceName, project_id: projectId,
resource_type: resources[location.pathname.split('/')[1]], project_name: projectName,
resource_kind: null, });
};
if (Array.isArray(resourceName)) {
const [name, kind] = resourceName;
queryString.resource_name = name;
queryString.resource_kind = kind;
}
const qs = encodeQueryString(queryString);
linkTo = `/templates/job_template/add/?${qs}`; linkTo = `/templates/job_template/add/?${qs}`;
} else { } else {
linkTo = '/templates/job_template/add'; linkTo = '/templates/job_template/add';

View File

@@ -1 +0,0 @@
/* eslint-disable import/prefer-default-export */

View File

@@ -55,7 +55,6 @@ function DateTimePicker({ dateFieldName, timeFieldName, label }) {
onChange={onDateChange} onChange={onDateChange}
/> />
<TimePicker <TimePicker
placeholder="hh:mm AM/PM"
stepMinutes={15} stepMinutes={15}
aria-label={ aria-label={
timeFieldName.startsWith('start') ? t`Start time` : t`End time` timeFieldName.startsWith('start') ? t`Start time` : t`End time`

View File

@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { t } from '@lingui/macro';
import {
Button,
Switch,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import {
TableComposable,
Tbody,
Thead,
Th,
Tr,
Td,
} from '@patternfly/react-table';
import { useField } from 'formik';
import ContentEmpty from 'components/ContentEmpty';
function FrequenciesList({ openWizard }) {
const [isShowingRules, setIsShowingRules] = useState(true);
const [frequencies] = useField('frequencies');
const list = (freq) => (
<Tr key={freq.rrule}>
<Td>{freq.frequency}</Td>
<Td>{freq.rrule}</Td>
<Td>{t`End`}</Td>
<Td>
<Button
variant="plain"
aria-label={t`Click to toggle default value`}
ouiaId={freq ? `${freq}-button` : 'new-freq-button'}
onClick={() => {
openWizard(true);
}}
>
<PencilAltIcon />
</Button>
</Td>
</Tr>
);
return (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<Button
onClick={() => {
openWizard(true);
}}
variant="secondary"
>
{isShowingRules ? t`Add RRules` : t`Add Exception`}
</Button>
</ToolbarItem>
<ToolbarItem>
<Switch
label={t`Occurances`}
labelOff={t`Exceptions`}
isChecked={isShowingRules}
onChange={(isChecked) => {
setIsShowingRules(isChecked);
}}
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<div css="overflow: auto">
{frequencies.value[0].frequency === '' &&
frequencies.value.length < 2 ? (
<ContentEmpty title={t`RRules`} message={t`Add RRules`} />
) : (
<TableComposable aria-label={t`RRules`} ouiaId="rrules-list">
<Thead>
<Tr>
<Th>{t`Frequency`}</Th>
<Th>{t`RRule`}</Th>
<Th>{t`Ending`}</Th>
<Th>{t`Actions`}</Th>
</Tr>
</Thead>
<Tbody>{frequencies.value.map((freq, i) => list(freq, i))}</Tbody>
</TableComposable>
)}
</div>
</>
);
}
export default FrequenciesList;

View File

@@ -1,568 +0,0 @@
import 'styled-components/macro';
import React from 'react';
import styled from 'styled-components';
import { useField } from 'formik';
import { t, Trans, Plural } from '@lingui/macro';
import { RRule } from 'rrule';
import {
Checkbox as _Checkbox,
FormGroup,
Radio,
TextInput,
} from '@patternfly/react-core';
import { required, requiredPositiveInteger } from 'util/validators';
import AnsibleSelect from '../../AnsibleSelect';
import FormField from '../../FormField';
import DateTimePicker from './DateTimePicker';
const RunOnRadio = styled(Radio)`
display: flex;
align-items: center;
label {
display: block;
width: 100%;
}
:not(:last-of-type) {
margin-bottom: 10px;
}
select:not(:first-of-type) {
margin-left: 10px;
}
`;
const RunEveryLabel = styled.p`
display: flex;
align-items: center;
`;
const Checkbox = styled(_Checkbox)`
:not(:last-of-type) {
margin-right: 10px;
}
`;
const FrequencyDetailSubform = ({ frequency, prefix, isException }) => {
const id = prefix.replace('.', '-');
const [runOnDayMonth] = useField({
name: `${prefix}.runOnDayMonth`,
});
const [runOnDayNumber] = useField({
name: `${prefix}.runOnDayNumber`,
});
const [runOnTheOccurrence] = useField({
name: `${prefix}.runOnTheOccurrence`,
});
const [runOnTheDay] = useField({
name: `${prefix}.runOnTheDay`,
});
const [runOnTheMonth] = useField({
name: `${prefix}.runOnTheMonth`,
});
const [startDate] = useField(`${prefix}.startDate`);
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField({
name: `${prefix}.daysOfWeek`,
validate: (val) => {
if (frequency === 'week') {
return required(t`Select a value for this field`)(val?.length > 0);
}
return undefined;
},
});
const [end, endMeta] = useField({
name: `${prefix}.end`,
validate: required(t`Select a value for this field`),
});
const [interval, intervalMeta] = useField({
name: `${prefix}.interval`,
validate: requiredPositiveInteger(),
});
const [runOn, runOnMeta] = useField({
name: `${prefix}.runOn`,
validate: (val) => {
if (frequency === 'month' || frequency === 'year') {
return required(t`Select a value for this field`)(val);
}
return undefined;
},
});
const monthOptions = [
{
key: 'january',
value: 1,
label: t`January`,
},
{
key: 'february',
value: 2,
label: t`February`,
},
{
key: 'march',
value: 3,
label: t`March`,
},
{
key: 'april',
value: 4,
label: t`April`,
},
{
key: 'may',
value: 5,
label: t`May`,
},
{
key: 'june',
value: 6,
label: t`June`,
},
{
key: 'july',
value: 7,
label: t`July`,
},
{
key: 'august',
value: 8,
label: t`August`,
},
{
key: 'september',
value: 9,
label: t`September`,
},
{
key: 'october',
value: 10,
label: t`October`,
},
{
key: 'november',
value: 11,
label: t`November`,
},
{
key: 'december',
value: 12,
label: t`December`,
},
];
const updateDaysOfWeek = (day, checked) => {
const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : [];
daysOfWeekHelpers.setTouched(true);
if (checked) {
newDaysOfWeek.push(day);
daysOfWeekHelpers.setValue(newDaysOfWeek);
} else {
daysOfWeekHelpers.setValue(
newDaysOfWeek.filter((selectedDay) => selectedDay !== day)
);
}
};
const getPeriodLabel = () => {
switch (frequency) {
case 'minute':
return t`Minute`;
case 'hour':
return t`Hour`;
case 'day':
return t`Day`;
case 'week':
return t`Week`;
case 'month':
return t`Month`;
case 'year':
return t`Year`;
default:
throw new Error(t`Frequency did not match an expected value`);
}
};
const getRunEveryLabel = () => {
const intervalValue = interval.value;
switch (frequency) {
case 'minute':
return <Plural value={intervalValue} one="minute" other="minutes" />;
case 'hour':
return <Plural value={intervalValue} one="hour" other="hours" />;
case 'day':
return <Plural value={intervalValue} one="day" other="days" />;
case 'week':
return <Plural value={intervalValue} one="week" other="weeks" />;
case 'month':
return <Plural value={intervalValue} one="month" other="months" />;
case 'year':
return <Plural value={intervalValue} one="year" other="years" />;
default:
throw new Error(t`Frequency did not match an expected value`);
}
};
return (
<>
<p css="grid-column: 1/-1">
<b>{getPeriodLabel()}</b>
</p>
<FormGroup
name={`${prefix}.interval`}
fieldId={`schedule-run-every-${id}`}
helperTextInvalid={intervalMeta.error}
isRequired
validated={
!intervalMeta.touched || !intervalMeta.error ? 'default' : 'error'
}
label={isException ? t`Skip every` : t`Run every`}
>
<div css="display: flex">
<TextInput
css="margin-right: 10px;"
id={`schedule-run-every-${id}`}
type="number"
min="1"
step="1"
{...interval}
onChange={(value, event) => {
interval.onChange(event);
}}
/>
<RunEveryLabel>{getRunEveryLabel()}</RunEveryLabel>
</div>
</FormGroup>
{frequency === 'week' && (
<FormGroup
name={`${prefix}.daysOfWeek`}
fieldId={`schedule-days-of-week-${id}`}
helperTextInvalid={daysOfWeekMeta.error}
isRequired
validated={
!daysOfWeekMeta.touched || !daysOfWeekMeta.error
? 'default'
: 'error'
}
label={t`On days`}
>
<div css="display: flex">
<Checkbox
label={t`Sun`}
isChecked={daysOfWeek.value?.includes(RRule.SU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SU, checked);
}}
aria-label={t`Sunday`}
id={`schedule-days-of-week-sun-${id}`}
ouiaId={`schedule-days-of-week-sun-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Mon`}
isChecked={daysOfWeek.value?.includes(RRule.MO)}
onChange={(checked) => {
updateDaysOfWeek(RRule.MO, checked);
}}
aria-label={t`Monday`}
id={`schedule-days-of-week-mon-${id}`}
ouiaId={`schedule-days-of-week-mon-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Tue`}
isChecked={daysOfWeek.value?.includes(RRule.TU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TU, checked);
}}
aria-label={t`Tuesday`}
id={`schedule-days-of-week-tue-${id}`}
ouiaId={`schedule-days-of-week-tue-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Wed`}
isChecked={daysOfWeek.value?.includes(RRule.WE)}
onChange={(checked) => {
updateDaysOfWeek(RRule.WE, checked);
}}
aria-label={t`Wednesday`}
id={`schedule-days-of-week-wed-${id}`}
ouiaId={`schedule-days-of-week-wed-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Thu`}
isChecked={daysOfWeek.value?.includes(RRule.TH)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TH, checked);
}}
aria-label={t`Thursday`}
id={`schedule-days-of-week-thu-${id}`}
ouiaId={`schedule-days-of-week-thu-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Fri`}
isChecked={daysOfWeek.value?.includes(RRule.FR)}
onChange={(checked) => {
updateDaysOfWeek(RRule.FR, checked);
}}
aria-label={t`Friday`}
id={`schedule-days-of-week-fri-${id}`}
ouiaId={`schedule-days-of-week-fri-${id}`}
name={`${prefix}.daysOfWeek`}
/>
<Checkbox
label={t`Sat`}
isChecked={daysOfWeek.value?.includes(RRule.SA)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SA, checked);
}}
aria-label={t`Saturday`}
id={`schedule-days-of-week-sat-${id}`}
ouiaId={`schedule-days-of-week-sat-${id}`}
name={`${prefix}.daysOfWeek`}
/>
</div>
</FormGroup>
)}
{(frequency === 'month' || frequency === 'year') &&
!Number.isNaN(new Date(startDate.value)) && (
<FormGroup
name={`${prefix}.runOn`}
fieldId={`schedule-run-on-${id}`}
helperTextInvalid={runOnMeta.error}
isRequired
validated={
!runOnMeta.touched || !runOnMeta.error ? 'default' : 'error'
}
label={t`Run on`}
>
<RunOnRadio
id={`schedule-run-on-day-${id}`}
name={`${prefix}.runOn`}
label={
<div css="display: flex;align-items: center;">
{frequency === 'month' && (
<span
id="radio-schedule-run-on-day"
css="margin-right: 10px;"
>
<Trans>Day</Trans>
</span>
)}
{frequency === 'year' && (
<AnsibleSelect
id={`schedule-run-on-day-month-${id}`}
css="margin-right: 10px"
isDisabled={runOn.value !== 'day'}
data={monthOptions}
{...runOnDayMonth}
/>
)}
<TextInput
id={`schedule-run-on-day-number-${id}`}
type="number"
min="1"
max="31"
step="1"
isDisabled={runOn.value !== 'day'}
{...runOnDayNumber}
onChange={(value, event) => {
runOnDayNumber.onChange(event);
}}
/>
</div>
}
value="day"
isChecked={runOn.value === 'day'}
onChange={(value, event) => {
event.target.value = 'day';
runOn.onChange(event);
}}
/>
<RunOnRadio
id={`schedule-run-on-the-${id}`}
name={`${prefix}.runOn`}
label={
<div css="display: flex;align-items: center;">
<span
id={`radio-schedule-run-on-the-${id}`}
css="margin-right: 10px;"
>
<Trans>The</Trans>
</span>
<AnsibleSelect
id={`schedule-run-on-the-occurrence-${id}`}
isDisabled={runOn.value !== 'the'}
data={[
{ value: 1, key: 'first', label: t`First` },
{
value: 2,
key: 'second',
label: t`Second`,
},
{ value: 3, key: 'third', label: t`Third` },
{
value: 4,
key: 'fourth',
label: t`Fourth`,
},
{ value: 5, key: 'fifth', label: t`Fifth` },
{ value: -1, key: 'last', label: t`Last` },
]}
{...runOnTheOccurrence}
/>
<AnsibleSelect
id={`schedule-run-on-the-day-${id}`}
isDisabled={runOn.value !== 'the'}
data={[
{
value: 'sunday',
key: 'sunday',
label: t`Sunday`,
},
{
value: 'monday',
key: 'monday',
label: t`Monday`,
},
{
value: 'tuesday',
key: 'tuesday',
label: t`Tuesday`,
},
{
value: 'wednesday',
key: 'wednesday',
label: t`Wednesday`,
},
{
value: 'thursday',
key: 'thursday',
label: t`Thursday`,
},
{
value: 'friday',
key: 'friday',
label: t`Friday`,
},
{
value: 'saturday',
key: 'saturday',
label: t`Saturday`,
},
{ value: 'day', key: 'day', label: t`Day` },
{
value: 'weekday',
key: 'weekday',
label: t`Weekday`,
},
{
value: 'weekendDay',
key: 'weekendDay',
label: t`Weekend day`,
},
]}
{...runOnTheDay}
/>
{frequency === 'year' && (
<>
<span
id={`of-schedule-run-on-the-month-${id}`}
css="margin-left: 10px;"
>
<Trans>of</Trans>
</span>
<AnsibleSelect
id={`schedule-run-on-the-month-${id}`}
isDisabled={runOn.value !== 'the'}
data={monthOptions}
{...runOnTheMonth}
/>
</>
)}
</div>
}
value="the"
isChecked={runOn.value === 'the'}
onChange={(value, event) => {
event.target.value = 'the';
runOn.onChange(event);
}}
/>
</FormGroup>
)}
<FormGroup
name={`${prefix}.end`}
fieldId={`schedule-end-${id}`}
helperTextInvalid={endMeta.error}
isRequired
validated={!endMeta.touched || !endMeta.error ? 'default' : 'error'}
label={t`End`}
>
<Radio
id={`end-never-${id}`}
name={`${prefix}.end`}
label={t`Never`}
value="never"
isChecked={end.value === 'never'}
onChange={(value, event) => {
event.target.value = 'never';
end.onChange(event);
}}
ouiaId={`end-never-radio-button-${id}`}
/>
<Radio
id={`end-after-${id}`}
name={`${prefix}.end`}
label={t`After number of occurrences`}
value="after"
isChecked={end.value === 'after'}
onChange={(value, event) => {
event.target.value = 'after';
end.onChange(event);
}}
ouiaId={`end-after-radio-button-${id}`}
/>
<Radio
id={`end-on-date-${id}`}
name={`${prefix}.end`}
label={t`On date`}
value="onDate"
isChecked={end.value === 'onDate'}
onChange={(value, event) => {
event.target.value = 'onDate';
end.onChange(event);
}}
ouiaId={`end-on-radio-button-${id}`}
/>
</FormGroup>
{end?.value === 'after' && (
<FormField
id={`schedule-occurrences-${id}`}
label={t`Occurrences`}
name={`${prefix}.occurrences`}
type="number"
min="1"
step="1"
isRequired
/>
)}
{end?.value === 'onDate' && (
<DateTimePicker
dateFieldName={`${prefix}.endDate`}
timeFieldName={`${prefix}.endTime`}
label={t`End date/time`}
/>
)}
</>
);
};
export default FrequencyDetailSubform;

View File

@@ -1,30 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { arrayOf, string } from 'prop-types'; import { t } from '@lingui/macro';
import { useField } from 'formik';
import { RRule } from 'rrule';
import { Select, SelectOption, SelectVariant } from '@patternfly/react-core'; import { Select, SelectOption, SelectVariant } from '@patternfly/react-core';
export default function FrequencySelect({ export default function FrequencySelect({ id, onBlur, placeholderText }) {
id,
value,
onChange,
onBlur,
placeholderText,
children,
}) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [frequency, , frequencyHelpers] = useField('freq');
const onSelect = (event, selectedValue) => {
if (selectedValue === 'none') {
onChange([]);
setIsOpen(false);
return;
}
const index = value.indexOf(selectedValue);
if (index === -1) {
onChange(value.concat(selectedValue));
} else {
onChange(value.slice(0, index).concat(value.slice(index + 1)));
}
};
const onToggle = (val) => { const onToggle = (val) => {
if (!val) { if (!val) {
@@ -35,21 +17,26 @@ export default function FrequencySelect({
return ( return (
<Select <Select
variant={SelectVariant.checkbox} onSelect={(e, v) => {
onSelect={onSelect} frequencyHelpers.setValue(v);
selections={value} setIsOpen(false);
}}
selections={frequency.value}
placeholderText={placeholderText} placeholderText={placeholderText}
onToggle={onToggle} onToggle={onToggle}
value={frequency.value}
isOpen={isOpen} isOpen={isOpen}
ouiaId={`frequency-select-${id}`} ouiaId={`frequency-select-${id}`}
onBlur={() => frequencyHelpers.setTouched(true)}
> >
{children} <SelectOption value={RRule.MINUTELY}>{t`Minute`}</SelectOption>
<SelectOption value={RRule.HOURLY}>{t`Hour`}</SelectOption>
<SelectOption value={RRule.DAILY}>{t`Day`}</SelectOption>
<SelectOption value={RRule.WEEKLY}>{t`Week`}</SelectOption>
<SelectOption value={RRule.MONTHLY}>{t`Month`}</SelectOption>
<SelectOption value={RRule.YEARLY}>{t`Year`}</SelectOption>
</Select> </Select>
); );
} }
FrequencySelect.propTypes = {
value: arrayOf(string).isRequired,
};
export { SelectOption, SelectVariant }; export { SelectOption, SelectVariant };

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { t } from '@lingui/macro';
import AnsibleSelect from 'components/AnsibleSelect';
import styled from 'styled-components';
import {
FormGroup,
Checkbox as _Checkbox,
Grid,
GridItem,
} from '@patternfly/react-core';
import { useField } from 'formik';
import { bysetposOptions, monthOptions } from './scheduleFormHelpers';
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
const Checkbox = styled(_Checkbox)`
:not(:last-of-type) {
margin-right: 10px;
}
`;
function MonthandYearForm({ id }) {
const [bySetPos, , bySetPosHelpers] = useField('bysetpos');
const [byMonth, , byMonthHelpers] = useField('bymonth');
return (
<>
<GroupWrapper
fieldId={`schedule-run-on-${id}`}
label={<b>{t`Run on a specific month`}</b>}
>
<Grid hasGutter>
{monthOptions.map((month) => (
<GridItem key={month.label} span={2} rowSpan={2}>
<Checkbox
label={month.label}
isChecked={byMonth.value?.includes(month.value)}
onChange={(checked) => {
if (checked) {
byMonthHelpers.setValue([...byMonth.value, month.value]);
} else {
const removed = byMonth.value.filter(
(i) => i !== month.value
);
byMonthHelpers.setValue(removed);
}
}}
id={`bymonth-${month.label}`}
ouiaId={`bymonth-${month.label}`}
name="bymonth"
/>
</GridItem>
))}
</Grid>
</GroupWrapper>
<GroupWrapper
label={<b>{t`Run on a specific week day at monthly intervals`}</b>}
>
<AnsibleSelect
id={`schedule-run-on-the-occurrence-${id}`}
data={bysetposOptions}
{...bySetPos}
onChange={(e, v) => {
bySetPosHelpers.setValue(v);
}}
/>
</GroupWrapper>
</>
);
}
export default MonthandYearForm;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { useField } from 'formik';
import { FormGroup, TextInput } from '@patternfly/react-core';
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
function OrdinalDayForm() {
const [byMonthDay] = useField('bymonthday');
const [byYearDay] = useField('byyearday');
return (
<GroupWrapper
label={<b>{t`On a specific number day`}</b>}
name="ordinalDay"
>
<TextInput
placeholder={t`Run on a day of month`}
aria-label={t`Type a numbered day`}
type="number"
onChange={(value, event) => {
byMonthDay.onChange(event);
}}
/>
<TextInput
placeholder={t`Run on a day of year`}
aria-label={t`Type a numbered day`}
type="number"
onChange={(value, event) => {
byYearDay.onChange(event);
}}
/>
</GroupWrapper>
);
}
export default OrdinalDayForm;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { useField } from 'formik';
import { t } from '@lingui/macro';
import { FormGroup, Radio } from '@patternfly/react-core';
import FormField from 'components/FormField';
import DateTimePicker from './DateTimePicker';
function ScheduleEndForm() {
const [endType, , { setValue }] = useField('endingType');
const [count] = useField('count');
return (
<>
<FormGroup name="end" label={t`End`}>
<Radio
id="endNever"
name={t`Never End`}
label={t`Never`}
value="never"
isChecked={endType.value === 'never'}
onChange={() => {
setValue('never');
}}
/>
<Radio
name="Count"
id="after"
label={t`After number of occurrences`}
value="after"
isChecked={endType.value === 'after'}
onChange={() => {
setValue('after');
}}
/>
<Radio
name="End Date"
label={t`On date`}
value="onDate"
id="endDate"
isChecked={endType.value === 'onDate'}
onChange={() => {
setValue('onDate');
}}
/>
</FormGroup>
{endType.value === 'after' && (
<FormField
label={t`Occurrences`}
name="count"
type="number"
min="1"
step="1"
isRequired
{...count}
/>
)}
{endType.value === 'onDate' && (
<DateTimePicker
dateFieldName="endDate"
timeFieldName="endTime"
label={t`End date/time`}
/>
)}
</>
);
}
export default ScheduleEndForm;

View File

@@ -18,14 +18,9 @@ import SchedulePromptableFields from './SchedulePromptableFields';
import ScheduleFormFields from './ScheduleFormFields'; import ScheduleFormFields from './ScheduleFormFields';
import UnsupportedScheduleForm from './UnsupportedScheduleForm'; import UnsupportedScheduleForm from './UnsupportedScheduleForm';
import parseRuleObj, { UnsupportedRRuleError } from './parseRuleObj'; import parseRuleObj, { UnsupportedRRuleError } from './parseRuleObj';
import buildRuleObj from './buildRuleObj'; import ScheduleFormWizard from './ScheduleFormWizard';
import buildRuleSet from './buildRuleSet'; import FrequenciesList from './FrequenciesList';
// import { validateSchedule } from './scheduleFormHelpers';
const NUM_DAYS_PER_FREQUENCY = {
week: 7,
month: 31,
year: 365,
};
function ScheduleForm({ function ScheduleForm({
hasDaysToKeepField, hasDaysToKeepField,
@@ -40,15 +35,16 @@ function ScheduleForm({
}) { }) {
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
const [isSaveDisabled, setIsSaveDisabled] = useState(false); const [isSaveDisabled, setIsSaveDisabled] = useState(false);
const [isScheduleWizardOpen, setIsScheduleWizardOpen] = useState(false);
const originalLabels = useRef([]); const originalLabels = useRef([]);
const originalInstanceGroups = useRef([]); const originalInstanceGroups = useRef([]);
let rruleError; let rruleError;
const now = DateTime.now(); const now = DateTime.now();
const closestQuarterHour = DateTime.fromMillis( const closestQuarterHour = DateTime.fromMillis(
Math.ceil(now.ts / 900000) * 900000 Math.ceil(now.ts / 900000) * 900000
); );
const tomorrow = closestQuarterHour.plus({ days: 1 });
const isTemplate = const isTemplate =
resource.type === 'workflow_job_template' || resource.type === 'workflow_job_template' ||
resource.type === 'job_template'; resource.type === 'job_template';
@@ -283,69 +279,10 @@ function ScheduleForm({
} }
const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO()); const [currentDate, time] = dateToInputDateTime(closestQuarterHour.toISO());
const [tomorrowDate] = dateToInputDateTime(tomorrow.toISO());
const initialFrequencyOptions = {
minute: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
},
hour: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
},
day: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
},
week: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
daysOfWeek: [],
},
month: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
runOn: 'day',
runOnTheOccurrence: 1,
runOnTheDay: 'sunday',
runOnDayNumber: 1,
},
year: {
interval: 1,
end: 'never',
occurrences: 1,
endDate: tomorrowDate,
endTime: time,
runOn: 'day',
runOnTheOccurrence: 1,
runOnTheDay: 'sunday',
runOnTheMonth: 1,
runOnDayMonth: 1,
runOnDayNumber: 1,
},
};
const initialValues = { const initialValues = {
description: schedule.description || '', description: schedule.description || '',
frequency: [], frequencies: [],
exceptionFrequency: [], exceptionFrequency: [],
frequencyOptions: initialFrequencyOptions,
exceptionOptions: initialFrequencyOptions,
name: schedule.name || '', name: schedule.name || '',
startDate: currentDate, startDate: currentDate,
startTime: time, startTime: time,
@@ -367,11 +304,9 @@ function ScheduleForm({
} }
initialValues.daysToKeep = initialDaysToKeep; initialValues.daysToKeep = initialDaysToKeep;
} }
let overriddenValues = {};
if (schedule.rrule) { if (schedule.rrule) {
try { try {
overriddenValues = parseRuleObj(schedule); parseRuleObj(schedule);
} catch (error) { } catch (error) {
if (error instanceof UnsupportedRRuleError) { if (error instanceof UnsupportedRRuleError) {
return ( return (
@@ -394,89 +329,33 @@ function ScheduleForm({
if (contentLoading) { if (contentLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
const frequencies = [];
const validate = (values) => { frequencies.push(parseRuleObj(schedule));
const errors = {};
values.frequency.forEach((freq) => {
const options = values.frequencyOptions[freq];
const freqErrors = {};
if (
(freq === 'month' || freq === 'year') &&
options.runOn === 'day' &&
(options.runOnDayNumber < 1 || options.runOnDayNumber > 31)
) {
freqErrors.runOn = t`Please select a day number between 1 and 31.`;
}
if (options.end === 'after' && !options.occurrences) {
freqErrors.occurrences = t`Please enter a number of occurrences.`;
}
if (options.end === 'onDate') {
if (
DateTime.fromFormat(
`${values.startDate} ${values.startTime}`,
'yyyy-LL-dd h:mm a'
).toMillis() >=
DateTime.fromFormat(
`${options.endDate} ${options.endTime}`,
'yyyy-LL-dd h:mm a'
).toMillis()
) {
freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`;
}
if (
DateTime.fromISO(options.endDate)
.diff(DateTime.fromISO(values.startDate), 'days')
.toObject().days < NUM_DAYS_PER_FREQUENCY[freq]
) {
const rule = new RRule(
buildRuleObj({
startDate: values.startDate,
startTime: values.startTime,
frequency: freq,
...options,
})
);
if (rule.all().length === 0) {
errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`;
freqErrors.endDate = t`Selected date range must have at least 1 schedule occurrence.`;
}
}
}
if (Object.keys(freqErrors).length > 0) {
if (!errors.frequencyOptions) {
errors.frequencyOptions = {};
}
errors.frequencyOptions[freq] = freqErrors;
}
});
if (values.exceptionFrequency.length > 0 && !scheduleHasInstances(values)) {
errors.exceptionFrequency = t`This schedule has no occurrences due to the selected exceptions.`;
}
return errors;
};
return ( return (
<Config> <Config>
{() => ( {() => (
<Formik <Formik
initialValues={{ initialValues={{
...initialValues, name: schedule.name || '',
...overriddenValues, description: schedule.description || '',
frequencyOptions: { frequencies: frequencies || [],
...initialValues.frequencyOptions, freq: RRule.DAILY,
...overriddenValues.frequencyOptions, interval: 1,
}, wkst: RRule.SU,
exceptionOptions: { byweekday: [],
...initialValues.exceptionOptions, byweekno: [],
...overriddenValues.exceptionOptions, bymonth: [],
}, bymonthday: '',
byyearday: '',
bysetpos: '',
until: schedule.until || null,
endDate: currentDate,
endTime: time,
count: 1,
endingType: 'never',
timezone: schedule.timezone || now.zoneName,
startDate: currentDate,
startTime: time,
}} }}
onSubmit={(values) => { onSubmit={(values) => {
submitSchedule( submitSchedule(
@@ -488,9 +367,10 @@ function ScheduleForm({
credentials credentials
); );
}} }}
validate={validate} validate={() => {}}
> >
{(formik) => ( {(formik) => (
<>
<Form autoComplete="off" onSubmit={formik.handleSubmit}> <Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout> <FormColumnLayout>
<ScheduleFormFields <ScheduleFormFields
@@ -517,6 +397,9 @@ function ScheduleForm({
instanceGroups={originalInstanceGroups.current} instanceGroups={originalInstanceGroups.current}
/> />
)} )}
<FormFullWidthLayout>
<FrequenciesList openWizard={setIsScheduleWizardOpen} />
</FormFullWidthLayout>
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />
<FormFullWidthLayout> <FormFullWidthLayout>
<ActionGroup> <ActionGroup>
@@ -531,6 +414,10 @@ function ScheduleForm({
{t`Save`} {t`Save`}
</Button> </Button>
<Button
onClick={() => {}}
>{t`Preview occurances`}</Button>
{isTemplate && showPromptButton && ( {isTemplate && showPromptButton && (
<Button <Button
ouiaId="schedule-form-prompt-button" ouiaId="schedule-form-prompt-button"
@@ -555,6 +442,15 @@ function ScheduleForm({
</FormFullWidthLayout> </FormFullWidthLayout>
</FormColumnLayout> </FormColumnLayout>
</Form> </Form>
{isScheduleWizardOpen && (
<ScheduleFormWizard
staticFormFormkik={formik}
isOpen={isScheduleWizardOpen}
handleSave={() => {}}
setIsOpen={setIsScheduleWizardOpen}
/>
)}
</>
)} )}
</Formik> </Formik>
)} )}
@@ -575,24 +471,3 @@ ScheduleForm.defaultProps = {
}; };
export default ScheduleForm; export default ScheduleForm;
function scheduleHasInstances(values) {
let rangeToCheck = 1;
values.frequency.forEach((freq) => {
if (NUM_DAYS_PER_FREQUENCY[freq] > rangeToCheck) {
rangeToCheck = NUM_DAYS_PER_FREQUENCY[freq];
}
});
const ruleSet = buildRuleSet(values, true);
const startDate = DateTime.fromISO(values.startDate);
const endDate = startDate.plus({ days: rangeToCheck });
const instances = ruleSet.between(
startDate.toJSDate(),
endDate.toJSDate(),
true,
(date, i) => i === 0
);
return instances.length > 0;
}

View File

@@ -1,41 +1,27 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useField } from 'formik'; import { useField } from 'formik';
import { FormGroup, Title } from '@patternfly/react-core'; import { FormGroup } from '@patternfly/react-core';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components';
import 'styled-components/macro';
import FormField from 'components/FormField'; import FormField from 'components/FormField';
import { required } from 'util/validators'; import { required } from 'util/validators';
import { useConfig } from 'contexts/Config'; import { useConfig } from 'contexts/Config';
import Popover from '../../Popover'; import Popover from '../../Popover';
import AnsibleSelect from '../../AnsibleSelect'; import AnsibleSelect from '../../AnsibleSelect';
import FrequencySelect, { SelectOption } from './FrequencySelect';
import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext'; import getHelpText from '../../../screens/Template/shared/JobTemplate.helptext';
import { SubFormLayout, FormColumnLayout } from '../../FormLayout';
import FrequencyDetailSubform from './FrequencyDetailSubform';
import DateTimePicker from './DateTimePicker'; import DateTimePicker from './DateTimePicker';
import sortFrequencies from './sortFrequencies';
const SelectClearOption = styled(SelectOption)`
& > input[type='checkbox'] {
display: none;
}
`;
export default function ScheduleFormFields({ export default function ScheduleFormFields({
hasDaysToKeepField, hasDaysToKeepField,
zoneOptions, zoneOptions,
zoneLinks, zoneLinks,
setTimeZone,
}) { }) {
const helpText = getHelpText(); const helpText = getHelpText();
const [timezone, timezoneMeta] = useField({ const [timezone, timezoneMeta] = useField({
name: 'timezone', name: 'timezone',
validate: required(t`Select a value for this field`), validate: required(t`Select a value for this field`),
}); });
const [frequency, frequencyMeta, frequencyHelper] = useField({
name: 'frequency',
validate: required(t`Select a value for this field`),
});
const [timezoneMessage, setTimezoneMessage] = useState(''); const [timezoneMessage, setTimezoneMessage] = useState('');
const warnLinkedTZ = (event, selectedValue) => { const warnLinkedTZ = (event, selectedValue) => {
if (zoneLinks[selectedValue]) { if (zoneLinks[selectedValue]) {
@@ -46,6 +32,7 @@ export default function ScheduleFormFields({
setTimezoneMessage(''); setTimezoneMessage('');
} }
timezone.onChange(event, selectedValue); timezone.onChange(event, selectedValue);
setTimeZone(zoneLinks(selectedValue));
}; };
let timezoneValidatedStatus = 'default'; let timezoneValidatedStatus = 'default';
if (timezoneMeta.touched && timezoneMeta.error) { if (timezoneMeta.touched && timezoneMeta.error) {
@@ -55,16 +42,6 @@ export default function ScheduleFormFields({
} }
const config = useConfig(); const config = useConfig();
const [exceptionFrequency, exceptionFrequencyMeta, exceptionFrequencyHelper] =
useField({
name: 'exceptionFrequency',
validate: required(t`Select a value for this field`),
});
const updateFrequency = (setFrequency) => (values) => {
setFrequency(values.sort(sortFrequencies));
};
return ( return (
<> <>
<FormField <FormField
@@ -103,33 +80,7 @@ export default function ScheduleFormFields({
onChange={warnLinkedTZ} onChange={warnLinkedTZ}
/> />
</FormGroup> </FormGroup>
<FormGroup
name="frequency"
fieldId="schedule-frequency"
helperTextInvalid={frequencyMeta.error}
validated={
!frequencyMeta.touched || !frequencyMeta.error ? 'default' : 'error'
}
label={t`Repeat frequency`}
>
<FrequencySelect
id="schedule-frequency"
onChange={updateFrequency(frequencyHelper.setValue)}
value={frequency.value}
placeholderText={
frequency.value.length ? t`Select frequency` : t`None (run once)`
}
onBlur={frequencyHelper.setTouched}
>
<SelectClearOption value="none">{t`None (run once)`}</SelectClearOption>
<SelectOption value="minute">{t`Minute`}</SelectOption>
<SelectOption value="hour">{t`Hour`}</SelectOption>
<SelectOption value="day">{t`Day`}</SelectOption>
<SelectOption value="week">{t`Week`}</SelectOption>
<SelectOption value="month">{t`Month`}</SelectOption>
<SelectOption value="year">{t`Year`}</SelectOption>
</FrequencySelect>
</FormGroup>
{hasDaysToKeepField ? ( {hasDaysToKeepField ? (
<FormField <FormField
id="schedule-days-to-keep" id="schedule-days-to-keep"
@@ -140,68 +91,6 @@ export default function ScheduleFormFields({
isRequired isRequired
/> />
) : null} ) : null}
{frequency.value.length ? (
<SubFormLayout>
<Title size="md" headingLevel="h4">
{t`Frequency Details`}
</Title>
{frequency.value.map((val) => (
<FormColumnLayout key={val} stacked>
<FrequencyDetailSubform
frequency={val}
prefix={`frequencyOptions.${val}`}
/>
</FormColumnLayout>
))}
<Title
size="md"
headingLevel="h4"
css="margin-top: var(--pf-c-card--child--PaddingRight)"
>{t`Exceptions`}</Title>
<FormColumnLayout stacked>
<FormGroup
name="exceptions"
fieldId="exception-frequency"
helperTextInvalid={exceptionFrequencyMeta.error}
validated={
!exceptionFrequencyMeta.touched || !exceptionFrequencyMeta.error
? 'default'
: 'error'
}
label={t`Add exceptions`}
>
<FrequencySelect
id="exception-frequency"
onChange={updateFrequency(exceptionFrequencyHelper.setValue)}
value={exceptionFrequency.value}
placeholderText={
exceptionFrequency.value.length
? t`Select frequency`
: t`None`
}
onBlur={exceptionFrequencyHelper.setTouched}
>
<SelectClearOption value="none">{t`None`}</SelectClearOption>
<SelectOption value="minute">{t`Minute`}</SelectOption>
<SelectOption value="hour">{t`Hour`}</SelectOption>
<SelectOption value="day">{t`Day`}</SelectOption>
<SelectOption value="week">{t`Week`}</SelectOption>
<SelectOption value="month">{t`Month`}</SelectOption>
<SelectOption value="year">{t`Year`}</SelectOption>
</FrequencySelect>
</FormGroup>
</FormColumnLayout>
{exceptionFrequency.value.map((val) => (
<FormColumnLayout key={val} stacked>
<FrequencyDetailSubform
frequency={val}
prefix={`exceptionOptions.${val}`}
isException
/>
</FormColumnLayout>
))}
</SubFormLayout>
) : null}
</> </>
); );
} }

View File

@@ -0,0 +1,199 @@
import React from 'react';
import {
Button,
FormGroup,
TextInput,
Title,
Wizard,
WizardContextConsumer,
WizardFooter,
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { RRule } from 'rrule';
import { useField, useFormikContext } from 'formik';
import { DateTime } from 'luxon';
import { formatDateString } from 'util/dates';
import FrequencySelect from './FrequencySelect';
import MonthandYearForm from './MonthandYearForm';
import OrdinalDayForm from './OrdinalDayForm';
import WeekdayForm from './WeekdayForm';
import ScheduleEndForm from './ScheduleEndForm';
import parseRuleObj from './parseRuleObj';
import { buildDtStartObj } from './buildRuleObj';
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
function ScheduleFormWizard({ isOpen, setIsOpen }) {
const { values, resetForm, initialValues } = useFormikContext();
const [freq, freqMeta] = useField('freq');
const [{ value: frequenciesValue }] = useField('frequencies');
const [interval, , intervalHelpers] = useField('interval');
const handleSubmit = (goToStepById) => {
const {
name,
description,
endingType,
endTime,
endDate,
timezone,
startDate,
startTime,
frequencies,
...rest
} = values;
if (endingType === 'onDate') {
const dt = DateTime.fromFormat(
`${endDate} ${endTime}`,
'yyyy-MM-dd h:mm a',
{
zone: timezone,
}
);
rest.until = formatDateString(dt, timezone);
delete rest.count;
}
if (endingType === 'never') delete rest.count;
const rule = new RRule(rest);
const start = buildDtStartObj({
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequency: values.freq,
});
const newFrequency = parseRuleObj({
timezone,
frequency: freq.value,
rrule: rule.toString(),
dtstart: start,
});
if (goToStepById) {
goToStepById(1);
}
resetForm({
values: {
...initialValues,
description: values.description,
name: values.name,
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequencies: frequenciesValue[0].frequency.length
? [...frequenciesValue, newFrequency]
: [newFrequency],
},
});
};
const CustomFooter = (
<WizardFooter>
<WizardContextConsumer>
{({ activeStep, onNext, onBack, goToStepById }) => (
<>
{activeStep.id === 2 ? (
<>
<Button
variant="primary"
onClick={() => {
handleSubmit(true, goToStepById);
}}
>{t`Finish and create new`}</Button>
<Button
variant="secondary"
onClick={() => {
handleSubmit(false);
setIsOpen(false);
}}
>{t`Finish and close`}</Button>
</>
) : (
<Button variant="primary" onClick={onNext}>{t`Next`}</Button>
)}
<Button variant="secondary" onClick={onBack}>{t`Back`}</Button>
<Button
variant="plain"
onClick={() => {
setIsOpen(false);
resetForm({
values: {
...initialValues,
description: values.description,
name: values.name,
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequencies: values.frequencies,
},
});
}}
>{t`Cancel`}</Button>
</>
)}
</WizardContextConsumer>
</WizardFooter>
);
return (
<Wizard
onClose={() => setIsOpen(false)}
isOpen={isOpen}
footer={CustomFooter}
steps={[
{
key: 'frequency',
name: 'Frequency',
id: 1,
component: (
<>
<Title size="md" headingLevel="h4">{t`Repeat frequency`}</Title>
<GroupWrapper
name="freq"
fieldId="schedule-frequency"
isRequired
helperTextInvalid={freqMeta.error}
validated={
!freqMeta.touched || !freqMeta.error ? 'default' : 'error'
}
label={<b>{t`Frequency`}</b>}
>
<FrequencySelect />
</GroupWrapper>
<GroupWrapper isRequired label={<b>{t`Interval`}</b>}>
<TextInput
type="number"
value={interval.value}
placeholder={t`Choose an interval for the schedule`}
aria-label={t`Choose an interval for the schedule`}
onChange={(v) => intervalHelpers.setValue(v)}
/>
</GroupWrapper>
<WeekdayForm />
<MonthandYearForm />
<OrdinalDayForm />
</>
),
},
{
name: 'End',
key: 'end',
id: 2,
component: <ScheduleEndForm />,
},
]}
/>
);
}
export default ScheduleFormWizard;

View File

@@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { t } from '@lingui/macro';
import {
Checkbox as _Checkbox,
FormGroup,
Select,
SelectOption,
} from '@patternfly/react-core';
import { useField } from 'formik';
import { RRule } from 'rrule';
import styled from 'styled-components';
import { weekdayOptions } from './scheduleFormHelpers';
const Checkbox = styled(_Checkbox)`
:not(:last-of-type) {
margin-right: 10px;
}
`;
const GroupWrapper = styled(FormGroup)`
&& .pf-c-form__group-control {
display: flex;
padding-top: 10px;
}
&& .pf-c-form__group-label {
padding-top: 20px;
}
`;
function WeekdayForm({ id }) {
const [isOpen, setIsOpen] = useState(false);
const [daysOfWeek, daysOfWeekMeta, daysOfWeekHelpers] = useField('byweekday');
const [weekStartDay, , weekStartDayHelpers] = useField('wkst');
const updateDaysOfWeek = (day, checked) => {
const newDaysOfWeek = daysOfWeek.value ? [...daysOfWeek.value] : [];
daysOfWeekHelpers.setTouched(true);
if (checked) {
newDaysOfWeek.push(day);
daysOfWeekHelpers.setValue(newDaysOfWeek);
} else {
daysOfWeekHelpers.setValue(
newDaysOfWeek.filter((selectedDay) => selectedDay !== day)
);
}
};
return (
<>
<GroupWrapper
name="wkst"
label={<b>{t`Select the first day of the week`}</b>}
>
<Select
onSelect={(e, value) => {
weekStartDayHelpers.setValue(value);
setIsOpen(false);
}}
onBlur={() => setIsOpen(false)}
selections={weekStartDay.value}
onToggle={(isopen) => setIsOpen(isopen)}
isOpen={isOpen}
id={`schedule-run-on-the-day-${id}`}
onChange={(e, v) => {
weekStartDayHelpers.setValue(v);
}}
{...weekStartDay}
>
{weekdayOptions.map(({ key, value, label }) => (
<SelectOption key={key} value={value}>
{label}
</SelectOption>
))}
</Select>
</GroupWrapper>
<GroupWrapper
name="byweekday"
fieldId={`schedule-days-of-week-${id}`}
helperTextInvalid={daysOfWeekMeta.error}
validated={
!daysOfWeekMeta.touched || !daysOfWeekMeta.error ? 'default' : 'error'
}
label={<b>{t`On selected day(s) of the week`}</b>}
>
<Checkbox
label={t`Sun`}
isChecked={daysOfWeek.value?.includes(RRule.SU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SU, checked);
}}
aria-label={t`Sunday`}
id={`schedule-days-of-week-sun-${id}`}
ouiaId={`schedule-days-of-week-sun-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Mon`}
isChecked={daysOfWeek.value?.includes(RRule.MO)}
onChange={(checked) => {
updateDaysOfWeek(RRule.MO, checked);
}}
aria-label={t`Monday`}
id={`schedule-days-of-week-mon-${id}`}
ouiaId={`schedule-days-of-week-mon-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Tue`}
isChecked={daysOfWeek.value?.includes(RRule.TU)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TU, checked);
}}
aria-label={t`Tuesday`}
id={`schedule-days-of-week-tue-${id}`}
ouiaId={`schedule-days-of-week-tue-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Wed`}
isChecked={daysOfWeek.value?.includes(RRule.WE)}
onChange={(checked) => {
updateDaysOfWeek(RRule.WE, checked);
}}
aria-label={t`Wednesday`}
id={`schedule-days-of-week-wed-${id}`}
ouiaId={`schedule-days-of-week-wed-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Thu`}
isChecked={daysOfWeek.value?.includes(RRule.TH)}
onChange={(checked) => {
updateDaysOfWeek(RRule.TH, checked);
}}
aria-label={t`Thursday`}
id={`schedule-days-of-week-thu-${id}`}
ouiaId={`schedule-days-of-week-thu-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Fri`}
isChecked={daysOfWeek.value?.includes(RRule.FR)}
onChange={(checked) => {
updateDaysOfWeek(RRule.FR, checked);
}}
aria-label={t`Friday`}
id={`schedule-days-of-week-fri-${id}`}
ouiaId={`schedule-days-of-week-fri-${id}`}
name="daysOfWeek"
/>
<Checkbox
label={t`Sat`}
isChecked={daysOfWeek.value?.includes(RRule.SA)}
onChange={(checked) => {
updateDaysOfWeek(RRule.SA, checked);
}}
aria-label={t`Saturday`}
id={`schedule-days-of-week-sat-${id}`}
ouiaId={`schedule-days-of-week-sat-${id}`}
name="daysOfWeek"
/>
</GroupWrapper>
</>
);
}
export default WeekdayForm;

View File

@@ -1,7 +1,5 @@
import { t } from '@lingui/macro';
import { RRule } from 'rrule'; import { RRule } from 'rrule';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getRRuleDayConstants } from 'util/dates';
window.RRule = RRule; window.RRule = RRule;
window.DateTime = DateTime; window.DateTime = DateTime;
@@ -22,7 +20,7 @@ export function buildDtStartObj(values) {
startHour startHour
)}${pad(startMinute)}00`; )}${pad(startMinute)}00`;
const rruleString = values.timezone const rruleString = values.timezone
? `DTSTART;TZID=${values.timezone}:${dateString}` ? `DTSTART;TZID=${values.timezone}${dateString}`
: `DTSTART:${dateString}Z`; : `DTSTART:${dateString}Z`;
const rule = RRule.fromString(rruleString); const rule = RRule.fromString(rruleString);
@@ -38,7 +36,8 @@ function pad(num) {
export default function buildRuleObj(values, includeStart) { export default function buildRuleObj(values, includeStart) {
const ruleObj = { const ruleObj = {
interval: values.interval, interval: values.interval || 1,
freq: values.freq,
}; };
if (includeStart) { if (includeStart) {
@@ -49,68 +48,6 @@ export default function buildRuleObj(values, includeStart) {
); );
} }
switch (values.frequency) {
case 'none':
ruleObj.count = 1;
ruleObj.freq = RRule.MINUTELY;
break;
case 'minute':
ruleObj.freq = RRule.MINUTELY;
break;
case 'hour':
ruleObj.freq = RRule.HOURLY;
break;
case 'day':
ruleObj.freq = RRule.DAILY;
break;
case 'week':
ruleObj.freq = RRule.WEEKLY;
ruleObj.byweekday = values.daysOfWeek;
break;
case 'month':
ruleObj.freq = RRule.MONTHLY;
if (values.runOn === 'day') {
ruleObj.bymonthday = values.runOnDayNumber;
} else if (values.runOn === 'the') {
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay);
}
break;
case 'year':
ruleObj.freq = RRule.YEARLY;
if (values.runOn === 'day') {
ruleObj.bymonth = parseInt(values.runOnDayMonth, 10);
ruleObj.bymonthday = values.runOnDayNumber;
} else if (values.runOn === 'the') {
ruleObj.bysetpos = parseInt(values.runOnTheOccurrence, 10);
ruleObj.byweekday = getRRuleDayConstants(values.runOnTheDay);
ruleObj.bymonth = parseInt(values.runOnTheMonth, 10);
}
break;
default:
throw new Error(t`Frequency did not match an expected value`);
}
if (values.frequency !== 'none') {
switch (values.end) {
case 'never':
break;
case 'after':
ruleObj.count = values.occurrences;
break;
case 'onDate': {
ruleObj.until = buildDateTime(
values.endDate,
values.endTime,
values.timezone
);
break;
}
default:
throw new Error(t`End did not match an expected value (${values.end})`);
}
}
return ruleObj; return ruleObj;
} }

View File

@@ -1,5 +1,6 @@
import { RRule, RRuleSet } from 'rrule'; import { RRule, RRuleSet } from 'rrule';
import buildRuleObj, { buildDtStartObj } from './buildRuleObj'; import buildRuleObj, { buildDtStartObj } from './buildRuleObj';
import { FREQUENCIESCONSTANTS } from './scheduleFormHelpers';
window.RRuleSet = RRuleSet; window.RRuleSet = RRuleSet;
@@ -12,42 +13,31 @@ export default function buildRuleSet(values, useUTCStart) {
startDate: values.startDate, startDate: values.startDate,
startTime: values.startTime, startTime: values.startTime,
timezone: values.timezone, timezone: values.timezone,
frequency: values.freq,
}); });
set.rrule(startRule); set.rrule(startRule);
} }
if (values.frequency.length === 0) { values.frequencies.forEach(({ frequency, rrule }) => {
const rule = buildRuleObj( if (!frequencies.includes(frequency)) {
{
startDate: values.startDate,
startTime: values.startTime,
timezone: values.timezone,
frequency: 'none',
interval: 1,
},
useUTCStart
);
set.rrule(new RRule(rule));
}
frequencies.forEach((frequency) => {
if (!values.frequency.includes(frequency)) {
return; return;
} }
const rule = buildRuleObj( const rule = buildRuleObj(
{ {
startDate: values.startDate, startDate: values.startDate,
startTime: values.startTime, startTime: values.startTime,
timezone: values.timezone, timezone: values.timezone,
frequency, freq: FREQUENCIESCONSTANTS[frequency],
...values.frequencyOptions[frequency], rrule,
}, },
useUTCStart true
); );
set.rrule(new RRule(rule)); set.rrule(new RRule(rule));
}); });
frequencies.forEach((frequency) => { values.exceptions?.forEach(({ frequency, rrule }) => {
if (!values.exceptionFrequency?.includes(frequency)) { if (!values.exceptionFrequency?.includes(frequency)) {
return; return;
} }
@@ -56,8 +46,8 @@ export default function buildRuleSet(values, useUTCStart) {
startDate: values.startDate, startDate: values.startDate,
startTime: values.startTime, startTime: values.startTime,
timezone: values.timezone, timezone: values.timezone,
frequency, freq: FREQUENCIESCONSTANTS[frequency],
...values.exceptionOptions[frequency], rrule,
}, },
useUTCStart useUTCStart
); );

View File

@@ -12,12 +12,14 @@ export class UnsupportedRRuleError extends Error {
export default function parseRuleObj(schedule) { export default function parseRuleObj(schedule) {
let values = { let values = {
frequency: [], frequency: '',
frequencyOptions: {}, rrules: '',
exceptionFrequency: [],
exceptionOptions: {},
timezone: schedule.timezone, timezone: schedule.timezone,
}; };
if (Object.values(schedule).length === 0) {
return values;
}
const ruleset = rrulestr(schedule.rrule.replace(' ', '\n'), { const ruleset = rrulestr(schedule.rrule.replace(' ', '\n'), {
forceset: true, forceset: true,
}); });
@@ -40,25 +42,9 @@ export default function parseRuleObj(schedule) {
} }
}); });
if (isSingleOccurrence(values)) {
values.frequency = [];
values.frequencyOptions = {};
}
return values; return values;
} }
function isSingleOccurrence(values) {
if (values.frequency.length > 1) {
return false;
}
if (values.frequency[0] !== 'minute') {
return false;
}
const options = values.frequencyOptions.minute;
return options.end === 'after' && options.occurrences === 1;
}
function parseDtstart(schedule, values) { function parseDtstart(schedule, values) {
// TODO: should this rely on DTSTART in rruleset rather than schedule.dtstart? // TODO: should this rely on DTSTART in rruleset rather than schedule.dtstart?
const [startDate, startTime] = dateToInputDateTime( const [startDate, startTime] = dateToInputDateTime(
@@ -81,27 +67,12 @@ const frequencyTypes = {
[RRule.YEARLY]: 'year', [RRule.YEARLY]: 'year',
}; };
function parseRrule(rruleString, schedule, values) { function parseRrule(rruleString, schedule) {
const { frequency, options } = parseRule( const { frequency } = parseRule(rruleString, schedule);
rruleString,
schedule,
values.exceptionFrequency
);
if (values.frequencyOptions[frequency]) { const freq = { frequency, rrule: rruleString };
throw new UnsupportedRRuleError(
'Duplicate exception frequency types not supported'
);
}
return { return freq;
...values,
frequency: [...values.frequency, frequency].sort(sortFrequencies),
frequencyOptions: {
...values.frequencyOptions,
[frequency]: options,
},
};
} }
function parseExRule(exruleString, schedule, values) { function parseExRule(exruleString, schedule, values) {
@@ -129,20 +100,10 @@ function parseExRule(exruleString, schedule, values) {
}; };
} }
function parseRule(ruleString, schedule, frequencies) { function parseRule(ruleString, schedule) {
const { const {
origOptions: { origOptions: { count, freq, interval, until, ...rest },
bymonth,
bymonthday,
bysetpos,
byweekday,
count,
freq,
interval,
until,
},
} = RRule.fromString(ruleString); } = RRule.fromString(ruleString);
const now = DateTime.now(); const now = DateTime.now();
const closestQuarterHour = DateTime.fromMillis( const closestQuarterHour = DateTime.fromMillis(
Math.ceil(now.ts / 900000) * 900000 Math.ceil(now.ts / 900000) * 900000
@@ -156,17 +117,17 @@ function parseRule(ruleString, schedule, frequencies) {
endTime: time, endTime: time,
occurrences: 1, occurrences: 1,
interval: 1, interval: 1,
end: 'never', endingType: 'never',
}; };
if (until) { if (until?.length) {
options.end = 'onDate'; options.endingType = 'onDate';
const end = DateTime.fromISO(until.toISOString()); const end = DateTime.fromISO(until.toISOString());
const [endDate, endTime] = dateToInputDateTime(end, schedule.timezone); const [endDate, endTime] = dateToInputDateTime(end, schedule.timezone);
options.endDate = endDate; options.endDate = endDate;
options.endTime = endTime; options.endTime = endTime;
} else if (count) { } else if (count) {
options.end = 'after'; options.endingType = 'after';
options.occurrences = count; options.occurrences = count;
} }
@@ -178,101 +139,10 @@ function parseRule(ruleString, schedule, frequencies) {
throw new Error(`Unexpected rrule frequency: ${freq}`); throw new Error(`Unexpected rrule frequency: ${freq}`);
} }
const frequency = frequencyTypes[freq]; const frequency = frequencyTypes[freq];
if (frequencies.includes(frequency)) {
throw new Error(`Duplicate frequency types not supported (${frequency})`);
}
if (freq === RRule.WEEKLY && byweekday) {
options.daysOfWeek = byweekday;
}
if (freq === RRule.MONTHLY) {
options.runOn = 'day';
options.runOnTheOccurrence = 1;
options.runOnTheDay = 'sunday';
options.runOnDayNumber = 1;
if (bymonthday) {
options.runOnDayNumber = bymonthday;
}
if (bysetpos) {
options.runOn = 'the';
options.runOnTheOccurrence = bysetpos;
options.runOnTheDay = generateRunOnTheDay(byweekday);
}
}
if (freq === RRule.YEARLY) {
options.runOn = 'day';
options.runOnTheOccurrence = 1;
options.runOnTheDay = 'sunday';
options.runOnTheMonth = 1;
options.runOnDayMonth = 1;
options.runOnDayNumber = 1;
if (bymonthday) {
options.runOnDayNumber = bymonthday;
options.runOnDayMonth = bymonth;
}
if (bysetpos) {
options.runOn = 'the';
options.runOnTheOccurrence = bysetpos;
options.runOnTheDay = generateRunOnTheDay(byweekday);
options.runOnTheMonth = bymonth;
}
}
return { return {
frequency, frequency,
options, ...options,
...rest,
}; };
} }
function generateRunOnTheDay(days = []) {
if (
[
RRule.MO,
RRule.TU,
RRule.WE,
RRule.TH,
RRule.FR,
RRule.SA,
RRule.SU,
].every((element) => days.indexOf(element) > -1)
) {
return 'day';
}
if (
[RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR].every(
(element) => days.indexOf(element) > -1
)
) {
return 'weekday';
}
if ([RRule.SA, RRule.SU].every((element) => days.indexOf(element) > -1)) {
return 'weekendDay';
}
if (days.indexOf(RRule.MO) > -1) {
return 'monday';
}
if (days.indexOf(RRule.TU) > -1) {
return 'tuesday';
}
if (days.indexOf(RRule.WE) > -1) {
return 'wednesday';
}
if (days.indexOf(RRule.TH) > -1) {
return 'thursday';
}
if (days.indexOf(RRule.FR) > -1) {
return 'friday';
}
if (days.indexOf(RRule.SA) > -1) {
return 'saturday';
}
if (days.indexOf(RRule.SU) > -1) {
return 'sunday';
}
return null;
}

View File

@@ -0,0 +1,232 @@
import { t } from '@lingui/macro';
import { DateTime } from 'luxon';
import { RRule } from 'rrule';
import buildRuleObj from './buildRuleObj';
import buildRuleSet from './buildRuleSet';
// const NUM_DAYS_PER_FREQUENCY = {
// week: 7,
// month: 31,
// year: 365,
// };
// const validateSchedule = () =>
// const errors = {};
// values.frequencies.forEach((freq) => {
// const options = values.frequencyOptions[freq];
// const freqErrors = {};
// if (
// (freq === 'month' || freq === 'year') &&
// options.runOn === 'day' &&
// (options.runOnDayNumber < 1 || options.runOnDayNumber > 31)
// ) {
// freqErrors.runOn = t`Please select a day number between 1 and 31.`;
// }
// if (options.end === 'after' && !options.occurrences) {
// freqErrors.occurrences = t`Please enter a number of occurrences.`;
// }
// if (options.end === 'onDate') {
// if (
// DateTime.fromFormat(
// `${values.startDate} ${values.startTime}`,
// 'yyyy-LL-dd h:mm a'
// ).toMillis() >=
// DateTime.fromFormat(
// `${options.endDate} ${options.endTime}`,
// 'yyyy-LL-dd h:mm a'
// ).toMillis()
// ) {
// freqErrors.endDate = t`Please select an end date/time that comes after the start date/time.`;
// }
// if (
// DateTime.fromISO(options.endDate)
// .diff(DateTime.fromISO(values.startDate), 'days')
// .toObject().days < NUM_DAYS_PER_FREQUENCY[freq]
// ) {
// const rule = new RRule(
// buildRuleObj({
// startDate: values.startDate,
// startTime: values.startTime,
// frequencies: freq,
// ...options,
// })
// );
// if (rule.all().length === 0) {
// errors.startDate = t`Selected date range must have at least 1 schedule occurrence.`;
// freqErrors.endDate = t`Selected date range must have at least 1 schedule occurrence.`;
// }
// }
// }
// if (Object.keys(freqErrors).length > 0) {
// if (!errors.frequencyOptions) {
// errors.frequencyOptions = {};
// }
// errors.frequencyOptions[freq] = freqErrors;
// }
// });
// if (values.exceptionFrequency.length > 0 && !scheduleHasInstances(values)) {
// errors.exceptionFrequency = t`This schedule has no occurrences due to the
// selected exceptions.`;
// }
// ({});
// function scheduleHasInstances(values) {
// let rangeToCheck = 1;
// values.frequencies.forEach((freq) => {
// if (NUM_DAYS_PER_FREQUENCY[freq] > rangeToCheck) {
// rangeToCheck = NUM_DAYS_PER_FREQUENCY[freq];
// }
// });
// const ruleSet = buildRuleSet(values, true);
// const startDate = DateTime.fromISO(values.startDate);
// const endDate = startDate.plus({ days: rangeToCheck });
// const instances = ruleSet.between(
// startDate.toJSDate(),
// endDate.toJSDate(),
// true,
// (date, i) => i === 0
// );
// return instances.length > 0;
// }
const bysetposOptions = [
{ value: '', key: 'none', label: 'None' },
{ value: 1, key: 'first', label: t`First` },
{
value: 2,
key: 'second',
label: t`Second`,
},
{ value: 3, key: 'third', label: t`Third` },
{
value: 4,
key: 'fourth',
label: t`Fourth`,
},
{ value: 5, key: 'fifth', label: t`Fifth` },
{ value: -1, key: 'last', label: t`Last` },
];
const monthOptions = [
{
key: 'january',
value: 1,
label: t`January`,
},
{
key: 'february',
value: 2,
label: t`February`,
},
{
key: 'march',
value: 3,
label: t`March`,
},
{
key: 'april',
value: 4,
label: t`April`,
},
{
key: 'may',
value: 5,
label: t`May`,
},
{
key: 'june',
value: 6,
label: t`June`,
},
{
key: 'july',
value: 7,
label: t`July`,
},
{
key: 'august',
value: 8,
label: t`August`,
},
{
key: 'september',
value: 9,
label: t`September`,
},
{
key: 'october',
value: 10,
label: t`October`,
},
{
key: 'november',
value: 11,
label: t`November`,
},
{
key: 'december',
value: 12,
label: t`December`,
},
];
const weekdayOptions = [
{
value: RRule.SU,
key: 'sunday',
label: t`Sunday`,
},
{
value: RRule.MO,
key: 'monday',
label: t`Monday`,
},
{
value: RRule.TU,
key: 'tuesday',
label: t`Tuesday`,
},
{
value: RRule.WE,
key: 'wednesday',
label: t`Wednesday`,
},
{
value: RRule.TH,
key: 'thursday',
label: t`Thursday`,
},
{
value: RRule.FR,
key: 'friday',
label: t`Friday`,
},
{
value: RRule.SA,
key: 'saturday',
label: t`Saturday`,
},
];
const FREQUENCIESCONSTANTS = {
minute: RRule.MINUTELY,
hour: RRule.HOURLY,
day: RRule.DAILY,
week: RRule.WEEKLY,
month: RRule.MONTHLY,
year: RRule.YEARLY,
};
export {
monthOptions,
weekdayOptions,
bysetposOptions,
// validateSchedule,
FREQUENCIESCONSTANTS,
};

View File

@@ -420,7 +420,7 @@ describe('<AdvancedSearch />', () => {
const selectOptions = wrapper.find( const selectOptions = wrapper.find(
'Select[aria-label="Related search type"] SelectOption' 'Select[aria-label="Related search type"] SelectOption'
); );
expect(selectOptions).toHaveLength(3); expect(selectOptions).toHaveLength(2);
expect( expect(
selectOptions.find('SelectOption[id="name-option-select"]').prop('value') selectOptions.find('SelectOption[id="name-option-select"]').prop('value')
).toBe('name__icontains'); ).toBe('name__icontains');

View File

@@ -31,12 +31,6 @@ function RelatedLookupTypeInput({
value="name__icontains" value="name__icontains"
description={t`Fuzzy search on name field.`} description={t`Fuzzy search on name field.`}
/> />
<SelectOption
id="name-exact-option-select"
key="name"
value="name"
description={t`Exact search on name field.`}
/>
<SelectOption <SelectOption
id="id-option-select" id="id-option-select"
key="id" key="id"

View File

@@ -22,16 +22,6 @@ import { CredentialsAPI } from 'api';
import CredentialDetail from './CredentialDetail'; import CredentialDetail from './CredentialDetail';
import CredentialEdit from './CredentialEdit'; import CredentialEdit from './CredentialEdit';
const jobTemplateCredentialTypes = [
'machine',
'cloud',
'net',
'ssh',
'vault',
'kubernetes',
'cryptography',
];
function Credential({ setBreadcrumb }) { function Credential({ setBreadcrumb }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -85,14 +75,13 @@ function Credential({ setBreadcrumb }) {
link: `/credentials/${id}/access`, link: `/credentials/${id}/access`,
id: 1, id: 1,
}, },
]; {
if (jobTemplateCredentialTypes.includes(credential?.kind)) {
tabsArray.push({
name: t`Job Templates`, name: t`Job Templates`,
link: `/credentials/${id}/job_templates`, link: `/credentials/${id}/job_templates`,
id: 2, id: 2,
}); },
} ];
let showCardHeader = true; let showCardHeader = true;
if (pathname.endsWith('edit') || pathname.endsWith('add')) { if (pathname.endsWith('edit') || pathname.endsWith('add')) {
@@ -144,7 +133,6 @@ function Credential({ setBreadcrumb }) {
<Route key="job_templates" path="/credentials/:id/job_templates"> <Route key="job_templates" path="/credentials/:id/job_templates">
<RelatedTemplateList <RelatedTemplateList
searchParams={{ credentials__id: credential.id }} searchParams={{ credentials__id: credential.id }}
resourceName={[credential.name, credential.kind]}
/> />
</Route>, </Route>,
<Route key="not-found" path="*"> <Route key="not-found" path="*">

View File

@@ -6,8 +6,7 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../testUtils/enzymeHelpers'; } from '../../../testUtils/enzymeHelpers';
import mockMachineCredential from './shared/data.machineCredential.json'; import mockCredential from './shared/data.scmCredential.json';
import mockSCMCredential from './shared/data.scmCredential.json';
import Credential from './Credential'; import Credential from './Credential';
jest.mock('../../api'); jest.mock('../../api');
@@ -22,10 +21,13 @@ jest.mock('react-router-dom', () => ({
describe('<Credential />', () => { describe('<Credential />', () => {
let wrapper; let wrapper;
test('initially renders user-based machine credential successfully', async () => { beforeEach(() => {
CredentialsAPI.readDetail.mockResolvedValueOnce({ CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockMachineCredential, data: mockCredential,
}); });
});
test('initially renders user-based credential successfully', async () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />); wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
}); });
@@ -34,18 +36,6 @@ describe('<Credential />', () => {
expect(wrapper.find('RoutedTabs li').length).toBe(4); expect(wrapper.find('RoutedTabs li').length).toBe(4);
}); });
test('initially renders user-based SCM credential successfully', async () => {
CredentialsAPI.readDetail.mockResolvedValueOnce({
data: mockSCMCredential,
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
wrapper.update();
expect(wrapper.find('Credential').length).toBe(1);
expect(wrapper.find('RoutedTabs li').length).toBe(3);
});
test('should render expected tabs', async () => { test('should render expected tabs', async () => {
const expectedTabs = [ const expectedTabs = [
'Back to Credentials', 'Back to Credentials',

View File

@@ -81,30 +81,35 @@ function InstanceDetails({ setBreadcrumb, instanceGroup }) {
const { const {
data: { results }, data: { results },
} = await InstanceGroupsAPI.readInstances(instanceGroup.id); } = await InstanceGroupsAPI.readInstances(instanceGroup.id);
let instanceDetails;
const isAssociated = results.some( const isAssociated = results.some(
({ id: instId }) => instId === parseInt(instanceId, 10) ({ id: instId }) => instId === parseInt(instanceId, 10)
); );
if (isAssociated) { if (isAssociated) {
const { data: details } = await InstancesAPI.readDetail(instanceId); const [{ data: details }, { data: healthCheckData }] =
if (details.node_type === 'execution') { await Promise.all([
const { data: healthCheckData } = InstancesAPI.readDetail(instanceId),
await InstancesAPI.readHealthCheckDetail(instanceId); InstancesAPI.readHealthCheckDetail(instanceId),
]);
instanceDetails = details;
setHealthCheck(healthCheckData); setHealthCheck(healthCheckData);
} } else {
setBreadcrumb(instanceGroup, details);
setForks(
computeForks(
details.mem_capacity,
details.cpu_capacity,
details.capacity_adjustment
)
);
return { instance: details };
}
throw new Error( throw new Error(
`This instance is not associated with this instance group` `This instance is not associated with this instance group`
); );
}
setBreadcrumb(instanceGroup, instanceDetails);
setForks(
computeForks(
instanceDetails.mem_capacity,
instanceDetails.cpu_capacity,
instanceDetails.capacity_adjustment
)
);
return { instance: instanceDetails };
}, [instanceId, setBreadcrumb, instanceGroup]), }, [instanceId, setBreadcrumb, instanceGroup]),
{ instance: {}, isLoading: true } { instance: {}, isLoading: true }
); );

View File

@@ -181,7 +181,6 @@ function Inventory({ setBreadcrumb }) {
> >
<RelatedTemplateList <RelatedTemplateList
searchParams={{ inventory__id: inventory.id }} searchParams={{ inventory__id: inventory.id }}
resourceName={inventory.name}
/> />
</Route>, </Route>,
<Route path="*" key="not-found"> <Route path="*" key="not-found">

View File

@@ -187,9 +187,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
useEffect(() => { useEffect(() => {
const pendingRequests = Object.values(eventByUuidRequests.current || {}); const pendingRequests = Object.values(eventByUuidRequests.current || {});
setHasContentLoading(true); // prevents "no content found" screen from flashing setHasContentLoading(true); // prevents "no content found" screen from flashing
if (location.search) {
setIsFollowModeEnabled(false); setIsFollowModeEnabled(false);
}
Promise.allSettled(pendingRequests).then(() => { Promise.allSettled(pendingRequests).then(() => {
setRemoteRowCount(0); setRemoteRowCount(0);
clearLoadedEvents(); clearLoadedEvents();
@@ -253,9 +251,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}); });
const updated = oldWsEvents.concat(newEvents); const updated = oldWsEvents.concat(newEvents);
jobSocketCounter.current = updated.length; jobSocketCounter.current = updated.length;
if (!oldWsEvents.length && min > remoteRowCount + 1) {
loadJobEvents(min);
}
return updated.sort((a, b) => a.counter - b.counter); return updated.sort((a, b) => a.counter - b.counter);
}); });
setCssMap((prevCssMap) => ({ setCssMap((prevCssMap) => ({
@@ -363,7 +358,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
} }
}; };
const loadJobEvents = async (firstWsCounter = null) => { const loadJobEvents = async () => {
const [params, loadRange] = getEventRequestParams(job, 50, [1, 50]); const [params, loadRange] = getEventRequestParams(job, 50, [1, 50]);
if (isMounted.current) { if (isMounted.current) {
@@ -376,9 +371,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
if (isFlatMode) { if (isFlatMode) {
params.not__stdout = ''; params.not__stdout = '';
} }
if (firstWsCounter) {
params.counter__lt = firstWsCounter;
}
const qsParams = parseQueryString(QS_CONFIG, location.search); const qsParams = parseQueryString(QS_CONFIG, location.search);
const eventPromise = getJobModel(job.type).readEvents(job.id, { const eventPromise = getJobModel(job.type).readEvents(job.id, {
...params, ...params,
@@ -443,7 +435,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
if (getEvent(counter)) { if (getEvent(counter)) {
return true; return true;
} }
if (index >= remoteRowCount && index < remoteRowCount + wsEvents.length) { if (index > remoteRowCount && index < remoteRowCount + wsEvents.length) {
return true; return true;
} }
return currentlyLoading.includes(counter); return currentlyLoading.includes(counter);
@@ -470,7 +462,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
} }
if ( if (
!event && !event &&
index >= remoteRowCount && index > remoteRowCount &&
index < remoteRowCount + wsEvents.length index < remoteRowCount + wsEvents.length
) { ) {
event = wsEvents[index - remoteRowCount]; event = wsEvents[index - remoteRowCount];
@@ -637,14 +629,10 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
setIsFollowModeEnabled(false); setIsFollowModeEnabled(false);
}; };
const scrollToEnd = useCallback(() => { const scrollToEnd = () => {
scrollToRow(-1); scrollToRow(-1);
let timeout;
if (isFollowModeEnabled) {
setTimeout(() => scrollToRow(-1), 100); setTimeout(() => scrollToRow(-1), 100);
} };
return () => clearTimeout(timeout);
}, [isFollowModeEnabled]);
const handleScrollLast = () => { const handleScrollLast = () => {
scrollToEnd(); scrollToEnd();

View File

@@ -179,7 +179,7 @@ function Project({ setBreadcrumb }) {
searchParams={{ searchParams={{
project__id: project.id, project__id: project.id,
}} }}
resourceName={project.name} projectName={project.name}
/> />
</Route> </Route>
{project?.scm_type && project.scm_type !== '' && ( {project?.scm_type && project.scm_type !== '' && (

View File

@@ -141,14 +141,14 @@ function JobsEdit() {
<FormColumnLayout> <FormColumnLayout>
<InputField <InputField
name="AWX_ISOLATION_BASE_PATH" name="AWX_ISOLATION_BASE_PATH"
config={jobs.AWX_ISOLATION_BASE_PATH ?? null} config={jobs.AWX_ISOLATION_BASE_PATH}
isRequired={Boolean(options?.AWX_ISOLATION_BASE_PATH)} isRequired
/> />
<InputField <InputField
name="SCHEDULE_MAX_JOBS" name="SCHEDULE_MAX_JOBS"
config={jobs.SCHEDULE_MAX_JOBS ?? null} config={jobs.SCHEDULE_MAX_JOBS}
type={options?.SCHEDULE_MAX_JOBS ? 'number' : undefined} type="number"
isRequired={Boolean(options?.SCHEDULE_MAX_JOBS)} isRequired
/> />
<InputField <InputField
name="DEFAULT_JOB_TIMEOUT" name="DEFAULT_JOB_TIMEOUT"

View File

@@ -122,22 +122,4 @@ describe('<JobsEdit />', () => {
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1); expect(wrapper.find('ContentError').length).toBe(1);
}); });
test('Form input fields that are invisible (due to being set manually via a settings file) should not prevent submitting the form', async () => {
const mockOptions = Object.assign({}, mockAllOptions);
// If AWX_ISOLATION_BASE_PATH has been set in a settings file it will be absent in the PUT options
delete mockOptions['actions']['PUT']['AWX_ISOLATION_BASE_PATH'];
await act(async () => {
wrapper = mountWithContexts(
<SettingsProvider value={mockOptions.actions}>
<JobsEdit />
</SettingsProvider>
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
await act(async () => {
wrapper.find('Form').invoke('onSubmit')();
});
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -397,10 +397,7 @@ const InputField = ({ name, config, type = 'text', isRequired = false }) => {
}; };
InputField.propTypes = { InputField.propTypes = {
name: string.isRequired, name: string.isRequired,
config: shape({}), config: shape({}).isRequired,
};
InputField.defaultProps = {
config: null,
}; };
const TextAreaField = ({ name, config, isRequired = false }) => { const TextAreaField = ({ name, config, isRequired = false }) => {

View File

@@ -9,31 +9,29 @@ function JobTemplateAdd() {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();
const resourceParams = { const projectParams = {
resource_id: null, project_id: null,
resource_name: null, project_name: null,
resource_type: null,
resource_kind: null,
}; };
history.location.search history.location.search
.replace(/^\?/, '') .replace(/^\?/, '')
.split('&') .split('&')
.map((s) => s.split('=')) .map((s) => s.split('='))
.forEach(([key, val]) => { .forEach(([key, val]) => {
if (!(key in resourceParams)) { if (!(key in projectParams)) {
return; return;
} }
resourceParams[key] = decodeURIComponent(val); projectParams[key] = decodeURIComponent(val);
}); });
let resourceValues = null; let projectValues = null;
if (history.location.search.includes('resource_id' && 'resource_name')) { if (
resourceValues = { Object.values(projectParams).filter((item) => item !== null).length === 2
id: resourceParams.resource_id, ) {
name: resourceParams.resource_name, projectValues = {
type: resourceParams.resource_type, id: projectParams.project_id,
kind: resourceParams.resource_kind, // refers to credential kind name: projectParams.project_name,
}; };
} }
@@ -124,7 +122,7 @@ function JobTemplateAdd() {
handleCancel={handleCancel} handleCancel={handleCancel}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
submitError={formSubmitError} submitError={formSubmitError}
resourceValues={resourceValues} projectValues={projectValues}
isOverrideDisabledLookup isOverrideDisabledLookup
/> />
</CardBody> </CardBody>

View File

@@ -274,14 +274,9 @@ describe('<JobTemplateAdd />', () => {
test('should parse and pre-fill project field from query params', async () => { test('should parse and pre-fill project field from query params', async () => {
const history = createMemoryHistory({ const history = createMemoryHistory({
initialEntries: [ initialEntries: [
'/templates/job_template/add?resource_id=6&resource_name=Demo%20Project&resource_type=project', '/templates/job_template/add/add?project_id=6&project_name=Demo%20Project',
], ],
}); });
ProjectsAPI.read.mockResolvedValueOnce({
count: 1,
results: [{ name: 'foo', id: 1, allow_override: true, organization: 1 }],
});
ProjectsAPI.readOptions.mockResolvedValueOnce({});
let wrapper; let wrapper;
await act(async () => { await act(async () => {
wrapper = mountWithContexts(<JobTemplateAdd />, { wrapper = mountWithContexts(<JobTemplateAdd />, {
@@ -289,9 +284,8 @@ describe('<JobTemplateAdd />', () => {
}); });
}); });
await waitForElement(wrapper, 'EmptyStateBody', (el) => el.length === 0); await waitForElement(wrapper, 'EmptyStateBody', (el) => el.length === 0);
expect(wrapper.find('input#project').prop('value')).toEqual('Demo Project'); expect(wrapper.find('input#project').prop('value')).toEqual('Demo Project');
expect(ProjectsAPI.readPlaybooks).toBeCalledWith(6); expect(ProjectsAPI.readPlaybooks).toBeCalledWith('6');
}); });
test('should not call ProjectsAPI.readPlaybooks if there is no project', async () => { test('should not call ProjectsAPI.readPlaybooks if there is no project', async () => {

View File

@@ -690,7 +690,7 @@ JobTemplateForm.defaultProps = {
}; };
const FormikApp = withFormik({ const FormikApp = withFormik({
mapPropsToValues({ resourceValues = null, template = {} }) { mapPropsToValues({ projectValues = {}, template = {} }) {
const { const {
summary_fields = { summary_fields = {
labels: { results: [] }, labels: { results: [] },
@@ -698,7 +698,7 @@ const FormikApp = withFormik({
}, },
} = template; } = template;
const initialValues = { return {
allow_callbacks: template.allow_callbacks || false, allow_callbacks: template.allow_callbacks || false,
allow_simultaneous: template.allow_simultaneous || false, allow_simultaneous: template.allow_simultaneous || false,
ask_credential_on_launch: template.ask_credential_on_launch || false, ask_credential_on_launch: template.ask_credential_on_launch || false,
@@ -739,7 +739,7 @@ const FormikApp = withFormik({
playbook: template.playbook || '', playbook: template.playbook || '',
prevent_instance_group_fallback: prevent_instance_group_fallback:
template.prevent_instance_group_fallback || false, template.prevent_instance_group_fallback || false,
project: summary_fields?.project || null, project: summary_fields?.project || projectValues || null,
scm_branch: template.scm_branch || '', scm_branch: template.scm_branch || '',
skip_tags: template.skip_tags || '', skip_tags: template.skip_tags || '',
timeout: template.timeout || 0, timeout: template.timeout || 0,
@@ -756,24 +756,6 @@ const FormikApp = withFormik({
execution_environment: execution_environment:
template.summary_fields?.execution_environment || null, template.summary_fields?.execution_environment || null,
}; };
if (resourceValues !== null) {
if (resourceValues.type === 'credentials') {
initialValues[resourceValues.type] = [
{
id: parseInt(resourceValues.id, 10),
name: resourceValues.name,
kind: resourceValues.kind,
},
];
} else {
initialValues[resourceValues.type] = {
id: parseInt(resourceValues.id, 10),
name: resourceValues.name,
};
}
}
return initialValues;
}, },
handleSubmit: async (values, { props, setErrors }) => { handleSubmit: async (values, { props, setErrors }) => {
try { try {

View File

@@ -46,216 +46,90 @@ action_groups:
plugin_routing: plugin_routing:
inventory: inventory:
tower: tower:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.controller instead.
redirect: awx.awx.controller redirect: awx.awx.controller
lookup: lookup:
tower_api: tower_api:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.controller_api instead.
redirect: awx.awx.controller_api redirect: awx.awx.controller_api
tower_schedule_rrule: tower_schedule_rrule:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* plugins have been deprecated, use awx.awx.schedule_rrule instead.
redirect: awx.awx.schedule_rrule redirect: awx.awx.schedule_rrule
modules: modules:
tower_ad_hoc_command_cancel: tower_ad_hoc_command_cancel:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command_cancel instead.
redirect: awx.awx.ad_hoc_command_cancel redirect: awx.awx.ad_hoc_command_cancel
tower_ad_hoc_command_wait: tower_ad_hoc_command_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command_wait instead.
redirect: awx.awx.ad_hoc_command_wait redirect: awx.awx.ad_hoc_command_wait
tower_ad_hoc_command: tower_ad_hoc_command:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.ad_hoc_command instead.
redirect: awx.awx.ad_hoc_command redirect: awx.awx.ad_hoc_command
tower_application: tower_application:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.application instead.
redirect: awx.awx.application redirect: awx.awx.application
tower_meta: tower_meta:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.controller_meta instead.
redirect: awx.awx.controller_meta redirect: awx.awx.controller_meta
tower_credential_input_source: tower_credential_input_source:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential_input_source instead.
redirect: awx.awx.credential_input_source redirect: awx.awx.credential_input_source
tower_credential_type: tower_credential_type:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential_type instead.
redirect: awx.awx.credential_type redirect: awx.awx.credential_type
tower_credential: tower_credential:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.credential instead.
redirect: awx.awx.credential redirect: awx.awx.credential
tower_execution_environment: tower_execution_environment:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.execution_environment instead.
redirect: awx.awx.execution_environment redirect: awx.awx.execution_environment
tower_export: tower_export:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.export instead.
redirect: awx.awx.export redirect: awx.awx.export
tower_group: tower_group:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.group instead.
redirect: awx.awx.group redirect: awx.awx.group
tower_host: tower_host:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.host instead.
redirect: awx.awx.host redirect: awx.awx.host
tower_import: tower_import:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.import instead.
redirect: awx.awx.import redirect: awx.awx.import
tower_instance_group: tower_instance_group:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.instance_group instead.
redirect: awx.awx.instance_group redirect: awx.awx.instance_group
tower_inventory_source_update: tower_inventory_source_update:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory_source_update instead.
redirect: awx.awx.inventory_source_update redirect: awx.awx.inventory_source_update
tower_inventory_source: tower_inventory_source:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory_source instead.
redirect: awx.awx.inventory_source redirect: awx.awx.inventory_source
tower_inventory: tower_inventory:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.inventory instead.
redirect: awx.awx.inventory redirect: awx.awx.inventory
tower_job_cancel: tower_job_cancel:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_cancel instead.
redirect: awx.awx.job_cancel redirect: awx.awx.job_cancel
tower_job_launch: tower_job_launch:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_launch instead.
redirect: awx.awx.job_launch redirect: awx.awx.job_launch
tower_job_list: tower_job_list:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_list instead.
redirect: awx.awx.job_list redirect: awx.awx.job_list
tower_job_template: tower_job_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_template instead.
redirect: awx.awx.job_template redirect: awx.awx.job_template
tower_job_wait: tower_job_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.job_wait instead.
redirect: awx.awx.job_wait redirect: awx.awx.job_wait
tower_label: tower_label:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.label instead.
redirect: awx.awx.label redirect: awx.awx.label
tower_license: tower_license:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.license instead.
redirect: awx.awx.license redirect: awx.awx.license
tower_notification_template: tower_notification_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.notification_template instead.
redirect: awx.awx.notification_template redirect: awx.awx.notification_template
tower_notification: tower_notification:
redirect: awx.awx.notification_template redirect: awx.awx.notification_template
tower_organization: tower_organization:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.organization instead.
redirect: awx.awx.organization redirect: awx.awx.organization
tower_project_update: tower_project_update:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.project_update instead.
redirect: awx.awx.project_update redirect: awx.awx.project_update
tower_project: tower_project:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.project instead.
redirect: awx.awx.project redirect: awx.awx.project
tower_role: tower_role:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.role instead.
redirect: awx.awx.role redirect: awx.awx.role
tower_schedule: tower_schedule:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.schedule instead.
redirect: awx.awx.schedule redirect: awx.awx.schedule
tower_settings: tower_settings:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.settings instead.
redirect: awx.awx.settings redirect: awx.awx.settings
tower_team: tower_team:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.team instead.
redirect: awx.awx.team redirect: awx.awx.team
tower_token: tower_token:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.token instead.
redirect: awx.awx.token redirect: awx.awx.token
tower_user: tower_user:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.user instead.
redirect: awx.awx.user redirect: awx.awx.user
tower_workflow_approval: tower_workflow_approval:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_approval instead.
redirect: awx.awx.workflow_approval redirect: awx.awx.workflow_approval
tower_workflow_job_template_node: tower_workflow_job_template_node:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_job_template_node instead.
redirect: awx.awx.workflow_job_template_node redirect: awx.awx.workflow_job_template_node
tower_workflow_job_template: tower_workflow_job_template:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_job_template instead.
redirect: awx.awx.workflow_job_template redirect: awx.awx.workflow_job_template
tower_workflow_launch: tower_workflow_launch:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_launch instead.
redirect: awx.awx.workflow_launch redirect: awx.awx.workflow_launch
tower_workflow_node_wait: tower_workflow_node_wait:
deprecation:
removal_date: '2022-01-23'
warning_text: The tower_* modules have been deprecated, use awx.awx.workflow_node_wait instead.
redirect: awx.awx.workflow_node_wait redirect: awx.awx.workflow_node_wait

View File

@@ -128,7 +128,7 @@ def main():
description = module.params.get('description') description = module.params.get('description')
state = module.params.pop('state') state = module.params.pop('state')
preserve_existing_hosts = module.params.get('preserve_existing_hosts') preserve_existing_hosts = module.params.get('preserve_existing_hosts')
preserve_existing_children = module.params.get('preserve_existing_children') preserve_existing_children = module.params.get('preserve_existing_groups')
variables = module.params.get('variables') variables = module.params.get('variables')
# Attempt to look up the related items the user specified (these will fail the module if not found) # Attempt to look up the related items the user specified (these will fail the module if not found)

View File

@@ -0,0 +1 @@
ad_hoc_command.py

View File

@@ -0,0 +1 @@
ad_hoc_command_cancel.py

View File

@@ -0,0 +1 @@
ad_hoc_command_wait.py

View File

@@ -0,0 +1 @@
application.py

View File

@@ -0,0 +1 @@
controller_meta.py

View File

@@ -0,0 +1 @@
credential.py

View File

@@ -0,0 +1 @@
credential_input_source.py

View File

@@ -0,0 +1 @@
credential_type.py

View File

@@ -0,0 +1 @@
execution_environment.py

View File

@@ -0,0 +1 @@
export.py

View File

@@ -0,0 +1 @@
group.py

View File

@@ -0,0 +1 @@
host.py

View File

@@ -0,0 +1 @@
import.py

View File

@@ -0,0 +1 @@
instance_group.py

View File

@@ -0,0 +1 @@
inventory.py

View File

@@ -0,0 +1 @@
inventory_source.py

View File

@@ -0,0 +1 @@
inventory_source_update.py

View File

@@ -0,0 +1 @@
job_cancel.py

View File

@@ -0,0 +1 @@
job_launch.py

View File

@@ -0,0 +1 @@
job_list.py

View File

@@ -0,0 +1 @@
job_template.py

View File

@@ -0,0 +1 @@
job_wait.py

View File

@@ -0,0 +1 @@
label.py

View File

@@ -0,0 +1 @@
license.py

View File

@@ -0,0 +1 @@
notification_template.py

View File

@@ -0,0 +1 @@
organization.py

View File

@@ -0,0 +1 @@
project.py

View File

@@ -0,0 +1 @@
project_update.py

View File

@@ -0,0 +1 @@
role.py

View File

@@ -0,0 +1 @@
schedule.py

View File

@@ -0,0 +1 @@
settings.py

View File

@@ -0,0 +1 @@
team.py

View File

@@ -0,0 +1 @@
token.py

View File

@@ -0,0 +1 @@
user.py

View File

@@ -0,0 +1 @@
workflow_approval.py

View File

@@ -0,0 +1 @@
workflow_job_template.py

View File

@@ -0,0 +1 @@
workflow_job_template_node.py

View File

@@ -0,0 +1 @@
workflow_launch.py

View File

@@ -0,0 +1 @@
workflow_node_wait.py

View File

@@ -19,6 +19,7 @@ author: "John Westcott IV (@john-westcott-iv)"
short_description: create, update, or destroy Automation Platform Controller workflow job templates. short_description: create, update, or destroy Automation Platform Controller workflow job templates.
description: description:
- Create, update, or destroy Automation Platform Controller workflow job templates. - Create, update, or destroy Automation Platform Controller workflow job templates.
- Replaces the deprecated tower_workflow_template module.
- Use workflow_job_template_node after this, or use the workflow_nodes parameter to build the workflow's graph - Use workflow_job_template_node after this, or use the workflow_nodes parameter to build the workflow's graph
options: options:
name: name:
@@ -613,10 +614,6 @@ def create_workflow_nodes(module, response, workflow_nodes, workflow_id):
if workflow_node['unified_job_template']['type'] != 'workflow_approval': if workflow_node['unified_job_template']['type'] != 'workflow_approval':
module.fail_json(msg="Unable to Find unified_job_template: {0}".format(search_fields)) module.fail_json(msg="Unable to Find unified_job_template: {0}".format(search_fields))
inventory = workflow_node.get('inventory')
if inventory:
workflow_node_fields['inventory'] = module.resolve_name_to_id('inventories', inventory)
# Lookup Values for other fields # Lookup Values for other fields
for field_name in ( for field_name in (

View File

@@ -20,6 +20,7 @@ short_description: create, update, or destroy Automation Platform Controller wor
description: description:
- Create, update, or destroy Automation Platform Controller workflow job template nodes. - Create, update, or destroy Automation Platform Controller workflow job template nodes.
- Use this to build a graph for a workflow, which dictates what the workflow runs. - Use this to build a graph for a workflow, which dictates what the workflow runs.
- Replaces the deprecated tower_workflow_template module schema command.
- You can create nodes first, and link them afterwards, and not worry about ordering. - You can create nodes first, and link them afterwards, and not worry about ordering.
For failsafe referencing of a node, specify identifier, WFJT, and organization. For failsafe referencing of a node, specify identifier, WFJT, and organization.
With those specified, you can choose to modify or not modify any other parameter. With those specified, you can choose to modify or not modify any other parameter.

View File

@@ -74,7 +74,6 @@ Notable releases of the `{{ collection_namespace }}.{{ collection_package }}` co
- 7.0.0 is intended to be identical to the content prior to the migration, aside from changes necessary to function as a collection. - 7.0.0 is intended to be identical to the content prior to the migration, aside from changes necessary to function as a collection.
- 11.0.0 has no non-deprecated modules that depend on the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/). - 11.0.0 has no non-deprecated modules that depend on the deprecated `tower-cli` [PyPI](https://pypi.org/project/ansible-tower-cli/).
- 19.2.1 large renaming purged "tower" names (like options and module names), adding redirects for old names - 19.2.1 large renaming purged "tower" names (like options and module names), adding redirects for old names
- 21.11.0 "tower" modules deprecated and symlinks removed.
- 0.0.1-devel is the version you should see if installing from source, which is intended for development and expected to be unstable. - 0.0.1-devel is the version you should see if installing from source, which is intended for development and expected to be unstable.
{% else %} {% else %}
- 3.7.0 initial release - 3.7.0 initial release

View File

@@ -1,10 +1,6 @@
awxkit awxkit
====== ======
A Python library that backs the provided `awx` command line client. Python library that backs the provided `awx` command line client.
It can be installed by running `pip install awxkit`.
The PyPI respository can be found [here](https://pypi.org/project/awxkit/).
For more information on installing the CLI and building the docs on how to use it, look [here](./awxkit/cli/docs). For more information on installing the CLI and building the docs on how to use it, look [here](./awxkit/cli/docs).