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
# =============================================================================
# =============================================================================
# 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)
# =============================================================================
# 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 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
# =============================================================================
# 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 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 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
# =============================================================================
# Assay Delegate
# =============================================================================
[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)
# =============================================================================
# Endpoint Delegate
# =============================================================================
[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)
# =============================================================================
# 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