Skip to content

Commit 38e2133

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/eja-eli-385-remove-wildcard-resource-and-passrole-permissions
2 parents 99f5572 + 5fb57a7 commit 38e2133

30 files changed

Lines changed: 1496 additions & 504 deletions

infrastructure/stacks/api-layer/patient_check.tf

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
resource "aws_api_gateway_request_validator" "patient_check_validator" {
32
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
43
name = "validate-path-params"
@@ -27,12 +26,12 @@ resource "aws_api_gateway_method" "get_patient_check" {
2726
}
2827

2928
resource "aws_api_gateway_integration" "get_patient_check" {
30-
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
31-
resource_id = aws_api_gateway_resource.patient.id
32-
http_method = aws_api_gateway_method.get_patient_check.http_method
29+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
30+
resource_id = aws_api_gateway_resource.patient.id
31+
http_method = aws_api_gateway_method.get_patient_check.http_method
3332
integration_http_method = "POST" # Needed for lambda proxy integration
34-
type = "AWS_PROXY"
35-
uri = module.eligibility_signposting_lambda_function.aws_lambda_invoke_arn
33+
type = "AWS_PROXY"
34+
uri = module.eligibility_signposting_lambda_function.aws_lambda_invoke_arn
3635

3736
depends_on = [
3837
aws_api_gateway_method.get_patient_check
@@ -47,3 +46,43 @@ resource "aws_lambda_permission" "get_patient_check" {
4746

4847
source_arn = "${module.eligibility_signposting_api_gateway.execution_arn}/*/*"
4948
}
49+
50+
resource "aws_api_gateway_gateway_response" "bad_request_parameters" {
51+
rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id
52+
response_type = "BAD_REQUEST_PARAMETERS"
53+
status_code = "400"
54+
55+
response_templates = {
56+
"application/fhir+json" = jsonencode({
57+
resourceType = "OperationOutcome"
58+
id = "$context.requestId"
59+
meta = {
60+
lastUpdated = "$context.requestTime"
61+
}
62+
issue = [
63+
{
64+
severity = "error"
65+
code = "invalid"
66+
details = {
67+
coding = [
68+
{
69+
system = "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
70+
code = "BAD_REQUEST",
71+
display = "Bad Request"
72+
}
73+
]
74+
}
75+
diagnostics = "Missing required NHS Number from path parameters",
76+
location = [
77+
"parameters/id"
78+
]
79+
}
80+
]
81+
})
82+
}
83+
84+
response_parameters = {
85+
"gatewayresponse.header.Access-Control-Allow-Origin" = "'*'"
86+
"gatewayresponse.header.Content-Type" = "'application/fhir+json'"
87+
}
88+
}

src/eligibility_signposting_api/common/api_error_response.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,26 +25,25 @@ class FHIRIssueCode(str, Enum):
2525

2626

2727
class FHIRSpineErrorCode(str, Enum):
28-
INVALID_NHS_NUMBER = "INVALID_NHS_NUMBER"
28+
ACCESS_DENIED = "ACCESS_DENIED"
2929
INVALID_PARAMETER = "INVALID_PARAMETER"
3030
INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"
3131
REFERENCE_NOT_FOUND = "REFERENCE_NOT_FOUND"
3232

3333

3434
class APIErrorResponse:
35-
def __init__( # noqa: PLR0913
35+
def __init__(
3636
self,
3737
status_code: HTTPStatus,
3838
fhir_issue_code: FHIRIssueCode,
3939
fhir_issue_severity: FHIRIssueSeverity,
40-
fhir_coding_system: str,
4140
fhir_error_code: str,
4241
fhir_display_message: str,
4342
) -> None:
4443
self.status_code = status_code
4544
self.fhir_issue_code = fhir_issue_code
4645
self.fhir_issue_severity = fhir_issue_severity
47-
self.fhir_coding_system = fhir_coding_system
46+
self.fhir_coding_system = "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1"
4847
self.fhir_error_code = fhir_error_code
4948
self.fhir_display_message = fhir_display_message
5049

@@ -94,7 +93,6 @@ def log_and_generate_response(
9493
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
9594
fhir_issue_code=FHIRIssueCode.VALUE,
9695
fhir_issue_severity=FHIRIssueSeverity.ERROR,
97-
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
9896
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
9997
fhir_display_message="The supplied value was not recognised by the API.",
10098
)
@@ -103,7 +101,6 @@ def log_and_generate_response(
103101
status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
104102
fhir_issue_code=FHIRIssueCode.VALUE,
105103
fhir_issue_severity=FHIRIssueSeverity.ERROR,
106-
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
107104
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
108105
fhir_display_message="The supplied category was not recognised by the API.",
109106
)
@@ -112,7 +109,6 @@ def log_and_generate_response(
112109
status_code=HTTPStatus.BAD_REQUEST,
113110
fhir_issue_code=FHIRIssueCode.VALUE,
114111
fhir_issue_severity=FHIRIssueSeverity.ERROR,
115-
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
116112
fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER,
117113
fhir_display_message="The given conditions were not in the expected format.",
118114
)
@@ -121,7 +117,6 @@ def log_and_generate_response(
121117
status_code=HTTPStatus.NOT_FOUND,
122118
fhir_issue_code=FHIRIssueCode.PROCESSING,
123119
fhir_issue_severity=FHIRIssueSeverity.ERROR,
124-
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
125120
fhir_error_code=FHIRSpineErrorCode.REFERENCE_NOT_FOUND,
126121
fhir_display_message="The given NHS number was not found in our datasets. "
127122
"This could be because the number is incorrect or "
@@ -132,7 +127,6 @@ def log_and_generate_response(
132127
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
133128
fhir_issue_code=FHIRIssueCode.PROCESSING,
134129
fhir_issue_severity=FHIRIssueSeverity.ERROR,
135-
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
136130
fhir_error_code=FHIRSpineErrorCode.INTERNAL_SERVER_ERROR,
137131
fhir_display_message="An unexpected internal server error occurred.",
138132
)
@@ -141,7 +135,6 @@ def log_and_generate_response(
141135
status_code=HTTPStatus.FORBIDDEN,
142136
fhir_issue_code=FHIRIssueCode.FORBIDDEN,
143137
fhir_issue_severity=FHIRIssueSeverity.ERROR,
144-
fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1",
145-
fhir_error_code=FHIRSpineErrorCode.INVALID_NHS_NUMBER,
146-
fhir_display_message="The provided NHS number does not match the record.",
138+
fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED,
139+
fhir_display_message="Access has been denied to process this request.",
147140
)

src/eligibility_signposting_api/common/request_validator.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
logger = logging.getLogger(__name__)
1818

19-
condition_pattern = re.compile(r"^\s*[a-zA-Z0-9]+\s*$", re.IGNORECASE)
19+
condition_pattern = re.compile(r"^\s*[a-z0-9]+\s*$", re.IGNORECASE)
2020
category_pattern = re.compile(r"^\s*(VACCINATIONS|SCREENING|ALL)\s*$", re.IGNORECASE)
2121
include_actions_pattern = re.compile(r"^\s*([YN])\s*$", re.IGNORECASE)
2222

@@ -60,10 +60,8 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None
6060
header_nhs_no = event.get("headers", {}).get(NHS_NUMBER_HEADER)
6161

6262
if not validate_nhs_number(path_nhs_no, header_nhs_no):
63-
message = f"NHS Number {path_nhs_no or ''} does not match the header NHS Number {header_nhs_no or ''}"
64-
return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response(
65-
log_message=message, diagnostics=message, location_param="id"
66-
)
63+
message = "You are not authorised to request information for the supplied NHS Number"
64+
return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response(log_message=message, diagnostics=message)
6765

6866
query_params = event.get("queryStringParameters")
6967
if query_params:

src/eligibility_signposting_api/model/campaign_config.py

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -228,21 +228,6 @@ def check_no_overlapping_iterations(self) -> typing.Self:
228228
raise ValueError(message)
229229
return self
230230

231-
@model_validator(mode="after")
232-
def check_has_iteration_from_start(self) -> typing.Self:
233-
iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_date"))
234-
if first_iteration := next(iter(iterations_by_date), None):
235-
if first_iteration.iteration_date > self.start_date:
236-
message = (
237-
f"campaign {self.id} starts on {self.start_date}, "
238-
f"1st iteration starts later - {first_iteration.iteration_date}"
239-
)
240-
raise ValueError(message)
241-
return self
242-
# Should never happen, since we are constraining self.iterations with a min_length of 1
243-
message = f"campaign {self.id} has no iterations."
244-
raise ValueError(message)
245-
246231
@cached_property
247232
def campaign_live(self) -> bool:
248233
today = datetime.now(tz=UTC).date()

src/eligibility_signposting_api/model/eligibility_status.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class Condition:
113113
condition_name: ConditionName
114114
status: Status
115115
cohort_results: list[CohortGroupResult]
116+
suitability_rules: list[Reason]
116117
status_text: StatusText
117118
actions: list[SuggestedAction] | None = None
118119

src/eligibility_signposting_api/repos/person_repo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def get_eligibility_data(self, nhs_number: NHSNumber) -> Person:
4040
response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_number))
4141
logger.debug("response %r for %r", response, nhs_number, extra={"response": response, "nhs_number": nhs_number})
4242

43-
if not (items := response.get("Items")):
43+
if not (items := response.get("Items")) or not next(
44+
(item for item in items if item.get("ATTRIBUTE_TYPE") == "PERSON"), None
45+
):
4446
message = f"Person not found with nhs_number {nhs_number}"
4547
raise NotFoundError(message)
4648

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
from collections import defaultdict
55
from dataclasses import dataclass, field
6+
from itertools import chain
67
from typing import TYPE_CHECKING
78

89
from wireup import service
@@ -16,6 +17,7 @@
1617
ConditionName,
1718
EligibilityStatus,
1819
IterationResult,
20+
Reason,
1921
Status,
2022
)
2123
from eligibility_signposting_api.services.processors.action_rule_handler import ActionRuleHandler
@@ -100,8 +102,10 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca
100102
condition_results[condition_name] = best_iteration_result.iteration_result
101103
condition_results[condition_name].actions = matched_action_detail.actions
102104

103-
condition_result = self.build_condition_results(condition_results[condition_name], condition_name)
104-
final_result.append(condition_result)
105+
condition: Condition = self.build_condition(
106+
iteration_result=condition_results[condition_name], condition_name=condition_name
107+
)
108+
final_result.append(condition)
105109

106110
AuditContext.append_audit_condition(
107111
condition_name,
@@ -150,39 +154,61 @@ def get_iteration_results(self, campaign_group: list[CampaignConfig]) -> dict[It
150154
return iteration_results
151155

152156
@staticmethod
153-
def build_condition_results(iteration_result: IterationResult, condition_name: ConditionName) -> Condition:
157+
def build_condition(iteration_result: IterationResult, condition_name: ConditionName) -> Condition:
154158
grouped_cohort_results = defaultdict(list)
155159

156160
for cohort_result in iteration_result.cohort_results:
157161
if iteration_result.status == cohort_result.status:
158162
grouped_cohort_results[cohort_result.cohort_code].append(cohort_result)
159163

160-
deduplicated_cohort_results = []
161-
162-
for group_cohort_code, group in grouped_cohort_results.items():
163-
if group:
164-
unique_rule_codes = set()
165-
deduplicated_reasons = []
166-
for cohort in group:
167-
for reason in cohort.reasons:
168-
if reason.rule_name not in unique_rule_codes and reason.rule_description:
169-
unique_rule_codes.add(reason.rule_name)
170-
deduplicated_reasons.append(reason)
171-
172-
non_empty_description = next((c.description for c in group if c.description), group[0].description)
173-
cohort_group_result = CohortGroupResult(
174-
cohort_code=group_cohort_code,
175-
status=group[0].status,
176-
reasons=deduplicated_reasons,
177-
description=non_empty_description,
178-
audit_rules=[],
179-
)
180-
deduplicated_cohort_results.append(cohort_group_result)
164+
deduplicated_cohort_results: list[CohortGroupResult] = EligibilityCalculator.deduplicate_cohort_results(
165+
grouped_cohort_results
166+
)
167+
168+
overall_deduplicated_reasons_for_condition = EligibilityCalculator.deduplicate_reasons(
169+
deduplicated_cohort_results
170+
)
181171

182172
return Condition(
183173
condition_name=condition_name,
184174
status=iteration_result.status,
185175
cohort_results=list(deduplicated_cohort_results),
176+
suitability_rules=list(overall_deduplicated_reasons_for_condition),
186177
actions=iteration_result.actions,
187178
status_text=iteration_result.status.get_status_text(condition_name),
188179
)
180+
181+
@staticmethod
182+
def deduplicate_cohort_results(
183+
grouped_cohort_results: dict[str, list[CohortGroupResult]],
184+
) -> list[CohortGroupResult]:
185+
results = []
186+
187+
for cohort_code, group_results in grouped_cohort_results.items():
188+
if not group_results:
189+
continue
190+
191+
deduped_reasons: list[Reason] = EligibilityCalculator.deduplicate_reasons(group_results)
192+
193+
description = next((c.description for c in group_results if c.description), group_results[0].description)
194+
195+
results.append(
196+
CohortGroupResult(
197+
cohort_code=cohort_code,
198+
status=group_results[0].status,
199+
reasons=list(deduped_reasons),
200+
description=description,
201+
audit_rules=[],
202+
)
203+
)
204+
205+
return results
206+
207+
@staticmethod
208+
def deduplicate_reasons(group_results: list[CohortGroupResult]) -> list[Reason]:
209+
all_reasons = chain.from_iterable(group_result.reasons for group_result in group_results)
210+
deduped = {}
211+
for reason in all_reasons:
212+
key = (reason.rule_type, reason.rule_priority)
213+
deduped.setdefault(key, reason)
214+
return list(deduped.values())

src/eligibility_signposting_api/services/processors/rule_processor.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,13 @@ def is_eligible(
5050
) -> bool:
5151
is_eligible = True
5252
priority_getter = attrgetter("priority")
53-
sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, filter_rules), key=priority_getter)
53+
sorted_rules_by_priority = sorted(filter_rules, key=priority_getter)
5454

5555
for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter):
56-
status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(person, rule_group)
56+
group_rules = list(rule_group)
57+
if self._should_skip_rule_group(cohort, group_rules):
58+
continue
59+
status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(person, iter(group_rules))
5760
if status.is_exclusion:
5861
if cohort.cohort_label is not None:
5962
cohort_results[cohort.cohort_label] = CohortGroupResult(
@@ -65,6 +68,7 @@ def is_eligible(
6568
)
6669
is_eligible = False
6770
break
71+
6872
return is_eligible
6973

7074
def is_actionable(
@@ -78,10 +82,14 @@ def is_actionable(
7882
priority_getter = attrgetter("priority")
7983
suppression_reasons = []
8084

81-
sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, suppression_rules), key=priority_getter)
85+
sorted_rules_by_priority = sorted(suppression_rules, key=priority_getter)
8286

8387
for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter):
84-
status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(person, rule_group)
88+
group_rules = list(rule_group)
89+
if self._should_skip_rule_group(cohort, group_rules):
90+
continue
91+
92+
status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(person, iter(group_rules))
8593
if status.is_exclusion:
8694
is_actionable = False
8795
suppression_reasons.extend(group_exclusion_reasons)
@@ -103,6 +111,12 @@ def is_actionable(
103111
suppression_reasons,
104112
)
105113

114+
@staticmethod
115+
def _should_skip_rule_group(cohort: IterationCohort, group_rules: list[IterationRule]) -> bool:
116+
cohort_specific_rules = [rule for rule in group_rules if rule.cohort_label is not None]
117+
matching_specific_rules = [rule for rule in cohort_specific_rules if rule.cohort_label == cohort.cohort_label]
118+
return bool(cohort_specific_rules and not matching_specific_rules)
119+
106120
def evaluate_rules_priority_group(
107121
self, person: Person, rules_group: Iterator[IterationRule]
108122
) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]:

0 commit comments

Comments
 (0)