diff --git a/script/selenium/selenium_lib.py b/script/selenium/selenium_lib.py index 723dcd0..aca1223 100644 --- a/script/selenium/selenium_lib.py +++ b/script/selenium/selenium_lib.py @@ -3,19 +3,22 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import datetime +import getpass +import json +import logging import os import random import re +import shutil +import subprocess import sys -import time -import json import tempfile -from subprocess import getoutput +import time import tkinter as tk +from subprocess import getoutput from tkinter import filedialog -import getpass -from pykeepass import PyKeePass +from pykeepass import PyKeePass from randomwordfr import RandomWordFr from selenium import webdriver from selenium.common.exceptions import ( @@ -30,17 +33,54 @@ from selenium.webdriver.firefox.options import Options from selenium.webdriver.firefox.service import Service from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support.ui import Select, WebDriverWait + +from script.config import config_file + +logging.basicConfig( + format=( + "%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d]" + " %(message)s" + ), + datefmt="%Y-%m-%d:%H:%M:%S", + level=logging.INFO, +) +_logger = logging.getLogger(__name__) # TODO maybe use TODO lib CONFIG_FILE = "./script/todo/todo.json" -CONFIG_OVERRIDE_FILE = "./private/todo.json" +CONFIG_OVERRIDE_FILE = "./private/todo/todo.json" +LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt" + +MONTHS_FR = { + "janvier": 1, + "février": 2, + "fevrier": 2, + "mars": 3, + "avril": 4, + "mai": 5, + "juin": 6, + "juillet": 7, + "août": 8, + "aout": 8, + "septembre": 9, + "octobre": 10, + "novembre": 11, + "décembre": 12, + "decembre": 12, +} + +# TODO maybe use TODO lib +CONFIG_FILE = "./script/todo/todo.json" +CONFIG_OVERRIDE_FILE = "./private/todo/todo.json" LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt" class SeleniumLib(object): def __init__(self, config): self.config = config + self.wait_human_time = config.human_time_speed + self.config_file = config_file.ConfigFile() self.kdbx = None self.video_recorder = None self.default_download_dir_path = tempfile.mkdtemp() @@ -60,8 +100,13 @@ class SeleniumLib(object): ) self.dirname_recording = dir_path_screencast self.driver = None - with open(".odoo-version") as txt: - self.odoo_version = txt.read() + if os.path.isfile(".odoo-version"): + with open(".odoo-version") as txt: + self.odoo_version = txt.read() + else: + # Default version + # TODO instead, need to detect the version from client and detection + self.odoo_version = "18.0" def do_screenshot(self): if self.config.scenario_screenshot: @@ -113,25 +158,155 @@ class SeleniumLib(object): # Créez une instance du navigateur Firefox avec les options de navigation privée try: - self.driver = webdriver.Firefox( - options=firefox_options, service=firefox_services + user = os.environ.get("USER", "Non défini") + uid = os.getuid() + gid = os.getgid() + print(f"INFO: Execute by user: {user}, UID: {uid}, GID: {gid}") + + # Exécuter la commande 'whoami' pour confirmation + whoami_result = subprocess.run( + ["whoami"], capture_output=True, text=True ) - except Exception: + print(f"INFO: Result 'whoami': {whoami_result.stdout.strip()}") + + except Exception as e: + print(f"ERROR when check user: {e}") + + try: + if self.config.use_chrome_driver: + from selenium.webdriver.chrome.options import ( + Options as ChromeOptions, + ) + from selenium.webdriver.chrome.service import Service + + chrome_options = ChromeOptions() + if self.config.window_size: + # chrome_options.add_argument("--window-size=1920,1080") + chrome_options.add_argument( + f"--window-size={self.config.window_size}" + ) + + if self.config.use_network: + chrome_options.set_capability( + "se:name", "ERPLibre selenium" + ) + self.driver = webdriver.Remote( + options=chrome_options, + command_executor=self.config.use_network, + ) + else: + from webdriver_manager.chrome import ChromeDriverManager + + service = Service( + ChromeDriverManager().install(), + log_path="/tmp/chromedriver.log", + ) + self.driver = webdriver.Chrome( + options=chrome_options, + # options=firefox_options, + service=service, + keep_alive=True, + ) + elif self.config.use_firefox_driver: + from selenium.webdriver.firefox.options import Options + from selenium.webdriver.firefox.service import Service + + firefox_services = None + # shutil.which("geckodriver") + if self.config.gecko_binary_path: + firefox_services = Service( + executable_path=self.config.gecko_binary_path, + log_output=self.config.debug, + ) + firefox_options = webdriver.FirefoxOptions() + if not self.config.not_private_mode: + firefox_options.add_argument("--private") + + if self.config.firefox_binary_path: + firefox_options.binary_location = ( + self.config.firefox_binary_path + ) + elif not self.config.use_network: + status_location = subprocess.check_output( + ["which", "firefox"], text=True + ).strip() + firefox_options.binary_location = status_location + + firefox_profile = webdriver.FirefoxProfile() + firefox_profile.set_preference( + "browser.cache.disk.enable", False + ) + firefox_profile.set_preference( + "browser.cache.memory.enable", False + ) + firefox_profile.set_preference( + "browser.cache.offline.enable", False + ) + firefox_profile.set_preference("network.http.use-cache", False) + firefox_options.set_preference( + "permissions.default.desktop-notification", 1 + ) + firefox_options.set_preference( + "browser.download.folderList", 2 + ) + firefox_options.set_preference( + "browser.download.manager.showWhenStarting", False + ) + firefox_options.set_preference( + "browser.download.dir", self.default_download_dir_path + ) + firefox_options.set_preference( + "browser.helperApps.neverAsk.saveToDisk", + "application/octet-stream,application/pdf,application/x-pdf", + ) + firefox_options.set_preference("pdfjs.disabled", True) + if self.config.window_size: + # chrome_options.add_argument("--window-size=1920,1080") + firefox_options.add_argument( + f"--window-size={self.config.window_size}" + ) + + if self.config.headless: + firefox_options.add_argument("--headless") + # firefox_options.add_argument("--disable-gpu") + firefox_options.add_argument("--no-sandbox") + firefox_options.add_argument("--disable-dev-shm-usage") + firefox_options.add_argument("--disable-dbus") + + if self.config.use_network: + firefox_options.set_capability( + "se:name", "ERPLibre selenium" + ) + self.driver = webdriver.Remote( + options=firefox_options, + command_executor=self.config.use_network, + ) + else: + self.driver = webdriver.Firefox( + options=firefox_options, + service=firefox_services, + keep_alive=True, + ) + + except Exception as e: print( "Cannot open Firefox profile, so will force firefox snap for" " Ubuntu users." ) - firefox_services = Service( - executable_path=getoutput( - "find /snap/firefox -name geckodriver" - ).split("\n")[-1] - ) - firefox_options.binary_location = getoutput( - "find /snap/firefox -name firefox" - ).split("\n")[-1] - self.driver = webdriver.Firefox( - options=firefox_options, service=firefox_services - ) + # TODO au lieu, faire une recherche de geckodriver avant de démarrer + # firefox_services = Service( + # executable_path=getoutput( + # "find /snap/firefox -name geckodriver" + # ).split("\n")[-1], log_path='/tmp/geckodriver.log' + # ) + # firefox_options.binary_location = getoutput( + # "find /snap/firefox -name firefox" + # ).split("\n")[-1] + # self.driver = webdriver.Firefox( + # options=firefox_options, service=firefox_services + # ) + raise e + print("The driver is launched!") # Ajout de l'enregistrement if self.config.record_mode: @@ -152,66 +327,100 @@ class SeleniumLib(object): # Install DarkReader to help my eyes # TODO do a script to check if it's the last version - if not self.config.no_dark_mode: - self.driver.install_addon( - "./script/selenium/darkreader-firefox.xpi", temporary=True - ) - self.driver.install_addon( - "./script/selenium/odoo_debug-4.0.xpi", temporary=True - ) - - if not self.config.not_private_mode and not self.config.no_dark_mode: - self.driver.get("about:addons") - # Enable Dark Reader into incognito - self.click("/html/body/div/div[1]/categories-box/button[2]") - self.click( - "/html/body/div/div[2]/div/addon-list/section[1]/addon-card/div/div/div/div/button", - ) - self.click( - "/html/body/div/div[2]/div/addon-list/section[1]/addon-card/div/addon-options/panel-list/panel-item[5]", - ) - # Enable Run in Private Windows - try: - self.click( - "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[6]/div/label[1]/input", + if self.config.use_firefox_driver and not self.config.use_network: + if not self.config.no_dark_mode: + self.driver.install_addon( + "./script/selenium/darkreader-firefox.xpi", temporary=True ) - except Exception: - # For old version before Firefox 138 - self.click( - "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[5]/div/label[1]/input", + self.driver.install_addon( + "./script/selenium/odoo_debug-4.0.xpi", temporary=True ) - # Enable Odoo_debug into incognito - # Back - self.click( - "/html/body/div/div[2]/addon-page-header/div/div[2]/button" - ) - self.click( - "/html/body/div/div[2]/div/addon-list/section[1]/addon-card[2]/div/div/div/div/button", - ) - self.click( - "/html/body/div/div[2]/div/addon-list/section[1]/addon-card[2]/div/addon-options/panel-list/panel-item[5]", - ) - # Enable Run in Private Windows - try: + if ( + not self.config.not_private_mode + and not self.config.no_dark_mode + ): + self.driver.get("about:addons") + # Enable Dark Reader into incognito + self.click("/html/body/div/div[1]/categories-box/button[2]") self.click( - "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[6]/div/label[1]/input", + "/html/body/div/div[2]/div/addon-list/section[1]/addon-card/div/div/div/div/button", ) - except Exception: - # For old version before Firefox 138 self.click( - "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[5]/div/label[1]/input", + "/html/body/div/div[2]/div/addon-list/section[1]/addon-card/div/addon-options/panel-list/panel-item[5]", ) + # Enable Run in Private Windows + try: + self.click( + "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[6]/div/label[1]/input", + ) + except Exception: + # For old version before Firefox 138 + self.click( + "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[5]/div/label[1]/input", + ) + + # Enable Odoo_debug into incognito + # Back + self.click( + "/html/body/div/div[2]/addon-page-header/div/div[2]/button" + ) + self.click( + "/html/body/div/div[2]/div/addon-list/section[1]/addon-card[2]/div/div/div/div/button", + ) + self.click( + "/html/body/div/div[2]/div/addon-list/section[1]/addon-card[2]/div/addon-options/panel-list/panel-item[5]", + ) + # Enable Run in Private Windows + try: + self.click( + "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[6]/div/label[1]/input", + ) + except Exception: + # For old version before Firefox 138 + self.click( + "/html/body/div/div[2]/div/addon-card/div/addon-details/named-deck/section/div[5]/div/label[1]/input", + ) # Ouvrez la page web if not ignore_open_web: self.driver.get(f"{self.config.url}/web") # Close tab opening by DarkReader - if self.config.not_private_mode and not self.config.no_dark_mode: - self.driver.switch_to.window(self.driver.window_handles[-1]) - self.driver.close() - self.driver.switch_to.window(self.driver.window_handles[0]) + if self.config.use_firefox_driver and not self.config.use_network: + if self.config.not_private_mode and not self.config.no_dark_mode: + self.driver.switch_to.window(self.driver.window_handles[-1]) + self.driver.close() + self.driver.switch_to.window(self.driver.window_handles[0]) + + def get_kdbx(self): + if self.kdbx: + return self.kdbx + print("Open KDBX") + # Open file + chemin_fichier_kdbx = self.config_file.get_config(["kdbx", "path"]) + if not chemin_fichier_kdbx: + root = tk.Tk() + root.withdraw() # Hide the main window + chemin_fichier_kdbx = filedialog.askopenfilename( + title="Select a File", + filetypes=(("KeepassX files", "*.kdbx"),), + ) + if not chemin_fichier_kdbx: + # _logger.error(f"KDBX is not configured, please fill {CONFIG_FILE}") + return + + mot_de_passe_kdbx = self.config_file.get_config(["kdbx", "password"]) + if not mot_de_passe_kdbx: + mot_de_passe_kdbx = getpass.getpass( + prompt="Entrez votre mot de passe : " + ) + + kp = PyKeePass(chemin_fichier_kdbx, password=mot_de_passe_kdbx) + + if kp: + self.kdbx = kp + return kp def get_kdbx(self): if self.kdbx: @@ -283,17 +492,46 @@ class SeleniumLib(object): button.click() return button - def get_element(self, by: str = By.ID, value: str = None, timeout=5): + def get_element( + self, + by: str = By.ID, + value: str = None, + timeout=5, + wait_clickable=False, + is_visible=True, + ): + only_one = False wait = WebDriverWait(self.driver, timeout) - ele = wait.until( - EC.visibility_of_any_elements_located( - ( - by, - value, + if wait_clickable: + ele = wait.until( + EC.element_to_be_clickable( + ( + by, + value, + ) ) ) - ) - + elif is_visible: + ele = wait.until( + EC.visibility_of_any_elements_located( + ( + by, + value, + ) + ) + ) + else: + only_one = True + ele = wait.until( + EC.presence_of_element_located( + ( + by, + value, + ) + ) + ) + if only_one: + return ele return ele[0] def get_element_not_visible( @@ -431,13 +669,14 @@ class SeleniumLib(object): viewport_ele_value: str = None, element: WebElement = None, with_index: bool = False, + is_visible=True, index_of_list: int = 0, ): # ele = self.driver.find_element(by, value) if element: ele = element else: - ele = self.get_element(by, value, timeout) + ele = self.get_element(by, value, timeout, is_visible=is_visible) if not no_scroll: viewport_ele = None if viewport_ele_value: @@ -742,40 +981,40 @@ class SeleniumLib(object): # Website def odoo_show_robot_message( - self, msg="Ho no!", hide_message_after_x_seconds=0 + self, msg="Ho no!", hide_message_after_x_seconds=0, robot_base64=None ): # Injecter l'animation SVG dans la page script_text = "" if msg: msg_to_print = msg.replace("'", "\\'") - script_text = f""" - // Ajouter le texte - const textElement = document.createElement('div'); - textElement.innerText = '{msg_to_print}'; - textElement.style.color = '#FFFFFF'; - textElement.style.fontSize = '24px'; - textElement.style.marginBottom = '20px'; - textElement.style.fontWeight = 'bold'; + lst_mst_to_print = msg_to_print.split("\n") + script_text = "" + for index, line in enumerate(lst_mst_to_print): + script_text += f""" + // Ajouter le texte + const textElement_{index} = document.createElement('div'); + textElement_{index}.textContent = '{line}'; + textElement_{index}.style.color = '#FFFFFF'; + textElement_{index}.style.fontSize = '24px'; + textElement_{index}.style.marginBottom = '20px'; + textElement_{index}.style.fontWeight = 'bold'; - overlay.appendChild(textElement); - """ - script = ( - """ - (function() { - // Créer un div pour l'overlay - const overlay = document.createElement('div'); - overlay.id = 'uniqueOverlay'; // Ajout de l'ID - overlay.style.position = 'fixed'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.width = '100%'; - overlay.style.height = '100%'; - overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; - overlay.style.display = 'flex'; - overlay.style.justifyContent = 'center'; - overlay.style.alignItems = 'center'; - overlay.style.zIndex = '9999'; + overlay.appendChild(textElement_{index}); + """ + if robot_base64: + script_robot = "" + script_add_robot = f""" + const img = new Image(); + img.alt = 'Robot'; + img.className = 'cute-robot'; + img.style.width = '150px'; + img.style.cursor = 'pointer'; + img.src = '{robot_base64}'; + overlay.appendChild(img); +""" + else: + script_robot = """ // Contenu SVG du robot const svgContent = ` @@ -801,11 +1040,32 @@ class SeleniumLib(object): `; +""" + script_add_robot = """ + // Ajouter le contenu SVG au div + overlay.innerHTML += svgContent; + """ + script = ( + """ + (function() { + // Créer un div pour l'overlay + const overlay = document.createElement('div'); + overlay.id = 'uniqueOverlay'; // Ajout de l'ID + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; + overlay.style.display = 'flex'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.zIndex = '9999'; """ + + script_robot + script_text + + script_add_robot + """ - // Ajouter le contenu SVG au div - overlay.innerHTML += svgContent; // Ajouter un gestionnaire de clic pour supprimer l'overlay overlay.onclick = function() { @@ -856,7 +1116,7 @@ class SeleniumLib(object): def odoo_web_click_principal_menu(self): # Click menu button - if self.odoo_version == "16.0": + if self.odoo_version in ["16.0", "17.0", "18.0"]: button = self.click_with_mouse_move( By.XPATH, "//button[contains(@class, 'dropdown-toggle') and" @@ -869,6 +1129,119 @@ class SeleniumLib(object): ) return button + def odoo_web_click_item_list(self, index=0, field_name="name"): + # Click item list + all_row = self.get_all_element( + by=By.XPATH, + value=f"//tr[contains(@class, 'o_data_row')]/td[contains(@name, '{field_name}')]", + timeout=10, + ) + row = all_row[index] if index < len(all_row) else all_row[-1] + button = self.click_with_mouse_move(element=row) + return button + + def odoo_web_click_open_search(self): + # Click search button + button = self.click_with_mouse_move( + By.XPATH, + "//button[contains(@class, 'o_searchview_dropdown_toggler')]", + timeout=10, + ) + return button + + def odoo_web_click_open_search_add_group_custom(self, group_name): + # Add personalize group + button = self.click_with_mouse_move( + By.XPATH, + value="//select[contains(@class, 'o_add_custom_group_menu')]", + timeout=10, + ) + if self.wait_human_time: + time.sleep(1) + select_elem = self.get_element( + By.XPATH, + value="//select[contains(@class, 'o_add_custom_group_menu')]", + timeout=10, + wait_clickable=False, + ) + + select_obj = Select(select_elem) + # (ex: ) + select_obj.select_by_value(group_name) + return select_elem + + def odoo_web_click_open_search_filter( + self, + filter_label=None, + filter_sub_label=None, + lst_update_condition=None, + ): + if not lst_update_condition: + lst_update_condition = [] + if filter_label: + button = self.click_with_mouse_move( + By.XPATH, + value=f"//button[contains(@class,'o_menu_item') and contains(normalize-space(.), '{filter_label}')]", + timeout=10, + ) + if filter_sub_label: + button = self.click_with_mouse_move( + By.XPATH, + value=f"//span[contains(@class,'o_item_option') and contains(normalize-space(.), '{filter_sub_label}')]", + timeout=10, + ) + if lst_update_condition: + filter_text = f"{filter_label}: {filter_sub_label}" + button = self.click_with_mouse_move( + By.XPATH, + value=f"//small[contains(@class,'o_facet_value') and contains(normalize-space(.), '{filter_text}')]/../..//i[contains(@class,'fa-cog')]", + timeout=10, + is_visible=False, + ) + time.sleep(1) + for item_update_condition in lst_update_condition: + condition_value = item_update_condition.get("condition") + if condition_value: + select_elem = self.click_with_mouse_move( + By.XPATH, + value=f"//div[contains(@class,'modal-content')]//select[contains(@class,'pe-3')]", + timeout=10, + is_visible=False, + ) + select_obj = Select(select_elem) + # (ex: ) + select_obj.select_by_value(condition_value) + else: + _logger.error( + f"Not supported item_update_condition '{item_update_condition}'" + ) + # Save + button = self.click_with_mouse_move( + By.XPATH, + value=f"//footer[contains(@class,'modal-footer')]//button[contains(@class,'btn-primary') and contains(normalize-space(.), 'Confirmer')]", + timeout=10, + ) + + def odoo_fill_search_input(self, text, selection_research=None): + input_elem = self.click_with_mouse_move( + By.XPATH, + value="//input[contains(@class, 'o_searchview_input')]", + timeout=10, + ) + input_elem.send_keys(text) + time.sleep(1) + if not selection_research: + input_elem.send_keys(Keys.ENTER) + else: + input_autocomplete_elem = self.click_with_mouse_move( + By.XPATH, + value="//ul[contains(@class,'o_searchview_autocomplete')]" + f"//li[contains(@class,'o_menu_item') and .//b[normalize-space(.)='{selection_research}']]" + "//a[not(contains(@class,'o_expand'))]", + ) + + return input_elem + def odoo_web_principal_menu_click_root_menu(self, from_text=""): if not from_text: raise Exception( @@ -952,6 +1325,23 @@ class SeleniumLib(object): "o_form_button_save" ) + def odoo_web_change_view( + self, view_name="list", raise_error=False, timeout=5 + ): + if view_name not in ["list", "kanban", "timeline"]: + msg = f"odoo_web_change_view view_name '{view_name}' invalid" + if raise_error: + raise Exception(msg) + _logger.error(msg) + return None + + status_button = self.click_with_mouse_move( + by=By.XPATH, + value=f"//nav[contains(@class, 'o_cp_switch_buttons')]/button[contains(@class, 'o_{view_name}')]", + ) + print(f"Change view '{view_name}' cliqué.") + return status_button + def odoo_web_form_click_web_publish_action(self): return self.odoo_web_form_click_button_action("website_publish_button") @@ -1059,14 +1449,20 @@ class SeleniumLib(object): checkbox_div.click() # TODO improve speed by observation of comportement time.sleep(0.5) - checkbox_date_div = self.driver.find_element( - By.CSS_SELECTOR, ".bootstrap-datetimepicker-widget" - ) - # time.sleep(0.5) str_date_today = date_to_select.strftime("%Y-%m-%d") - new_value = checkbox_date_div.find_element( - By.XPATH, f"//td[@data-day='{str_date_today}']" - ) + if self.odoo_version in ["17.0", "18.0"]: + checkbox_date_div = self.driver.find_element( + By.CSS_SELECTOR, ".o_datetime_picker" + ) + new_value = self.select_date(self.driver, date_to_select) + else: + checkbox_date_div = self.driver.find_element( + By.CSS_SELECTOR, ".bootstrap-datetimepicker-widget" + ) + # time.sleep(0.5) + new_value = checkbox_date_div.find_element( + By.XPATH, f"//td[@data-day='{str_date_today}']" + ) new_value.click() return new_value @@ -1108,6 +1504,9 @@ class SeleniumLib(object): time.sleep(var_wait_time) self.video_recorder.start() + def quit(self): + self.driver.quit() + def stop_record(self): time.sleep(self.config.selenium_default_delay_presentation) @@ -1122,10 +1521,13 @@ class SeleniumLib(object): ".webm", ".mp4" ) if self.config.selenium_video_auto_convert_mp4: - os.popen( - "ffmpeg -i" - f" {self.filename_recording} {filename_recording_mp4}" - ) + with_auto_fix = True + if with_auto_fix: + cmd = f'ffmpeg -i {self.filename_recording} -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" -c:v libx264 -pix_fmt yuv420p -crf 23 -preset veryfast -c:a aac -b:a 160k -movflags +faststart {filename_recording_mp4}' + else: + cmd = f"ffmpeg -i {self.filename_recording} {filename_recording_mp4}" + print(cmd) + os.popen(cmd) if self.config.selenium_video_auto_play_video: print(f"Play video {self.filename_recording}") @@ -1193,6 +1595,94 @@ class SeleniumLib(object): return date_naissance.strftime("%Y-%m-%d") + def _get_visible_month_year(self, picker): + header = picker.find_element( + By.CSS_SELECTOR, ".o_datetime_picker_header .o_header_part" + ) + text = header.text.strip().lower() # ex: "octobre 2025" + parts = text.split() + # s'il y a des espaces insécables ou formats bizarres + mois_txt = parts[0].replace("\xa0", " ") + annee = int(parts[-1]) + mois = MONTHS_FR[mois_txt] + return ( + mois, + annee, + header.text, + ) # on retourne aussi le texte exact pour attendre le changement + + def select_date(self, driver, date_to_select): + # 1) ouvrir/viser le datepicker (si besoin, clique l'input avant) + picker = WebDriverWait(driver, 10).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, ".o_datetime_picker") + ) + ) + + # 2) naviguer au bon mois + max_hops = 24 # sécurité (2 ans) + for _ in range(max_hops): + mois_vis, annee_vis, header_txt = self._get_visible_month_year( + picker + ) + if (annee_vis, mois_vis) == ( + date_to_select.year, + date_to_select.month, + ): + break + + # bouton suivant/précédent dans l'entête du picker courant + if (annee_vis, mois_vis) < ( + date_to_select.year, + date_to_select.month, + ): + btn = picker.find_element( + By.CSS_SELECTOR, ".o_datetime_picker_header .o_next" + ) + else: + btn = picker.find_element( + By.CSS_SELECTOR, ".o_datetime_picker_header .o_previous" + ) + + btn.click() + # attendre que l'en-tête change pour éviter les doubles clics trop rapides + WebDriverWait(driver, 10).until( + EC.text_to_be_present_in_element( + ( + By.CSS_SELECTOR, + ".o_datetime_picker_header .o_header_part", + ), + "", + ) + ) + WebDriverWait(driver, 10).until( + lambda d: picker.find_element( + By.CSS_SELECTOR, ".o_datetime_picker_header .o_header_part" + ).text + != header_txt + ) + else: + raise RuntimeError( + "Impossible d'atteindre le mois voulu dans le délai imparti." + ) + + # 3) cliquer le jour (les jours cliquables sont des