diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index 18afc6ecc..b54276cbb 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -63,10 +63,10 @@ def append_audit_condition( condition_name: ConditionName, best_results: tuple[Iteration | None, IterationResult | None, dict[str, CohortGroupResult] | None], campaign_details: tuple[CampaignID | None, CampaignVersion | None], - redirect_rule_details: tuple[RulePriority | None, RuleName | None], + action_rule_details: tuple[RulePriority | None, RuleName | None], ) -> None: - audit_eligibility_cohorts, audit_eligibility_cohort_groups = [], [] - audit_filter_rule, audit_suitability_rule = None, None + audit_eligibility_cohorts, audit_eligibility_cohort_groups, audit_actions = [], [], [] + audit_filter_rule, audit_suitability_rule, audit_action_rule = None, None, None best_active_iteration = best_results[0] best_candidate = best_results[1] best_cohort_results = best_results[2] @@ -88,7 +88,7 @@ def append_audit_condition( audit_filter_rule = AuditContext.create_audit_filter_rule(best_candidate, result) audit_suitability_rule = AuditContext.create_audit_suitability_rule(best_candidate, result) - audit_redirect_rule = AuditContext.get_audit_redirect_rule(best_candidate, redirect_rule_details) + audit_action_rule = AuditContext.add_rule_name_and_priority_to_audit(best_candidate, action_rule_details) audit_actions = AuditContext.create_audit_actions(suggested_actions) @@ -104,22 +104,26 @@ def append_audit_condition( eligibility_cohort_groups=audit_eligibility_cohort_groups, filter_rules=audit_filter_rule, suitability_rules=audit_suitability_rule, - action_rule=audit_redirect_rule, + action_rule=audit_action_rule, actions=audit_actions, ) g.audit_log.response.condition.append(audit_condition) @staticmethod - def get_audit_redirect_rule( - best_candidate: IterationResult | None, redirect_rule_details: tuple[RulePriority | None, RuleName | None] + def add_rule_name_and_priority_to_audit( + best_candidate: IterationResult | None, + action_rule_details: tuple[RulePriority | None, RuleName | None] | None, ) -> AuditRedirectRule | None: - audit_redirect_rule = None - if best_candidate and best_candidate.status and best_candidate.status.name == Status.actionable.name: - audit_redirect_rule = AuditRedirectRule( - rule_priority=str(redirect_rule_details[0]), rule_name=redirect_rule_details[1] - ) - return audit_redirect_rule + audit_action_rule = None + if best_candidate and best_candidate.status: + if action_rule_details is None or (action_rule_details[0] is None and action_rule_details[1] is None): + audit_action_rule = None + else: + audit_action_rule = AuditRedirectRule( + rule_priority=str(action_rule_details[0]), rule_name=action_rule_details[1] + ) + return audit_action_rule @staticmethod def add_response_details(response_id: UUID, last_updated: datetime) -> None: diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility.py index 15934880b..61ca94a1d 100644 --- a/src/eligibility_signposting_api/model/eligibility.py +++ b/src/eligibility_signposting_api/model/eligibility.py @@ -31,6 +31,8 @@ class RuleType(StrEnum): filter = "F" suppression = "S" redirect = "R" + not_eligible_actions = "X" + not_actionable_actions = "Y" @total_ordering diff --git a/src/eligibility_signposting_api/model/rules.py b/src/eligibility_signposting_api/model/rules.py index 541db6263..b7409baea 100644 --- a/src/eligibility_signposting_api/model/rules.py +++ b/src/eligibility_signposting_api/model/rules.py @@ -42,6 +42,8 @@ class RuleType(StrEnum): filter = "F" suppression = "S" redirect = "R" + not_eligible_actions = "X" + not_actionable_actions = "Y" class RuleOperator(StrEnum): @@ -153,6 +155,8 @@ class Iteration(BaseModel): approval_maximum: int | None = Field(None, alias="ApprovalMaximum") type: Literal["A", "M", "S", "O"] = Field(..., alias="Type") default_comms_routing: str = Field(..., alias="DefaultCommsRouting") + default_not_eligible_routing: str = Field(..., alias="DefaultNotEligibleRouting") + default_not_actionable_routing: str = Field(..., alias="DefaultNotActionableRouting") iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts") iteration_rules: list[IterationRule] = Field(..., alias="IterationRules") actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper") diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 43c832719..fa586a4e7 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -19,6 +19,7 @@ IterationCohort, RuleName, RulePriority, + RuleType, ) from wireup import service @@ -140,15 +141,20 @@ def get_rules_by_type( return filter_rules, suppression_rules @staticmethod - def get_redirect_rules( - active_iteration: Iteration, - ) -> tuple[tuple[rules.IterationRule, ...], ActionsMapper, str]: - redirect_rules = tuple( - rule for rule in active_iteration.iteration_rules if rule.type in rules.RuleType.redirect - ) - default_comms = active_iteration.default_comms_routing + def get_action_rules_components( + active_iteration: Iteration, rule_type: RuleType + ) -> tuple[tuple[rules.IterationRule, ...], ActionsMapper, str | None]: + action_rules = tuple(rule for rule in active_iteration.iteration_rules if rule.type in rule_type) + + routing_map = { + rules.RuleType.redirect: active_iteration.default_comms_routing, + rules.RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing, + rules.RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing, + } + + default_comms = routing_map.get(rule_type) action_mapper = active_iteration.actions_mapper - return redirect_rules, action_mapper, default_comms + return action_rules, action_mapper, default_comms def evaluate_eligibility( self, include_actions: str, conditions: list[str], category: str @@ -156,7 +162,7 @@ def evaluate_eligibility( include_actions_flag = include_actions.upper() == "Y" condition_results: dict[ConditionName, IterationResult] = {} actions: list[SuggestedAction] | None = [] - redirect_rule_priority, redirect_rule_name = None, None + action_rule_priority, action_rule_name = None, None for condition_name, campaign_group in self.campaigns_grouped_by_condition_name(conditions, category): best_active_iteration: Iteration | None @@ -188,16 +194,26 @@ def evaluate_eligibility( condition_results[condition_name] = best_candidate - if best_candidate.status == Status.actionable and best_active_iteration is not None: + status_to_rule_type = { + Status.actionable: rules.RuleType.redirect, + Status.not_eligible: rules.RuleType.not_eligible_actions, + Status.not_actionable: rules.RuleType.not_actionable_actions, + } + + if best_candidate.status in status_to_rule_type and best_active_iteration is not None: if include_actions_flag: - actions, matched_r_rule_priority, matched_r_rule_name = self.handle_redirect_rules( - best_active_iteration + rule_type = status_to_rule_type[best_candidate.status] + actions, matched_action_rule_priority, matched_action_rule_name = self.handle_action_rules( + best_active_iteration, rule_type ) - redirect_rule_name = matched_r_rule_name - redirect_rule_priority = matched_r_rule_priority + action_rule_name = matched_action_rule_name + action_rule_priority = matched_action_rule_priority else: actions = None + else: + actions = None + if best_candidate.status in (Status.not_eligible, Status.not_actionable) and not include_actions_flag: actions = None @@ -212,7 +228,7 @@ def evaluate_eligibility( condition_name, (best_active_iteration, best_candidate, best_cohort_results), (best_campaign_id, best_campaign_version), - (redirect_rule_priority, redirect_rule_name), + (action_rule_priority, action_rule_name), ) # Consolidate all the results and return @@ -240,15 +256,16 @@ def get_iteration_results( ) return iteration_results - def handle_redirect_rules( - self, best_active_iteration: Iteration + def handle_action_rules( + self, best_active_iteration: Iteration, rule_type: RuleType ) -> tuple[list[SuggestedAction] | None, RulePriority | None, RuleName | None]: - redirect_rules, action_mapper, default_comms = self.get_redirect_rules(best_active_iteration) + action_rules, action_mapper, default_comms = self.get_action_rules_components(best_active_iteration, rule_type) priority_getter = attrgetter("priority") - sorted_rules_by_priority = sorted(redirect_rules, key=priority_getter) + sorted_rules_by_priority = sorted(action_rules, key=priority_getter) + + actions: list[SuggestedAction] | None = self.get_actions_from_comms(action_mapper, default_comms) # pyright: ignore[reportArgumentType] - actions: list[SuggestedAction] | None = self.get_actions_from_comms(action_mapper, default_comms) - matched_redirect_rule_priority, matched_redirect_rule_name = None, None + matched_action_rule_priority, matched_action_rule_name = None, None for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): rule_group_list = list(rule_group) matcher_matched_list = [ @@ -261,11 +278,11 @@ def handle_redirect_rules( rule_actions = self.get_actions_from_comms(action_mapper, comms_routing) if rule_actions and len(rule_actions) > 0: actions = rule_actions - matched_redirect_rule_priority = rule_group_list[0].priority - matched_redirect_rule_name = rule_group_list[0].name + matched_action_rule_priority = rule_group_list[0].priority + matched_action_rule_name = rule_group_list[0].name break - return actions, matched_redirect_rule_priority, matched_redirect_rule_name + return actions, matched_action_rule_priority, matched_action_rule_name def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, CohortGroupResult]: cohort_results: dict[str, CohortGroupResult] = {} diff --git a/src/eligibility_signposting_api/services/calculators/rule_calculator.py b/src/eligibility_signposting_api/services/calculators/rule_calculator.py index 145a1e89f..03641e3be 100644 --- a/src/eligibility_signposting_api/services/calculators/rule_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/rule_calculator.py @@ -84,6 +84,8 @@ def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status rules.RuleType.filter: eligibility.Status.not_eligible, rules.RuleType.suppression: eligibility.Status.not_actionable, rules.RuleType.redirect: eligibility.Status.actionable, + rules.RuleType.not_eligible_actions: eligibility.Status.not_eligible, + rules.RuleType.not_actionable_actions: eligibility.Status.not_actionable, }[self.rule.type] return status, str(reason), matcher_matched matcher.describe_mismatch(attribute_value, reason) diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index 5388b113b..f0d7010b4 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -173,3 +173,27 @@ class ICBRedirectRuleFactory(IterationRuleFactory): attribute_name = rules.RuleAttributeName("ICB") comparator = rules.RuleComparator("QE1") comms_routing = rules.CommsRouting("ActionCode1") + + +class ICBNonEligibleActionRuleFactory(IterationRuleFactory): + type = rules.RuleType.not_eligible_actions + name = rules.RuleName("In QE1") + description = rules.RuleDescription("In QE1") + priority = rules.RulePriority(20) + operator = rules.RuleOperator.equals + attribute_level = rules.RuleAttributeLevel.PERSON + attribute_name = rules.RuleAttributeName("ICB") + comparator = rules.RuleComparator("QE1") + comms_routing = rules.CommsRouting("ActionCode1") + + +class ICBNonActionableActionRuleFactory(IterationRuleFactory): + type = rules.RuleType.not_actionable_actions + name = rules.RuleName("In QE1") + description = rules.RuleDescription("In QE1") + priority = rules.RulePriority(20) + operator = rules.RuleOperator.equals + attribute_level = rules.RuleAttributeLevel.PERSON + attribute_name = rules.RuleAttributeName("ICB") + comparator = rules.RuleComparator("QE1") + comms_routing = rules.CommsRouting("ActionCode1") diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index ab2672df5..643cbce2a 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -16,11 +16,16 @@ { "ID": "id_100", "DefaultCommsRouting": "INTERNALCONTACTGP1", + "DefaultNotActionableRouting": "INTERNALCONTACTGP1", + "DefaultNotEligibleRouting": "INTERNALCONTACTGP1", "ActionsMapper": { "INTERNALCONTACTGP1": {"ExternalRoutingCode": "CONTACTGP","ActionDescription":"Contact GP Text1 description", "ActionType":"text1"}, "INTERNALCONTACTGP2": {"ExternalRoutingCode": "CONTACTGP","ActionDescription":"Contact GP Link description", "ActionType":"link", "UrlLink": "link123", "UrlLabel": "link label"}, "INTERNALTESCO": {"ExternalRoutingCode": "TESCO","ActionDescription":"Tesco description", "ActionType":"link", "UrlLink": "tesco link", "UrlLabel": "link label"}, - "INTERNALFINDWALKIN": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"} + "INTERNALFINDWALKIN": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"}, + + "XRULEID1": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"}, + "YRULEID1": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"} }, "IterationCohorts": [ { @@ -91,6 +96,28 @@ "Operator": ">", "Comparator": "19000101", "CommsRouting": "INTERNALCONTACTGP1|INTERNALTESCO" + }, + { + "Type": "X", + "Name": "Test X Rule for not eligible", + "Description": "Test X Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": ">", + "Comparator": "19000101", + "CommsRouting": "XRULEID1|INTERNALTESCO" + }, + { + "Type": "Y", + "Name": "Test Y Rule for not actionable", + "Description": "Test Y Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": ">", + "Comparator": "19000101", + "CommsRouting": "YRULEID1|INTERNALTESCO" } ], "Version": "1", diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index c93edb77c..baa81b8b7 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -3,10 +3,12 @@ import pytest from faker import Faker +from flask import Flask, g from freezegun import freeze_time from hamcrest import assert_that, contains_exactly, contains_inanyorder, equal_to, has_item, has_items, is_, is_in from pydantic import HttpUrl, ValidationError +from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.model import rules from eligibility_signposting_api.model import rules as rules_model from eligibility_signposting_api.model.eligibility import ( @@ -37,9 +39,14 @@ from tests.fixtures.matchers.rules import is_iteration_rule +@pytest.fixture +def app(): + return Flask(__name__) + + class TestEligibilityCalculator: @staticmethod - def test_get_redirect_rules(): + def test_get_action_rules_components(): # Given iteration = rule_builder.IterationFactory.build( @@ -67,7 +74,9 @@ def test_get_redirect_rules(): ) # when - actual_rules, actual_action_mapper, actual_default_comms = EligibilityCalculator.get_redirect_rules(iteration) + actual_rules, actual_action_mapper, actual_default_comms = EligibilityCalculator.get_action_rules_components( + iteration, rules.RuleType.redirect + ) # then assert_that(actual_rules, has_item(is_iteration_rule().with_name(iteration.iteration_rules[0].name))) @@ -2404,3 +2413,667 @@ def test_campaigns_grouped_by_condition_name_filters_correctly( result = list(calculator.campaigns_grouped_by_condition_name(conditions_filter, category_filter)) assert_that([(str(name), group[0].type) for name, group in result], is_(expected_result)) + + +@pytest.mark.parametrize( + ( + "test_comment", + "person_icb", + "default_comms_routing", + "comms_routing", + "actions_mapper", + "expected_actions", + "expected_audit_actions", + "expected_rule_priority", + "expected_rule_name", + ), + [ + ( + """Not eligible person with matching NonEligibleActionRule""", + "QE1", + "", + "ActionCode1", + { + "ActionCode1": AvailableAction( + ActionType="InfoText", + ExternalRoutingCode="HealthcareProInfo", + ActionDescription="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("ActionCode1"), + action_type=ActionType("InfoText"), + action_code=ActionCode("HealthcareProInfo"), + action_description=ActionDescription( + """Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="ActionCode1", + action_code="HealthcareProInfo", + action_type="InfoText", + action_description="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + "20", + "In QE1", + ), + ( + """Not eligible person with NON matching NonEligibleActionRule""", + "WS3", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + None, + None, + ), + ( + """Not eligible person with matching but missing NonEligibleActionRule, fall back to default comms""", + "QE1", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + "20", + "In QE1", + ), + ], +) +def test_correct_actions_determined_from_not_eligible_action_rules( # noqa: PLR0913 + app, + test_comment, + person_icb, + default_comms_routing, + comms_routing, + actions_mapper, + expected_actions, + expected_audit_actions, + expected_rule_priority, + expected_rule_name, + faker: Faker, +): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"], icb=person_icb) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_eligible_routing=default_comms_routing, + actions_mapper=rule_builder.ActionsMapperFactory.build(root=actions_mapper), + iteration_rules=[ + rule_builder.ICBNonEligibleActionRuleFactory.build(comms_routing=comms_routing) + ], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_eligible)) + .and_actions(equal_to(expected_actions)) + ) + ), + test_comment, + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_actions + + assert getattr(cond.action_rule, "rule_priority", None) == expected_rule_priority + assert getattr(cond.action_rule, "rule_name", None) == expected_rule_name + + +def test_no_actions_returned_when_non_eligible_actions_and_defaultcomms_not_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonEligibleActions (X rules) should not return + any actions/default actions for NonEligible status + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"]) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + actions_mapper={}, + iteration_rules=[], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [] + expected_audit_action = [] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_eligible)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action + + +def test_actions_returned_when_non_eligible_actions_not_given_and_defaultcomms_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonEligibleActions (X rules) but with default comms routing + should return the default comms actions + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"]) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_eligible_routing="defaultCommsCode", + actions_mapper=rule_builder.ActionsMapperFactory.build( + root={ + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="Default Speak to your healthcare professional.", + ) + } + ), + iteration_rules=[], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription("Default Speak to your healthcare professional."), + url_link=None, + url_label=None, + ) + ] + expected_audit_action = [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="Default Speak to your healthcare professional.", + action_url=None, + action_url_label=None, + ) + ] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_eligible)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action + + +@pytest.mark.parametrize( + ( + "test_comment", + "person_icb", + "default_comms_routing", + "comms_routing", + "actions_mapper", + "expected_actions", + "expected_audit_actions", + ), + [ + ( + """Not actionable person with matching NonActionableActionRule""", + "QE1", + "", + "ActionCode1", + { + "ActionCode1": AvailableAction( + ActionType="InfoText", + ExternalRoutingCode="HealthcareProInfo", + ActionDescription="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("ActionCode1"), + action_type=ActionType("InfoText"), + action_code=ActionCode("HealthcareProInfo"), + action_description=ActionDescription( + """Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="ActionCode1", + action_code="HealthcareProInfo", + action_type="InfoText", + action_description="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + ), + ( + """Not actionable person with NON matching NonActionableActionRule""", + "WS3", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + ), + ( + """Not actionable person with matching but missing NonActionableActionRule, fall back to default comms""", + "QE1", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + ), + ], +) +def test_correct_actions_determined_from_not_actionable_action_rules( # noqa: PLR0913 + app, + test_comment, + person_icb, + default_comms_routing, + comms_routing, + actions_mapper, + expected_actions, + expected_audit_actions, + faker: Faker, +): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb=person_icb, de=True) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_actionable_routing=default_comms_routing, + actions_mapper=rule_builder.ActionsMapperFactory.build(root=actions_mapper), + iteration_rules=[ + rule_builder.DetainedEstateSuppressionRuleFactory.build(), + rule_builder.ICBNonActionableActionRuleFactory.build(comms_routing=comms_routing), + ], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_actionable)) + .and_actions(equal_to(expected_actions)) + ) + ), + test_comment, + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_actions + + +def test_no_actions_returned_when_non_actionable_actions_and_defaultcomms_not_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonActionableActions (Y rules) should not return + any actions/default actions for NonActionable status + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], de=True) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + actions_mapper={}, + iteration_rules=[rule_builder.DetainedEstateSuppressionRuleFactory.build()], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [] + expected_audit_action = [] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_actionable)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action + + +def test_actions_returned_when_non_actionable_actions_not_given_and_defaultcomms_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonActionableActions (Y rules) with default comms routing + should return default comms actions + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], de=True) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_actionable_routing="defaultCommsCode", + actions_mapper=rule_builder.ActionsMapperFactory.build( + root={ + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="Default Speak to your healthcare professional.", + ) + } + ), + iteration_rules=[rule_builder.DetainedEstateSuppressionRuleFactory.build()], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription("Default Speak to your healthcare professional."), + url_link=None, + url_label=None, + ) + ] + expected_audit_action = [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="Default Speak to your healthcare professional.", + action_url=None, + action_url_label=None, + ) + ] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_actionable)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action