Skip to content
Merged
285 changes: 70 additions & 215 deletions mxcubecore/HardwareObjects/MAXIV/ISPyBLims.py
Original file line number Diff line number Diff line change
@@ -1,241 +1,96 @@
import json
import logging
from json.decoder import JSONDecodeError
from typing import (
Dict,
List,
Optional,
# ruff: noqa: TD003, FIX002, ERA001
from mxcubecore.HardwareObjects.abstract.PyISPyBRestClient import PyISPyBRestClient
from mxcubecore.HardwareObjects.MAXIV.MAXIVPyISPYyBDataAdapter import (
LAZY_SESSION_PREFIX,
MAXIVPyISPyBDataAdapter,
)
from urllib.parse import urljoin

import requests
from suds import WebFault

from mxcubecore.HardwareObjects.abstract.ISPyBDataAdapter import ISPyBDataAdapter
from mxcubecore.HardwareObjects.UserTypeISPyBLims import UserTypeISPyBLims
from mxcubecore.model.lims_session import (
LimsSessionManager,
Session,
)

log = logging.getLogger("ispyb_client")

LAZY_SESSION_PREFIX = "lazy"


def _get_lazy_session_id(proposal: Dict) -> str:
prop_id = proposal["proposalId"]
return f"{LAZY_SESSION_PREFIX}{prop_id}"


def _is_lazy_session_id(session_id: str) -> bool:
return session_id.startswith(LAZY_SESSION_PREFIX)


def _check_ispyb_error_message(response):
def _expected_ispyb_err_msg(error_msg):
import re

match = re.match("^JBAS011843: Failed instantiate.*ldap.*ispyb", error_msg)
return match is not None

#
# check that we got the 'expected' error message on invalid credentials,
# otherwise log the error message, so we don't swallow new error messages
#
if _expected_ispyb_err_msg(response.text):
# all is fine
return

log.warning(
"unexpected response from ISPyB\n"
+ f"{response.status_code} {response.reason}\n{response.text}"
)


class ISPyBRestClient:
def __init__(self, rest_root: str):
self._rest_root = rest_root

def authenticate(self, user_name: str, password: str):
"""
authenticate with REST services
from mxcubecore.model.lims_session import Session

Args:
user_name: Username
password: Password
"""
token = None

auth_url = urljoin(self._rest_root, "authenticate?site=MAXIV")
response = requests.post(
auth_url, data={"login": user_name, "password": password}
)

try:
# if authentication is successful, we will get
# JSON response containing an auth token
token = response.json().get("token", None)
except JSONDecodeError:
# on invalid credentials, some ISPyB systems will reply with
# an internal error message, as plain text
_check_ispyb_error_message(response)

if token is None:
# we failed to obtain the auth token, thus we failed to authenticate
raise Exception("invalid credentials")


def _create_session_object(proposal, session_id: str, beamline_name: str) -> Session:
return Session(
proposal_id=proposal["proposalId"],
code=proposal["code"],
number=proposal["number"],
session_id=session_id,
beamline_name=beamline_name,
title=proposal["title"],
#
# At MAXIV we don't care if a session is scheduled
# or not, mark all sessions as scheduled.
#
is_scheduled_time=True,
is_scheduled_beamline=True,
)


class CustomISPyBDataAdapter(ISPyBDataAdapter):
"""
Extend the standard ISPyB data adapter with MAXIV specific logic of how to
deal with proposal sessions.
"""

def _get_proposals(self, username: str):
proposals = json.loads(
self._shipping.service.findProposalsByLoginName(username)
)

for proposal in proposals:
if proposal["type"].upper() not in ["MX", "MB"]:
continue
if proposal.get("state", "Open") != "Open":
continue

yield proposal

def _get_sessions(self, username: str, beamline_name: str) -> List[Session]:
def list_sessions():
for proposal in self._get_proposals(username):
sessions = self._collection.service.findSessionsByProposalAndBeamLine(
proposal["code"], proposal["number"], beamline_name
)

for sesssion in sessions:
yield _create_session_object(
proposal, sesssion["sessionId"], beamline_name
)

#
# A hack to lazily create new sessions.
#
# At MAXIV we don't schedule sessions for proposals ahead of time. Instead, we
# lazily create them as needed.
#
# If a proposal does not contain any active session, create a Session object
# with a special session ID.
#
# If user selects such a session, then we will ask ISPyB to create this session.
#
if len(sessions) == 0:
yield _create_session_object(
proposal, _get_lazy_session_id(proposal), beamline_name
)

return sorted(list_sessions(), key=lambda s: f"{s.code}{s.number}")

def get_sessions_by_username(
self, username: str, beamline_name: str
) -> LimsSessionManager:
try:
sessions = list(self._get_sessions(username, beamline_name))
return LimsSessionManager(sessions=sessions)
except WebFault as e:
log.exception(e.message)
class NoSessionException(Exception):
"""Exception raised when no expected session found."""


class ISPyBLims(UserTypeISPyBLims):
def __init__(self, name: str):
super().__init__(name)
self._duo_api_url = ""

def init(self):
self._duo_api_url: str = self.get_property("duo_api_url")
pyispyb_rest_root = self.get_property("pyispyb_rest_root")
self._rest_client = PyISPyBRestClient(pyispyb_rest_root)
super().init()

self._rest_client = ISPyBRestClient(self.get_property("rest_root"))

def _create_data_adapter(self) -> ISPyBDataAdapter:
return CustomISPyBDataAdapter(
self.ws_root.strip(),
self.proxy,
self.ws_username,
self.ws_password,
def _create_data_adapter(self) -> MAXIVPyISPyBDataAdapter:
return MAXIVPyISPyBDataAdapter(
self._duo_api_url,
self._rest_client,
self.beamline_name,
)

def ispyb_login(self, user_name: str, password: str):
"""Authenticate with ISPyB REST services.

In fact password is an access token obtained from Keycloak, but we call it
password to keep the interface consistent with other LIMS implementations.

Args:
user_name: Username to authenticate with
password: Password (access token) to authenticate with
Returns:
Tuple[bool, Optional[str]]: A tuple containing a boolean indicating
success or failure, and an optional error message.
"""
try:
self._rest_client.authenticate(user_name, password)
return True, None
self._rest_client.authenticate(user_name, token=password)
except Exception as ex:
return False, str(ex)
return True, None

def set_active_session_by_id(self, session_id: str) -> Session:
"""
Sets session with session_id to active session
"""Sets session with session_id to active session.

It is possible that user picks the session that does not exist in the database yet, so called lazy session created on the fly. In that case the session POST request is sent to the server in order to create the session in the database and get the proper session id.

Args:
session_id: session id
"""

def find_session() -> Optional[Session]:
for session in self.session_manager.sessions:
if session.session_id == session_id:
self.session_manager.active_session = session

return session

# session not found
return None

def replace_lazy(sessions: List[Session], new_session: Session):
def gen():
for session in sessions:
if session.session_id == session_id:
yield new_session
else:
yield session

return list(gen())

session = find_session()
if session is None:
raise Exception(f"no session with ID {session_id} found")

#
# user selected a session that does not exist yet,
# ask ISPyB to create it
#
if _is_lazy_session_id(session_id):
session = self.adapter.create_session(
session.proposal_id, session.beamline_name
)
# replace the old lazy-session object,
# with the new proper-session object
self.session_manager.sessions = replace_lazy(
self.session_manager.sessions, session
)

return session
session_to_activate = None
for _idx, session in enumerate(self.session_manager.sessions):
if session.session_id == session_id:
session_to_activate = session
break
else:
err = f"No session with ID {session_id} found."
raise NoSessionException(err)

if session_id.startswith(LAZY_SESSION_PREFIX):
payload = {
"proposalId": session_to_activate.proposal_id,
"startDate": session_to_activate.start_datetime.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
"endDate": session_to_activate.end_datetime.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
),
"beamLineName": session_to_activate.beamline_name,
"comments": "Session created by the BCM",
# At MAXIV we consider session created on fly as scheduled
"scheduled": True,
}
new_session = self.adapter.client.post("sessions", json=payload)
session_to_activate.session_id = str(new_session.get("sessionId"))
self.session_manager.sessions[_idx] = session_to_activate

self.session_manager.active_session = session_to_activate

return session_to_activate

def get_full_user_name(self) -> str:
person = self.adapter.get_person_by_username(self.user_name)

given_name = person["givenName"]
family_name = person["familyName"]
person = self.adapter.get_current_user_data()
return "%s %s" % (person["givenName"], person["familyName"])

return f"{given_name} {family_name}"
def xrf_spectrum_results_url(self, spectrum_id: int) -> str:
return self._rest_client.get_xrf_graph_url(spectrum_id)
72 changes: 72 additions & 0 deletions mxcubecore/HardwareObjects/MAXIV/MAXIVPyISPYyBDataAdapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# ruff: noqa: TD003, FIX002, ERA001
try:
# duo is a part of sdm package that lives at MaxIV private repository
from duo.UO import RestDuo
from sdm.config import DUOPASSWORD, DUOUSER
except ImportError:
RestDuo = None
DUOPASSWORD = None
DUOUSER = None

from mxcubecore.HardwareObjects.abstract.PyISPyBDataAdapter import PyISPyBDataAdapter
from mxcubecore.model.lims_session import Proposal, Session

DUO_API_URL = "https://duo-api.maxiv.lu.se"
LAZY_SESSION_PREFIX = "lazy"


class MAXIVPyISPyBDataAdapter(PyISPyBDataAdapter):
"""Extend the standard ISPyB data adapter with MAXIV specific logic."""

def __init__(self, duo_api_url: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.duo_api_url = duo_api_url

def get_proposals(self):
"""Override method to filter proposals by the type, state and beamline name.

Include proposals: of type ``MX`` or ``MB`` in ``Open`` state and assigned
to the current beamline. The last is checked via DUO API.
"""
duo = RestDuo(self.duo_api_url)
duo.login(DUOUSER, DUOPASSWORD)
beamline_proposals_ids = set(duo.get_beamline_proposals(self.beamline_name))
return [
self._PyISPyBDataAdapter__to_proposal(proposal)
for proposal in self.client.get("proposals")
if proposal.get("proposalCode").upper() in ["MX", "MB"]
and proposal.get("state", "").capitalize() == "Open"
and int(proposal.get("proposalNumber")) in beamline_proposals_ids
]

def create_session(self, proposal: Proposal) -> Session:
"""Create a new Session object for the given proposal and beamline.

This is a lazy session creation, done automatically on the fly in case
no appropriate session is found for the user, proposal and current day.
This session is labelled with ``lazy`` prefix and is not posted to
Py-ISPYB service until it is selected.

Args:
proposal: Proposal object to create session for

Returns:
Session: Created session object
"""
return Session(
code=proposal.code,
number=proposal.number,
proposal_name=proposal.code + proposal.number,
proposal_id=proposal.proposal_id,
session_id=f"{LAZY_SESSION_PREFIX}{proposal.proposal_id}",
beamline_name=self.beamline_name,
title=proposal.title,
# At MAXIV we don't care if a session is scheduled, set True as default
is_scheduled_time=True,
is_scheduled_beamline=True,
# TODO@dominikatrojanowska: check if we should set start and end time
# for the session created on fly, and if so, what time should be set.
# For now, we just set empty string, and let ISPyB handle it.
# "startDate": start_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
# "endDate": end_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
Loading
Loading