diff --git a/soitool/database.py b/soitool/database.py index 9a1c019274e6e1e8e499fc2b4e30b2256664fd6d..95d29151b0770ebcf508d016b6b5e8c8a3845d43 100644 --- a/soitool/database.py +++ b/soitool/database.py @@ -162,14 +162,50 @@ class Database: Returns ------- - List of strings - Categories + List + Containing categories (string). """ stmt = "SELECT DISTINCT Category FROM CategoryWords" queried = self.conn.execute(stmt) - categories = [] - for row in queried: - categories.append(row["Category"]) + categories = [row["Category"] for row in queried] + + return categories + + def get_random_category_word(self): + """Read a random word from database-table CategoryWords. + + Returns + ------- + String + Word (string). + """ + stmt = "SELECT Word FROM CategoryWords ORDER BY RANDOM() LIMIT 1" + word = self.conn.execute(stmt).fetchone()[0] + + return word + + def get_categories_from_codebook(self, small=False): + """Read categories from full or small codebook. + + Parameters + ---------- + small : bool, optional + Categories are from small codebook if True, full codebook if + False, by default False. + + Returns + ------- + List + Containing categories (string). + """ + stmt = "SELECT Category FROM Codebook" + if small: + stmt += " WHERE Type='Liten' GROUP BY Category" + else: + stmt += " GROUP BY Category" + + queried = self.conn.execute(stmt).fetchall() + categories = [row["Category"] for row in queried] return categories @@ -226,6 +262,31 @@ class Database: return codebook + def get_codebook_expressions_in_category(self, category, small=False): + """Read expressions, from full or small codebook, in a given category. + + Parameters + ---------- + category : string + Expressions in this category are returned. + small : bool, optional + Expressions are from small codebook if True, full codebook if + False, by default False. + + Returns + ------- + List + Containing expressions (string). + """ + stmt = "SELECT Word FROM Codebook WHERE Category=?" + if small: + stmt += " AND Type='Liten'" + queried = self.conn.execute(stmt, (category,)).fetchall() + + expressions = [row["Word"] for row in queried] + + return expressions + def update_codebook(self): """Update codes in DB.""" # Get all the words (PK) diff --git a/soitool/help_actions.py b/soitool/help_actions.py index 0d99369a6082c279ce8a5f4caa29ecef07c48739..7c30dba840d5279899f202b6e5b3e9ffd0d9e8eb 100644 --- a/soitool/help_actions.py +++ b/soitool/help_actions.py @@ -11,6 +11,7 @@ from PySide2.QtWidgets import ( QFormLayout, ) from PySide2.QtCore import Qt +from PySide2.QtGui import QFont class ShortcutsHelpDialog(QDialog): @@ -32,6 +33,15 @@ class ShortcutsHelpDialog(QDialog): self.button_ok = QPushButton("Ok") self.button_ok.clicked.connect(self.close) + self.header_main = QLabel("Hurtigtaster for applikasjonen:") + self.header_main.setFont(QFont("Verdana", 10)) + self.header_table = QLabel("Hurtigtaster for redigerbare tabeller:") + self.header_table.setFont(QFont("Verdana", 10)) + self.header_modules = QLabel("Hurtigtaster for noen gitte moduler:") + self.header_modules.setFont(QFont("Verdana", 10)) + + # Main/program shortcuts + self.layout_label.addRow(self.header_main) self.layout_label.addRow( QLabel("Opprett en ny SOI: "), QLabel("Ctrl + N") ) @@ -41,18 +51,41 @@ class ShortcutsHelpDialog(QDialog): self.layout_label.addRow( QLabel("Åpne SOI fra database: "), QLabel("Ctrl + D") ) + self.layout_label.addRow( + QLabel("Endre oppsett SOI: "), QLabel("Ctrl + I") + ) + self.layout_label.addRow( + QLabel("Legg til ny modul: "), QLabel("Ctrl + M") + ) self.layout_label.addRow(QLabel("Eksporter PDF: "), QLabel("Ctrl + P")) + self.layout_label.addRow( + QLabel("Eksporter komprimert SOI: "), QLabel("Ctrl + E") + ) self.layout_label.addRow( QLabel("Lagre i database: "), QLabel("Ctrl + S") ) + + # Table shortcuts + self.layout_label.addRow(QLabel()) + self.layout_label.addRow(self.header_table) + self.layout_label.addRow(QLabel("Legg til rad"), QLabel("Ctrl + +")) + self.layout_label.addRow(QLabel("Fjern rad"), QLabel("Ctrl + -")) self.layout_label.addRow( - QLabel("Eksporter komprimert SOI: "), QLabel("Ctrl + E") + QLabel("Legg til kolonne"), QLabel("Shift + +") + ) + self.layout_label.addRow(QLabel("Fjern kolonne"), QLabel("Shift + -")) + + # Other module-specific shortcuts + self.layout_label.addRow(QLabel()) + self.layout_label.addRow(self.header_modules) + self.layout_label.addRow( + QLabel("Rediger forhåndsavtalte koder: "), QLabel("Ctrl + R") ) self.layout_label.addRow( - QLabel("Legg til ny modul: "), QLabel("Ctrl + M") + QLabel("Rediger telefonliste: "), QLabel("Ctrl + R") ) self.layout_label.addRow( - QLabel("Endre oppsett SOI: "), QLabel("Ctrl + I") + QLabel("Rediger frekvenstabell: "), QLabel("Ctrl + R") ) self.layout_button.addWidget(self.button_ok, alignment=Qt.AlignRight) diff --git a/soitool/inline_editable_soi_view.py b/soitool/inline_editable_soi_view.py index 5bde3a8ba14af685065fa8200d96e2148e36651a..8690ad30f8010d51768593fcbe512b337781f493 100644 --- a/soitool/inline_editable_soi_view.py +++ b/soitool/inline_editable_soi_view.py @@ -12,7 +12,6 @@ from PySide2.QtWidgets import ( QGraphicsProxyWidget, ) from PySide2.QtGui import ( - QFont, QPixmap, QBrush, QPalette, @@ -22,6 +21,7 @@ from PySide2.QtPrintSupport import QPrinter from soitool.soi import ModuleLargerThanBinError from soitool.dialog_wrappers import exec_warning_dialog from soitool.serialize_export_import_soi import generate_soi_filename +from soitool.modules.module_base import qfont_with_pixel_size # How attachment pages should be numbered. The first page should be numbered # by the value of ATTACHMENT_NUMBERING_SCHEME[0], the second page @@ -419,7 +419,7 @@ class InlineEditableSOIView(QScrollArea): label_title = QLabel(self.soi.title) label_title.move(x, y + self.soi.HEADER_HEIGHT) label_title.setStyleSheet("background-color: rgba(0,0,0,0%)") - label_title.setFont(QFont("Times New Roman", 35)) + label_title.setFont(qfont_with_pixel_size("Times New Roman", 60)) proxy = self.scene.addWidget(label_title) proxy.setRotation(-90) @@ -427,7 +427,7 @@ class InlineEditableSOIView(QScrollArea): label_description = QLabel(self.soi.description) label_description.move(x + 57, y + self.soi.HEADER_HEIGHT) label_description.setStyleSheet("background-color: rgba(0,0,0,0%)") - label_description.setFont(QFont("Times New Roman", 15)) + label_description.setFont(qfont_with_pixel_size("Times New Roman", 25)) proxy = self.scene.addWidget(label_description) proxy.setRotation(-90) @@ -435,7 +435,9 @@ class InlineEditableSOIView(QScrollArea): creation_date = soi_date_string_to_user_friendly_string(self.soi.date) label_creation_date = QLabel("Opprettet: {}".format(creation_date)) label_creation_date.setStyleSheet("background-color: rgba(0,0,0,0%)") - label_creation_date.setFont(QFont("Times New Roman", 15)) + label_creation_date.setFont( + qfont_with_pixel_size("Times New Roman", 25) + ) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( @@ -470,7 +472,7 @@ class InlineEditableSOIView(QScrollArea): "1 av N", f"{self.copy_current} av {self.copy_total}" ) proxy.label.setStyleSheet("background-color: rgba(0,0,0,0%)") - proxy.label.setFont(QFont("Times New Roman", 40)) + proxy.label.setFont(qfont_with_pixel_size("Times New Roman", 70)) # Need to call this when label of proxy changes. See function docstring proxy.update_bounding_rect() @@ -493,7 +495,9 @@ class InlineEditableSOIView(QScrollArea): label_copy_number_header.setStyleSheet( "background-color: rgba(0,0,0,0%)" ) - label_copy_number_header.setFont(QFont("Times New Roman", 15)) + label_copy_number_header.setFont( + qfont_with_pixel_size("Times New Roman", 25) + ) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( @@ -519,7 +523,7 @@ class InlineEditableSOIView(QScrollArea): ) ) page_number.setStyleSheet("background-color: rgba(0,0,0,0%)") - page_number.setFont(QFont("Times New Roman", 15)) + page_number.setFont(qfont_with_pixel_size("Times New Roman", 25)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( @@ -534,7 +538,7 @@ class InlineEditableSOIView(QScrollArea): classification.setStyleSheet( "background-color: rgba(0,0,0,0%); " "color: red" ) - classification.setFont(QFont("Times New Roman", 35)) + classification.setFont(qfont_with_pixel_size("Times New Roman", 60)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( @@ -552,7 +556,7 @@ class InlineEditableSOIView(QScrollArea): ) label_valid_from = QLabel("Gyldig fra: {}".format(valid_from)) label_valid_from.setStyleSheet("background-color: rgba(0,0,0,0%)") - label_valid_from.setFont(QFont("Times New Roman", 15)) + label_valid_from.setFont(qfont_with_pixel_size("Times New Roman", 25)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( @@ -568,7 +572,7 @@ class InlineEditableSOIView(QScrollArea): valid_to = soi_date_string_to_user_friendly_string(self.soi.valid_to) label_valid_to = QLabel("Gyldig til: {}".format(valid_to)) label_valid_to.setStyleSheet("background-color: rgba(0,0,0,0%)") - label_valid_to.setFont(QFont("Times New Roman", 15)) + label_valid_to.setFont(qfont_with_pixel_size("Times New Roman", 25)) # Source: https://stackoverflow.com/a/8638114/3545896 # CAUTION: does not work if font is set through stylesheet label_width = ( diff --git a/soitool/main_window.py b/soitool/main_window.py index 08190ec2bbbccbe577f84c048ee55469dda404d1..5dc81632c957243ee2a1fc6e6560a7ca1d049e92 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -227,7 +227,7 @@ class MainWindow(QMainWindow): def open_soi_workspace_tab(self): """Open and select tab containing a SOIWorkspaceWidget.""" - tab = SOIWorkspaceWidget() + tab = SOIWorkspaceWidget(self.database) self.tabs.addTab(tab, tab.soi.title) self.tabs.setCurrentWidget(tab) @@ -315,10 +315,10 @@ class MainWindow(QMainWindow): )[0] if len(file_path) > 0: - soi = import_soi(file_path) + soi = import_soi(file_path, self.database) # Create and select tab - tab = SOIWorkspaceWidget(soi) + tab = SOIWorkspaceWidget(self.database, soi) self.tabs.addTab(tab, soi.title) self.tabs.setCurrentWidget(tab) diff --git a/soitool/media/predefinedcodesmodule.PNG b/soitool/media/predefinedcodesmodule.PNG new file mode 100644 index 0000000000000000000000000000000000000000..dcf0cc1bbb7dd9924e27ef5b713069ed8c6d6170 Binary files /dev/null and b/soitool/media/predefinedcodesmodule.PNG differ diff --git a/soitool/modules/code_table_base.py b/soitool/modules/code_table_base.py index 9cdff1ed3d297456eb864864791c2310d442eb7d..7028a3d6ac7f04e2cca30160aff2abbcc3538847 100644 --- a/soitool/modules/code_table_base.py +++ b/soitool/modules/code_table_base.py @@ -11,6 +11,7 @@ from soitool.modules.module_base import ( get_table_size, resize_table, HEADLINE_FONT, + TABLE_CELL_DEFAULT_FONT, ) AUTHENTICATIONBOARD_MODULE = "AuthenticationBoardModule" @@ -37,6 +38,7 @@ class CodeTableBase(ModuleBase, QTableWidget, metaclass=Meta): self.verticalHeader().hide() self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setFont(TABLE_CELL_DEFAULT_FONT) # Resize table when headline changes self.cellChanged.connect( @@ -92,11 +94,10 @@ class CodeTableBase(ModuleBase, QTableWidget, metaclass=Meta): # Set cell-items for i in range(self.rowCount()): for j in range(self.columnCount()): - item = QTableWidgetItem( - cells[i + 1][j] - ) # +1 skip headline + # + 1 skip headline + item = QTableWidgetItem(cells[i + 1][j]) + item.setTextAlignment(Qt.AlignCenter) if j == 2: - item.setTextAlignment(Qt.AlignCenter) item.setFont(self.code_font) self.setItem(i, j, item) diff --git a/soitool/modules/module_authentication_board.py b/soitool/modules/module_authentication_board.py index ca46c80dcfac75cb449104380700fe62e8d24916..06ba84f67f0fee4b0a46b072c50bde45af22b0ea 100644 --- a/soitool/modules/module_authentication_board.py +++ b/soitool/modules/module_authentication_board.py @@ -4,7 +4,11 @@ from secrets import choice 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.module_base import ( + resize_table, + qfont_with_pixel_size, + TABLE_CELL_DEFAULT_FONT, +) from soitool.modules.code_table_base import CodeTableBase # Characters for first column, @@ -21,7 +25,9 @@ CODE_LENGTH = 25 CODE_CHARACTER_TYPE = "ascii" # Font for authentication codes, should be a monospaced font -CODE_FONT = QtGui.QFont("Consolas", 10, QtGui.QFont.SansSerif) +CODE_FONT = qfont_with_pixel_size( + "Consolas", TABLE_CELL_DEFAULT_FONT.pixelSize(), QtGui.QFont.SansSerif +) # Adds space between sets of characters, 0 => no spaces # If code is 123456 and interval is 2, code will be 12 34 56 diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py index 1d6f055d20dd0a418317e7a77d3e140540159b2d..91d97dbcbade3b85432fbaed1415a3a394663b1d 100644 --- a/soitool/modules/module_base.py +++ b/soitool/modules/module_base.py @@ -2,11 +2,34 @@ from abc import ABC from PySide2 import QtGui + +def qfont_with_pixel_size(font_family, pixel_size, weight=None): + """Provide a QFont with given family and pixel size. + + Created because QFont does not have a constructor with pixel size as a + parameter. + + Parameters + ---------- + font_family : str + Name of font family. Sent to https://doc.qt.io/qt-5/qfont.html#QFont-1 + pixel_size : int + Pixel size. Sent to https://doc.qt.io/qt-5/qfont.html#setPixelSize + weight : QFont.Weight + Weight of font. Sent to https://doc.qt.io/qt-5/qfont.html#QFont-1 + """ + if weight is not None: + font = QtGui.QFont(font_family, weight=weight) + else: + font = QtGui.QFont(font_family) + font.setPixelSize(pixel_size) + return font + + # Font for module headline -HEADLINE_FONT = QtGui.QFont() -HEADLINE_FONT.setFamily("Arial") -HEADLINE_FONT.setPointSize(12) -HEADLINE_FONT.setWeight(100) +HEADLINE_FONT = qfont_with_pixel_size("Arial", 24, 100) + +TABLE_CELL_DEFAULT_FONT = qfont_with_pixel_size("Arial", 16) class ModuleBase(ABC): diff --git a/soitool/modules/module_freetext.py b/soitool/modules/module_freetext.py index 95066bceea66e73d73013f3b930fecd92ef1484d..14ede836bcd46425bc8d10a1a198121b566759af 100644 --- a/soitool/modules/module_freetext.py +++ b/soitool/modules/module_freetext.py @@ -13,7 +13,11 @@ from PySide2.QtWidgets import ( ) from PySide2.QtCore import QSize, Qt from PySide2.QtGui import QIcon, QFontMetricsF -from soitool.modules.module_base import ModuleBase, HEADLINE_FONT +from soitool.modules.module_base import ( + ModuleBase, + HEADLINE_FONT, + qfont_with_pixel_size, +) class TextEditWithSizeOfContent(QTextEdit): @@ -113,6 +117,9 @@ class FreeTextModule(ModuleBase, QWidget, metaclass=Meta): self.line_edit_header = LineEditWithSizeOfContent() self.line_edit_header.setFont(HEADLINE_FONT) self.text_edit_body = TextEditWithSizeOfContent() + self.text_edit_body.setFont( + qfont_with_pixel_size("Arial", HEADLINE_FONT.pixelSize()) + ) # When the contents of these widgets change we need to manually trigger # adjust of size, even on self. Without adjust of size on self the diff --git a/soitool/modules/module_predefined_codes.py b/soitool/modules/module_predefined_codes.py new file mode 100644 index 0000000000000000000000000000000000000000..7c9ce9b4bc7b1ae6d9e4f89a3992a3261a11ccd2 --- /dev/null +++ b/soitool/modules/module_predefined_codes.py @@ -0,0 +1,629 @@ +"""SOI-module 'Forhåndsavtalte koder'.""" +import string +from random import sample +from PySide2.QtWidgets import ( + QWidget, + QTableWidget, + QTableWidgetItem, + QHBoxLayout, + QVBoxLayout, + QLabel, + QLineEdit, + QDialog, + QListWidget, + QListWidgetItem, + QAbstractItemView, + QFormLayout, + QSpinBox, +) +from PySide2 import QtGui +from PySide2.QtCore import Qt +from soitool.modules.module_base import ModuleBase, HEADLINE_FONT, resize_table +from soitool.accept_reject_dialog import AcceptRejectDialog + +ALPHABET = string.ascii_uppercase +HEADLINE = "FORHÅNDSAVTALTE KODER" +MAXIMUM_COLUMN_HEIGHT = 1000 +DEFAULT_COLUMN_HEIGHT = 200 + + +class Meta(type(ModuleBase), type(QWidget)): + """Used as a metaclass to enable multiple inheritance.""" + + +class PredefinedCodesModule(ModuleBase, QWidget, metaclass=Meta): + """QWidget representing SOI-module 'Forhåndsavtalte koder'. + + This widget has a headline, a warning-word and a layout with x amount of + PredefinedCodesTable-objects containing all expressions and categories from + small codebook ("Type" = "Liten" in database-table "Codebook"). + + If parameter "data" is not given, the widget reads all expressions and + categories from small codebook, and a warning-word is randomly chosen from + database-table "CategoryWords". It then launches a dialog where the user + can set up the module - headline, warning_word, maximum height and category + order. + If parameter "data" is given, the widget is built based on the contents. + + Finally, it creates one PredefinedCodesTable per category and inserts all + expressions in the category. + + The shortcut "Ctrl + R" launches the setup-dialog so that the user can + modify and update the module. If changes have been made to small codebook, + the module will reflect the changes, and it will also randomly sort all + expressions in each category (so that a new code is assigned to each one). + + The PredefinedCodesTables are placed in one or more QVBoxLayouts, which are + placed next to each other. The QVBoxLayouts are therefore referred to as + columns. The user can decide (through the dialog PredefinedCodesSettings) + the maximum column-height. Each time there is not enough room for all + tables in a column within the maximum height, a new column is created. + The class has an attribute 'minimum_column_height', which reflects the + height of the tallest table. The user is not allowed to set a maximum + column-height smaller than the minimum_column_height, because one or more + tables would not fit. + + The widget does not use more room than needed, and resizes dynamically. + + Parameters + ---------- + database : soitool.database.Database + Database-instance used to read from database. + data : dict, optional + { + "headline": string, + "warning_word": string, + "maximum_column_height": int, + "categories": [strings], + "tables": [{ + "table_headline": string, + "expressions": [strings] + ]} + } + By default None. + """ + + def __init__(self, database, data=None): + self.type = "PredefinedCodesModule" + QWidget.__init__(self) + ModuleBase.__init__(self) + + self.database = database + self.tables = [] + self.categories = [] + + if data is not None: + self.headline = QLabel(data["headline"]) + self.warning_word = QLabel(data["warning_word"]) + self.maximum_column_height = data["maximum_column_height"] + self.categories = data["categories"] + self.create_tables_from_data(data) + self.create_and_set_layout() + else: + # Get a random warning-word and categories from database + warning_word = self.database.get_random_category_word() + categories_unsorted = self.database.get_categories_from_codebook( + small=True + ) + # Set up module + self.run_setup(warning_word, categories_unsorted) + + def run_setup(self, warning_word, categories): + """Launch setup-dialog, read input, create tables and set layout.""" + # Calculate height of the tallest table + self.minimum_column_height = self.calculate_height_of_tallest_table( + categories + ) + # Launch dialog + dialog = PredefinedCodesSettings(HEADLINE, warning_word, categories) + dialog.edit_column_height.setMinimum(self.minimum_column_height) + dialog.exec_() + + # Read dialog-input + categories_and_expressions = self.read_from_dialog(dialog) + + # Create tables and set layout + self.create_tables(categories_and_expressions) + self.create_and_set_layout() + + def read_from_dialog(self, dialog): + """Read input from dialog PredefinedCodesSettings. + + Parameters + ---------- + dialog : PredefinedCodesSettings + Dialog to read from. + + Returns + ------- + dict + With categories as keys and list of expressions as values. + """ + # Read headline and warning word, + # create QLabel or set text on existing QLabel + if hasattr(self, "headline"): + self.headline.setText(dialog.edit_headline.text()) + else: + self.headline = QLabel(dialog.edit_headline.text()) + if hasattr(self, "warning_word"): + self.warning_word.setText(dialog.edit_warning_word.text()) + else: + self.warning_word = QLabel(dialog.edit_warning_word.text()) + + self.maximum_column_height = dialog.edit_column_height.value() + + # Read categories in order + self.categories.clear() + for i in range(dialog.list_category_order.count()): + self.categories.append(dialog.list_category_order.item(i).text()) + + # Create dict containing categories and their expressions + categories_and_expressions = {} + for category in self.categories: + expressions = self.database.get_codebook_expressions_in_category( + category, small=True + ) + # Add expressions sorted randomly + categories_and_expressions[category] = sample( + expressions, len(expressions) + ) + + return categories_and_expressions + + def create_tables(self, categories_and_expressions): + """Create PredefinedCodesTable-objects. + + Parameters + ---------- + categories_and_expressions : dict + With categories as keys and list of expressions as values. + """ + # Delete previous tables + del self.tables[:] + + # Create new tables + for i, category in enumerate(categories_and_expressions.keys()): + headline = " " + ALPHABET[i] + " " + category + table = PredefinedCodesTable( + headline, categories_and_expressions[category] + ) + self.tables.append(table) + + def create_tables_from_data(self, data): + """Create PredefinedCodesTable-objects from data. + + Parameters + ---------- + Dict + Contains data needed, see module description. + """ + for table_data in data["tables"]: + table_headline = table_data["table_headline"] + expressions = table_data["expressions"] + table = PredefinedCodesTable(table_headline, expressions) + self.tables.append(table) + + self.minimum_column_height = self.get_height_of_tallest_table() + + def create_and_set_layout(self): + """Create, fill and set layout.""" + self.headline.setFont(HEADLINE_FONT) + self.headline.setAlignment(Qt.AlignCenter) + self.warning_word.setFont(HEADLINE_FONT) + self.warning_word.setAlignment(Qt.AlignCenter) + + # Layout for tables + table_layout = self.create_table_layout() + + # Layout for warning_word + warning_word_layout = QHBoxLayout() + warning_word_label = QLabel("Varslingsord: ") + warning_word_layout.addWidget(warning_word_label) + warning_word_layout.addWidget(self.warning_word) + warning_word_layout.setAlignment(warning_word_label, Qt.AlignHCenter) + warning_word_layout.setAlignment(self.warning_word, Qt.AlignHCenter) + + # Main layout + self.main_layout = QVBoxLayout() + self.main_layout.addWidget(self.headline) + self.main_layout.setAlignment(self.headline, Qt.AlignHCenter) + self.main_layout.addLayout(warning_word_layout) + self.main_layout.setAlignment(warning_word_layout, Qt.AlignHCenter) + self.main_layout.addLayout(table_layout) + + # Set layout and adjust size + self.setLayout(self.main_layout) + self.adjustSize() + + def create_table_layout(self): + """Create and return a table-layout. + + Returns + ------- + QHBoxLayout + Layout containing x amount of QVBoxLayouts with tables. + """ + # Create table-layout + table_layout = QHBoxLayout() + + # Algorithm-explanation: Create a "column" and fill it with tables + # until adding the next table will overshoot maximum_column_height, + # then add a new column and repeat the process until all tables are + # added. + i = 0 + column_height = 0 + # While all tables are not added + while i < len(self.tables): + # Create a "column" + vbox_layout = QVBoxLayout() + max_table_width = 0 + # While there are more tables and there is room for the next table, + # add table to column and save it's width if it is the widest table + while ( + i < len(self.tables) + and column_height + self.tables[i].height() + <= self.maximum_column_height + ): + table = self.tables[i] + if table.width() > max_table_width: + max_table_width = table.width() + vbox_layout.addWidget(table) + # Increase column-height + column_height += table.height() + i += 1 + + # Column does not have room to fit the next table. + # Make all tables in column have equal width. + for j in range(vbox_layout.count()): + widget = vbox_layout.itemAt(j).widget() + column_one_width = widget.columnWidth(0) + widget.setFixedWidth(max_table_width) + widget.setColumnWidth(1, max_table_width - column_one_width) + + # Add a QSpacerItem so that tables in column are pushed to the top + vbox_layout.addStretch(1) + + # Add column to the table_layout and reset column-height. + table_layout.addLayout(vbox_layout) + column_height = 0 + + return table_layout + + def new_table_layout(self): + """Replace current table-layout with a new one.""" + # Get current layout and remove it from main-layout. + table_layout = self.main_layout.itemAt(2) + self.main_layout.removeItem(table_layout) + + # Loop through table-layout's columns (QVBoxLayouts) and delete them + # along with their widgets to make them disappear. + columns = [table_layout.itemAt(i) for i in range(table_layout.count())] + for column in columns: + tables = [column.itemAt(i).widget() for i in range(column.count())] + for table in tables: + if table is not None: + table.close() + del table + del column + del table_layout + + # Create new table-layout + table_layout = self.create_table_layout() + + # Add table-layout and adjust own size + self.main_layout.addLayout(table_layout) + self.adjustSize() + + def get_size(self): + """Return size of table. + + Returns + ------- + Tuple + width, height. + """ + return self.width(), self.height() + + def get_data(self): + """Return a dict containing all module-data. + + Returns + ------- + Dict + { + "headline": string, + "warning_word": string, + "maximum_column_height": int, + "categories": [strings], + "tables": [{ + "table_headline": string, + "expressions": [strings] + ]} + } + """ + tables = [] + # Create a dict for each table and them to tables-list + for table in self.tables: + # Table-headline + item_headline = table.item(0, 0) + if item_headline is not None: + table_headline = item_headline.text() + else: + table_headline = "" + # Table-expressions + expressions = [] + for i in range(1, table.rowCount()): + expressions.append(table.item(i, 1).text()) + # Create dict with table-data and add it to list + table = { + "table_headline": table_headline, + "expressions": expressions, + } + tables.append(table) + + # Create main dict and add tables-list + data = { + "headline": self.headline.text(), + "warning_word": self.warning_word.text(), + "maximum_column_height": self.maximum_column_height, + "categories": self.categories, + "tables": tables, + } + + return data + + def get_height_of_tallest_table(self): + """Get height of the tallest table. + + Returns + ------- + int + Height of the tallest table. + """ + tallest_height = 0 + for table in self.tables: + if table.height() > tallest_height: + tallest_height = table.height() + + return tallest_height + + def calculate_height_of_tallest_table(self, categories): + """Find what table will be tallest and return it's height. + + Parameters + ---------- + categories : List + Containing categories, is used to retrieve expressions in each + category. + + Returns + ------- + int + Height of the tallest table. + """ + # Get expressions in each category, while noting the index of the + # category with the most expressions + all_expressions = [] + max_length = 0 + max_index = None + for i, category in enumerate(categories): + expressions = self.database.get_codebook_expressions_in_category( + category, small=True + ) + all_expressions.append(expressions) + if len(expressions) > max_length: + max_index = i + max_length = len(expressions) + + # Create the tallest table and get it's height + tallest_table = PredefinedCodesTable("", all_expressions[max_index]) + tallest_height = tallest_table.height() + + return tallest_height + + def keyPressEvent(self, event): + """Launch PredefinedCodesSettings-dialog when "Ctrl + R" is pressed.""" + if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_R: + dialog = PredefinedCodesSettings( + self.headline.text(), self.warning_word.text(), self.categories + ) + + # Modify dialog + dialog.edit_column_height.setMinimum(self.minimum_column_height) + dialog.edit_column_height.setValue(self.maximum_column_height) + dialog.button_ok.setText("Oppdater") + dialog.button_cancel.show() + + dialog_code = dialog.exec_() + # If user accepted, read dialog, create tables and set a + # new table-layout + if dialog_code == QDialog.DialogCode.Accepted: + categories_and_expressions = self.read_from_dialog(dialog) + + self.create_tables(categories_and_expressions) + self.new_table_layout() + + super().keyPressEvent(event) + + @staticmethod + def get_user_friendly_name(): + """Get user-friendly name of module.""" + return "Forhåndsavtalte koder" + + @staticmethod + def get_icon(): + """Get icon of module.""" + return QtGui.QIcon("soitool/media/predefinedcodesmodule.png") + + +class PredefinedCodesTable(QTableWidget): + """Modified QTableWidget displaying predefined-codes in a category. + + This table has a headline and two columns. The headline should consist of + a letter followed by a category. Each row contains a unique letter and an + expression. Practically speaking, the letter from the headline + the letter + from the row is used as a code for the expression on that row. + + Parameters + ---------- + headline : string + Will be the headline of the table, should be a letter + followed by a category. + expressions : list + Containing expressions (string). + """ + + def __init__(self, headline, expressions): + QTableWidget.__init__(self) + + # Set focus-policy to prevent PredefinedCodesModule's + # keyPressEvent-function to be called twice when a cell is selected. + self.setFocusPolicy(Qt.NoFocus) + + # Set row- and columncount + self.setRowCount(len(expressions)) + self.setColumnCount(2) + + # Remove headers and scrollbars + self.horizontalHeader().hide() + self.verticalHeader().hide() + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # Insert codes + self.insert_codes() + + # Insert expressions + for i, expression in enumerate(expressions): + item_expression = QTableWidgetItem(expression) + item_expression.setFlags( + item_expression.flags() ^ Qt.ItemIsEditable + ) + self.setItem(i, 1, item_expression) + + # Resize columns and rows to fit contents + self.resizeColumnsToContents() + self.resizeRowsToContents() + + # Insert headline and resize table + self.insert_headline(headline) + resize_table(self, rows=False, columns=False, has_headline=True) + + def insert_headline(self, text): + """Insert headline. + + Parameters + ---------- + text : string + Text of the headline. + """ + # Create QTableWidgetItem + item_headline = QTableWidgetItem(text) + item_headline.setFont(HEADLINE_FONT) + item_headline.setFlags(item_headline.flags() ^ Qt.ItemIsEditable) + + # Insert row, item and make it span all columns + self.insertRow(0) + self.setItem(0, 0, item_headline) + self.setSpan(0, 0, 1, self.columnCount()) + + def insert_codes(self): + """Insert codes A-Z in first column.""" + for i in range(self.rowCount()): + item_code = QTableWidgetItem(ALPHABET[i]) + item_code.setTextAlignment(Qt.AlignCenter) + item_code.setFlags(item_code.flags() ^ Qt.ItemIsEditable) + self.setItem(i, 0, item_code) + + +class PredefinedCodesSettings(AcceptRejectDialog): + """Dialog for setup and adjustment of PredefinedCodesModule. + + Parameters + ---------- + headline : string + Input-field for headline will be prefilled with this string. + warning_word : string + Input-field for warning-word will be prefilled with this string. + categories : list + Containing the categories (strings). + """ + + def __init__(self, headline, warning_word, categories): + super().__init__() + + # Hide help-button, disable close-button and set window title and width + self.setWindowFlag(Qt.WindowContextHelpButtonHint, False) + self.setWindowFlag(Qt.WindowCloseButtonHint, False) + self.setWindowTitle("Forhåndsavtalte koder") + self.setFixedWidth(350) + + # Headline + self.label_headline = QLabel("Overskrift") + self.edit_headline = QLineEdit() + self.edit_headline.setText(headline) + + # Warning-word + self.label_warning_word = QLabel("Varslingsord") + self.edit_warning_word = QLineEdit() + self.edit_warning_word.setText(warning_word) + + # Maximum column-height + self.label_column_height = QLabel("Maksimal kolonnehøyde") + self.edit_column_height = QSpinBox() + self.edit_column_height.lineEdit().setReadOnly(True) + self.edit_column_height.setRange(50, MAXIMUM_COLUMN_HEIGHT) + self.edit_column_height.setSingleStep(50) + self.edit_column_height.setValue(DEFAULT_COLUMN_HEIGHT) + + # Category-order + self.label_category_order = QLabel( + "Kategori-rekkefølge\n(dra og slipp)" + ) + self.list_category_order = QListWidget() + # Enable drag-and-drop + self.list_category_order.setDragEnabled(True) + self.list_category_order.viewport().setAcceptDrops(True) + self.list_category_order.setDragDropMode( + QAbstractItemView.InternalMove + ) + # Remove horizontal scrollbar + self.list_category_order.setHorizontalScrollBarPolicy( + Qt.ScrollBarAlwaysOff + ) + # Add uneditable categories + for i, category in enumerate(categories): + item = QListWidgetItem(category) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self.list_category_order.insertItem(i, item) + + self.button_ok.setText("Opprett") + + # Hide cancel-button, it is only used when modifying an existing + # PredefinedCodesModule + self.button_cancel.hide() + + self.create_and_set_layout() + + def create_and_set_layout(self): + """Create layouts, add widgets and set layout.""" + # Layout for input-widgets + self.form_layout = QFormLayout() + + # Add labels and their associated input-widgets + self.form_layout.addRow(self.label_headline, self.edit_headline) + self.form_layout.addRow( + self.label_warning_word, self.edit_warning_word + ) + self.form_layout.addRow( + self.label_column_height, self.edit_column_height + ) + self.form_layout.addRow( + self.label_category_order, self.list_category_order + ) + + # Main layout + self.main_layout = QVBoxLayout() + self.main_layout.addLayout(self.form_layout) + + self.layout_content.addLayout(self.main_layout) diff --git a/soitool/modules/module_subtractorcodes.py b/soitool/modules/module_subtractorcodes.py index e56c0d4c38b1f0b72df23e65a11494ca7eb33d02..89d3f5c479e4f153489101b426450849a5a3db59 100644 --- a/soitool/modules/module_subtractorcodes.py +++ b/soitool/modules/module_subtractorcodes.py @@ -3,7 +3,11 @@ 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.module_base import ( + resize_table, + qfont_with_pixel_size, + TABLE_CELL_DEFAULT_FONT, +) from soitool.modules.code_table_base import CodeTableBase # Characters for first and second column @@ -21,7 +25,9 @@ SPACE_INTERVAL = 4 SPACE_AMOUNT = 3 # Font for subtractorcodes -CODE_FONT = QtGui.QFont("Arial", 10, QtGui.QFont.SansSerif) +CODE_FONT = qfont_with_pixel_size( + "Arial", TABLE_CELL_DEFAULT_FONT.pixelSize(), QtGui.QFont.SansSerif +) HEADLINE_TEXT = "Subtraktorkoder" diff --git a/soitool/modules/module_table.py b/soitool/modules/module_table.py index 43bef7fb8d9e3ca625877c9ae7dfae4705b3dd93..fff51d7e058351e3df4dde972e1db077481b8316 100644 --- a/soitool/modules/module_table.py +++ b/soitool/modules/module_table.py @@ -6,6 +6,7 @@ from soitool.modules.module_base import ( resize_table, get_table_size, HEADLINE_FONT, + TABLE_CELL_DEFAULT_FONT, ) START_ROWS = 2 @@ -42,6 +43,7 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): self.verticalHeader().hide() self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setFont(TABLE_CELL_DEFAULT_FONT) # If parameters are None, start as empty table. if size is None and data is None: diff --git a/soitool/new_module_dialog.py b/soitool/new_module_dialog.py index d4e38d97d2fe40e4689973ed2512cbdcd1549b19..130b08e4e20a0b8084cf6a58b9b64ab1963d21ae 100644 --- a/soitool/new_module_dialog.py +++ b/soitool/new_module_dialog.py @@ -17,6 +17,7 @@ from soitool.modules.module_authentication_board import ( from soitool.modules.module_subtractorcodes import SubtractorcodesModule from soitool.modules.module_freetext import FreeTextModule from soitool.modules.module_phonebook import PhonebookModule +from soitool.modules.module_predefined_codes import PredefinedCodesModule from soitool.accept_reject_dialog import AcceptRejectDialog @@ -28,7 +29,11 @@ MODULE_CHOICES = [ AuthenticationBoardModule, SubtractorcodesModule, FreeTextModule, +<<<<<<< soitool/new_module_dialog.py PhonebookModule, +======= + PredefinedCodesModule, +>>>>>>> soitool/new_module_dialog.py ] diff --git a/soitool/serialize_export_import_soi.py b/soitool/serialize_export_import_soi.py index 5a2033212badacf5e9d8455e83b8899d62f674f4..439704501c85865d4af1f196ae83736dbd892723 100644 --- a/soitool/serialize_export_import_soi.py +++ b/soitool/serialize_export_import_soi.py @@ -11,6 +11,7 @@ from soitool.modules.module_authentication_board import ( from soitool.modules.module_subtractorcodes import SubtractorcodesModule from soitool.modules.module_freetext import FreeTextModule from soitool.modules.module_phonebook import PhonebookModule +from soitool.modules.module_predefined_codes import PredefinedCodesModule # Valid schema for serialized SOI SERIALIZED_SOI_SCHEMA = Schema( @@ -174,7 +175,7 @@ def export_soi(soi, compressed=True): file.close() -def import_soi(file_path): +def import_soi(file_path, database): """Import compressed or uncompressed serialized SOI. Reads content of file and decompresses it for .txt-files. @@ -184,6 +185,8 @@ def import_soi(file_path): ---------- file_path : string Full path to a file containing serialized SOI. + database : soitool.database.Database + Database-instance passed to specific modules. Returns ------- @@ -201,18 +204,22 @@ def import_soi(file_path): serialized = file.read() if file_path.endswith(".txt"): - return construct_soi_from_serialized(serialized, compressed=True) + return construct_soi_from_serialized( + serialized, database, compressed=True + ) - return construct_soi_from_serialized(serialized) + return construct_soi_from_serialized(serialized, database) -def construct_soi_from_serialized(serialized, compressed=False): +def construct_soi_from_serialized(serialized, database, compressed=False): """Construct an SOI-object from a serialized SOI. Parameters ---------- serialized : string Serialized SOI. + database : soitool.database.Database + Database-instance passed to specific modules. compressed : bool, optional True if serialized SOI is compressed, by default False. @@ -237,8 +244,12 @@ def construct_soi_from_serialized(serialized, compressed=False): raise ValueError("Serialized SOI does not have correct format.") # Construct modules and attachments - modules = construct_modules_from_serialized(serialized["modules"]) - attachments = construct_modules_from_serialized(serialized["attachments"]) + modules = construct_modules_from_serialized( + serialized["modules"], database + ) + attachments = construct_modules_from_serialized( + serialized["attachments"], database + ) # Create SOI soi = SOI( @@ -262,13 +273,15 @@ def construct_soi_from_serialized(serialized, compressed=False): return soi -def construct_modules_from_serialized(serialized_modules): +def construct_modules_from_serialized(serialized_modules, database): """Instantiate modules from serialized format. Parameters ---------- serialized_modules : list Containing dicts with serialized modules or attachment-modules. + database : soitool.database.Database + Database-instance passed to specific modules. Returns ------- @@ -284,16 +297,14 @@ def construct_modules_from_serialized(serialized_modules): for module in serialized_modules: module_type = module["type"] + size = module["size"] + data = module["data"] if module_type == "TableModule": - size = module["size"] - data = module["data"] modules.append( {"widget": TableModule(size, data), "meta": module["meta"]} ) elif module_type == "AuthenticationBoardModule": - size = module["size"] - data = module["data"] modules.append( { "widget": AuthenticationBoardModule(size, data), @@ -301,8 +312,6 @@ def construct_modules_from_serialized(serialized_modules): } ) elif module_type == "SubtractorcodesModule": - size = module["size"] - data = module["data"] modules.append( { "widget": SubtractorcodesModule(size, data), @@ -310,16 +319,23 @@ def construct_modules_from_serialized(serialized_modules): } ) elif module_type == "FreeTextModule": - size = module["size"] - data = module["data"] modules.append( {"widget": FreeTextModule(size, data), "meta": module["meta"]} ) +<<<<<<< soitool/serialize_export_import_soi.py elif module_type == "PhonebookModule": size = module["size"] data = module["data"] modules.append( {"widget": PhonebookModule(size, data), "meta": module["meta"]} +======= + elif module_type == "PredefinedCodesModule": + modules.append( + { + "widget": PredefinedCodesModule(database, data), + "meta": module["meta"], + } +>>>>>>> soitool/serialize_export_import_soi.py ) else: raise TypeError( diff --git a/soitool/soi_model_view.py b/soitool/soi_model_view.py index 680d27502fdefbec63c274fcd4c4c4ae1084b7b7..9f77f8b5189de45853732dd02e77da2be003966e 100644 --- a/soitool/soi_model_view.py +++ b/soitool/soi_model_view.py @@ -26,7 +26,8 @@ class SOITableView(QTableView): Parameters ---------- database : soitool.database.Database - Is used to create a QSqlDatabase from the database-file. + Is used to create a QSqlDatabase from the database-file, + and to instantiate SOIWorkspaceWidget. tab_widget : QTabWidget Is used to open a new tab. @@ -38,8 +39,10 @@ class SOITableView(QTableView): def __init__(self, database, tab_widget): super().__init__() + self.database = database + db = QSqlDatabase.addDatabase(DBTYPE, CONNAME) - db.setDatabaseName(database.db_path) + db.setDatabaseName(self.database.db_path) self.tab_widget = tab_widget if not db.open(): @@ -91,8 +94,10 @@ class SOITableView(QTableView): 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) + soi = construct_soi_from_serialized( + compressed_soi, self.database, compressed=True + ) + tab = SOIWorkspaceWidget(self.database, soi) # Add and select tab self.tab_widget.addTab(tab, soi.title) diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py index dc1e5c27163bcb62ad48953b79d17bd717a6251e..ed76226397cf2c2f8e90d2f7ebc4cdd3b0afa321 100644 --- a/soitool/soi_workspace_widget.py +++ b/soitool/soi_workspace_widget.py @@ -18,6 +18,10 @@ from soitool.inline_editable_soi_view import InlineEditableSOIView from soitool.setup_settings import Setup from soitool.new_module_dialog import NewModuleDialog from soitool.dialog_wrappers import exec_warning_dialog +from soitool.modules.module_predefined_codes import PredefinedCodesModule + +# List of SOI-modules that require a database as parameter +DATABASE_MODULES = [PredefinedCodesModule] class SOIWorkspaceWidget(QWidget): @@ -25,14 +29,17 @@ class SOIWorkspaceWidget(QWidget): Creates a new SOI by default, but can receive an existing SOI through it's init parameter 'soi'. + The parameter 'database' is passed to instantiate specific modules. The widget is used inside tabs in our application, and contains a sidebar with a module list along with a view of the SOI. """ - def __init__(self, soi=None): + def __init__(self, database, soi=None): super().__init__() + self.database = database + if soi is None: self.soi = SOI() else: @@ -118,8 +125,14 @@ class SOIWorkspaceWidget(QWidget): ) try: + if module_widget_implementation in DATABASE_MODULES: + module = module_widget_implementation( + database=self.database + ) + else: + module = module_widget_implementation() self.soi.add_module( - module_name, module_widget_implementation(), is_attachment, + module_name, module, is_attachment, ) except ModuleNameTaken: exec_warning_dialog( diff --git a/soitool/testdata/long_codebook.json b/soitool/testdata/long_codebook.json index 1c5a0fe6bcbc8081944a64f9b9cb849abd6434a1..5423c81cf1c4ae9de2cc9032fff3970a1d516601 100644 --- a/soitool/testdata/long_codebook.json +++ b/soitool/testdata/long_codebook.json @@ -1,9 +1,64 @@ [ + { + "word": "0", + "category": "Tall", + "type": "Liten" + }, + { + "word": "1", + "category": "Tall", + "type": "Liten" + }, + { + "word": "2", + "category": "Tall", + "type": "Liten" + }, + { + "word": "3", + "category": "Tall", + "type": "Liten" + }, + { + "word": "4", + "category": "Tall", + "type": "Liten" + }, { "word": "40 mm", "category": "V\u00e5penteknisk", "type": "Stor" }, + { + "word": "5", + "category": "Tall", + "type": "Liten" + }, + { + "word": "6", + "category": "Tall", + "type": "Liten" + }, + { + "word": "7", + "category": "Tall", + "type": "Liten" + }, + { + "word": "8", + "category": "Tall", + "type": "Liten" + }, + { + "word": "9", + "category": "Tall", + "type": "Liten" + }, + { + "word": "A", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "AG3", "category": "V\u00e5penteknisk", @@ -34,6 +89,11 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "B", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "BV-206", "category": "Kj\u00f8ret\u00f8y", @@ -47,12 +107,17 @@ { "word": "Bombe", "category": "V\u00e5penteknisk", - "type": "Stor" + "type": "Liten" }, { "word": "Bro", "category": "Landemerker", - "type": "Stor" + "type": "Liten" + }, + { + "word": "C", + "category": "Bokstaver", + "type": "Liten" }, { "word": "CV 90", @@ -84,6 +149,11 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "D", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Dal", "category": "Landemerker", @@ -104,6 +174,11 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "E", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "ERYX", "category": "V\u00e5penteknisk", @@ -117,7 +192,7 @@ { "word": "Elv", "category": "Landemerker", - "type": "Stor" + "type": "Liten" }, { "word": "Er i rute", @@ -129,11 +204,26 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "F", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Ferdigstilling", "category": "Straff", "type": "Stor" }, + { + "word": "Fram", + "category": "Retninger", + "type": "Liten" + }, + { + "word": "G", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "G-notes", "category": "Testkategori", @@ -145,29 +235,24 @@ "type": "Stor" }, { - "word": "HK-416", - "category": "V\u00e5penteknisk", - "type": "Stor" - }, - { - "word": "Hadza", - "category": "Testkategori", - "type": "Stor" + "word": "H", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Heron", - "category": "Testkategori", - "type": "Stor" + "word": "HK-416", + "category": "V\u00e5penteknisk", + "type": "Liten" }, { - "word": "Hindu", - "category": "Testkategori", - "type": "Stor" + "word": "Helikopter", + "category": "St\u00f8tte", + "type": "Liten" }, { "word": "H\u00e5ndgranat", "category": "V\u00e5penteknisk", - "type": "Stor" + "type": "Liten" }, { "word": "H\u00f8yde", @@ -175,15 +260,30 @@ "type": "Stor" }, { - "word": "Jacqui", - "category": "Testkategori", - "type": "Stor" + "word": "I", + "category": "Bokstaver", + "type": "Liten" + }, + { + "word": "J", + "category": "Bokstaver", + "type": "Liten" }, { "word": "Jamnagar", "category": "Testkategori", "type": "Stor" }, + { + "word": "K", + "category": "Bokstaver", + "type": "Liten" + }, + { + "word": "K9", + "category": "St\u00f8tte", + "type": "Liten" + }, { "word": "Khronos", "category": "Testkategori", @@ -205,18 +305,13 @@ "type": "Stor" }, { - "word": "Leopard 2", - "category": "Kj\u00f8ret\u00f8y", - "type": "Stor" - }, - { - "word": "Louis Quatorze", - "category": "Testkategori", - "type": "Stor" + "word": "L", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Lucifer", - "category": "Testkategori", + "word": "Leopard 2", + "category": "Kj\u00f8ret\u00f8y", "type": "Stor" }, { @@ -224,6 +319,11 @@ "category": "V\u00e5penteknisk", "type": "Stor" }, + { + "word": "M", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "M04", "category": "Bekledning", @@ -255,19 +355,14 @@ "type": "Stor" }, { - "word": "MP-5", - "category": "V\u00e5penteknisk", + "word": "MP", + "category": "St\u00f8tte", "type": "Liten" }, { - "word": "Maeander", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Manila", - "category": "Testkategori", - "type": "Stor" + "word": "MP-5", + "category": "V\u00e5penteknisk", + "type": "Liten" }, { "word": "Meldingsblankett (blokker)", @@ -275,14 +370,14 @@ "type": "Stor" }, { - "word": "Mendoza", - "category": "Testkategori", + "word": "Mine", + "category": "V\u00e5penteknisk", "type": "Stor" }, { - "word": "Midwest", - "category": "Testkategori", - "type": "Stor" + "word": "N", + "category": "Bokstaver", + "type": "Liten" }, { "word": "Netting under over", @@ -290,19 +385,19 @@ "type": "Stor" }, { - "word": "Nikkei index", - "category": "Testkategori", - "type": "Stor" + "word": "Nord", + "category": "Retninger", + "type": "Liten" }, { - "word": "Orthodox Jew", - "category": "Testkategori", - "type": "Stor" + "word": "O", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "P-80", - "category": "V\u00e5penteknisk", - "type": "Stor" + "word": "P", + "category": "Bokstaver", + "type": "Liten" }, { "word": "PACE-batteri/BA-3090 (stk)", @@ -329,6 +424,16 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "Q", + "category": "Bokstaver", + "type": "Liten" + }, + { + "word": "R", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Regnt\u00f8y", "category": "Bekledning", @@ -350,32 +455,22 @@ "type": "Stor" }, { - "word": "Saaniches", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Sarah", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Scylla", - "category": "Testkategori", - "type": "Stor" + "word": "S", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Senate", + "word": "Saaniches", "category": "Testkategori", "type": "Stor" }, { - "word": "Sephardim", - "category": "Testkategori", - "type": "Stor" + "word": "Sanitet", + "category": "St\u00f8tte", + "type": "Liten" }, { - "word": "Serpentes", + "word": "Sarah", "category": "Testkategori", "type": "Stor" }, @@ -390,44 +485,39 @@ "type": "Stor" }, { - "word": "Silicon Wadi", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Siralun", - "category": "Testkategori", + "word": "Stans, -e, -et", + "category": "Uttrykk/tiltak/oppdrag", "type": "Stor" }, { - "word": "Somerset Island", - "category": "Testkategori", - "type": "Stor" + "word": "Sti", + "category": "Landemerker", + "type": "Liten" }, { - "word": "Squalicum's", + "word": "Surinamese", "category": "Testkategori", "type": "Stor" }, { - "word": "Stans, -e, -et", - "category": "Uttrykk/tiltak/oppdrag", - "type": "Stor" + "word": "S\u00f8r", + "category": "Retninger", + "type": "Liten" }, { - "word": "Sti", - "category": "Landemerker", - "type": "Stor" + "word": "T", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "Surinamese", - "category": "Testkategori", - "type": "Stor" + "word": "Tilbake", + "category": "Retninger", + "type": "Liten" }, { - "word": "Tsvetaeva", - "category": "Testkategori", - "type": "Stor" + "word": "U", + "category": "Bokstaver", + "type": "Liten" }, { "word": "U-235", @@ -444,6 +534,11 @@ "category": "Personlig utrustning", "type": "Stor" }, + { + "word": "V", + "category": "Bokstaver", + "type": "Liten" + }, { "word": "Vann (l)", "category": "Etterforsyninger", @@ -460,94 +555,29 @@ "type": "Stor" }, { - "word": "Vest plate", - "category": "Personnlig utrustning", - "type": "Stor" - }, - { - "word": "Vicar of Christ", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Visayan", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "Winterfest", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "acanthocephalan", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "acceptress", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "accountants", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "advertisers", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "aestivates", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "aetiologies", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "after-damp", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "after-dinner", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "alalia", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "alanyl", - "category": "Testkategori", - "type": "Stor" + "word": "Vest", + "category": "Retninger", + "type": "Liten" }, { - "word": "alcoholometry", - "category": "Testkategori", - "type": "Stor" + "word": "W", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "alfresco", - "category": "Testkategori", - "type": "Stor" + "word": "X", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "algolagnic", - "category": "Testkategori", - "type": "Stor" + "word": "Y", + "category": "Bokstaver", + "type": "Liten" }, { - "word": "allured", - "category": "Testkategori", - "type": "Stor" + "word": "Z", + "category": "Bokstaver", + "type": "Liten" }, { "word": "angleworm", @@ -574,26 +604,6 @@ "category": "Testkategori", "type": "Stor" }, - { - "word": "antitragicus", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "apama", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "aphoristic", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "archpriest", - "category": "Testkategori", - "type": "Stor" - }, { "word": "areola", "category": "Testkategori", @@ -609,21 +619,6 @@ "category": "Testkategori", "type": "Stor" }, - { - "word": "assubjugated", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "athanasy", - "category": "Testkategori", - "type": "Stor" - }, - { - "word": "atony", - "category": "Testkategori", - "type": "Stor" - }, { "word": "attapulgite", "category": "Testkategori", @@ -3374,9 +3369,14 @@ "category": "Testkategori", "type": "Stor" }, + { + "word": "\u00d8st", + "category": "Retninger", + "type": "Liten" + }, { "word": "\u00e6nigmata", "category": "Testkategori", "type": "Stor" } -] +] \ No newline at end of file diff --git a/test/test_database.py b/test/test_database.py index 08dc93f079ba024302b4289c6bdff9389451161a..91378d02897dec218179ec99ef0bb523bddeaa9d 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -179,6 +179,48 @@ class DatabaseTest(unittest.TestCase): categories_db = self.database.get_categories() self.assertEqual(categories_file, categories_db) + def test_get_random_category_word(self): + """Assert function get_random_category_word works as expected.""" + # Read all category-words + stmt = "SELECT Word from CategoryWords" + queried = self.database.conn.execute(stmt).fetchall() + category_words = [row["Word"] for row in queried] + + # Generate 5 random category-words and assert that they are + # from table CategoryWords + generated_category_words = [] + for _ in range(5): + category_word = self.database.get_random_category_word() + self.assertTrue(category_word in category_words) + generated_category_words.append(category_word) + + # Assert that all words are not equal + self.assertTrue(len(set(generated_category_words)) > 1) + + def test_get_categories_from_codebook(self): + """Assert function get_categories_from_codebook works as expected.""" + # Get categories in full and small codebook + stmt = "SELECT Category FROM Codebook GROUP BY Category" + queried = self.database.conn.execute(stmt).fetchall() + expected_categories_full = [row["Category"] for row in queried] + + stmt = ( + "SELECT Category FROM Codebook " + "WHERE Type='Liten' GROUP BY Category" + ) + queried = self.database.conn.execute(stmt).fetchall() + expected_categories_small = [row["Category"] for row in queried] + + # Get categories in full and small codebook through function + actual_categories_full = self.database.get_categories_from_codebook() + actual_categories_small = self.database.get_categories_from_codebook( + small=True + ) + + # Assert actual categories matches expected categories + self.assertEqual(actual_categories_full, expected_categories_full) + self.assertEqual(actual_categories_small, expected_categories_small) + def test_get_codebook(self): """Assert function get_codebook returns full codebook.""" # Load full codebook @@ -226,6 +268,37 @@ class DatabaseTest(unittest.TestCase): self.assertRegex(actual[i]["code"], "[A-Z]{" + str(code_len) + "}") self.assertEqual(entry["type"], actual[i]["type"]) + def test_get_codebook_expressions_in_category(self): + """Test function get_codebook_expressions_in_category.""" + # Get a category in full and small codebook + category_full = self.database.get_categories_from_codebook()[0] + category_small = self.database.get_categories_from_codebook( + small=True + )[0] + + # Get expressions from category + stmt = "SELECT Word FROM Codebook WHERE Category=?" + queried = self.database.conn.execute(stmt, (category_full,)).fetchall() + expected_expressions_full = [row["Word"] for row in queried] + + stmt += " AND Type='Liten'" + queried = self.database.conn.execute( + stmt, (category_small,) + ).fetchall() + expected_expressions_small = [row["Word"] for row in queried] + + # Get expressions from function + actual_expr_full = self.database.get_codebook_expressions_in_category( + category_full + ) + actual_expr_small = self.database.get_codebook_expressions_in_category( + category_small, small=True + ) + + # Assert actual expressions matches expected expressions + self.assertEqual(actual_expr_full, expected_expressions_full) + self.assertEqual(actual_expr_small, expected_expressions_small) + def test_update_codebook(self): """Test that the codes get updated.""" # Get entries before and after update @@ -281,7 +354,7 @@ class DatabaseTest(unittest.TestCase): ) else: - print("ERROR: Database is not 675 entries long, cant run test") + print("ERROR: Database is not 676 entries long, cant run test") self.assertTrue(False) # pylint: disable=W1503 def test_seconds_to_next_update(self): diff --git a/test/test_module_predefined_codes.py b/test/test_module_predefined_codes.py new file mode 100644 index 0000000000000000000000000000000000000000..d18fa647fb13d5e94cdbe809ec8ceaccb2dda837 --- /dev/null +++ b/test/test_module_predefined_codes.py @@ -0,0 +1,155 @@ +"""Tests for PredefinedCodesModule.""" +import unittest +import string +from test.test_database import TESTDBPATH +from PySide2.QtCore import Qt, QTimer +from PySide2 import QtGui +from PySide2.QtTest import QTest +from PySide2.QtWidgets import QApplication +from soitool.database import Database +from soitool.modules.module_predefined_codes import ( + PredefinedCodesModule, + HEADLINE, + DEFAULT_COLUMN_HEIGHT, +) + +ALPHABET = string.ascii_uppercase + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + + +class TestDefaultPredefinedCodesModule(unittest.TestCase): + """TestCase for PredefinedCodesModule.""" + + def setUp(self): + """Create database and PredefinedCodesModule.""" + self.database = Database(db_path=TESTDBPATH) + + # Get categories in small codebook + self.categories = self.database.get_categories_from_codebook( + small=True + ) + + def press_enter_on_active_dialog(): + active_widget = app.activeModalWidget() + QTest.keyClick(active_widget, Qt.Key_Enter) + + QTimer.singleShot(0, press_enter_on_active_dialog) + self.module = PredefinedCodesModule(self.database) + + def test_default_dialog(self): + """Test default values in dialog.""" + + def test_and_close_dialog(): + dialog = app.activeModalWidget() + + # Assert prefilled headline is correct + self.assertEqual(dialog.edit_headline.text(), HEADLINE) + + # Assert prefilled warning-word is not empty + warning_word = dialog.edit_warning_word.text() + self.assertTrue(len(warning_word) > 0) + + # Assert prefilled maximum column-height is correct + maximum_column_height_value = dialog.edit_column_height.value() + self.assertTrue( + maximum_column_height_value >= DEFAULT_COLUMN_HEIGHT + ) + + # Assert categories are correct + for i, category in enumerate(self.categories): + dialog_category = dialog.list_category_order.item(i).text() + self.assertEqual(category, dialog_category) + + # Close dialog + dialog.accept() + + # Use shortcut to open dialog + QTimer.singleShot(0, test_and_close_dialog) + QTest.keyClicks(self.module, "R", Qt.ControlModifier) + + def test_default_module(self): + """Test default module.""" + # Assert headline is correct + self.assertEqual(self.module.headline.text(), HEADLINE) + + # Assert warning-word is not empty + self.assertTrue(len(self.module.warning_word.text()) > 0) + + # Assert module has one table per category + self.assertEqual(len(self.module.tables), len(self.categories)) + + # Assert each table has the correct headline and contents + for i, table in enumerate(self.module.tables): + expected_headline = " " + ALPHABET[i] + " " + self.categories[i] + actual_headline = table.item(0, 0).text() + self.assertEqual(actual_headline, expected_headline) + + expressions = self.database.get_codebook_expressions_in_category( + self.categories[i], small=True + ) + # Assert codes are correct and read expressions in table + actual_expressions = [] + for j in range(1, table.rowCount()): + expected_code = ALPHABET[j - 1] + actual_code = table.item(j, 0).text() + self.assertEqual(actual_code, expected_code) + + actual_expressions.append(table.item(j, 1).text()) + # Assert expressions are correct + for actual_expression in actual_expressions: + self.assertTrue(actual_expression in expressions) + + +class TestDefaultPredefinedCodesModuleFromData(unittest.TestCase): + """TestCase for initializing PredefinedCodesModule from data.""" + + def test_create_from_data(self): + """Test creating PredefinedCodesModule from data.""" + test_data = { + "headline": "HeadlineText", + "warning_word": "WarningWordText", + "maximum_column_height": 500, + "categories": ["CategoryOne", "CategoryTwo"], + "tables": [ + { + "table_headline": "TableHeadlineOne", + "expressions": ["expressionOne", "expressionTwo"], + }, + { + "table_headline": "TableHeadlineTwo", + "expressions": ["expressionOne", "expressionTwo"], + }, + ], + } + # Create PredefinedCodesModule from data + module = PredefinedCodesModule(Database(db_path=TESTDBPATH), test_data) + + # Assert headline is correct + self.assertEqual(module.headline.text(), "HeadlineText") + + # Assert warning-word is correct + self.assertEqual(module.warning_word.text(), "WarningWordText") + + # Assert module has two tables + self.assertEqual(len(module.tables), 2) + + # Assert each table has the correct headline and expressions + table_one = module.tables[0] + table_two = module.tables[1] + + # Assert correct headlines + self.assertEqual(table_one.item(0, 0).text(), "TableHeadlineOne") + self.assertEqual(table_two.item(0, 0).text(), "TableHeadlineTwo") + + # Assert correct codes and expressions + self.assertEqual(table_one.item(1, 1).text(), "expressionOne") + self.assertEqual(table_one.item(2, 1).text(), "expressionTwo") + self.assertEqual(table_two.item(1, 1).text(), "expressionOne") + self.assertEqual(table_two.item(2, 1).text(), "expressionTwo") + for i in range(1, 3): + self.assertEqual(table_one.item(i, 0).text(), ALPHABET[i - 1]) + self.assertEqual(table_two.item(i, 0).text(), ALPHABET[i - 1]) diff --git a/test/test_resolution_smoke_test.py b/test/test_resolution_smoke_test.py new file mode 100644 index 0000000000000000000000000000000000000000..3bc6fcbd0662307bc26cbfb840408c2339ced370 --- /dev/null +++ b/test/test_resolution_smoke_test.py @@ -0,0 +1,152 @@ +"""Test module that looks for differences in SOI modules across resolutions. + +This was written as a regression test for #125. Ideally it should also test the +header of the SOI, but this is much more complicated to test for.. + +## Important + +This test can not test across different resolutions by itself, this is up to +the user. Our pipeline will test across a couple of different resolutions. +""" +import unittest +from PySide2.QtWidgets import QApplication +from PySide2 import QtGui +from PySide2.QtCore import QTimer, Qt, QSysInfo +from PySide2.QtTest import QTest +from PySide2.QtGui import QGuiApplication +from soitool.soi import SOI +from soitool.modules.module_authentication_board import ( + AuthenticationBoardModule, +) +from soitool.modules.module_subtractorcodes import SubtractorcodesModule +from soitool.modules.module_predefined_codes import PredefinedCodesModule +from soitool.new_module_dialog import MODULE_CHOICES +from soitool.soi_workspace_widget import DATABASE_MODULES +from soitool.database import Database + +# The error being ignored here is pylint telling us that 'test' is a standard +# module, so the import should be placed further up. In our case we have an +# actual custom module called 'test', so pylint is confused. +# pylint: disable=C0411 +from test.test_database import TESTDBPATH + + +if isinstance(QtGui.qApp, type(None)): + app = QApplication([]) +else: + app = QtGui.qApp + +# Modules with a popup as part of their __init__ +POPUP_MODULES = [ + AuthenticationBoardModule, + SubtractorcodesModule, + PredefinedCodesModule, +] + + +def screen_information(): + """Get string with information about the screen. + + Returns + ------- + str + String with screen information. + """ + screen_string = "" + screen = QGuiApplication.primaryScreen() + screen_string += "screen.size() -> " + str(screen.size()) + "\n" + screen_string += ( + "screen.physicalDotsPerInchX() -> " + + str(screen.physicalDotsPerInchX()) + + "\n" + ) + screen_string += ( + "screen.physicalDotsPerInchY() -> " + + str(screen.physicalDotsPerInchY()) + + "\n" + ) + screen_string += ( + "screen.logicalDotsPerInchX() -> " + + str(screen.logicalDotsPerInchX()) + + "\n" + ) + screen_string += ( + "screen.logicalDotsPerInchY() -> " + + str(screen.logicalDotsPerInchY()) + + "\n" + ) + screen_string += ( + "screen.devicePixelRatio() -> " + str(screen.devicePixelRatio()) + "\n" + ) + return screen_string + + +class TestModulesAcrossResolutions(unittest.TestCase): + """TestCase for modules across resolutions.""" + + @unittest.skipUnless( + QSysInfo.productType() == "windows", + "Test currently only able to target Windows", + ) + def test_add_all_modules(self): + """Add all modules, reorganize, and assert result. + + Expected result was gotten by simply running reorganize and noting down + the results. We should get the same result across different + resolutions. + + Modules added to SOI must be the same every time the program is ran. + + Test only works on Windows, as the expected result is based on the + Windows look-and-feel. + + NOTE: This test needs to be updated when a new module is added, + deleted, or changed. It will fail until it is updated. + """ + expected_result = [ + {"x": 0, "y": 0, "page": 1, "name": "PredefinedCodesModule"}, + {"x": 654, "y": 0, "page": 1, "name": "AuthenticationBoardModule"}, + {"x": 1061.5, "y": 0, "page": 1, "name": "SubtractorcodesModule"}, + {"x": 1287.0, "y": 0, "page": 1, "name": "FreeTextModule"}, + {"x": 1387.0, "y": 0, "page": 1, "name": "TableModule"}, + ] + + # For use with modules that require a database + database = Database(db_path=TESTDBPATH) + + def press_enter(): + active_widget = app.activeModalWidget() + + # AuthenticationBoardModule needs special treatment because the + # title can contain random info + if isinstance(active_widget, AuthenticationBoardModule): + # triple click to select existing text for overwrite + QTest.mouseDClick(active_widget.edit_headline, Qt.LeftButton) + QTest.mouseClick(active_widget.edit_headline, Qt.LeftButton) + # need to overwrite text because title otherwise contains + # unpredictable text + QTest.keyClicks(active_widget.edit_headline, "TestTitle") + QTest.keyClick(active_widget, Qt.Key_Enter) + + soi = SOI() + + for module in MODULE_CHOICES: + # If we're adding one of the modules with a popup as part of it's + # constructor we need to singleShot pressing enter to close it + if module in POPUP_MODULES: + QTimer.singleShot(0, press_enter) + + if module in DATABASE_MODULES: + soi.add_module(module.__name__, module(database=database)) + else: + soi.add_module(module.__name__, module()) + + soi.reorganize() + + self.assertEqual( + expected_result, + [module["meta"] for module in soi.modules], + "All modules added to SOI and calling reorganize should result in " + "expected 'meta' information. Screen is: \n" + + screen_information(), + ) diff --git a/test/test_serialize_export_import.py b/test/test_serialize_export_import.py index 143c31635110ed0a69270a28e3124aba99960d80..c55f3b565db59ed040833ef8377c42a300d8b237 100644 --- a/test/test_serialize_export_import.py +++ b/test/test_serialize_export_import.py @@ -4,9 +4,11 @@ import unittest import json from pathlib import Path from datetime import datetime +from test.test_database import TESTDBPATH from PySide2.QtWidgets import QApplication from soitool.soi import SOI from soitool.modules.module_table import TableModule +from soitool.database import Database from soitool.serialize_export_import_soi import ( serialize_soi, export_soi, @@ -116,6 +118,7 @@ class SerializeTest(unittest.TestCase): self.file_path_compressed = os.path.join( SOITOOL_ROOT_PATH, file_name_compressed ) + self.database = Database(db_path=TESTDBPATH) def test_serialize_soi(self): """Serialize SOI and check its validity against schema.""" @@ -149,7 +152,7 @@ class SerializeTest(unittest.TestCase): # Assert import throws error with self.assertRaises(ValueError) as error: - import_soi(invalid_soi_file_path) + import_soi(invalid_soi_file_path, self.database) self.assertEqual( str(error.exception), "Serialized SOI does not have correct format.", @@ -160,8 +163,10 @@ class SerializeTest(unittest.TestCase): def test_import_soi(self): """Import serialized SOI's, check content and delete SOI-files.""" # Import uncompressed and compressed SOI's - soi_uncompressed = import_soi(self.file_path_uncompressed) - soi_compressed = import_soi(self.file_path_compressed) + soi_uncompressed = import_soi( + self.file_path_uncompressed, self.database + ) + soi_compressed = import_soi(self.file_path_compressed, self.database) # Assert SOI content is correct self.check_soi_content(soi_uncompressed)