Skip to content

Commit 2409dfb

Browse files
authored
Merge branch 'main' into feature/eja-eli-204-add-api-gateway
2 parents 4870804 + b566594 commit 2409dfb

File tree

29 files changed

+216
-94
lines changed

29 files changed

+216
-94
lines changed

infrastructure/Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ guard-%:
1414
# Initializes the Terraform configuration for the specified stack and environment.
1515
terraform-init: guard-env guard-stack
1616
rm -rf ./stacks/$(stack)/.terraform
17-
terraform -chdir=./stacks/$(stack) init -var-file=stacks/_shared/tfvars/$(env).tfvars -backend-config=backends/$(env).$(stack).tfbackend -upgrade
17+
terraform -chdir=./stacks/$(stack) init -backend-config=backends/$(env).$(stack).tfbackend -upgrade -reconfigure
1818
terraform -chdir=./stacks/$(stack) get -update
1919

2020
# Selects or creates a Terraform workspace for the specified stack and environment.
@@ -35,7 +35,7 @@ terraform-workspace-delete: guard-env guard-stack
3535

3636
# Runs a specified Terraform command (e.g., plan, apply) for the stack and environment.
3737
terraform: guard-env guard-stack guard-tf-command terraform-init terraform-workspace
38-
terraform -chdir=./stacks/$(stack) $(tf-command) -var-file=../_shared/tfvars/$(env).tfvars $(args) $(if $(filter $(tf-command),init),,--parallelism=30)
38+
terraform -chdir=./stacks/$(stack) $(tf-command) $(args) $(if $(filter $(tf-command),init),,--parallelism=30)
3939
rm -f ./terraform_outputs_$(stack).json || true
4040
terraform -chdir=./stacks/$(stack) output -json > ./build/terraform_outputs_$(stack).json
4141

@@ -45,9 +45,9 @@ terraform: guard-env guard-stack guard-tf-command terraform-init terraform-works
4545

4646
# Initializes the Terraform configuration for the bootstrap stack.
4747
bootstrap-terraform-init: guard-env
48-
terraform -chdir=./stacks/bootstrap init -var-file=stacks/_shared/tfvars/$(env).tfvars -upgrade
48+
terraform -chdir=./stacks/bootstrap init -upgrade
4949
terraform -chdir=./stacks/bootstrap get -update
5050

5151
# Runs a specified Terraform command (e.g., plan, apply) for the bootstrap stack.
5252
bootstrap-terraform: guard-env guard-tf-command bootstrap-terraform-init
53-
terraform -chdir=./stacks/bootstrap $(tf-command) -var-file=../_shared/tfvars/$(env).tfvars $(args)
53+
terraform -chdir=./stacks/bootstrap $(tf-command) $(args)
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
resource "aws_kms_key" "dynamodb_cmk" {
2-
description = "${var.table_name_suffix} Master Key"
2+
description = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.table_name_suffix} Master Key"
33
deletion_window_in_days = 14
44
is_enabled = true
55
enable_key_rotation = true
66
}
77

88
resource "aws_kms_alias" "dynamodb_cmk" {
9-
name = "alias/${var.project_name}-${var.table_name_suffix}-cmk"
9+
name = "alias/${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.table_name_suffix}-cmk"
1010
target_key_id = aws_kms_key.dynamodb_cmk.key_id
1111
}

src/eligibility_signposting_api/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import os
33
from collections.abc import Sequence
4-
from functools import lru_cache
4+
from functools import cache
55
from typing import Any, NewType
66

77
from pythonjsonlogger.json import JsonFormatter
@@ -17,7 +17,7 @@
1717
AwsSecretAccessKey = NewType("AwsSecretAccessKey", str)
1818

1919

20-
@lru_cache
20+
@cache
2121
def config() -> dict[str, Any]:
2222
return {
2323
"aws_access_key_id": AwsAccessKey(os.getenv("AWS_ACCESS_KEY_ID", "dummy_key")),

src/eligibility_signposting_api/model/rules.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ def serialize_dates(v: date, _info: SerializationInfo) -> str:
165165

166166

167167
class Rules(BaseModel):
168+
"""Eligibility rules.
169+
170+
This is a Pydantic model, into which we can de-serialise rules stored in DPS's format."""
171+
168172
campaign_config: CampaignConfig = Field(..., alias="CampaignConfig")
169173

170174
model_config = {"populate_by_name": True}

src/eligibility_signposting_api/repos/eligibility_repo.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ def eligibility_table_factory(
2525

2626
@service
2727
class EligibilityRepo:
28+
"""Repository class for the data held about a person which may be relevant to calculating their eligibility for
29+
vaccination.
30+
31+
This data is held in a handful of records in a single Dynamodb table.
32+
"""
33+
2834
def __init__(self, table: Annotated[Any, Inject(qualifier="eligibility_table")]) -> None:
2935
super().__init__()
3036
self.table = table
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
class NotFoundError(Exception):
2-
pass
2+
"""Requested entity not found in repository."""

src/eligibility_signposting_api/repos/rules_repo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
@service
1414
class RulesRepo:
15+
"""Repository class for Campaign Rules, which we can use to calculate a person's eligibility for vaccination.
16+
17+
These rules are stored as JSON files in AWS S3."""
18+
1519
def __init__(
1620
self,
1721
s3_client: Annotated[BaseClient, Inject(qualifier="s3")],

src/eligibility_signposting_api/services/eligibility_services.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def __init__(self, eligibility_repo: EligibilityRepo, rules_repo: RulesRepo) ->
2323
self.rules_repo = rules_repo
2424

2525
def get_eligibility_status(self, nhs_number: eligibility.NHSNumber | None = None) -> eligibility.EligibilityStatus:
26+
"""Calculate a person's eligibility for vaccination given an NHS number."""
2627
if nhs_number:
2728
try:
2829
person_data = self.eligibility_repo.get_eligibility_data(nhs_number)

src/eligibility_signposting_api/services/rules/operators.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,18 @@
1818

1919
@dataclass
2020
class Operator(BaseMatcher[str | None], ABC):
21+
"""An operator compares some person's data attribute - date of birth, postcode, flags or so on - against a value
22+
specified in a rule."""
23+
24+
ITEM_DEFAULT_PATTERN: ClassVar[str] = r"(?P<rule_value>[^\[]+)\[\[NVL:(?P<item_default>[^\]]+)\]\]"
25+
2126
rule_value: str
27+
item_default: str | None = None
28+
29+
def __post_init__(self) -> None:
30+
if self.rule_value and (match := re.match(self.ITEM_DEFAULT_PATTERN, self.rule_value)):
31+
self.rule_value = match.group("rule_value")
32+
self.item_default = match.group("item_default")
2233

2334
@abstractmethod
2435
def _matches(self, item: str | None) -> bool: ...
@@ -28,6 +39,8 @@ def describe_to(self, description: Description) -> None:
2839

2940

3041
class OperatorRegistry:
42+
"""Operators are registered and made available for retrieval here for each RuleOperator."""
43+
3144
registry: ClassVar[dict[RuleOperator, type[Operator]]] = {}
3245

3346
@staticmethod
@@ -50,6 +63,7 @@ class ScalarOperator(Operator, ABC):
5063
comparator: ClassVar[Callable[[str | None, str | None], bool]]
5164

5265
def _matches(self, item: str | None) -> bool:
66+
item = item if item is not None else self.item_default
5367
data_comparator = cast("Callable[[str|int, str|int], bool]", self.comparator)
5468
person_data: str | int
5569
rule_value: str | int
@@ -100,37 +114,43 @@ def describe_to(self, description: Description) -> None:
100114
@OperatorRegistry.register(RuleOperator.contains)
101115
class Contains(Operator):
102116
def _matches(self, item: str | None) -> bool:
117+
item = item if item is not None else self.item_default
103118
return bool(item) and self.rule_value in str(item)
104119

105120

106121
@OperatorRegistry.register(RuleOperator.not_contains)
107122
class NotContains(Operator):
108123
def _matches(self, item: str | None) -> bool:
124+
item = item if item is not None else self.item_default
109125
return self.rule_value not in str(item)
110126

111127

112128
@OperatorRegistry.register(RuleOperator.starts_with)
113129
class StartsWith(Operator):
114130
def _matches(self, item: str | None) -> bool:
131+
item = item if item is not None else self.item_default
115132
return str(item).startswith(self.rule_value)
116133

117134

118135
@OperatorRegistry.register(RuleOperator.not_starts_with)
119136
class NotStartsWith(Operator):
120137
def _matches(self, item: str | None) -> bool:
138+
item = item if item is not None else self.item_default
121139
return not str(item).startswith(self.rule_value)
122140

123141

124142
@OperatorRegistry.register(RuleOperator.ends_with)
125143
class EndsWith(Operator):
126144
def _matches(self, item: str | None) -> bool:
145+
item = item if item is not None else self.item_default
127146
return str(item).endswith(self.rule_value)
128147

129148

130149
@OperatorRegistry.register(RuleOperator.is_in)
131150
@OperatorRegistry.register(RuleOperator.member_of)
132151
class IsIn(Operator):
133152
def _matches(self, item: str | None) -> bool:
153+
item = item if item is not None else self.item_default
134154
comparators = str(self.rule_value).split(",")
135155
return str(item) in comparators
136156

@@ -139,6 +159,7 @@ def _matches(self, item: str | None) -> bool:
139159
@OperatorRegistry.register(RuleOperator.not_member_of)
140160
class NotIn(Operator):
141161
def _matches(self, item: str | None) -> bool:
162+
item = item if item is not None else self.item_default
142163
comparators = str(self.rule_value).split(",")
143164
return str(item) not in comparators
144165

@@ -156,8 +177,12 @@ def _matches(self, item: str | None) -> bool:
156177

157178

158179
class RangeOperator(Operator, ABC):
159-
def __init__(self, rule_value: str) -> None:
160-
super().__init__(rule_value=rule_value)
180+
low_comparator: int
181+
high_comparator: int
182+
183+
def __post_init__(self) -> None:
184+
super().__post_init__()
185+
161186
low_comparator_str, high_comparator_str = str(self.rule_value).split(",")
162187
self.low_comparator = min(int(low_comparator_str), int(high_comparator_str))
163188
self.high_comparator = max(int(low_comparator_str), int(high_comparator_str))
@@ -166,6 +191,7 @@ def __init__(self, rule_value: str) -> None:
166191
@OperatorRegistry.register(RuleOperator.is_between)
167192
class Between(RangeOperator):
168193
def _matches(self, item: str | None) -> bool:
194+
item = item if item is not None else self.item_default
169195
if item in (None, ""):
170196
return False
171197
return self.low_comparator <= int(item) <= self.high_comparator
@@ -174,6 +200,7 @@ def _matches(self, item: str | None) -> bool:
174200
@OperatorRegistry.register(RuleOperator.is_not_between)
175201
class NotBetween(RangeOperator):
176202
def _matches(self, item: str | None) -> bool:
203+
item = item if item is not None else self.item_default
177204
if item in (None, ""):
178205
return False
179206
return not self.low_comparator <= int(item) <= self.high_comparator
@@ -204,8 +231,17 @@ def _matches(self, item: str | None) -> bool:
204231

205232

206233
class DateOperator(Operator, ABC):
234+
OFFSET_PATTERN: ClassVar[str] = r"(?P<rule_value>[^\[]+)\[\[OFFSET:(?P<offset>\d{8})\]\]"
207235
delta_type: ClassVar[str]
208236
comparator: ClassVar[Callable[[date, date], bool]]
237+
offset: date | None = None
238+
239+
def __post_init__(self) -> None:
240+
super().__post_init__()
241+
242+
if self.rule_value and (match := re.match(self.OFFSET_PATTERN, self.rule_value)):
243+
self.rule_value = match.group("rule_value")
244+
self.offset = datetime.strptime(match.group("offset"), "%Y%m%d").replace(tzinfo=UTC).date()
209245

210246
@property
211247
def today(self) -> date:
@@ -219,9 +255,10 @@ def get_attribute_date(item: str | None) -> date | None:
219255
def cutoff(self) -> date:
220256
delta = relativedelta()
221257
setattr(delta, self.delta_type, int(self.rule_value))
222-
return self.today + delta
258+
return (self.offset if self.offset else self.today) + delta
223259

224260
def _matches(self, item: str | None) -> bool:
261+
item = item if item is not None else self.item_default
225262
if attribute_date := self.get_attribute_date(item):
226263
date_comparator = cast("Callable[[date, date], bool]", self.comparator)
227264
return date_comparator(attribute_date, self.cutoff)

src/eligibility_signposting_api/views/eligibility.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010

1111
from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber, Status
1212
from eligibility_signposting_api.services import EligibilityService, UnknownPersonError
13-
from eligibility_signposting_api.views import response_models
13+
from eligibility_signposting_api.views.response_model import eligibility
1414

1515
STATUS_MAPPING = {
16-
Status.actionable: response_models.Status.actionable,
17-
Status.not_actionable: response_models.Status.not_actionable,
18-
Status.not_eligible: response_models.Status.not_eligible,
16+
Status.actionable: eligibility.Status.actionable,
17+
Status.not_actionable: eligibility.Status.not_actionable,
18+
Status.not_eligible: eligibility.Status.not_eligible,
1919
}
2020

2121
logger = logging.getLogger(__name__)
@@ -48,23 +48,23 @@ def check_eligibility(nhs_number: NHSNumber, eligibility_service: Injected[Eligi
4848

4949
def build_eligibility_response(
5050
eligibility_status: EligibilityStatus,
51-
) -> response_models.EligibilityResponse:
51+
) -> eligibility.EligibilityResponse:
5252
"""Return an object representing the API response we are going to send, given an evaluation of the person's
5353
eligibility."""
54-
return response_models.EligibilityResponse( # pyright: ignore[reportCallIssue]
54+
return eligibility.EligibilityResponse( # pyright: ignore[reportCallIssue]
5555
response_id=uuid.uuid4(), # pyright: ignore[reportCallIssue]
56-
meta=response_models.Meta(last_updated=response_models.LastUpdated(datetime.now(tz=UTC))), # pyright: ignore[reportCallIssue]
56+
meta=eligibility.Meta(last_updated=eligibility.LastUpdated(datetime.now(tz=UTC))), # pyright: ignore[reportCallIssue]
5757
processed_suggestions=[ # pyright: ignore[reportCallIssue]
58-
response_models.ProcessedSuggestion( # pyright: ignore[reportCallIssue]
59-
condition_name=response_models.ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue]
58+
eligibility.ProcessedSuggestion( # pyright: ignore[reportCallIssue]
59+
condition_name=eligibility.ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue]
6060
status=STATUS_MAPPING[condition.status],
61-
status_text=response_models.StatusText(f"{condition.status}"), # pyright: ignore[reportCallIssue]
61+
status_text=eligibility.StatusText(f"{condition.status}"), # pyright: ignore[reportCallIssue]
6262
eligibility_cohorts=[], # pyright: ignore[reportCallIssue]
6363
suitability_rules=[ # pyright: ignore[reportCallIssue]
64-
response_models.SuitabilityRule( # pyright: ignore[reportCallIssue]
65-
type=response_models.RuleType(reason.rule_type.value), # pyright: ignore[reportCallIssue]
66-
rule_code=response_models.RuleCode(reason.rule_name), # pyright: ignore[reportCallIssue]
67-
rule_text=response_models.RuleText(reason.rule_result), # pyright: ignore[reportCallIssue]
64+
eligibility.SuitabilityRule( # pyright: ignore[reportCallIssue]
65+
type=eligibility.RuleType(reason.rule_type.value), # pyright: ignore[reportCallIssue]
66+
rule_code=eligibility.RuleCode(reason.rule_name), # pyright: ignore[reportCallIssue]
67+
rule_text=eligibility.RuleText(reason.rule_result), # pyright: ignore[reportCallIssue]
6868
)
6969
for reason in condition.reasons
7070
], # pyright: ignore[reportCallIssue]

0 commit comments

Comments
 (0)