Skip to content

Commit 607f29a

Browse files
Merge pull request #43 from NHSDigital/feature/eli-21-test-simple-rule
ELI-210: Test simple rule
2 parents 4fedc6d + 198aaaa commit 607f29a

File tree

21 files changed

+762
-266
lines changed

21 files changed

+762
-266
lines changed

poetry.lock

Lines changed: 123 additions & 111 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ mangum = "^0.19.0"
3333
wireup = "^1.0.1"
3434
python-json-logger = "^3.3.0"
3535
fhir-resources = "^8.0.0"
36+
python-dateutil = "^2.9.0"
3637

3738
[tool.poetry.group.dev.dependencies]
3839
ruff = "^0.11.0"

src/eligibility_signposting_api/error_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException:
2424
) # pyright: ignore[reportCallIssue]
2525
]
2626
)
27-
return make_response(problem.model_dump(), HTTPStatus.INTERNAL_SERVER_ERROR)
27+
return make_response(problem.model_dump(by_alias=True), HTTPStatus.INTERNAL_SERVER_ERROR)

src/eligibility_signposting_api/model/eligibility.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@
88
Postcode = NewType("Postcode", str)
99

1010

11-
class Eligibility(BaseModel):
12-
processed_suggestions: list[dict]
11+
class EligibilityStatus(BaseModel):
12+
eligible: bool
13+
reasons: list[dict]
14+
actions: list[dict]

src/eligibility_signposting_api/model/rules.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717
IterationName = NewType("IterationName", str)
1818
IterationVersion = NewType("IterationVersion", str)
1919
IterationID = NewType("IterationID", str)
20+
IterationDate = NewType("IterationDate", date)
2021
RuleName = NewType("RuleName", str)
2122
RuleDescription = NewType("RuleDescription", str)
2223
RulePriority = NewType("RulePriority", int)
23-
RuleAttributeLevel = NewType("RuleAttributeLevel", str)
2424
RuleAttributeName = NewType("RuleAttributeName", str)
2525
RuleComparator = NewType("RuleComparator", str)
2626
StartDate = NewType("StartDate", date)
@@ -34,17 +34,24 @@ class RuleType(str, Enum):
3434

3535

3636
class RuleOperator(str, Enum):
37+
equals = "="
38+
ne = "!="
3739
lt = "<"
40+
lte = "<="
3841
gt = ">"
42+
gte = ">="
3943
year_gt = "Y>"
4044
not_in = "not_in"
41-
equals = "="
42-
lte = "<="
43-
ne = "!="
4445
date_gte = "D>="
4546
member_of = "MemberOf"
4647

4748

49+
class RuleAttributeLevel(str, Enum):
50+
PERSON = "PERSON"
51+
TARGET = "TARGET"
52+
COHORT = "COHORT"
53+
54+
4855
class IterationCohort(BaseModel):
4956
cohort_label: str | None = Field(None, alias="CohortLabel")
5057
priority: int | None = Field(None, alias="Priority")
@@ -62,26 +69,38 @@ class IterationRule(BaseModel):
6269
operator: RuleOperator = Field(..., alias="Operator")
6370
comparator: RuleComparator = Field(..., alias="Comparator")
6471
attribute_target: str | None = Field(None, alias="AttributeTarget")
65-
comms_routing: str | None = Field(None, alias="CommsRouting")
6672

6773
model_config = {"populate_by_name": True}
6874

6975

7076
class Iteration(BaseModel):
7177
id: IterationID = Field(..., alias="ID")
72-
default_comms_routing: str | None = Field(None, alias="DefaultCommsRouting")
7378
version: IterationVersion = Field(..., alias="Version")
7479
name: IterationName = Field(..., alias="Name")
75-
iteration_date: str | None = Field(None, alias="IterationDate")
80+
iteration_date: IterationDate = Field(..., alias="IterationDate")
7681
iteration_number: int | None = Field(None, alias="IterationNumber")
77-
comms_type: Literal["I", "R"] = Field(..., alias="CommsType")
7882
approval_minimum: int | None = Field(None, alias="ApprovalMinimum")
7983
approval_maximum: int | None = Field(None, alias="ApprovalMaximum")
8084
type: Literal["A", "M", "S"] = Field(..., alias="Type")
81-
iteration_cohorts: list[IterationCohort] | None = Field(None, alias="IterationCohorts")
82-
iteration_rules: list[IterationRule] | None = Field(None, alias="IterationRules")
85+
iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts")
86+
iteration_rules: list[IterationRule] = Field(..., alias="IterationRules")
8387

84-
model_config = {"populate_by_name": True}
88+
model_config = {
89+
"populate_by_name": True,
90+
"arbitrary_types_allowed": True,
91+
}
92+
93+
@field_validator("iteration_date", mode="before")
94+
@classmethod
95+
def parse_dates(cls, v: str | date) -> date:
96+
if isinstance(v, date):
97+
return v
98+
return datetime.strptime(v, "%Y%m%d").date() # noqa: DTZ007
99+
100+
@field_serializer("iteration_date", when_used="always")
101+
@staticmethod
102+
def serialize_dates(v: date, _info: SerializationInfo) -> str:
103+
return v.strftime("%Y%m%d")
85104

86105

87106
class CampaignConfig(BaseModel):
@@ -101,7 +120,7 @@ class CampaignConfig(BaseModel):
101120
end_date: EndDate = Field(..., alias="EndDate")
102121
approval_minimum: int | None = Field(None, alias="ApprovalMinimum")
103122
approval_maximum: int | None = Field(None, alias="ApprovalMaximum")
104-
iterations: list[Iteration] | None = Field(None, alias="Iterations")
123+
iterations: list[Iteration] = Field(..., alias="Iterations")
105124

106125
model_config = {
107126
"populate_by_name": True,
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .eligibility_repo import EligibilityRepo
22
from .exceptions import NotFoundError
3+
from .rules_repo import RulesRepo
34

4-
__all__ = ["EligibilityRepo", "NotFoundError"]
5+
__all__ = ["EligibilityRepo", "NotFoundError", "RulesRepo"]

src/eligibility_signposting_api/repos/rules_repo.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import json
2+
from collections.abc import Generator
23
from typing import Annotated
34

45
from botocore.client import BaseClient
56
from wireup import Inject, service
67

7-
from eligibility_signposting_api.model.rules import BucketName, CampaignConfig, CampaignName, Rules
8+
from eligibility_signposting_api.model.rules import BucketName, CampaignConfig, Rules
89

910

1011
@service
@@ -18,7 +19,9 @@ def __init__(
1819
self.s3_client = s3_client
1920
self.bucket_name = bucket_name
2021

21-
def get_campaign_config(self, campaign: CampaignName) -> CampaignConfig:
22-
response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{campaign}.json")
23-
body = response["Body"].read()
24-
return Rules.model_validate(json.loads(body)).campaign_config
22+
def get_campaign_configs(self) -> Generator[CampaignConfig]:
23+
campaign_objects = self.s3_client.list_objects(Bucket=self.bucket_name)
24+
for campaign_object in campaign_objects["Contents"]:
25+
response = self.s3_client.get_object(Bucket=self.bucket_name, Key=f"{campaign_object['Key']}")
26+
body = response["Body"].read()
27+
yield Rules.model_validate(json.loads(body)).campaign_config
Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import logging
2+
from datetime import datetime
3+
from typing import Any
24

5+
from dateutil.relativedelta import relativedelta
36
from wireup import service
47

5-
from eligibility_signposting_api.model.eligibility import Eligibility, NHSNumber
6-
from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError
8+
from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber
9+
from eligibility_signposting_api.model.rules import CampaignConfig, IterationRule, RuleAttributeLevel, RuleOperator
10+
from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError, RulesRepo
711

812
logger = logging.getLogger(__name__)
913

@@ -14,23 +18,87 @@ class UnknownPersonError(Exception):
1418

1519
@service
1620
class EligibilityService:
17-
def __init__(self, eligibility_repo: EligibilityRepo) -> None:
21+
def __init__(self, eligibility_repo: EligibilityRepo, rules_repo: RulesRepo) -> None:
1822
super().__init__()
1923
self.eligibility_repo = eligibility_repo
24+
self.rules_repo = rules_repo
2025

21-
def get_eligibility(self, nhs_number: NHSNumber | None = None) -> Eligibility:
26+
def get_eligibility_status(self, nhs_number: NHSNumber | None = None) -> EligibilityStatus:
2227
if nhs_number:
2328
try:
24-
eligibility_data = self.eligibility_repo.get_eligibility_data(nhs_number)
29+
person_data = self.eligibility_repo.get_eligibility_data(nhs_number)
30+
campaign_configs = list(self.rules_repo.get_campaign_configs())
2531
logger.debug(
26-
"got eligibility_data %r",
27-
eligibility_data,
28-
extra={"eligibility_data": eligibility_data, "nhs_number": nhs_number},
32+
"got person_data for %r",
33+
nhs_number,
34+
extra={
35+
"campaign_configs": [c.model_dump(by_alias=True) for c in campaign_configs],
36+
"person_data": person_data,
37+
"nhs_number": nhs_number,
38+
},
2939
)
3040
except NotFoundError as e:
3141
raise UnknownPersonError from e
3242
else:
3343
# TODO: Apply rules here # noqa: TD002, TD003, FIX002
34-
return Eligibility(processed_suggestions=[])
44+
return self.evaluate_eligibility(campaign_configs, person_data)
3545

3646
raise UnknownPersonError
47+
48+
@staticmethod
49+
def evaluate_eligibility(
50+
campaign_configs: list[CampaignConfig], person_data: list[dict[str, Any]]
51+
) -> EligibilityStatus:
52+
eligible, reasons, actions = True, [], []
53+
for iteration_rule in [
54+
iteration_rule
55+
for campaign_config in campaign_configs
56+
for iteration in campaign_config.iterations
57+
for iteration_rule in iteration.iteration_rules
58+
]:
59+
if EligibilityService.evaluate_exclusion(iteration_rule, person_data):
60+
eligible = False
61+
62+
return EligibilityStatus(eligible=eligible, reasons=reasons, actions=actions)
63+
64+
@staticmethod
65+
def evaluate_exclusion(iteration_rule: IterationRule, person_data: list[dict[str, Any]]) -> bool:
66+
attribute_value = EligibilityService.get_attribute_value(iteration_rule, person_data)
67+
return EligibilityService.evaluate_rule(iteration_rule, attribute_value)
68+
69+
@staticmethod
70+
def get_attribute_value(iteration_rule: IterationRule, person_data: list[dict[str, Any]]) -> Any: # noqa: ANN401
71+
match iteration_rule.attribute_level:
72+
case RuleAttributeLevel.PERSON:
73+
person: dict[str, Any] | None = next(
74+
(r for r in person_data if r.get("ATTRIBUTE_TYPE", "").startswith("PERSON")), None
75+
)
76+
attribute_value = person.get(iteration_rule.attribute_name) if person else None
77+
case _:
78+
msg = f"{iteration_rule.attribute_level} not implemented"
79+
raise NotImplementedError(msg)
80+
return attribute_value
81+
82+
@staticmethod
83+
def evaluate_rule(iteration_rule: IterationRule, attribute_value: Any) -> bool: # noqa: PLR0911, ANN401
84+
match iteration_rule.operator:
85+
case RuleOperator.equals:
86+
return attribute_value == iteration_rule.comparator
87+
case RuleOperator.ne:
88+
return attribute_value != iteration_rule.comparator
89+
case RuleOperator.lt:
90+
return int(attribute_value) < int(iteration_rule.comparator)
91+
case RuleOperator.lte:
92+
return int(attribute_value) <= int(iteration_rule.comparator)
93+
case RuleOperator.gt:
94+
return int(attribute_value) > int(iteration_rule.comparator)
95+
case RuleOperator.gte:
96+
return int(attribute_value) >= int(iteration_rule.comparator)
97+
case RuleOperator.year_gt:
98+
attribute_date = datetime.strptime(str(attribute_value), "%Y%m%d") if attribute_value else None # noqa: DTZ007
99+
today = datetime.today() # noqa: DTZ002
100+
cutoff = today + relativedelta(years=int(iteration_rule.comparator))
101+
return (attribute_date > cutoff) if attribute_date else False
102+
case _:
103+
msg = f"{iteration_rule.operator} not implemented"
104+
raise NotImplementedError(msg)
Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import logging
22
from http import HTTPStatus
33

4-
from fhir.resources.operationoutcome import OperationOutcome, OperationOutcomeIssue
4+
from fhir.resources.R4B.bundle import Bundle, BundleEntry
5+
from fhir.resources.R4B.guidanceresponse import GuidanceResponse
6+
from fhir.resources.R4B.location import Location
7+
from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue
8+
from fhir.resources.R4B.requestgroup import RequestGroup
9+
from fhir.resources.R4B.task import Task
510
from flask import Blueprint, make_response, request
611
from flask.typing import ResponseReturnValue
712
from wireup import Injected
813

9-
from eligibility_signposting_api.model.eligibility import Eligibility, NHSNumber
14+
from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber
1015
from eligibility_signposting_api.services import EligibilityService, UnknownPersonError
11-
from eligibility_signposting_api.views.response_models import EligibilityResponse
1216

1317
logger = logging.getLogger(__name__)
1418

@@ -20,7 +24,7 @@ def check_eligibility(eligibility_service: Injected[EligibilityService]) -> Resp
2024
nhs_number = NHSNumber(request.args.get("nhs_number", ""))
2125
logger.debug("checking nhs_number %r in %r", nhs_number, eligibility_service, extra={"nhs_number": nhs_number})
2226
try:
23-
eligibility = eligibility_service.get_eligibility(nhs_number)
27+
eligibility_status = eligibility_service.get_eligibility_status(nhs_number)
2428
except UnknownPersonError:
2529
logger.debug("nhs_number %r not found", nhs_number, extra={"nhs_number": nhs_number})
2630
problem = OperationOutcome(
@@ -32,11 +36,22 @@ def check_eligibility(eligibility_service: Injected[EligibilityService]) -> Resp
3236
) # pyright: ignore[reportCallIssue]
3337
]
3438
)
35-
return make_response(problem.model_dump(), HTTPStatus.NOT_FOUND)
39+
return make_response(problem.model_dump(by_alias=True), HTTPStatus.NOT_FOUND)
3640
else:
37-
eligibility_response = build_eligibility_response(eligibility)
38-
return make_response(eligibility_response.model_dump(), HTTPStatus.OK)
39-
40-
41-
def build_eligibility_response(eligibility: Eligibility) -> EligibilityResponse:
42-
return EligibilityResponse(processed_suggestions=eligibility.processed_suggestions)
41+
bundle = build_bundle(eligibility_status)
42+
return make_response(bundle.model_dump(by_alias=True), HTTPStatus.OK)
43+
44+
45+
def build_bundle(_eligibility_status: EligibilityStatus) -> Bundle:
46+
return Bundle( # pyright: ignore[reportCallIssue]
47+
id="dummy-bundle",
48+
type="collection",
49+
entry=[
50+
BundleEntry( # pyright: ignore[reportCallIssue]
51+
resource=GuidanceResponse(id="dummy-guidance-response", status="requested", moduleCodeableConcept={}) # pyright: ignore[reportCallIssue]
52+
),
53+
BundleEntry(resource=RequestGroup(id="dummy-request-group", intent="proposal", status="requested")), # pyright: ignore[reportCallIssue]
54+
BundleEntry(resource=Task(id="dummy-task", intent="proposal", status="requested")), # pyright: ignore[reportCallIssue]
55+
BundleEntry(resource=Location(id="dummy-location")), # pyright: ignore[reportCallIssue]
56+
],
57+
)

src/eligibility_signposting_api/views/response_models.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)