Skip to content

Commit c47799a

Browse files
authored
ELI-351: Refactor (#254)
* ELI-351: Renames rules model to campaign_config * ELI-351: Extracts Person data class * ELI-351: Adds rule processor * ELI-351: Adds tests for rule processor * ELI-351: Moves get_cohort_group_results to rule processor * ELI-351: Adds tests for rule processor * ELI-351: Adds cohort handler using Chain of responsibility pattern * ELI-351: Renames * ELI-351: Fixes lint * ELI-351: Renames evaluate_eligibility to get_eligibility_status * ELI-351: Refactoring to get better readability for chaining * ELI-351: Fix lint
1 parent c1bed7d commit c47799a

30 files changed

Lines changed: 1267 additions & 464 deletions

src/eligibility_signposting_api/audit/audit_context.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,20 @@
1818
RequestAuditQueryParams,
1919
)
2020
from eligibility_signposting_api.audit.audit_service import AuditService
21+
from eligibility_signposting_api.model.campaign_config import (
22+
CampaignID,
23+
CampaignVersion,
24+
Iteration,
25+
RuleName,
26+
RulePriority,
27+
)
2128
from eligibility_signposting_api.model.eligibility_status import (
2229
CohortGroupResult,
2330
ConditionName,
2431
IterationResult,
2532
Status,
2633
SuggestedAction,
2734
)
28-
from eligibility_signposting_api.model.rules import CampaignID, CampaignVersion, Iteration, RuleName, RulePriority
2935

3036
logger = logging.getLogger(__name__)
3137

File renamed without changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from dataclasses import dataclass
2+
from typing import Any
3+
4+
5+
@dataclass
6+
class Person:
7+
data: list[dict[str, Any]]

src/eligibility_signposting_api/repos/campaign_repo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from botocore.client import BaseClient
66
from wireup import Inject, service
77

8-
from eligibility_signposting_api.model.rules import CampaignConfig, Rules
8+
from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules
99

1010
BucketName = NewType("BucketName", str)
1111

src/eligibility_signposting_api/repos/person_repo.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from wireup import Inject, service
77

88
from eligibility_signposting_api.model.eligibility_status import NHSNumber
9+
from eligibility_signposting_api.model.person import Person
910
from eligibility_signposting_api.repos.exceptions import NotFoundError
1011

1112
logger = logging.getLogger(__name__)
@@ -35,7 +36,7 @@ def __init__(self, table: Annotated[Any, Inject(qualifier="person_table")]) -> N
3536
super().__init__()
3637
self.table = table
3738

38-
def get_eligibility_data(self, nhs_number: NHSNumber) -> list[dict[str, Any]]:
39+
def get_eligibility_data(self, nhs_number: NHSNumber) -> Person:
3940
response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_number))
4041
logger.debug("response %r for %r", response, nhs_number, extra={"response": response, "nhs_number": nhs_number})
4142

@@ -44,4 +45,5 @@ def get_eligibility_data(self, nhs_number: NHSNumber) -> list[dict[str, Any]]:
4445
raise NotFoundError(message)
4546

4647
logger.debug("returning items %s", items, extra={"items": items})
47-
return items
48+
49+
return Person(data=items)

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 38 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,25 @@
22

33
from _operator import attrgetter
44
from collections import defaultdict
5-
from collections.abc import Collection, Iterable, Iterator, Mapping
65
from dataclasses import dataclass, field
76
from itertools import groupby
8-
from typing import TYPE_CHECKING, Any
9-
10-
from eligibility_signposting_api.audit.audit_context import AuditContext
11-
from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator
12-
from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader
13-
14-
if TYPE_CHECKING:
15-
from eligibility_signposting_api.model.rules import (
16-
ActionsMapper,
17-
CampaignConfig,
18-
CampaignID,
19-
CampaignVersion,
20-
Iteration,
21-
IterationCohort,
22-
RuleName,
23-
RulePriority,
24-
RuleType,
25-
)
7+
from typing import TYPE_CHECKING
268

279
from wireup import service
2810

29-
from eligibility_signposting_api.model import eligibility_status, rules
11+
from eligibility_signposting_api.audit.audit_context import AuditContext
12+
from eligibility_signposting_api.model import eligibility_status
13+
from eligibility_signposting_api.model.campaign_config import (
14+
ActionsMapper,
15+
CampaignConfig,
16+
CampaignID,
17+
CampaignVersion,
18+
Iteration,
19+
IterationRule,
20+
RuleName,
21+
RulePriority,
22+
RuleType,
23+
)
3024
from eligibility_signposting_api.model.eligibility_status import (
3125
ActionCode,
3226
ActionDescription,
@@ -44,24 +38,29 @@
4438
from eligibility_signposting_api.services.calculators.rule_calculator import (
4539
RuleCalculator,
4640
)
41+
from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator
42+
from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor
43+
44+
if TYPE_CHECKING:
45+
from collections.abc import Collection
4746

48-
Row = Collection[Mapping[str, Any]]
47+
from eligibility_signposting_api.model.person import Person
4948

5049

5150
@service
5251
class EligibilityCalculatorFactory:
5352
@staticmethod
54-
def get(person_data: Row, campaign_configs: Collection[rules.CampaignConfig]) -> EligibilityCalculator:
55-
return EligibilityCalculator(person_data=person_data, campaign_configs=campaign_configs)
53+
def get(person: Person, campaign_configs: Collection[CampaignConfig]) -> EligibilityCalculator:
54+
return EligibilityCalculator(person=person, campaign_configs=campaign_configs)
5655

5756

5857
@dataclass
5958
class EligibilityCalculator:
60-
person_data: Row
61-
campaign_configs: Collection[rules.CampaignConfig]
59+
person: Person
60+
campaign_configs: Collection[CampaignConfig]
6261

6362
campaign_evaluator: CampaignEvaluator = field(default_factory=CampaignEvaluator)
64-
person_data_reader: PersonDataReader = field(default_factory=PersonDataReader)
63+
rule_processor: RuleProcessor = field(default_factory=RuleProcessor)
6564

6665
results: list[eligibility_status.Condition] = field(default_factory=list)
6766

@@ -88,56 +87,34 @@ def get_the_best_cohort_memberships(
8887

8988
return best_status, best_cohorts
9089

91-
@staticmethod
92-
def get_exclusion_rules(
93-
cohort: IterationCohort, filter_rules: Iterable[rules.IterationRule]
94-
) -> Iterator[rules.IterationRule]:
95-
return (
96-
ir
97-
for ir in filter_rules
98-
if ir.cohort_label is None
99-
or cohort.cohort_label == ir.cohort_label
100-
or (isinstance(ir.cohort_label, (list, set, tuple)) and cohort.cohort_label in ir.cohort_label)
101-
)
102-
103-
@staticmethod
104-
def get_rules_by_type(
105-
active_iteration: Iteration,
106-
) -> tuple[tuple[rules.IterationRule, ...], tuple[rules.IterationRule, ...]]:
107-
filter_rules, suppression_rules = (
108-
tuple(rule for rule in active_iteration.iteration_rules if attrgetter("type")(rule) == rule_type)
109-
for rule_type in (rules.RuleType.filter, rules.RuleType.suppression)
110-
)
111-
return filter_rules, suppression_rules
112-
11390
@staticmethod
11491
def get_action_rules_components(
11592
active_iteration: Iteration, rule_type: RuleType
116-
) -> tuple[tuple[rules.IterationRule, ...], ActionsMapper, str | None]:
93+
) -> tuple[tuple[IterationRule, ...], ActionsMapper, str | None]:
11794
action_rules = tuple(rule for rule in active_iteration.iteration_rules if rule.type in rule_type)
11895

11996
routing_map = {
120-
rules.RuleType.redirect: active_iteration.default_comms_routing,
121-
rules.RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing,
122-
rules.RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing,
97+
RuleType.redirect: active_iteration.default_comms_routing,
98+
RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing,
99+
RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing,
123100
}
124101

125102
default_comms = routing_map.get(rule_type)
126103
action_mapper = active_iteration.actions_mapper
127104
return action_rules, action_mapper, default_comms
128105

129-
def evaluate_eligibility(
106+
def get_eligibility_status(
130107
self, include_actions: str, conditions: list[str], category: str
131108
) -> eligibility_status.EligibilityStatus:
132109
include_actions_flag = include_actions.upper() == "Y"
133110
condition_results: dict[ConditionName, IterationResult] = {}
134-
actions: list[SuggestedAction] | None = []
135111
action_rule_priority, action_rule_name = None, None
136112

137113
requested_grouped_campaigns = self.campaign_evaluator.get_requested_grouped_campaigns(
138114
self.campaign_configs, conditions, category
139115
)
140116
for condition_name, campaign_group in requested_grouped_campaigns:
117+
actions: list[SuggestedAction] | None = []
141118
best_active_iteration: Iteration | None
142119
best_candidate: IterationResult
143120
best_campaign_id: CampaignID | None
@@ -168,9 +145,9 @@ def evaluate_eligibility(
168145
condition_results[condition_name] = best_candidate
169146

170147
status_to_rule_type = {
171-
Status.actionable: rules.RuleType.redirect,
172-
Status.not_eligible: rules.RuleType.not_eligible_actions,
173-
Status.not_actionable: rules.RuleType.not_actionable_actions,
148+
Status.actionable: RuleType.redirect,
149+
Status.not_eligible: RuleType.not_eligible_actions,
150+
Status.not_actionable: RuleType.not_actionable_actions,
174151
}
175152

176153
if best_candidate.status in status_to_rule_type and best_active_iteration is not None:
@@ -192,8 +169,6 @@ def evaluate_eligibility(
192169

193170
# add actions to condition results
194171
condition_results[condition_name].actions = actions
195-
# reset actions for the next condition
196-
actions: list[SuggestedAction] | None = []
197172

198173
# add audit data
199174
AuditContext.append_audit_condition(
@@ -216,7 +191,9 @@ def get_iteration_results(
216191
] = {}
217192
for cc in campaign_group:
218193
active_iteration = cc.current_iteration
219-
cohort_results: dict[str, CohortGroupResult] = self.get_cohort_results(active_iteration)
194+
cohort_results: dict[str, CohortGroupResult] = self.rule_processor.get_cohort_group_results(
195+
self.person, active_iteration
196+
)
220197

221198
# Determine Result between cohorts - get the best
222199
status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results)
@@ -242,7 +219,7 @@ def handle_action_rules(
242219
for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter):
243220
rule_group_list = list(rule_group)
244221
matcher_matched_list = [
245-
RuleCalculator(person_data=self.person_data, rule=rule).evaluate_exclusion()[1].matcher_matched
222+
RuleCalculator(person=self.person, rule=rule).evaluate_exclusion()[1].matcher_matched
246223
for rule in rule_group_list
247224
]
248225

@@ -257,29 +234,6 @@ def handle_action_rules(
257234

258235
return actions, matched_action_rule_priority, matched_action_rule_name
259236

260-
def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, CohortGroupResult]:
261-
cohort_results: dict[str, CohortGroupResult] = {}
262-
filter_rules, suppression_rules = self.get_rules_by_type(active_iteration)
263-
for cohort in sorted(active_iteration.iteration_cohorts, key=attrgetter("priority")):
264-
# Base Eligibility - check
265-
person_cohorts = self.person_data_reader.get_person_cohorts(self.person_data)
266-
if cohort.cohort_label in person_cohorts or cohort.is_magic_cohort:
267-
# Eligibility - check
268-
if self.is_eligible_by_filter_rules(cohort, cohort_results, filter_rules):
269-
# Actionability - evaluation
270-
self.evaluate_suppression_rules(cohort, cohort_results, suppression_rules)
271-
272-
# Not base eligible
273-
elif cohort.cohort_label is not None:
274-
cohort_results[cohort.cohort_label] = CohortGroupResult(
275-
cohort.cohort_group,
276-
Status.not_eligible,
277-
[],
278-
cohort.negative_description,
279-
[],
280-
)
281-
return cohort_results
282-
283237
@staticmethod
284238
def build_condition_results(condition_results: dict[ConditionName, IterationResult]) -> list[Condition]:
285239
conditions: list[Condition] = []
@@ -318,85 +272,6 @@ def build_condition_results(condition_results: dict[ConditionName, IterationResu
318272
)
319273
return conditions
320274

321-
def is_eligible_by_filter_rules(
322-
self,
323-
cohort: IterationCohort,
324-
cohort_results: dict[str, CohortGroupResult],
325-
filter_rules: Iterable[rules.IterationRule],
326-
) -> bool:
327-
is_eligible = True
328-
priority_getter = attrgetter("priority")
329-
sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, filter_rules), key=priority_getter)
330-
331-
for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter):
332-
status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(rule_group)
333-
if status.is_exclusion:
334-
if cohort.cohort_label is not None:
335-
cohort_results[cohort.cohort_label] = CohortGroupResult(
336-
(cohort.cohort_group),
337-
Status.not_eligible,
338-
[],
339-
cohort.negative_description,
340-
group_exclusion_reasons,
341-
)
342-
is_eligible = False
343-
break
344-
return is_eligible
345-
346-
def evaluate_suppression_rules(
347-
self,
348-
cohort: IterationCohort,
349-
cohort_results: dict[str, CohortGroupResult],
350-
suppression_rules: Iterable[rules.IterationRule],
351-
) -> None:
352-
is_actionable: bool = True
353-
priority_getter = attrgetter("priority")
354-
suppression_reasons = []
355-
356-
sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, suppression_rules), key=priority_getter)
357-
358-
for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter):
359-
status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(rule_group)
360-
if status.is_exclusion:
361-
is_actionable = False
362-
suppression_reasons.extend(group_exclusion_reasons)
363-
if rule_stop:
364-
break
365-
366-
if cohort.cohort_label is not None:
367-
key = cohort.cohort_label
368-
if is_actionable:
369-
cohort_results[key] = CohortGroupResult(
370-
cohort.cohort_group, Status.actionable, [], cohort.positive_description, suppression_reasons
371-
)
372-
else:
373-
cohort_results[key] = CohortGroupResult(
374-
cohort.cohort_group,
375-
Status.not_actionable,
376-
suppression_reasons,
377-
cohort.positive_description,
378-
suppression_reasons,
379-
)
380-
381-
def evaluate_rules_priority_group(
382-
self, rules_group: Iterator[rules.IterationRule]
383-
) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]:
384-
is_rule_stop = False
385-
exclusion_reasons = []
386-
best_status = eligibility_status.Status.not_eligible
387-
388-
for rule in rules_group:
389-
is_rule_stop = rule.rule_stop or is_rule_stop
390-
rule_calculator = RuleCalculator(person_data=self.person_data, rule=rule)
391-
status, reason = rule_calculator.evaluate_exclusion()
392-
if status.is_exclusion:
393-
best_status = eligibility_status.Status.best(status, best_status)
394-
exclusion_reasons.append(reason)
395-
else:
396-
best_status = eligibility_status.Status.actionable
397-
398-
return best_status, exclusion_reasons, is_rule_stop
399-
400275
@staticmethod
401276
def get_actions_from_comms(action_mapper: ActionsMapper, comms: str) -> list[SuggestedAction] | None:
402277
suggested_actions: list[SuggestedAction] = []

0 commit comments

Comments
 (0)