diff --git a/docs/source/upcoming_release_notes/97-restore_functionality.rst b/docs/source/upcoming_release_notes/97-restore_functionality.rst new file mode 100644 index 0000000..85587c4 --- /dev/null +++ b/docs/source/upcoming_release_notes/97-restore_functionality.rst @@ -0,0 +1,24 @@ +97 restore functionality +################# + +API Breaks +---------- +- N/A + +Features +-------- +- add RestorePage table tooltips with PV name, status, and severity +- add working restore button and dialog to RestorePage + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- use signals and slots to coordinate RestorePage table live data display status +- add second linac snapshot, and include in demo config + +Contributors +------------ +- shilorigins diff --git a/superscore/bin/demo.py b/superscore/bin/demo.py index 8948776..5beb96d 100644 --- a/superscore/bin/demo.py +++ b/superscore/bin/demo.py @@ -65,5 +65,5 @@ def main(*args, db_path=None, **kwargs): client.save(entry) if isinstance(entry, (Snapshot, Setpoint, Readback)): filled.append(entry) - with IOCFactory.from_entries(filled)(prefix=''): + with IOCFactory.from_entries(filled, client)(prefix=''): ui_main(*args, client=client, **kwargs) diff --git a/superscore/tests/conftest.py b/superscore/tests/conftest.py index e7fc988..2de7a3e 100644 --- a/superscore/tests/conftest.py +++ b/superscore/tests/conftest.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import List from unittest.mock import MagicMock +from uuid import UUID import pytest @@ -642,6 +643,98 @@ def linac_data(): return all_col, all_snapshot +def comparison_linac_snapshot(): + _, snapshot = linac_data() + snapshot.title = 'AD Comparison' + snapshot.description = ('A snapshot with different values and statuses to compare ' + 'to the "standard" snapshot') + snapshot.uuid = UUID("8e0b1916-912a-457e-8ff9-4478b8018cec") + + lcls_nc_snapshot, facet_snapshot, lcls_sc_snapshot = snapshot.children + lcls_nc_snapshot.uuid = UUID("4e217631-a595-4cdd-b918-a10c54ff8e11") + facet_snapshot.uuid = UUID('ac7f4854-8d3f-4461-9ebf-2321d092657f') + lcls_sc_snapshot.uuid = UUID('8f9d7f91-bd13-4c0d-ac8c-26aa80c72df1') + + in20_snapshot, li21_snapshot, bsy_snapshot = lcls_nc_snapshot.children + in20_snapshot.uuid = UUID('a55281fe-6c20-4ed0-9d73-342b2ec4d1f9') + li21_snapshot.uuid = UUID('a430d9d2-9acb-4c98-be75-d61b674c478f') + bsy_snapshot.uuid = UUID('3a2c72f1-3792-4dba-8133-bd295c222ade') + + in10_snapshot, li10_snapshot = facet_snapshot.children + in10_snapshot.uuid = UUID('04c1cfbf-4f52-49af-a3f8-e637b7ac42c6') + li10_snapshot.uuid = UUID('90255db1-95d6-4b65-9105-b7f09c623354') + + gunb_snapshot, l0b_snapshot, _ = lcls_sc_snapshot.children + gunb_snapshot.uuid = UUID('59767800-60bd-4d1f-85b3-c71731818a4c') + l0b_snapshot.uuid = UUID('41cd90c4-d6d4-44bd-a4e4-04ef5c3920f5') + + lasr_in20_snapshot = in20_snapshot.children[0] + lasr_in20_snapshot.uuid = UUID('769c7df6-e807-407c-b2e3-5c94e09cc1a2') + + vac_li21_snapshot = li21_snapshot.children[0] + vac_li21_snapshot.uuid = UUID('1fc13363-cb6f-48bd-a26f-4d76cc0755eb') + + vac_bsy_snapshot = bsy_snapshot.children[0] + vac_bsy_snapshot.uuid = UUID('11efd7e3-48fb-4f23-a3b5-cc337af2aa1c') + + lasr_in10_snapshot = in10_snapshot.children[0] + lasr_in10_snapshot.uuid = UUID('9fb395b0-a544-4166-9b03-3b839d315b6a') + + vac_li10_snapshot = li10_snapshot.children[0] + vac_li10_snapshot.uuid = UUID('e6bea38f-0799-4771-9a16-814a40ab42ab') + + vac_gunb_snapshot, mgnt_gunb_snapshot, lasr_gunb_snapshot = gunb_snapshot.children + vac_gunb_snapshot.uuid = UUID('8118dcc6-2c9e-4b38-869e-2f0c724de4a8') + mgnt_gunb_snapshot.uuid = UUID('2b18360a-d038-4c80-a3aa-2739cdde7247') + lasr_gunb_snapshot.uuid = UUID('b82da301-8f85-4f62-89c2-e9c16e2e767d') + + vac_l0b_snapshot = l0b_snapshot.children[0] + vac_l0b_snapshot.uuid = UUID('c1d13a88-3dbc-4f40-860b-9f63c793232f') + + lasr_in20_value = lasr_in20_snapshot.children[0] + lasr_in20_value.uuid = UUID('ef321662-f98e-4511-b9b0-6f2d8037c302') + lasr_in20_value.data = -1 + lasr_in20_value.severity = Severity.MAJOR + + vac_li21_setpoint, vac_li21_readback = vac_li21_snapshot.children + vac_li21_setpoint.uuid = UUID('e977f215-a7c9-4caf-8f91-d2783f3e4a88') + vac_li21_setpoint.data = 0.0 + vac_li21_setpoint.severity = Severity.MINOR + vac_li21_readback.uuid = UUID('949a9837-95bd-4ca0-8dad-f478f57143dd') + + vac_bsy_value = vac_bsy_snapshot.children[0] + vac_bsy_value.uuid = UUID('b976bac4-d68b-45b0-a519-e0307a60b052') + vac_bsy_value.data = "lasdjfjasldfj" + + lasr_in10_value = lasr_in10_snapshot.children[0] + lasr_in10_value.uuid = UUID('21bf36a2-002c-49fe-a7c3-eade33d62dfd') + lasr_in10_value.data = 640.68 + + vac_li10_value = vac_li10_snapshot.children[0] + vac_li10_value.uuid = UUID('732cb745-482f-40a7-b83c-d7f2d4ed2305') + vac_li10_value.data = .27 + + vac_gunb_value1, vac_gunb_value2 = vac_gunb_snapshot.children + vac_gunb_value1.uuid = UUID('0e6c4d09-2a77-4ac2-b57a-fc9c049e9063') + vac_gunb_value2.uuid = UUID('d2a45d2b-bb7c-4ccb-a2e3-5e5a44c7dd30') + vac_gunb_value2.data = True + + mgnt_gunb_value = mgnt_gunb_snapshot.children[0] + mgnt_gunb_value.uuid = UUID('61c7ac48-77eb-430c-a86b-52c1267f8ef0') + + lasr_gunb_value1, lasr_gunb_value2 = lasr_gunb_snapshot.children + lasr_gunb_value1.uuid = UUID('4719d31c-62fc-490b-9729-7889f0b79df8') + lasr_gunb_value1.severity = Severity.INVALID + lasr_gunb_value2.uuid = UUID('bced6e63-f4f8-4ab5-9256-66a7da66b160') + + vac_l0b_value = vac_l0b_snapshot.children[0] + vac_l0b_value.uuid = UUID('de169754-cafd-4f38-9f26-cf92039e75d8') + vac_l0b_value.data = -15 + vac_l0b_value.severity = Severity.MINOR + + return snapshot + + @pytest.fixture(scope='function') def linac_backend(): all_col, all_snapshot = linac_data() @@ -812,8 +905,9 @@ def sample_client( return client -@pytest.fixture(scope='module') -def linac_ioc(): +@pytest.fixture +def linac_ioc(linac_backend): _, snapshot = linac_data() - with IOCFactory.from_entries(snapshot.children)(prefix="SCORETEST:") as ioc: + client = Client(backend=linac_backend) + with IOCFactory.from_entries(snapshot.children, client)(prefix="SCORETEST:") as ioc: yield ioc diff --git a/superscore/tests/demo.cfg b/superscore/tests/demo.cfg index 333ecad..c035d02 100644 --- a/superscore/tests/demo.cfg +++ b/superscore/tests/demo.cfg @@ -7,4 +7,4 @@ ca = true pva = true [demo] -fixtures = linac_data +fixtures = linac_data comparison_linac_snapshot diff --git a/superscore/tests/ioc/ioc_factory.py b/superscore/tests/ioc/ioc_factory.py index a9bbd9c..7dd0be9 100644 --- a/superscore/tests/ioc/ioc_factory.py +++ b/superscore/tests/ioc/ioc_factory.py @@ -1,11 +1,12 @@ from multiprocessing import Process -from typing import Iterable, Mapping, Union +from typing import Iterable, Mapping from caproto.server import PVGroup, pvproperty from caproto.server import run as run_ioc from epicscorelibs.ca import dbr -from superscore.model import Entry, Nestable, Parameter, Readback, Setpoint +from superscore.client import Client +from superscore.model import Entry, Readback, Setpoint class TempIOC(PVGroup): @@ -32,36 +33,26 @@ class IOCFactory: Generates TempIOC subclasses bound to a set of PVs. """ @staticmethod - def from_entries(entries: Iterable[Entry], **ioc_options) -> PVGroup: + def from_entries(entries: Iterable[Entry], client: Client, **ioc_options) -> PVGroup: """ Defines and instantiates a TempIOC subclass containing all PVs reachable from entries. """ - attrs = IOCFactory.prepare_attrs(entries) + attrs = IOCFactory.prepare_attrs(entries, client) IOC = type("IOC", (TempIOC,), attrs) return IOC @staticmethod - def collect_pvs(entries: Iterable[Entry]) -> Iterable[Union[Parameter, Setpoint, Readback]]: - """Returns a collection of all PVs reachable from entries""" - pvs = [] - q = entries.copy() - while len(q) > 0: - entry = q.pop() - if isinstance(entry, Nestable): - q.extend(entry.children) - else: - pvs.append(entry) - return pvs - - @staticmethod - def prepare_attrs(entries: Iterable[Entry]) -> Mapping[str, pvproperty]: + def prepare_attrs(entries: Iterable[Entry], client: Client) -> Mapping[str, pvproperty]: """ Turns a collecton of PVs into a Mapping from attribute names to caproto.pvproperties. The mapping is suitable for passing into a type() call as the dict arg. """ - pvs = IOCFactory.collect_pvs(entries) + pvs = [] + for entry in entries: + leaves = client._gather_leaves(entry) + pvs.extend(leaves) attrs = {} for entry in pvs: value = entry.data if isinstance(entry, (Setpoint, Readback)) else None diff --git a/superscore/tests/test_page.py b/superscore/tests/test_page.py index 95aeb33..2e186c2 100644 --- a/superscore/tests/test_page.py +++ b/superscore/tests/test_page.py @@ -1,6 +1,6 @@ """Largely smoke tests for various pages""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from pytestqt.qtbot import QtBot @@ -14,7 +14,7 @@ from superscore.widgets.page.entry import (BaseParameterPage, CollectionPage, ParameterPage, ReadbackPage, SetpointPage, SnapshotPage) -from superscore.widgets.page.restore import RestorePage +from superscore.widgets.page.restore import RestoreDialog, RestorePage from superscore.widgets.page.search import SearchPage @@ -241,3 +241,29 @@ def test_restore_page_toggle_live(qtbot: QtBot, restore_page): toggle_live_button.click() qtbot.waitUntil(lambda: all((tableView.isColumnHidden(column) for column in live_columns))) + + +@patch('superscore.control_layers.core.ControlLayer.put') +def test_restore_dialog_restore( + put_mock, + mock_client: Client, + simple_snapshot: Snapshot, +): + dialog = RestoreDialog(mock_client, simple_snapshot) + dialog.restore() + assert put_mock.call_args.args == mock_client._gather_data(simple_snapshot) + + +def test_restore_dialog_remove_pv(mock_client: Client, simple_snapshot: Snapshot): + dialog = RestoreDialog(mock_client, simple_snapshot) + tableWidget = dialog.tableWidget + assert tableWidget.rowCount() == len(simple_snapshot.children) + + PV_COLUMN = 0 + REMOVE_BUTTON_COLUMN = 2 + item_to_remove = tableWidget.item(1, PV_COLUMN) + tableWidget.setCurrentCell(1, REMOVE_BUTTON_COLUMN) + dialog.delete_row() + assert tableWidget.rowCount() == len(simple_snapshot.children) - 1 + items_left = [tableWidget.item(row, PV_COLUMN) for row in range(tableWidget.rowCount())] + assert item_to_remove not in items_left diff --git a/superscore/ui/restore_dialog.ui b/superscore/ui/restore_dialog.ui new file mode 100644 index 0000000..df50e97 --- /dev/null +++ b/superscore/ui/restore_dialog.ui @@ -0,0 +1,48 @@ + + + Form + + + + 0 + 0 + 382 + 395 + + + + Form + + + + + + PVs to Restore + + + Qt::AlignCenter + + + + + + + + + + Restore + + + + + + + Cancel + + + + + + + + diff --git a/superscore/ui/restore_page.ui b/superscore/ui/restore_page.ui index e4563db..273183e 100644 --- a/superscore/ui/restore_page.ui +++ b/superscore/ui/restore_page.ui @@ -22,27 +22,7 @@ QFrame::Raised - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Compare to Live - - - + @@ -82,6 +62,33 @@ + + + + Compare to Live + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Restore + + + diff --git a/superscore/widgets/page/restore.py b/superscore/widgets/page/restore.py index efc8aa7..b8464b8 100644 --- a/superscore/widgets/page/restore.py +++ b/superscore/widgets/page/restore.py @@ -7,7 +7,7 @@ from qtpy.QtGui import QCloseEvent from superscore.client import Client -from superscore.model import Snapshot +from superscore.model import Setpoint, Snapshot from superscore.widgets.core import Display from superscore.widgets.views import (LivePVHeader, LivePVTableModel, LivePVTableView) @@ -54,6 +54,52 @@ def toggle_live(self): self.set_live(not self._is_live) +class RestoreDialog(Display, QtWidgets.QWidget): + """A dialog for selecting PVs to write to the EPICS system""" + + filename = "restore_dialog.ui" + + cancelButton: QtWidgets.QPushButton + restoreButton: QtWidgets.QPushButton + + tableWidget: QtWidgets.QTableWidget + + def __init__(self, client: Client, snapshot: Snapshot = None): + super().__init__() + self.client = client + if snapshot is None: + self.entries = [] + else: + self.entries = [entry for entry in client._gather_leaves(snapshot) if isinstance(entry, Setpoint)] + + self.tableWidget.setRowCount(len(self.entries)) + self.tableWidget.setColumnCount(3) + for row, entry in enumerate(self.entries): + pv_item = QtWidgets.QTableWidgetItem(entry.pv_name) + self.tableWidget.setItem(row, 0, pv_item) + + value_item = QtWidgets.QTableWidgetItem(str(entry.data)) + value_item.setTextAlignment(QtCore.Qt.AlignCenter) + self.tableWidget.setItem(row, 1, value_item) + + remove_item = QtWidgets.QPushButton("Remove") + remove_item.clicked.connect(self.delete_row) + self.tableWidget.setCellWidget(row, 2, remove_item) + + self.restoreButton.clicked.connect(self.restore) + self.cancelButton.clicked.connect(self.deleteLater) + + def restore(self): + ephemeral_snapshot = Snapshot(children=self.entries) + self.client.apply(ephemeral_snapshot) + self.close() + + def delete_row(self) -> None: + row = self.tableWidget.currentRow() + self.entries.pop(row) + self.tableWidget.removeRow(row) + + class LiveButton(QtWidgets.QPushButton): """A button for toggling the status of live data on a SnapshotTableModel""" labels = ["Compare to Live", "Turn off Live"] @@ -78,6 +124,7 @@ class RestorePage(Display, QtWidgets.QWidget): secondarySnapshotTitle: QtWidgets.QLabel compareLiveButton: QtWidgets.QPushButton compareSnapshotButton: QtWidgets.QPushButton + restoreButton: QtWidgets.QPushButton tableView: SnapshotTableView @@ -110,12 +157,19 @@ def __init__( self.secondarySnapshotLabel.hide() self.secondarySnapshotTitle.hide() + self.restoreButton.clicked.connect(self.launch_dialog) + def set_live(self, is_live: bool): self.secondarySnapshotLabel.setVisible(is_live) self.secondarySnapshotTitle.setText("Live Data") self.secondarySnapshotTitle.setVisible(is_live) self.compareLiveButton.setChecked(is_live) + def launch_dialog(self): + self.dialog = RestoreDialog(self.client, self.snapshot) + self.dialog.restoreButton.clicked.connect(partial(self.tableView.set_live, True)) + self.dialog.show() + def closeEvent(self, a0: QCloseEvent) -> None: logging.debug("Closing SnapshotTableView") self.tableView.close()