Skip to content

Commit 0a34d53

Browse files
refer status of magic cohorts based on other cohorts info
1 parent fdcbe7e commit 0a34d53

10 files changed

Lines changed: 162 additions & 11 deletions

File tree

src/eligibility_signposting_api/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from mangum.types import LambdaContext, LambdaEvent
99

1010
from eligibility_signposting_api import repos, services
11-
from eligibility_signposting_api.config import config, init_logging
11+
from eligibility_signposting_api.config.config import config, init_logging
1212
from eligibility_signposting_api.error_handler import handle_exception
1313
from eligibility_signposting_api.views import eligibility_blueprint
1414

src/eligibility_signposting_api/config/__init__.py

Whitespace-only changes.
File renamed without changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MAGIC_COHORT_LABEL = "elid_all_people"

src/eligibility_signposting_api/model/rules.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
1212

13+
from eligibility_signposting_api.config.contants import MAGIC_COHORT_LABEL
14+
1315
if typing.TYPE_CHECKING: # pragma: no cover
1416
from pydantic import SerializationInfo
1517

@@ -107,14 +109,14 @@ class IterationRule(BaseModel):
107109
attribute_target: RuleAttributeTarget | None = Field(None, alias="AttributeTarget")
108110
rule_stop: RuleStop = Field(RuleStop(False), alias="RuleStop") # noqa: FBT003
109111

112+
model_config = {"populate_by_name": True, "extra": "ignore"}
113+
110114
@field_validator("rule_stop", mode="before")
111115
def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805
112116
if isinstance(v, str):
113117
return v.upper() == "Y"
114118
return v
115119

116-
model_config = {"populate_by_name": True, "extra": "ignore"}
117-
118120

119121
class Iteration(BaseModel):
120122
id: IterationID = Field(..., alias="ID")
@@ -142,6 +144,17 @@ def parse_dates(cls, v: str | date) -> date:
142144
def serialize_dates(v: date, _info: SerializationInfo) -> str:
143145
return v.strftime("%Y%m%d")
144146

147+
@cached_property
148+
def has_magic_cohort(self) -> bool:
149+
return next(
150+
(
151+
True
152+
for cc in self.iteration_cohorts
153+
if cc.cohort_label and cc.cohort_label.upper() == MAGIC_COHORT_LABEL.upper()
154+
),
155+
False,
156+
)
157+
145158

146159
class CampaignConfig(BaseModel):
147160
id: CampaignID = Field(..., alias="ID")

src/eligibility_signposting_api/repos/factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from wireup import Inject, service
88
from yarl import URL
99

10-
from eligibility_signposting_api.config import AwsAccessKey, AwsRegion, AwsSecretAccessKey
10+
from eligibility_signposting_api.config.config import AwsAccessKey, AwsRegion, AwsSecretAccessKey
1111

1212
logger = logging.getLogger(__name__)
1313

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from itertools import groupby
88
from typing import TYPE_CHECKING, Any
99

10+
from eligibility_signposting_api.config.contants import MAGIC_COHORT_LABEL
11+
1012
if TYPE_CHECKING:
1113
from eligibility_signposting_api.model.rules import Iteration, IterationCohort
1214

@@ -23,7 +25,6 @@
2325
from eligibility_signposting_api.services.calculators.rule_calculator import RuleCalculator
2426

2527
Row = Collection[Mapping[str, Any]]
26-
magic_cohort = "elid_all_people"
2728

2829

2930
@service
@@ -62,11 +63,31 @@ def person_cohorts(self) -> set[str]:
6263
return set(cohorts_row.get("COHORT_MAP", {}).get("cohorts", {}).get("M", {}).keys())
6364

6465
@staticmethod
65-
def get_best_cohort(cohort_results: dict[str, CohortResult]) -> tuple[Status, list[CohortResult]]:
66+
def get_the_best_cohort_memberships(cohort_results: dict[str, CohortResult]) -> tuple[Status, list[CohortResult]]:
67+
"""
68+
1. Get all the cohorts with the best status
69+
2. Case 1: Ignore magic cohort if other cohorts have better status
70+
Case 2: If no cohorts have the better status than magic cohort,
71+
then response excludes cohort memberships but will have actions/suitability rules
72+
also, excludes the cohorts with no positive or negative description
73+
"""
6674
if not cohort_results:
6775
return eligibility.Status.not_eligible, []
6876
best_status = eligibility.Status.best(*[result.status for result in cohort_results.values()])
6977
best_cohorts = [result for result in cohort_results.values() if result.status == best_status]
78+
if all(cc.cohort_code and str(cc.cohort_code.upper()) == MAGIC_COHORT_LABEL.upper() for cc in best_cohorts):
79+
# Update the magic cohort to have no cohort membership information
80+
best_cohorts = [
81+
CohortResult(cohort_code="", status=best_status, reasons=best_cohorts[0].reasons, description="")
82+
]
83+
else:
84+
best_cohorts = [
85+
cc
86+
for cc in best_cohorts
87+
if str(cc.cohort_code.upper()) != MAGIC_COHORT_LABEL.upper()
88+
and cc.description
89+
and cc.cohort_code.strip()
90+
]
7091
return best_status, best_cohorts
7192

7293
@staticmethod
@@ -102,9 +123,10 @@ def evaluate_eligibility(self) -> eligibility.EligibilityStatus:
102123
cohort_results: dict[str, CohortResult] = {}
103124

104125
filter_rules, suppression_rules = self.get_rules_by_type(active_iteration)
126+
105127
for cohort in sorted(active_iteration.iteration_cohorts, key=attrgetter("priority")):
106128
# Base Eligibility - check
107-
if cohort.cohort_label in self.person_cohorts or cohort.cohort_label == magic_cohort:
129+
if cohort.cohort_label in self.person_cohorts or active_iteration.has_magic_cohort:
108130
# Eligibility - check
109131
if self.is_eligible_by_filter_rules(cohort, cohort_results, filter_rules):
110132
# Actionability - evaluation
@@ -120,7 +142,8 @@ def evaluate_eligibility(self) -> eligibility.EligibilityStatus:
120142
)
121143

122144
# Determine Result between cohorts - get the best
123-
status, best_cohorts = self.get_best_cohort(cohort_results)
145+
status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results)
146+
124147
iteration_results[active_iteration.name] = IterationResult(status, best_cohorts)
125148

126149
# Determine results between iterations - get the best

tests/fixtures/builders/model/rule.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ def fix_iteration_date_invariants(iterations: list[rules.Iteration], start_date:
7373

7474

7575
# Iteration cohort factories
76+
class MagicCohortFactory(IterationCohortFactory):
77+
cohort_label = rules.CohortLabel("elid_all_people")
78+
79+
7680
class Rsv75RollingCohortFactory(IterationCohortFactory):
7781
cohort_label = rules.CohortLabel("rsv_75_rolling")
7882
cohort_group = rules.CohortGroup("rsv_age_range")
@@ -120,7 +124,18 @@ class PostcodeSuppressionRuleFactory(IterationRuleFactory):
120124
comparator = rules.RuleComparator("SW19")
121125

122126

123-
class ICBSuppressionRuleFactory(IterationRuleFactory):
127+
class DetainedEstateSuppressionRuleFactory(IterationRuleFactory):
128+
type = rules.RuleType.suppression
129+
name = rules.RuleName("Detained - Suppress Individuals In Detained Estates")
130+
description = rules.RuleDescription("Suppress where individual is identified as being in a Detained Estate")
131+
priority = rules.RulePriority(160)
132+
attribute_level = rules.RuleAttributeLevel.PERSON
133+
attribute_name = rules.RuleAttributeName("DE_FLAG")
134+
operator = rules.RuleOperator.equals
135+
comparator = rules.RuleComparator("Y")
136+
137+
138+
class ICBFilterRuleFactory(IterationRuleFactory):
124139
type = rules.RuleType.filter
125140
name = rules.RuleName("Not in QE1")
126141
description = rules.RuleDescription("Not in QE1")

tests/unit/services/calculators/test_eligibility_calculator.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ def test_base_eligible_and_icb_example(
631631
target="RSV",
632632
iterations=[
633633
rule_builder.IterationFactory.build(
634-
iteration_rules=[rule_builder.ICBSuppressionRuleFactory.build(type=rule_type)],
634+
iteration_rules=[rule_builder.ICBFilterRuleFactory.build(type=rule_type)],
635635
iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")],
636636
)
637637
],
@@ -1301,3 +1301,102 @@ def test_grouped_description_if_the_cohorts_in_group_have_different_descriptions
13011301
),
13021302
test_comment,
13031303
)
1304+
1305+
1306+
@pytest.mark.parametrize(
1307+
("person_rows", "expected_status", "expected_description", "expected_cohort_code", "test_comment"),
1308+
[
1309+
(
1310+
person_rows_builder(nhs_number="123", cohorts=[], postcode="AC01", de=True, icb="QE1"),
1311+
Status.not_eligible,
1312+
"rsv_age_range negative description",
1313+
"rsv_age_range",
1314+
"all the cohorts are not-eligible, so the result will not include magic cohort, but all other cohorts",
1315+
),
1316+
(
1317+
person_rows_builder(nhs_number="123", cohorts=[], postcode="SW19", de=False, icb="QE1"),
1318+
Status.not_actionable,
1319+
"rsv_age_range positive description",
1320+
"rsv_age_range",
1321+
"all the cohorts are not-actionable, so the result will not include magic cohort, but all other cohorts",
1322+
),
1323+
(
1324+
person_rows_builder(nhs_number="123", cohorts=[], postcode="AC01", de=False, icb="QE1"),
1325+
Status.actionable,
1326+
"rsv_age_range positive description",
1327+
"rsv_age_range",
1328+
"all the cohorts are actionable, so the result will not include magic cohort, but all other cohorts",
1329+
),
1330+
(
1331+
person_rows_builder(nhs_number="123", cohorts=[], postcode="AC01", de=False, icb="NOT_QE1"),
1332+
Status.actionable,
1333+
"",
1334+
"",
1335+
"magic_cohort is actionable, but not other, so removed cohort membership info from result",
1336+
),
1337+
(
1338+
person_rows_builder(nhs_number="123", cohorts=[], postcode="SW19", de=False, icb="NOT_QE1"),
1339+
Status.not_actionable,
1340+
"",
1341+
"",
1342+
"magic_cohort is not-actionable,but others are not eligible,so removed cohort membership info from result",
1343+
),
1344+
],
1345+
)
1346+
def test_grouped_description_if_magic_cohort_has_same_status_as_other_cohorts(
1347+
person_rows: list[dict[str, Any]],
1348+
expected_status: str,
1349+
expected_description: str,
1350+
expected_cohort_code: str,
1351+
test_comment: str,
1352+
):
1353+
# Given
1354+
campaign_configs = [
1355+
rule_builder.CampaignConfigFactory.build(
1356+
target="RSV",
1357+
iterations=[
1358+
rule_builder.IterationFactory.build(
1359+
iteration_cohorts=[
1360+
rule_builder.MagicCohortFactory.build(),
1361+
rule_builder.Rsv75RollingCohortFactory.build(),
1362+
],
1363+
iteration_rules=[
1364+
# common rules
1365+
rule_builder.DetainedEstateSuppressionRuleFactory.build(type=rules.RuleType.filter),
1366+
rule_builder.PostcodeSuppressionRuleFactory.build(
1367+
comparator=rules.RuleComparator("SW19"),
1368+
),
1369+
# rules for specific cohorts
1370+
rule_builder.ICBFilterRuleFactory.build(
1371+
cohort_label=rules.CohortLabel("rsv_75_rolling"),
1372+
),
1373+
],
1374+
)
1375+
],
1376+
)
1377+
]
1378+
1379+
calculator = EligibilityCalculator(person_rows, campaign_configs)
1380+
1381+
# When
1382+
actual = calculator.evaluate_eligibility()
1383+
1384+
# Then
1385+
assert_that(
1386+
actual,
1387+
is_eligibility_status().with_conditions(
1388+
has_items(
1389+
is_condition()
1390+
.with_condition_name(ConditionName("RSV"))
1391+
.and_cohort_results(
1392+
contains_exactly(
1393+
is_cohort_result()
1394+
.with_status(expected_status)
1395+
.and_cohort_code(expected_cohort_code)
1396+
.and_description(expected_description)
1397+
)
1398+
)
1399+
)
1400+
),
1401+
test_comment,
1402+
)

tests/unit/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from yarl import URL
55

6-
from eligibility_signposting_api.config import LOG_LEVEL, AwsAccessKey, AwsRegion, AwsSecretAccessKey, config
6+
from eligibility_signposting_api.config.config import LOG_LEVEL, AwsAccessKey, AwsRegion, AwsSecretAccessKey, config
77
from eligibility_signposting_api.repos.campaign_repo import BucketName
88
from eligibility_signposting_api.repos.person_repo import TableName
99

0 commit comments

Comments
 (0)