Skip to content

Commit 3a69fea

Browse files
authored
Merge pull request #562 from NHSDigital/bugfix/eja-eli-579-fixing-non-standard-names
eli-579 - handling 'unknown' targets when used in derivation functions
2 parents 90b3906 + 8c85d02 commit 3a69fea

9 files changed

Lines changed: 548 additions & 72 deletions

File tree

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ class AddDaysHandler(DerivedValueHandler):
2727

2828
function_name: str = "ADD_DAYS"
2929

30-
# Mapping of derived attribute names to their source attributes
3130
DERIVED_ATTRIBUTE_SOURCES: ClassVar[dict[str, str]] = {
3231
"NEXT_DOSE_DUE": "LAST_SUCCESSFUL_DATE",
3332
}
@@ -62,7 +61,6 @@ def get_source_attribute(self, target_attribute: str, function_args: str | None
6261
The source attribute name (e.g., 'LAST_SUCCESSFUL_DATE')
6362
"""
6463
if function_args and "," in function_args:
65-
# Extract source from args if present (second argument)
6664
parts = [p.strip() for p in function_args.split(",")]
6765
if len(parts) > 1 and parts[1]:
6866
return parts[1].upper()
@@ -98,6 +96,9 @@ def calculate(self, context: DerivedValueContext) -> str:
9896
def _find_source_date(self, context: DerivedValueContext) -> str | None:
9997
"""Find the source date value from person data.
10098
99+
For PERSON/COHORT-level attributes, looks for ATTRIBUTE_TYPE == attribute_level.
100+
For TARGET-level attributes, looks for ATTRIBUTE_TYPE == context.attribute_name (e.g., "COVID").
101+
101102
Args:
102103
context: The derived value context
103104
@@ -108,8 +109,13 @@ def _find_source_date(self, context: DerivedValueContext) -> str | None:
108109
if not source_attr:
109110
return None
110111

112+
if context.attribute_level in ("PERSON", "COHORT"):
113+
attribute_type_to_match = context.attribute_level
114+
else:
115+
attribute_type_to_match = context.attribute_name
116+
111117
for attribute in context.person_data:
112-
if attribute.get("ATTRIBUTE_TYPE") == context.attribute_name:
118+
if attribute.get("ATTRIBUTE_TYPE") == attribute_type_to_match:
113119
return attribute.get(source_attr)
114120

115121
return None
@@ -128,7 +134,6 @@ def _get_days_to_add(self, context: DerivedValueContext) -> int:
128134
Returns:
129135
Number of days to add
130136
"""
131-
# Priority 1: Token argument (if non-empty)
132137
if context.function_args:
133138
args = context.function_args.split(",")[0].strip()
134139
if args:
@@ -138,11 +143,9 @@ def _get_days_to_add(self, context: DerivedValueContext) -> int:
138143
message = f"Invalid days argument '{args}' for ADD_DAYS function. Expected an integer."
139144
raise ValueError(message) from e
140145

141-
# Priority 2: Vaccine-specific configuration
142146
if context.attribute_name in self.vaccine_type_days:
143147
return self.vaccine_type_days[context.attribute_name]
144148

145-
# Priority 3: Default
146149
return self.default_days
147150

148151
def _add_days_to_date(self, date_str: str, days: int) -> datetime:

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,20 @@ class DerivedValueContext:
99
1010
Attributes:
1111
person_data: List of person attribute dictionaries
12-
attribute_name: The condition/vaccine type (e.g., 'COVID', 'RSV')
12+
attribute_name: The condition/vaccine type (e.g., 'COVID', 'RSV') or person/cohort attribute
13+
(e.g., 'DATE_OF_BIRTH')
1314
source_attribute: The source attribute to derive from (e.g., 'LAST_SUCCESSFUL_DATE')
1415
function_args: Arguments passed to the function (e.g., number of days)
1516
date_format: Optional date format string for output formatting
17+
attribute_level: The level of the attribute ('TARGET', 'PERSON' or 'COHORT')
1618
"""
1719

1820
person_data: list[dict[str, Any]]
1921
attribute_name: str
2022
source_attribute: str | None
2123
function_args: str | None
2224
date_format: str | None
25+
attribute_level: str = "TARGET"
2326

2427

2528
class DerivedValueHandler(ABC):

src/eligibility_signposting_api/services/processors/token_processor.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,15 @@ def get_token_replacement(token: str, person_data: list[dict], present_attribute
9090
if TokenProcessor.should_replace_with_empty(parsed_token, present_attributes):
9191
return ""
9292

93-
# Check if this is a derived value (has a function like ADD_DAYS)
9493
if parsed_token.function_name:
9594
return TokenProcessor.get_derived_value(parsed_token, person_data, present_attributes, token)
9695

96+
TokenProcessor.validate_target_attribute(parsed_token, token)
97+
9798
found_attribute, key_to_replace = TokenProcessor.find_matching_attribute(parsed_token, person_data)
9899

99100
if not found_attribute or not key_to_replace:
100101
TokenProcessor.handle_token_not_found(parsed_token, token)
101-
# handle_token_not_found always raises, but the type checker needs help
102102
msg = "Unreachable"
103103
raise RuntimeError(msg) # pragma: no cover
104104

@@ -113,6 +113,11 @@ def get_derived_value(
113113
) -> str:
114114
"""Calculate a derived value using the registered handler.
115115
116+
For TARGET level tokens, validates that the condition is allowed before processing.
117+
If the vaccine type is not in person data, returns an empty string.
118+
For derived values, any target attribute name is allowed (e.g., NEXT_BOOKING_AVAILABLE)
119+
since it's just a placeholder that may be surfaced in the future.
120+
116121
Args:
117122
parsed_token: The parsed token containing function information
118123
person_data: List of person attribute dictionaries
@@ -136,20 +141,14 @@ def get_derived_value(
136141
message = f"Unknown function '{function_name}' in token '{token}'."
137142
raise ValueError(message)
138143

139-
# For TARGET level tokens, validate the condition is allowed
140144
if parsed_token.attribute_level == TARGET_ATTRIBUTE_LEVEL:
141145
is_allowed_condition = parsed_token.attribute_name in ALLOWED_CONDITIONS.__args__
142-
is_allowed_target_attr = parsed_token.attribute_value in ALLOWED_TARGET_ATTRIBUTES
143146

144-
# If condition is not allowed, raise error
145147
if not is_allowed_condition:
146148
TokenProcessor.handle_token_not_found(parsed_token, token)
147149

148-
# If vaccine type is not in person data but is allowed, return empty
149150
if parsed_token.attribute_name not in present_attributes:
150-
if is_allowed_target_attr:
151-
return ""
152-
TokenProcessor.handle_token_not_found(parsed_token, token)
151+
return ""
153152

154153
try:
155154
target_attribute = parsed_token.attribute_value or parsed_token.attribute_name
@@ -165,14 +164,14 @@ def get_derived_value(
165164
source_attribute=source_attribute,
166165
function_args=parsed_token.function_args,
167166
date_format=parsed_token.format,
167+
attribute_level=parsed_token.attribute_level,
168168
)
169169

170170
return registry.calculate(
171171
function_name=function_name,
172172
context=context,
173173
)
174174
except ValueError as e:
175-
# Re-raise with more context
176175
message = f"Error calculating derived value for token '{token}': {e}"
177176
raise ValueError(message) from e
178177

@@ -185,6 +184,26 @@ def should_replace_with_empty(parsed_token: ParsedToken, present_attributes: set
185184

186185
return all([is_target_level, is_allowed_condition, is_allowed_target_attr, is_attr_not_present])
187186

187+
@staticmethod
188+
def validate_target_attribute(parsed_token: ParsedToken, token: str) -> None:
189+
"""Validate that target attribute is allowed for non-derived tokens.
190+
191+
For regular (non-derived) tokens, only allow known target attributes.
192+
Derived values with functions can use any custom target attribute name.
193+
194+
Args:
195+
parsed_token: The parsed token to validate
196+
token: The original token string for error messages
197+
198+
Raises:
199+
ValueError: If the target attribute is not in ALLOWED_TARGET_ATTRIBUTES
200+
"""
201+
if (
202+
parsed_token.attribute_level == TARGET_ATTRIBUTE_LEVEL
203+
and parsed_token.attribute_value not in ALLOWED_TARGET_ATTRIBUTES
204+
):
205+
TokenProcessor.handle_token_not_found(parsed_token, token)
206+
188207
@staticmethod
189208
def find_matching_attribute(parsed_token: ParsedToken, person_data: list[dict]) -> tuple[dict | None, str | None]:
190209
attribute_level_map = {

tests/integration/conftest.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,48 @@ def campaign_config_with_multiple_add_days(
11621162
s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json")
11631163

11641164

1165+
@pytest.fixture
1166+
def campaign_config_with_custom_target_attributes(
1167+
s3_client: BaseClient, rules_bucket: BucketName
1168+
) -> Generator[CampaignConfig]:
1169+
"""Campaign config with custom target attribute names for derived values."""
1170+
campaign: CampaignConfig = rule.CampaignConfigFactory.build(
1171+
target="COVID",
1172+
iterations=[
1173+
rule.IterationFactory.build(
1174+
default_comms_routing="CUSTOM_BOOKING_DATE",
1175+
actions_mapper=rule.ActionsMapperFactory.build(
1176+
root={
1177+
"CUSTOM_BOOKING_DATE": AvailableAction(
1178+
ActionType="DataValue",
1179+
ExternalRoutingCode="NextBookingAvailable",
1180+
ActionDescription=(
1181+
"[[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE):"
1182+
"DATE(%d %B %Y)]]"
1183+
),
1184+
),
1185+
}
1186+
),
1187+
iteration_rules=[],
1188+
iteration_cohorts=[
1189+
rule.IterationCohortFactory.build(
1190+
cohort_label="cohort_label1",
1191+
cohort_group="cohort_group1",
1192+
positive_description="Positive Description",
1193+
negative_description="Negative Description",
1194+
)
1195+
],
1196+
)
1197+
],
1198+
)
1199+
campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)}
1200+
s3_client.put_object(
1201+
Bucket=rules_bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json"
1202+
)
1203+
yield campaign
1204+
s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json")
1205+
1206+
11651207
@pytest.fixture(scope="class")
11661208
def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]:
11671209
"""Create and upload multiple campaign configs to S3, then clean up after tests."""
@@ -1521,6 +1563,20 @@ def consumer_to_active_campaign_config_with_multiple_add_days_mapping(
15211563
s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json")
15221564

15231565

1566+
@pytest.fixture
1567+
def consumer_to_active_campaign_config_with_custom_target_attributes_mapping(
1568+
s3_client: BaseClient,
1569+
consumer_mapping_bucket: ConsumerMapping,
1570+
campaign_config_with_custom_target_attributes: CampaignConfig,
1571+
consumer_id: ConsumerId,
1572+
):
1573+
consumer_mapping = create_and_put_consumer_mapping_in_s3(
1574+
campaign_config_with_custom_target_attributes, consumer_id, consumer_mapping_bucket, s3_client
1575+
)
1576+
yield consumer_mapping
1577+
s3_client.delete_object(Bucket=consumer_mapping_bucket, Key="consumer_mapping.json")
1578+
1579+
15241580
@pytest.fixture
15251581
def consumer_to_campaign_having_inactive_iteration_mapping(
15261582
s3_client: BaseClient,

tests/integration/in_process/test_derived_values.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,65 @@ def test_multiple_actions_with_different_add_days_parameters(
246246
has_item(has_entries(actionCode="DateOfNextDoseAt61Days", description="20260330")),
247247
),
248248
)
249+
250+
251+
class TestCustomTargetAttributeNames:
252+
"""Test that custom target attribute names work with derived values in integration."""
253+
254+
def test_custom_target_attribute_with_derived_value(
255+
self,
256+
client: FlaskClient,
257+
person_with_covid_vaccination: NHSNumber,
258+
consumer_id: ConsumerId,
259+
consumer_to_active_campaign_config_with_custom_target_attributes_mapping: ConsumerMapping, # noqa: ARG002
260+
secretsmanager_client: BaseClient, # noqa: ARG002
261+
):
262+
"""
263+
Test that custom target attribute names like NEXT_BOOKING_AVAILABLE work with derived values.
264+
265+
This tests the issue reported in production where:
266+
[[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE):DATE(%d %B %Y)]]
267+
was raising a ValueError.
268+
269+
Given:
270+
- A person with COVID vaccination on 2026-01-28
271+
- A campaign config using a custom target attribute: NEXT_BOOKING_AVAILABLE
272+
- The token: [[TARGET.COVID.NEXT_BOOKING_AVAILABLE:ADD_DAYS(71, LAST_SUCCESSFUL_DATE):DATE(%d %B %Y)]]
273+
274+
Expected:
275+
- Should calculate 2026-01-28 + 71 days = 2026-04-09
276+
- Should format as "09 April 2026"
277+
- Should NOT raise ValueError about invalid attribute name
278+
"""
279+
# Given
280+
headers = {"nhs-login-nhs-number": str(person_with_covid_vaccination), UNIQUE_CONSUMER_HEADER: str(consumer_id)}
281+
282+
# When
283+
response = client.get(
284+
f"/patient-check/{person_with_covid_vaccination}?includeActions=Y",
285+
headers=headers,
286+
)
287+
288+
# Then
289+
assert_that(
290+
response,
291+
is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_key("processedSuggestions"))),
292+
)
293+
294+
body = response.get_json()
295+
assert_that(body, is_not(none()))
296+
processed_suggestions = body.get("processedSuggestions", [])
297+
298+
covid_suggestion = next(
299+
(s for s in processed_suggestions if s.get("condition") == "COVID"),
300+
None,
301+
)
302+
assert_that(covid_suggestion, is_not(none()))
303+
304+
actions = covid_suggestion.get("actions", []) # type: ignore[union-attr]
305+
306+
# Verify the custom target attribute with derived value works correctly
307+
assert_that(
308+
actions,
309+
has_item(has_entries(actionCode="NextBookingAvailable", description="09 April 2026")),
310+
)

tests/unit/services/processors/test_derived_values.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,38 @@ def test_function_args_priority_over_vaccine_config(self):
260260
# 2025-01-01 + 30 days = 2025-01-31
261261
assert_that(result, is_(equal_to("20250131")))
262262

263+
def test_calculate_returns_empty_when_source_attribute_is_none(self):
264+
"""Test that empty string is returned when source_attribute is None."""
265+
handler = AddDaysHandler(default_days=91)
266+
context = DerivedValueContext(
267+
person_data=[{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}],
268+
attribute_name="COVID",
269+
source_attribute=None, # This should cause early return None in _find_source_date
270+
function_args=None,
271+
date_format=None,
272+
attribute_level="TARGET",
273+
)
274+
275+
result = handler.calculate(context)
276+
277+
assert_that(result, is_(equal_to("")))
278+
279+
def test_calculate_returns_empty_when_source_attribute_is_empty(self):
280+
"""Test that empty string is returned when source_attribute is empty string."""
281+
handler = AddDaysHandler(default_days=91)
282+
context = DerivedValueContext(
283+
person_data=[{"ATTRIBUTE_TYPE": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}],
284+
attribute_name="COVID",
285+
source_attribute="", # This should cause early return None in _find_source_date
286+
function_args=None,
287+
date_format=None,
288+
attribute_level="TARGET",
289+
)
290+
291+
result = handler.calculate(context)
292+
293+
assert_that(result, is_(equal_to("")))
294+
263295

264296
class TestDerivedValueRegistry:
265297
"""Tests for the DerivedValueRegistry class."""

0 commit comments

Comments
 (0)