Skip to content

Commit feafe75

Browse files
Listen for new PACS entries and send upstream to Manage (#11)
Listen for new PACS entries and send upstream to Manage
1 parent 3cdcd6b commit feafe75

16 files changed

Lines changed: 1087 additions & 20 deletions

.gitleaksignore

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,108 @@
11
# Gitleaks allowlist for false positives
22
# Add file:line patterns for legitimate secrets that gitleaks flags incorrectly
33
#
4-
# Format: path/to/file:line_number:hash
5-
# Example: docs/examples/sample.md:42:b1234567890abcdef
4+
# Format: path/to/file:rule_id:line_number
5+
# These are DICOM UIDs that match the IPv4 pattern
66

7-
# Add your allowlist entries below this line
7+
# Git history fingerprints (for renamed/deleted files in old commits)
8+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:38
9+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:66
10+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:71
11+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:75
12+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:85
13+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:89
14+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:99
15+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/integration/test_upload_listener.py:ipv4:102
16+
064a96209818176322c8f84ab3781f78fc43d7c5:tests/services/dicom/test_cloud_uploader.py:ipv4:18
17+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:38
18+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:66
19+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:71
20+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:75
21+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:85
22+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:89
23+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:99
24+
caf78850d3de96da17abb731305cfed073fa0986:tests/integration/test_upload_listener.py:ipv4:102
25+
caf78850d3de96da17abb731305cfed073fa0986:tests/services/dicom/test_cloud_uploader.py:ipv4:18
26+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:95
27+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:124
28+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:130
29+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:134
30+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:145
31+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:149
32+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:161
33+
fda868296050b269384b6bd072136c0c474c61ec:tests/integration/test_pacs_storage_upload.py:ipv4:164
34+
19d6a0432e5d103823c45938f4a4edfa65239fd4:tests/services/test_storage.py:ipv4:136
35+
19d6a0432e5d103823c45938f4a4edfa65239fd4:tests/services/test_storage.py:ipv4:176
36+
3f3067bbd4f38ad6ce996ef50e57f624a591b3e0:tests/services/dicom/test_c_store.py:ipv4:15
37+
3f3067bbd4f38ad6ce996ef50e57f624a591b3e0:tests/services/dicom/test_c_store.py:ipv4:48
38+
b7f3d0e812393af74600b5c0e8374d89066384d7:src/services/storage.py:ipv4:72
39+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:src/services/storage.py:ipv4:74
40+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/integration/test_c_store_saves_metadata.py:ipv4:21
41+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/integration/test_c_store_saves_metadata.py:ipv4:41
42+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/integration/test_c_store_saves_metadata.py:ipv4:65
43+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/services/test_storage.py:ipv4:37
44+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/services/test_storage.py:ipv4:40
45+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/services/test_storage.py:ipv4:52
46+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/services/test_storage.py:ipv4:55
47+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/services/test_storage.py:ipv4:88
48+
c7095ccbf4f5458f19746dd3ae69c9f5e3e63aa6:tests/services/test_storage.py:ipv4:107
49+
d9b882c916499c8cf2107879c83de2897fe99247:tests/integration/test_c_store_saves_metadata.py:ipv4:52
850

51+
# Source files
52+
src/services/storage.py:ipv4:89
953

10-
src/services/storage.py:ipv4:74
11-
tests/services/dicom/test_c_store.py:ipv4:15
12-
tests/services/dicom/test_c_store.py:ipv4:19
13-
tests/services/dicom/test_c_store.py:ipv4:48
14-
tests/services/dicom/test_c_store.py:ipv4:62
15-
tests/integration/test_c_store_saves_metadata.py:ipv4:21
54+
# Test files - test_c_store_saves_metadata.py
1655
tests/integration/test_c_store_saves_metadata.py:ipv4:23
1756
tests/integration/test_c_store_saves_metadata.py:ipv4:38
18-
tests/integration/test_c_store_saves_metadata.py:ipv4:41
19-
tests/integration/test_c_store_saves_metadata.py:ipv4:52
2057
tests/integration/test_c_store_saves_metadata.py:ipv4:55
21-
tests/integration/test_c_store_saves_metadata.py:ipv4:65
2258
tests/integration/test_c_store_saves_metadata.py:ipv4:73
2359
tests/integration/test_c_store_saves_metadata.py:ipv4:91
2460
tests/integration/test_c_store_saves_metadata.py:ipv4:108
25-
tests/services/test_storage.py:ipv4:37
26-
tests/services/test_storage.py:ipv4:40
27-
tests/services/test_storage.py:ipv4:52
28-
tests/services/test_storage.py:ipv4:55
29-
tests/services/test_storage.py:ipv4:88
30-
tests/services/test_storage.py:ipv4:107
31-
tests/services/test_storage.py:ipv4:136
32-
tests/services/test_storage.py:ipv4:176
3361

62+
# Test files - test_pacs_storage_upload.py
63+
tests/integration/test_pacs_storage_upload.py:ipv4:21
64+
tests/integration/test_pacs_storage_upload.py:ipv4:25
65+
tests/integration/test_pacs_storage_upload.py:ipv4:29
66+
tests/integration/test_pacs_storage_upload.py:ipv4:30
67+
tests/integration/test_pacs_storage_upload.py:ipv4:32
68+
tests/integration/test_pacs_storage_upload.py:ipv4:36
69+
tests/integration/test_pacs_storage_upload.py:ipv4:45
70+
tests/integration/test_pacs_storage_upload.py:ipv4:46
71+
tests/integration/test_pacs_storage_upload.py:ipv4:48
72+
tests/integration/test_pacs_storage_upload.py:ipv4:52
73+
tests/integration/test_pacs_storage_upload.py:ipv4:62
74+
tests/integration/test_pacs_storage_upload.py:ipv4:65
75+
tests/integration/test_pacs_storage_upload.py:ipv4:66
76+
tests/integration/test_pacs_storage_upload.py:ipv4:67
77+
tests/integration/test_pacs_storage_upload.py:ipv4:69
78+
tests/integration/test_pacs_storage_upload.py:ipv4:72
79+
80+
# Test files - test_c_store.py
81+
tests/services/dicom/test_c_store.py:ipv4:19
82+
tests/services/dicom/test_c_store.py:ipv4:62
83+
84+
# Test files - test_dicom_uploader.py
85+
tests/services/dicom/test_dicom_uploader.py:ipv4:18
86+
tests/services/dicom/test_dicom_uploader.py:ipv4:28
87+
88+
# Test files - test_upload_processor.py
89+
tests/services/dicom/test_upload_processor.py:ipv4:52
90+
tests/services/dicom/test_upload_processor.py:ipv4:58
91+
tests/services/dicom/test_upload_processor.py:ipv4:74
92+
tests/services/dicom/test_upload_processor.py:ipv4:89
93+
tests/services/dicom/test_upload_processor.py:ipv4:90
94+
tests/services/dicom/test_upload_processor.py:ipv4:91
95+
tests/services/dicom/test_upload_processor.py:ipv4:95
96+
tests/services/dicom/test_upload_processor.py:ipv4:108
97+
tests/services/dicom/test_upload_processor.py:ipv4:113
98+
tests/services/dicom/test_upload_processor.py:ipv4:131
99+
tests/services/dicom/test_upload_processor.py:ipv4:150
100+
tests/services/dicom/test_upload_processor.py:ipv4:174
101+
102+
# Test files - test_storage.py
103+
tests/services/test_storage.py:ipv4:38
104+
tests/services/test_storage.py:ipv4:41
105+
tests/services/test_storage.py:ipv4:53
106+
tests/services/test_storage.py:ipv4:56
107+
tests/services/test_storage.py:ipv4:90
108+
tests/services/test_storage.py:ipv4:108

compose.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,29 @@ services:
6767
timeout: 5s
6868
retries: 3
6969

70+
upload:
71+
build:
72+
context: .
73+
dockerfile: Dockerfile
74+
container_name: upload-service
75+
command: ["uv", "run", "python", "-m", "upload_main"]
76+
volumes:
77+
- pacs-storage:/var/lib/pacs/storage
78+
- pacs-db:/var/lib/pacs
79+
environment:
80+
- CLOUD_API_ENDPOINT=${CLOUD_API_ENDPOINT:-http://host.docker.internal:8000/api/dicom/upload/}
81+
- PACS_DB_PATH=/var/lib/pacs/pacs.db
82+
- PACS_STORAGE_PATH=/var/lib/pacs/storage
83+
- MWL_DB_PATH=/var/lib/pacs/worklist.db
84+
- UPLOAD_POLL_INTERVAL=2
85+
- UPLOAD_BATCH_SIZE=10
86+
- MAX_UPLOAD_RETRIES=3
87+
- LOG_LEVEL=INFO
88+
depends_on:
89+
pacs:
90+
condition: service_healthy
91+
restart: unless-stopped
92+
7093
volumes:
7194
pacs-storage:
7295
driver: local

docs/upload/README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Upload Service
2+
3+
Background service that uploads stored DICOM images to the [Manage Breast Screening](https://github.com/NHSDigital/dtos-manage-breast-screening) cloud platform.
4+
5+
## Overview
6+
7+
The upload service:
8+
9+
- Polls the PACS database for stored images pending upload
10+
- Uploads images to the Manage API as multipart form data
11+
- Links images to appointments via the `X-Source-Message-ID` header
12+
- Runs alongside the [PACS Server](../pacs/README.md) and [MWL Server](../mwl/README.md)
13+
14+
## Architecture
15+
16+
```
17+
┌─────────────────────────────────────────────────────────────┐
18+
│ Upload Service │
19+
├─────────────────────────────────────────────────────────────┤
20+
│ │
21+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
22+
│ │ Upload │─────▶│ Upload │─────▶│ DICOM │ │
23+
│ │ Listener │ │ Processor │ │ Uploader│ │
24+
│ └──────────────┘ └──────────────┘ └──────────┘ │
25+
│ │ │ │ │
26+
│ │ ▼ │ │
27+
│ │ ┌──────────────┐ │ │
28+
│ │ │ PACS Storage │ │ │
29+
│ │ │ (files + DB) │ │ │
30+
│ │ └──────────────┘ │ │
31+
│ │ │ │ │
32+
│ │ ▼ ▼ │
33+
│ │ ┌──────────────┐ ┌────────────┐ │
34+
│ │ │ MWL Storage │ │ Manage API │ │
35+
│ │ │ (worklist DB)│ │ (HTTP) │ │
36+
│ └──────────────┴──────────────┘ └────────────┘ │
37+
└─────────────────────────────────────────────────────────────┘
38+
```
39+
40+
### Workflow
41+
42+
1. **Poll**: Listener polls PACS database every N seconds for pending uploads
43+
2. **Read**: Processor reads DICOM file from hash-based storage
44+
3. **Lookup**: Processor looks up `source_message_id` from MWL database via accession number
45+
4. **Upload**: Uploader sends multipart POST to cloud API with `X-Source-Message-ID` header
46+
5. **Update**: Processor updates upload status in PACS database (COMPLETE or retry)
47+
48+
### Retry Behaviour
49+
50+
- Failed uploads return to `PENDING` status for retry
51+
- After max retries (default 3), status becomes `FAILED`
52+
- Exponential backoff between batches when failures occur
53+
- Backoff starts at 1s, doubles each failure, caps at 60s
54+
55+
## Configuration
56+
57+
Environment variables:
58+
59+
| Variable | Default | Description |
60+
|----------|---------|-------------|
61+
| `CLOUD_API_ENDPOINT` | `http://localhost:8000/api/dicom/upload/` | Manage API upload endpoint |
62+
| `PACS_DB_PATH` | `/var/lib/pacs/pacs.db` | PACS SQLite database path |
63+
| `PACS_STORAGE_PATH` | `/var/lib/pacs/storage` | PACS file storage path |
64+
| `MWL_DB_PATH` | `/var/lib/pacs/worklist.db` | MWL SQLite database path |
65+
| `UPLOAD_POLL_INTERVAL` | `2` | Seconds between polling cycles |
66+
| `UPLOAD_BATCH_SIZE` | `10` | Max uploads per cycle |
67+
| `MAX_UPLOAD_RETRIES` | `3` | Retry attempts before permanent failure |
68+
| `LOG_LEVEL` | `INFO` | Logging level |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies = [
1414
"pylibjpeg-openjpeg>=2.2.0",
1515
"pillow>=11.0.0",
1616
"websockets==15.0.1",
17+
"requests>=2.32.0",
1718
]
1819

1920
[dependency-groups]

scripts/config/gitleaks.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ paths = [
2929
'''^venv/''',
3030
'''^ENV/''',
3131
'''^env/''',
32+
33+
# Build/cache artifacts
34+
'''__pycache__/''',
35+
'''^htmlcov/''',
3236
]
3337

3438
regexes = [
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Cloud DICOM uploader
3+
4+
Uploads DICOM files to the Manage Breast Screening HTTP API endpoint.
5+
"""
6+
7+
import io
8+
import logging
9+
import os
10+
from typing import Optional
11+
12+
import requests
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class DICOMUploader:
18+
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/")
20+
self.timeout = timeout
21+
self.verify_ssl = verify_ssl
22+
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 "",
29+
}
30+
31+
# Wrap bytes in BytesIO stream - Django expects a file-like object
32+
file_stream = io.BytesIO(dicom_bytes)
33+
files = {
34+
"file": (f"{sop_instance_uid}.dcm", file_stream),
35+
}
36+
37+
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+
)
42+
43+
response = requests.post(
44+
self.api_endpoint,
45+
files=files,
46+
headers=headers,
47+
timeout=self.timeout,
48+
verify=self.verify_ssl,
49+
)
50+
51+
if response.status_code in (200, 201, 204):
52+
logger.info(f"Successfully uploaded {sop_instance_uid} (status: {response.status_code})")
53+
return True
54+
else:
55+
logger.error(
56+
f"Upload failed for {sop_instance_uid}: status {response.status_code}, body: {response.text}"
57+
)
58+
return False
59+
60+
except requests.exceptions.Timeout:
61+
logger.error(f"Upload timeout for {sop_instance_uid} after {self.timeout}s")
62+
return False
63+
except requests.exceptions.RequestException as e:
64+
logger.error(f"Upload error for {sop_instance_uid}: {e}", exc_info=True)
65+
return False
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
DICOM upload listener service
3+
4+
Background service that polls the PACS database for stored DICOM images
5+
and uploads them to the Manage Breast Screening HTTP API endpoint.
6+
"""
7+
8+
import logging
9+
import time
10+
11+
from services.dicom.upload_processor import UploadProcessor
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class UploadListener:
17+
def __init__(
18+
self,
19+
processor: UploadProcessor,
20+
poll_interval: float = 2.0,
21+
batch_size: int = 10,
22+
):
23+
"""
24+
Initialize the upload listener.
25+
26+
Args:
27+
processor: UploadProcessor instance for handling uploads
28+
poll_interval: Base interval between polling cycles in seconds
29+
batch_size: Maximum number of uploads to process per cycle
30+
"""
31+
self.processor = processor
32+
self.poll_interval = poll_interval
33+
self.batch_size = batch_size
34+
self._running = False
35+
36+
def start(self):
37+
logger.info("Upload listener started")
38+
self._running = True
39+
40+
while self._running:
41+
try:
42+
self.processor.process_batch(limit=self.batch_size)
43+
44+
# Add backoff delay to poll interval when experiencing failures
45+
sleep_time = self.poll_interval + self.processor.backoff_delay
46+
time.sleep(sleep_time)
47+
48+
except Exception as e:
49+
logger.error(f"Error in upload listener: {e}", exc_info=True)
50+
time.sleep(self.poll_interval)
51+
52+
logger.info("Upload listener stopped")
53+
54+
def stop(self):
55+
logger.info("Stopping upload listener...")
56+
self._running = False

0 commit comments

Comments
 (0)