Skip to content

Commit 73b643c

Browse files
Allow MWL C-FIND filter by patient name
Including support for wildcard matching
1 parent 122f021 commit 73b643c

5 files changed

Lines changed: 183 additions & 5 deletions

File tree

src/services/mwl/c_find.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,31 @@ def call(self, event: evt.Event) -> Iterator[Tuple[int, Dataset | None]]:
3939
logger.info(f"C-FIND request from {requestor_aet}")
4040

4141
query_patient_id = identifier.get("PatientID")
42+
anonymised_patient_id = f"*******{query_patient_id[7:]}" if query_patient_id else "None"
43+
query_patient_name = identifier.get("PatientName")
4244

4345
procedure_sequence = identifier.get("ScheduledProcedureStepSequence", [{}])
4446
query_modality = procedure_sequence[0].get("Modality")
4547
query_date = procedure_sequence[0].get("ScheduledProcedureStepStartDate")
4648
query_accession_number = identifier.get("AccessionNumber")
4749
query_time = procedure_sequence[0].get("ScheduledProcedureStepStartTime")
4850

51+
logger.debug(
52+
"Query parameters: modality=%s, date=%s, patient_id=%s, patient_name=%s",
53+
query_modality,
54+
query_date,
55+
anonymised_patient_id,
56+
query_patient_name,
57+
)
58+
4959
try:
5060
items = self.storage.find_worklist_items(
5161
accession_number=query_accession_number if query_accession_number else None,
5262
modality=query_modality if query_modality else None,
5363
scheduled_date=query_date if query_date else None,
5464
scheduled_time=query_time if query_time else None,
5565
patient_id=query_patient_id if query_patient_id else None,
66+
patient_name=str(query_patient_name) if query_patient_name else None,
5667
)
5768

5869
logger.info("Found %s matching worklist items", len(items))

src/services/storage.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ def find_worklist_items(
369369
scheduled_date: Optional[str] = None,
370370
scheduled_time: Optional[str] = None,
371371
patient_id: Optional[str] = None,
372+
patient_name: Optional[str] = None,
372373
) -> List[WorklistItem]:
373374
"""
374375
Query worklist items with optional filters.
@@ -379,6 +380,7 @@ def find_worklist_items(
379380
scheduled_date: Filter by scheduled date (YYYYMMDD, or range like "20240101-20240131")
380381
scheduled_time: Filter by scheduled time (HHMMSS, or range like "080000-170000")
381382
patient_id: Filter by patient ID
383+
patient_name: Filter by patient name
382384
383385
Returns:
384386
List of WorklistItem instances matching the criteria
@@ -414,6 +416,15 @@ def find_worklist_items(
414416
where_clauses.append("patient_id = ?")
415417
params.append(patient_id)
416418

419+
if patient_name:
420+
# Convert DICOM wildcards (* → %, ? → _) to SQL LIKE syntax.
421+
sql_pattern = patient_name.replace("*", "%").replace("?", "_")
422+
if sql_pattern == patient_name: # no wildcards were present
423+
where_clauses.append("UPPER(patient_name) = UPPER(?)")
424+
else:
425+
where_clauses.append("UPPER(patient_name) LIKE UPPER(?)")
426+
params.append(sql_pattern)
427+
417428
if where_clauses:
418429
query += " WHERE " + " AND ".join(where_clauses)
419430

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
Integration-style tests for MWLStorage.find_worklist_items patient name filtering.
3+
4+
These tests use a real SQLite database to verify that wildcard conversion and case-insensitive
5+
matching work correctly in practice.
6+
"""
7+
8+
import pytest
9+
10+
from services.storage import MWLStorage, WorklistItem
11+
12+
13+
@pytest.fixture
14+
def storage(tmp_dir):
15+
return MWLStorage(f"{tmp_dir}/worklist.db")
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def items(storage):
20+
"""Insert a small set of worklist items with varied names."""
21+
names = [
22+
"SMITH^SARITA",
23+
"SMITH^JANE",
24+
"JONES^SARITA",
25+
"MÜLLER^DILMA",
26+
]
27+
for i, name in enumerate(names):
28+
storage.store_worklist_item(
29+
WorklistItem(
30+
accession_number=f"ACC{i:03d}",
31+
patient_id=f"999000000{i}",
32+
patient_name=name,
33+
patient_birth_date="19800101",
34+
scheduled_date="20260101",
35+
scheduled_time="090000",
36+
modality="MG",
37+
)
38+
)
39+
return names
40+
41+
42+
class TestPatientNameSearch:
43+
def test_trailing_wildcard(self, storage):
44+
results = storage.find_worklist_items(patient_name="SMITH*")
45+
assert {r.patient_name for r in results} == {"SMITH^SARITA", "SMITH^JANE"}
46+
47+
def test_wildcard_on_given_name(self, storage):
48+
results = storage.find_worklist_items(patient_name="*SARITA")
49+
assert {r.patient_name for r in results} == {"SMITH^SARITA", "JONES^SARITA"}
50+
51+
def test_single_character_wildcard(self, storage):
52+
results = storage.find_worklist_items(patient_name="SMITH^J?NE")
53+
assert {r.patient_name for r in results} == {"SMITH^JANE"}
54+
55+
def test_exact_match(self, storage):
56+
results = storage.find_worklist_items(patient_name="JONES^SARITA")
57+
assert len(results) == 1
58+
assert results[0].patient_name == "JONES^SARITA"
59+
60+
def test_no_match(self, storage):
61+
results = storage.find_worklist_items(patient_name="BROWN*")
62+
assert results == []
63+
64+
def test_case_insensitive_match(self, storage):
65+
results = storage.find_worklist_items(patient_name="smith*")
66+
assert {r.patient_name for r in results} == {"SMITH^SARITA", "SMITH^JANE"}
67+
68+
@pytest.mark.xfail(reason="SQLite's UPPER() is ASCII-only; non-ASCII case folding requires ICU compilation")
69+
def test_case_insensitive_non_ascii(self, storage):
70+
results = storage.find_worklist_items(patient_name="müller*")
71+
assert {r.patient_name for r in results} == {"MÜLLER^DILMA"}

tests/services/mwl/test_c_find.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ def test_call_with_accession_number_filter(self, handler, mock_storage, mock_eve
114114
list(handler.call(mock_event))
115115

116116
mock_storage.find_worklist_items.assert_called_once_with(
117-
accession_number="ACC12345", modality=None, scheduled_date=None, scheduled_time=None, patient_id=None
117+
accession_number="ACC12345",
118+
modality=None,
119+
scheduled_date=None,
120+
scheduled_time=None,
121+
patient_id=None,
122+
patient_name=None,
118123
)
119124

120125
def test_call_with_modality_filter(self, handler, mock_storage, mock_event):
@@ -127,7 +132,12 @@ def test_call_with_modality_filter(self, handler, mock_storage, mock_event):
127132
list(handler.call(mock_event))
128133

129134
mock_storage.find_worklist_items.assert_called_once_with(
130-
accession_number=None, modality="MG", scheduled_date=None, scheduled_time=None, patient_id=None
135+
accession_number=None,
136+
modality="MG",
137+
scheduled_date=None,
138+
scheduled_time=None,
139+
patient_id=None,
140+
patient_name=None,
131141
)
132142

133143
def test_call_with_date_filter(self, handler, mock_storage, mock_event):
@@ -139,7 +149,12 @@ def test_call_with_date_filter(self, handler, mock_storage, mock_event):
139149
list(handler.call(mock_event))
140150

141151
mock_storage.find_worklist_items.assert_called_once_with(
142-
accession_number=None, modality=None, scheduled_date="20260107", scheduled_time=None, patient_id=None
152+
accession_number=None,
153+
modality=None,
154+
scheduled_date="20260107",
155+
scheduled_time=None,
156+
patient_id=None,
157+
patient_name=None,
143158
)
144159

145160
def test_call_with_time_filter(self, handler, mock_storage, mock_event):
@@ -151,7 +166,12 @@ def test_call_with_time_filter(self, handler, mock_storage, mock_event):
151166
list(handler.call(mock_event))
152167

153168
mock_storage.find_worklist_items.assert_called_once_with(
154-
accession_number=None, modality=None, scheduled_date=None, scheduled_time="100000", patient_id=None
169+
accession_number=None,
170+
scheduled_time="100000",
171+
modality=None,
172+
scheduled_date=None,
173+
patient_id=None,
174+
patient_name=None,
155175
)
156176

157177
def test_call_with_patient_id_filter(self, handler, mock_storage, mock_event):
@@ -161,7 +181,27 @@ def test_call_with_patient_id_filter(self, handler, mock_storage, mock_event):
161181
list(handler.call(mock_event))
162182

163183
mock_storage.find_worklist_items.assert_called_once_with(
164-
accession_number=None, modality=None, scheduled_date=None, scheduled_time=None, patient_id="9876543210"
184+
accession_number=None,
185+
modality=None,
186+
scheduled_time=None,
187+
scheduled_date=None,
188+
patient_id="9876543210",
189+
patient_name=None,
190+
)
191+
192+
def test_call_with_patient_name_filter(self, handler, mock_storage, mock_event):
193+
mock_event.identifier.PatientName = "Smith*"
194+
mock_storage.find_worklist_items.return_value = []
195+
196+
list(handler.call(mock_event))
197+
198+
mock_storage.find_worklist_items.assert_called_once_with(
199+
accession_number=None,
200+
modality=None,
201+
scheduled_date=None,
202+
scheduled_time=None,
203+
patient_id=None,
204+
patient_name="Smith*",
165205
)
166206

167207
def test_call_handles_storage_exception(self, handler, mock_storage, mock_event):

tests/services/test_storage.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,51 @@ def test_find_worklist_items_with_date_and_time_range(self, mock_db, tmp_dir):
407407
["20240101", "20240131", "090000", "170000"],
408408
)
409409

410+
mock_connection.reset_mock()
411+
subject.find_worklist_items(patient_name="Smith*")
412+
413+
mock_connection.execute.assert_called_once_with(
414+
(
415+
"SELECT accession_number, modality, patient_birth_date, patient_id, "
416+
"patient_name, patient_sex, procedure_code, scheduled_date, scheduled_time, "
417+
"source_message_id, study_description, study_instance_uid, status, mpps_instance_uid "
418+
"FROM worklist_items WHERE UPPER(patient_name) LIKE UPPER(?) ORDER BY scheduled_date, scheduled_time"
419+
),
420+
["Smith%"],
421+
)
422+
423+
@pytest.mark.parametrize(
424+
"dicom_pattern, sql_pattern, operator",
425+
[
426+
("Smith*", "Smith%", "LIKE"), # trailing wildcard
427+
("*Smith*", "%Smith%", "LIKE"), # leading and trailing wildcard
428+
("Sm?th*", "Sm_th%", "LIKE"), # single-character wildcard combined with trailing
429+
("Smith^Jane", "Smith^Jane", "="), # exact name, no wildcards — uses = not LIKE
430+
],
431+
)
432+
def test_find_worklist_items_patient_name_wildcard_conversion(
433+
self, mock_db, tmp_dir, dicom_pattern, sql_pattern, operator
434+
):
435+
mock_cursor = MagicMock()
436+
mock_cursor.fetchall.return_value = []
437+
mock_connection = MagicMock()
438+
mock_connection.execute.return_value = mock_cursor
439+
mock_db.connect.return_value = mock_connection
440+
subject = MWLStorage(tmp_dir)
441+
mock_connection.reset_mock()
442+
443+
subject.find_worklist_items(patient_name=dicom_pattern)
444+
445+
mock_connection.execute.assert_called_once_with(
446+
(
447+
"SELECT accession_number, modality, patient_birth_date, patient_id, "
448+
"patient_name, patient_sex, procedure_code, scheduled_date, scheduled_time, "
449+
"source_message_id, study_description, study_instance_uid, status, mpps_instance_uid "
450+
f"FROM worklist_items WHERE UPPER(patient_name) {operator} UPPER(?) ORDER BY scheduled_date, scheduled_time"
451+
),
452+
[sql_pattern],
453+
)
454+
410455
def test_get_worklist_item(self, mock_db, tmp_dir, result):
411456
mock_cursor = MagicMock()
412457
mock_cursor.fetchone.return_value = result

0 commit comments

Comments
 (0)