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:
2026-08-20 19:54:50 +02:00
co-authored by Claude Fable 5
parent 4e64040d6b
commit 569b024456
6 changed files with 112 additions and 21 deletions
+18 -9
View File
@@ -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:
+34 -3
View File
@@ -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)
+24
View File
@@ -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.
+7 -1
View File
@@ -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)
+20 -2
View File
@@ -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)
+9 -6
View File
@@ -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"