Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bdb08ea
WIP: drafting out X and Y rules.
ayeshalshukri1-nhs Jul 8, 2025
03b3c53
WIP: updated config with x and y rule idea.
ayeshalshukri1-nhs Jul 8, 2025
62c64f4
WIP: Stub out x and y rules impl
ayeshalshukri1-nhs Jul 8, 2025
ab068a4
WIP: stubbing out impl.
ayeshalshukri1-nhs Jul 8, 2025
8865123
Refactored action support functions and renamed vars
robbailiff2 Jul 9, 2025
a5d56a4
WIP: Added X/Y Rule logic and test.
ayeshalshukri1-nhs Jul 9, 2025
9758439
Added tests for eligible and actionable actions.
ayeshalshukri1-nhs Jul 10, 2025
7b5b523
WIP: Added more tests for X and Y rule scenarios.
ayeshalshukri1-nhs Jul 10, 2025
2e8b7b5
WIP: flaky tests.
ayeshalshukri1-nhs Jul 11, 2025
950f6a6
WIP: Fixed failing tests for empty actions.
ayeshalshukri1-nhs Jul 11, 2025
af6fc3d
WIP: added audit record check to tests.
ayeshalshukri1-nhs Jul 11, 2025
41d99f0
WIP: file format and added audit rule priority and name test.
ayeshalshukri1-nhs Jul 11, 2025
1b253a1
Working tests. Refactored some audit logic.
ayeshalshukri1-nhs Jul 14, 2025
c3effee
Minor refactor
robbailiff2 Jul 14, 2025
125a330
Addressed linting issues
robbailiff2 Jul 15, 2025
2fd53ac
WIP - Resolving merge conflicts with main
robbailiff2 Jul 15, 2025
0884a33
WIP: fixed failing unit tests.
ayeshalshukri1-nhs Jul 16, 2025
4602704
Format.
ayeshalshukri1-nhs Jul 16, 2025
65a9262
Added tests.
ayeshalshukri1-nhs Jul 16, 2025
c2456a1
File format
ayeshalshukri1-nhs Jul 16, 2025
7126297
Merge branch 'main' into feature/eli-295-generic-text-for-not-eligibl…
ayeshalshukri1-nhs Jul 16, 2025
c01f590
Merge branch 'main' into feature/eli-295-generic-text-for-not-eligibl…
ayeshalshukri1-nhs Jul 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions src/eligibility_signposting_api/audit/audit_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)

Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/eligibility_signposting_api/model/eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class RuleType(StrEnum):
filter = "F"
suppression = "S"
redirect = "R"
not_eligible_actions = "X"
not_actionable_actions = "Y"


@total_ordering
Expand Down
4 changes: 4 additions & 0 deletions src/eligibility_signposting_api/model/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class RuleType(StrEnum):
filter = "F"
suppression = "S"
redirect = "R"
not_eligible_actions = "X"
not_actionable_actions = "Y"


class RuleOperator(StrEnum):
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
IterationCohort,
RuleName,
RulePriority,
RuleType,
)

from wireup import service
Expand Down Expand Up @@ -140,23 +141,28 @@ 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
) -> eligibility.EligibilityStatus:
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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 = [
Expand All @@ -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] = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions tests/fixtures/builders/model/rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
29 changes: 28 additions & 1 deletion tests/test_data/test_config/test_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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",
Expand Down
Loading