Reflect the current year in all TechnoLibre license headers across script/, test/, and docker/. Generated by Claude Code 2.1.74 model claude-sonnet-4-6 Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
1845 lines
66 KiB
Python
1845 lines
66 KiB
Python
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
import datetime
|
|
import getpass
|
|
import logging
|
|
import os
|
|
import random
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import tkinter as tk
|
|
from pathlib import Path
|
|
from tkinter import filedialog
|
|
|
|
from pykeepass import PyKeePass
|
|
from randomwordfr import RandomWordFr
|
|
from selenium import webdriver
|
|
from selenium.common.exceptions import (
|
|
ElementClickInterceptedException,
|
|
TimeoutException,
|
|
)
|
|
from selenium.webdriver import ActionChains
|
|
from selenium.webdriver.common.actions.wheel_input import ScrollOrigin
|
|
from selenium.webdriver.common.by import By
|
|
from selenium.webdriver.common.keys import Keys
|
|
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 Select, WebDriverWait
|
|
|
|
new_path = os.path.normpath(
|
|
os.path.join(os.path.dirname(__file__), "..", "..")
|
|
)
|
|
sys.path.append(new_path)
|
|
|
|
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__)
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
defaut_timeout = 60
|
|
|
|
|
|
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
|
|
if config.use_download_path_default:
|
|
self.default_download_dir_path = "/home/seluser/Downloads"
|
|
else:
|
|
self.default_download_dir_path = tempfile.mkdtemp()
|
|
if self.config.video_suffix:
|
|
self.filename_recording = (
|
|
f"video_{self.config.video_suffix}_"
|
|
+ time.strftime("%Y_%m_%d-%H_%M_%S")
|
|
+ ".webm"
|
|
)
|
|
else:
|
|
self.filename_recording = (
|
|
"video_" + time.strftime("%Y_%m_%d-%H_%M_%S") + ".webm"
|
|
)
|
|
dir_path_screencast = os.path.join(".", "screencasts")
|
|
self.filename_recording = os.path.join(
|
|
dir_path_screencast, self.filename_recording
|
|
)
|
|
self.dirname_recording = dir_path_screencast
|
|
self.driver = None
|
|
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:
|
|
dir_path = os.path.join(".", "screenshots")
|
|
if not os.path.isdir(dir_path):
|
|
os.mkdir(dir_path)
|
|
file_path = os.path.join(
|
|
dir_path,
|
|
f"{self.config.scenario}_{str(int(time.time() * 10000))}.png",
|
|
)
|
|
self.driver.save_screenshot(file_path)
|
|
|
|
def configure(self, ignore_open_web=False):
|
|
# Configuration pour lancer Firefox en mode de navigation privée
|
|
firefox_options = webdriver.FirefoxOptions()
|
|
if not self.config.not_private_mode:
|
|
firefox_options.add_argument("--private")
|
|
|
|
if self.config.headless:
|
|
firefox_options.add_argument("--headless")
|
|
|
|
# firefox_options.set_preference("browser.link.open_newwindow", 3)
|
|
# firefox_options.set_preference("browser.link.open_newwindow.restriction", 2)
|
|
|
|
# Définissez la préférence pour les notifications de bureau
|
|
# 1 signifie "autoriser", 2 signifie "bloquer"
|
|
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
|
|
)
|
|
if self.default_download_dir_path:
|
|
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)
|
|
firefox_services = None
|
|
if self.config.firefox_binary_path:
|
|
firefox_services = Service(
|
|
executable_path=self.config.firefox_binary_path
|
|
)
|
|
if self.config.gecko_binary_path:
|
|
firefox_options.binary_location = self.config.gecko_binary_path
|
|
|
|
# Créez une instance du navigateur Firefox avec les options de navigation privée
|
|
try:
|
|
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
|
|
)
|
|
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:
|
|
# test snap firefox for Ubuntu first
|
|
snap_firefox_path = (
|
|
"/snap/firefox/current/usr/lib/firefox/firefox"
|
|
)
|
|
firefox_path_exist = Path(snap_firefox_path).exists()
|
|
if firefox_path_exist:
|
|
firefox_options.binary_location = snap_firefox_path
|
|
else:
|
|
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
|
|
)
|
|
if self.default_download_dir_path:
|
|
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}"
|
|
# )
|
|
width, height = self.config.window_size.split(",")
|
|
firefox_options.add_argument("--width=" + str(width))
|
|
firefox_options.add_argument("--height=" + str(height))
|
|
|
|
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."
|
|
)
|
|
# 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:
|
|
# Create recording
|
|
new_path = os.path.normpath(
|
|
os.path.join(os.path.dirname(__file__), "..", "..")
|
|
)
|
|
sys.path.append(new_path)
|
|
from script.selenium.selenium_video import VideoRecorder
|
|
|
|
if not os.path.isdir(self.dirname_recording):
|
|
os.mkdir(self.dirname_recording)
|
|
|
|
self.video_recorder = VideoRecorder(
|
|
self.driver, filename=self.filename_recording
|
|
)
|
|
# import vlc
|
|
|
|
# Install DarkReader to help my eyes
|
|
# TODO do a script to check if it's the last version
|
|
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
|
|
)
|
|
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",
|
|
)
|
|
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.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_value(
|
|
["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_value(
|
|
["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
|
|
|
|
@staticmethod
|
|
def get_french_word_no_space_no_accent():
|
|
word = ""
|
|
rw = RandomWordFr()
|
|
while not word or " " in word:
|
|
word = rw.get().get("word")
|
|
if not re.match("^[a-zA-Z_]+$", word):
|
|
word = ""
|
|
return word
|
|
|
|
def click(self, xpath, timeout=defaut_timeout):
|
|
wait = WebDriverWait(self.driver, timeout)
|
|
button = wait.until(
|
|
EC.element_to_be_clickable(
|
|
(
|
|
By.XPATH,
|
|
xpath,
|
|
)
|
|
)
|
|
)
|
|
button.click()
|
|
return button
|
|
|
|
def get_element(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
timeout=defaut_timeout,
|
|
wait_clickable=False,
|
|
wait_is_invisible=False,
|
|
is_visible=True,
|
|
):
|
|
only_one = False
|
|
wait = WebDriverWait(self.driver, timeout)
|
|
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,
|
|
)
|
|
)
|
|
)
|
|
elif wait_is_invisible:
|
|
ele = wait.until(
|
|
EC.invisibility_of_element_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(
|
|
self, by: str = By.ID, value: str = None, timeout=defaut_timeout
|
|
):
|
|
wait = WebDriverWait(self.driver, timeout)
|
|
ele = wait.until(
|
|
EC.presence_of_element_located(
|
|
(
|
|
by,
|
|
value,
|
|
)
|
|
)
|
|
)
|
|
|
|
return ele
|
|
|
|
def get_elements_not_visible(
|
|
self, by: str = By.ID, value: str = None, timeout=defaut_timeout
|
|
):
|
|
wait = WebDriverWait(self.driver, timeout)
|
|
ele = wait.until(
|
|
EC.presence_of_all_elements_located(
|
|
(
|
|
by,
|
|
value,
|
|
)
|
|
)
|
|
)
|
|
|
|
return ele
|
|
|
|
def get_text_from_element(
|
|
self, by: str = By.ID, value: str = None, timeout=defaut_timeout
|
|
):
|
|
ele = self.get_element(by, value, timeout=timeout)
|
|
return ele.text
|
|
|
|
def get_all_element(
|
|
self, by: str = By.ID, value: str = None, timeout=defaut_timeout
|
|
):
|
|
wait = WebDriverWait(self.driver, timeout)
|
|
ele = wait.until(
|
|
EC.visibility_of_all_elements_located(
|
|
(
|
|
by,
|
|
value,
|
|
)
|
|
)
|
|
)
|
|
|
|
return ele
|
|
|
|
def wait_new_element_and_click(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
delay_wait=1,
|
|
timeout=defaut_timeout,
|
|
inject_cursor=False,
|
|
):
|
|
new_element = self.wait_new_element(
|
|
by=by, value=value, delay_wait=delay_wait, timeout=timeout
|
|
)
|
|
if new_element:
|
|
if inject_cursor:
|
|
self.inject_cursor()
|
|
self.click_with_mouse_move(element=new_element, no_scroll=True)
|
|
return new_element
|
|
|
|
def wait_new_element(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
delay_wait=1,
|
|
timeout=defaut_timeout,
|
|
timeout_wait=None,
|
|
):
|
|
element = None
|
|
# TODO support timeout_wait
|
|
while element is None:
|
|
time.sleep(delay_wait)
|
|
try:
|
|
element = self.get_element(by, value, timeout)
|
|
except TimeoutException as e:
|
|
pass
|
|
print(f"Waiting {delay_wait} seconds after value '{value}'")
|
|
return element
|
|
|
|
def wait_add_new_element_and_click(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
delay_wait=1,
|
|
timeout=defaut_timeout,
|
|
):
|
|
new_element = self.wait_add_new_element(
|
|
by=by, value=value, delay_wait=delay_wait, timeout=timeout
|
|
)
|
|
self.click_with_mouse_move(element=new_element, no_scroll=True)
|
|
return new_element
|
|
|
|
def wait_add_new_element(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
delay_wait=1,
|
|
timeout=defaut_timeout,
|
|
):
|
|
all_element_init = self.get_all_element(by=by, value=value)
|
|
len_all_element_init = len(all_element_init)
|
|
len_all_element = len_all_element_init
|
|
all_element = []
|
|
while len_all_element_init == len_all_element:
|
|
time.sleep(delay_wait)
|
|
all_element = self.get_all_element(by=by, value=value)
|
|
len_all_element = len(all_element)
|
|
print(f"Waiting {delay_wait} seconds after value '{value}'")
|
|
new_element = all_element[-1]
|
|
return new_element
|
|
|
|
def wait_increment_number_text_and_click(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
delay_wait=1,
|
|
timeout=defaut_timeout,
|
|
):
|
|
element_init = self.get_element(by=by, value=value, timeout=timeout)
|
|
actual_number = int(element_init.text)
|
|
number_goal = actual_number + 1
|
|
while actual_number < number_goal:
|
|
time.sleep(delay_wait)
|
|
element_goal = self.get_element(
|
|
by=by, value=value, timeout=timeout
|
|
)
|
|
actual_number = int(element_goal.text)
|
|
print(f"Waiting {delay_wait} seconds after value '{value}'")
|
|
self.click_with_mouse_move(element=element_init, no_scroll=True)
|
|
return element_init
|
|
|
|
def click_with_mouse_move(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
timeout: int = defaut_timeout,
|
|
no_scroll: bool = False,
|
|
viewport_ele_by: str = By.ID,
|
|
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, is_visible=is_visible)
|
|
if not no_scroll:
|
|
viewport_ele = None
|
|
if viewport_ele_value:
|
|
viewport_ele = self.get_element(
|
|
viewport_ele_by, viewport_ele_value, timeout
|
|
)
|
|
self.scrollto_element(ele, viewport_ele=viewport_ele)
|
|
# ActionChains(self.driver).move_to_element(ele).click().perform()
|
|
ActionChains(self.driver).move_to_element(ele).perform()
|
|
time.sleep(self.config.selenium_default_delay)
|
|
wait = WebDriverWait(self.driver, timeout)
|
|
if element:
|
|
button = wait.until(EC.element_to_be_clickable(element))
|
|
else:
|
|
if with_index:
|
|
buttons = wait.until(
|
|
EC.presence_of_all_elements_located(
|
|
(
|
|
by,
|
|
value,
|
|
)
|
|
)
|
|
)
|
|
button = buttons[index_of_list]
|
|
else:
|
|
button = wait.until(
|
|
EC.element_to_be_clickable(
|
|
(
|
|
by,
|
|
value,
|
|
)
|
|
)
|
|
)
|
|
try:
|
|
button.click()
|
|
except ElementClickInterceptedException as e:
|
|
try:
|
|
print(e)
|
|
self.driver.execute_script(
|
|
"arguments[0].scrollIntoView(true);", button
|
|
)
|
|
button.click()
|
|
except ElementClickInterceptedException as e:
|
|
print(e)
|
|
self.driver.execute_script("arguments[0].click();", button)
|
|
return button
|
|
|
|
def click_canvas_form(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
timeout: int = defaut_timeout,
|
|
form="carre",
|
|
):
|
|
|
|
# ele = self.driver.find_element(by, value)
|
|
ele = self.get_element(by, value, timeout)
|
|
actions = ActionChains(self.driver)
|
|
actions.move_to_element_with_offset(ele, -50, -50)
|
|
actions.click_and_hold()
|
|
if form == "carre":
|
|
actions.move_by_offset(50, 0)
|
|
actions.move_by_offset(0, 50)
|
|
actions.move_by_offset(-50, 0)
|
|
actions.move_by_offset(0, -50)
|
|
|
|
actions.release()
|
|
actions.perform()
|
|
|
|
def input_text_with_mouse_move(
|
|
self,
|
|
by: str = By.ID,
|
|
value: str = None,
|
|
text_value: str = "",
|
|
delay_after: int = -1,
|
|
timeout: int = defaut_timeout,
|
|
no_scroll: bool = False,
|
|
viewport_ele_by: str = By.ID,
|
|
viewport_ele_value: str = None,
|
|
clear_before: bool = False,
|
|
click_before: bool = False,
|
|
):
|
|
# ele = self.driver.find_element(by, value)
|
|
ele = self.get_element(by, value)
|
|
if not no_scroll:
|
|
viewport_ele = None
|
|
if viewport_ele_value:
|
|
viewport_ele = self.get_element(
|
|
viewport_ele_by, viewport_ele_value, timeout
|
|
)
|
|
self.scrollto_element(ele, viewport_ele=viewport_ele)
|
|
ActionChains(self.driver).move_to_element(ele).perform()
|
|
|
|
# Write content
|
|
if click_before:
|
|
# Need this to update a datetimepicker
|
|
ele.click()
|
|
time.sleep(0.3)
|
|
if clear_before:
|
|
ele.clear()
|
|
if click_before:
|
|
# Need this to update a datetimepicker
|
|
ele.click()
|
|
time.sleep(0.3)
|
|
ele.send_keys(text_value)
|
|
if delay_after > 0:
|
|
time.sleep(delay_after)
|
|
elif self.config.selenium_default_delay > 0:
|
|
time.sleep(self.config.selenium_default_delay)
|
|
|
|
def scrollto_element1(self, element_name):
|
|
# ele = self.driver.find_element(By.TAG_NAME, element_name)
|
|
ele = self.get_element(By.TAG_NAME, element_name)
|
|
# delta_y = ele.rect["y"]
|
|
ActionChains(self.driver).scroll_to_element(ele).perform()
|
|
# ele = self.get_element(By.TAG_NAME, element_name)
|
|
# delta_y = ele.rect["y"]
|
|
# ActionChains(self.driver).scroll_by_amount(0, delta_y).perform()
|
|
time.sleep(0.5)
|
|
|
|
def scrollto_xpath5(self, xpath):
|
|
footer = self.driver.find_element(By.XPATH, xpath)
|
|
# footer = self.get_element(By.XPATH, xpath)
|
|
delta_y = footer.rect["y"]
|
|
ActionChains(self.driver).scroll_by_amount(0, int(delta_y)).perform()
|
|
time.sleep(0.5)
|
|
|
|
def scrollto_xpath2(self, xpath):
|
|
footer = self.driver.find_element(By.XPATH, xpath)
|
|
# footer = self.get_element(By.XPATH, xpath)
|
|
scroll_origin = ScrollOrigin.from_element(footer)
|
|
ActionChains(self.driver).scroll_from_origin(
|
|
scroll_origin, 0, 100
|
|
).perform()
|
|
time.sleep(0.5)
|
|
|
|
def scrollto_xpath3(self, xpath):
|
|
footer = self.driver.find_element(By.XPATH, xpath)
|
|
# footer = self.get_element(By.XPATH, xpath)
|
|
scroll_origin = ScrollOrigin.from_element(footer)
|
|
ActionChains(self.driver).scroll_to_element(scroll_origin).perform()
|
|
time.sleep(0.5)
|
|
|
|
def scrollto_xpath(
|
|
self,
|
|
xpath,
|
|
offset_middle=True,
|
|
smooth_scroll=True,
|
|
smooth_speed=50,
|
|
smooth_fps=1 / 25,
|
|
):
|
|
footer = self.driver.find_element(By.XPATH, xpath)
|
|
# footer = self.get_element(By.XPATH, xpath)
|
|
scroll_origin = ScrollOrigin.from_viewport(10, 10)
|
|
delta_y = footer.rect["y"]
|
|
if offset_middle:
|
|
# TODO check size windows and divide by 2
|
|
delta_y -= 400
|
|
print(f"scroll to xpath at y:{delta_y} position")
|
|
if smooth_scroll:
|
|
actual_pos = 0
|
|
while delta_y > actual_pos:
|
|
actual_pos += smooth_speed
|
|
print(actual_pos)
|
|
ActionChains(self.driver).scroll_from_origin(
|
|
scroll_origin, 0, int(smooth_speed)
|
|
).perform()
|
|
print(f"Wait {smooth_fps} seconds")
|
|
time.sleep(smooth_fps)
|
|
print("end")
|
|
else:
|
|
ActionChains(self.driver).scroll_from_origin(
|
|
scroll_origin, 0, int(delta_y)
|
|
).perform()
|
|
|
|
time.sleep(0.5)
|
|
|
|
def scrollto_element(
|
|
self,
|
|
element,
|
|
smooth_scroll=True,
|
|
smooth_speed=50,
|
|
smooth_fps=1 / 25,
|
|
viewport_ele=None,
|
|
):
|
|
window_y = self.driver.execute_script(
|
|
"return window.scrollY || window.pageYOffset;"
|
|
)
|
|
window_height = self.driver.get_window_size().get("height")
|
|
element_height = element.rect["height"]
|
|
viewport_height = self.driver.execute_script(
|
|
"return window.innerHeight || document.documentElement.clientHeight;"
|
|
)
|
|
if viewport_ele:
|
|
scroll_origin = ScrollOrigin.from_element(viewport_ele, 10, 10)
|
|
else:
|
|
scroll_origin = ScrollOrigin.from_viewport(10, 10)
|
|
delta_y = element.rect["y"]
|
|
target_distance = (
|
|
delta_y - (viewport_height - element_height) / 2 - window_y
|
|
)
|
|
if window_y < delta_y < window_y + viewport_height - 50:
|
|
# No need to scroll
|
|
return
|
|
print(f"scroll to xpath at y:{delta_y} position")
|
|
if smooth_scroll:
|
|
actual_pos = 0
|
|
# Detect go top or bot
|
|
if delta_y > window_y:
|
|
step = abs(int(smooth_speed)) # to the top
|
|
condition = lambda a, d: a < d
|
|
go_down = True
|
|
else:
|
|
step = -abs(int(smooth_speed)) # to the bot
|
|
condition = lambda a, d: a > d
|
|
go_down = False
|
|
while condition(actual_pos, target_distance):
|
|
# TODO detect when cannot scroll, stop scrolling
|
|
actual_pos += step
|
|
if go_down and actual_pos > target_distance:
|
|
step = actual_pos - target_distance
|
|
elif not go_down and actual_pos < target_distance:
|
|
step = actual_pos - target_distance
|
|
|
|
ActionChains(self.driver).scroll_from_origin(
|
|
scroll_origin, 0, int(step)
|
|
).perform()
|
|
print(f"Wait {smooth_fps} seconds")
|
|
time.sleep(smooth_fps)
|
|
print("end")
|
|
else:
|
|
ActionChains(self.driver).scroll_from_origin(
|
|
scroll_origin, 0, int(target_distance)
|
|
).perform()
|
|
|
|
# action = ActionChains(self.driver)
|
|
# action.click_and_hold(element).move_by_offset(0, 1).perform()
|
|
# # Don't forget to release the click_and_hold, for example with:
|
|
# action.reset_actions()
|
|
time.sleep(0.5)
|
|
|
|
def inject_cursor(self):
|
|
cursor_script = """
|
|
var cursor = document.createElement('div');
|
|
cursor.style.position = 'absolute';
|
|
cursor.style.zIndex = '9999';
|
|
cursor.style.width = '30px';
|
|
cursor.style.height = '30px';
|
|
cursor.style.borderRadius = '50%';
|
|
cursor.style.backgroundColor = 'red';
|
|
cursor.style.pointerEvents = 'none';
|
|
document.body.appendChild(cursor);
|
|
|
|
document.addEventListener('mousemove', function(e) {
|
|
cursor.style.left = e.pageX - 5 + 'px';
|
|
cursor.style.top = e.pageY - 5 + 'px';
|
|
});
|
|
"""
|
|
try:
|
|
try:
|
|
self.driver.execute_script(cursor_script)
|
|
except Exception as e:
|
|
time.sleep(1)
|
|
print("Error execute script cursor, wait 1 second")
|
|
self.driver.execute_script(cursor_script)
|
|
except Exception as e:
|
|
time.sleep(3)
|
|
print("Error execute script cursor, wait 3 second")
|
|
self.driver.execute_script(cursor_script)
|
|
|
|
def scenario_create_new_account_with_random_user(
|
|
self, show_cursor=False, def_action_before_submit=None
|
|
):
|
|
# Click no account
|
|
if show_cursor:
|
|
self.inject_cursor()
|
|
self.click_with_mouse_move(
|
|
By.XPATH, "/html/body/div[1]/main/div/form/div[3]/div[1]/a[1]"
|
|
)
|
|
|
|
# Trouvez les éléments du formulaire
|
|
if show_cursor:
|
|
self.inject_cursor()
|
|
|
|
# Remplissez le courriel et le mot de passe
|
|
first_name = self.get_french_word_no_space_no_accent()
|
|
password = first_name.lower()
|
|
second_name = self.get_french_word_no_space_no_accent()
|
|
full_name = f"{first_name} {second_name}"
|
|
domain = self.get_french_word_no_space_no_accent().lower()
|
|
|
|
self.input_text_with_mouse_move(
|
|
By.NAME, "login", f"{password}@{domain}.com"
|
|
)
|
|
self.input_text_with_mouse_move(By.NAME, "name", full_name)
|
|
self.input_text_with_mouse_move(By.NAME, "password", password)
|
|
self.input_text_with_mouse_move(By.NAME, "confirm_password", password)
|
|
if def_action_before_submit:
|
|
def_action_before_submit()
|
|
self.click_with_mouse_move(
|
|
By.XPATH, "/html/body/div[1]/main/div/form/div[6]/button"
|
|
)
|
|
|
|
def open_tab(self, url):
|
|
# Open a new window
|
|
self.driver.execute_script("window.open('');")
|
|
# Switch to the new window and open new URL
|
|
self.driver.switch_to.window(self.driver.window_handles[1])
|
|
self.driver.get(url)
|
|
|
|
def switch_tab(self, index):
|
|
# Switch to the new window and open new URL
|
|
self.driver.switch_to.window(self.driver.window_handles[index])
|
|
|
|
def refresh(self):
|
|
self.driver.refresh()
|
|
|
|
def check_bot_chat_and_close(self):
|
|
try:
|
|
xpath_button = "/html/body/div[3]/div[1]/span[2]/a[2]"
|
|
button = self.driver.find_element(By.XPATH, xpath_button)
|
|
# button = self.get_element(By.XPATH, xpath_button)
|
|
self.click(self.driver, xpath_button)
|
|
except Exception:
|
|
print("Chatbot cannot be found, stop searching it.")
|
|
|
|
# Website
|
|
def odoo_show_robot_message(
|
|
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("'", "\\'")
|
|
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_{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 = `
|
|
<svg class="cute-robot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 150" style="width: 150px; cursor: pointer;">
|
|
<!-- Corps du robot -->
|
|
<rect x="25" y="50" width="50" height="70" rx="10" ry="10" fill="#4CAF50"/>
|
|
|
|
<!-- Tête du robot -->
|
|
<circle cx="50" cy="30" r="20" fill="#4CAF50"/>
|
|
|
|
<!-- Yeux du robot -->
|
|
<circle cx="42" cy="25" r="3" fill="#FFFFFF"/>
|
|
<circle cx="58" cy="25" r="3" fill="#FFFFFF"/>
|
|
|
|
<!-- Bouche du robot -->
|
|
<path d="M44 38 Q 50 42 56 38" stroke="#FFFFFF" stroke-width="2" fill="transparent"/>
|
|
|
|
<!-- Bras du robot -->
|
|
<rect x="15" y="60" width="15" height="50" rx="5" ry="5" fill="#388E3C"/>
|
|
<rect x="70" y="60" width="15" height="50" rx="5" ry="5" fill="#388E3C"/>
|
|
|
|
<!-- Jambes du robot -->
|
|
<rect x="35" y="120" width="15" height="30" rx="5" ry="5" fill="#388E3C"/>
|
|
<rect x="50" y="120" width="15" height="30" rx="5" ry="5" fill="#388E3C"/>
|
|
</svg>
|
|
`;
|
|
"""
|
|
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 un gestionnaire de clic pour supprimer l'overlay
|
|
overlay.onclick = function() {
|
|
overlay.style.display = 'none';
|
|
};
|
|
|
|
// Ajouter l'overlay au corps du document
|
|
document.body.appendChild(overlay);
|
|
})();
|
|
"""
|
|
)
|
|
|
|
# Exécuter le script pour injecter l'animation
|
|
self.driver.execute_script(script)
|
|
if hide_message_after_x_seconds:
|
|
time.sleep(hide_message_after_x_seconds)
|
|
delete_script = """(function() {
|
|
// Sélectionner l'overlay par ses attributs de style uniques
|
|
const overlay = document.getElementById('uniqueOverlay');
|
|
|
|
// Vérifier si l'overlay existe et le supprimer
|
|
if (overlay) {
|
|
overlay.remove();
|
|
}
|
|
})();"""
|
|
self.driver.execute_script(delete_script)
|
|
print(f"Script inject robot svg with msg '{msg}'")
|
|
|
|
def odoo_website_menu_click(self, from_text=""):
|
|
if not from_text:
|
|
raise Exception(
|
|
"Cannot click, empty parameter from method"
|
|
" odoo_website_menu_click"
|
|
)
|
|
button = self.click_with_mouse_move(
|
|
By.LINK_TEXT, from_text, timeout=defaut_timeout
|
|
)
|
|
return button
|
|
|
|
# Web
|
|
def odoo_fill_widget_char(self, field_name, text):
|
|
# Type input
|
|
text_field = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.NAME, field_name))
|
|
)
|
|
text_field.send_keys(text)
|
|
return text_field
|
|
|
|
def odoo_web_click_principal_menu(self):
|
|
# Click menu button
|
|
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"
|
|
" .//i[contains(@class, 'oi-apps')]]",
|
|
)
|
|
elif self.odoo_version == "14.0":
|
|
button = self.click_with_mouse_move(
|
|
By.CSS_SELECTOR, "a.full[data-toggle='dropdown']"
|
|
)
|
|
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}')]",
|
|
)
|
|
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')]",
|
|
)
|
|
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')]",
|
|
)
|
|
if self.wait_human_time:
|
|
time.sleep(1)
|
|
select_elem = self.get_element(
|
|
By.XPATH,
|
|
value="//select[contains(@class, 'o_add_custom_group_menu')]",
|
|
wait_clickable=False,
|
|
)
|
|
|
|
select_obj = Select(select_elem)
|
|
# (ex: <option value="customer">Client</option>)
|
|
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}')]",
|
|
)
|
|
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}')]",
|
|
)
|
|
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')]",
|
|
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')]",
|
|
is_visible=False,
|
|
)
|
|
select_obj = Select(select_elem)
|
|
# (ex: <option value="customer">Client</option>)
|
|
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')]",
|
|
)
|
|
|
|
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')]",
|
|
)
|
|
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(
|
|
"Cannot click, empty parameter from method"
|
|
" odoo_web_principal_menu_click_root_menu"
|
|
)
|
|
button = self.click_with_mouse_move(By.LINK_TEXT, from_text)
|
|
# If not work, force it
|
|
# selenium_tool.driver.execute_script("arguments[0].click();", button)
|
|
return button
|
|
|
|
def odoo_web_form_click_statusbar_button_status(
|
|
self, status_label, timeout=defaut_timeout
|
|
):
|
|
try:
|
|
status_button = self.click_with_mouse_move(
|
|
By.XPATH,
|
|
"//button[contains(@class, 'o_arrow_button') and"
|
|
f" contains(text(), '{status_label}')]",
|
|
timeout=timeout,
|
|
)
|
|
except Exception as e:
|
|
status_button = self.click_with_mouse_move(
|
|
By.XPATH,
|
|
"//button[contains(@class, 'o_arrow_button') and"
|
|
f" text()='{status_label}']",
|
|
timeout=timeout,
|
|
)
|
|
print(f"Bouton du statusbar avec le label '{status_label}' cliqué.")
|
|
return status_button
|
|
|
|
def odoo_profile_click(self, enable_dark_mode=False, enable_tour=False):
|
|
|
|
def toggle_data_menu(key, enable):
|
|
profile_button = self.click_with_mouse_move(
|
|
By.XPATH,
|
|
"//div[contains(@class, 'o_user_menu')]/button[contains(@class, 'py-1')]",
|
|
)
|
|
element = self.get_element(
|
|
By.XPATH,
|
|
f"//div[contains(@class, 'o_popover')]//span[@data-menu=\"{key}\"]//input",
|
|
)
|
|
if element.is_selected() != enable:
|
|
self.click_with_mouse_move(
|
|
By.XPATH,
|
|
f"//div[contains(@class, 'o_popover')]//span[@data-menu=\"{key}\"]//input",
|
|
)
|
|
else:
|
|
# close it
|
|
self.click_with_mouse_move(
|
|
By.XPATH,
|
|
"//div[contains(@class, 'o_user_menu')]/button[contains(@class, 'py-1')]",
|
|
)
|
|
|
|
toggle_data_menu("web_tour.tour_enabled", enable_tour)
|
|
toggle_data_menu("color_scheme.switch", enable_dark_mode)
|
|
|
|
def odoo_web_form_click_statusbar_button_status_floating(
|
|
self, status_label, timeout=defaut_timeout
|
|
):
|
|
status_button = self.click_with_mouse_move(
|
|
By.XPATH,
|
|
"//span[contains(@class, 'o_arrow_button') and contains(text(),"
|
|
f" '{status_label}')]",
|
|
timeout=timeout,
|
|
)
|
|
print(f"Bouton du statusbar avec le label '{status_label}' cliqué.")
|
|
return status_button
|
|
|
|
def odoo_web_form_click_statusbar_button_status_plus(
|
|
self, status_label, timeout=defaut_timeout
|
|
):
|
|
status_button = self.click_with_mouse_move(
|
|
By.XPATH,
|
|
"//button[contains(@class, 'o_arrow_button') and"
|
|
f" text()='{status_label}']",
|
|
timeout=timeout,
|
|
)
|
|
print(f"Bouton du statusbar avec le label '{status_label}' cliqué.")
|
|
return status_button
|
|
|
|
def odoo_web_form_click_button_statusbar(
|
|
self, btn_label, btn_class="btn-primary"
|
|
):
|
|
status_button = self.click_with_mouse_move(
|
|
By.XPATH,
|
|
f"//button[contains(@class, '{btn_class}') and contains(span,"
|
|
f" '{btn_label}')]",
|
|
)
|
|
print(f"Bouton du statusbar avec le label '{btn_label}' cliqué.")
|
|
return status_button
|
|
|
|
def odoo_web_form_click_button_action(self, btn_action):
|
|
status_button = self.click_with_mouse_move(
|
|
By.NAME,
|
|
btn_action,
|
|
)
|
|
print(f"Bouton avec l'action '{btn_action}' cliqué.")
|
|
return status_button
|
|
|
|
def odoo_web_form_click_save_action(self):
|
|
return self.odoo_web_form_click_by_classname_action(
|
|
"o_form_button_save"
|
|
)
|
|
|
|
def odoo_web_change_view(self, view_name="list", raise_error=False):
|
|
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")
|
|
|
|
def odoo_web_form_click_by_classname_action(self, custom_class_name):
|
|
status_button = self.click_with_mouse_move(
|
|
By.CLASS_NAME,
|
|
custom_class_name,
|
|
)
|
|
print(f"Bouton avec l'action '{custom_class_name}' cliqué.")
|
|
return status_button
|
|
|
|
def odoo_web_kanban_card(self, card_label):
|
|
# From web view kanban, list
|
|
kanban_card = self.click_with_mouse_move(
|
|
By.XPATH,
|
|
"//div[contains(@class,"
|
|
f" 'o_kanban_record')]//span[contains(text(), '{card_label}')]",
|
|
)
|
|
# kanban_card = self.driver.find_element(By.XPATH,
|
|
# f"//div[contains(@class, 'o_kanban_record')]//span[contains(text(), '{card_label}')]")
|
|
# kanban_card.click()
|
|
print(f"Carte Kanban avec le label '{card_label}' cliquée.")
|
|
return kanban_card
|
|
|
|
def odoo_web_action_button_new(self):
|
|
# From web view kanban, list
|
|
return self.click_with_mouse_move(By.CLASS_NAME, "o-kanban-button-new")
|
|
|
|
def odoo_web_kanban_click(self, button_text, btn_class="btn-primary"):
|
|
# From web view kanban, list
|
|
return self.click_with_mouse_move(
|
|
By.XPATH,
|
|
f"//button[contains(@class, 'btn {btn_class} o_kanban_edit') and"
|
|
f" text()='{button_text}']",
|
|
)
|
|
|
|
def odoo_web_form_notebook_tab_click(self, tab_text):
|
|
return self.click_with_mouse_move(
|
|
By.XPATH,
|
|
f"//a[text()='{tab_text}']",
|
|
)
|
|
|
|
# Web
|
|
def odoo_fill_widget_char(self, field_name, text, is_label=False):
|
|
# Type input
|
|
if self.odoo_version == "14.0":
|
|
text_field = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.NAME, field_name))
|
|
)
|
|
elif self.odoo_version == "16.0":
|
|
text_field = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.ID, field_name))
|
|
)
|
|
|
|
text_field.send_keys(text)
|
|
return text_field
|
|
|
|
def odoo_fill_widget_selection(self, field_name, text):
|
|
return self.odoo_fill_widget_many2one(field_name, text)
|
|
|
|
def odoo_fill_widget_boolean(self, field_name, value):
|
|
self.driver.implicitly_wait(10)
|
|
checkbox_div = self.driver.find_element(
|
|
By.XPATH, f"//div[@name='{field_name}']"
|
|
)
|
|
checkbox = checkbox_div.find_element(
|
|
By.CSS_SELECTOR, 'input[type="checkbox"]'
|
|
)
|
|
is_selected = checkbox.get_attribute("selected")
|
|
# Check if already set
|
|
if is_selected == "true" and value:
|
|
return checkbox
|
|
elif is_selected is None and not value:
|
|
return checkbox
|
|
parent_element = checkbox.find_element(By.XPATH, "..")
|
|
parent_element.click()
|
|
return checkbox
|
|
|
|
def odoo_fill_widget_many2one(self, field_name, text):
|
|
# TODO read this file odoo/addons/web/static/src/js/fields/field_registry.js
|
|
div_field = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.NAME, field_name))
|
|
)
|
|
|
|
input_field = div_field.find_element(By.XPATH, ".//input")
|
|
input_field.send_keys(text)
|
|
# TODO improve speed by observation of comportement
|
|
time.sleep(1)
|
|
input_field.send_keys(Keys.ENTER)
|
|
|
|
return input_field
|
|
|
|
def odoo_fill_widget_date(self, field_name, date_to_select):
|
|
# Type date
|
|
self.driver.implicitly_wait(10)
|
|
checkbox_div = self.driver.find_element(
|
|
By.XPATH, f"//div[@name='{field_name}']"
|
|
)
|
|
checkbox_div.click()
|
|
# TODO improve speed by observation of comportement
|
|
time.sleep(0.5)
|
|
str_date_today = date_to_select.strftime("%Y-%m-%d")
|
|
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
|
|
|
|
def start_record(self):
|
|
# Démarrer l'enregistrement
|
|
if self.config.record_mode:
|
|
if self.config.record_wait_before_start_time:
|
|
time.sleep(int(self.config.record_wait_before_start_time))
|
|
# Sync before record
|
|
has_sync_error = False
|
|
if self.config.sync_file_record_read:
|
|
if not self.config.sync_file_record_write:
|
|
print(
|
|
"Error, cannot sync, missing 'sync_file_record_write'"
|
|
)
|
|
has_sync_error = True
|
|
if self.config.sync_file_record_write:
|
|
if not self.config.sync_file_record_read:
|
|
print(
|
|
"Error, cannot sync, missing 'sync_file_record_read'"
|
|
)
|
|
has_sync_error = True
|
|
if (
|
|
not has_sync_error
|
|
and self.config.sync_file_record_read
|
|
and self.config.sync_file_record_write
|
|
):
|
|
# Do sync
|
|
file_name_1 = f"/tmp/erplibre_sync_temp_file{self.config.sync_file_record_write}"
|
|
file_name_2 = f"/tmp/erplibre_sync_temp_file{self.config.sync_file_record_read}"
|
|
with open(file_name_1, "w") as file:
|
|
file.write(f"Recording {self.filename_recording}\n")
|
|
while not os.path.exists(file_name_2):
|
|
var_wait_time = 0.001
|
|
print(
|
|
f"File {self.filename_recording} wait"
|
|
f" {var_wait_time} seconds"
|
|
)
|
|
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)
|
|
|
|
# Arrêter l'enregistrement
|
|
if self.config.record_mode:
|
|
time.sleep(self.config.selenium_video_time_waiting_end)
|
|
self.video_recorder.stop()
|
|
print("End of recording video")
|
|
print(self.filename_recording)
|
|
|
|
filename_recording_mp4 = self.filename_recording.replace(
|
|
".webm", ".mp4"
|
|
)
|
|
if self.config.selenium_video_auto_convert_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}")
|
|
# By python-vlc
|
|
# media_player = vlc.MediaPlayer(filename)
|
|
# media = vlc.Media(filename)
|
|
# media_player.set_media(media)
|
|
# media_player.play()
|
|
# time.sleep(15)
|
|
# By external process
|
|
if self.config.selenium_video_auto_convert_mp4:
|
|
os.popen(f"vlc {filename_recording_mp4}")
|
|
else:
|
|
os.popen(f"vlc {self.filename_recording}")
|
|
|
|
def generer_code_postal_quebec(self):
|
|
# Zip code from quebec canada
|
|
premieres_lettres = ["G", "H", "J"]
|
|
|
|
premiere_lettre = random.choice(premieres_lettres)
|
|
|
|
deuxieme_lettre = chr(random.randint(65, 90))
|
|
chiffre = str(random.randint(0, 9))
|
|
troisieme_lettre = chr(random.randint(65, 90))
|
|
|
|
code_postal = f"{premiere_lettre}{chiffre}{deuxieme_lettre} {chiffre}{troisieme_lettre}{chiffre}"
|
|
|
|
return code_postal
|
|
|
|
def generer_numero_telephone_quebec(self):
|
|
# Number from quebec canada
|
|
indicateurs_regionaux = [
|
|
"418",
|
|
"438",
|
|
"450",
|
|
"514",
|
|
"579",
|
|
"581",
|
|
"819",
|
|
"873",
|
|
]
|
|
|
|
indicatif = random.choice(indicateurs_regionaux)
|
|
|
|
numero = "".join(random.choices("0123456789", k=7))
|
|
|
|
numero_telephone = f"({indicatif}) {numero[:3]}-{numero[3:]}"
|
|
|
|
return numero_telephone
|
|
|
|
def generer_date_naissance(self, age_minimum=16):
|
|
annee_actuelle = datetime.datetime.now().year
|
|
annee_maximale = annee_actuelle - age_minimum
|
|
|
|
annee_minimale = 1900
|
|
|
|
annee_naissance = random.randint(annee_minimale, annee_maximale)
|
|
|
|
mois_naissance = random.randint(1, 12)
|
|
jour_naissance = random.randint(1, 28)
|
|
|
|
date_naissance = datetime.datetime(
|
|
annee_naissance, mois_naissance, jour_naissance
|
|
)
|
|
|
|
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 wait_is_invisible(self, by, value):
|
|
div_field = WebDriverWait(self.driver, 10).until(
|
|
EC.presence_of_element_located((By.NAME, field_name))
|
|
)
|
|
wait = WebDriverWait(driver, 10)
|
|
wait.until(
|
|
EC.invisibility_of_element_located((By.ID, "ui-datepicker-div"))
|
|
)
|
|
|
|
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 <button> avec un <span> = numéro)
|
|
day = str(int(date_to_select.day)) # sans zéro en tête
|
|
day_xpath = f".//button[contains(@class,'o_date_item_cell') and .//span[normalize-space(text())='{day}']]"
|
|
|
|
day_btn = WebDriverWait(picker, 10).until(
|
|
EC.element_to_be_clickable((By.XPATH, day_xpath))
|
|
)
|
|
|
|
return day_btn
|
|
|
|
|
|
def get_args(parser):
|
|
args = parser.parse_args()
|
|
if args.use_chrome_driver:
|
|
args.use_firefox_driver = False
|
|
return args
|
|
|
|
|
|
def fill_parser(parser):
|
|
parser.add_argument(
|
|
"--url",
|
|
default="http://127.0.0.1:8069",
|
|
help="First URL to open.",
|
|
)
|
|
|
|
group_animation = parser.add_argument_group(title="Animation")
|
|
group_animation.add_argument(
|
|
"--selenium_default_delay",
|
|
default=0.4,
|
|
type=float,
|
|
help="Pause at the end of an action.",
|
|
)
|
|
|
|
group_animation.add_argument(
|
|
"--human_time_speed",
|
|
action="store_true",
|
|
help="Will wait after each action to help human to follow",
|
|
)
|
|
|
|
group_browser = parser.add_argument_group(title="Browser")
|
|
group_browser.add_argument(
|
|
"--not_private_mode",
|
|
action="store_true",
|
|
help="Default is private mode.",
|
|
)
|
|
group_browser.add_argument(
|
|
"--use_chrome_driver",
|
|
action="store_true",
|
|
help="Switch to chrome instead of great Firefox.",
|
|
)
|
|
group_browser.add_argument(
|
|
"--use_firefox_driver",
|
|
action="store_true",
|
|
default=True,
|
|
help="The default is firefox.",
|
|
)
|
|
group_browser.add_argument(
|
|
"--use_network",
|
|
help="Specify the adress, example: http://localhost:4444",
|
|
)
|
|
group_browser.add_argument(
|
|
"--use_download_path_default",
|
|
action="store_true",
|
|
help="Actually, the download path is a temporary directory. "
|
|
"This will enable default path to /home/seluser/Downloads, "
|
|
"need this with Selenium Grid by network. "
|
|
"Will delete all file into /home/seluser/Downloads at startup.",
|
|
)
|
|
group_browser.add_argument(
|
|
"--window_size",
|
|
help="Example 1920,1080",
|
|
)
|
|
group_browser.add_argument(
|
|
"--headless",
|
|
action="store_true",
|
|
help="For automation without GUI.",
|
|
)
|
|
group_browser.add_argument(
|
|
"--no_dark_mode",
|
|
action="store_true",
|
|
help=(
|
|
"By default, will be in dark mode, because the main developer"
|
|
" like it!"
|
|
),
|
|
)
|
|
group_browser.add_argument(
|
|
"--firefox_binary_path",
|
|
help="Can specify firefox path to open selenium.",
|
|
)
|
|
group_browser.add_argument(
|
|
"--gecko_binary_path",
|
|
help="Can specify firefox path to open selenium.",
|
|
)
|
|
|
|
group_record = parser.add_argument_group(title="Recording")
|
|
group_record.add_argument(
|
|
"--record_mode",
|
|
action="store_true",
|
|
help="Start recording (not finish to be implemented).",
|
|
)
|
|
group_record.add_argument(
|
|
"--record_wait_before_start_time",
|
|
help="Time to wait in second before start recording.",
|
|
)
|
|
parser.add_argument(
|
|
"--video_suffix",
|
|
default="",
|
|
help="Will modify video name.",
|
|
)
|
|
parser.add_argument(
|
|
"--sync_file_record_read",
|
|
default="",
|
|
help=(
|
|
"Will sync and check file from tmp before record, write before"
|
|
" read."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--sync_file_record_write",
|
|
default="",
|
|
help=(
|
|
"Will sync and check file from tmp before record, write before"
|
|
" read."
|
|
),
|
|
)
|
|
group_record.add_argument(
|
|
"--selenium_video_time_waiting_end",
|
|
default=5,
|
|
type=int,
|
|
help="Pause at the end before close video.",
|
|
)
|
|
group_record.add_argument(
|
|
"--selenium_default_delay_presentation",
|
|
default=2,
|
|
type=float,
|
|
help="Pause at the end before close video.",
|
|
)
|
|
group_record.add_argument(
|
|
"--selenium_video_auto_play_video",
|
|
action="store_true",
|
|
help="Autoplay the video when done.",
|
|
)
|
|
group_record.add_argument(
|
|
"--selenium_video_auto_convert_mp4",
|
|
action="store_true",
|
|
default=True,
|
|
help="Will convert .webm to .mp4 when done.",
|
|
)
|