diff --git a/soitool/main_window.py b/soitool/main_window.py index a099da8f2186c4e701403a5506e0cf64c1abe3c4..e8f053c623929a089ebef73fa3f826c2333db0d1 100644 --- a/soitool/main_window.py +++ b/soitool/main_window.py @@ -20,6 +20,8 @@ from soitool.codebook_to_pdf import generate_codebook_pdf from soitool.dialog_wrappers import exec_info_dialog from soitool.codebook_widget import CodebookWidget from soitool.codebook_model_view import CodebookTableModel +from soitool.soi_db_widget import SOIDbWidget +from soitool.soi_model_view import SOITableModel from soitool.database import Database, DBPATH from soitool.help_actions import ShortcutsHelpDialog, BasicUsageHelpDialog from soitool.serialize_export_import_soi import ( @@ -109,6 +111,7 @@ class MainWindow(QMainWindow): open_file_db = QAction("Åpne fra DB", self) open_file_db.setShortcut("Ctrl+d") open_file_db.setStatusTip("Åpne en SOI fra databasen") + open_file_db.triggered.connect(self.show_soi_db) file_menu.addAction(open_file_db) # Preview SOI @@ -220,8 +223,8 @@ class MainWindow(QMainWindow): """ widget_in_tab = self.tabs.widget(index) - # Close db-connection if tab is a codebook-tab - if isinstance(widget_in_tab, CodebookWidget): + # Close db-connection if tab is a CodebookWidget or SOIDbWidget + if isinstance(widget_in_tab, (CodebookWidget, SOIDbWidget)): widget_in_tab.view.close_db_connection() self.tabs.removeTab(index) @@ -307,7 +310,24 @@ class MainWindow(QMainWindow): # If tab contains an SOI if isinstance(tab_widget, SOIWorkspaceWidget): + # Update tab showing SOI's in db if it is open, + # and pause database-lock by codebook-tab if it is open + soi_db_view = None + codebook_db_view = None + for i in range(self.tabs.count()): + if self.tabs.tabText(i) == "SOI'er i DB": + soi_db_view = self.tabs.widget(i).view + soi_db_view.setModel(None) + elif self.tabs.tabText(i) == "Kodebok": + codebook_db_view = self.tabs.widget(i).view + codebook_db_view.setModel(None) + self.database.insert_or_update_soi(tab_widget.soi) + + if soi_db_view is not None: + soi_db_view.setModel(SOITableModel()) + if codebook_db_view is not None: + codebook_db_view.setModel(CodebookTableModel()) else: exec_info_dialog( "Valgt tab er ingen SOI-tab", @@ -315,6 +335,23 @@ class MainWindow(QMainWindow): "Riktig tab må velges for å lagre en SOI i DB.", ) + def show_soi_db(self): + """Open and select tab containing SOIDbWidget. + + Select tab if it is already open, + create and select tab if not open. + """ + # Loop through tabs to look for existing SOI-db-tab: + for i in range(self.tabs.count()): + if self.tabs.tabText(i) == "SOI'er i DB": + self.tabs.setCurrentIndex(i) + break + # SOI-db-tab does not exist, create, add and select tab + else: + tab = SOIDbWidget(self.database, self.tabs) + self.tabs.addTab(tab, "SOI'er i DB") + self.tabs.setCurrentWidget(tab) + def open_shortcut_help(self): """Open shortcut dialog.""" self.popup_shortcut_help.setWindowTitle("Hurtigtaster") diff --git a/soitool/soi_db_widget.py b/soitool/soi_db_widget.py new file mode 100644 index 0000000000000000000000000000000000000000..343c7d4672c7b7db8557cb59c5836960d2dd317b --- /dev/null +++ b/soitool/soi_db_widget.py @@ -0,0 +1,19 @@ +"""Module containing a widget for viewing and opening SOI's from database.""" +from PySide2.QtWidgets import QWidget, QHBoxLayout +from soitool.soi_model_view import SOITableView + + +class SOIDbWidget(QWidget): + """Widget for viewing and opening SOI's from database.""" + + def __init__(self, database, tab_widget): + super().__init__() + + self.view = SOITableView(database, tab_widget) + self.create_and_set_layout() + + def create_and_set_layout(self): + """Create layout, add widget and set layout.""" + hbox = QHBoxLayout() + hbox.addWidget(self.view) + self.setLayout(hbox) diff --git a/soitool/soi_model_view.py b/soitool/soi_model_view.py new file mode 100644 index 0000000000000000000000000000000000000000..680d27502fdefbec63c274fcd4c4c4ae1084b7b7 --- /dev/null +++ b/soitool/soi_model_view.py @@ -0,0 +1,148 @@ +"""GUI-interface towards database-table 'SOI'. + +Contains functionality for viewing and opening SOI's from database-table 'SOI', +where (some) SOI's are stored. +""" +from PySide2.QtWidgets import QTableView +from PySide2.QtSql import QSqlDatabase, QSqlTableModel +from PySide2.QtCore import Qt +from soitool.style import CODEBOOK_HEADER_FONT, CODEBOOK_HEADER_BACKGROUND_CSS +from soitool.serialize_export_import_soi import construct_soi_from_serialized +from soitool.soi_workspace_widget import SOIWorkspaceWidget + +# Name and type of database +CONNAME = "SOIDB" +DBTYPE = "QSQLITE" + + +class SOITableView(QTableView): + """TableView with a model of the 'SOI'-table from database. + + This modified QTableView creates a SOITableModel, which reads data from the + SOI-table. When the user double-clicks or presses the enter-key on a cell, + a tab containing SOIWorkspaceWidget, with the SOI from the current row, is + opened and selected. + + Parameters + ---------- + database : soitool.database.Database + Is used to create a QSqlDatabase from the database-file. + tab_widget : QTabWidget + Is used to open a new tab. + + Raises + ------ + RuntimeError + If database does not open. + """ + + def __init__(self, database, tab_widget): + super().__init__() + db = QSqlDatabase.addDatabase(DBTYPE, CONNAME) + db.setDatabaseName(database.db_path) + self.tab_widget = tab_widget + + if not db.open(): + raise RuntimeError("Could not open database.") + + # Enable sorting + self.setSortingEnabled(True) + + # Create and set model + model = SOITableModel() + self.setModel(model) + + # Remove horizontal scrollbar, hide vertical header and 'SOI'-column + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.verticalHeader().hide() + self.hideColumn(2) + + # Set horizontal header-text and it's style + self.set_horizontal_header_text() + header = self.horizontalHeader() + header.setFont(CODEBOOK_HEADER_FONT) + header.setStyleSheet(CODEBOOK_HEADER_BACKGROUND_CSS) + + # Resize + self.resizeColumnsToContents() + width = ( + self.columnWidth(0) + self.columnWidth(1) + self.columnWidth(3) + 2 + ) # +2 offset + self.setFixedWidth(width) + + self.doubleClicked.connect(self.open_soi_tab) + + def set_horizontal_header_text(self): + """Set Norwegian names in horizontal header.""" + self.model().setHeaderData(0, Qt.Horizontal, "Tittel") + self.model().setHeaderData(1, Qt.Horizontal, "Versjon") + self.model().setHeaderData(3, Qt.Horizontal, "Dato") + + def keyPressEvent(self, event): + """Open SOI-tab if enter-key is pressed.""" + if event.key() == Qt.Key_Return: + self.open_soi_tab() + super().keyPressEvent(event) + + def open_soi_tab(self): + """Construct SOI and open SOIWorkspacewidget in new tab.""" + # Get index of the current row and read compressed, serialized SOI + row = self.currentIndex().row() + compressed_soi = self.model().index(row, 2).data() + + # Construct SOI and create SOIWorkspaceWidget + soi = construct_soi_from_serialized(compressed_soi, compressed=True) + tab = SOIWorkspaceWidget(soi) + + # Add and select tab + self.tab_widget.addTab(tab, soi.title) + self.tab_widget.setCurrentWidget(tab) + + def setModel(self, model): + """Set model, resize and hide 'SOI'-column. + + Parameters + ---------- + model : soitool.soi_model_view.SOITableModel or None + Model containing data to display. + """ + super().setModel(model) + if model is not None: + self.hideColumn(2) + self.resizeColumnsToContents() + width = ( + self.columnWidth(0) + + self.columnWidth(1) + + self.columnWidth(3) + + 2 # + 2 offset + ) + self.setFixedWidth(width) + self.sortByColumn(3, Qt.DescendingOrder) # Sort by 'Date'-column + + def close_db_connection(self): + """Close connection to database.""" + self.setModel(None) + QSqlDatabase.removeDatabase(CONNAME) + + +class SOITableModel(QSqlTableModel): + """Uneditable QSqlTableModel of database-table 'SOI'.""" + + def __init__(self): + super().__init__(None, QSqlDatabase.database(CONNAME)) + self.setTable("SOI") + self.select() + self.sort(3, Qt.DescendingOrder) # Sort by 'Date'-column + + def flags(self, index): + """Disable editing. + + Parameters + ---------- + index : QModelIndex + Is used to locate data in a model. + """ + flags = super().flags(index) + flags ^= Qt.ItemIsEditable + + return flags diff --git a/test/test_database.py b/test/test_database.py index 42c705285dbe516df3471c488d4032263c366fc1..219431d7ee2e89fb568771abfd6a2ab44f98afce 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -17,6 +17,9 @@ TESTDBPATH = os.path.join(SOITOOL_DIR, TESTDBNAME) TESTDATA_PATH = Path(__file__).parent.parent / "soitool/testdata" +# Tolerance for timed tests. Tolerating DELTA seconds of difference +DELTA = 10 + class DatabaseTest(unittest.TestCase): """Database tests.""" @@ -141,7 +144,7 @@ class DatabaseTest(unittest.TestCase): # Calculates difference in seconds time_diff = (second_time - first_time).total_seconds() # Compares difference with sleep_time - self.assertAlmostEqual(time_diff, sleep_time, delta=0.2) + self.assertAlmostEqual(time_diff, sleep_time, delta=DELTA) def test_get_categories(self): """Assert function get_categories works as expected.""" @@ -276,7 +279,7 @@ class DatabaseTest(unittest.TestCase): expected_time = seconds_in_24h - sleep_time actual_time = self.database.seconds_to_next_update(seconds_in_24h) # Compares expected and function return value with - self.assertAlmostEqual(expected_time, actual_time, delta=0.2) + self.assertAlmostEqual(expected_time, actual_time, delta=DELTA) def teset_seconds_to_next_update_complete_period(self): """Check that seconds to next update can returns 0 and not negative."""