Skip to content

Commit c7174b0

Browse files
Merge pull request #90 from NHSDigital/refactor/extract-rulecalculator-from-eligibilitycalculator
Extract RuleCalculator from EligibilityCalculator.
2 parents b0905f6 + 9afdae7 commit c7174b0

File tree

3 files changed

+95
-73
lines changed

3 files changed

+95
-73
lines changed

src/eligibility_signposting_api/model/eligibility.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from dataclasses import dataclass
24
from datetime import date
35
from enum import Enum, StrEnum, auto
@@ -30,6 +32,29 @@ def __lt__(self, other: Self) -> bool:
3032
return self.value < other.value
3133
return NotImplemented
3234

35+
@property
36+
def is_exclusion(self) -> bool:
37+
return self is not Status.actionable
38+
39+
@staticmethod
40+
def worst(*statuses: Status) -> Status:
41+
"""Pick the worst status from those given.
42+
43+
Here "worst" means furthest from being able to access vaccination, so not-eligible is "worse" than
44+
not-actionable, and not-actionable is "worse" than actionable.
45+
"""
46+
return min(statuses)
47+
48+
@staticmethod
49+
def best(*statuses: Status) -> Status:
50+
"""Pick the best status between the existing status, and the status implied by
51+
the rule excluding the person from vaccination.
52+
53+
Here "best" means closest to being able to access vaccination, so not-actionable is "better" than
54+
not-eligible, and actionable is "better" than not-actionable.
55+
"""
56+
return max(statuses)
57+
3358

3459
@dataclass
3560
class Reason:

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 8 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@
88
from itertools import groupby
99
from typing import Any
1010

11-
from hamcrest.core.string_description import StringDescription
1211
from wireup import service
1312

1413
from eligibility_signposting_api.model import eligibility, rules
15-
from eligibility_signposting_api.services.rules.operators import OperatorRegistry
14+
from eligibility_signposting_api.services.calculators.rule_calculator import RuleCalculator
1615

1716
Row = Collection[Mapping[str, Any]]
1817

@@ -153,45 +152,22 @@ def evaluate_priority_group(
153152
eligibility.Status.not_eligible if exclude_capable_rules else eligibility.Status.actionable
154153
)
155154
for iteration_rule in exclude_capable_rules:
156-
exclusion, reason = self.evaluate_exclusion(iteration_rule)
157-
if exclusion:
158-
best_status_so_far_for_priority_group = self.best_status(
159-
iteration_rule.type, best_status_so_far_for_priority_group
155+
rule_calculator = RuleCalculator(person_data=self.person_data, rule=iteration_rule)
156+
status, reason = rule_calculator.evaluate_exclusion()
157+
if status.is_exclusion:
158+
best_status_so_far_for_priority_group = eligibility.Status.best(
159+
status, best_status_so_far_for_priority_group
160160
)
161161
exclusion_reasons.append(reason)
162162
else:
163163
best_status_so_far_for_priority_group = eligibility.Status.actionable
164164
actionable_reasons.append(reason)
165165
return (
166-
self.worst_status(best_status_so_far_for_priority_group, worst_status_so_far_for_condition),
166+
eligibility.Status.worst(best_status_so_far_for_priority_group, worst_status_so_far_for_condition),
167167
actionable_reasons,
168168
exclusion_reasons,
169169
)
170170

171-
@staticmethod
172-
def worst_status(*statuses: eligibility.Status) -> eligibility.Status:
173-
"""Pick the worst status from those given.
174-
175-
Here "worst" means furthest from being able to access vaccination, so not-eligible is "worse" than
176-
not-actionable, and not-actionable is "worse" than actionable.
177-
"""
178-
return min(statuses)
179-
180-
@staticmethod
181-
def best_status(rule_type: rules.RuleType, status: eligibility.Status) -> eligibility.Status:
182-
"""Pick the best status between the existing status, and the status implied by
183-
the rule excluding the person from vaccination.
184-
185-
Here "best" means closest to being able to access vaccination, so not-actionable is "better" than
186-
not-eligible, and actionable is "better" than not-actionable.
187-
"""
188-
return max(
189-
status,
190-
eligibility.Status.not_eligible
191-
if rule_type == rules.RuleType.filter
192-
else eligibility.Status.not_actionable,
193-
)
194-
195171
def get_base_eligible_conditions(
196172
self,
197173
base_eligible_evaluations: Mapping[
@@ -204,48 +180,7 @@ def get_base_eligible_conditions(
204180
# for each condition for which the person is base eligible:
205181
# what is the "best" status, i.e. closest to actionable? Add the condition to the result with that status.
206182
for condition_name, reasons_by_status in base_eligible_evaluations.items():
207-
best_status = max(reasons_by_status.keys())
183+
best_status = eligibility.Status.best(*list(reasons_by_status.keys()))
208184
self.results[condition_name] = eligibility.Condition(
209185
condition_name=condition_name, status=best_status, reasons=reasons_by_status[best_status]
210186
)
211-
212-
def evaluate_exclusion(self, iteration_rule: rules.IterationRule) -> tuple[bool, eligibility.Reason]:
213-
"""Evaluate if a particular rule excludes this person. Return the result, and the reason for the result."""
214-
attribute_value = self.get_attribute_value(iteration_rule)
215-
exclusion, reason = self.evaluate_rule(iteration_rule, attribute_value)
216-
reason = eligibility.Reason(
217-
rule_name=eligibility.RuleName(iteration_rule.name),
218-
rule_type=eligibility.RuleType(iteration_rule.type),
219-
rule_result=eligibility.RuleResult(
220-
f"Rule {iteration_rule.name!r} ({iteration_rule.description!r}) "
221-
f"{'' if exclusion else 'not '}excluding - "
222-
f"{iteration_rule.attribute_name!r} {iteration_rule.comparator!r} {reason}"
223-
),
224-
)
225-
return exclusion, reason
226-
227-
def get_attribute_value(self, iteration_rule: rules.IterationRule) -> str | None:
228-
"""Pull out the correct attribute for a rule from the person's data."""
229-
match iteration_rule.attribute_level:
230-
case rules.RuleAttributeLevel.PERSON:
231-
person: Mapping[str, str | None] | None = next(
232-
(r for r in self.person_data if r.get("ATTRIBUTE_TYPE", "") == "PERSON"), None
233-
)
234-
attribute_value = person.get(iteration_rule.attribute_name) if person else None
235-
case _: # pragma: no cover
236-
msg = f"{iteration_rule.attribute_level} not implemented"
237-
raise NotImplementedError(msg)
238-
return attribute_value
239-
240-
@staticmethod
241-
def evaluate_rule(iteration_rule: rules.IterationRule, attribute_value: str | None) -> tuple[bool, str]:
242-
"""Evaluate a rule against a person data attribute. Return the result, and the reason for the result."""
243-
matcher_class = OperatorRegistry.get(iteration_rule.operator)
244-
matcher = matcher_class(rule_value=iteration_rule.comparator)
245-
246-
reason = StringDescription()
247-
if matcher.matches(attribute_value):
248-
matcher.describe_match(attribute_value, reason)
249-
return True, str(reason)
250-
matcher.describe_mismatch(attribute_value, reason)
251-
return False, str(reason)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Collection, Mapping
4+
from dataclasses import dataclass
5+
from typing import Any
6+
7+
from hamcrest.core.string_description import StringDescription
8+
9+
from eligibility_signposting_api.model import eligibility, rules
10+
from eligibility_signposting_api.services.rules.operators import OperatorRegistry
11+
12+
Row = Collection[Mapping[str, Any]]
13+
14+
15+
@dataclass
16+
class RuleCalculator:
17+
person_data: Row
18+
rule: rules.IterationRule
19+
20+
def evaluate_exclusion(self) -> tuple[eligibility.Status, eligibility.Reason]:
21+
"""Evaluate if a particular rule excludes this person. Return the result, and the reason for the result."""
22+
attribute_value = self.get_attribute_value()
23+
status, reason = self.evaluate_rule(attribute_value)
24+
reason = eligibility.Reason(
25+
rule_name=eligibility.RuleName(self.rule.name),
26+
rule_type=eligibility.RuleType(self.rule.type),
27+
rule_result=eligibility.RuleResult(
28+
f"Rule {self.rule.name!r} ({self.rule.description!r}) "
29+
f"{'' if status.is_exclusion else 'not '}excluding - "
30+
f"{self.rule.attribute_name!r} {self.rule.comparator!r} {reason}"
31+
),
32+
)
33+
return status, reason
34+
35+
def get_attribute_value(self) -> str | None:
36+
"""Pull out the correct attribute for a rule from the person's data."""
37+
match self.rule.attribute_level:
38+
case rules.RuleAttributeLevel.PERSON:
39+
person: Mapping[str, str | None] | None = next(
40+
(r for r in self.person_data if r.get("ATTRIBUTE_TYPE", "") == "PERSON"), None
41+
)
42+
attribute_value = person.get(self.rule.attribute_name) if person else None
43+
case _: # pragma: no cover
44+
msg = f"{self.rule.attribute_level} not implemented"
45+
raise NotImplementedError(msg)
46+
return attribute_value
47+
48+
def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status, str]:
49+
"""Evaluate a rule against a person data attribute. Return the result, and the reason for the result."""
50+
matcher_class = OperatorRegistry.get(self.rule.operator)
51+
matcher = matcher_class(rule_value=self.rule.comparator)
52+
53+
reason = StringDescription()
54+
if matcher.matches(attribute_value):
55+
matcher.describe_match(attribute_value, reason)
56+
status = {
57+
rules.RuleType.filter: eligibility.Status.not_eligible,
58+
rules.RuleType.suppression: eligibility.Status.not_actionable,
59+
}[self.rule.type]
60+
return status, str(reason)
61+
matcher.describe_mismatch(attribute_value, reason)
62+
return eligibility.Status.actionable, str(reason)

0 commit comments

Comments
 (0)