diff --git a/bec_widgets/applications/views/admin_view/admin_view.py b/bec_widgets/applications/views/admin_view/admin_view.py index 7c424565..3787b336 100644 --- a/bec_widgets/applications/views/admin_view/admin_view.py +++ b/bec_widgets/applications/views/admin_view/admin_view.py @@ -1,11 +1,39 @@ """Module for Admin View.""" +import os + from qtpy.QtWidgets import QWidget from bec_widgets.applications.views.view import ViewBase from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView +ADMIN_UI_ENV = "BEC_ADMIN_UI" + + +def create_admin_widget(parent: QWidget | None = None) -> QWidget: + """Create the admin panel selected by ``BEC_ADMIN_UI``. + + ``qml`` and ``qwidget`` select the reworked view with the guided experiment switch; anything + else keeps the current :class:`BECAtlasAdminView`. + """ + choice = os.environ.get(ADMIN_UI_ENV, "").strip().lower() + if choice == "qml": + # pylint: disable=import-outside-toplevel + from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qml import ( + AdminViewQML, + ) + + return AdminViewQML(parent=parent) + if choice == "qwidget": + # pylint: disable=import-outside-toplevel + from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qwidget import ( + AdminViewQWidget, + ) + + return AdminViewQWidget(parent=parent) + return BECAtlasAdminView(parent=parent) + class AdminView(ViewBase): """ @@ -23,7 +51,7 @@ class AdminView(ViewBase): **kwargs, ): super().__init__(parent=parent, content=content, view_id=view_id, title=title) - self.admin_widget = BECAtlasAdminView(parent=self) + self.admin_widget = create_admin_widget(parent=self) self.set_content(self.admin_widget) @SafeSlot() diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/__init__.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/__init__.py new file mode 100644 index 00000000..eb00f20b --- /dev/null +++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/__init__.py @@ -0,0 +1 @@ +"""Reworked admin view with QML and QWidget renderings of the same experiment-switch flow.""" diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_demo.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_demo.py new file mode 100644 index 00000000..49daf0db --- /dev/null +++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_demo.py @@ -0,0 +1,79 @@ +"""Demo data for the reworked admin view, used by its tests and screenshots. + +Beamtimes are placed relative to today, so "next" and "past" stay meaningful. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from bec_lib.messages import ( + DeploymentInfoMessage, + ExperimentInfoMessage, + MessagingConfig, + MessagingServiceScopeConfig, + SessionInfoMessage, +) + +_PEOPLE = { + "p22622": ("Jane", "Smith", "In-situ SAXS of lipid nanoparticle self-assembly", -2, 3), + "p22623": ("Marco", "Rossi", "Time-resolved WAXS on cycling battery cathodes", 3, 3), + "p22631": ("Aiko", "Tanaka", "Ptychographic X-ray tomography of trabecular bone", 11, 4), + "p22652": ("Lena", "Fischer", "Coherent diffraction imaging of strained nanocrystals", 33, 2), + "p22604": ("Klaus", "Weber", "Detector distance calibration and flat-field update", -24, 1), + "p22640": ("Beamline", "Staff", "Staff test and alignment account", None, None), +} + + +def _schedule(start_offset: int | None, length: int | None, now: datetime) -> list[dict]: + if start_offset is None: + return [] + start = (now + timedelta(days=start_offset)).replace(hour=8, minute=0, second=0) + end = (start + timedelta(days=length)).replace(hour=8) + fmt = "%d/%m/%Y %H:%M:%S" + return [{"start": start.strftime(fmt), "end": end.strftime(fmt)}] + + +def demo_experiments(realm: str = "X12SA", now: datetime | None = None) -> list[dict]: + """Experiments of one realm as BEC Atlas lists them.""" + now = now or datetime.now() + infos = [] + for pgroup, (first, last, title, offset, length) in _PEOPLE.items(): + number = pgroup[1:] + infos.append( + ExperimentInfoMessage( + realm_id=realm, + proposal="" if pgroup == "p22640" else f"2026{number}", + title=title, + firstname=first, + lastname=last, + email=f"{first.lower()}.{last.lower()}@psi.ch", + account=f"{last.lower()}_{first[0].lower()}", + pi_firstname=first, + pi_lastname=last, + pi_email=f"{first.lower()}.{last.lower()}@psi.ch", + pi_account=f"{last.lower()}_{first[0].lower()}", + eaccount=f"e{number}", + pgroup=pgroup, + abstract=( + f"{title}. The group combines scanning and full-field measurements to follow " + "structural changes in situ, with automated data reduction during the beamtime." + ), + schedule=_schedule(offset, length, now), + ).model_dump() + ) + return infos + + +def demo_deployment(active_pgroup: str = "p22622", realm: str = "X12SA") -> DeploymentInfoMessage: + """Deployment info with ``active_pgroup`` as the active experiment.""" + experiment = next(e for e in demo_experiments(realm) if e["pgroup"] == active_pgroup) + scope = MessagingServiceScopeConfig(enabled=False) + return DeploymentInfoMessage( + deployment_id="dep-x12sa", + name="cSAXS", + active_session=SessionInfoMessage( + name="default", experiment=ExperimentInfoMessage.model_validate(experiment) + ), + messaging_config=MessagingConfig(signal=scope, teams=scope, scilog=scope), + ) diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_ux_common.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_ux_common.py new file mode 100644 index 00000000..b5f9af73 --- /dev/null +++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_ux_common.py @@ -0,0 +1,787 @@ +"""State and behaviour shared by the QML and QWidget versions of the reworked admin view. + +The admin view lets beamline staff sign in to BEC Atlas and switch the active experiment. +Switching the experiment changes the BEC account (the p-group) that new scans are written to, +and the experiment's group works on the beamline computers under its own Linux account (the +e-account). The old view did not say any of this; this module turns it into an explicit +three-step flow (review, confirm, log in as the new account) and a notice that compares the +Linux login of this computer with the account of the active experiment. + +:class:`AdminViewBase` owns the Atlas connection and all state and renders it as one plain +dictionary (:meth:`AdminViewBase.view_state`). The QML and QWidget subclasses only draw that +dictionary and call the public slots, so both show exactly the same UX. +""" + +from __future__ import annotations + +import getpass +import re +import time +from datetime import datetime + +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from bec_lib.messages import DeploymentInfoMessage, ExperimentInfoMessage +from qtpy.QtCore import QTimer, Signal +from qtpy.QtNetwork import QNetworkReply, QNetworkRequest +from qtpy.QtWidgets import 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_http_service import ( + AtlasEndpoints, + AuthenticatedUserInfo, + BECAtlasHTTPService, + HTTPResponse, +) +from bec_widgets.widgets.services.bec_atlas_admin_view.experiment_selection.utils import ( + format_name, + format_schedule, +) + +logger = bec_logger.logger + +DEFAULT_ATLAS_URL = "https://bec-atlas-prod.psi.ch/api/v1" + +SECTIONS = [ + {"id": "experiment", "label": "Experiment", "icon": "science", "enabled": True, "badge": ""}, + {"id": "messaging", "label": "Messaging", "icon": "chat", "enabled": False, "badge": "Soon"}, +] +"""Sections of the signed-in admin view. Later staff tools are added here.""" + +WIZARD_STEPS = ["Review", "Confirm", "Log in"] + +HOW_IT_WORKS = [ + "Pick the experiment from the list. Nothing changes yet.", + "Review what changes and confirm. New data then goes to the new p-group.", + "Everyone on this computer logs out and logs in as the experiment's Linux account.", +] + +_EACCOUNT = re.compile(r"^e\d{5}$") + +STATUS_LABELS = { + "active": "Active", + "now": "Beamtime now", + "next": "Next", + "upcoming": "Upcoming", + "past": "Past", + "unscheduled": "No beamtime", +} + + +######################## +## Pure helpers +######################## + + +def linux_account_for(info: ExperimentInfoMessage | dict | None) -> str: + """Return the Linux account the experiment's group logs in with, e.g. ``e22622``. + + Args: + info(ExperimentInfoMessage | dict | None): Experiment information. + + Returns: + str: The e-account, derived from the p-group if Atlas did not send one, else ``""``. + """ + if info is None: + return "" + if isinstance(info, dict): + eaccount = info.get("eaccount") or "" + pgroup = info.get("pgroup") or "" + else: + eaccount = info.eaccount or "" + pgroup = info.pgroup or "" + if eaccount: + return eaccount + if re.match(r"^p\d{5}$", pgroup): + return f"e{pgroup[1:]}" + return "" + + +def format_beamtime(start: datetime | None, end: datetime | None) -> str: + """Format a beamtime as ``12 Oct 2026, 08:00 – 14 Oct, 18:00``.""" + if start is None: + return "No beamtime scheduled" + text = f"{start:%d %b %Y, %H:%M}" + if end is None: + return text + if end.date() == start.date(): + return f"{text} – {end:%H:%M}" + end_format = "%d %b, %H:%M" if end.year == start.year else "%d %b %Y, %H:%M" + return f"{text} – {end.strftime(end_format)}" + + +def select_next_pgroup(experiments: list[dict], now: datetime | None = None) -> str: + """Return the p-group of the experiment whose beamtime starts next (or runs now).""" + now = now or datetime.now() + best = None + for info in experiments: + start, end = format_schedule(info.get("schedule"), as_datetime=True) + if start is None: + continue + running = end is not None and start <= now <= end + if start < now and not running: + continue + key = (0 if running else 1, abs((start - now).total_seconds())) + if best is None or key < best[0]: + best = (key, info.get("pgroup", "")) + return best[1] if best else "" + + +def experiment_summary( + info: ExperimentInfoMessage | dict | None, + *, + active_pgroup: str = "", + next_pgroup: str = "", + now: datetime | None = None, +) -> dict: + """Flatten experiment information into the fields both views display. + + Args: + info(ExperimentInfoMessage | dict | None): Experiment information from Atlas. + active_pgroup(str): P-group of the active experiment, to mark it. + next_pgroup(str): P-group of the next scheduled experiment, to mark it. + now(datetime | None): Reference time, for tests. + + Returns: + dict: Display fields; empty if ``info`` is None. + """ + if info is None: + return {} + if isinstance(info, ExperimentInfoMessage): + info = info.model_dump() + now = now or datetime.now() + pgroup = info.get("pgroup") or "" + start, end = format_schedule(info.get("schedule"), as_datetime=True) + if pgroup and pgroup == active_pgroup: + status = "active" + elif start is None: + status = "unscheduled" + elif end is not None and start <= now <= end: + status = "now" + elif pgroup and pgroup == next_pgroup: + status = "next" + elif start > now: + status = "upcoming" + else: + status = "past" + try: + name = format_name(info) + except Exception: # pylint: disable=broad-except + name = " ".join(p for p in [info.get("firstname"), info.get("lastname")] if p) + pi = " ".join(p for p in [info.get("pi_firstname"), info.get("pi_lastname")] if p) + return { + "pgroup": pgroup, + "title": info.get("title") or "Untitled experiment", + "pi": pi or name or "—", + "contact": name or "—", + "email": info.get("pi_email") or info.get("email") or "", + "proposal": info.get("proposal") or "", + "hasProposal": bool(info.get("proposal")), + "linuxAccount": linux_account_for(info), + "dataAccount": pgroup, + "beamtime": format_beamtime(start, end), + "startSort": start.timestamp() if start else float("inf"), + "status": status, + "statusLabel": STATUS_LABELS[status], + "isActive": status == "active", + "isNext": pgroup != "" and pgroup == next_pgroup, + "abstract": (info.get("abstract") or "").strip(), + } + + +def filter_experiments(rows: list[dict], query: str = "", scope: str = "upcoming") -> list[dict]: + """Filter and order experiment summaries for the switch list. + + Args: + rows(list[dict]): Summaries from :func:`experiment_summary`. + query(str): Case-insensitive text matched against p-group, title, people and accounts. + scope(str): ``"upcoming"`` hides past beamtimes, ``"all"`` shows everything. + + Returns: + list[dict]: The active experiment first, then by beamtime start; unscheduled last. + """ + words = query.lower().split() + result = [] + for row in rows: + if scope == "upcoming" and row["status"] == "past": + continue + haystack = " ".join( + str(row.get(key, "")) + for key in ("pgroup", "title", "pi", "contact", "linuxAccount", "proposal") + ).lower() + if all(word in haystack for word in words): + result.append(row) + return sorted(result, key=lambda r: (not r["isActive"], r["startSort"], r["pgroup"])) + + +def account_notice(system_user: str, active: dict) -> dict: + """Compare the Linux login of this computer with the active experiment's account. + + Args: + system_user(str): Linux user running this BEC app. + active(dict): Summary of the active experiment. + + Returns: + dict: ``tone`` (``ok``, ``info`` or ``warning``), ``title`` and ``text``; empty if there + is no active experiment. + """ + account = active.get("linuxAccount", "") if active else "" + if not account: + return {} + if system_user == account: + return { + "tone": "ok", + "title": f"Logged in as {account}", + "text": f"This computer runs under the account of the active experiment {active['pgroup']}.", + } + if _EACCOUNT.match(system_user or ""): + return { + "tone": "warning", + "title": f"Still logged in as {system_user}", + "text": ( + f"{system_user} belongs to a different experiment. Close BEC, log out and log in " + f"as {account} before the group of {active['pgroup']} continues." + ), + } + return { + "tone": "info", + "title": f"Logged in as {system_user or 'unknown'} (staff)", + "text": f"The group of {active['pgroup']} works on this computer as {account}.", + } + + +def switch_plan(current: dict, target: dict, system_user: str, queue_busy: bool = False) -> dict: + """Describe what switching from ``current`` to ``target`` changes, for the confirmation flow. + + Args: + current(dict): Summary of the active experiment (may be empty). + target(dict): Summary of the experiment to switch to. + system_user(str): Linux user running this BEC app. + queue_busy(bool): Whether a scan is running or queued. + + Returns: + dict: ``changes`` (before/after rows), ``consequences`` (what happens), + ``loginSteps`` (what to do afterwards), ``ackText`` and ``linuxAccount``. + """ + old_pgroup = current.get("pgroup") or "none" + new_pgroup = target["pgroup"] + new_account = target.get("linuxAccount") or "the experiment account" + old_account = current.get("linuxAccount") or "—" + changes = [ + {"label": "Experiment", "before": old_pgroup, "after": new_pgroup, "key": False}, + { + "label": "Title", + "before": current.get("title", "—"), + "after": target["title"], + "key": False, + }, + {"label": "PI", "before": current.get("pi", "—"), "after": target["pi"], "key": False}, + {"label": "Linux login", "before": old_account, "after": new_account, "key": True}, + {"label": "Data saved to", "before": old_pgroup, "after": new_pgroup, "key": True}, + ] + consequences = [ + { + "icon": "save", + "tone": "info", + "title": f"New scans are saved for {new_pgroup}", + "text": "From the next scan on, file paths and scan numbers follow the new experiment.", + }, + { + "icon": "folder_shared", + "tone": "info", + "title": f"Data already recorded stays with {old_pgroup}", + "text": "Nothing is moved or deleted. The previous group keeps access to its data.", + }, + { + "icon": "terminal", + "tone": "warning", + "title": "Open BEC sessions keep their Linux login", + "text": ( + f"This app and any bec terminal keep running as {system_user or 'the current user'}" + f". Close them so nobody keeps working under the previous login." + ), + }, + ] + if queue_busy: + consequences.append( + { + "icon": "pending_actions", + "tone": "warning", + "title": "A scan is running or queued", + "text": ( + f"The running scan finishes in {old_pgroup}'s folder; scans that start after " + f"the switch go to {new_pgroup}. Wait for the queue to be idle if unsure." + ), + } + ) + else: + consequences.append( + { + "icon": "check_circle", + "tone": "ok", + "title": "The scan queue is idle", + "text": "No scan is affected by switching now.", + } + ) + login_steps = [ + {"title": "Close BEC", "text": "Quit this app and every bec terminal on this computer."}, + { + "title": "Log out", + "text": f"Log out of the desktop session of {system_user or 'the current user'}.", + }, + { + "title": f"Log in as {new_account}", + "text": "Use the password of the experiment account, as given to the user group.", + }, + { + "title": "Start BEC again", + "text": f"The bec prompt then starts with {new_pgroup}, confirming the new experiment.", + }, + ] + return { + "changes": changes, + "consequences": consequences, + "loginSteps": login_steps, + "ackText": f"After switching, everyone on this computer logs in as {new_account}.", + "linuxAccount": new_account, + } + + +def format_remaining(seconds: float) -> str: + """Format a remaining session time as ``14:32`` or ``1:02:03``.""" + seconds = max(0, int(seconds)) + hours, rest = divmod(seconds, 3600) + minutes, secs = divmod(rest, 60) + return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}" + + +######################## +## Atlas service +######################## + + +class AdminAtlasService(BECAtlasHTTPService): + """Atlas HTTP service that reports failed sign-ins and switches inline instead of in popups.""" + + request_failed = Signal(str, str) # endpoint kind ("login", "switch", "experiments"), message + + _KINDS = { + AtlasEndpoints.LOGIN.value: "login", + AtlasEndpoints.SET_EXPERIMENT.value: "switch", + AtlasEndpoints.REALMS_EXPERIMENTS.value: "experiments", + } + + @SafeSlot(QNetworkReply, popup_error=True) + def _handle_response(self, reply: QNetworkReply): + status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + url = reply.url().toString() + kind = next((k for endpoint, k in self._KINDS.items() if endpoint in url), None) + if kind is None or status == 200: + super()._handle_response(reply) + return + reply.deleteLater() + self.request_failed.emit(kind, self._failure_text(kind, status)) + + def _failure_text(self, kind: str, status: int | None) -> str: + if not status: + return f"BEC Atlas is not reachable at {self._base_url}." + if kind == "login" and status in (401, 403): + return "Wrong username or password." + if kind == "switch": + return f"BEC Atlas refused the switch (HTTP {status}). The experiment was not changed." + if kind == "experiments": + return f"Could not load the experiments of this beamline (HTTP {status})." + return f"Request failed (HTTP {status})." + + def _show_warning(self, text: str): + self.request_failed.emit("login", text) + + def cleanup(self): + """Close the Atlas session once; the widget and its close event may both ask.""" + if getattr(self, "_cleaned_up", False): + return + self._cleaned_up = True + super().cleanup() + + +######################## +## Shared view base +######################## + + +class AdminViewBase(BECWidget, QWidget): + """Admin view logic shared by the QML and QWidget versions. + + Subclasses implement :meth:`_render`, which receives :meth:`view_state` after every change. + """ + + RPC = False + + authenticated = Signal(bool) + experiment_switched = Signal(str) + + def __init__( + self, + parent=None, + atlas_url: str = DEFAULT_ATLAS_URL, + client=None, + system_user: str | None = None, + **kwargs, + ): + super().__init__(parent=parent, client=client, **kwargs) + self._atlas_url = atlas_url + self._system_user = system_user if system_user is not None else getpass.getuser() + self._deployment: DeploymentInfoMessage | None = None + self._active_info: ExperimentInfoMessage | None = None + self._experiments: list[dict] = [] + self._loading_experiments = False + self._auth: AuthenticatedUserInfo | None = None + self._signing_in = False + self._sign_in_error = "" + self._section = "experiment" + self._query = "" + self._scope = "upcoming" + self._selected = "" + self._queue_busy = False + self._wizard = self._closed_wizard() + + self.atlas_http_service = AdminAtlasService( + parent=self, base_url=atlas_url, headers={"accept": "application/json"} + ) + self.atlas_http_service.hide() + self.atlas_http_service.http_response.connect(self._on_http_response) + self.atlas_http_service.authenticated.connect(self._on_authenticated) + self.atlas_http_service.request_failed.connect(self._on_request_failed) + + self._tick = QTimer(self) + self._tick.setInterval(1000) + self._tick.timeout.connect(self._on_tick) + + self.bec_dispatcher.connect_slot( + self._on_deployment_info, MessageEndpoints.deployment_info(), from_start=True + ) + self.bec_dispatcher.connect_slot( + self._on_queue_status, MessageEndpoints.scan_queue_status() + ) + + @staticmethod + def _closed_wizard() -> dict: + return { + "open": False, + "step": 0, + "target": "", + "ack": False, + "phase": "review", + "error": "", + "frozen": None, + "fromPgroup": "", + } + + ######################## + ## Rendering + ######################## + + def _render(self, state: dict) -> None: # pragma: no cover - implemented by subclasses + raise NotImplementedError + + def refresh(self) -> None: + """Re-render the view from the current state.""" + self._render(self.view_state()) + + def _summaries(self) -> tuple[dict, list[dict]]: + active_pgroup = self._active_info.pgroup if self._active_info else "" + next_pgroup = select_next_pgroup( + [e for e in self._experiments if e.get("pgroup") != active_pgroup] + ) + active = experiment_summary( + self._active_info, active_pgroup=active_pgroup, next_pgroup=next_pgroup + ) + rows = [ + experiment_summary(info, active_pgroup=active_pgroup, next_pgroup=next_pgroup) + for info in self._experiments + ] + return active, rows + + def view_state(self) -> dict: + """Return everything the views display as one plain dictionary.""" + active, all_rows = self._summaries() + rows = filter_experiments(all_rows, self._query, self._scope) + by_pgroup = {row["pgroup"]: row for row in all_rows} + selected = by_pgroup.get(self._selected, {}) + wizard = dict(self._wizard) + target = by_pgroup.get(wizard["target"], {}) + wizard["targetInfo"] = target + frozen = wizard.pop("frozen") + if frozen is not None: + # Keep showing the change as confirmed, even after the active experiment moved. + wizard["plan"], wizard["targetInfo"] = frozen + else: + wizard["plan"] = ( + switch_plan(active, target, self._system_user, self._queue_busy) if target else {} + ) + wizard["steps"] = WIZARD_STEPS + wizard["canConfirm"] = ( + bool(wizard["targetInfo"]) and wizard["ack"] and wizard["phase"] != "switching" + ) + deployment = self._deployment + remaining = (self._auth.exp - time.time()) if self._auth else 0 + return { + "deploymentName": deployment.name if deployment else "No deployment", + "realm": (self._active_info.realm_id if self._active_info else "") or "", + "atlasUrl": self._atlas_url, + "signedIn": self._auth is not None, + "email": self._auth.email if self._auth else "", + "remaining": format_remaining(remaining) if self._auth else "", + "signingIn": self._signing_in, + "signInError": self._sign_in_error, + "systemUser": self._system_user, + "section": self._section, + "sections": SECTIONS, + "active": active, + "hasActive": bool(active), + "notice": account_notice(self._system_user, active), + "query": self._query, + "scope": self._scope, + "rows": rows, + "totalCount": len(all_rows), + "loadingExperiments": self._loading_experiments, + "selected": selected, + "canSwitch": bool(selected) and not selected.get("isActive", False), + "queueBusy": self._queue_busy, + "wizard": wizard, + } + + ######################## + ## User actions + ######################## + + @SafeSlot(str, str) + def login(self, username: str, password: str) -> None: + """Sign in to BEC Atlas with a PSI account.""" + if not username or not password: + self._sign_in_error = "Enter your PSI username and password." + self.refresh() + return + if self._auth is not None: + self.atlas_http_service.logout() + self._signing_in = True + self._sign_in_error = "" + self.refresh() + self.atlas_http_service.login(username=username, password=password) + + @SafeSlot() + def logout(self) -> None: + """Sign out of BEC Atlas.""" + self.atlas_http_service.logout() + + @SafeSlot(str, str, popup_error=True) + def set_experiment(self, experiment_id: str, deployment_id: str) -> None: + """Ask BEC Atlas to make ``experiment_id`` the active experiment of the deployment.""" + self.atlas_http_service.set_experiment(experiment_id, deployment_id) + + @SafeSlot(str) + def open_section(self, section: str) -> None: + """Show a section of the signed-in view.""" + if any(s["id"] == section and s["enabled"] for s in SECTIONS): + self._section = section + self.refresh() + + @SafeSlot(str) + def set_query(self, query: str) -> None: + """Filter the experiment list.""" + self._query = query + self.refresh() + + @SafeSlot(str) + def set_scope(self, scope: str) -> None: + """Show only upcoming beamtimes (``upcoming``) or all experiments (``all``).""" + self._scope = "all" if scope == "all" else "upcoming" + self.refresh() + + @SafeSlot(str) + def select_experiment(self, pgroup: str) -> None: + """Select an experiment in the list to see its details.""" + self._selected = pgroup + self.refresh() + + @SafeSlot(str) + def start_switch(self, pgroup: str = "") -> None: + """Open the switch flow for ``pgroup`` (default: the selected experiment).""" + pgroup = pgroup or self._selected + if not pgroup or self._auth is None: + return + self._wizard = self._closed_wizard() + active = self._active_info.pgroup if self._active_info else "" + self._wizard.update({"open": True, "target": pgroup, "fromPgroup": active or "none"}) + self.refresh() + + @SafeSlot() + def wizard_next(self) -> None: + """Go from reviewing the change to confirming it.""" + if self._wizard["open"] and self._wizard["step"] == 0: + self._wizard["step"] = 1 + self.refresh() + + @SafeSlot() + def wizard_back(self) -> None: + """Go back one step, or close the flow from the first step.""" + if self._wizard["phase"] in ("switching", "done"): + return + if self._wizard["step"] == 0: + self.close_wizard() + return + self._wizard["step"] -= 1 + self._wizard["error"] = "" + self.refresh() + + @SafeSlot(bool) + def set_acknowledged(self, acknowledged: bool) -> None: + """Tick or untick the confirmation that everyone logs in as the new account.""" + self._wizard["ack"] = bool(acknowledged) + self.refresh() + + @SafeSlot() + def confirm_switch(self) -> None: + """Send the switch to BEC Atlas once the change was confirmed.""" + wizard = self._wizard + if not (wizard["open"] and wizard["ack"] and wizard["phase"] in ("review", "error")): + return + if self._deployment is None: + wizard.update({"phase": "error", "error": "No deployment information from BEC."}) + self.refresh() + return + state = self.view_state()["wizard"] + wizard.update( + {"phase": "switching", "error": "", "frozen": (state["plan"], state["targetInfo"])} + ) + self.refresh() + self.set_experiment(wizard["target"], self._deployment.deployment_id) + + @SafeSlot() + def close_wizard(self) -> None: + """Leave the switch flow.""" + if self._wizard["phase"] == "switching": + return + self._wizard = self._closed_wizard() + self.refresh() + + ######################## + ## Incoming data + ######################## + + @SafeSlot(dict, dict) + def _on_deployment_info(self, msg: dict, _meta: dict) -> None: + deployment = DeploymentInfoMessage.model_validate(msg) + self._deployment = deployment + session = deployment.active_session + if session is not None and session.experiment is not None: + self._active_info = session.experiment + self.atlas_http_service._set_current_deployment_info( # pylint: disable=protected-access + deployment + ) + self.refresh() + + @SafeSlot(dict, dict) + def _on_queue_status(self, content: dict, _meta: dict) -> None: + primary = (content.get("queue") or {}).get("primary") + info = getattr(primary, "info", None) or [] + busy = any( + getattr(item, "status", "") not in ("STOPPED", "COMPLETED", "IDLE") for item in info + ) + if busy != self._queue_busy: + self._queue_busy = busy + self.refresh() + + def _on_http_response(self, response: dict) -> None: + response = HTTPResponse(**response) + if AtlasEndpoints.REALMS_EXPERIMENTS in response.request_url: + self._experiments = response.data if isinstance(response.data, list) else [] + self._loading_experiments = False + if not self._selected: + _active, rows = self._summaries() + upcoming = filter_experiments(rows, "", "upcoming") + choice = next((r for r in rows if r["isNext"]), None) or next( + (r for r in upcoming if not r["isActive"]), None + ) + self._selected = choice["pgroup"] if choice else "" + self.refresh() + elif AtlasEndpoints.SET_EXPERIMENT in response.request_url: + if self._wizard["open"]: + self._wizard.update({"phase": "done", "step": 2, "error": ""}) + target = self._wizard.get("target", "") + for info in self._experiments: + if info.get("pgroup") == target: + # Show the new experiment right away; deployment info confirms it shortly. + self._active_info = ExperimentInfoMessage.model_validate(info) + break + self.refresh() + self.experiment_switched.emit(target) + + @SafeSlot(str, str) + def _on_request_failed(self, kind: str, message: str) -> None: + if kind == "login": + self._signing_in = False + self._sign_in_error = message + elif kind == "switch" and self._wizard["open"]: + self._wizard.update({"phase": "error", "error": message}) + elif kind == "experiments": + self._loading_experiments = False + self._sign_in_error = message + self.refresh() + + @SafeSlot(dict) + def _on_authenticated(self, auth_info: dict) -> None: + self._signing_in = False + info = AuthenticatedUserInfo.model_validate(auth_info) if auth_info else None + if info is not None and ( + self._deployment is None or info.deployment_id != self._deployment.deployment_id + ): + self._sign_in_error = "This account has no owner rights on this beamline deployment." + info = None + self._auth = info + if info is not None: + self._sign_in_error = "" + self._tick.start() + self._fetch_experiments() + else: + self._tick.stop() + self._experiments = [] + self._selected = "" + self._wizard = self._closed_wizard() + self.refresh() + self.authenticated.emit(info is not None) + + def _fetch_experiments(self) -> None: + realm = self._active_info.realm_id if self._active_info else None + if not realm: + self._sign_in_error = "The active experiment has no realm; cannot list experiments." + return + self._loading_experiments = True + self.atlas_http_service.get_experiments_for_realm(realm) + + def _on_tick(self) -> None: + if self._auth is None: + self._tick.stop() + return + self._render_session_time(format_remaining(self._auth.exp - time.time())) + + def _render_session_time(self, remaining: str) -> None: + """Show the remaining session time; views may override this with a cheaper update.""" + self.refresh() + + ######################## + ## Cleanup + ######################## + + def cleanup(self): + """Stop the timer and close the Atlas session.""" + self._tick.stop() + self.bec_dispatcher.disconnect_slot( + self._on_deployment_info, MessageEndpoints.deployment_info() + ) + self.bec_dispatcher.disconnect_slot( + self._on_queue_status, MessageEndpoints.scan_queue_status() + ) + self.atlas_http_service.cleanup() + super().cleanup() diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qml.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qml.py new file mode 100644 index 00000000..25e580b7 --- /dev/null +++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qml.py @@ -0,0 +1,135 @@ +"""Admin view rendered with Qt Quick (QML). + +Same UX as :mod:`admin_view_qwidget`; the state comes from +:class:`~.admin_ux_common.AdminViewBase`. +""" + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import Property, QObject, Signal, Slot +from qtpy.QtWidgets import QVBoxLayout + +from bec_widgets.utils.quick import DictListModel, create_quick_widget, release_quick_widget +from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_ux_common import ( + DEFAULT_ATLAS_URL, + HOW_IT_WORKS, + AdminViewBase, +) + +QML_DIR = Path(__file__).parent / "qml" + +ROW_ROLES = ["pgroup", "title", "pi", "beamtime", "status", "statusLabel", "isActive", "isNext"] + + +class AdminBackend(QObject): + """Bridge between :class:`AdminViewQML` and ``AdminView.qml``.""" + + changed = Signal() + + def __init__(self, view: AdminViewQML): + super().__init__(view) + self._view = view + self._state: dict = {} + self._rows = DictListModel(ROW_ROLES, self) + + def update_from(self, state: dict) -> None: + """Take a new view state from the widget.""" + state = dict(state) + rows = state.pop("rows") + if [r["pgroup"] for r in rows] != [r["pgroup"] for r in self._rows.items] or any( + new["status"] != old["status"] for new, old in zip(rows, self._rows.items) + ): + self._rows.set_items([{key: row[key] for key in ROW_ROLES} for row in rows]) + state["rowCount"] = len(rows) + state["howItWorks"] = HOW_IT_WORKS + self._state = state + self.changed.emit() + + # pylint: disable=invalid-name,missing-function-docstring + @Slot(str, str) + def login(self, username: str, password: str) -> None: + self._view.login(username.strip(), password) + + @Slot() + def logout(self) -> None: + self._view.logout() + + @Slot(str) + def openSection(self, section: str) -> None: + self._view.open_section(section) + + @Slot(str) + def setQuery(self, query: str) -> None: + self._view.set_query(query) + + @Slot(str) + def setScope(self, scope: str) -> None: + self._view.set_scope(scope) + + @Slot(str) + def selectExperiment(self, pgroup: str) -> None: + self._view.select_experiment(pgroup) + + @Slot(str) + def startSwitch(self, pgroup: str) -> None: + self._view.start_switch(pgroup) + + @Slot() + def next(self) -> None: + self._view.wizard_next() + + @Slot() + def back(self) -> None: + self._view.wizard_back() + + @Slot(bool) + def setAcknowledged(self, value: bool) -> None: + self._view.set_acknowledged(value) + + @Slot() + def confirm(self) -> None: + self._view.confirm_switch() + + @Slot() + def closeWizard(self) -> None: + self._view.close_wizard() + + def _get_state(self) -> dict: + return self._state + + def _get_rows(self) -> DictListModel: + return self._rows + + state = Property("QVariantMap", _get_state, notify=changed) + rows = Property(QObject, _get_rows, constant=True) + + +class AdminViewQML(AdminViewBase): + """Admin view: sign in, see the active experiment and switch it with step-by-step guidance.""" + + def __init__(self, parent=None, atlas_url: str = DEFAULT_ATLAS_URL, client=None, **kwargs): + self.backend = None + self.view = None + super().__init__(parent=parent, atlas_url=atlas_url, client=client, **kwargs) + self.backend = AdminBackend(self) + self.backend.update_from(self.view_state()) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.view = create_quick_widget(self, QML_DIR / "AdminView.qml", {"backend": self.backend}) + layout.addWidget(self.view) + + def _render(self, state: dict) -> None: + if self.backend is not None: + self.backend.update_from(state) + + def apply_theme(self, theme: str): + """Match the QML clear colour to the new theme.""" + if self.view is not None: + self.view.setClearColor(self.palette().window().color()) + + def cleanup(self): + """Unload the QML scene before the backend goes away.""" + release_quick_widget(self.view) + super().cleanup() diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qwidget.py b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qwidget.py new file mode 100644 index 00000000..be1b86af --- /dev/null +++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/admin_view_qwidget.py @@ -0,0 +1,909 @@ +"""Admin view drawn with plain QWidgets. + +Same UX as :mod:`admin_view_qml`; the state comes from +:class:`~.admin_ux_common.AdminViewBase`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QSize, Qt, Signal +from qtpy.QtWidgets import ( + QButtonGroup, + QCheckBox, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import Card, StatusPill, TextButton, field_qss, refresh_kit_theme +from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_ux_common import ( + DEFAULT_ATLAS_URL, + HOW_IT_WORKS, + AdminViewBase, +) + + +def _label(text: str = "", role: str = "", parent: QWidget | None = None, wrap=False) -> QLabel: + label = QLabel(text, parent) + if role: + label.setProperty("role", role) + label.setWordWrap(wrap) + label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + return label + + +def _clear(layout) -> None: + while layout.count(): + item = layout.takeAt(0) + if item.widget() is not None: + item.widget().deleteLater() + elif item.layout() is not None: + _clear(item.layout()) + + +def tone_color(tokens: ThemeTokens, tone: str): + """Colour of a tone used by banners, chips and step markers.""" + return { + "ok": tokens.success, + "warning": tokens.warning, + "danger": tokens.danger, + "info": tokens.accent, + }.get(tone, tokens.fg_muted) + + +class IconLabel(QLabel): + """Material icon as a label.""" + + def __init__(self, name: str, size: int = 18, parent: QWidget | None = None): + super().__init__(parent) + self._name = name + self._size = size + self.setFixedSize(size, size) + + def set_icon(self, name: str, color) -> None: + """Draw ``name`` in ``color``.""" + self._name = name + self.setPixmap(material_icon(name, size=(self._size, self._size), color=color)) + + +class Banner(QFrame): + """Tinted message box with an icon, a bold title and a text.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setObjectName("banner") + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(10) + self.icon = IconLabel("info", 20, self) + layout.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignTop) + text = QVBoxLayout() + text.setSpacing(2) + self.title = _label("", "strong", self, wrap=True) + self.text = _label("", "muted", self, wrap=True) + text.addWidget(self.title) + text.addWidget(self.text) + layout.addLayout(text, 1) + self._tone = "info" + self._key = None + + def set_message(self, tone: str, title: str, text: str, tokens: ThemeTokens) -> None: + """Show a message in the colours of ``tone``.""" + key = (tone, title, text, tokens.name, tokens.card.name()) + if key == self._key: + return + self._key = key + self._tone = tone + color = tone_color(tokens, tone) + icon = {"ok": "check_circle", "warning": "warning", "danger": "error"}.get(tone, "info") + self.icon.set_icon(icon, color) + self.title.setText(title) + self.text.setText(text) + self.text.setVisible(bool(text)) + self.setStyleSheet( + f"QFrame#banner {{ background: {tokens.soft(color, 0.14).name()};" + f" border: 1px solid {tokens.soft(color, 0.45).name()}; border-radius: 8px; }}" + ) + + +class ExperimentRow(QFrame): + """One clickable experiment in the switch list.""" + + clicked = Signal(str) + + def __init__(self, row: dict, selected: bool, tokens: ThemeTokens, parent=None): + super().__init__(parent) + self.pgroup = row["pgroup"] + self.setObjectName("expRow") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setProperty("selected", selected) + layout = QGridLayout(self) + layout.setContentsMargins(12, 8, 12, 8) + layout.setHorizontalSpacing(10) + layout.setVerticalSpacing(2) + pgroup = _label(row["pgroup"], "rowKey", self) + pgroup.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + title = _label(row["title"], "", self) + title.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + title.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + meta = _label(f"{row['pi']} · {row['beamtime']}", "muted", self) + meta.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction) + meta.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + layout.addWidget(pgroup, 0, 0) + layout.addWidget(title, 0, 1) + layout.addWidget(meta, 1, 1) + if row["status"] in ("active", "now", "next", "past"): + pill = StatusPill(self) + tone = {"active": tokens.success, "now": tokens.warning, "next": tokens.primary}.get( + row["status"], tokens.fg_subtle + ) + pill.set_status(row["statusLabel"], tone) + layout.addWidget(pill, 0, 2, 2, 1, Qt.AlignmentFlag.AlignVCenter) + layout.setColumnStretch(1, 1) + + def mousePressEvent(self, event): # pylint: disable=invalid-name + """Select this experiment.""" + self.clicked.emit(self.pgroup) + super().mousePressEvent(event) + + +class Stepper(QWidget): + """Three numbered steps with labels; done steps show a tick.""" + + def __init__(self, labels: list[str], parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + self._dots: list[QLabel] = [] + self._labels: list[QLabel] = [] + self._lines: list[QFrame] = [] + for i, text in enumerate(labels): + if i: + line = QFrame(self) + line.setFixedHeight(2) + line.setMinimumWidth(24) + self._lines.append(line) + layout.addWidget(line, 1) + dot = QLabel(str(i + 1), self) + dot.setFixedSize(24, 24) + dot.setAlignment(Qt.AlignmentFlag.AlignCenter) + label = QLabel(text, self) + self._dots.append(dot) + self._labels.append(label) + layout.addWidget(dot) + layout.addWidget(label) + + def set_step(self, step: int, done: bool, tokens: ThemeTokens) -> None: + """Highlight ``step``; earlier steps (and ``step`` itself if ``done``) show a tick.""" + for i, (dot, label) in enumerate(zip(self._dots, self._labels)): + finished = i < step or (done and i == step) + current = i == step and not done + if finished: + fill, fg, text = tokens.success, "white", "✓" + elif current: + fill, fg, text = tokens.primary, "white", str(i + 1) + else: + fill, fg, text = tokens.track, tokens.fg_muted.name(), str(i + 1) + dot.setText(text) + dot.setStyleSheet( + f"background: {fill.name()}; color: {fg}; border-radius: 12px;" + f" font-size: 12px; font-weight: 700;" + ) + weight = 600 if current or finished else 400 + color = tokens.fg if current or finished else tokens.fg_muted + label.setStyleSheet(f"color: {color.name()}; font-size: 13px; font-weight: {weight};") + for i, line in enumerate(self._lines): + color = tokens.success if i < step or (done and i + 1 <= step) else tokens.border + line.setStyleSheet(f"background: {color.name()};") + + +class AdminViewQWidget(AdminViewBase): + """Admin view: sign in, see the active experiment and switch it with step-by-step guidance.""" + + def __init__(self, parent=None, atlas_url: str = DEFAULT_ATLAS_URL, client=None, **kwargs): + super().__init__(parent=parent, atlas_url=atlas_url, client=client, **kwargs) + self._tokens = ThemeTokens() + self._rows_key = None + self._wizard_key = None + self._nav_key = None + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self._build_header()) + self._pages = QStackedWidget(self) + self._pages.addWidget(self._build_signed_out()) + self._pages.addWidget(self._build_signed_in()) + root.addWidget(self._pages, 1) + self._apply_styles() + self.refresh() + + ######################## + ## Building + ######################## + + def _build_header(self) -> QWidget: + header = QFrame(self) + header.setObjectName("adminHeader") + layout = QHBoxLayout(header) + layout.setContentsMargins(16, 10, 16, 10) + layout.setSpacing(10) + self._header_icon = IconLabel("admin_panel_settings", 26, header) + layout.addWidget(self._header_icon) + titles = QVBoxLayout() + titles.setSpacing(0) + self._header_title = _label("Beamline admin", "h2", header) + self._header_sub = _label("", "muted", header) + titles.addWidget(self._header_title) + titles.addWidget(self._header_sub) + layout.addLayout(titles, 1) + self._session_pill = StatusPill(header) + layout.addWidget(self._session_pill) + self._sign_out = TextButton("Sign out", "neutral", "logout", header) + self._sign_out.clicked.connect(self.logout) + layout.addWidget(self._sign_out) + return header + + def _active_card(self, parent: QWidget) -> tuple[Card, dict]: + card = Card("Active experiment", parent, padding=16) + widgets: dict = {} + top = QHBoxLayout() + widgets["pgroup"] = _label("", "h1", card) + widgets["pill"] = StatusPill(card) + top.addWidget(widgets["pgroup"]) + top.addWidget(widgets["pill"]) + top.addStretch(1) + card.body.addLayout(top) + widgets["title"] = _label("", "strong", card, wrap=True) + card.body.addWidget(widgets["title"]) + grid = QGridLayout() + grid.setHorizontalSpacing(16) + grid.setVerticalSpacing(6) + for i, (key, text) in enumerate( + [ + ("pi", "PI"), + ("linuxAccount", "Linux login"), + ("dataAccount", "Data saved to"), + ("beamtime", "Beamtime"), + ] + ): + grid.addWidget(_label(text, "muted", card), i, 0) + role = "chip" if key in ("linuxAccount", "dataAccount") else "" + widgets[key] = _label("", role, card) + row = QHBoxLayout() + row.addWidget(widgets[key]) + row.addStretch(1) + grid.addLayout(row, i, 1) + grid.setColumnStretch(1, 1) + card.body.addLayout(grid) + widgets["empty"] = _label("BEC reports no active experiment.", "muted", card) + card.body.addWidget(widgets["empty"]) + return card, widgets + + def _build_signed_out(self) -> QWidget: + page = QWidget(self) + outer = QHBoxLayout(page) + outer.setContentsMargins(24, 24, 24, 24) + outer.addStretch(1) + column = QHBoxLayout() + column.setSpacing(16) + left = QVBoxLayout() + left.setSpacing(12) + self._out_notice = Banner(page) + card, self._out_active = self._active_card(page) + card.setMinimumWidth(380) + left.addWidget(card) + left.addWidget(self._out_notice) + left.addStretch(1) + column.addLayout(left, 1) + + sign_in = Card("Staff sign-in", page, padding=20) + sign_in.setFixedWidth(360) + self._sign_in_text = _label("", "muted", sign_in, wrap=True) + sign_in.body.addWidget(self._sign_in_text) + sign_in.body.addSpacing(4) + self._username = QLineEdit(sign_in) + self._username.setPlaceholderText("PSI username") + self._password = QLineEdit(sign_in) + self._password.setPlaceholderText("Password") + self._password.setEchoMode(QLineEdit.EchoMode.Password) + self._username.returnPressed.connect(self._password.setFocus) + self._password.returnPressed.connect(self._submit_login) + sign_in.body.addWidget(self._username) + sign_in.body.addWidget(self._password) + self._sign_in_error_label = _label("", "error", sign_in, wrap=True) + sign_in.body.addWidget(self._sign_in_error_label) + self._sign_in_button = TextButton("Sign in", "primary", "login", sign_in) + self._sign_in_button.clicked.connect(self._submit_login) + sign_in.body.addWidget(self._sign_in_button) + hint = _label( + "Signing in does not change anything yet. Switching the experiment asks for " + "confirmation first.", + "subtle", + sign_in, + wrap=True, + ) + sign_in.body.addWidget(hint) + right = QVBoxLayout() + right.addWidget(sign_in) + right.addStretch(1) + column.addLayout(right) + + holder = QWidget(page) + holder.setLayout(column) + holder.setMaximumWidth(980) + outer.addWidget(holder, 100) + outer.addStretch(1) + return page + + def _build_signed_in(self) -> QWidget: + page = QWidget(self) + layout = QHBoxLayout(page) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + nav = QFrame(page) + nav.setObjectName("adminNav") + nav.setFixedWidth(184) + self._nav_layout = QVBoxLayout(nav) + self._nav_layout.setContentsMargins(10, 14, 10, 14) + self._nav_layout.setSpacing(4) + layout.addWidget(nav) + + self._content = QStackedWidget(page) + self._content.addWidget(self._build_browse()) + self._content.addWidget(self._build_wizard()) + layout.addWidget(self._content, 1) + return page + + def _build_browse(self) -> QWidget: + page = QWidget(self) + layout = QVBoxLayout(page) + layout.setContentsMargins(20, 16, 20, 16) + layout.setSpacing(12) + top = QHBoxLayout() + top.setSpacing(12) + card, self._in_active = self._active_card(page) + top.addWidget(card, 3, Qt.AlignmentFlag.AlignTop) + self._in_notice = Banner(page) + notice_col = QVBoxLayout() + notice_col.setSpacing(12) + notice_col.addWidget(self._in_notice) + notice_col.addWidget(self._how_card(page)) + notice_col.addStretch(1) + top.addLayout(notice_col, 2) + layout.addLayout(top) + + switch = Card("Switch experiment", page, padding=16) + switch.body.setSpacing(10) + tools = QHBoxLayout() + self._search = QLineEdit(switch) + self._search.setPlaceholderText("Search p-group, title, PI or account") + self._search.setClearButtonEnabled(True) + self._search.textChanged.connect(self.set_query) + tools.addWidget(self._search, 1) + self._scope_group = QButtonGroup(switch) + for scope, text in (("upcoming", "Upcoming"), ("all", "All")): + button = QPushButton(text, switch) + button.setCheckable(True) + button.setProperty("segment", scope) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect(lambda _=False, s=scope: self.set_scope(s)) + self._scope_group.addButton(button) + tools.addWidget(button) + switch.body.addLayout(tools) + + split = QHBoxLayout() + split.setSpacing(12) + self._list_area = QScrollArea(switch) + self._list_area.setWidgetResizable(True) + self._list_area.setFrameShape(QFrame.Shape.NoFrame) + holder = QWidget() + holder.setObjectName("listHolder") + self._list_layout = QVBoxLayout(holder) + self._list_layout.setContentsMargins(0, 0, 4, 0) + self._list_layout.setSpacing(4) + self._list_area.setWidget(holder) + split.addWidget(self._list_area, 3) + + detail = QFrame(switch) + detail.setObjectName("detail") + detail_layout = QVBoxLayout(detail) + detail_layout.setContentsMargins(14, 12, 14, 12) + detail_layout.setSpacing(6) + self._detail = { + "pgroup": _label("", "h2", detail), + "title": _label("", "strong", detail, wrap=True), + "pi": _label("", "muted", detail), + "beamtime": _label("", "muted", detail, wrap=True), + "account": _label("", "", detail, wrap=True), + "abstract": _label("", "subtle", detail, wrap=True), + } + for widget in self._detail.values(): + detail_layout.addWidget(widget) + detail_layout.addStretch(1) + self._switch_button = TextButton("Switch…", "primary", "swap_horiz", detail) + self._switch_button.clicked.connect(lambda: self.start_switch("")) + detail_layout.addWidget(self._switch_button) + self._detail_empty = _label("Select an experiment to see its details.", "muted", detail) + detail_layout.addWidget(self._detail_empty) + split.addWidget(detail, 2) + switch.body.addLayout(split, 1) + switch.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) + layout.addWidget(switch, 1) + return page + + def _how_card(self, parent: QWidget) -> Card: + card = Card("How switching works", parent, padding=14) + card.body.setSpacing(6) + self._how_rows = [] + for i, step in enumerate(HOW_IT_WORKS, start=1): + row = QHBoxLayout() + row.setSpacing(8) + number = QLabel(str(i), card) + number.setFixedSize(20, 20) + number.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._how_rows.append(number) + row.addWidget(number, 0, Qt.AlignmentFlag.AlignTop) + row.addWidget(_label(step, "muted", card, wrap=True), 1) + card.body.addLayout(row) + return card + + def _build_wizard(self) -> QWidget: + page = QWidget(self) + outer = QHBoxLayout(page) + outer.setContentsMargins(20, 16, 20, 16) + outer.addStretch(1) + card = Card("", page, padding=20) + card.setMaximumWidth(780) + card.setMinimumWidth(560) + card.body.setSpacing(14) + head = QHBoxLayout() + head_text = QVBoxLayout() + head_text.setSpacing(2) + self._wiz_title = _label("Switch experiment", "h2", card) + self._wiz_sub = _label("", "muted", card) + head_text.addWidget(self._wiz_title) + head_text.addWidget(self._wiz_sub) + head.addLayout(head_text, 1) + card.body.addLayout(head) + self._stepper = Stepper(["Review", "Confirm", "Log in"], card) + card.body.addWidget(self._stepper) + line = QFrame(card) + line.setObjectName("rule") + line.setFixedHeight(1) + card.body.addWidget(line) + self._wiz_body = QVBoxLayout() + self._wiz_body.setSpacing(10) + card.body.addLayout(self._wiz_body) + self._ack = QCheckBox(card) + self._ack.toggled.connect(self.set_acknowledged) + card.body.addWidget(self._ack) + self._wiz_error = Banner(card) + card.body.addWidget(self._wiz_error) + card.body.addStretch(1) + footer = QHBoxLayout() + self._wiz_cancel = TextButton("Cancel", "ghost", "", card) + self._wiz_cancel.clicked.connect(self.close_wizard) + self._wiz_back = TextButton("Back", "neutral", "arrow_back", card) + self._wiz_back.clicked.connect(self.wizard_back) + self._wiz_primary = TextButton("Next", "primary", "arrow_forward", card) + self._wiz_primary.clicked.connect(self._wizard_primary) + footer.addWidget(self._wiz_cancel) + footer.addStretch(1) + footer.addWidget(self._wiz_back) + footer.addWidget(self._wiz_primary) + card.body.addLayout(footer) + column = QVBoxLayout() + column.addWidget(card) + column.addStretch(1) + outer.addLayout(column, 100) + outer.addStretch(1) + return page + + ######################## + ## Actions + ######################## + + def _submit_login(self) -> None: + self.login(self._username.text().strip(), self._password.text()) + self._password.clear() + + def _wizard_primary(self) -> None: + step = self._wizard["step"] + if step == 0: + self.wizard_next() + elif step == 1: + self.confirm_switch() + else: + self.close_wizard() + + ######################## + ## Rendering + ######################## + + def apply_theme(self, theme: str): + """Re-draw everything in the new theme.""" + self._tokens = refresh_kit_theme(self) + self._rows_key = self._wizard_key = self._nav_key = None + self._apply_styles() + self.refresh() + + def _apply_styles(self) -> None: + t = self._tokens + mono = "'JetBrains Mono', 'DejaVu Sans Mono', monospace" + self.setStyleSheet(field_qss(t) + f""" + QLabel {{ color: {t.fg.name()}; font-size: 13px; background: transparent; }} + QLabel[role="h1"] {{ font-size: 22px; font-weight: 700; }} + QLabel[role="h2"] {{ font-size: 16px; font-weight: 650; }} + QLabel[role="strong"] {{ font-weight: 600; }} + QLabel[role="muted"] {{ color: {t.fg_muted.name()}; }} + QLabel[role="subtle"] {{ color: {t.fg_subtle.name()}; font-size: 12px; }} + QLabel[role="error"] {{ color: {t.danger.name()}; }} + QLabel[role="rowKey"] {{ font-family: {mono}; font-weight: 600; }} + QLabel[role="chip"] {{ font-family: {mono}; font-weight: 600; + background: {t.soft(t.primary, 0.14).name()}; color: {t.fg.name()}; + border-radius: 4px; padding: 1px 6px; }} + QLabel[role="bigChip"] {{ font-family: {mono}; font-weight: 700; font-size: 20px; + background: {t.soft(t.primary, 0.18).name()}; color: {t.fg.name()}; + border: 1px solid {t.soft(t.primary, 0.5).name()}; + border-radius: 6px; padding: 4px 12px; }} + QFrame#adminHeader {{ background: {t.card.name()}; + border-bottom: 1px solid {t.border.name()}; }} + QFrame#adminNav {{ background: {t.card.name()}; + border-right: 1px solid {t.border.name()}; }} + QFrame#detail {{ background: {t.field.name()}; border: 1px solid {t.border.name()}; + border-radius: 8px; }} + QFrame#rule {{ background: {t.border.name()}; }} + QWidget#listHolder {{ background: transparent; }} + QScrollArea {{ background: transparent; }} + QFrame#expRow {{ background: transparent; border: 1px solid transparent; + border-radius: 8px; }} + QFrame#expRow:hover {{ background: {t.hover.name()}; }} + QFrame#expRow[selected="true"] {{ background: {t.soft(t.primary, 0.14).name()}; + border: 1px solid {t.soft(t.primary, 0.6).name()}; }} + QPushButton[segment] {{ background: transparent; color: {t.fg_muted.name()}; + border: 1px solid {t.border.name()}; padding: 0 12px; min-height: 30px; + font-size: 13px; }} + QPushButton[segment="upcoming"] {{ border-top-left-radius: 6px; + border-bottom-left-radius: 6px; }} + QPushButton[segment="all"] {{ border-top-right-radius: 6px; + border-bottom-right-radius: 6px; border-left: none; }} + QPushButton[segment]:checked {{ background: {t.soft(t.primary, 0.18).name()}; + color: {t.fg.name()}; font-weight: 600; }} + QPushButton[nav] {{ text-align: left; padding: 0 10px; min-height: 34px; + border: none; border-radius: 6px; color: {t.fg.name()}; font-size: 13px; + background: transparent; }} + QPushButton[nav]:hover {{ background: {t.hover.name()}; }} + QPushButton[nav]:checked {{ background: {t.soft(t.primary, 0.18).name()}; + font-weight: 600; }} + QPushButton[nav]:disabled {{ color: {t.fg_subtle.name()}; }} + QCheckBox {{ color: {t.fg.name()}; font-size: 13px; font-weight: 600; + spacing: 8px; }} + """) + self._header_icon.set_icon("admin_panel_settings", t.primary) + for number in getattr(self, "_how_rows", []): + number.setStyleSheet( + f"background: {t.soft(t.primary, 0.2).name()}; color: {t.fg.name()};" + " border-radius: 10px; font-weight: 700; font-size: 11px;" + ) + + def _render(self, state: dict) -> None: + if not hasattr(self, "_pages"): + return # called by the base class before the widgets exist + t = self._tokens + realm = f" · {state['realm']}" if state["realm"] else "" + self._header_sub.setText(f"{state['deploymentName']}{realm}") + if state["signedIn"]: + self._session_pill.set_status( + f"{state['email']} · {state['remaining']} left", t.success + ) + else: + self._session_pill.set_status("Not signed in", t.fg_subtle) + self._sign_out.setVisible(state["signedIn"]) + page = 1 if state["signedIn"] else 0 + if page != self._pages.currentIndex(): + self._pages.setCurrentIndex(page) + # do not drop the user into the search field (or keep focus on the password field) + self._pages.currentWidget().setFocus() + + for widgets in (self._out_active, self._in_active): + self._fill_active(widgets, state) + for banner in (self._out_notice, self._in_notice): + notice = state["notice"] + banner.setVisible(bool(notice)) + if notice: + banner.set_message(notice["tone"], notice["title"], notice["text"], t) + + self._sign_in_text.setText( + "Sign in with your PSI account to switch the active experiment. Your account needs " + f"owner rights on {state['deploymentName']}." + ) + self._sign_in_error_label.setText(state["signInError"]) + self._sign_in_error_label.setVisible(bool(state["signInError"])) + self._sign_in_button.setEnabled(not state["signingIn"]) + self._sign_in_button.setText("Signing in…" if state["signingIn"] else "Sign in") + + if state["signedIn"]: + self._render_nav(state) + self._render_browse(state) + self._render_wizard(state) + self._content.setCurrentIndex(1 if state["wizard"]["open"] else 0) + + def _render_session_time(self, remaining: str) -> None: + """Only the session pill changes every second.""" + if self._auth is not None: + self._session_pill.set_status( + f"{self._auth.email} · {remaining} left", self._tokens.success + ) + + def _fill_active(self, widgets: dict, state: dict) -> None: + active = state["active"] + has = state["hasActive"] + for key in ("pgroup", "pill", "title", "pi", "linuxAccount", "dataAccount", "beamtime"): + widgets[key].setVisible(has) + widgets["empty"].setVisible(not has) + if not has: + return + widgets["pgroup"].setText(active["pgroup"]) + widgets["pill"].set_status("Active", self._tokens.success) + widgets["title"].setText(active["title"]) + widgets["pi"].setText(active["pi"]) + widgets["linuxAccount"].setText(active["linuxAccount"] or "unknown") + widgets["dataAccount"].setText(active["dataAccount"]) + widgets["beamtime"].setText(active["beamtime"]) + + def _render_nav(self, state: dict) -> None: + key = (state["section"], tuple(s["id"] for s in state["sections"])) + if key == self._nav_key: + return + self._nav_key = key + _clear(self._nav_layout) + for section in state["sections"]: + text = section["label"] + (f" · {section['badge']}" if section["badge"] else "") + button = QPushButton(text, self) + button.setProperty("nav", True) + button.setCheckable(True) + button.setChecked(section["id"] == state["section"]) + button.setEnabled(section["enabled"]) + color = self._tokens.fg if section["enabled"] else self._tokens.fg_subtle + button.setIcon( + material_icon(section["icon"], size=(32, 32), color=color, convert_to_pixmap=False) + ) + button.setIconSize(QSize(18, 18)) + button.clicked.connect(lambda _=False, s=section["id"]: self.open_section(s)) + self._nav_layout.addWidget(button) + self._nav_layout.addStretch(1) + + def _render_browse(self, state: dict) -> None: + for button in self._scope_group.buttons(): + button.setChecked(button.property("segment") == state["scope"]) + if self._search.text() != state["query"]: + self._search.setText(state["query"]) + selected = state["selected"] + rows_key = ( + tuple((r["pgroup"], r["status"]) for r in state["rows"]), + selected.get("pgroup", ""), + state["loadingExperiments"], + ) + if rows_key != self._rows_key: + self._rows_key = rows_key + _clear(self._list_layout) + if state["loadingExperiments"]: + self._list_layout.addWidget(_label("Loading experiments…", "muted")) + elif not state["rows"]: + text = ( + "No experiment matches the search." + if state["totalCount"] + else "Atlas lists no experiments for this beamline." + ) + self._list_layout.addWidget(_label(text, "muted")) + for row in state["rows"]: + widget = ExperimentRow( + row, row["pgroup"] == selected.get("pgroup"), self._tokens, self + ) + widget.clicked.connect(self.select_experiment) + self._list_layout.addWidget(widget) + self._list_layout.addStretch(1) + + has = bool(selected) + for widget in self._detail.values(): + widget.setVisible(has) + self._switch_button.setVisible(has) + self._detail_empty.setVisible(not has) + if has: + self._detail["pgroup"].setText(selected["pgroup"]) + self._detail["title"].setText(selected["title"]) + self._detail["pi"].setText(f"PI: {selected['pi']}") + self._detail["beamtime"].setText(selected["beamtime"]) + self._detail["account"].setText( + f"Group logs in as {selected['linuxAccount'] or 'unknown'}" + ) + abstract = selected["abstract"] + self._detail["abstract"].setText( + abstract if len(abstract) < 260 else abstract[:257] + "…" + ) + self._detail["abstract"].setVisible(bool(abstract)) + self._switch_button.setEnabled(state["canSwitch"]) + self._switch_button.setText( + "This is the active experiment" + if selected["isActive"] + else f"Switch to {selected['pgroup']}…" + ) + + def _render_wizard(self, state: dict) -> None: + wizard = state["wizard"] + if not wizard["open"]: + self._wizard_key = None + return + t = self._tokens + target = wizard["targetInfo"] + plan = wizard["plan"] + step, phase = wizard["step"], wizard["phase"] + self._wiz_title.setText("Experiment switched" if phase == "done" else "Switch experiment") + self._wiz_sub.setText(f"{wizard['fromPgroup']} → {target.get('pgroup', '')}") + self._stepper.set_step(step, phase == "done", t) + + key = (step, phase, target.get("pgroup"), state["queueBusy"]) + if key != self._wizard_key: + self._wizard_key = key + _clear(self._wiz_body) + if step == 0: + self._fill_review(plan) + elif step == 1: + self._fill_confirm(plan) + else: + self._fill_login(plan, state) + + self._ack.setVisible(step == 1) + self._ack.setText(plan.get("ackText", "")) + self._ack.blockSignals(True) + self._ack.setChecked(wizard["ack"]) + self._ack.blockSignals(False) + self._ack.setEnabled(phase != "switching") + self._wiz_error.setVisible(bool(wizard["error"])) + if wizard["error"]: + self._wiz_error.set_message("danger", "The switch failed", wizard["error"], t) + + self._wiz_cancel.setVisible(phase not in ("done", "switching")) + self._wiz_back.setVisible(step > 0 and phase not in ("done", "switching")) + if step == 0: + text, variant, icon, enabled = "Next", "primary", "arrow_forward", True + elif step == 1: + text = "Switching…" if phase == "switching" else f"Switch to {target.get('pgroup')}" + variant, icon, enabled = "danger", "swap_horiz", wizard["canConfirm"] + else: + text, variant, icon, enabled = "Done", "primary", "check", True + self._wiz_primary.setText(text) + self._wiz_primary._icon_name = icon # pylint: disable=protected-access + self._wiz_primary.set_variant(variant) + self._wiz_primary.setEnabled(enabled) + + def _fill_review(self, plan: dict) -> None: + t = self._tokens + intro = _label( + "Check that this is the right experiment. Switching also changes which Linux account " + "the group uses on this computer.", + "muted", + wrap=True, + ) + self._wiz_body.addWidget(intro) + grid = QGridLayout() + grid.setHorizontalSpacing(12) + grid.setVerticalSpacing(0) + for col, text in ((1, "Now"), (3, "After switching")): + grid.addWidget(_label(text, "subtle"), 0, col) + for i, change in enumerate(plan["changes"], start=1): + row_frame = QFrame() + row_frame.setStyleSheet( + f"background: {t.soft(t.warning, 0.16).name()}; border-radius: 6px;" + if change["key"] + else "background: transparent;" + ) + grid.addWidget(row_frame, i, 0, 1, 4) + role = "chip" if change["key"] else "" + label = _label(change["label"], "strong" if change["key"] else "muted") + label.setContentsMargins(8, 7, 0, 7) + grid.addWidget(label, i, 0) + before = _label(change["before"], "muted") + before.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + grid.addWidget(before, i, 1) + arrow = IconLabel("arrow_forward", 16) + arrow.set_icon("arrow_forward", t.fg_subtle) + grid.addWidget(arrow, i, 2) + after = _label(change["after"], role or "strong") + after.setSizePolicy( + QSizePolicy.Policy.Maximum if role else QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Fixed, + ) + holder = QHBoxLayout() + holder.setContentsMargins(0, 0, 8, 0) + holder.addWidget(after) + if role: + holder.addStretch(1) + grid.addLayout(holder, i, 3) + grid.setColumnStretch(1, 1) + grid.setColumnStretch(3, 1) + self._wiz_body.addLayout(grid) + + def _fill_confirm(self, plan: dict) -> None: + t = self._tokens + self._wiz_body.addWidget(_label("What happens when you switch", "strong")) + for item in plan["consequences"]: + row = QHBoxLayout() + row.setSpacing(10) + icon = IconLabel(item["icon"], 20) + icon.set_icon(item["icon"], tone_color(t, item["tone"])) + row.addWidget(icon, 0, Qt.AlignmentFlag.AlignTop) + text = QVBoxLayout() + text.setSpacing(1) + text.addWidget(_label(item["title"], "strong", wrap=True)) + text.addWidget(_label(item["text"], "muted", wrap=True)) + row.addLayout(text, 1) + self._wiz_body.addLayout(row) + box = QFrame() + box.setObjectName("loginBox") + box.setStyleSheet( + f"QFrame#loginBox {{ background: {t.soft(t.warning, 0.14).name()};" + f" border: 1px solid {t.soft(t.warning, 0.5).name()}; border-radius: 8px; }}" + ) + box_layout = QHBoxLayout(box) + box_layout.setContentsMargins(12, 10, 12, 10) + icon = IconLabel("login", 22, box) + icon.set_icon("login", t.warning) + box_layout.addWidget(icon) + box_layout.addWidget( + _label("Afterwards the group must log in to this computer as", "strong", box) + ) + box_layout.addWidget(_label(plan["linuxAccount"], "chip", box)) + box_layout.addStretch(1) + self._wiz_body.addWidget(box) + + def _fill_login(self, plan: dict, state: dict) -> None: + t = self._tokens + banner = Banner() + banner.set_message( + "ok", + f"{state['wizard']['targetInfo'].get('pgroup', '')} is now the active experiment", + "New scans are saved for the new experiment. One step is left on this computer:", + t, + ) + self._wiz_body.addWidget(banner) + account_row = QHBoxLayout() + account_row.addWidget(_label("Log in as", "h2")) + account_row.addWidget(_label(plan["linuxAccount"], "bigChip")) + account_row.addStretch(1) + self._wiz_body.addLayout(account_row) + for i, step in enumerate(plan["loginSteps"], start=1): + row = QHBoxLayout() + row.setSpacing(10) + number = QLabel(str(i)) + number.setFixedSize(22, 22) + number.setAlignment(Qt.AlignmentFlag.AlignCenter) + number.setStyleSheet( + f"background: {t.soft(t.primary, 0.2).name()}; color: {t.fg.name()};" + " border-radius: 11px; font-weight: 700; font-size: 12px;" + ) + row.addWidget(number, 0, Qt.AlignmentFlag.AlignTop) + text = QVBoxLayout() + text.setSpacing(1) + text.addWidget(_label(step["title"], "strong", wrap=True)) + text.addWidget(_label(step["text"], "muted", wrap=True)) + row.addLayout(text, 1) + self._wiz_body.addLayout(row) + + def cleanup(self): + """Stop the session pills and the Atlas connection.""" + for pill in self.findChildren(StatusPill): + pill.cleanup() + super().cleanup() diff --git a/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/qml/AdminView.qml b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/qml/AdminView.qml new file mode 100644 index 00000000..01810c5e --- /dev/null +++ b/bec_widgets/widgets/services/bec_atlas_admin_view/admin_ux/qml/AdminView.qml @@ -0,0 +1,758 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Admin view: staff sign-in, the active experiment, and switching it in three guided steps +// (review, confirm, log in as the experiment's Linux account). Mirrors admin_view_qwidget.py. +Rectangle { + id: root + required property var backend + readonly property var s: backend.state + readonly property var wizard: s.wizard || ({}) + readonly property var plan: wizard.plan || ({}) + readonly property var target: wizard.targetInfo || ({}) + readonly property string mono: "DejaVu Sans Mono" + + color: theme.bg + + function toneColor(tone) { + return tone === "ok" ? theme.success : tone === "warning" ? theme.warning + : tone === "danger" ? theme.danger : theme.accent + } + function tint(c, a) { return Qt.rgba(c.r, c.g, c.b, a) } + + // ---- small building blocks ------------------------------------------------------------ + component Txt: Text { + property string role: "" + color: role === "muted" ? theme.fgMuted : role === "subtle" ? theme.fgSubtle + : role === "error" ? theme.danger : theme.fg + font.pixelSize: role === "h1" ? 22 : role === "h2" ? 16 : role === "subtle" ? 12 : 13 + font.weight: role === "h1" ? Font.Bold : role === "h2" ? Font.DemiBold + : role === "strong" ? Font.DemiBold : Font.Normal + wrapMode: Text.Wrap + } + + component Chip: Rectangle { + property string text: "" + property bool big: false + implicitWidth: chipText.implicitWidth + (big ? 24 : 12) + implicitHeight: chipText.implicitHeight + (big ? 8 : 2) + radius: big ? 6 : 4 + color: tint(theme.primary, big ? 0.18 : 0.14) + border.width: big ? 1 : 0 + border.color: tint(theme.primary, 0.5) + Text { + id: chipText + anchors.centerIn: parent + text: parent.text + color: theme.fg + font.family: root.mono + font.pixelSize: parent.big ? 20 : 13 + font.weight: parent.big ? Font.Bold : Font.DemiBold + } + } + + component Banner: Rectangle { + id: banner + property string tone: "info" + property string title: "" + property string text: "" + readonly property color toneColor: root.toneColor(tone) + implicitHeight: bannerRow.implicitHeight + 20 + radius: 8 + color: tint(toneColor, 0.14) + border.color: tint(toneColor, 0.45) + RowLayout { + id: bannerRow + anchors.fill: parent + anchors.margins: 10 + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + Icon { + Layout.alignment: Qt.AlignTop + name: banner.tone === "ok" ? "check_circle" : banner.tone === "warning" ? "warning" + : banner.tone === "danger" ? "error" : "info" + color: banner.toneColor + size: 20 + } + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + Txt { text: banner.title; role: "strong"; Layout.fillWidth: true } + Txt { text: banner.text; role: "muted"; visible: banner.text !== ""; Layout.fillWidth: true } + } + } + } + + component NumberDot: Rectangle { + property string text: "" + property int size: 22 + width: size; height: size; radius: size / 2 + color: tint(theme.primary, 0.2) + Text { + anchors.centerIn: parent + text: parent.text + color: theme.fg + font.pixelSize: parent.size > 20 ? 12 : 11 + font.weight: Font.Bold + } + } + + component ActiveCard: Card { + title: "Active experiment" + padding: 16 + readonly property var active: s.active || ({}) + RowLayout { + visible: s.hasActive + spacing: 8 + Txt { text: active.pgroup || ""; role: "h1" } + StatusPill { text: "Active"; tone: theme.success } + } + Txt { visible: s.hasActive; text: active.title || ""; role: "strong"; Layout.fillWidth: true } + GridLayout { + visible: s.hasActive + columns: 2 + columnSpacing: 16 + rowSpacing: 6 + Layout.fillWidth: true + Txt { text: "PI"; role: "muted" } + Txt { text: active.pi || ""; Layout.fillWidth: true } + Txt { text: "Linux login"; role: "muted" } + Chip { text: active.linuxAccount || "unknown" } + Txt { text: "Data saved to"; role: "muted" } + Chip { text: active.dataAccount || "" } + Txt { text: "Beamtime"; role: "muted" } + Txt { text: active.beamtime || ""; Layout.fillWidth: true } + } + Txt { visible: !s.hasActive; text: "BEC reports no active experiment."; role: "muted" } + } + + component NoticeBanner: Banner { + visible: !!s.notice && s.notice.tone !== undefined + tone: s.notice ? (s.notice.tone || "info") : "info" + title: s.notice ? (s.notice.title || "") : "" + text: s.notice ? (s.notice.text || "") : "" + } + + component Segmented: Rectangle { + id: seg + property var options: [] + property string value: "" + signal picked(string value) + implicitWidth: segRow.implicitWidth + 2 + implicitHeight: 32 + radius: 6 + color: "transparent" + border.color: theme.border + Row { + id: segRow + x: 1; y: 1 + height: parent.height - 2 + Repeater { + model: seg.options + delegate: AbstractButton { + required property var modelData + readonly property bool selected: modelData[0] === seg.value + height: segRow.height + implicitWidth: segText.implicitWidth + 24 + hoverEnabled: true + onClicked: seg.picked(modelData[0]) + background: Rectangle { + radius: 5 + color: parent.selected ? tint(theme.primary, 0.18) : parent.hovered ? theme.hover : "transparent" + } + contentItem: Text { + id: segText + text: parent.modelData[1] + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: parent.selected ? theme.fg : theme.fgMuted + font.pixelSize: 13 + font.weight: parent.selected ? Font.DemiBold : Font.Normal + } + } + } + } + } + + // ---- layout ------------------------------------------------------------------------- + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + implicitHeight: 56 + color: theme.card + Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border } + RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 10 + Icon { name: "admin_panel_settings"; color: theme.primary; size: 26 } + ColumnLayout { + spacing: 0 + Txt { text: "Beamline admin"; role: "h2" } + Txt { text: s.deploymentName + (s.realm ? " · " + s.realm : ""); role: "muted" } + } + Item { Layout.fillWidth: true } + StatusPill { + text: s.signedIn ? s.email + " · " + s.remaining + " left" : "Not signed in" + tone: s.signedIn ? theme.success : theme.fgSubtle + } + TextButton { + visible: s.signedIn + text: "Sign out" + iconName: "logout" + onClicked: backend.logout() + } + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: s.signedIn ? 1 : 0 + + // ---- signed out: the active experiment plus the sign-in card -------------------- + Item { + RowLayout { + anchors.top: parent.top + anchors.topMargin: 24 + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - 48, 980) + spacing: 16 + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: 12 + ActiveCard { Layout.fillWidth: true } + NoticeBanner { Layout.fillWidth: true } + } + Card { + id: signIn + title: "Staff sign-in" + padding: 20 + Layout.preferredWidth: 360 + Layout.alignment: Qt.AlignTop + function submit() { + backend.login(user.text, pass.text) + pass.text = "" + } + Txt { + text: "Sign in with your PSI account to switch the active experiment. " + + "Your account needs owner rights on " + s.deploymentName + "." + role: "muted" + Layout.fillWidth: true + } + Item { implicitHeight: 4 } + InputField { + id: user + placeholderText: "PSI username" + Layout.fillWidth: true + focus: true + onAccepted: pass.forceActiveFocus() + } + InputField { + id: pass + placeholderText: "Password" + echoMode: TextInput.Password + Layout.fillWidth: true + onAccepted: signIn.submit() + } + Txt { visible: s.signInError !== ""; text: s.signInError; role: "error"; Layout.fillWidth: true } + TextButton { + text: s.signingIn ? "Signing in…" : "Sign in" + variant: "primary" + iconName: "login" + enabled: !s.signingIn + Layout.fillWidth: true + onClicked: signIn.submit() + } + Txt { + text: "Signing in does not change anything yet. Switching the experiment asks for confirmation first." + role: "subtle" + Layout.fillWidth: true + } + } + } + } + + // ---- signed in: navigation plus the experiment section ---------------------- + RowLayout { + spacing: 0 + Rectangle { + Layout.preferredWidth: 184 + Layout.fillHeight: true + color: theme.card + Rectangle { anchors.right: parent.right; width: 1; height: parent.height; color: theme.border } + Column { + anchors.fill: parent + anchors.margins: 10 + anchors.topMargin: 14 + spacing: 4 + Repeater { + model: s.sections + delegate: AbstractButton { + required property var modelData + width: parent.width + height: 34 + enabled: modelData.enabled + hoverEnabled: true + readonly property bool current: modelData.id === s.section + onClicked: backend.openSection(modelData.id) + background: Rectangle { + radius: 6 + color: parent.current ? tint(theme.primary, 0.18) : parent.hovered ? theme.hover : "transparent" + } + contentItem: RowLayout { + spacing: 8 + Item { implicitWidth: 2 } + Icon { name: modelData.icon; size: 18; color: modelData.enabled ? theme.fg : theme.fgSubtle } + Text { + text: modelData.label + (modelData.badge ? " · " + modelData.badge : "") + color: modelData.enabled ? theme.fg : theme.fgSubtle + font.pixelSize: 13 + font.weight: parent.parent.current ? Font.DemiBold : Font.Normal + Layout.fillWidth: true + } + } + } + } + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: wizard.open ? 1 : 0 + + // -- browse: active experiment, guidance and the switch list -- + ColumnLayout { + spacing: 12 + Item { implicitHeight: 4 } + RowLayout { + Layout.leftMargin: 20 + Layout.rightMargin: 20 + spacing: 12 + ActiveCard { Layout.fillWidth: true; Layout.preferredWidth: 300; Layout.alignment: Qt.AlignTop } + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: 200 + Layout.alignment: Qt.AlignTop + spacing: 12 + NoticeBanner { Layout.fillWidth: true } + Card { + title: "How switching works" + padding: 14 + spacing: 6 + Layout.fillWidth: true + Repeater { + model: s.howItWorks + delegate: RowLayout { + required property var modelData + required property int index + spacing: 8 + Layout.fillWidth: true + NumberDot { text: index + 1; size: 20; Layout.alignment: Qt.AlignTop } + Txt { text: modelData; role: "muted"; Layout.fillWidth: true } + } + } + } + } + } + Card { + title: "Switch experiment" + padding: 16 + spacing: 10 + fillContent: true + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.bottomMargin: 16 + RowLayout { + Layout.fillWidth: true + spacing: 0 + InputField { + placeholderText: "Search p-group, title, PI or account" + text: s.query + Layout.fillWidth: true + Layout.rightMargin: 8 + onTextEdited: backend.setQuery(text) + } + Segmented { + options: [["upcoming", "Upcoming"], ["all", "All"]] + value: s.scope + onPicked: (value) => backend.setScope(value) + } + } + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + ListView { + id: list + Layout.fillWidth: true + Layout.preferredWidth: 300 + Layout.fillHeight: true + clip: true + spacing: 4 + model: backend.rows + ScrollBar.vertical: ScrollBar {} + delegate: Rectangle { + required property string pgroup + required property string title + required property string pi + required property string beamtime + required property string status + required property string statusLabel + readonly property bool selected: (s.selected || {}).pgroup === pgroup + width: list.width - 4 + height: 52 + radius: 8 + color: selected ? tint(theme.primary, 0.14) : rowMouse.containsMouse ? theme.hover : "transparent" + border.color: selected ? tint(theme.primary, 0.6) : "transparent" + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: backend.selectExperiment(pgroup) + } + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + Text { + Layout.alignment: Qt.AlignTop + Layout.topMargin: 8 + text: pgroup + color: theme.fg + font.family: root.mono + font.pixelSize: 13 + font.weight: Font.DemiBold + } + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + Text { text: title; color: theme.fg; font.pixelSize: 13; elide: Text.ElideRight; Layout.fillWidth: true } + Text { text: pi + " · " + beamtime; color: theme.fgMuted; font.pixelSize: 13; elide: Text.ElideRight; Layout.fillWidth: true } + } + StatusPill { + visible: ["active", "now", "next", "past"].indexOf(status) >= 0 + text: statusLabel + tone: status === "active" ? theme.success : status === "now" ? theme.warning + : status === "next" ? theme.primary : theme.fgSubtle + } + } + } + Txt { + anchors.top: parent.top + anchors.topMargin: 8 + visible: list.count === 0 + role: "muted" + text: s.loadingExperiments ? "Loading experiments…" + : s.totalCount > 0 ? "No experiment matches the search." + : "Atlas lists no experiments for this beamline." + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 200 + Layout.fillHeight: true + radius: 8 + color: theme.field + border.color: theme.border + readonly property var sel: s.selected || ({}) + readonly property bool has: sel.pgroup !== undefined + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + anchors.topMargin: 12 + spacing: 6 + Txt { visible: parent.parent.has; text: parent.parent.sel.pgroup || ""; role: "h2" } + Txt { visible: parent.parent.has; text: parent.parent.sel.title || ""; role: "strong"; Layout.fillWidth: true } + Txt { visible: parent.parent.has; text: "PI: " + (parent.parent.sel.pi || ""); role: "muted" } + Txt { visible: parent.parent.has; text: parent.parent.sel.beamtime || ""; role: "muted"; Layout.fillWidth: true } + Txt { + visible: parent.parent.has + textFormat: Text.StyledText + text: "Group logs in as " + (parent.parent.sel.linuxAccount || "unknown") + "" + Layout.fillWidth: true + } + Txt { + visible: parent.parent.has && (parent.parent.sel.abstract || "") !== "" + text: parent.parent.sel.abstract || "" + role: "subtle" + maximumLineCount: 4 + elide: Text.ElideRight + Layout.fillWidth: true + } + Txt { visible: !parent.parent.has; text: "Select an experiment to see its details."; role: "muted" } + Item { Layout.fillHeight: true } + TextButton { + visible: parent.parent.has + variant: "primary" + iconName: "swap_horiz" + enabled: s.canSwitch + text: parent.parent.sel.isActive ? "This is the active experiment" + : "Switch to " + parent.parent.sel.pgroup + "…" + Layout.fillWidth: true + onClicked: backend.startSwitch("") + } + } + } + } + } + } + + // -- the guided switch: review, confirm, log in -- + Item { + Flickable { + anchors.fill: parent + contentHeight: wizardCard.implicitHeight + 32 + clip: true + Card { + id: wizardCard + y: 16 + padding: 20 + spacing: 14 + width: Math.max(560, Math.min(780, root.width - 184 - 40)) + x: (parent.width - width) / 2 + ColumnLayout { + spacing: 2 + Txt { text: wizard.phase === "done" ? "Experiment switched" : "Switch experiment"; role: "h2" } + Txt { text: (wizard.fromPgroup || "") + " → " + (target.pgroup || ""); role: "muted" } + } + // stepper + RowLayout { + Layout.fillWidth: true + spacing: 8 + Repeater { + model: wizard.steps || [] + delegate: RowLayout { + required property var modelData + required property int index + readonly property bool finished: index < wizard.step || (wizard.phase === "done" && index === wizard.step) + readonly property bool current: index === wizard.step && wizard.phase !== "done" + spacing: 8 + Layout.fillWidth: index > 0 + Rectangle { + visible: index > 0 + Layout.fillWidth: true + Layout.minimumWidth: 24 + height: 2 + color: index <= wizard.step ? theme.success : theme.border + } + Rectangle { + width: 24; height: 24; radius: 12 + color: parent.finished ? theme.success : parent.current ? theme.primary : theme.track + Text { + anchors.centerIn: parent + text: parent.parent.finished ? "✓" : index + 1 + color: parent.parent.finished || parent.parent.current ? "white" : theme.fgMuted + font.pixelSize: 12 + font.weight: Font.Bold + } + } + Text { + text: modelData + color: parent.finished || parent.current ? theme.fg : theme.fgMuted + font.pixelSize: 13 + font.weight: parent.finished || parent.current ? Font.DemiBold : Font.Normal + } + } + } + } + Rectangle { Layout.fillWidth: true; height: 1; color: theme.border } + + // step 1: review + ColumnLayout { + visible: wizard.step === 0 + Layout.fillWidth: true + spacing: 6 + Txt { + text: "Check that this is the right experiment. Switching also changes which Linux account the group uses on this computer." + role: "muted" + Layout.fillWidth: true + } + RowLayout { + Layout.fillWidth: true + Item { Layout.preferredWidth: 110 } + Txt { text: "Now"; role: "subtle"; Layout.fillWidth: true; Layout.preferredWidth: 100 } + Item { Layout.preferredWidth: 16 } + Txt { text: "After switching"; role: "subtle"; Layout.fillWidth: true; Layout.preferredWidth: 100 } + } + Repeater { + model: plan.changes || [] + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: 30 + radius: 6 + color: modelData.key ? tint(theme.warning, 0.16) : "transparent" + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 0 + Txt { text: modelData.label; role: modelData.key ? "strong" : "muted"; Layout.preferredWidth: 102; wrapMode: Text.NoWrap } + Txt { text: modelData.before; role: "muted"; elide: Text.ElideRight; wrapMode: Text.NoWrap; Layout.fillWidth: true; Layout.preferredWidth: 100 } + Icon { name: "arrow_forward"; size: 16; color: theme.fgSubtle; Layout.rightMargin: 12; Layout.leftMargin: 4 } + Item { + Layout.fillWidth: true + Layout.preferredWidth: 100 + implicitHeight: 22 + Chip { visible: modelData.key; text: modelData.after; anchors.verticalCenter: parent.verticalCenter } + Txt { visible: !modelData.key; text: modelData.after; role: "strong"; elide: Text.ElideRight; wrapMode: Text.NoWrap; width: parent.width; anchors.verticalCenter: parent.verticalCenter } + } + } + } + } + } + + // step 2: what happens, then confirm + ColumnLayout { + visible: wizard.step === 1 + Layout.fillWidth: true + spacing: 10 + Txt { text: "What happens when you switch"; role: "strong" } + Repeater { + model: plan.consequences || [] + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 10 + Icon { name: modelData.icon; size: 20; color: root.toneColor(modelData.tone); Layout.alignment: Qt.AlignTop } + ColumnLayout { + spacing: 1 + Layout.fillWidth: true + Txt { text: modelData.title; role: "strong"; Layout.fillWidth: true } + Txt { text: modelData.text; role: "muted"; Layout.fillWidth: true } + } + } + } + Rectangle { + Layout.fillWidth: true + implicitHeight: 44 + radius: 8 + color: tint(theme.warning, 0.14) + border.color: tint(theme.warning, 0.5) + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 10 + Icon { name: "login"; size: 22; color: theme.warning } + Txt { text: "Afterwards the group must log in to this computer as"; role: "strong"; wrapMode: Text.NoWrap } + Chip { text: plan.linuxAccount || "" } + Item { Layout.fillWidth: true } + } + } + CheckBox { + id: ack + checked: !!wizard.ack + enabled: wizard.phase !== "switching" + text: plan.ackText || "" + onToggled: backend.setAcknowledged(checked) + indicator: Rectangle { + x: ack.leftPadding + y: (ack.height - height) / 2 + width: 18; height: 18; radius: 4 + color: ack.checked ? theme.primary : theme.field + border.color: ack.checked ? theme.primary : theme.fgSubtle + Text { anchors.centerIn: parent; visible: ack.checked; text: "✓"; color: "white"; font.pixelSize: 13; font.weight: Font.Bold } + } + contentItem: Text { + leftPadding: ack.indicator.width + 10 + text: ack.text + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + } + Banner { + visible: (wizard.error || "") !== "" + tone: "danger" + title: "The switch failed" + text: wizard.error || "" + Layout.fillWidth: true + } + } + + // step 3: log in as the experiment account + ColumnLayout { + visible: wizard.step === 2 + Layout.fillWidth: true + spacing: 10 + Banner { + tone: "ok" + title: (target.pgroup || "") + " is now the active experiment" + text: "New scans are saved for the new experiment. One step is left on this computer:" + Layout.fillWidth: true + } + RowLayout { + spacing: 10 + Txt { text: "Log in as"; role: "h2" } + Chip { text: plan.linuxAccount || ""; big: true } + } + Repeater { + model: plan.loginSteps || [] + delegate: RowLayout { + required property var modelData + required property int index + Layout.fillWidth: true + spacing: 10 + NumberDot { text: index + 1; Layout.alignment: Qt.AlignTop } + ColumnLayout { + spacing: 1 + Layout.fillWidth: true + Txt { text: modelData.title; role: "strong"; Layout.fillWidth: true } + Txt { text: modelData.text; role: "muted"; Layout.fillWidth: true } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + TextButton { + visible: wizard.phase !== "done" && wizard.phase !== "switching" + text: "Cancel" + variant: "ghost" + onClicked: backend.closeWizard() + } + Item { Layout.fillWidth: true } + TextButton { + visible: wizard.step > 0 && wizard.phase !== "done" && wizard.phase !== "switching" + text: "Back" + iconName: "arrow_back" + onClicked: backend.back() + } + TextButton { + text: wizard.step === 0 ? "Next" + : wizard.step === 1 ? (wizard.phase === "switching" ? "Switching…" : "Switch to " + (target.pgroup || "")) + : "Done" + variant: wizard.step === 1 ? "danger" : "primary" + iconName: wizard.step === 0 ? "arrow_forward" : wizard.step === 1 ? "swap_horiz" : "check" + busy: wizard.phase === "switching" + enabled: wizard.step !== 1 || !!wizard.canConfirm + onClicked: wizard.step === 0 ? backend.next() + : wizard.step === 1 ? backend.confirm() : backend.closeWizard() + } + } + } + } + } + } + } + } + } +} diff --git a/tests/unit_tests/test_admin_view_ports.py b/tests/unit_tests/test_admin_view_ports.py new file mode 100644 index 00000000..1cca515e --- /dev/null +++ b/tests/unit_tests/test_admin_view_ports.py @@ -0,0 +1,366 @@ +"""Tests for the reworked admin view (QML and QWidget) and its guided experiment switch.""" + +# pylint: disable=protected-access,redefined-outer-name +import time +from datetime import datetime +from unittest import mock + +import pytest +from qtpy.QtCore import QUrl +from qtpy.QtNetwork import QNetworkRequest + +from bec_widgets.applications.views.admin_view import admin_view as admin_view_module +from bec_widgets.tests.utils import create_widget +from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux import admin_demo +from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_ux_common import ( + AdminAtlasService, + account_notice, + experiment_summary, + filter_experiments, + format_beamtime, + format_remaining, + linux_account_for, + select_next_pgroup, + switch_plan, +) +from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qml import AdminViewQML +from bec_widgets.widgets.services.bec_atlas_admin_view.admin_ux.admin_view_qwidget import ( + AdminViewQWidget, +) +from bec_widgets.widgets.services.bec_atlas_admin_view.bec_atlas_admin_view import BECAtlasAdminView + +AUTH = {"email": "staff@psi.ch", "groups": [], "deployment_id": "dep-x12sa"} + + +def _summaries(): + experiments = admin_demo.demo_experiments() + next_pgroup = select_next_pgroup([e for e in experiments if e["pgroup"] != "p22622"]) + return { + e["pgroup"]: experiment_summary(e, active_pgroup="p22622", next_pgroup=next_pgroup) + for e in experiments + } + + +######################## +## Pure helpers +######################## + + +def test_linux_account_comes_from_eaccount_or_pgroup(): + assert linux_account_for({"eaccount": "e11111", "pgroup": "p22222"}) == "e11111" + assert linux_account_for({"eaccount": "", "pgroup": "p22222"}) == "e22222" + assert linux_account_for({"eaccount": "", "pgroup": "staff"}) == "" + assert linux_account_for(None) == "" + + +def test_summaries_mark_active_next_past_and_unscheduled(): + rows = _summaries() + assert rows["p22622"]["status"] == "active" + assert rows["p22623"]["status"] == "next" + assert rows["p22631"]["status"] == "upcoming" + assert rows["p22604"]["status"] == "past" + assert rows["p22640"]["status"] == "unscheduled" + assert rows["p22623"]["linuxAccount"] == "e22623" + assert rows["p22623"]["pi"] == "Marco Rossi" + + +def test_filter_hides_past_by_default_and_matches_all_words(): + rows = list(_summaries().values()) + upcoming = filter_experiments(rows, "", "upcoming") + assert "p22604" not in [r["pgroup"] for r in upcoming] + assert upcoming[0]["pgroup"] == "p22622" # the active experiment comes first + assert upcoming[-1]["pgroup"] == "p22640" # unscheduled last + assert "p22604" in [r["pgroup"] for r in filter_experiments(rows, "", "all")] + assert [r["pgroup"] for r in filter_experiments(rows, "rossi battery", "all")] == ["p22623"] + assert [r["pgroup"] for r in filter_experiments(rows, "e22631", "all")] == ["p22631"] + + +def test_select_next_prefers_running_then_soonest(): + now = datetime(2026, 1, 10, 12) + experiments = [ + { + "pgroup": "pa", + "schedule": [{"start": "20/01/2026 08:00:00", "end": "21/01/2026 08:00:00"}], + }, + { + "pgroup": "pb", + "schedule": [{"start": "15/01/2026 08:00:00", "end": "16/01/2026 08:00:00"}], + }, + { + "pgroup": "pc", + "schedule": [{"start": "01/01/2026 08:00:00", "end": "02/01/2026 08:00:00"}], + }, + ] + assert select_next_pgroup(experiments, now) == "pb" + experiments.append( + { + "pgroup": "pd", + "schedule": [{"start": "09/01/2026 08:00:00", "end": "11/01/2026 08:00:00"}], + } + ) + assert select_next_pgroup(experiments, now) == "pd" + assert select_next_pgroup([], now) == "" + + +def test_account_notice_tones(): + active = _summaries()["p22622"] + assert account_notice("e22622", active)["tone"] == "ok" + assert account_notice("wyzula_j", active)["tone"] == "info" + warning = account_notice("e22604", active) + assert warning["tone"] == "warning" + assert "e22622" in warning["text"] + assert account_notice("anyone", {}) == {} + + +def test_switch_plan_names_the_new_linux_account_and_the_queue(): + rows = _summaries() + plan = switch_plan(rows["p22622"], rows["p22623"], "wyzula_j", queue_busy=False) + login = next(c for c in plan["changes"] if c["label"] == "Linux login") + assert (login["before"], login["after"], login["key"]) == ("e22622", "e22623", True) + assert plan["linuxAccount"] == "e22623" + assert "e22623" in plan["ackText"] + assert any("e22623" in step["title"] for step in plan["loginSteps"]) + assert any("wyzula_j" in step["text"] for step in plan["loginSteps"]) + assert plan["consequences"][-1]["tone"] == "ok" + busy = switch_plan(rows["p22622"], rows["p22623"], "wyzula_j", queue_busy=True) + assert busy["consequences"][-1]["title"] == "A scan is running or queued" + + +def test_small_formatters(): + assert format_remaining(872) == "14:32" + assert format_remaining(3723) == "1:02:03" + assert format_remaining(-5) == "0:00" + assert format_beamtime(None, None) == "No beamtime scheduled" + start, end = datetime(2026, 10, 12, 8), datetime(2026, 10, 15, 8) + assert format_beamtime(start, end) == "12 Oct 2026, 08:00 – 15 Oct, 08:00" + + +######################## +## Atlas service +######################## + + +class _Reply: + def __init__(self, url: str, status: int): + self._url = url + self._status = status + self.deleteLater = mock.MagicMock() + + def attribute(self, attr): + assert attr == QNetworkRequest.Attribute.HttpStatusCodeAttribute + return self._status + + def url(self): + return QUrl(self._url) + + +@pytest.mark.parametrize( + "endpoint, status, kind, text", + [ + ("/user/login", 401, "login", "Wrong username or password."), + ("/user/login", 0, "login", "not reachable"), + ("/deployments/experiment", 500, "switch", "was not changed"), + ("/realms/experiments", 404, "experiments", "Could not load"), + ], +) +def test_service_reports_failures_inline(qtbot, endpoint, status, kind, text): + service = AdminAtlasService(base_url="http://atlas") + qtbot.addWidget(service) + received = [] + service.request_failed.connect(lambda k, m: received.append((k, m))) + reply = _Reply(f"http://atlas{endpoint}", status) + service._handle_response(reply) + assert received and received[0][0] == kind and text in received[0][1] + reply.deleteLater.assert_called_once() + service.cleanup() + service.cleanup() # a second cleanup (close event) is harmless + + +######################## +## Both views +######################## + + +@pytest.fixture(params=[AdminViewQWidget, AdminViewQML], ids=["qwidget", "qml"]) +def admin(request, qtbot, mocked_client): + widget = create_widget(qtbot, request.param, client=mocked_client, system_user="wyzula_j") + service = widget.atlas_http_service + for name in ("login", "logout", "get_experiments_for_realm", "set_experiment"): + setattr(service, name, mock.MagicMock()) + widget._on_deployment_info(admin_demo.demo_deployment().model_dump(), {}) + yield widget + + +def _sign_in(widget): + widget._on_authenticated({**AUTH, "exp": time.time() + 900}) + widget.atlas_http_service.get_experiments_for_realm.assert_called_with("X12SA") + widget._on_http_response( + { + "request_url": f"{widget._atlas_url}/realms/experiments?realm_id=X12SA", + "headers": {}, + "status": 200, + "data": admin_demo.demo_experiments(), + } + ) + + +def test_signed_out_shows_active_experiment_and_account_notice(admin): + state = admin.view_state() + assert not state["signedIn"] + assert state["active"]["pgroup"] == "p22622" + assert state["active"]["linuxAccount"] == "e22622" + assert state["notice"]["tone"] == "info" + admin.login("", "") + assert admin.view_state()["signInError"] + admin.atlas_http_service.login.assert_not_called() + admin.login("staff", "secret") + admin.atlas_http_service.login.assert_called_once_with(username="staff", password="secret") + assert admin.view_state()["signingIn"] + admin._on_request_failed("login", "Wrong username or password.") + state = admin.view_state() + assert not state["signingIn"] and state["signInError"] == "Wrong username or password." + + +def test_sign_in_for_another_deployment_is_refused(admin): + admin._on_authenticated({**AUTH, "deployment_id": "other", "exp": time.time() + 900}) + state = admin.view_state() + assert not state["signedIn"] + assert "owner rights" in state["signInError"] + + +def test_sign_in_lists_experiments_and_preselects_the_next(admin): + _sign_in(admin) + state = admin.view_state() + assert state["signedIn"] and state["remaining"] + assert state["selected"]["pgroup"] == "p22623" + assert state["canSwitch"] + assert [r["pgroup"] for r in state["rows"]][0] == "p22622" + admin.set_scope("all") + assert "p22604" in [r["pgroup"] for r in admin.view_state()["rows"]] + admin.set_query("tanaka") + assert [r["pgroup"] for r in admin.view_state()["rows"]] == ["p22631"] + admin.select_experiment("p22622") + assert not admin.view_state()["canSwitch"] + + +def test_guided_switch_needs_confirmation_and_ends_with_login_steps(admin): + _sign_in(admin) + admin.start_switch("") + wizard = admin.view_state()["wizard"] + assert wizard["open"] and wizard["step"] == 0 and wizard["fromPgroup"] == "p22622" + assert wizard["plan"]["linuxAccount"] == "e22623" + admin.wizard_next() + admin.confirm_switch() # not acknowledged yet: nothing is sent + admin.atlas_http_service.set_experiment.assert_not_called() + assert not admin.view_state()["wizard"]["canConfirm"] + admin.set_acknowledged(True) + assert admin.view_state()["wizard"]["canConfirm"] + admin.confirm_switch() + admin.atlas_http_service.set_experiment.assert_called_once_with("p22623", "dep-x12sa") + assert admin.view_state()["wizard"]["phase"] == "switching" + admin.close_wizard() # cannot leave while the request runs + assert admin.view_state()["wizard"]["open"] + switched = [] + admin.experiment_switched.connect(switched.append) + admin._on_http_response( + { + "request_url": f"{admin._atlas_url}/deployments/experiment?experiment_id=p22623", + "headers": {}, + "status": 200, + "data": {}, + } + ) + state = admin.view_state() + assert switched == ["p22623"] + assert state["wizard"]["phase"] == "done" and state["wizard"]["step"] == 2 + assert state["active"]["pgroup"] == "p22623" + # the confirmed change stays visible although the active experiment moved + login = next(c for c in state["wizard"]["plan"]["changes"] if c["label"] == "Linux login") + assert (login["before"], login["after"]) == ("e22622", "e22623") + admin.close_wizard() + assert not admin.view_state()["wizard"]["open"] + + +def test_failed_switch_stays_in_confirm_step(admin): + _sign_in(admin) + admin.start_switch("p22631") + admin.wizard_next() + admin.set_acknowledged(True) + admin.confirm_switch() + admin._on_request_failed("switch", "BEC Atlas refused the switch (HTTP 500).") + wizard = admin.view_state()["wizard"] + assert wizard["phase"] == "error" and wizard["step"] == 1 and "refused" in wizard["error"] + admin.confirm_switch() # retry is possible + assert admin.atlas_http_service.set_experiment.call_count == 2 + admin._on_request_failed("switch", "again") + admin.wizard_back() + assert admin.view_state()["wizard"]["step"] == 0 + admin.wizard_back() + assert not admin.view_state()["wizard"]["open"] + + +def test_busy_queue_adds_a_warning(admin): + _sign_in(admin) + item = mock.MagicMock(status="RUNNING") + admin._on_queue_status({"queue": {"primary": mock.MagicMock(info=[item])}}, {}) + admin.start_switch("") + consequences = admin.view_state()["wizard"]["plan"]["consequences"] + assert consequences[-1]["tone"] == "warning" + + +def test_sign_out_clears_the_session(admin): + _sign_in(admin) + admin.start_switch("") + admin._on_authenticated({}) + state = admin.view_state() + assert not state["signedIn"] and not state["rows"] and not state["wizard"]["open"] + + +def test_qml_backend_drives_the_same_flow(qtbot, mocked_client): + widget = create_widget(qtbot, AdminViewQML, client=mocked_client, system_user="e22622") + for name in ("login", "logout", "get_experiments_for_realm", "set_experiment"): + setattr(widget.atlas_http_service, name, mock.MagicMock()) + widget._on_deployment_info(admin_demo.demo_deployment().model_dump(), {}) + assert widget.view.rootObject() is not None + _sign_in(widget) + backend = widget.backend + assert backend.rows.rowCount() == len(widget.view_state()["rows"]) + assert backend.state["notice"]["tone"] == "ok" + backend.selectExperiment("p22631") + backend.startSwitch("") + backend.next() + backend.setAcknowledged(True) + backend.confirm() + widget.atlas_http_service.set_experiment.assert_called_once_with("p22631", "dep-x12sa") + + +def test_qwidget_buttons_drive_the_flow(qtbot, mocked_client): + widget = create_widget(qtbot, AdminViewQWidget, client=mocked_client, system_user="wyzula_j") + for name in ("login", "logout", "get_experiments_for_realm", "set_experiment"): + setattr(widget.atlas_http_service, name, mock.MagicMock()) + widget._on_deployment_info(admin_demo.demo_deployment().model_dump(), {}) + widget._username.setText("staff") + widget._password.setText("secret") + widget._sign_in_button.click() + widget.atlas_http_service.login.assert_called_once_with(username="staff", password="secret") + assert widget._password.text() == "" + _sign_in(widget) + assert widget._pages.currentIndex() == 1 + widget._switch_button.click() + assert widget._content.currentIndex() == 1 + widget._wiz_primary.click() + assert not widget._wiz_primary.isEnabled() + widget._ack.setChecked(True) + assert widget._wiz_primary.isEnabled() + widget._wiz_primary.click() + widget.atlas_http_service.set_experiment.assert_called_once_with("p22623", "dep-x12sa") + + +@pytest.mark.parametrize( + "value, cls", [("qml", AdminViewQML), ("qwidget", AdminViewQWidget), ("", BECAtlasAdminView)] +) +def test_admin_view_env_switch(qtbot, mocked_client, monkeypatch, value, cls): + monkeypatch.setenv(admin_view_module.ADMIN_UI_ENV, value) + widget = admin_view_module.create_admin_widget() + qtbot.addWidget(widget) + assert isinstance(widget, cls)