Skip to content

Commit ba462a1

Browse files
Eli 219 support action r rules (#140)
* Adding tests. * added include actions flag with some tests * added in handling for incorrect query key plus tests * added in default comms routing to the iteration model and a test to prove * added in actions mapper to the iteration model and a test to prove * added comms routing to rule model, and started new method test * added some prettier printing, some more function for new method and more testing of method * improved testing * Stubbed out some tests for AC on ticket. * Fleshed out R rule in test with detail defined in ticket. * part way through changes * Fixed failing unit tests. * returned actions properly and tested * Added test for cohort label not used for redirect rule. * refactor test. * WIP * WIP - Pydantic AvailableActionMap. * Revert "WIP - Pydantic AvailableActionMap." This reverts commit 29c3aeb. * Patch * Patch 2 * fixing some of the tests * fixing the final test * fleshing out testing scenarios. * Patch - added tests fixed multiple campaign bug. * started passing through flag to calc * Patch - Added tests and fixed some linting. * started passing through flag to calc * extra test * extra test * Updated cohort_label test for better coverage. * Patch - Fixed None issue and lint errors and tests. * JSON formatted. * Updated JSON file. * Removed unnecessary =none --------- Co-authored-by: ayeshalshukri1-nhs <[email protected]>
1 parent 379ad81 commit ba462a1

File tree

14 files changed

+1092
-74
lines changed

14 files changed

+1092
-74
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
MAGIC_COHORT_LABEL = "elid_all_people"
2+
RULE_STOP_DEFAULT = False

src/eligibility_signposting_api/model/eligibility.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
RuleName = NewType("RuleName", str)
1515
RuleDescription = NewType("RuleDescription", str)
1616

17+
ActionType = NewType("ActionType", str)
18+
ActionCode = NewType("ActionCode", str)
19+
ActionDescription = NewType("ActionDescription", str)
20+
UrlLink = NewType("UrlLink", str)
21+
UrlLabel = NewType("UrlLabel", str)
22+
1723

1824
class RuleType(StrEnum):
1925
filter = "F"
@@ -61,13 +67,29 @@ class Reason:
6167
rule_type: RuleType
6268
rule_name: RuleName
6369
rule_description: RuleDescription | None
70+
matcher_matched: bool
71+
72+
73+
@dataclass
74+
class SuggestedAction:
75+
action_type: ActionType
76+
action_code: ActionCode
77+
action_description: ActionDescription | None
78+
url_link: UrlLink | None
79+
url_label: UrlLabel | None
80+
81+
82+
@dataclass
83+
class SuggestedActions:
84+
actions: list[SuggestedAction]
6485

6586

6687
@dataclass
6788
class Condition:
6889
condition_name: ConditionName
6990
status: Status
7091
cohort_results: list[CohortGroupResult]
92+
actions: SuggestedActions | None = None
7193

7294

7395
@dataclass
@@ -82,6 +104,7 @@ class CohortGroupResult:
82104
class IterationResult:
83105
status: Status
84106
cohort_results: list[CohortGroupResult]
107+
actions: SuggestedActions | None
85108

86109

87110
@dataclass

src/eligibility_signposting_api/model/rules.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import json
34
import typing
45
from collections import Counter
56
from datetime import UTC, date, datetime
@@ -8,9 +9,9 @@
89
from operator import attrgetter
910
from typing import Literal, NewType
1011

11-
from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
12+
from pydantic import BaseModel, Field, RootModel, field_serializer, field_validator, model_validator
1213

13-
from eligibility_signposting_api.config.contants import MAGIC_COHORT_LABEL
14+
from eligibility_signposting_api.config.contants import MAGIC_COHORT_LABEL, RULE_STOP_DEFAULT
1415

1516
if typing.TYPE_CHECKING: # pragma: no cover
1617
from pydantic import SerializationInfo
@@ -34,6 +35,7 @@
3435
CohortGroup = NewType("CohortGroup", str)
3536
Description = NewType("Description", str)
3637
RuleStop = NewType("RuleStop", bool)
38+
CommsRouting = NewType("CommsRouting", str)
3739

3840

3941
class RuleType(StrEnum):
@@ -111,7 +113,8 @@ class IterationRule(BaseModel):
111113
operator: RuleOperator = Field(..., alias="Operator")
112114
comparator: RuleComparator = Field(..., alias="Comparator")
113115
attribute_target: RuleAttributeTarget | None = Field(None, alias="AttributeTarget")
114-
rule_stop: RuleStop = Field(RuleStop(False), alias="RuleStop") # noqa: FBT003
116+
rule_stop: RuleStop = Field(RuleStop(RULE_STOP_DEFAULT), alias="RuleStop")
117+
comms_routing: CommsRouting | None = Field(None, alias="CommsRouting")
115118

116119
model_config = {"populate_by_name": True, "extra": "ignore"}
117120

@@ -121,6 +124,24 @@ def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805
121124
return v.upper() == "Y"
122125
return v
123126

127+
def __str__(self) -> str:
128+
return json.dumps(self.model_dump(by_alias=True), indent=2)
129+
130+
131+
class AvailableAction(BaseModel):
132+
action_type: str = Field(..., alias="ActionType")
133+
action_code: str = Field(..., alias="ExternalRoutingCode")
134+
action_description: str | None = Field(None, alias="ActionDescription")
135+
url_link: str | None = Field(None, alias="UrlLink")
136+
url_label: str | None = Field(None, alias="UrlLabel")
137+
138+
model_config = {"populate_by_name": True}
139+
140+
141+
class ActionsMapper(RootModel[dict[str, AvailableAction]]):
142+
def get(self, key: str, default: AvailableAction | None = None) -> AvailableAction | None:
143+
return self.root.get(key, default)
144+
124145

125146
class Iteration(BaseModel):
126147
id: IterationID = Field(..., alias="ID")
@@ -131,8 +152,10 @@ class Iteration(BaseModel):
131152
approval_minimum: int | None = Field(None, alias="ApprovalMinimum")
132153
approval_maximum: int | None = Field(None, alias="ApprovalMaximum")
133154
type: Literal["A", "M", "S"] = Field(..., alias="Type")
155+
default_comms_routing: str = Field(..., alias="DefaultCommsRouting")
134156
iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts")
135157
iteration_rules: list[IterationRule] = Field(..., alias="IterationRules")
158+
actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper")
136159

137160
model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"}
138161

@@ -148,6 +171,9 @@ def parse_dates(cls, v: str | date) -> date:
148171
def serialize_dates(v: date, _info: SerializationInfo) -> str:
149172
return v.strftime("%Y%m%d")
150173

174+
def __str__(self) -> str:
175+
return json.dumps(self.model_dump(by_alias=True), indent=2)
176+
151177

152178
class CampaignConfig(BaseModel):
153179
id: CampaignID = Field(..., alias="ID")
@@ -224,6 +250,9 @@ def current_iteration(self) -> Iteration:
224250
iterations_by_date_descending = sorted(self.iterations, key=attrgetter("iteration_date"), reverse=True)
225251
return next(i for i in iterations_by_date_descending if i.iteration_date <= today)
226252

253+
def __str__(self) -> str:
254+
return json.dumps(self.model_dump(by_alias=True), indent=2)
255+
227256

228257
class Rules(BaseModel):
229258
"""Eligibility rules.

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@
88
from typing import TYPE_CHECKING, Any
99

1010
if TYPE_CHECKING:
11-
from eligibility_signposting_api.model.rules import Iteration, IterationCohort
11+
from eligibility_signposting_api.model.rules import ActionsMapper, Iteration, IterationCohort
1212

1313
from wireup import service
1414

1515
from eligibility_signposting_api.model import eligibility, rules
1616
from eligibility_signposting_api.model.eligibility import (
17+
ActionCode,
18+
ActionDescription,
19+
ActionType,
1720
CohortGroupResult,
1821
Condition,
1922
ConditionName,
2023
IterationResult,
2124
Status,
25+
SuggestedAction,
26+
SuggestedActions,
27+
UrlLabel,
28+
UrlLink,
2229
)
2330
from eligibility_signposting_api.services.calculators.rule_calculator import (
2431
RuleCalculator,
@@ -108,32 +115,78 @@ def get_rules_by_type(
108115
)
109116
return filter_rules, suppression_rules
110117

111-
def evaluate_eligibility(self) -> eligibility.EligibilityStatus:
118+
@staticmethod
119+
def get_redirect_rules(
120+
active_iteration: Iteration,
121+
) -> tuple[tuple[rules.IterationRule, ...], ActionsMapper, str]:
122+
redirect_rules = tuple(
123+
rule for rule in active_iteration.iteration_rules if rule.type in rules.RuleType.redirect
124+
)
125+
default_comms = active_iteration.default_comms_routing
126+
action_mapper = active_iteration.actions_mapper
127+
return redirect_rules, action_mapper, default_comms
128+
129+
def evaluate_eligibility(self, *, include_actions_flag: bool = True) -> eligibility.EligibilityStatus:
112130
"""Iterates over campaign groups, evaluates eligibility, and returns a consolidated status."""
113131
condition_results: dict[ConditionName, IterationResult] = {}
132+
actions: SuggestedActions | None = SuggestedActions([])
114133

115134
for condition_name, campaign_group in self.campaigns_grouped_by_condition_name:
116-
iteration_results: dict[str, IterationResult] = {}
135+
iteration_results: dict[str, tuple[Iteration, IterationResult]] = {}
117136

118137
for active_iteration in [cc.current_iteration for cc in campaign_group]:
119138
cohort_results: dict[str, CohortGroupResult] = self.get_cohort_results(active_iteration)
120139

121140
# Determine Result between cohorts - get the best
122141
status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results)
123-
124-
iteration_results[active_iteration.name] = IterationResult(status, best_cohorts)
142+
iteration_results[active_iteration.name] = (
143+
active_iteration,
144+
IterationResult(status, best_cohorts, actions),
145+
)
125146

126147
# Determine results between iterations - get the best
127148
if iteration_results:
128-
best_candidate = max(iteration_results.values(), key=lambda r: r.status.value)
149+
best_iteration_name, (best_active_iteration, best_candidate) = max(
150+
iteration_results.items(), key=lambda item: item[1][1].status.value
151+
)
129152
else:
130-
best_candidate = IterationResult(eligibility.Status.not_eligible, [])
153+
best_candidate = IterationResult(eligibility.Status.not_eligible, [], actions)
154+
best_active_iteration = None
131155
condition_results[condition_name] = best_candidate
132156

157+
if best_candidate.status == Status.actionable and best_active_iteration is not None:
158+
actions = self.handle_redirect_rules(best_active_iteration) if include_actions_flag else None
159+
if best_candidate.status in (Status.not_eligible, Status.not_actionable) and not include_actions_flag:
160+
actions = None
161+
162+
# add actions to condition results
163+
condition_results[condition_name].actions = actions
133164
# Consolidate all the results and return
134165
final_result = self.build_condition_results(condition_results)
135166
return eligibility.EligibilityStatus(conditions=final_result)
136167

168+
def handle_redirect_rules(self, best_active_iteration: Iteration) -> SuggestedActions | None:
169+
redirect_rules, action_mapper, default_comms = self.get_redirect_rules(best_active_iteration)
170+
priority_getter = attrgetter("priority")
171+
sorted_rules_by_priority = sorted(redirect_rules, key=priority_getter)
172+
173+
actions: SuggestedActions | None = self.get_actions_from_comms(action_mapper, default_comms)
174+
for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter):
175+
rule_group_list = list(rule_group)
176+
matcher_matched_list = [
177+
RuleCalculator(person_data=self.person_data, rule=rule).evaluate_exclusion()[1].matcher_matched
178+
for rule in rule_group_list
179+
]
180+
181+
comms_routing = rule_group_list[0].comms_routing
182+
if comms_routing and all(matcher_matched_list):
183+
rule_actions = self.get_actions_from_comms(action_mapper, comms_routing)
184+
if rule_actions and len(rule_actions.actions) > 0:
185+
actions = rule_actions
186+
break
187+
188+
return actions
189+
137190
def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, CohortGroupResult]:
138191
cohort_results: dict[str, CohortGroupResult] = {}
139192
filter_rules, suppression_rules = self.get_rules_by_type(active_iteration)
@@ -156,9 +209,7 @@ def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, Coh
156209
return cohort_results
157210

158211
@staticmethod
159-
def build_condition_results(
160-
condition_results: dict[ConditionName, IterationResult],
161-
) -> list[Condition]:
212+
def build_condition_results(condition_results: dict[ConditionName, IterationResult]) -> list[Condition]:
162213
conditions: list[Condition] = []
163214
# iterate over conditions
164215
for condition_name, active_iteration_result in condition_results.items():
@@ -188,6 +239,7 @@ def build_condition_results(
188239
condition_name=condition_name,
189240
status=active_iteration_result.status,
190241
cohort_results=list(deduplicated_cohort_results),
242+
actions=condition_results[condition_name].actions,
191243
)
192244
)
193245
return conditions
@@ -276,3 +328,22 @@ def evaluate_rules_priority_group(
276328
inclusion_reasons.append(reason)
277329

278330
return best_status, inclusion_reasons, exclusion_reasons, is_rule_stop
331+
332+
@staticmethod
333+
def get_actions_from_comms(action_mapper: ActionsMapper, comms: str) -> SuggestedActions | None:
334+
suggested_actions: SuggestedActions = SuggestedActions([])
335+
for comm in comms.split("|"):
336+
action = action_mapper.get(comm)
337+
if action is not None:
338+
suggested_actions.actions.append(
339+
SuggestedAction(
340+
action_type=ActionType(action.action_type),
341+
action_code=ActionCode(action.action_code),
342+
action_description=ActionDescription(action.action_description)
343+
if action.action_description
344+
else None,
345+
url_link=UrlLink(action.url_link) if action.url_link else None,
346+
url_label=UrlLabel(action.url_label) if action.url_label else None,
347+
)
348+
)
349+
return suggested_actions

src/eligibility_signposting_api/services/calculators/rule_calculator.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ class RuleCalculator:
2020
def evaluate_exclusion(self) -> tuple[eligibility.Status, eligibility.Reason]:
2121
"""Evaluate if a particular rule excludes this person. Return the result, and the reason for the result."""
2222
attribute_value = self.get_attribute_value()
23-
status, reason = self.evaluate_rule(attribute_value)
23+
status, reason, matcher_matched = self.evaluate_rule(attribute_value)
2424
reason = eligibility.Reason(
2525
rule_name=eligibility.RuleName(self.rule.name),
2626
rule_type=eligibility.RuleType(self.rule.type),
2727
rule_description=eligibility.RuleDescription(self.rule.description),
28+
matcher_matched=matcher_matched,
2829
)
2930
return status, reason
3031

@@ -69,18 +70,20 @@ def get_value(dictionary: Mapping[str, Any] | None, key: str) -> dict:
6970
v = dictionary.get(key, {}) if isinstance(dictionary, dict) else {}
7071
return v if isinstance(v, dict) else {}
7172

72-
def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status, str]:
73+
def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status, str, bool]:
7374
"""Evaluate a rule against a person data attribute. Return the result, and the reason for the result."""
7475
matcher_class = OperatorRegistry.get(self.rule.operator)
7576
matcher = matcher_class(rule_value=self.rule.comparator)
7677

78+
matcher_matched = matcher.matches(attribute_value)
7779
reason = StringDescription()
78-
if matcher.matches(attribute_value):
80+
if matcher_matched:
7981
matcher.describe_match(attribute_value, reason)
8082
status = {
8183
rules.RuleType.filter: eligibility.Status.not_eligible,
8284
rules.RuleType.suppression: eligibility.Status.not_actionable,
85+
rules.RuleType.redirect: eligibility.Status.actionable,
8386
}[self.rule.type]
84-
return status, str(reason)
87+
return status, str(reason), matcher_matched
8588
matcher.describe_mismatch(attribute_value, reason)
86-
return eligibility.Status.actionable, str(reason)
89+
return eligibility.Status.actionable, str(reason), matcher_matched

src/eligibility_signposting_api/services/eligibility_services.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ class UnknownPersonError(Exception):
1313
pass
1414

1515

16+
class InvalidQueryParamError(Exception):
17+
pass
18+
19+
1620
@service
1721
class EligibilityService:
1822
def __init__(
@@ -26,7 +30,9 @@ def __init__(
2630
self.campaign_repo = campaign_repo
2731
self.calculator_factory = calculator_factory
2832

29-
def get_eligibility_status(self, nhs_number: eligibility.NHSNumber | None = None) -> eligibility.EligibilityStatus:
33+
def get_eligibility_status(
34+
self, nhs_number: eligibility.NHSNumber | None = None, *, include_actions_flag: bool = True
35+
) -> eligibility.EligibilityStatus:
3036
"""Calculate a person's eligibility for vaccination given an NHS number."""
3137
if nhs_number:
3238
try:
@@ -45,6 +51,6 @@ def get_eligibility_status(self, nhs_number: eligibility.NHSNumber | None = None
4551
raise UnknownPersonError from e
4652
else:
4753
calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, campaign_configs)
48-
return calc.evaluate_eligibility()
54+
return calc.evaluate_eligibility(include_actions_flag=include_actions_flag)
4955

5056
raise UnknownPersonError # pragma: no cover

0 commit comments

Comments
 (0)