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