aaredaq: continuing work on baton exchange procedure

This commit is contained in:
2026-03-26 17:31:13 +01:00
parent 5a47411727
commit 5949f14599
9 changed files with 311 additions and 62 deletions
+2 -1
View File
@@ -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
incoming_request: bool = False
allow_non_staff_request: bool = False
+45 -44
View File
@@ -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:
+20 -1
View File
@@ -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"))
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
+18 -4
View File
@@ -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."""
+73 -6
View File
@@ -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")
+7 -2
View File
@@ -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)):
+3 -1
View File
@@ -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):
@@ -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 <b>{self._target_user}</b> 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)
+15 -3
View File
@@ -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}")