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()