feat: admin-gate beam diagnostics states, warn via passive red tip
Beam location, Beamstop alignment and Flux measurement grey out for non-staff in the state strip and the status-bar transitions menu. All "not admin" denials now show as an auto-dismissing QToolTip on a Catppuccin red wash instead of a click-to-close QMessageBox. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,6 +55,7 @@ from PySide6.QtWidgets import (
|
||||
QStackedWidget,
|
||||
QTabWidget,
|
||||
QToolBar,
|
||||
QToolTip,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -102,6 +103,7 @@ from aare.gui.styles import (
|
||||
THEME_FADE_MS,
|
||||
THEME_SUNRISE,
|
||||
THEME_SUNSET,
|
||||
admin_tip_qss,
|
||||
build_app_stylesheet,
|
||||
qcolor,
|
||||
)
|
||||
@@ -349,7 +351,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# The beamline state strip lives in a bottom toolbar row (created
|
||||
# after the docks), not in the left column. Always visible.
|
||||
self.beamline_state_panel = BeamlineStatePanel(parent=self)
|
||||
self.beamline_state_panel = BeamlineStatePanel(staff=self._decoded_token.staff, parent=self)
|
||||
|
||||
# Beamline / Experiment as tabs (like the Dewar samples dock) instead
|
||||
# of two stacked banner groups; the pages keep their banner children.
|
||||
@@ -1473,20 +1475,27 @@ class MainWindow(QMainWindow):
|
||||
row.addWidget(clone)
|
||||
return row
|
||||
|
||||
def _show_admin_tip(self, message: str) -> None:
|
||||
# "Not admin" denials are passive red tips, not QMessageBoxes:
|
||||
# nothing to click away. QToolTip inherits QSS from the widget it is
|
||||
# shown for, so a hidden anchor keeps the red wash off every other
|
||||
# tooltip; styled at show time so it always matches the theme.
|
||||
if not hasattr(self, "_admin_tip_anchor"):
|
||||
self._admin_tip_anchor = QLabel(self)
|
||||
self._admin_tip_anchor.hide()
|
||||
self._admin_tip_anchor.setStyleSheet(admin_tip_qss(self._theme_mode))
|
||||
QToolTip.showText(QCursor.pos(), message, self._admin_tip_anchor)
|
||||
|
||||
def _show_reference_tools_staff_only_popup(self) -> None:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Staff only",
|
||||
"The Auxiliary puck (reference tools) view is available to staff accounts only.",
|
||||
self._show_admin_tip(
|
||||
"The Auxiliary puck (reference tools) view is available to staff accounts only."
|
||||
)
|
||||
|
||||
def _show_beamline_staff_only_popup(self) -> None:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Staff only",
|
||||
self._show_admin_tip(
|
||||
"Beamline configuration is available to staff accounts only.\n"
|
||||
"Log in with a staff account or contact your local contact "
|
||||
"to configure the beamline.",
|
||||
"to configure the beamline."
|
||||
)
|
||||
|
||||
def _show_beamline_tab_gated_popup(self) -> None:
|
||||
|
||||
@@ -14,7 +14,7 @@ from PySide6.QtWidgets import (
|
||||
QToolTip,
|
||||
)
|
||||
|
||||
from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, state_colors
|
||||
from aare.gui.styles import FONT_VALUE, THEME_SUNRISE, admin_tip_qss, state_colors
|
||||
|
||||
# Shortcut transitions from the "Available transitions" menu in
|
||||
# widgets/status_bar.py show_state_menu — these come ON TOP of the one-hop
|
||||
@@ -121,6 +121,16 @@ class BeamlineStatePanel(QFrame):
|
||||
(BeamlineStateEnum.XrayFluorescence, "X-ray fluorescence"),
|
||||
)
|
||||
|
||||
# Beam-optics diagnostic states are admin (staff) only: greyed for
|
||||
# everyone else, with a red warning tip instead of the routes hint.
|
||||
_STAFF_ONLY_STATES: ClassVar[frozenset[BeamlineStateEnum]] = frozenset(
|
||||
{
|
||||
BeamlineStateEnum.BeamLocation,
|
||||
BeamlineStateEnum.BeamstopAlignment,
|
||||
BeamlineStateEnum.FluxMeasurement,
|
||||
}
|
||||
)
|
||||
|
||||
_TOOLTIPS: ClassVar[dict[BeamlineStateEnum, str]] = {
|
||||
BeamlineStateEnum.DewarTransfer: "Dewar transfer mode",
|
||||
BeamlineStateEnum.SampleExchange: "Manual sample exchange mode",
|
||||
@@ -134,10 +144,12 @@ class BeamlineStatePanel(QFrame):
|
||||
BeamlineStateEnum.XrayFluorescence: "X-ray fluorescence mode",
|
||||
}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, staff: bool = False, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("beamlineStatePanel")
|
||||
|
||||
# Fail-closed: callers must opt in to the staff-only states.
|
||||
self._staff = staff
|
||||
self._current_state: BeamlineStateEnum | None = None
|
||||
self._hovered_state: BeamlineStateEnum | None = None
|
||||
self._pending_target_state: BeamlineStateEnum | None = None
|
||||
@@ -152,6 +164,7 @@ class BeamlineStatePanel(QFrame):
|
||||
self._hover_hint_timer.timeout.connect(self._show_hover_hint)
|
||||
|
||||
# Per-theme colors (MainWindow._apply_theme calls set_theme).
|
||||
self._theme = THEME_SUNRISE
|
||||
self._colors = state_colors(THEME_SUNRISE)
|
||||
self._separators: list[QLabel] = []
|
||||
|
||||
@@ -255,7 +268,8 @@ class BeamlineStatePanel(QFrame):
|
||||
return frozenset()
|
||||
# Reachable in one step: the route graph plus the status-bar
|
||||
# shortcut transitions.
|
||||
return frozenset(_GRAPH.get(current, set())) | MENU_TRANSITIONS.get(current, frozenset())
|
||||
targets = frozenset(_GRAPH.get(current, set())) | MENU_TRANSITIONS.get(current, frozenset())
|
||||
return targets if self._staff else targets - self._STAFF_ONLY_STATES
|
||||
|
||||
def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None:
|
||||
self._hovered_state = state
|
||||
@@ -274,6 +288,17 @@ class BeamlineStatePanel(QFrame):
|
||||
self._show_unavailable_hint(state)
|
||||
|
||||
def _show_unavailable_hint(self, state: BeamlineStateEnum) -> None:
|
||||
if not self._staff and state in self._STAFF_ONLY_STATES:
|
||||
# Not a routes problem: the state is admin-gated. Red warning
|
||||
# tip (QSS appended in _apply_highlight), no dialog to close.
|
||||
button = self._buttons[state]
|
||||
QToolTip.showText(
|
||||
QCursor.pos(),
|
||||
f"{state.display_name()} requires admin mode (staff accounts only).",
|
||||
button,
|
||||
button.rect(),
|
||||
)
|
||||
return
|
||||
sources = set(_GRAPH.get(state, set()))
|
||||
sources |= {s for s, targets in MENU_TRANSITIONS.items() if state in targets}
|
||||
sources.discard(state)
|
||||
@@ -327,6 +352,7 @@ class BeamlineStatePanel(QFrame):
|
||||
"""Adopt the theme's state colors (MainWindow._apply_theme calls this
|
||||
on every switch — the colors are painted in code, so the app QSS
|
||||
alone cannot restyle them)."""
|
||||
self._theme = theme
|
||||
self._colors = state_colors(theme)
|
||||
for separator in self._separators:
|
||||
self._style_separator(separator)
|
||||
@@ -405,6 +431,11 @@ class BeamlineStatePanel(QFrame):
|
||||
f" padding: 1px 8px; }}"
|
||||
f" QPushButton:hover {{ color: {color};{hover_underline} }}"
|
||||
)
|
||||
# QToolTip inherits QSS from its widget: red-wash only the
|
||||
# admin-gated buttons' tips, so the current-state tip (a staff
|
||||
# user may have parked the beamline here) stays normal.
|
||||
if not self._staff and state in self._STAFF_ONLY_STATES and not is_current:
|
||||
qss += " " + admin_tip_qss(self._theme)
|
||||
if button.styleSheet() != qss:
|
||||
button.setStyleSheet(qss)
|
||||
|
||||
|
||||
@@ -559,6 +559,30 @@ TOOLTIP_FG = "#263043"
|
||||
DARK_TOOLTIP_BG = "#0e1728" # dusk panel2 — deepest opaque (menus/tooltips)
|
||||
DARK_TOOLTIP_FG = "#e9edf4" # dusk text
|
||||
|
||||
# "Needs admin/staff" warning tips: same passive QToolTip, on a Catppuccin
|
||||
# red wash — a denial reads as a warning without a click-to-close dialog.
|
||||
ADMIN_TIP_BG = "#d20f39" # latte red
|
||||
ADMIN_TIP_FG = "#ffffff"
|
||||
DARK_ADMIN_TIP_BG = "#f38ba8" # mocha red
|
||||
DARK_ADMIN_TIP_FG = "#11111b" # mocha crust
|
||||
|
||||
|
||||
def admin_tip_qss(theme: str) -> str:
|
||||
"""QToolTip rule for admin-only warning tips. Set it on the widget the
|
||||
tip is shown for, never app-wide — QToolTip inherits QSS from that
|
||||
widget, and an app-wide rule would turn every tooltip red."""
|
||||
bg, fg = (
|
||||
(DARK_ADMIN_TIP_BG, DARK_ADMIN_TIP_FG)
|
||||
if theme == THEME_SUNSET
|
||||
else (ADMIN_TIP_BG, ADMIN_TIP_FG)
|
||||
)
|
||||
# Transparent border required, same as the app-wide QToolTip rule.
|
||||
return (
|
||||
f"QToolTip {{ background-color: {bg}; color: {fg};"
|
||||
f" border: 1px solid transparent; padding: 4px 6px; }}"
|
||||
)
|
||||
|
||||
|
||||
# -- Sliders ----------------------------------------------------------------
|
||||
# Own knob instead of PRIMARY: full-saturation button blue was too loud for a
|
||||
# passive fill (illumination panel). Muted slate-blue, tweak freely.
|
||||
|
||||
@@ -539,7 +539,13 @@ class StatusBar(QStatusBar):
|
||||
action_3 = menu.addAction("Dewar transfer")
|
||||
action_3.triggered.connect(self.dl)
|
||||
action_4 = menu.addAction("Beam location")
|
||||
action_4.triggered.connect(self.beam_location)
|
||||
if self._is_staff:
|
||||
action_4.triggered.connect(self.beam_location)
|
||||
else:
|
||||
# Same gate as the state strip: greyed, not hidden, so
|
||||
# non-staff learn the state exists but needs admin mode.
|
||||
action_4.setText("Beam location (admin mode only)")
|
||||
action_4.setEnabled(False)
|
||||
elif self._status.state in [BeamlineStateEnum.Maintenance]:
|
||||
action_2 = menu.addAction("Manual sample exchange")
|
||||
action_2.triggered.connect(self.se)
|
||||
|
||||
@@ -5,8 +5,8 @@ from aare.gui.panels.beamline_state_panel import BeamlineStatePanel
|
||||
from aare.gui.styles import STATE_AVAILABLE, STATE_MSG_ERROR, STATE_MSG_INFO, STATE_UNAVAILABLE
|
||||
|
||||
|
||||
def _panel(qtbot):
|
||||
panel = BeamlineStatePanel()
|
||||
def _panel(qtbot, staff=True):
|
||||
panel = BeamlineStatePanel(staff=staff)
|
||||
qtbot.addWidget(panel)
|
||||
return panel
|
||||
|
||||
@@ -26,6 +26,24 @@ def test_availability_is_union_of_routes_and_menu_shortcuts(qtbot):
|
||||
assert BeamlineStateEnum.XtalSnapshot not in targets # two hops away
|
||||
|
||||
|
||||
def test_non_staff_never_reach_admin_only_states(qtbot):
|
||||
panel = _panel(qtbot, staff=False)
|
||||
panel.set_current_state(BeamlineStateEnum.SampleAlignment)
|
||||
targets = panel._available_targets()
|
||||
assert BeamlineStateEnum.BeamLocation not in targets
|
||||
assert BeamlineStateEnum.BeamstopAlignment not in targets
|
||||
assert BeamlineStateEnum.FluxMeasurement not in targets
|
||||
assert BeamlineStateEnum.DataCollection in targets # non-admin route stays
|
||||
# Greyed like unreachable states, and the warning tip carries the red
|
||||
# QToolTip wash while a normal-hint button does not.
|
||||
gated = panel._buttons[BeamlineStateEnum.BeamLocation]
|
||||
assert gated.cursor().shape() == Qt.CursorShape.ForbiddenCursor
|
||||
assert "QToolTip" in gated.styleSheet()
|
||||
assert "QToolTip" not in panel._buttons[BeamlineStateEnum.XtalSnapshot].styleSheet()
|
||||
with qtbot.assertNotEmitted(panel.beam_location):
|
||||
panel._emit_for_state(BeamlineStateEnum.BeamLocation)
|
||||
|
||||
|
||||
def test_no_targets_while_moving_or_unknown(qtbot):
|
||||
panel = _panel(qtbot)
|
||||
panel.set_current_state(None)
|
||||
|
||||
@@ -578,21 +578,24 @@ def test_nonstaff_beamline_gate_popups(qtbot, mock_ui_state):
|
||||
assert titles == ["Beamline setup", "ABR meas. pos.", "Beam configuration"]
|
||||
assert not hasattr(win, "monochromator_panel")
|
||||
|
||||
with patch("aare.gui.main_window.QMessageBox") as popup:
|
||||
# Staff-gate denials are passive red tips now, not QMessageBoxes.
|
||||
with patch("aare.gui.main_window.QToolTip") as tip:
|
||||
qtbot.mousePress(win._locked_beamline_banners[0], Qt.MouseButton.LeftButton)
|
||||
assert popup.information.called, "banner click must explain the staff gate"
|
||||
assert tip.showText.called, "banner click must explain the staff gate"
|
||||
# The tip anchor carries the red warning wash (per-widget QToolTip QSS).
|
||||
assert "QToolTip" in win._admin_tip_anchor.styleSheet()
|
||||
|
||||
# Active pgroup outside the token disables the whole Beamline tab;
|
||||
# a click on the disabled tab must explain itself, not vanish.
|
||||
win._apply_pgroup_gate("p999")
|
||||
assert not win.left_column_tabs.isTabEnabled(0)
|
||||
bar = win.left_column_tabs.tabBar()
|
||||
with patch("aare.gui.main_window.QMessageBox") as popup:
|
||||
with patch("aare.gui.main_window.QToolTip") as tip:
|
||||
qtbot.mousePress(bar, Qt.MouseButton.LeftButton, pos=bar.tabRect(0).center())
|
||||
assert popup.information.called, "gated tab click must explain the gate"
|
||||
assert tip.showText.called, "gated tab click must explain the gate"
|
||||
|
||||
# The greyed-out Auxiliary-puck tab explains itself the same way.
|
||||
aux_bar = win.sample_lists_tabs.tabBar()
|
||||
with patch("aare.gui.main_window.QMessageBox") as popup:
|
||||
with patch("aare.gui.main_window.QToolTip") as tip:
|
||||
qtbot.mousePress(aux_bar, Qt.MouseButton.LeftButton, pos=aux_bar.tabRect(1).center())
|
||||
assert popup.information.called, "aux-puck tab click must explain the lock"
|
||||
assert tip.showText.called, "aux-puck tab click must explain the lock"
|
||||
|
||||
Reference in New Issue
Block a user