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