Source code for schrodinger.application.livedesign.panel_components

import collections
import functools
from copy import deepcopy
from datetime import datetime
from enum import Enum
from operator import attrgetter

from typing import List

from schrodinger import structure
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import appframework as af1
from schrodinger.ui.qt import delegates
from schrodinger.ui.qt import pop_up_widgets
from schrodinger.ui.qt import table_helper
from schrodinger.ui.qt import utils as qtutils
from schrodinger.ui.qt import widgetmixins
from schrodinger.ui.qt.standard.icons import icons

from . import selection_pop_up_ui
from . import data_classes
from . import export_models
from . import icons_rc  # noqa:F401 # pylint: disable=unused-import
from . import ld_folder_tree as ldt
from .constants import LD_PROP_NAME

get_long_family_name = data_classes.get_long_family_name
LDData = data_classes.LDData
COLOR_MISSING = QtGui.QColor(225, 0, 0, 120)

NO_FOLDER_NAME = 'Project Home'
SELECT_TEXT = 'Select LiveReport...'
NEW_TEXT = 'New LiveReport'
CLICK = 'Click to select...'
FOLDER_CONTENTS = '__FOLDER_CONTENTS__'
TYPE_TO_CREATE = 'Type here to create new'
NEW_NAME = 'New name'
ID = 'ID'
STRUCTURE = 'Compound Structure'
CustomRole = table_helper.UserRolesEnum(
    "CustomRole", ("AssayData", "AssayFolderPathData", "EndpointData"))

ENDPOINT_MISSING = 'Endpoint Missing'
MODEL_OR_ASSAY_MISSING = 'Model or Assay Missing'

MAESTRO_ASSAY = 'Maestro'
MAESTRO_FAMILY_NAME = get_long_family_name('m')

ENDPOINT_3D = '3D'

LD_DATA_3D = export_models.LD_DATA_3D
LRSort = Enum('LRSort', ('Owner', 'Folder'))
DEFAULT_LR_SORT = LRSort.Folder

# =============================================================================
# StyleSheet
# =============================================================================

# Icon prefix
ICON_PREFIX = ':/maestro_ld_gui_dir/icons/'

SEARCH_BOX = """
QLineEdit{
    background-image: url('""" + ICON_PREFIX + """search_icon.png');
    background-repeat: no-repeat;
    background-position: left;
    padding-left: 17px;
    border: 1px solid #D3D3D3;
    padding-top: 1px;
    border-radius: 7px;
    margin-top: 1px;
    height: 20px;
}
"""

# =============================================================================
# Status Bar
# =============================================================================


[docs]class StatusBarDialog(QtWidgets.QDialog): """ Helper class to setup the status bar for the panel. This class acts as the parent to avoid thread issues. """ def _help(self): """ Display the help dialog. """ af1.help_dialog(self.help_topic, parent=self)
[docs]class LRSortCombo(QtWidgets.QComboBox): """ Combo box used to specify the method used to sort and organize live reports. Emits a custom signal with an enum designating the sort method. :cvar LRSortMethodChanged: signal indicating that a new live report sort method has been chosen; emitted with an `LRSort` value :vartype LRSortMethodChanged: QtCore.pyqtSignal """ LRSortMethodChanged = QtCore.pyqtSignal(LRSort)
[docs] def __init__(self, parent=None): super().__init__(parent=parent) # Populate the combo box and set up internal signal/slot connections for sort_type in LRSort: self.addItem(sort_type.name, sort_type) self.currentIndexChanged.connect(self.onCurrentIndexChanged) # Select the default sort method idx = self.findData(DEFAULT_LR_SORT) self.setCurrentIndex(idx)
[docs] def onCurrentIndexChanged(self): """ When the user makes a selection in this combo box, emit the enum value associated with their selection (rather than the less-useful index of the selection). """ self.LRSortMethodChanged.emit(self.currentData())
# ============================================================================= # Base Widgets # =============================================================================
[docs]class BaseSearchTreeWidgetHelper(object): """ Base class for a widget subclass to hold a search box QLineEdit and a QTreeView below. This is a helper class only and should be sub-classed. This class should NOT be used directly. """
[docs] def setUpWidgets(self, model, view): """ Sets up the search box, model, view, and search proxy. :param model: model for the tree :type model: `QtGui.QStandardItemModel` :param view: view for the model :type view: `QtWidgets.QTreeView` """ # Add search box self.search_le = QtWidgets.QLineEdit() self.search_le.setStyleSheet(SEARCH_BOX) self.search_le.setPlaceholderText("Search") # Setup the tree self.model = model self.view = view self.proxy_model = StringSearchFilterProxyModel() self.proxy_model.setSourceModel(self.model) self.view.setModel(self.proxy_model) # Signals self.search_le.textChanged.connect(self.searchTextChanged)
[docs] def setUpLayout(self): """ Sets up the layout, but needs to be added to the widget by the subclass. """ self.main_layout = QtWidgets.QVBoxLayout() self.search_layout = QtWidgets.QHBoxLayout() self.search_layout.addWidget(self.search_le) self.main_layout.addLayout(self.search_layout) self.main_layout.addWidget(self.view) # Aesthetics self.main_layout.setContentsMargins(0, 0, 0, 0)
[docs] def searchTextChanged(self, search_text): """ Set the search term to the sort filter proxy model to show only the matching tree items. Any filtered in items will be expanded to show all children. Otherwise, all the items will be collapsed. :param search_text: search terms to apply to proxy model. :type search_text: str """ self.proxy_model.setFilterRegularExpression(search_text) if not search_text: self.view.collapseAll() else: self.view.expandAll()
[docs] def resetWidgets(self): self.model.clear() self.search_le.setText('')
# ============================================================================= # LD Data Tree Model # =============================================================================
[docs]class CascadingCheckboxItem(QtGui.QStandardItem): """ A subclass of QStandardItem that implements checkboxes that automatically respond to changes in child/parent check state. Checking or unchecking an item will cause all of its children to become checked/unchecked accordingly and will update its parent to be either checked, unchecked, or partially checked, depending on the state of all of the other children. """
[docs] def __init__(self, *args, **kwargs): super(CascadingCheckboxItem, self).__init__(*args, **kwargs) self.setCheckable(True) self.update_in_progress = False
[docs] def getChildItems(self, column=0): """ Returns a list of all the child items. A column may be optionally specified if desired, otherwise the first column's item will be returned from each descendent row. :param column: the column of the item to be returned from each row :type column: int """ return [self.child(row, column) for row in range(self.rowCount())]
[docs] def updateCheckState(self): """ Updates the item's check state depending on the check state of all the child items. If all the child items are checked or unchecked, this item will be checked/unchecked accordingly. If only some of the children are checked, this item will be partially checked. """ if self.update_in_progress: return states = [item.checkState() for item in self.getChildItems()] if all([state == Qt.Unchecked for state in states]): self.setCheckState(Qt.Unchecked) elif all([state == Qt.Checked for state in states]): self.setCheckState(Qt.Checked) else: self.setCheckState(Qt.PartiallyChecked)
[docs] def updateEnabled(self): """ If this item has children and they are all disabled, disable this item. If any such children are enabled, enable this item. """ child_items = self.getChildItems() if not child_items: return enabled = any(item.isEnabled() for item in child_items) if self.isEnabled() != enabled: self.setEnabled(enabled)
[docs] def setData(self, value, role): """ Overrides setData() to trigger an update of the parent item's check state and enabled state, and propagate check state to all the child items (i.e. checking this item will cause all its child items to become checked). See parent class for documentation on parameters. """ self.update_in_progress = True super().setData(value, role) parent = self.parent() if parent: parent.updateCheckState() parent.updateEnabled() if value in (Qt.Unchecked, Qt.Checked): for item in self.getChildItems(): if item.isEnabled(): item.setCheckState(value) self.update_in_progress = False
[docs] def setEnabled(self, enabled): super().setEnabled(enabled) parent = self.parent() if parent: parent.updateEnabled()
[docs]class LDDataCheckboxItem(CascadingCheckboxItem): """ A `CascadingCheckboxItem` that stores and knows how to display a `data_classes.LDData` instance. """
[docs] def __init__(self, ld_data): """ :param ld_data: LD data instance :type ld_data: data_classes.LDData """ super(LDDataCheckboxItem, self).__init__(ld_data.user_name) self._ld_data = ld_data
@property def ld_data(self): return self._ld_data
[docs]class LDDataSelectionModel(QtGui.QStandardItemModel): """ A tree structured model for storing LD data by family name. :cvar item_dict: a dictionary mapping LD data to items from this model :vartype item_dict: dict(data_classes.LDData, QtGui.QStandardItem) """
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.item_dict = {}
[docs] def clear(self): super().clear() self.item_dict = {}
[docs] def loadData(self, ld_data_list): """ Replaces the data in the model with the specified list of export data. :param ld_data_list: a list of LD data to export :type ld_data_list: list(data_classes.LDData) """ self.clear() # Organize the data by family name ld_data_map = organize_ld_data_tree(ld_data_list) for family_name, ld_data_list in ld_data_map.items(): product_row_item = CascadingCheckboxItem(family_name) sort_func = lambda ld_data: ld_data.user_name.lower() for ld_data in sorted(ld_data_list, key=sort_func): ld_data_item = LDDataCheckboxItem(ld_data) self.item_dict[ld_data] = ld_data_item product_row_item.appendRow(ld_data_item) self.appendRow([product_row_item])
[docs] def getCheckedData(self): """ Recursively traverses the entire tree and returns LD data instances checked by the user. :return: LD data specified for export by the user :rtype: list(data_classes.LDData) """ ld_data_list = [] for row_idx in range(self.rowCount()): item = self.item(row_idx, 0) ld_data_list += self._recurseGetCheckedData(item) return ld_data_list
def _recurseGetCheckedData(self, item): """ Recursively search item and all children, returning data assocated with checked items. :param item: an item from this model :type item: QtGui.QStandardItem :return: a list of `LDData` associated with this item or its children if they are checked :rtype: list(data_classes.LDData) """ ld_data_list = [] child_items = item.getChildItems() for child_item in child_items: ld_data_list += self._recurseGetCheckedData(child_item) if not child_items and item.checkState() == Qt.Checked: ld_data_list = [item.ld_data] return ld_data_list
[docs] def setItemsChecked(self, ld_data_list, checked): """ Set the checkstate of the items associated with the supplied data, if they are enabled. :param ld_data_list: a list of `LDData` instances corresponding to items in the model :type ld_data_list: List[data_classes.LDData] :param checked: whether to check or uncheck the specified item :type checked: bool """ self.beginResetModel() for ld_data in ld_data_list: item = self.item_dict.get(ld_data) if item is None or not item.isEnabled(): continue if checked: item.setCheckState(Qt.Checked) else: item.setCheckState(Qt.Unchecked) self.endResetModel()
[docs] def uncheckAll(self): """ Un-check all items in tree. """ for row in range(self.rowCount()): item = self.item(row, 0) item.setData(Qt.Unchecked, Qt.CheckStateRole)
[docs] def setItemEnabled(self, ld_data, enable): """ Set an item to be enabled or disabled. :param ld_data: data object associated with the item to be enabled or disabled :type ld_data: data_classes.LDData :param enable: whether to enable (`True`) or disable (`False`) the specified item :type enable: bool """ item = self.item_dict.get(ld_data) if item is None: return item.setEnabled(enable) if not enable: item.setCheckState(Qt.Unchecked)
[docs] def getItemTooltip(self, ld_data): """ Return the tooltip of the item associated with the supplied LD data object. :param ld_data: a LiveDesign data object :type ld_data: data_classes.LDData :return: tooltip text, if the specified item can be found; otherwise, `None` :rtype: str or NoneType """ item = self.item_dict.get(ld_data) if item is not None: return item.toolTip()
[docs] def setItemTooltip(self, ld_data, tooltip): """ Apply a tooltip to an item. :param ld_data: data object associated with the item to set the tooltip on :type ld_data: data_classes.LDData :param tooltip: tooltip text :type tooltip: str """ item = self.item_dict.get(ld_data) if item is not None: item.setToolTip(tooltip)
# ============================================================================= # LD Data Tree View # =============================================================================
[docs]class LDDataSelectionTreeView(QtWidgets.QTreeView): """ A class for displaying LD data selection. """
[docs] def __init__(self): super().__init__() self.setMinimumWidth(200) self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding) self.setHeaderHidden(True)
# ============================================================================= # LD Data Tree Widget # =============================================================================
[docs]class LDDataTree(widgetmixins.InitMixin, BaseSearchTreeWidgetHelper, QtWidgets.QWidget): """ A QWidget with a initialization mixin to group together a search bar LineEdit and the LD Data Selection QTreeView. """ dataChanged = QtCore.pyqtSignal()
[docs] def initSetUp(self): """ Sets up the model, view, proxy and search box. See BaseSearchTreeWidgetHelper.setUpWidgets for more info. """ model = LDDataSelectionModel() view = LDDataSelectionTreeView() self.setUpWidgets(model, view) # Signals self.proxy_model.dataChanged.connect(self.dataChanged.emit)
[docs] def initLayOut(self): super(LDDataTree, self).initLayOut() # Add subwidgets self.main_layout.addWidget(self.search_le) self.main_layout.addWidget(self.view) # Aesthetics self.main_layout.setContentsMargins(0, 0, 0, 0)
[docs] def loadData(self, ld_data_list): """ See `LDDataSelectionModel.loadData()` for more information. """ checked_ldd_items = self.getCheckedData() self.model.loadData(ld_data_list) self.setCheckedData(checked_ldd_items)
[docs] def getCheckedData(self): """ See `LDDataSelectionModel.getCheckedData()` for more information. """ return self.model.getCheckedData()
[docs] def setCheckedData(self, ld_data_list): """ Check the items corresponding to the supplied LD data (if found), and uncheck all other items. :param ld_data_list: a list of LD data instances that should be checked in the tree :type ld_data_list: list(data_classes.LDData) """ with qtutils.suppress_signals(self): self.uncheckAll() self.setItemsChecked(ld_data_list, True) self.dataChanged.emit()
[docs] def setItemChecked(self, ld_data, checked): """ Convenience method to check or uncheck a single item. :param ld_data: a LD data object corresponding to an item in the model :type ld_data: data_classes.LDData :param checked: whether to check or uncheck the specified item :type checked: bool """ self.setItemsChecked([ld_data], checked)
[docs] def setItemsChecked(self, ld_data_list, checked): """ Set the checkstate of the items associated with the supplied data, if they are enabled. :param ld_data_list: a list of `LDData` instances corresponding to items in the model :type ld_data_list: List[data_classes.LDData] :param checked: whether to check or uncheck the specified items :type checked: bool """ self.model.setItemsChecked(ld_data_list, checked) self.dataChanged.emit()
[docs] def isItemChecked(self, ld_data): """ Return whether the item associated with the specified LiveDesign data object is checked. :param ld_data: a `LDData` instance corresponding to an item in the model :type ld_data: data_classes.LDData :return: whether the specified item is checked, if possible; if the item cannot be found, return `None` :rtype: bool or NoneType """ item = self.model.item_dict.get(ld_data) if item: return item.checkState() == Qt.Checked
[docs] def isItemEnabled(self, ld_data): """ Return whether the specified item is enabled. :param ld_data: a `LDData` instance corresponding to an item in the model :type ld_data: data_classes.LDData :return: if possible, whether the specified item is enabled; if no such item exists, return `None` :rtype: bool or NoneType """ item = self.model.item_dict.get(ld_data) if item is not None: return item.isEnabled()
[docs] def uncheckAll(self): """ See `LDDataSelectionModel.uncheckAll()` for more information. """ with qtutils.suppress_signals(self): self.model.uncheckAll() self.dataChanged.emit()
[docs] def initSetDefaults(self): self.resetWidgets()
[docs] def setItemsEnabled(self, ld_data_list, enabled): """ Enable or disable the items associated with the specified data list. :param ld_data_list: a list of LD Data objects that correspond to items that should be enabled or disabled :type ld_data_list: list(data_classes.LDData) :param enabled: enable (`True`) or disable (`False`) specified items :type enabled: bool """ for ld_data in ld_data_list: self.model.setItemEnabled(ld_data, enabled)
[docs] def getItemTooltip(self, ld_data): """ Get the tooltip for the item associated with the specified LD data. :param ld_data: a LD data object :type ld_data: data_classes.LDData :return: the tooltip text of the item associated with the supplied LD data object, if possible. If there is no item associated with the LD data object, return `None` :rtype: str or NoneType """ return self.model.getItemTooltip(ld_data)
[docs] def setItemsTooltip(self, ld_data_list, tooltip): """ Set tooltip for items associated with the specified data list. :param ld_data_list: a list of LD Data objects that correspond to items to set the tooltip on :type ld_data_list: list(data_classes.LDData) :param tooltip: the tooltip text :type tooltip: str """ for ld_data in ld_data_list: self.model.setItemTooltip(ld_data, tooltip)
[docs] def expandFamily(self, family_name): """ Expand the item associated with the supplied family name if possible. :param family_name: the family name of the item to expand :type family_name: str """ items = self.model.findItems(family_name) if not items: return # There should only be one item per LD data family name assert len(items) == 1 index = self.proxy_model.mapFromSource(items[0].index()) self.view.setExpanded(index, True)
# ============================================================================= # Column List Model # =============================================================================
[docs]class ColumnSelectionModel(QtGui.QStandardItemModel): """ A tree structured model for storing data by family. """
[docs] def __init__(self, *args, **kwargs): super(ColumnSelectionModel, self).__init__(*args, **kwargs) self.showing_unavailable = False
[docs] def loadData(self, columns, unavailable_ids=None): """ Populates the model using a list of ldclient.models.Column objects. This clears out all previous data in the model. :param columns: the list of columns to add :type columns: list of ldclient.models.Column :param unavailable_ids: list of column IDs that should always be unavailable for import :type unavailable_ids: `list` of `str`, or `None` """ self.clear() if not columns: return unavailable_ids = unavailable_ids or [] sorted_cols = sorted(columns, key=attrgetter(LD_PROP_NAME)) # pull out ID and Comp Structure and insert at beginning for i, col in enumerate(sorted_cols[:]): if col.name in [STRUCTURE, ID]: sorted_cols.insert(0, sorted_cols.pop(i)) for col in sorted_cols: unavailable = col.id in unavailable_ids product_row_item = ColumnCheckboxItem(col, force_unavailable=unavailable) # ID always required if col.name in [STRUCTURE, ID]: product_row_item.setCheckState(Qt.Checked) product_row_item.setEnabled(False) self.appendRow([product_row_item])
[docs] def getCheckedColumns(self, all_checked: bool = False): """ Traverse all the columns and return all the checked properties. :param all_checked: if the all columns checked property is enabled :return: the checked properties :rtype: list of structure.PropertyName """ checked_props = [] for row in range(self.rowCount()): item = self.item(row, 0) if (all_checked and item.isEnabled() or item.checkState() == Qt.Checked): checked_props.append(item.column()) return checked_props
[docs] def getVisibleColCount(self) -> int: """ Calculate the total amount of columns that are visible. """ return len([ row for row in range(self.rowCount()) if self.item(row, 0).isEnabled() or self.item(row, 0).checkState() == Qt.Checked ])
[docs] def getHiddenColCount(self): """ Calculate the total amount of unsupported columns. """ return len([ row for row in range(self.rowCount()) if not self.item(row, 0).isEnabled() and self.item(row, 0).checkState() != Qt.Checked ])
[docs]class ColumnSelectionProxyModel(QtCore.QSortFilterProxyModel): """ A proxy model to filter an assay model based on the availability """
[docs] def __init__(self, parent=None): super(ColumnSelectionProxyModel, self).__init__(parent) # maintain Qt4 dynamicSortFilter default self.setDynamicSortFilter(False) self.showing_unavailable = False
[docs] def filterAcceptsRow(self, source_row, source_parent): """ See Qt Documentation for more information on parameters. This filter accepts a particular row if any of the following are true: 1. The proxy model is currently set to show unavailable items 2. The item is marked as available Note that the conditions specified above are searched in that order. """ index = self.sourceModel().index(source_row, 0, source_parent) item = self.sourceModel().itemFromIndex(index) return self.showing_unavailable or item.available
[docs] def showUnavailable(self, show=True): self.showing_unavailable = show self.invalidate()
[docs]class StructSelectionModel(QtGui.QStandardItemModel): """ A selection model for storing structures to be selected. """
[docs] def loadData(self, structs: List[structure.Structure]): """ Populate the model using a list of structures. This clears all previous data in the model. :param structs: structures to be included in the table """ self.clear() if not structs: return for struct in sorted(structs, key=lambda st: st.title): product_row_item = StructCheckboxItem(struct) self.appendRow([product_row_item])
[docs] def getCheckedStructs(self, all_checked: bool = False ) -> List[structure.Structure]: """ Obtain a list of all checked structures. :param all_checked: if all structures should be included :return: all structures that are checked """ checked_structs = [] for row in range(self.rowCount()): item = self.item(row, 0) if all_checked or item.checkState() == Qt.Checked: checked_structs.append(item.structure) return checked_structs
[docs]class ColumnCheckboxItem(QtGui.QStandardItem): """ A CascadingCheckboxItem that stores and knows how to display a Column object. """ # TODO these will eventually be supported - PANEL-9680 UNAVAIL_VALUE_TYPES = ['attachment']
[docs] def __init__(self, col, force_unavailable=False): """ :param col: the object describing a live report column. :type col: ldclient.models.Column :param force_unavailable: if `True`, make this column unavailable for import by disabling it in the list view :type force_unavailable: `bool` """ super().__init__(col.name) self.col = col if force_unavailable: # Caller has specified that this column should be unavailable self.available = False else: self.available = (col.value_type not in self.UNAVAIL_VALUE_TYPES) self.setEnabled(self.available) self.setCheckable(True)
[docs] def column(self): return self.col
[docs]class StructCheckboxItem(QtGui.QStandardItem): """ A CascadingCheckboxItem that stores and displays a Structure. """
[docs] def __init__(self, struct): """ :param struct: the structure to be filtered on :type struct: structure.Structure """ super().__init__(struct.title) self._struct = struct self.setCheckable(True)
@property def structure(self): return self._struct
# ============================================================================= # Generic List View # =============================================================================
[docs]class SelectionListView(QtWidgets.QListView): """ A class for displaying a column selection """
[docs] def __init__(self): super().__init__() self.setMinimumWidth(200) self.setMinimumHeight(200) self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding)
# ============================================================================= # Export Table Model # ============================================================================= class ExportTableColumns(table_helper.TableColumns): Data = table_helper.Column('Maestro Property') Assay = table_helper.Column('LiveDesign Column', editable=True) Endpoint = table_helper.Column('LiveDesign Property', editable=True) Units = table_helper.Column('Units') Decimal = table_helper.Column('Decimal Places') Options = table_helper.Column('Options')
[docs]class ExportRow: """ An object for data in each row in the table. """
[docs] def __init__(self, ld_data=None, assay=None, endpoint=None, units=None, decimal=None, option=None, assay_folder_path=None): self.ld_data = ld_data self.assay = assay self.endpoint = endpoint self.units = units self.decimal = decimal self.option = option self.assay_folder_path = assay_folder_path
def __eq__(self, other): """ Compare this ExportRow to other instance of ExportRow to see if they are equivalent. :param other: instance to compare :type other: ExportRow :return: whether the two instances are equal :rtype: bool """ return self.__dict__ == other.__dict__ def __ne__(self, other): """ Compare this ExportRow to other instance of ExportRow to see if they are NOT equivalent. :param other: instance to compare :type other: ExportRow :return: whether the two instances are not equal :rtype: bool """ return not self.__eq__(other)
[docs]class ExportTableModel(table_helper.RowBasedTableModel): Column = ExportTableColumns ROW_CLASS = ExportRow
[docs] def __init__(self): super().__init__() self.assay_data = None self.endpoint_dict = {} self._disable_lr_columns = True self.highlight_missing_fields = False self._3d_data_spec_map = {} self._ffc_data_spec_map = {}
[docs] def set3DDataSpecMap(self, spec_map): self._3d_data_spec_map = spec_map
[docs] def setFFCDataSpecMap(self, spec_map): self._ffc_data_spec_map = spec_map
@property def disable_lr_columns(self): """ :return: whether to disable certain live report-dependent export columns :rtype: bool """ return self._disable_lr_columns @disable_lr_columns.setter def disable_lr_columns(self, disable): """ Set certain live report-dependent table columns to be disabled or not. If the state of this value is changed, emit signals notifying the table view that it should be updated. :param disable: whether certain live report-dependent columns should be disabled :type disable: bool """ if self.disable_lr_columns == disable: return self._disable_lr_columns = disable for column in self.getLRDependentColumns(): self.columnChanged(column)
[docs] def getLRDependentColumns(self): """ Return a list of columns that should be disabled if a live report is not selected in the panel. :return: a list of columns that depend on the live report selection :rtype: `list` of `table_helper.TableColumns` enum values """ return [self.Column.Endpoint, self.Column.Assay]
[docs] @table_helper.model_reset_method def reset(self): super().reset() self.disable_lr_columns = True
[docs] @table_helper.model_reset_method def loadData(self, ld_data_list): """ Load in the data values to be shown as rows with default information for Assays and Endpoints. Note, this method resets the table. :param ld_data_list: list of data values :type ld_data_list: list(data_classes.LDData) """ rows = [] primary_3d_row = self._getPrimary3DRow() assay_3d = primary_3d_row.assay if primary_3d_row else None for ld_data in ld_data_list: row_idx = self._getRowIndexByLDData(ld_data) if row_idx >= 0: # LD data is already loaded to the table rows.append(self._rows[row_idx]) continue # By default, new properties have no assay/endpoint assay, endpoint = CLICK, CLICK if ld_data.requires_3d: assay = LD_DATA_3D.family_name endpoint = ENDPOINT_3D if assay_3d: assay = assay_3d elif ld_data.family_name == MAESTRO_FAMILY_NAME: # Maestro properties assigned assays/endpoints automatically assay = MAESTRO_ASSAY endpoint = ld_data.user_name rows.append( self.ROW_CLASS(ld_data=ld_data, assay=assay, endpoint=endpoint)) super().loadData(rows)
def _getPrimary3DRow(self): """ :return: the row corresponding to the `LD_DATA_3D` LD data instance :rtype: ExportRow or None """ for row in self.rows: if row.ld_data == LD_DATA_3D: return row
[docs] def loadMappings(self, map_rows): """ Load in the mapping data. The properties in the mapping data are assumed to already exist in the table. Note, that this method does not reset the table. :param map_rows: mapped row data to set :type map_rows: List of ExportRow :return: whether the loading of the mapping data was successful :rtype: bool """ success = True for map_row in map_rows: # ignore custom Maestro rows with default values if map_row.assay == MAESTRO_ASSAY and not map_row.assay_folder_path: continue # get row index row_idx = self._getRowIndexByLDData(map_row.ld_data) if row_idx == -1: success = False continue self._applyRowMapping(row_idx, map_row) return success
def _applyRowMapping(self, row_idx, map_row): """ Apply mapping data to row at specified index. :param row_idx: the row index to apply mapping to :type row_idx: int :param map_row: mapped row data to set :type map_row: ExportData """ # Set assay-related data assay_names_to_path = { a_wrapper.name: a_wrapper.folder_path for a_wrapper in self.assay_data } map_assay = map_row.assay map_assay_folder_path = map_row.assay_folder_path assay_index = self.index(row_idx, self.Column.Assay) if map_assay != CLICK and ( map_assay not in assay_names_to_path or assay_names_to_path[map_assay] != map_assay_folder_path): # since this assay doesn't exist in the host's assay data, # this assay will be added as a new user created assay. if map_assay_folder_path is not None: map_assay_folder_path = 'User Created' new_assay = BaseLDTreeItemWrapper(ld_name=map_assay, path=map_assay_folder_path) self.setData(assay_index, new_assay, role=CustomRole.AssayData) self.setData(assay_index, map_assay) self.setData(assay_index, map_assay_folder_path, role=CustomRole.AssayFolderPathData) # Set endpoint, units, decimal, and options endpoint_index = self.index(row_idx, self.Column.Endpoint) self.setData(endpoint_index, map_row.endpoint) units_index = self.index(row_idx, self.Column.Units) self.setData(units_index, map_row.units) decimal_index = self.index(row_idx, self.Column.Decimal) self.setData(decimal_index, map_row.decimal) options_index = self.index(row_idx, self.Column.Options) self.setData(options_index, map_row.option)
[docs] def getMappableRows(self): """ Get rows that can be saved to a mapping state. :return: mapped row data :rtype: List of ExportRow """ return [deepcopy(row) for row in self.rows]
def _getRowIndexByLDData(self, ld_data): """ Returns the row index that matches the supplied LD Data. :param ld_data: object identifying one piece of data for export :type ld_data: data_classes.LDData :return: row index, or -1 if does not exist :rtype: int """ for r, row_obj in enumerate(self.rows): if row_obj.ld_data == ld_data: return r return -1
[docs] def loadAssayData(self, assay_paths, favorite_assay_paths): """ Load in the complete Assay data - full path name - wrapped as BaseLDTreeItemWrapper :param assay_paths: Assay data to store. :type assay_paths: List of paths :param favorite_assay_paths: Favorite Assay data to store. :type favorite_assay_paths: List of (assay names, folder path) tuples """ assay_data = [] pathsplit = lambda path: path.rsplit(ldt.SEP, 1) for a_path, a_name in map(pathsplit, assay_paths): assay_data.append( BaseLDTreeItemWrapper(ld_name=a_name, path=str(a_path))) # keep track of duplicate favorites so we can add their original path # to distinguish them favorite_names = collections.defaultdict(int) for a_path, a_name in map(pathsplit, favorite_assay_paths): favorite_names[a_name] += 1 for a_path, a_name in map(pathsplit, favorite_assay_paths): is_duplicate = favorite_names[a_name] > 1 # TODO should path be 'Project Favorites/subfolders/assay_name' or # just 'Project Favorites/assay_name'? assay_data.append( BaseLDTreeItemWrapper(ld_name=a_name, path='Project Favorites', linked_path=str(a_path), show_path=is_duplicate)) self.assay_data = assay_data
[docs] def loadEndpointData(self, endpoint_map): """ Set the assay path to endpoint dictionary. :param endpoint_map: a dictionary that maps assay folder paths to endpoints :type endpoint_map: dict(str, set(str)) """ self.endpoint_dict = endpoint_map
@table_helper.data_method(CustomRole.AssayData) def _assayData(self, col, row_data, role): """ Get the Assay data. Note, all rows in the Model / Assay column hold the same Assay Data. See superclass for argument docstring. :return: list of Assays :rtype: List of BaseLDTreeItemWrapper. """ if col == self.Column.Assay: return self.assay_data @table_helper.data_method(CustomRole.AssayFolderPathData) def _assayFolderPathData(self, col, row_data, role): """ Get the Assay folder path data for this row See superclass for argument docstring. :return: the assay folder path for this cell :rtype: str """ if col == self.Column.Assay: return row_data.assay_folder_path @table_helper.data_method(CustomRole.EndpointData) def _endpointData(self, col, row_data, role): """ Get the Endpoint Data for this row given an Assay has already been selected. Filter out the correct existing endpoints for the given Assay / Model name. See superclass for argument docstring. :return: list of endpoints :rtype: List of str """ if col == self.Column.Endpoint: assay_name = row_data.assay assay_folder_path = row_data.assay_folder_path endpoint_folder_path = None if assay_folder_path: # existing assay endpoint_folder_path = ldt.SEP.join( [assay_folder_path, assay_name]) # FIXME: Finalize whether only Assay Types should be shown here # or Endpoints in the form of Model (Assay Type). # FIXME figure out how to use favorite_endpoints for selections # from project favorites return self.endpoint_dict.get(endpoint_folder_path, set()) @table_helper.data_method(Qt.BackgroundRole) def _backgroundData(self, col, row, role): if not self.highlight_missing_fields: return if col == self.Column.Assay: if not row.assay or row.assay == CLICK: return QtGui.QBrush(COLOR_MISSING) if col == self.Column.Endpoint: if not row.endpoint or row.endpoint == CLICK: return QtGui.QBrush(COLOR_MISSING) @table_helper.data_method(Qt.DisplayRole, Qt.EditRole) def _data(self, col, row, role): if col == self.Column.Data: return row.ld_data.user_name elif col == self.Column.Assay: return row.assay elif col == self.Column.Endpoint: return row.endpoint elif col == self.Column.Units: return row.units def _setData(self, col, row_data, value, role, row_num): allowed_roles = (Qt.EditRole, Qt.DisplayRole, CustomRole.AssayFolderPathData, CustomRole.AssayData) if role not in allowed_roles: return False if col == self.Column.Assay and role == CustomRole.AssayFolderPathData: row_data.assay_folder_path = value elif col == self.Column.Assay and role == CustomRole.AssayData: self.assay_data.append(value) elif col == self.Column.Endpoint and role == CustomRole.EndpointData: pass # TODO - add to the endpoint_dict with the title in the form # of Model (Assay Type) depending on what Noeris says. elif col == self.Column.Assay: row_data.assay = value elif col == self.Column.Endpoint: # user editing already locked, but stop programmatic changes # to the 3D data columns if not row_data.ld_data.requires_3d: row_data.endpoint = value elif col == self.Column.Options: row_data.option = value elif col == self.Column.Units: row_data.units = value else: return False return True
[docs] def flags(self, index): """ See Qt documentation for an method documentation. Overriding table_helper.RowBasedTableModel. """ flag = super().flags(index) col_num = index.column() try: column = self.Column(col_num) except (ValueError, TypeError): # If the request is for a column that isn't defined in self.Column, # then no further processing is necessary or possible return flag if column in self.getLRDependentColumns() and self.disable_lr_columns: return Qt.NoItemFlags if column == self.Column.Endpoint: row = self._getRowFromIndex(index) ldd = row.ld_data if row.assay == CLICK or ldd.requires_3d or ldd.requires_ffc: # Do not allow the user to edit 3D and FFC data item endpoints return Qt.NoItemFlags return flag
[docs] def getPropertyExportSpecMap(self): """ Return a dictionary mapping LDData to corresponding property export specs. Property export specs are export specs that represent data meant to be stored as structure properties for export to LiveDesign. :return: a dictionary mapping an `LDData` instance to the corresponding export spec for property rows in this table :rtype: dict[data_classes.LDData, export_models.PropertyExportSpec] """ prop_rows = [ row for row in self.rows if not row.ld_data.requires_3d and not row.ld_data.requires_ffc ] return self.getExportSpecMap(prop_rows)
[docs] def get3DExportSpecMap(self): """ Return dictionary mapping LDData to corresponding 3D export specs in this table. :return: a dictionary mapping an `LDData` instance to the corresponding export spec for 3D data rows in this table :rtype: dict[data_classes.LDData, export_models.Base3DExportSpec] """ three_d_rows = [row for row in self.rows if row.ld_data.requires_3d] return self.getExportSpecMap(three_d_rows)
[docs] def getFFCExportSpecMap(self): """ Return dictionary mapping LDData to corresponding freeform column export specs in this table. :return: a dictionary mapping an `LDData` instance to the corresponding export spec for FFC data rows in this table :rtype: dict[data_classes.LDData, export_models.Base3DExportSpec] """ ffc_rows = [row for row in self.rows if row.ld_data.requires_ffc] return self.getExportSpecMap(ffc_rows)
[docs] def getExportSpecMap(self, rows=None): """ Return a list of specs corresponding to the supplied list of rows. :param rows: optionally, a list of rows to include in the map; all rows are used by default :type rows: list[ExportRow] or NoneType :return: a dictionary mapping `LDData` to corresponding export specs for each of `rows` :rtype: dict[data_classes.LDData, export_models.DataExportSpec] """ ld_data_spec_map = {} rows = self.rows if rows is None else rows for row in rows: model = filter_missing_entry(row.assay, MODEL_OR_ASSAY_MISSING) endpoint = filter_missing_entry(row.endpoint, ENDPOINT_MISSING) if row.ld_data.data_name is not None: spec = export_models.PropertyExportSpec() elif row.ld_data in self._3d_data_spec_map: spec = self._3d_data_spec_map[row.ld_data]() elif row.ld_data in self._ffc_data_spec_map: spec = self._ffc_data_spec_map[row.ld_data]() else: msg = f'No export spec exists for data row "{row}".' raise ValueError(msg) spec.data_name = row.ld_data.data_name spec.ld_model = model spec.ld_endpoint = endpoint spec.units = row.units spec.option = row.option ld_data_spec_map[row.ld_data] = spec return ld_data_spec_map
[docs] def isCustomMaestroAssay(self, assay_index): """ Checks if the given assay index is a custom Maestro assay, which is the initial custom assay of 'Maestro' selected for Maestro properties, along with the endpoint. :param assay_index: the index of the assay cell :type assay_index: `QtCore.QModelIndex` :return: whether the assay holds the initial 'Maestro' assay set for maestro properties. :rtype: bool """ assay = assay_index.data() assay_folder_path = assay_index.data( role=CustomRole.AssayFolderPathData) return assay == MAESTRO_ASSAY and not assay_folder_path
# ============================================================================= # Export Table View # =============================================================================
[docs]class ExportTableView(QtWidgets.QTableView): """ The table view showing all the Properties and Assay / Endpoints. Assay and Endpoint columns are set with different PopUp Delegates to show when the respective column cells are edited. """
[docs] def __init__(self, parent): super().__init__(parent) self.setMinimumWidth(700) self.horizontalHeader().setSectionResizeMode( QtWidgets.QHeaderView.Stretch) self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft) self.endpoint_delegate = None self.setAlternatingRowColors(True) self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.setEditTriggers(QtWidgets.QAbstractItemView.CurrentChanged | QtWidgets.QAbstractItemView.SelectedClicked | QtWidgets.QAbstractItemView.DoubleClicked)
[docs] def setModel(self, model): super().setModel(model) self.setExtraColumnsVisible(False) self.setColumnHidden(self.Column.Options, True) self._setupDelegates() self.selectionModel().selectionChanged.connect(self._onSelectionChanged)
def _onSelectionChanged(self): num_selected_rows = len(self.selectionModel().selectedRows()) self.assay_delegate.numSelectedRowsChanged.emit(num_selected_rows) self.endpoint_delegate.numSelectedRowsChanged.emit(num_selected_rows) @property def Column(self): return self.model().Column def _setupDelegates(self): parent = self.parent() self.assay_delegate = AssaySelectionPopUpDelegate(parent) self.setItemDelegateForColumn(self.Column.Assay, self.assay_delegate) self.endpoint_delegate = EndpointSelectionPopUpDelegate(parent) self.setItemDelegateForColumn(self.Column.Endpoint, self.endpoint_delegate) self.assay_delegate.commitDataToSelected.connect( self.onCommitDataToSelected) self.endpoint_delegate.commitDataToSelected.connect( self.onCommitDataToSelected) self.assay_delegate.newAssaySelected.connect(self._setDefaultEndpoint) self.assay_delegate.assaySelected.connect(self._setDefaultEndpoint) def _setDefaultEndpoint(self, index=None): """ When the model/assay value is set, update the endpoint value to an approprate default, according to the following hierarchy: 1. If there is no assay name (the cell shows `CLICK`), show `CLICK` 2. If the property name is among the known list of endpoints, use that 3. If the current endpoint value is among the known list of endpoints, use that 4. Finally, default to using the property name. :param index: Index of the current assay/model cell in the table. If index is None, the current index of the table model is used. :type index: QModelIndex or None """ model = self.model() if index is None: index = self.currentIndex() cur_row = index.row() assay_name = model.index(cur_row, self.Column.Assay).data() endpoint_index = model.index(cur_row, self.Column.Endpoint) if assay_name == CLICK: endpoint = CLICK else: prop_name = model.index(cur_row, self.Column.Data).data() endpoints = endpoint_index.data(role=CustomRole.EndpointData) current_endpoint = endpoint_index.data() if prop_name in endpoints: endpoint = prop_name elif current_endpoint in endpoints: endpoint = current_endpoint else: endpoint = prop_name model.setData(endpoint_index, endpoint)
[docs] def onCommitDataToSelected(self, editor, index, delegate): """ Called when "Apply to Selected Rows" is clicked in Assay or Endpoint popup. See parent class for args documentations. """ indices = self.selectionModel().selectedIndexes() if index not in indices: indices.append(index) # possible using keyboard navigation model = self.model() for cur_index in indices: if cur_index.column() == delegate.COLUMN_TYPE: delegate.setModelData(editor, model, cur_index)
[docs] def setExtraColumnsVisible(self, visible): """ Show or hide units and decimal places columns from table. :param hide: whether to show or hide columns. :type hide: bool """ hidden = not visible self.setColumnHidden(self.Column.Units, hidden) self.setColumnHidden(self.Column.Decimal, hidden) self.resizeColumnsToContents()
# ============================================================================= # Assay Tree Model # =============================================================================
[docs]class PathTreeDict(collections.defaultdict):
[docs] def __init__(self, paths=None): super().__init__(PathTreeDict) if paths is None: return for path in paths: self.addPath(path)
[docs] def addPath(self, path): split_path = path.split(ldt.SEP, 1) root_path = split_path[0] child_dict = self[root_path] if len(split_path) == 1: child_dict[FOLDER_CONTENTS] = [] return child_path = split_path[1] child_dict.addPath(child_path)
[docs] def findPath(self, path): split_path = path.split(ldt.SEP, 1) root_path = split_path[0] if root_path not in self: raise KeyError('%s not in tree.' % root_path) sub_tree = self[root_path] if len(split_path) == 1: return sub_tree child_path = split_path[1] return sub_tree.findPath(child_path)
[docs]def make_ld_item_tree(ld_items): """ Makes LD folder/assay(model) tree. :param ld_items: List of LD items. :type ld_items: list of BaseLDTreeItemWrapper """ path_set = set() for ld_item in ld_items: path_set.add(ld_item.folder_path) path_tree = PathTreeDict(path_set) for ld_item in ld_items: # all ld_items are leaf nodes, so we don't need to worry about # skipping subfolders folder_contents = path_tree.findPath( ld_item.folder_path)[FOLDER_CONTENTS] folder_contents.append(ld_item) return path_tree
[docs]class LDTreeItem(QtGui.QStandardItem): """ A custom Tree item. """
[docs] def __init__(self, ld_item): """ :param ld_item: an object that holds a name and folder_path attributes. :type ld_item: BaseLDTreeItemWrapper """ super(LDTreeItem, self).__init__(ld_item.display_name) self.ld_item = ld_item self.setEditable(False)
[docs]class LDSelectionModel(QtGui.QStandardItemModel): """ Tree model class which stores BaseLDTreeItemWrappers. """
[docs] def loadData(self, ld_items): """ Load in the generic LiveDesign item and store in Tree form. Note: All previous rows in model are removed. :param ld_items: an object that holds a name and folder_path attributes. :type ld_items: List of BaseLDTreeItemWrapper """ self.clear() ld_tree = make_ld_item_tree(ld_items) self._generateItemTree(self, ld_tree)
[docs] def loadRows(self, row_data): """ Load in data and append each data item as a row in Tree. Note: All previous rows in model are removed. :param row_data: text to set for each row. :type row_data: list of str """ self.clear() if row_data is None: return for data in row_data: self.loadRow(data)
[docs] def loadRow(self, row_data): """ Append a single row to the tree model. This method does not clear the model. :param row_data: text to set for row. :type row_data: str """ self.appendRow([QtGui.QStandardItem(row_data)])
def _generateItemTree(self, root, tree): # The tree is sorted (but not grouped) in a case-insensitive manner item_key = lambda x: x.display_name.lower() for branch, sub_tree in _sort_live_report_branches(tree): if isinstance(sub_tree, PathTreeDict): folder_item = QtGui.QStandardItem(branch) root.appendRow([folder_item]) self._generateItemTree(folder_item, sub_tree) else: for ld_item in sorted(sub_tree, key=item_key): ld_tree_item = LDTreeItem(ld_item) root.appendRow([ld_tree_item])
[docs] def flags(self, index): """ Only leaf nodes in the tree model are selectable. """ parent_item_flags = Qt.ItemIsEnabled leave_item_flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable if self.hasChildren(index): return parent_item_flags else: return leave_item_flags
[docs] def findItem(self, item_id, item=None): """ Recursively finds the livereport item under a given QStandardItem that matches the lr_id. If item is None, the search will start from the root of the model :param item_id: Id of the item to be found in the model :type item_id: str :param item: A model item under which the item_id has to be searched. If None, the serach will start from the root item of the model :type item: QStandardItem :return: Returns the matched livereport item or None :rtype: BaseLDTreeItemWrapper """ if not item: return self.findItem(item_id, self.invisibleRootItem()) if isinstance(item, QtGui.QStandardItem) and item.hasChildren(): # item is a QStandardItem which has children for row in range(item.rowCount()): child_item = item.child(row, 0) if not child_item: continue ld_item = self.findItem(item_id, child_item) if ld_item: return ld_item elif isinstance(item, LDTreeItem) and item.ld_item.id == item_id: # item is a LDTreeItem. return item.ld_item
[docs]class EndpointSelectionModel(LDSelectionModel):
[docs] def flags(self, index): """ Prevent user from selecting endpoints with the `ENDPOINT_UNAVAILABLE` text. It indicates that the correct endpoint could not be parsed from LiveDesign. """ if index.data() == ldt.ENDPOINT_UNAVAILABLE: return Qt.NoItemFlags return super(EndpointSelectionModel, self).flags(index)
[docs] def data(self, index, role=Qt.DisplayRole): if role != Qt.ToolTipRole: return super(EndpointSelectionModel, self).data(index, role) if index.data() == ldt.ENDPOINT_UNAVAILABLE: return ( 'The LiveDesign column that describes the endpoint of this' ' assay has been aliased in such a way that the endpoint string' ' cannot be recovered. Please select another endpoint.') return super(EndpointSelectionModel, self).data(index, role)
# ============================================================================= # Live Report Wrapper # =============================================================================
[docs]class BaseLDTreeItemWrapper(object): """ Simple wrapper for storing either a ld_entities.LiveReport.name or ld_entities.Assay.name, and the folder path used to determine its position in the tree. By building a common wrapper for both items, much of the popup tree item code is simplified. """
[docs] def __init__(self, ld_name, ld_id=None, path='', linked_path=None, show_path=False): """ :param ld_name: ld_entities.LiveReport.name or ld_entities.Assay.name :type ld_name: str :param ld_id: LiveReport.id as a unique identifier :type ld_id: str or None :param path: the folder path to determine position in tree. :type path: str :param linked_path: for items duplicated in favorites, the original folder path :type linked_path: str :param show_path: whether to show the linked_path in the display name :type show_path: bool :raise ValueError if no name is given. """ if not ld_name: raise ValueError('No name was given.') self.name = ld_name self.id = ld_id self.folder_path = path self.linked_path = linked_path self.display_name = ld_name # for favorite items, multiple assays with the same name can appear # in a single folder, this helps to distinguish them by showing their # origin in the tree if show_path and linked_path: # replace separator with more user friendly /, even if some of the # names will also have / characters. linked_path_display = linked_path.replace(ldt.SEP, '/') self.display_name += ' ({})'.format(linked_path_display)
# ============================================================================= # Assay / Live Report Tree Views # =============================================================================
[docs]class LDSelectionTreeView(QtWidgets.QTreeView): """ Base class for Selecting an item from a Tree. """ itemSelected = QtCore.pyqtSignal(BaseLDTreeItemWrapper)
[docs] def __init__(self): super(LDSelectionTreeView, self).__init__() self.setMinimumWidth(200) self.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding) self.setHeaderHidden(True) self.setSelectionMode(self.SingleSelection)
[docs] def selectionChanged(self, selected, deselected): """ See Qt QTreeView for more information. """ super(LDSelectionTreeView, self).selectionChanged(selected, deselected) if not selected.indexes(): return index = selected.indexes()[0] model = self.model() source_model = model.sourceModel() item = source_model.itemFromIndex(model.mapToSource(index)) # FIXME: Set top items as global constants top_items = [ 'Project Favorites', 'Computational Models', 'Experimental Assays' ] if item.text() in top_items: return if isinstance(item, LDTreeItem): self.itemSelected.emit(item.ld_item) else: folder_path = self._generatePath(item.parent()) ld_item = BaseLDTreeItemWrapper(item.text(), folder_path) self.itemSelected.emit(ld_item)
def _generatePath(self, item): """ Helper method to generate the folder path for a given tree item. :param item: starting item. :type item: QtGui.QStandardItem :return: the path to the root from this item in tree composed of the item's text and separated by `ld_folder_tree.SEP`. :rtype: str """ folder_path = '' while item: text = item.text() folder_path = ldt.SEP.join([text, folder_path ]) if folder_path else text item = item.parent() return folder_path
[docs] def scrollToItemWithPath(self, full_assay_path): """ Find the item with the given path, scroll to it, and select it. Useful for making the last selected item visible when a cell is clicked again. :param full_assay_path: the full path of the assay including the name :type full_assay_path: str """ path_segments = full_assay_path.split(ldt.SEP) model = self.model() source_model = model.sourceModel() root_item = source_model.invisibleRootItem() for segment in path_segments: for row_num in range(root_item.rowCount()): child_item = root_item.child(row_num) if child_item.text() == segment: root_item = child_item break source_index = source_model.indexFromItem(root_item) model_index = model.mapFromSource(source_index) self.scrollTo(model_index) selection_model = self.selectionModel() selection_model.setCurrentIndex(model_index, selection_model.ClearAndSelect)
# ============================================================================= # Custom Sort Filter Proxy Model # =============================================================================
[docs]class StringSearchFilterProxyModel(QtCore.QSortFilterProxyModel): """ A proxy model to filter a tree model to show both parents and children nodes if they match the regular expression string. """
[docs] def __init__(self, parent=None): super(StringSearchFilterProxyModel, self).__init__(parent) # maintain Qt4 dynamicSortFilter default self.setDynamicSortFilter(False)
[docs] def filterAcceptsRow(self, source_row, source_parent): """ See Qt Documentation for more information on parameters. This filter accepts a particular row if any of the following are true: 1. The index's item's text matches the reg exp. 2. Any of the index's children match the reg exp. 3. Any of the index's parents (up to the root) match the reg exp. Note that the conditions specified above are searched in that order. """ index = self.sourceModel().index(source_row, 0, source_parent) return self.filterAcceptsIndex(index) or self.filterAcceptsParent( index.parent())
[docs] def filterAcceptsIndex(self, index): """ Checks whether this index's item should be accepted by the filter. This DFS method checks if either this index's item's text or any of its children matches the filter reg exp. :param index: the index to filter in or out according to regular exp. :type index: QtCore.QModelIndex :return: whether the index should be accepted by the filter :rtype: bool """ if not self.filterRegularExpression().pattern(): # if the regular expression is empty return True if self._matchText(index): return True source_model = self.sourceModel() rows = source_model.rowCount(index) if rows: for child_row_num in range(rows): child_index = source_model.index(child_row_num, 0, index) if not child_index.isValid(): break if self.filterAcceptsIndex(child_index): return True return False
[docs] def filterAcceptsParent(self, index): """ Checks whether this index's item's text or any of its ancestors matches the filter reg exp. :param index: the index to filter in or out according to regular exp. :type index: QtCore.QModelIndex :return: whether the index should be accepted by the filter :rtype: bool """ if not index.isValid(): return False if self._matchText(index): return True return self.filterAcceptsParent(index.parent())
def _matchText(self, index): """ Helper method to check if the given index's text matches the filter reg exp. The comparison is done in lower case to be case insensitive. :param index: the index to match to regular exp. :type index: QtCore.QModelIndex :return: whether the given index matches the regular exp. :rtype: bool """ item = self.sourceModel().itemFromIndex(index) if item: text = item.text() if self.filterRegularExpression().pattern().lower() in text.lower(): return True
# ============================================================================= # Assay / Live Report / Endpoint PopUps # =============================================================================
[docs]class BaseSelectionPopUp(BaseSearchTreeWidgetHelper, pop_up_widgets.PopUp): """ Base class for a popup used to selecting either an Assay or Live Report - this class only adds the common widgets and aesthetics to confirm both Popups look identical. """
[docs] def setup(self): """ Sets up the model, view, proxy and search box. See BaseSearchTreeWidgetHelper.setUpWidgets for more info. """ model = LDSelectionModel() view = LDSelectionTreeView() self.setUpWidgets(model, view) self.setUpLayout() self.setLayout(self.main_layout)
[docs] def reset(self): self.resetWidgets()
[docs]class BaseLiveReportSelectionPopup(BaseSelectionPopUp): """ A `BaseSelectionPopup` that also has a combo box that can be used to select how Live Report data is organized. :cvar LRSortMethodChanged: signal indicating that a new live report sort method has been chosen; emitted with an `LRSort` value :vartype LRSortMethodChanged: QtCore.pyqtSignal """ LRSortMethodChanged = QtCore.pyqtSignal(LRSort)
[docs] def setUpWidgets(self, model, view): super().setUpWidgets(model, view) # Set up live report sort combo self.sort_lbl = QtWidgets.QLabel('Organize by:') self.sort_combo = LRSortCombo(self) self.sort_combo.LRSortMethodChanged.connect(self.LRSortMethodChanged)
[docs] def setUpLayout(self): super(BaseLiveReportSelectionPopup, self).setUpLayout() sort_layout = QtWidgets.QHBoxLayout() sort_layout.addWidget(self.sort_lbl) sort_layout.addWidget(self.sort_combo) self.main_layout.addLayout(sort_layout)
[docs]class LiveReportSelectionPopup(BaseLiveReportSelectionPopup): """ A popup for selecting Live Reports. Contains a refresh button. """ refreshRequested = QtCore.pyqtSignal() PRE_REFRESH_TEXT = 'Refresh' REFRESHING_TEXT = 'Refreshing...'
[docs] def setUpWidgets(self, model, view): super().setUpWidgets(model, view) self.refresh_btn = QtWidgets.QToolButton(parent=self) self.refresh_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.refresh_btn.setText(self.PRE_REFRESH_TEXT) new_icon = self.style().standardIcon(QtWidgets.QStyle.SP_BrowserReload) self.refresh_btn.setIcon(new_icon) self.refresh_btn.clicked.connect(self.onRefreshClicked)
[docs] def setUpLayout(self): super().setUpLayout() self.search_layout.addWidget(self.refresh_btn) self.search_layout.setContentsMargins(5, 5, 5, 5)
[docs] def onRefreshClicked(self): self.refresh_btn.setText(self.REFRESHING_TEXT) self.refresh_btn.setDisabled(True) # Send off siganl on a timer so button repaints text QtCore.QTimer.singleShot(100, self.refreshRequested.emit)
[docs] def onRefreshCompleted(self): self.refresh_btn.setText(self.PRE_REFRESH_TEXT) self.refresh_btn.setEnabled(True)
[docs]class LiveReportSelectionComboBox(pop_up_widgets.ComboBoxWithPopUp): """ A custom Combo Box to show a Popup (LiveReportSelectionPopup) when the user clicks on the menu arrow. Also provides a "refresh" button to update the list of live reports from the host. :cvar refreshRequested: signal indicating that a refresh was requested from the pop up. :cvar liveReportSelected: signal emitted when a live report has been chosen in the combo box, with an argument of the live report id. :vartype liveReportSelected: `QtCore.pyqtSignal` :cvar LRSortMethodChanged: signal indicating that a new live report sort method has been chosen; emitted with an `LRSort` value :vartype LRSortMethodChanged: QtCore.pyqtSignal """ refreshRequested = QtCore.pyqtSignal() liveReportSelected = QtCore.pyqtSignal(str) LRSortMethodChanged = QtCore.pyqtSignal(LRSort)
[docs] def __init__(self, parent, lr_widget): """ Create an instance. :type parent: `PyQt5.QtWidgets.QWidget` :param parent: the Qt parent widget """ self.lr_widget = lr_widget super().__init__(parent, pop_up_class=LiveReportSelectionPopup) self.addItem(SELECT_TEXT) # Signals self._pop_up.view.itemSelected.connect(self.onLiveReportSelected) self._pop_up.LRSortMethodChanged.connect(self.LRSortMethodChanged) self._pop_up.refreshRequested.connect(self.refreshRequested)
[docs] def reset(self): """ Reset the combo box to initial state. """ self._pop_up.reset() self.setItemText(0, SELECT_TEXT)
[docs] def setData(self, live_reports): """ Load in the live reports to the Tree widget. :param live_reports: live reports to be added. :type live_reports: List of BaseLDTreeItemWrapper """ self._pop_up.model.loadData(live_reports)
[docs] def addNewLiveReport(self): """ Generates a new name for the live report depending on any reports selected in the tree and the current date. Sets value on combo box for a new report. :return: Name of new live report :rtype: str """ date = datetime.now().strftime("%m/%d/%y") name = f'{self.lr_widget.model.ld_destination.proj_name} {date}' self.setItemText(0, NEW_TEXT) self._pop_up.view.clearSelection() self._pop_up.close() return name
[docs] def onLiveReportSelected(self, item): """ Slot connected to tree view's selection. :param item: selected live report item in the tree view. :type item: BaseLDTreeItemWrapper """ self.setItemText(0, item.name) self.liveReportSelected.emit(item.id) self._pop_up.close()
[docs] def setCurrentLR(self, lr_id): """ Sets the current livereport to the item pointed by lr_id :param lr_id: Livereport id :type lr_id: str :return: True if success else False :rtype: bool """ ld_item = self._pop_up.model.findItem(lr_id) if ld_item: self._pop_up.view.itemSelected.emit(ld_item) return True else: return False
[docs] def onRefreshCompleted(self): self._pop_up.onRefreshCompleted()
[docs]class LiveReportSelector(widgetmixins.InitMixin, QtWidgets.QWidget): """ A widget containing a `LiveReportSelectionComboBox` and a create new LR button. :cvar refreshRequested: signals that the user has requested a refresh by clicking on the refresh button :vartype refreshRequested: QtCore.pyqtSignal :cvar liveReportSelected: signal emitted when a live report has been chosen in the combo box, with an argument of the live report id. :vartype liveReportSelected: `QtCore.pyqtSignal` :cvar newLiveReportSelected: signal emitted when a live report has been created, with an argument of the live report id. :vartype newLiveReportSelected: `QtCore.pyqtSignal` :cvar LRSortMethodChanged: signal indicating that a new live report sort method has been chosen; emitted with an `LRSort` value :vartype LRSortMethodChanged: QtCore.pyqtSignal """ refreshRequested = QtCore.pyqtSignal() liveReportSelected = QtCore.pyqtSignal(str) newLiveReportSelected = QtCore.pyqtSignal(str) LRSortMethodChanged = QtCore.pyqtSignal(LRSort)
[docs] def __init__(self, parent, lr_widget, allow_add=True): # Save this data only long enough to pass to combo box self.lr_widget = lr_widget self.allow_add = allow_add super().__init__(parent=parent)
[docs] def initSetUp(self): super().initSetUp() # Set up live report selection combo box self.lr_combo = LiveReportSelectionComboBox(self, self.lr_widget) self.add_new_btn = QtWidgets.QPushButton('Create New', parent=self) self.add_new_btn.clicked.connect(self.addNewLiveReport) self.add_new_btn.setVisible(self.allow_add) del self.lr_widget # Connect signals self.lr_combo.liveReportSelected.connect(self.onLiveReportSelected) self.lr_combo.LRSortMethodChanged.connect(self.LRSortMethodChanged) self.lr_combo.refreshRequested.connect(self.refreshRequested) # Set size policies policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.Fixed) self.setSizePolicy(policy) self.lr_combo.setSizePolicy(policy)
[docs] def initLayOut(self): super().initLayOut() h_layout = QtWidgets.QHBoxLayout() h_layout.addWidget(self.lr_combo) h_layout.addWidget(self.add_new_btn) self.layout().addLayout(h_layout)
[docs] def initSetDefaults(self): super().initSetDefaults() self.add_new_btn.setVisible(self.allow_add) self.lr_combo.reset()
[docs] def setData(self, live_reports): """ Load in the live reports to the Tree widget on the combo box. :param live_reports: live reports to be added. :type live_reports: List of BaseLDTreeItemWrapper """ self.lr_combo.setData(live_reports)
[docs] def setLiveReport(self, live_report_id): """ Set the active live report, refreshing the available list if necessary. :param live_report_id: the live report ID of the desired live report :type live_report_id: str :return: True if success else False :rtype: bool """ status = True if not self.lr_combo.setCurrentLR(live_report_id): self.refreshRequested.emit() # live_report_id could have been archived on LiveDesign meanwhile status = self.lr_combo.setCurrentLR(live_report_id) return status
[docs] def onLiveReportSelected(self, lr_id): self.add_new_btn.hide() self.liveReportSelected.emit(lr_id)
[docs] def onRefreshCompleted(self): self.lr_combo.onRefreshCompleted()
[docs] def addNewLiveReport(self): name = self.lr_combo.addNewLiveReport() self.newLiveReportSelected.emit(name)
[docs] def setComboToSelect(self): """ Sets live report combo to Select mode """ self.lr_combo.setItemText(0, SELECT_TEXT) self.add_new_btn.setVisible(self.allow_add)
[docs]class AddNewLineEdit(QtWidgets.QLineEdit): default_text = ''
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs)
[docs] def focusInEvent(self, e): super(AddNewLineEdit, self).focusInEvent(e) if not self.isModified(): self.setText(self.default_text) QtCore.QTimer.singleShot(0, self.selectAll)
# ============================================================================= # Abstract Delegate # =============================================================================
[docs]class AbstractSelectionPopUp(BaseSelectionPopUp): """ A popup for selecting LiveDesign values. """ newValueSelected = QtCore.pyqtSignal(str) SEARCH_MODE_PAGE = 0 CREATE_MODE_PAGE = 1 SEARCH_TEXT = 'Placeholder search text'
[docs] def setup(self): super().setup() pop_up_ui = QtWidgets.QWidget() form = selection_pop_up_ui.Ui_Form() form.setupUi(pop_up_ui) self.ui = form # Grab layout from parent, use as pop up widget self.pop_up_wdg = QtWidgets.QWidget(self) self.pop_up_wdg.setLayout(self.main_layout) self.add_new_le = AddNewLineEdit(self) self.add_new_le.setPlaceholderText(NEW_NAME) self.ui.selection_sw.addWidget(self.pop_up_wdg) self.ui.selection_sw.addWidget(self.add_new_le) self.main_layout = QtWidgets.QVBoxLayout(self) self.main_layout.setContentsMargins(3, 3, 3, 3) self.main_layout.addWidget(pop_up_ui) # Create button group self.value_btn_group = QtWidgets.QButtonGroup(self) self.value_btn_group.addButton(self.ui.search_btn) self.value_btn_group.addButton(self.ui.create_btn) self.value_btn_group.buttonClicked.connect(self._onValueModeChanged) self.ui.apply_to_rows_btn.setDefault(True) self.ui.apply_to_rows_btn.clicked.connect(self.selectNewValue) self.ui.search_btn.setText(self.SEARCH_TEXT) self._onValueModeChanged()
def _onValueModeChanged(self): """ Show the relevant widgets when search mode is updated. """ search_mode = self.SEARCH_MODE_PAGE if self.ui.search_btn.isChecked( ) else self.CREATE_MODE_PAGE self.ui.selection_sw.setCurrentIndex(search_mode) # Change policy to prepare for adjustSize() for idx in range(self.ui.selection_sw.count()): if idx == self.ui.selection_sw.currentIndex(): policy = QtWidgets.QSizePolicy.Expanding else: policy = QtWidgets.QSizePolicy.Ignored page = self.ui.selection_sw.widget(idx) page.setSizePolicy(policy, policy) QtCore.QTimer.singleShot(0, self.adjustSize)
[docs] def selectNewValue(self): """ Creates a new value to add to the line edit. """ new_value_text = self.add_new_le.text().strip() # use isModified so that stray clicks do not create new assays if self.add_new_le.isModified() and new_value_text: self.newValueSelected.emit(new_value_text) self.close()
[docs] def setNumSelectedRows(self, num_rows): text = f'Apply to Selected Rows ({num_rows})' self.ui.apply_to_rows_btn.setText(text)
[docs] def setDefaultText(self, text): self.add_new_le.setText('') self.add_new_le.default_text = text
[docs]class AbstractSelectionLineEdit(pop_up_widgets.LineEditWithPopUp): """ Custom Line Edit to show a Selection PopUp when the user clicks on the table cell. """ newValueSelected = QtCore.pyqtSignal(str) valueSelected = QtCore.pyqtSignal(str) numSelectedRowsChanged = QtCore.pyqtSignal(int) POPUP_CLS = NotImplemented
[docs] def __init__(self, parent, initial_rows=1): """ :param parent: the Qt parent widget :type parent: `PyQt5.QtWidgets.QWidget` :param initial_rows: Initial rows selected on creation of widget :type initial_rows: int """ super().__init__(parent, pop_up_class=self.POPUP_CLS) self.setReadOnly(True) icon = QtGui.QIcon(icons.MENU_CARET_LB) self.addAction(icon, QtWidgets.QLineEdit.TrailingPosition) # Signals self._pop_up.newValueSelected.connect(self.addNew) self._pop_up.ui.apply_to_rows_btn.clicked.connect( self.onApplyToRowsClicked) self._pop_up.view.itemSelected.connect(self.onValueSelected) self.numSelectedRowsChanged.connect(self._onNumSelectedRows) self._onNumSelectedRows(initial_rows)
[docs] def addNew(self, name): """ Adds the new value name to the table cell and the popup tree. :param name: new value name :type name: str """ self.setText(name) self.newValueSelected.emit(name)
def _onNumSelectedRows(self, num_rows: int): self._pop_up.setNumSelectedRows(num_rows)
[docs] def onApplyToRowsClicked(self): """ Trigger PopUpDelegate.commitDataToSelected signal """ self.popUpClosing.emit(pop_up_widgets.ACCEPT_MULTI)
[docs] def onValueSelected(self, item): """ Slot connected to the view's selection. :param item: selected item in the tree view. :type item: BaseLDTreeItemWrapper """ raise NotImplementedError
[docs] def addData(self, data): """ Add the data value to the line edit. """ raise NotImplementedError
[docs]class AbstractSelectionPopUpDelegate(pop_up_widgets.PopUpDelegate, delegates.AbstractDelegateWithEditIcon): """ Delegate for handling column line edits. :cvar numSelectedRowsChanged: signal emitted when the amount of selected rows are changed. """ numSelectedRowsChanged = QtCore.pyqtSignal(int) EDIT_ICON = icons.MENU_CARET_LB COLUMN_TYPE: ExportTableColumns = NotImplemented POPUP_CLS = NotImplemented LINE_EDIT_CLS = NotImplemented
[docs] def __init__(self, parent): super().__init__(parent, pop_up_class=self.POPUP_CLS, enable_accept_multi=True) self.commitData.connect(self._onCommitData) self.num_rows = 0 self.numSelectedRowsChanged.connect(self._onSelectedRowsChanged)
def _onCommitData(self, editor): """ Apply contents of line edit widget if popup data is committed. """ editor._pop_up.selectNewValue() def _createEditor(self, parent, option, index): le_editor = self.LINE_EDIT_CLS(parent, self.num_rows) self.numSelectedRowsChanged.connect(le_editor.numSelectedRowsChanged) return le_editor def _onSelectedRowsChanged(self, rows: int): self.num_rows = rows
[docs] def paint(self, painter, option, index): super().paint(painter, option, index) self._drawEditIcon(painter, option, index)
# ============================================================================= # Assay Delegate # =============================================================================
[docs]class AssaySelectionPopUp(AbstractSelectionPopUp): """ A popup for selecting Assays. """ SEARCH_TEXT = 'Search model or assay name'
[docs] def setup(self): super().setup() # Setup view interactions self.view.setExpandsOnDoubleClick(False) self.view.clicked.connect(self._onTreeViewClicked) self.view.setFocusPolicy(Qt.TabFocus)
def _onTreeViewClicked(self, model_idx): """ Enables single click opening/closing of QTreeView elements :param model_idx: Model index to expand/collapse :type model_idx: QModelIndex """ self.view.setExpanded(model_idx, not self.view.isExpanded(model_idx))
[docs]class AssaySelectionLineEdit(AbstractSelectionLineEdit): """ Custom Line Edit to show an AssaySelectionPopUp. """ mappingSaved = QtCore.pyqtSignal(list) POPUP_CLS = AssaySelectionPopUp
[docs] def __init__(self, parent, num_rows): super().__init__(parent, num_rows) self.popUpClosing.connect(self.saveMapping) self.assay_path_data = None self.assay_folder_path = None self.new_assay = None self.expanded_items = []
[docs] def addNew(self, name): # overrides: AbstractSelectionLineEdit self.assay_folder_path = 'User Created' self.new_assay = BaseLDTreeItemWrapper(ld_name=self.text(), path=self.assay_folder_path) self.assay_path_data.append(self.new_assay) self.addData(self.assay_path_data) super().addNew(name)
[docs] def addData(self, data): """ Add the assay data to populate the pop up tree model. :param data: the list of tree item wrappers containing the name and path of the assays :type data: List of BaseLDTreeItemWrapper """ self.assay_path_data = data self._pop_up.model.loadData(data)
[docs] def onValueSelected(self, item): """ Slot connected to tree view's selection. :param item: selected Assay item in the tree view. :type item: BaseLDTreeItemWrapper """ assay_name = item.name # the display objects in the tree use the folder path, but the ExportRow # should use the linked path if it exists in order to find the endpoint if item.linked_path is not None: self.assay_folder_path = item.linked_path else: self.assay_folder_path = item.folder_path self.setText(assay_name) self.valueSelected.emit(assay_name)
[docs] def saveMapping(self): """ Saves the state of collapsed and expanded items in the QTreeView """ view = self._pop_up.view model = view.model() self.expanded_items = [] rows = model.rowCount() for row_idx in range(rows): model_index = model.index(row_idx, 0) self.saveExpansionBFS(model_index) self.mappingSaved.emit(self.expanded_items)
[docs] def saveExpansionBFS(self, model_idx: QtCore.QModelIndex): """ Searches for and saves the expanded items of an element and its children. Uses breadth first searching """ view = self._pop_up.view model = view.model() if view.isExpanded(model_idx): self.expanded_items.append(self.getTreePath(model_idx)) for row_idx in range(model.rowCount(model_idx)): child = model_idx.child(row_idx, 0) self.saveExpansionBFS(child)
[docs] def getTreePath(self, model_idx: QtCore.QModelIndex) -> List[str]: """ Gets full path of element at the model index. :: X Y Z tree path of element Z is ['X', 'Y', 'Z'] """ view = self._pop_up.view model = view.model() data = model_idx.data() tree_path = [] while data is not None: tree_path.insert(0, data) parent = model.parent(model_idx) model_idx = parent data = parent.data() return tree_path
[docs] def applyMapping(self, mapping: List[List[str]]): """ Applies the given expansion mapping to elements in the view """ view = self._pop_up.view model = view.model() rows = model.rowCount() for row_idx in range(rows): model_index = model.index(row_idx, 0) self.setExpansionBFS(model_index, mapping)
[docs] def setExpansionBFS(self, model_idx: QtCore.QModelIndex, mapping: List[List[str]]): """ Applies the expansion states from mapping to the element at the given model index """ view = self._pop_up.view model = view.model() idx_path = self.getTreePath(model_idx) if idx_path in mapping: view.expand(model_idx) for row_idx in range(model.rowCount(model_idx)): child = model_idx.child(row_idx, 0) self.setExpansionBFS(child, mapping)
[docs] def getAssayFolderPath(self): """ Return the currently selected assay's folder path. If no assay is selected, None is returned. :return: folder path of currently selected assay. :rtype: str or None """ return self.assay_folder_path
[docs] def setAllAssayData(self, assays_list, assay_name, assay_folder_path): """ Set all the assay data including the list of possible assays, the current (ie last selected) assay name value, and the folder path to the current assay (if exists). This will add the possible assays to the popup's selection view, set the line edit text, store the assay folder path, and scroll to and select the item corresponding to the current selection in the popup. Note that because the assay name and path does not currently store whether the original location was from its original path or the 'Project Favorites' folder, the selection always scrolls to the original location rather than the 'Project Favorites' location. :param assays_list: the list of ld items specifying the assay names and paths :type assays_list: List of BaseLDTreeItemWrapper :param assay_name: the current value of the assay name :type assay_name: str :param assay_folder_path: the current value of the assay folder path :type assay_folder_path: str """ self.addData(assays_list) self.setText(assay_name) self.assay_folder_path = assay_folder_path if assay_folder_path is not None: full_assay_path = ldt.SEP.join([assay_folder_path, assay_name]) self._pop_up.view.scrollToItemWithPath(full_assay_path)
[docs] def getNewAssay(self): """ Return the newly created assay if one exists. :return: the newly created assay :rtype: BaseLDTreeItemWrapper or None """ return self.new_assay
[docs] def setText(self, text): if text != CLICK: self._pop_up.setDefaultText(text) super().setText(text)
[docs]class AssaySelectionPopUpDelegate(AbstractSelectionPopUpDelegate): """ Delegate for handling assay line edits. :cvar newAssaySelected: signal emitted when a new assay is created by user and selected for use. This signal is emitted once the assay data is set in the model, and with the assay index. :vartype newAssaySelected: QtCore.pyqtSignal(QtCore.QModelIndex) :cvar assaySelected: signal emitted when an existing assay is selected and applied. This signal is emitted once the assay data is set in the model, and with the assay index. :vartype assaySelected: QtCore.pyqtSignal(QtCore.QModelIndex) """ newAssaySelected = QtCore.pyqtSignal(QtCore.QModelIndex) assaySelected = QtCore.pyqtSignal(QtCore.QModelIndex) numSelectedRowsChanged = QtCore.pyqtSignal(int) mappingSaved = QtCore.pyqtSignal(list) COLUMN_TYPE = ExportTableColumns.Assay POPUP_CLS = AssaySelectionPopUp LINE_EDIT_CLS = AssaySelectionLineEdit
[docs] def __init__(self, parent): super().__init__(parent) self.new_assay_created = False self.mapping = []
def _createEditor(self, parent, option, index): assay_le = super()._createEditor(parent, option, index) assay_le.newValueSelected.connect(self.onNewAssaySelected) assay_le.valueSelected.connect(self.onAssaySelected) assay_le.mappingSaved.connect(self.setMapping) return assay_le
[docs] def onNewAssaySelected(self): self.new_assay_created = True
[docs] def onAssaySelected(self): self.new_assay_created = False
[docs] def setMapping(self, mapping): self.mapping = mapping
[docs] def setEditorData(self, editor, index): # See Qt documentation assays_list = index.data(role=CustomRole.AssayData) assay_folder_path = index.data(role=CustomRole.AssayFolderPathData) assay_name = index.data() editor.setAllAssayData(assays_list, assay_name, assay_folder_path) if self.mapping: editor.applyMapping(self.mapping)
[docs] def setModelData(self, editor, model, index): # See parent class and Qt documentation # prevent setEditorData from being called via the model.dataChanged # signal with qtutils.suppress_signals(model): new_assay = editor.getNewAssay() if new_assay: # append new assay to table's assay data model.setData(index, new_assay, role=CustomRole.AssayData) value = editor.text() model.setData(index, value) assay_folder_path = editor.getAssayFolderPath() model.setData(index, assay_folder_path, role=CustomRole.AssayFolderPathData) # The signal emission must be on a timer so that draw events that # update the table and hide the popup do not interfere with one # another (PANEL-15156) QtCore.QTimer.singleShot( 0, functools.partial(self._emitSelectionSignal, index))
def _emitSelectionSignal(self, index): """ Emit the appropriate signal indicating that an assay has been selected. :param index: the index of the affected assay table cell :type index: QtCore.QModelIndex """ if self.new_assay_created: self.newAssaySelected.emit(index) else: self.assaySelected.emit(index)
# ============================================================================= # Endpoint Delegate # =============================================================================
[docs]class EndpointSelectionPopUp(AbstractSelectionPopUp): """ A popup for selecting Endpoints. """ SEARCH_TEXT = 'Search property name'
[docs] def setup(self): super().setup() # Assign special model to disable unavailable endpoints self.model = EndpointSelectionModel() self.proxy_model.setSourceModel(self.model)
[docs]class EndpointSelectionLineEdit(AbstractSelectionLineEdit): """ Custom Line Edit to show a PopUp (EndpointSelectionPopUp) when the user double clicks on the table cell. """ POPUP_CLS = EndpointSelectionPopUp
[docs] def addNew(self, name): # overrides: AbstractSelectionLineEdit self._pop_up.model.loadRow(name) super().addNew(name)
[docs] def addData(self, data): """ Add the endpoint data to populate the pop up tree model. :param data: data values that need to be added as rows :type data: List of str """ self._pop_up.model.loadRows(data)
[docs] def onValueSelected(self, item): """ Slot connected to tree view's selection. :param item: selected Endpoint item in the tree view. :type item: BaseLDTreeItemWrapper """ endpoint_name = item.name self.setText(endpoint_name) self.valueSelected.emit(endpoint_name)
[docs]class EndpointSelectionPopUpDelegate(AbstractSelectionPopUpDelegate): """ Delegate for handling endpoint line edit. """ COLUMN_TYPE = ExportTableColumns.Endpoint POPUP_CLS = EndpointSelectionPopUp LINE_EDIT_CLS = EndpointSelectionLineEdit
[docs] def __init__(self, parent): super().__init__(parent) self.endpoint_data = None
def _createEditor(self, parent, option, index): endpoint_le = super()._createEditor(parent, option, index) if index.data(): endpoint_le.setText(index.data()) return endpoint_le
[docs] def setEditorData(self, editor, index): # See Qt documentation value = index.data(role=CustomRole.EndpointData) editor.addData(value)
[docs] def setModelData(self, editor, model, index): # See parent class and Qt documentation # prevent setEditorData from being called via the model.dataChanged # signal with qtutils.suppress_signals(model): value = editor.text() model.setData(index, value)
# ============================================================================= # LD Projects ComboBox # =============================================================================
[docs]class LiveDesignProjectsCombo(QtWidgets.QComboBox): """ This is a standard QComboBox with a few helper methods. :cvar projectSelected: signal for when a any project in the combo box is selected. Emitted with the project name and ID. :vartype projectSelected: `QtCore.pyqtSignal` :cvar placeholderSelected: signal for when the placeholder in the combo box is selected. Emitted with no arguments. :vartype placeholderSelected: `QtCore.pyqtSignal` """ projectSelected = QtCore.pyqtSignal(str, str) placeholderSelected = QtCore.pyqtSignal()
[docs] def __init__(self, parent=None): """ Custom QComboBox for selecting a LiveDesign Project """ super().__init__(parent) self.setDefaults() self.currentIndexChanged.connect(self._checkIfProjectSelected)
[docs] def setDefaults(self): """ Resets the combobox and sets the default placeholder entry. """ self.clear() self.addItem('Select Project...', None)
[docs] def addProjects(self, projects): """ Resets the combobox and adds the new LD projects in alphabetical order, along with the project id as the user data. :param projects: list of LD projects :type projects: [ldclient.models.Project] """ self.setDefaults() # PANEL-8782 sort projects alphabetically by name proj_names = [p.name for p in projects] for proj_name, project in sorted(zip(proj_names, projects)): # must convert project ID to int since it's in unicode self.addItem(proj_name, str(project.id))
def _checkIfProjectSelected(self, index): """ Slot called when selection changes. Emits a projectChanged() signal if a project was indeed selected, otherwise the placeholderSelected() signal will be emitted. :param index: current index selected :type index: int """ if index == -1: # Invalid selection when combobox is empty pass elif self.isPlaceholderItemSelected(): self.placeholderSelected.emit() else: self.projectSelected.emit(self.currentProjectName(), self.currentProjectID())
[docs] def currentProjectName(self): """ Return the current selected project name or None if no project is selected. :return: project name if applicable :rtype: str or None """ if not self.isPlaceholderItemSelected(): return self.currentText()
[docs] def currentProjectID(self): """ Return the current selected project's id. If placeholder item is currently selected, None will be returned. :return: project id if applicable :rtype: str or None """ return self.currentData()
[docs] def isPlaceholderItemSelected(self): """ Returns whether the placeholder text is currently selected. :return: whether the placeholder is selected. :rtype: bool """ return self.currentIndex() == 0
[docs] def selectPlaceholderItem(self): """ Simply set the current item to be the placeholder text. """ self.setCurrentIndex(0)
[docs]def organize_ld_data_tree(ld_data_list): """ Given a list of LD data, organize it for display in the exportable data tree. :param ld_data_list: LD data to organize :type ld_data_list: list(data_classes.LDData) :return: a tuple representing the organized data: each :rtype: collections.OrderedDict(str, list(data_classes.LDData)) """ family_map = collections.defaultdict(list) for ld_data in ld_data_list: family_map[ld_data.family_name].append(ld_data) st_family_names = set( [ldd.family_name for ldd in ld_data_list if ldd.data_name is not None]) other_family_names = set( [ldd.family_name for ldd in ld_data_list if ldd.data_name is None]) # Non-structure property data should be first tree_map = _sort_ld_data_tree(family_map, other_family_names) # Structure property data should be last st_tree_map = _sort_ld_data_tree(family_map, st_family_names) tree_map.update(st_tree_map) return tree_map
def _sort_ld_data_tree(family_map, family_names): """ Return a dictionary mapping each of the family names in `family_names` to (sorted alphabetically) to the corresponding list of `LDData` objects from `family_map`, sorted alphabetically by user name. :param family_map: a dictionary mapping family names to a list of `LDData` objects that share the family name :type family_map: dict[str, list[data_classes.LDData]] :param family_names: a list of family names, which must be a subset of the keys of `family_map` :type family_names: list[str] :return: collections.OrderedDict[str, list[data_classes.LDData]] """ sorted_map = collections.OrderedDict() for family_name in sorted(family_names, key=lambda x: x.lower()): ld_data_list = family_map[family_name] sorted_map[family_name] = sorted( ld_data_list, key=lambda ld_data: ld_data.user_name.lower()) return sorted_map
[docs]def filter_missing_entry(entry, default_value): if entry in (CLICK, ''): return default_value else: return entry
def _sort_live_report_branches(lr_tree): """ Sort live reports by branch name. Live reports without a folder are always at the top of the list; otherwise, sort alphabetically :param lr_tree: dictionary containing tree information :type lr_tree: PathTreeDict :return: properly-ordered list of (branch, sub tree) pairs that can be used to populate the live report tree :rtype: list(tuple(str, list) or tuple(str, PathTreeDict)) """ tree_key = lambda x: (x[0].lower(), x[0]) sorted_tree = sorted(lr_tree.items(), key=tree_key) project_home_item = None for branch, sub_tree in sorted_tree: if branch == NO_FOLDER_NAME: project_home_item = (branch, sub_tree) break if project_home_item is not None: sorted_tree.remove(project_home_item) sorted_tree.insert(0, project_home_item) return sorted_tree