Skip to content

Commit 36f6cda

Browse files
committed
eli-579 allowing custom target field names and source fields
1 parent cf832bf commit 36f6cda

6 files changed

Lines changed: 159 additions & 11 deletions

File tree

src/eligibility_signposting_api/services/processors/derived_values/add_days_handler.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,25 @@ def __init__(
4848
self.default_days = default_days
4949
self.vaccine_type_days = vaccine_type_days or {}
5050

51-
def get_source_attribute(self, target_attribute: str) -> str:
51+
def get_source_attribute(self, target_attribute: str, function_args: str | None = None) -> str:
5252
"""Get the source attribute for a derived attribute.
5353
54+
Check if source is provided in function args (e.g., ADD_DAYS(91, SOURCE_FIELD)).
55+
If not, fall back to mapping or return target_attribute as default.
56+
5457
Args:
5558
target_attribute: The derived attribute name (e.g., 'NEXT_DOSE_DUE')
59+
function_args: Optional arguments from token (e.g., '91, LAST_SUCCESSFUL_DATE')
5660
5761
Returns:
5862
The source attribute name (e.g., 'LAST_SUCCESSFUL_DATE')
5963
"""
64+
if function_args and "," in function_args:
65+
# Extract source from args if present (second argument)
66+
parts = [p.strip() for p in function_args.split(",")]
67+
if len(parts) > 1 and parts[1]:
68+
return parts[1].upper()
69+
6070
return self.DERIVED_ATTRIBUTE_SOURCES.get(target_attribute, target_attribute)
6171

6272
def calculate(self, context: DerivedValueContext) -> str:
@@ -120,11 +130,13 @@ def _get_days_to_add(self, context: DerivedValueContext) -> int:
120130
"""
121131
# Priority 1: Token argument (if non-empty)
122132
if context.function_args:
123-
try:
124-
return int(context.function_args)
125-
except ValueError as e:
126-
message = f"Invalid days argument '{context.function_args}' for ADD_DAYS function. Expected an integer."
127-
raise ValueError(message) from e
133+
args = context.function_args.split(",")[0].strip()
134+
if args:
135+
try:
136+
return int(args)
137+
except ValueError as e:
138+
message = f"Invalid days argument '{args}' for ADD_DAYS function. Expected an integer."
139+
raise ValueError(message) from e
128140

129141
# Priority 2: Vaccine-specific configuration
130142
if context.attribute_name in self.vaccine_type_days:

src/eligibility_signposting_api/services/processors/derived_values/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,14 @@ def calculate(self, context: DerivedValueContext) -> str:
5353
"""
5454

5555
@abstractmethod
56-
def get_source_attribute(self, target_attribute: str) -> str:
56+
def get_source_attribute(self, target_attribute: str, function_args: str | None = None) -> str:
5757
"""Get the source attribute name needed for this derived value.
5858
5959
For example, NEXT_DOSE_DUE derives from LAST_SUCCESSFUL_DATE.
6060
6161
Args:
6262
target_attribute: The target derived attribute name (e.g., 'NEXT_DOSE_DUE')
63+
function_args: Optional arguments from the token function call
6364
6465
Returns:
6566
The source attribute name to use for calculation

src/eligibility_signposting_api/services/processors/derived_values/registry.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,24 +104,26 @@ def is_derived_attribute(self, attribute_value: str) -> bool:
104104
True if any handler can derive this attribute
105105
"""
106106
for handler in self._handlers.values():
107-
source = handler.get_source_attribute(attribute_value)
107+
# Pass None for function_args as we're just checking capability
108+
source = handler.get_source_attribute(attribute_value, function_args=None)
108109
if source != attribute_value:
109110
return True
110111
return False
111112

112-
def get_source_attribute(self, function_name: str, target_attribute: str) -> str:
113+
def get_source_attribute(self, function_name: str, target_attribute: str, function_args: str | None = None) -> str:
113114
"""Get the source attribute for a derived attribute.
114115
115116
Args:
116117
function_name: The function name of the handler
117118
target_attribute: The target derived attribute
119+
function_args: Optional arguments from the token function call
118120
119121
Returns:
120122
The source attribute name, or the target if no handler found
121123
"""
122124
handler = self.get_handler(function_name)
123125
if handler:
124-
return handler.get_source_attribute(target_attribute)
126+
return handler.get_source_attribute(target_attribute, function_args)
125127
return target_attribute
126128

127129
def calculate(

src/eligibility_signposting_api/services/processors/token_processor.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,11 @@ def get_derived_value(
153153

154154
try:
155155
target_attribute = parsed_token.attribute_value or parsed_token.attribute_name
156-
source_attribute = registry.get_source_attribute(function_name, target_attribute)
156+
source_attribute = registry.get_source_attribute(
157+
function_name,
158+
target_attribute,
159+
parsed_token.function_args,
160+
)
157161

158162
context = DerivedValueContext(
159163
person_data=person_data,

tests/unit/services/processors/test_derived_values.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,93 @@ def test_calculate_with_function_args_override(self):
4444
# 2025-01-01 + 30 days = 2025-01-31
4545
assert_that(result, is_(equal_to("20250131")))
4646

47+
def test_get_source_attribute_from_args(self):
48+
"""Test that source attribute is extracted from function args."""
49+
handler = AddDaysHandler()
50+
51+
# Test with source attribute provided
52+
source = handler.get_source_attribute("target", "91, CUSTOM_SOURCE")
53+
assert_that(source, is_(equal_to("CUSTOM_SOURCE")))
54+
55+
# Test with only days provided (should fallback to default mapping or target)
56+
source = handler.get_source_attribute("NEXT_DOSE_DUE", "91")
57+
assert_that(source, is_(equal_to("LAST_SUCCESSFUL_DATE")))
58+
59+
# Test with empty args
60+
source = handler.get_source_attribute("NEXT_DOSE_DUE", None)
61+
assert_that(source, is_(equal_to("LAST_SUCCESSFUL_DATE")))
62+
63+
def test_calculate_with_args_source_override(self):
64+
"""Test calculation with source attribute in args."""
65+
handler = AddDaysHandler(default_days=91)
66+
# Note: In the real flow, get_source_attribute is called before context creation
67+
# to set source_attribute in the context. Checking if calculate handles the complex args correctly
68+
# for days parsing.
69+
70+
context = DerivedValueContext(
71+
person_data=[{"ATTRIBUTE_TYPE": "COVID", "CUSTOM_DATE": "20250101"}],
72+
attribute_name="COVID",
73+
source_attribute="CUSTOM_DATE", # This would have been resolved by get_source_attribute
74+
function_args="30, CUSTOM_DATE",
75+
date_format=None,
76+
)
77+
78+
result = handler.calculate(context)
79+
80+
# 2025-01-01 + 30 days = 2025-01-31
81+
assert_that(result, is_(equal_to("20250131")))
82+
83+
def test_calculate_with_blank_days_and_source_override(self):
84+
"""Test that blank days arg with override falls back to defaults."""
85+
handler = AddDaysHandler(default_days=91)
86+
context = DerivedValueContext(
87+
person_data=[{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}],
88+
attribute_name="COVID",
89+
source_attribute="LAST_SUCCESSFUL_DATE",
90+
function_args=", LAST_SUCCESSFUL_DATE",
91+
date_format=None,
92+
)
93+
94+
result = handler.calculate(context)
95+
96+
# No explicit days provided, so default 91 days should be used
97+
assert_that(result, is_(equal_to("20250402")))
98+
99+
def test_source_override_trims_whitespace_and_case(self):
100+
"""Test override parsing handles whitespace and lowercase inputs."""
101+
handler = AddDaysHandler(default_days=91)
102+
103+
source = handler.get_source_attribute("DOSE_DUE", " 30 , last_successful_date ")
104+
assert_that(source, is_(equal_to("LAST_SUCCESSFUL_DATE")))
105+
106+
context = DerivedValueContext(
107+
person_data=[{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}],
108+
attribute_name="COVID",
109+
source_attribute=source,
110+
function_args=" 30 , last_successful_date ",
111+
date_format=None,
112+
)
113+
114+
result = handler.calculate(context)
115+
116+
# 2025-01-01 + 30 days = 2025-01-31
117+
assert_that(result, is_(equal_to("20250131")))
118+
119+
def test_calculate_with_missing_custom_source_returns_empty(self):
120+
"""Test that missing custom source attribute returns empty string."""
121+
handler = AddDaysHandler(default_days=91)
122+
context = DerivedValueContext(
123+
person_data=[{"ATTRIBUTE_TYPE": "COVID"}],
124+
attribute_name="COVID",
125+
source_attribute="CUSTOM_DATE",
126+
function_args="30, CUSTOM_DATE",
127+
date_format=None,
128+
)
129+
130+
result = handler.calculate(context)
131+
132+
assert_that(result, is_(equal_to("")))
133+
47134
def test_calculate_with_vaccine_specific_days(self):
48135
"""Test that vaccine-specific days are used when configured."""
49136
handler = AddDaysHandler(

tests/unit/services/processors/test_token_processor_derived.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,48 @@ def test_missing_last_successful_date_returns_empty(self):
124124

125125
assert result.status_text == "Next dose: "
126126

127+
def test_custom_target_without_mapping_returns_empty(self):
128+
"""Test unknown target without source override returns empty string."""
129+
person = Person(
130+
[
131+
{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"},
132+
]
133+
)
134+
135+
condition = Condition(
136+
condition_name=ConditionName("COVID"),
137+
status=Status.actionable,
138+
status_text=StatusText("Due: [[TARGET.COVID.DOSE_DUE:ADD_DAYS(91)]]"),
139+
cohort_results=[],
140+
suitability_rules=[],
141+
actions=[],
142+
)
143+
144+
result = TokenProcessor.find_and_replace_tokens(person, condition)
145+
146+
assert result.status_text == "Due: "
147+
148+
def test_custom_target_with_source_override_uses_override(self):
149+
"""Test custom target with explicit source override derives date."""
150+
person = Person(
151+
[
152+
{"ATTRIBUTE_TYPE": "COVID", "CUSTOM_DATE": "20250101"},
153+
]
154+
)
155+
156+
condition = Condition(
157+
condition_name=ConditionName("COVID"),
158+
status=Status.actionable,
159+
status_text=StatusText("Next dose: [[TARGET.COVID.DOSE_DUE:ADD_DAYS(30, CUSTOM_DATE)]]"),
160+
cohort_results=[],
161+
suitability_rules=[],
162+
actions=[],
163+
)
164+
165+
result = TokenProcessor.find_and_replace_tokens(person, condition)
166+
167+
assert result.status_text == "Next dose: 20250131"
168+
127169
def test_mixed_regular_and_derived_tokens(self):
128170
"""Test mixing regular tokens with derived value tokens."""
129171
person = Person(

0 commit comments

Comments
 (0)