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 LRSelectionMode(enum.IntEnum): use_existing = 0 add_new = 1
[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 initFinalize(self): super().initFinalize() self._onLRSelectionModeChanged()
[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()
[docs] def getLiveReportID(self): if self.model: return self.model.ld_destination.lr_id
[docs] def getProjectID(self): if self.model: return self.model.ld_destination.proj_id
[docs] def getProjectName(self): if self.model: return self.model.ld_destination.proj_name
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