Skip to content

Commit 9184fd1

Browse files
committed
Lock editing pre-session info 24h before session start
1 parent b3e462e commit 9184fd1

File tree

9 files changed

+173
-16
lines changed

9 files changed

+173
-16
lines changed

src/scaup/crud/pre_sessions.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
from fastapi import HTTPException, status
2+
from fastapi.security import HTTPAuthorizationCredentials
3+
from lims_utils.auth import GenericUser
24
from sqlalchemy import select
35
from sqlalchemy.dialects.postgresql import insert
46

57
from ..models.inner_db.tables import PreSession
6-
from ..models.pre_sessions import PreSessionIn
8+
from ..models.pre_sessions import PreSessionIn, PreSessionOut
79
from ..utils.crud import assert_not_booked
810
from ..utils.database import inner_db
11+
from ..utils.session import check_session_locked
912

1013

1114
@assert_not_booked
@@ -21,11 +24,15 @@ def create_pre_session_info(shipmentId: int, params: PreSessionIn):
2124
return pre_session
2225

2326

24-
def get_pre_session_info(shipmentId: int):
25-
pre_session_info = inner_db.session.scalar(select(PreSession).filter(PreSession.shipmentId == shipmentId))
27+
def get_pre_session_info(shipment_id: int, user: GenericUser, token: HTTPAuthorizationCredentials):
28+
pre_session_info = inner_db.session.scalar(select(PreSession).filter(PreSession.shipmentId == shipment_id))
2629
if not pre_session_info:
2730
raise HTTPException(
2831
status.HTTP_404_NOT_FOUND,
2932
"Shipment does not have a request assigned to it",
3033
)
31-
return pre_session_info
34+
35+
validated_model = PreSessionOut.model_validate(pre_session_info, from_attributes=True)
36+
validated_model.isLocked = check_session_locked(shipment_id, user, token)
37+
38+
return validated_model

src/scaup/models/pre_sessions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
class BasePreSession(BaseModel):
77
details: Optional[dict[str, Any]] = None
8+
isLocked: bool = False
89

910

1011
class PreSessionIn(BasePreSession):

src/scaup/routes/shipments.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from fastapi import APIRouter, Body, Depends, Query, status
44
from fastapi.responses import RedirectResponse
55
from fastapi.security import HTTPAuthorizationCredentials
6+
from lims_utils.auth import GenericUser
67
from lims_utils.models import Paged, pagination
78

8-
from ..auth import Permissions, auth_scheme
9+
from scaup.utils.session import session_unlocked
10+
11+
from ..auth import Permissions, User, auth_scheme
912
from ..crud import containers as container_crud
1013
from ..crud import pdf as pdf_crud
1114
from ..crud import pre_sessions as ps_crud
@@ -159,9 +162,11 @@ def get_shipment_request(shipmentId=Depends(auth)):
159162
)
160163
def get_pre_session(
161164
shipmentId=Depends(auth),
165+
user: GenericUser = Depends(User),
166+
token: HTTPAuthorizationCredentials = Depends(auth_scheme),
162167
):
163168
"""Create new pre session information"""
164-
return ps_crud.get_pre_session_info(shipmentId)
169+
return ps_crud.get_pre_session_info(shipmentId, user, token)
165170

166171

167172
@router.put(
@@ -170,7 +175,7 @@ def get_pre_session(
170175
status_code=status.HTTP_201_CREATED,
171176
)
172177
def create_pre_session(
173-
shipmentId=Depends(auth),
178+
shipmentId=Depends(session_unlocked),
174179
parameters: PreSessionIn = Body(),
175180
):
176181
"""Upsert new pre session information"""

src/scaup/utils/session.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import re
2+
from datetime import datetime, timedelta
23

3-
from fastapi import HTTPException, status
4+
from fastapi import Depends, HTTPException, status
5+
from fastapi.security import HTTPAuthorizationCredentials
6+
from lims_utils.auth import GenericUser
47
from lims_utils.logging import app_logger
58
from psycopg.errors import ForeignKeyViolation, UniqueViolation
6-
from sqlalchemy import update
9+
from sqlalchemy import func, select, update
710
from sqlalchemy.exc import IntegrityError
811

12+
from ..auth import Permissions, User, auth_scheme
913
from ..models.inner_db import tables as db_tables
14+
from ..models.inner_db.tables import Shipment
1015
from .database import inner_db
16+
from .external import ExternalRequest
1117

1218
UNIQUE_VIOLATION_REGEX = r"\((.*)\)=\((.*)\)"
1319

@@ -70,9 +76,60 @@ def wrap(*args, **kwargs):
7076
case ForeignKeyViolation():
7177
columns = _get_columns_and_values(e.__cause__.diag.message_detail)
7278
raise HTTPException(
73-
status_code=status.HTTP_404_NOT_FOUND, detail=f"Invalid {', '.join(columns.keys())} provided"
79+
status_code=status.HTTP_404_NOT_FOUND,
80+
detail=f"Invalid {', '.join(columns.keys())} provided",
7481
)
7582
case _:
7683
app_logger.warning("Integrity error whilst performing request", exc_info=e)
7784

7885
return wrap
86+
87+
88+
def check_session_locked(shipment_id: int, user: GenericUser, token: HTTPAuthorizationCredentials):
89+
# Staff are exempt from this limitation
90+
if bool({"em_admin", "super_admin"} & set(user.permissions)):
91+
return False
92+
93+
session = inner_db.session.execute(
94+
select(
95+
func.concat(Shipment.proposalCode, Shipment.proposalNumber).label("proposal"),
96+
Shipment.visitNumber,
97+
).filter(Shipment.id == shipment_id)
98+
).one()
99+
100+
response = ExternalRequest.request(
101+
token=token.credentials,
102+
method="GET",
103+
url=f"/proposals/{session.proposal}/sessions/{session.visitNumber}",
104+
)
105+
106+
if response.status_code != 200:
107+
app_logger.warning("Failed to retrieve session from ISPyB at %s: %s", response.url, response.text)
108+
109+
raise HTTPException(
110+
status_code=status.HTTP_424_FAILED_DEPENDENCY,
111+
detail="Resource can't be verified upstream",
112+
)
113+
114+
session_start = datetime.strptime(response.json()["startDate"], "%Y-%m-%dT%H:%M:%S")
115+
116+
if session_start - datetime.now() < timedelta(hours=24):
117+
return True
118+
119+
return False
120+
121+
122+
def session_unlocked(
123+
shipment_id: int = Depends(Permissions.shipment),
124+
user: GenericUser = Depends(User),
125+
token: HTTPAuthorizationCredentials = Depends(auth_scheme),
126+
):
127+
"""Check if a session is locked, and its resources can't be modified any further"""
128+
129+
if check_session_locked(shipment_id, user, token):
130+
raise HTTPException(
131+
status_code=status.HTTP_400_BAD_REQUEST,
132+
detail="Resource can't be modified 24 hours before session",
133+
)
134+
135+
return shipment_id

tests/conftest.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import os
2-
from datetime import datetime
32
from unittest.mock import patch
43

54
# FastAPI's server has side-effects on import. This ensures the env. vars are set beforehand
@@ -151,7 +150,7 @@ def register_responses(request):
151150
"sessionId": 1,
152151
"beamLineOperator": "John Doe",
153152
"beamLineName": "m03",
154-
"startDate": str(datetime.now()),
155-
"endDate": str(datetime.now()),
153+
"startDate": "2025-07-21T01:00:00",
154+
"endDate": "2025-07-24T01:00:00",
156155
},
157156
)

tests/shipments/pre_session/__init__.py

Whitespace-only changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import pytest
2+
import responses
3+
from freezegun import freeze_time
4+
5+
from ...test_utils.users import user
6+
7+
8+
@freeze_time("2025-07-01T15:00:00")
9+
@responses.activate
10+
@pytest.mark.parametrize("mock_user", [user], indirect=True)
11+
def test_get(mock_user, client):
12+
"""Should get pre session information"""
13+
14+
resp = client.get("/shipments/2/preSession")
15+
16+
assert resp.status_code == 200
17+
18+
assert resp.json() == {"details": {"name": "previous"}, "isLocked": False}
19+
20+
21+
@freeze_time("2025-07-20T15:00:00")
22+
@responses.activate
23+
@pytest.mark.parametrize("mock_user", [user], indirect=True)
24+
def test_get_locked(mock_user, client):
25+
"""Should get correct lock status for pre session information"""
26+
27+
resp = client.get("/shipments/2/preSession")
28+
29+
assert resp.status_code == 200
30+
31+
assert resp.json() == {"details": {"name": "previous"}, "isLocked": True}

tests/shipments/pre_session/test_upsert.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import pytest
2+
import responses
3+
from freezegun import freeze_time
14
from sqlalchemy import select
25

36
from scaup.models.inner_db.tables import PreSession
7+
from scaup.utils.config import Config
48
from scaup.utils.database import inner_db
59

10+
from ...test_utils.users import admin, em_admin, user
611

7-
def test_create(client):
12+
13+
@freeze_time("2025-07-01T15:00:00")
14+
@responses.activate
15+
@pytest.mark.parametrize("mock_user", [user], indirect=True)
16+
def test_create(mock_user, client):
817
"""Should create new pre session information entry"""
918

1019
resp = client.put(
@@ -17,7 +26,10 @@ def test_create(client):
1726
assert inner_db.session.scalar(select(PreSession.details).filter(PreSession.shipmentId == 1)) == {"name": "test"}
1827

1928

20-
def test_update(client):
29+
@freeze_time("2025-07-01T15:00:00")
30+
@responses.activate
31+
@pytest.mark.parametrize("mock_user", [user], indirect=True)
32+
def test_update(mock_user, client):
2133
"""Should update pre session information entry"""
2234

2335
resp = client.put(
@@ -28,3 +40,48 @@ def test_update(client):
2840
assert resp.status_code == 201
2941

3042
assert inner_db.session.scalar(select(PreSession.details).filter(PreSession.shipmentId == 2)) == {"name": "newName"}
43+
44+
45+
@freeze_time("2025-07-20T15:00:00")
46+
@pytest.mark.parametrize("mock_user", [user], indirect=True)
47+
@responses.activate
48+
def test_locked(client, mock_user):
49+
"""Should return error if session is locked and user is not staff"""
50+
resp = client.put(
51+
"/shipments/2/preSession",
52+
json={"details": {"name": "newName"}},
53+
)
54+
55+
assert resp.status_code == 400
56+
assert resp.json()["detail"] == "Resource can't be modified 24 hours before session"
57+
58+
59+
@freeze_time("2025-07-20T15:00:00")
60+
@responses.activate
61+
@pytest.mark.parametrize("mock_user", [admin, em_admin], indirect=True)
62+
def test_locked_staff(mock_user, client):
63+
"""Should allow staff to modify experimental parameters in locked sessions"""
64+
resp = client.put(
65+
"/shipments/2/preSession",
66+
json={"details": {"name": "newName"}},
67+
)
68+
69+
assert resp.status_code == 201
70+
71+
assert inner_db.session.scalar(select(PreSession.details).filter(PreSession.shipmentId == 2)) == {"name": "newName"}
72+
73+
74+
@pytest.mark.noregister
75+
@freeze_time("2025-07-20T15:00:00")
76+
@responses.activate
77+
@pytest.mark.parametrize("mock_user", [user], indirect=True)
78+
def test_session_unavailable(mock_user, client):
79+
"""Should return error if upstream service can't find session"""
80+
responses.get(Config.ispyb_api.url + "/proposals/cm2/sessions/1", json={"detail": "Not found"}, status=404)
81+
82+
resp = client.put(
83+
"/shipments/2/preSession",
84+
json={"details": {"name": "newName"}},
85+
)
86+
87+
assert resp.status_code == 424

tests/shipments/test_report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def test_get(client):
2929

3030

3131
@pytest.mark.noregister
32-
@responses.activate()
32+
@responses.activate
3333
def test_no_session(client):
3434
"""Should return 404 if session is not found upstream"""
3535
responses.get(f"{Config.ispyb_api.url}/proposals/cm1/sessions/1", status=404)

0 commit comments

Comments
 (0)