diff --git a/src/services/mwl/c_find.py b/src/services/mwl/c_find.py index ce1bda20..ef7ce0ed 100644 --- a/src/services/mwl/c_find.py +++ b/src/services/mwl/c_find.py @@ -39,6 +39,8 @@ def call(self, event: evt.Event) -> Iterator[Tuple[int, Dataset | None]]: logger.info(f"C-FIND request from {requestor_aet}") query_patient_id = identifier.get("PatientID") + anonymised_patient_id = f"*******{query_patient_id[7:]}" if query_patient_id else "None" + query_patient_name = identifier.get("PatientName") procedure_sequence = identifier.get("ScheduledProcedureStepSequence", [{}]) query_modality = procedure_sequence[0].get("Modality") @@ -46,6 +48,14 @@ def call(self, event: evt.Event) -> Iterator[Tuple[int, Dataset | None]]: query_accession_number = identifier.get("AccessionNumber") query_time = procedure_sequence[0].get("ScheduledProcedureStepStartTime") + logger.debug( + "Query parameters: modality=%s, date=%s, patient_id=%s, patient_name=%s", + query_modality, + query_date, + anonymised_patient_id, + query_patient_name, + ) + try: items = self.storage.find_worklist_items( accession_number=query_accession_number if query_accession_number else None, @@ -53,6 +63,7 @@ def call(self, event: evt.Event) -> Iterator[Tuple[int, Dataset | None]]: scheduled_date=query_date if query_date else None, scheduled_time=query_time if query_time else None, patient_id=query_patient_id if query_patient_id else None, + patient_name=str(query_patient_name) if query_patient_name else None, ) logger.info("Found %s matching worklist items", len(items)) diff --git a/src/services/storage.py b/src/services/storage.py index 5f563cf2..3028b7ed 100644 --- a/src/services/storage.py +++ b/src/services/storage.py @@ -369,6 +369,7 @@ def find_worklist_items( scheduled_date: Optional[str] = None, scheduled_time: Optional[str] = None, patient_id: Optional[str] = None, + patient_name: Optional[str] = None, ) -> List[WorklistItem]: """ Query worklist items with optional filters. @@ -379,6 +380,7 @@ def find_worklist_items( scheduled_date: Filter by scheduled date (YYYYMMDD, or range like "20240101-20240131") scheduled_time: Filter by scheduled time (HHMMSS, or range like "080000-170000") patient_id: Filter by patient ID + patient_name: Filter by patient name Returns: List of WorklistItem instances matching the criteria @@ -414,6 +416,15 @@ def find_worklist_items( where_clauses.append("patient_id = ?") params.append(patient_id) + if patient_name: + # Convert DICOM wildcards (* → %, ? → _) to SQL LIKE syntax. + sql_pattern = patient_name.replace("*", "%").replace("?", "_") + if sql_pattern == patient_name: # no wildcards were present + where_clauses.append("UPPER(patient_name) = UPPER(?)") + else: + where_clauses.append("UPPER(patient_name) LIKE UPPER(?)") + params.append(sql_pattern) + if where_clauses: query += " WHERE " + " AND ".join(where_clauses) diff --git a/tests/integration/test_mwl_storage_patient_name_search.py b/tests/integration/test_mwl_storage_patient_name_search.py new file mode 100644 index 00000000..11a14d3a --- /dev/null +++ b/tests/integration/test_mwl_storage_patient_name_search.py @@ -0,0 +1,71 @@ +""" +Integration-style tests for MWLStorage.find_worklist_items patient name filtering. + +These tests use a real SQLite database to verify that wildcard conversion and case-insensitive +matching work correctly in practice. +""" + +import pytest + +from services.storage import MWLStorage, WorklistItem + + +@pytest.fixture +def storage(tmp_dir): + return MWLStorage(f"{tmp_dir}/worklist.db") + + +@pytest.fixture(autouse=True) +def items(storage): + """Insert a small set of worklist items with varied names.""" + names = [ + "SMITH^SARITA", + "SMITH^JANE", + "JONES^SARITA", + "MÜLLER^DILMA", + ] + for i, name in enumerate(names): + storage.store_worklist_item( + WorklistItem( + accession_number=f"ACC{i:03d}", + patient_id=f"999000000{i}", + patient_name=name, + patient_birth_date="19800101", + scheduled_date="20260101", + scheduled_time="090000", + modality="MG", + ) + ) + return names + + +class TestPatientNameSearch: + def test_trailing_wildcard(self, storage): + results = storage.find_worklist_items(patient_name="SMITH*") + assert {r.patient_name for r in results} == {"SMITH^SARITA", "SMITH^JANE"} + + def test_wildcard_on_given_name(self, storage): + results = storage.find_worklist_items(patient_name="*SARITA") + assert {r.patient_name for r in results} == {"SMITH^SARITA", "JONES^SARITA"} + + def test_single_character_wildcard(self, storage): + results = storage.find_worklist_items(patient_name="SMITH^J?NE") + assert {r.patient_name for r in results} == {"SMITH^JANE"} + + def test_exact_match(self, storage): + results = storage.find_worklist_items(patient_name="JONES^SARITA") + assert len(results) == 1 + assert results[0].patient_name == "JONES^SARITA" + + def test_no_match(self, storage): + results = storage.find_worklist_items(patient_name="BROWN*") + assert results == [] + + def test_case_insensitive_match(self, storage): + results = storage.find_worklist_items(patient_name="smith*") + assert {r.patient_name for r in results} == {"SMITH^SARITA", "SMITH^JANE"} + + @pytest.mark.xfail(reason="SQLite's UPPER() is ASCII-only; non-ASCII case folding requires ICU compilation") + def test_case_insensitive_non_ascii(self, storage): + results = storage.find_worklist_items(patient_name="müller*") + assert {r.patient_name for r in results} == {"MÜLLER^DILMA"} diff --git a/tests/services/mwl/test_c_find.py b/tests/services/mwl/test_c_find.py index fd13eb92..b6488753 100644 --- a/tests/services/mwl/test_c_find.py +++ b/tests/services/mwl/test_c_find.py @@ -114,7 +114,12 @@ def test_call_with_accession_number_filter(self, handler, mock_storage, mock_eve list(handler.call(mock_event)) mock_storage.find_worklist_items.assert_called_once_with( - accession_number="ACC12345", modality=None, scheduled_date=None, scheduled_time=None, patient_id=None + accession_number="ACC12345", + modality=None, + scheduled_date=None, + scheduled_time=None, + patient_id=None, + patient_name=None, ) 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): list(handler.call(mock_event)) mock_storage.find_worklist_items.assert_called_once_with( - accession_number=None, modality="MG", scheduled_date=None, scheduled_time=None, patient_id=None + accession_number=None, + modality="MG", + scheduled_date=None, + scheduled_time=None, + patient_id=None, + patient_name=None, ) 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): list(handler.call(mock_event)) mock_storage.find_worklist_items.assert_called_once_with( - accession_number=None, modality=None, scheduled_date="20260107", scheduled_time=None, patient_id=None + accession_number=None, + modality=None, + scheduled_date="20260107", + scheduled_time=None, + patient_id=None, + patient_name=None, ) 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): list(handler.call(mock_event)) mock_storage.find_worklist_items.assert_called_once_with( - accession_number=None, modality=None, scheduled_date=None, scheduled_time="100000", patient_id=None + accession_number=None, + scheduled_time="100000", + modality=None, + scheduled_date=None, + patient_id=None, + patient_name=None, ) 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): list(handler.call(mock_event)) mock_storage.find_worklist_items.assert_called_once_with( - accession_number=None, modality=None, scheduled_date=None, scheduled_time=None, patient_id="9876543210" + accession_number=None, + modality=None, + scheduled_time=None, + scheduled_date=None, + patient_id="9876543210", + patient_name=None, + ) + + def test_call_with_patient_name_filter(self, handler, mock_storage, mock_event): + mock_event.identifier.PatientName = "Smith*" + mock_storage.find_worklist_items.return_value = [] + + list(handler.call(mock_event)) + + mock_storage.find_worklist_items.assert_called_once_with( + accession_number=None, + modality=None, + scheduled_date=None, + scheduled_time=None, + patient_id=None, + patient_name="Smith*", ) def test_call_handles_storage_exception(self, handler, mock_storage, mock_event): diff --git a/tests/services/test_storage.py b/tests/services/test_storage.py index 8627ee38..07474e3e 100644 --- a/tests/services/test_storage.py +++ b/tests/services/test_storage.py @@ -407,6 +407,51 @@ def test_find_worklist_items_with_date_and_time_range(self, mock_db, tmp_dir): ["20240101", "20240131", "090000", "170000"], ) + mock_connection.reset_mock() + subject.find_worklist_items(patient_name="Smith*") + + mock_connection.execute.assert_called_once_with( + ( + "SELECT accession_number, modality, patient_birth_date, patient_id, " + "patient_name, patient_sex, procedure_code, scheduled_date, scheduled_time, " + "source_message_id, study_description, study_instance_uid, status, mpps_instance_uid " + "FROM worklist_items WHERE UPPER(patient_name) LIKE UPPER(?) ORDER BY scheduled_date, scheduled_time" + ), + ["Smith%"], + ) + + @pytest.mark.parametrize( + "dicom_pattern, sql_pattern, operator", + [ + ("Smith*", "Smith%", "LIKE"), # trailing wildcard + ("*Smith*", "%Smith%", "LIKE"), # leading and trailing wildcard + ("Sm?th*", "Sm_th%", "LIKE"), # single-character wildcard combined with trailing + ("Smith^Jane", "Smith^Jane", "="), # exact name, no wildcards — uses = not LIKE + ], + ) + def test_find_worklist_items_patient_name_wildcard_conversion( + self, mock_db, tmp_dir, dicom_pattern, sql_pattern, operator + ): + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_connection = MagicMock() + mock_connection.execute.return_value = mock_cursor + mock_db.connect.return_value = mock_connection + subject = MWLStorage(tmp_dir) + mock_connection.reset_mock() + + subject.find_worklist_items(patient_name=dicom_pattern) + + mock_connection.execute.assert_called_once_with( + ( + "SELECT accession_number, modality, patient_birth_date, patient_id, " + "patient_name, patient_sex, procedure_code, scheduled_date, scheduled_time, " + "source_message_id, study_description, study_instance_uid, status, mpps_instance_uid " + f"FROM worklist_items WHERE UPPER(patient_name) {operator} UPPER(?) ORDER BY scheduled_date, scheduled_time" + ), + [sql_pattern], + ) + def test_get_worklist_item(self, mock_db, tmp_dir, result): mock_cursor = MagicMock() mock_cursor.fetchone.return_value = result