diff --git a/soitool/coder.py b/soitool/coder.py index c0423364f2a1454eda1c1d35340449506ffe6b89..5e36925ac509e70ac493e4910ed1cac8c6509ed4 100644 --- a/soitool/coder.py +++ b/soitool/coder.py @@ -6,20 +6,22 @@ import string import secrets -def get_code(code_length, mode="ascii", space_interval=0): +def get_code(code_length, mode="ascii", space_interval=0, space_amount=1): """ Generate a single random code. Parameters ---------- code_length : int - The length of the code + The length of the code. mode : string 'ascii' for letters, 'digits' for digits and 'combo' for combination of letters and digits, by default 'ascii'. space_interval : int Spaces will be inserted into code each interval for readability if not 0, by default 0. + space_amount : int + Amount of spaces per interval, by default 1. Return ------ @@ -47,12 +49,14 @@ def get_code(code_length, mode="ascii", space_interval=0): # Add spaces to code if interval is given if space_interval > 0: - code = insert_spaces(code, space_interval) + code = insert_spaces(code, space_interval, space_amount) return code -def get_code_set(count, code_length, mode="ascii", space_interval=0): +def get_code_set( + count, code_length, mode="ascii", space_interval=0, space_amount=1 +): """ Generate a set of unique, random codes. @@ -68,6 +72,8 @@ def get_code_set(count, code_length, mode="ascii", space_interval=0): space_interval : int Spaces will be inserted into code each interval for readability if not 0, by default 0. + space_amount : int + Amount of spaces per interval, by default 1. Return ------ @@ -77,7 +83,7 @@ def get_code_set(count, code_length, mode="ascii", space_interval=0): codes = set() while len(codes) < count: - code = get_code(code_length, mode, space_interval) + code = get_code(code_length, mode, space_interval, space_amount) codes.add(code) return codes @@ -105,7 +111,7 @@ def get_code_length_needed(number_of_entries): return code_length -def insert_spaces(code, interval): +def insert_spaces(code, interval, space_amount=1): """Insert space after every x'th character, x = interval. Parameters @@ -114,6 +120,8 @@ def insert_spaces(code, interval): String to add spaces to. interval : int Interval for inserting spaces. + space_amount : int + Amount of spaces per interval, by default 1. Returns ------- @@ -122,7 +130,7 @@ def insert_spaces(code, interval): """ # Convert to list to insert spaces between characters code = list(code) - for i in range(interval - 1, len(code), interval): - code[i] += " " + for i in range(interval - 1, len(code) - 1, interval): + code[i] += " " * space_amount return "".join(code) diff --git a/soitool/main_window.py b/soitool/main_window.py index e322fda586b0e8860fa8ba7e0eaf688363337e2f..01567b6739600253ed433d1544207ced22c23319 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -20,6 +20,8 @@ from soitool.codebook_to_pdf import generate_codebook_pdf from soitool.dialog_wrappers import exec_info_dialog from soitool.codebook_widget import CodebookWidget from soitool.codebook_model_view import CodebookTableModel +from soitool.soi_db_widget import SOIDbWidget +from soitool.soi_model_view import SOITableModel from soitool.database import Database, DBPATH from soitool.help_actions import ShortcutsHelpDialog, BasicUsageHelpDialog from soitool.serialize_export_import_soi import ( @@ -117,6 +119,7 @@ class MainWindow(QMainWindow): open_file_db = QAction("Åpne fra DB", self) open_file_db.setShortcut("Ctrl+d") open_file_db.setStatusTip("Åpne en SOI fra databasen") + open_file_db.triggered.connect(self.show_soi_db) file_menu.addAction(open_file_db) # Preview SOI @@ -235,8 +238,8 @@ class MainWindow(QMainWindow): """ widget_in_tab = self.tabs.widget(index) - # Close db-connection if tab is a codebook-tab - if isinstance(widget_in_tab, CodebookWidget): + # Close db-connection if tab is a CodebookWidget or SOIDbWidget + if isinstance(widget_in_tab, (CodebookWidget, SOIDbWidget)): widget_in_tab.view.close_db_connection() self.tabs.removeTab(index) @@ -335,7 +338,24 @@ class MainWindow(QMainWindow): # If tab contains an SOI if isinstance(tab_widget, SOIWorkspaceWidget): + # Update tab showing SOI's in db if it is open, + # and pause database-lock by codebook-tab if it is open + soi_db_view = None + codebook_db_view = None + for i in range(self.tabs.count()): + if self.tabs.tabText(i) == "SOI'er i DB": + soi_db_view = self.tabs.widget(i).view + soi_db_view.setModel(None) + elif self.tabs.tabText(i) == "Kodebok": + codebook_db_view = self.tabs.widget(i).view + codebook_db_view.setModel(None) + self.database.insert_or_update_soi(tab_widget.soi) + + if soi_db_view is not None: + soi_db_view.setModel(SOITableModel()) + if codebook_db_view is not None: + codebook_db_view.setModel(CodebookTableModel()) else: exec_info_dialog( "Valgt tab er ingen SOI-tab", @@ -343,6 +363,23 @@ class MainWindow(QMainWindow): "Riktig tab må velges for å lagre en SOI i DB.", ) + def show_soi_db(self): + """Open and select tab containing SOIDbWidget. + + Select tab if it is already open, + create and select tab if not open. + """ + # Loop through tabs to look for existing SOI-db-tab: + for i in range(self.tabs.count()): + if self.tabs.tabText(i) == "SOI'er i DB": + self.tabs.setCurrentIndex(i) + break + # SOI-db-tab does not exist, create, add and select tab + else: + tab = SOIDbWidget(self.database, self.tabs) + self.tabs.addTab(tab, "SOI'er i DB") + self.tabs.setCurrentWidget(tab) + def open_shortcut_help(self): """Open shortcut dialog.""" self.popup_shortcut_help.setWindowTitle("Hurtigtaster") diff --git a/soitool/media/authenticationboardmodule.PNG b/soitool/media/authenticationboardmodule.PNG index 0a92c03e3e403e6cb1bd41716b7feacc5d050b69..9df0e912d0c92a22c40cbe72641897146749ab5f 100644 Binary files a/soitool/media/authenticationboardmodule.PNG and b/soitool/media/authenticationboardmodule.PNG differ diff --git a/soitool/media/subtractorcodesmodule.PNG b/soitool/media/subtractorcodesmodule.PNG new file mode 100644 index 0000000000000000000000000000000000000000..a74e8c1a2b9bcec0b1a55e6205c137c53ca8aabc Binary files /dev/null and b/soitool/media/subtractorcodesmodule.PNG differ diff --git a/soitool/module_list.py b/soitool/module_list.py index 9c4b9ba9795ce4dca060f7b839e5236cbb7b6a75..64c62e84200bf56fad6bb145357433b1bf463fff 100644 --- a/soitool/module_list.py +++ b/soitool/module_list.py @@ -171,10 +171,10 @@ class ModuleList(QListWidget): # Update module/attachment priority (order in list): if ModuleType(self.type) == ModuleType.MAIN_MODULE: - moving_module = self.parent.soi.modules.pop(origin) + moving_module = self.soi.modules.pop(origin) self.soi.modules.insert(destination, moving_module) elif ModuleType(self.type) == ModuleType.ATTACHMENT_MODULE: - moving_module = self.parent.soi.attachments.pop(origin) + moving_module = self.soi.attachments.pop(origin) self.soi.attachments.insert(destination, moving_module) self.soi.reorganize() diff --git a/soitool/modules/code_table_base.py b/soitool/modules/code_table_base.py new file mode 100644 index 0000000000000000000000000000000000000000..f00585f7f2fdd66083af9aaf3a2d3b8bdc1ccd87 --- /dev/null +++ b/soitool/modules/code_table_base.py @@ -0,0 +1,224 @@ +"""Module containing parent-class for code-table-modules. + +Parent-class for AuthenticationBoardModule and SubtractorCodesModule. +""" +from PySide2.QtWidgets import QTableWidget, QTableWidgetItem +from PySide2.QtCore import Qt +from soitool.coder import get_code_set, get_code +from soitool.modules.module_base import ( + ModuleBase, + get_table_size, + resize_table, + HEADLINE_FONT, +) + +AUTHENTICATIONBOARD_MODULE = "AuthenticationBoardModule" +SUBTRACTORCODES_MODULE = "SubtractorcodesModule" + + +class Meta(type(ModuleBase), type(QTableWidget)): + """Used as a metaclass to enable multiple inheritance.""" + + +class CodeTableBase(ModuleBase, QTableWidget, metaclass=Meta): + """Parent-class for AuthenticationBoardModule and SubtractorcodesModule. + + Inherits from ModuleBase and QTableWidget. + ModuleBase is used as an interface, it's methods are overridden. + """ + + def __init__(self, size, data): + if self.type not in [ + AUTHENTICATIONBOARD_MODULE, + SUBTRACTORCODES_MODULE, + ]: + raise ValueError( + "Invalid value for class-variable type: " + "'{}'".format(self.type) + ) + QTableWidget.__init__(self) + ModuleBase.__init__(self) + + # Remove headers and scrollbars + self.horizontalHeader().hide() + self.verticalHeader().hide() + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # Resize table when headline changes + self.cellChanged.connect( + lambda: resize_table( + self, + resize_rows=False, + resize_columns=False, + has_headline=True, + ) + ) + # If parameters are None, generate new table + if size is None and data is None: + self.generate_table() + self.resizeColumnsToContents() + self.insert_headline(self.headline) + + # Resize height of rows and set size of window + resize_table(self, resize_columns=False, has_headline=True) + else: + self.setColumnCount(len(data[1])) + self.setRowCount(len(data) - 1) # - 1 to skip headline + + # Set cell-items + for i in range(self.rowCount()): + for j in range(self.columnCount()): + item = QTableWidgetItem(data[i + 1][j]) # +1 skip headline + item.setTextAlignment(Qt.AlignCenter) + self.setItem(i, j, item) + + self.resizeColumnsToContents() + self.insert_headline(data[0]) + + resize_table(self, resize_columns=False, has_headline=True) + self.setFixedWidth(size["width"]) + self.setFixedHeight(size["height"]) + + def generate_table(self): + """Insert row identifiers and codes.""" + # Set number of rows and columns + self.setRowCount(self.start_no_of_codes) + self.setColumnCount(3) + self.insert_row_identifiers() + + # Generate codes + codes = list( + get_code_set( + self.start_no_of_codes, + self.code_length, + self.code_character_type, + self.space_interval, + self.space_amount, + ) + ) + # Insert codes + for i in range(self.start_no_of_codes): + # Insert non-editable code in third column + item_third = QTableWidgetItem(codes[i]) + if self.type == SUBTRACTORCODES_MODULE: + item_third.setTextAlignment(Qt.AlignCenter) + item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable) + self.setItem(i, 2, item_third) + + def insert_headline(self, text=None): + """Insert headline text. + + Parameters + ---------- + text : string, optional + The headline text, self.headline is used if None, + by default None. + """ + headline = self.headline if text is None else text + + item_headline = QTableWidgetItem(headline) + item_headline.setTextAlignment(Qt.AlignHCenter) + item_headline.setFont(HEADLINE_FONT) + + self.insertRow(0) + self.setItem(0, 0, item_headline) + self.setSpan(0, 0, 1, self.columnCount()) # Make cell span all columns + + def generate_unique_authentication_code(self): + """Generate authentication-code that does not already exist. + + Returns + ------- + string + Generated, unique authentication-code. + """ + # Get existing codes + existing_codes = self.get_codes() + + # Randomly generate a new code until it is unique + unique_code = False + while not unique_code: + code = get_code( + self.code_length, + self.code_character_type, + self.space_interval, + self.space_amount, + ) + unique_code = code not in existing_codes + + return code + + def get_codes(self): + """Get all codes in table. + + Returns + ------- + List + List containing codes. + """ + codes = [] + + # Start from 1 to skip headline-row + for i in range(1, self.rowCount()): + codes.append(self.item(i, 2).text()) + + return codes + + def keyPressEvent(self, event): + """Add or remove row when 'Ctrl + +' and 'Ctrl + -' are pressed.""" + if ( + event.modifiers() == Qt.ControlModifier + and event.key() == Qt.Key_Plus + ): + # If a row is selected + row_index = self.currentRow() + if row_index != -1: + self.add_row(row_index) + elif ( + event.modifiers() == Qt.ControlModifier + and event.key() == Qt.Key_Underscore + ): + # If at least one row (+ headline-row) exists and a row other than + # headline-row is selected + row_index = self.currentRow() + if self.rowCount() > 2 and row_index != 0 and row_index != -1: + self.remove_row(row_index) + else: + super().keyPressEvent(event) + + def get_size(self): + """Return size of table.""" + return get_table_size(self) + + def get_data(self): + """Return list containing all data. + + Returns + ------- + List + List[0] contains headline, + list[x][y] represents value of row x, column y. + """ + data = [] + item_headline = self.item(0, 0) + if item_headline is not None: + data.append(item_headline.text()) + + for i in range(1, self.rowCount()): + row = [] + for j in range(self.columnCount()): + row.append(self.item(i, j).text()) + data.append(row) + + return data + + @staticmethod + def get_icon(): + """Abstract method, should be implemented by derived class.""" + raise NotImplementedError + + @staticmethod + def get_user_friendly_name(): + """Abstract method, should be implemented by derived class.""" + raise NotImplementedError diff --git a/soitool/modules/module_authentication_board.py b/soitool/modules/module_authentication_board.py index 92a35dd08f7fe9f9b030def1940564da9bd4b369..e1dc99965b92e0c85d5c973da40d8a504b96c4e3 100644 --- a/soitool/modules/module_authentication_board.py +++ b/soitool/modules/module_authentication_board.py @@ -1,278 +1,146 @@ """Module containing SOI-module 'Autentiseringstavle'.""" import string -from PySide2.QtWidgets import QTableWidget, QTableWidgetItem +from PySide2.QtWidgets import QTableWidgetItem from PySide2 import QtGui from PySide2.QtCore import Qt -from soitool.coder import get_code_set, get_code -from soitool.modules.module_base import ( - ModuleBase, - get_table_size, - resize_table, - HEADLINE_FONT, -) +from soitool.modules.module_base import resize_table +from soitool.modules.code_table_base import CodeTableBase -START_NO_OF_AUTHENTICATION_CODES = 10 +# Maximum number of codes is len(ROW_IDENTIFIERS) +START_NO_OF_CODES = 10 CODE_LENGTH = 25 -# Has to be 'ascii', 'digits' or 'combo'. Codes will consist of A-Z if -# 'ascii', 0-9 if 'digits' and A-Z+0-9 if 'combo'. -CODE_CHARACTERS = "ascii" +# Has to be 'ascii', 'digits' or 'combo' +# Codes will consist of A-Z if 'ascii', 0-9 if 'digits' and A-Z+0-9 if 'combo'. +CODE_CHARACTER_TYPE = "ascii" -# Characters for first column. It's length determines maximum number of -# codes (rows). +# Characters for first column, +# it's length determines maximum number of codes (rows). ROW_IDENTIFIERS = string.ascii_uppercase -# Adds space between sets of characters, 0 => no spaces +# Adds space between sets of characters, 0 => no spaces. # If code is 123456 and interval is 2, code will be 12 34 56 SPACE_INTERVAL = 5 +SPACE_AMOUNT = 2 HEADLINE_TEXT = "Autentiseringstavle" -class Meta(type(ModuleBase), type(QTableWidget)): - """Used as a metaclass to enable multiple inheritance.""" - - -class AuthenticationBoardModule(ModuleBase, QTableWidget, metaclass=Meta): - """Modified QTableWidget representing a 'Autentiseringstavle'. +class AuthenticationBoardModule(CodeTableBase): + """Modified QTableWidget representing SOI-module 'Autentiseringstavle'. By default, the widget initializes with a headline, a row-count of - START_NO_OF_AUTHENTICATION_CODES and three columns. + START_NO_OF_CODES and three columns. Row x in the first column contains the character ROW_IDENTIFIERS[x]. Row x in the second column contains the character x. - Row x in the third column contains an authentication code. + Row x in the third column contains an authentication code of length + CODE_LENGTH, spaced out for readability if SPACE_INTERVAL and SPACE_AMOUNT + is larger than 0. If parameters are given, the widget initializes accordingly: 'size' is a dict: {"width": int, "height": int}, 'data' is a 2D list where data[0] is the headline, - and data[x][y] represents row x, column y. + and data[x][y] represents the value in row x, column y. The widget does not use more room than needed, and resizes dynamically. It has shortcuts for adding and removing rows. - Inherits from ModuleBase and QTableWidget. - ModuleBase is used as an interface, it's methods are overridden. + Codes are not horizontally centered for readability because 'BGD' is wider + than 'III' (example) in certain fonts. """ def __init__(self, size=None, data=None): self.type = "AuthenticationBoardModule" - QTableWidget.__init__(self) - ModuleBase.__init__(self) - if CODE_CHARACTERS == "ascii": + if CODE_CHARACTER_TYPE == "ascii": self.code_characters = string.ascii_uppercase - elif CODE_CHARACTERS == "digits": + elif CODE_CHARACTER_TYPE == "digits": self.code_characters = string.digits - elif CODE_CHARACTERS == "combo": + elif CODE_CHARACTER_TYPE == "combo": self.code_characters = string.ascii_uppercase + string.digits else: raise ValueError( - "Invalid value for CONSTANT 'CODE_CHARACTERS': " - "'{}'".format(CODE_CHARACTERS) - ) - - # Remove headers and scrollbars - self.horizontalHeader().hide() - self.verticalHeader().hide() - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - # If parameters are None, generate new table - if size is None and data is None: - self.generate_table() - self.resizeColumnsToContents() - self.insert_headline() - - # Resize height of rows and set size of window - resize_table(self, resize_column=False) - else: - self.setColumnCount(len(data[1])) - self.setRowCount(len(data) - 1) # - 1 to skip headline - - # Set cell-items - for i in range(self.rowCount()): - for j in range(self.columnCount()): - item = QTableWidgetItem(data[i + 1][j]) # +1 skip headline - self.setItem(i, j, item) - - self.resizeColumnsToContents() - resize_table(self, resize_column=False) - self.setFixedWidth(size["width"]) - self.setFixedHeight(size["height"]) - - self.insert_headline(data[0]) - - def generate_table(self): - """Insert row identifiers and authentication codes.""" - # Set number of rows and columns - self.setRowCount(START_NO_OF_AUTHENTICATION_CODES) - self.setColumnCount(3) - - # Generate codes - codes = list( - get_code_set( - START_NO_OF_AUTHENTICATION_CODES, - CODE_LENGTH, - CODE_CHARACTERS, - SPACE_INTERVAL, + "Invalid value for CONSTANT 'CODE_CHARACTER_TYPE': " + "'{}'".format(CODE_CHARACTER_TYPE) ) - ) - - # Insert table data - for i in range(START_NO_OF_AUTHENTICATION_CODES): + self.start_no_of_codes = START_NO_OF_CODES + self.code_length = CODE_LENGTH + self.space_interval = SPACE_INTERVAL + self.space_amount = SPACE_AMOUNT + self.code_character_type = CODE_CHARACTER_TYPE + self.headline = HEADLINE_TEXT + + CodeTableBase.__init__(self, size, data) + + def insert_row_identifiers(self): + """Insert values in column one and two.""" + for i in range(self.rowCount()): # Insert non-editable row identifier in first column item_first = QTableWidgetItem(ROW_IDENTIFIERS[i]) + item_first.setTextAlignment(Qt.AlignCenter) item_first.setFlags(item_first.flags() ^ Qt.ItemIsEditable) self.setItem(i, 0, item_first) # Insert non-editable row identifier (int) in second column item_second = QTableWidgetItem(str(i)) + item_second.setTextAlignment(Qt.AlignCenter) item_second.setFlags(item_second.flags() ^ Qt.ItemIsEditable) self.setItem(i, 1, item_second) - # Insert non-editable code in third column - item_third = QTableWidgetItem(codes[i]) - item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable) - self.setItem(i, 2, item_third) - - def insert_headline(self, text=HEADLINE_TEXT): - """Insert headline text. + def add_row(self, selected_row_index): + """Insert row below the selected row and add data. Parameters ---------- - text : string, optional - The headline text, by default HEADLINE_TEXT + selected_row_index : int + Index of the selected row. """ - item_headline = QTableWidgetItem(text) - item_headline.setTextAlignment(Qt.AlignHCenter) - item_headline.setFont(HEADLINE_FONT) - - self.insertRow(0) - self.setItem(0, 0, item_headline) - self.setSpan(0, 0, 1, self.columnCount()) # Make cell span all columns - - def generate_unique_authentication_code(self): - """Generate authentication-code that does not already exist. - - Returns - ------- - string - Generated, unique authentication-code - """ - # Get existing codes - existing_codes = self.get_codes() - - # Randomly generate a new code until it is unique - unique_code = False - while not unique_code: - code = get_code(CODE_LENGTH, CODE_CHARACTERS, SPACE_INTERVAL) - unique_code = code not in existing_codes - - return code - - def keyPressEvent(self, event): - """Add or remove row when 'Ctrl + +' and 'Ctrl + -' are pressed.""" - if ( - event.modifiers() == Qt.ControlModifier - and event.key() == Qt.Key_Plus - ): - self.add_row() - elif ( - event.modifiers() == Qt.ControlModifier - and event.key() == Qt.Key_Underscore - ): - self.remove_row() - else: - super(AuthenticationBoardModule, self).keyPressEvent(event) - - def add_row(self): - """Insert row below the selected row and add data.""" - row_index = self.currentRow() - # If maximum amount of rows not reached and a row is selected - # (+ 1 to skip row containing headline) - if self.rowCount() < len(ROW_IDENTIFIERS) + 1 and row_index != -1: + # If maximum amount of rows not reached (- 1 to skip headline) + if self.rowCount() - 1 < len(ROW_IDENTIFIERS): # Generate unique code and insert row code = self.generate_unique_authentication_code() - self.insertRow(row_index + 1) + self.insertRow(selected_row_index + 1) # Loop through all rows starting with the new row - for i in range(row_index + 1, self.rowCount()): + for i in range(selected_row_index + 1, self.rowCount()): # Insert row identifier in first column item_first = QTableWidgetItem(self.code_characters[i - 1]) + item_first.setTextAlignment(Qt.AlignCenter) item_first.setFlags(item_first.flags() ^ Qt.ItemIsEditable) self.setItem(i, 0, item_first) # Insert row identifier (int) in second column item_second = QTableWidgetItem(str(i - 1)) + item_second.setTextAlignment(Qt.AlignCenter) item_second.setFlags(item_second.flags() ^ Qt.ItemIsEditable) self.setItem(i, 1, item_second) # Insert authentication-code in third column item_third = QTableWidgetItem(code) item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable) - self.setItem(row_index + 1, 2, item_third) + self.setItem(selected_row_index + 1, 2, item_third) # Resize code-column in case it got wider # Example: 'BGD' is wider than 'III' (depending on font) self.resizeColumnToContents(2) - resize_table(self, resize_column=False) - - def remove_row(self): - """Remove selected row.""" - row_index = self.currentRow() - # If at least one row (+ headline-row) exists and a row other than - # headline-row is selected - if self.rowCount() > 2 and row_index != 0 and row_index != -1: - # Remove row - self.removeRow(row_index) - # 'Decrease' row identifiers below the removed row - # If first row is removed, identifier A,B,C becomes A,B (not B,C) - for i in range(row_index, self.rowCount()): - self.item(i, 0).setText(self.code_characters[i - 1]) - self.item(i, 1).setText(str(i - 1)) - resize_table(self, resize_column=False) + resize_table(self, resize_columns=False, has_headline=True) - def get_codes(self): - """Get all authentication-codes in table. - - Returns - ------- - List - List containing authentication-codes. - """ - codes = [] + def remove_row(self, row_index): + """Remove selected row. - # Start with row 1 to skip headline-row - for i in range(1, self.rowCount()): - codes.append(self.item(i, 2).text()) - - return codes - - def get_size(self): - """Return size of widget.""" - return get_table_size(self) - - def get_data(self): - """Return list containing all data. - - Returns - ------- - List - List[0] contains headline, - list[x][y] represents value of row x, column y. + Parameters + ---------- + row_index : int + Index of the row to remove. """ - content = [] - item_headline = self.item(0, 0) - if item_headline is not None: - content.append(item_headline.text()) - - for i in range(1, self.rowCount()): - row = [] - for j in range(self.columnCount()): - row.append(self.item(i, j).text()) - content.append(row) - - return content + self.removeRow(row_index) + + # 'Decrease' row identifiers below the removed row + # If first row is removed, identifier A,B,C becomes A,B (not B,C) + for i in range(row_index, self.rowCount()): + self.item(i, 0).setText(self.code_characters[i - 1]) + self.item(i, 1).setText(str(i - 1)) + resize_table(self, resize_columns=False, has_headline=True) @staticmethod def get_user_friendly_name(): diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py index c608a17177d43a16371b675d8f5a815f82a87e27..7b87d96b67669e18bd34a5315ea358b38a4f9ae7 100644 --- a/soitool/modules/module_base.py +++ b/soitool/modules/module_base.py @@ -15,6 +15,7 @@ class ModuleBase(ABC): type = None def __init__(self): + """Class-variable 'type' should be set by derived class.""" if self.type is None: raise NotImplementedError @@ -37,27 +38,50 @@ class ModuleBase(ABC): raise NotImplementedError -def resize_table(widget, resize_row=True, resize_column=True): - """Calculate and set the size of a QTableWidget. +def resize_table( + table, resize_rows=True, resize_columns=True, has_headline=False +): + """Resize a given QTableWidget. Parameters ---------- - widget : QTableWidget - QTablewidget-instance to calculate and set size. - resize_row : bool + table : QTableWidget + QTablewidget-instance to resize. + resize_rows : bool Resizes rows to contents if True, by default True. - resize_column : bool + resize_columns : bool Resizes columns to contents if True, by default True. + has_headline : bool + True if the table has a headline, by default False. + Last column is widened if headline is wider than table. """ - if resize_row: - widget.resizeRowsToContents() - if resize_column: - widget.resizeColumnsToContents() - - width, height = get_table_size(widget) - - widget.setFixedWidth(width) - widget.setFixedHeight(height) + if resize_columns: + table.resizeColumnsToContents() + if resize_rows: + table.resizeRowsToContents() + + # If table has a headline, make sure table is wide enough to fit it. + if has_headline: + last_column_index = table.columnCount() - 1 + table.resizeColumnToContents(last_column_index) + width, height = get_table_size(table) + + # Get width of headline + headline = table.item(0, 0).text() + headline_width = ( + QtGui.QFontMetricsF(HEADLINE_FONT).horizontalAdvance(headline) + 10 + ) + # If headline is wider than table + if width < headline_width: + difference = headline_width - width + width += difference + old_width = table.columnWidth(last_column_index) + table.setColumnWidth(last_column_index, old_width + difference) + else: + width, height = get_table_size(table) + + table.setFixedWidth(width) + table.setFixedHeight(height) def get_table_size(widget): @@ -73,7 +97,6 @@ def get_table_size(widget): Tuple Total (width, height) """ - # Calculate total width and height of columns and rows width = 0 height = 0 diff --git a/soitool/modules/module_subtractorcodes.py b/soitool/modules/module_subtractorcodes.py new file mode 100644 index 0000000000000000000000000000000000000000..3b04ab28ef7d67fcb7944d731c9ed37e989d4988 --- /dev/null +++ b/soitool/modules/module_subtractorcodes.py @@ -0,0 +1,141 @@ +"""Module containing SOI-module 'Subtraktorkoder'.""" +import string +from PySide2.QtWidgets import QTableWidgetItem +from PySide2 import QtGui +from PySide2.QtCore import Qt +from soitool.modules.module_base import resize_table +from soitool.modules.code_table_base import CodeTableBase + +# Maximum number of codes is len(ROW_IDENTIFIERS)/2 columns = 13 +START_NO_OF_CODES = 7 +CODE_LENGTH = 8 + +# Characters for first and second column +ROW_IDENTIFIERS = string.ascii_uppercase + +# Adds space between sets of characters, 0 => no spaces. +# If code is 12345678 and interval is 2, code will be 1234 5678 +SPACE_INTERVAL = 4 +SPACE_AMOUNT = 5 + +HEADLINE_TEXT = "Subtraktorkoder" + + +class SubtractorcodesModule(CodeTableBase): + """Modified QTablewidget representing SOI-module 'Subtraktorkoder'. + + By default, the widget initializes with a headline, a row-count of + START_NO_OF_CODES and three columns. + If there are 2 rows and ROW_IDENTIFIERS is the alphabet, the first + column will contain A and B, and the second column will contain C and D. + The third column contains subtractorcodes of length CODE_LENGTH, spaced out + for readability if SPACE_INTERVAL and SPACE_AMOUNT larger than 0. + + If parameters are given, the widget initializes accordingly: + 'size' is a dict: {"width": int, "height": int}, + 'data' is a 2D list where data[0] is the headline, + and data[x][y] represents the value in row x, column y. + + The widget does not use more room than needed, and resizes dynamically. + It has shortcuts for adding and removing rows. + """ + + def __init__(self, size=None, data=None): + self.type = "SubtractorcodesModule" + + if START_NO_OF_CODES > 13: + raise ValueError( + "Invalid value for CONSTANT 'START_NO_OF_CODES': " + "'{}'".format(START_NO_OF_CODES) + ) + self.headline = HEADLINE_TEXT + self.code_length = CODE_LENGTH + self.start_no_of_codes = START_NO_OF_CODES + self.space_interval = SPACE_INTERVAL + self.space_amount = SPACE_AMOUNT + self.code_character_type = "digits" + + CodeTableBase.__init__(self, size, data) + + def insert_row_identifiers(self, has_headline=False): + """Insert row identifiers in first and second column. + + Parameters + ---------- + has_headline : bool, optional + True if a headline-row exists, by default False. + """ + start_row = 1 if has_headline else 0 + + # Get row identifiers (start-index to skip headline) + # for column one and two + last_id_index_one = self.rowCount() - start_row + start_id_index_two = self.rowCount() - start_row + last_id_index_two = self.rowCount() * 2 - 2 * start_row + + # If table has 3 rows and ROW_IDENTIFIERS are A-Z, + # identifiers_one = A, B, C and identifiers_two = C, D, E + identifiers_one = ROW_IDENTIFIERS[0:last_id_index_one] + identifiers_two = ROW_IDENTIFIERS[start_id_index_two:last_id_index_two] + + # Insert identifiers in column one and two + for i in range(2): + for j in range(start_row, self.rowCount()): + text = ( + identifiers_one[j - start_row] + if i == 0 + else identifiers_two[j - start_row] + ) + item = QTableWidgetItem(text) + item.setTextAlignment(Qt.AlignCenter) + item.setFlags(item.flags() ^ Qt.ItemIsEditable) + self.setItem(j, i, item) + + def add_row(self, selected_row_index): + """Insert row below the selected row and add data. + + Parameters + ---------- + selected_row_index : int + Index of the selected row. + """ + # If maximum amount of rows not reached (- 1 to skip headline) + if self.rowCount() - 1 < len(ROW_IDENTIFIERS) / 2: + + # Generate unique code and insert row + code = self.generate_unique_authentication_code() + self.insertRow(selected_row_index + 1) + + # Insert row identifiers + self.insert_row_identifiers(has_headline=True) + + # Insert code + item_code = QTableWidgetItem(code) + item_code.setTextAlignment(Qt.AlignCenter) + item_code.setFlags(item_code.flags() ^ Qt.ItemIsEditable) + self.setItem(selected_row_index + 1, 2, item_code) + + resize_table(self, resize_columns=False, has_headline=True) + + def remove_row(self, row_index): + """Remove the selected row. + + Parameters + ---------- + row_index : int + Index of the row to remove. + """ + self.removeRow(row_index) + + self.insert_row_identifiers(has_headline=True) + resize_table(self, resize_columns=False, has_headline=True) + + @staticmethod + def get_user_friendly_name(): + """Get user-friendly name of module.""" + return "Subtraktorkoder" + + @staticmethod + def get_icon(): + """Get icon of module.""" + return QtGui.QIcon("soitool/media/subtractorcodesmodule.png") diff --git a/soitool/modules/module_table.py b/soitool/modules/module_table.py index 030ad405ddfc7142d84f0ac45697af09e7c685e3..a4b917ab9911bd4d9d9961d35f92f9f24a4323f1 100644 --- a/soitool/modules/module_table.py +++ b/soitool/modules/module_table.py @@ -50,7 +50,7 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): self.setRowCount(START_ROWS) # Resize width and height of rows, columns and window - resize_table(self, resize_row=True, resize_column=True) + resize_table(self, resize_rows=True, resize_columns=True) # Set header-items for i in range(self.columnCount()): @@ -75,7 +75,7 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): self.setFixedHeight(size["height"]) self.cellChanged.connect( - lambda: resize_table(self, resize_row=True, resize_column=True) + lambda: resize_table(self, resize_rows=True, resize_columns=True) ) def keyPressEvent(self, event): @@ -131,24 +131,24 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): """Add column to the right of selected column.""" self.insertColumn(self.currentColumn() + 1) self.set_header_item(self.currentColumn() + 1, "") - resize_table(self, resize_row=True, resize_column=True) + resize_table(self, resize_rows=True, resize_columns=True) def remove_column(self): """Remove selected column if two or more columns exist.""" if self.columnCount() > 1: self.removeColumn(self.currentColumn()) - resize_table(self, resize_row=True, resize_column=True) + resize_table(self, resize_rows=True, resize_columns=True) def add_row(self): """Add row below selected row.""" self.insertRow(self.currentRow() + 1) - resize_table(self, resize_row=True, resize_column=True) + resize_table(self, resize_rows=True, resize_columns=True) def remove_row(self): """Remove selected row if two or more rows exist (including header).""" if self.rowCount() > 2 and self.currentRow() != 0: self.removeRow(self.currentRow()) - resize_table(self, resize_row=True, resize_column=True) + resize_table(self, resize_rows=True, resize_columns=True) def get_size(self): """Return size of widget.""" diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py index acc3746ca017a7d8869694a71d58ee7788843338..c0b33101cbae9df2a79d25d94c3bd22933575236 100644 --- a/soitool/new_module_dialog.py +++ b/soitool/new_module_dialog.py @@ -16,6 +16,7 @@ from soitool.modules.module_table import TableModule from soitool.modules.module_authentication_board import ( AuthenticationBoardModule, ) +from soitool.modules.module_subtractorcodes import SubtractorcodesModule from soitool.modules.module_freetext import FreeTextModule @@ -25,6 +26,7 @@ from soitool.modules.module_freetext import FreeTextModule MODULE_CHOICES = [ TableModule, AuthenticationBoardModule, + SubtractorcodesModule, FreeTextModule, ] diff --git a/soitool/serialize_export_import_soi.py b/soitool/serialize_export_import_soi.py index 1f1beef2265a83a37d19710a91334d40494ab1cb..97c6cb236daa0c770853992285d5e32e4c4a3522 100644 --- a/soitool/serialize_export_import_soi.py +++ b/soitool/serialize_export_import_soi.py @@ -8,6 +8,7 @@ from soitool.modules.module_table import TableModule from soitool.modules.module_authentication_board import ( AuthenticationBoardModule, ) +from soitool.modules.module_subtractorcodes import SubtractorcodesModule from soitool.modules.module_freetext import FreeTextModule # Valid schema for serialized SOI @@ -254,6 +255,15 @@ def construct_soi_from_serialized(serialized, compressed=False): "meta": module["meta"], } ) + elif module_type == "SubtractorcodesModule": + size = module["size"] + data = module["data"] + modules.append( + { + "widget": SubtractorcodesModule(size, data), + "meta": module["meta"], + } + ) elif module_type == "FreeTextModule": size = module["size"] data = module["data"] diff --git a/soitool/soi_db_widget.py b/soitool/soi_db_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..343c7d4672c7b7db8557cb59c5836960d2dd317b --- /dev/null +++ b/soitool/soi_db_widget.py @@ -0,0 +1,19 @@ +"""Module containing a widget for viewing and opening SOI's from database.""" +from PySide2.QtWidgets import QWidget, QHBoxLayout +from soitool.soi_model_view import SOITableView + + +class SOIDbWidget(QWidget): + """Widget for viewing and opening SOI's from database.""" + + def __init__(self, database, tab_widget): + super().__init__() + + self.view = SOITableView(database, tab_widget) + self.create_and_set_layout() + + def create_and_set_layout(self): + """Create layout, add widget and set layout.""" + hbox = QHBoxLayout() + hbox.addWidget(self.view) + self.setLayout(hbox) diff --git a/soitool/soi_model_view.py b/soitool/soi_model_view.py new file mode 100644 index 0000000000000000000000000000000000000000..680d27502fdefbec63c274fcd4c4c4ae1084b7b7 --- /dev/null +++ b/soitool/soi_model_view.py @@ -0,0 +1,148 @@ +"""GUI-interface towards database-table 'SOI'. + +Contains functionality for viewing and opening SOI's from database-table 'SOI', +where (some) SOI's are stored. +""" +from PySide2.QtWidgets import QTableView +from PySide2.QtSql import QSqlDatabase, QSqlTableModel +from PySide2.QtCore import Qt +from soitool.style import CODEBOOK_HEADER_FONT, CODEBOOK_HEADER_BACKGROUND_CSS +from soitool.serialize_export_import_soi import construct_soi_from_serialized +from soitool.soi_workspace_widget import SOIWorkspaceWidget + +# Name and type of database +CONNAME = "SOIDB" +DBTYPE = "QSQLITE" + + +class SOITableView(QTableView): + """TableView with a model of the 'SOI'-table from database. + + This modified QTableView creates a SOITableModel, which reads data from the + SOI-table. When the user double-clicks or presses the enter-key on a cell, + a tab containing SOIWorkspaceWidget, with the SOI from the current row, is + opened and selected. + + Parameters + ---------- + database : soitool.database.Database + Is used to create a QSqlDatabase from the database-file. + tab_widget : QTabWidget + Is used to open a new tab. + + Raises + ------ + RuntimeError + If database does not open. + """ + + def __init__(self, database, tab_widget): + super().__init__() + db = QSqlDatabase.addDatabase(DBTYPE, CONNAME) + db.setDatabaseName(database.db_path) + self.tab_widget = tab_widget + + if not db.open(): + raise RuntimeError("Could not open database.") + + # Enable sorting + self.setSortingEnabled(True) + + # Create and set model + model = SOITableModel() + self.setModel(model) + + # Remove horizontal scrollbar, hide vertical header and 'SOI'-column + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.verticalHeader().hide() + self.hideColumn(2) + + # Set horizontal header-text and it's style + self.set_horizontal_header_text() + header = self.horizontalHeader() + header.setFont(CODEBOOK_HEADER_FONT) + header.setStyleSheet(CODEBOOK_HEADER_BACKGROUND_CSS) + + # Resize + self.resizeColumnsToContents() + width = ( + self.columnWidth(0) + self.columnWidth(1) + self.columnWidth(3) + 2 + ) # +2 offset + self.setFixedWidth(width) + + self.doubleClicked.connect(self.open_soi_tab) + + def set_horizontal_header_text(self): + """Set Norwegian names in horizontal header.""" + self.model().setHeaderData(0, Qt.Horizontal, "Tittel") + self.model().setHeaderData(1, Qt.Horizontal, "Versjon") + self.model().setHeaderData(3, Qt.Horizontal, "Dato") + + def keyPressEvent(self, event): + """Open SOI-tab if enter-key is pressed.""" + if event.key() == Qt.Key_Return: + self.open_soi_tab() + super().keyPressEvent(event) + + def open_soi_tab(self): + """Construct SOI and open SOIWorkspacewidget in new tab.""" + # Get index of the current row and read compressed, serialized SOI + row = self.currentIndex().row() + compressed_soi = self.model().index(row, 2).data() + + # Construct SOI and create SOIWorkspaceWidget + soi = construct_soi_from_serialized(compressed_soi, compressed=True) + tab = SOIWorkspaceWidget(soi) + + # Add and select tab + self.tab_widget.addTab(tab, soi.title) + self.tab_widget.setCurrentWidget(tab) + + def setModel(self, model): + """Set model, resize and hide 'SOI'-column. + + Parameters + ---------- + model : soitool.soi_model_view.SOITableModel or None + Model containing data to display. + """ + super().setModel(model) + if model is not None: + self.hideColumn(2) + self.resizeColumnsToContents() + width = ( + self.columnWidth(0) + + self.columnWidth(1) + + self.columnWidth(3) + + 2 # + 2 offset + ) + self.setFixedWidth(width) + self.sortByColumn(3, Qt.DescendingOrder) # Sort by 'Date'-column + + def close_db_connection(self): + """Close connection to database.""" + self.setModel(None) + QSqlDatabase.removeDatabase(CONNAME) + + +class SOITableModel(QSqlTableModel): + """Uneditable QSqlTableModel of database-table 'SOI'.""" + + def __init__(self): + super().__init__(None, QSqlDatabase.database(CONNAME)) + self.setTable("SOI") + self.select() + self.sort(3, Qt.DescendingOrder) # Sort by 'Date'-column + + def flags(self, index): + """Disable editing. + + Parameters + ---------- + index : QModelIndex + Is used to locate data in a model. + """ + flags = super().flags(index) + flags ^= Qt.ItemIsEditable + + return flags diff --git a/test/test_database.py b/test/test_database.py index 42c705285dbe516df3471c488d4032263c366fc1..219431d7ee2e89fb568771abfd6a2ab44f98afce 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -17,6 +17,9 @@ TESTDBPATH = os.path.join(SOITOOL_DIR, TESTDBNAME) TESTDATA_PATH = Path(__file__).parent.parent / "soitool/testdata" +# Tolerance for timed tests. Tolerating DELTA seconds of difference +DELTA = 10 + class DatabaseTest(unittest.TestCase): """Database tests.""" @@ -141,7 +144,7 @@ class DatabaseTest(unittest.TestCase): # Calculates difference in seconds time_diff = (second_time - first_time).total_seconds() # Compares difference with sleep_time - self.assertAlmostEqual(time_diff, sleep_time, delta=0.2) + self.assertAlmostEqual(time_diff, sleep_time, delta=DELTA) def test_get_categories(self): """Assert function get_categories works as expected.""" @@ -276,7 +279,7 @@ class DatabaseTest(unittest.TestCase): expected_time = seconds_in_24h - sleep_time actual_time = self.database.seconds_to_next_update(seconds_in_24h) # Compares expected and function return value with - self.assertAlmostEqual(expected_time, actual_time, delta=0.2) + self.assertAlmostEqual(expected_time, actual_time, delta=DELTA) def teset_seconds_to_next_update_complete_period(self): """Check that seconds to next update can returns 0 and not negative.""" diff --git a/test/test_module_authentication_and_subtractor.py b/test/test_module_authentication_and_subtractor.py new file mode 100644 index 0000000000000000000000000000000000000000..412bab95d15d7ca830fc5d445e2bd3bbc9ba936d --- /dev/null +++ b/test/test_module_authentication_and_subtractor.py @@ -0,0 +1,243 @@ +"""Test AuthenticationBoardModule.""" +import unittest +import string +from PySide2 import QtGui +from PySide2.QtWidgets import QApplication +from PySide2.QtCore import Qt +from PySide2.QtTest import QTest +from soitool.modules.module_authentication_board import ( + AuthenticationBoardModule, + START_NO_OF_CODES as START_NO_OF_CODES_AUTHENTICATION, + HEADLINE_TEXT as HEADLINE_TEXT_AUTHENTICATION, + ROW_IDENTIFIERS as ROW_IDENTIFIERS_AUTHENTICATION, + CODE_LENGTH as CODE_LENGTH_AUTHENTICATION, +) +from soitool.modules.module_subtractorcodes import ( + SubtractorcodesModule, + START_NO_OF_CODES as START_NO_OF_CODES_SUBTRACTOR, + HEADLINE_TEXT as HEADLINE_TEXT_SUBTRACTOR, + ROW_IDENTIFIERS as ROW_IDENTIFIERS_SUBTRACTOR, + CODE_LENGTH as CODE_LENGTH_SUBTRACTOR, +) +from soitool.soi import SOI + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + + +class TestDefaultAuthenticationBoardAndSubtractorcodesModule( + unittest.TestCase +): + """TestCase for AuthenticationBoardModule and SubtractorcodesModule. + + Both modules inherit from CodeTableBase. Therefore, only + AuthenticationBoardModule is used to test mutual functions. + Module-specific functionality-tests are, of course, executed. + """ + + def setUp(self): + """Create new AuthenticationBoardModule.""" + self.authentication = AuthenticationBoardModule() + self.subtractor = SubtractorcodesModule() + + def test_default_module(self): + """Test that module is initialized properly.""" + # Assert correct headline + self.assertEqual( + self.authentication.item(0, 0).text(), HEADLINE_TEXT_AUTHENTICATION + ) + self.assertEqual( + self.subtractor.item(0, 0).text(), HEADLINE_TEXT_SUBTRACTOR + ) + + # Assert correct number of rows including header + self.assertEqual( + self.authentication.rowCount(), + START_NO_OF_CODES_AUTHENTICATION + 1, + ) + self.assertEqual( + self.subtractor.rowCount(), START_NO_OF_CODES_SUBTRACTOR + 1, + ) + # Assert correct number of columns + self.assertEqual(self.authentication.columnCount(), 3) + self.assertEqual(self.subtractor.columnCount(), 3) + + # Assert cell content in first and second column is correct + for i in range(1, 3): # From 1 to skip headline-row + self.assertEqual( + self.authentication.item(i, 0).text(), + ROW_IDENTIFIERS_AUTHENTICATION[i - 1], + ) + self.assertEqual( + self.authentication.item(i, 1).text(), str(i - 1), + ) + + self.assertEqual( + self.subtractor.item(i, 0).text(), + ROW_IDENTIFIERS_SUBTRACTOR[i - 1], + ) + self.assertEqual( + self.subtractor.item(i, 1).text(), + ROW_IDENTIFIERS_SUBTRACTOR[ + i - 1 + START_NO_OF_CODES_SUBTRACTOR + ], + ) + + # Assert cell content in third column is correct + code = self.authentication.item(1, 2).text() + self.assertTrue(len(code) >= CODE_LENGTH_AUTHENTICATION) + # Assert all code characters are taken from list code_characters + for _, character in enumerate(code): + if character != " ": + self.assertTrue( + character in self.authentication.code_characters + ) + code = self.subtractor.item(1, 2).text() + self.assertTrue(len(code) >= CODE_LENGTH_SUBTRACTOR) + # Assert all code characters are digits + for _, character in enumerate(code): + if character != " ": + self.assertTrue(character in string.digits) + + # Assert all codes are unique + code_list = self.authentication.get_codes() + code_set = set(code_list) + self.assertEqual(len(code_list), len(code_set)) + + def test_generate_unique_authentication_code(self): + """Test function generate_unique_authentication_module.""" + code_list = self.authentication.get_codes() + generated_code = ( + self.authentication.generate_unique_authentication_code() + ) + + # Assert code length is equal to an existing code + self.assertEqual(len(generated_code), len(code_list[0])) + # Assert generated code is not equal to any existing codes + self.assertFalse(generated_code in code_list) + + def test_get_codes(self): + """Test function get_codes.""" + # Get codes + code_list = self.authentication.get_codes() + + # Assert codes are correct + for i, code in enumerate(code_list): + self.assertEqual(code, self.authentication.item(i + 1, 2).text()) + + def test_gui_add_row(self): + """Test adding rows with shortcuts.""" + # Widget must be shown for shortcuts to work + self.authentication.show() + QTest.qWaitForWindowExposed(self.authentication) + + # From experience it can happen that the widget is not fully + # initialized before the QTest.keyClicks below. By calling + # processEvents here we give Qt the opportunity to catch up with us + app.processEvents() + + old_row_count = self.authentication.rowCount() + + # Use shortcut 'Ctrl + +' + QTest.keyClicks(self.authentication, "+", Qt.ControlModifier) + + # Assert a new row is added + new_row_count = self.authentication.rowCount() + self.assertEqual(old_row_count + 1, new_row_count) + + # Assert new row has correct content + row_index = new_row_count - 1 + self.assertEqual( + self.authentication.item(row_index, 0).text(), + ROW_IDENTIFIERS_AUTHENTICATION[row_index - 1], + ) + self.assertEqual( + self.authentication.item(row_index, 1).text(), str(row_index - 1) + ) + new_code = self.authentication.item(row_index, 2).text() + existing_code = self.authentication.item(1, 2).text() + self.assertEqual(len(new_code), len(existing_code)) + + def test_gui_remove_row(self): + """Test removing rows with shortcuts.""" + # Widget must be shown for shortcuts to work + self.authentication.show() + QTest.qWaitForWindowExposed(self.authentication) + + # From experience it can happen that the widget is not fully + # initialized before the QTest.keyClicks below. By calling + # processEvents here we give Qt the opportunity to catch up with us + app.processEvents() + + old_row_count = self.authentication.rowCount() + + # User should not be able to delete headline-row with shortcut + # Locate headline-item, move mouse and and click (select) item + item_center = self.authentication.visualItemRect( + self.authentication.item(0, 0) + ).center + viewport = self.authentication.viewport() + QTest.mouseMove(viewport, item_center()) + QTest.mouseClick(viewport, Qt.LeftButton, Qt.NoModifier, item_center()) + + # Try to delete row with shortcut + QTest.keyClicks(self.authentication, "_", Qt.ControlModifier) + + # Assert row was not removed + new_row_count = self.authentication.rowCount() + self.assertEqual(old_row_count, new_row_count) + + # Move to first row (below headline), then use shortcut to delete it + QTest.keyClick(self.authentication, Qt.Key_Down) + value_to_delete = self.authentication.item(1, 1).text() + QTest.keyClicks(self.authentication, "_", Qt.ControlModifier) + # Assert row was removed and replaced by the row below + new_row_count = self.authentication.rowCount() + self.assertEqual(old_row_count - 1, new_row_count) + self.assertEqual( + self.authentication.item(1, 1).text(), value_to_delete + ) + + # Remove rows until only headline-row and a single code-row exist + for _ in range(1, self.authentication.rowCount() - 1): + QTest.keyClick(self.authentication, Qt.Key_Down) + QTest.keyClicks(self.authentication, "_", Qt.ControlModifier) + self.assertTrue(self.authentication.rowCount() == 2) + # Try to remove final code-row, should not work + QTest.keyClick(self.authentication, Qt.Key_Down) + QTest.keyClicks(self.authentication, "_", Qt.ControlModifier) + # Assert row was not removed + self.assertTrue(self.authentication.rowCount() == 2) + + def test_add_to_soi_smoke_test(self): + """Test that module can be added to SOI.""" + soi = SOI() + authentication_name = "Test name" + soi.add_module(authentication_name, self.authentication) + self.assertTrue(soi.module_name_taken(authentication_name)) + + +class TestAuthenticationBoardModuleFromData(unittest.TestCase): + """TestCase for initializing AuthenticationBoardModule from data.""" + + def test_create_from_data(self): + """Test creating AuthenticationBoardModule from data.""" + test_data = [ + "Headline text", + ["A", "0", "TEST CODE ONE"], + ["B", "1", "TEST CODE TWO"], + ] + test_size = {"width": 100, "height": 100} + + module = AuthenticationBoardModule(size=test_size, data=test_data) + + # Assert module contains expected data + self.assertEqual(module.item(0, 0).text(), "Headline text") + self.assertEqual(module.item(1, 0).text(), "A") + self.assertEqual(module.item(1, 1).text(), "0") + self.assertEqual(module.item(1, 2).text(), "TEST CODE ONE") + self.assertEqual(module.item(2, 0).text(), "B") + self.assertEqual(module.item(2, 1).text(), "1") + self.assertEqual(module.item(2, 2).text(), "TEST CODE TWO") diff --git a/test/test_module_authentication_board.py b/test/test_module_authentication_board.py deleted file mode 100644 index 5b3262e48055e0290b8676076e562da3b73913c5..0000000000000000000000000000000000000000 --- a/test/test_module_authentication_board.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Test AuthenticationBoardModule.""" -import unittest -from PySide2 import QtGui -from PySide2.QtWidgets import QApplication -from PySide2.QtCore import Qt -from PySide2.QtTest import QTest -from soitool.modules.module_authentication_board import ( - AuthenticationBoardModule, - START_NO_OF_AUTHENTICATION_CODES, - HEADLINE_TEXT, - ROW_IDENTIFIERS, - CODE_LENGTH, -) -from soitool.soi import SOI - -if isinstance(QtGui.qApp, type(None)): - app = QApplication([]) -else: - app = QtGui.qApp - - -class TestDefaultAuthenticationBoardModule(unittest.TestCase): - """TestCase for AuthenticationBoardModule.""" - - def setUp(self): - """Create new AuthenticationBoardModule.""" - self.module = AuthenticationBoardModule() - - def test_default_module(self): - """Test that module is initialized properly.""" - # Assert correct headline - self.assertEqual(self.module.item(0, 0).text(), HEADLINE_TEXT) - - # Assert correct number of rows including header - self.assertEqual( - self.module.rowCount(), START_NO_OF_AUTHENTICATION_CODES + 1 - ) - # Assert correct number of columns - self.assertEqual(self.module.columnCount(), 3) - - # Assert cell content in first column is correct - self.assertEqual(self.module.item(1, 0).text(), ROW_IDENTIFIERS[0]) - self.assertEqual(self.module.item(2, 0).text(), ROW_IDENTIFIERS[1]) - - # Assert cell content in second column is correct - self.assertEqual(self.module.item(1, 1).text(), "0") - self.assertEqual(self.module.item(2, 1).text(), "1") - - # Assert cell content in third column is correct - code = self.module.item(1, 2).text() - self.assertTrue(len(code) >= CODE_LENGTH) - # Assert all code characters are taken from list code_characters - for _, character in enumerate(code): - if character != " ": - self.assertTrue(character in self.module.code_characters) - # Assert all codes are unique - code_list = self.module.get_codes() - code_set = set(code_list) - self.assertEqual(len(code_list), len(code_set)) - - def test_generate_unique_authentication_code(self): - """Test function generate_unique_authentication_module.""" - code_list = self.module.get_codes() - generated_code = self.module.generate_unique_authentication_code() - - # Assert code length is equal to an existing code - self.assertEqual(len(generated_code), len(code_list[0])) - # Assert generated code is not equal to any existing codes - self.assertFalse(generated_code in code_list) - - def test_get_codes(self): - """Test function get_codes.""" - # Get codes - code_list = self.module.get_codes() - - # Assert codes are correct - for i, code in enumerate(code_list): - self.assertEqual(code, self.module.item(i + 1, 2).text()) - - def test_gui_add_row(self): - """Test adding rows with shortcuts.""" - # Widget must be shown for shortcuts to work - self.module.show() - QTest.qWaitForWindowExposed(self.module) - - # From experience it can happen that the widget is not fully - # initialized before the QTest.keyClicks below. By calling - # processEvents here we give Qt the opportunity to catch up with us - app.processEvents() - - old_row_count = self.module.rowCount() - - # Use shortcut 'Ctrl + +' - QTest.keyClicks(self.module, "+", Qt.ControlModifier) - - # Assert a new row is added - new_row_count = self.module.rowCount() - self.assertEqual(old_row_count + 1, new_row_count) - - # Assert new row has correct content - row_index = new_row_count - 1 - self.assertEqual( - self.module.item(row_index, 0).text(), - ROW_IDENTIFIERS[row_index - 1], - ) - self.assertEqual( - self.module.item(row_index, 1).text(), str(row_index - 1) - ) - new_code = self.module.item(row_index, 2).text() - existing_code = self.module.item(1, 2).text() - self.assertEqual(len(new_code), len(existing_code)) - - def test_gui_remove_row(self): - """Test removing rows with shortcuts.""" - # Widget must be shown for shortcuts to work - self.module.show() - QTest.qWaitForWindowExposed(self.module) - - # From experience it can happen that the widget is not fully - # initialized before the QTest.keyClicks below. By calling - # processEvents here we give Qt the opportunity to catch up with us - app.processEvents() - - old_row_count = self.module.rowCount() - - # First row is selected on startup, it contains headline - # and user should not be able to delete it with shortcut - QTest.keyClicks(self.module, "_", Qt.ControlModifier) - # Assert row was not removed - new_row_count = self.module.rowCount() - self.assertEqual(old_row_count, new_row_count) - - # Move to first row, then use shortcut to delete it - QTest.keyClick(self.module, Qt.Key_Down) - QTest.keyClicks(self.module, "_", Qt.ControlModifier) - # Assert row was removed and replaced by the row below - value_to_delete = self.module.item(1, 1).text() - new_row_count = self.module.rowCount() - self.assertEqual(old_row_count - 1, new_row_count) - self.assertEqual(self.module.item(1, 1).text(), value_to_delete) - - # Remove rows until only headline-row and a single code-row exist - for _ in range(1, self.module.rowCount() - 1): - QTest.keyClick(self.module, Qt.Key_Down) - QTest.keyClicks(self.module, "_", Qt.ControlModifier) - self.assertTrue(self.module.rowCount() == 2) - # Try to remove final code-row, should not work - QTest.keyClick(self.module, Qt.Key_Down) - QTest.keyClicks(self.module, "_", Qt.ControlModifier) - # Assert row was not removed - self.assertTrue(self.module.rowCount() == 2) - - def test_add_to_soi_smoke_test(self): - """Test that module can be added to SOI.""" - soi = SOI() - module_name = "Test name" - soi.add_module(module_name, self.module) - self.assertTrue(soi.module_name_taken(module_name)) - - -class TestAuthenticationBoardModuleFromData(unittest.TestCase): - """TestCase for initializing AuthenticationBoardModule from data.""" - - def test_create_from_data(self): - """Test creating AuthenticationBoardModule from data.""" - test_data = [ - "Headline text", - ["A", "0", "TEST CODE ONE"], - ["B", "1", "TEST CODE TWO"], - ] - test_size = {"width": 100, "height": 100} - - module = AuthenticationBoardModule(size=test_size, data=test_data) - - # Assert module contains expected data - self.assertEqual(module.item(0, 0).text(), "Headline text") - self.assertEqual(module.item(1, 0).text(), "A") - self.assertEqual(module.item(1, 1).text(), "0") - self.assertEqual(module.item(1, 2).text(), "TEST CODE ONE") - self.assertEqual(module.item(2, 0).text(), "B") - self.assertEqual(module.item(2, 1).text(), "1") - self.assertEqual(module.item(2, 2).text(), "TEST CODE TWO")