diff --git a/src/scaup/auth/micro.py b/src/scaup/auth/micro.py index 1cbc88c..cbc0503 100644 --- a/src/scaup/auth/micro.py +++ b/src/scaup/auth/micro.py @@ -135,7 +135,9 @@ def proposal( proposalReference: str, token: HTTPAuthorizationCredentials = Depends(auth_scheme), ): - return _check_perms(proposalReference, "proposal", token.credentials) + proposal_reference = parse_proposal(proposal_reference=proposalReference) + _check_perms(proposalReference, "proposal", token.credentials) + return proposal_reference @staticmethod def session( diff --git a/src/scaup/auth/template.py b/src/scaup/auth/template.py index 20fbabc..3c45aea 100644 --- a/src/scaup/auth/template.py +++ b/src/scaup/auth/template.py @@ -7,7 +7,7 @@ class GenericPermissions(Protocol): @staticmethod def proposal(proposalReference: str): - return proposalReference + return parse_proposal(proposal_reference=proposalReference) @staticmethod def session(proposalReference: str, visitNumber: int): diff --git a/src/scaup/crud/proposals.py b/src/scaup/crud/proposals.py index d16d941..d550241 100644 --- a/src/scaup/crud/proposals.py +++ b/src/scaup/crud/proposals.py @@ -35,9 +35,13 @@ def get_shipments(token: str, proposal_reference: ProposalReference, limit: int, query = select(Shipment).filter( Shipment.proposalCode == proposal_reference.code, Shipment.proposalNumber == proposal_reference.number, - Shipment.visitNumber == proposal_reference.visit_number, ) + if proposal_reference.visit_number: + query = query.filter(Shipment.visitNumber == proposal_reference.visit_number) + else: + query = query.order_by(Shipment.visitNumber.desc(), Shipment.creationDate.desc()) + shipments: Paged[Shipment] = inner_db.paginate(query, limit, page, slow_count=False, scalar=False) shipments.items = update_shipment_statuses(shipments.items, token) diff --git a/src/scaup/main.py b/src/scaup/main.py index e945dcb..a447ea7 100644 --- a/src/scaup/main.py +++ b/src/scaup/main.py @@ -12,6 +12,7 @@ internal, proposals, samples, + sessions, shipments, top_level_containers, ) @@ -72,5 +73,6 @@ async def general_exception_handler(request: Request, exc: Exception): api.include_router(containers.router) api.include_router(top_level_containers.router) api.include_router(internal.router) +api.include_router(sessions.router) app.mount(os.getenv("MOUNT_POINT", "/api"), api) diff --git a/src/scaup/models/sessions.py b/src/scaup/models/sessions.py new file mode 100644 index 0000000..7166c6b --- /dev/null +++ b/src/scaup/models/sessions.py @@ -0,0 +1,44 @@ +from datetime import datetime +from typing import Optional, Set + +from pydantic import AliasChoices, Field + +from ..utils.models import OrmBaseModel + + +class SessionOut(OrmBaseModel): + beamLineSetupId: int | None = None + beamCalendarId: int | None = None + startDate: datetime | None = None + endDate: datetime | None = None + beamLineName: str | None = Field(None, max_length=45) + scheduled: int | None = Field(None, lt=10) + nbShifts: int | None = Field(None, lt=1e9) + comments: str | None = Field(None, max_length=2000) + visit_number: int | None = Field( + None, + lt=1e9, + serialization_alias="visitNumber", + validation_alias=AliasChoices("visitNumber", "visit_number"), + ) + usedFlag: int | None = Field( + None, + lt=2, + description="Indicates if session has Datacollections or XFE or EnergyScans attached", # noqa: E501 + ) + lastUpdate: datetime | None = Field( + None, + description="Last update timestamp: by default the end of the session, the last collect", # noqa: E501 + ) + parentProposal: str | None = None + proposalId: int = Field(..., lt=1e9, description="Proposal ID") + sessionId: int = Field(..., lt=1e9, description="Session ID") + beamLineOperator: Set[str] | None = None + bltimeStamp: datetime + purgedProcessedData: bool + archived: int = Field( + ..., + lt=2, + description="The data for the session is archived and no longer available on disk", # noqa: E501 + ) + collectionGroups: Optional[int] = None diff --git a/src/scaup/routes/proposals.py b/src/scaup/routes/proposals.py index 7f3090f..c32fd4c 100644 --- a/src/scaup/routes/proposals.py +++ b/src/scaup/routes/proposals.py @@ -103,6 +103,16 @@ def get_shipment_data( return ExternalRequest.request(token=token.credentials, url=f"/proposals/{proposalReference}/data").json() +@router.get("/{proposalReference}/shipments") +def get_proposal_shipments( + proposalReference: ProposalReference = Depends(Permissions.proposal), + token: HTTPAuthorizationCredentials = Depends(auth_scheme), + page: dict[str, int] = Depends(pagination), +): + """Get shipments in proposal""" + return crud.get_shipments(token=token.credentials, proposal_reference=proposalReference, **page) + + @router.post( "/{proposalReference}/sessions/{visitNumber}/assign-data-collection-groups", ) diff --git a/src/scaup/routes/sessions.py b/src/scaup/routes/sessions.py new file mode 100644 index 0000000..2ec6bdb --- /dev/null +++ b/src/scaup/routes/sessions.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.security import HTTPAuthorizationCredentials +from lims_utils.logging import app_logger +from lims_utils.models import Paged, pagination + +from ..auth import auth_scheme +from ..models.sessions import SessionOut +from ..utils.external import ExternalRequest + +router = APIRouter( + tags=["Sessions"], + prefix="/sessions", +) + + +@router.get("", response_model=Paged[SessionOut]) +def get_sessions( + token: HTTPAuthorizationCredentials = Depends(auth_scheme), + page: dict[str, int] = Depends(pagination), + minEndDate: str | None = Query(default=None, description="Minimum session end date"), +): + """Get sessions a user can view (wrapper for Expeye endpoint)""" + # TODO: replace search once Expeye supports filtering by beamline name + url = f"/sessions?limit={page['limit']}&page={page['page']}&search=m" + if minEndDate is not None: + url += f"&minEndDate={minEndDate}" + + expeye_response = ExternalRequest.request( + token=token.credentials, + url=url, + ) + + if expeye_response.status_code != 200: + app_logger.warning(f"Failed to fetch proposals from Expeye: {expeye_response.text}") + raise HTTPException( + status_code=expeye_response.status_code, + detail="Failed to fetch proposals", + ) + + return expeye_response.json() diff --git a/tests/proposals/test_get_shipment.py b/tests/proposals/test_get_session_shipments.py similarity index 85% rename from tests/proposals/test_get_shipment.py rename to tests/proposals/test_get_session_shipments.py index 78682c0..438618c 100644 --- a/tests/proposals/test_get_shipment.py +++ b/tests/proposals/test_get_session_shipments.py @@ -5,7 +5,7 @@ @responses.activate def test_get(client): - """Should get shipments in proposal""" + """Should get shipments in session""" responses.get( f"{Config.ispyb_api.url}/shipments/63975", status=200, @@ -23,7 +23,7 @@ def test_get(client): @responses.activate def test_get_inexistent(client): - """Should return empty list if no shipments exist in proposal""" + """Should return empty list if no shipments exist in session""" resp = client.get("/proposals/cm55555/sessions/1/shipments") assert resp.status_code == 200 diff --git a/tests/proposals/test_get_shipments.py b/tests/proposals/test_get_shipments.py new file mode 100644 index 0000000..d997f62 --- /dev/null +++ b/tests/proposals/test_get_shipments.py @@ -0,0 +1,33 @@ +import responses + +from scaup.utils.config import Config + + +@responses.activate +def test_get(client): + """Should get shipments in proposal""" + responses.get( + f"{Config.ispyb_api.url}/shipments/63975", + status=200, + json={"shippingStatus": "opened"}, + ) + + resp = client.get("/proposals/bi23047/shipments") + + assert resp.status_code == 200 + + data = resp.json() + + assert len(data["items"]) == 5 + + +@responses.activate +def test_get_inexistent(client): + """Should return empty list if no shipments exist in proposal""" + resp = client.get("/proposals/cm55555/shipments") + + assert resp.status_code == 200 + + data = resp.json() + + assert len(data["items"]) == 0 diff --git a/tests/sessions/__init__.py b/tests/sessions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sessions/test_get.py b/tests/sessions/test_get.py new file mode 100644 index 0000000..c733e01 --- /dev/null +++ b/tests/sessions/test_get.py @@ -0,0 +1,74 @@ +import responses + +from scaup.utils.config import Config + + +@responses.activate +def test_get(client): + """Should get sessions data""" + responses.get( + f"{Config.ispyb_api.url}/sessions", + status=200, + json={ + "page": 1, + "total": 99, + "limit": 1, + "items": [ + { + "bltimeStamp": "2025-01-01T12:00:00Z", + "visitNumber": 1, + "parentProposal": "cm00001", + "sessionId": 1, + "purgedProcessedData": False, + "proposalId": 1, + "archived": False, + } + ], + }, + ) + + resp = client.get("/sessions") + + assert resp.status_code == 200 + + assert resp.json()["total"] == 99 + + +@responses.activate +def test_min_end_date(client): + """Should include search params in expeye request""" + resp_get = responses.get( + f"{Config.ispyb_api.url}/sessions", + status=200, + json={ + "page": 1, + "total": 99, + "limit": 1, + "items": [ + { + "bltimeStamp": "2025-01-01T12:00:00Z", + "visitNumber": 1, + "parentProposal": "cm00001", + "sessionId": 1, + "purgedProcessedData": False, + "proposalId": 1, + "archived": False, + } + ], + }, + ) + + resp = client.get("/sessions?minEndDate=2025-01-01T00:00:00Z") + + assert resp.status_code == 200 + assert resp_get.calls[0].request.url.endswith("/sessions?limit=25&page=0&search=m&minEndDate=2025-01-01T00:00:00Z") + + +@responses.activate +def test_invalid_response(client): + """Should propagate error if upstream API fails""" + responses.get(f"{Config.ispyb_api.url}/sessions", status=404) + + resp = client.get("/sessions") + + assert resp.status_code == 404