Skip to content

Commit 291b33d

Browse files
authored
Merge pull request #16 from NHSDigital/fix/make-put-request-to-manage
Amend DicomUploader to PUT to Manage
2 parents d2b755e + 9538b00 commit 291b33d

6 files changed

Lines changed: 69 additions & 65 deletions

File tree

.env.development

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ AZURE_RELAY_HYBRID_CONNECTION=name-of-your-choice-relay-test-hc
44
AZURE_RELAY_KEY_NAME=RootManageSharedAccessKey
55
AZURE_RELAY_SHARED_ACCESS_KEY=YOUR_SHARED_ACCESS_KEY_HERE
66

7+
CLOUD_API_ENDPOINT=https://localhost:8000/api/v1/dicom
8+
CLOUD_API_TOKEN=testtoken
79
# MWL Server Configuration
810
MWL_AET=SCREENING_MWL
911
MWL_PORT=4243

compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ services:
1111
- pacs-storage:/var/lib/pacs/storage
1212
- pacs-db:/var/lib/pacs
1313
environment:
14+
- CLOUD_API_ENDPOINT=${CLOUD_API_ENDPOINT}
15+
- CLOUD_API_TOKEN=${CLOUD_API_TOKEN}
1416
- PACS_AET=SCREENING_PACS
1517
- PACS_PORT=4244
1618
- PACS_STORAGE_PATH=/var/lib/pacs/storage

src/services/dicom/dicom_uploader.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,39 +16,36 @@
1616

1717
class DICOMUploader:
1818
def __init__(self, api_endpoint: str | None = None, timeout: int = 30, verify_ssl: bool = True):
19-
self.api_endpoint = api_endpoint or os.getenv("CLOUD_API_ENDPOINT", "http://localhost:8000/api/dicom/upload/")
19+
self.api_endpoint = api_endpoint or os.getenv("CLOUD_API_ENDPOINT", "http://localhost:8000/api/v1/dicom")
2020
self.timeout = timeout
2121
self.verify_ssl = verify_ssl
2222

23-
def upload_dicom(self, sop_instance_uid: str, dicom_bytes: bytes, action_id: Optional[str]) -> bool:
24-
if not action_id:
25-
logger.warning(f"No action_id for {sop_instance_uid}, upload will be rejected by server")
26-
27-
headers = {
28-
"X-Source-Message-ID": action_id or "",
23+
def headers(self) -> dict:
24+
return {
25+
"Authorization": f"Bearer {os.getenv('CLOUD_API_TOKEN', '')}",
2926
}
3027

31-
# Wrap bytes in BytesIO stream - Django expects a file-like object
32-
file_stream = io.BytesIO(dicom_bytes)
28+
def upload_dicom(self, sop_instance_uid: str, dicom_stream: io.BufferedReader, action_id: Optional[str]) -> bool:
29+
if not action_id:
30+
logger.error(f"No action_id for {sop_instance_uid}, upload will be rejected by server")
31+
return False
32+
3333
files = {
34-
"file": (f"{sop_instance_uid}.dcm", file_stream),
34+
"file": (f"{sop_instance_uid}.dcm", dicom_stream),
3535
}
3636

3737
try:
38-
logger.info(
39-
f"Uploading {sop_instance_uid} to {self.api_endpoint} "
40-
f"(size: {len(dicom_bytes)} bytes, action_id: {action_id})"
41-
)
38+
logger.info(f"Uploading {sop_instance_uid} to {self.api_endpoint}/{action_id}")
4239

43-
response = requests.post(
44-
self.api_endpoint,
40+
response = requests.put(
41+
f"{self.api_endpoint}/{action_id}",
4542
files=files,
46-
headers=headers,
4743
timeout=self.timeout,
4844
verify=self.verify_ssl,
45+
headers=self.headers(),
4946
)
5047

51-
if response.status_code in (200, 201, 204):
48+
if response.status_code == 201:
5249
logger.info(f"Successfully uploaded {sop_instance_uid} (status: {response.status_code})")
5350
return True
5451
else:

src/services/dicom/upload_processor.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,9 @@ def upload_instance(self, instance: dict) -> bool:
9797
self._mark_failed(sop_instance_uid, error, attempt_count + 1)
9898
return False
9999

100-
dicom_bytes = dicom_path.read_bytes()
101-
logger.debug(f"Read {len(dicom_bytes)} bytes from {dicom_path}")
102-
103100
action_id = self.mwl_storage.get_source_message_id(accession_number) if accession_number else None
104101

105-
if self.uploader.upload_dicom(sop_instance_uid, dicom_bytes, action_id):
102+
if self.uploader.upload_dicom(sop_instance_uid, open(dicom_path, "rb"), action_id):
106103
self.pacs_storage.mark_upload_complete(sop_instance_uid)
107104
logger.info(f"Successfully uploaded {sop_instance_uid}")
108105
return True
Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,87 @@
11
import io
2+
import tempfile
23
from unittest.mock import Mock, patch
34

5+
import pydicom
6+
import pytest
47
import requests
58

69
from services.dicom.dicom_uploader import DICOMUploader
710

811

912
class TestDICOMUploader:
10-
@patch("services.dicom.dicom_uploader.requests.post")
11-
def test_upload_success(self, mock_post):
13+
@pytest.fixture
14+
def dicom_file(self):
15+
tf = tempfile.NamedTemporaryFile(delete=False)
16+
tf.write(b"fake dicom data")
17+
tf.close()
18+
yield tf.name
19+
20+
@patch("services.dicom.dicom_uploader.requests.put")
21+
def test_upload_success(self, mock_put, dicom_file):
1222
mock_response = Mock()
1323
mock_response.status_code = 201
14-
mock_post.return_value = mock_response
24+
mock_put.return_value = mock_response
25+
26+
sop_instance_uid = pydicom.uid.generate_uid()
1527

1628
uploader = DICOMUploader(api_endpoint="http://test.com/api/upload")
1729

18-
dicom_bytes = b"fake dicom data"
1930
result = uploader.upload_dicom(
20-
sop_instance_uid="1.2.3.4.5", # gitleaks:allow
21-
dicom_bytes=dicom_bytes,
31+
sop_instance_uid=sop_instance_uid,
32+
dicom_stream=open(dicom_file, "rb"),
2233
action_id="ACTION123",
2334
)
2435

2536
assert result is True
26-
mock_post.assert_called_once()
37+
mock_put.assert_called_once_with(
38+
"http://test.com/api/upload/ACTION123",
39+
files=mock_put.call_args[1]["files"],
40+
timeout=30,
41+
verify=True,
42+
headers=uploader.headers(),
43+
)
2744

28-
# Verify multipart form upload with BytesIO stream
29-
call_kwargs = mock_post.call_args[1]
30-
assert call_kwargs["headers"]["X-Source-Message-ID"] == "ACTION123"
45+
call_kwargs = mock_put.call_args[1]
3146
assert "files" in call_kwargs
3247
file_tuple = call_kwargs["files"]["file"]
33-
assert file_tuple[0] == "1.2.3.4.5.dcm" # gitleaks:allow
34-
assert isinstance(file_tuple[1], io.BytesIO) # stream
35-
assert file_tuple[1].getvalue() == dicom_bytes # content
36-
37-
@patch("services.dicom.dicom_uploader.requests.post")
38-
def test_upload_without_action_id(self, mock_post):
39-
"""Upload without action_id sends empty header (server will reject)."""
40-
mock_response = Mock()
41-
mock_response.status_code = 400
42-
mock_response.text = "Missing X-Source-Message-ID header"
43-
mock_post.return_value = mock_response
48+
assert file_tuple[0] == f"{sop_instance_uid}.dcm"
49+
assert isinstance(file_tuple[1], io.BufferedReader)
50+
assert file_tuple[1].read() == open(dicom_file, "rb").read()
4451

52+
def test_upload_without_action_id(self, dicom_file):
53+
"""Upload without action_id does not make request."""
4554
uploader = DICOMUploader()
46-
result = uploader.upload_dicom(sop_instance_uid="1.2.3", dicom_bytes=b"data", action_id=None)
55+
result = uploader.upload_dicom(sop_instance_uid="1.2.3", dicom_stream=open(dicom_file, "rb"), action_id=None)
4756

4857
assert result is False
49-
call_kwargs = mock_post.call_args[1]
50-
assert call_kwargs["headers"]["X-Source-Message-ID"] == ""
5158

52-
@patch("services.dicom.dicom_uploader.requests.post")
53-
def test_upload_failure_status_code(self, mock_post):
59+
@patch("services.dicom.dicom_uploader.requests.put")
60+
def test_upload_failure_status_code(self, mock_put, dicom_file):
5461
mock_response = Mock()
5562
mock_response.status_code = 500
5663
mock_response.text = "Internal server error"
57-
mock_post.return_value = mock_response
64+
mock_put.return_value = mock_response
5865

5966
uploader = DICOMUploader()
60-
result = uploader.upload_dicom("1.2.3", b"data", None)
67+
result = uploader.upload_dicom("1.2.3", open(dicom_file, "rb"), None)
6168

6269
assert result is False
6370

64-
@patch("services.dicom.dicom_uploader.requests.post")
65-
def test_upload_timeout(self, mock_post):
66-
mock_post.side_effect = requests.exceptions.Timeout()
71+
@patch("services.dicom.dicom_uploader.requests.put")
72+
def test_upload_timeout(self, mock_put, dicom_file):
73+
mock_put.side_effect = requests.exceptions.Timeout()
6774

6875
uploader = DICOMUploader(timeout=5)
69-
result = uploader.upload_dicom("1.2.3", b"data", None)
76+
result = uploader.upload_dicom("1.2.3", open(dicom_file, "rb"), None)
7077

7178
assert result is False
7279

73-
@patch("services.dicom.dicom_uploader.requests.post")
74-
def test_upload_network_error(self, mock_post):
75-
mock_post.side_effect = requests.exceptions.ConnectionError()
80+
@patch("services.dicom.dicom_uploader.requests.put")
81+
def test_upload_network_error(self, mock_put, dicom_file):
82+
mock_put.side_effect = requests.exceptions.ConnectionError()
7683

7784
uploader = DICOMUploader()
78-
result = uploader.upload_dicom("1.2.3", b"data", None)
85+
result = uploader.upload_dicom("1.2.3", open(dicom_file, "rb"), None)
7986

8087
assert result is False

tests/services/dicom/test_upload_processor.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pathlib import Path
2-
from unittest.mock import Mock, patch
2+
from unittest.mock import Mock, mock_open, patch
33

44
import pytest
55

@@ -62,8 +62,9 @@ def test_process_batch_processes_all_instances(self, processor, mock_pacs_storag
6262
},
6363
]
6464
mock_uploader.upload_dicom.return_value = True
65+
mo = mock_open(read_data=b"dicom data")
6566

66-
with patch.object(Path, "exists", return_value=True), patch.object(Path, "read_bytes", return_value=b"dicom"):
67+
with patch.object(Path, "exists", return_value=True), patch("builtins.open", mo):
6768
result = processor.process_batch(limit=10)
6869

6970
assert result == 2
@@ -79,16 +80,14 @@ def test_upload_instance_success(self, processor, mock_pacs_storage, mock_mwl_st
7980
mock_mwl_storage.get_source_message_id.return_value = "ACTION123"
8081
mock_uploader.upload_dicom.return_value = True
8182

82-
with (
83-
patch.object(Path, "exists", return_value=True),
84-
patch.object(Path, "read_bytes", return_value=b"dicom data"),
85-
):
83+
mo = mock_open(read_data=b"dicom data")
84+
with patch.object(Path, "exists", return_value=True), patch("builtins.open", mo):
8685
result = processor.upload_instance(instance)
8786

8887
assert result is True
8988
mock_pacs_storage.mark_upload_started.assert_called_once_with("1.2.3.4") # gitleaks:allow
9089
mock_pacs_storage.mark_upload_complete.assert_called_once_with("1.2.3.4") # gitleaks:allow
91-
mock_uploader.upload_dicom.assert_called_once_with("1.2.3.4", b"dicom data", "ACTION123") # gitleaks:allow
90+
mock_uploader.upload_dicom.assert_called_once_with("1.2.3.4", mo(), "ACTION123") # gitleaks:allow
9291

9392
def test_upload_instance_file_not_found(self, processor, mock_pacs_storage):
9493
instance = {

0 commit comments

Comments
 (0)