1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

refactor(admin-view): Refactor experiment selection, http service, admin view, and add main view

This commit is contained in:
2026-02-17 15:48:05 +01:00
parent f220640b01
commit d679ea7cdd
7 changed files with 479 additions and 518 deletions

View File

@@ -3,6 +3,7 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.admin_view.admin_view import AdminView
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
@@ -55,6 +56,7 @@ class BECMainApp(BECMainWindow):
self.dock_area = DockAreaView(self)
self.device_manager = DeviceManagerView(self)
self.developer_view = DeveloperView(self)
self.admin_view = AdminView(self)
self.add_view(
icon="widgets",
@@ -77,6 +79,13 @@ class BECMainApp(BECMainWindow):
id="developer_view",
exclusive=True,
)
self.add_view(
icon="admin_panel_settings",
title="Admin View",
widget=self.admin_view,
id="admin_view",
mini_text="Admin",
)
if self._show_examples:
self.add_section("Examples", "examples")

View File

@@ -6,10 +6,15 @@ from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import DeploymentInfoMessage
from qtpy.QtWidgets import QStackedLayout, QWidget
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QSizePolicy, QStackedLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
ExperimentSelection,
)
class AdminWidget(BECWidget, QWidget):
@@ -19,22 +24,34 @@ class AdminWidget(BECWidget, QWidget):
def __init__(self, parent=None, client=None):
super().__init__(parent=parent, client=client)
self._current_deployment_info: DeploymentInfoMessage | None = None
# Overview widget
layout = QVBoxLayout(self)
self.admin_view_widget = BECAtlasAdminView(parent=self, client=self.client)
layout.addWidget(self.admin_view_widget)
self.stacked_layout = QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
self.setLayout(self.stacked_layout)
def on_enter(self) -> None:
"""Called after the widget becomes visible."""
self.admin_view_widget.check_health()
self.bec_dispatcher.connect_slot(
slot=self._update_deployment_info,
endpoint=MessageEndpoints.deployment_info(),
from_start=True,
)
def on_exit(self) -> None:
"""Called before the widget is hidden."""
self.admin_view_widget.logout()
@SafeSlot(dict, dict)
def _update_deployment_info(self, msg: dict, metadata: dict) -> None:
"""Fetch current deployment info from the server."""
deployment = DeploymentInfoMessage.model_validate(msg)
self._current_deployment_info = deployment
if __name__ == "__main__":
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication([])
apply_theme("dark")
w = QWidget()
l = QVBoxLayout(w)
widget = AdminWidget(parent=w)
dark_mode_button = DarkModeButton(parent=w)
l.addWidget(dark_mode_button)
l.addWidget(widget)
w.show()
app.exec()

View File

@@ -1,86 +1,325 @@
"""Admin View panel for setting up account and messaging services in BEC."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QHBoxLayout, QWidget
from qtpy.QtWidgets import (
QHBoxLayout,
QPushButton,
QSizePolicy,
QStackedLayout,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_http_service import (
BECAtlasHTTPService,
HTTPResponse,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_mat_card import (
ExperimentMatCard,
)
from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.experiment_selection import (
CurrentExperimentInfoFrame,
ExperimentSelection,
)
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import ExperimentInfoMessage
logger = bec_logger.logger
class OverviewWidget(QWidget):
"""Overview Widget for the BEC Atlas Admin view"""
login_requested = Signal()
def __init__(self, parent=None):
super().__init__(parent=parent)
layout = QHBoxLayout(self)
self.setAutoFillBackground(True)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(16)
self._experiment_info: ExperimentInfoMessage | None = None
self._mat_card = ExperimentMatCard(
parent=self,
show_activate_button=True,
button_text="Change Experiment",
title="Current Experiment",
)
layout.addWidget(self._mat_card)
self._mat_card.experiment_selected.connect(self._on_experiment_selected)
def _on_experiment_selected(self, experiment_info: dict) -> None:
"""We reuse the experiment_selected signal from the mat card to trigger the login and experiment change process."""
self.login_requested.emit()
@SafeSlot(dict)
def set_experiment_info(self, experiment_info: dict) -> None:
self._experiment_info = ExperimentInfoMessage.model_validate(experiment_info)
self._mat_card.set_experiment_info(self._experiment_info)
class BECAtlasAdminView(BECWidget, QWidget):
authenticated = Signal(bool)
account_changed = Signal(str)
messaging_service_activated = Signal(str)
def __init__(
self,
parent=None,
atlas_url: str = "https://bec-atlas-qa.psi.ch/api/v1",
headers: dict | None = None,
self, parent=None, atlas_url: str = "https://bec-atlas-dev.psi.ch/api/v1", client=None
):
super().__init__(parent=parent)
if headers is None:
headers = {"accept": "application/json"}
# Main layout
self.main_layout = QHBoxLayout(self)
self.main_layout.setContentsMargins(24, 18, 24, 18)
self.main_layout.setSpacing(24)
super().__init__(parent=parent, client=client)
# Atlas HTTP service
# State variables
self._current_deployment_info: DeploymentInfoMessage | None = None
self._current_deployment_info = None
self._current_session_info = None
self._current_experiment_info = None
self._authenticated = False
# Root layout
self.root_layout = QVBoxLayout(self)
self.root_layout.setContentsMargins(0, 0, 0, 0)
self.root_layout.setSpacing(0)
# Toolbar for navigation between different views in the admin panel
self.toolbar = ModularToolBar(self)
self.init_toolbar()
self.root_layout.insertWidget(0, self.toolbar)
self.toolbar.show_bundles(["view", "auth"])
# Stacked layout to switch between overview, experiment selection and messaging services
# It is added below the toolbar
self.stacked_layout = QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
self.root_layout.addLayout(self.stacked_layout)
# Overview widget
self.overview_widget = OverviewWidget(parent=self)
self.stacked_layout.addWidget(self.overview_widget)
self.overview_widget.login_requested.connect(self.login)
# Experiment Selection widget
self.experiment_selection = ExperimentSelection(parent=self)
self.stacked_layout.addWidget(self.experiment_selection)
self.experiment_selection.experiment_selected.connect(self._on_experiment_selected)
# BEC Atlas HTTP Service
self.atlas_http_service = BECAtlasHTTPService(
parent=self, base_url=atlas_url, headers=headers
parent=self, base_url=atlas_url, headers={"accept": "application/json"}
)
# Current Experinment Info Frame
self.current_experiment_frame = CurrentExperimentInfoFrame(parent=self)
self.dummy_msg_frame = CurrentExperimentInfoFrame(parent=self)
self.dummy_acl_frame = CurrentExperimentInfoFrame(parent=self)
self.main_layout.addWidget(self.current_experiment_frame)
self.main_layout.addWidget(self.dummy_msg_frame)
self.main_layout.addWidget(self.dummy_acl_frame)
# Connect signals
self.atlas_http_service.http_response_received.connect(self._display_response)
self.current_experiment_frame.request_change_experiment.connect(
self._on_request_change_experiment
self.atlas_http_service.http_response_received.connect(self._on_http_response_received)
self.atlas_http_service.authenticated.connect(self._on_authenticated)
self.bec_dispatcher.connect_slot(
slot=self._update_deployment_info,
topics=MessageEndpoints.deployment_info(),
from_start=True,
)
def set_experiment_info(self, experiment_info: ExperimentInfoMessage) -> None:
"""Set the current experiment information to display."""
self.current_experiment_frame.set_experiment_info(experiment_info)
@SafeSlot(dict)
def _on_experiment_selected(self, experiment_info: dict) -> None:
"""Handle the experiment selected signal from the experiment selection widget"""
experiment_info = ExperimentInfoMessage.model_validate(experiment_info)
experiment_id = experiment_info.pgroup
deployment_id = self._current_deployment_info.deployment_id
self.set_experiment(experiment_id=experiment_id, deployment_id=deployment_id)
def _on_request_change_experiment(self):
"""Handle the request to change the current experiment."""
@SafeSlot(dict, dict)
def _update_deployment_info(self, msg: dict, metadata: dict) -> None:
"""Fetch current deployment info from the server."""
deployment = DeploymentInfoMessage.model_validate(msg)
self._current_deployment_info = deployment
self._current_session_info = deployment.active_session
if self._current_session_info is not None:
self._current_experiment_info = self._current_session_info.experiment
# For demonstration, we will just call the method to get realms.
# In a real application, this could open a dialog to select a new experiment.
self.atlas_http_service.login() # Ensure we are authenticated before fetching realms
self.overview_widget.set_experiment_info(
self._current_experiment_info.model_dump() if self._current_experiment_info else {}
)
def _display_response(self, response: dict):
"""Display the HTTP response in the text edit widget."""
def init_toolbar(self):
"""Initialize the toolbar for the admin view. This allows to switch between different views in the admin panel."""
# Overview
overview = MaterialIconAction(
icon_name="home",
tooltip="Show Overview Panel",
label_text="Overview",
text_position="under",
parent=self,
filled=True,
)
overview.action.triggered.connect(self._on_overview_selected)
self.toolbar.components.add_safe("overview", overview)
# Experiment Selection
experiment_selection = MaterialIconAction(
icon_name="experiment",
tooltip="Show Experiment Selection Panel",
label_text="Experiment Selection",
text_position="under",
parent=self,
filled=True,
)
experiment_selection.action.triggered.connect(self._on_experiment_selection_selected)
experiment_selection.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("experiment_selection", experiment_selection)
# Messaging Services
messaging_services = MaterialIconAction(
icon_name="chat",
tooltip="Show Messaging Services Panel",
label_text="Messaging Services",
text_position="under",
parent=self,
filled=True,
)
messaging_services.action.triggered.connect(self._on_messaging_services_selected)
messaging_services.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("messaging_services", messaging_services)
# Login
login_action = MaterialIconAction(
icon_name="login",
tooltip="Login",
label_text="Login",
text_position="under",
parent=self,
filled=True,
)
login_action.action.triggered.connect(self.login)
self.toolbar.components.add_safe("login", login_action)
# Logout
logout_action = MaterialIconAction(
icon_name="logout",
tooltip="Logout",
label_text="Logout",
text_position="under",
parent=self,
filled=True,
)
logout_action.action.triggered.connect(self.logout)
logout_action.action.setEnabled(False) # Initially disabled until authenticated
self.toolbar.components.add_safe("logout", logout_action)
# Add view_bundle to toolbar
view_bundle = ToolbarBundle("view", self.toolbar.components)
view_bundle.add_action("overview")
view_bundle.add_action("experiment_selection")
view_bundle.add_action("messaging_services")
self.toolbar.add_bundle(view_bundle)
# Add auth_bundle to toolbar
auth_bundle = ToolbarBundle("auth", self.toolbar.components)
auth_bundle.add_action("login")
auth_bundle.add_action("logout")
self.toolbar.add_bundle(auth_bundle)
def _on_overview_selected(self):
"""Show the overview panel."""
self.overview_widget.setVisible(True)
self.experiment_selection.setVisible(False)
self.stacked_layout.setCurrentWidget(self.overview_widget)
def _on_experiment_selection_selected(self):
"""Show the experiment selection panel."""
if not self._authenticated:
logger.warning("Attempted to access experiment selection without authentication.")
return
self.overview_widget.setVisible(False)
self.experiment_selection.setVisible(True)
self.stacked_layout.setCurrentWidget(self.experiment_selection)
def _on_messaging_services_selected(self):
"""Show the messaging services panel."""
logger.info("Messaging services panel is not implemented yet.")
# TODO
return
# if not self._authenticated:
# logger.warning("Attempted to access messaging services without authentication.")
# return
# self.overview_widget.setVisible(False)
# self.experiment_selection.setVisible(False)
def _fetch_available_experiments(self):
"""Fetch the list of available experiments for the authenticated user."""
# What if this is None, should this be an optional user input in the UI?
if self._current_experiment_info is None:
logger.error(
"No current experiment info available, cannot fetch available experiments."
)
return
current_realm_id = self._current_experiment_info.realm_id
if current_realm_id is None:
logger.error(
"Current experiment does not have a realm_id, cannot fetch available experiments."
)
return
self.atlas_http_service.get_experiments_for_realm(current_realm_id)
def _on_http_response_received(self, response: dict) -> None:
"""Handle the HTTP response received from the BEC Atlas API."""
response = HTTPResponse(**response)
text = f"Endpoint: {response.request_url}\nStatus Code: {response.status}\n\n"
if response.data:
data_str = ""
if isinstance(response.data, str):
data_str = response.data
elif isinstance(response.data, dict):
data_str = json.dumps(response.data, indent=4)
elif isinstance(response.data, list):
for item in response.data:
data_str += json.dumps(item, indent=4) + "\n"
text += f"Response Data:\n{data_str}"
print(text)
# self.response_text.setPlainText(text)
logger.info(f"HTTP Response received: {response.request_url} with status {response.status}")
if "realms/experiments" in response.request_url and response.status == 200:
experiments = response.data if isinstance(response.data, list) else []
self.experiment_selection.set_experiment_infos(experiments)
self._on_experiment_selection_selected() # Switch to experiment selection once experiments are loaded
def _on_authenticated(self, authenticated: bool) -> None:
"""Handle authentication state change."""
self._authenticated = authenticated
self.authenticated.emit(authenticated)
if authenticated:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(True)
self.toolbar.components.get_action("messaging_services").action.setEnabled(True)
self.toolbar.components.get_action("login").action.setEnabled(False)
self.toolbar.components.get_action("logout").action.setEnabled(True)
self._fetch_available_experiments() # Fetch experiments upon successful authentication
else:
self.toolbar.components.get_action("experiment_selection").action.setEnabled(False)
self.toolbar.components.get_action("messaging_services").action.setEnabled(False)
self.toolbar.components.get_action("login").action.setEnabled(True)
self.toolbar.components.get_action("logout").action.setEnabled(False)
# Delete data in experiment selection widget upon logout
self.experiment_selection.set_experiment_infos([])
self._on_overview_selected() # Switch back to overview on logout
@SafeSlot(dict)
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
"""Set the experiment information for the current experiment."""
self.atlas_http_service.set_experiment(experiment_id, deployment_id)
def check_health(self) -> None:
"""Check the health of the BEC Atlas API."""
self.atlas_http_service.check_health()
def login(self) -> None:
"""Login to the BEC Atlas API."""
self.atlas_http_service.login()
def logout(self) -> None:
"""Logout from the BEC Atlas API."""
self.atlas_http_service.logout()
def cleanup(self):
self.atlas_http_service.cleanup()
@@ -99,25 +338,28 @@ if __name__ == "__main__":
window = BECAtlasAdminView()
exp_info_dict = {
"realm_id": "ADDAMS",
"proposal": "20190723",
"title": "In situ heat treatment of Transformation Induced Plasticity High Entropy Alloys: engineering the microstructure for optimum mechanical properties.",
"firstname": "Efthymios",
"lastname": "Polatidis",
"email": "polatidis@upatras.gr",
"account": "",
"pi_firstname": "Efthymios",
"pi_lastname": "Polatidis",
"pi_email": "polatidis@upatras.gr",
"pi_account": "",
"eaccount": "e17932",
"pgroup": "p17932",
"abstract": "High Entropy Alloys (HEAs) are becoming increasingly important structural materials for numerous engineering applications due to their excellent strength/ductility combination. The proposed material is a novel Al-containing HEA, processed by friction stirring and subsequent annealing, which exhibits the transformation induced plasticity (TRIP) effect. Upon annealing, the parent fcc phase transforms into hcp martensitically which strongly affects the mechanical properties. The main goal of this experiment is to investigate the evolution of phases in this TRIP-HEA, upon isothermal annealing at different temperatures. Obtaining insight into the mechanisms of phase formation during annealing, would aid designing processing methods and tailoring the microstructure with a view to optimizing the mechanical behavior.",
"schedule": [{"start": "08/07/2019 07:00:00", "end": "09/07/2019 07:00:00"}],
"proposal_submitted": "15/03/2019",
"proposal_expire": "31/12/2019",
"proposal_status": "Finished",
"delta_last_schedule": 2258,
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}
from bec_lib.messages import ExperimentInfoMessage

View File

@@ -1,7 +1,7 @@
import json
from pydantic import BaseModel
from qtpy.QtCore import QUrl, Signal
from qtpy.QtCore import QUrl, QUrlQuery, Signal
from qtpy.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
from qtpy.QtWidgets import QMessageBox, QWidget
@@ -21,6 +21,7 @@ class BECAtlasHTTPService(QWidget):
http_response_received = Signal(dict)
authenticated = Signal(bool)
account_changed = Signal(bool)
def __init__(self, parent=None, base_url: str = "", headers: dict | None = None):
super().__init__(parent)
@@ -68,6 +69,10 @@ class BECAtlasHTTPService(QWidget):
elif "logout" in request_url and status == 200:
self._authenticated = False
self.authenticated.emit(False)
if "deployments/experiment" in request_url and status == 200:
self.account_changed.emit(True)
elif "deployments/experiment" in request_url and status != 200:
self.account_changed.emit(False)
# TODO, should we handle failures here or rather on more high levels?
if status == 401:
@@ -115,28 +120,44 @@ class BECAtlasHTTPService(QWidget):
# HTTP Methods
################
def get_request(self, endpoint: str):
def get_request(self, endpoint: str, query_parameters: dict | None = None):
"""
GET request to the API endpoint.
Args:
endpoint (str): The API endpoint to send the GET request to.
query_parameters (dict | None): Optional query parameters to include in the URL.
"""
url = QUrl(self._base_url + endpoint)
if query_parameters:
query = QUrlQuery()
for key, value in query_parameters.items():
query.addQueryItem(key, value)
url.setQuery(query)
request = QNetworkRequest(url)
for key, value in self._headers.items():
request.setRawHeader(key.encode("utf-8"), value.encode("utf-8"))
self.network_manager.get(request)
def post_request(self, endpoint: str, payload: dict):
def post_request(
self, endpoint: str, payload: dict | None = None, query_parameters: dict | None = None
):
"""
POST request to the API endpoint with a JSON payload.
Args:
endpoint (str): The API endpoint to send the POST request to.
payload (dict): The JSON payload to include in the POST request.
query_parameters (dict | None): Optional query parameters to include in the URL.
"""
if payload is None:
payload = {}
url = QUrl(self._base_url + endpoint)
if query_parameters:
query = QUrlQuery()
for key, value in query_parameters.items():
query.addQueryItem(key, value)
url.setQuery(query)
request = QNetworkRequest(url)
# Headers
@@ -146,7 +167,6 @@ class BECAtlasHTTPService(QWidget):
payload_dump = json.dumps(payload).encode("utf-8")
reply = self.network_manager.post(request, payload_dump)
reply.finished.connect(lambda: self._handle_reply(reply))
################
# API Methods
@@ -166,13 +186,16 @@ class BECAtlasHTTPService(QWidget):
"""Check the health status of BEC Atlas."""
self.get_request("/health")
def get_realms(self, include_deployments: bool = True):
def get_experiments_for_realm(self, realm_id: str):
"""Get the list of realms from BEC Atlas. Requires authentication."""
if not self._authenticated:
self._show_login()
endpoint = "/realms/experiments"
query_parameters = {"realm_id": realm_id}
self.get_request(endpoint, query_parameters=query_parameters)
# Requires authentication
endpoint = "/realms"
if include_deployments:
endpoint += "?include_deployments=true"
self.get_request(endpoint)
@SafeSlot(str, str)
def set_experiment(self, experiment_id: str, deployment_id: str) -> None:
"""Set the current experiment information for the service."""
self.post_request(
"/deployments/experiment",
query_parameters={"experiment_id": experiment_id, "deployment_id": deployment_id},
)

View File

@@ -42,6 +42,7 @@ class ExperimentMatCard(BECWidget, QWidget):
self,
parent=None,
show_activate_button: bool = True,
button_text: str = "Activate",
title: str = "Next Experiment",
**kwargs,
):
@@ -73,7 +74,9 @@ class ExperimentMatCard(BECWidget, QWidget):
self._group_box.setStyleSheet(
"QGroupBox { border: none; }; QLabel { border: none; padding: 0px; }"
)
self._fill_group_box(title=title, show_activate_button=show_activate_button)
self._fill_group_box(
title=title, show_activate_button=show_activate_button, button_text=button_text
)
self.apply_theme("light")
def apply_theme(self, theme: str):
@@ -88,7 +91,9 @@ class ExperimentMatCard(BECWidget, QWidget):
if isinstance(shadow, QGraphicsDropShadowEffect):
shadow.setColor(palette.shadow().color())
def _fill_group_box(self, title: str, show_activate_button: bool):
def _fill_group_box(
self, title: str, show_activate_button: bool, button_text: str = "Activate"
):
group_layout = QVBoxLayout(self._group_box)
group_layout.setContentsMargins(16, 16, 16, 16)
group_layout.setSpacing(12)
@@ -149,8 +154,11 @@ class ExperimentMatCard(BECWidget, QWidget):
group_layout.addWidget(self._abstract_label)
# Add activate button at the bottom
self._activate_button = QPushButton("Activate", self._group_box)
self._activate_button = QPushButton(button_text, self._group_box)
self._activate_button.clicked.connect(self._emit_next_experiment)
self._activate_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
group_layout.addWidget(self._activate_button, alignment=Qt.AlignmentFlag.AlignHCenter)
self._activate_button.setVisible(show_activate_button)
@@ -167,12 +175,7 @@ class ExperimentMatCard(BECWidget, QWidget):
layout.addLayout(card_row)
layout.addStretch(0)
def _remove_border_from_labels(self):
for label in self._group_box.findChildren(BorderLessLabel):
label.setStyleSheet("border: none;")
def _emit_next_experiment(self):
print("Emitting next experiment signal with info:", self.experiment_info)
self.experiment_selected.emit(self.experiment_info)
def set_experiment_info(self, info: ExperimentInfoMessage | dict):
@@ -215,26 +218,26 @@ if __name__ == "__main__":
exp_info = {
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22622"],
"realm_id": "Debye",
"proposal": "20250656",
"title": "In-situ XAS Investigation of Cu Single-Atom Catalysts under Pulsed Electrochemical CO2 reduction reaction",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "Adam",
"pi_lastname": "Clark",
"pi_email": "adam.clark@psi.ch",
"pi_account": "clark_a",
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "Some cool abstract which is now a very long text to test the popup functionality. This should be at least 500 characters long to ensure the popup can handle large amounts of text without issues. So text wrapping will not pose any problems.", # "",
"schedule": [{"start": "27/06/2025 15:00:00", "end": "30/06/2025 07:00:00"}],
"proposal_submitted": "13/06/2025",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Finished",
"delta_last_schedule": 187,
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
}

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Any
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QCheckBox,
QHBoxLayout,
@@ -12,6 +12,7 @@ from qtpy.QtWidgets import (
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QTabWidget,
@@ -83,11 +84,16 @@ class ExperimentSelection(QWidget):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(16, 16, 16, 16)
main_layout.setSpacing(12)
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setAutoFillBackground(True)
self._tabs = QTabWidget(self)
main_layout.addWidget(self._tabs, stretch=1)
self._card_tab = ExperimentMatCard(parent=self, show_activate_button=False)
self._card_tab = ExperimentMatCard(
parent=self, show_activate_button=True, button_text="Activate Next Experiment"
)
if self._next_experiment:
self._card_tab.set_experiment_info(self._next_experiment)
self._table_tab = QWidget(self)
@@ -102,12 +108,21 @@ class ExperimentSelection(QWidget):
self._select_button = QPushButton("Activate", self)
self._select_button.setEnabled(False)
self._select_button.clicked.connect(self._emit_selected_experiment)
self._cancel_button = QPushButton("Cancel", self)
self._cancel_button.clicked.connect(self.close)
button_layout.addWidget(self._select_button)
button_layout.addWidget(self._cancel_button)
main_layout.addLayout(button_layout)
self._apply_table_filters()
self.restore_default_view()
def restore_default_view(self):
"""Reset the view to the default state, showing the next experiment card."""
self._tabs.setCurrentWidget(self._card_tab)
def set_experiment_infos(self, experiment_infos: list[dict]):
self._experiment_infos = experiment_infos
self._next_experiment = self._select_next_experiment(self._experiment_infos)
if self._next_experiment:
self._card_tab.set_experiment_info(self._next_experiment)
self._apply_table_filters()
def _setup_search(self, layout: QVBoxLayout):
"""
@@ -216,7 +231,9 @@ class ExperimentSelection(QWidget):
layout.addLayout(hor_layout)
@SafeSlot()
def _apply_table_filters(self):
@SafeSlot(int)
@SafeSlot(bool) # Overload for buttons
def _apply_table_filters(self, *args, **kwargs):
if self._tabs.currentWidget() is not self._table_tab:
self._select_button.setEnabled(True)
return
@@ -237,6 +254,9 @@ class ExperimentSelection(QWidget):
self._update_selection_state()
def _populate_table(self):
# Clear table before populating, this keeps headers intact
self._table.setRowCount(0)
# Refill table
self._table.setRowCount(len(self._table_infos))
for row, info in enumerate(self._table_infos):
pgroup = info.get("pgroup", "")
@@ -347,408 +367,55 @@ if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
experiment_infos = [
{
"_id": "p22619",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22619"],
"realm_id": "Debye",
"proposal": "20250267",
"title": "Iridium-Tantalum Mixed Metal Oxides for the Acidic Oxygen Evolution Reaction",
"firstname": "Andreas",
"lastname": "Göpfert",
"email": "a.goepfert@fz-juelich.de",
"account": "",
"pi_firstname": "Andreas",
"pi_lastname": "Göpfert",
"pi_email": "a.goepfert@fz-juelich.de",
"pi_account": "",
"eaccount": "e22619",
"pgroup": "p22619",
"abstract": "The coordination environment, the electronic structure, and the interatomic distance of the different Ta- and Ir-based nanocrystalline electrocatalysts need to be examined to prove the structure of the catalysts. XANES and EXAFS spectra of the Ir and Ta L3-edge need to be recorded.",
"schedule": [
{"start": "23/07/2025 23:00:00", "end": "24/07/2025 07:00:00"},
{"start": "24/07/2025 23:00:00", "end": "27/07/2025 15:00:00"},
],
"proposal_submitted": "07/05/2025",
"proposal_expire": "",
"proposal_status": "Finished",
"delta_last_schedule": 160,
"mainproposal": "",
},
{
"_id": "p22622",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22622"],
"realm_id": "Debye",
"proposal": "20250656",
"title": "In-situ XAS Investigation of Cu Single-Atom Catalysts under Pulsed Electrochemical CO2 reduction reaction",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "Adam",
"pi_lastname": "Clark",
"pi_email": "adam.clark@psi.ch",
"pi_account": "clark_a",
"access_groups": ["unx-sls_xda_bs", "p22622"],
"realm_id": "TestBeamline",
"proposal": "12345967",
"title": "Test Experiment for Mat Card Widget",
"firstname": "John",
"lastname": "Doe",
"email": "john.doe@psi.ch",
"account": "doe_j",
"pi_firstname": "Jane",
"pi_lastname": "Smith",
"pi_email": "jane.smith@psi.ch",
"pi_account": "smith_j",
"eaccount": "e22622",
"pgroup": "p22622",
"abstract": "",
"schedule": [{"start": "27/06/2025 15:00:00", "end": "30/06/2025 07:00:00"}],
"proposal_submitted": "13/06/2025",
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
"proposal_submitted": "15/12/2024",
"proposal_expire": "31/12/2025",
"proposal_status": "Finished",
"delta_last_schedule": 187,
"proposal_status": "Scheduled",
"delta_last_schedule": 30,
"mainproposal": "",
},
{
"_id": "p22621",
"_id": "p22623",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22621"],
"realm_id": "Debye",
"proposal": "20250681",
"title": "Tracking Fe dynamics and coordination in N2O-mediated red-ox reactions",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "Adam",
"pi_lastname": "Clark",
"pi_email": "adam.clark@psi.ch",
"pi_account": "clark_a",
"eaccount": "e22621",
"pgroup": "p22621",
"access_groups": ["unx-sls_xda_bs", "p22623"],
"realm_id": "TestBeamline",
"proposal": "",
"title": "Experiment without Proposal",
"firstname": "Alice",
"lastname": "Johnson",
"email": "alice.johnson@psi.ch",
"account": "johnson_a",
"pi_firstname": "Bob",
"pi_lastname": "Brown",
"pi_email": "bob.brown@psi.ch",
"pi_account": "brown_b",
"eaccount": "e22623",
"pgroup": "p22623",
"abstract": "",
"schedule": [{"start": "09/07/2025 15:00:00", "end": "12/07/2025 15:00:00"}],
"proposal_submitted": "25/06/2025",
"proposal_expire": "31/12/2025",
"proposal_status": "Finished",
"delta_last_schedule": 175,
"mainproposal": "",
},
{
"_id": "p22481",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22481"],
"realm_id": "Debye",
"proposal": "",
"title": "p22481",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e22481",
"pgroup": "p22481",
"abstract": "Debye beamline commissioning pgroup",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"delta_last_schedule": None,
"mainproposal": None,
},
{
"_id": "p22540",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22540"],
"realm_id": "Debye",
"proposal": "",
"title": "p22540",
"firstname": "Markus",
"lastname": "Knecht",
"email": "markus.knecht@psi.ch",
"account": "knecht_m",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e22540",
"pgroup": "p22540",
"abstract": "Yet another testaccount",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"delta_last_schedule": None,
"mainproposal": None,
},
{
"_id": "p22890",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22890"],
"realm_id": "Debye",
"proposal": "",
"title": "p22890",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e22890",
"pgroup": "p22890",
"abstract": "Debye Beamline E-account",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"delta_last_schedule": None,
"mainproposal": None,
},
{
"_id": "p22900",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22900"],
"realm_id": "Debye",
"proposal": "",
"title": "p22900",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e22900",
"pgroup": "p22900",
"abstract": "",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"delta_last_schedule": None,
"mainproposal": None,
},
{
"_id": "p22901",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22901"],
"realm_id": "Debye",
"proposal": "",
"title": "p22901",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e22901",
"pgroup": "p22901",
"abstract": "",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"delta_last_schedule": None,
"mainproposal": None,
},
{
"_id": "p19492",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p19492"],
"realm_id": "Debye",
"proposal": "",
"title": "p19492",
"firstname": "Klaus",
"lastname": "Wakonig",
"email": "klaus.wakonig@psi.ch",
"account": "wakonig_k",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e19492",
"pgroup": "p19492",
"abstract": "BEC tests",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"delta_last_schedule": None,
"mainproposal": None,
},
{
"_id": "p22914",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22914"],
"realm_id": "Debye",
"proposal": "20250676",
"title": "ReMade: Monitoring tin speciation in zeolites for renewable sugar catalysis",
"firstname": "Gleb",
"lastname": "Ivanushkin",
"email": "gleb.ivanushkin@kuleuven.be",
"account": "",
"pi_firstname": "Gleb",
"pi_lastname": "Ivanushkin",
"pi_email": "gleb.ivanushkin@kuleuven.be",
"pi_account": "",
"eaccount": "e22914",
"pgroup": "p22914",
"abstract": "Efficient conversion of renewable feedstocks, such as biomass, into fuels and chemicals is crucial for a sustainable chemical industry. While there is a vast amount of literature available on the catalytic properties of Sn-Beta, the Lewis acid site chemistry has never been assessed in situ under relevant industrial conditions. We propose (1) an in situ XAS investigation of sugar conversion on tin-containing zeolites of different loading and synthesis origin. Since we also speculate that the pore opening size could vary in the materials, depending on the method of preparation, the investigation will be focused on the conversion of larger substrates rather than dihydroxy acetone.",
"schedule": [{"start": "13/11/2025 07:00:00", "end": "16/11/2025 07:00:00"}],
"proposal_submitted": "23/06/2025",
"schedule": [],
"proposal_submitted": "",
"proposal_expire": "",
"proposal_status": "Finished",
"delta_last_schedule": 49,
"mainproposal": "",
},
{
"_id": "p22979",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22979"],
"realm_id": "Debye",
"proposal": "20250865",
"title": "Studying the dynamic Fe speciation in Fe-ZSM5 for low-temperature liquid phase methane partial oxidation",
"firstname": "John Mark Christian",
"lastname": "Dela Cruz",
"email": "john.dela-cruz@psi.ch",
"account": "delacr_j",
"pi_firstname": "Maarten",
"pi_lastname": "Nachtegaal",
"pi_email": "maarten.nachtegaal@psi.ch",
"pi_account": "nachtegaal",
"eaccount": "e22979",
"pgroup": "p22979",
"abstract": "This operando XAS study aims to investigate the evolution of Fe speciation in ZSM-5 under low-temperature (<90 °C) liquid-phase conditions during the partial oxidation of methane to methanol. These reaction conditions remain largely unexplored, and the exact reaction mechanism at the active site is still unresolved. Most previous in situ experiments have not been representative of actual catalytic testing environments. To address this gap, we employ a capillary flow reactor that enables both operando XAS measurements and catalytic testing under relevant conditions.",
"schedule": [{"start": "04/12/2025 07:00:00", "end": "05/12/2025 07:00:00"}],
"proposal_submitted": "20/08/2025",
"proposal_expire": "31/12/2025",
"proposal_status": "Finished",
"delta_last_schedule": 28,
"mainproposal": "",
},
{
"_id": "p22978",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22978"],
"realm_id": "Debye",
"proposal": "20250867",
"title": "Synthesis-Dependent Redox Dynamics of Fe-Zeolites in NO-Assisted N2O Decomposition",
"firstname": "Gabriela-Teodora",
"lastname": "Dutca",
"email": "gabriela-teodora.dutca@psi.ch",
"account": "dutca_g",
"pi_firstname": "Gabriela-Teodora",
"pi_lastname": "Dutca",
"pi_email": "gabriela-teodora.dutca@psi.ch",
"pi_account": "dutca_g",
"eaccount": "e22978",
"pgroup": "p22978",
"abstract": "This study focuses on the investigation of the redox and coordination dynamics of Fe ions in Fe-zeolites during their interaction with N2O in N2O decomposition as well as with NO and N2O simultaneously in NO-assisted N2O decomposition. To this end, time-resolved quick-XAS will be employed at the Fe K-edge in transient experiments. These will allow us to capture transient redox changes on a (sub)second timescale, enabling direct correlation between the extent of redox dynamics of Fe ions and the synthesis method of the Fe zeolites. The results will provide insights into the influence of synthesis methods on active site evolution under reaction conditions, guiding the rational design of improved Fe zeolite catalysts.",
"schedule": [{"start": "05/12/2025 07:00:00", "end": "08/12/2025 07:00:00"}],
"proposal_submitted": "20/08/2025",
"proposal_expire": "31/12/2025",
"proposal_status": "Finished",
"delta_last_schedule": 27,
"mainproposal": "",
},
{
"_id": "p22977",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22977"],
"realm_id": "Debye",
"proposal": "20250871",
"title": "Towards Atomic-Level Insight into Rhenium Surface Dispersion on TiO2 for Low-Temperature and High Pressure Methanol Synthesis from CO2 Hydrogenation",
"firstname": "Iván",
"lastname": "López Luque",
"email": "i.ivanlopezluque@tudelft.nl",
"account": "ext-lopezl_i",
"pi_firstname": "Iván",
"pi_lastname": "López Luque",
"pi_email": "i.ivanlopezluque@tudelft.nl",
"pi_account": "ext-lopezl_i",
"eaccount": "e22977",
"pgroup": "p22977",
"abstract": "We propose operando XAS/XRD experiments at the PSI Debye beamline to resolve the atomic-scale evolution of Re/TiO2 catalysts during CO2 hydrogenation. Debyes high flux and stability are essential for tracking subtle changes at the Re L3-edge under in situ calcination, reduction, and reaction conditions. Real-time XANES will monitor redox dynamics, while room-temperature EXAFS and simultaneous XRD will reveal coordination and structural evolution. The beamlines unique energy range and operando cell compatibility make it ideally suited to establish correlations between Re dispersion, support interactions, and catalytic performance under realistic conditions.",
"schedule": [{"start": "12/12/2025 07:00:00", "end": "15/12/2025 07:00:00"}],
"proposal_submitted": "19/08/2025",
"proposal_expire": "31/12/2025",
"proposal_status": "Finished",
"delta_last_schedule": 20,
"mainproposal": "",
},
{
"_id": "p22976",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p22976"],
"realm_id": "Debye",
"proposal": "20250876",
"title": "Operando Pd K-edge XAS analysis of an active phase in a multicomponent catalyst enabling enhanced MTH performance",
"firstname": "Matteo",
"lastname": "Vanni",
"email": "matteo.vanni@psi.ch",
"account": "",
"pi_firstname": "Vladimir",
"pi_lastname": "Paunovic",
"pi_email": "vladimir.paunovic@psi.ch",
"pi_account": "paunovic_v",
"eaccount": "e22976",
"pgroup": "p22976",
"abstract": "The conversion of methanol into hydrocarbons (MTH) over one-dimensional zeolites offers a promising route to sustainable olefins and fuels, but suffers from rapid catalyst deactivation. Incorporation of Pd into the catalyst formulation, combined with H2 cofeeds, significantly extends catalyst lifetime after an initial induction period. Using operando XAS, we aim to identify the Pd phase responsible for the enhanced olefin selectivity and prolonged catalyst stability, and to elucidate the dynamics of Pd restructuring at the onset of the reaction.",
"schedule": [{"start": "20/11/2025 07:00:00", "end": "22/11/2025 07:00:00"}],
"proposal_submitted": "20/08/2025",
"proposal_expire": "31/12/2025",
"proposal_status": "Finished",
"delta_last_schedule": 42,
"mainproposal": "",
},
{
"_id": "p23034",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p23034"],
"realm_id": "Debye",
"proposal": "",
"title": "p23034",
"firstname": "Daniele",
"lastname": "Bonavia",
"email": "daniele.bonavia@psi.ch",
"account": "",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e23034",
"pgroup": "p23034",
"abstract": "creation of eaccount to make a pgroup for daniele",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"proposal_status": "",
"delta_last_schedule": None,
"mainproposal": None,
},
{
"_id": "p23039",
"owner_groups": ["admin"],
"access_groups": ["unx-sls_x01da_bs", "p23039"],
"realm_id": "Debye",
"proposal": "",
"title": "p23039",
"firstname": "Adam",
"lastname": "Clark",
"email": "adam.clark@psi.ch",
"account": "clark_a",
"pi_firstname": "",
"pi_lastname": "",
"pi_email": "",
"pi_account": "",
"eaccount": "e23039",
"pgroup": "p23039",
"abstract": "shell",
"schedule": [{}],
"proposal_submitted": None,
"proposal_expire": None,
"proposal_status": None,
"delta_last_schedule": None,
"mainproposal": None,
"mainproposal": "",
},
]

View File

@@ -19,7 +19,7 @@ def format_schedule(
) -> tuple[str, str] | tuple[datetime | None, datetime | None]:
"""Format the schedule information to display start and end times."""
if not schedule:
return "", ""
return (None, None) if as_datetime else ("", "")
start, end = _pick_schedule_entry(schedule)
if as_datetime:
return start, end