aaredaq: continuing work on baton exchange procedure
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user