diff --git a/pyproject.toml b/pyproject.toml index f29dcdba..78bfe0ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/aare/common/config/x06da.yaml b/src/aare/common/config/x06da.yaml index 4af162f2..9b17f3e5 100644 --- a/src/aare/common/config/x06da.yaml +++ b/src/aare/common/config/x06da.yaml @@ -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" diff --git a/src/aare/common/config/x06sa.yaml b/src/aare/common/config/x06sa.yaml index 49073f43..7e022dc7 100644 --- a/src/aare/common/config/x06sa.yaml +++ b/src/aare/common/config/x06sa.yaml @@ -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: diff --git a/src/aare/common/config/x10sa.yaml b/src/aare/common/config/x10sa.yaml index 52166687..cc370a2d 100644 --- a/src/aare/common/config/x10sa.yaml +++ b/src/aare/common/config/x10sa.yaml @@ -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: diff --git a/src/aare/daq/auth.py b/src/aare/daq/auth.py index 33ed853d..027d0c82 100644 --- a/src/aare/daq/auth.py +++ b/src/aare/daq/auth.py @@ -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()) diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index 62c817b4..df4bd402 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -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__": diff --git a/src/aare/gui/auth.py b/src/aare/gui/auth.py index 2cabc77a..3bbe26bb 100644 --- a/src/aare/gui/auth.py +++ b/src/aare/gui/auth.py @@ -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." diff --git a/src/aare/gui/graphics/aare_banner.png b/src/aare/gui/graphics/aare_banner.png new file mode 100644 index 00000000..831224d2 Binary files /dev/null and b/src/aare/gui/graphics/aare_banner.png differ diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index 51995574..bd50867e 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -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. " diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 1af8a7f0..648e820f 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -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) diff --git a/src/aare/gui/models/user_sample_model.py b/src/aare/gui/models/user_sample_model.py index 5f2e5dd6..e9163eb2 100644 --- a/src/aare/gui/models/user_sample_model.py +++ b/src/aare/gui/models/user_sample_model.py @@ -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 diff --git a/src/aare/gui/panels/compact_automation_panel.py b/src/aare/gui/panels/compact_automation_panel.py index 3504ed77..3c64887f 100644 --- a/src/aare/gui/panels/compact_automation_panel.py +++ b/src/aare/gui/panels/compact_automation_panel.py @@ -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") diff --git a/src/aare/gui/panels/reference_tools_panel.py b/src/aare/gui/panels/reference_tools_panel.py index b21807b4..03cbd1d8 100644 --- a/src/aare/gui/panels/reference_tools_panel.py +++ b/src/aare/gui/panels/reference_tools_panel.py @@ -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] = [] diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index f50fe798..4fb8965f 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -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): """ diff --git a/src/aare/gui/threads/sse_client.py b/src/aare/gui/threads/sse_client.py index be1db929..05221c10 100644 --- a/src/aare/gui/threads/sse_client.py +++ b/src/aare/gui/threads/sse_client.py @@ -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) diff --git a/src/aare/gui/widgets/automation_progress.py b/src/aare/gui/widgets/automation_progress.py index 2803fa0a..c700bf9c 100644 --- a/src/aare/gui/widgets/automation_progress.py +++ b/src/aare/gui/widgets/automation_progress.py @@ -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"
" - f"
{icon}
" - f"
{title}
" + f"
" + f"
{icon}
" + f"
{title}
" f"
" ) @@ -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"Status: {state_text}" - f"    Current: {current_sample}" - f"    Queue: {self._queue_count}" - f"    ETA: {self._format_duration(queue_remaining)}" - f"    Done by: {self._format_eta(eta)}" - ) + self._summary_label.setText( + f"Status: " + f"{state_text}" + f"    Now: {current_sample}" + f"    Queue: {self._queue_count}" + f"    ETA: {self._format_duration(queue_remaining)}" + f"    Done: {self._format_eta(eta)}" + ) 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))) \ No newline at end of file diff --git a/tests/unit/common/test_data_collection_parameters.py b/tests/unit/common/test_data_collection_parameters.py index b5ea0a1f..41e5f887 100644 --- a/tests/unit/common/test_data_collection_parameters.py +++ b/tests/unit/common/test_data_collection_parameters.py @@ -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" \ No newline at end of file + 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 \ No newline at end of file diff --git a/tests/unit/common/test_models_extra.py b/tests/unit/common/test_models_extra.py index 2c7c5032..7c9557b8 100644 --- a/tests/unit/common/test_models_extra.py +++ b/tests/unit/common/test_models_extra.py @@ -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) == "-" \ No newline at end of file diff --git a/tests/unit/daq/test_auth.py b/tests/unit/daq/test_auth.py index ac895b69..bbfc2f2b 100644 --- a/tests/unit/daq/test_auth.py +++ b/tests/unit/daq/test_auth.py @@ -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"] diff --git a/tests/unit/daq/test_server.py b/tests/unit/daq/test_server.py index e41b9c53..54862bc4 100644 --- a/tests/unit/daq/test_server.py +++ b/tests/unit/daq/test_server.py @@ -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"} diff --git a/tests/unit/daq/test_spreadsheetupdater.py b/tests/unit/daq/test_spreadsheetupdater.py index c747db4e..c96e9529 100644 --- a/tests/unit/daq/test_spreadsheetupdater.py +++ b/tests/unit/daq/test_spreadsheetupdater.py @@ -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() \ No newline at end of file diff --git a/tests/unit/gui/test_auth_mock.py b/tests/unit/gui/test_auth_mock.py index 29c400ad..91e908b2 100644 --- a/tests/unit/gui/test_auth_mock.py +++ b/tests/unit/gui/test_auth_mock.py @@ -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() \ No newline at end of file