Skip to content

Commit fcb6130

Browse files
Add iacuc function to utils (#480)
* Added a function for fetching the protocol ID from labtracks * Update docstring to note that dataverse will have info in the future * Allow function to take int * Using new protocol_number field instead of parsing group field * Docstring fix * Simplify function
1 parent dbfaa6e commit fcb6130

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

src/aind_metadata_mapper/utils.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
INSTRUMENT_BASE_URL = "http://aind-metadata-service/api/v2/instrument"
2020
PROCEDURES_BASE_URL = "http://aind-metadata-service/api/v2/procedures"
2121
SUBJECT_BASE_URL = "http://aind-metadata-service/api/v2/subject"
22+
LABTRACKS_SUBJECT_BASE_URL = "http://aind-metadata-service/api/v2/labtracks/subject"
2223

2324

2425
def normalize_utc_timezone(dt: str) -> str:
@@ -173,6 +174,46 @@ def get_procedures(subject_id: str, base_url: str = PROCEDURES_BASE_URL, timeout
173174
return None
174175

175176

177+
def get_iacuc_protocol(subject_id: str | int, base_url: str = LABTRACKS_SUBJECT_BASE_URL) -> Optional[str]:
178+
"""Fetch a subject's current IACUC protocol number from LabTracks.
179+
180+
LabTracks is the regulatory source of truth for which protocol a mouse is on. The
181+
metadata service exposes the LabTracks subject record, which includes a
182+
``protocol_number`` field (e.g. "2414") joined directly from the LabTracks
183+
``IacucProtocol`` table.
184+
185+
Note that, in the future, this information will also be tracked in DataVerse.
186+
This function could be updated to pull from there in the future if desired.
187+
188+
Parameters
189+
----------
190+
subject_id : str or int
191+
The subject ID to query. An int is cast to str automatically.
192+
base_url : str
193+
Base URL for the LabTracks subject endpoint. Defaults to LABTRACKS_SUBJECT_BASE_URL.
194+
195+
Returns
196+
-------
197+
Optional[str]
198+
The IACUC protocol number (e.g. "2414"), or None if the subject is not in
199+
LabTracks, has no associated protocol, or the request fails.
200+
"""
201+
try:
202+
subject_id = str(subject_id)
203+
# This endpoint takes subject_id as a query parameter, not a path segment.
204+
url = f"{base_url}?subject_id={subject_id}"
205+
records = metadata_service_helper(url)
206+
if not records:
207+
logger.warning(f"Could not fetch LabTracks subject {subject_id}")
208+
return None
209+
records = records if isinstance(records, list) else [records]
210+
protocol_number = records[0].get("protocol_number")
211+
return str(protocol_number) if protocol_number else None
212+
except Exception as e:
213+
logger.warning(f"Unexpected error fetching IACUC protocol for subject {subject_id}: {e}")
214+
return None
215+
216+
176217
def get_intended_measurements(
177218
subject_id: str,
178219
base_url: str = "http://aind-metadata-service/intended_measurements",

tests/test_utils.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
check_existing_instrument,
2424
check_instrument_id,
2525
ensure_timezone,
26+
get_iacuc_protocol,
2627
get_instrument,
2728
get_intended_measurements,
2829
get_procedures,
@@ -639,5 +640,63 @@ def input_func(prompt):
639640
self.assertTrue(any("Help" in str(call) for call in mock_print.call_args_list))
640641

641642

643+
class TestGetIacucProtocol(unittest.TestCase):
644+
"""Tests for get_iacuc_protocol function."""
645+
646+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
647+
def test_returns_protocol_number_field(self, mock_helper):
648+
"""The protocol_number field (joined from the IacucProtocol table) is returned."""
649+
mock_helper.return_value = [{"group_name": "Exp-ND-01-001-2414", "protocol_number": "2414"}]
650+
self.assertEqual(get_iacuc_protocol("818908"), "2414")
651+
652+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
653+
def test_accepts_int_subject_id(self, mock_helper):
654+
"""An int subject_id is cast to str and handled."""
655+
mock_helper.return_value = [{"protocol_number": "2414"}]
656+
self.assertEqual(get_iacuc_protocol(818908), "2414")
657+
658+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
659+
def test_returns_protocol_when_group_name_lacks_number(self, mock_helper):
660+
"""A breeding-group name with no trailing number still resolves via protocol_number."""
661+
mock_helper.return_value = [{"group_name": "Pdyn-IRES-Cre;Oi9(ND)", "protocol_number": "2453"}]
662+
self.assertEqual(get_iacuc_protocol("x"), "2453")
663+
664+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
665+
def test_numeric_protocol_number_is_coerced_to_str(self, mock_helper):
666+
"""A non-string protocol_number is coerced to str."""
667+
mock_helper.return_value = [{"protocol_number": 2414}]
668+
self.assertEqual(get_iacuc_protocol("x"), "2414")
669+
670+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
671+
def test_null_protocol_number_returns_none(self, mock_helper):
672+
"""A record with a null protocol_number yields None."""
673+
mock_helper.return_value = [{"group_name": "Practice Mice", "protocol_number": None}]
674+
self.assertIsNone(get_iacuc_protocol("x"))
675+
676+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
677+
def test_missing_protocol_number_returns_none(self, mock_helper):
678+
"""A record without a protocol_number yields None."""
679+
mock_helper.return_value = [{}]
680+
self.assertIsNone(get_iacuc_protocol("x"))
681+
682+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
683+
def test_no_records_returns_none(self, mock_helper):
684+
"""No records (helper returns None) yields None."""
685+
mock_helper.return_value = None
686+
self.assertIsNone(get_iacuc_protocol("x"))
687+
688+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
689+
def test_dict_error_body_returns_none(self, mock_helper):
690+
"""A non-list error body (e.g. {"detail": "Not Found"}) yields None."""
691+
mock_helper.return_value = {"detail": "Not Found"}
692+
self.assertIsNone(get_iacuc_protocol("x"))
693+
694+
@patch("aind_metadata_mapper.utils.metadata_service_helper")
695+
def test_exception_returns_none(self, mock_helper):
696+
"""An unexpected error is caught and yields None."""
697+
mock_helper.side_effect = ValueError("boom")
698+
self.assertIsNone(get_iacuc_protocol("x"))
699+
700+
642701
if __name__ == "__main__":
643702
unittest.main()

0 commit comments

Comments
 (0)