Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions src/scaup/models/top_level_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,7 @@
from datetime import datetime
from typing import Any, List, Optional

from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
computed_field,
field_validator,
)
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator

from ..utils.models import BaseExternal

Expand Down Expand Up @@ -59,6 +52,9 @@ class TopLevelContainerExternal(BaseExternal):
comments: str
code: str
barCode: uuid.UUID
firstExperimentId: int | None = None
weight: float = 18
dewarRegistryId: int | None = None

@computed_field
def facilityCode(self) -> str:
Expand Down
4 changes: 2 additions & 2 deletions src/scaup/utils/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ def edit_item(
inner_db.session.commit()

if updated_item and updated_item.externalId is not None:
ext_obj = ExternalObject(updated_item, item_id)
ext_obj = ExternalObject(token, updated_item, item_id)

ExternalRequest.request(
token,
method="PATCH",
url=f"{ext_obj.external_link_prefix}{updated_item.externalId}",
json=ext_obj.item_body.model_dump(mode="json"),
json=ext_obj.item_body.model_dump(mode="json", exclude=ext_obj.to_exclude),
)

return updated_item
Expand Down
97 changes: 74 additions & 23 deletions src/scaup/utils/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import requests
from fastapi import HTTPException, status
from lims_utils.logging import app_logger
from sqlalchemy import update
from sqlalchemy import func, select, update

from ..models.containers import ContainerExternal
from ..models.inner_db.tables import (
Expand All @@ -31,16 +31,57 @@
}


# TODO: possibly replace this with middleware, or httpx client instances
class ExternalRequest:
@staticmethod
def request(
token,
base_url=Config.ispyb_api.url,
*args,
**kwargs,
):
"""Wrapper for request object. Since the URL is validated before any
auth actions happen, we cannot wrap this in a custom auth implementation,
we must do all the preparation work before the actual request."""

kwargs["url"] = f"{base_url}{kwargs['url']}"
kwargs["method"] = kwargs.get("method", "GET")
kwargs["headers"] = {"Authorization": f"Bearer {token}"}

return requests.request(**kwargs)


def _get_resource_from_ispyb(token: str, url: str):
response = ExternalRequest.request(token, url=url)

if response.status_code != 200:
app_logger.error(
(
f"Failed getting session information from ISPyB at URL {url}, service returned "
f"{response.status_code}: {response.text}"
)
)

raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="Received invalid response from upstream service",
)

return response.json()


class ExternalObject:
"""Object representing a link to the ISPyB instance of the object"""

item_body: OrmBaseModel = OrmBaseModel()
external_link_prefix = ""
external_key = ""
url = ""
to_exclude: set[str] = set()

def __init__(
self,
token: str,
item: AvailableTable,
item_id: int | str | None,
root_id: int | None = None,
Expand All @@ -66,6 +107,36 @@ def __init__(
self.url = f"/shipments/{item_id}/dewars"
self.external_link_prefix = "/dewars/"
self.item_body = TopLevelContainerExternal.model_validate(item)

shipment = inner_db.session.execute(
select(
func.concat(Shipment.proposalCode, Shipment.proposalNumber).label("proposal"),
Shipment.visitNumber,
).filter(Shipment.id == item.shipmentId)
).one()

# When creating the dewar in ISPyB, since ISPyB has no concept of shipments belonging to sessions,
# dewars have to be assigned to sessions instead, and this is done through the firstExperimentId
# column, which despite the cryptic name, points to the BLSession table.
if item.externalId is None:
session = _get_resource_from_ispyb(
token,
f"/proposals/{shipment.proposal}/sessions/{shipment.visitNumber}",
)
self.item_body.firstExperimentId = session["sessionId"]
else:
self.to_exclude = {"firstExperimentId"}

# We store the dewar's facility code, but not the numeric dewar registry ID that ISPyB also expects.
# Even though the alphanumeric code is a primary key in the DewarRegistry table, the dewar table still
# expects a numeric dewarRegistryId which is used in some systems.
# Since the facility code can be changed by the user, we need to update this even if it was already
# pushed to ISPyB
dewar_reg = _get_resource_from_ispyb(
token, f"/proposals/{shipment.proposal}/dewar-registry/{item.code}"
)

self.item_body.dewarRegistryId = dewar_reg["dewarRegistryId"]
self.external_key = "dewarId"
case Sample():
if item_id is None:
Expand All @@ -80,26 +151,6 @@ def __init__(
raise NotImplementedError()


# TODO: possibly replace this with middleware, or httpx client instances
class ExternalRequest:
@staticmethod
def request(
token,
base_url=Config.ispyb_api.url,
*args,
**kwargs,
):
"""Wrapper for request object. Since the URL is validated before any
auth actions happen, we cannot wrap this in a custom auth implementation,
we must do all the preparation work before the actual request."""

kwargs["url"] = f"{base_url}{kwargs['url']}"
kwargs["method"] = kwargs.get("method", "GET")
kwargs["headers"] = {"Authorization": f"Bearer {token}"}

return requests.request(**kwargs)


class Expeye:
@classmethod
def upsert(
Expand All @@ -119,7 +170,7 @@ def upsert(
Returns:
External link and external ID"""

ext_obj = ExternalObject(item, parent_id, root_id)
ext_obj = ExternalObject(token, item, parent_id, root_id)
method = "POST"

if item.externalId:
Expand All @@ -130,7 +181,7 @@ def upsert(
token,
method=method,
url=ext_obj.url,
json=ext_obj.item_body.model_dump(mode="json"),
json=ext_obj.item_body.model_dump(mode="json", exclude=ext_obj.to_exclude),
)

if response.status_code not in [201, 200]:
Expand Down
4 changes: 2 additions & 2 deletions tests/shipments/top_level_containers/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ def registered_dewar_callback(request: PreparedRequest):
# Return valid response for dewar with facility code DLS-EM-0000, return 404 for all else
dewar_id = get_match(registered_dewar_regex, request.url, 2)

if dewar_id == "DLS-EM-0000" or dewar_id == "DLS-EM-0001":
return (200, {}, json.dumps({}))
if dewar_id in ["DLS-EM-0000", "DLS-EM-0001", "DLS-4", "DLS-1"]:
return (200, {}, json.dumps({"dewarRegistryId": 456}))

return (404, {}, "")

Expand Down
50 changes: 50 additions & 0 deletions tests/utils/external/test_external_object.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import copy
from datetime import datetime
from uuid import UUID

import pytest
import responses
from fastapi import HTTPException

from scaup.models.inner_db.tables import TopLevelContainer
from scaup.utils.config import Config
from scaup.utils.external import ExternalObject, _get_resource_from_ispyb

base_dewar = TopLevelContainer(
code="DLS-EM-0001",
id=1,
shipmentId=1,
type="dewar",
name="Test_Dewar",
barCode=UUID(bytes=b"ffffffffffffffff"),
creationDate=datetime.now(),
)


@responses.activate
def test_new_top_level_container(client):
"""Should get session information from ISPyB and populate it in dewar before pushing it"""
dewar = ExternalObject("token", base_dewar, 1, 5)

assert dewar.item_body.firstExperimentId == 1
assert dewar.item_body.dewarRegistryId == 456


@responses.activate
def test_update_top_level_container(client):
"""Should not get session ID if item has already been pushed to ISPyB"""
external_dewar = copy.deepcopy(base_dewar)
external_dewar.externalId = 1
dewar = ExternalObject("token", external_dewar, 1, 5)

assert dewar.item_body.firstExperimentId is None
assert dewar.to_exclude == {"firstExperimentId"}


@responses.activate
def test_upstream_request_fail():
"""Should raise exception if Expeye returns invalid response"""
responses.get(f"{Config.ispyb_api.url}/foo", status=404)

with pytest.raises(HTTPException, match="Received invalid response from upstream service"):
_get_resource_from_ispyb("token", "/foo")
Loading