Skip to content

Commit ddb475d

Browse files
Allow MWL C-FIND filter by patient name
Including support for wildcard matching
1 parent 42e712e commit ddb475d

5 files changed

Lines changed: 144 additions & 4 deletions

File tree

src/services/mwl/c_find.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,26 @@ def call(self, event: evt.Event) -> Iterator[Tuple[int, Dataset | None]]:
4040

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

4445
procedure_sequence = identifier.get("ScheduledProcedureStepSequence", [{}])
4546
query_modality = procedure_sequence[0].get("Modality")
4647
query_date = procedure_sequence[0].get("ScheduledProcedureStepStartDate")
4748

4849
logger.debug(
49-
"Query parameters: modality=%s, date=%s, patient_id=%s", query_modality, query_date, anonymised_patient_id
50+
"Query parameters: modality=%s, date=%s, patient_id=%s, patient_name=%s",
51+
query_modality,
52+
query_date,
53+
anonymised_patient_id,
54+
query_patient_name,
5055
)
5156

5257
try:
5358
items = self.storage.find_worklist_items(
5459
modality=query_modality if query_modality else None,
5560
scheduled_date=query_date if query_date else None,
5661
patient_id=query_patient_id if query_patient_id else None,
62+
patient_name=str(query_patient_name) if query_patient_name else None,
5763
)
5864

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

src/services/storage.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ def find_worklist_items(
367367
modality: Optional[str] = None,
368368
scheduled_date: Optional[str] = None,
369369
patient_id: Optional[str] = None,
370+
patient_name: Optional[str] = None,
370371
) -> List[WorklistItem]:
371372
"""
372373
Query worklist items with optional filters.
@@ -375,6 +376,7 @@ def find_worklist_items(
375376
modality: Filter by modality (e.g., "MG")
376377
scheduled_date: Filter by scheduled date (YYYYMMDD)
377378
patient_id: Filter by patient ID
379+
patient_name: Filter by patient name
378380
379381
Returns:
380382
List of WorklistItem instances matching the criteria
@@ -400,6 +402,12 @@ def find_worklist_items(
400402
where_clauses.append("patient_id = ?")
401403
params.append(patient_id)
402404

405+
if patient_name:
406+
# Convert DICOM wildcards (* → %, ? → _) to SQL LIKE syntax.
407+
sql_pattern = patient_name.replace("*", "%").replace("?", "_")
408+
where_clauses.append("UPPER(patient_name) LIKE UPPER(?)")
409+
params.append(sql_pattern)
410+
403411
if where_clauses:
404412
query += " WHERE " + " AND ".join(where_clauses)
405413

tests/services/mwl/test_c_find.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ def test_call_with_modality_filter(self, handler, mock_storage, mock_event):
116116

117117
list(handler.call(mock_event))
118118

119-
mock_storage.find_worklist_items.assert_called_once_with(modality="MG", scheduled_date=None, patient_id=None)
119+
mock_storage.find_worklist_items.assert_called_once_with(
120+
modality="MG", scheduled_date=None, patient_id=None, patient_name=None
121+
)
120122

121123
def test_call_with_date_filter(self, handler, mock_storage, mock_event):
122124
sps_item = Dataset()
@@ -127,7 +129,7 @@ def test_call_with_date_filter(self, handler, mock_storage, mock_event):
127129
list(handler.call(mock_event))
128130

129131
mock_storage.find_worklist_items.assert_called_once_with(
130-
modality=None, scheduled_date="20260107", patient_id=None
132+
modality=None, scheduled_date="20260107", patient_id=None, patient_name=None
131133
)
132134

133135
def test_call_with_patient_id_filter(self, handler, mock_storage, mock_event):
@@ -137,7 +139,17 @@ def test_call_with_patient_id_filter(self, handler, mock_storage, mock_event):
137139
list(handler.call(mock_event))
138140

139141
mock_storage.find_worklist_items.assert_called_once_with(
140-
modality=None, scheduled_date=None, patient_id="9876543210"
142+
modality=None, scheduled_date=None, patient_id="9876543210", patient_name=None
143+
)
144+
145+
def test_call_with_patient_name_filter(self, handler, mock_storage, mock_event):
146+
mock_event.identifier.PatientName = "Smith*"
147+
mock_storage.find_worklist_items.return_value = []
148+
149+
list(handler.call(mock_event))
150+
151+
mock_storage.find_worklist_items.assert_called_once_with(
152+
modality=None, scheduled_date=None, patient_id=None, patient_name="Smith*"
141153
)
142154

143155
def test_call_handles_storage_exception(self, handler, mock_storage, mock_event):
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/test_storage.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,49 @@ def test_find_worklist_items_with_filters(self, mock_db, tmp_dir):
283283
["MG", "20240101", "999123456"],
284284
)
285285

286+
mock_connection.reset_mock()
287+
subject.find_worklist_items(patient_name="Smith*")
288+
289+
mock_connection.execute.assert_called_once_with(
290+
(
291+
"SELECT accession_number, modality, patient_birth_date, patient_id, "
292+
"patient_name, patient_sex, procedure_code, scheduled_date, scheduled_time, "
293+
"source_message_id, study_description, study_instance_uid, status, mpps_instance_uid "
294+
"FROM worklist_items WHERE UPPER(patient_name) LIKE UPPER(?) ORDER BY scheduled_date, scheduled_time"
295+
),
296+
["Smith%"],
297+
)
298+
299+
@pytest.mark.parametrize(
300+
"dicom_pattern, sql_pattern",
301+
[
302+
("Smith*", "Smith%"), # trailing wildcard
303+
("*Smith*", "%Smith%"), # leading and trailing wildcard
304+
("Sm?th*", "Sm_th%"), # single-character wildcard combined with trailing
305+
("Smith^Jane", "Smith^Jane"), # exact name, no wildcards
306+
],
307+
)
308+
def test_find_worklist_items_patient_name_wildcard_conversion(self, mock_db, tmp_dir, dicom_pattern, sql_pattern):
309+
mock_cursor = MagicMock()
310+
mock_cursor.fetchall.return_value = []
311+
mock_connection = MagicMock()
312+
mock_connection.execute.return_value = mock_cursor
313+
mock_db.connect.return_value = mock_connection
314+
subject = MWLStorage(tmp_dir)
315+
mock_connection.reset_mock()
316+
317+
subject.find_worklist_items(patient_name=dicom_pattern)
318+
319+
mock_connection.execute.assert_called_once_with(
320+
(
321+
"SELECT accession_number, modality, patient_birth_date, patient_id, "
322+
"patient_name, patient_sex, procedure_code, scheduled_date, scheduled_time, "
323+
"source_message_id, study_description, study_instance_uid, status, mpps_instance_uid "
324+
"FROM worklist_items WHERE UPPER(patient_name) LIKE UPPER(?) ORDER BY scheduled_date, scheduled_time"
325+
),
326+
[sql_pattern],
327+
)
328+
286329
def test_get_worklist_item(self, mock_db, tmp_dir, result):
287330
mock_cursor = MagicMock()
288331
mock_cursor.fetchone.return_value = result

0 commit comments

Comments
 (0)