diff --git a/.pylintrc b/.pylintrc index e6d1055..662f255 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,13 +1,5 @@ [pylint.messages_control] disable = - C0303, # Trailing whitespace - C0103, # Variable names - C0305, # Trailing newlines - C0304, # Missing final line C0301, # Line too long I1101, E1101, # C-modules members - W0621, # Redefine outer name R0913 # Too many arguments - -[MASTER] -ignore-paths = ^tests/ # Ignore the tests folder diff --git a/ITK_dev_shared_components/SAP/sap_login.py b/ITK_dev_shared_components/SAP/sap_login.py deleted file mode 100644 index c2c98f5..0000000 --- a/ITK_dev_shared_components/SAP/sap_login.py +++ /dev/null @@ -1,119 +0,0 @@ -"""This module provides a functions to open SAP GUI.""" - -import os -import pathlib -import subprocess -import time -from selenium import webdriver -from selenium.webdriver.common.by import By -import pywintypes -from ITK_dev_shared_components.SAP import multi_session - - -def login_using_portal(username:str, password:str): - """Open KMD Portal in Edge, login and start SAP GUI. - - Args: - user: KMD Portal username. - password: KMD Portal password. - """ - driver = webdriver.Chrome() - driver.implicitly_wait(10) - driver.get('https://portal.kmd.dk/irj/portal') - driver.maximize_window() - - #Login - user_field = driver.find_element(By.ID, 'logonuidfield') - pass_field = driver.find_element(By.ID, 'logonpassfield') - login_button = driver.find_element(By.ID, 'buttonLogon') - - user_field.clear() - user_field.send_keys(username) - - pass_field.clear() - pass_field.send_keys(password) - - login_button.click() - - #Opus - mine_genveje = driver.find_element(By.CSS_SELECTOR, "div[title='Mine Genveje']") - mine_genveje.click() - - #Wait for download and launch file - _wait_for_download() - - driver.quit() - - _wait_for_sap_to_open() - - -def _wait_for_download(): - """Private function that checks if the SAP.erp file has been downloaded. - - Raises: - TimeoutError: If the file hasn't been downloaded within 5 seconds. - """ - downloads_folder = str(pathlib.Path.home() / "Downloads") - for _ in range(10): - for file in os.listdir(downloads_folder): - if file.endswith(".sap"): - path = os.path.join(downloads_folder, file) - os.startfile(path) - return - - time.sleep(0.5) - raise TimeoutError(f".SAP file not found in {downloads_folder}") - - -def login_using_cli(username: str, password: str, client='751', system='P02') -> None: - """Open and login to SAP with commandline expressions. - - Args: - username (str): AZ username - password (str): password - client (str, optional): Kommune ID (Aarhus = 751). Defaults to '751'. - system (str, optional): Environment SID (e.g. P02 = 'KMD OPUS Produktion [P02]'). Defaults to 'P02'. - """ - - command_args = [ - r"C:\Program Files (x86)\SAP\FrontEnd\SAPgui\sapshcut.exe", - f"-system={system}", - f"-client={client}", - f"-user={username}", - f"-pw={password}" - ] - - subprocess.run(command_args, check=False) - - _wait_for_sap_to_open() - - -def _wait_for_sap_to_open() -> None: - """Check every second for 10 seconds if the SAP Gui scripting engine is available. - - Raises: - TimeoutError: If SAP doesn't start within 10 seconds. - """ - for _ in range(10): - time.sleep(1) - try: - sessions = multi_session.get_all_SAP_sessions() - if len(sessions) > 0: - return - except pywintypes.com_error: - pass - - raise TimeoutError("SAP didn't respond within 10 seconds.") - -def kill_sap(): - """Kills all SAP processes currently running.""" - os.system("taskkill /F /IM saplogon.exe") - -if __name__=="__main__": - # user = "az12345" - # password = "Hunter2" - # login_using_portal(user, password) - # login_using_cli(user, password) - kill_sap() - - diff --git a/ITK_dev_shared_components/SAP/tree_util.py b/ITK_dev_shared_components/SAP/tree_util.py deleted file mode 100644 index 034186e..0000000 --- a/ITK_dev_shared_components/SAP/tree_util.py +++ /dev/null @@ -1,71 +0,0 @@ -"""This module provides static functions to peform common tasks with SAP GuiTree COM objects.""" - -def get_node_key_by_text(tree, text: str, fuzzy=False) -> str: - """Get the node key of a node based on its text. - tree: A SAP GuiTree object. - text: The text to search for. - fuzzy: Whether to check if the node text just contains the search text. - """ - for key in tree.GetAllNodeKeys(): - t = tree.GetNodeTextByKey(key) - - if t == text or (fuzzy and text in t): - return key - - raise ValueError(f"No node with the text '{text}' was found.") - -def get_item_by_text(tree, text: str, fuzzy=False) -> tuple[str,str]: - """Get the node key and item name of an item based on its text. - tree: A SAP GuiTree object. - text: The text to search for. - fuzzy: Whether to check if the item text just contains the search text. - """ - for key in tree.GetAllNodeKeys(): - for name in tree.GetColumnNames(): - t = tree.GetItemText(key, name) - - if t == text or (fuzzy and text in t): - return (key, name) - - raise ValueError(f"No item with the text '{text}' was found.") - -def check_all_check_boxes(tree) -> None: - """Find and check all checkboxes in the tree. - tree: A SAP GuiTree object. - """ - for key in tree.GetAllNodeKeys(): - for name in tree.GetColumnNames(): - if tree.GetItemType(key, name) == 3: - tree.ChangeCheckBox(key, name, True) - -def uncheck_all_check_boxes(tree) -> None: - """Find and uncheck all checkboxes in the tree. - tree: A SAP GuiTree object. - """ - for key in tree.GetAllNodeKeys(): - for name in tree.GetColumnNames(): - if tree.GetItemType(key, name) == 3: - tree.ChangeCheckBox(key, name, False) - - - - -if __name__ == '__main__': - from ITK_dev_shared_components.SAP import multi_session - - session = multi_session.spawn_sessions(1)[0] - - tree = session.findById('/app/con[0]/ses[0]/wnd[1]/usr/cntlCONTAINER_PSOBKEY/shellcont/shell/shellcont[1]/shell[1]') - - # print([tree.GetNodeTextByKey(key) for key in tree.GetAllNodeKeys()]) - # print([tree.GetItemText(key, '&Hierarchy') for key in tree.GetAllNodeKeys()]) - - # print(list(tree.GetColumnNames())) - - # print(tree.GetItemText(' 2', '&Hierarchy')) - - # key, name = get_item_by_text(tree, '2291987', True) - # tree.ChangeCheckBox(key, name, True) - - check_all_check_boxes(tree) - uncheck_all_check_boxes(tree) \ No newline at end of file diff --git a/ITK_dev_shared_components/SAP/__init__.py b/itk_dev_shared_components/__init__.py similarity index 100% rename from ITK_dev_shared_components/SAP/__init__.py rename to itk_dev_shared_components/__init__.py diff --git a/ITK_dev_shared_components/__init__.py b/itk_dev_shared_components/graph/__init__.py similarity index 100% rename from ITK_dev_shared_components/__init__.py rename to itk_dev_shared_components/graph/__init__.py diff --git a/ITK_dev_shared_components/graph/authentication.py b/itk_dev_shared_components/graph/authentication.py similarity index 100% rename from ITK_dev_shared_components/graph/authentication.py rename to itk_dev_shared_components/graph/authentication.py diff --git a/ITK_dev_shared_components/graph/mail.py b/itk_dev_shared_components/graph/mail.py similarity index 99% rename from ITK_dev_shared_components/graph/mail.py rename to itk_dev_shared_components/graph/mail.py index 0333b06..5bd120a 100644 --- a/ITK_dev_shared_components/graph/mail.py +++ b/itk_dev_shared_components/graph/mail.py @@ -6,7 +6,7 @@ import requests from bs4 import BeautifulSoup -from ITK_dev_shared_components.graph.authentication import GraphAccess +from itk_dev_shared_components.graph.authentication import GraphAccess @dataclass @@ -234,8 +234,6 @@ def delete_email(email: Email, graph_access: GraphAccess, *, permanent: bool=Fal move_email(email, "deleteditems", graph_access, well_known_folder=True) - - def _find_folder(response: dict, target_folder: str) -> str: """Find the target folder in diff --git a/ITK_dev_shared_components/graph/__init__.py b/itk_dev_shared_components/sap/__init__.py similarity index 100% rename from ITK_dev_shared_components/graph/__init__.py rename to itk_dev_shared_components/sap/__init__.py diff --git a/ITK_dev_shared_components/SAP/gridview_util.py b/itk_dev_shared_components/sap/gridview_util.py similarity index 80% rename from ITK_dev_shared_components/SAP/gridview_util.py rename to itk_dev_shared_components/sap/gridview_util.py index f927675..8889c74 100644 --- a/ITK_dev_shared_components/SAP/gridview_util.py +++ b/itk_dev_shared_components/sap/gridview_util.py @@ -1,4 +1,4 @@ -"""This module provides static functions to peform common tasks with SAP GuiGridView COM objects.""" +"""This module provides static functions to perform common tasks with SAP GuiGridView COM objects.""" def scroll_entire_table(grid_view, return_to_top=False) -> None: """This function scrolls through the entire table to load all cells. @@ -6,14 +6,13 @@ def scroll_entire_table(grid_view, return_to_top=False) -> None: Args: grid_view: A SAP GuiGridView object. return_to_top: Whether to return the table to the first row after scrolling. Defaults to False. - - Returns: - _type_: _description_ - """ + """ + if grid_view.RowCount == 0 or grid_view.VisibleRowCount == 0: + return for i in range(0, grid_view.RowCount, grid_view.VisibleRowCount): grid_view.FirstVisibleRow = i - + if return_to_top: grid_view.FirstVisibleRow = 0 @@ -28,7 +27,7 @@ def get_all_rows(grid_view, pre_load=True) -> tuple[tuple[str]]: Returns: tuple[tuple[str]]: A 2D tuple of all cell values in the gridview. - """ + """ if pre_load: scroll_entire_table(grid_view, True) @@ -43,9 +42,9 @@ def get_all_rows(grid_view, pre_load=True) -> tuple[tuple[str]]: for c in columns: v = grid_view.GetCellValue(r, c) row_data.append(v) - + output.append(tuple(row_data)) - + return tuple(output) @@ -60,7 +59,7 @@ def get_row(grid_view, row:int, scroll_to_row=False) -> tuple[str]: Returns: tuple[str]: A tuple of the row's data. - """ + """ if scroll_to_row: grid_view.FirstVisibleRow = row @@ -83,7 +82,7 @@ def iterate_rows(grid_view) -> tuple[str]: Yields: tuple[str]: A tuple of the next row's data. - """ + """ row = 0 while row < grid_view.RowCount: @@ -104,7 +103,7 @@ def get_column_titles(grid_view) -> tuple[str]: Returns: tuple[str]: A tuple of the gridview's column titles. - """ + """ return tuple(grid_view.GetColumnTitles(c)[0] for c in grid_view.ColumnOrder) @@ -123,7 +122,7 @@ def find_row_index_by_value(grid_view, column:str, value:str) -> int: Returns: int: The index of the first row which column value matches the given value. - """ + """ if column not in grid_view.ColumnOrder: raise ValueError(f"Column '{column}' not in grid_view") @@ -132,14 +131,14 @@ def find_row_index_by_value(grid_view, column:str, value:str) -> int: # Only scroll when row isn't visible if not grid_view.FirstVisibleRow <= row <= grid_view.FirstVisibleRow + grid_view.VisibleRowCount-1: grid_view.FirstVisibleRow = row - + if grid_view.GetCellValue(row, column) == value: return row - + return -1 -def find_all_row_indecies_by_value(grid_view, column:str, value:str) -> list[int]: - """Find all indecies of the rows where the given column's value +def find_all_row_indices_by_value(grid_view, column:str, value:str) -> list[int]: + """Find all indices of the rows where the given column's value match the given value. Returns an empty list if no row is found. Args: @@ -151,8 +150,8 @@ def find_all_row_indecies_by_value(grid_view, column:str, value:str) -> list[int ValueError: If the column name doesn't exist in the grid view. Returns: - list[int]: A list of row indecies where the value matches. - """ + list[int]: A list of row indices where the value matches. + """ if column not in grid_view.ColumnOrder: raise ValueError(f"Column '{column}' not in grid_view") @@ -162,39 +161,8 @@ def find_all_row_indecies_by_value(grid_view, column:str, value:str) -> list[int # Only scroll when row isn't visible if not grid_view.FirstVisibleRow <= row <= grid_view.FirstVisibleRow + grid_view.VisibleRowCount-1: grid_view.FirstVisibleRow = row - + if grid_view.GetCellValue(row, column) == value: rows.append(row) - - return rows - - - - -if __name__=='__main__': - import win32com.client - - SAP = win32com.client.GetObject("SAPGUI") - app = SAP.GetScriptingEngine - connection = app.Connections(0) - session = connection.Sessions(0) - - table = session.findById("wnd[0]/usr/cntlGRID1/shellcont/shell") - - rows = find_all_row_indecies_by_value(table, "ZZ_PARTNER", '15879880') - print(rows) - table.setCurrentCell(rows[0], "ZZ_PARTNER") - - # print(get_row(table, 1, True)) - - # scroll_entire_table(table) - - # data = get_all_rows(table) - # print(len(data), len(data[0])) - - # for r in iterate_rows(table): - # print(r) - - # print(get_column_titles(table)) - + return rows diff --git a/ITK_dev_shared_components/SAP/multi_session.py b/itk_dev_shared_components/sap/multi_session.py similarity index 80% rename from ITK_dev_shared_components/SAP/multi_session.py rename to itk_dev_shared_components/sap/multi_session.py index f1ce075..b3d01de 100644 --- a/ITK_dev_shared_components/SAP/multi_session.py +++ b/itk_dev_shared_components/sap/multi_session.py @@ -5,13 +5,15 @@ import time import threading from typing import Callable +import math + import pythoncom import win32com.client import win32gui def run_with_session(session_index:int, func:Callable, args:tuple) -> None: - """Run a function in a sepcific session based on the sessions index. - This function is meant to be run inside a seperate thread. + """Run a function in a specific session based on the sessions index. + This function is meant to be run inside a separate thread. The function must take a session object as its first argument. Note that this function will not spawn the sessions before running, use spawn_sessions to do that. @@ -19,8 +21,8 @@ def run_with_session(session_index:int, func:Callable, args:tuple) -> None: pythoncom.CoInitialize() - SAP = win32com.client.GetObject("SAPGUI") - app = SAP.GetScriptingEngine + sap = win32com.client.GetObject("SAPGUI") + app = sap.GetScriptingEngine connection = app.Connections(0) session = connection.Sessions(session_index) @@ -28,6 +30,7 @@ def run_with_session(session_index:int, func:Callable, args:tuple) -> None: pythoncom.CoUninitialize() + def run_batch(func:Callable, args:tuple[tuple], num_sessions=6) -> None: """Run a function in parallel sessions. The function will be run {num_sessions} times with args[i] as input. @@ -40,7 +43,7 @@ def run_batch(func:Callable, args:tuple[tuple], num_sessions=6) -> None: for i in range(num_sessions): t = ExThread(target=run_with_session, args=(i, func, args[i])) threads.append(t) - + for t in threads: t.start() for t in threads: @@ -49,6 +52,7 @@ def run_batch(func:Callable, args:tuple[tuple], num_sessions=6) -> None: if t.error: raise t.error + def run_batches(func:Callable, args:tuple[tuple], num_sessions=6): """Run a function in parallel batches. This function runs the input function for each set of arguments in args. @@ -62,16 +66,25 @@ def run_batches(func:Callable, args:tuple[tuple], num_sessions=6): batch = args[b:b+num_sessions] run_batch(func, args, len(batch)) + def spawn_sessions(num_sessions=6) -> list: """A function to spawn multiple sessions of SAP. This function will attempt to spawn the desired number of sessions. - If the current number of open sessions exceeds the desired number of sessions + If the current number of already open sessions exceeds the desired number of sessions the already open sessions will not be closed to match the desired number. The number of sessions must be between 1 and 6. - Returns a list of all open sessions. + + Args: + num_sessions: The number of sessions desired. Defaults to 6. + + Raises: + ValueError: If the number of sessions is not between 1 and 6. + + Returns: + tuple: A tuple of all currently open sessions. """ - SAP = win32com.client.GetObject("SAPGUI") - app = SAP.GetScriptingEngine + sap = win32com.client.GetObject("SAPGUI") + app = sap.GetScriptingEngine connection = app.Connections(0) session = connection.Sessions(0) @@ -82,25 +95,17 @@ def spawn_sessions(num_sessions=6) -> list: for _ in range(num_sessions - connection.Sessions.count): session.CreateSession() - + # Wait for the sessions to spawn while connection.Sessions.count < num_sessions: time.sleep(0.1) - sessions = list(connection.Sessions) + sessions = tuple(connection.Sessions) num_sessions = len(sessions) - if num_sessions == 1: - c = 1 - elif num_sessions <= 4: - c = 2 - elif num_sessions <= 6: - c = 3 - - if num_sessions < 3: - r = 1 - else: - r = 2 + # Calculate number of columns and rows + c = math.ceil(math.sqrt(num_sessions)) + r = math.ceil(num_sessions / c) w, h = 1920//c, 1040//r @@ -111,20 +116,22 @@ def spawn_sessions(num_sessions=6) -> list: x = i % c * w y = i // c * h win32gui.MoveWindow(hwnd, x, y, w, h, True) - + return sessions -def get_all_SAP_sessions() -> tuple: + +def get_all_sap_sessions() -> tuple: """Returns a tuple of all open SAP sessions (on connection index 0). Returns: tuple: A tuple of SAP GuiSession objects. """ - SAP = win32com.client.GetObject("SAPGUI") - app = SAP.GetScriptingEngine + sap = win32com.client.GetObject("SAPGUI") + app = sap.GetScriptingEngine connection = app.Connections(0) return tuple(connection.Sessions) + class ExThread(threading.Thread): """A thread with a handle to get an exception raised inside the thread: ExThread.error""" def __init__(self, *args, **kwargs): @@ -136,5 +143,3 @@ def run(self): self._target(*self._args, **self._kwargs) except Exception as e: # pylint: disable=broad-exception-caught self.error = e - - diff --git a/ITK_dev_shared_components/SAP/opret_kundekontakt.py b/itk_dev_shared_components/sap/opret_kundekontakt.py similarity index 81% rename from ITK_dev_shared_components/SAP/opret_kundekontakt.py rename to itk_dev_shared_components/sap/opret_kundekontakt.py index e084b67..306abff 100644 --- a/ITK_dev_shared_components/SAP/opret_kundekontakt.py +++ b/itk_dev_shared_components/sap/opret_kundekontakt.py @@ -2,11 +2,11 @@ from typing import Literal import win32clipboard -from ITK_dev_shared_components.SAP import tree_util +from itk_dev_shared_components.sap import tree_util -def opret_kundekontakter(session, fp:str, aftaler:list[str] | None, - art:Literal[' ', 'Automatisk', 'Fakturagrundlag', 'Fuldmagt ifm. værge', 'Konverteret', 'Myndighedshenvend.', 'Orientering', 'Returpost', 'Ringeaktivitet', 'Skriftlig henvend.', 'Telefonisk henvend.'], +def opret_kundekontakter(session, fp:str, aftaler:list[str] | None, + art:Literal[' ', 'Automatisk', 'Fakturagrundlag', 'Fuldmagt ifm. værge', 'Konverteret', 'Myndighedshenvend.', 'Orientering', 'Returpost', 'Ringeaktivitet', 'Skriftlig henvend.', 'Telefonisk henvend.'], notat:str, lock=None) -> None: """Creates a kundekontakt on the given FP and aftaler. @@ -41,11 +41,11 @@ def opret_kundekontakter(session, fp:str, aftaler:list[str] | None, # Go to editor and paste (lock if multithreaded) session.findById("wnd[0]/usr/subNOTICE:SAPLEENO:1002/btnEENO_TEXTE-EDITOR").press() - if lock: + if lock: lock.acquire() - _setClipboard(notat) + _set_clipboard(notat) session.findById("wnd[0]/tbar[1]/btn[9]").press() - if lock: + if lock: lock.release() # Back and save @@ -53,21 +53,13 @@ def opret_kundekontakter(session, fp:str, aftaler:list[str] | None, session.findById("wnd[0]/tbar[0]/btn[11]").press() -def _setClipboard(text:str) -> None: +def _set_clipboard(text:str) -> None: + """Private function to set text to the clipboard. + + Args: + text: Text to set to clipboard. + """ win32clipboard.OpenClipboard() win32clipboard.EmptyClipboard() win32clipboard.SetClipboardText(text) win32clipboard.CloseClipboard() - - -if __name__ == '__main__': - from ITK_dev_shared_components.SAP import multi_session - from datetime import datetime - - session = multi_session.spawn_sessions(1)[0] - fp = '25564617' - aftaler = ['2291987', '2421562', '2311094'] - art = 'Orientering' - notat = 'Test '+ str(datetime.now()) - - opret_kundekontakter(session, fp, aftaler, 'Automatisk', notat) \ No newline at end of file diff --git a/itk_dev_shared_components/sap/sap_login.py b/itk_dev_shared_components/sap/sap_login.py new file mode 100644 index 0000000..27ffc78 --- /dev/null +++ b/itk_dev_shared_components/sap/sap_login.py @@ -0,0 +1,186 @@ +"""This module provides functions to handle opening and closing SAP Gui +as well as a function to change user passwords.""" + +import os +import pathlib +import subprocess +import time + +from selenium import webdriver +from selenium.webdriver.common.by import By +import pywintypes +import win32com.client + +from itk_dev_shared_components.sap import multi_session + + +def login_using_portal(username:str, password:str): + """Open KMD Portal in Edge, login and start SAP GUI. + Args: + user: KMD Portal username. + password: KMD Portal password. + """ + driver = webdriver.Chrome() + driver.implicitly_wait(10) + driver.get('https://portal.kmd.dk/irj/portal') + driver.maximize_window() + + #Login + user_field = driver.find_element(By.ID, 'logonuidfield') + pass_field = driver.find_element(By.ID, 'logonpassfield') + login_button = driver.find_element(By.ID, 'buttonLogon') + user_field.clear() + user_field.send_keys(username) + pass_field.clear() + pass_field.send_keys(password) + login_button.click() + + #Opus + mine_genveje = driver.find_element(By.CSS_SELECTOR, "div[title='Mine Genveje']") + mine_genveje.click() + + #Wait for download and launch file + _wait_for_download() + + driver.quit() + _wait_for_sap_session(10) + + +def _wait_for_download(): + """Private function that checks if the SAP.erp file has been downloaded. + Raises: + TimeoutError: If the file hasn't been downloaded within 5 seconds. + """ + downloads_folder = str(pathlib.Path.home() / "Downloads") + for _ in range(10): + for file in os.listdir(downloads_folder): + if file.endswith(".sap"): + path = os.path.join(downloads_folder, file) + os.startfile(path) + return + + time.sleep(0.5) + raise TimeoutError(f".sap file not found in {downloads_folder}") + + +def login_using_cli(username: str, password: str, client:str='751', system:str='P02', timeout:int=10) -> None: + """Open and login to SAP with commandline expressions. + Args: + username: AZ username + password: password + client: Kommune ID (Aarhus = 751). Defaults to '751'. + system: Environment SID (e.g. P02 = 'KMD OPUS Produktion [P02]'). Defaults to 'P02'. + timeout: The time in seconds to wait for SAP Logon to start. Defaults to 10. + Raises: + TimeoutError: If SAP doesn't start within timeout limit. + ValueError: If SAP is unable to log in using the given credentials. + """ + + command_args = [ + r"C:\Program Files (x86)\SAP\FrontEnd\SAPgui\sapshcut.exe", + f"-system={system}", + f"-client={client}", + f"-user={username}", + f"-pw={password}" + ] + + subprocess.run(command_args, check=False) + _wait_for_sap_session(timeout) + if not _check_for_splash_screen(): + raise ValueError("Unable to log in. Please check username and password.") + + +def _wait_for_sap_session(timeout:int) -> None: + """Check every second if the SAP Gui scripting engine is available until timeout is reached. + Args: + timeout: The time in seconds to wait for SAP Logon to start. Defaults to 10. + Raises: + TimeoutError: If SAP doesn't start within timeout limit. + """ + for _ in range(timeout): + time.sleep(1) + try: + sessions = multi_session.get_all_sap_sessions() + if len(sessions) > 0: + return + except pywintypes.com_error: + pass + + raise TimeoutError(f"SAP didn't respond within timeout limit: {timeout} seconds.") + + +def _check_for_splash_screen() -> bool: + """Check if the splash screen image is currently present. + Returns: + bool: True if the splash screen image is currently present. + """ + session = multi_session.get_all_sap_sessions()[0] + image = session.findById("wnd[0]/usr/cntlIMAGE_CONTAINER/shellcont/shell/shellcont[1]/shell", False) + + return image is not None + + +def change_password(username:str, old_password:str, new_password:str, + client:str='751', + system:str='...KMD OPUS Produktion [P02]', + timeout:int=10) -> None: + """Change the password of a user in SAP Gui. Closes SAP when done. + Args: + username: The username of the user. + old_password: The current password of the user. + new_password: The new password to change to. + client: The client number. Defaults to '751'. + system: The description string of the connection as displayed in SAP Logon. Defaults to '...KMD OPUS Produktion [P02]'. + timeout: The time in seconds to wait for SAP Logon to start. Defaults to 10. + Raises: + TimeoutError: If the connection couldn't be established within the timeout limit. + ValueError: If the current credentials are not valid or if the password can't be changed. + ValueError: If the new password is not valid. + + """ + + subprocess.Popen(r"C:\Program Files (x86)\SAP\FrontEnd\SAPgui\saplogon.exe") #pylint: disable=consider-using-with + + # Wait for SAP Logon to open + for _ in range(timeout): + time.sleep(1) + try: + sap = win32com.client.GetObject("SAPGUI") + app = sap.GetScriptingEngine + app.OpenConnection(system) + break + except pywintypes.com_error: + pass + else: + raise TimeoutError(f"SAP Logon didn't open within timeout limit: {timeout} seconds.") + + session = multi_session.get_all_sap_sessions()[0] + + # Enter credentials + session.findById("wnd[0]/usr/txtRSYST-MANDT").text = client + session.findById("wnd[0]/usr/txtRSYST-BNAME").text = username + session.findById("wnd[0]/usr/pwdRSYST-BCODE").text = old_password + session.findById("wnd[0]/tbar[1]/btn[5]").press() + + # Check status bar + status_bar = session.findById("wnd[0]/sbar") + if status_bar.MessageType != 'S': + text = status_bar.Text + kill_sap() + raise ValueError(f"Password change was blocked: {text}") + + # Enter new password + session.findById("wnd[1]/usr/pwdRSYST-NCODE").text = new_password + session.findById("wnd[1]/usr/pwdRSYST-NCOD2").text = new_password + session.findById("wnd[1]/tbar[0]/btn[0]").press() + + if not _check_for_splash_screen(): + kill_sap() + raise ValueError("New password couldn't be set. Please check password requirements.") + + kill_sap() + + +def kill_sap(): + """Kills all SAP processes currently running.""" + os.system("taskkill /F /IM saplogon.exe > NUL 2>&1") diff --git a/ITK_dev_shared_components/SAP/sap_util.py b/itk_dev_shared_components/sap/sap_util.py similarity index 75% rename from ITK_dev_shared_components/SAP/sap_util.py rename to itk_dev_shared_components/sap/sap_util.py index 698d17a..70d022d 100644 --- a/ITK_dev_shared_components/SAP/sap_util.py +++ b/itk_dev_shared_components/sap/sap_util.py @@ -1,7 +1,7 @@ """This module provides miscellaneous static functions to peform common tasks in SAP.""" def print_all_descendants(container, max_depth=-1, indent=0): - """Prints the object and all of its decendants recursivly + """Prints the object and all of its descendants recursively to the console. Args: @@ -23,13 +23,3 @@ def print_all_descendants(container, max_depth=-1, indent=0): print_all_descendants(child, max_depth, indent+1) else: print(indent_text) - - -if __name__=='__main__': - from ITK_dev_shared_components.SAP import multi_session - - session = multi_session.spawn_sessions(1)[0] - - usr = session.FindById('/app/con[0]/ses[0]/wnd[0]/usr') - - print_all_descendants(usr) \ No newline at end of file diff --git a/itk_dev_shared_components/sap/tree_util.py b/itk_dev_shared_components/sap/tree_util.py new file mode 100644 index 0000000..3eeca44 --- /dev/null +++ b/itk_dev_shared_components/sap/tree_util.py @@ -0,0 +1,71 @@ +"""This module provides static functions to perform common tasks with SAP GuiTree COM objects.""" + +def get_node_key_by_text(tree, text: str, fuzzy: bool=False) -> str: + """Get the node key of a node based on its text. + + Args: + tree: A SAP GuiTree object. + text: The text to search for. + fuzzy: Whether to check if the node text just contains the search text. + + Raises: + ValueError: If no node is found with the given text. + + Returns: + str: The node key of the found node. + """ + for key in tree.GetAllNodeKeys(): + t = tree.GetNodeTextByKey(key) + + if t == text or (fuzzy and text in t): + return key + + raise ValueError(f"No node with the text '{text}' was found.") + + +def get_item_by_text(tree, text: str, fuzzy: bool=False) -> tuple[str,str]: + """Get the node key and item name of an item based on its text. + + Args: + tree: A SAP GuiTree object. + text: The text to search for. + fuzzy: Whether to check if the item text just contains the search text. + + Raises: + ValueError: If no tem is found with the given text. + + Returns: + tuple[str,str]: The node key and item name of the found item. + """ + for key in tree.GetAllNodeKeys(): + for name in tree.GetColumnNames(): + t = tree.GetItemText(key, name) + + if t == text or (fuzzy and text in t): + return (key, name) + + raise ValueError(f"No item with the text '{text}' was found.") + + +def check_all_check_boxes(tree) -> None: + """Find and check all checkboxes in the tree. + + Args: + tree: A SAP GuiTree object. + """ + for key in tree.GetAllNodeKeys(): + for name in tree.GetColumnNames(): + if tree.GetItemType(key, name) == 3: + tree.ChangeCheckBox(key, name, True) + + +def uncheck_all_check_boxes(tree) -> None: + """Find and uncheck all checkboxes in the tree. + + Args: + tree: A SAP GuiTree object. + """ + for key in tree.GetAllNodeKeys(): + for name in tree.GetColumnNames(): + if tree.GetItemType(key, name) == 3: + tree.ChangeCheckBox(key, name, False) diff --git a/pyproject.toml b/pyproject.toml index a52a9a5..94cd2c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=65.0"] build-backend = "setuptools.build_meta" [project] -name = "ITK_dev_shared_components" +name = "itk_dev_shared_components" version = "0.0.6" authors = [ { name="ITK Development", email="itk-rpa@mkb.aarhus.dk" }, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/run_tests.bat b/tests/run_tests.bat new file mode 100644 index 0000000..c1e6516 --- /dev/null +++ b/tests/run_tests.bat @@ -0,0 +1,26 @@ +@echo off + +:: Change dir to parent dir +echo Changing directory... +cd /d %~dp0.. + + +choice /C YN /M "Do you want to reset venv?" + +if errorlevel 2 ( + echo Activating excisting venv... + call .venv\Scripts\activate + +) else ( + echo Setting up new venv... + python -m venv .venv + call .venv\Scripts\activate + + echo Installing package... + pip install . +) + +echo Running unit tests... +python -m unittest discover + +pause \ No newline at end of file diff --git a/tests/test_graph/test_mail.py b/tests/test_graph/test_mail.py index 476c983..681baf2 100644 --- a/tests/test_graph/test_mail.py +++ b/tests/test_graph/test_mail.py @@ -4,7 +4,7 @@ import json import os -from ITK_dev_shared_components.graph import authentication, mail +from itk_dev_shared_components.graph import authentication, mail class EmailTest(unittest.TestCase): """Tests relating to the graph.mail module.""" diff --git a/tests/test_sap/__init__.py b/tests/test_sap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_gridview_util.py b/tests/test_sap/test_gridview_util.py similarity index 84% rename from tests/test_gridview_util.py rename to tests/test_sap/test_gridview_util.py index 846c355..b033a44 100644 --- a/tests/test_gridview_util.py +++ b/tests/test_sap/test_gridview_util.py @@ -2,7 +2,9 @@ import unittest import os -from ITK_dev_shared_components.SAP import gridview_util, sap_login, multi_session +from itk_dev_shared_components.sap import gridview_util, sap_login, multi_session + +# Some tests might look similiar, and we want this. pylint: disable=duplicate-code class TestGridviewUtil(unittest.TestCase): """Tests relating to the module SAP.gridview_util.""" @@ -11,7 +13,12 @@ def setUpClass(cls): """Launch SAP and navigate to fmcacov on FP 25564617 (Test person).""" sap_login.kill_sap() - test_tree_util.navigate_to_test_page() + user, password = os.environ['SAP Login'].split(';') + sap_login.login_using_cli(user, password) + session = multi_session.get_all_sap_sessions()[0] + session.startTransaction("fmcacov") + session.findById("wnd[0]/usr/ctxtGPART_DYN").text = "25564617" + session.findById("wnd[0]").sendVKey(0) @classmethod def tearDownClass(cls): @@ -19,7 +26,7 @@ def tearDownClass(cls): def setUp(self) -> None: # Find SAP gridview (table) object for testing - session = multi_session.get_all_SAP_sessions()[0] + session = multi_session.get_all_sap_sessions()[0] self.table = session.findById("wnd[0]/usr/tabsDATA_DISP/tabpDATA_DISP_FC1/ssubDATA_DISP_SCA:RFMCA_COV:0202/cntlRFMCA_COV_0100_CONT5/shellcont/shell") def test_scroll_entire_table(self): @@ -73,7 +80,7 @@ def test_find_row_index_by_value(self): with self.assertRaises(ValueError): gridview_util.find_row_index_by_value(self.table, "Foo", "Bar") - def test_find_all_row_indecies_by_value(self): + def test_find_all_row_indices_by_value(self): """Test finding all rows by column value.""" # Test finding an actual value. indices = gridview_util.find_all_row_indices_by_value(self.table, "TXTU2", "Gebyr") diff --git a/tests/test_sap/test_multi_session.py b/tests/test_sap/test_multi_session.py new file mode 100644 index 0000000..5e89358 --- /dev/null +++ b/tests/test_sap/test_multi_session.py @@ -0,0 +1,67 @@ +"""Tests relating to the module SAP.multi_session.""" + +import unittest +import os +import threading +from itk_dev_shared_components.sap import sap_login, multi_session, opret_kundekontakt + +class TestMultiSession(unittest.TestCase): + """Tests relating to the module SAP.multi_session.""" + + def setUp(self): + sap_login.kill_sap() + user, password = os.environ['SAP Login'].split(';') + sap_login.login_using_cli(user, password) + + def tearDown(self): + sap_login.kill_sap() + + def test_spawn_sessions(self): + """Test spawning of multiple sessions. + It should only be possible to spawn between 1-6 sessions. + Also test getting already open sessions. + """ + with self.assertRaises(ValueError): + multi_session.spawn_sessions(0) + + with self.assertRaises(ValueError): + multi_session.spawn_sessions(7) + + sessions = multi_session.spawn_sessions(1) + self.assertEqual(len(sessions), 1) + + sessions = multi_session.spawn_sessions(6) + self.assertEqual(len(sessions), 6) + + sessions = multi_session.get_all_sap_sessions() + self.assertEqual(len(sessions), 6) + + def test_run_batches(self): + """Test running a task in parallel in multiple sessions. + This also tests: + run_batch, run_with_session, ExThread + """ + + num_sessions = 6 + + multi_session.spawn_sessions(num_sessions) + + #Data + lock = threading.Lock() + + data = [ + ("25564617", None, 'Orientering', f"Hej {i}", lock) for i in range(12) + ] + + # Test with 12 cases + multi_session.run_batches(opret_kundekontakt.opret_kundekontakter, data, num_sessions) + + # Test with 5 cases + multi_session.run_batches(opret_kundekontakt.opret_kundekontakter, data[0:5], num_sessions) + + # Test with 1 case + multi_session.run_batches(opret_kundekontakt.opret_kundekontakter, data[0:1], num_sessions) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_opret_kundekontakt.py b/tests/test_sap/test_opret_kundekontakt.py similarity index 89% rename from tests/test_opret_kundekontakt.py rename to tests/test_sap/test_opret_kundekontakt.py index e6e1cf5..78b13e3 100644 --- a/tests/test_opret_kundekontakt.py +++ b/tests/test_sap/test_opret_kundekontakt.py @@ -2,7 +2,7 @@ import unittest import os -from ITK_dev_shared_components.SAP import sap_login, multi_session, opret_kundekontakt +from itk_dev_shared_components.sap import sap_login, multi_session, opret_kundekontakt class TestOpretKundekontakt(unittest.TestCase): """Test relating to the module SAP.opret_kundekontakt.""" @@ -20,7 +20,7 @@ def test_opret_kundekontakt(self): fp = "25564617" aftaler = ("2544577", "1990437", "1473781") - session = multi_session.get_all_SAP_sessions()[0] + session = multi_session.get_all_sap_sessions()[0] # Test with 3 aftaler opret_kundekontakt.opret_kundekontakter(session, fp, aftaler, 'Orientering', "Test 1") diff --git a/tests/test_sap_login.py b/tests/test_sap/test_sap_login.py similarity index 97% rename from tests/test_sap_login.py rename to tests/test_sap/test_sap_login.py index a564fe7..4f4786d 100644 --- a/tests/test_sap_login.py +++ b/tests/test_sap/test_sap_login.py @@ -4,7 +4,7 @@ import unittest from tkinter import simpledialog -from ITK_dev_shared_components.SAP import sap_login +from itk_dev_shared_components.sap import sap_login class TestSapLogin(unittest.TestCase): """Tests relating to the module SAP.sap_login.""" diff --git a/tests/test_tree_util.py b/tests/test_sap/test_tree_util.py similarity index 91% rename from tests/test_tree_util.py rename to tests/test_sap/test_tree_util.py index 98e6463..eaadf33 100644 --- a/tests/test_tree_util.py +++ b/tests/test_sap/test_tree_util.py @@ -3,7 +3,7 @@ import unittest import os -from ITK_dev_shared_components.SAP import tree_util, sap_login, multi_session +from itk_dev_shared_components.sap import tree_util, sap_login, multi_session class TestTreeUtil(unittest.TestCase): """Tests relating to the module SAP.tree_util.""" @@ -22,7 +22,7 @@ def test_get_node_key_by_text(self): Test that strict search and fuzzy search works and throws errors on nonsense input. """ - session = multi_session.get_all_SAP_sessions()[0] + session = multi_session.get_all_sap_sessions()[0] tree = session.findById("wnd[0]/shellcont/shell") result = tree_util.get_node_key_by_text(tree, "25564617") @@ -43,7 +43,7 @@ def test_get_item_by_text(self): Test that strict search and fuzzy search works and throws errors on nonsense input. """ - session = multi_session.get_all_SAP_sessions()[0] + session = multi_session.get_all_sap_sessions()[0] tree = session.findById("wnd[0]/shellcont/shell") result = tree_util.get_item_by_text(tree, "25564617") @@ -62,7 +62,7 @@ def test_get_item_by_text(self): def test_check_uncheck_all_check_boxes(self): """Test check_all_check_boxes and uncheck_all_check_boxes.""" # Open popup with tree containing many checkboxes. - session = multi_session.get_all_SAP_sessions()[0] + session = multi_session.get_all_sap_sessions()[0] session.findById("wnd[0]/shellcont/shell").nodeContextMenu("GP0000000001") session.findById("wnd[0]/shellcont/shell").selectContextMenuItem("FLERE") @@ -83,7 +83,7 @@ def navigate_to_test_page(): """Launch SAP and navigate to fmcacov on FP 25564617.""" user, password = os.environ['SAP Login'].split(';') sap_login.login_using_cli(user, password) - session = multi_session.get_all_SAP_sessions()[0] + session = multi_session.get_all_sap_sessions()[0] session.startTransaction("fmcacov") session.findById("wnd[0]/usr/ctxtGPART_DYN").text = "25564617" session.findById("wnd[0]").sendVKey(0)