Merge branch 'apache-auth'
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest==9.0.3",
|
||||
# "pytest==9.0.3",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-mock==3.14.0",
|
||||
"pytest-qt==4.4.0",
|
||||
|
||||
@@ -10,8 +10,8 @@ gui:
|
||||
gonio_camera_id: "1"
|
||||
|
||||
daq:
|
||||
daq_url: "http://mx-x06da-queue-01.psi.ch:5210"
|
||||
|
||||
daq_url: "https://mx-x06da-queue-01.psi.ch"
|
||||
cert_path: "/sls/x06da/misc/.cert/6d.crt"
|
||||
shared:
|
||||
jfjoch:
|
||||
jfjoch_url: "http://sls-gpu-001:8080"
|
||||
|
||||
@@ -8,7 +8,8 @@ gui:
|
||||
gonio_camera_id: ""
|
||||
|
||||
daq:
|
||||
daq_url: "http://mx-x06sa-queue-01.psi.ch:5210"
|
||||
daq_url: "https://mx-x06sa-queue-01.psi.ch"
|
||||
cert_path: "/sls/x06sa/misc/.cert/6s.crt"
|
||||
|
||||
shared:
|
||||
jfjoch:
|
||||
|
||||
@@ -9,7 +9,8 @@ gui:
|
||||
gonio_camera_id: "1"
|
||||
|
||||
daq:
|
||||
daq_url: "http://mx-x10sa-queue-01.psi.ch:5210"
|
||||
daq_url: "https://mx-x10sa-queue-01.psi.ch"
|
||||
cert_path: "/sls/x10sa/misc/.cert/10s.crt"
|
||||
|
||||
shared:
|
||||
jfjoch:
|
||||
|
||||
+83
-5
@@ -1,13 +1,18 @@
|
||||
import grp
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from typing import List
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("aareDAQ")
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -30,6 +35,10 @@ BATON_REQUEST_TIMEOUT_SECONDS = 30
|
||||
STAFF_GROUP = "unx-MXgroup"
|
||||
SUPER_USERS = ["e10019", "e11206", "e18147"]
|
||||
|
||||
APACHE_ACCESS_LOG = "/var/log/httpd/daq-access.log"
|
||||
# Common Log Format: IP - username [timestamp] "request" status bytes ...
|
||||
_LOG_PATTERN = re.compile(r'^\S+ \S+ (\S+) \[.*?\] ".*?" (\d+)')
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
class TokenData(BaseModel):
|
||||
@@ -46,15 +55,84 @@ def create_access_token(token: TokenData):
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def authenticate_user(cfg: BeamlineConfig, form_data: OAuth2PasswordRequestForm) -> str:
|
||||
user_info = pwd.getpwnam(form_data.username)
|
||||
def authenticate_from_apache_log() -> str:
|
||||
"""
|
||||
Read the Apache access log and return the username from the most recent
|
||||
200 response. Apache and the DAQ server are co-located on the same machine.
|
||||
"""
|
||||
try:
|
||||
with open(APACHE_ACCESS_LOG, 'r') as f:
|
||||
lines = f.readlines()
|
||||
except OSError as e:
|
||||
raise AuthenticationException(
|
||||
message=f"Cannot read Apache access log: {e}",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
code=AuthErrorCode.INVALID_TOKEN,
|
||||
) from e
|
||||
|
||||
for line in reversed(lines):
|
||||
m = _LOG_PATTERN.match(line)
|
||||
if m and m.group(2) == '200' and m.group(1) != '-':
|
||||
return m.group(1)
|
||||
|
||||
raise AuthenticationException(
|
||||
message="No authenticated user found in Apache access log",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
code=AuthErrorCode.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
def _is_loopback(host: str | None) -> bool:
|
||||
if host is None:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
# Unwrap IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) before checking
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
return addr.ipv4_mapped.is_loopback
|
||||
return addr.is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def authenticate_from_proxy_header(request: Request) -> str:
|
||||
"""
|
||||
Extract the pre-authenticated username from the trusted Apache proxy header.
|
||||
Only trust this header when the request originates from localhost, meaning
|
||||
only the Apache proxy running on the same host can supply it.
|
||||
"""
|
||||
client_host = request.client.host if request.client else None
|
||||
logger.debug(f"[auth] /token client_host={client_host!r} request.client={request.client!r}")
|
||||
if not _is_loopback(client_host):
|
||||
logger.warning(f"[auth] Rejecting X-Remote-User: client_host {client_host!r} is not loopback")
|
||||
raise AuthenticationException(
|
||||
message="X-Remote-User header is only trusted from the localhost proxy",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
code=AuthErrorCode.INVALID_TOKEN,
|
||||
)
|
||||
remote_user = request.headers.get("X-Remote-User")
|
||||
if not remote_user:
|
||||
raise AuthenticationException(
|
||||
message="Missing X-Remote-User header from proxy",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
code=AuthErrorCode.INVALID_TOKEN,
|
||||
)
|
||||
return remote_user
|
||||
|
||||
|
||||
def authenticate_user(cfg: BeamlineConfig, username: str) -> str:
|
||||
user_info = pwd.getpwnam(username)
|
||||
|
||||
groups = os.getgrouplist(user_info.pw_name, user_info.pw_gid)
|
||||
supplementary_groups = [grp.getgrgid(g).gr_name.lower() for g in groups]
|
||||
super_user = form_data.username in SUPER_USERS
|
||||
super_user = username in SUPER_USERS
|
||||
pgroups = [group for group in supplementary_groups if group.startswith('p')]
|
||||
staff = "unx-mxgroup" in supplementary_groups or "unx-sls_mx" in supplementary_groups or super_user
|
||||
token = TokenData(sub=form_data.username,
|
||||
token = TokenData(sub=username,
|
||||
pgroups=pgroups,
|
||||
staff=staff,
|
||||
session=cfg.generate_session())
|
||||
|
||||
+13
-4
@@ -24,7 +24,7 @@ from aare.common.automation_models import AutomationProgress
|
||||
from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid
|
||||
from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan
|
||||
from aare.common.sample_geometry import SampleGeometryModel
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi import FastAPI, Depends, Request
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi import HTTPException
|
||||
from fastapi import status as api_status
|
||||
@@ -256,17 +256,26 @@ async def automation_progress_event_stream() -> AsyncGenerator[str, None]:
|
||||
return
|
||||
|
||||
@app.post("/token")
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
"""
|
||||
Authenticate a user and return an access token.
|
||||
|
||||
When the request carries an X-Remote-User header (set by the Apache Kerberos
|
||||
proxy), the username is taken from that header and the proxy origin is verified.
|
||||
Otherwise the username from the form data is used (local / dev access).
|
||||
|
||||
Args:
|
||||
request: The incoming HTTP request (used to inspect headers and client IP).
|
||||
form_data: OAuth2 password request form containing username and password.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the access token and token type.
|
||||
"""
|
||||
data = await run_in_threadpool(auth.authenticate_user, cfg, form_data)
|
||||
if request.headers.get("X-Remote-User"):
|
||||
username = auth.authenticate_from_proxy_header(request)
|
||||
else:
|
||||
username = auth.authenticate_from_apache_log()
|
||||
data = await run_in_threadpool(auth.authenticate_user, cfg, username)
|
||||
return {"access_token": data, "token_type": "bearer"}
|
||||
|
||||
@app.get("/meta/error-codes")
|
||||
@@ -2377,7 +2386,7 @@ def main():
|
||||
urllib3.disable_warnings()
|
||||
|
||||
# Run the application using uvicorn
|
||||
uvicorn.run("aare.daq.server:app", host="0.0.0.0", port=5210, workers=2, log_config=get_uvicorn_logging_config())
|
||||
uvicorn.run("aare.daq.server:app", host="127.0.0.1", port=5210, workers=2, proxy_headers=False, log_config=get_uvicorn_logging_config())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+41
-26
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import jwt
|
||||
import requests
|
||||
|
||||
from aare.common.models import TokenData
|
||||
from aare.common.auth_models import get_user
|
||||
@@ -8,7 +8,7 @@ from aare.common.logger_config import setup_logger
|
||||
|
||||
logger = setup_logger('aareGUI')
|
||||
|
||||
def auth(base_url: str | None) -> str:
|
||||
def auth(base_url: str | None, cert_path: str | None) -> str:
|
||||
curr_user = get_user()
|
||||
if base_url is None:
|
||||
token_data = TokenData(sub=curr_user,
|
||||
@@ -17,38 +17,53 @@ def auth(base_url: str | None) -> str:
|
||||
pgroups=["p16371", "p22233"])
|
||||
return jwt.encode(token_data.model_dump(), "ABC123")
|
||||
|
||||
# Single call: Kerberos SPNEGO through Apache, which proxies to the DAQ server.
|
||||
# Apache injects X-Remote-User; the DAQ server reads it from the localhost-proxied request.
|
||||
url = f"{base_url}/token"
|
||||
cacert = f"{cert_path}"
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data={
|
||||
"username": curr_user,
|
||||
"password": ""
|
||||
},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
timeout=(3.0, 15.0), # (connect timeout, read timeout)
|
||||
token_result = subprocess.run(
|
||||
['curl', '-s', '--cacert', cacert,
|
||||
'--negotiate', '-u', ':',
|
||||
'-X', 'POST', url,
|
||||
'-d', 'username=&password=',
|
||||
'-H', 'Content-Type: application/x-www-form-urlencoded'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=18.0,
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Authentication request failed (network): {e}")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
logger.error(f"Token request curl timed out: {e}")
|
||||
raise RuntimeError(
|
||||
"Cannot reach AareDAQ server (network error). "
|
||||
"Cannot reach AareDAQ server (timeout). "
|
||||
"Please check the server is running and your connection."
|
||||
) from e
|
||||
|
||||
if response.status_code != 200:
|
||||
# Avoid dumping full HTML/tracebacks into the GUI; keep it short and actionable
|
||||
logger.error(f"Authentication request failed: HTTP {response.status_code}. Body: {response.text[:500]}")
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"curl not found: {e}")
|
||||
raise RuntimeError("curl not found on this system.") from e
|
||||
except OSError as e:
|
||||
logger.error(f"Token request curl OS error: {e}")
|
||||
raise RuntimeError(
|
||||
f"Authentication failed (HTTP {response.status_code}). "
|
||||
"The server may be starting up or unavailable."
|
||||
"Cannot reach AareDAQ server (OS error). "
|
||||
"Please check the server is running and your connection."
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.error(f"Token request curl error: {e}")
|
||||
raise RuntimeError(
|
||||
"Cannot reach AareDAQ server (unknown error). "
|
||||
)
|
||||
|
||||
if token_result.returncode != 0:
|
||||
logger.error(f"Token curl exited {token_result.returncode}. stderr: {token_result.stderr[:500]}")
|
||||
raise RuntimeError(
|
||||
f"Token request failed (curl exit {token_result.returncode}). "
|
||||
"Check Kerberos ticket is valid (kinit) and server is reachable."
|
||||
)
|
||||
|
||||
try:
|
||||
response_json = response.json()
|
||||
except ValueError as e:
|
||||
logger.error(f"Authentication response was not JSON. Body: {response.text[:500]}")
|
||||
response_json = json.loads(token_result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Token response not JSON. stdout: {token_result.stdout[:500]}")
|
||||
raise RuntimeError(
|
||||
"Authentication failed (invalid server response). "
|
||||
"The server may be starting up or misconfigured."
|
||||
@@ -56,7 +71,7 @@ def auth(base_url: str | None) -> str:
|
||||
|
||||
token = response_json.get("access_token")
|
||||
if not token or not isinstance(token, str):
|
||||
logger.error(f"Authentication response missing access_token. Keys: {list(response_json.keys())}")
|
||||
logger.error(f"Missing access_token. Keys: {list(response_json.keys())}. Server response {response_json}")
|
||||
raise RuntimeError(
|
||||
"Authentication failed (missing token in server response). "
|
||||
"The server may be starting up."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
+17
-4
@@ -45,21 +45,24 @@ def main():
|
||||
#TODO if zmq and pred stream come from same source, do not need images from both streams, can combine
|
||||
match mx_beamline():
|
||||
case MXBeamline.X06DA:
|
||||
default_url = cfg_get("gui.daq.daq_url", "http://mx-x06da-queue-01.psi.ch:5210")
|
||||
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06da-queue-01.psi.ch")
|
||||
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06da/misc/.cert/6d.crt")
|
||||
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "tcp://x06da-pserv-01:9089")
|
||||
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "tcp://mx-ml:9091")
|
||||
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "x06da-axis-1.psi.ch")
|
||||
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ed2972e.psi.ch")
|
||||
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 3))
|
||||
case MXBeamline.X10SA:
|
||||
default_url = cfg_get("gui.daq.daq_url", "http://mx-x10sa-queue-01.psi.ch:5210")
|
||||
default_url = cfg_get("gui.daq.daq_url", "https://mx-x10sa-queue-01.psi.ch")
|
||||
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x10sa/misc/.cert/10s.crt")
|
||||
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "tcp://x10sa-spark-01:9091")
|
||||
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "tcp://x10sa-spark-01:9091")
|
||||
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "axis-accc8eb02488.psi.ch")
|
||||
default_gonio_cam_addr = cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ea5e463.psi.ch")
|
||||
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
|
||||
case MXBeamline.X06SA:
|
||||
default_url = cfg_get("gui.daq.daq_url", "http://mx-x06sa-queue-01.psi.ch:5210")
|
||||
default_url = cfg_get("gui.daq.daq_url", "https://mx-x06sa-queue-01.psi.ch")
|
||||
default_cert_path = cfg_get("gui.daq.cert_path", "/sls/x06sa/misc/.cert/6s.crt")
|
||||
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
|
||||
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
|
||||
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
|
||||
@@ -67,6 +70,7 @@ def main():
|
||||
default_gonio_camera_id = int(cfg_get("gui.cameras.gonio_camera_id", 1))
|
||||
case _:
|
||||
default_url = cfg_get("gui.daq.daq_url", "")
|
||||
default_cert_path = cfg_get("gui.daq.cert_path", "")
|
||||
default_zmq_addr = cfg_get("gui.cameras.sample_camera_zmq_url", "")
|
||||
default_pred_zmq_addr = cfg_get("gui.cameras.prediction_zmq_url", "")
|
||||
default_beamline_cam_addr = cfg_get("gui.cameras.beamline_camera_url", "")
|
||||
@@ -79,6 +83,11 @@ def main():
|
||||
"url", default_url)
|
||||
parser.addOption(urlOption)
|
||||
|
||||
certPath = QCommandLineOption(["c", "aaredaq-cert-path"],
|
||||
"Server Certificate Path (for self-signed certificates)",
|
||||
"cert", default_cert_path)
|
||||
parser.addOption(certPath)
|
||||
|
||||
defaultImage = QCommandLineOption(["i", "image"],
|
||||
"Default image to display in absence of the ZMQ stream",
|
||||
"image")
|
||||
@@ -105,6 +114,10 @@ def main():
|
||||
if base_url == "" or base_url.lower() == "none":
|
||||
base_url = None
|
||||
|
||||
cert_path = parser.value(certPath)
|
||||
if cert_path == "" or cert_path.lower() == "none":
|
||||
cert_path = None
|
||||
|
||||
zmq_addr = parser.value(cameraZeroMQ)
|
||||
if zmq_addr == "":
|
||||
zmq_addr = None
|
||||
@@ -123,7 +136,7 @@ def main():
|
||||
|
||||
try:
|
||||
splash.set_progress(50, f"Connecting to {base_url or 'backend'}...")
|
||||
token = auth(base_url)
|
||||
token = auth(base_url, cert_path)
|
||||
if not token or token.count(".") != 2:
|
||||
raise RuntimeError(
|
||||
"Authentication did not return a valid token. "
|
||||
|
||||
+71
-19
@@ -106,6 +106,13 @@ class MainWindow(QMainWindow):
|
||||
self._controls_help_dialog = None
|
||||
self._cleanup_done = False
|
||||
self._default_window_state = None
|
||||
self._pre_automation_window_state = None
|
||||
self._pre_automation_ref_tools_visible = False
|
||||
self._pre_automation_left_column_visible = True
|
||||
self._pre_automation_right_column_visible = True
|
||||
self._in_compact_automation_view = False
|
||||
self._enter_automation_view_action = None
|
||||
self._return_main_view_action = None
|
||||
|
||||
self._beamline_cam_addr = beamline_cam_addr
|
||||
self._gonio_cam_addr = gonio_cam_addr
|
||||
@@ -199,7 +206,7 @@ class MainWindow(QMainWindow):
|
||||
self.raster = RasterGridManager(geom=geom)
|
||||
self.rotation = RotationScanManager()
|
||||
|
||||
collection_controls_scroll = NoWheelScrollArea(top_widget)
|
||||
self.collection_controls_scroll = NoWheelScrollArea(top_widget)
|
||||
|
||||
self.left_column = QWidget(parent=top_widget)
|
||||
self.left_column_layout = QVBoxLayout(self.left_column)
|
||||
@@ -226,13 +233,13 @@ class MainWindow(QMainWindow):
|
||||
self.beamline_state_panel.hide()
|
||||
self.left_column_layout.addStretch()
|
||||
|
||||
top_widget_layout.addWidget(collection_controls_scroll)
|
||||
collection_controls_scroll.setWidget(self.left_column)
|
||||
collection_controls_scroll.setHorizontalScrollBarPolicy(
|
||||
top_widget_layout.addWidget(self.collection_controls_scroll)
|
||||
self.collection_controls_scroll.setWidget(self.left_column)
|
||||
self.collection_controls_scroll.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
collection_controls_scroll.setWidgetResizable(True)
|
||||
collection_controls_scroll.setFixedWidth(
|
||||
self.collection_controls_scroll.setWidgetResizable(True)
|
||||
self.collection_controls_scroll.setFixedWidth(
|
||||
max(
|
||||
self.data_collection.set_width,
|
||||
self.loop_centering.sizeHint().width(),
|
||||
@@ -295,7 +302,7 @@ class MainWindow(QMainWindow):
|
||||
self.compact_automation_page.setStyleSheet(
|
||||
"""
|
||||
QWidget#compactAutomationPage {
|
||||
background-color: #e8eefc;
|
||||
background-color: rgb(216, 228, 253);
|
||||
}
|
||||
"""
|
||||
)
|
||||
@@ -307,15 +314,15 @@ class MainWindow(QMainWindow):
|
||||
top_widget_layout.addWidget(self.video_tab)
|
||||
self._start_axis_camera_threads()
|
||||
|
||||
beamline_controls_scroll = NoWheelScrollArea(top_widget)
|
||||
self.beamline_controls_scroll = NoWheelScrollArea(top_widget)
|
||||
|
||||
self.beamline = BeamlineControls(beamline_controls_scroll, staff=self.__decoded_token.staff)
|
||||
top_widget_layout.addWidget(beamline_controls_scroll)
|
||||
beamline_controls_scroll.setWidget(self.beamline)
|
||||
beamline_controls_scroll.setHorizontalScrollBarPolicy(
|
||||
self.beamline = BeamlineControls(self.beamline_controls_scroll, staff=self.__decoded_token.staff)
|
||||
top_widget_layout.addWidget(self.beamline_controls_scroll)
|
||||
self.beamline_controls_scroll.setWidget(self.beamline)
|
||||
self.beamline_controls_scroll.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10)
|
||||
self.beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10)
|
||||
|
||||
self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s=[]))
|
||||
self.ref_tools_panel = ReferenceToolsPanel(samples=SampleShortInfoList(s=[]))
|
||||
@@ -478,6 +485,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.setWindowTitle("AareGUI")
|
||||
self.create_menu_bar()
|
||||
self._update_view_mode_actions()
|
||||
self._setup_global_shortcuts()
|
||||
self._capture_default_window_state()
|
||||
self._restore_window_state()
|
||||
@@ -916,6 +924,17 @@ class MainWindow(QMainWindow):
|
||||
key=lambda sample: sample.loc_str_sort(),
|
||||
)
|
||||
|
||||
def _update_view_mode_actions(self) -> None:
|
||||
in_automation_view = bool(self._in_compact_automation_view)
|
||||
|
||||
if self._enter_automation_view_action is not None:
|
||||
self._enter_automation_view_action.setVisible(not in_automation_view)
|
||||
self._enter_automation_view_action.setEnabled(not in_automation_view)
|
||||
|
||||
if self._return_main_view_action is not None:
|
||||
self._return_main_view_action.setVisible(in_automation_view)
|
||||
self._return_main_view_action.setEnabled(in_automation_view)
|
||||
|
||||
@Slot()
|
||||
def enter_compact_automation_view(self) -> None:
|
||||
self.job_list_panel.ensure_default_queue_from_samples(
|
||||
@@ -923,6 +942,12 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
self._refresh_compact_queue_preview()
|
||||
|
||||
if not self._in_compact_automation_view:
|
||||
self._pre_automation_window_state = self.saveState()
|
||||
self._pre_automation_ref_tools_visible = self.ref_tools_dock.isVisible()
|
||||
self._pre_automation_left_column_visible = self.collection_controls_scroll.isVisible()
|
||||
self._pre_automation_right_column_visible = self.beamline_controls_scroll.isVisible()
|
||||
|
||||
self.tell_samples_dock.setVisible(False)
|
||||
self.job_list_dock.setVisible(False)
|
||||
self.manual_sample_dock.setVisible(False)
|
||||
@@ -937,12 +962,29 @@ class MainWindow(QMainWindow):
|
||||
if self.__decoded_token.staff:
|
||||
self.ref_tools_dock.setVisible(False)
|
||||
|
||||
self.collection_controls_scroll.setVisible(False)
|
||||
self.beamline_controls_scroll.setVisible(False)
|
||||
|
||||
self.content_stack.setCurrentWidget(self.compact_automation_page)
|
||||
self._in_compact_automation_view = True
|
||||
self._update_view_mode_actions()
|
||||
|
||||
@Slot()
|
||||
def _return_from_compact_automation_view(self) -> None:
|
||||
self.content_stack.setCurrentWidget(self._standard_main_page)
|
||||
self.restore_default_view()
|
||||
|
||||
if self._pre_automation_window_state is not None:
|
||||
self.restoreState(self._pre_automation_window_state)
|
||||
|
||||
self.collection_controls_scroll.setVisible(self._pre_automation_left_column_visible)
|
||||
self.beamline_controls_scroll.setVisible(self._pre_automation_right_column_visible)
|
||||
|
||||
if self.__decoded_token.staff:
|
||||
self.ref_tools_dock.setVisible(self._pre_automation_ref_tools_visible)
|
||||
|
||||
self._in_compact_automation_view = False
|
||||
self._update_view_mode_actions()
|
||||
|
||||
self.job_list_dock.setVisible(True)
|
||||
self.tell_samples_dock.setVisible(True)
|
||||
self.job_list_dock.raise_()
|
||||
@@ -1025,6 +1067,16 @@ class MainWindow(QMainWindow):
|
||||
quit_action.triggered.connect(self.close)
|
||||
file_menu.addAction(quit_action)
|
||||
|
||||
self._enter_automation_view_action = QAction("Automation View", self)
|
||||
self._enter_automation_view_action.setShortcut(QKeySequence("Ctrl+5"))
|
||||
self._enter_automation_view_action.triggered.connect(self.enter_compact_automation_view)
|
||||
menu_bar.addAction(self._enter_automation_view_action)
|
||||
|
||||
self._return_main_view_action = QAction("Return to Main View", self)
|
||||
self._return_main_view_action.setShortcut(QKeySequence("Ctrl+Shift+5"))
|
||||
self._return_main_view_action.triggered.connect(self._return_from_compact_automation_view)
|
||||
menu_bar.addAction(self._return_main_view_action)
|
||||
|
||||
view_menu = menu_bar.addMenu("View")
|
||||
|
||||
if self._beamline_state_panel_enabled:
|
||||
@@ -1138,11 +1190,6 @@ class MainWindow(QMainWindow):
|
||||
beamline_combined_tab_action.triggered.connect(lambda: self.video_tab.setCurrentIndex(3))
|
||||
view_menu.addAction(beamline_combined_tab_action)
|
||||
|
||||
compact_automation_action = QAction("Automation View", self)
|
||||
compact_automation_action.setShortcut(QKeySequence("Ctrl+5"))
|
||||
compact_automation_action.triggered.connect(self.enter_compact_automation_view)
|
||||
view_menu.addAction(compact_automation_action)
|
||||
|
||||
view_menu.addSeparator()
|
||||
|
||||
restore_default_view_action = QAction("Restore Default View", self)
|
||||
@@ -1194,6 +1241,11 @@ class MainWindow(QMainWindow):
|
||||
self.restoreState(self._default_window_state)
|
||||
|
||||
self.content_stack.setCurrentWidget(self._standard_main_page)
|
||||
self._in_compact_automation_view = False
|
||||
self._update_view_mode_actions()
|
||||
|
||||
self.collection_controls_scroll.setVisible(True)
|
||||
self.beamline_controls_scroll.setVisible(True)
|
||||
|
||||
self.tell_samples_dock.setVisible(True)
|
||||
self.job_list_dock.setVisible(True)
|
||||
|
||||
@@ -22,6 +22,12 @@ def get_entry(sample: SampleShortInfo, column: int):
|
||||
elif column == 6:
|
||||
return sample.mount_count
|
||||
elif column == 7:
|
||||
return sample.raster_count
|
||||
elif column == 8:
|
||||
return sample.rotation_count
|
||||
elif column == 9:
|
||||
return sample.screening_count
|
||||
elif column == 10:
|
||||
return sample.comment
|
||||
|
||||
|
||||
@@ -46,6 +52,9 @@ class UserSampleSpreadsheet(QAbstractTableModel):
|
||||
"Priority",
|
||||
"User",
|
||||
"Mount count",
|
||||
"Raster count",
|
||||
"Rotation count",
|
||||
"Screening count",
|
||||
"Comment",
|
||||
]
|
||||
self.current_sample = current_sample
|
||||
|
||||
@@ -37,48 +37,52 @@ class CompactAutomationPanel(QFrame):
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QFrame#compactAutomationPanel {
|
||||
background: #edf3fb;
|
||||
background: #d8e4fd;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
QFrame#compactTopCard,
|
||||
QFrame#compactCameraCard,
|
||||
QFrame#compactControlsCard,
|
||||
QFrame#compactProgressCard,
|
||||
QFrame#compactControlsCard {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d9e4f2;
|
||||
QFrame#compactQueueCard,
|
||||
QFrame#compactQueueItem {
|
||||
background: #e6eefc;
|
||||
border: 1px solid #b9ccee;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
QFrame#compactQueueItem {
|
||||
background: #f7faff;
|
||||
border: 1px solid #dbe6f5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
QLabel#compactQueueTitle {
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
QLabel#compactQueueValue {
|
||||
color: #0f172a;
|
||||
QLabel#compactSectionTitle {
|
||||
background: transparent;
|
||||
color: #17324d;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLabel#compactDashboardLabel {
|
||||
color: #334155;
|
||||
QLabel#compactSectionHint {
|
||||
background: transparent;
|
||||
color: #51657d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
QLabel#compactQueueTitle {
|
||||
background: transparent;
|
||||
color: #51657d;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLabel#compactQueueValue {
|
||||
background: transparent;
|
||||
color: #10263a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QToolButton#compactMenuButton {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #cbdcf8;
|
||||
color: #17324d;
|
||||
border: 1px solid #9fb9e5;
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: 18px;
|
||||
@@ -86,23 +90,19 @@ class CompactAutomationPanel(QFrame):
|
||||
}
|
||||
|
||||
QToolButton#compactMenuButton:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
QFrame#compactAutomationProgressStrip {
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: #bfd4f6;
|
||||
}
|
||||
|
||||
QLabel#compactProgressSummary {
|
||||
color: #1e293b;
|
||||
background: transparent;
|
||||
color: #17324d;
|
||||
font-size: 13px;
|
||||
padding: 2px 2px 6px 2px;
|
||||
}
|
||||
|
||||
QLabel#compactProgressStep {
|
||||
background: #f8fbff;
|
||||
border: 1px solid #dbe6f5;
|
||||
background: #dfe9fb;
|
||||
border: 1px solid #bfd1ef;
|
||||
border-radius: 10px;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
@@ -121,10 +121,11 @@ class CompactAutomationPanel(QFrame):
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
QPushButton#compactSecondaryButton {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
border: 1px solid #cbd5e1;
|
||||
QPushButton#compactSecondaryButton,
|
||||
QToolButton#compactSecondaryButton {
|
||||
background: #dfe9fb;
|
||||
color: #17324d;
|
||||
border: 1px solid #b2c7eb;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
@@ -133,17 +134,7 @@ class CompactAutomationPanel(QFrame):
|
||||
|
||||
QPushButton#compactSecondaryButton:hover,
|
||||
QToolButton#compactSecondaryButton:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
QToolButton#compactSecondaryButton {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: #d3e1f8;
|
||||
}
|
||||
"""
|
||||
)
|
||||
@@ -152,44 +143,18 @@ class CompactAutomationPanel(QFrame):
|
||||
main_layout.setContentsMargins(18, 18, 18, 18)
|
||||
main_layout.setSpacing(12)
|
||||
|
||||
top_card = QFrame(self)
|
||||
top_card.setObjectName("compactTopCard")
|
||||
top_layout = QHBoxLayout(top_card)
|
||||
top_layout.setContentsMargins(12, 12, 12, 12)
|
||||
top_layout.setSpacing(10)
|
||||
camera_card = QFrame(self)
|
||||
camera_card.setObjectName("compactCameraCard")
|
||||
camera_layout = QVBoxLayout(camera_card)
|
||||
camera_layout.setContentsMargins(12, 12, 12, 12)
|
||||
camera_layout.setSpacing(8)
|
||||
|
||||
self.current_card = self._build_queue_item("Current", "—")
|
||||
self.next_card = self._build_queue_item("Next", "—")
|
||||
self.next_next_card = self._build_queue_item("Then", "—")
|
||||
camera_title = QLabel("Camera", self)
|
||||
camera_title.setObjectName("compactSectionTitle")
|
||||
camera_layout.addWidget(camera_title)
|
||||
camera_layout.addWidget(camera_widget, 1)
|
||||
|
||||
top_layout.addWidget(self.current_card["frame"], 2)
|
||||
top_layout.addWidget(self.next_card["frame"], 2)
|
||||
top_layout.addWidget(self.next_next_card["frame"], 2)
|
||||
|
||||
self.menu_button = QToolButton(self)
|
||||
self.menu_button.setObjectName("compactMenuButton")
|
||||
self.menu_button.setText("☰")
|
||||
self.menu_button.setToolTip("Return to main view")
|
||||
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
|
||||
top_layout.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
main_layout.addWidget(top_card)
|
||||
main_layout.addWidget(camera_widget, 1)
|
||||
|
||||
progress_card = QFrame(self)
|
||||
progress_card.setObjectName("compactProgressCard")
|
||||
progress_layout = QVBoxLayout(progress_card)
|
||||
progress_layout.setContentsMargins(12, 10, 12, 10)
|
||||
progress_layout.setSpacing(6)
|
||||
|
||||
progress_title = QLabel("Automation progress", self)
|
||||
progress_title.setObjectName("compactDashboardLabel")
|
||||
progress_layout.addWidget(progress_title)
|
||||
|
||||
self.progress_strip = CompactAutomationProgressStrip(self)
|
||||
progress_layout.addWidget(self.progress_strip)
|
||||
|
||||
main_layout.addWidget(progress_card)
|
||||
main_layout.addWidget(camera_card, 1)
|
||||
|
||||
controls_card = QFrame(self)
|
||||
controls_card.setObjectName("compactControlsCard")
|
||||
@@ -197,9 +162,31 @@ class CompactAutomationPanel(QFrame):
|
||||
controls_layout.setContentsMargins(12, 12, 12, 12)
|
||||
controls_layout.setSpacing(10)
|
||||
|
||||
controls_header = QHBoxLayout()
|
||||
controls_header.setSpacing(10)
|
||||
|
||||
controls_title_wrap = QVBoxLayout()
|
||||
controls_title_wrap.setContentsMargins(0, 0, 0, 0)
|
||||
controls_title_wrap.setSpacing(2)
|
||||
|
||||
controls_title = QLabel("Controls", self)
|
||||
controls_title.setObjectName("compactDashboardLabel")
|
||||
controls_layout.addWidget(controls_title)
|
||||
controls_title.setObjectName("compactSectionTitle")
|
||||
controls_hint = QLabel("Run and manage the automation queue.", self)
|
||||
controls_hint.setObjectName("compactSectionHint")
|
||||
|
||||
controls_title_wrap.addWidget(controls_title)
|
||||
controls_title_wrap.addWidget(controls_hint)
|
||||
|
||||
self.menu_button = QToolButton(self)
|
||||
self.menu_button.setObjectName("compactMenuButton")
|
||||
self.menu_button.setText("☰")
|
||||
self.menu_button.setToolTip("Return to main view")
|
||||
self.menu_button.clicked.connect(self.show_full_view_requested.emit)
|
||||
|
||||
controls_header.addLayout(controls_title_wrap, 1)
|
||||
controls_header.addWidget(self.menu_button, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
controls_layout.addLayout(controls_header)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
button_row.setSpacing(10)
|
||||
@@ -212,7 +199,7 @@ class CompactAutomationPanel(QFrame):
|
||||
self.skip_button.setObjectName("compactSecondaryButton")
|
||||
self.skip_button.clicked.connect(self.skip_clicked.emit)
|
||||
|
||||
self.step_button = QPushButton("Step Through", self)
|
||||
self.step_button = QPushButton("Step", self)
|
||||
self.step_button.setObjectName("compactSecondaryButton")
|
||||
self.step_button.setCheckable(True)
|
||||
self.step_button.toggled.connect(self._on_step_toggled)
|
||||
@@ -238,6 +225,45 @@ class CompactAutomationPanel(QFrame):
|
||||
controls_layout.addLayout(button_row)
|
||||
main_layout.addWidget(controls_card)
|
||||
|
||||
progress_card = QFrame(self)
|
||||
progress_card.setObjectName("compactProgressCard")
|
||||
progress_layout = QVBoxLayout(progress_card)
|
||||
progress_layout.setContentsMargins(12, 12, 12, 12)
|
||||
progress_layout.setSpacing(8)
|
||||
|
||||
progress_title = QLabel("Progress", self)
|
||||
progress_title.setObjectName("compactSectionTitle")
|
||||
progress_layout.addWidget(progress_title)
|
||||
|
||||
self.progress_strip = CompactAutomationProgressStrip(self)
|
||||
progress_layout.addWidget(self.progress_strip)
|
||||
|
||||
main_layout.addWidget(progress_card)
|
||||
|
||||
queue_card = QFrame(self)
|
||||
queue_card.setObjectName("compactQueueCard")
|
||||
queue_layout = QVBoxLayout(queue_card)
|
||||
queue_layout.setContentsMargins(12, 12, 12, 12)
|
||||
queue_layout.setSpacing(10)
|
||||
|
||||
queue_title = QLabel("Queue Preview", self)
|
||||
queue_title.setObjectName("compactSectionTitle")
|
||||
queue_layout.addWidget(queue_title)
|
||||
|
||||
queue_row = QHBoxLayout()
|
||||
queue_row.setSpacing(10)
|
||||
|
||||
self.current_card = self._build_queue_item("Now", "—")
|
||||
self.next_card = self._build_queue_item("Next", "—")
|
||||
self.next_next_card = self._build_queue_item("Later", "—")
|
||||
|
||||
queue_row.addWidget(self.current_card["frame"], 1)
|
||||
queue_row.addWidget(self.next_card["frame"], 1)
|
||||
queue_row.addWidget(self.next_next_card["frame"], 1)
|
||||
|
||||
queue_layout.addLayout(queue_row)
|
||||
main_layout.addWidget(queue_card)
|
||||
|
||||
def _build_queue_item(self, title: str, value: str) -> dict[str, QWidget | QLabel]:
|
||||
frame = QFrame(self)
|
||||
frame.setObjectName("compactQueueItem")
|
||||
|
||||
@@ -27,6 +27,12 @@ def get_entry(sample: SampleShortInfo, column: int):
|
||||
return sample.sample_name
|
||||
elif column == 2:
|
||||
return sample.mount_count
|
||||
elif column == 3:
|
||||
return sample.rotation_count
|
||||
elif column == 4:
|
||||
return sample.raster_count
|
||||
elif column == 5:
|
||||
return sample.screening_count
|
||||
return ""
|
||||
|
||||
class ReferenceToolsModel(QAbstractTableModel):
|
||||
@@ -35,7 +41,7 @@ class ReferenceToolsModel(QAbstractTableModel):
|
||||
|
||||
self.samples: list[SampleShortInfo] = rows or []
|
||||
self.current_reference = current_reference
|
||||
self.header = ["Position", "Sample name", "Mount count"]
|
||||
self.header = ["Position", "Sample name", "Mount count", "Raster count", "Rotation count", "Screening count",]
|
||||
self.__sort_col = 0
|
||||
self.__sort_order = Qt.SortOrder.AscendingOrder
|
||||
self.__sorted_samples: list[SampleShortInfo] = []
|
||||
|
||||
@@ -10,7 +10,7 @@ from collections import deque
|
||||
from typing import cast, Literal
|
||||
|
||||
from PySide6.QtCore import Signal, QUrl, Slot, QTimer, QObject, QByteArray
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply, QSslError
|
||||
from jfjoch_client import ScanResult, ScanResultImagesInner
|
||||
|
||||
from aare.common.auth_models import BatonStatus
|
||||
@@ -120,6 +120,7 @@ class DAQWorker(QObject):
|
||||
self.__token = token
|
||||
self.__base_url = base_url
|
||||
self.__net_manager = QNetworkAccessManager()
|
||||
self.__net_manager.sslErrors.connect(self._handle_ssl_errors)
|
||||
self.__timer = QTimer()
|
||||
self.__timer.setInterval(500)
|
||||
self.__timer.timeout.connect(self.regular_update)
|
||||
@@ -326,6 +327,18 @@ class DAQWorker(QObject):
|
||||
reply = self.__net_manager.get(request)
|
||||
reply.finished.connect(lambda: self.handle_status_response(reply))
|
||||
|
||||
_SELF_SIGNED_ERRORS = {
|
||||
QSslError.SslError.SelfSignedCertificate,
|
||||
QSslError.SslError.SelfSignedCertificateInChain,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _handle_ssl_errors(reply: QNetworkReply, errors: list):
|
||||
self_signed = [e for e in errors if e.error() in DAQWorker._SELF_SIGNED_ERRORS]
|
||||
if self_signed:
|
||||
reply.ignoreSslErrors(self_signed)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def handle_response(reply: QNetworkReply):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Optional, Dict
|
||||
from PySide6.QtCore import QObject, Signal, Slot, QUrl, QTimer, QByteArray
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply, QSslError
|
||||
|
||||
|
||||
class SSEClient(QObject):
|
||||
@@ -14,6 +14,7 @@ class SSEClient(QObject):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._network_manager = QNetworkAccessManager(self)
|
||||
self._network_manager.sslErrors.connect(self._handle_ssl_errors)
|
||||
self._reply: Optional[QNetworkReply] = None
|
||||
self._reconnect_timer = QTimer(self)
|
||||
self._reconnect_timer.setSingleShot(True)
|
||||
@@ -60,6 +61,17 @@ class SSEClient(QObject):
|
||||
"""Check if connected to SSE"""
|
||||
return self._connected and self._reply and self._reply.isOpen()
|
||||
|
||||
_SELF_SIGNED_ERRORS = {
|
||||
QSslError.SslError.SelfSignedCertificate,
|
||||
QSslError.SslError.SelfSignedCertificateInChain,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _handle_ssl_errors(reply: QNetworkReply, errors: list):
|
||||
self_signed = [e for e in errors if e.error() in SSEClient._SELF_SIGNED_ERRORS]
|
||||
if self_signed:
|
||||
reply.ignoreSslErrors(self_signed)
|
||||
|
||||
def _start_connection(self):
|
||||
"""Start SSE connection"""
|
||||
request = QNetworkRequest(self._url)
|
||||
|
||||
@@ -18,16 +18,24 @@ class CompactAutomationProgressStrip(QFrame):
|
||||
self._queue_count = 0
|
||||
self._running = False
|
||||
self._step_labels: dict[WorkflowStateKind, QLabel] = {}
|
||||
self._summary_label: QLabel | None = None
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(1000)
|
||||
self._timer.timeout.connect(self._refresh_live_view)
|
||||
|
||||
self.setObjectName("compactAutomationProgressStrip")
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QFrame#compactAutomationProgressStrip {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(14, 12, 14, 12)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self._summary_label = QLabel("Automation idle", self)
|
||||
self._summary_label.setObjectName("compactProgressSummary")
|
||||
@@ -46,7 +54,6 @@ class CompactAutomationProgressStrip(QFrame):
|
||||
):
|
||||
label = QLabel(self)
|
||||
label.setObjectName("compactProgressStep")
|
||||
label.setMinimumWidth(90)
|
||||
label.setWordWrap(True)
|
||||
steps_row.addWidget(label, 1)
|
||||
self._step_labels[step] = label
|
||||
@@ -55,8 +62,7 @@ class CompactAutomationProgressStrip(QFrame):
|
||||
self._apply_empty_state()
|
||||
|
||||
def _apply_empty_state(self) -> None:
|
||||
if self._summary_label is not None:
|
||||
self._summary_label.setText("Automation idle")
|
||||
self._summary_label.setText("Automation idle")
|
||||
for step, label in self._step_labels.items():
|
||||
label.setText(self._format_step_html(step, StepStatus.PENDING))
|
||||
|
||||
@@ -97,9 +103,9 @@ class CompactAutomationProgressStrip(QFrame):
|
||||
icon = self._step_icon(status)
|
||||
title = self._step_title(step)
|
||||
return (
|
||||
f"<div style='text-align:center;'>"
|
||||
f"<div style='font-size:18px; color:{color}; font-weight:700;'>{icon}</div>"
|
||||
f"<div style='font-size:12px; color:{color}; font-weight:700;'>{title}</div>"
|
||||
f"<div style='text-align:center; background:transparent;'>"
|
||||
f"<div style='font-size:18px; color:{color}; font-weight:700; background:transparent;'>{icon}</div>"
|
||||
f"<div style='font-size:12px; color:{color}; font-weight:700; background:transparent;'>{title}</div>"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
@@ -120,6 +126,7 @@ class CompactAutomationProgressStrip(QFrame):
|
||||
return "N/A"
|
||||
return datetime.fromtimestamp(epoch_seconds).strftime("%H:%M:%S")
|
||||
|
||||
@Slot()
|
||||
def _refresh_live_view(self) -> None:
|
||||
if self._progress is not None:
|
||||
self.set_progress(self._progress)
|
||||
@@ -163,19 +170,16 @@ class CompactAutomationProgressStrip(QFrame):
|
||||
elif self._running:
|
||||
state_text = "Running"
|
||||
state_color = "#2563eb"
|
||||
elif not self._running:
|
||||
state_text = "Paused"
|
||||
state_color = "#c2410c"
|
||||
|
||||
if self._summary_label is not None:
|
||||
self._summary_label.setText(
|
||||
f"<b>Status:</b> <span style='color:{state_color}; font-weight:700;'>{state_text}</span>"
|
||||
f" <b>Current:</b> {current_sample}"
|
||||
f" <b>Queue:</b> {self._queue_count}"
|
||||
f" <b>ETA:</b> {self._format_duration(queue_remaining)}"
|
||||
f" <b>Done by:</b> {self._format_eta(eta)}"
|
||||
)
|
||||
self._summary_label.setText(
|
||||
f"<span style='background:transparent;'><b>Status:</b> "
|
||||
f"<span style='color:{state_color}; font-weight:700; background:transparent;'>{state_text}</span>"
|
||||
f" <b>Now:</b> {current_sample}"
|
||||
f" <b>Queue:</b> {self._queue_count}"
|
||||
f" <b>ETA:</b> {self._format_duration(queue_remaining)}"
|
||||
f" <b>Done:</b> {self._format_eta(eta)}</span>"
|
||||
)
|
||||
|
||||
states = {step.step: step.status for step in progress.steps}
|
||||
for step, label in self._step_labels.items():
|
||||
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))
|
||||
label.setText(self._format_step_html(step, states.get(step, StepStatus.PENDING)))
|
||||
@@ -46,4 +46,30 @@ def test_aperture_accepts_float_string():
|
||||
|
||||
def test_processingpipeline_accepts_unknown_value_after_refactor():
|
||||
params = DataCollectionParameters(processingpipeline="xia2")
|
||||
assert params.processingpipeline == "xia2"
|
||||
assert params.processingpipeline == "xia2"
|
||||
|
||||
|
||||
def test_datacollectionparameters_accepts_legacy_aliases():
|
||||
params = DataCollectionParameters(
|
||||
totalrange=180,
|
||||
cellparameters="10 20 30 90 90 120",
|
||||
userresolution=1.4,
|
||||
)
|
||||
assert params.totalangle == 180
|
||||
assert params.unitcell == "10 20 30 90 90 120"
|
||||
assert params.processingresolution == 1.4
|
||||
|
||||
|
||||
def test_datacollectionparameters_accepts_new_fields():
|
||||
params = DataCollectionParameters(
|
||||
totalangle=90,
|
||||
unitcell="11,22,33,90,90,120",
|
||||
processingresolution=1.2,
|
||||
pdbmodel="model.pdb",
|
||||
cloud=False,
|
||||
)
|
||||
assert params.totalangle == 90
|
||||
assert params.unitcell == "11,22,33,90,90,120"
|
||||
assert params.processingresolution == 1.2
|
||||
assert params.pdbmodel == "model.pdb"
|
||||
assert params.cloud is False
|
||||
@@ -1,5 +1,14 @@
|
||||
import pytest
|
||||
from aare.common.models import SampleShortInfo, DewarAddress, BeamMarkCoeffModel, MLOutputModel, MLBoxType, BeamlineStateEnum
|
||||
from aare.common.models import (
|
||||
SampleShortInfo,
|
||||
DewarAddress,
|
||||
BeamMarkCoeffModel,
|
||||
MLOutputModel,
|
||||
MLBoxType,
|
||||
BeamlineStateEnum,
|
||||
DataCollectionParameters,
|
||||
)
|
||||
|
||||
|
||||
def test_sample_short_info_methods():
|
||||
info = SampleShortInfo(
|
||||
@@ -8,34 +17,42 @@ def test_sample_short_info_methods():
|
||||
dewar_name="dew1",
|
||||
sample_name="sample1",
|
||||
run_number=1,
|
||||
aaredb_params=DataCollectionParameters(
|
||||
totalangle=180,
|
||||
processingresolution=1.5,
|
||||
cloud=True,
|
||||
),
|
||||
user="group1",
|
||||
pin=3,
|
||||
location=DewarAddress(segment="A", pos=2),
|
||||
)
|
||||
|
||||
# Test tell_address (line 380)
|
||||
|
||||
addr = info.tell_address()
|
||||
assert addr.puck.segment == "A"
|
||||
assert addr.puck.pos == 2
|
||||
assert addr.pin == 3
|
||||
|
||||
# Test loc_str (lines 383-386)
|
||||
|
||||
assert info.loc_str() == "A2-3"
|
||||
|
||||
# Test loc_str_sort (lines 389-392)
|
||||
assert info.loc_str_sort() == "A2-03"
|
||||
|
||||
assert info.aaredb_params is not None
|
||||
assert info.aaredb_params.totalangle == 180
|
||||
assert info.aaredb_params.processingresolution == 1.5
|
||||
|
||||
info_no_loc = info.model_copy(update={"location": None})
|
||||
assert info_no_loc.loc_str() == "-"
|
||||
assert info_no_loc.loc_str_sort() == ""
|
||||
|
||||
# Test from_dict (line 396)
|
||||
data = {
|
||||
"db_id": 2,
|
||||
"puck_name": "puck2",
|
||||
"dewar_name": "dew2",
|
||||
"sample_name": "sample2",
|
||||
"run_number": 2,
|
||||
"aaredb_params": {
|
||||
"totalrange": 120,
|
||||
"userresolution": 1.8,
|
||||
"cloud": "",
|
||||
},
|
||||
"user": "group2",
|
||||
"pin": 4,
|
||||
"location": {"segment": "B", "pos": 5}
|
||||
@@ -43,47 +60,43 @@ def test_sample_short_info_methods():
|
||||
info2 = SampleShortInfo.from_dict(data)
|
||||
assert info2.db_id == 2
|
||||
assert info2.location.segment == "B"
|
||||
assert info2.aaredb_params is not None
|
||||
assert info2.aaredb_params.totalangle == 120
|
||||
assert info2.aaredb_params.processingresolution == 1.8
|
||||
assert info2.aaredb_params.cloud is True
|
||||
|
||||
|
||||
def test_beam_mark_coeff_model_apply():
|
||||
# Test apply (line 414)
|
||||
model = BeamMarkCoeffModel(
|
||||
coeff_x=(1.0, 2.0, 5.0),
|
||||
coeff_y=(3.0, 4.0, 6.0)
|
||||
)
|
||||
# zoom = 10
|
||||
# x = 1.0 * 100 + 2.0 * 10 + 5.0 = 125.0
|
||||
# y = 3.0 * 100 + 4.0 * 10 + 6.0 = 346.0
|
||||
res = model.apply(10.0)
|
||||
assert res.x == 125.0
|
||||
assert res.y == 346.0
|
||||
|
||||
|
||||
def test_ml_output_model_extra_methods():
|
||||
model = MLOutputModel()
|
||||
key = model.add_box(MLBoxType.CRYSTAL, (1, 2, 3, 4), 0.8)
|
||||
|
||||
# Test get_box_model (line 499)
|
||||
|
||||
box_model = model.get_box_model(key)
|
||||
assert box_model.conf == 0.8
|
||||
|
||||
# Test get_box_tuple (lines 502-505)
|
||||
|
||||
assert model.get_box_tuple(key) == (1, 2, 3, 4)
|
||||
assert model.get_box_tuple("NonExistent") is None
|
||||
|
||||
# Test get_box_tuple_with_conf (lines 508-512)
|
||||
|
||||
assert model.get_box_tuple_with_conf(key) == (1, 2, 3, 4, 0.8)
|
||||
assert model.get_box_tuple_with_conf("NonExistent") is None
|
||||
|
||||
# Test get_tuples_for_class (lines 523-527)
|
||||
|
||||
tuples = model.get_tuples_for_class(MLBoxType.CRYSTAL)
|
||||
assert len(tuples) == 1
|
||||
assert tuples[0] == (1, 2, 3, 4)
|
||||
|
||||
# Test get_tuples_with_conf_for_class (lines 530-534)
|
||||
|
||||
tuples_conf = model.get_tuples_with_conf_for_class(MLBoxType.CRYSTAL)
|
||||
assert len(tuples_conf) == 1
|
||||
assert tuples_conf[0] == (1, 2, 3, 4, 0.8)
|
||||
|
||||
# Test get_class_str (lines 470, 475-481)
|
||||
|
||||
assert MLOutputModel.get_class_str(MLBoxType.LOOP_ALL) == "Loop_all"
|
||||
assert MLOutputModel.get_class_str(MLBoxType.PIN) == "Pin"
|
||||
assert MLOutputModel.get_class_str(MLBoxType.CRYSTAL) == "Crystal"
|
||||
@@ -92,9 +105,8 @@ def test_ml_output_model_extra_methods():
|
||||
assert MLOutputModel.get_class_str(MLBoxType.NEEDLE) == "Needle"
|
||||
assert MLOutputModel.get_class_str(100) == "Unknown"
|
||||
|
||||
|
||||
def test_beamline_state_enum_display_name():
|
||||
# Test display_name (line 556)
|
||||
assert BeamlineStateEnum.SampleExchange.display_name() == "Sample exchange"
|
||||
assert BeamlineStateEnum.Moving.display_name() == "Moving"
|
||||
# Using value for potentially unknown
|
||||
assert BeamlineStateEnum.display_name(None) == "-"
|
||||
assert BeamlineStateEnum.display_name(None) == "-"
|
||||
+10
-11
@@ -50,25 +50,24 @@ def test_authenticate_user(mock_cfg):
|
||||
patch('os.getgrouplist') as mock_groups, \
|
||||
patch('grp.getgrgid') as mock_grp, \
|
||||
patch('aare.daq.auth.SECRET_KEY', 'test_secret'):
|
||||
|
||||
|
||||
mock_pwd.return_value.pw_name = "testuser"
|
||||
mock_pwd.return_value.pw_gid = 1000
|
||||
mock_groups.return_value = [1000, 1001]
|
||||
|
||||
|
||||
def get_group(gid):
|
||||
m = MagicMock()
|
||||
if gid == 1000: m.gr_name = "p12345"
|
||||
else: m.gr_name = "unx-MXgroup"
|
||||
if gid == 1000:
|
||||
m.gr_name = "p12345"
|
||||
else:
|
||||
m.gr_name = "unx-MXgroup"
|
||||
return m
|
||||
|
||||
|
||||
mock_grp.side_effect = get_group
|
||||
|
||||
form_data = MagicMock()
|
||||
form_data.username = "testuser"
|
||||
|
||||
token = authenticate_user(mock_cfg, form_data)
|
||||
|
||||
token = authenticate_user(mock_cfg, "testuser")
|
||||
assert isinstance(token, str)
|
||||
|
||||
|
||||
payload = jwt.decode(token, 'test_secret', algorithms=["HS256"])
|
||||
assert payload["sub"] == "testuser"
|
||||
assert "p12345" in payload["pgroups"]
|
||||
|
||||
@@ -49,9 +49,13 @@ def test_omega_put(client):
|
||||
|
||||
|
||||
def test_login_success(client):
|
||||
with patch("aare.daq.auth.authenticate_user") as mock_auth:
|
||||
mock_auth.return_value = "fake-access-token"
|
||||
response = client.post("/token", data={"username": "user", "password": "pwd"})
|
||||
with patch("aare.daq.auth.authenticate_from_proxy_header", return_value="user"), \
|
||||
patch("aare.daq.auth.authenticate_user", return_value="fake-access-token"):
|
||||
response = client.post(
|
||||
"/token",
|
||||
data={"username": "user", "password": "pwd"},
|
||||
headers={"X-Remote-User": "user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"access_token": "fake-access-token", "token_type": "bearer"}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
from aare.daq.spreadsheetupdater import on_message, get_ws_headers, set_spreadsheet_in_redis
|
||||
from aare.common.models import SampleShortInfoList
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
with patch('aare.daq.spreadsheetupdater.config') as mock:
|
||||
@@ -14,30 +15,28 @@ def mock_config():
|
||||
mock._BeamlineConfig__bl = mock._BeamlineConfig__bl
|
||||
yield mock
|
||||
|
||||
|
||||
def test_get_ws_headers_success():
|
||||
with patch('os.getenv', return_value="secret"):
|
||||
headers = get_ws_headers()
|
||||
assert headers == ["X-Shared-Password: secret"]
|
||||
|
||||
|
||||
def test_get_ws_headers_fail():
|
||||
with patch('os.getenv', return_value=None):
|
||||
with pytest.raises(ValueError):
|
||||
get_ws_headers()
|
||||
|
||||
|
||||
def test_set_spreadsheet_in_redis(mock_config):
|
||||
data = {"test": "data"}
|
||||
with patch('aare.daq.spreadsheetupdater.config') as mock_cfg_internal:
|
||||
# Mocking BOTH possible name-mangled names for the client
|
||||
mock_client = MagicMock()
|
||||
mock_cfg_internal._BeamlineConfig__client = mock_client
|
||||
mock_cfg_internal.client = mock_client # In case it's not mangled or mangled differently
|
||||
|
||||
# Also need to mock where it's actually used: config.__client.set
|
||||
# If I can't guess the mangling, I'll just check what attributes mock_cfg_internal has
|
||||
|
||||
mock_cfg_internal.client = mock_client
|
||||
|
||||
set_spreadsheet_in_redis(data)
|
||||
|
||||
# Check all mock attributes for a 'set' call
|
||||
|
||||
found = False
|
||||
for attr in dir(mock_cfg_internal):
|
||||
val = getattr(mock_cfg_internal, attr)
|
||||
@@ -46,6 +45,7 @@ def test_set_spreadsheet_in_redis(mock_config):
|
||||
break
|
||||
assert found or mock_client.set.called
|
||||
|
||||
|
||||
def test_on_message_success(mock_config):
|
||||
message = json.dumps({
|
||||
"samples": [
|
||||
@@ -67,6 +67,9 @@ def test_on_message_success(mock_config):
|
||||
"position": 1,
|
||||
"priority": 1,
|
||||
"mount_count": 0,
|
||||
"rotation_count": 0,
|
||||
"raster_count": 0,
|
||||
"screening_count": 0,
|
||||
"data_collection_parameters": {}
|
||||
}
|
||||
]
|
||||
@@ -89,24 +92,26 @@ def test_on_message_success(mock_config):
|
||||
"position": 1,
|
||||
"priority": 1,
|
||||
"mount_count": 0,
|
||||
"rotation_count": 0,
|
||||
"raster_count": 0,
|
||||
"screening_count": 0,
|
||||
"data_collection_parameters": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
on_message(None, message)
|
||||
|
||||
# Check if normal spreadsheet was written
|
||||
|
||||
normal_key = "X10SA:sample_spreadsheet"
|
||||
calls = mock_config._BeamlineConfig__client.set.call_args_list
|
||||
assert any(call.args[0] == normal_key for call in calls)
|
||||
|
||||
# Check if reference tools were written
|
||||
|
||||
ref_key = "X10SA:reference-tools"
|
||||
assert any(call.args[0] == ref_key for call in calls)
|
||||
|
||||
|
||||
def test_on_message_empty_ref(mock_config):
|
||||
message = json.dumps({
|
||||
"samples": [
|
||||
@@ -123,14 +128,13 @@ def test_on_message_empty_ref(mock_config):
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
on_message(None, message)
|
||||
|
||||
# Check if reference tools were deleted
|
||||
|
||||
ref_key = "X10SA:reference-tools"
|
||||
mock_config._BeamlineConfig__client.delete.assert_called_with(ref_key)
|
||||
|
||||
|
||||
def test_on_message_invalid_json(mock_config):
|
||||
# Should not raise exception, just print error
|
||||
on_message(None, "invalid json")
|
||||
mock_config._BeamlineConfig__client.set.assert_not_called()
|
||||
mock_config._BeamlineConfig__client.set.assert_not_called()
|
||||
@@ -1,72 +1,44 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from aare.gui.auth import auth
|
||||
|
||||
# pytest-mock provides the 'mocker' fixture, which is a wrapper around the
|
||||
# standard unittest.mock. It simplifies mocking by automatically handling
|
||||
# cleanup (unpatching) after each test, and providing a more "pytest-native"
|
||||
# feel compared to using @patch decorators or context managers.
|
||||
|
||||
def test_auth_success(mocker):
|
||||
"""
|
||||
Test successful authentication using pytest-mock's mocker fixture.
|
||||
|
||||
In standard pytest/unittest, you would typically use:
|
||||
with mock.patch('requests.post') as mock_post:
|
||||
...
|
||||
Or a decorator:
|
||||
@patch('requests.post')
|
||||
def test_auth(mock_post):
|
||||
...
|
||||
|
||||
pytest-mock allows you to use the 'mocker' fixture directly in the function arguments.
|
||||
This avoids deeply nested context managers and makes it easier to mock multiple things.
|
||||
"""
|
||||
|
||||
# We mock 'requests.post' to simulate a successful server response.
|
||||
# mocker.patch returns a MagicMock object.
|
||||
mock_post = mocker.patch("requests.post")
|
||||
|
||||
# Configure the mock response
|
||||
mock_response = mocker.Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"access_token": "fake_token_abc.123.xyz"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
# Call the function under test
|
||||
mock_run = mocker.patch("aare.gui.auth.subprocess.run")
|
||||
mock_run.return_value = mocker.Mock(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"access_token": "fake_token_abc.123.xyz"}),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
token = auth("http://test-server")
|
||||
|
||||
# Verify the results
|
||||
|
||||
assert token == "fake_token_abc.123.xyz"
|
||||
mock_post.assert_called_once()
|
||||
|
||||
# Check that it was called with the expected URL
|
||||
args, kwargs = mock_post.call_args
|
||||
assert args[0] == "http://test-server/token"
|
||||
mock_run.assert_called_once()
|
||||
args, kwargs = mock_run.call_args
|
||||
assert "curl" in args[0]
|
||||
assert "http://test-server/token" in args[0]
|
||||
|
||||
|
||||
def test_auth_network_failure(mocker):
|
||||
"""
|
||||
Test authentication failure due to network error using mocker.
|
||||
"""
|
||||
# Mock requests.post to raise an exception
|
||||
mock_post = mocker.patch("requests.post")
|
||||
mock_post.side_effect = requests.RequestException("Connection refused")
|
||||
|
||||
mock_run = mocker.patch("aare.gui.auth.subprocess.run")
|
||||
mock_run.side_effect = OSError("Connection refused")
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
auth("http://test-server")
|
||||
|
||||
|
||||
assert "Cannot reach AareDAQ server" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_auth_no_url_returns_dummy_jwt(mocker):
|
||||
"""
|
||||
Test that when base_url is None, it returns a dummy JWT without network calls.
|
||||
We can use mocker to verify that requests.post was NEVER called.
|
||||
"""
|
||||
mock_post = mocker.patch("requests.post")
|
||||
mocker.patch("os.getlogin", return_value="testuser")
|
||||
|
||||
mock_run = mocker.patch("aare.gui.auth.subprocess.run")
|
||||
mocker.patch("aare.gui.auth.get_user", return_value="testuser")
|
||||
|
||||
token = auth(None)
|
||||
|
||||
|
||||
assert isinstance(token, str)
|
||||
assert token.count('.') == 2 # Basic JWT structure check
|
||||
mock_post.assert_not_called()
|
||||
assert token.count('.') == 2
|
||||
mock_run.assert_not_called()
|
||||
Reference in New Issue
Block a user