From 781dfdcda2ed5d28db5cf06ad9ca43add77f866a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ladislav=20Va=C5=A1ina?= Date: Tue, 25 Jul 2023 11:29:21 +0200 Subject: [PATCH] Implementation of views and entities for new "Alternate Content Sources" page (#876) * Implementation of views and entities for Alternate Content Sources * Match keyword replaced with ifs * Correct docstrings so sphinx is happy * Correct docstrings so sphinx is happy 2 * Pre commit run * Remove TODOs as some BZs were resolved * PR comments addressed * Remove sleep --- airgun/browser.py | 8 + airgun/entities/acs.py | 829 +++++++++++++++++++++++++++++++++++++++ airgun/session.py | 6 + airgun/views/acs.py | 378 ++++++++++++++++++ airgun/views/host_new.py | 12 +- airgun/widgets.py | 48 +++ 6 files changed, 1270 insertions(+), 11 deletions(-) create mode 100644 airgun/entities/acs.py create mode 100644 airgun/views/acs.py diff --git a/airgun/browser.py b/airgun/browser.py index 127c2eba1..7031741d4 100644 --- a/airgun/browser.py +++ b/airgun/browser.py @@ -280,6 +280,14 @@ def after_click(self, element, locator=None): # ignore_ajax=True usage from browser click pass + def do_refresh(self): + """ + Refresh current page. + """ + + self.browser.refresh() + self.browser.plugin.ensure_page_safe() + class AirgunBrowser(Browser): """A wrapper around :class:`widgetastic.browser.Browser` which injects diff --git a/airgun/entities/acs.py b/airgun/entities/acs.py new file mode 100644 index 000000000..115360601 --- /dev/null +++ b/airgun/entities/acs.py @@ -0,0 +1,829 @@ +import time + +from wait_for import wait_for + +from airgun.entities.base import BaseEntity +from airgun.navigation import NavigateStep +from airgun.navigation import navigator +from airgun.utils import retry_navigation +from airgun.views.acs import AddAlternateContentSourceModal +from airgun.views.acs import AlternateContentSourcesView +from airgun.views.acs import EditCapsulesModal +from airgun.views.acs import EditCredentialsModal +from airgun.views.acs import EditDetailsModal +from airgun.views.acs import EditProductsModal +from airgun.views.acs import EditUrlAndSubpathsModal +from airgun.views.acs import RowDrawer + + +class AcsEntity(BaseEntity): + def wait_for_content_table(self, view): + wait_for(lambda: view.acs_drawer.content_table.is_displayed, timeout=10, delay=1) + + def get_row_drawer_content(self, row_id=None, acs_name=None): + """ + Function that returns a dictionary with one ACS detail + + Args: + row_id (int): Row ID of ACS item in ACS table + acs_name (str): ACS name to get info of + + Raises: + ValueError: If row_id and acs_name are not specified or both specified + ValueError: If no ACS is found + ValueError: If given ACS name does not exist + """ + + if (row_id is None and acs_name is None) or (row_id is not None and acs_name is not None): + raise ValueError('Either row_id or acs_name must be specified!') + + view = self.navigate_to(self, 'ACS') + view.wait_displayed() + self.browser.plugin.ensure_page_safe() + if not view.acs_drawer.content_table.is_displayed: + raise ValueError('No ACS found!') + + if row_id is not None: + view.acs_drawer.content_table[row_id][1].widget.click() + elif acs_name is not None: + view.acs_drawer.search_bar.fill(f'name = {acs_name}') + time.sleep(3) + if not view.acs_drawer.content_table.is_displayed: + raise ValueError(f'ACS {acs_name} not found!') + # Open ACS details side panel + view.acs_drawer.content_table[0][1].widget.click() + self.browser.plugin.ensure_page_safe() + view.wait_displayed() + result = RowDrawer(self.browser).read() + result['details']['last_refresh'] = RowDrawer(self.browser).last_refresh.text + self.close_details_side_panel() + return result + + def get_all_acs_info(self): + """ + Function that returns list of dictionaries where each dictionary + is one ACS represented on one row of the ACS table + """ + + view = self.navigate_to(self, 'ACS') + view.wait_displayed() + self.browser.plugin.ensure_page_safe() + if view.acs_drawer.clear_search_btn.is_displayed: + view.acs_drawer.clear_search_btn.click() + wait_for(lambda: view.acs_drawer.content_table.is_displayed, timeout=10, delay=1) + acs_table = view.acs_drawer.content_table.read() + for i, row in enumerate(acs_table): + row['details'] = self.get_row_drawer_content(row_id=i) + return acs_table + + def item_action(self, acs_name=None, action=None): + """ + Function that performs action[Refresh, Delete] on ACS item(s) + + Args: + acs_name (str or list): ACS name or list of ACS names + action (str): Action to be performed on ACS item(s) [Refresh, Delete] + + Raises: + ValueError: If acs_name or action is not specified + ValueError: If acs_name is empty list + ValueError: If ACS is not found + ValueError: If error message is displayed + """ + + if acs_name is None or action is None: + raise ValueError('Either acs_name and action must be specified!') + if isinstance(acs_name, list) and not acs_name: + raise ValueError('acs_name must not be empty list!') + + view = self.navigate_to(self, 'ACS') + view.wait_displayed() + self.browser.plugin.ensure_page_safe() + # Convert string to list for further use + acs_name = [acs_name] if isinstance(acs_name, str) else acs_name + + for acs in acs_name: + view.acs_drawer.search_bar.fill(f'name = {acs}') + if not view.acs_drawer.content_table.is_displayed: + raise ValueError(f'ACS {acs} not found!') + # Check the checkbox of ACS item + view.acs_drawer.content_table[0][0].widget.click() + + # Wait for ACS table to be refreshed + wait_for(lambda: view.acs_drawer.content_table.is_displayed, timeout=10, delay=1) + + view.acs_drawer.kebab_menu.item_select(action) + if view.error_message.is_displayed: + raise ValueError(f'Error while taking an action on ACS: {view.error_message.read()}') + if action == 'Refresh': + view.acs_drawer.clear_search_btn.click() + + self.browser.plugin.do_refresh() + wait_for(lambda: view.acs_drawer.content_table.is_displayed, timeout=10, delay=1) + + def refresh_acs(self, acs_name): + """Function that refreshes ACS item(s)""" + self.item_action(acs_name, 'Refresh') + + def delete_acs(self, acs_name): + """Function that deletes ACS item(s)""" + self.item_action(acs_name, 'Delete') + + def all_items_action(self, action): + """ + Function that performs action[Refresh, Delete] on all ACS items + + Args: + action (str): Action to be performed on all ACS items [Refresh, Delete] + + Raises: + ValueError: If no ACS is found + ValueError: If error message is displayed + """ + + view = self.navigate_to(self, 'ACS') + view.wait_displayed() + self.browser.plugin.ensure_page_safe() + wait_for(lambda: view.acs_drawer.content_table.is_displayed, timeout=10, delay=1) + if not view.acs_drawer.content_table.is_displayed: + raise ValueError('No ACS found!') + + # Wait for ACS table to be refreshed + self.wait_for_content_table(view) + view.acs_drawer.select_all.click() + view.acs_drawer.kebab_menu.item_select(action) + if view.error_message.is_displayed: + raise ValueError( + f'Error while taking an action on all ACS: {view.error_message.read()}' + ) + + self.browser.plugin.do_refresh() + wait_for(lambda: view.title.is_displayed, timeout=10, delay=1) + + def refresh_all_acs(self): + """Function that refreshes all ACS items""" + self.all_items_action('Refresh') + + def delete_all_acs(self): + """Function that deletes all ACS items""" + self.all_items_action('Delete') + + def edit_helper(self, acs_name_to_edit): + """ + Helper function that navigates to ACS item and loads side panel view + + Args: + acs_name_to_edit (str): ACS name to edit + + Raises: + ValueError: If acs_name_to_edit cannot be found + """ + + view = self.navigate_to(self, 'ACS') + view.wait_displayed() + self.browser.plugin.ensure_page_safe() + + # Check if acs we want to edit exists + view.acs_drawer.search_bar.fill(f'name = {acs_name_to_edit}') + view.wait_displayed() + if not view.acs_drawer.content_table.is_displayed: + raise ValueError(f'ACS {acs_name_to_edit} not found!') + # Click on ACS name in ACS table + view.acs_drawer.content_table[0][1].widget.click() + # Load side panel view + view = RowDrawer(self.browser) + wait_for(lambda: view.details.title.is_displayed, timeout=10, delay=1) + return view + + def close_details_side_panel(self): + """Function that closes side panel view""" + + view = AlternateContentSourcesView(self.browser) + time.sleep(2) + view.acs_drawer.content_table[0][1].widget.click() + + def edit_acs_details( + self, acs_name_to_edit=None, new_acs_name=None, new_description=None, check_parameters=True + ): + """ + Function that edits ACS items details + + Args: + acs_name_to_edit (str): ACS name to be edited + new_acs_name (str): New ACS name + new_description (str): Description to be set for ACS item + check_parameters (bool): Whether to check function parameters + + Raises: + ValueError: At least acs_name_to_edit and one of new_acs_name or new_description + must be specified! + ValueError: If Error alert is displayed after editing ACS item + """ + + if ( + (acs_name_to_edit is None) + and ((new_acs_name is None) or (new_description is None)) + and check_parameters + ): + raise ValueError( + 'At least acs_name_to_edit and one of new_acs_name or new_description ' + 'must be specified!' + ) + + view = self.edit_helper(acs_name_to_edit) + view.details.edit_details.click() + # Load EditDetailsModal view + view = EditDetailsModal(self.browser) + if new_acs_name is not None: + view.name.fill(new_acs_name) + if new_description is not None: + view.description.fill(new_description) + view.edit_button.click() + # Wait for the possible error to pop up + time.sleep(1) + if view.error_message.is_displayed: + raise ValueError(f'Error while editing: {view.error_message.read()}') + self.close_details_side_panel() + + def dual_list_selector_edit_helper( + self, + view=None, + add_all=False, + remove_all=False, + options_to_add=None, + options_to_remove=None, + ): + """ + Function that provides action over dual list selector + like adding one or selection of items from left or right list, + adding or removing all items from left or right list + and submitting the changes. + + It also checks if entered parameters are valid. + + Args: + view: View to be used for the action + add_all (bool): Whether to add all available options + remove_all (bool): Whether to remove all available options + options_to_add (str or list): List of options to add + options_to_remove (str or list): List of options to remove + + Raises: + ValueError: When given option is not available for addition + ValueError: When given option is not available for removal + ValueError: If add_all is True but add_all button is disabled + ValueError: If remove_all is True but remove_all button is disabled + """ + + # Manage the options + if options_to_add is not None: + for option in options_to_add: + view.available_options_search.fill(option) + # Check if option is available + x = view.available_options_list.read() + if (not option) or (option not in x): + raise ValueError(f'Option {option} not available for addition!') + view.available_options_list.fill(option) + view.add_selected.click() + + if options_to_remove is not None: + for option in options_to_remove: + view.chosen_options_search.fill(option) + # Check if option is available to be removed + x = view.chosen_options_list.read() + if (not option) or (option not in x): + raise ValueError(f'Option {option} not available for removing!') + view.chosen_options_list.fill(option) + view.remove_selected.click() + + if add_all: + if view.add_all.disabled: + raise ValueError('Add all button is disabled, cannot add all options!') + view.add_all.click() + if remove_all: + remove_all_flag = view.remove_all.disabled + if remove_all_flag: + raise ValueError('Remove all button is disabled, cannot remove all options!') + view.remove_all.click() + + view.edit_button.click() + + def edit_capsules( + self, + acs_name_to_edit=None, + use_http_proxies=False, + add_all=False, + remove_all=False, + options_to_add=None, + options_to_remove=None, + check_parameters=True, + ): + """ + Function that edits ACS capsules. + + Args: + acs_name_to_edit (str): ACS name to be edited + use_http_proxies (bool): Whether to use HTTP proxies + add_all (bool): Whether to add all available options + remove_all (bool): Whether to remove all available options + options_to_add (str or list): List of options to add + options_to_remove (str or list): List of options to remove + check_parameters (bool): Whether to check function parameters + + Raises: + ValueError: If add_all and remove_all are True at the same time + ValueError: If add_all or remove_all are True at the same time + with options_to_add or options_to_remove + """ + if check_parameters: + if add_all and remove_all: + raise ValueError('add_all and remove_all cannot be True at the same time!') + if (add_all or remove_all) and ( + options_to_add is not None or options_to_remove is not None + ): + raise ValueError( + 'add_all or remove_all cannot be True at the same time with ' + 'options_to_add or options_to_remove!' + ) + + # Check options parameters and cast them to list + if options_to_add is not None and isinstance(options_to_add, str): + options_to_add = [options_to_add] + if options_to_remove is not None and isinstance(options_to_remove, str): + options_to_remove = [options_to_remove] + + view = self.edit_helper(acs_name_to_edit) + # Close side panel view + view.capsules.edit_capsules.click() + view = EditCapsulesModal(self.browser) + wait_for(lambda: view.available_options_search.is_displayed, timeout=10, delay=1) + # Toggle Use HTTP proxies + if use_http_proxies is False and view.use_http_proxies.selected: + view.use_http_proxies.click() + if use_http_proxies is True and not view.use_http_proxies.selected: + view.use_http_proxies.click() + + # Call helper function that handles adding/removing + # options from dual list selector abd saves changes + self.dual_list_selector_edit_helper( + view, add_all, remove_all, options_to_add, options_to_remove + ) + self.close_details_side_panel() + + def edit_url_subpaths( + self, acs_name_to_edit=None, new_url=None, new_subpaths=None, check_parameters=True + ): + """ + Function that edits ACS url and subpaths + + Args: + acs_name_to_edit (str): ACS name to be edited + new_url (str): New ACS url + new_subpaths (str or list): Subpaths to be set for ACS item + check_parameters (bool): Whether to check function parameters + + Raises: + ValueError: At least acs_name_to_edit and one of new_url + or new_subpaths must be specified! + ValueError: If Error alert is displayed while editing ACS item's url or subpaths + """ + if check_parameters: + if (acs_name_to_edit is None) and ((new_url is None) or (new_subpaths is None)): + raise ValueError( + 'At least acs_name_to_edit and one of new_url or ' + 'new_subpaths must be specified!' + ) + + view = self.edit_helper(acs_name_to_edit) + view.url_and_subpaths.edit_url_and_subpaths.click() + # Load EditUrlAndSubpathsModal view + view = EditUrlAndSubpathsModal(self.browser) + new_subpaths = new_subpaths if isinstance(new_subpaths, list) else [new_subpaths] + # if there is more than one element in new_subpaths, + # join them to one string separated by comma as required by the satellite + new_subpaths = ','.join(new_subpaths) if len(new_subpaths) > 1 else new_subpaths[0] + if new_url is not None: + view.base_url.fill(new_url) + if view.url_err.is_displayed: + raise ValueError(f'Error while editing url: {view.url_err.read()}') + if new_subpaths is not None: + view.subpaths.fill(new_subpaths) + if view.paths_err.is_displayed: + raise ValueError(f'Error while editing subpaths: {view.paths_err.read()}') + + # Save changes + view.edit_button.click() + self.close_details_side_panel() + + def edit_credentials( + self, + acs_name_to_edit=None, + verify_ssl=False, + ca_cert=None, + manual_auth=False, + username=None, + password=None, + content_credentials_auth=False, + ssl_client_cert=None, + ssl_client_key=None, + none_auth=False, + check_parameters=True, + ): + """ + Function that edits ACS credentials. + User needs to choose only one of the authentication methods + [manual_auth, content_credentials_auth, none_auth]. + + Args: + acs_name_to_edit (str): ACS name to be edited + verify_ssl (bool): Whether to verify SSL + ca_cert (str): CA certificate to choose + manual_auth (bool): Whether to use manual authentication + username (str): Username to be set + password (str): Password to be set + content_credentials_auth (bool): Whether to use content credentials authentication + ssl_client_cert (str): SSL client certificate to choose + ssl_client_key (str): SSL client key to choose + none_auth (bool): Whether to use no authentication + check_parameters (bool): Whether to check function parameters + + Raises: + ValueError: At least acs_name_to_edit and one of manual_auth, + content_credentials_auth, none_auth must be specified! + ValueError: At least one of username and password + must be specified when using manual authentication + ValueError: At least one of ssl_client_cert and ssl_client_key + must be specified when using credentials + """ + + if check_parameters: + if (acs_name_to_edit is None) and ( + sum([manual_auth, content_credentials_auth, none_auth] != 1) + ): + raise ValueError( + 'At least acs_name_to_edit and one of ' + 'manual_auth, content_credentials_auth, none_auth must be specified!' + ) + + view = self.edit_helper(acs_name_to_edit) + view.credentials.edit_credentials.click() + # Load EditCredentialsModal view + view = EditCredentialsModal(self.browser) + wait_for(lambda: view.verify_ssl_toggle.is_displayed, timeout=10, delay=1) + + # Toggle verify_ssl + if verify_ssl is False and view.verify_ssl_toggle.selected: + view.verify_ssl_toggle.click() + if verify_ssl is True and not view.verify_ssl_toggle.selected: + view.verify_ssl_toggle.click() + + # Select CA certificate + if ca_cert is not None and view.verify_ssl_toggle.selected: + view.select_ca_cert.fill(ca_cert) + + # Select manual authentication method + if manual_auth: + if (username is None) and (password is None) and check_parameters: + raise ValueError( + 'At least one of username and password must be specified ' + 'when using manual authentication!' + ) + + view.manual_auth_radio_btn.fill(True) + if username is not None: + view.username.fill(username) + if password is not None: + view.password.fill(password) + + # Select content credentials authentication method + if content_credentials_auth: + if (ssl_client_cert is None) and (ssl_client_key is None) and check_parameters: + raise ValueError( + 'At least one of ssl_client_cert and ssl_client_key ' + 'must be specified when using content credentials authentication!' + ) + + view.content_credentials_radio_btn.fill(True) + if ssl_client_cert is not None: + view.ssl_client_cert.fill(ssl_client_cert) + if ssl_client_key is not None: + view.ssl_client_key.fill(ssl_client_key) + + # Select no authentication method + if none_auth and not view.none_auth_toggle.selected: + view.none_auth_toggle.click() + + # Save changes + view.edit_button.click() + self.close_details_side_panel() + + def edit_products( + self, + acs_name_to_edit=None, + add_all=False, + remove_all=False, + products_to_add=None, + products_to_remove=None, + check_parameters=True, + ): + """ + Function that edits ACS products. + + Args: + acs_name_to_edit (str): ACS name to be edited + add_all (bool): Whether to add all available options + remove_all (bool): Whether to remove all available options + products_to_add (str or list): List of products to add + products_to_remove (str or list): List of products to remove + check_parameters (bool): Whether to check function parameters + + Raises: + ValueError: If add_all and remove_all are True at the same time + ValueError: If add_all or remove_all are True at the same time + with options_to_add or options_to_remove + """ + + if check_parameters: + if add_all and remove_all: + raise ValueError('add_all and remove_all cannot be True at the same time!') + if (add_all or remove_all) and ( + products_to_add is not None or products_to_remove is not None + ): + raise ValueError( + 'add_all or remove_all cannot be True at the same time with ' + 'options_to_add or options_to_remove!' + ) + + # Check options parameters and cast them to list + if products_to_add is not None and isinstance(products_to_add, str): + products_to_add = [products_to_add] + if products_to_remove is not None and isinstance(products_to_remove, str): + products_to_remove = [products_to_remove] + + view = self.edit_helper(acs_name_to_edit) + view.products.edit_products.click() + view = EditProductsModal(self.browser) + wait_for(lambda: view.available_options_search.is_displayed, timeout=10, delay=1) + # Call helper function that handles adding/removing options + # from dual list selector and saves changes + self.dual_list_selector_edit_helper( + view, add_all, remove_all, products_to_add, products_to_remove + ) + self.close_details_side_panel() + + def create_new_acs( + self, + custom_type=False, + simplified_type=False, + rhui_type=False, + content_type=None, + name=None, + description=None, + add_all_capsules=False, + capsules_to_add=None, + use_http_proxies=False, + add_all_products=False, + products_to_add=None, + base_url=None, + subpaths=None, + verify_ssl=False, + ca_cert=None, + manual_auth=False, + username=None, + password=None, + content_credentials_auth=False, + ssl_client_cert=None, + ssl_client_key=None, + none_auth=False, + check_parameters=True, + ): + """ + Function that creates new ACS according to the given parameters. + + Args: + custom_type (bool): Whether to create custom type ACS + simplified_type (bool): Whether to create simplified type ACS + rhui_type (bool): Whether to create RHUI type ACS + content_type (str): Content type to be selected ['yum', 'file'] + name (str): Name of ACS to be created + description (str): Description of ACS to be created + add_all_capsules (bool): Whether to add all capsules + capsules_to_add (str or list): List of capsules to add + use_http_proxies (bool): Whether to use https proxies + add_all_products (bool): Whether to add all products + products_to_add (str or list): List of products to add + base_url (str): Base URL of ACS to be created + subpaths (str or list): List(multiple paths) ['path1/', 'foo/bar'] + or + String(only one path) 'path1/' + of subpaths to be added. + !!! Each subpath entry must end with '/' !!! + verify_ssl (bool): Whether to verify SSL + ca_cert (str): CA certificate to be selected + manual_auth (bool): Whether to use manual authentication + username (str): Username to be used for manual authentication + password (str): Password to be used for manual authentication + content_credentials_auth (bool): Whether to use content credentials authentication + ssl_client_cert (str): SSL client certificate to be used for content + credentials authentication + ssl_client_key (str): SSL client key to be used for content credentials authentication + none_auth (bool): Whether to use no authentication + check_parameters (bool): Whether to check function parameters + + Raises: + ValueError: If more than one type is selected + ValueError: If more than one credential type is selected + ValueError: If content_type is specified when rhui_type is True + ValueError: If name is None + ValueError: If content_type is not specified when + custom_type or simplified_type is True + ValueError: If capsules_to_add is none when add_all_capsules is False + ValueError: If products_to_add is none when add_all_products is False + ValueError: If base_url is None and custom_type or rhui_type is True + ValueError: If verify_ssl is False and ca_cert is not None + ValueError: If manual_auth is True and username and password is None + ValueError: If rhui_type is True and manual_auth is True + ValueError: If given capsule is not available for addition + ValueError: If you are trying to create ACS with already taken name + ValueError: If given product is not available for addition + ValueError: If base url is not valid + ValueError: If subpaths are not valid + ValueError: If there is some general error after adding ACS + """ + if check_parameters: + # CHECK THE PARAMETERS + if sum([custom_type, simplified_type, rhui_type]) != 1: + raise ValueError('Only one type can be selected!') + + if custom_type or rhui_type: + if sum([manual_auth, content_credentials_auth, none_auth]) != 1: + raise ValueError('Only one credential type can be selected!') + + if rhui_type and content_type is not None: + raise ValueError('content_type cannot be specified when rhui_type is True!') + + if name is None: + raise ValueError('name cannot be None!') + + if (custom_type or simplified_type) and content_type is None: + raise ValueError( + 'content_type cannot be None when custom_type or simplified_type is True!' + ) + + if add_all_capsules is False and capsules_to_add is None: + raise ValueError('capsules_to_add cannot be None when add_all_capsules is False!') + + if simplified_type and (add_all_products is False and products_to_add is None): + raise ValueError( + 'While simplified mode is selected ' + 'products_to_add cannot be None when add_all_products is False!' + ) + + if (custom_type is True or rhui_type is True) and base_url is None: + raise ValueError('base_url cannot be None when custom_type or rhui_type is True!') + + if verify_ssl is False and ca_cert is not None: + raise ValueError('ca_cert must be None when verify_ssl is False!') + + if manual_auth is True and (username is None or password is None): + raise ValueError('username and password cannot be None when manual_auth is True!') + + if rhui_type is True and manual_auth is True: + raise ValueError('manual_auth cannot be True when rhui_type is True!') + + view = self.navigate_to(self, 'ACS') + view.wait_displayed() + self.browser.plugin.ensure_page_safe() + wait_for(lambda: view.title.is_displayed, timeout=10, delay=1) + # If there are some ACS already created + if view.acs_drawer.content_table.is_displayed: + # Check if we are not creating ACS with the name that already exists + view.acs_drawer.search_bar.fill(f'name = {name}') + if view.acs_drawer.content_table.is_displayed: + raise ValueError(f'ACS with {name} already exists!') + else: + view.acs_drawer.clear_search.click() + time.sleep(2) + + view.acs_drawer.add_source.click() + # Load wizard modal for adding new ACS + view = AddAlternateContentSourceModal(self.browser) + view.wait_displayed() + self.browser.plugin.ensure_page_safe() + wait_for(lambda: view.title.is_displayed, timeout=10, delay=1) + + # Select ACS type + if custom_type: + view.select_source_type.custom_option.click() + view.select_source_type.content_type_select.fill(content_type.capitalize()) + elif simplified_type: + view.select_source_type.simplified_option.click() + view.select_source_type.content_type_select.fill(content_type.capitalize()) + elif rhui_type: + view.select_source_type.rhui_option.click() + + # Fill name and description + view.name_source + view.fill({'name_source.name': name, 'name_source.description': description}) + + # Select capsules + if add_all_capsules: + view.select_capsule.add_all.click() + if capsules_to_add is not None: + # If provided argument is string, convert it to list + if isinstance(capsules_to_add, str): + capsules_to_add = [capsules_to_add] + for capsule in capsules_to_add: + view.select_capsule.available_options_search.fill(capsule) + # Check if capsule is available + x = view.select_capsule.available_options_list.read() + if (not capsule) or (capsule not in x): + raise ValueError(f'Capsule {capsule} not available for adition!') + view.select_capsule.available_options_list.fill(capsule) + view.select_capsule.add_selected.click() + + if use_http_proxies: + view.select_capsule.use_http_proxies.fill(True) + + # Select products + if simplified_type: + if add_all_products: + view.select_products.add_all.click() + if products_to_add is not None: + # If provided argument is string, convert it to list + if isinstance(products_to_add, str): + products_to_add = [products_to_add] + for product in products_to_add: + view.select_products.available_options_search.fill(product) + # Check if product is available + x = view.select_products.available_options_list.read() + if (not product) or (product not in x): + raise ValueError(f'Product {product} not available for adition!') + view.select_products.available_options_list.fill(product) + view.select_products.add_selected.click() + + # Fill in URLs and paths and credentials + if custom_type or rhui_type: + # URLs and paths + view.url_and_paths.base_url.fill(base_url) + if view.url_and_paths.url_err.is_displayed: + raise ValueError(f'Error while adding url: {view.url_and_paths.url_err.read()}') + if subpaths is not None: + # Create string to be filled as subpaths + if isinstance(subpaths, list): + subpaths = ','.join(subpaths) if len(subpaths) > 1 else subpaths[0] + if isinstance(subpaths, str): + view.url_and_paths.subpaths.fill(subpaths) + if view.url_and_paths.paths_err.is_displayed: + raise ValueError( + f'Error while editing subpaths: {view.url_and_paths.paths_err.read()}' + ) + + # Credentials + if verify_ssl: + view.credentials.verify_ssl_toggle.fill(True) + if ca_cert is not None: + view.credentials.select_ca_cert.fill(ca_cert) + if manual_auth: + view.fill( + { + 'credentials.manual_auth_radio_btn': True, + 'credentials.username': username, + 'credentials.password': password, + } + ) + elif content_credentials_auth: + view.fill( + { + 'credentials.content_credentials_radio_btn': True, + 'credentials.ssl_client_cert': ssl_client_cert, + 'credentials.ssl_client_key': ssl_client_key, + } + ) + elif none_auth: + view.credentials.none_auth_radio_btn.fill(True) + + # Confirm addition + view.review_details.add_button.click() + # Wait for modal to close + wait_for(lambda: view.title.is_displayed is False, timeout=10, delay=1) + # Check that there is no error message after adding new ACS + view = AlternateContentSourcesView(self.browser) + if view.error_message.is_displayed: + raise ValueError(f'Error while adding ACS: {view.error_message.read()}') + # Wait for ACS to be added to the table + wait_for(lambda: view.acs_drawer.content_table.is_displayed, timeout=10, delay=1) + # Close the side panel + view = AlternateContentSourcesView(self.browser) + view.acs_drawer.content_table[-1][1].widget.click() + + +@navigator.register(AcsEntity, 'ACS') +class OpenAcsPage(NavigateStep): + """Navigate to the ACS page""" + + VIEW = AlternateContentSourcesView + + @retry_navigation + def step(self, *args, **kwargs): + self.view.menu.select('Content', 'Alternate Content Sources') diff --git a/airgun/session.py b/airgun/session.py index 671fd1a9f..66a00686a 100644 --- a/airgun/session.py +++ b/airgun/session.py @@ -10,6 +10,7 @@ from airgun import settings from airgun.browser import AirgunBrowser from airgun.browser import SeleniumBrowserFactory +from airgun.entities.acs import AcsEntity from airgun.entities.activationkey import ActivationKeyEntity from airgun.entities.ansible_role import AnsibleRolesEntity from airgun.entities.ansible_variable import AnsibleVariablesEntity @@ -316,6 +317,11 @@ def take_screenshot(self): if not self.browser.selenium.save_screenshot(path): LOGGER.error('Failed to save screenshot %s', path) + @cached_property + def acs(self): + """Instance of Alternate Content Sources entity.""" + return self._open(AcsEntity) + @cached_property def activationkey(self): """Instance of Activation Key entity.""" diff --git a/airgun/views/acs.py b/airgun/views/acs.py new file mode 100644 index 000000000..d1d9b0cf1 --- /dev/null +++ b/airgun/views/acs.py @@ -0,0 +1,378 @@ +from widgetastic.widget import Checkbox +from widgetastic.widget import Text +from widgetastic.widget import TextInput +from widgetastic.widget import View +from widgetastic_patternfly4 import Button +from widgetastic_patternfly4 import Drawer +from widgetastic_patternfly4 import Dropdown +from widgetastic_patternfly4 import FormSelect +from widgetastic_patternfly4 import Pagination +from widgetastic_patternfly4 import Radio +from widgetastic_patternfly4 import Switch +from widgetastic_patternfly4.ouia import Button as OUIAButton +from widgetastic_patternfly4.ouia import FormSelect as OUIAFormSelect +from widgetastic_patternfly4.ouia import PatternflyTable +from widgetastic_patternfly4.ouia import Switch as OUIASwitch +from widgetastic_patternfly4.ouia import Text as OUIAText +from widgetastic_patternfly4.ouia import TextInput as OUIATextInput + +from airgun.views.common import BaseLoggedInView +from airgun.views.common import WizardStepView +from airgun.widgets import DualListSelector +from airgun.widgets import EditModal +from airgun.widgets import ItemsList +from airgun.widgets import SearchInput + + +class EditDetailsModal(EditModal): + """Class representing the Edit Details modal.""" + + ROOT = '//div[@data-ouia-component-id="acs-edit-details-modal"]' + + name = OUIATextInput('acs-edit-name-field') + description = TextInput(locator='.//textarea[@id="acs_description_field"]') + + edit_button = OUIAButton('edit-acs-details-submit') + cancel_button = OUIAButton('edit-acs-details-cancel') + + +class EditCapsulesModal(DualListSelector): + """Class representing the Edit Capsule modal.""" + + ROOT = '//div[@data-ouia-component-id="acs-edit-smart-proxies-modal"]' + + use_http_proxies = Switch(locator='.//label[@for="use-http-proxies-switch"]') + + edit_button = OUIAButton('edit-acs-smart-proxies-submit') + cancel_button = OUIAButton('edit-acs-smart-proxies-cancel') + + +class EditUrlAndSubpathsModal(EditModal): + """Class repsenting the Edit URL and Subpaths modal.""" + + ROOT = '//div[@data-ouia-component-id="acs-edit-url-paths-modal"]' + + base_url = OUIATextInput('acs-base-url-field') + url_err = Text('.//div[contains(@id, "acs_base_url-helper")]') + subpaths = TextInput(locator='.//textarea[@id="acs_subpath_field"]') + paths_err = Text('.//div[contains(@id, "acs_subpaths-helper")]') + + edit_button = OUIAButton('edit-acs-url-submit') + cancel_button = OUIAButton('edit-acs-url-cancel') + + +class EditCredentialsModal(EditModal): + """Class representing the Edit Credentials modal.""" + + ROOT = '//div[@data-ouia-component-id="acs-edit-credentials-modal"]' + + verify_ssl_toggle = Switch(locator='.//label[@for="verify-ssl-switch"]') + select_ca_cert = OUIAFormSelect('sslCAcert-select') + + manual_auth_radio_btn = Radio(id='manual_auth') + username = OUIATextInput('acs-username-field') + password = OUIATextInput('acs-password-field') + + content_credentials_radio_btn = Radio(id='content_credentials') + ssl_client_cert = OUIAFormSelect('ssl-client-cert-select') + ssl_client_key = OUIAFormSelect('ssl_client_key_select') + + none_auth_radio_btn = Radio(id='none') + + edit_button = OUIAButton('edit-acs-credentials-submit') + cancel_button = OUIAButton('edit-acs-credentials-cancel') + + +class EditProductsModal(DualListSelector): + """Class representing the Edit Products modal.""" + + ROOT = '//div[@data-ouia-component-id="acs-edit-products-modal"]' + + edit_button = OUIAButton('edit-acs-products-submit') + cancel_button = OUIAButton('edit-acs-products-cancel') + + +class AddAlternateContentSourceModal(View): + """ + Class representing the "Add Alternate Content Source" modal. + It contains multiple nested classes each representing a step of the wizard. + + There are two variations of wizard steps depending on selected source type: + + * Select source type + * Name source + * Select Capsule + + @ Simplified: + * Select products + + @ Custom, RHUI: + * URL and paths + * Credentials + + * Review details + """ + + ROOT = '//div[contains(@data-ouia-component-id, "OUIA-Generated-Modal-large-")]' + + title = Text('.//h2[contains(@class, "pf-c-title")]') + close_modal = Button(locator='.//button[@aria-label="Close"]') + + @View.nested + class select_source_type(WizardStepView): + expander = Text('.//button[contains(.,"Select source type")]') + custom_option = Text('//*[@id="custom"]') + simplified_option = Text('//*[@id="simplified"]') + rhui_option = Text('//*[@id="rhui"]') + content_type_select = OUIAFormSelect('content-type-select') + + @View.nested + class name_source(WizardStepView): + expander = Text('.//button[contains(.,"Name source")]') + name = OUIATextInput('acs_name_field') + description = TextInput(locator='.//textarea[@id="acs_description_field"]') + + @View.nested + class select_capsule(WizardStepView, DualListSelector): + expander = Text( + './/button[contains(.,"Select smart proxy") or contains(.,"Select capsule")]' + ) + use_http_proxies = OUIASwitch('use-http-proxies-switch') + + @View.nested + class url_and_paths(WizardStepView): + expander = Text('.//button[contains(.,"URL and paths")]') + base_url = OUIATextInput('acs_base_url_field') + url_err = Text('.//div[contains(@id, "acs_base_url-helper")]') + subpaths = TextInput(locator='.//textarea[@id="acs_subpath_field"]') + paths_err = Text('.//div[contains(@id, "acs_subpaths-helper")]') + + @View.nested + class credentials(WizardStepView): + expander = Text('.//button[contains(.,"Credentials")]') + verify_ssl_toggle = OUIASwitch('verify-ssl-switch') + select_ca_cert = FormSelect(locator='.//select[option[text()="Select a CA certificate"]]') + + manual_auth_radio_btn = Radio(id='manual_auth') + username = OUIATextInput('acs_username_field') + password = OUIATextInput('acs_password_field') + + content_credentials_radio_btn = Radio(id='content_credentials') + ssl_client_cert = OUIAFormSelect('sslCert-select') + ssl_client_key = OUIAFormSelect('sslKey-select') + + none_auth_radio_btn = Radio(id='none') + + @View.nested + class select_products(WizardStepView, DualListSelector): + expander = Text('.//button[contains(.,"Select products")]') + + @View.nested + class review_details(WizardStepView): + expander = Text('.//button[contains(.,"Review details")]') + add_button = Button(locator='.//button[normalize-space(.)="Add"]') + cancel_button = Button(locator='.//button[normalize-space(.)="Cancel"]') + + +class AcsStackItem: + """ + Class containing basic properties and methods + for stack item in the ACS drawer. + """ + + @property + def is_expanded(self): + """Returns True if the Details stack item is expanded.""" + return 'pf-m-expanded' in self.browser.classes(self.ROOT) + + def expand(self): + """Expands the Details stack item.""" + if not self.is_expanded: + self.browser.click(self.title) + + def collapse(self): + """Collapses the stack item.""" + if self.is_expanded: + self.browser.click(self.title) + + +class RowDrawer(View): + """ + Class that describes row drawer of the Alternate Content Sources page. + Drawer can contain following items depending on the type of the ACS: + + * Details: [Simplified, Custom, RHUI] + * Capsules: [Simplified, Custom, RHUI] + * URL and subpaths: [Custom, RHUI] + * Credentials: [Custom, RHUI] + * Products: [Simplified] + + """ + + title = OUIAText('acs-name-text') + refresh_resource = OUIAButton('refresh-acs') + kebab_menu = Dropdown(locator='//button[contains(@aria-label, "details_actions")]') + last_refresh = Text('//dd[contains(@aria-label, "last_refresh_text_value")]') + + @View.nested + class details(View, AcsStackItem): + """Class representing the Details stack item in the ACS drawer.""" + + ROOT = ( + '//div[normalize-space(.)="Details" and contains(@class, "pf-c-expandable-section")]' + ) + + title = OUIAText('expandable-details-text') + edit_details = Button( + locator='//button[contains(@aria-label, "edit-details-pencil-edit")]' + ) + + @View.nested + class details_stack_content(View): + """Class representing content of the Details stack item.""" + + ROOT = '//div[@id="showDetails"]' + + name = Text('//dd[@aria-label="name_text_value"]') + description = Text('//dd[@aria-label="description_text_value"]') + type = Text('//dd[@aria-label="type_text_value"]') + content_type = Text('//dd[@aria-label="content_type_text_value"]') + + @View.nested + class capsules(View, AcsStackItem): + """Class representing the Capsules stack item in the ACS drawer""" + + ROOT = ( + '//div[(normalize-space(.)="Capsules")' + ' and contains(@class, "pf-c-expandable-section")]' + ) + title = OUIAText('expandable-smart-proxies-text') + edit_capsules = Button( + locator='//button[contains(@aria-label, "edit-smart-proxies-pencil-edit")]' + ) + + @View.nested + class capsules_stack_content(View): + """Class representing content of the Capsules stack item.""" + + ROOT = '//div[@id="showSmartProxies"]' + + capsules_list = ItemsList(locator='.//ul[contains(@class, "pf-c-list")]') + use_http_proxies = Text('//dd[@aria-label="useHttpProxies_value"]') + + @View.nested + class url_and_subpaths(View, AcsStackItem): + """ + Class representing the URL and subpaths stack item in the ACS drawer. + Present only if ACS is of type 'Custom' or 'RHUI'. + """ + + ROOT = ( + '//div[normalize-space(.)="URL and subpaths" ' + 'and contains(@class, "pf-c-expandable-section")]' + ) + + title = OUIAText('expandable-url-paths-text') + edit_url_and_subpaths = Button( + locator='//button[contains(@aria-label, "edit-urls-pencil-edit")]' + ) + + @View.nested + class url_and_subpaths_stack_content(View): + """Class representing content of the URL and subpaths stack item.""" + + ROOT = '//div[@id="showUrlPaths"]' + + url = Text('//dd[@aria-label="url_text_value"]') + subpaths = Text('//dd[@aria-label="subpaths_text_value"]') + + @View.nested + class credentials(View, AcsStackItem): + """ + Class representing the Credentials stack item in the ACS drawer. + Present only if ACS is of type 'Custom' or 'RHUI'. + """ + + ROOT = ( + '//div[normalize-space(.)="Credentials" ' + 'and contains(@class, "pf-c-expandable-section")]' + ) + + title = OUIAText('expandable-credentials-text') + edit_credentials = Button( + locator='//button[contains(@aria-label, "edit-credentials-pencil-edit")]' + ) + + @View.nested + class credentials_stack_content(View): + """Class representing content of the Credentials stack item.""" + + ROOT = '//div[@id="showCredentials"]' + + verify_ssl = Text('//dd[@aria-label="verifySSL_value"]') + ssl_ca_certificate = Text('//dd[@aria-label="sslCaCert_value"]') + ssl_client_certificate = Text('//dd[@aria-label="sslClientCert_value"]') + ssl_client_key = Text('//dd[@aria-label="sslClientKey_value"]') + username = Text('//dd[@aria-label="username_value"]') + password = Text('//dd[@aria-label="password_value"]') + + @View.nested + class products(View, AcsStackItem): + """ + Class representing the Products stack item in the ACS drawer. + Present only if ACS is of type 'Simplified'. + """ + + ROOT = ( + '//div[normalize-space(.)="Products" and contains(@class, "pf-c-expandable-section")]' + ) + + title = OUIAText('expandable-products-text') + edit_products = Button( + locator='//button[contains(@aria-label, "edit-products-pencil-edit")]' + ) + + @View.nested + class products_stack_content(View): + """Class representing content of the Products stack item.""" + + ROOT = '//div[@id="showProducts"]' + + products_list = ItemsList(locator='.//ul[contains(@class, "pf-c-list")]') + + +class AlternateContentSourcesView(BaseLoggedInView): + """Class that describes view of the Alternate Content Sources page.""" + + title = Text('//h1[contains(., "Alternate Content Sources")]') + error_message = Text('//div[contains(@aria-label, "Danger Alert")]') + + @View.nested + class acs_drawer(Drawer): + """Class that describes drawer of the Alternate Content Sources page""" + + select_all = Checkbox(locator='//input[contains(@aria-label, "Select all")]') + search_bar = SearchInput(locator='.//div[contains(@class, "pf-c-input-group")]//input') + clear_search_btn = Button(locator='//button[@aria-label="Reset search"]') + add_source = OUIAButton('create-acs') + kebab_menu = Dropdown( + locator='.//div[contains(@data-ouia-component-id, "acs-bulk-actions")]' + ) + + content_table = PatternflyTable( + component_id='alternate-content-sources-table', + column_widgets={ + 0: Checkbox(locator='.//input[@type="checkbox"]'), + 'Name': Text('.//a[contains(@data-ouia-component-id, "acs-link-text-")]'), + 'Type': Text('.//td[3]'), + 'LastRefresh': Text('.//td[4]'), + 4: Dropdown(locator='.//div[contains(@class, "pf-c-dropdown")]'), + }, + ) + + clear_search = OUIAButton('empty-state-secondary-action-router-link') + pagination = Pagination() + + @property + def is_displayed(self): + return self.browser.wait_for_element(self.title, exception=False) is not None diff --git a/airgun/views/host_new.py b/airgun/views/host_new.py index b3f95c94e..db57bbb01 100644 --- a/airgun/views/host_new.py +++ b/airgun/views/host_new.py @@ -1,6 +1,5 @@ import time -from selenium.webdriver.common.keys import Keys from widgetastic.widget import Checkbox from widgetastic.widget import Text from widgetastic.widget import TextInput @@ -26,16 +25,7 @@ from airgun.widgets import Pf4ActionsDropdown from airgun.widgets import Pf4ConfirmationDialog from airgun.widgets import SatTableWithoutHeaders - - -class SearchInput(TextInput): - def fill(self, value, enter_timeout=1): - changed = super().fill(value) - if changed: - # workaround for BZ #2140636 - time.sleep(enter_timeout) - self.browser.send_keys(Keys.ENTER, self) - return changed +from airgun.widgets import SearchInput class RemediationView(View): diff --git a/airgun/widgets.py b/airgun/widgets.py index 8a4c34dc1..c9e937a83 100644 --- a/airgun/widgets.py +++ b/airgun/widgets.py @@ -1,4 +1,7 @@ +import time + from cached_property import cached_property +from selenium.webdriver.common.keys import Keys from wait_for import wait_for from widgetastic.exceptions import NoSuchElementException from widgetastic.exceptions import WidgetOperationFailed @@ -2478,3 +2481,48 @@ def fill(self, value): def read(self): return self.selected + + +class SearchInput(TextInput): + def fill(self, value, enter_timeout=1, after_enter_timeout=3): + changed = super().fill(value) + if changed: + # workaround for BZ #2140636 + time.sleep(enter_timeout) + self.browser.send_keys(Keys.ENTER, self) + time.sleep(after_enter_timeout) + return changed + + +class EditModal(View): + """Class representing the Edit modal header""" + + title = Text('.//h1') + close_button = PF4Button('acs-edit-details-modal-ModalBoxCloseButton') + + error_message = Text('//div[contains(@aria-label, "Danger Alert")]') + + +class DualListSelector(EditModal): + """Class representing the Dual List Selector in a modal.""" + + from widgetastic_patternfly4 import Button + + available_options_search = SearchInput( + locator='.//input[@aria-label="Available search input"]' + ) + available_options_list = ItemsList( + locator='.//div[contains(@class, "pf-m-available")]' + '//ul[@class="pf-c-dual-list-selector__list"]' + ) + + add_selected = Button(locator='.//button[@aria-label="Add selected"]') + add_all = Button(locator='.//button[@aria-label="Add all"]') + remove_all = Button(locator='.//button[@aria-label="Remove all"]') + remove_selected = Button(locator='.//button[@aria-label="Remove selected"]') + + chosen_options_search = SearchInput(locator='.//input[@aria-label="Chosen search input"]') + chosen_options_list = ItemsList( + locator='.//div[contains(@class, "pf-m-chosen")]' + '//ul[@class="pf-c-dual-list-selector__list"]' + )