diff --git a/.pylintrc b/.pylintrc
index 32a66e20..82b2e3d0 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -3,7 +3,7 @@
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code
-extension-pkg-whitelist=
+extension-pkg-whitelist=PySide6
# Add files or directories to the blacklist. They should be base names, not
# paths.
@@ -11,7 +11,7 @@ ignore=CVS
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
-ignore-patterns=
+ignore-patterns=ontology_configuration.py,create_type_dialog.py
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
diff --git a/pasta_eln/GUI/ontology_configuration/__init__.py b/pasta_eln/GUI/ontology_configuration/__init__.py
new file mode 100644
index 00000000..0e780dd4
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/__init__.py
@@ -0,0 +1,8 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: __init__.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
diff --git a/pasta_eln/GUI/ontology_configuration/create_type_dialog.py b/pasta_eln/GUI/ontology_configuration/create_type_dialog.py
new file mode 100644
index 00000000..ef59a335
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/create_type_dialog.py
@@ -0,0 +1,80 @@
+# Form implementation generated from reading ui file 'create_type_dialog.ui'
+#
+# Created by: PyQt6 UI code generator 6.4.2
+#
+# WARNING: Any manual changes made to this file will be lost when pyuic6 is
+# run again. Do not edit this file unless you know what you are doing.
+
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+
+class Ui_CreateTypeDialog(object):
+ def setupUi(self, CreateTypeDialog):
+ CreateTypeDialog.setObjectName("CreateTypeDialog")
+ CreateTypeDialog.resize(459, 301)
+ self.buttonBox = QtWidgets.QDialogButtonBox(parent=CreateTypeDialog)
+ self.buttonBox.setGeometry(QtCore.QRect(30, 240, 341, 32))
+ self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
+ self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
+ self.buttonBox.setObjectName("buttonBox")
+ self.verticalLayoutWidget = QtWidgets.QWidget(parent=CreateTypeDialog)
+ self.verticalLayoutWidget.setGeometry(QtCore.QRect(-1, -1, 461, 221))
+ self.verticalLayoutWidget.setObjectName("verticalLayoutWidget")
+ self.mainVerticalLayout = QtWidgets.QVBoxLayout(self.verticalLayoutWidget)
+ self.mainVerticalLayout.setContentsMargins(20, 0, 20, 0)
+ self.mainVerticalLayout.setObjectName("mainVerticalLayout")
+ self.tileHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.tileHorizontalLayout.setObjectName("tileHorizontalLayout")
+ self.titleLabel = QtWidgets.QLabel(parent=self.verticalLayoutWidget)
+ self.titleLabel.setObjectName("titleLabel")
+ self.tileHorizontalLayout.addWidget(self.titleLabel)
+ spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum)
+ self.tileHorizontalLayout.addItem(spacerItem)
+ self.titleLineEdit = QtWidgets.QLineEdit(parent=self.verticalLayoutWidget)
+ self.titleLineEdit.setToolTip("")
+ self.titleLineEdit.setObjectName("titleLineEdit")
+ self.tileHorizontalLayout.addWidget(self.titleLineEdit)
+ self.mainVerticalLayout.addLayout(self.tileHorizontalLayout)
+ self.horizontalLayout = QtWidgets.QHBoxLayout()
+ self.horizontalLayout.setObjectName("horizontalLayout")
+ self.typeLabel = QtWidgets.QLabel(parent=self.verticalLayoutWidget)
+ self.typeLabel.setObjectName("typeLabel")
+ self.horizontalLayout.addWidget(self.typeLabel)
+ spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum)
+ self.horizontalLayout.addItem(spacerItem1)
+ self.labelLineEdit = QtWidgets.QLineEdit(parent=self.verticalLayoutWidget)
+ self.labelLineEdit.setObjectName("labelLineEdit")
+ self.horizontalLayout.addWidget(self.labelLineEdit)
+ self.mainVerticalLayout.addLayout(self.horizontalLayout)
+ self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
+ self.horizontalLayout_2.setObjectName("horizontalLayout_2")
+ self.structuralLevelCheckBox = QtWidgets.QCheckBox(parent=self.verticalLayoutWidget)
+ self.structuralLevelCheckBox.setObjectName("structuralLevelCheckBox")
+ self.horizontalLayout_2.addWidget(self.structuralLevelCheckBox)
+ self.mainVerticalLayout.addLayout(self.horizontalLayout_2)
+
+ self.retranslateUi(CreateTypeDialog)
+ self.buttonBox.accepted.connect(CreateTypeDialog.accept) # type: ignore
+ self.buttonBox.rejected.connect(CreateTypeDialog.reject) # type: ignore
+ QtCore.QMetaObject.connectSlotsByName(CreateTypeDialog)
+
+ def retranslateUi(self, CreateTypeDialog):
+ _translate = QtCore.QCoreApplication.translate
+ CreateTypeDialog.setWindowTitle(_translate("CreateTypeDialog", "Create New Type"))
+ self.titleLabel.setText(_translate("CreateTypeDialog", "Enter Type title"))
+ self.titleLineEdit.setPlaceholderText(_translate("CreateTypeDialog", "Enter the title for the new type"))
+ self.typeLabel.setText(_translate("CreateTypeDialog", "Enter Type Label"))
+ self.labelLineEdit.setPlaceholderText(_translate("CreateTypeDialog", "Enter the label for the new type"))
+ self.structuralLevelCheckBox.setToolTip(_translate("CreateTypeDialog", "If this is a structural type, then title will be automatically populated as (x0, x1...xn). Next number will be chosen for xn from the existing list of structural items."))
+ self.structuralLevelCheckBox.setText(_translate("CreateTypeDialog", "Is this a structural Type?"))
+
+
+if __name__ == "__main__":
+ import sys
+ app = QtWidgets.QApplication(sys.argv)
+ CreateTypeDialog = QtWidgets.QDialog()
+ ui = Ui_CreateTypeDialog()
+ ui.setupUi(CreateTypeDialog)
+ CreateTypeDialog.show()
+ sys.exit(app.exec())
diff --git a/pasta_eln/GUI/ontology_configuration/create_type_dialog.ui b/pasta_eln/GUI/ontology_configuration/create_type_dialog.ui
new file mode 100644
index 00000000..51407ade
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/create_type_dialog.ui
@@ -0,0 +1,171 @@
+
+
+ CreateTypeDialog
+
+
+
+ 0
+ 0
+ 459
+ 301
+
+
+
+ Create New Type
+
+
+
+
+ 30
+ 240
+ 341
+ 32
+
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+ -1
+ -1
+ 461
+ 221
+
+
+
+
+ 20
+
+
+ 20
+
+ -
+
+
-
+
+
+ Enter Type title
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+
+
+ Enter the title for the new type
+
+
+
+
+
+ -
+
+
-
+
+
+ Enter Type Label
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Enter the label for the new type
+
+
+
+
+
+ -
+
+
-
+
+
+ If this is a structural type, then title will be automatically populated as (x0, x1...xn). Next number will be chosen for xn from the existing list of structural items.
+
+
+ Is this a structural Type?
+
+
+
+
+
+
+
+
+
+
+
+ buttonBox
+ accepted()
+ CreateTypeDialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ CreateTypeDialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/pasta_eln/GUI/ontology_configuration/create_type_dialog_extended.py b/pasta_eln/GUI/ontology_configuration/create_type_dialog_extended.py
new file mode 100644
index 00000000..3ee18004
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/create_type_dialog_extended.py
@@ -0,0 +1,116 @@
+""" CreateTypeDialog used for the create type dialog """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023.
+#
+# Author: Jithu Murugan
+# Filename: create_type_dialog_extended.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+#
+# Author: Jithu Murugan
+# Filename: create_type_dialog_extended.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+from collections.abc import Callable
+from typing import Any
+
+from PySide6 import QtCore
+from PySide6.QtWidgets import QDialog
+
+from pasta_eln.GUI.ontology_configuration.create_type_dialog import Ui_CreateTypeDialog
+
+
+class CreateTypeDialog(Ui_CreateTypeDialog):
+ """
+ Abstracted dialog for the create type
+ """
+
+ def __new__(cls, *_: Any, **__: Any) -> Any:
+ """
+ Instantiates the create type dialog
+ """
+ return super(CreateTypeDialog, cls).__new__(cls)
+
+ def __init__(self,
+ accepted_callback: Callable[[], None],
+ rejected_callback: Callable[[], None]) -> None:
+ """
+ Initializes the create type dialog
+ Args:
+ accepted_callback (Callable): Accepted button parent callback.
+ rejected_callback (Callable): Rejected button parent callback.
+ """
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+ self.next_struct_level: str | None = ""
+ self.instance = QDialog()
+ super().setupUi(self.instance)
+ self.setup_slots(accepted_callback, rejected_callback)
+
+ def setup_slots(self,
+ accepted_callback: Callable[[], None],
+ rejected_callback: Callable[[], None]) -> None:
+ """
+ Sets up the slots for the dialog
+ Args:
+ accepted_callback (Callable): Accepted button parent callback.
+ rejected_callback (Callable): Rejected button parent callback.
+
+ Returns: None
+
+ """
+ self.buttonBox.accepted.connect(accepted_callback)
+ self.buttonBox.rejected.connect(rejected_callback)
+ self.structuralLevelCheckBox.stateChanged.connect(self.structural_level_checkbox_callback)
+
+ def structural_level_checkbox_callback(self) -> None:
+ """
+ Callback invoked when the state changes for structuralLevelCheckBox
+
+ Returns: Nothing
+ """
+ if self.structuralLevelCheckBox.isChecked():
+ self.titleLineEdit.setText(self.next_struct_level if self.next_struct_level else "")
+ self.titleLineEdit.setDisabled(True)
+ else:
+ self.titleLineEdit.clear()
+ self.titleLineEdit.setDisabled(False)
+
+ def show(self) -> None:
+ """
+ Displays the dialog
+
+ Returns: None
+
+ """
+ self.instance.setWindowModality(QtCore.Qt.ApplicationModal)
+ self.instance.show()
+
+ def clear_ui(self) -> None:
+ """
+ Clear the Dialog UI
+
+ Returns: Nothing
+
+ """
+ self.labelLineEdit.clear()
+ self.titleLineEdit.clear()
+ self.structuralLevelCheckBox.setChecked(False)
+
+ def set_structural_level_title(self,
+ structural_level: str | None) -> None:
+ """
+ Set the next possible structural type level title
+
+ Args:
+ structural_level (str): Passed in structural level of the format (x0, x1, x2 ...)
+
+ Returns: Nothing
+
+ """
+ self.logger.info("Next structural level set: {%s}...", structural_level)
+ self.next_struct_level = structural_level
diff --git a/pasta_eln/GUI/ontology_configuration/delete_column_delegate.py b/pasta_eln/GUI/ontology_configuration/delete_column_delegate.py
new file mode 100644
index 00000000..ec79717e
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/delete_column_delegate.py
@@ -0,0 +1,93 @@
+""" DeleteColumnDelegate for the table views """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: delete_column_delegate.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+from typing import Union
+
+from PySide6.QtCore import QModelIndex, QPersistentModelIndex, QEvent, QAbstractItemModel, Signal
+from PySide6.QtGui import QPainter
+from PySide6.QtWidgets import QStyledItemDelegate, QPushButton, QWidget, QStyleOptionViewItem, QStyleOptionButton, \
+ QStyle, QApplication
+
+from .utility_functions import is_click_within_bounds
+
+
+class DeleteColumnDelegate(QStyledItemDelegate):
+ """
+ Delegate for creating the delete icon for the delete column in the ontology table views
+ """
+ delete_clicked_signal = Signal(
+ int) # Signal to inform the delete button click with the position in the table as the parameter
+
+ def __init__(self) -> None:
+ """
+ Constructor
+ """
+ super().__init__()
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+
+ def paint(self,
+ painter: QPainter,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> None:
+ """
+ Draws the delete button within the cell represented by index
+ Args:
+ painter (QPainter): Painter instance for painting the button.
+ option (QStyleOptionViewItem): Style option for the cell represented by index.
+ index (Union[QModelIndex, QPersistentModelIndex]): Cell index.
+
+ Returns: None
+
+ """
+ button = QPushButton()
+ opt = QStyleOptionButton()
+ opt.state = QStyle.State_Active | QStyle.State_Enabled # type: ignore[operator]
+ opt.rect = option.rect
+ opt.text = "Delete"
+ QApplication.style().drawControl(QStyle.CE_PushButton, opt, painter, button)
+
+ def createEditor(self,
+ parent: QWidget,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> QWidget:
+ """
+ Disable the editor for the delete column by simply returning None
+ Args:
+ parent (QWidget): Parent table view.
+ option (QStyleOptionViewItem): Style option for the cell represented by index.
+ index (Union[QModelIndex, QPersistentModelIndex]): Cell index.
+
+ Returns: None
+
+ """
+ return None # type: ignore[return-value]
+
+ def editorEvent(self,
+ event: QEvent,
+ model: QAbstractItemModel,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> bool:
+ """
+ In case of click detected within the cell represented by index, the respective delete signal is emitted
+ Args:
+ event (QEvent): The editor event information.
+ model (QAbstractItemModel): Model data representing the table view.
+ option (QStyleOptionViewItem): QStyleOption for the table cell.
+ index (Union[QModelIndex, QPersistentModelIndex]): Table cell index.
+
+ Returns (bool): True if deleted otherwise False
+
+ """
+ if is_click_within_bounds(event, option):
+ row = index.row()
+ self.logger.info("Delete signal emitted for the position: {%s}", row)
+ self.delete_clicked_signal.emit(row)
+ return True
+ return False
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_attachments_tableview_data_model.py b/pasta_eln/GUI/ontology_configuration/ontology_attachments_tableview_data_model.py
new file mode 100644
index 00000000..51e961c3
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_attachments_tableview_data_model.py
@@ -0,0 +1,40 @@
+""" OntologyAttachmentsTableViewModel used for the ontology editor's attachments table view """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: ontology_attachments_tableview_data_model.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+from typing import Union
+
+from PySide6.QtWidgets import QWidget
+
+from .ontology_tableview_data_model import OntologyTableViewModel
+
+
+class OntologyAttachmentsTableViewModel(OntologyTableViewModel):
+ """
+ Abstracted data-model for the ontology editor's attachments table view
+ """
+
+ def __init__(self,
+ parent: Union[QWidget | None] = None):
+ """
+ Initialize the data model representing attachments from ontology document in the database
+ Args:
+ parent (QWidget): Parent view or the widget
+ """
+ super().__init__(parent)
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+ self.data_set = []
+ self.data_name_map = {
+ 0: "location",
+ 1: "link",
+ 2: "delete",
+ 3: "re-order"
+ }
+ self.header_values: list[str] = list(self.data_name_map.values())
+ self.columns_count: int = len(self.header_values)
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_config_generic_exception.py b/pasta_eln/GUI/ontology_configuration/ontology_config_generic_exception.py
new file mode 100644
index 00000000..c8fa2950
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_config_generic_exception.py
@@ -0,0 +1,38 @@
+""" OntologyConfigGenericException used for the ontology configuration """
+
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023.
+#
+# Author: Jithu Murugan
+# Filename: ontology_config_generic_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+#
+# Author: Jithu Murugan
+# Filename: ontology_config_generic_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+class OntologyConfigGenericException(Exception):
+ """
+ Custom generic exception class for ontology configuration
+ """
+
+ def __init__(self,
+ message: str,
+ detailed_errors: dict[str, str]):
+ """
+ Constructs OntologyConfigGenericException
+ Args:
+ message (str): Error message to be thrown
+ detailed_errors (dict): Additional errors passed via exception
+ """
+ super().__init__(message)
+ self.message = message
+ self.detailed_errors = detailed_errors
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_config_key_not_found_exception.py b/pasta_eln/GUI/ontology_configuration/ontology_config_key_not_found_exception.py
new file mode 100644
index 00000000..164a30bd
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_config_key_not_found_exception.py
@@ -0,0 +1,38 @@
+""" OntologyConfigKeyNotFoundException used for the ontology configuration """
+
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023.
+#
+# Author: Jithu Murugan
+# Filename: ontology_config_key_not_found_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+#
+# Author: Jithu Murugan
+# Filename: ontology_config_key_not_found_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+class OntologyConfigKeyNotFoundException(Exception):
+ """
+ Custom exception class for null ontology document
+ """
+
+ def __init__(self,
+ message: str,
+ detailed_errors: dict[str, str]):
+ """
+ Constructs OntologyConfigKeyNotFoundException
+ Args:
+ message (str): Error message to be thrown
+ detailed_errors (dict): Additional errors passed via exception
+ """
+ super().__init__(message)
+ self.message = message
+ self.detailed_errors = detailed_errors
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_configuration.py b/pasta_eln/GUI/ontology_configuration/ontology_configuration.py
new file mode 100644
index 00000000..ca90cc04
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_configuration.py
@@ -0,0 +1,239 @@
+# Form implementation generated from reading ui file 'ontology_configuration.ui'
+#
+# Created by: PyQt6 UI code generator 6.4.2
+#
+# WARNING: Any manual changes made to this file will be lost when pyuic6 is
+# run again. Do not edit this file unless you know what you are doing.
+
+
+from PySide6 import QtCore, QtGui, QtWidgets
+
+
+class Ui_OntologyConfigurationBaseForm(object):
+ def setupUi(self, OntologyConfigurationBaseForm):
+ OntologyConfigurationBaseForm.setObjectName("OntologyConfigurationBaseForm")
+ OntologyConfigurationBaseForm.resize(1243, 874)
+ OntologyConfigurationBaseForm.setToolTip("")
+ self.gridLayout = QtWidgets.QGridLayout(OntologyConfigurationBaseForm)
+ self.gridLayout.setContentsMargins(10, 10, 10, 10)
+ self.gridLayout.setObjectName("gridLayout")
+ self.mainWidget = QtWidgets.QWidget(parent=OntologyConfigurationBaseForm)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.mainWidget.sizePolicy().hasHeightForWidth())
+ self.mainWidget.setSizePolicy(sizePolicy)
+ self.mainWidget.setObjectName("mainWidget")
+ self.verticalLayoutWidget_2 = QtWidgets.QWidget(parent=self.mainWidget)
+ self.verticalLayoutWidget_2.setGeometry(QtCore.QRect(0, 0, 1221, 821))
+ self.verticalLayoutWidget_2.setObjectName("verticalLayoutWidget_2")
+ self.mainGridLayout = QtWidgets.QGridLayout(self.verticalLayoutWidget_2)
+ self.mainGridLayout.setContentsMargins(30, 0, 30, 0)
+ self.mainGridLayout.setObjectName("mainGridLayout")
+ self.typePropsTableView = QtWidgets.QTableView(parent=self.verticalLayoutWidget_2)
+ self.typePropsTableView.setSortingEnabled(True)
+ self.typePropsTableView.setObjectName("typePropsTableView")
+ self.typePropsTableView.horizontalHeader().setCascadingSectionResizes(True)
+ self.typePropsTableView.horizontalHeader().setSortIndicatorShown(True)
+ self.typePropsTableView.horizontalHeader().setStretchLastSection(True)
+ self.typePropsTableView.verticalHeader().setCascadingSectionResizes(True)
+ self.typePropsTableView.verticalHeader().setSortIndicatorShown(True)
+ self.typePropsTableView.verticalHeader().setStretchLastSection(False)
+ self.mainGridLayout.addWidget(self.typePropsTableView, 6, 0, 1, 1)
+ self.attachmentsHeaderHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.attachmentsHeaderHorizontalLayout.setContentsMargins(0, 5, 0, 5)
+ self.attachmentsHeaderHorizontalLayout.setObjectName("attachmentsHeaderHorizontalLayout")
+ self.attachmentsHeaderLabel = QtWidgets.QLabel(parent=self.verticalLayoutWidget_2)
+ self.attachmentsHeaderLabel.setObjectName("attachmentsHeaderLabel")
+ self.attachmentsHeaderHorizontalLayout.addWidget(self.attachmentsHeaderLabel)
+ self.mainGridLayout.addLayout(self.attachmentsHeaderHorizontalLayout, 10, 0, 1, 1)
+ self.propsCategoryHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.propsCategoryHorizontalLayout.setContentsMargins(0, 5, 0, 5)
+ self.propsCategoryHorizontalLayout.setObjectName("propsCategoryHorizontalLayout")
+ self.propsCategoryLabel = QtWidgets.QLabel(parent=self.verticalLayoutWidget_2)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.propsCategoryLabel.sizePolicy().hasHeightForWidth())
+ self.propsCategoryLabel.setSizePolicy(sizePolicy)
+ self.propsCategoryLabel.setMinimumSize(QtCore.QSize(130, 0))
+ self.propsCategoryLabel.setObjectName("propsCategoryLabel")
+ self.propsCategoryHorizontalLayout.addWidget(self.propsCategoryLabel)
+ self.propsCategoryComboBox = QtWidgets.QComboBox(parent=self.verticalLayoutWidget_2)
+ self.propsCategoryComboBox.setMinimumSize(QtCore.QSize(0, 0))
+ self.propsCategoryComboBox.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents)
+ self.propsCategoryComboBox.setObjectName("propsCategoryComboBox")
+ self.propsCategoryHorizontalLayout.addWidget(self.propsCategoryComboBox)
+ self.addPropsCategoryLineEdit = QtWidgets.QLineEdit(parent=self.verticalLayoutWidget_2)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Fixed)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.addPropsCategoryLineEdit.sizePolicy().hasHeightForWidth())
+ self.addPropsCategoryLineEdit.setSizePolicy(sizePolicy)
+ self.addPropsCategoryLineEdit.setMinimumSize(QtCore.QSize(405, 0))
+ self.addPropsCategoryLineEdit.setObjectName("addPropsCategoryLineEdit")
+ self.propsCategoryHorizontalLayout.addWidget(self.addPropsCategoryLineEdit)
+ self.addPropsCategoryPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.addPropsCategoryPushButton.setStatusTip("")
+ self.addPropsCategoryPushButton.setObjectName("addPropsCategoryPushButton")
+ self.propsCategoryHorizontalLayout.addWidget(self.addPropsCategoryPushButton)
+ self.deletePropsCategoryPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.deletePropsCategoryPushButton.setObjectName("deletePropsCategoryPushButton")
+ self.propsCategoryHorizontalLayout.addWidget(self.deletePropsCategoryPushButton)
+ self.mainGridLayout.addLayout(self.propsCategoryHorizontalLayout, 3, 0, 1, 1)
+ self.datatypeHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.datatypeHorizontalLayout.setContentsMargins(0, 5, 0, 5)
+ self.datatypeHorizontalLayout.setObjectName("datatypeHorizontalLayout")
+ self.typeLabel = QtWidgets.QLabel(parent=self.verticalLayoutWidget_2)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Preferred)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.typeLabel.sizePolicy().hasHeightForWidth())
+ self.typeLabel.setSizePolicy(sizePolicy)
+ self.typeLabel.setMinimumSize(QtCore.QSize(130, 0))
+ self.typeLabel.setObjectName("typeLabel")
+ self.datatypeHorizontalLayout.addWidget(self.typeLabel)
+ self.typeComboBox = QtWidgets.QComboBox(parent=self.verticalLayoutWidget_2)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Preferred, QtWidgets.QSizePolicy.Policy.Fixed)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.typeComboBox.sizePolicy().hasHeightForWidth())
+ self.typeComboBox.setSizePolicy(sizePolicy)
+ self.typeComboBox.setMinimumSize(QtCore.QSize(0, 0))
+ self.typeComboBox.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents)
+ self.typeComboBox.setObjectName("typeComboBox")
+ self.datatypeHorizontalLayout.addWidget(self.typeComboBox)
+ self.typeLabelLineEdit = QtWidgets.QLineEdit(parent=self.verticalLayoutWidget_2)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.typeLabelLineEdit.sizePolicy().hasHeightForWidth())
+ self.typeLabelLineEdit.setSizePolicy(sizePolicy)
+ self.typeLabelLineEdit.setMinimumSize(QtCore.QSize(0, 0))
+ self.typeLabelLineEdit.setText("")
+ self.typeLabelLineEdit.setObjectName("typeLabelLineEdit")
+ self.datatypeHorizontalLayout.addWidget(self.typeLabelLineEdit)
+ self.typeLinkLineEdit = QtWidgets.QLineEdit(parent=self.verticalLayoutWidget_2)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.typeLinkLineEdit.sizePolicy().hasHeightForWidth())
+ self.typeLinkLineEdit.setSizePolicy(sizePolicy)
+ self.typeLinkLineEdit.setObjectName("typeLinkLineEdit")
+ self.datatypeHorizontalLayout.addWidget(self.typeLinkLineEdit)
+ self.addTypePushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.addTypePushButton.setObjectName("addTypePushButton")
+ self.datatypeHorizontalLayout.addWidget(self.addTypePushButton)
+ self.deleteTypePushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.deleteTypePushButton.setObjectName("deleteTypePushButton")
+ self.datatypeHorizontalLayout.addWidget(self.deleteTypePushButton)
+ self.mainGridLayout.addLayout(self.datatypeHorizontalLayout, 1, 0, 1, 1)
+ self.propsTableButtonHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.propsTableButtonHorizontalLayout.setContentsMargins(0, 5, 0, 5)
+ self.propsTableButtonHorizontalLayout.setObjectName("propsTableButtonHorizontalLayout")
+ self.addPropsRowPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Minimum)
+ sizePolicy.setHorizontalStretch(0)
+ sizePolicy.setVerticalStretch(0)
+ sizePolicy.setHeightForWidth(self.addPropsRowPushButton.sizePolicy().hasHeightForWidth())
+ self.addPropsRowPushButton.setSizePolicy(sizePolicy)
+ self.addPropsRowPushButton.setMinimumSize(QtCore.QSize(200, 0))
+ self.addPropsRowPushButton.setObjectName("addPropsRowPushButton")
+ self.propsTableButtonHorizontalLayout.addWidget(self.addPropsRowPushButton)
+ spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
+ self.propsTableButtonHorizontalLayout.addItem(spacerItem)
+ self.mainGridLayout.addLayout(self.propsTableButtonHorizontalLayout, 8, 0, 1, 1)
+ self.structureTableHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.structureTableHorizontalLayout.setContentsMargins(0, 5, 0, 5)
+ self.structureTableHorizontalLayout.setObjectName("structureTableHorizontalLayout")
+ self.addAttachmentPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.addAttachmentPushButton.setMinimumSize(QtCore.QSize(200, 0))
+ self.addAttachmentPushButton.setObjectName("addAttachmentPushButton")
+ self.structureTableHorizontalLayout.addWidget(self.addAttachmentPushButton)
+ spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
+ self.structureTableHorizontalLayout.addItem(spacerItem1)
+ self.mainGridLayout.addLayout(self.structureTableHorizontalLayout, 13, 0, 1, 1)
+ self.propertiesTableHeaderHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.propertiesTableHeaderHorizontalLayout.setContentsMargins(-1, 5, -1, 5)
+ self.propertiesTableHeaderHorizontalLayout.setObjectName("propertiesTableHeaderHorizontalLayout")
+ self.propertiesTableHeaderLabel = QtWidgets.QLabel(parent=self.verticalLayoutWidget_2)
+ self.propertiesTableHeaderLabel.setObjectName("propertiesTableHeaderLabel")
+ self.propertiesTableHeaderHorizontalLayout.addWidget(self.propertiesTableHeaderLabel)
+ self.mainGridLayout.addLayout(self.propertiesTableHeaderHorizontalLayout, 4, 0, 1, 1)
+ self.typeAttachmentsTableView = QtWidgets.QTableView(parent=self.verticalLayoutWidget_2)
+ self.typeAttachmentsTableView.setObjectName("typeAttachmentsTableView")
+ self.typeAttachmentsTableView.horizontalHeader().setStretchLastSection(True)
+ self.mainGridLayout.addWidget(self.typeAttachmentsTableView, 12, 0, 1, 1)
+ self.headerHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.headerHorizontalLayout.setContentsMargins(0, 20, 0, 20)
+ self.headerHorizontalLayout.setObjectName("headerHorizontalLayout")
+ self.headerLabel = QtWidgets.QLabel(parent=self.verticalLayoutWidget_2)
+ self.headerLabel.setObjectName("headerLabel")
+ self.headerHorizontalLayout.addWidget(self.headerLabel)
+ spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
+ self.headerHorizontalLayout.addItem(spacerItem2)
+ self.loadOntologyPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.loadOntologyPushButton.setObjectName("loadOntologyPushButton")
+ self.headerHorizontalLayout.addWidget(self.loadOntologyPushButton)
+ self.saveOntologyPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.saveOntologyPushButton.setObjectName("saveOntologyPushButton")
+ self.headerHorizontalLayout.addWidget(self.saveOntologyPushButton)
+ self.helpPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.helpPushButton.setObjectName("helpPushButton")
+ self.headerHorizontalLayout.addWidget(self.helpPushButton)
+ self.cancelPushButton = QtWidgets.QPushButton(parent=self.verticalLayoutWidget_2)
+ self.cancelPushButton.setObjectName("cancelPushButton")
+ self.headerHorizontalLayout.addWidget(self.cancelPushButton)
+ self.mainGridLayout.addLayout(self.headerHorizontalLayout, 0, 0, 1, 1)
+ self.gridLayout.addWidget(self.mainWidget, 0, 1, 1, 1)
+
+ self.retranslateUi(OntologyConfigurationBaseForm)
+ QtCore.QMetaObject.connectSlotsByName(OntologyConfigurationBaseForm)
+
+ def retranslateUi(self, OntologyConfigurationBaseForm):
+ _translate = QtCore.QCoreApplication.translate
+ OntologyConfigurationBaseForm.setWindowTitle(_translate("OntologyConfigurationBaseForm", "Ontology Editor"))
+ self.typePropsTableView.setToolTip(_translate("OntologyConfigurationBaseForm", "Table which lists and allows editing of all the properties associated with the above selected type"))
+ self.attachmentsHeaderLabel.setText(_translate("OntologyConfigurationBaseForm", "Attachments"))
+ self.propsCategoryLabel.setText(_translate("OntologyConfigurationBaseForm", "Property Category"))
+ self.propsCategoryComboBox.setToolTip(_translate("OntologyConfigurationBaseForm", "Select the category of properties to be listed below in the table"))
+ self.addPropsCategoryLineEdit.setToolTip(_translate("OntologyConfigurationBaseForm", "Enter the new category to be added to the data type"))
+ self.addPropsCategoryLineEdit.setPlaceholderText(_translate("OntologyConfigurationBaseForm", "Enter the new category to be added"))
+ self.addPropsCategoryPushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Add a new category of props to the data type, table below will be reset to empty list!"))
+ self.addPropsCategoryPushButton.setText(_translate("OntologyConfigurationBaseForm", "+ Add"))
+ self.deletePropsCategoryPushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Delete the selected category in the category combobox"))
+ self.deletePropsCategoryPushButton.setText(_translate("OntologyConfigurationBaseForm", "- Delete"))
+ self.typeLabel.setText(_translate("OntologyConfigurationBaseForm", "Data Type"))
+ self.typeComboBox.setToolTip(_translate("OntologyConfigurationBaseForm", "Select the type from the loaded ontology"))
+ self.typeLabelLineEdit.setToolTip(_translate("OntologyConfigurationBaseForm", "Modify the label property of the type"))
+ self.typeLabelLineEdit.setPlaceholderText(_translate("OntologyConfigurationBaseForm", "Modify the type label here"))
+ self.typeLinkLineEdit.setToolTip(_translate("OntologyConfigurationBaseForm", "Enter the link/url to be associated with this data-type"))
+ self.typeLinkLineEdit.setPlaceholderText(_translate("OntologyConfigurationBaseForm", "Enter the type for the link"))
+ self.addTypePushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Add a new type (structural or normal type) to the ontology data set."))
+ self.addTypePushButton.setText(_translate("OntologyConfigurationBaseForm", "+ Add"))
+ self.deleteTypePushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Delete the type with the full properties completely"))
+ self.deleteTypePushButton.setText(_translate("OntologyConfigurationBaseForm", "- Delete"))
+ self.addPropsRowPushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Add a new property row to the above table with empty values"))
+ self.addPropsRowPushButton.setText(_translate("OntologyConfigurationBaseForm", "+ Add Property"))
+ self.addAttachmentPushButton.setText(_translate("OntologyConfigurationBaseForm", "+ Add Attachment"))
+ self.propertiesTableHeaderLabel.setText(_translate("OntologyConfigurationBaseForm", "Properties"))
+ self.typeAttachmentsTableView.setToolTip(_translate("OntologyConfigurationBaseForm", "Table which displays the attachments for the above selected data type"))
+ self.headerLabel.setText(_translate("OntologyConfigurationBaseForm", "Edit Questionnaires"))
+ self.loadOntologyPushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Load ontology from local database"))
+ self.loadOntologyPushButton.setText(_translate("OntologyConfigurationBaseForm", "Load"))
+ self.saveOntologyPushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Save loaded ontology in local database"))
+ self.saveOntologyPushButton.setText(_translate("OntologyConfigurationBaseForm", "Save"))
+ self.helpPushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Navigate to the help page"))
+ self.helpPushButton.setText(_translate("OntologyConfigurationBaseForm", "Help"))
+ self.cancelPushButton.setToolTip(_translate("OntologyConfigurationBaseForm", "Close the editor"))
+ self.cancelPushButton.setText(_translate("OntologyConfigurationBaseForm", "Cancel"))
+
+
+if __name__ == "__main__":
+ import sys
+ app = QtWidgets.QApplication(sys.argv)
+ OntologyConfigurationBaseForm = QtWidgets.QWidget()
+ ui = Ui_OntologyConfigurationBaseForm()
+ ui.setupUi(OntologyConfigurationBaseForm)
+ OntologyConfigurationBaseForm.show()
+ sys.exit(app.exec())
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_configuration.ui b/pasta_eln/GUI/ontology_configuration/ontology_configuration.ui
new file mode 100644
index 00000000..570aa64d
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_configuration.ui
@@ -0,0 +1,537 @@
+
+
+ OntologyConfigurationBaseForm
+
+
+
+ 0
+ 0
+ 1243
+ 874
+
+
+
+ Ontology Editor
+
+
+
+
+
+
+ 10
+
+
+ 10
+
+
+ 10
+
+
+ 10
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+
+ 0
+ 0
+ 1221
+ 821
+
+
+
+
+ 30
+
+
+ 30
+
+
-
+
+
+ Table which lists and allows editing of all the properties associated with the above selected type
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ 0
+
+
+ 5
+
+
+ 0
+
+
+ 5
+
+
-
+
+
+ Attachments
+
+
+ 0
+
+
+
+
+
+ -
+
+
+ 0
+
+
+ 5
+
+
+ 0
+
+
+ 5
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 130
+ 0
+
+
+
+ Property Category
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Select the category of properties to be listed below in the table
+
+
+ QComboBox::AdjustToContents
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 405
+ 0
+
+
+
+ Enter the new category to be added to the data type
+
+
+ Enter the new category to be added
+
+
+
+ -
+
+
+ Add a new category of props to the data type, table below will be reset to empty list!
+
+
+
+
+
+ + Add
+
+
+
+ -
+
+
+ Delete the selected category in the category combobox
+
+
+ - Delete
+
+
+
+
+
+ -
+
+
+ 0
+
+
+ 5
+
+
+ 0
+
+
+ 5
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 130
+ 0
+
+
+
+ Data Type
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+ Select the type from the loaded ontology
+
+
+ QComboBox::AdjustToContents
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 0
+
+
+
+ Modify the label property of the type
+
+
+
+
+
+ Modify the type label here
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Enter the link/url to be associated with this data-type
+
+
+ Enter the type for the link
+
+
+
+ -
+
+
+ Add a new type (structural or normal type) to the ontology data set.
+
+
+ + Add
+
+
+
+ -
+
+
+ Delete the type with the full properties completely
+
+
+ - Delete
+
+
+
+
+
+ -
+
+
+ 0
+
+
+ 5
+
+
+ 0
+
+
+ 5
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 200
+ 0
+
+
+
+ Add a new property row to the above table with empty values
+
+
+ + Add Property
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Expanding
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ 0
+
+
+ 5
+
+
+ 0
+
+
+ 5
+
+
-
+
+
+
+ 200
+ 0
+
+
+
+ + Add Attachment
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Expanding
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ 5
+
+
+ 5
+
+
-
+
+
+ Properties
+
+
+
+
+
+ -
+
+
+ Table which displays the attachments for the above selected data type
+
+
+ true
+
+
+
+ -
+
+
+ 0
+
+
+ 20
+
+
+ 0
+
+
+ 20
+
+
-
+
+
+ Edit Questionnaires
+
+
+ 0
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Load ontology from local database
+
+
+ Load
+
+
+
+ -
+
+
+ Save loaded ontology in local database
+
+
+ Save
+
+
+
+ -
+
+
+ Navigate to the help page
+
+
+ Help
+
+
+
+ -
+
+
+ Close the editor
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_configuration_constants.py b/pasta_eln/GUI/ontology_configuration/ontology_configuration_constants.py
new file mode 100644
index 00000000..37ed3dac
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_configuration_constants.py
@@ -0,0 +1,17 @@
+""" Constants used in the ontology configuration """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: ontology_configuration_constants.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+# Ontology Table view constants
+PROPS_TABLE_REQUIRED_COLUMN_INDEX = 4
+PROPS_TABLE_DELETE_COLUMN_INDEX = 6
+PROPS_TABLE_REORDER_COLUMN_INDEX = 7
+
+ATTACHMENT_TABLE_DELETE_COLUMN_INDEX = 2
+ATTACHMENT_TABLE_REORDER_COLUMN_INDEX = 3
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_configuration_extended.py b/pasta_eln/GUI/ontology_configuration/ontology_configuration_extended.py
new file mode 100644
index 00000000..3b9c7682
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_configuration_extended.py
@@ -0,0 +1,409 @@
+""" OntologyConfigurationForm which is extended from the Ui_OntologyConfigurationBaseForm """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: ontology_configuration_extended.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+import sys
+from typing import Any
+
+from PySide6 import QtWidgets
+from PySide6.QtWidgets import QApplication
+from cloudant.document import Document
+
+from .create_type_dialog_extended import CreateTypeDialog
+from .ontology_config_generic_exception import OntologyConfigGenericException
+from .ontology_config_key_not_found_exception import \
+ OntologyConfigKeyNotFoundException
+from .ontology_document_null_exception import OntologyDocumentNullException
+from .ontology_attachments_tableview_data_model import OntologyAttachmentsTableViewModel
+from .ontology_configuration import Ui_OntologyConfigurationBaseForm
+from .ontology_configuration_constants import PROPS_TABLE_DELETE_COLUMN_INDEX, PROPS_TABLE_REORDER_COLUMN_INDEX, \
+ PROPS_TABLE_REQUIRED_COLUMN_INDEX, ATTACHMENT_TABLE_DELETE_COLUMN_INDEX, ATTACHMENT_TABLE_REORDER_COLUMN_INDEX
+from .ontology_props_tableview_data_model import OntologyPropsTableViewModel
+from .delete_column_delegate import DeleteColumnDelegate
+from .reorder_column_delegate import ReorderColumnDelegate
+from .required_column_delegate import RequiredColumnDelegate
+from .utility_functions import adjust_ontology_data_to_v3, show_message, \
+ get_next_possible_structural_level_label
+
+
+class OntologyConfigurationForm(Ui_OntologyConfigurationBaseForm):
+ """ OntologyConfigurationForm class which is extended from the Ui_OntologyConfigurationBaseForm
+ and contains the UI elements and related logic"""
+
+ def __new__(cls, *_: Any, **__: Any) -> Any:
+ """
+ Instantiates the OntologyConfigurationForm
+ """
+ return super(OntologyConfigurationForm, cls).__new__(cls)
+
+ def __init__(self,
+ ontology_document: Document) -> None:
+ """
+ Constructs the ontology data editor
+
+ Args:
+ ontology_document (Document): Ontology data document from couch DB instance passed by the parent.
+
+ Raises:
+ OntologyDocumentNullException: Raised when passed in argument @ontology_document is null.
+ """
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+
+ self.ontology_loaded: bool = False
+ self.ontology_types: Any = {}
+ self.selected_type_properties: dict[str, list[dict[str, Any]]] | Any = {}
+
+ # Set up the UI elements
+ self.instance = QtWidgets.QDialog()
+ super().setupUi(self.instance)
+
+ # Gets the ontology data from db and adjust the data to the latest version
+ if not ontology_document:
+ raise OntologyDocumentNullException("Null document passed for ontology data", {})
+
+ self.ontology_document: Document = ontology_document
+ adjust_ontology_data_to_v3(self.ontology_document)
+
+ # Instantiates property & attachment table models along with the column delegates
+ self.props_table_data_model = OntologyPropsTableViewModel()
+ self.attachments_table_data_model = OntologyAttachmentsTableViewModel()
+
+ self.required_column_delegate_props_table = RequiredColumnDelegate()
+ self.delete_column_delegate_props_table = DeleteColumnDelegate()
+ self.reorder_column_delegate_props_table = ReorderColumnDelegate()
+ self.delete_column_delegate_attach_table = DeleteColumnDelegate()
+ self.reorder_column_delegate_attach_table = ReorderColumnDelegate()
+
+ self.typePropsTableView.setItemDelegateForColumn(PROPS_TABLE_REQUIRED_COLUMN_INDEX,
+ self.required_column_delegate_props_table)
+ self.typePropsTableView.setItemDelegateForColumn(PROPS_TABLE_DELETE_COLUMN_INDEX,
+ self.delete_column_delegate_props_table)
+ self.typePropsTableView.setItemDelegateForColumn(PROPS_TABLE_REORDER_COLUMN_INDEX,
+ self.reorder_column_delegate_props_table)
+ self.typePropsTableView.setModel(self.props_table_data_model)
+
+ self.typeAttachmentsTableView.setItemDelegateForColumn(
+ ATTACHMENT_TABLE_DELETE_COLUMN_INDEX,
+ self.delete_column_delegate_attach_table)
+ self.typeAttachmentsTableView.setItemDelegateForColumn(
+ ATTACHMENT_TABLE_REORDER_COLUMN_INDEX,
+ self.reorder_column_delegate_attach_table)
+ self.typeAttachmentsTableView.setModel(self.attachments_table_data_model)
+
+ # Create the dialog for new type creation
+ self.create_type_dialog = CreateTypeDialog(self.create_type_accepted_callback, self.create_type_rejected_callback)
+
+ # Set up the slots for the UI items
+ self.setup_slots()
+
+ def type_combo_box_changed(self,
+ new_type_selected: Any) -> None:
+ """
+ Combobox value changed callback for the selected type
+ Args:
+ new_type_selected (Any): Newly set value for the combobox.
+
+ Returns: Nothing
+
+ Raises:
+ OntologyConfigKeyNotFoundException: Raised when passed in argument @new_type_selected is not found in ontology_types
+
+ """
+ self.logger.info("New type selected in UI: {%s}", new_type_selected)
+ self.clear_ui()
+ if new_type_selected and self.ontology_types:
+ if new_type_selected not in self.ontology_types:
+ raise OntologyConfigKeyNotFoundException(f"Key {new_type_selected} "
+ f"not found in ontology_types", {})
+ if new_type_selected in self.ontology_types:
+ selected_type = self.ontology_types.get(new_type_selected)
+ # Get the properties for the selected type and store the list in selected_type_properties
+ self.selected_type_properties = selected_type.get('prop')
+
+ # Type label is set in a line edit
+ self.typeLabelLineEdit.setText(selected_type.get('label'))
+
+ # Type link is set in a line edit
+ self.typeLinkLineEdit.setText(selected_type.get('link'))
+
+ # Gets the attachment data from selected type and set it in table view
+ self.attachments_table_data_model.update(selected_type.get('attachments'))
+
+ # Reset the props category combo-box
+ self.propsCategoryComboBox.addItems(list(self.selected_type_properties.keys())
+ if self.selected_type_properties else [])
+ self.propsCategoryComboBox.setCurrentIndex(0)
+
+ def category_combo_box_changed(self,
+ new_selected_prop_category: Any) -> None:
+ """
+ Combobox value changed callback for the selected type property categories
+ Args:
+ new_selected_prop_category (Any): Newly set value for the combobox.
+
+ Returns: Nothing
+ """
+ self.logger.info("New property category selected in UI: {%s}", new_selected_prop_category)
+ if new_selected_prop_category and self.selected_type_properties:
+ # Update the property table as per the selected property category from combobox
+ self.props_table_data_model.update(self.selected_type_properties.get(new_selected_prop_category))
+
+ def add_new_prop_category(self) -> None:
+ """
+ Click event handler for adding new property category
+ Returns: Nothing
+ """
+ new_category = self.addPropsCategoryLineEdit.text()
+ if not new_category:
+ show_message("Enter non-null/valid category name!!.....")
+ return None
+ if not self.ontology_loaded or self.ontology_types is None:
+ show_message("Load the ontology data first....")
+ return None
+ if new_category in self.selected_type_properties.keys():
+ show_message("Category already exists....")
+ return None
+ # Add the new category to the property list and refresh the category combo box
+ self.logger.info("User added new category: {%s}", new_category)
+ self.selected_type_properties[new_category] = []
+ self.propsCategoryComboBox.clear()
+ self.propsCategoryComboBox.addItems(list(self.selected_type_properties.keys()))
+ self.propsCategoryComboBox.setCurrentIndex(len(self.selected_type_properties.keys()) - 1)
+ return None
+
+ def delete_selected_prop_category(self) -> None:
+ """
+ Click event handler for deleting the selected property category
+ Returns: Nothing
+ """
+ selected_category = self.propsCategoryComboBox.currentText()
+ if self.selected_type_properties is None:
+ show_message("Load the ontology data first....")
+ return None
+ if selected_category and selected_category in self.selected_type_properties.keys():
+ self.logger.info("User deleted the selected category: {%s}", selected_category)
+ self.selected_type_properties.pop(selected_category)
+ self.propsCategoryComboBox.clear()
+ self.typePropsTableView.model().update([])
+ self.propsCategoryComboBox.addItems(list(self.selected_type_properties.keys()))
+ self.propsCategoryComboBox.setCurrentIndex(len(self.selected_type_properties.keys()) - 1)
+ return None
+
+ def update_structure_label(self,
+ modified_type_label: str) -> None:
+ """
+ Value changed callback for the type label line edit
+
+ Args:
+ modified_type_label (str): Modified ontology type label
+
+ Returns: Nothing
+ """
+ current_type = self.typeComboBox.currentText()
+ if modified_type_label and current_type in self.ontology_types:
+ self.ontology_types.get(current_type)["label"] = modified_type_label
+
+ def update_type_link(self, modified_link: str) -> None:
+ """
+ Value changed callback for the link line edit
+
+ Args:
+ modified_link (str): Modified link to be set for the selected type
+
+ Returns: Nothing
+ """
+ current_type = self.typeComboBox.currentText()
+ if modified_link and current_type in self.ontology_types:
+ self.ontology_types.get(current_type)["link"] = modified_link
+
+ def delete_selected_type(self) -> None:
+ """
+ Delete the selected type from the type selection combo-box and also from the loaded ontology_types
+
+ Returns: Nothing
+ """
+ selected_type = self.typeComboBox.currentText()
+ if not self.ontology_loaded:
+ show_message("Load the ontology data first....")
+ return
+ if self.ontology_types is None or self.ontology_document is None:
+ show_message("Load the ontology data first....")
+ return
+ if (selected_type and selected_type in self.ontology_types
+ and selected_type in self.ontology_document):
+ self.logger.info("User deleted the selected type: {%s}", selected_type)
+ self.ontology_types.pop(selected_type)
+ self.ontology_document.pop(selected_type)
+ self.typeComboBox.clear()
+ self.typeComboBox.addItems(self.ontology_types.keys())
+ self.typeComboBox.setCurrentIndex(0)
+
+ def clear_ui(self) -> None:
+ """
+ Clear the UI elements including the tables.
+ Invoked when the type combobox selection changes
+ Returns: None
+
+ """
+ self.typeLabelLineEdit.clear()
+ self.typeLinkLineEdit.clear()
+ self.propsCategoryComboBox.clear()
+ self.addPropsCategoryLineEdit.clear()
+ self.typePropsTableView.model().update([])
+ self.typeAttachmentsTableView.model().update([])
+
+ def create_type_accepted_callback(self) -> None:
+ """
+ Callback for the OK button of CreateTypeDialog to create a new type in the ontology data set
+
+ Returns: Nothing
+ """
+ title = self.create_type_dialog.titleLineEdit.text()
+ label = self.create_type_dialog.labelLineEdit.text()
+ self.create_type_dialog.clear_ui()
+ self.create_new_type(title, label)
+
+ def create_type_rejected_callback(self) -> None:
+ """
+ Callback for the cancel button of CreateTypeDialog
+
+ Returns: Nothing
+ """
+ self.create_type_dialog.clear_ui()
+
+ def show_create_type_dialog(self) -> None:
+ """
+ Opens a dialog which allows the user to enter the details to create a new type (structural or normal)
+ Returns: Nothing
+ """
+ if self.ontology_types is not None and self.ontology_loaded:
+ structural_title = get_next_possible_structural_level_label(self.ontology_types.keys())
+ self.create_type_dialog.set_structural_level_title(structural_title)
+ self.create_type_dialog.show()
+ else:
+ show_message("Load the ontology data first...")
+
+ def setup_slots(self) -> None:
+ """
+ Set up the slots for the UI elements of Ontology editor
+ Returns: Nothing
+ """
+ self.logger.info("Setting up slots for the editor..")
+ # Slots for the buttons
+ self.loadOntologyPushButton.clicked.connect(self.load_ontology_data)
+ self.addPropsRowPushButton.clicked.connect(self.props_table_data_model.add_data_row)
+ self.addAttachmentPushButton.clicked.connect(self.attachments_table_data_model.add_data_row)
+ self.saveOntologyPushButton.clicked.connect(self.save_ontology)
+ self.addPropsCategoryPushButton.clicked.connect(self.add_new_prop_category)
+ self.deletePropsCategoryPushButton.clicked.connect(self.delete_selected_prop_category)
+ self.deleteTypePushButton.clicked.connect(self.delete_selected_type)
+ self.addTypePushButton.clicked.connect(self.show_create_type_dialog)
+
+ # Slots for the combo-boxes
+ self.typeComboBox.currentTextChanged.connect(self.type_combo_box_changed)
+ self.propsCategoryComboBox.currentTextChanged.connect(self.category_combo_box_changed)
+
+ # Slots for line edits
+ self.typeLabelLineEdit.textChanged[str].connect(self.update_structure_label)
+ self.typeLinkLineEdit.textChanged[str].connect(self.update_type_link)
+
+ # Slots for the delegates
+ self.delete_column_delegate_props_table.delete_clicked_signal.connect(self.props_table_data_model.delete_data)
+ self.reorder_column_delegate_props_table.re_order_signal.connect(self.props_table_data_model.re_order_data)
+
+ self.delete_column_delegate_attach_table.delete_clicked_signal.connect(
+ self.attachments_table_data_model.delete_data)
+ self.reorder_column_delegate_attach_table.re_order_signal.connect(self.attachments_table_data_model.re_order_data)
+
+ def load_ontology_data(self) -> None:
+ """
+ Load button click event handler which loads the data in the UI
+ Returns:
+
+ """
+ self.logger.info("User loaded the ontology data in UI")
+ if self.ontology_document is None:
+ raise OntologyConfigGenericException("Null ontology_document, erroneous app state", {})
+ # Load the ontology types from the db document
+ for data in self.ontology_document:
+ if isinstance(self.ontology_document[data], dict):
+ self.ontology_types[data] = self.ontology_document[data]
+ self.ontology_loaded = True
+
+ # Set the types in the type selector combo-box
+ self.typeComboBox.clear()
+ self.typeComboBox.addItems(self.ontology_types.keys())
+ self.typeComboBox.setCurrentIndex(0)
+
+ def save_ontology(self) -> None:
+ """
+ Save the modified ontology document data in database
+ """
+ self.logger.info("User saved the ontology data document!!")
+ self.ontology_document.save()
+ show_message("Ontology data saved successfully..")
+
+ def create_new_type(self,
+ title: str,
+ label: str) -> None:
+ """
+ Add a new type to the loaded ontology_data from the db
+ Args:
+ title (str): The new key entry used for the ontology_data
+ label (str): The new label set for the new type entry in ontology_data
+
+ Returns:
+
+ """
+ if self.ontology_document is None or self.ontology_types is None:
+ self.logger.error("Null ontology_document/ontology_types, erroneous app state")
+ raise OntologyConfigGenericException("Null ontology_document/ontology_types, erroneous app state", {})
+ if title in self.ontology_document:
+ show_message(f"Type (title: {title} label: {label}) cannot be added since it exists in DB already....")
+ else:
+ if title is None:
+ self.logger.warning("Enter non-null/valid title!!.....")
+ show_message("Enter non-null/valid title!!.....")
+ return
+ self.logger.info("User created a new type and added "
+ "to the ontology document: Title: {%s}, Label: {%s}", title, label)
+ empty_type = {
+ "link": "",
+ "label": label,
+ "prop": {
+ "default": []
+ },
+ "attachments": []
+ }
+ self.ontology_document[title] = empty_type
+ self.ontology_types[title] = empty_type
+ self.typeComboBox.clear()
+ self.typeComboBox.addItems(self.ontology_types.keys())
+ self.typeComboBox.setCurrentIndex(len(self.ontology_types) - 1)
+ show_message(f"Type (title: {title} label: {label}) has been added....")
+
+
+def get_gui(ontology_document: Document) -> tuple[
+ QApplication | QApplication, QtWidgets.QDialog, OntologyConfigurationForm]:
+ """
+ Creates the editor UI and return it
+ Args:
+ ontology_document (object): Ontology document from the couch DB.
+ Returns:
+
+ """
+ instance = QApplication.instance()
+ if instance is None:
+ application = QApplication(sys.argv)
+ else:
+ application = instance
+
+ ontology_form: OntologyConfigurationForm = OntologyConfigurationForm(ontology_document)
+
+ return application, ontology_form.instance, ontology_form
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_document_null_exception.py b/pasta_eln/GUI/ontology_configuration/ontology_document_null_exception.py
new file mode 100644
index 00000000..bbfe1ac0
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_document_null_exception.py
@@ -0,0 +1,38 @@
+""" Custom exception class for null ontology document """
+
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023.
+#
+# Author: Jithu Murugan
+# Filename: ontology_document_null_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+#
+# Author: Jithu Murugan
+# Filename: ontology_document_null_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+class OntologyDocumentNullException(Exception):
+ """
+ Custom exception class for null ontology document
+ """
+
+ def __init__(self,
+ message: str,
+ detailed_errors: dict[str, str]):
+ """
+ Constructs OntologyDocumentNullException
+ Args:
+ message (str): Error message to be thrown
+ detailed_errors (dict): Additional errors passed via exception
+ """
+ super().__init__(message)
+ self.message = message
+ self.detailed_errors = detailed_errors
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_props_tableview_data_model.py b/pasta_eln/GUI/ontology_configuration/ontology_props_tableview_data_model.py
new file mode 100644
index 00000000..f54c3ec4
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_props_tableview_data_model.py
@@ -0,0 +1,44 @@
+""" Table view model used for the properties table in the ontology editor """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: ontology_props_tableview_data_model.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+from typing import Union
+
+from PySide6.QtWidgets import QWidget
+
+from .ontology_tableview_data_model import OntologyTableViewModel
+
+
+class OntologyPropsTableViewModel(OntologyTableViewModel):
+ """
+ Data-model for the ontology property table view
+ """
+
+ def __init__(self,
+ parent: Union[QWidget | None] = None):
+ """
+ Initialize the data model representing the properties of a type in the ontology document
+ Args:
+ parent (QWidget): Parent view or the widget
+ """
+ super().__init__(parent)
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+ self.data_set = []
+ self.data_name_map = {
+ 0: "name",
+ 1: "query",
+ 2: "list",
+ 3: "link",
+ 4: "required",
+ 5: "unit",
+ 6: "delete",
+ 7: "re-order"
+ }
+ self.header_values: list[str] = list(self.data_name_map.values())
+ self.columns_count: int = len(self.header_values)
diff --git a/pasta_eln/GUI/ontology_configuration/ontology_tableview_data_model.py b/pasta_eln/GUI/ontology_configuration/ontology_tableview_data_model.py
new file mode 100644
index 00000000..b872db51
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/ontology_tableview_data_model.py
@@ -0,0 +1,226 @@
+""" OntologyTableViewModel Generic module used for the table views """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: ontology_attachments_tableview_data_model.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+# import logging
+import logging
+from typing import Union, Any
+
+from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QPersistentModelIndex, Slot
+from PySide6.QtWidgets import QWidget
+
+
+class OntologyTableViewModel(QAbstractTableModel):
+ """
+ Abstracted data-model base for the ontology table views
+ """
+
+ def __init__(self,
+ parent: Union[QWidget | None] = None):
+ """
+ Initialize the data model representing attachments from ontology document in the database
+ Args:
+ parent (QWidget): Parent view or widget
+ """
+ super().__init__(parent)
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+ self.data_set: list[dict[Any, Any]] | Any = []
+ self.data_name_map: dict[int, str] = {}
+ self.header_values: list[str] = []
+ self.columns_count = 0
+
+ def hasChildren(self, parent: Union[QModelIndex, QPersistentModelIndex] = ...) -> bool: # type: ignore[assignment]
+ """
+ Returns whether the model has children
+ Args:
+ parent (Union[QModelIndex, QPersistentModelIndex]): Parent index
+
+ Returns: False since it's a tree view model
+
+ """
+ return False # Since it's a table model, the children are not supported
+
+ def headerData(self,
+ section: int,
+ orientation: Qt.Orientation,
+ role: int = Qt.ItemDataRole) -> Any: # type: ignore[assignment]
+ """
+ Returns the header data from self.header_values
+ Args:
+ section (int): Index section of the table header
+ orientation (Qt.Orientation): Orientation of the table header
+ role (int): Display role for the table
+
+ Returns (Any): The header name for the column represented by the section index
+
+ """
+ if orientation == Qt.Horizontal and role == Qt.DisplayRole:
+ return self.header_values[section].capitalize()
+ return super().headerData(section, orientation, role)
+
+ def update(self,
+ updated_table_data: list[dict[Any, Any]] | Any) -> None:
+ """
+ Updating the table data model
+ Args:
+ updated_table_data (dict): Newly set data model
+
+ Returns: None
+
+ """
+ self.logger.info("Table Data updated..")
+ self.data_set = updated_table_data
+ self.layoutChanged.emit()
+
+ def rowCount(self,
+ parent: Union[QModelIndex, QPersistentModelIndex] = ...) -> int: # type: ignore[assignment]
+ """
+ Returns the row count for the table under the given parent
+ Args:
+ parent (Union[QModelIndex, QPersistentModelIndex]): Parent index
+
+ Returns (int): Row count
+
+ """
+ return len(self.data_set) \
+ if self.data_set \
+ else 0
+
+ def columnCount(self,
+ parent: Union[QModelIndex, QPersistentModelIndex] = ...) -> int: # type: ignore[assignment]
+ """
+ Returns the column count for the table under the given parent
+ Args:
+ parent (Union[QModelIndex, QPersistentModelIndex]): Parent index
+
+ Returns (int): Column count
+
+ """
+ return self.columns_count
+
+ def setData(self,
+ index: Union[QModelIndex, QPersistentModelIndex],
+ value: Any,
+ role: int = Qt.ItemDataRole) -> bool: # type: ignore[assignment]
+ """
+ Sets the data for the table cell represented by the index. The data is set only for the below cases
+ - EditRole: When the data in the cell is edited via line edit
+ - UserRole: When the cell contains radio button and is set via required_column_delegate
+ Args:
+ index (Union[QModelIndex, QPersistentModelIndex]): Index with row & column representing the table cell
+ value (Any): The updated value from the table view
+ role (int): Role for which the data is set
+
+ Returns (bool): True when data is set, otherwise false
+
+ """
+ if index.isValid() and role in (Qt.EditRole, Qt.UserRole):
+ row_index = index.row()
+ column = self.data_name_map.get(index.column())
+ self.data_set[row_index][column] = value
+ self.dataChanged.emit(index, index, role)
+ return True
+ return False
+
+ def data(self,
+ index: Union[QModelIndex, QPersistentModelIndex],
+ role: int = Qt.ItemDataRole) -> Any: # type: ignore[assignment]
+ """
+ Gets the data from the table cell represented by the index. Data is only retrieved for the following roles:
+ - DisplayRole: When the table needs to be displayed
+ - EditRole: When the data in the cell is edited via line edit
+ - UserRole: When the cell contains radio button and is set via required_column_delegate
+ Args:
+ index (Union[QModelIndex, QPersistentModelIndex]): Index of the respective table cell to retrieve data
+ role (int): Role for the data operation.
+
+ Returns (Any): String data representation if available, otherwise null-string/None
+
+ """
+ if (index.isValid() and
+ role in (Qt.DisplayRole, Qt.EditRole, Qt.UserRole)):
+ row = index.row()
+ column = index.column()
+ value = self.data_set[row].get(self.data_name_map.get(column))
+ return str(value if value else '')
+ else:
+ return None
+
+ def flags(self,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> Qt.ItemFlags:
+ """
+ Flags required for the table cell
+ Args:
+ index (Union[QModelIndex, QPersistentModelIndex]): Table cell index
+
+ Returns (Qt.ItemFlags): The combinations of flags for the cell represented by the index
+
+ """
+ if index.isValid():
+ return (Qt.ItemIsEditable # type: ignore[operator]
+ | Qt.ItemIsSelectable
+ | Qt.ItemIsEnabled)
+ return None # type: ignore[return-value]
+
+ @Slot(int)
+ def delete_data(self, position: int) -> None:
+ """
+ Slot invoked to delete the data from data set at the specific position
+ Args:
+ position (int): Position of the data to be deleted from the data set
+
+ Returns: None
+
+ """
+ try:
+ data_deleted = self.data_set.pop(position)
+ except IndexError:
+ self.logger.warning("Invalid position: {%s}", position)
+ return None
+ self.logger.info("Deleted (row: {%s}, data: {%s})...", position, data_deleted)
+ self.layoutChanged.emit()
+ return None
+
+ @Slot(int)
+ def re_order_data(self,
+ position: int) -> None:
+ """
+ Slot invoked to re-order the data position in the data set. Data at the position: row will be moved above in the set
+ Args:
+ position (int): Position of the data to be shifted up.
+
+ Returns: None
+
+ """
+ try:
+ data_to_be_pushed = self.data_set.pop(position)
+ except IndexError:
+ self.logger.warning("Invalid position: {%s}", position)
+ return None
+ shift_position = position - 1
+ shift_position = shift_position if shift_position > 0 else 0
+ self.data_set.insert(shift_position, data_to_be_pushed)
+ self.logger.info("Reordered the data, Actual position: {%s}, "
+ "New Position: {%s}, "
+ "data: {%s})", position,
+ shift_position,
+ data_to_be_pushed)
+ self.layoutChanged.emit()
+ return None
+
+ def add_data_row(self) -> None:
+ """
+ Add an empty row to the table data set
+ Returns: None
+
+ """
+ if self.data_set is not None:
+ self.logger.info("Added new row...")
+ self.data_set.insert(len(self.data_set), {})
+ self.layoutChanged.emit()
+ return None
diff --git a/pasta_eln/GUI/ontology_configuration/reorder_column_delegate.py b/pasta_eln/GUI/ontology_configuration/reorder_column_delegate.py
new file mode 100644
index 00000000..8c079291
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/reorder_column_delegate.py
@@ -0,0 +1,92 @@
+""" ReorderColumnDelegate for the table views """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: reorder_column_delegate.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+from typing import Union
+
+from PySide6.QtCore import QModelIndex, QPersistentModelIndex, Signal, QEvent, QAbstractItemModel
+from PySide6.QtGui import QPainter
+from PySide6.QtWidgets import QStyledItemDelegate, QPushButton, QWidget, QStyleOptionViewItem, QStyleOptionButton, \
+ QStyle, QApplication
+
+from .utility_functions import is_click_within_bounds
+
+
+class ReorderColumnDelegate(QStyledItemDelegate):
+ """
+ Delegate for creating the icons for the re-order column in the ontology editor tables
+ """
+ re_order_signal = Signal(int)
+
+ def __init__(self) -> None:
+ """
+ Constructor
+ """
+ super().__init__()
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+
+ def paint(self,
+ painter: QPainter,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> None:
+ """
+ Draws the required re-order button within the cell represented by index
+ Args:
+ painter (QPainter): Painter instance for painting the button.
+ option (QStyleOptionViewItem): Style option for the cell represented by index.
+ index (Union[QModelIndex, QPersistentModelIndex]): Table cell index
+
+ Returns: None
+
+ """
+ button = QPushButton()
+ opt = QStyleOptionButton()
+ opt.state = QStyle.State_Active | QStyle.State_Enabled # type: ignore[operator]
+ opt.rect = option.rect
+ opt.text = "^"
+ QApplication.style().drawControl(QStyle.CE_PushButton, opt, painter, button)
+
+ def createEditor(self,
+ parent: QWidget,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> QWidget:
+ """
+ Disable the editor for the whole re-order column by simply returning None
+ Args:
+ parent (QWidget): Parent table view.
+ option (QStyleOptionViewItem): Style option for the cell represented by index.
+ index (Union[QModelIndex, QPersistentModelIndex]): Cell index.
+
+ Returns: None
+
+ """
+ return None # type: ignore[return-value]
+
+ def editorEvent(self,
+ event: QEvent,
+ model: QAbstractItemModel,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> bool:
+ """
+ In case of mouse click event, the re_order_signal is emitted for the respective table cell position
+ Args:
+ event (QEvent): The editor event information.
+ model (QAbstractItemModel): Model data representing the table view.
+ option (QStyleOptionViewItem): QStyleOption for the table cell.
+ index (Union[QModelIndex, QPersistentModelIndex]): Table cell index.
+
+ Returns (bool): True/False
+
+ """
+ if is_click_within_bounds(event, option):
+ row = index.row()
+ self.logger.info("Re-order signal emitted for the position: {%s} in the table..", row)
+ self.re_order_signal.emit(row)
+ return True
+ return False
diff --git a/pasta_eln/GUI/ontology_configuration/required_column_delegate.py b/pasta_eln/GUI/ontology_configuration/required_column_delegate.py
new file mode 100644
index 00000000..411b2a26
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/required_column_delegate.py
@@ -0,0 +1,92 @@
+""" RequiredColumnDelegate module used for the table views """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: required_column_delegate.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+from typing import Union
+
+from PySide6.QtCore import QModelIndex, QPersistentModelIndex, QEvent, QAbstractItemModel, QRect, Qt
+from PySide6.QtGui import QPainter
+from PySide6.QtWidgets import QStyledItemDelegate, QWidget, QStyleOptionViewItem, QStyleOptionButton, \
+ QStyle, QApplication
+
+
+class RequiredColumnDelegate(QStyledItemDelegate):
+ """
+ Delegate for creating the radio buttons for the required column in ontology editor tables
+ """
+
+ def __init__(self) -> None:
+ """
+ Constructor
+ """
+ super().__init__()
+ self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__)
+
+ def paint(self,
+ painter: QPainter,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> None:
+ """
+ Draws the required radio button within the cell represented by index
+ Args:
+ painter (QPainter): Painter instance for painting the button.
+ option (QStyleOptionViewItem): Style option for the cell represented by index.
+ index (Union[QModelIndex, QPersistentModelIndex]): Table cell index.
+
+ Returns: None
+
+ """
+ widget = option.widget
+ style = widget.style() if widget else QApplication.style()
+ opt = QStyleOptionButton()
+ opt.rect = QRect(option.rect.left() + option.rect.width() / 2 - 5,
+ option.rect.top(),
+ option.rect.width(),
+ option.rect.height())
+ is_user_role = index.data(Qt.UserRole) == 'True' # type: ignore[arg-type]
+ opt.state = QStyle.State_On \
+ if is_user_role \
+ else QStyle.State_Off
+ style.drawControl(QStyle.CE_RadioButton, opt, painter, widget)
+
+ def editorEvent(self,
+ event: QEvent,
+ model: QAbstractItemModel,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> bool:
+ """
+ In case of mouse click event, the model data is toggled for the respective table cell index
+ Args:
+ event (QEvent): The editor event information.
+ model (QAbstractItemModel): Model data representing the table view.
+ option (QStyleOptionViewItem): QStyleOption for the table cell.
+ index (Union[QModelIndex, QPersistentModelIndex]): Table cell index.
+
+ Returns (bool): True/False
+
+ """
+ if event.type() == QEvent.MouseButtonRelease:
+ model.setData(index, str(not index.data(Qt.UserRole) == 'True'), Qt.UserRole) # type: ignore[arg-type]
+ return super().editorEvent(event, model, option, index)
+
+ def createEditor(self,
+ parent: QWidget,
+ option: QStyleOptionViewItem,
+ index: Union[QModelIndex, QPersistentModelIndex]) -> QWidget:
+ """
+ Disable the editor for the whole required column by simply returning None
+ Args:
+ parent (QWidget): Parent table view.
+ option (QStyleOptionViewItem): Style option for the cell represented by index.
+ index (Union[QModelIndex, QPersistentModelIndex]): Cell index.
+
+ Returns: None
+
+ """
+ return None # type: ignore[return-value]
diff --git a/pasta_eln/GUI/ontology_configuration/utility_functions.py b/pasta_eln/GUI/ontology_configuration/utility_functions.py
new file mode 100644
index 00000000..50cab182
--- /dev/null
+++ b/pasta_eln/GUI/ontology_configuration/utility_functions.py
@@ -0,0 +1,139 @@
+""" Utility function used by the ontology configuration module """
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: utility_functions.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+from typing import Any
+
+from PySide6.QtCore import QEvent
+from PySide6.QtGui import QMouseEvent
+from PySide6.QtWidgets import QStyleOptionViewItem, QMessageBox
+from cloudant import CouchDB
+from cloudant.document import Document
+
+
+def is_click_within_bounds(event: QEvent,
+ option: QStyleOptionViewItem) -> bool:
+ """
+ Check if the click event happened within the rect area of the QStyleOptionViewItem
+ Args:
+ event (QEvent): Mouse event captured from the view
+ option (QStyleOptionViewItem): Option send during the edit event
+
+ Returns (bool): True/False
+
+ """
+ if event and option:
+ if event.type() == QEvent.MouseButtonRelease:
+ e = QMouseEvent(event)
+ click_x = e.x()
+ click_y = e.y()
+ r = option.rect
+ if r.left() < click_x < r.left() + r.width():
+ if r.top() < click_y < r.top() + r.height():
+ return True
+ return False
+
+
+def adjust_ontology_data_to_v3(ontology_doc: Document) -> None:
+ """Correct the ontology data and add missing information if the loaded data is of version < 3.0
+
+ Args:
+ ontology_doc (Document): Ontology document loaded from the database
+
+ Returns: None
+ """
+ if not ontology_doc:
+ return None
+ type_structures = {}
+ for data in ontology_doc:
+ if isinstance(ontology_doc[data], dict):
+ type_structures[data] = ontology_doc[data]
+ ontology_doc["-version"] = 3
+ if type_structures:
+ for _, type_structure in type_structures.items():
+ type_structure.setdefault("attachments", [])
+ props = type_structure.get("prop")
+ if props is None:
+ type_structure["prop"] = {"default": []}
+ continue
+ if not isinstance(props, dict):
+ type_structure["prop"] = {"default": props}
+ return None
+
+
+def show_message(message: str) -> None:
+ """
+ Displays a message to the user using QMessageBox
+ Args:
+ message (str): Message to be displayed
+
+ Returns: Return None if message is empty otherwise displays the message
+
+ """
+ if message:
+ msg_box = QMessageBox()
+ msg_box.setText(message)
+ msg_box.exec()
+
+
+def get_next_possible_structural_level_label(existing_type_labels: Any) -> str | None:
+ """
+ Get the title for the next possible structural type level
+ Args:
+ existing_type_labels (Any): The list of labels existing in the ontology document
+
+ Returns (str|None):
+ The next possible name is returned with the decimal part greater than the existing largest one
+ """
+ if existing_type_labels is not None:
+ if len(existing_type_labels) > 0:
+ import re
+ regexp = re.compile(r'^[Xx][0-9]+$')
+ labels = [int(label.replace('x', '').replace('X', ''))
+ for label in existing_type_labels if regexp.match(label)]
+ new_level = max(labels, default=-1)
+ return f"x{new_level + 1}"
+ else:
+ return "x0"
+ else:
+ return None
+
+
+def get_db(db_name: str,
+ db_user: str,
+ db_pass: str,
+ db_url: str,
+ logger: logging.Logger) -> CouchDB | None:
+ """
+ Get the db instance for the test purpose
+ Args:
+ logger (logging): Logger instance
+ db_name (str): Database instance name in CouchDB
+ db_user (str): Database user-name used for CouchDB access.
+ db_pass (str): Database password used for CouchDB access.
+ db_url (str): Database connection URL.
+
+ Returns (CouchDB | None):
+ Connected DB instance
+
+ """
+ try:
+ client = CouchDB(user=db_user,
+ auth_token=db_pass,
+ url=db_url,
+ connect=True)
+ except Exception as ex:
+ if logger:
+ logger.error(f'Could not connect with username+password to local server, error: {ex}')
+ return None
+ if db_name in client.all_dbs():
+ db_instance = client[db_name]
+ else:
+ db_instance = client.create_database(db_name)
+ return db_instance
diff --git a/pasta_eln/database.py b/pasta_eln/database.py
index d076678f..f8ee700f 100644
--- a/pasta_eln/database.py
+++ b/pasta_eln/database.py
@@ -45,7 +45,7 @@ def __init__(self, user:str, password:str, databaseName:str, configuration:dict[
self.initGeneralViews()
self.ontology = self.db['-ontology-']
if '-version' not in self.ontology or self.ontology['-version']!=2:
- print("**ERROR wrong ontology version")
+ print(F"**ERROR wrong ontology version: {self.ontology['-version']}")
raise ValueError("Wrong ontology version")
self.dataLabels = {i:self.ontology[i]['label'] for i in self.ontology if i[0] not in ['_','-']}
self.basePath = basePath
diff --git a/pasta_eln/gui.py b/pasta_eln/gui.py
index 04b1e39c..097be9c7 100644
--- a/pasta_eln/gui.py
+++ b/pasta_eln/gui.py
@@ -1,92 +1,100 @@
""" Graphical user interface includes all widgets """
-import os, logging, webbrowser, json, sys
+import json
+import logging
+import os
+import sys
+import webbrowser
from enum import Enum
-from typing import Any, Optional
from pathlib import Path
-from PySide6.QtWidgets import QMainWindow, QApplication, QFileDialog, QScrollArea # pylint: disable=no-name-in-module
-from PySide6.QtCore import Qt, Slot # pylint: disable=no-name-in-module
+from typing import Any, Union
+
+from PySide6.QtCore import Qt, Slot, QCoreApplication # pylint: disable=no-name-in-module
from PySide6.QtGui import QIcon, QPixmap, QShortcut # pylint: disable=no-name-in-module
-from qt_material import apply_stylesheet #of https://github.com/UN-GCPDS/qt-material
+from PySide6.QtWidgets import QMainWindow, QApplication, QFileDialog # pylint: disable=no-name-in-module
+from qt_material import apply_stylesheet # of https://github.com/UN-GCPDS/qt-material
from pasta_eln import __version__
-from .backend import Backend
-from .guiCommunicate import Communicate
-from .GUI.sidebar import Sidebar
from .GUI.body import Body
-from .GUI.form import Form
from .GUI.config import Configuration
+from .GUI.form import Form
from .GUI.projectGroup import ProjectGroup
-from .GUI.ontology import Ontology
-from .miscTools import updateExtractorList, restart
-from .guiStyle import Action, showMessage, widgetAndLayout, shortCuts
+from .GUI.sidebar import Sidebar
+from .backend import Backend
from .fixedStringsJson import shortcuts
+from .guiCommunicate import Communicate
+from .guiStyle import Action, showMessage, widgetAndLayout, shortCuts
from .inputOutput import exportELN, importELN
+from .GUI.ontology_configuration.ontology_configuration_extended import OntologyConfigurationForm
+from .miscTools import updateExtractorList, restart
+
os.environ['QT_API'] = 'pyside6'
+
# Subclass QMainWindow to customize your application's main window
class MainWindow(QMainWindow):
""" Graphical user interface includes all widgets """
+
def __init__(self) -> None:
- #global setting
+ # global setting
super().__init__()
venv = ' without venv' if sys.prefix == sys.base_prefix and 'CONDA_PREFIX' not in os.environ else ' in venv'
self.setWindowTitle(f"PASTA-ELN {__version__}{venv}")
- self.setWindowState(Qt.WindowMaximized) # type: ignore
- resourcesDir = Path(__file__).parent/'Resources'
- self.setWindowIcon(QIcon(QPixmap(resourcesDir/'Icons'/'favicon64.png')))
+ self.setWindowState(Qt.WindowMaximized) # type: ignore
+ resourcesDir = Path(__file__).parent / 'Resources'
+ self.setWindowIcon(QIcon(QPixmap(resourcesDir / 'Icons' / 'favicon64.png')))
self.backend = Backend()
self.comm = Communicate(self.backend)
self.comm.formDoc.connect(self.formDoc)
- #Menubar
+ # Menubar
menu = self.menuBar()
projectMenu = menu.addMenu("&Project")
- Action('&Export .eln', self, [Command.EXPORT], projectMenu)
- Action('&Import .eln', self, [Command.IMPORT], projectMenu)
+ Action('&Export .eln', self, [Command.EXPORT], projectMenu)
+ Action('&Import .eln', self, [Command.IMPORT], projectMenu)
projectMenu.addSeparator()
- Action('&Exit', self, [Command.EXIT], projectMenu)
+ Action('&Exit', self, [Command.EXIT], projectMenu)
viewMenu = menu.addMenu("&Lists")
if hasattr(self.backend, 'db'):
for docType, docLabel in self.comm.backend.db.dataLabels.items():
- if docType[0]=='x' and docType[1]!='0':
+ if docType[0] == 'x' and docType[1] != '0':
continue
shortcut = f"Ctrl+{shortCuts[docType]}" if docType in shortCuts else None
- Action(docLabel, self, [Command.VIEW, docType], viewMenu, shortcut=shortcut)
- if docType=='x0':
+ Action(docLabel, self, [Command.VIEW, docType], viewMenu, shortcut=shortcut)
+ if docType == 'x0':
viewMenu.addSeparator()
viewMenu.addSeparator()
- Action('&Tags', self, [Command.VIEW, '_tags_'], viewMenu, shortcut='Ctrl+T')
- Action('&Unidentified', self, [Command.VIEW, '-'], viewMenu, shortcut='Ctrl+U')
+ Action('&Tags', self, [Command.VIEW, '_tags_'], viewMenu, shortcut='Ctrl+T')
+ Action('&Unidentified', self, [Command.VIEW, '-'], viewMenu, shortcut='Ctrl+U')
systemMenu = menu.addMenu("&System")
- Action('&Project groups', self, [Command.PROJECT_GROUP], systemMenu)
+ Action('&Project groups', self, [Command.PROJECT_GROUP], systemMenu)
changeProjectGroups = systemMenu.addMenu("&Change project group")
- if hasattr(self.backend, 'configuration'): #not case in fresh install
+ if hasattr(self.backend, 'configuration'): # not case in fresh install
for name in self.backend.configuration['projectGroups'].keys():
- Action(name, self, [Command.CHANGE_PG, name], changeProjectGroups)
- Action('&Syncronize', self, [Command.SYNC], systemMenu, shortcut='F5')
- Action('&Questionaires', self, [Command.ONTOLOGY], systemMenu)
+ Action(name, self, [Command.CHANGE_PG, name], changeProjectGroups)
+ Action('&Syncronize', self, [Command.SYNC], systemMenu, shortcut='F5')
+ Action('&Questionaires', self, [Command.ONTOLOGY], systemMenu, shortcut='F8')
systemMenu.addSeparator()
- Action('&Test extraction from a file', self, [Command.TEST1], systemMenu)
- Action('Test &selected item extraction', self, [Command.TEST2], systemMenu, shortcut='F2')
- Action('Update &Extractor list', self, [Command.UPDATE], systemMenu)
+ Action('&Test extraction from a file', self, [Command.TEST1], systemMenu)
+ Action('Test &selected item extraction', self, [Command.TEST2], systemMenu, shortcut='F2')
+ Action('Update &Extractor list', self, [Command.UPDATE], systemMenu)
systemMenu.addSeparator()
- Action('&Configuration', self, [Command.CONFIG], systemMenu, shortcut='Ctrl+0')
+ Action('&Configuration', self, [Command.CONFIG], systemMenu, shortcut='Ctrl+0')
helpMenu = menu.addMenu("&Help")
- Action('&Website', self, [Command.WEBSITE], helpMenu)
- Action('&Verify database', self, [Command.VERIFY_DB], helpMenu, shortcut='Ctrl+?')
- Action('Shortcuts', self, [Command.SHORTCUTS], helpMenu)
+ Action('&Website', self, [Command.WEBSITE], helpMenu)
+ Action('&Verify database', self, [Command.VERIFY_DB], helpMenu, shortcut='Ctrl+?')
+ Action('Shortcuts', self, [Command.SHORTCUTS], helpMenu)
helpMenu.addSeparator()
- #shortcuts for advanced usage (user should not need)
- QShortcut('F9', self, lambda : self.execute([Command.RESTART]))
+ # shortcuts for advanced usage (user should not need)
+ QShortcut('F9', self, lambda: self.execute([Command.RESTART]))
- #GUI elements
+ # GUI elements
mainWidget, mainLayout = widgetAndLayout('H')
- self.setCentralWidget(mainWidget) # Set the central widget of the Window
- body = Body(self.comm) #body with information
- self.sidebar = Sidebar(self.comm) #sidebar with buttons
+ self.setCentralWidget(mainWidget) # Set the central widget of the Window
+ body = Body(self.comm) # body with information
+ self.sidebar = Sidebar(self.comm) # sidebar with buttons
# sidebarScroll = QScrollArea()
# sidebarScroll.setWidget(self.sidebar)
# if hasattr(self.comm.backend, 'configuration'):
@@ -94,11 +102,10 @@ def __init__(self) -> None:
# mainLayout.addWidget(sidebarScroll)
mainLayout.addWidget(self.sidebar)
mainLayout.addWidget(body)
- self.comm.changeTable.emit('x0','')
-
+ self.comm.changeTable.emit('x0', '')
@Slot(str)
- def formDoc(self, doc:dict[str,Any]) -> None:
+ def formDoc(self, doc: dict[str, Any]) -> None:
"""
What happens when new/edit dialog is shown
@@ -106,19 +113,18 @@ def formDoc(self, doc:dict[str,Any]) -> None:
doc (dict): document
"""
if '_id' in doc:
- logging.debug('gui:formdoc '+str(doc['_id']))
+ logging.debug('gui:formdoc ' + str(doc['_id']))
elif '_ids' in doc:
- logging.debug('gui:formdoc '+str(doc['_ids']))
+ logging.debug('gui:formdoc ' + str(doc['_ids']))
else:
- logging.debug('gui:formdoc of type '+str(doc['-type']))
+ logging.debug('gui:formdoc of type ' + str(doc['-type']))
formWindow = Form(self.comm, doc)
ret = formWindow.exec()
- if ret==0:
+ if ret == 0:
self.comm.stopSequentialEdit.emit()
return
-
- def execute(self, command:list[Any]) -> None:
+ def execute(self, command: list[Any]) -> None:
"""
action after clicking menu item
"""
@@ -127,38 +133,37 @@ def execute(self, command:list[Any]) -> None:
if self.comm.projectID == '':
showMessage(self, 'Error', 'You have to open a project to export', 'Warning')
return
- fileName = QFileDialog.getSaveFileName(self,'Save data into .eln file',str(Path.home()),'*.eln')[0]
+ fileName = QFileDialog.getSaveFileName(self, 'Save data into .eln file', str(Path.home()), '*.eln')[0]
status = exportELN(self.comm.backend, self.comm.projectID, fileName)
showMessage(self, 'Finished', status, 'Information')
elif command[0] is Command.IMPORT:
- fileName = QFileDialog.getOpenFileName(self,'Load data from .eln file',str(Path.home()),'*.eln')[0]
+ fileName = QFileDialog.getOpenFileName(self, 'Load data from .eln file', str(Path.home()), '*.eln')[0]
status = importELN(self.comm.backend, fileName)
showMessage(self, 'Finished', status, 'Information')
self.comm.changeSidebar.emit('redraw')
- self.comm.changeTable.emit('x0','')
+ self.comm.changeTable.emit('x0', '')
elif command[0] is Command.EXIT:
self.close()
- #view menu
+ # view menu
elif command[0] is Command.VIEW:
self.comm.changeTable.emit(command[1], '')
- #system menu
+ # system menu
elif command[0] is Command.PROJECT_GROUP:
dialog = ProjectGroup(self.comm)
dialog.exec()
elif command[0] is Command.CHANGE_PG:
self.backend.configuration['defaultProjectGroup'] = command[1]
- with open(Path.home()/'.pastaELN.json', 'w', encoding='utf-8') as fConf:
- fConf.write(json.dumps(self.backend.configuration,indent=2))
+ with open(Path.home() / '.pastaELN.json', 'w', encoding='utf-8') as fConf:
+ fConf.write(json.dumps(self.backend.configuration, indent=2))
restart()
elif command[0] is Command.SYNC:
report = self.comm.backend.replicateDB(progressBar=self.sidebar.progress)
showMessage(self, 'Report of syncronization', report, style='QLabel {min-width: 450px}')
elif command[0] is Command.ONTOLOGY:
- showMessage(self, 'To be implemented','A possibility to change the questionaires / change the ontology is missing.')
- # dialog = Ontology(self.comm.backend)
- # dialog.exec()
+ ontologyForm = OntologyConfigurationForm(self.comm.backend.db.ontology)
+ ontologyForm.instance.exec()
elif command[0] is Command.TEST1:
- fileName = QFileDialog.getOpenFileName(self,'Open file for extractor test',str(Path.home()),'*.*')[0]
+ fileName = QFileDialog.getOpenFileName(self, 'Open file for extractor test', str(Path.home()), '*.*')[0]
report = self.comm.backend.testExtractor(fileName, outputStyle='html')
showMessage(self, 'Report of extractor test', report)
elif command[0] is Command.TEST2:
@@ -170,7 +175,7 @@ def execute(self, command:list[Any]) -> None:
elif command[0] is Command.CONFIG:
dialog = Configuration(self.comm)
dialog.exec()
- #remainder
+ # remainder
elif command[0] is Command.WEBSITE:
webbrowser.open('https://pasta-eln.github.io/pasta-eln/')
elif command[0] is Command.VERIFY_DB:
@@ -182,61 +187,74 @@ def execute(self, command:list[Any]) -> None:
elif command[0] is Command.RESTART:
restart()
else:
- print("**ERROR gui menu unknown:",command)
+ print("**ERROR gui menu unknown:", command)
return
-##############
-## Main function
-def main() -> None:
- """ Main method and entry point for commands """
+def mainGUI() -> tuple[Union[QCoreApplication, None], MainWindow]:
+ """
+ Main method and entry point for commands
+ Returns:
+
+ """
# logging has to be started first
- logPath = Path.home()/'pastaELN.log'
+ log_path = Path.home() / 'pastaELN.log'
# old versions of basicConfig do not know "encoding='utf-8'"
- logging.basicConfig(filename=logPath, level=logging.INFO, format='%(asctime)s|%(levelname)s:%(message)s',
+ logging.basicConfig(filename=log_path, level=logging.INFO, format='%(asctime)s|%(levelname)s:%(message)s',
datefmt='%m-%d %H:%M:%S')
for package in ['urllib3', 'requests', 'asyncio', 'PIL', 'matplotlib']:
logging.getLogger(package).setLevel(logging.WARNING)
logging.info('Start PASTA GUI')
# remainder
- app = QApplication()
- window = MainWindow()
- logging.getLogger().setLevel(getattr(logging, window.backend.configuration['GUI']['loggingLevel']))
- theme = window.backend.configuration['GUI']['theme']
- if theme!='none':
- apply_stylesheet(app, theme=f'{theme}.xml')
+ if not QApplication.instance():
+ application = QApplication().instance()
+ else:
+ application = QApplication.instance()
+ main_window = MainWindow()
+ logging.getLogger().setLevel(getattr(logging, main_window.backend.configuration['GUI']['loggingLevel']))
+ theme = main_window.backend.configuration['GUI']['theme']
+ if theme != 'none':
+ apply_stylesheet(application, theme=f'{theme}.xml')
# qtawesome and matplot cannot coexist
import qtawesome as qta
if not isinstance(qta.icon('fa5s.times'), QIcon):
logging.error('qtawesome: could not load. Likely matplotlib is included and can not coexist.')
print('qtawesome: could not load. Likely matplotlib is included and can not coexist.')
# end test coexistance
- window.show()
- app.exec()
logging.info('End PASTA GUI')
- return
+ return application, main_window
class Command(Enum):
""" Commands used in this file """
EXPORT = 1
IMPORT = 2
- EXIT = 3
- VIEW = 4
+ EXIT = 3
+ VIEW = 4
PROJECT_GROUP = 5
- CHANGE_PG = 6
- SYNC = 7
- ONTOLOGY = 8
- TEST1 = 9
- TEST2 = 10
- UPDATE = 11
- CONFIG = 12
- WEBSITE = 13
- VERIFY_DB = 14
- SHORTCUTS = 15
- RESTART = 16
+ CHANGE_PG = 6
+ SYNC = 7
+ ONTOLOGY = 8
+ TEST1 = 9
+ TEST2 = 10
+ UPDATE = 11
+ CONFIG = 12
+ WEBSITE = 13
+ VERIFY_DB = 14
+ SHORTCUTS = 15
+ RESTART = 16
+
+
+def startMain() -> None:
+ """
+ Main function to start GUI. Extra function is required to allow starting in module fashion
+ """
+ app, window = mainGUI()
+ window.show()
+ if app:
+ app.exec()
# called by python3 -m pasta_eln.gui
if __name__ == '__main__':
- main()
+ startMain()
diff --git a/run_tests.sh b/run_tests.sh
new file mode 100755
index 00000000..be730963
--- /dev/null
+++ b/run_tests.sh
@@ -0,0 +1,2 @@
+#!/bin/sh
+coverage run --omit="*/test*" -m pytest . && coverage html -i
\ No newline at end of file
diff --git a/setup.cfg b/setup.cfg
index f91cc493..21effe67 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -31,6 +31,11 @@ install_requires =
win-unicode-console;platform_system=='Windows'
winshell;platform_system=='Windows'
pypiwin32;platform_system=='Windows'
+ pytest
+ pytest-qt
+ pytest-qt-app
+ pytest-mock
+ coverage
packages = find_namespace:
include_package_data = True
@@ -46,7 +51,11 @@ devel =
pytest-cov
pytest-qt
pytest-xvfb
+ pytest-mock
coverage
+ pyqt6
+ pyqt6-tools
+ scalene
[options.entry_points]
# install the GUI starter as direct entrypoints
@@ -70,8 +79,20 @@ disallow_incomplete_defs = true
disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
-ignore_missing_imports = True
-exclude = pasta_eln/Extractors
+ignore_missing_imports = true
+exclude = (?x)(
+ pasta_eln/GUI/ontology_configuration/create_type_dialog.py
+ |pasta_eln/GUI/ontology_configuration/ontology_configuration.py
+ |pasta_eln/Extractors )
+
+[mypy-pasta_eln/GUI/ontology_configuration.*]
+follow_imports = skip
[tool:pytest]
-addopts = -p no:warnings
\ No newline at end of file
+minversion = 6.0
+addopts = -ra -q
+testpaths =
+ tests
+ integration
+env =
+ D:QT_QPA_PLATFORM=offscreen
\ No newline at end of file
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/app_tests/__init__.py b/tests/app_tests/__init__.py
new file mode 100644
index 00000000..c4e63d6d
--- /dev/null
+++ b/tests/app_tests/__init__.py
@@ -0,0 +1,8 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: __init__.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
\ No newline at end of file
diff --git a/tests/app_tests/common/__init__.py b/tests/app_tests/common/__init__.py
new file mode 100644
index 00000000..0e780dd4
--- /dev/null
+++ b/tests/app_tests/common/__init__.py
@@ -0,0 +1,8 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: __init__.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
diff --git a/tests/app_tests/common/fixtures.py b/tests/app_tests/common/fixtures.py
new file mode 100644
index 00000000..45032f07
--- /dev/null
+++ b/tests/app_tests/common/fixtures.py
@@ -0,0 +1,184 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: fixtures.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+from typing import Union
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import QCoreApplication
+from PySide6.QtWidgets import QApplication, QDialog
+from cloudant.document import Document
+from pytest import fixture
+from pytestqt.qtbot import QtBot
+
+from pasta_eln.GUI.ontology_configuration.create_type_dialog_extended import CreateTypeDialog
+from pasta_eln.GUI.ontology_configuration.delete_column_delegate import DeleteColumnDelegate
+from pasta_eln.GUI.ontology_configuration.ontology_attachments_tableview_data_model import \
+ OntologyAttachmentsTableViewModel
+from pasta_eln.GUI.ontology_configuration.ontology_config_key_not_found_exception import \
+ OntologyConfigKeyNotFoundException
+from pasta_eln.GUI.ontology_configuration.ontology_configuration_extended import OntologyConfigurationForm, get_gui
+from pasta_eln.GUI.ontology_configuration.ontology_document_null_exception import OntologyDocumentNullException
+from pasta_eln.GUI.ontology_configuration.ontology_props_tableview_data_model import OntologyPropsTableViewModel
+from pasta_eln.GUI.ontology_configuration.ontology_tableview_data_model import OntologyTableViewModel
+from pasta_eln.GUI.ontology_configuration.reorder_column_delegate import ReorderColumnDelegate
+from pasta_eln.GUI.ontology_configuration.required_column_delegate import RequiredColumnDelegate
+from pasta_eln.gui import mainGUI, MainWindow
+from tests.app_tests.common.test_utils import get_ontology_document
+
+
+@fixture()
+def create_type_dialog_mock(mocker) -> CreateTypeDialog:
+ mock_callable_1 = mocker.patch('typing.Callable')
+ mock_callable_2 = mocker.patch('typing.Callable')
+ mocker.patch.object(CreateTypeDialog, 'setup_slots')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.create_type_dialog_extended.logging.getLogger')
+ mocker.patch(
+ 'pasta_eln.GUI.ontology_configuration.create_type_dialog_extended.Ui_CreateTypeDialog.setupUi')
+ mocker.patch.object(QDialog, '__new__')
+ return CreateTypeDialog(mock_callable_1, mock_callable_2)
+
+
+@fixture()
+def configuration_extended(mocker) -> OntologyConfigurationForm:
+ mock_document = mocker.patch('cloudant.document.Document')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.create_type_dialog_extended.logging.getLogger')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration.Ui_OntologyConfigurationBaseForm.setupUi')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.adjust_ontology_data_to_v3')
+ mocker.patch.object(QDialog, '__new__')
+ mocker.patch.object(OntologyPropsTableViewModel, '__new__')
+ mocker.patch.object(OntologyAttachmentsTableViewModel, '__new__')
+ mocker.patch.object(RequiredColumnDelegate, '__new__', lambda _: mocker.MagicMock())
+ mocker.patch.object(DeleteColumnDelegate, '__new__', lambda _: mocker.MagicMock())
+ mocker.patch.object(ReorderColumnDelegate, '__new__', lambda _: mocker.MagicMock())
+ mocker.patch.object(OntologyConfigurationForm, 'typePropsTableView', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeAttachmentsTableView', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'loadOntologyPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addPropsRowPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addAttachmentPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'saveOntologyPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addPropsCategoryPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'deletePropsCategoryPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'deleteTypePushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addTypePushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeComboBox', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'propsCategoryComboBox', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeLabelLineEdit', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeLinkLineEdit', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'delete_column_delegate_props_table', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'reorder_column_delegate_props_table', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'delete_column_delegate_attach_table', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'reorder_column_delegate_attach_table', create=True)
+ mocker.patch.object(CreateTypeDialog, '__new__')
+ config_instance = OntologyConfigurationForm(mock_document)
+ return config_instance
+
+
+@fixture()
+def doc_null_exception(request) -> OntologyDocumentNullException:
+ return OntologyDocumentNullException(request.param['message'],
+ request.param['errors'])
+
+
+@fixture()
+def key_not_found_exception(request) -> OntologyConfigKeyNotFoundException:
+ return OntologyConfigKeyNotFoundException(request.param['message'],
+ request.param['errors'])
+
+
+@fixture()
+def table_model() -> OntologyTableViewModel:
+ base_model = OntologyTableViewModel()
+ base_model.setObjectName("OntologyTableViewModel")
+ return base_model
+
+
+@fixture()
+def props_table_model() -> OntologyPropsTableViewModel:
+ props_model = OntologyPropsTableViewModel()
+ props_model.setObjectName("OntologyPropsTableViewModel")
+ return props_model
+
+
+@fixture()
+def attachments_table_model() -> OntologyAttachmentsTableViewModel:
+ attachments_model = OntologyAttachmentsTableViewModel()
+ attachments_model.setObjectName("OntologyAttachmentsTableViewModel")
+ return attachments_model
+
+
+@fixture()
+def reorder_delegate() -> ReorderColumnDelegate:
+ return ReorderColumnDelegate()
+
+
+@fixture()
+def required_delegate() -> RequiredColumnDelegate:
+ return RequiredColumnDelegate()
+
+
+@fixture()
+def delete_delegate() -> DeleteColumnDelegate:
+ return DeleteColumnDelegate()
+
+
+@fixture()
+def ontology_doc_mock(mocker) -> Document:
+ mock_doc = mocker.patch('cloudant.document.Document')
+ mock_doc_content = get_ontology_document('ontology_document.json')
+ mocker.patch.object(mock_doc, "__len__",
+ lambda x, y: len(mock_doc_content))
+ mock_doc.__getitem__.side_effect = mock_doc_content.__getitem__
+ mock_doc.__setitem__.side_effect = mock_doc_content.__setitem__
+ mock_doc.__contains__.side_effect = mock_doc_content.__contains__
+ mock_doc.__iter__.side_effect = mock_doc_content.__iter__
+ mock_doc.keys.side_effect = mock_doc_content.keys
+ mock_doc.types.side_effect = mocker.MagicMock(return_value=dict((data, mock_doc_content[data])
+ for data in mock_doc_content
+ if type(mock_doc_content[data]) is dict))
+ mock_doc.types_list.side_effect = mocker.MagicMock(return_value=[data for data in mock_doc_content
+ if type(mock_doc_content[data]) is dict])
+ return mock_doc
+
+
+@fixture()
+def ontology_editor_gui(request, ontology_doc_mock) -> tuple[QApplication,
+QtWidgets.QDialog,
+OntologyConfigurationForm,
+QtBot]:
+ app, ui_dialog, ui_form_extended = get_gui(ontology_doc_mock)
+ qtbot: QtBot = QtBot(app)
+ return app, ui_dialog, ui_form_extended, qtbot
+
+
+@fixture()
+def props_column_names():
+ return {
+ 0: "name",
+ 1: "query",
+ 2: "list",
+ 3: "link",
+ 4: "required",
+ 5: "unit"
+ }
+
+
+@fixture()
+def attachments_column_names():
+ return {
+ 0: "location",
+ 1: "link"
+ }
+
+
+@fixture(scope="module")
+def pasta_gui(request) -> tuple[Union[QApplication, QCoreApplication, None],
+MainWindow,
+QtBot]:
+ app, image_viewer = mainGUI()
+ qtbot = QtBot(app)
+ return app, image_viewer, qtbot
diff --git a/tests/app_tests/common/test_delegate_funcs_common.py b/tests/app_tests/common/test_delegate_funcs_common.py
new file mode 100644
index 00000000..9d6379ee
--- /dev/null
+++ b/tests/app_tests/common/test_delegate_funcs_common.py
@@ -0,0 +1,78 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_delegate_funcs_common.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+from typing import Union
+
+from PySide6.QtWidgets import QPushButton, QStyleOptionButton, QApplication, QStyle
+
+from pasta_eln.GUI.ontology_configuration.delete_column_delegate import DeleteColumnDelegate
+from pasta_eln.GUI.ontology_configuration.reorder_column_delegate import ReorderColumnDelegate
+from tests.app_tests.common.fixtures import delete_delegate, reorder_delegate
+
+
+def delegate_paint_common(mocker,
+ delegate: Union[delete_delegate, reorder_delegate],
+ button_text):
+ mock_painter = mocker.patch("PySide6.QtGui.QPainter")
+ mock_option = mocker.patch("PySide6.QtWidgets.QStyleOptionViewItem")
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+ mock_push_button = mocker.patch("PySide6.QtWidgets.QPushButton")
+ mock_button_option = mocker.patch("PySide6.QtWidgets.QStyleOptionButton")
+ mocker.patch.object(QPushButton, "__new__",
+ lambda x: mock_push_button)
+ mocker.patch.object(QStyleOptionButton, "__new__",
+ lambda x: mock_button_option)
+ mock_style = mocker.patch("PySide6.QtWidgets.QStyle")
+ draw_control_spy = mocker.spy(mock_style, 'drawControl')
+ mocker.patch.object(QApplication, "style",
+ lambda: mock_style)
+
+ delegate.paint(mock_painter, mock_option, mock_index)
+ draw_control_spy.assert_called_once_with(QStyle.CE_PushButton, mock_button_option, mock_painter, mock_push_button)
+ assert mock_button_option.rect == mock_option.rect, "rect should be the same as the option passed"
+ assert mock_button_option.text == button_text, "button text should be delete"
+ assert mock_button_option.state == QStyle.State_Active | QStyle.State_Enabled, "button state should be active and enabled"
+
+
+def delegate_editor_method_common(delegate: Union[delete_delegate, reorder_delegate],
+ mocker):
+ mock_painter = mocker.patch("PySide6.QtGui.QPainter")
+ mock_option = mocker.patch("PySide6.QtWidgets.QStyleOptionViewItem")
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+ assert delegate.createEditor(mock_painter,
+ mock_option,
+ mock_index) is None, "create editor should return None"
+
+
+def delegate_editor_event_common(delegate: Union[delete_delegate, reorder_delegate],
+ mocker,
+ is_click_within_bounds):
+ mock_event = mocker.patch("PySide6.QtCore.QEvent")
+ mock_model = mocker.patch("PySide6.QtCore.QAbstractItemModel")
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+ mock_option = mocker.patch("PySide6.QtWidgets.QStyleOptionViewItem")
+ mocker.patch.object(mock_index, "row",
+ mocker.MagicMock(return_value=2))
+ emit_spy = None
+ if type(delegate) is DeleteColumnDelegate:
+ delegate.delete_clicked_signal = mocker.patch("PySide6.QtCore.Signal")
+ emit_spy = mocker.spy(delegate.delete_clicked_signal, 'emit')
+ elif type(delegate) is ReorderColumnDelegate:
+ delegate.re_order_signal = mocker.patch("PySide6.QtCore.Signal")
+ emit_spy = mocker.spy(delegate.re_order_signal, 'emit')
+ assert delegate.editorEvent(mock_event,
+ mock_model,
+ mock_option,
+ mock_index) is is_click_within_bounds, "editorEvent should return True"
+ row_call_count = 1 if is_click_within_bounds else 0
+ assert mock_index.row.call_count == row_call_count, f"index.row should be called {row_call_count} times"
+ assert emit_spy, "emit should be set"
+ if is_click_within_bounds:
+ emit_spy.assert_called_once_with(2)
+ else:
+ assert emit_spy.call_count == 0, "emit should not be called"
diff --git a/tests/app_tests/common/test_utils.py b/tests/app_tests/common/test_utils.py
new file mode 100644
index 00000000..a74e4935
--- /dev/null
+++ b/tests/app_tests/common/test_utils.py
@@ -0,0 +1,71 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_utils.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+#
+# Author: Jithu Murugan
+# Filename: test_utils.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import json
+import os
+
+from cloudant.document import Document
+
+
+def save_couchdb_document_in_file(couch_db_doc: Document,
+ save_file_name: str) -> None:
+ """
+ Dumps the json object into the file given within test_data folder in cd
+ Args:
+ couch_db_doc (Document): Document to be saved.
+ save_file_name (str): File name where the document to be dumped.
+ Returns:
+ None
+ """
+ test_data_path = get_test_data_path(os.getcwd(), save_file_name)
+ json.dump(couch_db_doc, open(test_data_path, 'w'))
+
+
+def get_ontology_document(document_json_file: str) -> dict | None:
+ """
+ Returns the dict representation of ontology document from the file
+ Args:
+ document_json_file (str): File name representing document data in test_data folder
+
+ Returns:
+ Json representation of ontology document
+ """
+ test_data_path = get_test_data_path(os.getcwd(), document_json_file)
+ if not os.path.exists(test_data_path):
+ return None
+ with open(test_data_path) as f:
+ return json.loads(f.read())
+
+
+def get_test_data_path(executing_path: str,
+ document_json_file: str):
+ """
+ Resolve the test data path
+ Args:
+ document_json_file (str):
+ executing_path (str):
+
+ Returns:
+
+ """
+ if os.path.exists(os.path.join(executing_path, "tests", "app_tests", "test_data")):
+ test_data_path = os.path.join(executing_path, "tests", "app_tests", "test_data", document_json_file)
+ else:
+ test_data_path = os.path.join(executing_path, "..//test_data", document_json_file)
+ import pathlib
+ if not os.path.exists(pathlib.Path(test_data_path).parent):
+ os.makedirs(pathlib.Path(test_data_path).parent)
+ return test_data_path
diff --git a/tests/app_tests/component_tests/__init__.py b/tests/app_tests/component_tests/__init__.py
new file mode 100644
index 00000000..0e780dd4
--- /dev/null
+++ b/tests/app_tests/component_tests/__init__.py
@@ -0,0 +1,8 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: __init__.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
diff --git a/tests/app_tests/component_tests/test_ontology_configuration_extended.py b/tests/app_tests/component_tests/test_ontology_configuration_extended.py
new file mode 100644
index 00000000..f4da4089
--- /dev/null
+++ b/tests/app_tests/component_tests/test_ontology_configuration_extended.py
@@ -0,0 +1,213 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_configuration_extended.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_configuration_extended.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+from PySide6 import QtWidgets
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QApplication
+from pytestqt.qtbot import QtBot
+
+from pasta_eln.GUI.ontology_configuration.ontology_configuration_extended import OntologyConfigurationForm
+from tests.app_tests.common.fixtures import ontology_editor_gui, ontology_doc_mock, props_column_names, \
+ attachments_column_names
+
+
+class TestOntologyConfigurationExtended(object):
+
+ def test_component_launch_should_display_all_ui_elements(self,
+ ontology_editor_gui: tuple[
+ QApplication,
+ QtWidgets.QDialog,
+ OntologyConfigurationForm,
+ QtBot]):
+ app, ui_dialog, ui_form, qtbot = ontology_editor_gui
+ assert ui_form.headerLabel is not None, "Header not loaded!"
+ assert ui_form.typeLabel is not None, "Data type label not loaded!"
+ assert ui_form.loadOntologyPushButton is not None, "Bush button not loaded!"
+ assert ui_form.saveOntologyPushButton is not None, "Save button not loaded!"
+ assert ui_form.helpPushButton is not None, "Help button not loaded!"
+ assert ui_form.cancelPushButton is not None, "Cancel button not loaded!"
+ assert ui_form.typePropsTableView is not None, "Properties table view not loaded!"
+ assert ui_form.typeAttachmentsTableView is not None, "Type table view not loaded!"
+ assert ui_form.addAttachmentPushButton is not None, "Add attachment button not loaded!"
+ assert ui_form.addTypePushButton is not None, "Add type button not loaded!"
+ assert ui_form.addPropsRowPushButton is not None, "Add property row button not loaded!"
+ assert ui_form.addPropsCategoryPushButton is not None, "Add property category button not loaded!"
+ assert ui_form.typeLabelLineEdit is not None, "Data type line edit not loaded!"
+ assert ui_form.typeLinkLineEdit is not None, "Data type link line edit not loaded!"
+ assert ui_form.addPropsCategoryLineEdit is not None, "Property category line edit not loaded!"
+ assert ui_form.typeComboBox is not None, "Data type combo box not loaded!"
+ assert ui_form.propsCategoryComboBox is not None, "Property category combo box not loaded!"
+
+ def test_component_load_button_click_should_load_ontology_data(self,
+ ontology_editor_gui: tuple[
+ QApplication,
+ QtWidgets.QDialog,
+ OntologyConfigurationForm,
+ QtBot],
+ ontology_doc_mock: ontology_doc_mock,
+ props_column_names: props_column_names,
+ attachments_column_names: attachments_column_names):
+ app, ui_dialog, ui_form, qtbot = ontology_editor_gui
+ assert ([ui_form.typeComboBox.itemText(i) for i in range(ui_form.typeComboBox.count())]
+ == []), "Type combo box should not be loaded!"
+ assert ui_form.loadOntologyPushButton.click() is None, "Load button not clicked!"
+ assert ([ui_form.typeComboBox.itemText(i) for i in range(ui_form.typeComboBox.count())]
+ == ontology_doc_mock.types_list()), "Type combo box not loaded!"
+ assert (ui_form.typeComboBox.currentText()
+ == ontology_doc_mock.types_list()[0]), "Type combo box should be selected to first item"
+ selected_type = ontology_doc_mock.types()[ui_form.typeComboBox.currentText()]
+ assert (ui_form.typeLabelLineEdit.text() ==
+ selected_type["label"]), "Data type label line edit not loaded!"
+ assert (ui_form.typeLinkLineEdit.text() ==
+ selected_type["link"]), "Data type link line edit not loaded!"
+
+ categories = list(selected_type["prop"].keys())
+ assert ([ui_form.propsCategoryComboBox.itemText(i) for i in range(ui_form.propsCategoryComboBox.count())]
+ == categories), "propsCategoryComboBox combo box not loaded!"
+ assert (ui_form.propsCategoryComboBox.currentText()
+ == categories[0]), "propsCategoryComboBox should be selected to first item"
+ self.check_table_contents(attachments_column_names, props_column_names, selected_type, ui_form)
+
+ @staticmethod
+ def check_table_view_model(model, column_names, data_selected):
+ for row in range(model.rowCount()):
+ data = data_selected[row]
+ for column in range(model.columnCount() - 2):
+ index = model.index(row, column)
+ if column_names[column] in data:
+ assert (model.data(index, Qt.DisplayRole)
+ == str(data[column_names[column]])), f"{column_names[column]} not loaded!"
+ else:
+ assert model.data(index, Qt.DisplayRole) == "", f"{column_names[column]} should be null string!"
+
+ def check_table_contents(self, attachments_column_names, props_column_names, selected_type, ui_form):
+ categories = list(selected_type["prop"].keys())
+ # Assert if the properties are loaded in the table view
+ model = ui_form.typePropsTableView.model()
+ self.check_table_view_model(model, props_column_names, selected_type["prop"][categories[0]])
+ # Assert if the attachments are loaded in the table view
+ model = ui_form.typeAttachmentsTableView.model()
+ self.check_table_view_model(model, attachments_column_names, selected_type["attachments"])
+
+ def test_component_add_new_type_with_ontology_loaded_should_display_error_message(self,
+ ontology_editor_gui: tuple[
+ QApplication,
+ QtWidgets.QDialog,
+ OntologyConfigurationForm,
+ QtBot],
+ ontology_doc_mock: ontology_doc_mock,
+ mocker):
+ app, ui_dialog, ui_form, qtbot = ontology_editor_gui
+ mock_show_message = mocker.patch(
+ "pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message")
+ qtbot.mouseClick(ui_form.addTypePushButton, Qt.LeftButton)
+ mock_show_message.assert_called_once_with("Load the ontology data first...")
+ assert ui_form.create_type_dialog.buttonBox.isVisible() is False, "Create new type dialog should not be shown!"
+
+ def test_component_add_new_type_with_loaded_ontology_should_display_create_new_type_window(self,
+ ontology_editor_gui: tuple[
+ QApplication,
+ QtWidgets.QDialog,
+ OntologyConfigurationForm,
+ QtBot],
+ ontology_doc_mock: ontology_doc_mock):
+ app, ui_dialog, ui_form, qtbot = ontology_editor_gui
+ assert ui_form.create_type_dialog.buttonBox.isVisible() is False, "Create new type dialog should not be shown!"
+ qtbot.mouseClick(ui_form.loadOntologyPushButton, Qt.LeftButton)
+ qtbot.mouseClick(ui_form.addTypePushButton, Qt.LeftButton)
+ assert ui_form.create_type_dialog.buttonBox.isVisible() is True, "Create new type dialog not shown!"
+
+ def test_component_delete_new_type_with_ontology_loaded_should_show_error_message(self,
+ ontology_editor_gui: tuple[
+ QApplication,
+ QtWidgets.QDialog,
+ OntologyConfigurationForm,
+ QtBot],
+ ontology_doc_mock: ontology_doc_mock,
+ mocker):
+ app, ui_dialog, ui_form, qtbot = ontology_editor_gui
+ mock_show_message = mocker.patch(
+ "pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message")
+ qtbot.mouseClick(ui_form.deleteTypePushButton, Qt.LeftButton)
+ mock_show_message.assert_called_once_with("Load the ontology data first....")
+ assert ui_form.create_type_dialog.buttonBox.isVisible() is False, "Create new type dialog should not be shown!"
+
+ def test_component_delete_selected_type_with_loaded_ontology_should_delete_and_update_ui(self,
+ ontology_editor_gui:
+ tuple[
+ QApplication,
+ QtWidgets.QDialog,
+ OntologyConfigurationForm,
+ QtBot],
+ ontology_doc_mock: ontology_doc_mock,
+ props_column_names: props_column_names,
+ attachments_column_names: attachments_column_names):
+ app, ui_dialog, ui_form, qtbot = ontology_editor_gui
+ assert ui_form.create_type_dialog.buttonBox.isVisible() is False, "Create new type dialog should not be shown!"
+ qtbot.mouseClick(ui_form.loadOntologyPushButton, Qt.LeftButton)
+ current_selected_type = ui_form.typeComboBox.currentText()
+ previous_types_count = ui_form.typeComboBox.count()
+ qtbot.mouseClick(ui_form.deleteTypePushButton, Qt.LeftButton)
+ assert (current_selected_type not in [ui_form.typeComboBox.itemText(i)
+ for i in range(ui_form.typeComboBox.count())]), \
+ f"Deleted type:{current_selected_type} should not exist in combo list!"
+ assert (previous_types_count - 1 == ui_form.typeComboBox.count()), \
+ f"Combo list should have {previous_types_count - 1} items!"
+ assert ui_form.typeComboBox.currentText() == ontology_doc_mock.types_list()[1], \
+ "Type combo box should be selected to second item"
+ selected_type = ontology_doc_mock.types()[ui_form.typeComboBox.currentText()]
+ assert ui_form.typeLabelLineEdit.text() == selected_type["label"], \
+ "Type label line edit should be selected to second item"
+ assert ui_form.typeLinkLineEdit.text() == selected_type["link"], \
+ "Type label line edit should be selected to second item"
+ assert ui_form.propsCategoryComboBox.currentText() == list(selected_type["prop"].keys())[0], \
+ "Type label line edit should be selected to second item"
+ self.check_table_contents(attachments_column_names, props_column_names, selected_type, ui_form)
+
+ def test_component_add_selected_type_with_loaded_ontology_should_delete_and_update_ui(self,
+ ontology_editor_gui:
+ tuple[
+ QApplication,
+ QtWidgets.QDialog,
+ OntologyConfigurationForm,
+ QtBot],
+ ontology_doc_mock: ontology_doc_mock,
+ props_column_names: props_column_names,
+ attachments_column_names: attachments_column_names):
+ app, ui_dialog, ui_form, qtbot = ontology_editor_gui
+ assert ui_form.create_type_dialog.buttonBox.isVisible() is False, "Create new type dialog should not be shown!"
+ qtbot.mouseClick(ui_form.loadOntologyPushButton, Qt.LeftButton)
+ current_selected_type = ui_form.typeComboBox.currentText()
+ previous_types_count = ui_form.typeComboBox.count()
+ qtbot.mouseClick(ui_form.deleteTypePushButton, Qt.LeftButton)
+ assert (current_selected_type not in [ui_form.typeComboBox.itemText(i)
+ for i in range(ui_form.typeComboBox.count())]), \
+ f"Deleted type:{current_selected_type} should not exist in combo list!"
+ assert (previous_types_count - 1 == ui_form.typeComboBox.count()), \
+ f"Combo list should have {previous_types_count - 1} items!"
+ assert ui_form.typeComboBox.currentText() == ontology_doc_mock.types_list()[1], \
+ "Type combo box should be selected to second item"
+ types = ontology_doc_mock.types()
+ text = ui_form.typeComboBox.currentText()
+ selected_type = types[text]
+ assert ui_form.typeLabelLineEdit.text() == selected_type["label"], \
+ "Type label line edit should be selected to second item"
+ assert ui_form.typeLinkLineEdit.text() == selected_type["link"], \
+ "Type Link line edit should be selected to second item"
+ assert ui_form.propsCategoryComboBox.currentText() == list(selected_type["prop"].keys())[0], \
+ "Type label line edit should be selected to second item"
+ self.check_table_contents(attachments_column_names, props_column_names, selected_type, ui_form)
diff --git a/tests/app_tests/integration/__init__.py b/tests/app_tests/integration/__init__.py
new file mode 100644
index 00000000..0e780dd4
--- /dev/null
+++ b/tests/app_tests/integration/__init__.py
@@ -0,0 +1,8 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: __init__.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
diff --git a/tests/app_tests/integration/test_pasta_app_ui.py b/tests/app_tests/integration/test_pasta_app_ui.py
new file mode 100644
index 00000000..d82bde46
--- /dev/null
+++ b/tests/app_tests/integration/test_pasta_app_ui.py
@@ -0,0 +1,26 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_pasta_app_ui.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+from typing import Union
+
+import pytest
+from PySide6.QtCore import QCoreApplication
+from PySide6.QtWidgets import QApplication
+from pytestqt.qtbot import QtBot
+
+from pasta_eln.gui import MainWindow
+from tests.app_tests.common.fixtures import pasta_gui
+
+
+class TestPastaAppUI(object):
+ @pytest.mark.skip(reason="Disabled until the PASTA GUI app is modified for the latest schema changes in ontology data")
+ def test_app_launch(self, pasta_gui: tuple[Union[QApplication, QCoreApplication, None], MainWindow, QtBot]):
+ app, image_viewer, qtbot = pasta_gui
+ assert image_viewer.sidebar is not None, "Sidebar not loaded!"
+ assert image_viewer.sidebar.widgetsList is not None, "Widgets not loaded!"
+ assert len(image_viewer.sidebar.widgetsList) == 3, "Widgets count does not match"
diff --git a/tests/app_tests/test_data/__init__.py b/tests/app_tests/test_data/__init__.py
new file mode 100644
index 00000000..0e780dd4
--- /dev/null
+++ b/tests/app_tests/test_data/__init__.py
@@ -0,0 +1,8 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: __init__.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
diff --git a/tests/app_tests/test_data/ontology_document.json b/tests/app_tests/test_data/ontology_document.json
new file mode 100644
index 00000000..d5a7c801
--- /dev/null
+++ b/tests/app_tests/test_data/ontology_document.json
@@ -0,0 +1,211 @@
+{
+ "_id": "-ontology-",
+ "_rev": "5-a3f3f6b96ba5839081fa8b2f745df64a",
+ "-version": 3,
+ "x0": {
+ "link": "http://url.com",
+ "label": "Projects",
+ "prop": {
+ "default": [
+ {
+ "name": "-name",
+ "query": "What is the name of the project?",
+ "link": "http://url.com",
+ "required": "True"
+ },
+ {
+ "name": "status",
+ "query": "What is the project status",
+ "list": [
+ "active",
+ "paused",
+ "passive",
+ "finished"
+ ]
+ },
+ {
+ "name": "objective",
+ "query": "What is the objective?"
+ },
+ {
+ "name": "-tags"
+ },
+ {
+ "name": "comment",
+ "query": "#tags comments remarks :field:value:"
+ }
+ ],
+ "category1": [
+ {
+ "name": "-name",
+ "query": "What is the name of the property?",
+ "required": "True",
+ "link": "http://url.com",
+ "unit": ""
+ },
+ {
+ "name": "sample1",
+ "query": "",
+ "required": "True",
+ "link": "http://url.com",
+ "unit": "m"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "x1": {
+ "link": "",
+ "label": "Tasks",
+ "prop": {
+ "default": [
+ {
+ "name": "-name",
+ "query": "What is the name of task?"
+ },
+ {
+ "name": "comment",
+ "query": "#tags comments remarks :field:value:"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "x2": {
+ "link": "",
+ "label": "Subtasks",
+ "prop": {
+ "default": [
+ {
+ "name": "-name",
+ "query": "What is the name of subtask?"
+ },
+ {
+ "name": "comment",
+ "query": "#tags comments remarks :field:value:"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "measurement": {
+ "link": "",
+ "label": "Measurements",
+ "prop": {
+ "default": [
+ {
+ "name": "-name",
+ "query": "What is the file name?"
+ },
+ {
+ "name": "-tags"
+ },
+ {
+ "name": "comment",
+ "query": "#tags comments remarks :field:value:"
+ },
+ {
+ "name": "-type"
+ },
+ {
+ "name": "image"
+ },
+ {
+ "name": "#_curated"
+ },
+ {
+ "name": "sample",
+ "query": "Which sample was used?",
+ "list": "sample",
+ "unit": "m",
+ "required": "True",
+ "link": "http://url.com"
+ },
+ {
+ "name": "procedure",
+ "query": "Which procedure was used?",
+ "list": "procedure"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "sample": {
+ "link": "",
+ "label": "Samples",
+ "prop": {
+ "default": [
+ {
+ "name": "-name",
+ "query": "What is the name / identifier of the sample?"
+ },
+ {
+ "name": "chemistry",
+ "query": "What is its chemical composition?"
+ },
+ {
+ "name": "-tags"
+ },
+ {
+ "name": "comment",
+ "query": "#tags comments remarks :field:value:"
+ },
+ {
+ "name": "qrCode"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "procedure": {
+ "link": "",
+ "label": "Procedures",
+ "prop": {
+ "default": [
+ {
+ "name": "-name",
+ "query": "What is the name / path?"
+ },
+ {
+ "name": "-tags"
+ },
+ {
+ "name": "comment",
+ "query": "#tags comments :field:value: e.g. #SOP_v1"
+ },
+ {
+ "name": "content",
+ "query": "What is procedure (Markdown possible; autofilled if file given)?"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "instrument": {
+ "link": "",
+ "label": "Instruments",
+ "prop": {
+ "default": [
+ {
+ "name": "-name",
+ "query": "What is the name / path?",
+ "required": "True"
+ },
+ {
+ "name": "comment",
+ "query": "#tags comments :field:value: e.g. #SOP_v1"
+ },
+ {
+ "name": "vendor",
+ "query": "Who is the vendor?"
+ }
+ ]
+ },
+ "attachments": [
+ {
+ "location": "Right side of the instrument",
+ "link": "device1"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tests/app_tests/unit_tests/__init__.py b/tests/app_tests/unit_tests/__init__.py
new file mode 100644
index 00000000..0e780dd4
--- /dev/null
+++ b/tests/app_tests/unit_tests/__init__.py
@@ -0,0 +1,8 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: __init__.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
diff --git a/tests/app_tests/unit_tests/test_ontology_config_configuration_extended.py b/tests/app_tests/unit_tests/test_ontology_config_configuration_extended.py
new file mode 100644
index 00000000..bcc80273
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_configuration_extended.py
@@ -0,0 +1,807 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_configuration_extended.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+import pytest
+from PySide6.QtWidgets import QApplication, QDialog
+
+from pasta_eln.GUI.ontology_configuration.create_type_dialog_extended import CreateTypeDialog
+from pasta_eln.GUI.ontology_configuration.delete_column_delegate import DeleteColumnDelegate
+from pasta_eln.GUI.ontology_configuration.ontology_config_generic_exception import OntologyConfigGenericException
+from pasta_eln.GUI.ontology_configuration.ontology_config_key_not_found_exception import \
+ OntologyConfigKeyNotFoundException
+from pasta_eln.GUI.ontology_configuration.ontology_document_null_exception import OntologyDocumentNullException
+from pasta_eln.GUI.ontology_configuration.ontology_attachments_tableview_data_model import \
+ OntologyAttachmentsTableViewModel
+from pasta_eln.GUI.ontology_configuration.ontology_configuration_extended import OntologyConfigurationForm, get_gui
+from pasta_eln.GUI.ontology_configuration.ontology_props_tableview_data_model import OntologyPropsTableViewModel
+from pasta_eln.GUI.ontology_configuration.reorder_column_delegate import ReorderColumnDelegate
+from pasta_eln.GUI.ontology_configuration.required_column_delegate import RequiredColumnDelegate
+from tests.app_tests.common.fixtures import configuration_extended, ontology_doc_mock
+
+
+class TestOntologyConfigConfiguration(object):
+
+ def test_instantiation_should_succeed(self,
+ mocker):
+ mock_document = mocker.patch('cloudant.document.Document')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.create_type_dialog_extended.logging.getLogger')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration.Ui_OntologyConfigurationBaseForm.setupUi')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.adjust_ontology_data_to_v3')
+ mocker.patch.object(QDialog, '__new__')
+ mocker.patch.object(OntologyPropsTableViewModel, '__new__')
+ mocker.patch.object(OntologyAttachmentsTableViewModel, '__new__')
+ mocker.patch.object(RequiredColumnDelegate, '__new__', lambda _: mocker.MagicMock())
+ mocker.patch.object(DeleteColumnDelegate, '__new__', lambda _: mocker.MagicMock())
+ mocker.patch.object(ReorderColumnDelegate, '__new__', lambda _: mocker.MagicMock())
+ mocker.patch.object(OntologyConfigurationForm, 'typePropsTableView', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeAttachmentsTableView', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'loadOntologyPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addPropsRowPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addAttachmentPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'saveOntologyPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addPropsCategoryPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'deletePropsCategoryPushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'deleteTypePushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'addTypePushButton', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeComboBox', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'propsCategoryComboBox', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeLabelLineEdit', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'typeLinkLineEdit', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'delete_column_delegate_props_table', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'reorder_column_delegate_props_table', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'delete_column_delegate_attach_table', create=True)
+ mocker.patch.object(OntologyConfigurationForm, 'reorder_column_delegate_attach_table', create=True)
+ mocker.patch.object(CreateTypeDialog, '__new__')
+ config_instance = OntologyConfigurationForm(mock_document)
+ assert config_instance, "OntologyConfigurationForm should be created"
+
+ def test_instantiation_with_null_document_should_throw_exception(self,
+ mocker):
+ mocker.patch('pasta_eln.GUI.ontology_configuration.create_type_dialog_extended.logging.getLogger')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration.Ui_OntologyConfigurationBaseForm.setupUi')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.adjust_ontology_data_to_v3')
+ mocker.patch.object(QDialog, '__new__')
+ with pytest.raises(OntologyDocumentNullException, match="Null document passed for ontology data"):
+ OntologyConfigurationForm(None)
+
+ @pytest.mark.parametrize("new_type_selected, mock_ontology_types", [
+ ("x0", {
+ "x0": {
+ "label": "x0",
+ "link": "url",
+ "prop": {
+ "default": [
+ {
+ "key": "key",
+ "value": "value"}
+ ],
+ "category1": [
+ {
+ "key": "key",
+ "value": "value"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "x1": {
+ "label": "x0",
+ "link": "url",
+ "prop": {
+ "default": [
+ {
+ "key": "key",
+ "value": "value"}
+ ],
+ "category1": [
+ {
+ "key": "key",
+ "value": "value"
+ }
+ ]
+ },
+ "attachments": []
+ }
+ }),
+ ("x1", {
+ "x0": {
+ "label": "x0",
+ "link": "url",
+ "prop": {
+ "default": [
+ {
+ "key": "key",
+ "value": "value"}
+ ],
+ "category1": [
+ {
+ "key": "key",
+ "value": "value"
+ }
+ ]
+ },
+ "attachments": []
+ },
+ "x1": {
+ "label": "x0",
+ "link": "url",
+ "prop": {
+ "default": [
+ {
+ "key": "key",
+ "value": "value"}
+ ],
+ "category1": [
+ {
+ "key": "key",
+ "value": "value"
+ }
+ ]
+ },
+ "attachments": []
+ }
+ }),
+ (None, {}),
+ ("x0", {}),
+ ("x0", {"x1": {}}),
+ ("x0", {"x0": {}}),
+ ("x0", {"x0": {"label": None, "link": None, "prop": None, "attachments": None}}),
+ ("x0", {"x0": {"label": None, "link": None, "prop": {"": None}, "attachments": [{"": None}]}}),
+ ("x0", {"x0": {"": None, "§": None, "props": {"": None}, "attachment": [{"": None}]}})
+ ])
+ def test_type_combo_box_changed_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ new_type_selected,
+ mock_ontology_types):
+ logger_info_spy = mocker.spy(configuration_extended.logger, 'info')
+ mocker.patch.object(configuration_extended, 'addPropsCategoryLineEdit', create=True)
+ mocker.patch.object(configuration_extended, 'ontology_types', mock_ontology_types, create=True)
+ mocker.patch.object(configuration_extended, 'typeLabelLineEdit', create=True)
+ mocker.patch.object(configuration_extended, 'typeLinkLineEdit', create=True)
+ mocker.patch.object(configuration_extended, 'attachments_table_data_model', create=True)
+ mocker.patch.object(configuration_extended, 'propsCategoryComboBox', create=True)
+ set_text_label_line_edit_spy = mocker.spy(configuration_extended.typeLabelLineEdit, 'setText')
+ set_text_link_line_edit_spy = mocker.spy(configuration_extended.typeLinkLineEdit, 'setText')
+ set_current_index_category_combo_box_spy = mocker.spy(configuration_extended.propsCategoryComboBox,
+ 'setCurrentIndex')
+ clear_add_props_category_line_edit_spy = mocker.spy(configuration_extended.addPropsCategoryLineEdit, 'clear')
+ clear_category_combo_box_spy = mocker.spy(configuration_extended.propsCategoryComboBox, 'clear')
+ add_items_category_combo_box_spy = mocker.spy(configuration_extended.propsCategoryComboBox, 'addItems')
+ update_attachment_table_model_spy = mocker.spy(configuration_extended.attachments_table_data_model, 'update')
+ if mock_ontology_types is not None and len(
+ mock_ontology_types) > 0 and new_type_selected not in mock_ontology_types:
+ with pytest.raises(OntologyConfigKeyNotFoundException,
+ match=f"Key {new_type_selected} not found in ontology_types"):
+ assert configuration_extended.type_combo_box_changed(
+ new_type_selected) is not None, "Nothing should be returned"
+
+ if (mock_ontology_types
+ and new_type_selected
+ and new_type_selected in mock_ontology_types):
+ assert configuration_extended.type_combo_box_changed(new_type_selected) is None, "Nothing should be returned"
+ logger_info_spy.assert_called_once_with("New type selected in UI: {%s}", new_type_selected)
+ clear_add_props_category_line_edit_spy.assert_called_once_with()
+ set_text_label_line_edit_spy.assert_called_once_with(mock_ontology_types.get(new_type_selected).get('label'))
+ set_text_link_line_edit_spy.assert_called_once_with(mock_ontology_types.get(new_type_selected).get('link'))
+ set_current_index_category_combo_box_spy.assert_called_once_with(0)
+ clear_category_combo_box_spy.assert_called_once_with()
+ add_items_category_combo_box_spy.assert_called_once_with(
+ list(mock_ontology_types.get(new_type_selected).get('prop').keys())
+ if mock_ontology_types.get(new_type_selected).get('prop') else [])
+ update_attachment_table_model_spy.assert_called_once_with(
+ mock_ontology_types.get(new_type_selected).get('attachments'))
+
+ @pytest.mark.parametrize("new_selected_prop_category, selected_type_props", [
+ (None, {}),
+ ("default", {}),
+ ("default", {"default": [], "category1": [], "category2": []}),
+ ("category1", {"default": [], "category1": [], "category2": []}),
+ ("default", {"default": [], "category1": [], "category2": []}),
+ ("category1", {"default": [], "category1": [], "category2": []}),
+ ("category2", {"default": [], "category1": [], "category2": []}),
+ ("category2", {"default": [], "category1": [{"name": "key", "value": "value"}], "category2": []}),
+ ("category1", {"default": [], "category1": [{"name": None, "value": None}], "category2": None}),
+ ("category2", {"default": [], "category1": [{"name": None, "value": None}], "category2": None}),
+ ("category3", {"default": [], "category1": [], "category2": []}),
+ ])
+ def test_type_category_combo_box_changed_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ new_selected_prop_category,
+ selected_type_props):
+ logger_info_spy = mocker.spy(configuration_extended.logger, 'info')
+ mocker.patch.object(configuration_extended, 'selected_type_properties', selected_type_props, create=True)
+ mocker.patch.object(configuration_extended, 'props_table_data_model', create=True)
+ update_props_table_model_spy = mocker.spy(configuration_extended.props_table_data_model, 'update')
+ assert configuration_extended.category_combo_box_changed(
+ new_selected_prop_category) is None, "Nothing should be returned"
+ logger_info_spy.assert_called_once_with("New property category selected in UI: {%s}", new_selected_prop_category)
+ if new_selected_prop_category and selected_type_props:
+ update_props_table_model_spy.assert_called_once_with(selected_type_props.get(new_selected_prop_category))
+
+ @pytest.mark.parametrize("new_category, ontology_types, selected_type_properties", [
+ (None, None, {}),
+ ("default", None, {}),
+ (None, {0: "x0"}, {"default": [], "category1": [], "category2": []}),
+ ("default", {0: "x0"}, {"default": [], "category1": [], "category2": []}),
+ ("category1", {0: "x0"}, {"default": [], "category1": [], "category2": []}),
+ ("default", {0: "x0"}, {"default": [], "category1": [], "category2": []}),
+ ("category1", {0: "x0"}, {"default": [], "category1": [], "category2": []}),
+ ("category2", {0: "x0"}, {"default": [], "category1": [], "category2": []}),
+ ("category2", {0: "x0"}, {"default": [], "category1": [{"name": "key", "value": "value"}], "category2": []}),
+ ("category1", {0: "x0"}, {"default": [], "category1": [{"name": None, "value": None}], "category2": None}),
+ ("category2", {0: "x0"}, {"default": [], "category1": [{"name": None, "value": None}], "category2": None}),
+ ("category3", {0: "x0"}, {"default": [], "category1": [], "category2": []}),
+ ])
+ def test_add_new_prop_category_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ new_category,
+ ontology_types,
+ selected_type_properties):
+ logger_info_spy = mocker.spy(configuration_extended.logger, 'info')
+ mocker.patch.object(configuration_extended, 'addPropsCategoryLineEdit', create=True)
+ mocker.patch.object(configuration_extended.addPropsCategoryLineEdit, 'text', return_value=new_category)
+ mocker.patch.object(configuration_extended, 'ontology_types', ontology_types, create=True)
+ mocker.patch.object(configuration_extended, 'propsCategoryComboBox', create=True)
+ mocker.patch.object(configuration_extended, 'ontology_loaded', create=True)
+ add_items_selected_spy = mocker.spy(configuration_extended.propsCategoryComboBox, 'addItems')
+ clear_category_combo_box_spy = mocker.spy(configuration_extended.propsCategoryComboBox, 'clear')
+ set_current_index_category_combo_box_spy = mocker.spy(configuration_extended.propsCategoryComboBox,
+ 'setCurrentIndex')
+ mocker.patch.object(configuration_extended, 'selected_type_properties', create=True)
+ configuration_extended.selected_type_properties.__setitem__.side_effect = selected_type_properties.__setitem__
+ configuration_extended.selected_type_properties.__getitem__.side_effect = selected_type_properties.__getitem__
+ configuration_extended.selected_type_properties.__iter__.side_effect = selected_type_properties.__iter__
+ configuration_extended.selected_type_properties.keys.side_effect = selected_type_properties.keys
+ set_items_selected_spy = mocker.spy(configuration_extended.selected_type_properties, '__setitem__')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.len',
+ lambda x: len(selected_type_properties.keys()))
+ mock_show_message = mocker.patch(
+ 'pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message')
+
+ if not new_category:
+ assert configuration_extended.add_new_prop_category() is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with("Enter non-null/valid category name!!.....")
+ return
+ if not ontology_types:
+ assert configuration_extended.add_new_prop_category() is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with("Load the ontology data first....")
+ return
+
+ if new_category in selected_type_properties:
+ assert configuration_extended.add_new_prop_category() is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with("Category already exists....")
+ else:
+ if new_category:
+ assert configuration_extended.add_new_prop_category() is None, "Nothing should be returned"
+ logger_info_spy.assert_called_once_with("User added new category: {%s}", new_category)
+ set_items_selected_spy.assert_called_once_with(new_category, [])
+ set_current_index_category_combo_box_spy.assert_called_once_with(len(selected_type_properties.keys()) - 1)
+ clear_category_combo_box_spy.assert_called_once_with()
+ add_items_selected_spy.assert_called_once_with(
+ list(selected_type_properties.keys())
+ )
+ else:
+ assert configuration_extended.add_new_prop_category() is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with("Enter non-null/valid category name!!.....")
+
+ @pytest.mark.parametrize("selected_category, selected_type_properties", [
+ (None, {}),
+ ("default", {}),
+ ("default", None),
+ (None, {"default": [], "category1": [], "category2": []}),
+ ("default", {"default": [], "category1": [], "category2": []}),
+ ("category1", {"default": [], "category1": [], "category2": []}),
+ ("default", {"default": [], "category1": [], "category2": []}),
+ ("category1", {"default": [], "category1": [], "category2": []}),
+ ("category2", {"default": [], "category1": [], "category2": []}),
+ ("category2", {"default": [], "category1": [{"name": "key", "value": "value"}], "category2": []}),
+ ("category1", {"default": [], "category1": [{"name": None, "value": None}], "category2": None}),
+ ("category2", {"default": [], "category1": [{"name": None, "value": None}], "category2": None}),
+ ("category3", {"default": [], "category1": [], "category2": []}),
+ ])
+ def test_delete_selected_category_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ selected_category,
+ selected_type_properties):
+ logger_info_spy = mocker.spy(configuration_extended.logger, 'info')
+ mocker.patch.object(configuration_extended, 'propsCategoryComboBox', create=True)
+ current_text_category_combo_box_spy = mocker.patch.object(configuration_extended.propsCategoryComboBox,
+ 'currentText', return_value=selected_category)
+ add_items_selected_spy = mocker.spy(configuration_extended.propsCategoryComboBox, 'addItems')
+ clear_category_combo_box_spy = mocker.spy(configuration_extended.propsCategoryComboBox, 'clear')
+ set_current_index_category_combo_box_spy = mocker.spy(configuration_extended.propsCategoryComboBox,
+ 'setCurrentIndex')
+ mocker.patch.object(configuration_extended, 'selected_type_properties', create=True)
+ mock_show_message = mocker.patch(
+ "pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message")
+ if selected_type_properties:
+ configuration_extended.selected_type_properties.__setitem__.side_effect = selected_type_properties.__setitem__
+ configuration_extended.selected_type_properties.__getitem__.side_effect = selected_type_properties.__getitem__
+ configuration_extended.selected_type_properties.pop.side_effect = selected_type_properties.pop
+ configuration_extended.selected_type_properties.keys.side_effect = selected_type_properties.keys
+ pop_items_selected_spy = mocker.spy(configuration_extended.selected_type_properties, 'pop')
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.len',
+ lambda x: len(selected_type_properties.keys()))
+
+ if selected_type_properties is None:
+ mocker.patch.object(configuration_extended, 'selected_type_properties', None)
+ assert configuration_extended.delete_selected_prop_category() is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with("Load the ontology data first....")
+ return
+ if selected_type_properties and selected_category in selected_type_properties:
+ assert configuration_extended.delete_selected_prop_category() is None, "Nothing should be returned"
+ current_text_category_combo_box_spy.assert_called_once_with()
+ logger_info_spy.assert_called_once_with("User deleted the selected category: {%s}", selected_category)
+ pop_items_selected_spy.assert_called_once_with(selected_category)
+ clear_category_combo_box_spy.assert_called_once_with()
+ add_items_selected_spy.assert_called_once_with(
+ list(selected_type_properties.keys())
+ )
+ set_current_index_category_combo_box_spy.assert_called_once_with(len(selected_type_properties.keys()) - 1)
+
+ @pytest.mark.parametrize("modified_type_label, current_type, ontology_types", [
+ (None, None, None),
+ ("new_label_1", None, None),
+ (None, "x0", {"x0": {"label": "x0"}, "x1": {"label": "x1"}}),
+ ("new_label_2", "x1", {"x0": {"label": "x0"}, "x1": {"label": "x1"}}),
+ ("new_label_2", "instrument", {"x0": {"label": "x0"}, "instrument": {"label": "x1"}}),
+ ("type_new_label", "subtask4", {"x0": {"label": "x0"}, "subtask5": {"label": "x1"}}),
+ ])
+ def test_update_structure_label_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ modified_type_label,
+ current_type,
+ ontology_types):
+ mocker.patch.object(configuration_extended, 'typeComboBox', create=True)
+ mocker.patch.object(configuration_extended.typeComboBox, 'currentText', return_value=current_type)
+
+ mocker.patch.object(configuration_extended, 'ontology_types', create=True)
+ if ontology_types:
+ configuration_extended.ontology_types.__setitem__.side_effect = ontology_types.__setitem__
+ configuration_extended.ontology_types.__getitem__.side_effect = ontology_types.__getitem__
+ configuration_extended.ontology_types.__iter__.side_effect = ontology_types.__iter__
+ configuration_extended.ontology_types.__contains__.side_effect = ontology_types.__contains__
+ configuration_extended.ontology_types.get.side_effect = ontology_types.get
+ configuration_extended.ontology_types.keys.side_effect = ontology_types.keys
+
+ get_ontology_types_spy = mocker.spy(configuration_extended.ontology_types, 'get')
+
+ if modified_type_label:
+ assert configuration_extended.update_structure_label(modified_type_label) is None, "Nothing should be returned"
+ if ontology_types is not None and current_type in ontology_types:
+ get_ontology_types_spy.assert_called_once_with(current_type)
+ assert ontology_types[current_type]["label"] == modified_type_label
+
+ @pytest.mark.parametrize("modified_type_link, current_type, ontology_types", [
+ (None, None, None),
+ ("new_url", None, None),
+ (None, "x0", {"x0": {"label": "x0"}, "x1": {"label": "x1"}}),
+ ("new_url_2", "x1", {"x0": {"label": "x0"}, "x1": {"label": "x1"}}),
+ ("new_url_2", "instrument", {"x0": {"label": "x0"}, "instrument": {"label": "x1"}}),
+ ("type_new_url", "subtask4", {"x0": {"label": "x0"}, "subtask5": {"label": "x1"}}),
+ ])
+ def test_update_type_link_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ modified_type_link,
+ current_type,
+ ontology_types):
+ mocker.patch.object(configuration_extended, 'typeComboBox', create=True)
+ mocker.patch.object(configuration_extended.typeComboBox, 'currentText', return_value=current_type)
+
+ mocker.patch.object(configuration_extended, 'ontology_types', create=True)
+ if ontology_types:
+ configuration_extended.ontology_types.__setitem__.side_effect = ontology_types.__setitem__
+ configuration_extended.ontology_types.__getitem__.side_effect = ontology_types.__getitem__
+ configuration_extended.ontology_types.__iter__.side_effect = ontology_types.__iter__
+ configuration_extended.ontology_types.__contains__.side_effect = ontology_types.__contains__
+ configuration_extended.ontology_types.get.side_effect = ontology_types.get
+ configuration_extended.ontology_types.keys.side_effect = ontology_types.keys
+
+ get_ontology_types_spy = mocker.spy(configuration_extended.ontology_types, 'get')
+
+ if modified_type_link:
+ assert configuration_extended.update_type_link(modified_type_link) is None, "Nothing should be returned"
+ if ontology_types is not None and current_type in ontology_types:
+ get_ontology_types_spy.assert_called_once_with(current_type)
+ assert ontology_types[current_type]["link"] == modified_type_link
+
+ @pytest.mark.parametrize("selected_type, ontology_types, ontology_document", [
+ (None, None, None),
+ ("x0", None, None),
+ (None, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ ("x3", {"x0": {"link": "x0"}, "x1": {"link": "x1"}}, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ ("instrument", {"x0": {"link": "x0"}, "instrument": {"link": "x1"}},
+ {"x0": {"link": "x0"}, "instrument": {"link": "x1"}}),
+ (
+ "subtask5", {"x0": {"link": "x0"}, "subtask5": {"link": "x1"}},
+ {"x0": {"link": "x0"}, "subtask5": {"link": "x1"}}),
+ ("x0", {"x0": {"link": "x0"}, "subtask5": {"link": "x1"}}, {"subtask5": {"link": "x1"}}),
+ ("x0", {"subtask5": {"link": "x1"}}, {"x0": {"link": "x0"}, "subtask5": {"link": "x1"}}),
+ ])
+ def test_delete_selected_type_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ selected_type,
+ ontology_types,
+ ontology_document):
+ mocker.patch.object(configuration_extended, 'typeComboBox', create=True)
+ mocker.patch.object(configuration_extended.typeComboBox, 'currentText', return_value=selected_type)
+
+ mocker.patch.object(configuration_extended, 'ontology_types', create=True)
+ mocker.patch.object(configuration_extended, 'ontology_document', create=True)
+ clear_category_combo_box_spy = mocker.spy(configuration_extended.typeComboBox, 'clear')
+ set_current_index_category_combo_box_spy = mocker.spy(configuration_extended.typeComboBox, 'setCurrentIndex')
+ add_items_selected_spy = mocker.spy(configuration_extended.typeComboBox, 'addItems')
+ logger_info_spy = mocker.spy(configuration_extended.logger, 'info')
+ mock_show_message = mocker.patch(
+ "pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message")
+ mocker.patch.object(configuration_extended, 'ontology_loaded', True, create=True)
+ if ontology_document:
+ original_ontology_document = ontology_document.copy()
+ configuration_extended.ontology_document.__setitem__.side_effect = ontology_document.__setitem__
+ configuration_extended.ontology_document.__getitem__.side_effect = ontology_document.__getitem__
+ configuration_extended.ontology_document.__iter__.side_effect = ontology_document.__iter__
+ configuration_extended.ontology_document.__contains__.side_effect = ontology_document.__contains__
+ configuration_extended.ontology_document.get.side_effect = ontology_document.get
+ configuration_extended.ontology_document.keys.side_effect = ontology_document.keys
+ configuration_extended.ontology_document.pop.side_effect = ontology_document.pop
+ pop_items_selected_ontology_document_spy = mocker.spy(configuration_extended.ontology_document, 'pop')
+ if ontology_types:
+ original_ontology_types = ontology_types.copy()
+ configuration_extended.ontology_types.__setitem__.side_effect = ontology_types.__setitem__
+ configuration_extended.ontology_types.__getitem__.side_effect = ontology_types.__getitem__
+ configuration_extended.ontology_types.__iter__.side_effect = ontology_types.__iter__
+ configuration_extended.ontology_types.__contains__.side_effect = ontology_types.__contains__
+ configuration_extended.ontology_types.get.side_effect = ontology_types.get
+ configuration_extended.ontology_types.keys.side_effect = ontology_types.keys
+ configuration_extended.ontology_types.pop.side_effect = ontology_types.pop
+ pop_items_selected_ontology_types_spy = mocker.spy(configuration_extended.ontology_types, 'pop')
+
+ if ontology_document is None or ontology_types is None:
+ mocker.patch.object(configuration_extended, 'ontology_types', ontology_types)
+ mocker.patch.object(configuration_extended, 'ontology_document', ontology_document)
+ assert configuration_extended.delete_selected_type() is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with("Load the ontology data first....")
+ return
+ if selected_type:
+ assert configuration_extended.delete_selected_type() is None, "Nothing should be returned"
+ if selected_type and selected_type in original_ontology_types and selected_type in original_ontology_document:
+ logger_info_spy.assert_called_once_with("User deleted the selected type: {%s}", selected_type)
+ pop_items_selected_ontology_types_spy.assert_called_once_with(selected_type)
+ pop_items_selected_ontology_document_spy.assert_called_once_with(selected_type)
+ clear_category_combo_box_spy.assert_called_once_with()
+ add_items_selected_spy.assert_called_once_with(
+ ontology_types.keys()
+ )
+ set_current_index_category_combo_box_spy.assert_called_once_with(0)
+ assert selected_type not in ontology_types and ontology_document, "selected_type should be deleted"
+ else:
+ logger_info_spy.assert_not_called()
+ logger_info_spy.assert_not_called()
+ pop_items_selected_ontology_types_spy.assert_not_called()
+ pop_items_selected_ontology_document_spy.assert_not_called()
+ clear_category_combo_box_spy.assert_not_called()
+ add_items_selected_spy.assert_not_called()
+ set_current_index_category_combo_box_spy.assert_not_called()
+
+ @pytest.mark.parametrize("new_title, new_label", [
+ (None, None),
+ ("x0", None),
+ (None, "x2"),
+ ("x3", "x3"),
+ ("instrument", "new Instrument")
+ ])
+ def test_create_type_accepted_callback_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ new_title,
+ new_label):
+ mocker.patch.object(configuration_extended, 'create_type_dialog', create=True)
+ mocker.patch.object(configuration_extended.create_type_dialog, 'titleLineEdit', create=True)
+ mocker.patch.object(configuration_extended.create_type_dialog.titleLineEdit, 'text', return_value=new_title)
+ mocker.patch.object(configuration_extended.create_type_dialog, 'labelLineEdit', create=True)
+ mocker.patch.object(configuration_extended.create_type_dialog.labelLineEdit, 'text', return_value=new_label)
+ clear_ui_spy = mocker.patch.object(configuration_extended.create_type_dialog, 'clear_ui', create=True)
+ create_new_type_spy = mocker.patch.object(configuration_extended, 'create_new_type', create=True)
+ text_title_line_edit_text_spy = mocker.spy(configuration_extended.create_type_dialog.titleLineEdit, 'text')
+ text_label_line_edit_text_spy = mocker.spy(configuration_extended.create_type_dialog.labelLineEdit, 'text')
+
+ assert configuration_extended.create_type_accepted_callback() is None, "Nothing should be returned"
+ text_title_line_edit_text_spy.assert_called_once_with()
+ text_label_line_edit_text_spy.assert_called_once_with()
+ clear_ui_spy.assert_called_once_with()
+ create_new_type_spy.assert_called_once_with(
+ new_title, new_label
+ )
+
+ def test_create_type_rejected_callback_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended):
+ mocker.patch.object(configuration_extended, 'create_type_dialog', create=True)
+ clear_ui_spy = mocker.patch.object(configuration_extended.create_type_dialog, 'clear_ui', create=True)
+ assert configuration_extended.create_type_rejected_callback() is None, "Nothing should be returned"
+ clear_ui_spy.assert_called_once_with()
+
+ @pytest.mark.parametrize("new_structural_title, ontology_types", [
+ (None, None),
+ ("x0", None),
+ (None, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ ("x3", {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ ("x7", {"x0": {"link": "x0"}, "instrument": {"link": "x1"}}),
+ ("x6", {"x0": {"link": "x0"}, "subtask5": {"link": "x1"}})
+ ])
+ def test_show_create_type_dialog_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ new_structural_title,
+ ontology_types):
+ mocker.patch.object(configuration_extended, 'create_type_dialog', create=True)
+ mocker.patch.object(configuration_extended, 'ontology_types', create=True)
+ set_structural_level_title_spy = mocker.patch.object(configuration_extended.create_type_dialog,
+ 'set_structural_level_title', create=True)
+ mocker.patch.object(configuration_extended, 'ontology_loaded', create=True)
+ show_create_type_dialog_spy = mocker.patch.object(configuration_extended.create_type_dialog, 'show', create=True)
+ show_message_spy = mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message')
+ get_next_possible_structural_level_label_spy = mocker.patch(
+ 'pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.get_next_possible_structural_level_label',
+ return_value=new_structural_title)
+ if ontology_types is not None:
+ configuration_extended.ontology_types.__setitem__.side_effect = ontology_types.__setitem__
+ configuration_extended.ontology_types.__getitem__.side_effect = ontology_types.__getitem__
+ configuration_extended.ontology_types.__iter__.side_effect = ontology_types.__iter__
+ configuration_extended.ontology_types.__contains__.side_effect = ontology_types.__contains__
+ configuration_extended.ontology_types.get.side_effect = ontology_types.get
+ configuration_extended.ontology_types.keys.side_effect = ontology_types.keys
+ configuration_extended.ontology_types.pop.side_effect = ontology_types.pop
+ else:
+ mocker.patch.object(configuration_extended, 'ontology_types', None)
+
+ assert configuration_extended.show_create_type_dialog() is None, "Nothing should be returned"
+ if ontology_types is not None:
+ get_next_possible_structural_level_label_spy.assert_called_once_with(ontology_types.keys())
+ set_structural_level_title_spy.assert_called_once_with(new_structural_title)
+ show_create_type_dialog_spy.assert_called_once_with()
+ else:
+ show_message_spy.assert_called_once_with("Load the ontology data first...")
+ get_next_possible_structural_level_label_spy.assert_not_called()
+ set_structural_level_title_spy.assert_not_called()
+ show_create_type_dialog_spy.assert_not_called()
+
+ def test_setup_slots_should_do_expected(self,
+ configuration_extended: configuration_extended):
+ configuration_extended.logger.info.assert_called_once_with(f"Setting up slots for the editor..")
+ configuration_extended.loadOntologyPushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.load_ontology_data)
+ configuration_extended.loadOntologyPushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.load_ontology_data)
+ configuration_extended.addPropsRowPushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.props_table_data_model.add_data_row)
+ configuration_extended.addAttachmentPushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.attachments_table_data_model.add_data_row)
+ configuration_extended.saveOntologyPushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.save_ontology)
+ configuration_extended.addPropsCategoryPushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.add_new_prop_category)
+ configuration_extended.deletePropsCategoryPushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.delete_selected_prop_category)
+ configuration_extended.deleteTypePushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.delete_selected_type)
+ configuration_extended.addTypePushButton.clicked.connect.assert_called_once_with(
+ configuration_extended.show_create_type_dialog)
+
+ # Slots for the combo-boxes
+ configuration_extended.typeComboBox.currentTextChanged.connect.assert_called_once_with(
+ configuration_extended.type_combo_box_changed)
+ configuration_extended.propsCategoryComboBox.currentTextChanged.connect.assert_called_once_with(
+ configuration_extended.category_combo_box_changed)
+
+ # Slots for line edits
+ configuration_extended.typeLabelLineEdit.textChanged[str].connect.assert_called_once_with(
+ configuration_extended.update_structure_label)
+ configuration_extended.typeLinkLineEdit.textChanged[str].connect.assert_called_once_with(
+ configuration_extended.update_type_link)
+
+ # Slots for the delegates
+ configuration_extended.delete_column_delegate_props_table.delete_clicked_signal.connect.assert_called_once_with(
+ configuration_extended.props_table_data_model.delete_data)
+ configuration_extended.reorder_column_delegate_props_table.re_order_signal.connect.assert_called_once_with(
+ configuration_extended.props_table_data_model.re_order_data)
+
+ configuration_extended.delete_column_delegate_attach_table.delete_clicked_signal.connect.assert_called_once_with(
+ configuration_extended.attachments_table_data_model.delete_data)
+ configuration_extended.reorder_column_delegate_attach_table.re_order_signal.connect.assert_called_once_with(
+ configuration_extended.attachments_table_data_model.re_order_data)
+
+ @pytest.mark.parametrize("ontology_document", [
+ 'ontology_doc_mock',
+ None,
+ {"x0": {"link": "x0"}, "": {"link": "x1"}},
+ {"x0": {"link": "x0"}, "": {"link": "x1"}, 23: "test", "__id": "test"},
+ {"test": ["test1", "test2", "test3"]}
+ ])
+ def test_load_ontology_data_should_with_variant_types_of_doc_should_do_expected(self,
+ mocker,
+ ontology_document,
+ configuration_extended: configuration_extended,
+ request):
+ doc = request.getfixturevalue(ontology_document) \
+ if ontology_document and type(ontology_document) is str \
+ else ontology_document
+ mocker.patch.object(configuration_extended, 'ontology_document', doc, create=True)
+ if ontology_document is None:
+ with pytest.raises(OntologyConfigGenericException, match="Null ontology_document, erroneous app state"):
+ assert configuration_extended.load_ontology_data() is None, "Nothing should be returned"
+ return
+ assert configuration_extended.load_ontology_data() is None, "Nothing should be returned"
+ configuration_extended.typeComboBox.clear.assert_called_once_with()
+ configuration_extended.typeComboBox.addItems.assert_called_once_with(configuration_extended.ontology_types.keys())
+ configuration_extended.typeComboBox.setCurrentIndex.assert_called_once_with(0)
+ for data in ontology_document:
+ if type(data) is dict:
+ assert data in configuration_extended.ontology_types, "Data should be loaded"
+
+ @pytest.mark.parametrize("ontology_document", [
+ 'ontology_doc_mock',
+ None,
+ {"x0": {"link": "x0"}, "": {"link": "x1"}},
+ {"x0": {"link": "x0"}, "": {"link": "x1"}, 23: "test", "__id": "test"},
+ {"test": ["test1", "test2", "test3"]}
+ ])
+ def test_save_ontology_should_do_expected(self,
+ mocker,
+ ontology_document,
+ configuration_extended: configuration_extended,
+ request):
+ doc = request.getfixturevalue(ontology_document) \
+ if ontology_document and type(ontology_document) is str \
+ else ontology_document
+ mocker.patch.object(configuration_extended, 'ontology_document', create=True)
+ if doc:
+ configuration_extended.ontology_document.__setitem__.side_effect = doc.__setitem__
+ configuration_extended.ontology_document.__getitem__.side_effect = doc.__getitem__
+ configuration_extended.ontology_document.__iter__.side_effect = doc.__iter__
+ configuration_extended.ontology_document.__contains__.side_effect = doc.__contains__
+
+ mocker.patch.object(configuration_extended.logger, 'info')
+ mock_show_message = mocker.patch(
+ 'pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message')
+ assert configuration_extended.save_ontology() is None, "Nothing should be returned"
+ configuration_extended.logger.info.assert_called_once_with("User saved the ontology data document!!")
+ configuration_extended.ontology_document.save.assert_called_once()
+ mock_show_message.assert_called_once_with("Ontology data saved successfully..")
+
+ @pytest.mark.parametrize("new_title, new_label, ontology_document, ontology_types", [
+ (None, None, None, None),
+ (None, None, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ ("x0", None, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ (None, "x1", {"x0": {"link": "x0"}, "x1": {"link": "x1"}}, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ ("x0", "x1", {"x0": {"link": "x0"}, "x1": {"link": "x1"}}, {"x0": {"link": "x0"}, "x1": {"link": "x1"}}),
+ ("x0", "x1", None, None),
+ ("instrument", "new Instrument", {"x0": {"link": "x0"}, "x1": {"link": "x1"}},
+ {"x0": {"link": "x0"}, "x1": {"link": "x1"}})
+ ])
+ def test_create_new_type_should_do_expected(self,
+ mocker,
+ new_title,
+ new_label,
+ ontology_document,
+ ontology_types,
+ configuration_extended: configuration_extended):
+ mocker.patch.object(configuration_extended, 'ontology_document', create=True)
+ mocker.patch.object(configuration_extended, 'ontology_types', create=True)
+ mock_show_message = mocker.patch(
+ 'pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.show_message')
+ mock_log_info = mocker.patch.object(configuration_extended.logger, 'info')
+ mock_log_error = mocker.patch.object(configuration_extended.logger, 'error')
+ mock_log_warn = mocker.patch.object(configuration_extended.logger, 'warning')
+ if ontology_document:
+ configuration_extended.ontology_document.__setitem__.side_effect = ontology_document.__setitem__
+ configuration_extended.ontology_document.__getitem__.side_effect = ontology_document.__getitem__
+ configuration_extended.ontology_document.__iter__.side_effect = ontology_document.__iter__
+ configuration_extended.ontology_document.__contains__.side_effect = ontology_document.__contains__
+ configuration_extended.ontology_document.get.side_effect = ontology_document.get
+ configuration_extended.ontology_document.keys.side_effect = ontology_document.keys
+ configuration_extended.ontology_document.pop.side_effect = ontology_document.pop
+ if ontology_types is not None:
+ configuration_extended.ontology_types.__setitem__.side_effect = ontology_types.__setitem__
+ configuration_extended.ontology_types.__getitem__.side_effect = ontology_types.__getitem__
+ configuration_extended.ontology_types.__iter__.side_effect = ontology_types.__iter__
+ configuration_extended.ontology_types.__contains__.side_effect = ontology_types.__contains__
+ configuration_extended.ontology_types.get.side_effect = ontology_types.get
+ configuration_extended.ontology_types.keys.side_effect = ontology_types.keys
+ configuration_extended.ontology_types.pop.side_effect = ontology_types.pop
+ configuration_extended.ontology_types.__len__.side_effect = ontology_types.__len__
+
+ if ontology_document is None:
+ mocker.patch.object(configuration_extended, 'ontology_document', None, create=True)
+ if ontology_types is None:
+ mocker.patch.object(configuration_extended, 'ontology_types', None, create=True)
+
+ if ontology_document is None or ontology_types is None or new_title in ontology_document:
+ if ontology_document is None or ontology_types is None:
+ with pytest.raises(OntologyConfigGenericException,
+ match="Null ontology_document/ontology_types, erroneous app state"):
+ assert configuration_extended.create_new_type(new_title, new_label) is None, "Nothing should be returned"
+ mock_log_error.assert_called_once_with("Null ontology_document/ontology_types, erroneous app state")
+ else:
+ assert configuration_extended.create_new_type(new_title, new_label) is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with(f"Type (title: {new_title} "
+ f"label: {new_label}) cannot be added "
+ f"since it exists in DB already....")
+ else:
+ if new_title is None:
+ assert configuration_extended.create_new_type(None, new_label) is None, "Nothing should be returned"
+ mock_show_message.assert_called_once_with("Enter non-null/valid title!!.....")
+ mock_log_warn.assert_called_once_with("Enter non-null/valid title!!.....")
+ else:
+ assert configuration_extended.create_new_type(new_title, new_label) is None, "Nothing should be returned"
+ mock_log_info.assert_called_once_with("User created a new type and added "
+ "to the ontology document: Title: {%s}, Label: {%s}", new_title,
+ new_label)
+
+ (configuration_extended.ontology_document
+ .__setitem__.assert_called_once_with(new_title,
+ {
+ "link": "",
+ "label": new_label,
+ "prop": {
+ "default": []
+ },
+ "attachments": []
+ }))
+ (configuration_extended.ontology_types
+ .__setitem__.assert_called_once_with(new_title,
+ {
+ "link": "",
+ "label": new_label,
+ "prop": {
+ "default": []
+ },
+ "attachments": []
+ }))
+ configuration_extended.typeComboBox.clear.assert_called_once_with()
+ configuration_extended.typeComboBox.addItems.assert_called_once_with(
+ configuration_extended.ontology_types.keys())
+ mock_show_message.assert_called_once_with(f"Type (title: {new_title} label: {new_label}) has been added....")
+
+ @pytest.mark.parametrize("instance_exists", [True, False])
+ def test_get_gui_should_do_expected(self,
+ mocker,
+ configuration_extended: configuration_extended,
+ instance_exists):
+ mock_form = mocker.MagicMock()
+ mock_sys_argv = mocker.patch(
+ "pasta_eln.GUI.ontology_configuration.ontology_configuration_extended.sys.argv")
+ mock_new_app_inst = mocker.patch("PySide6.QtWidgets.QApplication")
+ mock_exist_app_inst = mocker.patch("PySide6.QtWidgets.QApplication")
+ mock_form_instance = mocker.patch("PySide6.QtWidgets.QDialog")
+ mock_document = mocker.patch("cloudant.document.Document")
+
+ mocker.patch.object(QApplication, 'instance', return_value=mock_exist_app_inst if instance_exists else None)
+ mocker.patch.object(mock_form, 'instance', mock_form_instance, create=True)
+ spy_new_app_inst = mocker.patch.object(QApplication, '__new__', return_value=mock_new_app_inst)
+ spy_form_inst = mocker.patch.object(OntologyConfigurationForm, '__new__', return_value=mock_form)
+
+ (app, form_inst, form) = get_gui(mock_document)
+ spy_form_inst.assert_called_once_with(OntologyConfigurationForm, mock_document)
+ if instance_exists:
+ assert app is mock_exist_app_inst, "Should return existing instance"
+ assert form_inst is mock_form_instance, "Should return existing instance"
+ assert form is mock_form, "Should return existing instance"
+ else:
+ spy_new_app_inst.assert_called_once_with(QApplication, mock_sys_argv)
+ assert app is mock_new_app_inst, "Should return new instance"
+ assert form_inst is mock_form_instance, "Should return existing instance"
+ assert form is mock_form, "Should return existing instance"
diff --git a/tests/app_tests/unit_tests/test_ontology_config_create_type_dialog_extended.py b/tests/app_tests/unit_tests/test_ontology_config_create_type_dialog_extended.py
new file mode 100644
index 00000000..4f68c28c
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_create_type_dialog_extended.py
@@ -0,0 +1,83 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_create_type_dialog_extended.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import pytest
+from PySide6.QtCore import Qt
+
+from tests.app_tests.common.fixtures import create_type_dialog_mock
+
+
+class TestOntologyConfigCreateTypeDialog(object):
+
+ @pytest.mark.parametrize("checked, next_level", [
+ (True, "x0"),
+ (False, "x1")
+ ])
+ def test_structural_level_checkbox_callback_should_do_expected(self,
+ mocker,
+ create_type_dialog_mock,
+ checked,
+ next_level):
+ mock_check_box = mocker.patch('PySide6.QtWidgets.QCheckBox')
+ mock_line_edit = mocker.patch('PySide6.QtWidgets.QLineEdit')
+ mocker.patch.object(mock_check_box, 'isChecked', return_value=checked)
+ mocker.patch.object(create_type_dialog_mock, 'structuralLevelCheckBox', mock_check_box, create=True)
+ mocker.patch.object(create_type_dialog_mock, 'titleLineEdit', mock_line_edit, create=True)
+ mocker.patch.object(create_type_dialog_mock, 'next_struct_level', next_level, create=True)
+ set_text_line_edit_spy = mocker.spy(mock_line_edit, 'setText')
+ set_disabled_line_edit_spy = mocker.spy(mock_line_edit, 'setDisabled')
+ clear_line_edit_spy = mocker.spy(mock_line_edit, 'clear')
+ assert create_type_dialog_mock.structural_level_checkbox_callback() is None, "create_type_dialog_mock.structural_level_checkbox_callback() should return None"
+ if checked:
+ set_text_line_edit_spy.assert_called_once_with(next_level)
+ set_disabled_line_edit_spy.assert_called_once_with(True)
+ else:
+ clear_line_edit_spy.assert_called_once_with()
+ set_disabled_line_edit_spy.assert_called_once_with(False)
+
+ def test_show_callback_should_do_expected(self,
+ mocker,
+ create_type_dialog_mock):
+ set_window_modality_spy = mocker.spy(create_type_dialog_mock.instance, 'setWindowModality')
+ show_spy = mocker.spy(create_type_dialog_mock.instance, 'show')
+ assert create_type_dialog_mock.show() is None, "create_type_dialog_mock.show() should return None"
+ set_window_modality_spy.assert_called_once_with(Qt.ApplicationModal)
+ assert show_spy.call_count == 1, "show() should be called once"
+
+ def test_clear_ui_callback_should_do_expected(self,
+ mocker,
+ create_type_dialog_mock):
+ mock_check_box = mocker.patch('PySide6.QtWidgets.QCheckBox')
+ mock_title_line_edit = mocker.patch('PySide6.QtWidgets.QLineEdit')
+ mock_label_line_edit = mocker.patch('PySide6.QtWidgets.QLineEdit')
+ mocker.patch.object(create_type_dialog_mock, 'structuralLevelCheckBox', mock_check_box, create=True)
+ mocker.patch.object(create_type_dialog_mock, 'titleLineEdit', mock_title_line_edit, create=True)
+ mocker.patch.object(create_type_dialog_mock, 'labelLineEdit', mock_label_line_edit, create=True)
+ title_line_edit_clear_spy = mocker.spy(mock_title_line_edit, 'clear')
+ label_line_edit_clear_spy = mocker.spy(mock_label_line_edit, 'clear')
+ check_box_set_checked_spy = mocker.spy(mock_check_box, 'setChecked')
+ assert create_type_dialog_mock.clear_ui() is None, "create_type_dialog_mock.clear_ui() should return None"
+ assert title_line_edit_clear_spy.call_count == 1, "titleLineEdit.clear() should be called once"
+ assert label_line_edit_clear_spy.call_count == 1, "labelLineEdit.clear() should be called once"
+ check_box_set_checked_spy.assert_called_once_with(False)
+
+ @pytest.mark.parametrize("next_level", [
+ "x0",
+ "x1"
+ ])
+ def test_set_structural_level_title_should_do_expected(self,
+ mocker,
+ create_type_dialog_mock,
+ next_level):
+ next_set = None
+ mocker.patch.object(create_type_dialog_mock, 'next_struct_level', next_set, create=True)
+ logger_info_spy = mocker.spy(create_type_dialog_mock.logger, 'info')
+ assert create_type_dialog_mock.set_structural_level_title(
+ next_level) is None, "set_structural_level_title() should return None"
+ logger_info_spy.assert_called_once_with("Next structural level set: {%s}...", next_level)
+ assert create_type_dialog_mock.next_struct_level == next_level, "next_struct_level should be set to next_level"
diff --git a/tests/app_tests/unit_tests/test_ontology_config_delete_column_delegate.py b/tests/app_tests/unit_tests/test_ontology_config_delete_column_delegate.py
new file mode 100644
index 00000000..c58e85a7
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_delete_column_delegate.py
@@ -0,0 +1,39 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_delete_column_delegate.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+from tests.app_tests.common.fixtures import delete_delegate
+from tests.app_tests.common.test_delegate_funcs_common import delegate_paint_common, delegate_editor_method_common, \
+ delegate_editor_event_common
+
+
+class TestOntologyConfigDeleteColumnDelegate(object):
+
+ def test_delegate_paint_method(self,
+ mocker,
+ delete_delegate: delete_delegate):
+ delegate_paint_common(mocker, delete_delegate, "Delete")
+
+ def test_delegate_create_editor_method(self,
+ mocker,
+ delete_delegate: delete_delegate):
+ delegate_editor_method_common(delete_delegate, mocker)
+
+ def test_delegate_create_editor_event_method_when_clicked_within_bounds_returns_true(self,
+ mocker,
+ delete_delegate: delete_delegate):
+ mocker.patch('pasta_eln.GUI.ontology_configuration.delete_column_delegate.is_click_within_bounds',
+ return_value=True)
+ delegate_editor_event_common(delete_delegate, mocker, is_click_within_bounds=True)
+
+ def test_delegate_create_editor_event_method_when_clicked_outside_bounds_returns_false(self,
+ mocker,
+ delete_delegate: delete_delegate):
+ mocker.patch('pasta_eln.GUI.ontology_configuration.delete_column_delegate.is_click_within_bounds',
+ return_value=False)
+ delegate_editor_event_common(delete_delegate, mocker, is_click_within_bounds=False)
diff --git a/tests/app_tests/unit_tests/test_ontology_config_document_null_exception.py b/tests/app_tests/unit_tests/test_ontology_config_document_null_exception.py
new file mode 100644
index 00000000..0f7d1ca1
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_document_null_exception.py
@@ -0,0 +1,24 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_document_null_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import pytest
+
+from tests.app_tests.common.fixtures import doc_null_exception
+
+
+class TestOntologyConfigDocumentNullException(object):
+ @pytest.mark.parametrize('doc_null_exception',
+ [{'message': 'error thrown', 'errors': {'error1': 'error1', 'error2': 'error2'}}],
+ indirect=True)
+ def test_ontology_config_document_null_exception(self,
+ doc_null_exception,
+ request):
+ assert str(doc_null_exception) or doc_null_exception.message == "error thrown", \
+ "doc_null_exception) should return error thrown"
+ assert doc_null_exception.detailed_errors == {'error1': 'error1', 'error2': 'error2'}, \
+ "doc_null_exception.detailed_errors should return error1 and error2"
diff --git a/tests/app_tests/unit_tests/test_ontology_config_key_not_found_exception.py b/tests/app_tests/unit_tests/test_ontology_config_key_not_found_exception.py
new file mode 100644
index 00000000..82a9c1d6
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_key_not_found_exception.py
@@ -0,0 +1,24 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_document_null_exception.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import pytest
+
+from tests.app_tests.common.fixtures import key_not_found_exception
+
+
+class TestOntologyConfigKeyNotFoundException(object):
+ @pytest.mark.parametrize('key_not_found_exception',
+ [{'message': 'error thrown', 'errors': {'error1': 'error1', 'error2': 'error2'}}],
+ indirect=True)
+ def test_ontology_config_document_null_exception(self,
+ key_not_found_exception,
+ request):
+ assert str(key_not_found_exception) or key_not_found_exception.message == "error thrown", \
+ "key_not_found_exception should return error thrown"
+ assert key_not_found_exception.detailed_errors == {'error1': 'error1', 'error2': 'error2'}, \
+ "key_not_found_exception.detailed_errors should return error1 and error2"
diff --git a/tests/app_tests/unit_tests/test_ontology_config_reorder_column_delegate.py b/tests/app_tests/unit_tests/test_ontology_config_reorder_column_delegate.py
new file mode 100644
index 00000000..9509e5d1
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_reorder_column_delegate.py
@@ -0,0 +1,38 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_reorder_column_delegate.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+
+from tests.app_tests.common.fixtures import reorder_delegate
+from tests.app_tests.common.test_delegate_funcs_common import delegate_paint_common, delegate_editor_method_common, \
+ delegate_editor_event_common
+
+
+class TestOntologyConfigReorderColumnDelegate(object):
+ def test_delegate_paint_method(self,
+ mocker,
+ reorder_delegate: reorder_delegate):
+ delegate_paint_common(mocker, reorder_delegate, "^")
+
+ def test_delegate_create_editor_method(self,
+ mocker,
+ reorder_delegate: reorder_delegate):
+ delegate_editor_method_common(reorder_delegate, mocker)
+
+ def test_delegate_create_editor_event_method_when_clicked_within_bounds_returns_true(self,
+ mocker,
+ reorder_delegate: reorder_delegate):
+ mocker.patch('pasta_eln.GUI.ontology_configuration.reorder_column_delegate.is_click_within_bounds',
+ return_value=True)
+ delegate_editor_event_common(reorder_delegate, mocker, is_click_within_bounds=True)
+
+ def test_delegate_create_editor_event_method_when_clicked_outside_bounds_returns_false(self,
+ mocker,
+ reorder_delegate: reorder_delegate):
+ mocker.patch('pasta_eln.GUI.ontology_configuration.reorder_column_delegate.is_click_within_bounds',
+ return_value=False)
+ delegate_editor_event_common(reorder_delegate, mocker, is_click_within_bounds=False)
diff --git a/tests/app_tests/unit_tests/test_ontology_config_required_column_delegate.py b/tests/app_tests/unit_tests/test_ontology_config_required_column_delegate.py
new file mode 100644
index 00000000..c0ca9d44
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_required_column_delegate.py
@@ -0,0 +1,93 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_required_column_delegate.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import pytest
+from PySide6.QtCore import QRect, QEvent, Qt
+from PySide6.QtWidgets import QStyleOptionButton, QApplication, QStyle, QStyledItemDelegate
+
+from tests.app_tests.common.fixtures import required_delegate
+from tests.app_tests.common.test_delegate_funcs_common import delegate_editor_method_common
+
+
+class TestOntologyConfigRequiredColumnDelegate(object):
+ def test_delegate_paint_method(self,
+ mocker,
+ required_delegate: required_delegate):
+ # When data set if True, the required radio box is checked
+ self.verify_delegate_paint_method(mocker, required_delegate, 'True')
+ # When data set if False, the required radio box is un-checked
+ self.verify_delegate_paint_method(mocker, required_delegate, 'False')
+
+ def test_delegate_create_editor_method(self,
+ mocker,
+ required_delegate: required_delegate):
+ delegate_editor_method_common(required_delegate, mocker)
+
+ @pytest.mark.parametrize("test_data_value, expected", [
+ ('False', 'True'),
+ ('True', 'False')
+ ])
+ def test_delegate_editor_event_method(self,
+ mocker,
+ required_delegate: required_delegate,
+ test_data_value,
+ expected):
+ mock_option_event = mocker.patch("PySide6.QtCore.QEvent")
+ mock_table_model = mocker.patch("PySide6.QtCore.QAbstractItemModel")
+ mock_option = mocker.patch("PySide6.QtWidgets.QStyleOptionViewItem")
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+
+ mocker.patch.object(mock_index, "data",
+ mocker.MagicMock(return_value=test_data_value))
+ mocker.patch.object(QStyledItemDelegate, "editorEvent", mocker.MagicMock(return_value=expected))
+ mocker.patch.object(mock_option_event, "type", mocker.MagicMock(return_value=QEvent.MouseButtonRelease))
+ model_set_data_spy = mocker.spy(mock_table_model, 'setData')
+ delegate_editor_event_spy = mocker.spy(QStyledItemDelegate, 'editorEvent')
+ assert required_delegate.editorEvent(mock_option_event, mock_table_model, mock_option, mock_index) is expected, \
+ "editorEvent should return expected value"
+
+ assert mock_option_event.type.call_count == 1, "editorEvent.type should be called once"
+ assert mock_index.data.call_count == 1, "editorEvent.index.data should be called once"
+ model_set_data_spy.assert_called_once_with(mock_index, expected, Qt.UserRole)
+ delegate_editor_event_spy.assert_called_once_with(mock_option_event, mock_table_model, mock_option, mock_index)
+
+ @staticmethod
+ def verify_delegate_paint_method(mocker, required_delegate, editor_data_value):
+ mock_painter = mocker.patch("PySide6.QtGui.QPainter")
+ mock_option = mocker.patch("PySide6.QtWidgets.QStyleOptionViewItem")
+ mock_option_rect = mocker.patch("PySide6.QtCore.QRect")
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+ mock_option_widget = mocker.patch("PySide6.QtWidgets.QRadioButton")
+ mock_button_option = mocker.patch("PySide6.QtWidgets.QStyleOptionButton")
+ mock_style = mocker.patch("PySide6.QtWidgets.QStyle")
+ mocker.patch.object(QStyleOptionButton, "__new__",
+ lambda x: mock_button_option)
+ mocker.patch.object(mock_option, "widget", None)
+ mocker.patch.object(mock_option, "rect", mock_option_rect)
+ mocker.patch.object(mock_option_rect, "left", mocker.MagicMock(return_value=5))
+ mocker.patch.object(mock_option_rect, "top", mocker.MagicMock(return_value=5))
+ mocker.patch.object(mock_option_rect, "height", mocker.MagicMock(return_value=5))
+ mocker.patch.object(mock_option_rect, "width", mocker.MagicMock(return_value=5))
+ mocker.patch.object(mock_index, "data",
+ mocker.MagicMock(return_value=editor_data_value))
+ mocker.patch.object(QStyle, "State_On", QStyle.State_On)
+ mocker.patch.object(QStyle, "State_Off", QStyle.State_Off)
+ mocker.patch.object(QApplication, "style",
+ lambda: mock_style)
+ mocker.patch.object(mock_option_widget, "style",
+ mocker.MagicMock(return_value=mock_style))
+ draw_control_spy = mocker.spy(mock_style, 'drawControl')
+ required_delegate.paint(mock_painter, mock_option, mock_index)
+ draw_control_spy.assert_called_once_with(QStyle.CE_RadioButton, mock_button_option, mock_painter, None)
+ assert mock_option.rect.left.call_count == 1, "rect.left should be called once"
+ assert mock_option.rect.top.call_count == 1, "rect.top should be called once"
+ assert mock_option.rect.width.call_count == 2, "rect.top should be called twice"
+ assert mock_option.rect.height.call_count == 1, "rect.height should be called once"
+ assert mock_button_option.rect == QRect(2, 5, 5, 5), "rect should be the expected QRect(2, 5, 5, 5)"
+ assert mock_button_option.state == QStyle.State_On if editor_data_value == 'True' else QStyle.State_Off, \
+ f"button state should be {'on' if editor_data_value == 'True' else 'off'}"
diff --git a/tests/app_tests/unit_tests/test_ontology_config_table_view_data_model.py b/tests/app_tests/unit_tests/test_ontology_config_table_view_data_model.py
new file mode 100644
index 00000000..5045cd55
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_table_view_data_model.py
@@ -0,0 +1,318 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_table_view_data_model.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import pytest
+from PySide6.QtCore import Qt
+
+from tests.app_tests.common.fixtures import table_model, props_table_model, attachments_table_model
+
+
+class TestOntologyConfigTableViewDataModel(object):
+
+ def test_data_models_basic(self,
+ table_model: table_model,
+ qtmodeltester):
+ items = {i: str(i) for i in range(4)}
+ table_model.update(items)
+ qtmodeltester.check(table_model)
+
+ def test_data_models_property_table_model(self,
+ props_table_model: props_table_model,
+ qtmodeltester):
+
+ props_items = [
+ {"name": "name", "query": "query", "list": "list", "link": "link", "required": "required", "unit": "unit"},
+ {"name": "name", "query": "query", "list": "list", "link": "link", "required": "required", "unit": "unit"}
+ ]
+ props_table_model.update(props_items)
+ with pytest.raises(AssertionError):
+ qtmodeltester.check(props_table_model, force_py=True)
+
+ def test_data_models_attachments_table_model(self,
+ attachments_table_model: attachments_table_model,
+ qtmodeltester):
+ attachments = [
+ {"location": "location"},
+ {"location": "location"},
+ {"location": "location"},
+ {"location": "location"}
+ ]
+ attachments_table_model.update(attachments)
+ with pytest.raises(AssertionError):
+ qtmodeltester.check(attachments_table_model, force_py=True)
+
+ def test_data_models_basic_has_children_returns_false(self,
+ table_model: table_model,
+ mocker):
+ assert table_model.hasChildren(mocker.MagicMock(spec="PySide6.QtCore.QModelIndex")) is False, \
+ "hasChildren() should return False"
+
+ @pytest.mark.parametrize(
+ "is_valid, flag_combination", [
+ (False, None),
+ (True, Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsEnabled)
+ ])
+ def test_data_models_basic_get_flags_returns_expected(self,
+ table_model: table_model,
+ mocker,
+ is_valid,
+ flag_combination):
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+ mocker.patch.object(mock_index, "isValid",
+ mocker.MagicMock(return_value=is_valid))
+ assert table_model.flags(mock_index) == flag_combination, \
+ f"flags() should return {flag_combination}"
+
+ @pytest.mark.parametrize(
+ "is_valid, data_to_be_set, display_role, index_data, data_name_map, data_set, data_set_success", [
+ (False, 23233, Qt.EditRole, (0, 0), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, False),
+ (False, 43432, Qt.UserRole, (1, 1), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, False),
+ (True, 562332, Qt.EditRole, (34, 1), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, True),
+ (True, 765322, Qt.UserRole, (4, 0), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, True),
+ (True, "as12", Qt.UserRole, (6, 1), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, True),
+ (True, "test", Qt.EditRole, (9, 0), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, True)
+ ])
+ def test_data_models_basic_set_data_should_return_expected(self,
+ table_model: table_model,
+ mocker,
+ is_valid,
+ data_to_be_set,
+ display_role,
+ index_data,
+ data_name_map,
+ data_set,
+ data_set_success):
+
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+ mock_data_changed = mocker.patch("PySide6.QtCore.SignalInstance")
+ mocker.patch.object(mock_index, "isValid",
+ mocker.MagicMock(return_value=is_valid))
+ mocker.patch.object(mock_index, "row",
+ mocker.MagicMock(return_value=index_data[0]))
+ mocker.patch.object(mock_index, "column",
+ mocker.MagicMock(return_value=index_data[1]))
+ mock_data_name_map = mocker.MagicMock()
+ mock_data_name_map.__getitem__.side_effect = data_name_map.__getitem__
+ mock_data_name_map.__setitem__.side_effect = data_name_map.__setitem__
+ mock_data_name_map.get.side_effect = data_name_map.get
+ mock_data_set = mocker.MagicMock()
+ mock_data_set.__getitem__.side_effect = data_set.__getitem__
+ mock_data_set.__setitem__.side_effect = data_set.__setitem__
+ mocker.patch.object(table_model, "data_name_map", mock_data_name_map)
+ mocker.patch.object(table_model, "data_set", mock_data_set)
+ table_model.dataChanged = mock_data_changed
+ data_name_map_get_spy = mocker.spy(mock_data_name_map, 'get')
+ data_changed_emit_spy = mocker.spy(mock_data_changed, 'emit')
+ assert table_model.setData(mock_index, data_to_be_set, display_role) is data_set_success, \
+ f"setData() should return {data_set_success}"
+ assert mock_index.isValid.call_count == 1, "isValid() should be called once"
+ if data_set_success:
+ assert mock_index.row.call_count == 1, "row() should be called once"
+ assert mock_index.column.call_count == 1, "column() should be called once"
+ assert data_set[index_data[0]][data_name_map[index_data[1]]] == data_to_be_set, "data should be set"
+ data_name_map_get_spy.assert_called_once_with(index_data[1])
+ data_changed_emit_spy.assert_called_once_with(mock_index, mock_index, display_role)
+
+ @pytest.mark.parametrize("is_valid, display_role, index_data, data_name_map, data_set, data_retrieved", [
+ (False, Qt.EditRole, (0, 0), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, None),
+ (False, Qt.UserRole, (1, 1), {0: "name", 1: "query"}, [{"name": "name", "query": "query"}] * 35, None),
+ (True, Qt.EditRole, (34, 1), {0: "name", 1: "query"}, [{"name": "name", "query": "25"}] * 35, "25"),
+ (True, Qt.UserRole, (4, 0), {0: "name", 1: "query"}, [{"name": "tom", "query": "query"}] * 35, "tom"),
+ (True, Qt.DisplayRole, (6, 1), {0: "name", 1: "query"}, [{"name": "name", "query": "1234"}] * 35, "1234"),
+ (True, Qt.EditRole, (9, 0), {0: "name", 1: "query"}, [{"name": "2&§%&%&", "query": "query"}] * 35, "2&§%&%&")
+ ])
+ def test_data_models_basic_get_data_should_return_expected(self,
+ table_model: table_model,
+ mocker,
+ is_valid,
+ display_role,
+ index_data,
+ data_name_map,
+ data_set,
+ data_retrieved):
+
+ mock_index = mocker.patch("PySide6.QtCore.QModelIndex")
+ mocker.patch.object(mock_index, "isValid",
+ mocker.MagicMock(return_value=is_valid))
+ mocker.patch.object(mock_index, "row",
+ mocker.MagicMock(return_value=index_data[0]))
+ mocker.patch.object(mock_index, "column",
+ mocker.MagicMock(return_value=index_data[1]))
+ mock_data_name_map = mocker.MagicMock()
+ mock_data_name_map.__getitem__.side_effect = data_name_map.__getitem__
+ mock_data_name_map.__setitem__.side_effect = data_name_map.__setitem__
+ mock_data_name_map.get.side_effect = data_name_map.get
+ mock_data_set = mocker.MagicMock()
+ mock_data_set.__getitem__.side_effect = data_set.__getitem__
+ mock_data_set.__setitem__.side_effect = data_set.__setitem__
+ mocker.patch.object(table_model, "data_name_map", mock_data_name_map)
+ mocker.patch.object(table_model, "data_set", mock_data_set)
+ data_name_map_get_spy = mocker.spy(mock_data_name_map, 'get')
+ assert table_model.data(mock_index, display_role) is data_retrieved, \
+ f"data() should return {data_retrieved}"
+ assert mock_index.isValid.call_count == 1, "isValid() should be called once"
+ if data_retrieved:
+ assert mock_index.row.call_count == 1, "row() should be called once"
+ assert mock_index.column.call_count == 1, "column() should be called once"
+ data_name_map_get_spy.assert_called_once_with(index_data[1])
+
+ @pytest.mark.parametrize("data_set, data_set_modified", [
+ ([{"name": "name", "query": "query"}] * 4, [{"name": "name", "query": "query"}] * 4 + [{}]),
+ ([{"name": "name", "query": "query"}] * 10, [{"name": "name", "query": "query"}] * 10 + [{}]),
+ ([{"name": "name", "query": "query"}] * 33, [{"name": "name", "query": "query"}] * 33 + [{}]),
+ ([{"name": "name", "query": "query"}] * 41, [{"name": "name", "query": "query"}] * 41 + [{}])
+ ])
+ def test_data_models_basic_slot_add_data_should_do_expected(self,
+ table_model: table_model,
+ mocker,
+ data_set,
+ data_set_modified):
+
+ mock_data_set = mocker.MagicMock()
+ mock_layout_changed = mocker.patch("PySide6.QtCore.SignalInstance")
+ mock_data_set.__getitem__.side_effect = data_set.__getitem__
+ mock_data_set.__setitem__.side_effect = data_set.__setitem__
+ mocker.patch('pasta_eln.GUI.ontology_configuration.ontology_tableview_data_model.len', lambda x: len(data_set))
+ mock_data_set.insert.side_effect = data_set.insert
+ mocker.patch.object(table_model, "data_set", mock_data_set)
+ data_set_insert_spy = mocker.spy(mock_data_set, 'insert')
+ layout_changed_emit_spy = mocker.spy(mock_layout_changed, 'emit')
+ table_model.layoutChanged = mock_layout_changed
+ assert table_model.add_data_row() is None, \
+ f"add_data_row() should return none"
+ assert data_set == data_set_modified, "data_set should be set to expected value"
+ data_set_insert_spy.assert_called_once_with(len(data_set) - 1, {})
+ assert layout_changed_emit_spy.call_count == 1, "layout_changed_emit() should be called once"
+
+ @pytest.mark.parametrize("data_set, delete_position, data_set_modified", [
+ ([{"name": "name", "query": "query"}] * 4 + [{"name": "delete", "query": "delete"}] + [
+ {"name": "name", "query": "query"}] * 4,
+ 4,
+ [{"name": "name", "query": "query"}] * 8),
+ ([{"name": "name", "query": "query"}] * 7 + [{"name": "delete", "query": "delete"}] + [
+ {"name": "name", "query": "query"}] * 3,
+ 7,
+ [{"name": "name", "query": "query"}] * 10),
+ ([{"name": "name", "query": "query"}] * 27 + [{"name": "delete", "query": "delete"}] + [
+ {"name": "name", "query": "query"}] * 6,
+ 27,
+ [{"name": "name", "query": "query"}] * 33),
+ ([{"name": "name", "query": "query"}] * 40 + [{"name": "delete", "query": "delete"}],
+ 40,
+ [{"name": "name", "query": "query"}] * 40),
+ ([{"name": "delete", "query": "delete"}] + [{"name": "name", "query": "query"}] * 27,
+ 0,
+ [{"name": "name", "query": "query"}] * 27),
+ ([{"name": "delete", "query": "delete"}] + [{"name": "name", "query": "query"}] * 27,
+ -40, # Out of range delete position
+ [{"name": "delete", "query": "delete"}] + [{"name": "name", "query": "query"}] * 27),
+ ([{"name": "delete", "query": "delete"}] + [{"name": "name", "query": "query"}] * 27,
+ 30, # Out of range delete position
+ [{"name": "delete", "query": "delete"}] + [{"name": "name", "query": "query"}] * 27)
+ ])
+ def test_data_models_basic_slot_delete_data_should_do_expected(self,
+ table_model: table_model,
+ mocker,
+ data_set,
+ delete_position,
+ data_set_modified):
+
+ mock_data_set = mocker.MagicMock()
+ mock_layout_changed = mocker.patch("PySide6.QtCore.SignalInstance")
+ mock_logger = mocker.patch("logging.Logger")
+ mock_data_set.__getitem__.side_effect = data_set.__getitem__
+ mock_data_set.__setitem__.side_effect = data_set.__setitem__
+ mock_data_set.pop.side_effect = data_set.pop
+ mock_data_set.insert.side_effect = data_set.insert
+ mocker.patch.object(table_model, "data_set", mock_data_set)
+ data_set_pop_spy = mocker.spy(mock_data_set, 'pop')
+ layout_changed_emit_spy = mocker.spy(mock_layout_changed, 'emit')
+ logger_info_spy = mocker.spy(mock_logger, 'info')
+ logger_warning_spy = mocker.spy(mock_logger, 'warning')
+ table_model.layoutChanged = mock_layout_changed
+ table_model.logger = mock_logger
+ data_to_be_deleted = data_set[delete_position] if 0 <= delete_position < len(data_set) else None
+ assert table_model.delete_data(delete_position) is None, \
+ f"add_data_row() should return none"
+ assert data_set == data_set_modified, "data_set should be set to expected value"
+ if data_to_be_deleted:
+ data_set_pop_spy.assert_called_once_with(delete_position)
+ logger_info_spy.assert_called_once_with("Deleted (row: {%s}, data: {%s})...", delete_position, data_to_be_deleted)
+ assert layout_changed_emit_spy.call_count == 1, "layout_changed_emit() should be called once"
+ else:
+ logger_warning_spy.assert_called_once_with("Invalid position: {%s}", delete_position)
+
+ @pytest.mark.parametrize("data_set, re_order_position, data_set_modified", [
+ ([{"name": "name", "query": "query"}] * 4 + [{"name": "reorder", "query": "reorder"}] + [
+ {"name": "name", "query": "query"}] * 4,
+ 4,
+ [{"name": "name", "query": "query"}] * 3 + [{"name": "reorder", "query": "reorder"}] + [
+ {"name": "name", "query": "query"}] * 5),
+ ([{"name": "name", "query": "query"}] * 7 + [{"name": "reorder", "query": "reorder"}] + [
+ {"name": "name", "query": "query"}] * 3,
+ 7,
+ [{"name": "name", "query": "query"}] * 6 + [{"name": "reorder", "query": "reorder"}] + [
+ {"name": "name", "query": "query"}] * 4),
+ ([{"name": "name", "query": "query"}] * 27 + [{"name": "reorder", "query": "reorder"}] + [
+ {"name": "name", "query": "query"}] * 6,
+ 27,
+ [{"name": "name", "query": "query"}] * 26 + [{"name": "reorder", "query": "reorder"}] + [
+ {"name": "name", "query": "query"}] * 7),
+ ([{"name": "name", "query": "query"}] * 40 + [{"name": "reorder", "query": "reorder"}],
+ 40,
+ [{"name": "name", "query": "query"}] * 39 + [{"name": "reorder", "query": "reorder"}] + [
+ {"name": "name", "query": "query"}] * 1),
+ ([{"name": "reorder", "query": "reorder"}] + [{"name": "name", "query": "query"}] * 27,
+ 0,
+ [{"name": "reorder", "query": "reorder"}] + [{"name": "name", "query": "query"}] * 27),
+ ([{"name": "reorder", "query": "reorder"}] + [{"name": "name", "query": "query"}] * 27,
+ -40, # Out of range re-order position
+ [{"name": "reorder", "query": "reorder"}] + [{"name": "name", "query": "query"}] * 27),
+ ([{"name": "reorder", "query": "reorder"}] + [{"name": "name", "query": "query"}] * 27,
+ 30, # Out of range re-order position
+ [{"name": "reorder", "query": "reorder"}] + [{"name": "name", "query": "query"}] * 27)
+ ])
+ def test_data_models_basic_slot_re_order_data_should_do_expected(self,
+ table_model: table_model,
+ mocker,
+ data_set,
+ re_order_position,
+ data_set_modified):
+
+ mock_data_set = mocker.MagicMock()
+ mock_layout_changed = mocker.patch("PySide6.QtCore.SignalInstance")
+ mock_logger = mocker.patch("logging.Logger")
+ mock_data_set.__getitem__.side_effect = data_set.__getitem__
+ mock_data_set.__setitem__.side_effect = data_set.__setitem__
+ mock_data_set.pop.side_effect = data_set.pop
+ mock_data_set.insert.side_effect = data_set.insert
+ mocker.patch.object(table_model, "data_set", mock_data_set)
+ data_set_pop_spy = mocker.spy(mock_data_set, 'pop')
+ data_set_insert_spy = mocker.spy(mock_data_set, 'insert')
+ layout_changed_emit_spy = mocker.spy(mock_layout_changed, 'emit')
+ logger_info_spy = mocker.spy(mock_logger, 'info')
+ logger_warning_spy = mocker.spy(mock_logger, 'warning')
+ table_model.layoutChanged = mock_layout_changed
+ table_model.logger = mock_logger
+ data_to_be_ordered = data_set[re_order_position] if 0 <= re_order_position < len(data_set) else None
+ assert table_model.re_order_data(re_order_position) is None, \
+ f"re_order_data() should return none"
+ assert data_set == data_set_modified, "data_set should be set to expected value"
+ if data_to_be_ordered:
+ data_set_pop_spy.assert_called_once_with(re_order_position)
+ shift_position = re_order_position - 1 if re_order_position > 0 else re_order_position
+ data_set_insert_spy.assert_called_once_with(shift_position, data_to_be_ordered)
+ logger_info_spy.assert_called_once_with("Reordered the data, Actual position: {%s}, "
+ "New Position: {%s}, "
+ "data: {%s})",
+ re_order_position,
+ shift_position,
+ data_to_be_ordered)
+ assert layout_changed_emit_spy.call_count == 1, "layout_changed_emit() should be called once"
+ else:
+ logger_warning_spy.assert_called_once_with("Invalid position: {%s}", re_order_position)
diff --git a/tests/app_tests/unit_tests/test_ontology_config_utility_functions.py b/tests/app_tests/unit_tests/test_ontology_config_utility_functions.py
new file mode 100644
index 00000000..fe7b3593
--- /dev/null
+++ b/tests/app_tests/unit_tests/test_ontology_config_utility_functions.py
@@ -0,0 +1,222 @@
+# PASTA-ELN and all its sub-parts are covered by the MIT license.
+#
+# Copyright (c) 2023
+#
+# Author: Jithu Murugan
+# Filename: test_ontology_config_utility_functions.py
+#
+# You should have received a copy of the license with this file. Please refer the license file for more information.
+import logging
+
+import pytest
+from PySide6.QtCore import QEvent
+from PySide6.QtGui import QMouseEvent
+from PySide6.QtWidgets import QMessageBox
+from cloudant import CouchDB
+
+from pasta_eln.GUI.ontology_configuration.utility_functions import is_click_within_bounds, adjust_ontology_data_to_v3, \
+ get_next_possible_structural_level_label, get_db, show_message
+
+
+class TestOntologyConfigUtilityFunctions(object):
+
+ def test_is_click_within_bounds_when_null_arguments_returns_false(self,
+ mocker):
+ assert is_click_within_bounds(mocker.patch('PySide6.QtGui.QSinglePointEvent'),
+ None) == False, "is_click_within_bounds should return False for null argument"
+ assert is_click_within_bounds(None, mocker.patch(
+ 'PySide6.QtWidgets.QStyleOptionViewItem')) == False, "is_click_within_bounds should return False for null argument"
+ assert is_click_within_bounds(None, None) == False, "is_click_within_bounds should return False for null argument"
+
+ def test_is_click_within_bounds_when_within_bounds_returns_true(self,
+ mocker):
+ mock_mouse_event = mocker.patch('PySide6.QtGui.QSinglePointEvent')
+ mock_mouse_event.type.side_effect = lambda: QEvent.MouseButtonRelease
+ mock_mouse_event.x.side_effect = lambda: 10 # left is 5 and right is left + width 5+10=15
+ mock_mouse_event.y.side_effect = lambda: 20 # top is 10 and bottom is top + height 10+20=30
+ mock_q_style_option_view_item = mocker.patch('PySide6.QtWidgets.QStyleOptionViewItem')
+ mock_q_style_option_view_item.rect.left.side_effect = lambda: 5
+ mock_q_style_option_view_item.rect.width.side_effect = lambda: 10
+ mock_q_style_option_view_item.rect.top.side_effect = lambda: 10
+ mock_q_style_option_view_item.rect.height.side_effect = lambda: 20
+ mocker.patch.object(QMouseEvent, "__new__",
+ lambda x, y: mock_mouse_event) # Patch the QMouseEvent instantiation to return the same event
+ assert is_click_within_bounds(mock_mouse_event,
+ mock_q_style_option_view_item) == True, "is_click_within_bounds should return True"
+ assert mock_mouse_event.type.call_count == 1, "Event type call count must be 1"
+ assert mock_mouse_event.x.call_count == 1, "Event x() call count must be 1"
+ assert mock_mouse_event.y.call_count == 1, "Event y() call count must be 1"
+ assert mock_q_style_option_view_item.rect.left.call_count == 2, "QStyleOptionViewItem left call count must be two"
+ assert mock_q_style_option_view_item.rect.top.call_count == 2, "QStyleOptionViewItem top call count must be two"
+ assert mock_q_style_option_view_item.rect.width.call_count == 1, "QStyleOptionViewItem left call count must be 1"
+ assert mock_q_style_option_view_item.rect.height.call_count == 1, "QStyleOptionViewItem top call count must be 1"
+
+ def test_is_click_within_bounds_when_outside_bounds_returns_false(self,
+ mocker):
+ mock_mouse_event = mocker.patch('PySide6.QtGui.QSinglePointEvent')
+ mock_mouse_event.type.side_effect = lambda: QEvent.MouseButtonRelease
+ mock_mouse_event.x.side_effect = lambda: 10 # range: 5 -> 15 (within range)
+ mock_mouse_event.y.side_effect = lambda: 5 # range: 10 -> 10 (out of range)
+ mock_q_style_option_view_item = mocker.patch('PySide6.QtWidgets.QStyleOptionViewItem')
+ mock_q_style_option_view_item.rect.left.side_effect = lambda: 5
+ mock_q_style_option_view_item.rect.width.side_effect = lambda: 10
+ mock_q_style_option_view_item.rect.top.side_effect = lambda: 10
+ mock_q_style_option_view_item.rect.height.side_effect = lambda: 20
+ mocker.patch.object(QMouseEvent, "__new__",
+ lambda x, y: mock_mouse_event) # Patch the QMouseEvent instantiation to return the same event
+ assert is_click_within_bounds(mock_mouse_event,
+ mock_q_style_option_view_item) == False, "is_click_within_bounds should return True"
+ assert mock_mouse_event.type.call_count == 1, "Event type call count must be 1"
+ assert mock_mouse_event.x.call_count == 1, "Event x() call count must be 1"
+ assert mock_mouse_event.y.call_count == 1, "Event y() call count must be 1"
+ assert mock_q_style_option_view_item.rect.left.call_count == 2, "QStyleOptionViewItem left call count must be two"
+ assert mock_q_style_option_view_item.rect.top.call_count == 1, "QStyleOptionViewItem top call count must be one"
+ assert mock_q_style_option_view_item.rect.width.call_count == 1, "QStyleOptionViewItem left call count must be 1"
+ assert mock_q_style_option_view_item.rect.height.call_count == 0, "QStyleOptionViewItem top call count must be zero"
+
+ def test_adjust_ontology_data_to_v3_when_empty_document_do_nothing(self,
+ mocker):
+ contents = {}
+ mock_doc = self.create_mock_doc(contents, mocker)
+ assert adjust_ontology_data_to_v3(mock_doc) is None, "adjust_ontology_data_to_v3 should return None"
+ assert list(contents.keys()) == ["-version"], "Only version should be added"
+
+ assert adjust_ontology_data_to_v3(None) is None, "adjust_ontology_data_to_v3 should return None"
+
+ def test_adjust_ontology_data_to_v3_when_v2document_given_do_expected(self,
+ mocker):
+ # Without attachments
+ contents = {
+ "-version": 2,
+ "x0":
+ {
+ "label": "",
+ "prop": []
+ }
+ }
+ mock_doc = self.create_mock_doc(contents, mocker)
+ assert adjust_ontology_data_to_v3(mock_doc) is None, "adjust_ontology_data_to_v3 should return None"
+ assert "attachments" in contents["x0"], "attachments should be set"
+ assert "prop" in contents["x0"], "prop should be set"
+ assert type(contents["x0"]["prop"]) is dict, "prop should be dictionary"
+ assert contents["-version"] == 3, "Version must be updated to 3"
+
+ # Without anything much
+ contents = {
+ "-version": 2,
+ "x0": {}
+ }
+ mock_doc = self.create_mock_doc(contents, mocker)
+ assert adjust_ontology_data_to_v3(mock_doc) is None, "adjust_ontology_data_to_v3 should return None"
+ assert "attachments" in contents["x0"], "attachments should be set"
+ assert "prop" in contents["x0"], "prop should be set"
+ assert type(contents["x0"]["prop"]) is dict, "prop should be dictionary"
+ assert "default" in contents["x0"]["prop"] and len(
+ contents["x0"]["prop"]["default"]) == 0, "default prop list be defined"
+ assert contents["-version"] == 3, "Version must be updated to 3"
+
+ # With some content
+ contents = {
+ "-version": 2,
+ "x1":
+ {
+ "attachments": [{"test": "test", "test1": "test2"}],
+ "label": "",
+ "prop": {"default": [
+ {
+ "name": "value",
+ "test": "test1"
+ }
+ ]}
+ }
+ }
+ mock_doc = self.create_mock_doc(contents, mocker)
+ assert adjust_ontology_data_to_v3(mock_doc) is None, "adjust_ontology_data_to_v3 should return None"
+ assert "attachments" in contents["x1"], "attachments should be set"
+ assert "prop" in contents["x1"], "prop should be set"
+ assert type(contents["x1"]["prop"]) is dict, "prop should be dictionary"
+ assert "default" in contents["x1"]["prop"] and len(
+ contents["x1"]["prop"]["default"]) == 1, "default prop list should be the same"
+ assert contents["-version"] == 3, "Version must be updated to 3"
+
+ @staticmethod
+ def create_mock_doc(contents, mocker):
+ mock_doc = mocker.patch('cloudant.document.Document')
+ mock_doc.__iter__ = mocker.Mock(return_value=iter(contents))
+ mock_doc.__getitem__.side_effect = contents.__getitem__
+ mock_doc.__setitem__.side_effect = contents.__setitem__
+ return mock_doc
+
+ def test_get_next_possible_structural_level_label_when_null_arg_returns_none(self):
+ assert get_next_possible_structural_level_label(None) is None, \
+ "get_next_possible_structural_level_label should return True"
+
+ @pytest.mark.parametrize("existing_list, expected_next_level", [
+ (None, None),
+ ([], "x0"),
+ (["x0", "x2"], "x3"),
+ (["x0", "xa", "x3", "x-1", "x10"], "x11"),
+ (["x0", "xa", "x3", "x-1", "a10", "X23"], "x24"),
+ (["a"], "x0")
+ ])
+ def test_get_next_possible_structural_level_label_when_valid_list_arg_returns_right_result(self,
+ mocker,
+ existing_list,
+ expected_next_level):
+ assert get_next_possible_structural_level_label(
+ existing_list) == expected_next_level, "get_next_possible_structural_level_label should return as expected"
+
+ def test_get_db_with_right_arguments_returns_valid_db_instance(self,
+ mocker):
+ mock_client = mocker.MagicMock(spec=CouchDB)
+ mock_client.all_dbs.return_value = ["db_name1", "db_name2"]
+ db_instances = {"db_name1": mocker.MagicMock(spec=CouchDB), "db_name2": mocker.MagicMock(spec=CouchDB)}
+ created_db_instance = mocker.MagicMock(spec=CouchDB)
+ mock_client.__getitem__.side_effect = db_instances.__getitem__
+ mock_client.create_database.side_effect = mocker.MagicMock(side_effect=(lambda name: created_db_instance))
+ mocker.patch.object(CouchDB, "__new__", lambda s, user, auth_token, url, connect: mock_client)
+ mocker.patch.object(CouchDB, "__init__", lambda s, user, auth_token, url, connect: None)
+
+ assert get_db("db_name1", "test", "test", "test", None) is db_instances["db_name1"], \
+ "get_db should return valid db instance"
+ assert mock_client.all_dbs.call_count == 1, "get_db should call all_dbs"
+ assert mock_client.__getitem__.call_count == 1, "get_db should call __getitem__"
+
+ assert get_db("db_name2", "test", "test", "test", None) is db_instances["db_name2"], \
+ "get_db should return valid db instance"
+ assert mock_client.all_dbs.call_count == 2, "get_db should call all_dbs"
+ assert mock_client.__getitem__.call_count == 2, "get_db should call __getitem__"
+
+ assert get_db("db_name3", "test", "test", "test", None) is created_db_instance, \
+ "get_db should return created db instance"
+ assert mock_client.all_dbs.call_count == 3, "get_db should call all_dbs"
+ assert mock_client.create_database.call_count == 1, "get_db should call create_database"
+
+ def test_get_db_with_wrong_arguments_throws_exception(self,
+ mocker):
+ mock_logger = mocker.MagicMock(spec=logging)
+ logger_spy = mocker.spy(mock_logger, 'error')
+ mocker.patch.object(CouchDB, "__new__", mocker.MagicMock(side_effect=Exception('Database error')))
+ mocker.patch.object(CouchDB, "__init__", lambda s, user, auth_token, url, connect: None)
+ assert get_db("db_name1", "test", "test", "test", None) is None, \
+ "get_db should return None"
+ assert mock_logger.error.call_count == 0, "get_db should not call log.error"
+
+ assert get_db("db_name1", "test", "test", "test", mock_logger) is None, \
+ "get_db should return None"
+ assert mock_logger.error.call_count == 1, "get_db should call log.error"
+ logger_spy.assert_called_once_with(
+ "Could not connect with username+password to local server, error: Database error")
+
+ def test_show_message_with_none_argument_does_nothing(self):
+ assert show_message(None) is None, "show_message should return None"
+
+ def test_show_message_with_valid_argument_shows_message(self,
+ mocker):
+ mock_msg_box = mocker.patch("PySide6.QtWidgets.QMessageBox")
+ set_text_spy = mocker.spy(mock_msg_box, 'setText')
+ mocker.patch.object(QMessageBox, "__new__", lambda s: mock_msg_box)
+ assert show_message("Valid message") is None, "show_message should return None"
+ set_text_spy.assert_called_once_with(
+ "Valid message")
+ assert mock_msg_box.exec.call_count == 1, "show_message should call exec()"
diff --git a/tests/test_99_3Projects.py b/tests/test_99_3Projects.py
index ecd2907a..203a8062 100644
--- a/tests/test_99_3Projects.py
+++ b/tests/test_99_3Projects.py
@@ -5,6 +5,9 @@
import warnings
import unittest
from pathlib import Path
+
+import pytest
+
from pasta_eln.backend import Backend
from pasta_eln.miscTools import outputString
from pasta_eln.miscTools import DummyProgressBar