Skip to content

Commit a671187

Browse files
Merge pull request #953 from NHSDigital/feat/gateway-action-model
Add gateway app with GatewayAction model and WorklistItemService
2 parents 7ce1730 + 29261b8 commit a671187

File tree

11 files changed

+292
-0
lines changed

11 files changed

+292
-0
lines changed

.tool-versions

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
gitleaks 8.18.4
12
nodejs 24.11.0
23
pre-commit 3.6.0
34
python 3.14.0

manage_breast_screening/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def list_env(key):
8585
"manage_breast_screening.participants",
8686
"manage_breast_screening.mammograms",
8787
"manage_breast_screening.manual_images",
88+
"manage_breast_screening.gateway",
8889
"rules.apps.AutodiscoverRulesConfig",
8990
]
9091

manage_breast_screening/gateway/__init__.py

Whitespace-only changes.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class GatewayConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "manage_breast_screening.gateway"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 6.0.1 on 2026-01-28 15:29
2+
3+
import django.db.models.deletion
4+
import uuid
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
initial = True
11+
12+
dependencies = [
13+
('participants', '0057_alter_appointmentstatus_unique_together_and_more'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='GatewayAction',
19+
fields=[
20+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
21+
('created_at', models.DateTimeField(auto_now_add=True)),
22+
('updated_at', models.DateTimeField(auto_now=True)),
23+
('type', models.CharField(choices=[('worklist.create_item', 'Create Worklist Item')], max_length=50)),
24+
('payload', models.JSONField()),
25+
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('confirmed', 'Confirmed'), ('failed', 'Failed')], default='pending', max_length=20)),
26+
('accession_number', models.CharField(db_index=True, max_length=100, unique=True)),
27+
('sent_at', models.DateTimeField(blank=True, null=True)),
28+
('confirmed_at', models.DateTimeField(blank=True, null=True)),
29+
('retry_count', models.IntegerField(default=0)),
30+
('next_retry_at', models.DateTimeField(blank=True, null=True)),
31+
('last_error', models.TextField(blank=True)),
32+
('appointment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='gateway_actions', to='participants.appointment')),
33+
],
34+
options={
35+
'ordering': ['-created_at'],
36+
'indexes': [models.Index(fields=['status', 'next_retry_at'], name='gateway_gat_status_e73bd1_idx')],
37+
},
38+
),
39+
]

manage_breast_screening/gateway/migrations/__init__.py

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
0001_create_gateway_action
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Gateway action tracking models."""
2+
3+
from django.db import models
4+
5+
from manage_breast_screening.core.models import BaseModel
6+
7+
8+
class GatewayActionStatus(models.TextChoices):
9+
PENDING = "pending", "Pending"
10+
SENT = "sent", "Sent"
11+
CONFIRMED = "confirmed", "Confirmed"
12+
FAILED = "failed", "Failed"
13+
14+
15+
class GatewayActionType(models.TextChoices):
16+
WORKLIST_CREATE = "worklist.create_item", "Create Worklist Item"
17+
18+
19+
class GatewayAction(BaseModel):
20+
"""Tracks actions sent to the screening gateway."""
21+
22+
appointment = models.ForeignKey(
23+
"participants.Appointment",
24+
on_delete=models.PROTECT,
25+
related_name="gateway_actions",
26+
)
27+
type = models.CharField(max_length=50, choices=GatewayActionType.choices)
28+
payload = models.JSONField()
29+
status = models.CharField(
30+
max_length=20,
31+
choices=GatewayActionStatus.choices,
32+
default=GatewayActionStatus.PENDING,
33+
)
34+
35+
accession_number = models.CharField(max_length=100, unique=True, db_index=True)
36+
37+
sent_at = models.DateTimeField(null=True, blank=True)
38+
confirmed_at = models.DateTimeField(null=True, blank=True)
39+
40+
retry_count = models.IntegerField(default=0)
41+
next_retry_at = models.DateTimeField(null=True, blank=True)
42+
last_error = models.TextField(blank=True)
43+
44+
class Meta:
45+
ordering = ["-created_at"]
46+
indexes = [
47+
models.Index(fields=["status", "next_retry_at"]),
48+
]
49+
50+
def __str__(self):
51+
return f"{self.type} - {self.accession_number} ({self.status})"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Gateway action services."""
2+
3+
import logging
4+
import uuid
5+
from datetime import datetime, timezone
6+
7+
from manage_breast_screening.participants.models import Appointment
8+
9+
from .models import GatewayAction, GatewayActionStatus, GatewayActionType
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class WorklistItemService:
15+
"""Service for creating worklist item actions for the gateway."""
16+
17+
def __init__(self, appointment: Appointment):
18+
self.appointment = appointment
19+
self.participant = appointment.participant
20+
21+
@classmethod
22+
def create(cls, appointment: Appointment) -> GatewayAction:
23+
"""
24+
Create a worklist item action for the given appointment.
25+
26+
Returns the GatewayAction with status PENDING.
27+
The action will be picked up and sent by the relay sender service.
28+
"""
29+
service = cls(appointment)
30+
return service._create_action()
31+
32+
def _generate_accession_number(self) -> str:
33+
"""Generate unique accession number (max 16 chars per DICOM SH limit)."""
34+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d")
35+
random_suffix = uuid.uuid4().hex[:4].upper()
36+
return f"ACC{timestamp}{random_suffix}"
37+
38+
def _build_payload(self, action_id: uuid.UUID, accession_number: str) -> dict:
39+
"""Build the worklist create payload."""
40+
participant = self.participant
41+
appointment = self.appointment
42+
43+
# Format name: LAST^FIRST
44+
participant_name = (
45+
f"{participant.last_name.upper()}^{participant.first_name.upper()}"
46+
)
47+
48+
scheduled_datetime = appointment.clinic_slot.starts_at
49+
scheduled_date = scheduled_datetime.strftime("%Y%m%d")
50+
scheduled_time = scheduled_datetime.strftime("%H%M%S")
51+
52+
return {
53+
"schema_version": 1,
54+
"action_id": str(action_id),
55+
"action_type": GatewayActionType.WORKLIST_CREATE,
56+
"timestamp": datetime.now(timezone.utc).isoformat(),
57+
"source_system": "manage-breast-screening",
58+
"source_reference": {
59+
"appointment_id": str(appointment.pk),
60+
"participant_id": participant.nhs_number,
61+
},
62+
"parameters": {
63+
"worklist_item": {
64+
"accession_number": accession_number,
65+
"participant": {
66+
"nhs_number": participant.nhs_number,
67+
"name": participant_name,
68+
"birth_date": participant.date_of_birth.strftime("%Y%m%d"),
69+
"sex": participant.gender[0].upper()
70+
if participant.gender
71+
else "F",
72+
},
73+
"scheduled": {
74+
"date": scheduled_date,
75+
"time": scheduled_time,
76+
},
77+
"procedure": {
78+
"modality": "MG",
79+
"study_description": "Screening Mammography",
80+
},
81+
}
82+
},
83+
}
84+
85+
def _create_action(self) -> GatewayAction:
86+
"""Create and persist the gateway action."""
87+
action_id = uuid.uuid4()
88+
accession_number = self._generate_accession_number()
89+
payload = self._build_payload(action_id, accession_number)
90+
91+
action = GatewayAction.objects.create(
92+
id=action_id,
93+
appointment=self.appointment,
94+
type=GatewayActionType.WORKLIST_CREATE,
95+
accession_number=accession_number,
96+
payload=payload,
97+
status=GatewayActionStatus.PENDING,
98+
)
99+
100+
logger.info(
101+
f"Created gateway action {action_id} for appointment {self.appointment.pk}"
102+
)
103+
return action

manage_breast_screening/gateway/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)