From d54f247007e65cd6bff73f6b13ea3085601a37ad Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 2 Sep 2026 15:27:01 +0200 Subject: [PATCH] wip --- .../bec_widgets/widgets/scheduler/enums.py | 11 + .../widgets/scheduler/notification_dialog.py | 210 ++++++++++++++++-- .../widgets/scheduler/notifications.py | 145 ++++++++---- .../widgets/scheduler/schedule_item.py | 10 +- .../widgets/scheduler/scheduler.py | 72 +++++- 5 files changed, 371 insertions(+), 77 deletions(-) create mode 100644 debye_bec/bec_widgets/widgets/scheduler/enums.py diff --git a/debye_bec/bec_widgets/widgets/scheduler/enums.py b/debye_bec/bec_widgets/widgets/scheduler/enums.py new file mode 100644 index 0000000..71bc706 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scheduler/enums.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from enum import Enum + + +class ScheduleItemStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + ABORTED = "aborted" diff --git a/debye_bec/bec_widgets/widgets/scheduler/notification_dialog.py b/debye_bec/bec_widgets/widgets/scheduler/notification_dialog.py index 0397c58..cc7dd81 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/notification_dialog.py +++ b/debye_bec/bec_widgets/widgets/scheduler/notification_dialog.py @@ -1,25 +1,56 @@ -"""Small settings dialog for `notifications.NotificationSettings` - kept separate from the send logic itself.""" +""" +Settings dialog for `notifications.NotificationSettings` - kept separate +from the send logic itself (`notifications.py`). +""" from __future__ import annotations -from qtpy.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QLabel, QVBoxLayout +from bec_lib.logger import bec_logger +from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QGroupBox, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, +) -from .notifications import NotificationSettings +from .enums import ScheduleItemStatus +from .notifications import NotificationSettings, NotificationTarget, send_notification + +logger = bec_logger.logger + + +def _service_state(client, name: str) -> tuple[bool, list[str]]: + """ + Best-effort read of a messaging service's enabled/scopes state (see + the module docstring in notifications.py for why this is the only + introspection available). Never raises - a client without `messaging` + set up yet just looks like "not enabled, no scopes". + """ + messaging = getattr(client, "messaging", None) + service = getattr(messaging, name, None) if messaging is not None else None + if service is None: + return False, [] + return bool(getattr(service, "_enabled", False)), sorted(getattr(service, "_scopes", set())) class NotificationSettingsDialog(QDialog): - """Choose whether, and for which item types, finish/fail notifications are sent.""" + """Choose whether/where/for-which-item-types finish/fail notifications are sent.""" - def __init__(self, settings: NotificationSettings, parent=None): + def __init__(self, settings: NotificationSettings, client, parent=None): super().__init__(parent) + self._client = client self.setWindowTitle("Notifications") + self.setMinimumWidth(380) layout = QVBoxLayout(self) info_label = QLabel( - "Sends a BEC notification (see MessageEndpoints.notification) whenever a " - "schedule item finishes or fails. Whether it reaches you as a message depends " - "on your facility's notification routing (SciHub + Signal/Teams/SciLog) being " - "configured for these events - ask your beamline admin if you're not sure." + "Sends a message through the selected BEC messaging service whenever a " + "schedule item finishes or fails." ) info_label.setWordWrap(True) layout.addWidget(info_label) @@ -28,32 +59,173 @@ class NotificationSettingsDialog(QDialog): self.enabled_check.setChecked(settings.enabled) layout.addWidget(self.enabled_check) + kind_box = QGroupBox("Notify for") + kind_layout = QVBoxLayout(kind_box) self.scan_check = QCheckBox("Scans") self.scan_check.setChecked(settings.notify_scan) - layout.addWidget(self.scan_check) - self.move_check = QCheckBox("Movements") self.move_check.setChecked(settings.notify_move) - layout.addWidget(self.move_check) - self.rpc_check = QCheckBox("RPC / custom commands") self.rpc_check.setChecked(settings.notify_rpc) - layout.addWidget(self.rpc_check) - for check in (self.scan_check, self.move_check, self.rpc_check): - self.enabled_check.toggled.connect(check.setEnabled) - check.setEnabled(settings.enabled) + kind_layout.addWidget(check) + layout.addWidget(kind_box) + + target_box = QGroupBox("Send via") + target_layout = QVBoxLayout(target_box) + self.scilog_check = QCheckBox("SciLog") + self.teams_check = QCheckBox("Microsoft Teams") + self.signal_check = QCheckBox("Signal Messenger") + for check in (self.scilog_check, self.teams_check, self.signal_check): + target_layout.addWidget(check) + layout.addWidget(target_box) + + event_box = QGroupBox("Send on") + event_layout = QVBoxLayout(event_box) + self.completed_check = QCheckBox("Completed") + self.aborted_check = QCheckBox("Aborted") + self.failed_check = QCheckBox("Failed") + for check in (self.completed_check, self.aborted_check, self.failed_check): + event_layout.addWidget(check) + layout.addWidget(event_box) + + # Teams: contact/channel can only be a scope an admin has already + # registered for the deployment - there's no way to address an + # arbitrary Teams user from the client, so this is a dropdown, not + # free text (see notifications.py's module docstring). + self.teams_box = QGroupBox("Teams settings") + teams_layout = QVBoxLayout(self.teams_box) + teams_enabled, teams_scopes = _service_state(client, "teams") + self.teams_combo = QComboBox() + self.teams_combo.addItems(teams_scopes) + self.teams_combo.setEnabled(teams_enabled) + teams_layout.addWidget(QLabel("Contact or channel")) + teams_layout.addWidget(self.teams_combo) + if not teams_enabled: + teams_layout.addWidget(_dim_label("Teams messaging is not enabled for this session.")) + elif not teams_scopes: + teams_layout.addWidget(_dim_label("No Teams contacts/channels are registered.")) + layout.addWidget(self.teams_box) + + # Signal: a raw phone number works directly (BEC normalizes it - + # see the docs), so this is an editable combo box: type a number, + # or pick a pre-registered scope if any exist. + self.signal_box = QGroupBox("Signal settings") + signal_layout = QVBoxLayout(self.signal_box) + signal_enabled, signal_scopes = _service_state(client, "signal") + self.signal_combo = QComboBox() + self.signal_combo.setEditable(True) + self.signal_combo.addItems(signal_scopes) + self.signal_combo.setEnabled(signal_enabled) + signal_layout.addWidget(QLabel("Phone number or contact")) + self.signal_combo.setEditText("") + self.signal_combo.lineEdit().setPlaceholderText("e.g. +41791234567") + signal_layout.addWidget(self.signal_combo) + if not signal_enabled: + signal_layout.addWidget(_dim_label("Signal messaging is not enabled for this session.")) + layout.addWidget(self.signal_box) + + # pre-fill from the persisted settings + { + NotificationTarget.SCILOG: self.scilog_check, + NotificationTarget.TEAMS: self.teams_check, + NotificationTarget.SIGNAL: self.signal_check, + }.get(settings.target, self.scilog_check).setChecked(True) + for event in settings.events: + { + ScheduleItemStatus.COMPLETED: self.completed_check, + ScheduleItemStatus.ABORTED: self.aborted_check, + ScheduleItemStatus.FAILED: self.failed_check, + }.get(event).setChecked(True) + if settings.teams_scope: + idx = self.teams_combo.findText(settings.teams_scope) + if idx >= 0: + self.teams_combo.setCurrentIndex(idx) + if settings.signal_number: + self.signal_combo.setEditText(settings.signal_number) + + self.scilog_check.toggled.connect(lambda *_: self._sync_target_widgets(self.scilog_check)) + self.teams_check.toggled.connect(lambda *_: self._sync_target_widgets(self.teams_check)) + self.signal_check.toggled.connect(lambda *_: self._sync_target_widgets(self.signal_check)) + + self.test_btn = QPushButton("Send test message") + self.test_btn.clicked.connect(self._on_test_clicked) + layout.addWidget(self.test_btn) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) - def result_settings(self) -> NotificationSettings: - """Valid after `exec_()` returns `QDialog.Accepted`.""" + self._sync_target_widgets() + + def _sync_target_widgets(self, current_button: QCheckBox = None): + if current_button is None: + for button in [self.scilog_check, self.teams_check, self.signal_check]: + if button.isChecked(): + current_button = button + break + if current_button.isChecked(): + for button in [self.scilog_check, self.teams_check, self.signal_check]: + if button != current_button: + button.setChecked(False) + if current_button == self.teams_check: + self.teams_box.setVisible(True) + self.signal_box.setVisible(False) + elif current_button == self.signal_check: + self.teams_box.setVisible(False) + self.signal_box.setVisible(True) + else: + self.teams_box.setVisible(False) + self.signal_box.setVisible(False) + self.layout().activate() + self.adjustSize() + + def _current_settings(self) -> NotificationSettings: + if self.scilog_check.isChecked(): + target = NotificationTarget.SCILOG + elif self.teams_check.isChecked(): + target = NotificationTarget.TEAMS + elif self.signal_check.isChecked(): + target = NotificationTarget.SIGNAL + else: + raise ValueError(f"Target {target} not supported") + + events = [] + if self.completed_check.isChecked(): + events.append(ScheduleItemStatus.COMPLETED) + if self.aborted_check.isChecked(): + events.append(ScheduleItemStatus.ABORTED) + if self.failed_check.isChecked(): + events.append(ScheduleItemStatus.FAILED) + return NotificationSettings( enabled=self.enabled_check.isChecked(), notify_scan=self.scan_check.isChecked(), notify_move=self.move_check.isChecked(), notify_rpc=self.rpc_check.isChecked(), + target=target, + events=events, + teams_scope=self.teams_combo.currentText().strip() or None, + signal_number=self.signal_combo.currentText().strip() or None, ) + + def _on_test_clicked(self): + settings = self._current_settings() + try: + send_notification(self._client, settings, "Test message from the BEC schedule widget.") + except Exception as exc: # pylint: disable=broad-except + QMessageBox.critical(self, "Test message failed", str(exc)) + return + QMessageBox.information(self, "Test message sent", "The test message was sent.") + + def result_settings(self) -> NotificationSettings: + """Valid after `exec_()` returns `QDialog.Accepted`.""" + return self._current_settings() + + +def _dim_label(text: str) -> QLabel: + label = QLabel(text) + label.setStyleSheet("color: gray;") + label.setWordWrap(True) + return label diff --git a/debye_bec/bec_widgets/widgets/scheduler/notifications.py b/debye_bec/bec_widgets/widgets/scheduler/notifications.py index b7542e2..16b9f34 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/notifications.py +++ b/debye_bec/bec_widgets/widgets/scheduler/notifications.py @@ -2,42 +2,55 @@ A small, self-contained "tell the user when something finished/failed" feature, independent of scheduling/execution logic. -Sends via BEC's own notification transport: `MessageEndpoints.notification` -carries a `messages.NotificationMessage`, the same message type/endpoint -BEC's own scan lifecycle events (e.g. "scan_completed") use. Reusing it -means schedule-item notifications are just more entries in the same -mechanism, not a plugin-invented side channel - and if your facility runs -SciHub with messaging-service routing configured (Signal/Teams/SciLog), -they can be forwarded out the same way any other BEC notification would -be, by adding a route for the event names below in that routing config. +Sends through BEC's own `client.messaging` container +(`bec_lib.messaging_services.MessagingContainer`, exposed as `bec.messaging` +in the IPython client) - the same mechanism documented at +https://bec.readthedocs.io/latest/how-to/general/send-messages-to-signal.html +and +https://bec.readthedocs.io/latest/how-to/general/send-messages-to-scilog.html: -Nothing here is scheduling/BECWidget-specific: `notify_item_finished` only -needs a connector, a settings object and a few plain values, so it can be -called from `schedule_widget.py` without either module depending on the -other's internals. + bec.messaging.signal.new("Beamline checks completed.").send(scope="+41791234567") + bec.messaging.scilog.new("Beamline checks completed.").send() + bec.messaging.teams.new("Beamline checks completed.").send(scope=) + +Notes on each service, read directly from `bec_lib.messaging_services` +rather than assumed: + +- **SciLog** needs no `scope` - it posts to the logbook of the currently + active pgroup automatically. +- **Signal** accepts a raw phone number as `scope` (BEC normalizes it, + defaulting to the Swiss country code for numbers without one - see the + docs above) *or* a pre-registered scope name for a group. +- **Teams** is a plain `MessagingService` with no Teams-specific methods: + a contact/channel can only be addressed by a `scope` an admin has + already registered for the deployment (`service._scopes`) - there is no + way to address an arbitrary Teams user/email directly from the client. + This is why the Teams UI in `notification_dialog.py` is a dropdown of + known scopes rather than free text, while Signal's is free text (with + any registered scopes offered as suggestions). + +`client.messaging.._enabled` / `._scopes` are the only +introspection available (no public accessor exists at the time of +writing) - used here and in the dialog to grey out a target that isn't +configured for the current session instead of only failing at send time. """ from __future__ import annotations -from bec_lib.endpoints import MessageEndpoints +from enum import Enum + from bec_lib.logger import bec_logger -from bec_lib.messages import MessagingServiceTextContent, NotificationMessage from pydantic import BaseModel +from .enums import ScheduleItemStatus + logger = bec_logger.logger -# Namespaced so they can't collide with BEC's own built-in event names -# (e.g. "scan_completed"); an admin who wants these routed to Signal/Teams/ -# SciLog can add a route for exactly these names in their notification -# routing config (`MessageEndpoints.notification_config()`). -_EVENT_NAMES = { - ("scan", True): "schedule_scan_completed", - ("scan", False): "schedule_scan_failed", - ("move", True): "schedule_move_completed", - ("move", False): "schedule_move_failed", - ("custom", True): "schedule_rpc_completed", - ("custom", False): "schedule_rpc_failed", -} + +class NotificationTarget(str, Enum): + SCILOG = "scilog" + TEAMS = "teams" + SIGNAL = "signal" class NotificationSettings(BaseModel): @@ -51,6 +64,11 @@ class NotificationSettings(BaseModel): # any hand-typed command, so it's exposed to the user as "RPC". notify_rpc: bool = True + target: NotificationTarget = NotificationTarget.SCILOG + events: list[ScheduleItemStatus] = [] + teams_scope: str | None = None # a pre-registered Teams scope (channel/contact) + signal_number: str | None = None # a phone number, or a pre-registered Signal scope + def _enabled_for_kind(settings: NotificationSettings, kind: str) -> bool: if not settings.enabled: @@ -62,33 +80,80 @@ def _enabled_for_kind(settings: NotificationSettings, kind: str) -> bool: }.get(kind, False) +def send_notification(client, settings: NotificationSettings, text: str) -> None: + """ + Send `text` through whichever service `settings.target` selects. + + Raises on failure (no target selected, missing Teams scope/Signal + number, the target service not enabled for this session, ...) - + callers that want a fire-and-forget send should catch around this. + `notify_item_finished` does; the dialog's "Send test message" button + deliberately does not, so the operator sees exactly what went wrong. + """ + if settings.target == NotificationTarget.SCILOG: + client.messaging.scilog.new(text).send() + elif settings.target == NotificationTarget.TEAMS: + if not settings.teams_scope: + raise ValueError("No Teams contact/channel selected.") + client.messaging.teams.new(text).send(scope=settings.teams_scope) + elif settings.target == NotificationTarget.SIGNAL: + if not settings.signal_number: + raise ValueError("No Signal phone number/contact set.") + client.messaging.signal.new(text).send(scope=settings.signal_number) + else: + raise ValueError("No notification target selected.") + + def notify_item_finished( - connector, - schedule_name: str, + client, kind: str, command: str, - success: bool, + scan_number: str, + final_status: ScheduleItemStatus, settings: NotificationSettings, error: str | None = None, ): """ - Sends a `NotificationMessage` if notifications are enabled for `kind`. - Never raises - a notification failing to send should not affect - schedule execution. + Sends a message if notifications are enabled for `kind` and a target + is configured. Never raises - a notification failing to send should + not affect schedule execution. """ if not _enabled_for_kind(settings, kind): return + if final_status not in settings.events: + return - event = _EVENT_NAMES.get((kind, success), "schedule_item_finished") - status_word = "completed" if success else "failed" - text = f"[{schedule_name}] {kind} {status_word}: {command}" + status_word = final_status.name + text = f"**BEC Scheduler Widget**\nStatus: {kind} {status_word}" + if scan_number is not None: + text = text + f"\nScan number: {scan_number}" + text = text + f"\n\nCommand: {command}" if error: text += f"\n{error.strip().splitlines()[-1]}" try: - connector.send( - MessageEndpoints.notification(event), - NotificationMessage(event=event, message=[MessagingServiceTextContent(content=text)]), - ) + send_notification(client, settings, text) except Exception: # pylint: disable=broad-except - logger.exception("Failed to send schedule notification for event '%s'", event) + logger.exception(f"Failed to send schedule notification via {settings.target.value}") + + +def notify_schedule_state(client, settings, schedule_name, state): + """ + Sends a message if notifications are enabled and a schedule is started or stopped. + Never raises - a notification failing to send should not affect schedule execution. + """ + text = f'**BEC Scheduler Widget**\nSchedule "{schedule_name}" ' + if state == "started": + text = text + "has started" + elif state == "aborted": + text = text + "was aborted" + elif state == "widget_closed": + text = text + "has finished prematurely because widget was closed" + elif state == "finished": + text = text + "has finished" + else: + logger.warning(f"Unknown schedule state {state}") + try: + send_notification(client, settings, text) + except Exception: # pylint: disable=broad-except + logger.exception(f"Failed to send schedule notification via {settings.target.value}") diff --git a/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py b/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py index 74cb2e0..b675094 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py +++ b/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py @@ -15,18 +15,10 @@ from typing import Literal from pydantic import BaseModel, Field +from .enums import ScheduleItemStatus from .guard import GuardSettings from .notifications import NotificationSettings - -class ScheduleItemStatus(str, Enum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - ABORTED = "aborted" - - # Which tab of the Add/Edit dialog built `command`, and therefore how to # re-open that dialog pre-filled with the same values for editing. "custom" # covers hand-typed text (including RPC calls to other widgets), which has diff --git a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py index 0b44652..bc4d826 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py +++ b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py @@ -46,12 +46,18 @@ from qtpy.QtWidgets import ( ) from .endpoints import schedule as schedule_endpoint +from .enums import ScheduleItemStatus from .guard import SignalGuard from .guard_dialog import GuardSettingsDialog from .item_dialog import ScheduleItemDialog from .notification_dialog import NotificationSettingsDialog -from .notifications import NotificationSettings, notify_item_finished -from .schedule_item import Schedule, ScheduleItem, ScheduleItemStatus +from .notifications import ( + NotificationSettings, + NotificationTarget, + notify_item_finished, + notify_schedule_state, +) +from .schedule_item import Schedule, ScheduleItem from .schedule_logic import index_of, pick_next_runnable, protected_prefix_length logger = bec_logger.logger @@ -323,7 +329,9 @@ class Scheduler(BECWidget, QWidget): guard_row.addStretch() notifications_row = QHBoxLayout() self.notifications_btn = MyButton("Notifications", "default") + self.notification_status_label = QLabel() notifications_row.addWidget(self.notifications_btn) + notifications_row.addWidget(self.notification_status_label) notifications_row.addStretch() settings_layout.addLayout(guard_row) settings_layout.addLayout(notifications_row) @@ -346,6 +354,9 @@ class Scheduler(BECWidget, QWidget): layout.addWidget(file_group) + self._update_guard_label() + self._update_notifications_label() + self.apply_theme() def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None): @@ -694,14 +705,39 @@ class Scheduler(BECWidget, QWidget): # ---- notifications / auto-pause settings ---- # @SafeSlot() def _on_notifications_clicked(self): - dialog = NotificationSettingsDialog(self.schedule.notifications, parent=self) + dialog = NotificationSettingsDialog(self.schedule.notifications, self.client, parent=self) if dialog.exec_() != QDialog.Accepted: return with self._lock: self.schedule.notifications = dialog.result_settings() + self._update_notifications_label() self._persist_locked() self._refresh_ui() + def _update_notifications_label(self): + if self.schedule.notifications.enabled: + types = [] + if self.schedule.notifications.notify_scan: + types.append("scan") + if self.schedule.notifications.notify_move: + types.append("move") + if self.schedule.notifications.notify_rpc: + types.append("rpc") + match self.schedule.notifications.target: + case NotificationTarget.SIGNAL: + target = "Signal Messenger" + case NotificationTarget.TEAMS: + target = "Microsoft Teams" + case NotificationTarget.SCILOG: + target = "Scilog" + events = [] + for event in self.schedule.notifications.events: + events.append(event.value) + text = f"Enabled: Notify through {target}, for item type {types}, and events {events}" + else: + text = "Disabled" + self.notification_status_label.setText(text) + @SafeSlot() def _on_guard_clicked(self): device_names = sorted(self.dev.keys()) @@ -732,6 +768,7 @@ class Scheduler(BECWidget, QWidget): except Exception: # pylint: disable=broad-except logger.exception("Failed to cancel the running scan for the auto-pause guard") self.schedule_changed.emit() + self._update_guard_label() @SafeSlot(float) def _on_guard_resumed(self, value: float): @@ -1114,7 +1151,9 @@ class Scheduler(BECWidget, QWidget): super().cleanup() def _run_all(self, repeat_aborted_item): - logger.info("_run_all was called") + notify_schedule_state( + self.client, self.schedule.notifications, self.schedule_name, "started" + ) namespace = {"scans": self.scans, "dev": self.dev} while not self._abort_requested and not self._closing.is_set(): with self._lock: @@ -1126,7 +1165,18 @@ class Scheduler(BECWidget, QWidget): self._await_running_item(next_item) else: self._execute_item(next_item, namespace) - + if self._abort_requested: + notify_schedule_state( + self.client, self.schedule.notifications, self.schedule_name, "aborted" + ) + elif self._closing.is_set(): + notify_schedule_state( + self.client, self.schedule.notifications, self.schedule_name, "widget_closed" + ) + else: + notify_schedule_state( + self.client, self.schedule.notifications, self.schedule_name, "finished" + ) with self._lock: logger.info("in _run_all, set is_running to false") self.schedule.is_running = False @@ -1209,13 +1259,17 @@ class Scheduler(BECWidget, QWidget): else: final_status = None - if final_status in (ScheduleItemStatus.COMPLETED, ScheduleItemStatus.FAILED): + if final_status in ( + ScheduleItemStatus.COMPLETED, + ScheduleItemStatus.FAILED, + ScheduleItemStatus.ABORTED, + ): notify_item_finished( - self.connector, - self.schedule_name, + self.client, item.kind, item.command, - success=(final_status == ScheduleItemStatus.COMPLETED), + item.scan_number, + final_status=final_status, settings=self.schedule.notifications, error=item.error, )