diff --git a/src/aare/common/auth_models.py b/src/aare/common/auth_models.py index 6897751c..9930e7cd 100644 --- a/src/aare/common/auth_models.py +++ b/src/aare/common/auth_models.py @@ -48,4 +48,5 @@ class BatonStatus(BaseModel): queued_transfer: BatonTransferQueue | None = None you_are_holder: bool = False you_have_pending_request: bool = False - incoming_request: bool = False # True if YOU are being asked to give up baton \ No newline at end of file + incoming_request: bool = False + allow_non_staff_request: bool = False \ No newline at end of file diff --git a/src/aare/daq/auth.py b/src/aare/daq/auth.py index 5e946fd0..ac9b6601 100644 --- a/src/aare/daq/auth.py +++ b/src/aare/daq/auth.py @@ -117,7 +117,13 @@ def check_jwt_staff(cfg: BeamlineConfig, data: TokenData) -> None: ) from e def force_current_sesion(cfg: BeamlineConfig, data: TokenData) -> None: - cfg.force_set_active_session(data.session, SESSION_EXPIRE_SECONDS) + cfg.execute_baton_transfer( + to_session=data.session, + to_username=data.sub, + to_is_staff=data.staff, + to_pgroup=cfg.pgroup, + expiry_sec=SESSION_EXPIRE_SECONDS + ) def _finalize_expired_baton_request(cfg: BeamlineConfig, pending: BatonRequest) -> BatonStatus: """ @@ -172,48 +178,43 @@ def resolve_baton_timeout_if_needed(cfg: BeamlineConfig) -> BatonStatus | None: return _finalize_expired_baton_request(cfg, pending) def get_baton_status(cfg: BeamlineConfig, data: TokenData) -> BatonStatus: - """Get full baton status for a user.""" + """ + Build baton status scoped to the requesting session. + + Important: + - requester sees you_have_pending_request + - holder sees incoming_request + - nobody else sees the request as actionable + """ + holder = cfg.baton_holder pending = cfg.pending_baton_request - if pending is not None and pending.status == BatonRequestStatus.PENDING: - elapsed = time.time() - pending.created_at - if elapsed >= pending.timeout_seconds: - resolve_baton_timeout_if_needed(cfg) - pending = cfg.pending_baton_request - - holder = cfg.baton_holder - queued = cfg.queued_baton_transfer - - you_are_holder = holder is not None and holder.session == data.session - you_have_pending_request = ( - pending is not None and - pending.requester_session == data.session and - pending.status == BatonRequestStatus.PENDING + is_requester = bool( + pending + and pending.status in (BatonRequestStatus.PENDING, BatonRequestStatus.REFUSED) + and pending.requester_session == data.session ) - incoming_request = ( - pending is not None and - holder is not None and - holder.session == data.session and - pending.status == BatonRequestStatus.PENDING + + is_holder = bool( + pending + and pending.status == BatonRequestStatus.PENDING + and pending.holder_session == data.session ) + # Only expose the pending request object to the two relevant sessions + scoped_pending = pending if (is_requester or is_holder) else None + return BatonStatus( holder=holder, - pending_request=pending, - queued_transfer=queued, - you_are_holder=you_are_holder, - you_have_pending_request=you_have_pending_request, - incoming_request=incoming_request + pending_request=scoped_pending, + queued_transfer=cfg.queued_baton_transfer, + you_are_holder=bool(holder and holder.session == data.session), + you_have_pending_request=is_requester, + incoming_request=is_holder, + allow_non_staff_request=cfg.allow_non_staff_request_from_staff, ) - def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: - """ - Request the baton (control) of the beamline. - - This should not depend on active p-group membership at the server route level. - Baton ownership rules are handled here. - """ resolve_baton_timeout_if_needed(cfg) session_state = cfg.session_state(data.session) @@ -224,7 +225,7 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: to_username=data.sub, to_is_staff=data.staff, to_pgroup=cfg.pgroup, - expiry_sec=SESSION_EXPIRE_SECONDS + expiry_sec=SESSION_EXPIRE_SECONDS, ) return {"granted": True, "message": "Baton acquired (beamline was vacant)"} @@ -233,11 +234,11 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: return {"already_holder": True, "message": "You already hold the baton"} holder = cfg.baton_holder - - if holder and holder.is_staff and not data.staff: + print(cfg.allow_non_staff_request_from_staff) + if holder and holder.is_staff and not data.staff and not cfg.allow_non_staff_request_from_staff: return { "error": True, - "message": "Cannot request baton from staff. Please ask them directly." + "message": "Requesting baton from staff is disabled by backend policy.", } if data.staff: @@ -248,11 +249,11 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: target_is_staff=data.staff, target_pgroup=cfg.pgroup, queued_at=time.time(), - reason="beamline_busy_staff_override" + reason="beamline_busy_staff_override", ) return { "queued": True, - "message": "Staff override queued - will transfer when beamline is available" + "message": "Staff override queued - will transfer when beamline is available", } cfg.execute_baton_transfer( @@ -260,7 +261,7 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: to_username=data.sub, to_is_staff=data.staff, to_pgroup=cfg.pgroup, - expiry_sec=SESSION_EXPIRE_SECONDS + expiry_sec=SESSION_EXPIRE_SECONDS, ) return {"granted": True, "override": True, "message": "Staff override - baton acquired"} @@ -275,11 +276,11 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: "pending": True, "existing": True, "remaining_seconds": max(0, remaining), - "message": f"Request already pending ({remaining:.0f}s remaining)" + "message": f"Request already pending ({remaining:.0f}s remaining)", } return { "error": True, - "message": "Another user already has a pending request" + "message": "Another user already has a pending request", } request = BatonRequest( @@ -291,7 +292,7 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: holder_session=holder.session if holder else None, created_at=time.time(), timeout_seconds=BATON_REQUEST_TIMEOUT_SECONDS, - status=BatonRequestStatus.PENDING + status=BatonRequestStatus.PENDING, ) cfg.set_pending_baton_request(request, timeout_sec=BATON_REQUEST_TIMEOUT_SECONDS) @@ -299,7 +300,7 @@ def request_baton(cfg: BeamlineConfig, data: TokenData) -> dict: "pending": True, "request_id": request.request_id, "timeout_seconds": BATON_REQUEST_TIMEOUT_SECONDS, - "message": f"Request sent to {holder.username if holder else 'current user'}" + "message": f"Request sent to {holder.username if holder else 'current holder'}", } def respond_to_baton_request(cfg: BeamlineConfig, data: TokenData, accept: bool) -> dict: diff --git a/src/aare/daq/config.py b/src/aare/daq/config.py index ba8f8328..827915d3 100644 --- a/src/aare/daq/config.py +++ b/src/aare/daq/config.py @@ -84,6 +84,20 @@ class BeamlineConfig: # Session and authentication management + @property + def allow_non_staff_request_from_staff(self) -> bool: + raw = self.__client.get(f"{self.__bl}:allow_non_staff_request_from_staff") + if raw is None: + return False + return str(raw).strip().lower() in {"1", "true", "yes", "on"} + + @allow_non_staff_request_from_staff.setter + def allow_non_staff_request_from_staff(self, enabled: bool) -> None: + if enabled: + self.__client.set(f"{self.__bl}:allow_non_staff_request_from_staff", "1") + else: + self.__client.delete(f"{self.__bl}:allow_non_staff_request_from_staff") + def generate_session(self) -> int: return int(self.__client.incr(f"{self.__bl}:session")) @@ -751,4 +765,9 @@ class BeamlineConfig: self.__client.set(f"{self.__bl}:failed_mount_count", count) def increment_failed_mount_count(self) -> int: - return int(self.__client.incr(f"{self.__bl}:failed_mount_count")) \ No newline at end of file + return int(self.__client.incr(f"{self.__bl}:failed_mount_count")) + +if __name__ == "__main__": + from aare.common.beamline import mx_beamline + cfg = BeamlineConfig(bl=mx_beamline()) + cfg.allow_non_staff_request_from_staff = True \ No newline at end of file diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index c0877fdf..9393c8c5 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -8,7 +8,7 @@ import cv2 import urllib3 import uvicorn -from aare.common.auth_models import BatonStatus +from aare.common.auth_models import BatonStatus, BatonRequestStatus from aare.common.coordinate import SmargonCoordinate, Coordinate from aare.common.error_codes import export_error_codes, export_error_codes_grouped from aare.common.logger_config import setup_logger @@ -726,6 +726,7 @@ async def baton_request(token: str = Depends(oauth2_scheme)) -> dict: - If same level: creates pending request with timeout - Non-staff cannot request from staff """ + logger.debug(cfg.allow_non_staff_request_from_staff) data = auth.parse_token(token) return auth.request_baton(cfg, data) @@ -763,17 +764,24 @@ async def baton_check_timeout(token: str = Depends(oauth2_scheme)) -> dict: """ data = auth.parse_token(token) - resolved = auth.resolve_baton_timeout_if_needed(cfg) - if resolved is not None: - return resolved.model_dump() + auth.resolve_baton_timeout_if_needed(cfg) pending = cfg.pending_baton_request if pending is None: + if cfg.baton_holder and cfg.baton_holder.session == data.session: + return {"granted": True, "message": "Baton acquired!"} + queued = cfg.queued_baton_transfer + if queued and queued.target_session == data.session: + return {"queued": True, "message": "Transfer queued"} return {"no_pending": True} if pending.requester_session != data.session: return {"not_your_request": True} + if pending.status == BatonRequestStatus.REFUSED: + cfg.clear_pending_baton_request() + return {"refused": True, "message": "Request refused"} + elapsed = time.time() - pending.created_at if elapsed < pending.timeout_seconds: return { @@ -783,6 +791,12 @@ async def baton_check_timeout(token: str = Depends(oauth2_scheme)) -> dict: return auth.request_baton(cfg, data) +@app.put("/access/allow_non_staff_request_from_staff") +async def set_allow_non_staff_request_from_staff(val: bool, token: str = Depends(oauth2_scheme)) -> str: + data = auth.parse_token(token) + auth.check_jwt_staff_only(data) + cfg.allow_non_staff_request_from_staff = val + return "OK" async def baton_status_event_stream(data: TokenData) -> AsyncGenerator[str, None]: """SSE stream for baton status updates.""" diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a237f3f7..dcdfeca6 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -44,7 +44,7 @@ from aare.gui.threads.daq_worker import DAQWorker from aare.gui.threads.jfjoch_viewer import JFJochDBusClient from aare.gui.tutorials.tutorial_registration import register_tutorials from aare.gui.widgets.alert_banner import AlertBanner -from aare.gui.widgets.baton_request_dialog import BatonRequestDialog +from aare.gui.widgets.baton_request_dialog import BatonRequestDialog, BatonPendingDialog from aare.gui.widgets.camera_image import SampleCameraImageLabel from aare.gui.widgets.no_wheel_scroll_area import NoWheelScrollArea from aare.gui.widgets.status_bar import StatusBar @@ -76,6 +76,7 @@ class MainWindow(QMainWindow): self._waiting_for_baton_response: bool = False self._baton_request_dialog: BatonRequestDialog | None = None + self._baton_pending_dialog: BatonPendingDialog | None = None # Tutorial manager (define tutorials after widgets exist) self.tutorial_manager = TutorialManager(self) @@ -291,6 +292,7 @@ class MainWindow(QMainWindow): self.daq = DAQWorker(base_url=self.__base_url, token=self.__token) self.daq.baton_status_changed.connect(self.status_bar.update_baton_status) + self.daq.baton_status_changed.connect(self._on_baton_status_changed) self.daq.baton_request_result.connect(self._on_baton_request_result) self.daq.baton_response_result.connect(self._on_baton_response_result) self.daq.baton_timeout_checked.connect(self._on_baton_timeout_checked) @@ -716,6 +718,26 @@ class MainWindow(QMainWindow): def _refuse_baton_request(self): self.daq.respond_to_baton_request(False) + @Slot() + def _accept_baton_request(self): + self.daq.respond_to_baton_request(True) + + @Slot() + def _refuse_baton_request(self): + self.daq.respond_to_baton_request(False) + + @Slot(BatonStatus) + def _on_baton_status_changed(self, status: BatonStatus): + """Close the pending dialog immediately if the baton request has been resolved via SSE.""" + if self._waiting_for_baton_response and not status.you_have_pending_request: + self._waiting_for_baton_response = False + self._close_baton_pending_dialog() + + if status.you_are_holder: + self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) + else: + self.alert_banner.show_message("Request declined or cancelled", False, auto_clear_ms=10000) + @Slot(dict) def _on_baton_request_result(self, result: dict): """Handle result of our baton request - show waiting banner with countdown.""" @@ -723,7 +745,9 @@ class MainWindow(QMainWindow): self._waiting_for_baton_response = False self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) logger.info("Baton acquired") - self._close_baton_dialog() + + # Close the pending dialog immediately before showing p-group prompt + self._close_baton_pending_dialog() available_pgroups = [str(p).strip() for p in (self.__decoded_token.pgroups or []) if p is not None and str(p).strip()] if len(available_pgroups) == 1: @@ -735,6 +759,16 @@ class MainWindow(QMainWindow): self._waiting_for_baton_response = True timeout = result.get("timeout_seconds", 30) holder = result.get("message", "Waiting for response...") + + if getattr(self, "_baton_pending_dialog", None) is None: + target_user = holder.replace("Request sent to ", "") + self._baton_pending_dialog = BatonPendingDialog(target_user=target_user, timeout_seconds=timeout, + parent=self) + self._baton_pending_dialog.cancelled_signal.connect(self.daq.cancel_baton_request) + self._baton_pending_dialog.show() + else: + self._baton_pending_dialog.update_remaining(timeout) + self.alert_banner.show_waiting(f"Requesting control - {holder}", timeout) logger.info(f"Baton request pending - {timeout}s timeout") @@ -743,14 +777,23 @@ class MainWindow(QMainWindow): self.alert_banner.show_waiting("Control transfer queued - waiting for beamline") logger.info("Baton transfer queued") + if getattr(self, "_baton_pending_dialog", None) is None: + self._baton_pending_dialog = BatonPendingDialog(target_user="Current Holder", timeout_seconds=0, + parent=self) + self._baton_pending_dialog.cancelled_signal.connect(self.daq.cancel_baton_request) + self._baton_pending_dialog.show() + self._baton_pending_dialog.set_queued_state() + elif result.get("already_holder"): self._waiting_for_baton_response = False logger.debug("Already baton holder") + self._close_baton_pending_dialog() elif result.get("error"): self._waiting_for_baton_response = False self.alert_banner.show_message(result.get("message", "Request failed"), True) logger.warning(f"Baton request failed: {result.get('message')}") + self._close_baton_pending_dialog() @Slot(dict) def _on_baton_response_result(self, result: dict): @@ -777,33 +820,57 @@ class MainWindow(QMainWindow): remaining = int(result.get("remaining_seconds", 0)) if self._waiting_for_baton_response: self.alert_banner.show_waiting("Requesting control", remaining) + if getattr(self, "_baton_pending_dialog", None) is not None: + self._baton_pending_dialog.update_remaining(remaining) elif result.get("granted"): self._waiting_for_baton_response = False self.alert_banner.show_message("Baton acquired!", False, auto_clear_ms=10000) - self._close_baton_dialog() + + # Close the pending dialog immediately before showing p-group prompt + self._close_baton_pending_dialog() + # P-group logic will be handled automatically by the status_bar stream update elif result.get("queued"): self._waiting_for_baton_response = True self.alert_banner.show_waiting("Control transfer queued - waiting for beamline") - self._close_baton_dialog() + + if getattr(self, "_baton_pending_dialog", None) is not None: + self._baton_pending_dialog.set_queued_state() + else: + self._baton_pending_dialog = BatonPendingDialog(target_user="Current Holder", timeout_seconds=0, + parent=self) + self._baton_pending_dialog.cancelled_signal.connect(self.daq.cancel_baton_request) + self._baton_pending_dialog.show() + self._baton_pending_dialog.set_queued_state() elif result.get("refused"): self._waiting_for_baton_response = False self.alert_banner.show_message("Request declined", False, auto_clear_ms=10000) - self._close_baton_dialog() + self._close_baton_pending_dialog() else: logger.debug(f"replied with {result}") self.alert_banner.clear_message() + self._close_baton_pending_dialog() def _close_baton_dialog(self) -> None: - if self._baton_request_dialog is not None: + if getattr(self, "_baton_request_dialog", None) is not None: try: self._baton_request_dialog.close() finally: self._baton_request_dialog = None + def _close_baton_pending_dialog(self) -> None: + if getattr(self, "_baton_pending_dialog", None) is not None: + try: + if hasattr(self._baton_pending_dialog, '_timer'): + self._baton_pending_dialog._timer.stop() + self._baton_pending_dialog.close() + finally: + self._baton_pending_dialog = None + + def _restore_window_state(self) -> None: settings = QSettings() geometry = settings.value("main_window/geometry") diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index 7643aaf9..9c3a1de9 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -100,7 +100,6 @@ class DAQWorker(QObject): if self.__base_url is not None: self.start_face_detection_stream() self.start_baton_stream() - self._baton_timeout_timer.start() def get_last_error_payload(self) -> dict: return dict(self._last_error_payload or {}) @@ -1136,7 +1135,13 @@ class DAQWorker(QObject): if payload: status = BatonStatus.model_validate_json(payload) - # Detect incoming request + if status.you_have_pending_request: + if not self._baton_timeout_timer.isActive(): + self._baton_timeout_timer.start() + else: + if self._baton_timeout_timer.isActive(): + self._baton_timeout_timer.stop() + if (status.incoming_request and (self._last_baton_status is None or not self._last_baton_status.incoming_request)): diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 6389a4e3..3267a7f2 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -144,7 +144,9 @@ class AlertBanner(QFrame): self._countdown_remaining -= 1 if self._countdown_remaining <= 0: self._stop_countdown() - # Don't auto-clear - let the baton status update handle that + self.clear_message() + return + self._update_waiting_text() def _stop_countdown(self): diff --git a/src/aare/gui/widgets/baton_request_dialog.py b/src/aare/gui/widgets/baton_request_dialog.py index 90aab3b9..b23bca2e 100644 --- a/src/aare/gui/widgets/baton_request_dialog.py +++ b/src/aare/gui/widgets/baton_request_dialog.py @@ -216,5 +216,133 @@ class BatonRequestDialog(QDialog): """Closing the dialog counts as ignoring = auto-accept on timeout.""" # Don't emit anything here - let the timeout handle it # or the SSE stream will close the dialog when resolved + self._timer.stop() + super().closeEvent(event) + +class BatonPendingDialog(QDialog): + """ + Dialog shown to the user who requested the baton while they wait for a response + or for the beamline queue to clear. + """ + cancelled_signal = Signal() + + def __init__(self, target_user: str, timeout_seconds: int = 30, parent=None): + super().__init__(parent) + self.setWindowTitle("⏳ Baton Request Pending") + self.setModal(False) + self.setMinimumWidth(400) + self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowStaysOnTopHint) + + self._timeout = timeout_seconds + self._remaining = timeout_seconds + self._target_user = target_user + + self._setup_ui() + self._start_timer() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(15) + + self.header = QLabel("⏳ Requesting Control") + header_font = QFont() + header_font.setPointSize(14) + header_font.setBold(True) + self.header.setFont(header_font) + self.header.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.header) + + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) + layout.addWidget(line) + + self.message_label = QLabel( + f"Waiting for {self._target_user} to respond..." + ) + self.message_label.setWordWrap(True) + self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.message_label) + + self.progress_layout = QVBoxLayout() + self.progress = QProgressBar() + self.progress.setRange(0, max(1, self._timeout)) + self.progress.setValue(self._timeout) + self.progress.setTextVisible(False) + self.progress.setFixedHeight(8) + self.progress.setStyleSheet(""" + QProgressBar { + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f0f0f0; + } + QProgressBar::chunk { + background-color: #2196F3; + border-radius: 3px; + } + """) + self.progress_layout.addWidget(self.progress) + + self.time_label = QLabel(f"{self._timeout} seconds remaining") + self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.time_label.setStyleSheet("color: #666;") + self.progress_layout.addWidget(self.time_label) + + layout.addLayout(self.progress_layout) + + button_layout = QHBoxLayout() + self.cancel_btn = QPushButton("✗ Cancel Request") + self.cancel_btn.setMinimumHeight(40) + self.cancel_btn.setStyleSheet(""" + QPushButton { + background-color: #f44336; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + font-size: 13px; + } + QPushButton:hover { background-color: #da190b; } + QPushButton:pressed { background-color: #c41000; } + """) + self.cancel_btn.clicked.connect(self._on_cancel) + button_layout.addWidget(self.cancel_btn) + layout.addLayout(button_layout) + + def _start_timer(self): + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self._tick) + self._timer.start() + + def _tick(self): + self._remaining -= 1 + if self._remaining < 0: + self._remaining = 0 + + self.progress.setValue(self._remaining) + self.time_label.setText(f"{self._remaining} seconds remaining") + if self._remaining <= 0: + self._timer.stop() + + def update_remaining(self, remaining: int): + self._remaining = remaining + self.progress.setValue(self._remaining) + self.time_label.setText(f"{self._remaining} seconds remaining") + + def set_queued_state(self): + self._timer.stop() + self.header.setText("⏳ Transfer Queued") + self.message_label.setText("Waiting for current action to finish before receiving baton...") + self.progress.hide() + self.time_label.hide() + # Keep cancel button so they can abort the wait if they change their mind + + def _on_cancel(self): + self._timer.stop() + self.cancelled_signal.emit() + self.reject() + + def closeEvent(self, event): self._timer.stop() super().closeEvent(event) \ No newline at end of file diff --git a/src/aare/gui/widgets/status_bar.py b/src/aare/gui/widgets/status_bar.py index 6bf7d833..a6b546a9 100644 --- a/src/aare/gui/widgets/status_bar.py +++ b/src/aare/gui/widgets/status_bar.py @@ -219,10 +219,19 @@ class StatusBar(QStatusBar): def update_baton_status(self, status: BatonStatus): """Update baton status from SSE stream.""" prev_incoming = bool(self._baton_status and self._baton_status.incoming_request) + + # Detect if we just became the holder (e.g., from a queue resolving) + was_holder = bool(self._baton_status and self._baton_status.you_are_holder) + now_holder = bool(status and status.you_are_holder) + self._baton_status = status self._has_pending_request = status.you_have_pending_request if status else False self._update_session_display() + # If we just received the baton (and weren't the holder a moment ago) + if now_holder and not was_holder: + self._after_baton_granted_select_pgroup() + incoming = bool(status and status.incoming_request) if incoming and not prev_incoming: self._emit_incoming_baton_request(status) @@ -311,8 +320,11 @@ class StatusBar(QStatusBar): action_grab.triggered.connect(self._on_grab_clicked) elif holder_is_staff: # Non-staff cannot request from staff - action_grab = menu.addAction("Request (Staff has control)") - action_grab.setEnabled(False) + allowed = self._baton_status and getattr(self._baton_status, "allow_non_staff_request", False) + action_grab = menu.addAction("Request from Staff") + action_grab.setEnabled(allowed) + if allowed: + action_grab.triggered.connect(self._on_grab_clicked) else: # Same level - request with timeout action_request = menu.addAction("Request Control") @@ -343,7 +355,7 @@ class StatusBar(QStatusBar): menu.exec() def show_pgroup_menu(self): - in_curr = self.__status and self.__status.session.current_pgroup in (self.__allowed_pgroups or []) + in_curr = self.__status logger.info(f"in_curr is {in_curr}")