Skip to content

Commit 4351922

Browse files
Merge pull request #86 from NHSDigital/feature/ELI-254-rule-priority
ELI-254 Implement rule priority
2 parents 748e4d3 + 0daff15 commit 4351922

File tree

4 files changed

+195
-31
lines changed

4 files changed

+195
-31
lines changed

src/eligibility_signposting_api/services/eligibility_services.py

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
from collections import defaultdict
3-
from collections.abc import Collection, Mapping
3+
from collections.abc import Collection, Iterator, Mapping
4+
from itertools import groupby
5+
from operator import attrgetter
46
from typing import Any
57

68
from hamcrest.core.string_description import StringDescription
@@ -136,6 +138,8 @@ def evaluate_for_base_eligible_campaigns(
136138
or not eligible (due to "S" rules").
137139
138140
For each condition, evaluate all iterations for inclusion or exclusion."""
141+
priority_getter = attrgetter("priority")
142+
139143
base_eligible_evaluations: dict[
140144
eligibility.ConditionName, dict[eligibility.Status, list[eligibility.Reason]]
141145
] = defaultdict(dict)
@@ -144,29 +148,81 @@ def evaluate_for_base_eligible_campaigns(
144148
for cc in base_eligible_campaigns
145149
if cc.current_iteration
146150
]:
147-
status = eligibility.Status.actionable
151+
# Until we see a worse status, we assume someone is actionable for this iteration.
152+
worst_status_so_far_for_condition = eligibility.Status.actionable
148153
exclusion_reasons, actionable_reasons = [], []
149-
for iteration_rule in iteration.iteration_rules:
150-
if iteration_rule.type not in (rules.RuleType.filter, rules.RuleType.suppression):
151-
continue
152-
exclusion, reason = EligibilityService.evaluate_exclusion(iteration_rule, person_data)
153-
if exclusion:
154-
status = min(
155-
status,
156-
eligibility.Status.not_eligible
157-
if iteration_rule.type == rules.RuleType.filter
158-
else eligibility.Status.not_actionable,
154+
for _priority, iteration_rule_group in groupby(
155+
sorted(iteration.iteration_rules, key=priority_getter), key=priority_getter
156+
):
157+
worst_status_so_far_for_condition, group_actionable_reasons, group_exclusion_reasons = (
158+
EligibilityService.evaluate_priority_group(
159+
iteration_rule_group, person_data, worst_status_so_far_for_condition
159160
)
160-
exclusion_reasons.append(reason)
161-
else:
162-
actionable_reasons.append(reason)
161+
)
162+
actionable_reasons.extend(group_actionable_reasons)
163+
exclusion_reasons.extend(group_exclusion_reasons)
163164
condition_entry = base_eligible_evaluations.setdefault(condition_name, {})
164-
condition_status_entry = condition_entry.setdefault(status, [])
165+
condition_status_entry = condition_entry.setdefault(worst_status_so_far_for_condition, [])
165166
condition_status_entry.extend(
166-
actionable_reasons if status is eligibility.Status.actionable else exclusion_reasons
167+
actionable_reasons
168+
if worst_status_so_far_for_condition is eligibility.Status.actionable
169+
else exclusion_reasons
167170
)
168171
return base_eligible_evaluations
169172

173+
@staticmethod
174+
def evaluate_priority_group(
175+
iteration_rule_group: Iterator[rules.IterationRule],
176+
person_data: Collection[Mapping[str, Any]],
177+
worst_status_so_far_for_condition: eligibility.Status,
178+
) -> tuple[eligibility.Status, list[eligibility.Reason], list[eligibility.Reason]]:
179+
actionable_reasons, exclusion_reasons = [], []
180+
exclude_capable_rules = [
181+
ir for ir in iteration_rule_group if ir.type in (rules.RuleType.filter, rules.RuleType.suppression)
182+
]
183+
best_status_so_far_for_priority_group = (
184+
eligibility.Status.not_eligible if exclude_capable_rules else eligibility.Status.actionable
185+
)
186+
for iteration_rule in exclude_capable_rules:
187+
exclusion, reason = EligibilityService.evaluate_exclusion(iteration_rule, person_data)
188+
if exclusion:
189+
best_status_so_far_for_priority_group = EligibilityService.best_status(
190+
iteration_rule.type, best_status_so_far_for_priority_group
191+
)
192+
exclusion_reasons.append(reason)
193+
else:
194+
best_status_so_far_for_priority_group = eligibility.Status.actionable
195+
actionable_reasons.append(reason)
196+
return (
197+
EligibilityService.worst_status(best_status_so_far_for_priority_group, worst_status_so_far_for_condition),
198+
actionable_reasons,
199+
exclusion_reasons,
200+
)
201+
202+
@staticmethod
203+
def worst_status(*statuses: eligibility.Status) -> eligibility.Status:
204+
"""Pick the worst status from those given.
205+
206+
Here "worst" means furthest from being able to access vaccination, so not-eligible is "worse" than
207+
not-actionable, and not-actionable is "worse" than actionable.
208+
"""
209+
return min(statuses)
210+
211+
@staticmethod
212+
def best_status(rule_type: rules.RuleType, status: eligibility.Status) -> eligibility.Status:
213+
"""Pick the best status between the existing status, and the status implied by
214+
the rule excluding the person from vaccination.
215+
216+
Here "best" means closest to being able to access vaccination, so not-actionable is "better" than
217+
not-eligible, and actionable is "better" than not-actionable.
218+
"""
219+
return max(
220+
status,
221+
eligibility.Status.not_eligible
222+
if rule_type == rules.RuleType.filter
223+
else eligibility.Status.not_actionable,
224+
)
225+
170226
@staticmethod
171227
def get_base_eligible_conditions(
172228
base_eligible_evaluations: Mapping[

tests/fixtures/builders/model/rule.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from polyfactory.factories.pydantic_factory import ModelFactory
66

77
from eligibility_signposting_api.model import rules
8-
from eligibility_signposting_api.model.rules import RulePriority
98

109

1110
def past_date(days_behind: int = 365) -> date:
@@ -37,10 +36,21 @@ class CampaignConfigFactory(ModelFactory[rules.CampaignConfig]):
3736

3837
class PersonAgeSuppressionRuleFactory(IterationRuleFactory):
3938
type = rules.RuleType.suppression
40-
name = "Exclude too young less than 75"
41-
description = "Exclude too young less than 75"
42-
priority = RulePriority(10)
39+
name = rules.RuleName("Exclude too young less than 75")
40+
description = rules.RuleDescription("Exclude too young less than 75")
41+
priority = rules.RulePriority(10)
4342
operator = rules.RuleOperator.year_gt
4443
attribute_level = rules.RuleAttributeLevel.PERSON
45-
attribute_name = "DATE_OF_BIRTH"
46-
comparator = "-75"
44+
attribute_name = rules.RuleAttributeName("DATE_OF_BIRTH")
45+
comparator = rules.RuleComparator("-75")
46+
47+
48+
class PostcodeSuppressionRuleFactory(IterationRuleFactory):
49+
type = rules.RuleType.suppression
50+
name = rules.RuleName("In SW19")
51+
description = rules.RuleDescription("In SW19")
52+
priority = rules.RulePriority(10)
53+
operator = rules.RuleOperator.starts_with
54+
attribute_level = rules.RuleAttributeLevel.PERSON
55+
attribute_name = rules.RuleAttributeName("POSTCODE")
56+
comparator = rules.RuleComparator("SW19")

tests/fixtures/builders/repos/eligibility.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@
55
from faker import Faker
66

77
from eligibility_signposting_api.model import eligibility
8-
from eligibility_signposting_api.model.eligibility import DateOfBirth
98

109

1110
def eligibility_rows_builder(
1211
nhs_number: eligibility.NHSNumber,
1312
*,
1413
date_of_birth: eligibility.DateOfBirth | None = None,
14+
postcode: eligibility.Postcode | None = None,
1515
cohorts: Sequence[str] | None = None,
1616
vaccines: Sequence[str] | None = None,
1717
) -> list[dict[str, Any]]:
1818
faker = Faker("en_UK")
1919

2020
key = f"PERSON#{nhs_number}"
21-
date_of_birth = date_of_birth or DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=99))
22-
postcode = eligibility.Postcode(faker.postcode())
21+
date_of_birth = date_of_birth or eligibility.DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=99))
22+
postcode = postcode or eligibility.Postcode(faker.postcode())
2323
cohorts = cohorts if cohorts is not None else ["cohort-a", "cohort-b"]
2424
vaccines = vaccines if vaccines is not None else ["RSV", "COVID"]
2525
rows: list[dict[str, Any]] = [

tests/unit/services/test_eligibility_services.py

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
from freezegun import freeze_time
77
from hamcrest import assert_that, empty, has_item
88

9-
from eligibility_signposting_api.model.eligibility import ConditionName, DateOfBirth, NHSNumber, Status
10-
from eligibility_signposting_api.model.rules import IterationDate, RuleType
9+
from eligibility_signposting_api.model.eligibility import ConditionName, DateOfBirth, NHSNumber, Postcode, Status
10+
from eligibility_signposting_api.model.rules import IterationDate, IterationRule, RuleComparator, RulePriority, RuleType
1111
from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError, RulesRepo
1212
from eligibility_signposting_api.services import EligibilityService, UnknownPersonError
1313
from tests.fixtures.builders.model import rule as rule_builder
@@ -359,9 +359,15 @@ def test_multiple_rule_types_cause_correct_status(faker: Faker):
359359
iterations=[
360360
rule_builder.IterationFactory.build(
361361
iteration_rules=[
362-
rule_builder.PersonAgeSuppressionRuleFactory.build(type=RuleType.suppression),
363-
rule_builder.PersonAgeSuppressionRuleFactory.build(type=RuleType.filter),
364-
rule_builder.PersonAgeSuppressionRuleFactory.build(type=RuleType.suppression),
362+
rule_builder.PersonAgeSuppressionRuleFactory.build(
363+
priority=RulePriority(5), type=RuleType.suppression
364+
),
365+
rule_builder.PersonAgeSuppressionRuleFactory.build(
366+
priority=RulePriority(10), type=RuleType.filter
367+
),
368+
rule_builder.PersonAgeSuppressionRuleFactory.build(
369+
priority=RulePriority(15), type=RuleType.suppression
370+
),
365371
],
366372
iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")],
367373
)
@@ -382,3 +388,95 @@ def test_multiple_rule_types_cause_correct_status(faker: Faker):
382388
has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_eligible))
383389
),
384390
)
391+
392+
393+
@pytest.mark.parametrize(
394+
("test_comment", "rule1", "rule2", "expected_status"),
395+
[
396+
(
397+
"two rules, both exclude, same priority, should exclude",
398+
rule_builder.PersonAgeSuppressionRuleFactory.build(priority=RulePriority(5)),
399+
rule_builder.PostcodeSuppressionRuleFactory.build(priority=RulePriority(5)),
400+
Status.not_actionable,
401+
),
402+
(
403+
"two rules, rule 1 excludes, same priority, should allow",
404+
rule_builder.PersonAgeSuppressionRuleFactory.build(priority=RulePriority(5)),
405+
rule_builder.PostcodeSuppressionRuleFactory.build(
406+
priority=RulePriority(5), comparator=RuleComparator("NW1")
407+
),
408+
Status.actionable,
409+
),
410+
(
411+
"two rules, rule 2 excludes, same priority, should allow",
412+
rule_builder.PersonAgeSuppressionRuleFactory.build(
413+
priority=RulePriority(5), comparator=RuleComparator("-65")
414+
),
415+
rule_builder.PostcodeSuppressionRuleFactory.build(priority=RulePriority(5)),
416+
Status.actionable,
417+
),
418+
(
419+
"two rules, rule 1 excludes, different priority, should exclude",
420+
rule_builder.PersonAgeSuppressionRuleFactory.build(priority=RulePriority(5)),
421+
rule_builder.PostcodeSuppressionRuleFactory.build(
422+
priority=RulePriority(10), comparator=RuleComparator("NW1")
423+
),
424+
Status.not_actionable,
425+
),
426+
(
427+
"two rules, rule 2 excludes, different priority, should exclude",
428+
rule_builder.PersonAgeSuppressionRuleFactory.build(
429+
priority=RulePriority(5), comparator=RuleComparator("-65")
430+
),
431+
rule_builder.PostcodeSuppressionRuleFactory.build(priority=RulePriority(10)),
432+
Status.not_actionable,
433+
),
434+
(
435+
"two rules, both excludes, different priority, should exclude",
436+
rule_builder.PersonAgeSuppressionRuleFactory.build(priority=RulePriority(5)),
437+
rule_builder.PostcodeSuppressionRuleFactory.build(priority=RulePriority(10)),
438+
Status.not_actionable,
439+
),
440+
],
441+
)
442+
def test_rules_with_same_priority_must_all_match_to_exclude(
443+
test_comment: str, rule1: IterationRule, rule2: IterationRule, expected_status: Status, faker: Faker
444+
):
445+
# Given
446+
nhs_number = NHSNumber(f"5{faker.random_int(max=999999999):09d}")
447+
date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74))
448+
449+
eligibility_repo = MagicMock(spec=EligibilityRepo)
450+
rules_repo = MagicMock(spec=RulesRepo)
451+
eligibility_repo.get_eligibility_data = MagicMock(
452+
return_value=eligibility_rows_builder(
453+
nhs_number, date_of_birth=date_of_birth, postcode=Postcode("SW19 2BH"), cohorts=["cohort1"]
454+
)
455+
)
456+
rules_repo.get_campaign_configs = MagicMock(
457+
return_value=[
458+
rule_builder.CampaignConfigFactory.build(
459+
target="RSV",
460+
iterations=[
461+
rule_builder.IterationFactory.build(
462+
iteration_rules=[rule1, rule2],
463+
iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")],
464+
)
465+
],
466+
)
467+
]
468+
)
469+
470+
service = EligibilityService(eligibility_repo, rules_repo)
471+
472+
# When
473+
actual = service.get_eligibility_status(NHSNumber(nhs_number))
474+
475+
# Then
476+
assert_that(
477+
actual,
478+
is_eligibility_status().with_conditions(
479+
has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status))
480+
),
481+
test_comment,
482+
)

0 commit comments

Comments
 (0)