feat(admin_view): QML and QWidget admin views with a guided experiment switch that names the Linux account to log in with

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4kzsfXBk1iaZU32We5XLP
This commit is contained in:
Claude committed 2026-10-09 22:09:28 +00:00
1 parent 9e68268ebb
commit 94cbb920f2
8 files changed
+3064 -1

No files matched your search

@@ -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()
@@ -0,0 +1 @@
"""Reworked admin view with QML and QWidget renderings of the same experiment-switch flow."""
@@ -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),
)
@@ -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()
@@ -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()
@@ -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 <b>{selected['linuxAccount'] or 'unknown'}</b>"
)
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()
@@ -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 <b>" + (parent.parent.sel.linuxAccount || "unknown") + "</b>"
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()
}
}
}
}
}
}
}
}
}
}
+366
View File
@@ -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)