diff --git a/.gitignore b/.gitignore index 673ab0bc892bc30d1c485353c1b600f7d565bbd8..5d90984aa8cd0607c9d1192fd1166eeaea1f013a 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ __pycache__/ # Generated codebook PDF-files Kodebok_*.pdf + +# Generated SOI-files +SOI_*_*_*_*.txt +SOI_*_*_*_*.json \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bbcbfcad0b3ddf945af0e2206ca36faa343937fc..fd26951f2e03dcd16cb2f2ec374ecf7c52146742 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/soitool/codebook_to_pdf.py b/soitool/codebook_to_pdf.py index 991b105c9eb147f4f2f6b5d35871be02b73a1a9d..90130e44c017e6d0bdfa8385824ff19bba3f1e09 100644 --- a/soitool/codebook_to_pdf.py +++ b/soitool/codebook_to_pdf.py @@ -125,12 +125,12 @@ def generate_filename(small=False): 'Kodebok_YYYY_mm_dd.pdf' or 'Kodebok_liten_YYYY_mm_dd.pdf' """ # Get date on format YYYY_mm_dd - today = datetime.now().strftime("%Y_%m_%d") + date = datetime.now().strftime("%Y_%m_%d") if small: - return f"Kodebok_liten_{today}.pdf" + return f"Kodebok_liten_{date}.pdf" - return f"Kodebok_{today}.pdf" + return f"Kodebok_{date}.pdf" def get_codebook_data(database, small=False): diff --git a/soitool/main_window.py b/soitool/main_window.py index d0329b27be10a84dd2f97d47eabe727e9a784199..99f0bf70f5e75042e0b94dd442d39bf5cffb19f7 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -11,15 +11,21 @@ from PySide2.QtWidgets import ( QMainWindow, QApplication, QAction, + QFileDialog, ) from PySide2.QtGui import QIcon from PySide2.QtCore import QTimer from soitool.soi_workspace_widget import SOIWorkspaceWidget from soitool.codebook_to_pdf import generate_codebook_pdf +from soitool.dialog_wrappers import exec_info_dialog from soitool.codebook_widget import CodebookWidget from soitool.codebook_model_view import CodeBookTableModel from soitool.database import Database, DBPATH from soitool.help_actions import Hotkeys, EasyUse +from soitool.serialize_export_import_soi import ( + export_soi, + import_soi, +) class ModuleType(Enum): @@ -54,14 +60,36 @@ class MainWindow(QMainWindow): self.timer.timeout.connect(partial(self.regenerate_codes, auto=True)) self.timer.start() - # flytt ut til egen funksjon, for setup av menubar + # Add SOIWorkspaceWidget-tab + self.tabs = QTabWidget() + self.tabs.setTabsClosable(True) + + tab = SOIWorkspaceWidget() + self.tabs.addTab(tab, "MainTab") + self.setCentralWidget(self.tabs) + + self.setup_menubar() + + # Add HV logo + filename = "media/HVlogo.PNG" + dirname = os.path.dirname(__file__) + filepath = os.path.join(dirname, filename) + self.setWindowIcon(QIcon(filepath)) + + self.action_hotkeys.triggered.connect(self.open_hotkeys) + self.action_easy_use.triggered.connect(self.open_easy_use) + + # pylint: disable=R0914, R0915 + # Ignoring "Too many local variables" and "Too many statements" + def setup_menubar(self): + """Set up menubar with submenus and actions.""" menu = self.menuBar() file_menu = menu.addMenu("SOI") codebook_menu = menu.addMenu("Kodebok") help_menu = menu.addMenu("Hjelp") # New SOI - new_soi = QAction("Ny SOI", self) + new_soi = QAction("Ny", self) new_soi.setShortcut("Ctrl+n") new_soi.setStatusTip("Opprett en ny SOI") file_menu.addAction(new_soi) @@ -71,6 +99,7 @@ class MainWindow(QMainWindow): open_file = QAction("Åpne", self) open_file.setShortcut("Ctrl+o") open_file.setStatusTip("Åpne en SOI fra fil") + open_file.triggered.connect(self.import_soi) file_menu.addAction(open_file) # Open file from DB @@ -91,11 +120,24 @@ class MainWindow(QMainWindow): save_soi.setStatusTip("Lagre SOI i databasen") file_menu.addAction(save_soi) - # Export - export = QAction("Eksporter", self) - export.setShortcut("Ctrl+e") - export.setStatusTip("Eksporter SOI til annet filformat") - file_menu.addAction(export) + # Export SOI + export_serialized_soi = file_menu.addMenu("Eksporter") + # Compressed SOI + export_compressed = QAction("Komprimert", self) + export_compressed.setShortcut("Ctrl+e") + export_compressed.setStatusTip("Eksporter komprimert SOI") + export_compressed.triggered.connect( + partial(self.try_export_soi, compressed=True) + ) + # Uncompressed SOI + export_uncompressed = QAction("Ukomprimert", self) + export_uncompressed.setStatusTip("Eksporter ukomprimert SOI") + export_uncompressed.triggered.connect( + partial(self.try_export_soi, compressed=False) + ) + export_serialized_soi.addAction(export_compressed) + export_serialized_soi.addAction(export_uncompressed) + file_menu.addMenu(export_serialized_soi) # View/edit Codebook codebook = QAction("Se/rediger kodebok", self) @@ -138,23 +180,6 @@ class MainWindow(QMainWindow): self.action_easy_use.setStatusTip("Vis enkel bruk av programvaren") help_menu.addAction(self.action_easy_use) - # Add SOIWorkspaceWidget-tab - self.tabs = QTabWidget() - self.tabs.setTabsClosable(True) - - tab = SOIWorkspaceWidget() - self.tabs.addTab(tab, "MainTab") - self.setCentralWidget(self.tabs) - - # Add HV logo - filename = "media/HVlogo.PNG" - dirname = os.path.dirname(__file__) - filepath = os.path.join(dirname, filename) - self.setWindowIcon(QIcon(filepath)) - - self.action_hotkeys.triggered.connect(self.open_hotkeys) - self.action_easy_use.triggered.connect(self.open_easy_use) - def open_codebook_tab(self): """Open tab containing CodeBookTableView and CodebookRowAdder. @@ -172,6 +197,53 @@ class MainWindow(QMainWindow): self.tabs.addTab(tab, "Kodebok") self.tabs.setCurrentWidget(tab) + def try_export_soi(self, compressed=True): + """Export the SOI in the current tab. + + Feedback is given through a dialog if the current tab does not contain + an SOI (tab is not a SOIWorkspaceWidget). + + Parameters + ---------- + compressed : bool, optional + Serialized SOI is compressed if True (default) + """ + tab_widget = self.tabs.currentWidget() + + # If tab contains an SOI + if isinstance(tab_widget, SOIWorkspaceWidget): + export_soi(tab_widget.soi, compressed) + else: + exec_info_dialog( + "Valgt tab er ingen SOI-tab", + "Den valgte taben inneholder ingen SOI.\n" + "For å eksportere en SOI må riktig tab velges.", + ) + + def import_soi(self): + """Import serialized SOI. + + Launches a QFileDialog with a name-filter, where .txt and .json are + accepted file extensions. + A SOIWorkspaceWidget containing the SOI-object is created and opened + in a new tab, which is selected. + """ + # Get file-path from dialog + file_path = QFileDialog().getOpenFileName( + self, + "Åpne SOI", + os.getcwd(), + "Text/JSON-filer (SOI_*.txt SOI_*.json)", + )[0] + + if len(file_path) > 0: + soi = import_soi(file_path) + + # Create and select tab + tab = SOIWorkspaceWidget(soi) + self.tabs.addTab(tab, soi.title) + self.tabs.setCurrentWidget(tab) + def regenerate_codes(self, auto=False): """Regenerate codebook-codes and update codebook-tab if open. diff --git a/soitool/module_list.py b/soitool/module_list.py index 978d7b7dac5117c70bc4a0f823f046370d7a6ad3..9c4b9ba9795ce4dca060f7b839e5236cbb7b6a75 100644 --- a/soitool/module_list.py +++ b/soitool/module_list.py @@ -7,7 +7,7 @@ from PySide2.QtWidgets import ( ) from soitool.soi import ModuleType from soitool.dialog_wrappers import exec_warning_dialog -import soitool +from soitool.soi import SOI class ModuleList(QListWidget): @@ -21,33 +21,30 @@ class ModuleList(QListWidget): ---------- module_type : int Determines whether names are from modules or attachment-modules. - Is used with the Enum 'ModuleType' to know which list in parent - to update when changes are made. - parent : soitool.SOIWorkspaceWidget - Reference to parent. + Is used with the Enum 'ModuleType' to know which SOI-list (modules or + attachments) to update when changes are made. + soi : soitool.soi.SOI + Reference to SOI-instance. """ - def __init__(self, module_type, parent): + def __init__(self, module_type, soi): super().__init__() - # full import path below to avoid circular dependency - if not isinstance( - parent, soitool.soi_workspace_widget.SOIWorkspaceWidget - ): + if not isinstance(soi, SOI): raise RuntimeError( "Only soitool.SOIWorkspaceWidget is " "acceptable type for parent-variable " "in class Module_list." ) self.type = module_type - self.parent = parent + self.soi = soi self.original_element_name = None self.original_element_index = None self.setup_list() self.fill_list() - self.parent.soi.add_reorganization_listener(self.update_list_order) + self.soi.add_reorganization_listener(self.update_list_order) def setup_list(self): """Prepare list. @@ -66,13 +63,11 @@ class ModuleList(QListWidget): """Fill list with elements in order defined in SOI.""" # Get names of modules/attachments: if ModuleType(self.type) == ModuleType.MAIN_MODULE: - names = [ - module["meta"]["name"] for module in self.parent.soi.modules - ] + names = [module["meta"]["name"] for module in self.soi.modules] elif ModuleType(self.type) == ModuleType.ATTACHMENT_MODULE: names = [ attachment["meta"]["name"] - for attachment in self.parent.soi.attachments + for attachment in self.soi.attachments ] for i, name in enumerate(names): @@ -102,8 +97,10 @@ class ModuleList(QListWidget): Is sent to super(). """ super().currentChanged(current, previous) - self.original_element_name = self.item(current.row()).text() - self.original_element_index = current.row() + # If an item is selected + if current.row() != -1: + self.original_element_name = self.item(current.row()).text() + self.original_element_index = current.row() def dataChanged(self, index1, index2, roles): """Check for duplicate name and notify parent when an element changes. @@ -135,9 +132,9 @@ class ModuleList(QListWidget): name = self.item(index).text() if ModuleType(self.type) == ModuleType.MAIN_MODULE: - self.parent.soi.modules[index]["meta"]["name"] = name + self.soi.modules[index]["meta"]["name"] = name elif ModuleType(self.type) == ModuleType.ATTACHMENT_MODULE: - self.parent.soi.attachments[index]["meta"]["name"] = name + self.soi.attachments[index]["meta"]["name"] = name super().dataChanged(index1, index2, roles) @@ -154,7 +151,7 @@ class ModuleList(QListWidget): event : QDropEvent Is sent to super(). """ - if self.parent.soi.algorithm_sort != "none": + if self.soi.algorithm_sort != "none": exec_warning_dialog( text="SOI er ikke innstilt for automatisk plassering av " "moduler!", @@ -166,7 +163,6 @@ class ModuleList(QListWidget): ) else: - super().dropEvent(event) # Get origin and destination index of module/attachment @@ -176,11 +172,11 @@ class ModuleList(QListWidget): # Update module/attachment priority (order in list): if ModuleType(self.type) == ModuleType.MAIN_MODULE: moving_module = self.parent.soi.modules.pop(origin) - self.parent.soi.modules.insert(destination, moving_module) + self.soi.modules.insert(destination, moving_module) elif ModuleType(self.type) == ModuleType.ATTACHMENT_MODULE: moving_module = self.parent.soi.attachments.pop(origin) - self.parent.soi.attachments.insert(destination, moving_module) + self.soi.attachments.insert(destination, moving_module) - self.parent.soi.reorganize() + self.soi.reorganize() self.original_element_index = self.currentRow() diff --git a/soitool/modules/module_base.py b/soitool/modules/module_base.py index 82b78aa26966556f35c77ebfb0d3fd49f82bf37c..f252d1e7bdf8a95536e69700659665809a151b27 100644 --- a/soitool/modules/module_base.py +++ b/soitool/modules/module_base.py @@ -5,6 +5,12 @@ from abc import ABC class ModuleBase(ABC): """Interface for SOI-modules.""" + type = None + + def __init__(self): + if self.type is None: + raise NotImplementedError + def get_size(self): """Abstract method, should be implemented by derived class.""" raise NotImplementedError @@ -17,6 +23,10 @@ class ModuleBase(ABC): """Abstract method, should be implemented by derived class.""" raise NotImplementedError + def get_data(self): + """Abstract method, should be implemented by derived class.""" + raise NotImplementedError + @staticmethod def get_user_friendly_name(): """Abstract method, should be implemented by derived class.""" diff --git a/soitool/modules/module_table.py b/soitool/modules/module_table.py index 0a0389fa4df3006c16ccb0b5fdb291226701b79f..a568ec91771f01a8011ec2ffd48a47e7a9b7354a 100644 --- a/soitool/modules/module_table.py +++ b/soitool/modules/module_table.py @@ -9,8 +9,8 @@ HEADER_FONT.setFamily("Arial") HEADER_FONT.setPointSize(12) HEADER_FONT.setWeight(100) -START_COLUMNS = 2 START_ROWS = 2 +START_COLUMNS = 2 class Meta(type(ModuleBase), type(QTableWidget)): @@ -26,42 +26,56 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): 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. - """ - - def set_pos(self, pos): - """Set position of widget. - Parameters - ---------- - pos : QPoint - Position (x, y). - """ - self.move(pos) + 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. + """ - def __init__(self): + def __init__(self, size=None, data=None): + self.type = "TableModule" QTableWidget.__init__(self) - super(QTableWidget) + ModuleBase.__init__(self) - # Remove headers + # Remove headers and scrollbars self.horizontalHeader().hide() self.verticalHeader().hide() + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - # Set number of columns and rows - self.setColumnCount(START_COLUMNS) - self.setRowCount(START_ROWS) + # If parameters are None, start as empty table. + if size is None and data is None: + # Set number of columns and rows + self.setColumnCount(START_COLUMNS) + self.setRowCount(START_ROWS) - # Resize width and height of columns and rows, and 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 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) - # Remove scrollbars - self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + # Set header-items + for i in range(self.columnCount()): + self.set_header_item(i, "") + else: + self.setColumnCount(len(data[0])) + self.setRowCount(len(data)) - # Set headers - for i in range(self.columnCount()): - self.set_header_item(i, "") + # Set header-items + for i in range(self.columnCount()): + self.set_header_item(i, data[0][i]) + + # Set cell-items + for i in range(1, self.rowCount()): + for j in range(self.columnCount()): + item = QTableWidgetItem(data[i][j]) + self.setItem(i, j, item) + + self.resizeColumnsToContents() + self.resizeRowsToContents() + self.setFixedWidth(size["width"]) + self.setFixedHeight(size["height"]) self.cellChanged.connect(self.resize) @@ -180,9 +194,40 @@ class TableModule(ModuleBase, QTableWidget, metaclass=Meta): 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.""" + def get_data(self): + """Return list containing module data. + + Returns + ------- + List (2D) + list[x][y] represents value of row x, column y. + """ + content = [] + for i in range(self.rowCount()): + row = [] + for j in range(self.columnCount()): + item = self.item(i, j) + if item is not None: + row.append(item.text()) + else: + row.append("") + content.append(row) + + return content + @staticmethod def get_user_friendly_name(): """Get user-friendly name of module.""" diff --git a/soitool/serialize_export_import_soi.py b/soitool/serialize_export_import_soi.py new file mode 100644 index 0000000000000000000000000000000000000000..eb5dd8d35bf0df367255ce91cc392b2d5aae1c4f --- /dev/null +++ b/soitool/serialize_export_import_soi.py @@ -0,0 +1,263 @@ +"""Includes functionality for serializing, exporting and importing SOI.""" +import json +from datetime import datetime +from schema import Schema, And, Or +from soitool.soi import SOI +from soitool.compressor import compress, decompress +from soitool.modules.module_table import TableModule + +# Valid schema for serialized SOI +SERIALIZED_SOI_SCHEMA = Schema( + { + "title": And(str, len), + "description": str, + "version": And(str, len), + "date": Or(str, None), + "valid": {"from_date": Or(str, None), "to_date": Or(str, None)}, + "icon": Or(str, None), + "classification": And( + str, + len, + Or( + "UGRADERT", + "BEGRENSET", + "KONFIDENSIELT", + "HEMMELIG", + "STRENGT HEMMELIG", + ), + ), + "orientation": And(str, len, Or("portrait", "landscape")), + "placement_strategy": And(str, len, Or("manual", "auto")), + "algorithm_bin": And(str, len, Or("BFF", "BBF")), + "algorithm_pack": And( + str, len, Or("MaxRectsBl", "SkylineBl", "GuillotineBssfSas") + ), + "algorithm_sort": And(str, len, Or("none", "area", "width", "height")), + "modules": [ + { + "type": And(str, len), + "data": object, + "size": { + "width": And(Or(int, float), lambda w: w > 0), + "height": And(Or(int, float), lambda h: h > 0), + }, + "meta": { + "x": And(Or(int, float), lambda x: x >= 0), + "y": And(Or(int, float), lambda y: y >= 0), + "page": And(int, lambda page: page >= 0), + "name": And(str, len), + }, + } + ], + "attachments": [ + { + "type": And(str, len), + "data": object, + "size": { + "width": And(Or(int, float), lambda w: w > 0), + "height": And(Or(int, float), lambda h: h > 0), + }, + "meta": { + "x": And(Or(int, float), lambda x: x >= 0), + "y": And(Or(int, float), lambda y: y >= 0), + "page": And(int, lambda page: page >= 0), + "name": And(str, len), + }, + } + ], + } +) + + +def serialize_soi(soi): + """Serialize SOI to JSON-string. + + Parameters + ---------- + soi : soitool.soi.SOI + SOI to serialize. + + Returns + ------- + String + JSON-string containing all SOI-information. + + Raises + ------ + TypeError + Raises error if parameter 'soi' is not an SOI. + """ + # If parameter 'soi' is not an SOI + if not isinstance(soi, SOI): + raise TypeError( + "Invalid type for parameter 'soi': " + "'{}'.".format(soi) + ) + + # Create dict with relevant module-information + modules = [] + for module in soi.modules: + width, height = module["widget"].get_size() + modules.append( + { + "type": module["widget"].type, + "data": module["widget"].get_data(), + "size": {"width": width, "height": height}, + "meta": module["meta"], + } + ) + + # Create dict with relevant attachment-information + attachments = [] + for attachment in soi.attachments: + width, height = attachment["widget"].get_size() + attachments.append( + { + "type": attachment["widget"].type, + "data": attachment["widget"].get_data(), + "size": {"width": width, "height": height}, + "meta": attachment["meta"], + } + ) + + # Create dict with all relevant SOI-information + serialized = { + "title": soi.title, + "description": soi.description, + "version": soi.version, + "date": soi.date, + "valid": {"from_date": soi.valid_from, "to_date": soi.valid_to}, + "icon": soi.icon, + "classification": soi.classification, + "orientation": soi.orientation, + "placement_strategy": soi.placement_strategy, + "algorithm_bin": soi.algorithm_bin, + "algorithm_pack": soi.algorithm_pack, + "algorithm_sort": soi.algorithm_sort, + "modules": modules, + "attachments": attachments, + } + + return json.dumps(serialized) + + +def export_soi(soi, compressed=True): + """Export SOI. + + A .txt-file is created to contain compressed SOI. + A .json-file is created to contain uncompressed SOI. + + The generated file-name will be on the format: "SOI_title_YYYY_mm_dd", + where title is the SOI-title. + + Parameters + ---------- + soi: soitool.soi.SOI + SOI to export + compressed : bool, optional + Serialized SOI will be compressed if True (default). + """ + # Serialize SOI + serialized = serialize_soi(soi) + + # Generate filename + title = soi.title + date = datetime.now().strftime("%Y_%m_%d") + file_name = f"SOI_{title}_{date}" + + if compressed: + serialized = compress(serialized) + file = open(file_name + ".txt", "w") + else: + file = open(file_name + ".json", "w") + + file.write(str(serialized)) + file.close() + + +def import_soi(file_path): + """Import compressed or uncompressed serialized SOI. + + Reads content of file and decompresses it for .txt-files. + Creates an SOI-object based on the file content. + + Parameters + ---------- + file_path : string + Full path to a file containing serialized SOI. + + Returns + ------- + soitool.soi.SOI + SOI-object + + Raises + ------ + ValueError + If file content is invalid against SERIALIZED_SOI_SCHEMA. + TypeError + If 'type' of module or attachment is not implemented. + """ + with open(file_path, "r") as file: + if file_path.endswith(".txt"): + serialized = json.loads(decompress(file.read())) + else: + serialized = json.load(file) + + # Raise error if file content is invalid + if not SERIALIZED_SOI_SCHEMA.is_valid(serialized): + raise ValueError("Serialized SOI does not have correct format.") + + # Create dict for modules with instantiated widget(s) + modules = [] + for module in serialized["modules"]: + module_type = module["type"] + + if module_type == "TableModule": + size = module["size"] + data = module["data"] + modules.append( + {"widget": TableModule(size, data), "meta": module["meta"]} + ) + else: + raise TypeError( + "Module-type '{}' is not recognized.".format(module_type) + ) + + # Create dict for attachments with instantiated widget(s) + attachments = [] + for attachment in serialized["attachments"]: + module_type = attachment["type"] + + if module_type == "TableModule": + size = attachment["size"] + data = attachment["data"] + attachments.append( + { + "widget": TableModule(size, data), + "meta": attachment["meta"], + } + ) + else: + raise TypeError( + "Module-type '{}' is not recognized.".format(module_type) + ) + # Create SOI + soi = SOI( + serialized["title"], + serialized["description"], + serialized["version"], + serialized["date"], + serialized["valid"]["from_date"], + serialized["valid"]["to_date"], + serialized["icon"], + serialized["classification"], + serialized["orientation"], + serialized["placement_strategy"], + serialized["algorithm_bin"], + serialized["algorithm_pack"], + serialized["algorithm_sort"], + modules, + attachments, + ) + + return soi diff --git a/soitool/soi.py b/soitool/soi.py index a8c340fc7a670b06a8074224d3eecb2a29459280..1394c197930cf1d12415a66482c15343565d5cb6 100644 --- a/soitool/soi.py +++ b/soitool/soi.py @@ -266,7 +266,7 @@ class SOI: valid_from=None, valid_to=None, icon="soitool/media/HVlogo.png", - classification="ugradert", + classification="UGRADERT", orientation="landscape", placement_strategy="auto", algorithm_bin="BFF", @@ -307,7 +307,7 @@ class SOI: self.modules = modules self.attachments = attachments - # the following propertiese are relevant when self.placement_strategy + # the following properties are relevant when self.placement_strategy # is "auto". They are used by rectpack self.algorithm_bin = algorithm_bin self.algorithm_pack = algorithm_pack @@ -392,6 +392,9 @@ class SOI: """ if self.placement_strategy == "auto": self.reorganize_rectpack() + elif self.placement_strategy == "manual": + # nothing to do + pass else: raise Exception( "Unknown placement strategy: {}".format( diff --git a/soitool/soi_workspace_widget.py b/soitool/soi_workspace_widget.py index 267fa37f847292223b91d919d6c66d81690fded5..520a3bbaed8d9261d9889fe5bc93f226a53bf5fb 100644 --- a/soitool/soi_workspace_widget.py +++ b/soitool/soi_workspace_widget.py @@ -23,14 +23,21 @@ from soitool.dialog_wrappers import exec_warning_dialog class SOIWorkspaceWidget(QWidget): """Contains the working area for a single SOI. + Creates a new SOI by default, but can receive an existing SOI through + it's init parameter 'soi'. + 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): + def __init__(self, soi=None): super().__init__() - self.soi = SOI() + if soi is None: + self.soi = SOI() + else: + self.soi = soi + self.popup = Setup(self.soi) self.layout_wrapper = QHBoxLayout() @@ -44,8 +51,10 @@ class SOIWorkspaceWidget(QWidget): self.button_setup.setShortcut("Ctrl+i") self.button_setup.setStatusTip("Endre oppsett på SOI") - self.list_modules = ModuleList(ModuleType.MAIN_MODULE, self) - self.list_attachments = ModuleList(ModuleType.ATTACHMENT_MODULE, self) + self.list_modules = ModuleList(ModuleType.MAIN_MODULE, self.soi) + self.list_attachments = ModuleList( + ModuleType.ATTACHMENT_MODULE, self.soi + ) self.view = InlineEditableSOIView(self.soi) self.widget_sidebar = QWidget() diff --git a/test/test_codebook_to_pdf.py b/test/test_codebook_to_pdf.py index 5f4fe854723c35e9792fe5eee3819bf87a9070e6..b3d5ef0a5b2733667377358012da326912d7a789 100644 --- a/test/test_codebook_to_pdf.py +++ b/test/test_codebook_to_pdf.py @@ -1,7 +1,6 @@ """Test exporting codebook to PDF.""" import unittest import os -from functools import partial from pathlib import Path from datetime import datetime from soitool import codebook_to_pdf @@ -50,16 +49,11 @@ class ExportTest(unittest.TestCase): # Assert file exists self.assertTrue(os.path.exists(file_path_small)) - self.addCleanup( - partial(delete_generated_files, file_path_full, file_path_small) - ) - - -def delete_generated_files(file_path1, file_path2): - """Delete generated PDF-files.""" - if os.path.exists(file_path1): - os.remove(file_path1) - if os.path.exists(file_path2): - os.remove(file_path2) - if os.path.exists(TESTDBPATH): - os.remove(TESTDBPATH) + # Delete generated files + if os.path.exists(file_path_full): + os.remove(file_path_full) + if os.path.exists(file_path_small): + os.remove(file_path_small) + if os.path.exists(TESTDBPATH): + database.conn.close() + os.remove(TESTDBPATH) diff --git a/test/test_database.py b/test/test_database.py index c9a6598e63977ff4e3fa145ee2a00d63e4757cb9..b34691a32c49a5def6548e6925cd1b4f71e3cf12 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -295,8 +295,8 @@ class DatabaseTest(unittest.TestCase): def delete_db(self): """Delete generated database-file.""" - del self.database if os.path.exists(TESTDBPATH): + self.database.conn.close() os.remove(TESTDBPATH) diff --git a/test/test_main_window.py b/test/test_main_window.py index dc6691d86bf8df1c21785ac7c09601378e6ebc62..311f8732c1675d36dd4ed941420f0cf8ae9d6a57 100644 --- a/test/test_main_window.py +++ b/test/test_main_window.py @@ -34,9 +34,8 @@ class TestMainWindow(unittest.TestCase): def delete_db(self): """Delete generated 'testDatabase'-file.""" - del self.test_mw.timer - del self.test_mw if os.path.exists(TESTDBPATH): + self.test_mw.database.conn.close() os.remove(TESTDBPATH) diff --git a/test/test_serialize_export_import.py b/test/test_serialize_export_import.py new file mode 100644 index 0000000000000000000000000000000000000000..c1c2afa1e3d7b85e65ae73da6792352935a64b3d --- /dev/null +++ b/test/test_serialize_export_import.py @@ -0,0 +1,169 @@ +"""Test serializing, exporting and importing of SOI.""" +import os +import unittest +import json +from pathlib import Path +from datetime import datetime +from PySide2.QtWidgets import QApplication +from soitool.soi import SOI +from soitool.modules.module_table import TableModule +from soitool.serialize_export_import_soi import ( + serialize_soi, + export_soi, + import_soi, + SERIALIZED_SOI_SCHEMA, +) + +app = QApplication.instance() +if app is None: + app = QApplication([]) + +SOITOOL_ROOT_PATH = Path(__file__).parent.parent + +# SOI content +TITLE = "testSOI" +DESCRIPTION = "This is a description" +VERSION = "1" +DATE = "01.01.2020" +VALID_FROM = "01.01.2020" +VALID_TO = "02.01.2020" +ICON = "soitool/media/HVlogo.png" +CLASSIFICATION = "UGRADERT" +ORIENTATION = "portrait" +PLACEMENT_STRATEGY = "manual" +ALGORITHM_BIN = "BFF" +ALGORITHM_PACK = "MaxRectsBl" +ALGORITHM_SORT = "area" +MODULES = [ + { + "widget": TableModule( + size={"width": 50, "height": 75}, + data=[["H1"], ["Row1"], ["Row2"]], + ), + "meta": {"x": 0, "y": 0, "page": 1, "name": "Table1"}, + }, + { + "widget": TableModule( + size={"width": 100, "height": 75}, + data=[["H1", "H2"], ["Row1Col1", "Row1Col2"]], + ), + "meta": {"x": 200, "y": 150, "page": 1, "name": "Table1"}, + }, +] + + +class SerializeTest(unittest.TestCase): + """Testcase for functions in module 'serialize_export_import_soi.py'.""" + + def setUp(self): + """Create SOI-object and generate filepath for exported SOI.""" + self.soi = SOI( + title=TITLE, + description=DESCRIPTION, + version=VERSION, + date=DATE, + valid_from=VALID_FROM, + valid_to=VALID_TO, + icon=ICON, + classification=CLASSIFICATION, + orientation=ORIENTATION, + placement_strategy=PLACEMENT_STRATEGY, + algorithm_bin=ALGORITHM_BIN, + algorithm_pack=ALGORITHM_PACK, + algorithm_sort=ALGORITHM_SORT, + modules=MODULES, + attachments=MODULES, + ) + date = datetime.now().strftime("%Y_%m_%d") + file_name_uncompressed = f"SOI_{TITLE}_{date}.json" + file_name_compressed = f"SOI_{TITLE}_{date}.txt" + + self.file_path_uncompressed = os.path.join( + SOITOOL_ROOT_PATH, file_name_uncompressed + ) + self.file_path_compressed = os.path.join( + SOITOOL_ROOT_PATH, file_name_compressed + ) + + def test_serialize_soi(self): + """Serialize SOI and check its validity against schema.""" + # Serialize SOI and load as dict + serialized = serialize_soi(self.soi) + json_data = json.loads(serialized) + # Assert serialized SOI format matches schema + self.assertTrue(SERIALIZED_SOI_SCHEMA.is_valid(json_data)) + + def test_export_soi(self): + """Export SOI and assert files were created.""" + # Export SOI + export_soi(self.soi, compressed=False) + export_soi(self.soi, compressed=True) + + # Assert files exist + self.assertTrue(os.path.exists(self.file_path_uncompressed)) + self.assertTrue(os.path.exists(self.file_path_compressed)) + + def test_import_soi_invalid(self): + """Assert invalid serialization throws error on import.""" + # Write invalid serialization to file + invalid_soi_file_path = "test_file.json" + invalid_serialization = { + "title": "SOI-test", + "invalid_key": "All keys do not exist", + } + file = open(invalid_soi_file_path, "w") + file.write(json.dumps(invalid_serialization)) + file.close() + + # Assert import throws error + with self.assertRaises(ValueError) as error: + import_soi(invalid_soi_file_path) + self.assertEqual( + str(error.exception), + "Serialized SOI does not have correct format.", + ) + if os.path.exists(invalid_soi_file_path): + os.remove(invalid_soi_file_path) + + 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) + + # Assert SOI content is correct + self.check_soi_content(soi_uncompressed) + self.check_soi_content(soi_compressed) + + # Delete exported SOI-files + if os.path.exists(self.file_path_compressed): + os.remove(self.file_path_compressed) + if os.path.exists(self.file_path_uncompressed): + os.remove(self.file_path_uncompressed) + + def check_soi_content(self, soi): + """Assert SOI-content is correct. + + Parameters + ---------- + soi : soitool.soi.SOI + SOI-instance with content to check. + """ + # Assert SOI content is correct + self.assertEqual(TITLE, soi.title) + self.assertEqual(DESCRIPTION, soi.description) + self.assertEqual(VERSION, soi.version) + self.assertEqual(DATE, soi.date) + self.assertEqual(VALID_FROM, soi.valid_from) + self.assertEqual(VALID_TO, soi.valid_to) + self.assertEqual(ICON, soi.icon) + self.assertEqual(CLASSIFICATION, soi.classification) + self.assertEqual(ORIENTATION, soi.orientation) + self.assertEqual(PLACEMENT_STRATEGY, soi.placement_strategy) + self.assertEqual(ALGORITHM_BIN, soi.algorithm_bin) + self.assertEqual(ALGORITHM_PACK, soi.algorithm_pack) + self.assertEqual(ALGORITHM_SORT, soi.algorithm_sort) + self.assertEqual(type(MODULES[0]["widget"]), TableModule) + self.assertEqual(type(MODULES[1]["widget"]), TableModule) + self.assertEqual(MODULES[0]["meta"], soi.modules[0]["meta"]) + self.assertEqual(MODULES[1]["meta"], soi.modules[1]["meta"]) diff --git a/test/test_soi.py b/test/test_soi.py index b1223b4b60e10a16aa109abac9b7b4fb49c392a8..9e1a4d2ea85ea4668ea246c2189308b442704663 100644 --- a/test/test_soi.py +++ b/test/test_soi.py @@ -32,7 +32,10 @@ class TestModule(ModuleBase, QWidget, metaclass=Meta): """A simple module of given width, height and color for testing.""" def __init__(self, color, width, height, *args, **kwargs): - super(TestModule, self).__init__(*args, **kwargs) + self.type = "TestModule" + QWidget.__init__(self, *args, **kwargs) + ModuleBase.__init__(self) + self.setAutoFillBackground(True) palette = self.palette() palette.setColor(QPalette.Window, QColor(color)) @@ -52,6 +55,10 @@ class TestModule(ModuleBase, QWidget, metaclass=Meta): """Not used.""" raise NotImplementedError() + def get_as_dict(self): + """Not used.""" + raise NotImplementedError() + # The modules below have sizes that make the ideal for testing. # Sorting them by width should yield