Source code for schrodinger.application.livedesign.live_report_widget
import enum
import re
from requests.exceptions import HTTPError
from schrodinger.models import mappers
from schrodinger.models import parameters
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt import mapperwidgets
from schrodinger.ui.qt import swidgets
from schrodinger.ui.qt.basewidgets import BaseWidget
from schrodinger.ui.qt.recent_completer import RecentCompleter
from schrodinger.ui.qt.standard.icons import icons
from . import export_models
from . import live_report_widget_ui
from . import panel_components
from .login import GLOBAL_PROJECT_ID
RefreshResult = export_models.RefreshResult
URL_PATH_RE = re.compile('/livedesign/#/projects/([0-9]+)/livereports/([0-9]+)')
LRSort = panel_components.LRSort
DEFAULT_LR_SORT = panel_components.DEFAULT_LR_SORT
NO_FOLDER_NAME = panel_components.NO_FOLDER_NAME
NONE_SELECTED = 'None Selected'
[docs]class LiveReportType(enum.Enum):
"""
Enumerate the different LD LiveReport types.
"""
COMPOUND = 'compound'
REACTANT = 'reactant'
DEVICE = 'device'
def __str__(self):
return self.value
[docs]class LRInputType(enum.IntEnum):
"""
Enum class corresponding to the options that the user has for selecting
a live report. The values assigned to each option corresponds to its index
in the input type combo box.
"""
title = 0
id_or_url = 1
# Define text to display for `LRInputType` options
LR_INPUT_TYPE_TUPLES = [
(LRInputType.title, 'Title'),
(LRInputType.id_or_url, 'LiveReport ID or URL')
] # yapf: disable
[docs]class SwallowEnterFilter(QtCore.QObject):
[docs] def eventFilter(self, widget, event):
"""
Swallow certain key presses so that if the user presses "Return" or
"Enter" while `widget` is in focus, the only result will be that
`widget` loses focus, and the key press event will not be propagated.
:param widget: the widget being watched by this event filter
:type widget: `QtWidgets.QWidget`
:param event: an event
:type event: `QtCore.QEvent`
:return: `True` if the event should be ignored, `False` otherwise
:rtype: `bool`
"""
if (event.type() == QtCore.QEvent.KeyPress and
event.key() in (Qt.Key_Return, Qt.Key_Enter)):
widget.clearFocus()
return True
return False
[docs]class LiveReportModel(export_models.LDClientModelMixin,
parameters.CompoundParam):
ld_client: object
ld_destination: export_models.LDDestination
lr_user_text: str
previous_lr_user_text: str
lr_sort_method: LRSort = panel_components.DEFAULT_LR_SORT
lr_input_type: LRInputType
lr_selection_mode: LRSelectionMode
[docs]class LiveReportWidget(mappers.MapperMixin, BaseWidget):
"""
Compound widget for allowing the user to specify a live report by either
1. Selecting a project title and live report title (or creating a new
live report)
2. Specifying a live report URL
3. Specifying a live report ID
:ivar refreshRequested: a signal propagated from the live report selector
widget indicating that the user wants the list of available live reports
to be refreshed
:vartype refreshRequested: QtCore.pyqtSignal
:ivar projectSelected: a signal emitted when the target project changes
:vartype projectSelected: QtCore.pyqtSignal
:ivar liveReportSelected: a signal propagated from the live report selector
widget indicating that the user has selected an existing live report.
Includes the live report's ID as its argument.
:vartype liveReportSelected: QtCore.pyqtSignal
:ivar newLiveReportSelected: a signal propagated from the live report
selector widget indicating that the user has decided to create a new
live report. Includes the new live report's name as its argument.
:vartype newLiveReportSelected: QtCore.pyqtSignal
:ivar liveReportDefaultsApplied: a signal indicating that the current live
report data has been cleared and replaced by default values.
:vartype liveReportDefaultsApplied: QtCore.pyqtSignal
"""
SHOW_AS_WINDOW = False # ExecutionMixin
model_class = LiveReportModel # MapperMixin
ui_module = live_report_widget_ui # InitMixin
disconnected = QtCore.pyqtSignal()
refreshRequested = QtCore.pyqtSignal()
projectSelected = QtCore.pyqtSignal(str, str)
liveReportSelected = QtCore.pyqtSignal(str)
newLiveReportSelected = QtCore.pyqtSignal(str)
liveReportDefaultsApplied = QtCore.pyqtSignal()
[docs] def __init__(self, parent=None, allow_add_live_reports=False):
self.allow_add_live_reports = allow_add_live_reports
super().__init__(parent=parent)
[docs] def initSetUp(self):
super().initSetUp()
self._setUpSelectByTitleWidgets()
self._setUpSelectByIDOrURLWidgets()
self.ui.ld_input_stack.setEnum(LRInputType)
self.ui.lr_load_url_tb.clicked.connect(self._onLiveReportURLLoad)
self.ui.lr_load_url_tb.setIcon(QtGui.QIcon(icons.OK_LB))
self.choose_project_grp = mapperwidgets.MappableButtonGroup({
self.ui.lr_choose_project_title_rb: LRInputType.title,
self.ui.lr_choose_project_link_rb: LRInputType.id_or_url,
})
self.ui.lr_text_le.setPlaceholderText('Enter URL or ID')
self.info_btn = swidgets.InfoButton()
self.ui.paste_url_layout.setContentsMargins(0, 0, 0, 0)
self.ui.paste_url_layout.setSpacing(0)
tip = (
'<b>URL</b> should include "https://" <br />'
'<b>ID</b> is the last group of digits in the URL. (E.g. if the URL is '
'<i>https://...projects/1234/livereports/56789,</i> then the ID is 56789)'
)
self.info_btn.setToolTip(tip)
self.ui.paste_url_layout.insertWidget(1, self.info_btn)
self.ui.new_lr_cancel_btn.clicked.connect(self._onNewLiveReportCanceled)
[docs] def initLayOut(self):
super().initLayOut()
self.ui.ld_project_choose_layout.addWidget(self.ld_project_combo)
self.ui.lr_combo_layout.addWidget(self.lr_selector)
[docs] def initSetDefaults(self):
"""
Meant to be called when switching between LD input combo box options.
Resets data entered into the panel so that the source of the LiveReport
will be unambiguous.
"""
super().initSetDefaults()
self.clearProject()
[docs] def defineMappings(self):
M = self.model_class
ui = self.ui
lr_name_target = mappers.TargetSpec(ui.lr_text_le,
slot=self._updateLoadLRButton)
project_state_target = mappers.TargetSpec(
ui.project_state_lbl, setter=self._setProjectStateLabel)
lr_state_target = mappers.TargetSpec(ui.lr_state_lbl,
setter=self._setLRStateLabel)
return super().defineMappings() + [
(project_state_target, M.ld_destination.proj_name),
(ui.new_live_report_le, M.ld_destination.lr_name),
(lr_name_target, M.lr_user_text),
(lr_state_target, M.ld_destination.lr_name),
(self.choose_project_grp, M.lr_input_type),
(ui.ld_input_stack, M.lr_input_type),
]
[docs] def getSignalsAndSlots(self, model):
return super().getSignalsAndSlots(model) + [
(model.ld_destination.proj_nameChanged, self._onProjectNameChanged),
(model.lr_sort_methodChanged, self.refreshLiveReportSelector),
(model.lr_input_typeChanged, self.clearProject),
(model.lr_selection_modeChanged, self._onLRSelectionModeChanged),
(model.ld_clientChanged, self._onLDClientChanged),
] # yapf: disable
def _setUpSelectByTitleWidgets(self):
"""
Create, insert, and connect widgets associated with the "select by
title" part of the live report selection stack widget.
"""
# Project combo box
self.ld_project_combo = panel_components.LiveDesignProjectsCombo()
self.ld_project_combo.projectSelected.connect(self.onProjectSelected)
self.ld_project_combo.placeholderSelected.connect(self.clearProject)
# Live report combo box
lr_selector = panel_components.LiveReportSelector(
self, self, allow_add=self.allow_add_live_reports)
lr_selector.setEnabled(False)
lr_selector.refreshRequested.connect(self.refreshLiveReportSelector)
lr_selector.refreshRequested.connect(self.refreshRequested)
lr_selector.liveReportSelected.connect(self._onLiveReportSelected)
lr_selector.newLiveReportSelected.connect(self._onNewLiveReportSelected)
lr_selector.LRSortMethodChanged.connect(self.onLRSortMethodChanged)
self.lr_selector = lr_selector
def _setUpSelectByIDOrURLWidgets(self):
"""
Prepare the line edit in the "select by live report ID or URL" part of
the live report selection stack widget.
"""
# Install an event filter on the URL line edit to prevent the import
# button from being pressed when the user presses "Enter" after typing
# their live report specifier.
le = self.ui.lr_text_le
lr_text_filter = SwallowEnterFilter(parent=le)
prefkey = self.parent().__class__.__name__
completer = RecentCompleter(parent=le, prefkey=prefkey)
le.setCompleter(completer)
le.installEventFilter(lr_text_filter)
def _updateLoadLRButton(self):
enable = bool(self.model.lr_user_text)
self.ui.lr_load_url_tb.setEnabled(enable)
[docs] def setLiveReport(self, lr_id):
"""
Set the active live report.
:param lr_id: the ID of the desired live report
:type lr_id: str
"""
self.lr_selector.setLiveReport(lr_id)
[docs] def clearProject(self):
"""
Clear widget state related to the selected project.
"""
if self.model:
M = self.model_class
ld_dest = M.ld_destination
params_to_reset = [
ld_dest.proj_id, ld_dest.proj_name, ld_dest.lr_id,
ld_dest.lr_name, M.lr_user_text, M.previous_lr_user_text,
M.lr_selection_mode
]
self.model.reset(*params_to_reset)
self.ld_project_combo.selectPlaceholderItem()
self.setLiveReportDefaults()
self._updateLoadLRButton()
[docs] def setLiveReportDefaults(self):
"""
Reset current live report selection and anything that depends on it.
"""
ld_dest = self.model_class.ld_destination
self.model.reset(ld_dest.lr_name, ld_dest.lr_id)
self.lr_selector.setEnabled(False)
self.lr_selector.initSetDefaults()
self.model.lr_selection_mode = LRSelectionMode.use_existing
title_mode_enabled = self.model.lr_input_type == LRInputType.title
self.ld_project_combo.setEnabled(title_mode_enabled)
self.ui.lr_text_le.setEnabled(not title_mode_enabled)
self.ui.lr_load_url_tb.setEnabled(not title_mode_enabled)
self.liveReportDefaultsApplied.emit()
def _onLiveReportURLLoad(self):
"""
Parse data from the live report line edit and import the specified live
report column names.
Meant to be called automatically when the user finishes editing in the
live report line edit.
"""
model = self.model
lr_text = model.lr_user_text.strip()
if not lr_text or lr_text == model.previous_lr_user_text:
# If the live report text is blank or stale, do nothing
return
model.previous_lr_user_text = lr_text
# Parse the specified project and live report IDs and attempt to find
# the specified live report.
proj_id, lr_id = self.evaluateLiveReportText()
if None in (proj_id, lr_id):
self.clearProject()
return
if self.model.refreshLDClient() == RefreshResult.failure:
self.disconnected.emit()
live_report = None
else:
live_report = self._getLiveReport(lr_id)
if live_report is None:
self.clearProject()
return
# Update project name from project ID
ld_dest = model.ld_destination
ld_dest.proj_id = proj_id
ld_dest.proj_name = self._getProjectNameFromProjectID(proj_id)
# Import column names and LR title
ld_dest.lr_name = live_report.title
ld_dest.lr_id = lr_id
self.liveReportSelected.emit(lr_id)
# Add the text to the list of suggestions for the line edit
completer = self.ui.lr_text_le.completer()
completer.addSuggestion(lr_text)
def _getLiveReport(self, lr_id):
"""
For a given live report ID, return the associated live report object
on the LiveDesign server. If no such live report is found, present the
user with a warning dialog.
:param lr_id: a live report ID. Valid IDs are string representations of
nonnegative integers
:type lr_id: str
:return: the associated live report, if possible
:rtype: ldclient.models.LiveReport or None
"""
if self.model.refreshLDClient() == RefreshResult.failure:
self.disconnected.emit()
return
try:
live_report = self.model.ld_client.live_report(lr_id)
except HTTPError:
live_report = None
host = self.model.ld_destination.host
msg = (f'Could not find the specified live report ({lr_id}) on the'
f' host ({host}).')
self.warning(msg)
return live_report
[docs] def evaluateLiveReportText(self):
"""
Evaluate the text in the live report line edit and return the associated
project and live report IDs, if possible.
:return: a 2-tuple containing the project and live report IDs, if they
can be found
:rtype: tuple(str, str) or tuple(None, None)
"""
host = self.model.ld_destination.host
lr_text = self.model.lr_user_text
if lr_text.isdigit():
proj_id, lr_id = self._getIDsFromLiveReportID(lr_text)
elif lr_text.startswith(host):
proj_id, lr_id = self._getIDsFromLiveReportURL(lr_text)
else:
if lr_text:
msg = (f'Must enter a live report URL or a live report ID from'
f' this server: {host}.')
self.warning(msg)
return None, None
if proj_id is None and lr_id is None:
return None, None
# Verify that the user has access to the specified project by searching
# available live reports to find the project name
proj_name = self._getProjectNameFromProjectID(proj_id)
if proj_name is not None:
return proj_id, lr_id
if proj_id == GLOBAL_PROJECT_ID:
msg = 'Only admin users have access to the Global project.'
else:
msg = 'Project not found.'
self.warning(msg)
return None, None
def _getProjectNameFromProjectID(self, proj_id):
"""
Given a LiveDesign project ID, return the corresponding project name if
that project can be found, and if the user has permission to edit it.
:param proj_id: a project ID
:type proj_id: str
:return: a project name, if possible
:rtype: str or None
"""
if proj_id is None:
return None
for project in self.model.ld_client.projects():
if project.id == proj_id:
return project.name
def _getIDsFromLiveReportID(self, lr_id):
"""
Evaluate the supplied live report ID, and return it along with the
associated project ID.
:param lr_id: a live report ID
:type lr_id: str
:return: a 2-tuple containing the project and live report IDs, if they
can be found
:rtype: tuple(str, str) or tuple(None, None)
"""
live_report = self._getLiveReport(lr_id)
if live_report is None:
return None, None
return live_report.project_id, lr_id
def _getIDsFromLiveReportURL(self, lr_url):
"""
Parse the supplied URL and extract information about the associated
live report. If it is valid, return the project ID and the LR ID.
:param lr_id: a live report ID
:type lr_id: str
:return: a 2-tuple containing the project and live report IDs, if they
can be found
:rtype: tuple(str, str) or tuple(None, None)
"""
host = self.model.ld_destination.host
path = lr_url[len(host):]
match = URL_PATH_RE.match(path)
if match is None:
msg = 'The Live Design path cannot be parsed from the supplied URL.'
self.warning(msg)
return None, None
return match.group(1, 2)
[docs] def refresh(self):
"""
If the user previously closed the panel with a live report ID or URL
specified, re-evaluate the text to extract its live report information.
"""
if self.model.lr_input_type == LRInputType.id_or_url:
self._onLiveReportURLLoad()
[docs] def onProjectSelected(self, proj_name, proj_id):
"""
Slot invoked when a project is chosen from combobox.
:param proj_name: the selected project name
:type proj_name: str
:param proj_name: the selected project ID
:type proj_name: str
"""
if self.model.refreshLDClient() != RefreshResult.none:
proj_id = ''
proj_name = ''
self.setLiveReportDefaults()
self.model.ld_destination.proj_id = proj_id
self.model.ld_destination.proj_name = proj_name
[docs] def refreshLiveReportSelector(self):
"""
Refresh project data based on panel state.
"""
if self.model.refreshLDClient() == RefreshResult.failure:
self.disconnected.emit()
project_selected = False
else:
project_selected = bool(self.model.ld_destination.proj_name)
self.lr_selector.setEnabled(project_selected)
if project_selected:
self.loadLiveReports()
self.lr_selector.onRefreshCompleted()
[docs] def loadLiveReports(self):
"""
Fetch the project's live reports and load them into the combo box.
Note that template and device LRs are filtered out for LD v8.3+.
"""
model = self.model
ld_client = model.ld_client
proj_id = model.ld_destination.proj_id
live_reports = ld_client.live_reports(project_ids=[proj_id])
lr_sort = model.lr_sort_method
if lr_sort is LRSort.Folder:
folder_map = {
fld.id: fld.name for fld in ld_client.list_folders([proj_id])
}
live_report_data = []
for lr in filter(lr_filter, live_reports):
if not lr.title or (lr_sort is LRSort.Owner and not lr.owner):
continue # some of the LRs in val server
if lr_sort is LRSort.Owner:
folder_name = lr.owner
elif lr_sort is LRSort.Folder and lr.tags:
folder_name = folder_map[lr.tags[0]]
else:
folder_name = NO_FOLDER_NAME
live_report_data.append(
panel_components.BaseLDTreeItemWrapper(ld_name=lr.title,
ld_id=lr.id,
path=folder_name))
self.lr_selector.setData(live_report_data)
[docs] def onLRSortMethodChanged(self, sort_value):
"""
Store the selected sort method in response to the user selecting a new
LiveReport sort method.
:param sort_value: an enum value associated with a sort method
:type sort_value: panel_components.LRSort
"""
self.model.lr_sort_method = sort_value
def _onLiveReportSelected(self, lr_id):
"""
Respond to an extant live report selection in the LR selector.
:param lr_id: the ID of the selected live report
:type lr_id: str
"""
if self.model.refreshLDClient() == RefreshResult.failure:
self.disconnected.emit()
self.clearProject()
return
live_report = self.model.ld_client.live_report(lr_id)
self.model.lr_selection_mode = LRSelectionMode.use_existing
self.model.ld_destination.lr_name = live_report.title
self.model.ld_destination.lr_id = lr_id
self.liveReportSelected.emit(lr_id)
def _onNewLiveReportSelected(self, lr_name):
"""
Respond to the user specifying a new live report in the LR selector.
:param lr_name: the name of the new live report
:type lr_name: str
"""
self.model.ld_destination.lr_name = lr_name
self.model.reset(self.model_class.ld_destination.lr_id)
self.model.lr_selection_mode = LRSelectionMode.add_new
self.newLiveReportSelected.emit(lr_name)
def _onProjectNameChanged(self):
"""
Respond when the project name is changed.
"""
model = self.model
ld_dest = model.ld_destination
self.projectSelected.emit(ld_dest.proj_name, ld_dest.proj_id)
self.refreshLiveReportSelector()
if ld_dest.lr_id and self.model.ld_client:
live_report = self.model.ld_client.live_report(ld_dest.lr_id)
if live_report.project_id != ld_dest.proj_id:
model.reset(self.model_class.ld_destination.lr_id)
model.reset(self.model_class.ld_destination.lr_name)
def _onLRSelectionModeChanged(self):
"""
Respond to the live report selection mode changing.
"""
visible = self.model.lr_selection_mode == LRSelectionMode.add_new
self.ui.new_live_report_le.setVisible(visible)
self.ui.new_lr_cancel_btn.setVisible(visible)
self.ui.new_lr_lbl.setVisible(visible)
if self.allow_add_live_reports:
self.lr_selector.add_new_btn.setVisible(not visible)
# Repaint explicitly, as `update()` doesn't fully update the widget
# appearance
self.repaint()
def _onLDClientChanged(self):
"""
Respond to a change in the LD client by re-populating the LR project
combo box.
"""
self.ld_project_combo.setDefaults()
ld_client = self.model.ld_client
if ld_client is not None:
projects = ld_client.projects()
self.ld_project_combo.addProjects(projects)
def _onNewLiveReportCanceled(self):
"""
Occurs on canceling the new live report workflow
"""
self.lr_selector.setComboToSelect()
self.model.lr_selection_mode = LRSelectionMode.use_existing
def _setStateLabel(self, label, value):
"""
Sets the bolded text value on the given state label
:param label: Label to change
:type label: QLabel
:param value: Value to set text to
:type value: str or None
"""
if value:
text = f'<b>{value}</b>'
enabled = True
else:
text = NONE_SELECTED
enabled = False
label.setText(text)
label.setEnabled(enabled)
def _setProjectStateLabel(self, value):
"""
Setter function for project state label
"""
self._setStateLabel(self.ui.project_state_lbl, value)
def _setLRStateLabel(self, value):
"""
Setter function for live report label
"""
self._setStateLabel(self.ui.lr_state_lbl, value)
[docs]def lr_filter(lr):
"""
Filter live reports that do not meet desired criteria.
This includes LRs from LD v8.3+ that have a `type` attribute
marking them as devices.
:param lr: a LiveReport
:type lr: models.LiveReport
:return: whether the LiveReport should be presented to the user
:rtype: bool
"""
if hasattr(lr, 'type') and lr.type == LiveReportType.DEVICE:
return False
return not lr.template