diff --git a/soitool/coder.py b/soitool/coder.py index 533e25995904ffd936170b028beb5071f8ce432d..fdc418a5ea286d50c1bb4b824b2aa275f177b145 100644 --- a/soitool/coder.py +++ b/soitool/coder.py @@ -1,11 +1,12 @@ -"""Generate codes.""" -import string +"""Generate codes. -# https://realpython.com/lessons/cryptographically-secure-random-data-python/ +Source: # https://realpython.com/lessons/cryptographically-secure-random-data-python/ +""" +import string import secrets -def get_code(code_length, mode="ascii"): +def get_code(code_length, mode="ascii", space_interval=0): """ Generate a single random code. @@ -13,18 +14,18 @@ def get_code(code_length, mode="ascii"): ---------- code_length : int The length of the code - mode : string 'ascii' for letters (default), 'digits' for digits and 'combo' - for combination of letters and digits. + for combination of letters and digits, by default 'ascii'. + space_interval : int or 0 + Spaces will be inserted to code each interval if not 0, by default 0. Return ------ code : string - The code + The code. """ code = "" - i = 0 if mode == "ascii": characters = string.ascii_uppercase @@ -37,14 +38,26 @@ def get_code(code_length, mode="ascii"): "Invalid value for argument 'mode': " "'{}'".format(mode) ) + if not isinstance(space_interval, int): + raise ValueError( + "Invalid value for argument 'separate_interval': " + "'{}'".format(space_interval) + ) + + i = 0 while i < code_length: letter = secrets.choice(characters) code += letter i += 1 + + # Add spaces to code if interval is given + if space_interval > 0: + code = insert_spaces(code, space_interval) + return code -def get_code_set(count, code_length, mode="ascii"): +def get_code_set(count, code_length, mode="ascii", space_interval=0): """ Generate a set of unique, random codes. @@ -52,13 +65,13 @@ def get_code_set(count, code_length, mode="ascii"): ---------- count : int Number of codes to be returned - code_length : int The length of each code - mode : string 'ascii' for letters (default), 'digits' for digits and 'combo' for combination of letters and digits. + space_interval : int or 0 + Spaces will be inserted to code each interval if not 0, by default 0. Return ------ @@ -68,7 +81,8 @@ def get_code_set(count, code_length, mode="ascii"): codes = set() while len(codes) < count: - codes.add(get_code(code_length, mode)) + code = get_code(code_length, mode, space_interval) + codes.add(code) return codes @@ -93,3 +107,26 @@ def get_code_length_needed(number_of_entries): code_length = code_length + 1 return code_length + + +def insert_spaces(code, interval): + """Insert space after every x'th character, x = interval. + + Parameters + ---------- + code : string + String to add spaces to. + interval : int + Interval for inserting spaces. + + Returns + ------- + string + code separated with spaces. + """ + # Convert to list to insert spaces between characters + code = list(code) + for i in range(interval - 1, len(code), interval): + code[i] += " " + + return "".join(code) diff --git a/soitool/media/authentificationboardmodule.PNG b/soitool/media/authentificationboardmodule.PNG new file mode 100644 index 0000000000000000000000000000000000000000..439ad8b1928211b7a4c8341377123fb2d7dbd303 Binary files /dev/null and b/soitool/media/authentificationboardmodule.PNG differ diff --git a/soitool/modules/module_authentication_board.py b/soitool/modules/module_authentication_board.py new file mode 100644 index 0000000000000000000000000000000000000000..cd0bfa2842c52dd3093ec51d52aa5d922139fc8b --- /dev/null +++ b/soitool/modules/module_authentication_board.py @@ -0,0 +1,279 @@ +"""Module containing SOI-module 'Autentifiseringstavle'.""" +import string +from PySide2.QtWidgets import QTableWidget, 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, +) + +START_NO_OF_AUTHENTICATION_CODES = 10 +CODE_LENGTH = 25 +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'. +ROW_IDENTIFIERS = string.ascii_uppercase # Characters for first column, +# it's length determines maximum number of codes (rows). +SPACE_INTERVAL = 5 # Adds space between sets of characters, 0 => no spaces +# If code is 123456 and interval is 2, code will be 12 34 56 + +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 'Autentifiseringstavle'. + + By default, the widget initializes with a headline, a row-count of + START_NO_OF_AUTHENTIFICATION_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 authentification code. + + 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. + + 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. + """ + + def __init__(self, size=None, data=None): + self.type = "AuthentificationBoardModule" + QTableWidget.__init__(self) + ModuleBase.__init__(self) + + if CODE_CHARACTERS == "ascii": + self.code_characters = string.ascii_uppercase + elif CODE_CHARACTERS == "digits": + self.code_characters = string.digits + elif CODE_CHARACTERS == "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 authentification 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, + ) + ) + + # Insert table data + for i in range(START_NO_OF_AUTHENTICATION_CODES): + # Insert non-editable row identifier in first column + item_first = QTableWidgetItem(ROW_IDENTIFIERS[i]) + 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.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. + + Parameters + ---------- + text : string, optional + The headline text, by default HEADLINE_TEXT + """ + 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_authentification_code(self): + """Generate authentification-code that does not already exist. + + Returns + ------- + string + Generated, unique authentification-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: + # Generate unique code and insert row + code = self.generate_unique_authentification_code() + self.insertRow(row_index + 1) + + # Loop through all rows starting with the new row + for i in range(row_index + 1, self.rowCount()): + # Insert row identifier in first column + item_first = QTableWidgetItem(self.code_characters[i - 1]) + 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.setFlags(item_second.flags() ^ Qt.ItemIsEditable) + self.setItem(i, 1, item_second) + + # Insert authentification-code in third column + item_third = QTableWidgetItem(code) + item_third.setFlags(item_third.flags() ^ Qt.ItemIsEditable) + self.setItem(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) + + def get_codes(self): + """Get all authentification-codes in table. + + Returns + ------- + List + List containing authentification-codes. + """ + codes = [] + + # 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. + """ + 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 + + @staticmethod + def get_user_friendly_name(): + """Get user-friendly name of module.""" + return "Autentiseringstavle" + + @staticmethod + def get_icon(): + """Get icon of module.""" + return QtGui.QIcon("soitool/media/authentificationboardmodule.png") diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py index f252d1e7bdf8a95536e69700659665809a151b27..c608a17177d43a16371b675d8f5a815f82a87e27 100644 --- a/soitool/modules/module_base.py +++ b/soitool/modules/module_base.py @@ -1,5 +1,12 @@ """Base/interface of each module.""" from abc import ABC +from PySide2 import QtGui + +# Font for module headline +HEADLINE_FONT = QtGui.QFont() +HEADLINE_FONT.setFamily("Arial") +HEADLINE_FONT.setPointSize(12) +HEADLINE_FONT.setWeight(100) class ModuleBase(ABC): @@ -15,14 +22,6 @@ class ModuleBase(ABC): """Abstract method, should be implemented by derived class.""" raise NotImplementedError - def set_pos(self, pos): - """Abstract method, should be implemented by derived class.""" - raise NotImplementedError - - def render_onto_pdf(self): - """Abstract method, should be implemented by derived class.""" - raise NotImplementedError - def get_data(self): """Abstract method, should be implemented by derived class.""" raise NotImplementedError @@ -36,3 +35,65 @@ class ModuleBase(ABC): def get_icon(): """Abstract method, should be implemented by derived class.""" raise NotImplementedError + + +def resize_table(widget, resize_row=True, resize_column=True): + """Calculate and set the size of a QTableWidget. + + Parameters + ---------- + widget : QTableWidget + QTablewidget-instance to calculate and set size. + resize_row : bool + Resizes rows to contents if True, by default True. + resize_column : bool + Resizes columns to contents if True, by default True. + """ + if resize_row: + widget.resizeRowsToContents() + if resize_column: + widget.resizeColumnsToContents() + + width, height = get_table_size(widget) + + widget.setFixedWidth(width) + widget.setFixedHeight(height) + + +def get_table_size(widget): + """Calculate and return total width and height of a QTableWidget. + + Parameters + ---------- + widget : QTableWidget + QTableWidget-instance to calculate and return size of. + + Returns + ------- + Tuple + Total (width, height) + """ + # Calculate total width and height of columns and rows + width = 0 + height = 0 + + for i in range(widget.columnCount()): + width += widget.columnWidth(i) + 0.5 + + for i in range(widget.rowCount()): + height += widget.rowHeight(i) + 0.5 + + return width, height + + +def set_module_pos(widget, pos): + """Set position of module (widget). + + Parameters + ---------- + widget : QWidget + Widget to move. + pos : QPoint + Position (x, y). + """ + widget.move(pos) diff --git a/soitool/modules/module_table.py b/soitool/modules/module_table.py index a568ec91771f01a8011ec2ffd48a47e7a9b7354a..433587a1248d644433df24ae7c7ddafda7448a80 100644 --- a/soitool/modules/module_table.py +++ b/soitool/modules/module_table.py @@ -1,13 +1,12 @@ -"""Module containing subclassed SOIModule (QTableWidget, ModuleBase).""" +"""Module containing a general SOI-module table.""" from PySide2.QtWidgets import QTableWidget, QTableWidgetItem from PySide2 import QtGui, QtCore -from PySide2.QtGui import QIcon -from soitool.modules.module_base import ModuleBase - -HEADER_FONT = QtGui.QFont() -HEADER_FONT.setFamily("Arial") -HEADER_FONT.setPointSize(12) -HEADER_FONT.setWeight(100) +from soitool.modules.module_base import ( + ModuleBase, + resize_table, + get_table_size, + HEADLINE_FONT, +) START_ROWS = 2 START_COLUMNS = 2 @@ -20,17 +19,17 @@ class Meta(type(ModuleBase), type(QTableWidget)): class TableModule(ModuleBase, QTableWidget, metaclass=Meta): """Modified QTableWidget. - Inherits from ModuleBase and QTableWidget. - ModuleBase is used as an interface, it's methods are overridden. + By default, the widget initializes as an empty START_ROWS * START_COLUMNS + table. If parameters are given, the table initializes accordingly: + 'size' is a dict: {"width": int, "height": int}, + 'data' is a 2D list where data[x][y] represents row x, column y. The widget does not use more room than needed, and resizes dynamically. Columnheaders are styled with light grey background and bold text. - Has shortcuts for adding and removing rows and columns. + It has shortcuts for adding and removing rows and columns. - By default, the widget initializes as an empty START_ROWS * START_COLUMNS - table. If parameters are given, the table initializes accordingly: - 'size' is a dict: {"width": int, "height": int}, - 'data' is a 2D list where content[x][y] represents row x, column y. + Inherits from ModuleBase and QTableWidget. + ModuleBase is used as an interface, it's methods are overridden. """ def __init__(self, size=None, data=None): @@ -50,10 +49,8 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): self.setColumnCount(START_COLUMNS) self.setRowCount(START_ROWS) - # Resize width and height of columns and rows, & set size of window - self.resize() - self.setFixedWidth(START_COLUMNS * self.columnWidth(0) + 2) - self.setFixedHeight(START_ROWS * self.rowHeight(0) + 5) + # Resize width and height of rows, columns and window + resize_table(self) # Set header-items for i in range(self.columnCount()): @@ -77,7 +74,7 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): self.setFixedWidth(size["width"]) self.setFixedHeight(size["height"]) - self.cellChanged.connect(self.resize) + self.cellChanged.connect(resize_table(self)) def keyPressEvent(self, event): """Launch actions when specific combinations of keys are pressed. @@ -125,87 +122,35 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): """ item = QTableWidgetItem(text) item.setBackground(QtGui.QBrush(QtGui.QColor(220, 220, 220))) - item.setFont(HEADER_FONT) + item.setFont(HEADLINE_FONT) self.setItem(0, column, item) def add_column(self): """Add column to the right of selected column.""" self.insertColumn(self.currentColumn() + 1) self.set_header_item(self.currentColumn() + 1, "") - self.resize() + resize_table(self) def remove_column(self): """Remove selected column if two or more columns exist.""" if self.columnCount() > 1: self.removeColumn(self.currentColumn()) - self.resize() + resize_table(self) def add_row(self): """Add row below selected row.""" self.insertRow(self.currentRow() + 1) - self.resize() + resize_table(self) 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()) - self.resize() - - def resize(self): - """Resize widget, rows and columns. - - Resize widget size to total width and height of rows and columns. - Resize rows and columns to contents. - """ - self.resizeColumnsToContents() - self.resizeRowsToContents() - - # Calculate total width and height of columns and rows - width = 0 - height = 0 - - for x in range(self.columnCount()): - width += self.columnWidth(x) + 0.5 - - for y in range(self.rowCount()): - height += self.rowHeight(y) + 0.5 - - # Set total width and height - self.setFixedWidth(width) - self.setFixedHeight(height) + resize_table(self) def get_size(self): - """Get size of widget. - - Returns - ------- - Tuple - (width, height) (total) - """ - # Calculate total width and height of columns and rows - width = 0 - height = 0 - - for i in range(self.columnCount()): - width += self.columnWidth(i) + 0.5 - - for i in range(self.rowCount()): - height += self.rowHeight(i) + 0.5 - - return width, height - - def set_pos(self, pos): - """Set position of widget. - - Parameters - ---------- - pos : QPoint - Position (x, y). - """ - self.move(pos) - - def render_onto_pdf(self): - """Render onto pdf.""" + """Return size of widget.""" + return get_table_size(self) def get_data(self): """Return list containing module data. @@ -236,4 +181,4 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): @staticmethod def get_icon(): """Get icon of module.""" - return QIcon("soitool/media/tablemodule.png") + return QtGui.QIcon("soitool/media/tablemodule.png") diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py index 2138c97961ac469ca49f4e20ff2aa1473815edb3..2fd3501b3b27c606dac34482280c70d95d975138 100644 --- a/soitool/new_module_dialog.py +++ b/soitool/new_module_dialog.py @@ -12,35 +12,10 @@ from PySide2.QtWidgets import ( QCheckBox, ) from PySide2.QtCore import QSize, Qt -from PySide2.QtGui import QIcon from soitool.modules.module_table import TableModule -from soitool.modules.module_base import ModuleBase - - -class ModulePlaceholder(ModuleBase): - """Dummy module used only to fill dialog with content while developing.""" - - def set_pos(self, pos): - """Not used.""" - raise NotImplementedError - - def get_size(self): - """Not used.""" - raise NotImplementedError - - def render_onto_pdf(self): - """Not used.""" - raise NotImplementedError - - @staticmethod - def get_user_friendly_name(): - """Get placeholder name.""" - return "Modul" - - @staticmethod - def get_icon(): - """Get standard placeholder icon.""" - return QIcon("soitool/media/placeholder.png") +from soitool.modules.module_authentication_board import ( + AuthenticationBoardModule, +) # Constant holding all modules the user can choose from. This is intended as a @@ -48,9 +23,7 @@ class ModulePlaceholder(ModuleBase): # placed here, and the rest of the program will respect them. MODULE_CHOICES = [ TableModule, - ModulePlaceholder, - ModulePlaceholder, - ModulePlaceholder, + AuthenticationBoardModule, ] diff --git a/soitool/serialize_export_import_soi.py b/soitool/serialize_export_import_soi.py index eb5dd8d35bf0df367255ce91cc392b2d5aae1c4f..a5c1dc80224023b4c042fa1343507e7bc955cf0a 100644 --- a/soitool/serialize_export_import_soi.py +++ b/soitool/serialize_export_import_soi.py @@ -5,6 +5,9 @@ from schema import Schema, And, Or from soitool.soi import SOI from soitool.compressor import compress, decompress from soitool.modules.module_table import TableModule +from soitool.modules.module_authentication_board import ( + AuthenticationBoardModule, +) # Valid schema for serialized SOI SERIALIZED_SOI_SCHEMA = Schema( @@ -218,6 +221,15 @@ def import_soi(file_path): modules.append( {"widget": TableModule(size, data), "meta": module["meta"]} ) + elif module_type == "AuthentificationBoardModule": + size = module["size"] + data = module["data"] + modules.append( + { + "widget": AuthenticationBoardModule(size, data), + "meta": module["meta"], + } + ) else: raise TypeError( "Module-type '{}' is not recognized.".format(module_type) diff --git a/soitool/soi.py b/soitool/soi.py index b9b7a724ea755598b95cccf10dabf4fc64475ab3..590b61e7e2f00ed4e9caf2a4b3ff04088f05e600 100644 --- a/soitool/soi.py +++ b/soitool/soi.py @@ -11,8 +11,9 @@ from rectpack import ( skyline, guillotine, ) +from soitool.modules.module_base import set_module_pos -# functions to sort modules by different criteria +# Functions to sort modules by different criteria def modules_sort_by_none(modules): @@ -381,7 +382,7 @@ class SOI: + scene_skip_distance_page_height ) - module["widget"].set_pos(QPoint(new_x, new_y)) + set_module_pos(module["widget"], QPoint(new_x, new_y)) def get_module_with_name(self, name): """Return module with given name. diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py index 520a3bbaed8d9261d9889fe5bc93f226a53bf5fb..5355999e61f2dad4d18d28c4c3bf9004990adf07 100644 --- a/soitool/soi_workspace_widget.py +++ b/soitool/soi_workspace_widget.py @@ -16,7 +16,7 @@ from soitool.soi import SOI, ModuleType, ModuleNameTaken from soitool.module_list import ModuleList from soitool.inline_editable_soi_view import InlineEditableSOIView from soitool.setup_settings import Setup -from soitool.new_module_dialog import NewModuleDialog, ModulePlaceholder +from soitool.new_module_dialog import NewModuleDialog from soitool.dialog_wrappers import exec_warning_dialog @@ -102,42 +102,27 @@ class SOIWorkspaceWidget(QWidget): module_widget_implementation = chosen_module.widget_implementation is_attachment = new_module_dialog.checkbox_attachment.isChecked() - if module_widget_implementation is ModulePlaceholder: + # No module name means the user expects one to be generated + # Autogenerated name is not meant to be pretty, it's just meant + # to be unique + if not module_name: + module_name = "{} {}".format( + module_choice, + str(len(self.soi.modules) + len(self.soi.attachments) + 1), + ) + + try: + self.soi.add_module( + module_name, module_widget_implementation(), is_attachment, + ) + except ModuleNameTaken: exec_warning_dialog( text="Modulen ble ikke lagt til.", - informative_text="Den valgte modulen er ikke " - "implementert. Modulen er trolig bare valgbar for å fylle " - "ut valgene til flere moduler er implementert.", + informative_text="Navnet du valgte er allerede i " + "bruk. Modulnavn må være unike. Velg et unikt " + "modulnavn, eller la programmet lage et navn " + "automatisk.", ) - else: - # no module name means the user expects one to be generated - # autogenerated name is not meant to be pretty, it's just meant - # to be unique - if not module_name: - module_name = "{} {}".format( - module_choice, - str( - len(self.soi.modules) - + len(self.soi.attachments) - + 1 - ), - ) - - try: - self.soi.add_module( - module_name, - module_widget_implementation(), - is_attachment, - ) - except ModuleNameTaken: - exec_warning_dialog( - text="Modulen ble ikke lagt til.", - informative_text="Navnet du valgte er allerede i " - "bruk. Modulnavn må være unike. Velg et unikt " - "modulnavn, eller la programmet lage et navn " - "automatisk.", - ) - elif dialogcode == QDialog.DialogCode.Rejected: pass else: diff --git a/test/test_module_authentication_board.py b/test/test_module_authentication_board.py new file mode 100644 index 0000000000000000000000000000000000000000..d5f5869433a0dfe8c7ca36f467cee19e4a22c32a --- /dev/null +++ b/test/test_module_authentication_board.py @@ -0,0 +1,172 @@ +"""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_authentification_code(self): + """Test function generate_unique_authentification_module.""" + code_list = self.module.get_codes() + generated_code = self.module.generate_unique_authentification_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) + + 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) + + 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")