From 32a4567b9a8015700cdfad2e87f86d059511ce55 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:06:08 +0100 Subject: [PATCH 01/28] WIP: basic find replace, with one value, one person data. --- .../model/eligibility_status.py | 4 +- .../calculators/eligibility_calculator.py | 35 +- .../test_eligibility_calculator.py | 355 +++++++++++------- 3 files changed, 250 insertions(+), 144 deletions(-) diff --git a/src/eligibility_signposting_api/model/eligibility_status.py b/src/eligibility_signposting_api/model/eligibility_status.py index 9cc8809fc..b9b16f70b 100644 --- a/src/eligibility_signposting_api/model/eligibility_status.py +++ b/src/eligibility_signposting_api/model/eligibility_status.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, fields from datetime import date from enum import Enum, StrEnum, auto from functools import total_ordering @@ -117,6 +117,8 @@ class Condition: status_text: StatusText actions: list[SuggestedAction] | None = None +all_condition_fields = fields(Condition) + @dataclass class CohortGroupResult: diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 66cba4664..cd34e0277 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -2,12 +2,13 @@ import logging from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from itertools import chain from typing import TYPE_CHECKING from wireup import service +import eligibility_signposting_api.model.eligibility_status from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.eligibility_status import ( @@ -34,6 +35,8 @@ ) from eligibility_signposting_api.model.person import Person +import re + logger = logging.getLogger(__name__) @@ -212,3 +215,33 @@ def deduplicate_reasons(group_results: list[CohortGroupResult]) -> list[Reason]: key = (reason.rule_type, reason.rule_priority) deduped.setdefault(key, reason) return list(deduped.values()) + + @staticmethod + def find_and_replace_tokens(person: Person, condition: Condition) -> Condition: + #pattern = r"\[\[.*?\]\]" + pattern = r"\[\[(.*?)\]\]" + all_fields = fields(condition) + + for field in all_fields: + if field.type == "StatusText": + value = getattr(condition, field.name) + all_search = re.findall(pattern, value) + + for item in all_search: + #middle = item[2:-2] + middle = item + attribute_type = middle.split(".")[0] + attribute_name = middle.split(".")[1] + + person_attribute_value = person.data[0].get(attribute_name) + + #re.replace(pattern, value, person_attribute_value) + sub = re.sub(pattern, person_attribute_value, value) + + setattr(condition, field.name, sub) + pass + pass + + return condition + + diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 37a13e8ff..79ea20926 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -37,8 +37,9 @@ RuleDescription, RulePriority, Status, - SuggestedAction, + SuggestedAction, StatusText, ) +from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator from tests.fixtures.builders.model import rule as rule_builder from tests.fixtures.builders.model.eligibility import ReasonFactory @@ -852,130 +853,115 @@ def test_build_condition_results_cohorts_status_not_matching_iteration_status(se assert_that(result.cohort_results[0].status, is_(Status.not_eligible)) -@pytest.mark.parametrize( - ("reason_1", "reason_2", "reason_3", "expected_reasons"), - [ - # Same rule name, type, and priority, different description - ( - ReasonFactory.build(rule_description="description1", matcher_matched=True), - ReasonFactory.build(rule_description="description2", matcher_matched=True), - ReasonFactory.build(rule_description="description3", matcher_matched=True), - [ReasonFactory.build(rule_description="description1", matcher_matched=True)], - ), - # Different rule name, same type, same priority - ( - ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True), - ReasonFactory.build(rule_name="Supress Rule 2", rule_description="description2", matcher_matched=True), - ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description3", matcher_matched=True), - [ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True)], - ), - # Same rule name, same type, different priority - ( - ReasonFactory.build(rule_priority="1", rule_description="description1", matcher_matched=True), - ReasonFactory.build(rule_priority="2", rule_description="description2", matcher_matched=True), - ReasonFactory.build(rule_priority="1", rule_description="description3", matcher_matched=True), - [ + @pytest.mark.parametrize( + ("reason_1", "reason_2", "reason_3", "expected_reasons"), + [ + # Same rule name, type, and priority, different description + ( + ReasonFactory.build(rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_description="description2", matcher_matched=True), + ReasonFactory.build(rule_description="description3", matcher_matched=True), + [ReasonFactory.build(rule_description="description1", matcher_matched=True)], + ), + # Different rule name, same type, same priority + ( + ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_name="Supress Rule 2", rule_description="description2", matcher_matched=True), + ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description3", matcher_matched=True), + [ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True)], + ), + # Same rule name, same type, different priority + ( ReasonFactory.build(rule_priority="1", rule_description="description1", matcher_matched=True), ReasonFactory.build(rule_priority="2", rule_description="description2", matcher_matched=True), - ], - ), - # Same rule name, same priority, different type - ( - ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True), - ReasonFactory.build(rule_type=RuleType.filter, rule_description="description2", matcher_matched=True), - ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description3", matcher_matched=True), - [ - ReasonFactory.build( - rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True - ), + ReasonFactory.build(rule_priority="1", rule_description="description3", matcher_matched=True), + [ + ReasonFactory.build(rule_priority="1", rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_priority="2", rule_description="description2", matcher_matched=True), + ], + ), + # Same rule name, same priority, different type + ( + ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True), ReasonFactory.build(rule_type=RuleType.filter, rule_description="description2", matcher_matched=True), - ], - ), - ], -) -def test_build_condition_results_grouping_reasons(reason_1, reason_2, reason_3, expected_reasons): - cohort_group_results = [ - CohortGroupResult( - "COHORT_X", - Status.not_actionable, - [reason_1, reason_3], - "Cohort X Description", - [], - ), - CohortGroupResult( - "COHORT_Y", - Status.not_actionable, - [reason_2, reason_3], - "Cohort Y Description", - [], - ), - ] + ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description3", matcher_matched=True), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True + ), + ReasonFactory.build(rule_type=RuleType.filter, rule_description="description2", matcher_matched=True), + ], + ), + ], + ) + def test_build_condition_results_grouping_reasons(self, reason_1, reason_2, reason_3, expected_reasons): + cohort_group_results = [ + CohortGroupResult( + "COHORT_X", + Status.not_actionable, + [reason_1, reason_3], + "Cohort X Description", + [], + ), + CohortGroupResult( + "COHORT_Y", + Status.not_actionable, + [reason_2, reason_3], + "Cohort Y Description", + [], + ), + ] - iteration_result = IterationResult(Status.not_actionable, cohort_group_results, []) + iteration_result = IterationResult(Status.not_actionable, cohort_group_results, []) - result: Condition = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) + result: Condition = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) - assert_that(result.suitability_rules, contains_inanyorder(*expected_reasons)) + assert_that(result.suitability_rules, contains_inanyorder(*expected_reasons)) -@pytest.mark.parametrize( - ("reason_2", "expected_reasons"), - [ - # Same rule name, type, and priority, different description - ( - ReasonFactory.build( - rule_type=RuleType.suppression, - rule_description="Matching", - rule_name="Supress Rule 1", - rule_priority="1", - matcher_matched=True, - ), - [ - ReasonFactory.build( - rule_type=RuleType.suppression, - rule_description="Not matching", - rule_name="Supress Rule 1", - rule_priority="1", - matcher_matched=True, - ) - ], - ), - # Different rule name - ( - ReasonFactory.build( - rule_type=RuleType.suppression, - rule_description="Matching", - rule_name="Supress Rule 2", - rule_priority="1", - matcher_matched=True, - ), - [ + @pytest.mark.parametrize( + ("reason_2", "expected_reasons"), + [ + # Same rule name, type, and priority, different description + ( ReasonFactory.build( rule_type=RuleType.suppression, - rule_description="Not matching", + rule_description="Matching", rule_name="Supress Rule 1", rule_priority="1", matcher_matched=True, - ) - ], - ), - # Different priority - ( - ReasonFactory.build( - rule_type=RuleType.suppression, - rule_description="Matching", - rule_name="Supress Rule 1", - rule_priority="2", - matcher_matched=True, + ), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ) + ], ), - [ + # Different rule name + ( ReasonFactory.build( rule_type=RuleType.suppression, - rule_description="Not matching", - rule_name="Supress Rule 1", + rule_description="Matching", + rule_name="Supress Rule 2", rule_priority="1", matcher_matched=True, ), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ) + ], + ), + # Different priority + ( ReasonFactory.build( rule_type=RuleType.suppression, rule_description="Matching", @@ -983,25 +969,25 @@ def test_build_condition_results_grouping_reasons(reason_1, reason_2, reason_3, rule_priority="2", matcher_matched=True, ), - ], - ), - # Different type - ( - ReasonFactory.build( - rule_type=RuleType.filter, - rule_description="Matching", - rule_name="Supress Rule 1", - rule_priority="2", - matcher_matched=True, + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ), + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Matching", + rule_name="Supress Rule 1", + rule_priority="2", + matcher_matched=True, + ), + ], ), - [ - ReasonFactory.build( - rule_type=RuleType.suppression, - rule_description="Not matching", - rule_name="Supress Rule 1", - rule_priority="1", - matcher_matched=True, - ), + # Different type + ( ReasonFactory.build( rule_type=RuleType.filter, rule_description="Matching", @@ -1009,25 +995,110 @@ def test_build_condition_results_grouping_reasons(reason_1, reason_2, reason_3, rule_priority="2", matcher_matched=True, ), - ], - ), - ], -) -def test_build_condition_results_single_cohort(reason_2, expected_reasons): - reason_1 = ReasonFactory.build( - rule_type=RuleType.suppression, - rule_description="Not matching", - rule_name="Supress Rule 1", - rule_priority="1", - matcher_matched=True, + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ), + ReasonFactory.build( + rule_type=RuleType.filter, + rule_description="Matching", + rule_name="Supress Rule 1", + rule_priority="2", + matcher_matched=True, + ), + ], + ), + ], ) + def test_build_condition_results_single_cohort(self, reason_2, expected_reasons): + reason_1 = ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ) - cohort_group_results = [ - CohortGroupResult("COHORT_Y", Status.not_actionable, [reason_1, reason_2], "Cohort Y Description", []) - ] + cohort_group_results = [ + CohortGroupResult("COHORT_Y", Status.not_actionable, [reason_1, reason_2], "Cohort Y Description", []) + ] + + iteration_result = IterationResult(Status.not_actionable, cohort_group_results, []) + result = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) + + assert_that(len(result.cohort_results), is_(1)) + assert_that(result.cohort_results[0].reasons, contains_inanyorder(*expected_reasons)) + +# Condition token placement + +class TestTokenReplacement: + def test_simple_string(self): + + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your age is [[PERSON.AGE]]."), + cohort_results=[], + suitability_rules=[], + actions=[] + ) - iteration_result = IterationResult(Status.not_actionable, cohort_group_results, []) - result = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) + expected = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your age is 30."), + cohort_results=[], + suitability_rules=[], + actions=[] + ) + + assert_that(EligibilityCalculator.find_and_replace_tokens(person, condition), expected) + + + def test_deep_nesting(self): + + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + reason1 = Reason(RuleType.suppression, eligibility_status.RuleName("Rule1"), RulePriority("1"), + RuleDescription("This is a rule."), False) + reason2 = Reason(RuleType.filter, eligibility_status.RuleName("Rule2"), RulePriority("1"), + RuleDescription("Rule [[PERSON.AGE]] here."), True) + + cohort_result = CohortGroupResult( + cohort_code="C1", + status=Status.actionable, + reasons=[reason1, reason2], + description="Results for cohort [[PERSON.AGE]].", + audit_rules=[] + ) + + condition = Condition( + condition_name=ConditionName("C2"), + status=Status.not_actionable, + status_text=StatusText("Everything is fine."), + cohort_results=[cohort_result], + suitability_rules=[], + actions=[] + ) + + actual = EligibilityCalculator.find_and_replace_tokens(person, condition) + + cohort_result.description = "Results for cohort 30." + cohort_result.reasons[1].rule_description = "Rule 30 here." + + expected = Condition( + condition_name=ConditionName("C2"), + status=Status.not_actionable, + status_text=StatusText("Everything is fine."), + cohort_results=[cohort_result], + suitability_rules=[], + actions=[] + ) - assert_that(len(result.cohort_results), is_(1)) - assert_that(result.cohort_results[0].reasons, contains_inanyorder(*expected_reasons)) + assert_that(actual, expected) From b2fbceca4032e5c5599c477df424378824d63b1f Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:39:38 +0100 Subject: [PATCH 02/28] ELI-223: Nested token support added --- .../model/eligibility_status.py | 4 +- .../calculators/eligibility_calculator.py | 66 +++++++---- .../test_eligibility_calculator.py | 108 ++++++++++++------ 3 files changed, 113 insertions(+), 65 deletions(-) diff --git a/src/eligibility_signposting_api/model/eligibility_status.py b/src/eligibility_signposting_api/model/eligibility_status.py index b9b16f70b..9cc8809fc 100644 --- a/src/eligibility_signposting_api/model/eligibility_status.py +++ b/src/eligibility_signposting_api/model/eligibility_status.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass from datetime import date from enum import Enum, StrEnum, auto from functools import total_ordering @@ -117,8 +117,6 @@ class Condition: status_text: StatusText actions: list[SuggestedAction] | None = None -all_condition_fields = fields(Condition) - @dataclass class CohortGroupResult: diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index cd34e0277..f72c17103 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -1,14 +1,14 @@ from __future__ import annotations import logging +import re from collections import defaultdict -from dataclasses import dataclass, field, fields +from dataclasses import dataclass, field, fields, is_dataclass from itertools import chain -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar from wireup import service -import eligibility_signposting_api.model.eligibility_status from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.eligibility_status import ( @@ -35,9 +35,9 @@ ) from eligibility_signposting_api.model.person import Person -import re logger = logging.getLogger(__name__) +T = TypeVar("T") @service @@ -217,31 +217,47 @@ def deduplicate_reasons(group_results: list[CohortGroupResult]) -> list[Reason]: return list(deduped.values()) @staticmethod - def find_and_replace_tokens(person: Person, condition: Condition) -> Condition: - #pattern = r"\[\[.*?\]\]" - pattern = r"\[\[(.*?)\]\]" - all_fields = fields(condition) + def find_and_replace_tokens_recursive(person: Person, data_class: T) -> T: + if not is_dataclass(data_class): + return data_class + + for class_field in fields(data_class): + value = getattr(data_class, class_field.name) + + if isinstance(value, str): + setattr(data_class, class_field.name, EligibilityCalculator.replace_tokens_in_string(value, person)) + + elif isinstance(value, list): + for i, item in enumerate(value): + if is_dataclass(item): + value[i] = EligibilityCalculator.find_and_replace_tokens_recursive(person, item) + elif isinstance(item, str): + value[i] = EligibilityCalculator.replace_tokens_in_string(item, person) + + elif is_dataclass(value): + setattr( + data_class, class_field.name, EligibilityCalculator.find_and_replace_tokens_recursive(person, value) + ) - for field in all_fields: - if field.type == "StatusText": - value = getattr(condition, field.name) - all_search = re.findall(pattern, value) + return data_class - for item in all_search: - #middle = item[2:-2] - middle = item - attribute_type = middle.split(".")[0] - attribute_name = middle.split(".")[1] + @staticmethod + def replace_tokens_in_string(text: str, person: Person) -> str: + if not isinstance(text, str): + return text - person_attribute_value = person.data[0].get(attribute_name) + pattern = r"\[\[.*?\]\]" + all_tokens = re.findall(pattern, text) - #re.replace(pattern, value, person_attribute_value) - sub = re.sub(pattern, person_attribute_value, value) + for token in all_tokens: + middle = token[2:-2] + try: + attribute_name = middle.split(".")[1] + person_attribute_value = person.data[0].get(attribute_name, token) - setattr(condition, field.name, sub) - pass + if person_attribute_value is not None: + text = text.replace(token, str(person_attribute_value)) + except IndexError: pass - return condition - - + return text diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 79ea20926..7a96b9f65 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -37,7 +37,8 @@ RuleDescription, RulePriority, Status, - SuggestedAction, StatusText, + StatusText, + SuggestedAction, ) from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator @@ -852,7 +853,6 @@ def test_build_condition_results_cohorts_status_not_matching_iteration_status(se assert_that(result.cohort_results[0].cohort_code, is_("COHORT_X")) assert_that(result.cohort_results[0].status, is_(Status.not_eligible)) - @pytest.mark.parametrize( ("reason_1", "reason_2", "reason_3", "expected_reasons"), [ @@ -868,7 +868,11 @@ def test_build_condition_results_cohorts_status_not_matching_iteration_status(se ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True), ReasonFactory.build(rule_name="Supress Rule 2", rule_description="description2", matcher_matched=True), ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description3", matcher_matched=True), - [ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True)], + [ + ReasonFactory.build( + rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True + ) + ], ), # Same rule name, same type, different priority ( @@ -882,14 +886,20 @@ def test_build_condition_results_cohorts_status_not_matching_iteration_status(se ), # Same rule name, same priority, different type ( - ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True), + ReasonFactory.build( + rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True + ), ReasonFactory.build(rule_type=RuleType.filter, rule_description="description2", matcher_matched=True), - ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description3", matcher_matched=True), + ReasonFactory.build( + rule_type=RuleType.suppression, rule_description="description3", matcher_matched=True + ), [ ReasonFactory.build( rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True ), - ReasonFactory.build(rule_type=RuleType.filter, rule_description="description2", matcher_matched=True), + ReasonFactory.build( + rule_type=RuleType.filter, rule_description="description2", matcher_matched=True + ), ], ), ], @@ -918,7 +928,6 @@ def test_build_condition_results_grouping_reasons(self, reason_1, reason_2, reas assert_that(result.suitability_rules, contains_inanyorder(*expected_reasons)) - @pytest.mark.parametrize( ("reason_2", "expected_reasons"), [ @@ -1033,11 +1042,9 @@ def test_build_condition_results_single_cohort(self, reason_2, expected_reasons) assert_that(len(result.cohort_results), is_(1)) assert_that(result.cohort_results[0].reasons, contains_inanyorder(*expected_reasons)) -# Condition token placement class TestTokenReplacement: def test_simple_string(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) condition = Condition( @@ -1046,7 +1053,7 @@ def test_simple_string(self): status_text=StatusText("Your age is [[PERSON.AGE]]."), cohort_results=[], suitability_rules=[], - actions=[] + actions=[], ) expected = Condition( @@ -1055,50 +1062,77 @@ def test_simple_string(self): status_text=StatusText("Your age is 30."), cohort_results=[], suitability_rules=[], - actions=[] + actions=[], ) - assert_that(EligibilityCalculator.find_and_replace_tokens(person, condition), expected) + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + assert actual == expected - def test_deep_nesting(self): + def test_simple_string_with_multiple_tokens(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText( + "You are a [[PERSON.QUALITY]] [[PERSON.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." + ), + cohort_results=[], + suitability_rules=[], + actions=[], + ) - reason1 = Reason(RuleType.suppression, eligibility_status.RuleName("Rule1"), RulePriority("1"), - RuleDescription("This is a rule."), False) - reason2 = Reason(RuleType.filter, eligibility_status.RuleName("Rule2"), RulePriority("1"), - RuleDescription("Rule [[PERSON.AGE]] here."), True) + expected = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("You are a NICE NICE DOCTOR and your age is 30."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + assert actual == expected + + def test_deep_nesting(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) + + reason1 = Reason( + RuleType.suppression, + eligibility_status.RuleName("Rule1"), + RulePriority("1"), + RuleDescription("This is a rule."), + matcher_matched=False, + ) + reason2 = Reason( + RuleType.filter, + eligibility_status.RuleName("Rule2"), + RulePriority("1"), + RuleDescription("Rule [[PERSON.AGE]] here."), + matcher_matched=True, + ) cohort_result = CohortGroupResult( - cohort_code="C1", + cohort_code="CohortCode", status=Status.actionable, reasons=[reason1, reason2], description="Results for cohort [[PERSON.AGE]].", - audit_rules=[] + audit_rules=[], ) condition = Condition( - condition_name=ConditionName("C2"), + condition_name=ConditionName("ConditionName"), status=Status.not_actionable, - status_text=StatusText("Everything is fine."), + status_text=StatusText("Everything is [[PERSON.QUALITY]]."), cohort_results=[cohort_result], suitability_rules=[], - actions=[] + actions=[], ) - actual = EligibilityCalculator.find_and_replace_tokens(person, condition) - - cohort_result.description = "Results for cohort 30." - cohort_result.reasons[1].rule_description = "Rule 30 here." - - expected = Condition( - condition_name=ConditionName("C2"), - status=Status.not_actionable, - status_text=StatusText("Everything is fine."), - cohort_results=[cohort_result], - suitability_rules=[], - actions=[] - ) + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - assert_that(actual, expected) + assert actual.cohort_results[0].description == "Results for cohort 30." + assert actual.cohort_results[0].reasons[1].rule_description == "Rule 30 here." + assert actual.status_text == StatusText("Everything is NICE.") From 9828e657695c17a6a08bf6c3fbdd3d7abe05eb7b Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Fri, 15 Aug 2025 17:03:16 +0100 Subject: [PATCH 03/28] ELI-223: Replaces token with given valid format --- .../calculators/eligibility_calculator.py | 54 +++++- .../test_eligibility_calculator.py | 181 ++++++++++++++++++ 2 files changed, 226 insertions(+), 9 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index f72c17103..71712c39f 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -4,6 +4,7 @@ import re from collections import defaultdict from dataclasses import dataclass, field, fields, is_dataclass +from datetime import datetime from itertools import chain from typing import TYPE_CHECKING, TypeVar @@ -225,14 +226,14 @@ def find_and_replace_tokens_recursive(person: Person, data_class: T) -> T: value = getattr(data_class, class_field.name) if isinstance(value, str): - setattr(data_class, class_field.name, EligibilityCalculator.replace_tokens_in_string(value, person)) + setattr(data_class, class_field.name, EligibilityCalculator.replace_token(value, person)) elif isinstance(value, list): for i, item in enumerate(value): if is_dataclass(item): value[i] = EligibilityCalculator.find_and_replace_tokens_recursive(person, item) elif isinstance(item, str): - value[i] = EligibilityCalculator.replace_tokens_in_string(item, person) + value[i] = EligibilityCalculator.replace_token(item, person) elif is_dataclass(value): setattr( @@ -242,22 +243,57 @@ def find_and_replace_tokens_recursive(person: Person, data_class: T) -> T: return data_class @staticmethod - def replace_tokens_in_string(text: str, person: Person) -> str: + def replace_token(text: str, person: Person) -> str: if not isinstance(text, str): return text pattern = r"\[\[.*?\]\]" + date_pattern = r"\DATE\((.*?)\)" all_tokens = re.findall(pattern, text) for token in all_tokens: middle = token[2:-2] try: + attribute_level = middle.split(".")[0] attribute_name = middle.split(".")[1] - person_attribute_value = person.data[0].get(attribute_name, token) - - if person_attribute_value is not None: - text = text.replace(token, str(person_attribute_value)) - except IndexError: - pass + replace_with = "" + valid_person_keys = EligibilityCalculator.get_all_valid_person_keys(person) + + for attribute in person.data: + if attribute_level == "PERSON" and attribute.get("ATTRIBUTE_TYPE") == "PERSON": + if attribute_name in valid_person_keys: + replace_with = attribute.get(attribute_name) if attribute.get(attribute_name) else "" + else: + raise ValueError(f"Invalid attribute name '{attribute_name}' in token '{token}'.") + + if attribute_level == "TARGET": + if attribute.get("ATTRIBUTE_TYPE") == attribute_name: + attribute_value = middle.split(".")[2] + if attribute_value in valid_person_keys: + if len(attribute_value.split(":")) > 1: + token_format_type = attribute_value.split(":")[1] + token_date_format = re.search(date_pattern, token_format_type).group(1) + unformatted_replace_with = attribute.get(attribute_value.split(":")[0]) + if unformatted_replace_with is not None: + replace_with_date_object = datetime.strptime(str(unformatted_replace_with), '%Y%m%d') + replace_with = replace_with_date_object.strftime(str(token_date_format)) + else: + replace_with = attribute.get(attribute_value) if attribute.get(attribute_name) else "" + else: + raise ValueError(f"Invalid target attribute name '{attribute_value}' in token '{token}'.") + + text = text.replace(token, str(replace_with)) + + + except ValueError as e: + raise ValueError(e) return text + + + @staticmethod + def get_all_valid_person_keys(person: Person) -> set[str]: + all_keys = set() + for item in person.data: + all_keys.update(item.keys()) + return all_keys diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 7a96b9f65..0bd33f0d6 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -1043,6 +1043,7 @@ def test_build_condition_results_single_cohort(self, reason_2, expected_reasons) assert_that(result.cohort_results[0].reasons, contains_inanyorder(*expected_reasons)) +# TODO: Rename test cases class TestTokenReplacement: def test_simple_string(self): person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) @@ -1069,6 +1070,50 @@ def test_simple_string(self): assert actual == expected + def test_simple_string_with_invalid_person_attribute_name(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your age is [[PERSON.ICECREAM]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + with pytest.raises(ValueError): + EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + def test_simple_string_with_invalid_target_attribute_name(self): + person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}]) + + condition = Condition( + condition_name=ConditionName("Condition name is [[TARGET.RSV.CONDITION_NAME]]"), + status=Status.actionable, + status_text=StatusText("Some status"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + with pytest.raises(ValueError): + EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + + def test_simple_string_when_target_attribute_name_does_not_have_value(self): + person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": None}]) + + condition = Condition( + condition_name=ConditionName("Condition name is [[TARGET.RSV.LAST_SUCCESSFUL_DATE]]"), + status=Status.actionable, + status_text=StatusText("Some status"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + assert EligibilityCalculator.find_and_replace_tokens_recursive(person, condition).condition_name == "Condition name is " + def test_simple_string_with_multiple_tokens(self): person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) @@ -1136,3 +1181,139 @@ def test_deep_nesting(self): assert actual.cohort_results[0].description == "Results for cohort 30." assert actual.cohort_results[0].reasons[1].rule_description == "Rule 30 here." assert actual.status_text == StatusText("Everything is NICE.") + + + def test_simple_string_with_different_attribute_types(self): + person = Person([ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ]) + + condition = Condition( + condition_name=ConditionName("Condition name is [[TARGET.RSV.CONDITION_NAME]]"), + status=Status.actionable, + status_text=StatusText("Your age is: [[PERSON.AGE]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("Condition name is RSV"), + status=Status.actionable, + status_text=StatusText("Your age is: 30."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + assert actual.status_text == expected.status_text + assert actual.condition_name == expected.condition_name + + + + def test_simple_string_with_token_and_format_for_dates(self): + person = Person([ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ]) + + condition = Condition( + condition_name=ConditionName("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]"), + status=Status.actionable, + status_text=StatusText("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("You had your RSV vaccine on 01 January 2025"), + status=Status.actionable, + status_text=StatusText("You had your RSV vaccine on 1 January 2025"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + assert actual.status_text == expected.status_text + assert actual.condition_name == expected.condition_name + + + def test_simple_string_where_person_data_is_missing(self): + person = Person([ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV"}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ]) + + condition = Condition( + condition_name=ConditionName("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]"), + status=Status.actionable, + status_text=StatusText("You are from [[PERSON.POSTCODE]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("You had your RSV vaccine on "), + status=Status.actionable, + status_text=StatusText("You are from ."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + assert actual.status_text == expected.status_text + assert actual.condition_name == expected.condition_name + + def test_get_all_person_keys(self): + person = Person([ + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + { + "COHORT_LABEL": "rsv_75_rolling", + "DATE_JOINED": "20231020" + } + ] + }, + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "LS1 1AB", + "POSTCODE_SECTOR": "LS1", + "POSTCODE_OUTCODE": "1AB", + "MSOA": "E02001111", + "LSOA": "E01005348", + "GP_PRACTICE_CODE": "B87008", + "PCN": "U43084", + "ICB": "QWO", + "COMMISSIONING_REGION": "Y63", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N" + }, + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "RSV", + "LAST_SUCCESSFUL_DATE": "20250326" + } + ]) + keys = EligibilityCalculator.get_all_valid_person_keys(person) + + assert keys == {'13Q_FLAG', 'ATTRIBUTE_TYPE', 'CARE_HOME_FLAG', 'COHORT_MEMBERSHIPS', 'COMMISSIONING_REGION', + 'DATE_OF_BIRTH', 'DE_FLAG', 'GENDER', 'GP_PRACTICE_CODE', 'ICB', 'LAST_SUCCESSFUL_DATE', 'LSOA', + 'MSOA', 'NHS_NUMBER', 'PCN', 'POSTCODE', 'POSTCODE_OUTCODE', 'POSTCODE_SECTOR'} From 304c6a474bbaa5021bddeeb9d260fa25e3e08d81 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:35:19 +0100 Subject: [PATCH 04/28] ELI-223: Fixes tests --- .../services/calculators/eligibility_calculator.py | 4 ++-- .../unit/services/calculators/test_eligibility_calculator.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 71712c39f..61721cb2a 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -269,7 +269,7 @@ def replace_token(text: str, person: Person) -> str: if attribute_level == "TARGET": if attribute.get("ATTRIBUTE_TYPE") == attribute_name: attribute_value = middle.split(".")[2] - if attribute_value in valid_person_keys: + if attribute_value.split(":")[0] in valid_person_keys: if len(attribute_value.split(":")) > 1: token_format_type = attribute_value.split(":")[1] token_date_format = re.search(date_pattern, token_format_type).group(1) @@ -278,7 +278,7 @@ def replace_token(text: str, person: Person) -> str: replace_with_date_object = datetime.strptime(str(unformatted_replace_with), '%Y%m%d') replace_with = replace_with_date_object.strftime(str(token_date_format)) else: - replace_with = attribute.get(attribute_value) if attribute.get(attribute_name) else "" + replace_with = attribute.get(attribute_value) if attribute.get(attribute_value) else "" else: raise ValueError(f"Invalid target attribute name '{attribute_value}' in token '{token}'.") diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 0bd33f0d6..893c10027 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -1248,8 +1248,8 @@ def test_simple_string_with_token_and_format_for_dates(self): def test_simple_string_where_person_data_is_missing(self): person = Person([ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV"}, + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "POSTCODE": None}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": None}, {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, ]) From fb973dc7b1616d8425c430ef9dbc62cf82f12bbb Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:53:31 +0100 Subject: [PATCH 05/28] ELI-223: Date format replacement for person and target --- .../calculators/eligibility_calculator.py | 28 +- .../test_eligibility_calculator.py | 242 +++++++----------- 2 files changed, 116 insertions(+), 154 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 61721cb2a..42e0d5311 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -261,8 +261,9 @@ def replace_token(text: str, person: Person) -> str: for attribute in person.data: if attribute_level == "PERSON" and attribute.get("ATTRIBUTE_TYPE") == "PERSON": - if attribute_name in valid_person_keys: - replace_with = attribute.get(attribute_name) if attribute.get(attribute_name) else "" + if attribute_name.split(":")[0] in valid_person_keys: + replace_with = EligibilityCalculator.replace_with_formatting(attribute, attribute_name, + date_pattern, replace_with) else: raise ValueError(f"Invalid attribute name '{attribute_name}' in token '{token}'.") @@ -270,15 +271,8 @@ def replace_token(text: str, person: Person) -> str: if attribute.get("ATTRIBUTE_TYPE") == attribute_name: attribute_value = middle.split(".")[2] if attribute_value.split(":")[0] in valid_person_keys: - if len(attribute_value.split(":")) > 1: - token_format_type = attribute_value.split(":")[1] - token_date_format = re.search(date_pattern, token_format_type).group(1) - unformatted_replace_with = attribute.get(attribute_value.split(":")[0]) - if unformatted_replace_with is not None: - replace_with_date_object = datetime.strptime(str(unformatted_replace_with), '%Y%m%d') - replace_with = replace_with_date_object.strftime(str(token_date_format)) - else: - replace_with = attribute.get(attribute_value) if attribute.get(attribute_value) else "" + replace_with = EligibilityCalculator.replace_with_formatting(attribute, attribute_value, + date_pattern, replace_with) else: raise ValueError(f"Invalid target attribute name '{attribute_value}' in token '{token}'.") @@ -290,6 +284,18 @@ def replace_token(text: str, person: Person) -> str: return text + @staticmethod + def replace_with_formatting(attribute, attribute_value, date_pattern, replace_with): + if len(attribute_value.split(":")) > 1: + token_format_type = attribute_value.split(":")[1] + token_date_format = re.search(date_pattern, token_format_type).group(1) + unformatted_replace_with = attribute.get(attribute_value.split(":")[0]) + if unformatted_replace_with is not None: + replace_with_date_object = datetime.strptime(str(unformatted_replace_with), '%Y%m%d') + replace_with = replace_with_date_object.strftime(str(token_date_format)) + else: + replace_with = attribute.get(attribute_value) if attribute.get(attribute_value) else "" + return replace_with @staticmethod def get_all_valid_person_keys(person: Person) -> set[str]: diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 893c10027..b90b25a14 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -1043,9 +1043,8 @@ def test_build_condition_results_single_cohort(self, reason_2, expected_reasons) assert_that(result.cohort_results[0].reasons, contains_inanyorder(*expected_reasons)) -# TODO: Rename test cases class TestTokenReplacement: - def test_simple_string(self): + def test_simple_token_replacement(self): person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) condition = Condition( @@ -1070,78 +1069,7 @@ def test_simple_string(self): assert actual == expected - def test_simple_string_with_invalid_person_attribute_name(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) - - condition = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText("Your age is [[PERSON.ICECREAM]]."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - with pytest.raises(ValueError): - EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - def test_simple_string_with_invalid_target_attribute_name(self): - person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}]) - - condition = Condition( - condition_name=ConditionName("Condition name is [[TARGET.RSV.CONDITION_NAME]]"), - status=Status.actionable, - status_text=StatusText("Some status"), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - with pytest.raises(ValueError): - EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - - def test_simple_string_when_target_attribute_name_does_not_have_value(self): - person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": None}]) - - condition = Condition( - condition_name=ConditionName("Condition name is [[TARGET.RSV.LAST_SUCCESSFUL_DATE]]"), - status=Status.actionable, - status_text=StatusText("Some status"), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - assert EligibilityCalculator.find_and_replace_tokens_recursive(person, condition).condition_name == "Condition name is " - - def test_simple_string_with_multiple_tokens(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) - - condition = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText( - "You are a [[PERSON.QUALITY]] [[PERSON.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." - ), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - expected = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText("You are a NICE NICE DOCTOR and your age is 30."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual == expected - - def test_deep_nesting(self): + def test_deep_nesting_token_replacement(self): person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) reason1 = Reason( @@ -1182,59 +1110,97 @@ def test_deep_nesting(self): assert actual.cohort_results[0].reasons[1].rule_description == "Rule 30 here." assert actual.status_text == StatusText("Everything is NICE.") - - def test_simple_string_with_different_attribute_types(self): + def test_get_all_person_keys(self): person = Person([ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, - {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + { + "COHORT_LABEL": "rsv_75_rolling", + "DATE_JOINED": "20231020" + } + ] + }, + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "LS1 1AB", + "POSTCODE_SECTOR": "LS1", + "POSTCODE_OUTCODE": "1AB", + "MSOA": "E02001111", + "LSOA": "E01005348", + "GP_PRACTICE_CODE": "B87008", + "PCN": "U43084", + "ICB": "QWO", + "COMMISSIONING_REGION": "Y63", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N" + }, + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "RSV", + "LAST_SUCCESSFUL_DATE": "20250326" + } ]) + keys = EligibilityCalculator.get_all_valid_person_keys(person) + + assert keys == {'13Q_FLAG', 'ATTRIBUTE_TYPE', 'CARE_HOME_FLAG', 'COHORT_MEMBERSHIPS', 'COMMISSIONING_REGION', + 'DATE_OF_BIRTH', 'DE_FLAG', 'GENDER', 'GP_PRACTICE_CODE', 'ICB', 'LAST_SUCCESSFUL_DATE', 'LSOA', + 'MSOA', 'NHS_NUMBER', 'PCN', 'POSTCODE', 'POSTCODE_OUTCODE', 'POSTCODE_SECTOR'} + + def test_invalid_token_on_person_attribute_should_raise_error(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) condition = Condition( - condition_name=ConditionName("Condition name is [[TARGET.RSV.CONDITION_NAME]]"), + condition_name=ConditionName("RSV"), status=Status.actionable, - status_text=StatusText("Your age is: [[PERSON.AGE]]."), + status_text=StatusText("Your age is [[PERSON.ICECREAM]]."), cohort_results=[], suitability_rules=[], actions=[], ) + with pytest.raises(ValueError): + EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - expected = Condition( - condition_name=ConditionName("Condition name is RSV"), + def test_invalid_token_on_target_attribute_should_raise_error(self): + person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}]) + + condition = Condition( + condition_name=ConditionName("Condition name is [[TARGET.RSV.CONDITION_NAME]]"), status=Status.actionable, - status_text=StatusText("Your age is: 30."), + status_text=StatusText("Some status"), cohort_results=[], suitability_rules=[], actions=[], ) - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual.status_text == expected.status_text - assert actual.condition_name == expected.condition_name - - + with pytest.raises(ValueError): + EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - def test_simple_string_with_token_and_format_for_dates(self): + def test_valid_token_but_missing_attribute_data_to_replace(self): person = Person([ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "POSTCODE": None}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": None}, {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, ]) condition = Condition( condition_name=ConditionName("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]"), status=Status.actionable, - status_text=StatusText("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]"), + status_text=StatusText("You are from [[PERSON.POSTCODE]]."), cohort_results=[], suitability_rules=[], actions=[], ) expected = Condition( - condition_name=ConditionName("You had your RSV vaccine on 01 January 2025"), + condition_name=ConditionName("You had your RSV vaccine on "), status=Status.actionable, - status_text=StatusText("You had your RSV vaccine on 1 January 2025"), + status_text=StatusText("You are from ."), cohort_results=[], suitability_rules=[], actions=[], @@ -1245,27 +1211,53 @@ def test_simple_string_with_token_and_format_for_dates(self): assert actual.status_text == expected.status_text assert actual.condition_name == expected.condition_name + def test_simple_string_with_multiple_tokens(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText( + "You are a [[PERSON.QUALITY]] [[PERSON.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." + ), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("You are a NICE NICE DOCTOR and your age is 30."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) - def test_simple_string_where_person_data_is_missing(self): + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + assert actual == expected + + def test_valid_token_valid_format_should_replace_with_date_formatting(self): person = Person([ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "POSTCODE": None}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": None}, + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, ]) condition = Condition( condition_name=ConditionName("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]"), status=Status.actionable, - status_text=StatusText("You are from [[PERSON.POSTCODE]]."), + status_text=StatusText("Your birthday is on [[PERSON.DATE_OF_BIRTH:DATE(%-d %B %Y)]]"), cohort_results=[], suitability_rules=[], actions=[], ) expected = Condition( - condition_name=ConditionName("You had your RSV vaccine on "), + condition_name=ConditionName("You had your RSV vaccine on 01 January 2025"), status=Status.actionable, - status_text=StatusText("You are from ."), + status_text=StatusText("Your birthday is on 27 March 1990"), cohort_results=[], suitability_rules=[], actions=[], @@ -1273,47 +1265,11 @@ def test_simple_string_where_person_data_is_missing(self): actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - assert actual.status_text == expected.status_text assert actual.condition_name == expected.condition_name + assert actual.status_text == expected.status_text - def test_get_all_person_keys(self): - person = Person([ - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [ - { - "COHORT_LABEL": "rsv_75_rolling", - "DATE_JOINED": "20231020" - } - ] - }, - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "<>", - "GENDER": "0", - "POSTCODE": "LS1 1AB", - "POSTCODE_SECTOR": "LS1", - "POSTCODE_OUTCODE": "1AB", - "MSOA": "E02001111", - "LSOA": "E01005348", - "GP_PRACTICE_CODE": "B87008", - "PCN": "U43084", - "ICB": "QWO", - "COMMISSIONING_REGION": "Y63", - "13Q_FLAG": "N", - "CARE_HOME_FLAG": "N", - "DE_FLAG": "N" - }, - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "20250326" - } - ]) - keys = EligibilityCalculator.get_all_valid_person_keys(person) + def test_valid_token_invalid_format_should_raise_error(self): + pass - assert keys == {'13Q_FLAG', 'ATTRIBUTE_TYPE', 'CARE_HOME_FLAG', 'COHORT_MEMBERSHIPS', 'COMMISSIONING_REGION', - 'DATE_OF_BIRTH', 'DE_FLAG', 'GENDER', 'GP_PRACTICE_CODE', 'ICB', 'LAST_SUCCESSFUL_DATE', 'LSOA', - 'MSOA', 'NHS_NUMBER', 'PCN', 'POSTCODE', 'POSTCODE_OUTCODE', 'POSTCODE_SECTOR'} + def test_valid_token_missing_format_should_replace_without_any_formatting(self): + pass From 97a109f12743133e00d6744515e69b866514a8e9 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:55:59 +0100 Subject: [PATCH 06/28] WIP: Added test for invalid formatter. --- .../test_eligibility_calculator.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index b90b25a14..ad5cbc4c2 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -1269,7 +1269,24 @@ def test_valid_token_valid_format_should_replace_with_date_formatting(self): assert actual.status_text == expected.status_text def test_valid_token_invalid_format_should_raise_error(self): - pass + person = Person([ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"} + ]) + + condition = Condition( + condition_name=ConditionName("You had your RSV vaccine"), + status=Status.actionable, + status_text=StatusText("Your birthday is on [[PERSON.DATE_OF_BIRTH:INVALIDDATEFORMATTER(%ABC)]]"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + with pytest.raises(Exception) as excinfo: + EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + #assert "INVALIDDATEFORMATTER" in str(excinfo.value) + def test_valid_token_missing_format_should_replace_without_any_formatting(self): pass From 5553cf7ffcc2ed7a1cafbc0ca268da0aaf9d9df1 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 19 Aug 2025 11:40:36 +0100 Subject: [PATCH 07/28] ELI-223: Handle error scenarios --- .../calculators/eligibility_calculator.py | 38 ++-- .../test_eligibility_calculator.py | 171 ++++++++++++------ 2 files changed, 134 insertions(+), 75 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 42e0d5311..7fc76fb11 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -262,8 +262,9 @@ def replace_token(text: str, person: Person) -> str: for attribute in person.data: if attribute_level == "PERSON" and attribute.get("ATTRIBUTE_TYPE") == "PERSON": if attribute_name.split(":")[0] in valid_person_keys: - replace_with = EligibilityCalculator.replace_with_formatting(attribute, attribute_name, - date_pattern, replace_with) + replace_with = EligibilityCalculator.replace_with_formatting( + attribute, attribute_name, date_pattern, replace_with + ) else: raise ValueError(f"Invalid attribute name '{attribute_name}' in token '{token}'.") @@ -271,14 +272,16 @@ def replace_token(text: str, person: Person) -> str: if attribute.get("ATTRIBUTE_TYPE") == attribute_name: attribute_value = middle.split(".")[2] if attribute_value.split(":")[0] in valid_person_keys: - replace_with = EligibilityCalculator.replace_with_formatting(attribute, attribute_value, - date_pattern, replace_with) + replace_with = EligibilityCalculator.replace_with_formatting( + attribute, attribute_value, date_pattern, replace_with + ) else: - raise ValueError(f"Invalid target attribute name '{attribute_value}' in token '{token}'.") + raise ValueError( + f"Invalid target attribute name '{attribute_value}' in token '{token}'." + ) text = text.replace(token, str(replace_with)) - except ValueError as e: raise ValueError(e) @@ -286,16 +289,19 @@ def replace_token(text: str, person: Person) -> str: @staticmethod def replace_with_formatting(attribute, attribute_value, date_pattern, replace_with): - if len(attribute_value.split(":")) > 1: - token_format_type = attribute_value.split(":")[1] - token_date_format = re.search(date_pattern, token_format_type).group(1) - unformatted_replace_with = attribute.get(attribute_value.split(":")[0]) - if unformatted_replace_with is not None: - replace_with_date_object = datetime.strptime(str(unformatted_replace_with), '%Y%m%d') - replace_with = replace_with_date_object.strftime(str(token_date_format)) - else: - replace_with = attribute.get(attribute_value) if attribute.get(attribute_value) else "" - return replace_with + try: + if len(attribute_value.split(":")) > 1: + token_format_type = attribute_value.split(":")[1] + token_date_format = re.search(date_pattern, token_format_type).group(1) + unformatted_replace_with = attribute.get(attribute_value.split(":")[0]) + if unformatted_replace_with is not None: + replace_with_date_object = datetime.strptime(str(unformatted_replace_with), "%Y%m%d") + replace_with = replace_with_date_object.strftime(str(token_date_format)) + else: + replace_with = attribute.get(attribute_value) if attribute.get(attribute_value) else "" + return replace_with + except AttributeError: + raise AttributeError("Invalid token format") @staticmethod def get_all_valid_person_keys(person: Person) -> set[str]: diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index ad5cbc4c2..7e909df2e 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -1111,46 +1111,56 @@ def test_deep_nesting_token_replacement(self): assert actual.status_text == StatusText("Everything is NICE.") def test_get_all_person_keys(self): - person = Person([ - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [ - { - "COHORT_LABEL": "rsv_75_rolling", - "DATE_JOINED": "20231020" - } - ] - }, - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "<>", - "GENDER": "0", - "POSTCODE": "LS1 1AB", - "POSTCODE_SECTOR": "LS1", - "POSTCODE_OUTCODE": "1AB", - "MSOA": "E02001111", - "LSOA": "E01005348", - "GP_PRACTICE_CODE": "B87008", - "PCN": "U43084", - "ICB": "QWO", - "COMMISSIONING_REGION": "Y63", - "13Q_FLAG": "N", - "CARE_HOME_FLAG": "N", - "DE_FLAG": "N" - }, - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "RSV", - "LAST_SUCCESSFUL_DATE": "20250326" - } - ]) + person = Person( + [ + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "rsv_75_rolling", "DATE_JOINED": "20231020"}], + }, + { + "NHS_NUMBER": "5000000009", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "LS1 1AB", + "POSTCODE_SECTOR": "LS1", + "POSTCODE_OUTCODE": "1AB", + "MSOA": "E02001111", + "LSOA": "E01005348", + "GP_PRACTICE_CODE": "B87008", + "PCN": "U43084", + "ICB": "QWO", + "COMMISSIONING_REGION": "Y63", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N", + }, + {"NHS_NUMBER": "5000000009", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250326"}, + ] + ) keys = EligibilityCalculator.get_all_valid_person_keys(person) - assert keys == {'13Q_FLAG', 'ATTRIBUTE_TYPE', 'CARE_HOME_FLAG', 'COHORT_MEMBERSHIPS', 'COMMISSIONING_REGION', - 'DATE_OF_BIRTH', 'DE_FLAG', 'GENDER', 'GP_PRACTICE_CODE', 'ICB', 'LAST_SUCCESSFUL_DATE', 'LSOA', - 'MSOA', 'NHS_NUMBER', 'PCN', 'POSTCODE', 'POSTCODE_OUTCODE', 'POSTCODE_SECTOR'} + assert keys == { + "13Q_FLAG", + "ATTRIBUTE_TYPE", + "CARE_HOME_FLAG", + "COHORT_MEMBERSHIPS", + "COMMISSIONING_REGION", + "DATE_OF_BIRTH", + "DE_FLAG", + "GENDER", + "GP_PRACTICE_CODE", + "ICB", + "LAST_SUCCESSFUL_DATE", + "LSOA", + "MSOA", + "NHS_NUMBER", + "PCN", + "POSTCODE", + "POSTCODE_OUTCODE", + "POSTCODE_SECTOR", + } def test_invalid_token_on_person_attribute_should_raise_error(self): person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) @@ -1182,14 +1192,18 @@ def test_invalid_token_on_target_attribute_should_raise_error(self): EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) def test_valid_token_but_missing_attribute_data_to_replace(self): - person = Person([ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "POSTCODE": None}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": None}, - {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, - ]) + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "POSTCODE": None}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": None}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ] + ) condition = Condition( - condition_name=ConditionName("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]"), + condition_name=ConditionName( + "You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + ), status=Status.actionable, status_text=StatusText("You are from [[PERSON.POSTCODE]]."), cohort_results=[], @@ -1239,14 +1253,18 @@ def test_simple_string_with_multiple_tokens(self): assert actual == expected def test_valid_token_valid_format_should_replace_with_date_formatting(self): - person = Person([ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, - {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, - ]) + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ] + ) condition = Condition( - condition_name=ConditionName("You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]"), + condition_name=ConditionName( + "You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + ), status=Status.actionable, status_text=StatusText("Your birthday is on [[PERSON.DATE_OF_BIRTH:DATE(%-d %B %Y)]]"), cohort_results=[], @@ -1268,25 +1286,60 @@ def test_valid_token_valid_format_should_replace_with_date_formatting(self): assert actual.condition_name == expected.condition_name assert actual.status_text == expected.status_text - def test_valid_token_invalid_format_should_raise_error(self): - person = Person([ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"} - ]) + @pytest.mark.parametrize( + "token_format", + [ + ":INVALID_DATE_FORMATTER(%ABC)", + ":INVALID_DATE_FORMATTER(19900327)", + ":()", + ":FORMAT(DATE)", + ":DATE[%d %B %Y]", + ], + ) + def test_valid_token_invalid_format_should_raise_error(self, token_format: str): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}]) condition = Condition( condition_name=ConditionName("You had your RSV vaccine"), status=Status.actionable, - status_text=StatusText("Your birthday is on [[PERSON.DATE_OF_BIRTH:INVALIDDATEFORMATTER(%ABC)]]"), + status_text=StatusText(f"Your birthday is on [[PERSON.DATE_OF_BIRTH{token_format}]]"), cohort_results=[], suitability_rules=[], actions=[], ) - with pytest.raises(Exception) as excinfo: + with pytest.raises(AttributeError): EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - #assert "INVALIDDATEFORMATTER" in str(excinfo.value) + @pytest.mark.parametrize( + ("token_format", "expected"), + [ + (":DATE(%d %B %Y)", "27 March 1990"), + (":DATE(%d %b %Y)", "27 Mar 1990"), + (":DATE()", ""), + ("", "19900327"), + (":DATE(random_value)", "random_value"), + (":DATE(%A, %d %B %Y)", "Tuesday, 27 March 1990"), + (":DATE(%A, (%d) %B %Y)", "Tuesday, (27"), + (":DATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), + ], + ) + def test_valid_date_format(self, token_format: str, expected: str, faker: Faker): + person = Person( + [ + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "19900327"}, + ] + ) + + condition = Condition( + condition_name=ConditionName(f"Date: [[TARGET.RSV.LAST_SUCCESSFUL_DATE{token_format}]]"), + status=Status.actionable, + status_text=StatusText("Some text"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - def test_valid_token_missing_format_should_replace_without_any_formatting(self): - pass + assert actual.condition_name == f"Date: {expected}" From a18667a19d9d775e53afef088dbf32d4df0fed64 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:22:27 +0100 Subject: [PATCH 08/28] ELI-223: Handles case insensitive token replacement --- .../calculators/eligibility_calculator.py | 29 ++- .../test_eligibility_calculator.py | 187 +++++++++++++++++- 2 files changed, 205 insertions(+), 11 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 7fc76fb11..b4d46f913 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -109,7 +109,11 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca condition: Condition = self.build_condition( iteration_result=condition_results[condition_name], condition_name=condition_name ) - final_result.append(condition) + condition_with_replaced_tokens = EligibilityCalculator.find_and_replace_tokens_recursive( + self.person, condition + ) + + final_result.append(condition_with_replaced_tokens) AuditContext.append_audit_condition( condition_name, @@ -249,19 +253,23 @@ def replace_token(text: str, person: Person) -> str: pattern = r"\[\[.*?\]\]" date_pattern = r"\DATE\((.*?)\)" - all_tokens = re.findall(pattern, text) + all_tokens = re.findall(pattern, text, re.IGNORECASE) for token in all_tokens: middle = token[2:-2] try: - attribute_level = middle.split(".")[0] + attribute_level = middle.split(".")[0].upper() attribute_name = middle.split(".")[1] replace_with = "" valid_person_keys = EligibilityCalculator.get_all_valid_person_keys(person) + allowed_attribute_levels = ["PERSON", "TARGET"] for attribute in person.data: + if attribute_level not in allowed_attribute_levels: + raise ValueError(f"Invalid attribute level '{attribute_level}' in token '{token}'.") + if attribute_level == "PERSON" and attribute.get("ATTRIBUTE_TYPE") == "PERSON": - if attribute_name.split(":")[0] in valid_person_keys: + if attribute_name.split(":")[0].upper() in valid_person_keys: replace_with = EligibilityCalculator.replace_with_formatting( attribute, attribute_name, date_pattern, replace_with ) @@ -269,9 +277,9 @@ def replace_token(text: str, person: Person) -> str: raise ValueError(f"Invalid attribute name '{attribute_name}' in token '{token}'.") if attribute_level == "TARGET": - if attribute.get("ATTRIBUTE_TYPE") == attribute_name: + if attribute.get("ATTRIBUTE_TYPE") == attribute_name.upper(): attribute_value = middle.split(".")[2] - if attribute_value.split(":")[0] in valid_person_keys: + if attribute_value.split(":")[0].upper() in valid_person_keys: replace_with = EligibilityCalculator.replace_with_formatting( attribute, attribute_value, date_pattern, replace_with ) @@ -292,8 +300,8 @@ def replace_with_formatting(attribute, attribute_value, date_pattern, replace_wi try: if len(attribute_value.split(":")) > 1: token_format_type = attribute_value.split(":")[1] - token_date_format = re.search(date_pattern, token_format_type).group(1) - unformatted_replace_with = attribute.get(attribute_value.split(":")[0]) + token_date_format = re.search(date_pattern, token_format_type, re.IGNORECASE).group(1) + unformatted_replace_with = attribute.get(attribute_value.split(":")[0].upper()) if unformatted_replace_with is not None: replace_with_date_object = datetime.strptime(str(unformatted_replace_with), "%Y%m%d") replace_with = replace_with_date_object.strftime(str(token_date_format)) @@ -307,5 +315,8 @@ def replace_with_formatting(attribute, attribute_value, date_pattern, replace_wi def get_all_valid_person_keys(person: Person) -> set[str]: all_keys = set() for item in person.data: - all_keys.update(item.keys()) + keys = item.keys() + for key in keys: + key.upper() + all_keys.update(keys) return all_keys diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 7e909df2e..b661afc95 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -7,10 +7,12 @@ from flask import Flask from freezegun import freeze_time from hamcrest import assert_that, contains_exactly, contains_inanyorder, has_item, has_items, is_, is_in +from pydantic import HttpUrl from eligibility_signposting_api.model import campaign_config as rules_model from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( + AvailableAction, CohortLabel, Description, RuleAttributeLevel, @@ -678,6 +680,136 @@ def test_no_active_campaign(faker: Faker): assert_that(actual, is_eligibility_status().with_conditions([])) +def test_eligibility_status_replaces_tokens_with_attribute_data(faker: Faker): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + date_of_birth = DateOfBirth(datetime.date(2025, 5, 10)) + + person_rows = person_rows_builder( + nhs_number, + date_of_birth=date_of_birth, + cohorts=["cohort_1", "cohort_2", "cohort_3"], + vaccines=[("RSV", datetime.date(2024, 1, 3))], + icb="QE1", + gp_practice=None, + ) + + person_attribute_token = "DOB: [[PERSON.DATE_OF_BIRTH]]" + target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + available_action = AvailableAction( + ActionType="ButtonAuthLink", + ExternalRoutingCode="BookNBS", + ActionDescription="## Get vaccinated at your GP surgery in [[PERSON.ICB]].", + UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), + UrlLabel="Your GP practice code is [[PERSON.GP_PRACTICE]].", + ) + + campaign_configs = [ + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[ + rule_builder.IterationCohortFactory.build( + cohort_label="cohort_1", positive_description=person_attribute_token + ), + rule_builder.IterationCohortFactory.build( + cohort_label="cohort_2", positive_description=target_attribute_token + ), + ], + iteration_rules=[ + rule_builder.PersonAgeSuppressionRuleFactory.build(), + rule_builder.ICBNonActionableActionRuleFactory.build(comms_routing="TOKEN_TEST"), + ], + actions_mapper=rule_builder.ActionsMapperFactory.build(root={"TOKEN_TEST": available_action}), + ) + ], + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") + + # Then + assert_that( + actual, + is_eligibility_status().with_conditions( + has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_actionable)) + ), + ) + + assert actual.conditions[0].cohort_results[0].description == "DOB: 20250510" + assert actual.conditions[0].cohort_results[1].description == "LAST_SUCCESSFUL_DATE: 03 January 2024" + assert actual.conditions[0].actions[0].action_description == "## Get vaccinated at your GP surgery in QE1." + assert actual.conditions[0].actions[0].url_label == "Your GP practice code is ." + + +def test_eligibility_status_with_invalid_tokens_raises_attribute_error(faker: Faker): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + date_of_birth = DateOfBirth(datetime.date(2025, 5, 10)) + + person_rows = person_rows_builder( + nhs_number, date_of_birth=date_of_birth, cohorts=["cohort_1"], vaccines=[("RSV", datetime.date(2024, 1, 3))] + ) + + target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE:INVALID_DATE_FORMAT(%d %B %Y)]]" + campaign_configs = [ + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[ + rule_builder.IterationCohortFactory.build( + cohort_label="cohort_1", positive_description=target_attribute_token + ), + ], + iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], + ) + ], + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + with pytest.raises(AttributeError): + calculator.get_eligibility_status("Y", ["ALL"], "ALL") + + +def test_eligibility_status_with_invalid_person_attribute_name_raises_value_error(faker: Faker): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + date_of_birth = DateOfBirth(datetime.date(2025, 5, 10)) + + person_rows = person_rows_builder( + nhs_number, date_of_birth=date_of_birth, cohorts=["cohort_1"], vaccines=[("RSV", datetime.date(2024, 1, 3))] + ) + + target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.ICECREAM]]" + campaign_configs = [ + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[ + rule_builder.IterationCohortFactory.build( + cohort_label="cohort_1", positive_description=target_attribute_token + ), + ], + iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], + ) + ], + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + with pytest.raises(ValueError): + calculator.get_eligibility_status("Y", ["ALL"], "ALL") + + class TestEligibilityResultBuilder: def test_build_condition_results_single_condition_single_cohort_actionable(self): cohort_group_results = [CohortGroupResult("COHORT_A", Status.actionable, [], "Cohort A Description", [])] @@ -1176,11 +1308,25 @@ def test_invalid_token_on_person_attribute_should_raise_error(self): with pytest.raises(ValueError): EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + def test_invalid_token_should_raise_error(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your favourite flavor is: [[ICECREAM.FLAVOR]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + with pytest.raises(ValueError): + EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + def test_invalid_token_on_target_attribute_should_raise_error(self): person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}]) condition = Condition( - condition_name=ConditionName("Condition name is [[TARGET.RSV.CONDITION_NAME]]"), + condition_name=ConditionName("Condition name is [[TARGET.RSV.ICECREAM]]"), status=Status.actionable, status_text=StatusText("Some status"), cohort_results=[], @@ -1232,7 +1378,7 @@ def test_simple_string_with_multiple_tokens(self): condition_name=ConditionName("RSV"), status=Status.actionable, status_text=StatusText( - "You are a [[PERSON.QUALITY]] [[PERSON.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." + "You are a [[PERSON.QUALITY]] [[person.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." ), cohort_results=[], suitability_rules=[], @@ -1322,6 +1468,8 @@ def test_valid_token_invalid_format_should_raise_error(self, token_format: str): (":DATE(%A, %d %B %Y)", "Tuesday, 27 March 1990"), (":DATE(%A, (%d) %B %Y)", "Tuesday, (27"), (":DATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), + (":dATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), + (":date(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), ], ) def test_valid_date_format(self, token_format: str, expected: str, faker: Faker): @@ -1343,3 +1491,38 @@ def test_valid_date_format(self, token_format: str, expected: str, faker: Faker) actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) assert actual.condition_name == f"Date: {expected}" + + @pytest.mark.parametrize( + ("token", "expected"), + [ + ("[[person.DATE_OF_BIRTH:DATE(%d %B %Y)]]", "27 March 1990"), + ("[[PERSON.date_of_birth:DATE(%d %B %Y)]]", "27 March 1990"), + ("[[PERSON.DATE_OF_BIRTH:date(%d %B %Y)]]", "27 March 1990"), + ("[[pErSoN.DATE_OF_BIRTH:DATE(%d %B %Y)]]", "27 March 1990"), + ("[[target.RSV.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.rsv.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.RSV.last_successful_date:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.RSV.last_successful_date:date(%-d %B %Y)]]", "1 January 2025"), + ], + ) + def test_token_replace_is_case_insensitive(self, token: str, expected: str, faker: Faker): + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + ] + ) + + condition = Condition( + condition_name=ConditionName(f"RSV vaccine on: {token}."), + status=Status.actionable, + status_text=StatusText(f"Your DOB is: {token}."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + result = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) + + assert result.status_text == f"Your DOB is: {expected}." + assert result.condition_name == f"RSV vaccine on: {expected}." From 0a2a6d722a04dce5e668895bbfc4f31636ced417 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:59:32 +0100 Subject: [PATCH 09/28] ELI-223: Supports token replacement in audit --- .../services/calculators/eligibility_calculator.py | 12 +++++++----- tests/integration/conftest.py | 6 ++++-- .../integration/lambda/test_app_running_as_lambda.py | 12 ++++++++++-- .../calculators/test_eligibility_calculator.py | 4 ++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index b4d46f913..140bbc3a5 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -103,17 +103,17 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca include_actions_flag=include_actions_flag, ) + best_iteration_result = self.find_and_replace_tokens_recursive(self.person, best_iteration_result) + matched_action_detail = self.find_and_replace_tokens_recursive(self.person, matched_action_detail) + condition_results[condition_name] = best_iteration_result.iteration_result condition_results[condition_name].actions = matched_action_detail.actions condition: Condition = self.build_condition( iteration_result=condition_results[condition_name], condition_name=condition_name ) - condition_with_replaced_tokens = EligibilityCalculator.find_and_replace_tokens_recursive( - self.person, condition - ) - final_result.append(condition_with_replaced_tokens) + final_result.append(condition) AuditContext.append_audit_condition( condition_name, @@ -296,7 +296,9 @@ def replace_token(text: str, person: Person) -> str: return text @staticmethod - def replace_with_formatting(attribute, attribute_value, date_pattern, replace_with): + def replace_with_formatting( + attribute: dict[str, T], attribute_value: T, date_pattern: str, replace_with: str + ) -> str: try: if len(attribute_value.split(":")) > 1: token_format_type = attribute_value.split(":")[1] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b7ee18261..1b3969f78 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -597,8 +597,10 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter, priority=8, cohort_label="cohort_label4"), ], targets[1]: [ - rule.PersonAgeSuppressionRuleFactory.build(description="TOO YOUNG"), - rule.PostcodeSuppressionRuleFactory.build(priority=12, cohort_label="cohort_label2"), + rule.PersonAgeSuppressionRuleFactory.build(description="TOO YOUNG, your icb is: [[PERSON.ICB]]"), + rule.PostcodeSuppressionRuleFactory.build( + priority=12, cohort_label="cohort_label2", description="Your postcode is: [[PERSON.POSTCODE]]" + ), ], targets[2]: [rule.ICBRedirectRuleFactory.build()], } diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index c54ea08c2..7b0aa2cea 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -495,8 +495,16 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # ], "filterRules": None, "suitabilityRules": [ - {"rulePriority": "10", "ruleName": "Exclude too young less than 75", "ruleMessage": "TOO YOUNG"}, - {"rulePriority": "12", "ruleName": "Excluded postcode In SW19", "ruleMessage": "In SW19"}, + { + "rulePriority": "10", + "ruleName": "Exclude too young less than 75", + "ruleMessage": "TOO YOUNG, your icb is: QE1", + }, + { + "rulePriority": "12", + "ruleName": "Excluded postcode In SW19", + "ruleMessage": "Your postcode is: SW19", + }, ], "actionRule": None, "actions": [], diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index b661afc95..efe782f62 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -1472,7 +1472,7 @@ def test_valid_token_invalid_format_should_raise_error(self, token_format: str): (":date(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), ], ) - def test_valid_date_format(self, token_format: str, expected: str, faker: Faker): + def test_valid_date_format(self, token_format: str, expected: str): person = Person( [ {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "19900327"}, @@ -1505,7 +1505,7 @@ def test_valid_date_format(self, token_format: str, expected: str, faker: Faker) ("[[TARGET.RSV.last_successful_date:date(%-d %B %Y)]]", "1 January 2025"), ], ) - def test_token_replace_is_case_insensitive(self, token: str, expected: str, faker: Faker): + def test_token_replace_is_case_insensitive(self, token: str, expected: str): person = Person( [ {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, From 5ba36a65935e82b1d8568ade5499054831c04e7f Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:27:04 +0100 Subject: [PATCH 10/28] ELI-223: Token Parser --- .../services/calculators/token_parser.py | 42 +++++++++++++++++++ .../services/calculators/test_token_parser.py | 31 ++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/eligibility_signposting_api/services/calculators/token_parser.py create mode 100644 tests/unit/services/calculators/test_token_parser.py diff --git a/src/eligibility_signposting_api/services/calculators/token_parser.py b/src/eligibility_signposting_api/services/calculators/token_parser.py new file mode 100644 index 000000000..90150b0cd --- /dev/null +++ b/src/eligibility_signposting_api/services/calculators/token_parser.py @@ -0,0 +1,42 @@ +import re +from dataclasses import dataclass + + +@dataclass +class ParsedToken: + attribute_level: str # example: "PERSON" or "TARGET" + attribute_name: str # example: "POSTCODE" or "RSV" + attribute_value: str | None # example: "LAST_SUCCESSFUL_DATE" if attribute_level is TARGET + format: str | None # example: "%d %B %Y" if DATE formatting is used + + +class TokenParser: + @staticmethod + def parse(token: str) -> ParsedToken: + + token_body = token[2:-2] # Strip the surrounding [[ ]] + token_parts = token_body.split(".") # Split by dot to separate level and attribute(s) + + if len(token_parts) < 2: + raise ValueError("Invalid token format.") + + token_level = token_parts[0].upper() + token_name = token_parts[-1] + + # Check if the name contains a date format + format_match = re.search(r":DATE\((.*?)\)", token_name, re.IGNORECASE) + format_str = format_match.group(1) if format_match else None + + # Remove the date format from the last part + last_part = re.sub(r":DATE\(.*?\)", "", token_name, flags=re.IGNORECASE) + + if len(token_parts) == 2: + # Person token, example, [[PERSON.AGE]] + name = last_part.upper() + value = None + else: + # Target token, example, [[TARGET.RSV.LAST_SUCCESSFUL_DATE]] + name = token_parts[1].upper() + value = last_part.upper() + + return ParsedToken(attribute_level=token_level, attribute_name=name, attribute_value=value, format=format_str) diff --git a/tests/unit/services/calculators/test_token_parser.py b/tests/unit/services/calculators/test_token_parser.py new file mode 100644 index 000000000..8da9aa64d --- /dev/null +++ b/tests/unit/services/calculators/test_token_parser.py @@ -0,0 +1,31 @@ +import pytest + +from eligibility_signposting_api.services.calculators.token_parser import TokenParser + + +class TestTokenParser: + @pytest.mark.parametrize( + "token, expected_level, expected_name, expected_value, expected_format", + [ + ("[[PERSON.AGE]]", "PERSON", "AGE", None, None), + ("[[TARGET.RSV.LAST_SUCCESSFUL_DATE]]", "TARGET", "RSV", "LAST_SUCCESSFUL_DATE", None), + ("[[PERSON.DATE_OF_BIRTH:DATE(%Y-%m-%d)]]", "PERSON", "DATE_OF_BIRTH", None, "%Y-%m-%d"), + ("[[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]", "TARGET", "RSV", "LAST_SUCCESSFUL_DATE", "%d %B %Y"), + ("[[PERSON.DATE_OF_BIRTH:DATE()]]", "PERSON", "DATE_OF_BIRTH", None, ""), + ("[[person.age]]", "PERSON", "AGE", None, None), + ("[[PERSON.age]]", "PERSON", "AGE", None, None), + ("[[TARGET.RSV.last_successful_date]]", "TARGET", "RSV", "LAST_SUCCESSFUL_DATE", None), + ("[[PERSON.DATE_OF_BIRTH:date(%Y-%m-%d)]]", "PERSON", "DATE_OF_BIRTH", None, "%Y-%m-%d"), + ("[[PERSON.AGE.EXTRA]]", "PERSON", "AGE", "EXTRA", None), + ], + ) + def test_parse_valid_tokens(self, token, expected_level, expected_name, expected_value, expected_format): + parsed_token = TokenParser.parse(token) + assert parsed_token.attribute_level == expected_level + assert parsed_token.attribute_name == expected_name + assert parsed_token.attribute_value == expected_value + assert parsed_token.format == expected_format + + def test_parse_malformed_token_raises_error(self): + with pytest.raises(ValueError): + TokenParser.parse("[[PERSONAGE]]") From 27f9918099d015318ea150219e4890130f33e536 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:09:43 +0100 Subject: [PATCH 11/28] ELI-223: Integrate token parser in calculator --- .../calculators/eligibility_calculator.py | 55 +++++++------------ .../services/calculators/token_parser.py | 7 ++- .../test_eligibility_calculator.py | 9 +-- 3 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 140bbc3a5..642d339cf 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -22,6 +22,7 @@ Reason, Status, ) +from eligibility_signposting_api.services.calculators.token_parser import TokenParser from eligibility_signposting_api.services.processors.action_rule_handler import ActionRuleHandler from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor @@ -252,16 +253,15 @@ def replace_token(text: str, person: Person) -> str: return text pattern = r"\[\[.*?\]\]" - date_pattern = r"\DATE\((.*?)\)" all_tokens = re.findall(pattern, text, re.IGNORECASE) + valid_person_keys = EligibilityCalculator.get_all_valid_person_keys(person) for token in all_tokens: - middle = token[2:-2] + parsed_token = TokenParser.parse(token) try: - attribute_level = middle.split(".")[0].upper() - attribute_name = middle.split(".")[1] + attribute_level = parsed_token.attribute_level + attribute_name = parsed_token.attribute_name replace_with = "" - valid_person_keys = EligibilityCalculator.get_all_valid_person_keys(person) allowed_attribute_levels = ["PERSON", "TARGET"] for attribute in person.data: @@ -269,24 +269,17 @@ def replace_token(text: str, person: Person) -> str: raise ValueError(f"Invalid attribute level '{attribute_level}' in token '{token}'.") if attribute_level == "PERSON" and attribute.get("ATTRIBUTE_TYPE") == "PERSON": - if attribute_name.split(":")[0].upper() in valid_person_keys: - replace_with = EligibilityCalculator.replace_with_formatting( - attribute, attribute_name, date_pattern, replace_with - ) + if attribute_name in valid_person_keys: + replace_with = EligibilityCalculator.replace_token_with_formatting(attribute, attribute_name, parsed_token.format) else: raise ValueError(f"Invalid attribute name '{attribute_name}' in token '{token}'.") - if attribute_level == "TARGET": - if attribute.get("ATTRIBUTE_TYPE") == attribute_name.upper(): - attribute_value = middle.split(".")[2] - if attribute_value.split(":")[0].upper() in valid_person_keys: - replace_with = EligibilityCalculator.replace_with_formatting( - attribute, attribute_value, date_pattern, replace_with - ) - else: - raise ValueError( - f"Invalid target attribute name '{attribute_value}' in token '{token}'." - ) + if attribute_level == "TARGET" and attribute.get("ATTRIBUTE_TYPE") == attribute_name.upper(): + attribute_value = parsed_token.attribute_value + if attribute_value in valid_person_keys: + replace_with = EligibilityCalculator.replace_token_with_formatting(attribute, attribute_value, parsed_token.format) + else: + raise ValueError(f"Invalid target attribute name '{attribute_value}' in token '{token}'.") text = text.replace(token, str(replace_with)) @@ -296,19 +289,14 @@ def replace_token(text: str, person: Person) -> str: return text @staticmethod - def replace_with_formatting( - attribute: dict[str, T], attribute_value: T, date_pattern: str, replace_with: str - ) -> str: + def replace_token_with_formatting(attribute: dict[str, T], attribute_value: T, date_format: str | None) -> str: try: - if len(attribute_value.split(":")) > 1: - token_format_type = attribute_value.split(":")[1] - token_date_format = re.search(date_pattern, token_format_type, re.IGNORECASE).group(1) - unformatted_replace_with = attribute.get(attribute_value.split(":")[0].upper()) - if unformatted_replace_with is not None: - replace_with_date_object = datetime.strptime(str(unformatted_replace_with), "%Y%m%d") - replace_with = replace_with_date_object.strftime(str(token_date_format)) + attribute_data = attribute.get(attribute_value) + if (date_format or date_format == "") and attribute_data: + replace_with_date_object = datetime.strptime(str(attribute_data), "%Y%m%d") + replace_with = replace_with_date_object.strftime(str(date_format)) else: - replace_with = attribute.get(attribute_value) if attribute.get(attribute_value) else "" + replace_with = attribute_data if attribute_data else "" return replace_with except AttributeError: raise AttributeError("Invalid token format") @@ -317,8 +305,5 @@ def replace_with_formatting( def get_all_valid_person_keys(person: Person) -> set[str]: all_keys = set() for item in person.data: - keys = item.keys() - for key in keys: - key.upper() - all_keys.update(keys) + all_keys.update(item.keys()) return all_keys diff --git a/src/eligibility_signposting_api/services/calculators/token_parser.py b/src/eligibility_signposting_api/services/calculators/token_parser.py index 90150b0cd..b93962a65 100644 --- a/src/eligibility_signposting_api/services/calculators/token_parser.py +++ b/src/eligibility_signposting_api/services/calculators/token_parser.py @@ -18,13 +18,16 @@ def parse(token: str) -> ParsedToken: token_parts = token_body.split(".") # Split by dot to separate level and attribute(s) if len(token_parts) < 2: - raise ValueError("Invalid token format.") + raise ValueError("Invalid token.") token_level = token_parts[0].upper() token_name = token_parts[-1] # Check if the name contains a date format - format_match = re.search(r":DATE\((.*?)\)", token_name, re.IGNORECASE) + format_match = re.search(r":DATE\(([^()]*)\)", token_name, re.IGNORECASE) + if not format_match and len(token_name.split(":")) > 1: + raise ValueError("Invalid token format.") + format_str = format_match.group(1) if format_match else None # Remove the date format from the last part diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index efe782f62..cd8fd09c1 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -774,7 +774,7 @@ def test_eligibility_status_with_invalid_tokens_raises_attribute_error(faker: Fa calculator = EligibilityCalculator(person_rows, campaign_configs) - with pytest.raises(AttributeError): + with pytest.raises(ValueError): calculator.get_eligibility_status("Y", ["ALL"], "ALL") @@ -1439,7 +1439,9 @@ def test_valid_token_valid_format_should_replace_with_date_formatting(self): ":INVALID_DATE_FORMATTER(19900327)", ":()", ":FORMAT(DATE)", + ":FORMAT(BLAH)", ":DATE[%d %B %Y]", + ":DATE(%A, (%d) %B %Y)", ], ) def test_valid_token_invalid_format_should_raise_error(self, token_format: str): @@ -1454,19 +1456,18 @@ def test_valid_token_invalid_format_should_raise_error(self, token_format: str): actions=[], ) - with pytest.raises(AttributeError): + with pytest.raises(ValueError): EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) @pytest.mark.parametrize( ("token_format", "expected"), [ - (":DATE(%d %B %Y)", "27 March 1990"), (":DATE(%d %b %Y)", "27 Mar 1990"), (":DATE()", ""), ("", "19900327"), (":DATE(random_value)", "random_value"), + (":DATE(%d %B %Y)", "27 March 1990"), (":DATE(%A, %d %B %Y)", "Tuesday, 27 March 1990"), - (":DATE(%A, (%d) %B %Y)", "Tuesday, (27"), (":DATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), (":dATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), (":date(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), From ebe82e78a2a47cc6b3cd13f3e2af9a381e2a722b Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:24:32 +0100 Subject: [PATCH 12/28] ELI-223: Adds tests --- .../calculators/eligibility_calculator.py | 8 +++-- .../services/calculators/token_parser.py | 18 ++++++----- .../test_eligibility_calculator.py | 2 +- .../services/calculators/test_token_parser.py | 31 +++++++++++++++++-- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 642d339cf..ff8470359 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -270,14 +270,18 @@ def replace_token(text: str, person: Person) -> str: if attribute_level == "PERSON" and attribute.get("ATTRIBUTE_TYPE") == "PERSON": if attribute_name in valid_person_keys: - replace_with = EligibilityCalculator.replace_token_with_formatting(attribute, attribute_name, parsed_token.format) + replace_with = EligibilityCalculator.replace_token_with_formatting( + attribute, attribute_name, parsed_token.format + ) else: raise ValueError(f"Invalid attribute name '{attribute_name}' in token '{token}'.") if attribute_level == "TARGET" and attribute.get("ATTRIBUTE_TYPE") == attribute_name.upper(): attribute_value = parsed_token.attribute_value if attribute_value in valid_person_keys: - replace_with = EligibilityCalculator.replace_token_with_formatting(attribute, attribute_value, parsed_token.format) + replace_with = EligibilityCalculator.replace_token_with_formatting( + attribute, attribute_value, parsed_token.format + ) else: raise ValueError(f"Invalid target attribute name '{attribute_value}' in token '{token}'.") diff --git a/src/eligibility_signposting_api/services/calculators/token_parser.py b/src/eligibility_signposting_api/services/calculators/token_parser.py index b93962a65..7ee3af5a2 100644 --- a/src/eligibility_signposting_api/services/calculators/token_parser.py +++ b/src/eligibility_signposting_api/services/calculators/token_parser.py @@ -4,20 +4,24 @@ @dataclass class ParsedToken: - attribute_level: str # example: "PERSON" or "TARGET" - attribute_name: str # example: "POSTCODE" or "RSV" - attribute_value: str | None # example: "LAST_SUCCESSFUL_DATE" if attribute_level is TARGET - format: str | None # example: "%d %B %Y" if DATE formatting is used + attribute_level: str # example: "PERSON" or "TARGET" + attribute_name: str # example: "POSTCODE" or "RSV" + attribute_value: str | None # example: "LAST_SUCCESSFUL_DATE" if attribute_level is TARGET + format: str | None # example: "%d %B %Y" if DATE formatting is used class TokenParser: @staticmethod def parse(token: str) -> ParsedToken: + token_body = token[2:-2] # Strip the surrounding [[ ]] + # Check for empty body after stripping, e.g., '[[]]' + if not token_body: + raise ValueError("Invalid token.") - token_body = token[2:-2] # Strip the surrounding [[ ]] - token_parts = token_body.split(".") # Split by dot to separate level and attribute(s) + token_parts = token_body.split(".") - if len(token_parts) < 2: + # Check for empty parts created by leading/trailing dots or tokens with no dot + if len(token_parts) < 2 or not all(token_parts): raise ValueError("Invalid token.") token_level = token_parts[0].upper() diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index cd8fd09c1..5ccd6dcfa 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -774,7 +774,7 @@ def test_eligibility_status_with_invalid_tokens_raises_attribute_error(faker: Fa calculator = EligibilityCalculator(person_rows, campaign_configs) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Invalid token."): calculator.get_eligibility_status("Y", ["ALL"], "ALL") diff --git a/tests/unit/services/calculators/test_token_parser.py b/tests/unit/services/calculators/test_token_parser.py index 8da9aa64d..996169e18 100644 --- a/tests/unit/services/calculators/test_token_parser.py +++ b/tests/unit/services/calculators/test_token_parser.py @@ -26,6 +26,31 @@ def test_parse_valid_tokens(self, token, expected_level, expected_name, expected assert parsed_token.attribute_value == expected_value assert parsed_token.format == expected_format - def test_parse_malformed_token_raises_error(self): - with pytest.raises(ValueError): - TokenParser.parse("[[PERSONAGE]]") + @pytest.mark.parametrize( + "token", + [ + "[[.AGE]]", + "[[PERSON.]]", + "[[]]", + "[[PERSON]]", + "[[.PERSON.AGE]]", + "[[PERSON.AGE.]]", + ], + ) + def test_parse_invalid_tokens_raises_error(self, token): + with pytest.raises(ValueError, match="Invalid token."): + TokenParser.parse(token) + + @pytest.mark.parametrize( + "token", + [ + "[[PERSON.DATE_OF_BIRTH:DATE(]]", + "[[PERSON.DATE_OF_BIRTH:DATE)]]", + "[[PERSON.DATE_OF_BIRTH:DATE]]", + "[[PERSON.DATE_OF_BIRTH:INVALID_FORMAT(abc)]]", + "[[PERSON.DATE_OF_BIRTH:INVALID_FORMAT(a (b) c)]]", + ], + ) + def test_parse_invalid_token_format_raises_error(self, token): + with pytest.raises(ValueError, match="Invalid token format."): + TokenParser.parse(token) From 0231a27d65716a59c10770f2af03d8eee2a6f48c Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:27:15 +0100 Subject: [PATCH 13/28] ELI-223: Moves token logic to token_processor --- .../calculators/eligibility_calculator.py | 100 +---- .../token_parser.py | 0 .../services/processors/token_processor.py | 94 +++++ .../test_eligibility_calculator.py | 356 ------------------ .../test_token_parser.py | 3 +- .../processors/test_token_processor.py | 326 ++++++++++++++++ 6 files changed, 426 insertions(+), 453 deletions(-) rename src/eligibility_signposting_api/services/{calculators => processors}/token_parser.py (100%) create mode 100644 src/eligibility_signposting_api/services/processors/token_processor.py rename tests/unit/services/{calculators => processors}/test_token_parser.py (94%) create mode 100644 tests/unit/services/processors/test_token_processor.py diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index ff8470359..b74b1ea0e 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -1,10 +1,8 @@ from __future__ import annotations import logging -import re from collections import defaultdict -from dataclasses import dataclass, field, fields, is_dataclass -from datetime import datetime +from dataclasses import dataclass, field from itertools import chain from typing import TYPE_CHECKING, TypeVar @@ -22,10 +20,10 @@ Reason, Status, ) -from eligibility_signposting_api.services.calculators.token_parser import TokenParser from eligibility_signposting_api.services.processors.action_rule_handler import ActionRuleHandler from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor +from eligibility_signposting_api.services.processors.token_processor import TokenProcessor if TYPE_CHECKING: from collections.abc import Collection @@ -104,8 +102,8 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca include_actions_flag=include_actions_flag, ) - best_iteration_result = self.find_and_replace_tokens_recursive(self.person, best_iteration_result) - matched_action_detail = self.find_and_replace_tokens_recursive(self.person, matched_action_detail) + best_iteration_result = TokenProcessor.find_and_replace_tokens(self.person, best_iteration_result) + matched_action_detail = TokenProcessor.find_and_replace_tokens(self.person, matched_action_detail) condition_results[condition_name] = best_iteration_result.iteration_result condition_results[condition_name].actions = matched_action_detail.actions @@ -221,93 +219,3 @@ def deduplicate_reasons(group_results: list[CohortGroupResult]) -> list[Reason]: key = (reason.rule_type, reason.rule_priority) deduped.setdefault(key, reason) return list(deduped.values()) - - @staticmethod - def find_and_replace_tokens_recursive(person: Person, data_class: T) -> T: - if not is_dataclass(data_class): - return data_class - - for class_field in fields(data_class): - value = getattr(data_class, class_field.name) - - if isinstance(value, str): - setattr(data_class, class_field.name, EligibilityCalculator.replace_token(value, person)) - - elif isinstance(value, list): - for i, item in enumerate(value): - if is_dataclass(item): - value[i] = EligibilityCalculator.find_and_replace_tokens_recursive(person, item) - elif isinstance(item, str): - value[i] = EligibilityCalculator.replace_token(item, person) - - elif is_dataclass(value): - setattr( - data_class, class_field.name, EligibilityCalculator.find_and_replace_tokens_recursive(person, value) - ) - - return data_class - - @staticmethod - def replace_token(text: str, person: Person) -> str: - if not isinstance(text, str): - return text - - pattern = r"\[\[.*?\]\]" - all_tokens = re.findall(pattern, text, re.IGNORECASE) - valid_person_keys = EligibilityCalculator.get_all_valid_person_keys(person) - - for token in all_tokens: - parsed_token = TokenParser.parse(token) - try: - attribute_level = parsed_token.attribute_level - attribute_name = parsed_token.attribute_name - replace_with = "" - - allowed_attribute_levels = ["PERSON", "TARGET"] - for attribute in person.data: - if attribute_level not in allowed_attribute_levels: - raise ValueError(f"Invalid attribute level '{attribute_level}' in token '{token}'.") - - if attribute_level == "PERSON" and attribute.get("ATTRIBUTE_TYPE") == "PERSON": - if attribute_name in valid_person_keys: - replace_with = EligibilityCalculator.replace_token_with_formatting( - attribute, attribute_name, parsed_token.format - ) - else: - raise ValueError(f"Invalid attribute name '{attribute_name}' in token '{token}'.") - - if attribute_level == "TARGET" and attribute.get("ATTRIBUTE_TYPE") == attribute_name.upper(): - attribute_value = parsed_token.attribute_value - if attribute_value in valid_person_keys: - replace_with = EligibilityCalculator.replace_token_with_formatting( - attribute, attribute_value, parsed_token.format - ) - else: - raise ValueError(f"Invalid target attribute name '{attribute_value}' in token '{token}'.") - - text = text.replace(token, str(replace_with)) - - except ValueError as e: - raise ValueError(e) - - return text - - @staticmethod - def replace_token_with_formatting(attribute: dict[str, T], attribute_value: T, date_format: str | None) -> str: - try: - attribute_data = attribute.get(attribute_value) - if (date_format or date_format == "") and attribute_data: - replace_with_date_object = datetime.strptime(str(attribute_data), "%Y%m%d") - replace_with = replace_with_date_object.strftime(str(date_format)) - else: - replace_with = attribute_data if attribute_data else "" - return replace_with - except AttributeError: - raise AttributeError("Invalid token format") - - @staticmethod - def get_all_valid_person_keys(person: Person) -> set[str]: - all_keys = set() - for item in person.data: - all_keys.update(item.keys()) - return all_keys diff --git a/src/eligibility_signposting_api/services/calculators/token_parser.py b/src/eligibility_signposting_api/services/processors/token_parser.py similarity index 100% rename from src/eligibility_signposting_api/services/calculators/token_parser.py rename to src/eligibility_signposting_api/services/processors/token_parser.py diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py new file mode 100644 index 000000000..adbea238e --- /dev/null +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -0,0 +1,94 @@ +import re +from dataclasses import fields, is_dataclass +from datetime import datetime +from typing import TypeVar + +from wireup import service + +from eligibility_signposting_api.model.person import Person +from eligibility_signposting_api.services.processors.token_parser import TokenParser + +T = TypeVar("T") + + +@service +class TokenProcessor: + @staticmethod + def find_and_replace_tokens(person: Person, data_class: T) -> T: + if not is_dataclass(data_class): + return data_class + + for class_field in fields(data_class): + value = getattr(data_class, class_field.name) + + if isinstance(value, str): + setattr(data_class, class_field.name, TokenProcessor.replace_token(value, person)) + + elif isinstance(value, list): + for i, item in enumerate(value): + if is_dataclass(item): + value[i] = TokenProcessor.find_and_replace_tokens(person, item) + elif isinstance(item, str): + value[i] = TokenProcessor.replace_token(item, person) + + elif is_dataclass(value): + setattr(data_class, class_field.name, TokenProcessor.find_and_replace_tokens(person, value)) + + return data_class + + @staticmethod + def replace_token(text: str, person: Person) -> str: + if not isinstance(text, str): + return text + + pattern = r"\[\[.*?\]\]" + all_tokens = re.findall(pattern, text, re.IGNORECASE) + + for token in all_tokens: + parsed_token = TokenParser.parse(token) + found_attribute, key_to_replace = None, None + + attribute_level_map = { + "TARGET": parsed_token.attribute_value, + "PERSON": parsed_token.attribute_name, + } + + key_to_find = attribute_level_map.get(parsed_token.attribute_level) + + for attribute in person.data: + is_target_attribute = attribute.get("ATTRIBUTE_TYPE") == parsed_token.attribute_name.upper() + is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == "PERSON" + + if (is_target_attribute or is_person_attribute) and key_to_find in attribute: + found_attribute = attribute + key_to_replace = key_to_find + break + + if not found_attribute: + TokenProcessor.handle_token_not_found(parsed_token, token) + + replace_with = TokenProcessor.apply_formatting(found_attribute, key_to_replace, parsed_token.format) + text = text.replace(token, str(replace_with)) + + return text + + @staticmethod + def handle_token_not_found(parsed_token, token): + if parsed_token.attribute_level == "TARGET": + raise ValueError(f"Invalid attribute name '{parsed_token.attribute_value}' in token '{token}'.") + if parsed_token.attribute_level == "PERSON": + raise ValueError(f"Invalid attribute name '{parsed_token.attribute_name}' in token '{token}'.") + raise ValueError(f"Invalid attribute level '{parsed_token.attribute_level}' in token '{token}'.") + + @staticmethod + def apply_formatting(attribute: dict[str, T], attribute_value: T, date_format: str | None) -> str: + try: + attribute_data = attribute.get(attribute_value) + if (date_format or date_format == "") and attribute_data: + replace_with_date_object = datetime.strptime(str(attribute_data), "%Y%m%d") + replace_with = replace_with_date_object.strftime(str(date_format)) + else: + replace_with = attribute_data if attribute_data else "" + return replace_with + except AttributeError: + raise AttributeError("Invalid token format") diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 5ccd6dcfa..0b714fb38 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -39,10 +39,8 @@ RuleDescription, RulePriority, Status, - StatusText, SuggestedAction, ) -from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator from tests.fixtures.builders.model import rule as rule_builder from tests.fixtures.builders.model.eligibility import ReasonFactory @@ -1173,357 +1171,3 @@ def test_build_condition_results_single_cohort(self, reason_2, expected_reasons) assert_that(len(result.cohort_results), is_(1)) assert_that(result.cohort_results[0].reasons, contains_inanyorder(*expected_reasons)) - - -class TestTokenReplacement: - def test_simple_token_replacement(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) - - condition = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText("Your age is [[PERSON.AGE]]."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - expected = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText("Your age is 30."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual == expected - - def test_deep_nesting_token_replacement(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) - - reason1 = Reason( - RuleType.suppression, - eligibility_status.RuleName("Rule1"), - RulePriority("1"), - RuleDescription("This is a rule."), - matcher_matched=False, - ) - reason2 = Reason( - RuleType.filter, - eligibility_status.RuleName("Rule2"), - RulePriority("1"), - RuleDescription("Rule [[PERSON.AGE]] here."), - matcher_matched=True, - ) - - cohort_result = CohortGroupResult( - cohort_code="CohortCode", - status=Status.actionable, - reasons=[reason1, reason2], - description="Results for cohort [[PERSON.AGE]].", - audit_rules=[], - ) - - condition = Condition( - condition_name=ConditionName("ConditionName"), - status=Status.not_actionable, - status_text=StatusText("Everything is [[PERSON.QUALITY]]."), - cohort_results=[cohort_result], - suitability_rules=[], - actions=[], - ) - - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual.cohort_results[0].description == "Results for cohort 30." - assert actual.cohort_results[0].reasons[1].rule_description == "Rule 30 here." - assert actual.status_text == StatusText("Everything is NICE.") - - def test_get_all_person_keys(self): - person = Person( - [ - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "rsv_75_rolling", "DATE_JOINED": "20231020"}], - }, - { - "NHS_NUMBER": "5000000009", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "<>", - "GENDER": "0", - "POSTCODE": "LS1 1AB", - "POSTCODE_SECTOR": "LS1", - "POSTCODE_OUTCODE": "1AB", - "MSOA": "E02001111", - "LSOA": "E01005348", - "GP_PRACTICE_CODE": "B87008", - "PCN": "U43084", - "ICB": "QWO", - "COMMISSIONING_REGION": "Y63", - "13Q_FLAG": "N", - "CARE_HOME_FLAG": "N", - "DE_FLAG": "N", - }, - {"NHS_NUMBER": "5000000009", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250326"}, - ] - ) - keys = EligibilityCalculator.get_all_valid_person_keys(person) - - assert keys == { - "13Q_FLAG", - "ATTRIBUTE_TYPE", - "CARE_HOME_FLAG", - "COHORT_MEMBERSHIPS", - "COMMISSIONING_REGION", - "DATE_OF_BIRTH", - "DE_FLAG", - "GENDER", - "GP_PRACTICE_CODE", - "ICB", - "LAST_SUCCESSFUL_DATE", - "LSOA", - "MSOA", - "NHS_NUMBER", - "PCN", - "POSTCODE", - "POSTCODE_OUTCODE", - "POSTCODE_SECTOR", - } - - def test_invalid_token_on_person_attribute_should_raise_error(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) - - condition = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText("Your age is [[PERSON.ICECREAM]]."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - with pytest.raises(ValueError): - EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - def test_invalid_token_should_raise_error(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) - - condition = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText("Your favourite flavor is: [[ICECREAM.FLAVOR]]."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - with pytest.raises(ValueError): - EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - def test_invalid_token_on_target_attribute_should_raise_error(self): - person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}]) - - condition = Condition( - condition_name=ConditionName("Condition name is [[TARGET.RSV.ICECREAM]]"), - status=Status.actionable, - status_text=StatusText("Some status"), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - with pytest.raises(ValueError): - EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - def test_valid_token_but_missing_attribute_data_to_replace(self): - person = Person( - [ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "POSTCODE": None}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": None}, - {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, - ] - ) - - condition = Condition( - condition_name=ConditionName( - "You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" - ), - status=Status.actionable, - status_text=StatusText("You are from [[PERSON.POSTCODE]]."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - expected = Condition( - condition_name=ConditionName("You had your RSV vaccine on "), - status=Status.actionable, - status_text=StatusText("You are from ."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual.status_text == expected.status_text - assert actual.condition_name == expected.condition_name - - def test_simple_string_with_multiple_tokens(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) - - condition = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText( - "You are a [[PERSON.QUALITY]] [[person.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." - ), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - expected = Condition( - condition_name=ConditionName("RSV"), - status=Status.actionable, - status_text=StatusText("You are a NICE NICE DOCTOR and your age is 30."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual == expected - - def test_valid_token_valid_format_should_replace_with_date_formatting(self): - person = Person( - [ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, - {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, - ] - ) - - condition = Condition( - condition_name=ConditionName( - "You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" - ), - status=Status.actionable, - status_text=StatusText("Your birthday is on [[PERSON.DATE_OF_BIRTH:DATE(%-d %B %Y)]]"), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - expected = Condition( - condition_name=ConditionName("You had your RSV vaccine on 01 January 2025"), - status=Status.actionable, - status_text=StatusText("Your birthday is on 27 March 1990"), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual.condition_name == expected.condition_name - assert actual.status_text == expected.status_text - - @pytest.mark.parametrize( - "token_format", - [ - ":INVALID_DATE_FORMATTER(%ABC)", - ":INVALID_DATE_FORMATTER(19900327)", - ":()", - ":FORMAT(DATE)", - ":FORMAT(BLAH)", - ":DATE[%d %B %Y]", - ":DATE(%A, (%d) %B %Y)", - ], - ) - def test_valid_token_invalid_format_should_raise_error(self, token_format: str): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}]) - - condition = Condition( - condition_name=ConditionName("You had your RSV vaccine"), - status=Status.actionable, - status_text=StatusText(f"Your birthday is on [[PERSON.DATE_OF_BIRTH{token_format}]]"), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - with pytest.raises(ValueError): - EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - @pytest.mark.parametrize( - ("token_format", "expected"), - [ - (":DATE(%d %b %Y)", "27 Mar 1990"), - (":DATE()", ""), - ("", "19900327"), - (":DATE(random_value)", "random_value"), - (":DATE(%d %B %Y)", "27 March 1990"), - (":DATE(%A, %d %B %Y)", "Tuesday, 27 March 1990"), - (":DATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), - (":dATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), - (":date(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), - ], - ) - def test_valid_date_format(self, token_format: str, expected: str): - person = Person( - [ - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "19900327"}, - ] - ) - - condition = Condition( - condition_name=ConditionName(f"Date: [[TARGET.RSV.LAST_SUCCESSFUL_DATE{token_format}]]"), - status=Status.actionable, - status_text=StatusText("Some text"), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - actual = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert actual.condition_name == f"Date: {expected}" - - @pytest.mark.parametrize( - ("token", "expected"), - [ - ("[[person.DATE_OF_BIRTH:DATE(%d %B %Y)]]", "27 March 1990"), - ("[[PERSON.date_of_birth:DATE(%d %B %Y)]]", "27 March 1990"), - ("[[PERSON.DATE_OF_BIRTH:date(%d %B %Y)]]", "27 March 1990"), - ("[[pErSoN.DATE_OF_BIRTH:DATE(%d %B %Y)]]", "27 March 1990"), - ("[[target.RSV.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), - ("[[TARGET.rsv.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), - ("[[TARGET.RSV.last_successful_date:DATE(%-d %B %Y)]]", "1 January 2025"), - ("[[TARGET.RSV.last_successful_date:date(%-d %B %Y)]]", "1 January 2025"), - ], - ) - def test_token_replace_is_case_insensitive(self, token: str, expected: str): - person = Person( - [ - {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, - ] - ) - - condition = Condition( - condition_name=ConditionName(f"RSV vaccine on: {token}."), - status=Status.actionable, - status_text=StatusText(f"Your DOB is: {token}."), - cohort_results=[], - suitability_rules=[], - actions=[], - ) - - result = EligibilityCalculator.find_and_replace_tokens_recursive(person, condition) - - assert result.status_text == f"Your DOB is: {expected}." - assert result.condition_name == f"RSV vaccine on: {expected}." diff --git a/tests/unit/services/calculators/test_token_parser.py b/tests/unit/services/processors/test_token_parser.py similarity index 94% rename from tests/unit/services/calculators/test_token_parser.py rename to tests/unit/services/processors/test_token_parser.py index 996169e18..38862f340 100644 --- a/tests/unit/services/calculators/test_token_parser.py +++ b/tests/unit/services/processors/test_token_parser.py @@ -1,6 +1,6 @@ import pytest -from eligibility_signposting_api.services.calculators.token_parser import TokenParser +from eligibility_signposting_api.services.processors.token_parser import TokenParser class TestTokenParser: @@ -49,6 +49,7 @@ def test_parse_invalid_tokens_raises_error(self, token): "[[PERSON.DATE_OF_BIRTH:DATE]]", "[[PERSON.DATE_OF_BIRTH:INVALID_FORMAT(abc)]]", "[[PERSON.DATE_OF_BIRTH:INVALID_FORMAT(a (b) c)]]", + "[[PERSON.DATE_OF_BIRTH:DATE(a (b) c)]]", ], ) def test_parse_invalid_token_format_raises_error(self, token): diff --git a/tests/unit/services/processors/test_token_processor.py b/tests/unit/services/processors/test_token_processor.py new file mode 100644 index 000000000..7be5e6dea --- /dev/null +++ b/tests/unit/services/processors/test_token_processor.py @@ -0,0 +1,326 @@ +import re + +import pytest + +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.eligibility_status import ( + CohortGroupResult, + Condition, + ConditionName, + Reason, + RuleDescription, + RulePriority, + RuleType, + Status, + StatusText, +) +from eligibility_signposting_api.model.person import Person +from eligibility_signposting_api.services.processors.token_processor import TokenProcessor + + +class TestTokenProcessor: + def test_simple_token_replacement(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your age is [[PERSON.AGE]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your age is 30."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = TokenProcessor.find_and_replace_tokens(person, condition) + + assert actual == expected + + def test_deep_nesting_token_replacement(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) + + reason1 = Reason( + RuleType.suppression, + eligibility_status.RuleName("Rule1"), + RulePriority("1"), + RuleDescription("This is a rule."), + matcher_matched=False, + ) + reason2 = Reason( + RuleType.filter, + eligibility_status.RuleName("Rule2"), + RulePriority("1"), + RuleDescription("Rule [[PERSON.AGE]] here."), + matcher_matched=True, + ) + + cohort_result = CohortGroupResult( + cohort_code="CohortCode", + status=Status.actionable, + reasons=[reason1, reason2], + description="Results for cohort [[PERSON.AGE]].", + audit_rules=[], + ) + + condition = Condition( + condition_name=ConditionName("ConditionName"), + status=Status.not_actionable, + status_text=StatusText("Everything is [[PERSON.QUALITY]]."), + cohort_results=[cohort_result], + suitability_rules=[], + actions=[], + ) + + actual = TokenProcessor.find_and_replace_tokens(person, condition) + + assert actual.cohort_results[0].description == "Results for cohort 30." + assert actual.cohort_results[0].reasons[1].rule_description == "Rule 30 here." + assert actual.status_text == StatusText("Everything is NICE.") + + def test_invalid_token_on_person_attribute_should_raise_error(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your age is [[PERSON.ICECREAM]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected_error = re.escape("Invalid attribute name 'ICECREAM' in token '[[PERSON.ICECREAM]]'.") + + with pytest.raises(ValueError, match=expected_error): + TokenProcessor.find_and_replace_tokens(person, condition) + + def test_invalid_token_should_raise_error(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("Your favourite flavor is: [[ICECREAM.FLAVOR]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected_error = re.escape("Invalid attribute level 'ICECREAM' in token '[[ICECREAM.FLAVOR]]'.") + with pytest.raises(ValueError, match=expected_error): + TokenProcessor.find_and_replace_tokens(person, condition) + + def test_invalid_token_on_target_attribute_should_raise_error(self): + person = Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}]) + + condition = Condition( + condition_name=ConditionName("Condition name is [[TARGET.RSV.ICECREAM]]"), + status=Status.actionable, + status_text=StatusText("Some status"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected_error = re.escape("Invalid attribute name 'ICECREAM' in token '[[TARGET.RSV.ICECREAM]]'.") + with pytest.raises(ValueError, match=expected_error): + TokenProcessor.find_and_replace_tokens(person, condition) + + def test_valid_token_but_missing_attribute_data_to_replace(self): + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "POSTCODE": None}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": None}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ] + ) + + condition = Condition( + condition_name=ConditionName( + "You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + ), + status=Status.actionable, + status_text=StatusText("You are from [[PERSON.POSTCODE]]."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("You had your RSV vaccine on "), + status=Status.actionable, + status_text=StatusText("You are from ."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = TokenProcessor.find_and_replace_tokens(person, condition) + + assert actual.status_text == expected.status_text + assert actual.condition_name == expected.condition_name + + def test_simple_string_with_multiple_tokens(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) + + condition = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText( + "You are a [[PERSON.QUALITY]] [[person.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." + ), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("RSV"), + status=Status.actionable, + status_text=StatusText("You are a NICE NICE DOCTOR and your age is 30."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = TokenProcessor.find_and_replace_tokens(person, condition) + + assert actual == expected + + def test_valid_token_valid_format_should_replace_with_date_formatting(self): + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ] + ) + + condition = Condition( + condition_name=ConditionName( + "You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + ), + status=Status.actionable, + status_text=StatusText("Your birthday is on [[PERSON.DATE_OF_BIRTH:DATE(%-d %B %Y)]]"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected = Condition( + condition_name=ConditionName("You had your RSV vaccine on 01 January 2025"), + status=Status.actionable, + status_text=StatusText("Your birthday is on 27 March 1990"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = TokenProcessor.find_and_replace_tokens(person, condition) + + assert actual.condition_name == expected.condition_name + assert actual.status_text == expected.status_text + + @pytest.mark.parametrize( + "token_format", + [ + ":INVALID_DATE_FORMATTER(%ABC)", + ":INVALID_DATE_FORMATTER(19900327)", + ":()", + ":FORMAT(DATE)", + ":FORMAT(BLAH)", + ":DATE[%d %B %Y]", + ":DATE(%A, (%d) %B %Y)", + ], + ) + def test_valid_token_invalid_format_should_raise_error(self, token_format: str): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}]) + + condition = Condition( + condition_name=ConditionName("You had your RSV vaccine"), + status=Status.actionable, + status_text=StatusText(f"Your birthday is on [[PERSON.DATE_OF_BIRTH{token_format}]]"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + with pytest.raises(ValueError, match="Invalid token format."): + TokenProcessor.find_and_replace_tokens(person, condition) + + @pytest.mark.parametrize( + ("token_format", "expected"), + [ + (":DATE(%d %b %Y)", "27 Mar 1990"), + (":DATE()", ""), + ("", "19900327"), + (":DATE(random_value)", "random_value"), + (":DATE(%d %B %Y)", "27 March 1990"), + (":DATE(%A, %d %B %Y)", "Tuesday, 27 March 1990"), + (":DATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), + (":dATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), + (":date(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), + ], + ) + def test_valid_date_format(self, token_format: str, expected: str): + person = Person( + [ + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "19900327"}, + ] + ) + + condition = Condition( + condition_name=ConditionName(f"Date: [[TARGET.RSV.LAST_SUCCESSFUL_DATE{token_format}]]"), + status=Status.actionable, + status_text=StatusText("Some text"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = TokenProcessor.find_and_replace_tokens(person, condition) + + assert actual.condition_name == f"Date: {expected}" + + @pytest.mark.parametrize( + ("token", "expected"), + [ + ("[[person.DATE_OF_BIRTH:DATE(%d %B %Y)]]", "27 March 1990"), + ("[[PERSON.date_of_birth:DATE(%d %B %Y)]]", "27 March 1990"), + ("[[PERSON.DATE_OF_BIRTH:date(%d %B %Y)]]", "27 March 1990"), + ("[[pErSoN.DATE_OF_BIRTH:DATE(%d %B %Y)]]", "27 March 1990"), + ("[[target.RSV.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.rsv.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.RSV.last_successful_date:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.RSV.last_successful_date:date(%-d %B %Y)]]", "1 January 2025"), + ], + ) + def test_token_replace_is_case_insensitive(self, token: str, expected: str): + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, + {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + ] + ) + + condition = Condition( + condition_name=ConditionName(f"RSV vaccine on: {token}."), + status=Status.actionable, + status_text=StatusText(f"Your DOB is: {token}."), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + result = TokenProcessor.find_and_replace_tokens(person, condition) + + assert result.status_text == f"Your DOB is: {expected}." + assert result.condition_name == f"RSV vaccine on: {expected}." From b33606f93476081185c7d63b9a7336653f38ee49 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:40:40 +0100 Subject: [PATCH 14/28] ELI-223: Adds audit token replacement and tests --- .../services/processors/token_processor.py | 24 +++++- tests/integration/conftest.py | 86 +++++++++++++++++++ .../lambda/test_app_running_as_lambda.py | 50 +++++++++++ .../test_eligibility_calculator.py | 7 ++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index adbea238e..153cff0f3 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -30,6 +30,15 @@ def find_and_replace_tokens(person: Person, data_class: T) -> T: value[i] = TokenProcessor.find_and_replace_tokens(person, item) elif isinstance(item, str): value[i] = TokenProcessor.replace_token(item, person) + setattr(data_class, class_field.name, value) + + elif isinstance(value, dict): + for key, dict_value in value.items(): + if isinstance(dict_value, str): + value[key] = TokenProcessor.replace_token(dict_value, person) + elif is_dataclass(dict_value): + value[key] = TokenProcessor.find_and_replace_tokens(person, dict_value) + setattr(data_class, class_field.name, value) elif is_dataclass(value): setattr(data_class, class_field.name, TokenProcessor.find_and_replace_tokens(person, value)) @@ -43,6 +52,13 @@ def replace_token(text: str, person: Person) -> str: pattern = r"\[\[.*?\]\]" all_tokens = re.findall(pattern, text, re.IGNORECASE) + allowed_target_attributes = [ + "NHS_NUMBER", + "ATTRIBUTE_TYPE", + "LAST_SUCCESSFUL_DATE", + "OPTOUT", + "LAST_INVITE_DATE", + ] for token in all_tokens: parsed_token = TokenParser.parse(token) @@ -58,8 +74,14 @@ def replace_token(text: str, person: Person) -> str: for attribute in person.data: is_target_attribute = attribute.get("ATTRIBUTE_TYPE") == parsed_token.attribute_name.upper() is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == "PERSON" + is_target_rsv = True if parsed_token.attribute_name.upper() == "RSV" else False + + valid_person_attribute = is_person_attribute and key_to_find in attribute + valid_target_attribute = ( + is_target_attribute and is_target_rsv and key_to_find in allowed_target_attributes + ) - if (is_target_attribute or is_person_attribute) and key_to_find in attribute: + if valid_target_attribute or valid_person_attribute: found_attribute = attribute key_to_replace = key_to_find break diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 1b3969f78..a3c3b59c2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -19,6 +19,7 @@ from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( + AvailableAction, CampaignConfig, EndDate, RuleType, @@ -395,6 +396,38 @@ def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[e person_table.delete_item(Key={"NHS_NUMBER": row["NHS_NUMBER"], "ATTRIBUTE_TYPE": row["ATTRIBUTE_TYPE"]}) +@pytest.fixture +def person_with_all_data(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility_status.DateOfBirth(datetime.date(1990, 2, 28)) + + for row in ( + rows := person_rows_builder( + nhs_number=nhs_number, + date_of_birth=date_of_birth, + gender="0", + postcode="SW18", + cohorts=["cohort_label1", "cohort_label2"], + vaccines=[("RSV", None)], + icb="QE1", + gp_practice="C81002", + pcn="U78207", + comissioning_region="Y60", + thirteen_q=True, + care_home=True, + de=False, + msoa="E02001562", + lsoa="E01030316", + ).data + ): + person_table.put_item(Item=row) + + yield nhs_number + + for row in rows: + person_table.delete_item(Key={"NHS_NUMBER": row["NHS_NUMBER"], "ATTRIBUTE_TYPE": row["ATTRIBUTE_TYPE"]}) + + @pytest.fixture def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) @@ -585,6 +618,59 @@ def campaign_config_with_and_rule(s3_client: BaseClient, rules_bucket: BucketNam s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def campaign_config_with_tokens(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: + campaign: CampaignConfig = rule.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule.IterationFactory.build( + actions_mapper=rule.ActionsMapperFactory.build( + root={ + "TOKEN_TEST": AvailableAction( + ActionType="ButtonAuthLink", + ExternalRoutingCode="BookNBS", + ActionDescription="## Token - PERSON.POSTCODE: [[PERSON.POSTCODE]].", + UrlLabel="Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): [[PERSON.DATE_OF_BIRTH:DATE(%d %B %Y)]].", + ), + "TOKEN_TEST2": AvailableAction( + ActionType="ButtonAuthLink", + ExternalRoutingCode="BookNBS", + ActionDescription="## Token - PERSON.GENDER: [[PERSON.GENDER]].", + UrlLabel="Token - PERSON.DATE_OF_BIRTH: [[PERSON.DATE_OF_BIRTH]].", + ), + } + ), + iteration_rules=[ + rule.PostcodeSuppressionRuleFactory.build(), + rule.PersonAgeSuppressionRuleFactory.build(), + rule.ICBNonEligibleActionRuleFactory.build(comms_routing="TOKEN_TEST|TOKEN_TEST2"), + rule.ICBNonActionableActionRuleFactory.build(comms_routing="TOKEN_TEST"), + ], + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label="cohort1", + cohort_group="cohort_group1", + positive_description="Positive Description", + negative_description="Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE]]", + ), + rule.IterationCohortFactory.build( + cohort_label="cohort2", + cohort_group="cohort_group2", + positive_description="Positive Description", + negative_description="Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]", + ), + ], + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + s3_client.put_object( + Bucket=rules_bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json" + ) + yield campaign + s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") + + @pytest.fixture(scope="class") def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 7b0aa2cea..aeab8654a 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -585,3 +585,53 @@ def test_no_active_iteration_returns_empty_processed_suggestions( has_entries("condition", "FLU"), ), ) + + +def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 + lambda_client: BaseClient, # noqa:ARG001 + person_with_all_data: NHSNumber, + campaign_config_with_tokens: CampaignConfig, + s3_client: BaseClient, + audit_bucket: BucketName, + api_gateway_endpoint: URL, + flask_function: str, + logs_client: BaseClient, +): + # Given + # When + invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" + response = httpx.get( + invoke_url, + headers={"nhs-login-nhs-number": str(person_with_all_data)}, + timeout=10, + ) + + # Then + assert_that( + response, + is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))), + ) + + processed_suggestions = response.json()["processedSuggestions"][0] + + assert processed_suggestions["actions"][0]["description"] == "## Token - PERSON.POSTCODE: SW18." + assert processed_suggestions["actions"][0]["urlLabel"] == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + assert processed_suggestions["actions"][1]["description"] == "## Token - PERSON.GENDER: 0." + assert processed_suggestions["actions"][1]["urlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." + assert processed_suggestions["eligibilityCohorts"][0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " + assert processed_suggestions["eligibilityCohorts"][1]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + + # Then - check if audited + objects = s3_client.list_objects_v2(Bucket=audit_bucket).get("Contents", []) + object_keys = [obj["Key"] for obj in objects] + latest_key = sorted(object_keys)[-1] + audit_data = json.loads(s3_client.get_object(Bucket=audit_bucket, Key=latest_key)["Body"].read()) + + audit_condition = audit_data["response"]["condition"][0] + assert audit_condition["actions"][0]["actionDescription"] == "## Token - PERSON.POSTCODE: SW18." + assert audit_condition["actions"][0]["actionUrlLabel"] == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + assert audit_condition["actions"][1]["actionDescription"] == "## Token - PERSON.GENDER: 0." + assert audit_condition["actions"][1]["actionUrlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." + assert audit_condition["eligibilityCohortGroups"][0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " + assert audit_condition["eligibilityCohortGroups"][1]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 0b714fb38..70b8b275e 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -5,6 +5,7 @@ import pytest from faker import Faker from flask import Flask +from flask import g from freezegun import freeze_time from hamcrest import assert_that, contains_exactly, contains_inanyorder, has_item, has_items, is_, is_in from pydantic import HttpUrl @@ -743,6 +744,12 @@ def test_eligibility_status_replaces_tokens_with_attribute_data(faker: Faker): assert actual.conditions[0].actions[0].action_description == "## Get vaccinated at your GP surgery in QE1." assert actual.conditions[0].actions[0].url_label == "Your GP practice code is ." + audit_condition = g.audit_log.response.condition[0] + assert audit_condition.eligibility_cohort_groups[0].cohort_text in ["DOB: 20250510", "LAST_SUCCESSFUL_DATE: 03 January 2024"] + assert audit_condition.eligibility_cohort_groups[1].cohort_text in ["DOB: 20250510", "LAST_SUCCESSFUL_DATE: 03 January 2024"] + assert audit_condition.actions[0].action_description == "## Get vaccinated at your GP surgery in QE1." + assert audit_condition.actions[0].action_url_label == "Your GP practice code is ." + def test_eligibility_status_with_invalid_tokens_raises_attribute_error(faker: Faker): # Given From cfd8325adee4b7f682f5457b9630406a1ae67f18 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:46:32 +0100 Subject: [PATCH 15/28] ELI-223: Adds all valid TARGET fields --- .../services/processors/token_processor.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index 153cff0f3..57fc1773a 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -55,9 +55,14 @@ def replace_token(text: str, person: Person) -> str: allowed_target_attributes = [ "NHS_NUMBER", "ATTRIBUTE_TYPE", + "VALID_DOSES_COUNT", + "INVALID_DOSES_COUNT", "LAST_SUCCESSFUL_DATE", - "OPTOUT", + "LAST_VALID_DOSE_DATE", + "BOOKED_APPOINTMENT_DATE", + "BOOKED_APPOINTMENT_PROVIDER", "LAST_INVITE_DATE", + "LAST_INVITE_STATUS", ] for token in all_tokens: From ef1aa47da34c442f7c097537919b722bd4e4de96 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Fri, 22 Aug 2025 13:40:00 +0100 Subject: [PATCH 16/28] Added test case for clarity. --- tests/unit/services/processors/test_token_processor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/services/processors/test_token_processor.py b/tests/unit/services/processors/test_token_processor.py index 7be5e6dea..6beac64e5 100644 --- a/tests/unit/services/processors/test_token_processor.py +++ b/tests/unit/services/processors/test_token_processor.py @@ -263,6 +263,7 @@ def test_valid_token_invalid_format_should_raise_error(self, token_format: str): (":DATE()", ""), ("", "19900327"), (":DATE(random_value)", "random_value"), + (":DATE(random_value %Y)", "random_value 1990"), (":DATE(%d %B %Y)", "27 March 1990"), (":DATE(%A, %d %B %Y)", "Tuesday, 27 March 1990"), (":DATE(%A, {%d} %B %Y)", "Tuesday, {27} March 1990"), From 730fca8f55ece887a8444038547b07f34b17071c Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:08:10 +0100 Subject: [PATCH 17/28] Formatting. --- .../lambda/test_app_running_as_lambda.py | 21 ++++++++++++++----- .../test_eligibility_calculator.py | 13 ++++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index aeab8654a..a22460f26 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -615,11 +615,17 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 processed_suggestions = response.json()["processedSuggestions"][0] assert processed_suggestions["actions"][0]["description"] == "## Token - PERSON.POSTCODE: SW18." - assert processed_suggestions["actions"][0]["urlLabel"] == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + assert ( + processed_suggestions["actions"][0]["urlLabel"] + == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + ) assert processed_suggestions["actions"][1]["description"] == "## Token - PERSON.GENDER: 0." assert processed_suggestions["actions"][1]["urlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." assert processed_suggestions["eligibilityCohorts"][0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " - assert processed_suggestions["eligibilityCohorts"][1]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + assert ( + processed_suggestions["eligibilityCohorts"][1]["cohortText"] + == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + ) # Then - check if audited objects = s3_client.list_objects_v2(Bucket=audit_bucket).get("Contents", []) @@ -629,9 +635,14 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 audit_condition = audit_data["response"]["condition"][0] assert audit_condition["actions"][0]["actionDescription"] == "## Token - PERSON.POSTCODE: SW18." - assert audit_condition["actions"][0]["actionUrlLabel"] == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + assert ( + audit_condition["actions"][0]["actionUrlLabel"] + == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + ) assert audit_condition["actions"][1]["actionDescription"] == "## Token - PERSON.GENDER: 0." assert audit_condition["actions"][1]["actionUrlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." assert audit_condition["eligibilityCohortGroups"][0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " - assert audit_condition["eligibilityCohortGroups"][1]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " - + assert ( + audit_condition["eligibilityCohortGroups"][1]["cohortText"] + == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + ) diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 70b8b275e..68f958578 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -4,8 +4,7 @@ import pytest from faker import Faker -from flask import Flask -from flask import g +from flask import Flask, g from freezegun import freeze_time from hamcrest import assert_that, contains_exactly, contains_inanyorder, has_item, has_items, is_, is_in from pydantic import HttpUrl @@ -745,8 +744,14 @@ def test_eligibility_status_replaces_tokens_with_attribute_data(faker: Faker): assert actual.conditions[0].actions[0].url_label == "Your GP practice code is ." audit_condition = g.audit_log.response.condition[0] - assert audit_condition.eligibility_cohort_groups[0].cohort_text in ["DOB: 20250510", "LAST_SUCCESSFUL_DATE: 03 January 2024"] - assert audit_condition.eligibility_cohort_groups[1].cohort_text in ["DOB: 20250510", "LAST_SUCCESSFUL_DATE: 03 January 2024"] + assert audit_condition.eligibility_cohort_groups[0].cohort_text in [ + "DOB: 20250510", + "LAST_SUCCESSFUL_DATE: 03 January 2024", + ] + assert audit_condition.eligibility_cohort_groups[1].cohort_text in [ + "DOB: 20250510", + "LAST_SUCCESSFUL_DATE: 03 January 2024", + ] assert audit_condition.actions[0].action_description == "## Get vaccinated at your GP surgery in QE1." assert audit_condition.actions[0].action_url_label == "Your GP practice code is ." From 119917a5404e8d1b9d0e8b219fea7a37ef97085f Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:35:50 +0100 Subject: [PATCH 18/28] WIP: linting. --- .../services/processors/token_parser.py | 22 ++++++++++++++----- tests/integration/conftest.py | 13 ++++++++--- .../lambda/test_app_running_as_lambda.py | 6 ++--- .../test_eligibility_calculator.py | 10 ++++----- .../services/processors/test_token_parser.py | 2 +- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/token_parser.py b/src/eligibility_signposting_api/services/processors/token_parser.py index 7ee3af5a2..120aabfd5 100644 --- a/src/eligibility_signposting_api/services/processors/token_parser.py +++ b/src/eligibility_signposting_api/services/processors/token_parser.py @@ -2,6 +2,16 @@ from dataclasses import dataclass +class InvalidTokenError(ValueError): + def __init__(self, message: str = "Invalid token.") -> None: + super().__init__(message) + + +class InvalidTokenFormatError(ValueError): + def __init__(self, message: str = "Invalid token format.") -> None: + super().__init__(message) + + @dataclass class ParsedToken: attribute_level: str # example: "PERSON" or "TARGET" @@ -11,18 +21,20 @@ class ParsedToken: class TokenParser: + MIN_TOKEN_PARTS = 2 + @staticmethod def parse(token: str) -> ParsedToken: token_body = token[2:-2] # Strip the surrounding [[ ]] # Check for empty body after stripping, e.g., '[[]]' if not token_body: - raise ValueError("Invalid token.") + raise InvalidTokenError token_parts = token_body.split(".") # Check for empty parts created by leading/trailing dots or tokens with no dot - if len(token_parts) < 2 or not all(token_parts): - raise ValueError("Invalid token.") + if len(token_parts) < TokenParser.MIN_TOKEN_PARTS or not all(token_parts): + raise InvalidTokenError token_level = token_parts[0].upper() token_name = token_parts[-1] @@ -30,14 +42,14 @@ def parse(token: str) -> ParsedToken: # Check if the name contains a date format format_match = re.search(r":DATE\(([^()]*)\)", token_name, re.IGNORECASE) if not format_match and len(token_name.split(":")) > 1: - raise ValueError("Invalid token format.") + raise InvalidTokenFormatError format_str = format_match.group(1) if format_match else None # Remove the date format from the last part last_part = re.sub(r":DATE\(.*?\)", "", token_name, flags=re.IGNORECASE) - if len(token_parts) == 2: + if len(token_parts) == TokenParser.MIN_TOKEN_PARTS: # Person token, example, [[PERSON.AGE]] name = last_part.upper() value = None diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a3c3b59c2..874dd1a47 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -630,7 +630,9 @@ def campaign_config_with_tokens(s3_client: BaseClient, rules_bucket: BucketName) ActionType="ButtonAuthLink", ExternalRoutingCode="BookNBS", ActionDescription="## Token - PERSON.POSTCODE: [[PERSON.POSTCODE]].", - UrlLabel="Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): [[PERSON.DATE_OF_BIRTH:DATE(%d %B %Y)]].", + UrlLabel=( + "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y):[[PERSON.DATE_OF_BIRTH:DATE(%d %B %Y)]]." + ), ), "TOKEN_TEST2": AvailableAction( ActionType="ButtonAuthLink", @@ -651,13 +653,18 @@ def campaign_config_with_tokens(s3_client: BaseClient, rules_bucket: BucketName) cohort_label="cohort1", cohort_group="cohort_group1", positive_description="Positive Description", - negative_description="Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE]]", + negative_description=( + "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE]]" + ), ), rule.IterationCohortFactory.build( cohort_label="cohort2", cohort_group="cohort_group2", positive_description="Positive Description", - negative_description="Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]", + negative_description=( + "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + "[[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + ), ), ], ) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index a22460f26..de6536081 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -590,12 +590,12 @@ def test_no_active_iteration_returns_empty_processed_suggestions( def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 lambda_client: BaseClient, # noqa:ARG001 person_with_all_data: NHSNumber, - campaign_config_with_tokens: CampaignConfig, + campaign_config_with_tokens: CampaignConfig, # noqa:ARG001 s3_client: BaseClient, audit_bucket: BucketName, api_gateway_endpoint: URL, - flask_function: str, - logs_client: BaseClient, + flask_function: str, # noqa:ARG001 + logs_client: BaseClient, # noqa:ARG001 ): # Given # When diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 68f958578..74c6fb1d5 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -692,8 +692,8 @@ def test_eligibility_status_replaces_tokens_with_attribute_data(faker: Faker): gp_practice=None, ) - person_attribute_token = "DOB: [[PERSON.DATE_OF_BIRTH]]" - target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + person_attribute_token = "DOB: [[PERSON.DATE_OF_BIRTH]]" # noqa: S105 + target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" # noqa: S105 available_action = AvailableAction( ActionType="ButtonAuthLink", ExternalRoutingCode="BookNBS", @@ -765,7 +765,7 @@ def test_eligibility_status_with_invalid_tokens_raises_attribute_error(faker: Fa nhs_number, date_of_birth=date_of_birth, cohorts=["cohort_1"], vaccines=[("RSV", datetime.date(2024, 1, 3))] ) - target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE:INVALID_DATE_FORMAT(%d %B %Y)]]" + target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE:INVALID_DATE_FORMAT(%d %B %Y)]]" # noqa: S105 campaign_configs = [ rule_builder.CampaignConfigFactory.build( target="RSV", @@ -797,7 +797,7 @@ def test_eligibility_status_with_invalid_person_attribute_name_raises_value_erro nhs_number, date_of_birth=date_of_birth, cohorts=["cohort_1"], vaccines=[("RSV", datetime.date(2024, 1, 3))] ) - target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.ICECREAM]]" + target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.ICECREAM]]" # noqa: S105 campaign_configs = [ rule_builder.CampaignConfigFactory.build( target="RSV", @@ -816,7 +816,7 @@ def test_eligibility_status_with_invalid_person_attribute_name_raises_value_erro calculator = EligibilityCalculator(person_rows, campaign_configs) - with pytest.raises(ValueError): + with pytest.raises(ValueError): # noqa: PT011 calculator.get_eligibility_status("Y", ["ALL"], "ALL") diff --git a/tests/unit/services/processors/test_token_parser.py b/tests/unit/services/processors/test_token_parser.py index 38862f340..d81465b3f 100644 --- a/tests/unit/services/processors/test_token_parser.py +++ b/tests/unit/services/processors/test_token_parser.py @@ -5,7 +5,7 @@ class TestTokenParser: @pytest.mark.parametrize( - "token, expected_level, expected_name, expected_value, expected_format", + ("token", "expected_level", "expected_name", "expected_value", "expected_format"), [ ("[[PERSON.AGE]]", "PERSON", "AGE", None, None), ("[[TARGET.RSV.LAST_SUCCESSFUL_DATE]]", "TARGET", "RSV", "LAST_SUCCESSFUL_DATE", None), From 67551acde48406ac3d220693366f720f2c41735a Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:45:03 +0100 Subject: [PATCH 19/28] Fixed integration test. --- tests/integration/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 874dd1a47..89ffe20cf 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -631,7 +631,7 @@ def campaign_config_with_tokens(s3_client: BaseClient, rules_bucket: BucketName) ExternalRoutingCode="BookNBS", ActionDescription="## Token - PERSON.POSTCODE: [[PERSON.POSTCODE]].", UrlLabel=( - "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y):[[PERSON.DATE_OF_BIRTH:DATE(%d %B %Y)]]." + "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): [[PERSON.DATE_OF_BIRTH:DATE(%d %B %Y)]]." ), ), "TOKEN_TEST2": AvailableAction( From 4ce0080177e9eaebb25ac4a0ad36138f8720f262 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:39:24 +0100 Subject: [PATCH 20/28] ELI-223: Fixed linting --- .../services/processors/token_processor.py | 70 ++++++++++--------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index 57fc1773a..f1dcff31b 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -1,12 +1,12 @@ import re -from dataclasses import fields, is_dataclass -from datetime import datetime -from typing import TypeVar +from dataclasses import Field, fields, is_dataclass +from datetime import UTC, datetime +from typing import Any, Never, TypeVar from wireup import service from eligibility_signposting_api.model.person import Person -from eligibility_signposting_api.services.processors.token_parser import TokenParser +from eligibility_signposting_api.services.processors.token_parser import ParsedToken, TokenParser T = TypeVar("T") @@ -17,34 +17,36 @@ class TokenProcessor: def find_and_replace_tokens(person: Person, data_class: T) -> T: if not is_dataclass(data_class): return data_class - for class_field in fields(data_class): value = getattr(data_class, class_field.name) - if isinstance(value, str): setattr(data_class, class_field.name, TokenProcessor.replace_token(value, person)) - elif isinstance(value, list): - for i, item in enumerate(value): - if is_dataclass(item): - value[i] = TokenProcessor.find_and_replace_tokens(person, item) - elif isinstance(item, str): - value[i] = TokenProcessor.replace_token(item, person) - setattr(data_class, class_field.name, value) - + TokenProcessor.process_list(class_field, data_class, person, value) elif isinstance(value, dict): - for key, dict_value in value.items(): - if isinstance(dict_value, str): - value[key] = TokenProcessor.replace_token(dict_value, person) - elif is_dataclass(dict_value): - value[key] = TokenProcessor.find_and_replace_tokens(person, dict_value) - setattr(data_class, class_field.name, value) - + TokenProcessor.process_dict(class_field, data_class, person, value) elif is_dataclass(value): setattr(data_class, class_field.name, TokenProcessor.find_and_replace_tokens(person, value)) - return data_class + @staticmethod + def process_dict(class_field: Field, data_class: T, person: Person, value: dict[Any, Any]) -> None: + for key, dict_value in value.items(): + if isinstance(dict_value, str): + value[key] = TokenProcessor.replace_token(dict_value, person) + elif is_dataclass(dict_value): + value[key] = TokenProcessor.find_and_replace_tokens(person, dict_value) + setattr(data_class, class_field.name, value) + + @staticmethod + def process_list(class_field: Field, data_class: T, person: Person, value: list[Any]) -> None: + for i, item in enumerate(value): + if is_dataclass(item): + value[i] = TokenProcessor.find_and_replace_tokens(person, item) + elif isinstance(item, str): + value[i] = TokenProcessor.replace_token(item, person) + setattr(data_class, class_field.name, value) + @staticmethod def replace_token(text: str, person: Person) -> str: if not isinstance(text, str): @@ -79,7 +81,7 @@ def replace_token(text: str, person: Person) -> str: for attribute in person.data: is_target_attribute = attribute.get("ATTRIBUTE_TYPE") == parsed_token.attribute_name.upper() is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == "PERSON" - is_target_rsv = True if parsed_token.attribute_name.upper() == "RSV" else False + is_target_rsv = parsed_token.attribute_name.upper() == "RSV" valid_person_attribute = is_person_attribute and key_to_find in attribute valid_target_attribute = ( @@ -100,22 +102,26 @@ def replace_token(text: str, person: Person) -> str: return text @staticmethod - def handle_token_not_found(parsed_token, token): + def handle_token_not_found(parsed_token: ParsedToken, token: str) -> Never: if parsed_token.attribute_level == "TARGET": - raise ValueError(f"Invalid attribute name '{parsed_token.attribute_value}' in token '{token}'.") + message = f"Invalid attribute name '{parsed_token.attribute_value}' in token '{token}'." + raise ValueError(message) if parsed_token.attribute_level == "PERSON": - raise ValueError(f"Invalid attribute name '{parsed_token.attribute_name}' in token '{token}'.") - raise ValueError(f"Invalid attribute level '{parsed_token.attribute_level}' in token '{token}'.") + message = f"Invalid attribute name '{parsed_token.attribute_name}' in token '{token}'." + raise ValueError(message) + message = f"Invalid attribute level '{parsed_token.attribute_level}' in token '{token}'." + raise ValueError(message) @staticmethod - def apply_formatting(attribute: dict[str, T], attribute_value: T, date_format: str | None) -> str: + def apply_formatting(attribute: dict[str, T], attribute_value: str, date_format: str | None) -> str: try: attribute_data = attribute.get(attribute_value) if (date_format or date_format == "") and attribute_data: - replace_with_date_object = datetime.strptime(str(attribute_data), "%Y%m%d") + replace_with_date_object = datetime.strptime(str(attribute_data), "%Y%m%d").replace(tzinfo=UTC) replace_with = replace_with_date_object.strftime(str(date_format)) else: replace_with = attribute_data if attribute_data else "" - return replace_with - except AttributeError: - raise AttributeError("Invalid token format") + return str(replace_with) + except (AttributeError, ValueError) as error: + message = "Invalid token format" + raise AttributeError(message) from error From 5d1bda85c5a7f7254f3cba76cf3e2862148255b9 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:53:35 +0100 Subject: [PATCH 21/28] ELI-223: Fixed linting --- .../services/processors/token_processor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index f1dcff31b..7f571d001 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -30,7 +30,7 @@ def find_and_replace_tokens(person: Person, data_class: T) -> T: return data_class @staticmethod - def process_dict(class_field: Field, data_class: T, person: Person, value: dict[Any, Any]) -> None: + def process_dict(class_field: Field, data_class: object, person: Person, value: dict[Any, Any]) -> None: for key, dict_value in value.items(): if isinstance(dict_value, str): value[key] = TokenProcessor.replace_token(dict_value, person) @@ -39,7 +39,7 @@ def process_dict(class_field: Field, data_class: T, person: Person, value: dict[ setattr(data_class, class_field.name, value) @staticmethod - def process_list(class_field: Field, data_class: T, person: Person, value: list[Any]) -> None: + def process_list(class_field: Field, data_class: object, person: Person, value: list[Any]) -> None: for i, item in enumerate(value): if is_dataclass(item): value[i] = TokenProcessor.find_and_replace_tokens(person, item) @@ -93,7 +93,7 @@ def replace_token(text: str, person: Person) -> str: key_to_replace = key_to_find break - if not found_attribute: + if not found_attribute or key_to_replace is None: TokenProcessor.handle_token_not_found(parsed_token, token) replace_with = TokenProcessor.apply_formatting(found_attribute, key_to_replace, parsed_token.format) @@ -122,6 +122,6 @@ def apply_formatting(attribute: dict[str, T], attribute_value: str, date_format: else: replace_with = attribute_data if attribute_data else "" return str(replace_with) - except (AttributeError, ValueError) as error: + except AttributeError as error: message = "Invalid token format" raise AttributeError(message) from error From 767ddf3eadd0e1152679f2afde796d4258c203ef Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:49:49 +0100 Subject: [PATCH 22/28] ELI-223: Adds error integration tests --- tests/integration/conftest.py | 42 ++++++++ .../lambda/test_app_running_as_lambda.py | 97 ++++++++++++++----- 2 files changed, 113 insertions(+), 26 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 89ffe20cf..24efd3fa4 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -678,6 +678,48 @@ def campaign_config_with_tokens(s3_client: BaseClient, rules_bucket: BucketName) s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def campaign_config_with_invalid_tokens(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: + campaign: CampaignConfig = rule.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule.IterationFactory.build( + actions_mapper=rule.ActionsMapperFactory.build( + root={ + "TOKEN_TEST": AvailableAction( + ActionType="ButtonAuthLink", + ExternalRoutingCode="BookNBS", + ActionDescription="## Token - PERSON.ICECREAM: [[PERSON.ICECREAM]].", + UrlLabel=( + "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): [[PERSON.DATE_OF_BIRTH:DATE(%d %B %Y)]]." + ), + ) + } + ), + iteration_rules=[ + rule.ICBNonEligibleActionRuleFactory.build(comms_routing="TOKEN_TEST"), + ], + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label="cohort1", + cohort_group="cohort_group1", + positive_description="Positive Description", + negative_description=( + "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE]]" + ), + ) + ], + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + s3_client.put_object( + Bucket=rules_bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json" + ) + yield campaign + s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") + + @pytest.fixture(scope="class") def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index de6536081..69fb5d1b7 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -597,8 +597,6 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 flask_function: str, # noqa:ARG001 logs_client: BaseClient, # noqa:ARG001 ): - # Given - # When invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" response = httpx.get( invoke_url, @@ -606,43 +604,90 @@ def test_token_formatting_in_eligibility_response_and_audit( # noqa: PLR0913 timeout=10, ) - # Then assert_that( response, is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))), ) processed_suggestions = response.json()["processedSuggestions"][0] + response_actions = processed_suggestions["actions"] + response_eligibility_cohorts = processed_suggestions["eligibilityCohorts"] - assert processed_suggestions["actions"][0]["description"] == "## Token - PERSON.POSTCODE: SW18." - assert ( - processed_suggestions["actions"][0]["urlLabel"] - == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." - ) - assert processed_suggestions["actions"][1]["description"] == "## Token - PERSON.GENDER: 0." - assert processed_suggestions["actions"][1]["urlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." - assert processed_suggestions["eligibilityCohorts"][0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " - assert ( - processed_suggestions["eligibilityCohorts"][1]["cohortText"] - == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " - ) + assert response_actions[0]["description"] == "## Token - PERSON.POSTCODE: SW18." + assert response_actions[0]["urlLabel"] == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + assert response_actions[1]["description"] == "## Token - PERSON.GENDER: 0." + assert response_actions[1]["urlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." + assert response_eligibility_cohorts[0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " + assert response_eligibility_cohorts[1]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " - # Then - check if audited objects = s3_client.list_objects_v2(Bucket=audit_bucket).get("Contents", []) object_keys = [obj["Key"] for obj in objects] latest_key = sorted(object_keys)[-1] audit_data = json.loads(s3_client.get_object(Bucket=audit_bucket, Key=latest_key)["Body"].read()) audit_condition = audit_data["response"]["condition"][0] - assert audit_condition["actions"][0]["actionDescription"] == "## Token - PERSON.POSTCODE: SW18." - assert ( - audit_condition["actions"][0]["actionUrlLabel"] - == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + audit_actions = audit_condition["actions"] + audit_eligibility_cohorts = audit_condition["eligibilityCohortGroups"] + + assert audit_actions[0]["actionDescription"] == "## Token - PERSON.POSTCODE: SW18." + assert audit_actions[0]["actionUrlLabel"] == "Token - PERSON.DATE_OF_BIRTH:DATE(%d %B %Y): 28 February 1990." + assert audit_actions[1]["actionDescription"] == "## Token - PERSON.GENDER: 0." + assert audit_actions[1]["actionUrlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." + assert audit_eligibility_cohorts[0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " + assert audit_eligibility_cohorts[1]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + + +def test_incorrect_token_causes_internal_server_error( # noqa: PLR0913 + lambda_client: BaseClient, # noqa:ARG001 + person_with_all_data: NHSNumber, + campaign_config_with_invalid_tokens: CampaignConfig, # noqa:ARG001 + s3_client: BaseClient, + audit_bucket: BucketName, + api_gateway_endpoint: URL, + flask_function: str, + logs_client: BaseClient, +): + invoke_url = f"{api_gateway_endpoint}/patient-check/{person_with_all_data}" + response = httpx.get( + invoke_url, + headers={"nhs-login-nhs-number": str(person_with_all_data)}, + timeout=10, + ) + + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.INTERNAL_SERVER_ERROR) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) + .and_body( + is_json_that( + has_entries( + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="processing", + diagnostics="An unexpected error occurred.", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "INTERNAL_SERVER_ERROR", + "display": "An unexpected internal server error occurred.", + } + ] + }, + ) + ), + ) + ), + ), ) - assert audit_condition["actions"][1]["actionDescription"] == "## Token - PERSON.GENDER: 0." - assert audit_condition["actions"][1]["actionUrlLabel"] == "Token - PERSON.DATE_OF_BIRTH: 19900228." - assert audit_condition["eligibilityCohortGroups"][0]["cohortText"] == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE: " - assert ( - audit_condition["eligibilityCohortGroups"][1]["cohortText"] - == "Token - TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y): " + + objects = s3_client.list_objects_v2(Bucket=audit_bucket).get("Contents", []) + assert len(objects) == 0 # Check there are no audit logs + + assert_that( + get_log_messages(flask_function, logs_client), + has_item(contains_string("Invalid attribute name 'ICECREAM' in token '[[PERSON.ICECREAM]]'.")), ) From 1bb5e7f33f1e3a60a9a6dd4b564971ba51f3dfb3 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:41:54 +0100 Subject: [PATCH 23/28] ELI-223: Adds more tests --- .../services/processors/token_processor.py | 46 +++++++++-------- .../processors/test_token_processor.py | 50 +++++++++++++++++-- 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index 7f571d001..1b5ac5d51 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -69,7 +69,7 @@ def replace_token(text: str, person: Person) -> str: for token in all_tokens: parsed_token = TokenParser.parse(token) - found_attribute, key_to_replace = None, None + found_attribute, key_to_replace, replace_with = None, None, None attribute_level_map = { "TARGET": parsed_token.attribute_value, @@ -78,27 +78,33 @@ def replace_token(text: str, person: Person) -> str: key_to_find = attribute_level_map.get(parsed_token.attribute_level) + present_attributes = [] for attribute in person.data: - is_target_attribute = attribute.get("ATTRIBUTE_TYPE") == parsed_token.attribute_name.upper() - is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == "PERSON" - is_target_rsv = parsed_token.attribute_name.upper() == "RSV" - - valid_person_attribute = is_person_attribute and key_to_find in attribute - valid_target_attribute = ( - is_target_attribute and is_target_rsv and key_to_find in allowed_target_attributes - ) - - if valid_target_attribute or valid_person_attribute: - found_attribute = attribute - key_to_replace = key_to_find - break - - if not found_attribute or key_to_replace is None: - TokenProcessor.handle_token_not_found(parsed_token, token) - - replace_with = TokenProcessor.apply_formatting(found_attribute, key_to_replace, parsed_token.format) + present_attributes.append(attribute.get("ATTRIBUTE_TYPE")) + + if ( + parsed_token.attribute_level == "TARGET" + and parsed_token.attribute_name == "RSV" + and parsed_token.attribute_value in allowed_target_attributes + and parsed_token.attribute_name not in present_attributes + ): + replace_with = "" + + if replace_with != "": + for attribute in person.data: + is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == "PERSON" + is_target_rsv = parsed_token.attribute_name.upper() == "RSV" + + if (is_target_rsv or is_person_attribute) and key_to_find in attribute: + found_attribute = attribute + key_to_replace = key_to_find + break + + if not found_attribute or key_to_replace is None: + TokenProcessor.handle_token_not_found(parsed_token, token) + + replace_with = TokenProcessor.apply_formatting(found_attribute, key_to_replace, parsed_token.format) text = text.replace(token, str(replace_with)) - return text @staticmethod diff --git a/tests/unit/services/processors/test_token_processor.py b/tests/unit/services/processors/test_token_processor.py index 6beac64e5..98624e843 100644 --- a/tests/unit/services/processors/test_token_processor.py +++ b/tests/unit/services/processors/test_token_processor.py @@ -134,6 +134,45 @@ def test_invalid_token_on_target_attribute_should_raise_error(self): with pytest.raises(ValueError, match=expected_error): TokenProcessor.find_and_replace_tokens(person, condition) + def test_missing_patient_vaccine_data_on_target_attribute_should_replace_with_empty(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("Last successful date: [[TARGET.RSV.LAST_SUCCESSFUL_DATE]]"), + status=Status.actionable, + status_text=StatusText("Some status"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + actual = TokenProcessor.find_and_replace_tokens(person, condition) + + assert actual.condition_name == "Last successful date: " + + def test_non_rsv_target_token_should_raise_error(self): + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, + {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + ] + ) + + condition = Condition( + condition_name=ConditionName("Last successful date: [[TARGET.COVID.LAST_SUCCESSFUL_DATE]]"), + status=Status.actionable, + status_text=StatusText("Some status"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected_error = re.escape( + "Invalid attribute name 'LAST_SUCCESSFUL_DATE' in token '[[TARGET.COVID.LAST_SUCCESSFUL_DATE]]'." + ) + with pytest.raises(ValueError, match=expected_error): + TokenProcessor.find_and_replace_tokens(person, condition) + def test_valid_token_but_missing_attribute_data_to_replace(self): person = Person( [ @@ -169,13 +208,18 @@ def test_valid_token_but_missing_attribute_data_to_replace(self): assert actual.condition_name == expected.condition_name def test_simple_string_with_multiple_tokens(self): - person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}]) + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}, + # {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"} + ] + ) condition = Condition( condition_name=ConditionName("RSV"), status=Status.actionable, status_text=StatusText( - "You are a [[PERSON.QUALITY]] [[person.QUALITY]] [[PERSON.DEGREE]] and your age is [[PERSON.AGE]]." + "You are a [[PERSON.QUALITY]] [[person.QUALITY]] [[TARGET.RSV.LAST_SUCCESSFUL_DATE]] and your age is [[PERSON.AGE]]." ), cohort_results=[], suitability_rules=[], @@ -185,7 +229,7 @@ def test_simple_string_with_multiple_tokens(self): expected = Condition( condition_name=ConditionName("RSV"), status=Status.actionable, - status_text=StatusText("You are a NICE NICE DOCTOR and your age is 30."), + status_text=StatusText("You are a NICE NICE and your age is 30."), cohort_results=[], suitability_rules=[], actions=[], From 99a553cff82ef5f68974d1aa5d119fa0e8830365 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:05:38 +0100 Subject: [PATCH 24/28] ELI-223: Adds more tests and linting --- .../services/processors/token_processor.py | 8 +++----- tests/unit/services/processors/test_token_processor.py | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index 1b5ac5d51..6267acb62 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -54,7 +54,7 @@ def replace_token(text: str, person: Person) -> str: pattern = r"\[\[.*?\]\]" all_tokens = re.findall(pattern, text, re.IGNORECASE) - allowed_target_attributes = [ + allowed_target_attributes = { "NHS_NUMBER", "ATTRIBUTE_TYPE", "VALID_DOSES_COUNT", @@ -65,7 +65,7 @@ def replace_token(text: str, person: Person) -> str: "BOOKED_APPOINTMENT_PROVIDER", "LAST_INVITE_DATE", "LAST_INVITE_STATUS", - ] + } for token in all_tokens: parsed_token = TokenParser.parse(token) @@ -78,9 +78,7 @@ def replace_token(text: str, person: Person) -> str: key_to_find = attribute_level_map.get(parsed_token.attribute_level) - present_attributes = [] - for attribute in person.data: - present_attributes.append(attribute.get("ATTRIBUTE_TYPE")) + present_attributes = [attribute.get("ATTRIBUTE_TYPE") for attribute in person.data] if ( parsed_token.attribute_level == "TARGET" diff --git a/tests/unit/services/processors/test_token_processor.py b/tests/unit/services/processors/test_token_processor.py index 98624e843..a265fb5ab 100644 --- a/tests/unit/services/processors/test_token_processor.py +++ b/tests/unit/services/processors/test_token_processor.py @@ -211,7 +211,6 @@ def test_simple_string_with_multiple_tokens(self): person = Person( [ {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DEGREE": "DOCTOR", "QUALITY": "NICE"}, - # {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"} ] ) @@ -219,7 +218,8 @@ def test_simple_string_with_multiple_tokens(self): condition_name=ConditionName("RSV"), status=Status.actionable, status_text=StatusText( - "You are a [[PERSON.QUALITY]] [[person.QUALITY]] [[TARGET.RSV.LAST_SUCCESSFUL_DATE]] and your age is [[PERSON.AGE]]." + "You are a [[PERSON.QUALITY]] [[person.QUALITY]] " + "[[TARGET.RSV.LAST_SUCCESSFUL_DATE]] and your age is [[PERSON.AGE]]." ), cohort_results=[], suitability_rules=[], From c0816f01fe90e8310261477028b2610e36a982a6 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:13:14 +0100 Subject: [PATCH 25/28] ELI-223: Adds tests --- .../services/processors/test_token_processor.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/services/processors/test_token_processor.py b/tests/unit/services/processors/test_token_processor.py index a265fb5ab..8fbd2fe72 100644 --- a/tests/unit/services/processors/test_token_processor.py +++ b/tests/unit/services/processors/test_token_processor.py @@ -134,6 +134,22 @@ def test_invalid_token_on_target_attribute_should_raise_error(self): with pytest.raises(ValueError, match=expected_error): TokenProcessor.find_and_replace_tokens(person, condition) + def test_valid_token_on_missing_target_attribute_and_invalid_token_should_raise_error(self): + person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + condition = Condition( + condition_name=ConditionName("Condition name is [[TARGET.RSV.ICECREAM]]"), + status=Status.actionable, + status_text=StatusText("Some status"), + cohort_results=[], + suitability_rules=[], + actions=[], + ) + + expected_error = re.escape("Invalid attribute name 'ICECREAM' in token '[[TARGET.RSV.ICECREAM]]'.") + with pytest.raises(ValueError, match=expected_error): + TokenProcessor.find_and_replace_tokens(person, condition) + def test_missing_patient_vaccine_data_on_target_attribute_should_replace_with_empty(self): person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) From c873c62a693f86d7a67f57f696d76135962fd08a Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:13:52 +0100 Subject: [PATCH 26/28] ELI-223: Renames test --- tests/unit/services/processors/test_token_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/services/processors/test_token_processor.py b/tests/unit/services/processors/test_token_processor.py index 8fbd2fe72..d5b5f3ef8 100644 --- a/tests/unit/services/processors/test_token_processor.py +++ b/tests/unit/services/processors/test_token_processor.py @@ -134,7 +134,7 @@ def test_invalid_token_on_target_attribute_should_raise_error(self): with pytest.raises(ValueError, match=expected_error): TokenProcessor.find_and_replace_tokens(person, condition) - def test_valid_token_on_missing_target_attribute_and_invalid_token_should_raise_error(self): + def test_missing_target_attribute_and_invalid_token_should_raise_error(self): person = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) condition = Condition( From 90202e4a176790e3755e9b8f37b124e7b0a309f0 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:48:01 +0100 Subject: [PATCH 27/28] ELI-223: Extracts constants --- .../services/processors/token_processor.py | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index 6267acb62..c1e7566d1 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -11,6 +11,11 @@ T = TypeVar("T") +TARGET_ATTRIBUTE_LEVEL = "TARGET" +PERSON_ATTRIBUTE_LEVEL = "PERSON" +ALLOWED_CONDITION = "RSV" + + @service class TokenProcessor: @staticmethod @@ -66,23 +71,22 @@ def replace_token(text: str, person: Person) -> str: "LAST_INVITE_DATE", "LAST_INVITE_STATUS", } + present_attributes = [attribute.get("ATTRIBUTE_TYPE") for attribute in person.data] for token in all_tokens: parsed_token = TokenParser.parse(token) found_attribute, key_to_replace, replace_with = None, None, None attribute_level_map = { - "TARGET": parsed_token.attribute_value, - "PERSON": parsed_token.attribute_name, + TARGET_ATTRIBUTE_LEVEL: parsed_token.attribute_value, + PERSON_ATTRIBUTE_LEVEL: parsed_token.attribute_name, } key_to_find = attribute_level_map.get(parsed_token.attribute_level) - present_attributes = [attribute.get("ATTRIBUTE_TYPE") for attribute in person.data] - if ( - parsed_token.attribute_level == "TARGET" - and parsed_token.attribute_name == "RSV" + parsed_token.attribute_level == TARGET_ATTRIBUTE_LEVEL + and parsed_token.attribute_name == ALLOWED_CONDITION and parsed_token.attribute_value in allowed_target_attributes and parsed_token.attribute_name not in present_attributes ): @@ -90,8 +94,8 @@ def replace_token(text: str, person: Person) -> str: if replace_with != "": for attribute in person.data: - is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == "PERSON" - is_target_rsv = parsed_token.attribute_name.upper() == "RSV" + is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == PERSON_ATTRIBUTE_LEVEL + is_target_rsv = parsed_token.attribute_name.upper() == ALLOWED_CONDITION if (is_target_rsv or is_person_attribute) and key_to_find in attribute: found_attribute = attribute @@ -107,10 +111,10 @@ def replace_token(text: str, person: Person) -> str: @staticmethod def handle_token_not_found(parsed_token: ParsedToken, token: str) -> Never: - if parsed_token.attribute_level == "TARGET": + if parsed_token.attribute_level == TARGET_ATTRIBUTE_LEVEL: message = f"Invalid attribute name '{parsed_token.attribute_value}' in token '{token}'." raise ValueError(message) - if parsed_token.attribute_level == "PERSON": + if parsed_token.attribute_level == PERSON_ATTRIBUTE_LEVEL: message = f"Invalid attribute name '{parsed_token.attribute_name}' in token '{token}'." raise ValueError(message) message = f"Invalid attribute level '{parsed_token.attribute_level}' in token '{token}'." From 4519ee64bbbf682dab192598bc9bd6c4b022427b Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 28 Aug 2025 14:29:39 +0100 Subject: [PATCH 28/28] ELI-223: Fixes review comments --- .../config/contants.py | 3 + .../model/campaign_config.py | 4 +- .../calculators/eligibility_calculator.py | 3 +- .../services/processors/token_parser.py | 59 +++++++++++-------- .../services/processors/token_processor.py | 33 +++++------ .../processors/test_token_processor.py | 30 +++++----- 6 files changed, 72 insertions(+), 60 deletions(-) diff --git a/src/eligibility_signposting_api/config/contants.py b/src/eligibility_signposting_api/config/contants.py index 3ac359875..6d34f650b 100644 --- a/src/eligibility_signposting_api/config/contants.py +++ b/src/eligibility_signposting_api/config/contants.py @@ -1,3 +1,6 @@ +from typing import Literal + MAGIC_COHORT_LABEL = "elid_all_people" RULE_STOP_DEFAULT = False NHS_NUMBER_HEADER = "nhs-login-nhs-number" +ALLOWED_CONDITIONS = Literal["COVID", "FLU", "MMR", "RSV"] diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index 989f2e53d..1fe137d85 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -11,7 +11,7 @@ from pydantic import BaseModel, Field, HttpUrl, RootModel, field_serializer, field_validator, model_validator -from eligibility_signposting_api.config.contants import MAGIC_COHORT_LABEL, RULE_STOP_DEFAULT +from eligibility_signposting_api.config.contants import ALLOWED_CONDITIONS, MAGIC_COHORT_LABEL, RULE_STOP_DEFAULT if typing.TYPE_CHECKING: # pragma: no cover from pydantic import SerializationInfo @@ -184,7 +184,7 @@ class CampaignConfig(BaseModel): version: CampaignVersion = Field(..., alias="Version") name: CampaignName = Field(..., alias="Name") type: Literal["V", "S"] = Field(..., alias="Type") - target: Literal["COVID", "FLU", "MMR", "RSV"] = Field(..., alias="Target") + target: ALLOWED_CONDITIONS = Field(..., alias="Target") manager: list[str] | None = Field(None, alias="Manager") approver: list[str] | None = Field(None, alias="Approver") reviewer: list[str] | None = Field(None, alias="Reviewer") diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index b74b1ea0e..ab3122953 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -4,7 +4,7 @@ from collections import defaultdict from dataclasses import dataclass, field from itertools import chain -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from wireup import service @@ -37,7 +37,6 @@ logger = logging.getLogger(__name__) -T = TypeVar("T") @service diff --git a/src/eligibility_signposting_api/services/processors/token_parser.py b/src/eligibility_signposting_api/services/processors/token_parser.py index 120aabfd5..3f873ba34 100644 --- a/src/eligibility_signposting_api/services/processors/token_parser.py +++ b/src/eligibility_signposting_api/services/processors/token_parser.py @@ -2,22 +2,27 @@ from dataclasses import dataclass -class InvalidTokenError(ValueError): - def __init__(self, message: str = "Invalid token.") -> None: - super().__init__(message) - - -class InvalidTokenFormatError(ValueError): - def __init__(self, message: str = "Invalid token format.") -> None: - super().__init__(message) - - @dataclass class ParsedToken: - attribute_level: str # example: "PERSON" or "TARGET" - attribute_name: str # example: "POSTCODE" or "RSV" - attribute_value: str | None # example: "LAST_SUCCESSFUL_DATE" if attribute_level is TARGET - format: str | None # example: "%d %B %Y" if DATE formatting is used + """ + A class to represent a parsed token. + ... + Attributes + ---------- + attribute_level : str + Example: "PERSON" or "TARGET" + attribute_name : str + Example: "POSTCODE" or "RSV" + attribute_value : int + Example: "LAST_SUCCESSFUL_DATE" if attribute_level is TARGET + format : str + Example: "%d %B %Y" if DATE formatting is used + """ + + attribute_level: str + attribute_name: str + attribute_value: str | None + format: str | None class TokenParser: @@ -25,36 +30,42 @@ class TokenParser: @staticmethod def parse(token: str) -> ParsedToken: - token_body = token[2:-2] # Strip the surrounding [[ ]] - # Check for empty body after stripping, e.g., '[[]]' + """Parses a token into its parts. + Steps: + Strip the surrounding [[ ]] + Check for empty body after stripping, e.g., '[[]]' + Check for empty parts created by leading/trailing dots or tokens with no dot + Check if the name contains a date format + Return a ParsedToken object + """ + + token_body = token[2:-2] if not token_body: - raise InvalidTokenError + message = "Invalid token." + raise ValueError(message) token_parts = token_body.split(".") - # Check for empty parts created by leading/trailing dots or tokens with no dot if len(token_parts) < TokenParser.MIN_TOKEN_PARTS or not all(token_parts): - raise InvalidTokenError + message = "Invalid token." + raise ValueError(message) token_level = token_parts[0].upper() token_name = token_parts[-1] - # Check if the name contains a date format format_match = re.search(r":DATE\(([^()]*)\)", token_name, re.IGNORECASE) if not format_match and len(token_name.split(":")) > 1: - raise InvalidTokenFormatError + message = "Invalid token format." + raise ValueError(message) format_str = format_match.group(1) if format_match else None - # Remove the date format from the last part last_part = re.sub(r":DATE\(.*?\)", "", token_name, flags=re.IGNORECASE) if len(token_parts) == TokenParser.MIN_TOKEN_PARTS: - # Person token, example, [[PERSON.AGE]] name = last_part.upper() value = None else: - # Target token, example, [[TARGET.RSV.LAST_SUCCESSFUL_DATE]] name = token_parts[1].upper() value = last_part.upper() diff --git a/src/eligibility_signposting_api/services/processors/token_processor.py b/src/eligibility_signposting_api/services/processors/token_processor.py index c1e7566d1..de509daff 100644 --- a/src/eligibility_signposting_api/services/processors/token_processor.py +++ b/src/eligibility_signposting_api/services/processors/token_processor.py @@ -5,6 +5,7 @@ from wireup import service +from eligibility_signposting_api.config.contants import ALLOWED_CONDITIONS from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.processors.token_parser import ParsedToken, TokenParser @@ -13,7 +14,17 @@ TARGET_ATTRIBUTE_LEVEL = "TARGET" PERSON_ATTRIBUTE_LEVEL = "PERSON" -ALLOWED_CONDITION = "RSV" +ALLOWED_TARGET_ATTRIBUTES = { + "ATTRIBUTE_TYPE", + "VALID_DOSES_COUNT", + "INVALID_DOSES_COUNT", + "LAST_SUCCESSFUL_DATE", + "LAST_VALID_DOSE_DATE", + "BOOKED_APPOINTMENT_DATE", + "BOOKED_APPOINTMENT_PROVIDER", + "LAST_INVITE_DATE", + "LAST_INVITE_STATUS", +} @service @@ -59,18 +70,6 @@ def replace_token(text: str, person: Person) -> str: pattern = r"\[\[.*?\]\]" all_tokens = re.findall(pattern, text, re.IGNORECASE) - allowed_target_attributes = { - "NHS_NUMBER", - "ATTRIBUTE_TYPE", - "VALID_DOSES_COUNT", - "INVALID_DOSES_COUNT", - "LAST_SUCCESSFUL_DATE", - "LAST_VALID_DOSE_DATE", - "BOOKED_APPOINTMENT_DATE", - "BOOKED_APPOINTMENT_PROVIDER", - "LAST_INVITE_DATE", - "LAST_INVITE_STATUS", - } present_attributes = [attribute.get("ATTRIBUTE_TYPE") for attribute in person.data] for token in all_tokens: @@ -86,8 +85,8 @@ def replace_token(text: str, person: Person) -> str: if ( parsed_token.attribute_level == TARGET_ATTRIBUTE_LEVEL - and parsed_token.attribute_name == ALLOWED_CONDITION - and parsed_token.attribute_value in allowed_target_attributes + and parsed_token.attribute_name in ALLOWED_CONDITIONS.__args__ + and parsed_token.attribute_value in ALLOWED_TARGET_ATTRIBUTES and parsed_token.attribute_name not in present_attributes ): replace_with = "" @@ -95,9 +94,9 @@ def replace_token(text: str, person: Person) -> str: if replace_with != "": for attribute in person.data: is_person_attribute = attribute.get("ATTRIBUTE_TYPE") == PERSON_ATTRIBUTE_LEVEL - is_target_rsv = parsed_token.attribute_name.upper() == ALLOWED_CONDITION + is_allowed_target = parsed_token.attribute_name.upper() in ALLOWED_CONDITIONS.__args__ - if (is_target_rsv or is_person_attribute) and key_to_find in attribute: + if (is_allowed_target or is_person_attribute) and key_to_find in attribute: found_attribute = attribute key_to_replace = key_to_find break diff --git a/tests/unit/services/processors/test_token_processor.py b/tests/unit/services/processors/test_token_processor.py index d5b5f3ef8..78dbbeff6 100644 --- a/tests/unit/services/processors/test_token_processor.py +++ b/tests/unit/services/processors/test_token_processor.py @@ -166,16 +166,16 @@ def test_missing_patient_vaccine_data_on_target_attribute_should_replace_with_em assert actual.condition_name == "Last successful date: " - def test_non_rsv_target_token_should_raise_error(self): + def test_not_allowed_target_conditions_token_should_raise_error(self): person = Person( [ {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, - {"ATTRIBUTE_TYPE": "COVID", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, + {"ATTRIBUTE_TYPE": "YELLOW_FEVER", "CONDITION_NAME": "COVID", "LAST_SUCCESSFUL_DATE": "20250101"}, ] ) condition = Condition( - condition_name=ConditionName("Last successful date: [[TARGET.COVID.LAST_SUCCESSFUL_DATE]]"), + condition_name=ConditionName("Last successful date: [[TARGET.YELLOW_FEVER.LAST_SUCCESSFUL_DATE]]"), status=Status.actionable, status_text=StatusText("Some status"), cohort_results=[], @@ -184,7 +184,7 @@ def test_non_rsv_target_token_should_raise_error(self): ) expected_error = re.escape( - "Invalid attribute name 'LAST_SUCCESSFUL_DATE' in token '[[TARGET.COVID.LAST_SUCCESSFUL_DATE]]'." + "Invalid attribute name 'LAST_SUCCESSFUL_DATE' in token '[[TARGET.YELLOW_FEVER.LAST_SUCCESSFUL_DATE]]'." ) with pytest.raises(ValueError, match=expected_error): TokenProcessor.find_and_replace_tokens(person, condition) @@ -266,7 +266,7 @@ def test_valid_token_valid_format_should_replace_with_date_formatting(self): condition = Condition( condition_name=ConditionName( - "You had your RSV vaccine on [[TARGET.RSV.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" + "You had your COVID vaccine on [[TARGET.COVID.LAST_SUCCESSFUL_DATE:DATE(%d %B %Y)]]" ), status=Status.actionable, status_text=StatusText("Your birthday is on [[PERSON.DATE_OF_BIRTH:DATE(%-d %B %Y)]]"), @@ -276,7 +276,7 @@ def test_valid_token_valid_format_should_replace_with_date_formatting(self): ) expected = Condition( - condition_name=ConditionName("You had your RSV vaccine on 01 January 2025"), + condition_name=ConditionName("You had your COVID vaccine on 01 January 2025"), status=Status.actionable, status_text=StatusText("Your birthday is on 27 March 1990"), cohort_results=[], @@ -334,12 +334,12 @@ def test_valid_token_invalid_format_should_raise_error(self, token_format: str): def test_valid_date_format(self, token_format: str, expected: str): person = Person( [ - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "19900327"}, + {"ATTRIBUTE_TYPE": "MMR", "CONDITION_NAME": "MMR", "LAST_SUCCESSFUL_DATE": "19900327"}, ] ) condition = Condition( - condition_name=ConditionName(f"Date: [[TARGET.RSV.LAST_SUCCESSFUL_DATE{token_format}]]"), + condition_name=ConditionName(f"Date: [[TARGET.MMR.LAST_SUCCESSFUL_DATE{token_format}]]"), status=Status.actionable, status_text=StatusText("Some text"), cohort_results=[], @@ -358,22 +358,22 @@ def test_valid_date_format(self, token_format: str, expected: str): ("[[PERSON.date_of_birth:DATE(%d %B %Y)]]", "27 March 1990"), ("[[PERSON.DATE_OF_BIRTH:date(%d %B %Y)]]", "27 March 1990"), ("[[pErSoN.DATE_OF_BIRTH:DATE(%d %B %Y)]]", "27 March 1990"), - ("[[target.RSV.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), - ("[[TARGET.rsv.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), - ("[[TARGET.RSV.last_successful_date:DATE(%-d %B %Y)]]", "1 January 2025"), - ("[[TARGET.RSV.last_successful_date:date(%-d %B %Y)]]", "1 January 2025"), + ("[[target.FLU.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.FLU.LAST_SUCCESSFUL_DATE:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.FLU.last_successful_date:DATE(%-d %B %Y)]]", "1 January 2025"), + ("[[TARGET.FLU.last_successful_date:date(%-d %B %Y)]]", "1 January 2025"), ], ) def test_token_replace_is_case_insensitive(self, token: str, expected: str): person = Person( [ {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30", "DATE_OF_BIRTH": "19900327"}, - {"ATTRIBUTE_TYPE": "RSV", "CONDITION_NAME": "RSV", "LAST_SUCCESSFUL_DATE": "20250101"}, + {"ATTRIBUTE_TYPE": "FLU", "CONDITION_NAME": "FLU", "LAST_SUCCESSFUL_DATE": "20250101"}, ] ) condition = Condition( - condition_name=ConditionName(f"RSV vaccine on: {token}."), + condition_name=ConditionName(f"FLU vaccine on: {token}."), status=Status.actionable, status_text=StatusText(f"Your DOB is: {token}."), cohort_results=[], @@ -384,4 +384,4 @@ def test_token_replace_is_case_insensitive(self, token: str, expected: str): result = TokenProcessor.find_and_replace_tokens(person, condition) assert result.status_text == f"Your DOB is: {expected}." - assert result.condition_name == f"RSV vaccine on: {expected}." + assert result.condition_name == f"FLU vaccine on: {expected}."