Skip to content

Commit faed1f6

Browse files
Eli 316: rule mapper implementation (#439)
* add rule code to campaign_config.py * fix flaky test - by setting rule code none by default * added integration tests * unit test * introduce property ref * update rule_code usages * lint fix * unit tests * fix the fixture. * Integration tests * improved unit tests * lint fixes * added version 1.2 config to the data * lint fix * added rule text reference from rule mapper logic * Refactor `RuleDescription` to `RuleText` across * Refactor test names * updated and synced boto3 and botcore - tactical solution for size issue * lint issues * lint issues
1 parent aa0e9ac commit faed1f6

File tree

14 files changed

+686
-82
lines changed

14 files changed

+686
-82
lines changed

src/eligibility_signposting_api/audit/audit_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def create_audit_suitability_rule(reasons: list[Reason]) -> list[AuditSuitabilit
169169
AuditSuitabilityRule(
170170
rule_priority=rule.rule_priority,
171171
rule_name=rule.rule_name,
172-
rule_message=rule.rule_description,
172+
rule_message=rule.rule_text,
173173
)
174174
for rule in unique_reasons
175175
]

src/eligibility_signposting_api/model/campaign_config.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,16 @@
99
from operator import attrgetter
1010
from typing import Literal, NewType
1111

12-
from pydantic import BaseModel, Field, HttpUrl, RootModel, field_serializer, field_validator, model_validator
12+
from pydantic import (
13+
BaseModel,
14+
Field,
15+
HttpUrl,
16+
PrivateAttr,
17+
RootModel,
18+
field_serializer,
19+
field_validator,
20+
model_validator,
21+
)
1322

1423
from eligibility_signposting_api.config.contants import ALLOWED_CONDITIONS, RULE_STOP_DEFAULT
1524

@@ -36,6 +45,8 @@
3645
Description = NewType("Description", str)
3746
RuleStop = NewType("RuleStop", bool)
3847
CommsRouting = NewType("CommsRouting", str)
48+
RuleCode = NewType("RuleCode", str)
49+
RuleText = NewType("RuleText", str)
3950

4051

4152
class RuleType(StrEnum):
@@ -127,7 +138,8 @@ def normalize_virtual(cls, value: str) -> Virtual:
127138
class IterationRule(BaseModel):
128139
type: RuleType = Field(..., alias="Type")
129140
name: RuleName = Field(..., alias="Name")
130-
description: RuleDescription = Field(..., alias="Description")
141+
code: RuleCode | None = Field(None, alias="Code", description="use the `rule_code` property instead.")
142+
description: RuleDescription = Field(..., alias="Description", description="use the `rule_text` property instead.")
131143
priority: RulePriority = Field(..., alias="Priority")
132144
attribute_level: RuleAttributeLevel = Field(..., alias="AttributeLevel")
133145
attribute_name: RuleAttributeName | None = Field(None, alias="AttributeName")
@@ -146,6 +158,45 @@ def parse_yn_to_bool(cls, v: str | bool) -> bool: # noqa: N805
146158
return v.upper() == "Y"
147159
return v
148160

161+
_parent: Iteration | None = PrivateAttr(default=None)
162+
163+
def set_parent(self, parent: Iteration) -> None:
164+
self._parent = parent
165+
166+
@property
167+
def rule_code(self) -> str:
168+
"""
169+
Resolves the rule code using the parent Iteration's rules_mapper.
170+
171+
If the rule name matches any entry in the rules_mapper, the corresponding
172+
rule_code is returned.
173+
174+
If no match is found, rule code is returned if it exists, otherwise the rule name is returned.
175+
"""
176+
rule_code = None
177+
if self._parent and self._parent.rules_mapper:
178+
for rule_entry in self._parent.rules_mapper.values():
179+
if rule_entry and self.name in rule_entry.rule_names:
180+
rule_code = rule_entry.rule_code
181+
return rule_code or self.code or self.name
182+
183+
@property
184+
def rule_text(self) -> str:
185+
"""
186+
Resolves the rule text using the parent Iteration's rules_mapper.
187+
188+
If the rule name matches any entry in the rules_mapper, the corresponding
189+
rule_text is returned.
190+
191+
If no match is found, the rule description is returned.
192+
"""
193+
rule_text = None
194+
if self._parent and self._parent.rules_mapper:
195+
for rule_entry in self._parent.rules_mapper.values():
196+
if rule_entry and self.name in rule_entry.rule_names:
197+
rule_text = rule_entry.rule_text
198+
return rule_text or self.description
199+
149200
def __str__(self) -> str:
150201
return json.dumps(self.model_dump(by_alias=True), indent=2)
151202

@@ -173,6 +224,14 @@ class StatusText(BaseModel):
173224
model_config = {"populate_by_name": True, "extra": "ignore"}
174225

175226

227+
class RuleEntry(BaseModel):
228+
rule_names: list[RuleName] = Field(..., alias="RuleNames")
229+
rule_code: RuleCode | None = Field(None, alias="RuleCode")
230+
rule_text: RuleText | None = Field(None, alias="RuleText")
231+
232+
model_config = {"populate_by_name": True}
233+
234+
176235
class Iteration(BaseModel):
177236
id: IterationID = Field(..., alias="ID")
178237
version: IterationVersion = Field(..., alias="Version")
@@ -188,6 +247,7 @@ class Iteration(BaseModel):
188247
iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts")
189248
iteration_rules: list[IterationRule] = Field(..., alias="IterationRules")
190249
actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper")
250+
rules_mapper: dict[str, RuleEntry] | None = Field(None, alias="RulesMapper")
191251
status_text: StatusText | None = Field(None, alias="StatusText")
192252

193253
model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"}
@@ -204,6 +264,12 @@ def parse_dates(cls, v: str | date) -> date:
204264
def serialize_dates(v: date, _info: SerializationInfo) -> str:
205265
return v.strftime("%Y%m%d")
206266

267+
@model_validator(mode="after")
268+
def attach_rule_parents(self) -> Iteration:
269+
for rule in self.iteration_rules:
270+
rule.set_parent(self)
271+
return self
272+
207273
def __str__(self) -> str:
208274
return json.dumps(self.model_dump(by_alias=True), indent=2)
209275

src/eligibility_signposting_api/model/eligibility_status.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
ConditionName = NewType("ConditionName", str)
1919

2020
RuleName = NewType("RuleName", str)
21-
RuleDescription = NewType("RuleDescription", str)
21+
RuleCode = NewType("RuleCode", str)
22+
RuleText = NewType("RuleText", str)
2223
RulePriority = NewType("RulePriority", str)
2324

2425
InternalActionCode = NewType("InternalActionCode", str)
@@ -93,8 +94,9 @@ def get_action_rule_type(self) -> RuleType:
9394
class Reason:
9495
rule_type: RuleType
9596
rule_name: RuleName
97+
rule_code: RuleCode
9698
rule_priority: RulePriority
97-
rule_description: RuleDescription | None
99+
rule_text: RuleText | None
98100
matcher_matched: bool
99101

100102

src/eligibility_signposting_api/services/calculators/rule_calculator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@ def evaluate_exclusion(self) -> tuple[eligibility_status.Status, eligibility_sta
2727
"""Evaluate if a particular rule excludes this person. Return the result, and the reason for the result."""
2828
attribute_value = self.get_attribute_value()
2929
status, reason, matcher_matched = self.evaluate_rule(attribute_value)
30+
rule_code = eligibility_status.RuleCode(self.rule.rule_code)
3031
reason = eligibility_status.Reason(
3132
rule_name=eligibility_status.RuleName(self.rule.name),
33+
rule_code=rule_code,
3234
rule_type=eligibility_status.RuleType(self.rule.type),
3335
rule_priority=eligibility_status.RulePriority(str(self.rule.priority)),
34-
rule_description=eligibility_status.RuleDescription(self.rule.description),
36+
rule_text=eligibility_status.RuleText(self.rule.rule_text),
3537
matcher_matched=matcher_matched,
3638
)
3739
return status, reason

src/eligibility_signposting_api/views/eligibility.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,11 @@ def build_suitability_results(condition: Condition) -> list[eligibility_response
172172
return [
173173
eligibility_response.SuitabilityRule(
174174
ruleType=eligibility_response.RuleType(reason.rule_type.value),
175-
ruleCode=eligibility_response.RuleCode(reason.rule_name),
176-
ruleText=eligibility_response.RuleText(reason.rule_description),
175+
ruleCode=eligibility_response.RuleCode(reason.rule_code),
176+
ruleText=eligibility_response.RuleText(reason.rule_text),
177177
)
178178
for reason in condition.suitability_rules
179-
if reason.rule_description
179+
if reason.rule_text
180180
]
181181

182182

tests/fixtures/builders/model/eligibility.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77
from eligibility_signposting_api.model import eligibility_status
88
from eligibility_signposting_api.model.eligibility_status import (
9-
RuleDescription,
109
RuleName,
1110
RulePriority,
11+
RuleText,
1212
RuleType,
1313
UrlLink,
1414
)
@@ -21,8 +21,9 @@ class SuggestedActionFactory(DataclassFactory[eligibility_status.SuggestedAction
2121
class ReasonFactory(DataclassFactory[eligibility_status.Reason]):
2222
rule_type = RuleType.filter
2323
rule_name = RuleName("name")
24+
rule_code = RuleName("code")
2425
rule_priority = RulePriority("1")
25-
rule_description = RuleDescription("description")
26+
rule_text = RuleText("text")
2627
matcher_matched = False
2728

2829

tests/fixtures/builders/model/rule.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
RuleAttributeName,
2121
RuleComparator,
2222
RuleDescription,
23+
RuleEntry,
2324
RuleName,
2425
RuleOperator,
2526
RulePriority,
@@ -49,6 +50,7 @@ class IterationRuleFactory(ModelFactory[IterationRule]):
4950
comparator = "-1"
5051
cohort_label = None
5152
rule_stop = False
53+
code = None
5254

5355

5456
class AvailableActionDetailFactory(ModelFactory[AvailableAction]):
@@ -69,12 +71,16 @@ class StatusTextFactory(ModelFactory[StatusText]):
6971
actionable = "Actionable status text"
7072

7173

74+
class RuleEntryFactory(ModelFactory[RuleEntry]): ...
75+
76+
7277
class IterationFactory(ModelFactory[Iteration]):
7378
iteration_cohorts = Use(IterationCohortFactory.batch, size=2)
7479
iteration_rules = Use(IterationRuleFactory.batch, size=2)
7580
iteration_date = Use(past_date)
7681
default_comms_routing = "defaultcomms"
7782
actions_mapper = Use(ActionsMapperFactory.build)
83+
rules_mapper = None
7884

7985

8086
class RawCampaignConfigFactory(ModelFactory[CampaignConfig]):
@@ -156,6 +162,7 @@ class RsvPretendClinicalCohortFactory(IterationCohortFactory):
156162
class PersonAgeSuppressionRuleFactory(IterationRuleFactory):
157163
type = RuleType.suppression
158164
name = RuleName("Exclude too young less than 75")
165+
code = None
159166
description = RuleDescription("Exclude too young less than 75")
160167
priority = RulePriority(10)
161168
operator = RuleOperator.year_gt
@@ -167,6 +174,7 @@ class PersonAgeSuppressionRuleFactory(IterationRuleFactory):
167174
class PostcodeSuppressionRuleFactory(IterationRuleFactory):
168175
type = RuleType.suppression
169176
name = RuleName("Excluded postcode In SW19")
177+
code = None
170178
description = RuleDescription("In SW19")
171179
priority = RulePriority(10)
172180
operator = RuleOperator.starts_with
@@ -178,6 +186,7 @@ class PostcodeSuppressionRuleFactory(IterationRuleFactory):
178186
class DetainedEstateSuppressionRuleFactory(IterationRuleFactory):
179187
type = RuleType.suppression
180188
name = RuleName("Detained - Suppress Individuals In Detained Estates")
189+
code = None
181190
description = RuleDescription("Suppress where individual is identified as being in a Detained Estate")
182191
priority = RulePriority(160)
183192
attribute_level = RuleAttributeLevel.PERSON
@@ -189,6 +198,7 @@ class DetainedEstateSuppressionRuleFactory(IterationRuleFactory):
189198
class ICBFilterRuleFactory(IterationRuleFactory):
190199
type = RuleType.filter
191200
name = RuleName("Not in QE1")
201+
code = None
192202
description = RuleDescription("Not in QE1")
193203
priority = RulePriority(10)
194204
operator = RuleOperator.ne
@@ -200,6 +210,7 @@ class ICBFilterRuleFactory(IterationRuleFactory):
200210
class ICBRedirectRuleFactory(IterationRuleFactory):
201211
type = RuleType.redirect
202212
name = RuleName("In QE1")
213+
code = None
203214
description = RuleDescription("In QE1")
204215
priority = RulePriority(20)
205216
operator = RuleOperator.equals
@@ -212,6 +223,7 @@ class ICBRedirectRuleFactory(IterationRuleFactory):
212223
class ICBNonEligibleActionRuleFactory(IterationRuleFactory):
213224
type = RuleType.not_eligible_actions
214225
name = RuleName("In QE1")
226+
code = None
215227
description = RuleDescription("In QE1")
216228
priority = RulePriority(20)
217229
operator = RuleOperator.equals
@@ -224,6 +236,7 @@ class ICBNonEligibleActionRuleFactory(IterationRuleFactory):
224236
class ICBNonActionableActionRuleFactory(IterationRuleFactory):
225237
type = RuleType.not_actionable_actions
226238
name = RuleName("In QE1")
239+
code = None
227240
description = RuleDescription("In QE1")
228241
priority = RulePriority(20)
229242
operator = RuleOperator.equals

tests/integration/conftest.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
AvailableAction,
2323
CampaignConfig,
2424
EndDate,
25+
RuleCode,
26+
RuleEntry,
27+
RuleName,
28+
RuleText,
2529
RuleType,
2630
StartDate,
2731
StatusText,
@@ -541,6 +545,84 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato
541545
s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json")
542546

543547

548+
@pytest.fixture
549+
def campaign_config_with_rules_having_rule_code(
550+
s3_client: BaseClient, rules_bucket: BucketName
551+
) -> Generator[CampaignConfig]:
552+
campaign: CampaignConfig = rule.CampaignConfigFactory.build(
553+
target="RSV",
554+
iterations=[
555+
rule.IterationFactory.build(
556+
iteration_rules=[
557+
rule.PostcodeSuppressionRuleFactory.build(
558+
type=RuleType.filter, code="Rule Code Excluded postcode In SW19"
559+
),
560+
rule.PersonAgeSuppressionRuleFactory.build(code="Rule Code Excluded age less than 75"),
561+
],
562+
iteration_cohorts=[
563+
rule.IterationCohortFactory.build(
564+
cohort_label="cohort1",
565+
cohort_group="cohort_group1",
566+
positive_description="positive_description",
567+
negative_description="negative_description",
568+
)
569+
],
570+
status_text=None,
571+
)
572+
],
573+
)
574+
campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)}
575+
s3_client.put_object(
576+
Bucket=rules_bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json"
577+
)
578+
yield campaign
579+
s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json")
580+
581+
582+
@pytest.fixture
583+
def campaign_config_with_rules_having_rule_mapper(
584+
s3_client: BaseClient, rules_bucket: BucketName
585+
) -> Generator[CampaignConfig]:
586+
campaign: CampaignConfig = rule.CampaignConfigFactory.build(
587+
target="RSV",
588+
iterations=[
589+
rule.IterationFactory.build(
590+
iteration_rules=[
591+
rule.PostcodeSuppressionRuleFactory.build(
592+
type=RuleType.filter, code="Rule Code Excluded postcode In SW19"
593+
),
594+
rule.PersonAgeSuppressionRuleFactory.build(
595+
name="age_rule_name1", code="Rule Code Excluded age less than 75"
596+
),
597+
],
598+
iteration_cohorts=[
599+
rule.IterationCohortFactory.build(
600+
cohort_label="cohort1",
601+
cohort_group="cohort_group1",
602+
positive_description="positive_description",
603+
negative_description="negative_description",
604+
)
605+
],
606+
rules_mapper={
607+
"OTHER_SETTINGS": RuleEntry(
608+
RuleNames=[RuleName("age_rule_name1")],
609+
RuleCode=RuleCode("Age rule code from mapper"),
610+
RuleText=RuleText("Age Rule Description from mapper"),
611+
),
612+
"ALREADY_JABBED": RuleEntry(RuleNames=[], RuleCode=RuleCode(""), RuleText=RuleText("")),
613+
},
614+
status_text=None,
615+
)
616+
],
617+
)
618+
campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)}
619+
s3_client.put_object(
620+
Bucket=rules_bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json"
621+
)
622+
yield campaign
623+
s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json")
624+
625+
544626
@pytest.fixture(scope="class")
545627
def inactive_iteration_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]:
546628
campaigns, campaign_data_keys = [], []

0 commit comments

Comments
 (0)