From 542431d5f38376d015f938423e5b668a356ee07a Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:37:51 +0100 Subject: [PATCH 01/61] ELI-318: Adds content-type in 404 and 500 error response (#232) --- .../error_handler.py | 2 +- .../views/eligibility.py | 2 +- .../lambda/test_app_running_as_lambda.py | 53 ++++++++++--------- tests/unit/views/test_eligibility.py | 2 + 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/eligibility_signposting_api/error_handler.py b/src/eligibility_signposting_api/error_handler.py index 5ff156d5b..3841bf170 100644 --- a/src/eligibility_signposting_api/error_handler.py +++ b/src/eligibility_signposting_api/error_handler.py @@ -21,4 +21,4 @@ def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException: response = INTERNAL_SERVER_ERROR.log_and_generate_response( log_message=f"An unexpected error occurred: {full_traceback}", diagnostics="An unexpected error occurred." ) - return make_response(response.get("body"), response.get("statusCode")) + return make_response(response.get("body"), response.get("statusCode"), response.get("headers")) diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 0f507f65c..8875ae5e9 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -100,7 +100,7 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: response = NHS_NUMBER_NOT_FOUND_ERROR.log_and_generate_response( log_message=diagnostics, diagnostics=diagnostics, location_param="id" ) - return make_response(response.get("body"), response.get("statusCode")) + return make_response(response.get("body"), response.get("statusCode"), response.get("headers")) def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility.EligibilityResponse: diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 54e0370f8..99c936e08 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -124,36 +124,39 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( timeout=10, ) - # Then + decoded_body_bytes = base64.b64decode(response.text) + decoded_body_json = json.loads(decoded_body_bytes.decode("utf-8")) + assert_that( response, is_response() .with_status_code(HTTPStatus.NOT_FOUND) - .and_body( - is_json_that( + .with_headers(has_entries({"Content-Type": "application/fhir+json"})), + ) + + # Then + assert_that( + decoded_body_json, + has_entries( + resourceType=equal_to("OperationOutcome"), + issue=contains_exactly( has_entries( - resourceType="OperationOutcome", - issue=contains_exactly( - has_entries( - severity="error", - code="processing", - diagnostics=f"NHS Number '{nhs_number!s}' was not " - f"recognised by the Eligibility Signposting API", - details={ - "coding": [ - { - "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", - "code": "REFERENCE_NOT_FOUND", - "display": "The given NHS number was not found in our datasets. " - "This could be because the number is incorrect or " - "some other reason we cannot process that number.", - } - ] - }, - ) - ), + severity="error", + code="processing", + diagnostics=f"NHS Number '{nhs_number!s}' was not recognised by the Eligibility Signposting API", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "REFERENCE_NOT_FOUND", + "display": "The given NHS number was not found in our datasets. " + "This could be because the number is incorrect or " + "some other reason we cannot process that number.", + } + ] + }, ) - ) + ), ), ) @@ -292,6 +295,7 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu response, is_response() .with_status_code(HTTPStatus.FORBIDDEN) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) .and_body( is_json_that( has_entries( @@ -338,6 +342,7 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( response, is_response() .with_status_code(HTTPStatus.FORBIDDEN) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) .and_body( is_json_that( has_entries( diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index cd0dda37b..7174f349f 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -121,6 +121,7 @@ def test_no_nhs_number_given(app: Flask, client: FlaskClient): response, is_response() .with_status_code(HTTPStatus.NOT_FOUND) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) .and_text( is_json_that( has_entries( @@ -158,6 +159,7 @@ def test_unexpected_error(app: Flask, client: FlaskClient): response, is_response() .with_status_code(HTTPStatus.INTERNAL_SERVER_ERROR) + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) .and_text( is_json_that( has_entries( From 6ea92236bec53245276c815dbbc53fd7e3b6bd77 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:30:11 +0100 Subject: [PATCH 02/61] ELI-294: Personalised/customised status text (#234) * ELI-294: Personalised/customised status text * ELI-294: Personalised/customised status text * ELI-294: Adds unit tests for Status Enum * ELI-294: Fix sonar issues --- .../audit/audit_context.py | 20 ++++-- .../model/eligibility.py | 11 +++ .../calculators/eligibility_calculator.py | 67 ++++++++++--------- .../views/eligibility.py | 2 +- .../in_process/test_eligibility_endpoint.py | 24 +++---- .../lambda/test_app_running_as_lambda.py | 8 +-- tests/unit/audit/test_audit_context.py | 2 +- tests/unit/model/test_status.py | 40 +++++++++++ 8 files changed, 120 insertions(+), 54 deletions(-) create mode 100644 tests/unit/model/test_status.py diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index b52edbe9b..18afc6ecc 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -66,7 +66,7 @@ def append_audit_condition( redirect_rule_details: tuple[RulePriority | None, RuleName | None], ) -> None: audit_eligibility_cohorts, audit_eligibility_cohort_groups = [], [] - audit_filter_rule, audit_suitability_rule, audit_redirect_rule = None, None, None + audit_filter_rule, audit_suitability_rule = None, None best_active_iteration = best_results[0] best_candidate = best_results[1] best_cohort_results = best_results[2] @@ -88,10 +88,7 @@ def append_audit_condition( audit_filter_rule = AuditContext.create_audit_filter_rule(best_candidate, result) audit_suitability_rule = AuditContext.create_audit_suitability_rule(best_candidate, result) - if best_candidate and best_candidate.status and best_candidate.status.name == Status.actionable.name: - audit_redirect_rule = AuditRedirectRule( - rule_priority=str(redirect_rule_details[0]), rule_name=redirect_rule_details[1] - ) + audit_redirect_rule = AuditContext.get_audit_redirect_rule(best_candidate, redirect_rule_details) audit_actions = AuditContext.create_audit_actions(suggested_actions) @@ -102,7 +99,7 @@ def append_audit_condition( iteration_version=best_active_iteration.version if best_active_iteration else None, condition_name=condition_name, status=best_candidate.status.name if best_candidate and best_candidate.status else None, - status_text=best_candidate.status.name if best_candidate and best_candidate.status else None, + status_text=best_candidate.status.get_status_text(condition_name) if best_candidate else None, eligibility_cohorts=audit_eligibility_cohorts, eligibility_cohort_groups=audit_eligibility_cohort_groups, filter_rules=audit_filter_rule, @@ -113,6 +110,17 @@ def append_audit_condition( g.audit_log.response.condition.append(audit_condition) + @staticmethod + def get_audit_redirect_rule( + best_candidate: IterationResult | None, redirect_rule_details: tuple[RulePriority | None, RuleName | None] + ) -> AuditRedirectRule | None: + audit_redirect_rule = None + if best_candidate and best_candidate.status and best_candidate.status.name == Status.actionable.name: + audit_redirect_rule = AuditRedirectRule( + rule_priority=str(redirect_rule_details[0]), rule_name=redirect_rule_details[1] + ) + return audit_redirect_rule + @staticmethod def add_response_details(response_id: UUID, last_updated: datetime) -> None: g.audit_log.response.response_id = response_id diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility.py index bad948361..15934880b 100644 --- a/src/eligibility_signposting_api/model/eligibility.py +++ b/src/eligibility_signposting_api/model/eligibility.py @@ -24,6 +24,8 @@ UrlLink = NewType("UrlLink", HttpUrl) UrlLabel = NewType("UrlLabel", str) +StatusText = NewType("StatusText", str) + class RuleType(StrEnum): filter = "F" @@ -65,6 +67,14 @@ def best(*statuses: Status) -> Status: """ return max(statuses) + def get_status_text(self, condition_name: ConditionName) -> StatusText: + status_to_text_mapping = { + self.not_eligible: lambda: StatusText("We do not believe you can have it"), + self.not_actionable: lambda: StatusText(f"You should have the {condition_name} vaccine"), + self.actionable: lambda: StatusText(f"You should have the {condition_name} vaccine"), + } + return status_to_text_mapping.get(self, lambda: StatusText("Unknown status provided"))() + @dataclass class Reason: @@ -90,6 +100,7 @@ class Condition: condition_name: ConditionName status: Status cohort_results: list[CohortGroupResult] + status_text: StatusText actions: list[SuggestedAction] | None = None diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index a64a8b878..43c832719 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from eligibility_signposting_api.model.rules import ( ActionsMapper, + CampaignConfig, CampaignID, CampaignVersion, Iteration, @@ -67,9 +68,14 @@ def campaigns_grouped_by_condition_name( ) -> Iterator[tuple[eligibility.ConditionName, list[rules.CampaignConfig]]]: """Generator that yields campaign groups filtered by condition names and campaign category.""" - allowed_types = ( - {"V", "S"} if category == "ALL" else {category[0]} if category in {"VACCINATIONS", "SCREENING"} else set() - ) + mapping = { + "ALL": {"V", "S"}, + "VACCINATIONS": {"V"}, + "SCREENING": {"S"}, + } + + allowed_types = mapping.get(category, set()) + filter_all_conditions = "ALL" in conditions for condition_name, campaign_group in groupby( @@ -159,23 +165,7 @@ def evaluate_eligibility( best_campaign_version: CampaignVersion | None best_cohort_results: dict[str, CohortGroupResult] | None - iteration_results: dict[ - str, tuple[Iteration, IterationResult, CampaignID, CampaignVersion, dict[str, CohortGroupResult]] - ] = {} - - for cc in campaign_group: - active_iteration = cc.current_iteration - cohort_results: dict[str, CohortGroupResult] = self.get_cohort_results(active_iteration) - - # Determine Result between cohorts - get the best - status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results) - iteration_results[active_iteration.name] = ( - active_iteration, - IterationResult(status, best_cohorts, actions), - cc.id, - cc.version, - cohort_results, - ) + iteration_results = self.get_iteration_results(actions, campaign_group) # Determine results between iterations - get the best if iteration_results: @@ -229,6 +219,27 @@ def evaluate_eligibility( final_result = self.build_condition_results(condition_results) return eligibility.EligibilityStatus(conditions=final_result) + def get_iteration_results( + self, actions: list[SuggestedAction] | None, campaign_group: list[CampaignConfig] + ) -> dict[str, tuple[Iteration, IterationResult, CampaignID, CampaignVersion, dict[str, CohortGroupResult]]]: + iteration_results: dict[ + str, tuple[Iteration, IterationResult, CampaignID, CampaignVersion, dict[str, CohortGroupResult]] + ] = {} + for cc in campaign_group: + active_iteration = cc.current_iteration + cohort_results: dict[str, CohortGroupResult] = self.get_cohort_results(active_iteration) + + # Determine Result between cohorts - get the best + status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results) + iteration_results[active_iteration.name] = ( + active_iteration, + IterationResult(status, best_cohorts, actions), + cc.id, + cc.version, + cohort_results, + ) + return iteration_results + def handle_redirect_rules( self, best_active_iteration: Iteration ) -> tuple[list[SuggestedAction] | None, RulePriority | None, RuleName | None]: @@ -311,6 +322,7 @@ def build_condition_results(condition_results: dict[ConditionName, IterationResu status=active_iteration_result.status, cohort_results=list(deduplicated_cohort_results), actions=condition_results[condition_name].actions, + status_text=active_iteration_result.status.get_status_text(condition_name), ) ) return conditions @@ -326,9 +338,7 @@ def is_eligible_by_filter_rules( sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, filter_rules), key=priority_getter) for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): - status, group_inclusion_reasons, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group( - rule_group - ) + status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(rule_group) if status.is_exclusion: if cohort.cohort_label is not None: cohort_results[cohort.cohort_label] = CohortGroupResult( @@ -355,9 +365,7 @@ def evaluate_suppression_rules( sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, suppression_rules), key=priority_getter) for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): - status, group_inclusion_reasons, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group( - rule_group - ) + status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(rule_group) if status.is_exclusion: is_actionable = False suppression_reasons.extend(group_exclusion_reasons) @@ -381,9 +389,9 @@ def evaluate_suppression_rules( def evaluate_rules_priority_group( self, rules_group: Iterator[rules.IterationRule] - ) -> tuple[eligibility.Status, list[eligibility.Reason], list[eligibility.Reason], bool]: + ) -> tuple[eligibility.Status, list[eligibility.Reason], bool]: is_rule_stop = False - inclusion_reasons, exclusion_reasons = [], [] + exclusion_reasons = [] best_status = eligibility.Status.not_eligible for rule in rules_group: @@ -395,9 +403,8 @@ def evaluate_rules_priority_group( exclusion_reasons.append(reason) else: best_status = eligibility.Status.actionable - inclusion_reasons.append(reason) - return best_status, inclusion_reasons, exclusion_reasons, is_rule_stop + return best_status, exclusion_reasons, is_rule_stop @staticmethod def get_actions_from_comms(action_mapper: ActionsMapper, comms: str) -> list[SuggestedAction] | None: diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 8875ae5e9..1ce27ca39 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -113,7 +113,7 @@ def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibi suggestions = ProcessedSuggestion( # pyright: ignore[reportCallIssue] condition=eligibility.ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue] status=STATUS_MAPPING[condition.status], - statusText=eligibility.StatusText(f"{condition.status}"), # pyright: ignore[reportCallIssue] + statusText=eligibility.StatusText(condition.status_text), # pyright: ignore[reportCallIssue] eligibilityCohorts=build_eligibility_cohorts(condition), # pyright: ignore[reportCallIssue] suitabilityRules=build_suitability_results(condition), # pyright: ignore[reportCallIssue] actions=build_actions(condition), diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 5356eed59..229d8652c 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -85,7 +85,7 @@ def test_not_base_eligible( ], "actions": [], "suitabilityRules": [], - "statusText": "Status.not_eligible", + "statusText": "We do not believe you can have it", } ] ), @@ -128,7 +128,7 @@ def test_not_eligible_by_rule( ], "actions": [], "suitabilityRules": [], - "statusText": "Status.not_eligible", + "statusText": "We do not believe you can have it", } ] ), @@ -177,7 +177,7 @@ def test_not_actionable( "ruleType": "S", } ], - "statusText": "Status.not_actionable", + "statusText": "You should have the RSV vaccine", } ] ), @@ -228,7 +228,7 @@ def test_actionable( } ], "suitabilityRules": [], - "statusText": "Status.actionable", + "statusText": "You should have the RSV vaccine", } ] ), @@ -273,7 +273,7 @@ def test_not_eligible_by_rule_when_only_magic_cohort_is_present( ], "actions": [], "suitabilityRules": [], - "statusText": "Status.not_eligible", + "statusText": "We do not believe you can have it", } ] ), @@ -322,7 +322,7 @@ def test_not_actionable_when_only_magic_cohort_is_present( "ruleType": "S", } ], - "statusText": "Status.not_actionable", + "statusText": "You should have the COVID vaccine", } ] ), @@ -373,7 +373,7 @@ def test_actionable_when_only_magic_cohort_is_present( } ], "suitabilityRules": [], - "statusText": "Status.actionable", + "statusText": "You should have the COVID vaccine", } ] ), @@ -412,7 +412,7 @@ def test_not_base_eligible( "eligibilityCohorts": [], "actions": [], "suitabilityRules": [], - "statusText": "Status.not_eligible", + "statusText": "We do not believe you can have it", } ] ), @@ -449,7 +449,7 @@ def test_not_eligible_by_rule( "eligibilityCohorts": [], "actions": [], "suitabilityRules": [], - "statusText": "Status.not_eligible", + "statusText": "We do not believe you can have it", } ] ), @@ -492,7 +492,7 @@ def test_not_actionable( "ruleType": "S", } ], - "statusText": "Status.not_actionable", + "statusText": "You should have the FLU vaccine", } ] ), @@ -537,7 +537,7 @@ def test_actionable( } ], "suitabilityRules": [], - "statusText": "Status.actionable", + "statusText": "You should have the FLU vaccine", } ] ), @@ -573,7 +573,7 @@ def test_actionable_no_actions( "status": "Actionable", "eligibilityCohorts": [], "suitabilityRules": [], - "statusText": "Status.actionable", + "statusText": "You should have the FLU vaccine", } ] ), diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 99c936e08..0e9308796 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -235,7 +235,7 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i "iterationVersion": campaign_config.iterations[0].version, "conditionName": campaign_config.target, "status": "not_actionable", - "statusText": "not_actionable", + "statusText": f"You should have the {campaign_config.target} vaccine", "eligibilityCohorts": [{"cohortCode": "cohort1", "cohortStatus": "not_actionable"}], "eligibilityCohortGroups": [ { @@ -461,7 +461,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # "iterationVersion": rsv_campaign.iterations[0].version, "conditionName": rsv_campaign.target, "status": "not_eligible", - "statusText": "not_eligible", + "statusText": "We do not believe you can have it", "eligibilityCohorts": [{"cohortCode": "cohort_label1", "cohortStatus": "not_eligible"}], "eligibilityCohortGroups": [ { @@ -482,7 +482,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # "iterationVersion": covid_campaign.iterations[0].version, "conditionName": covid_campaign.target, "status": "not_actionable", - "statusText": "not_actionable", + "statusText": f"You should have the {covid_campaign.target} vaccine", "eligibilityCohorts": [{"cohortCode": "cohort_label2", "cohortStatus": "not_actionable"}], "eligibilityCohortGroups": [ { @@ -507,7 +507,7 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # "iterationVersion": flu_campaign.iterations[0].version, "conditionName": flu_campaign.target, "status": "actionable", - "statusText": "actionable", + "statusText": f"You should have the {flu_campaign.target} vaccine", "eligibilityCohorts": [{"cohortCode": "cohort_label3", "cohortStatus": "actionable"}], "eligibilityCohortGroups": [ { diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py index 2ee9aafdc..25897bd9a 100644 --- a/tests/unit/audit/test_audit_context.py +++ b/tests/unit/audit/test_audit_context.py @@ -149,7 +149,7 @@ def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): assert cond.iteration_id == iteration.id assert cond.iteration_version == iteration.version assert cond.status == best_results[1].status.name - assert cond.status_text == best_results[1].status.name + assert cond.status_text == "You should have the Condition1 vaccine" assert cond.actions == expected_audit_action assert cond.action_rule.rule_priority == "1" assert cond.action_rule.rule_name == "RedirectRuleName1" diff --git a/tests/unit/model/test_status.py b/tests/unit/model/test_status.py new file mode 100644 index 000000000..72ba73b62 --- /dev/null +++ b/tests/unit/model/test_status.py @@ -0,0 +1,40 @@ +from eligibility_signposting_api.model.eligibility import ConditionName, Status, StatusText + + +class TestStatus: + def test_ordering(self): + assert Status.not_eligible < Status.not_actionable + assert Status.not_actionable < Status.actionable + assert Status.actionable > Status.not_actionable + assert Status.not_actionable > Status.not_eligible + assert Status.not_eligible == Status.not_eligible + + def test_is_exclusion(self): + assert Status.not_eligible.is_exclusion + assert Status.not_actionable.is_exclusion + assert not Status.actionable.is_exclusion + + def test_worst_status(self): + assert Status.worst(Status.not_eligible, Status.actionable) == Status.not_eligible + assert Status.worst(Status.actionable, Status.not_actionable) == Status.not_actionable + assert Status.worst(Status.not_eligible, Status.not_actionable, Status.actionable) == Status.not_eligible + assert Status.worst(Status.actionable) == Status.actionable + + def test_best_status(self): + assert Status.best(Status.not_eligible, Status.actionable) == Status.actionable + assert Status.best(Status.actionable, Status.not_actionable) == Status.actionable + assert Status.best(Status.not_eligible, Status.not_actionable, Status.actionable) == Status.actionable + assert Status.best(Status.not_eligible) == Status.not_eligible + + def test_get_status_text(self): + assert Status.not_eligible.get_status_text(ConditionName("COVID")) == StatusText( + "We do not believe you can have it" + ) + + assert Status.not_actionable.get_status_text(ConditionName("FLU")) == StatusText( + "You should have the FLU vaccine" + ) + + assert Status.actionable.get_status_text(ConditionName("COVID")) == StatusText( + "You should have the COVID vaccine" + ) From 5889dcd187227f479376097b352bf0a93faa8d6c Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:49:18 +0100 Subject: [PATCH 03/61] ELI-318: Adds application/fhir+json as valid mime type in Mangum (#235) --- src/eligibility_signposting_api/app.py | 1 + .../lambda/test_app_running_as_lambda.py | 49 +++++++++---------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index 9073bb779..26be07c99 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -29,6 +29,7 @@ def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] app = create_app() app.debug = config()["log_level"] == logging.DEBUG handler = Mangum(WsgiToAsgi(app), lifespan="off") + handler.config["text_mime_types"].append("application/fhir+json") return handler(event, context) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 0e9308796..b157fe44f 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -124,37 +124,34 @@ def test_install_and_call_flask_lambda_with_unknown_nhs_number( timeout=10, ) - decoded_body_bytes = base64.b64decode(response.text) - decoded_body_json = json.loads(decoded_body_bytes.decode("utf-8")) - assert_that( response, is_response() .with_status_code(HTTPStatus.NOT_FOUND) - .with_headers(has_entries({"Content-Type": "application/fhir+json"})), - ) - - # Then - assert_that( - decoded_body_json, - has_entries( - resourceType=equal_to("OperationOutcome"), - issue=contains_exactly( + .with_headers(has_entries({"Content-Type": "application/fhir+json"})) + .and_body( + is_json_that( has_entries( - severity="error", - code="processing", - diagnostics=f"NHS Number '{nhs_number!s}' was not recognised by the Eligibility Signposting API", - details={ - "coding": [ - { - "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", - "code": "REFERENCE_NOT_FOUND", - "display": "The given NHS number was not found in our datasets. " - "This could be because the number is incorrect or " - "some other reason we cannot process that number.", - } - ] - }, + resourceType="OperationOutcome", + issue=contains_exactly( + has_entries( + severity="error", + code="processing", + diagnostics=f"NHS Number '{nhs_number!s}' was not " + f"recognised by the Eligibility Signposting API", + details={ + "coding": [ + { + "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + "code": "REFERENCE_NOT_FOUND", + "display": "The given NHS number was not found in our datasets. " + "This could be because the number is incorrect or " + "some other reason we cannot process that number.", + } + ] + }, + ) + ), ) ), ), From 14d6a5ec9cc71b193e68ffc7169fdb3e3a1c2ee6 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:20:39 +0100 Subject: [PATCH 04/61] ELI-331: Mask PII/GDPR info (#239) * ELI-331: Mask PII/GDPR info * ELI-331: Fix formatting * ELI-331: Fix format and lint --- .../modules/api_gateway/cloudwatch.tf | 93 +++++++++++++++++++ .../services/eligibility_services.py | 7 ++ .../lambda/test_app_running_as_lambda.py | 4 - 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/infrastructure/modules/api_gateway/cloudwatch.tf b/infrastructure/modules/api_gateway/cloudwatch.tf index 272a1c465..a124c8434 100644 --- a/infrastructure/modules/api_gateway/cloudwatch.tf +++ b/infrastructure/modules/api_gateway/cloudwatch.tf @@ -8,3 +8,96 @@ resource "aws_cloudwatch_log_group" "api_gateway" { prevent_destroy = false } } + +resource "aws_cloudwatch_log_data_protection_policy" "api_gateway_data_protection" { + log_group_name = aws_cloudwatch_log_group.api_gateway.name + policy_document = jsonencode({ + Name = "data-protection-policy" + Version = "2021-06-01" + Statement = [ + { + Sid = "MaskSensitiveData" + Effect = "Deny" + Principal = { "AWS" : "*" } + Action = "cloudwatch:PutLogEvents" + Resource = "*" + DataIdentifier = [ + "arn:aws:dataprotection::aws:data-identifier/DateOfBirth", + "arn:aws:dataprotection::aws:data-identifier/UkPostcode", + "arn:aws:dataprotection::aws:data-identifier/Custom:UkPostcodeSector", + "arn:aws:dataprotection::aws:data-identifier/Custom:GpPracticeCode", + "arn:aws:dataprotection::aws:data-identifier/Custom:13QFlag", + "arn:aws:dataprotection::aws:data-identifier/Custom:CareHomeFlag", + "arn:aws:dataprotection::aws:data-identifier/Custom:DEFlag", + "arn:aws:dataprotection::aws:data-identifier/Custom:RemovalReasonCode", + "arn:aws:dataprotection::aws:data-identifier/Custom:ValidDosesCount", + "arn:aws:dataprotection::aws:data-identifier/Custom:InvalidDosesCount", + "arn:aws:dataprotection::aws:data-identifier/Custom:LastSuccessfulDate", + "arn:aws:dataprotection::aws:data-identifier/Custom:LastValidDoseDate", + "arn:aws:dataprotection::aws:data-identifier/Custom:CohortLabel" + + ] + Operation = { + "cloudwatch:Mask" = {} + } + }, + ] + CustomDataIdentifier = [ + { + Name = "UkPostcodeSector" + Regex = "[A-Z]{1,2}[0-9R-9][0A-Z]? ?[0-9]" + Severity = "High" + }, + { + Name = "GpPracticeCode" + Regex = "GP_PRACTICE[\\s\\\"':=]*([A-Z][0-9]{5})" + Severity = "High" + }, + { + Name = "13QFlag" + Regex = "13Q_FLAG[\\s\\\"':=]*[YN]" + Severity = "High" + }, + { + Name = "CareHomeFlag" + Regex = "CARE_HOME_FLAG[\\s\\\"':=]*[YN]" + Severity = "High" + }, + { + Name = "DEFlag" + Regex = "DE_FLAG[\\s\\\"':=]*[YN]" + Severity = "High" + }, + { + Name = "RemovalReasonCode" + Regex = "REMOVAL_REASON_CODE[\\s\\\"':=]*([A-Z]{3})" + Severity = "High" + }, + { + Name = "ValidDosesCount" + Regex = "VALID_DOSES_COUNT[\\s\\\"':=]*([0-9]{1,2}|100)" + Severity = "High" + }, + { + Name = "InvalidDosesCount" + Regex = "INVALID_DOSES_COUNT[\\s\\\"':=]*([0-9]{1,2}|100)" + Severity = "High" + }, + { + Name = "LastSuccessfulDate" + Regex = "LAST_SUCCESSFUL_DATE[\\s\\\"':=]*([0-9]{8})" + Severity = "High" + }, + { + Name = "LastValidDoseDate" + Regex = "LAST_VALID_DOSE_DATE[\\s\\\"':=]*([0-9]{8})" + Severity = "High" + }, + { + Name = "CohortLabel" + Regex = "COHORT_LABEL[\\s\\\"':=]*([A-Za-z0-9_ -]{1,100})" + Severity = "High" + } + ] + }) +} diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 48586290b..e8db56d69 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -51,6 +51,13 @@ def get_eligibility_status( "nhs_number": nhs_number, }, ) + + if person_data and person_data[0] and campaign_configs and campaign_configs[0]: + logger.info("Test data masking person data: %r", person_data[0]) + logger.info( + "Test data masking campaign config data: %r", campaign_configs[0].model_dump(by_alias=True) + ) + except NotFoundError as e: raise UnknownPersonError from e else: diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index b157fe44f..3328a8939 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -1,4 +1,3 @@ -import base64 import json import logging from http import HTTPStatus @@ -69,7 +68,6 @@ def test_install_and_call_lambda_flask( Payload=json.dumps(request_payload), LogType="Tail", ) - log_output = base64.b64decode(response["LogResult"]).decode("utf-8") # Then assert_that(response, has_entries(StatusCode=HTTPStatus.OK)) @@ -80,8 +78,6 @@ def test_install_and_call_lambda_flask( has_entries(statusCode=HTTPStatus.OK, body=is_json_that(has_key("processedSuggestions"))), ) - assert_that(log_output, contains_string("person_data")) - def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, From b465617bfd2784b39df79d9bf570bac880870374 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:22:52 +0100 Subject: [PATCH 05/61] Revert "ELI-331: Mask PII/GDPR info (#239)" (#240) This reverts commit 14d6a5ec9cc71b193e68ffc7169fdb3e3a1c2ee6. --- .../modules/api_gateway/cloudwatch.tf | 93 ------------------- .../services/eligibility_services.py | 7 -- .../lambda/test_app_running_as_lambda.py | 4 + 3 files changed, 4 insertions(+), 100 deletions(-) diff --git a/infrastructure/modules/api_gateway/cloudwatch.tf b/infrastructure/modules/api_gateway/cloudwatch.tf index a124c8434..272a1c465 100644 --- a/infrastructure/modules/api_gateway/cloudwatch.tf +++ b/infrastructure/modules/api_gateway/cloudwatch.tf @@ -8,96 +8,3 @@ resource "aws_cloudwatch_log_group" "api_gateway" { prevent_destroy = false } } - -resource "aws_cloudwatch_log_data_protection_policy" "api_gateway_data_protection" { - log_group_name = aws_cloudwatch_log_group.api_gateway.name - policy_document = jsonencode({ - Name = "data-protection-policy" - Version = "2021-06-01" - Statement = [ - { - Sid = "MaskSensitiveData" - Effect = "Deny" - Principal = { "AWS" : "*" } - Action = "cloudwatch:PutLogEvents" - Resource = "*" - DataIdentifier = [ - "arn:aws:dataprotection::aws:data-identifier/DateOfBirth", - "arn:aws:dataprotection::aws:data-identifier/UkPostcode", - "arn:aws:dataprotection::aws:data-identifier/Custom:UkPostcodeSector", - "arn:aws:dataprotection::aws:data-identifier/Custom:GpPracticeCode", - "arn:aws:dataprotection::aws:data-identifier/Custom:13QFlag", - "arn:aws:dataprotection::aws:data-identifier/Custom:CareHomeFlag", - "arn:aws:dataprotection::aws:data-identifier/Custom:DEFlag", - "arn:aws:dataprotection::aws:data-identifier/Custom:RemovalReasonCode", - "arn:aws:dataprotection::aws:data-identifier/Custom:ValidDosesCount", - "arn:aws:dataprotection::aws:data-identifier/Custom:InvalidDosesCount", - "arn:aws:dataprotection::aws:data-identifier/Custom:LastSuccessfulDate", - "arn:aws:dataprotection::aws:data-identifier/Custom:LastValidDoseDate", - "arn:aws:dataprotection::aws:data-identifier/Custom:CohortLabel" - - ] - Operation = { - "cloudwatch:Mask" = {} - } - }, - ] - CustomDataIdentifier = [ - { - Name = "UkPostcodeSector" - Regex = "[A-Z]{1,2}[0-9R-9][0A-Z]? ?[0-9]" - Severity = "High" - }, - { - Name = "GpPracticeCode" - Regex = "GP_PRACTICE[\\s\\\"':=]*([A-Z][0-9]{5})" - Severity = "High" - }, - { - Name = "13QFlag" - Regex = "13Q_FLAG[\\s\\\"':=]*[YN]" - Severity = "High" - }, - { - Name = "CareHomeFlag" - Regex = "CARE_HOME_FLAG[\\s\\\"':=]*[YN]" - Severity = "High" - }, - { - Name = "DEFlag" - Regex = "DE_FLAG[\\s\\\"':=]*[YN]" - Severity = "High" - }, - { - Name = "RemovalReasonCode" - Regex = "REMOVAL_REASON_CODE[\\s\\\"':=]*([A-Z]{3})" - Severity = "High" - }, - { - Name = "ValidDosesCount" - Regex = "VALID_DOSES_COUNT[\\s\\\"':=]*([0-9]{1,2}|100)" - Severity = "High" - }, - { - Name = "InvalidDosesCount" - Regex = "INVALID_DOSES_COUNT[\\s\\\"':=]*([0-9]{1,2}|100)" - Severity = "High" - }, - { - Name = "LastSuccessfulDate" - Regex = "LAST_SUCCESSFUL_DATE[\\s\\\"':=]*([0-9]{8})" - Severity = "High" - }, - { - Name = "LastValidDoseDate" - Regex = "LAST_VALID_DOSE_DATE[\\s\\\"':=]*([0-9]{8})" - Severity = "High" - }, - { - Name = "CohortLabel" - Regex = "COHORT_LABEL[\\s\\\"':=]*([A-Za-z0-9_ -]{1,100})" - Severity = "High" - } - ] - }) -} diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index e8db56d69..48586290b 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -51,13 +51,6 @@ def get_eligibility_status( "nhs_number": nhs_number, }, ) - - if person_data and person_data[0] and campaign_configs and campaign_configs[0]: - logger.info("Test data masking person data: %r", person_data[0]) - logger.info( - "Test data masking campaign config data: %r", campaign_configs[0].model_dump(by_alias=True) - ) - except NotFoundError as e: raise UnknownPersonError from e else: diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 3328a8939..b157fe44f 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -1,3 +1,4 @@ +import base64 import json import logging from http import HTTPStatus @@ -68,6 +69,7 @@ def test_install_and_call_lambda_flask( Payload=json.dumps(request_payload), LogType="Tail", ) + log_output = base64.b64decode(response["LogResult"]).decode("utf-8") # Then assert_that(response, has_entries(StatusCode=HTTPStatus.OK)) @@ -78,6 +80,8 @@ def test_install_and_call_lambda_flask( has_entries(statusCode=HTTPStatus.OK, body=is_json_that(has_key("processedSuggestions"))), ) + assert_that(log_output, contains_string("person_data")) + def test_install_and_call_flask_lambda_over_http( persisted_person: NHSNumber, From a9194c5b6a1a64b96243f1324fac5d5709ea6292 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Thu, 17 Jul 2025 11:13:53 +0100 Subject: [PATCH 06/61] Feature/eli 295 generic text for not eligible xrules (#238) * WIP: drafting out X and Y rules. * WIP: updated config with x and y rule idea. * WIP: Stub out x and y rules impl * WIP: stubbing out impl. * Refactored action support functions and renamed vars * WIP: Added X/Y Rule logic and test. * Added tests for eligible and actionable actions. * WIP: Added more tests for X and Y rule scenarios. * WIP: flaky tests. * WIP: Fixed failing tests for empty actions. * WIP: added audit record check to tests. * WIP: file format and added audit rule priority and name test. * Working tests. Refactored some audit logic. * Minor refactor * Addressed linting issues * WIP: fixed failing unit tests. * Format. * Added tests. * File format --------- Co-authored-by: Robert --- .../audit/audit_context.py | 30 +- .../model/eligibility.py | 2 + .../model/rules.py | 4 + .../calculators/eligibility_calculator.py | 65 +- .../services/calculators/rule_calculator.py | 2 + tests/fixtures/builders/model/rule.py | 24 + tests/test_data/test_config/test_config.json | 29 +- .../test_eligibility_calculator.py | 677 +++++++++++++++++- 8 files changed, 793 insertions(+), 40 deletions(-) diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index 18afc6ecc..b54276cbb 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -63,10 +63,10 @@ def append_audit_condition( condition_name: ConditionName, best_results: tuple[Iteration | None, IterationResult | None, dict[str, CohortGroupResult] | None], campaign_details: tuple[CampaignID | None, CampaignVersion | None], - redirect_rule_details: tuple[RulePriority | None, RuleName | None], + action_rule_details: tuple[RulePriority | None, RuleName | None], ) -> None: - audit_eligibility_cohorts, audit_eligibility_cohort_groups = [], [] - audit_filter_rule, audit_suitability_rule = None, None + audit_eligibility_cohorts, audit_eligibility_cohort_groups, audit_actions = [], [], [] + audit_filter_rule, audit_suitability_rule, audit_action_rule = None, None, None best_active_iteration = best_results[0] best_candidate = best_results[1] best_cohort_results = best_results[2] @@ -88,7 +88,7 @@ def append_audit_condition( audit_filter_rule = AuditContext.create_audit_filter_rule(best_candidate, result) audit_suitability_rule = AuditContext.create_audit_suitability_rule(best_candidate, result) - audit_redirect_rule = AuditContext.get_audit_redirect_rule(best_candidate, redirect_rule_details) + audit_action_rule = AuditContext.add_rule_name_and_priority_to_audit(best_candidate, action_rule_details) audit_actions = AuditContext.create_audit_actions(suggested_actions) @@ -104,22 +104,26 @@ def append_audit_condition( eligibility_cohort_groups=audit_eligibility_cohort_groups, filter_rules=audit_filter_rule, suitability_rules=audit_suitability_rule, - action_rule=audit_redirect_rule, + action_rule=audit_action_rule, actions=audit_actions, ) g.audit_log.response.condition.append(audit_condition) @staticmethod - def get_audit_redirect_rule( - best_candidate: IterationResult | None, redirect_rule_details: tuple[RulePriority | None, RuleName | None] + def add_rule_name_and_priority_to_audit( + best_candidate: IterationResult | None, + action_rule_details: tuple[RulePriority | None, RuleName | None] | None, ) -> AuditRedirectRule | None: - audit_redirect_rule = None - if best_candidate and best_candidate.status and best_candidate.status.name == Status.actionable.name: - audit_redirect_rule = AuditRedirectRule( - rule_priority=str(redirect_rule_details[0]), rule_name=redirect_rule_details[1] - ) - return audit_redirect_rule + audit_action_rule = None + if best_candidate and best_candidate.status: + if action_rule_details is None or (action_rule_details[0] is None and action_rule_details[1] is None): + audit_action_rule = None + else: + audit_action_rule = AuditRedirectRule( + rule_priority=str(action_rule_details[0]), rule_name=action_rule_details[1] + ) + return audit_action_rule @staticmethod def add_response_details(response_id: UUID, last_updated: datetime) -> None: diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility.py index 15934880b..61ca94a1d 100644 --- a/src/eligibility_signposting_api/model/eligibility.py +++ b/src/eligibility_signposting_api/model/eligibility.py @@ -31,6 +31,8 @@ class RuleType(StrEnum): filter = "F" suppression = "S" redirect = "R" + not_eligible_actions = "X" + not_actionable_actions = "Y" @total_ordering diff --git a/src/eligibility_signposting_api/model/rules.py b/src/eligibility_signposting_api/model/rules.py index 541db6263..b7409baea 100644 --- a/src/eligibility_signposting_api/model/rules.py +++ b/src/eligibility_signposting_api/model/rules.py @@ -42,6 +42,8 @@ class RuleType(StrEnum): filter = "F" suppression = "S" redirect = "R" + not_eligible_actions = "X" + not_actionable_actions = "Y" class RuleOperator(StrEnum): @@ -153,6 +155,8 @@ class Iteration(BaseModel): approval_maximum: int | None = Field(None, alias="ApprovalMaximum") type: Literal["A", "M", "S", "O"] = Field(..., alias="Type") default_comms_routing: str = Field(..., alias="DefaultCommsRouting") + default_not_eligible_routing: str = Field(..., alias="DefaultNotEligibleRouting") + default_not_actionable_routing: str = Field(..., alias="DefaultNotActionableRouting") iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts") iteration_rules: list[IterationRule] = Field(..., alias="IterationRules") actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper") diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 43c832719..fa586a4e7 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -19,6 +19,7 @@ IterationCohort, RuleName, RulePriority, + RuleType, ) from wireup import service @@ -140,15 +141,20 @@ def get_rules_by_type( return filter_rules, suppression_rules @staticmethod - def get_redirect_rules( - active_iteration: Iteration, - ) -> tuple[tuple[rules.IterationRule, ...], ActionsMapper, str]: - redirect_rules = tuple( - rule for rule in active_iteration.iteration_rules if rule.type in rules.RuleType.redirect - ) - default_comms = active_iteration.default_comms_routing + def get_action_rules_components( + active_iteration: Iteration, rule_type: RuleType + ) -> tuple[tuple[rules.IterationRule, ...], ActionsMapper, str | None]: + action_rules = tuple(rule for rule in active_iteration.iteration_rules if rule.type in rule_type) + + routing_map = { + rules.RuleType.redirect: active_iteration.default_comms_routing, + rules.RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing, + rules.RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing, + } + + default_comms = routing_map.get(rule_type) action_mapper = active_iteration.actions_mapper - return redirect_rules, action_mapper, default_comms + return action_rules, action_mapper, default_comms def evaluate_eligibility( self, include_actions: str, conditions: list[str], category: str @@ -156,7 +162,7 @@ def evaluate_eligibility( include_actions_flag = include_actions.upper() == "Y" condition_results: dict[ConditionName, IterationResult] = {} actions: list[SuggestedAction] | None = [] - redirect_rule_priority, redirect_rule_name = None, None + action_rule_priority, action_rule_name = None, None for condition_name, campaign_group in self.campaigns_grouped_by_condition_name(conditions, category): best_active_iteration: Iteration | None @@ -188,16 +194,26 @@ def evaluate_eligibility( condition_results[condition_name] = best_candidate - if best_candidate.status == Status.actionable and best_active_iteration is not None: + status_to_rule_type = { + Status.actionable: rules.RuleType.redirect, + Status.not_eligible: rules.RuleType.not_eligible_actions, + Status.not_actionable: rules.RuleType.not_actionable_actions, + } + + if best_candidate.status in status_to_rule_type and best_active_iteration is not None: if include_actions_flag: - actions, matched_r_rule_priority, matched_r_rule_name = self.handle_redirect_rules( - best_active_iteration + rule_type = status_to_rule_type[best_candidate.status] + actions, matched_action_rule_priority, matched_action_rule_name = self.handle_action_rules( + best_active_iteration, rule_type ) - redirect_rule_name = matched_r_rule_name - redirect_rule_priority = matched_r_rule_priority + action_rule_name = matched_action_rule_name + action_rule_priority = matched_action_rule_priority else: actions = None + else: + actions = None + if best_candidate.status in (Status.not_eligible, Status.not_actionable) and not include_actions_flag: actions = None @@ -212,7 +228,7 @@ def evaluate_eligibility( condition_name, (best_active_iteration, best_candidate, best_cohort_results), (best_campaign_id, best_campaign_version), - (redirect_rule_priority, redirect_rule_name), + (action_rule_priority, action_rule_name), ) # Consolidate all the results and return @@ -240,15 +256,16 @@ def get_iteration_results( ) return iteration_results - def handle_redirect_rules( - self, best_active_iteration: Iteration + def handle_action_rules( + self, best_active_iteration: Iteration, rule_type: RuleType ) -> tuple[list[SuggestedAction] | None, RulePriority | None, RuleName | None]: - redirect_rules, action_mapper, default_comms = self.get_redirect_rules(best_active_iteration) + action_rules, action_mapper, default_comms = self.get_action_rules_components(best_active_iteration, rule_type) priority_getter = attrgetter("priority") - sorted_rules_by_priority = sorted(redirect_rules, key=priority_getter) + sorted_rules_by_priority = sorted(action_rules, key=priority_getter) + + actions: list[SuggestedAction] | None = self.get_actions_from_comms(action_mapper, default_comms) # pyright: ignore[reportArgumentType] - actions: list[SuggestedAction] | None = self.get_actions_from_comms(action_mapper, default_comms) - matched_redirect_rule_priority, matched_redirect_rule_name = None, None + matched_action_rule_priority, matched_action_rule_name = None, None for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): rule_group_list = list(rule_group) matcher_matched_list = [ @@ -261,11 +278,11 @@ def handle_redirect_rules( rule_actions = self.get_actions_from_comms(action_mapper, comms_routing) if rule_actions and len(rule_actions) > 0: actions = rule_actions - matched_redirect_rule_priority = rule_group_list[0].priority - matched_redirect_rule_name = rule_group_list[0].name + matched_action_rule_priority = rule_group_list[0].priority + matched_action_rule_name = rule_group_list[0].name break - return actions, matched_redirect_rule_priority, matched_redirect_rule_name + return actions, matched_action_rule_priority, matched_action_rule_name def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, CohortGroupResult]: cohort_results: dict[str, CohortGroupResult] = {} diff --git a/src/eligibility_signposting_api/services/calculators/rule_calculator.py b/src/eligibility_signposting_api/services/calculators/rule_calculator.py index 145a1e89f..03641e3be 100644 --- a/src/eligibility_signposting_api/services/calculators/rule_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/rule_calculator.py @@ -84,6 +84,8 @@ def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status rules.RuleType.filter: eligibility.Status.not_eligible, rules.RuleType.suppression: eligibility.Status.not_actionable, rules.RuleType.redirect: eligibility.Status.actionable, + rules.RuleType.not_eligible_actions: eligibility.Status.not_eligible, + rules.RuleType.not_actionable_actions: eligibility.Status.not_actionable, }[self.rule.type] return status, str(reason), matcher_matched matcher.describe_mismatch(attribute_value, reason) diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index 5388b113b..f0d7010b4 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -173,3 +173,27 @@ class ICBRedirectRuleFactory(IterationRuleFactory): attribute_name = rules.RuleAttributeName("ICB") comparator = rules.RuleComparator("QE1") comms_routing = rules.CommsRouting("ActionCode1") + + +class ICBNonEligibleActionRuleFactory(IterationRuleFactory): + type = rules.RuleType.not_eligible_actions + name = rules.RuleName("In QE1") + description = rules.RuleDescription("In QE1") + priority = rules.RulePriority(20) + operator = rules.RuleOperator.equals + attribute_level = rules.RuleAttributeLevel.PERSON + attribute_name = rules.RuleAttributeName("ICB") + comparator = rules.RuleComparator("QE1") + comms_routing = rules.CommsRouting("ActionCode1") + + +class ICBNonActionableActionRuleFactory(IterationRuleFactory): + type = rules.RuleType.not_actionable_actions + name = rules.RuleName("In QE1") + description = rules.RuleDescription("In QE1") + priority = rules.RulePriority(20) + operator = rules.RuleOperator.equals + attribute_level = rules.RuleAttributeLevel.PERSON + attribute_name = rules.RuleAttributeName("ICB") + comparator = rules.RuleComparator("QE1") + comms_routing = rules.CommsRouting("ActionCode1") diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index ab2672df5..643cbce2a 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -16,11 +16,16 @@ { "ID": "id_100", "DefaultCommsRouting": "INTERNALCONTACTGP1", + "DefaultNotActionableRouting": "INTERNALCONTACTGP1", + "DefaultNotEligibleRouting": "INTERNALCONTACTGP1", "ActionsMapper": { "INTERNALCONTACTGP1": {"ExternalRoutingCode": "CONTACTGP","ActionDescription":"Contact GP Text1 description", "ActionType":"text1"}, "INTERNALCONTACTGP2": {"ExternalRoutingCode": "CONTACTGP","ActionDescription":"Contact GP Link description", "ActionType":"link", "UrlLink": "link123", "UrlLabel": "link label"}, "INTERNALTESCO": {"ExternalRoutingCode": "TESCO","ActionDescription":"Tesco description", "ActionType":"link", "UrlLink": "tesco link", "UrlLabel": "link label"}, - "INTERNALFINDWALKIN": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"} + "INTERNALFINDWALKIN": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"}, + + "XRULEID1": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"}, + "YRULEID1": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"} }, "IterationCohorts": [ { @@ -91,6 +96,28 @@ "Operator": ">", "Comparator": "19000101", "CommsRouting": "INTERNALCONTACTGP1|INTERNALTESCO" + }, + { + "Type": "X", + "Name": "Test X Rule for not eligible", + "Description": "Test X Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": ">", + "Comparator": "19000101", + "CommsRouting": "XRULEID1|INTERNALTESCO" + }, + { + "Type": "Y", + "Name": "Test Y Rule for not actionable", + "Description": "Test Y Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": ">", + "Comparator": "19000101", + "CommsRouting": "YRULEID1|INTERNALTESCO" } ], "Version": "1", diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index c93edb77c..baa81b8b7 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -3,10 +3,12 @@ import pytest from faker import Faker +from flask import Flask, g from freezegun import freeze_time from hamcrest import assert_that, contains_exactly, contains_inanyorder, equal_to, has_item, has_items, is_, is_in from pydantic import HttpUrl, ValidationError +from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.model import rules from eligibility_signposting_api.model import rules as rules_model from eligibility_signposting_api.model.eligibility import ( @@ -37,9 +39,14 @@ from tests.fixtures.matchers.rules import is_iteration_rule +@pytest.fixture +def app(): + return Flask(__name__) + + class TestEligibilityCalculator: @staticmethod - def test_get_redirect_rules(): + def test_get_action_rules_components(): # Given iteration = rule_builder.IterationFactory.build( @@ -67,7 +74,9 @@ def test_get_redirect_rules(): ) # when - actual_rules, actual_action_mapper, actual_default_comms = EligibilityCalculator.get_redirect_rules(iteration) + actual_rules, actual_action_mapper, actual_default_comms = EligibilityCalculator.get_action_rules_components( + iteration, rules.RuleType.redirect + ) # then assert_that(actual_rules, has_item(is_iteration_rule().with_name(iteration.iteration_rules[0].name))) @@ -2404,3 +2413,667 @@ def test_campaigns_grouped_by_condition_name_filters_correctly( result = list(calculator.campaigns_grouped_by_condition_name(conditions_filter, category_filter)) assert_that([(str(name), group[0].type) for name, group in result], is_(expected_result)) + + +@pytest.mark.parametrize( + ( + "test_comment", + "person_icb", + "default_comms_routing", + "comms_routing", + "actions_mapper", + "expected_actions", + "expected_audit_actions", + "expected_rule_priority", + "expected_rule_name", + ), + [ + ( + """Not eligible person with matching NonEligibleActionRule""", + "QE1", + "", + "ActionCode1", + { + "ActionCode1": AvailableAction( + ActionType="InfoText", + ExternalRoutingCode="HealthcareProInfo", + ActionDescription="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("ActionCode1"), + action_type=ActionType("InfoText"), + action_code=ActionCode("HealthcareProInfo"), + action_description=ActionDescription( + """Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="ActionCode1", + action_code="HealthcareProInfo", + action_type="InfoText", + action_description="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + "20", + "In QE1", + ), + ( + """Not eligible person with NON matching NonEligibleActionRule""", + "WS3", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + None, + None, + ), + ( + """Not eligible person with matching but missing NonEligibleActionRule, fall back to default comms""", + "QE1", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + "20", + "In QE1", + ), + ], +) +def test_correct_actions_determined_from_not_eligible_action_rules( # noqa: PLR0913 + app, + test_comment, + person_icb, + default_comms_routing, + comms_routing, + actions_mapper, + expected_actions, + expected_audit_actions, + expected_rule_priority, + expected_rule_name, + faker: Faker, +): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"], icb=person_icb) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_eligible_routing=default_comms_routing, + actions_mapper=rule_builder.ActionsMapperFactory.build(root=actions_mapper), + iteration_rules=[ + rule_builder.ICBNonEligibleActionRuleFactory.build(comms_routing=comms_routing) + ], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_eligible)) + .and_actions(equal_to(expected_actions)) + ) + ), + test_comment, + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_actions + + assert getattr(cond.action_rule, "rule_priority", None) == expected_rule_priority + assert getattr(cond.action_rule, "rule_name", None) == expected_rule_name + + +def test_no_actions_returned_when_non_eligible_actions_and_defaultcomms_not_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonEligibleActions (X rules) should not return + any actions/default actions for NonEligible status + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"]) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + actions_mapper={}, + iteration_rules=[], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [] + expected_audit_action = [] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_eligible)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action + + +def test_actions_returned_when_non_eligible_actions_not_given_and_defaultcomms_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonEligibleActions (X rules) but with default comms routing + should return the default comms actions + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"]) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_eligible_routing="defaultCommsCode", + actions_mapper=rule_builder.ActionsMapperFactory.build( + root={ + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="Default Speak to your healthcare professional.", + ) + } + ), + iteration_rules=[], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription("Default Speak to your healthcare professional."), + url_link=None, + url_label=None, + ) + ] + expected_audit_action = [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="Default Speak to your healthcare professional.", + action_url=None, + action_url_label=None, + ) + ] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_eligible)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action + + +@pytest.mark.parametrize( + ( + "test_comment", + "person_icb", + "default_comms_routing", + "comms_routing", + "actions_mapper", + "expected_actions", + "expected_audit_actions", + ), + [ + ( + """Not actionable person with matching NonActionableActionRule""", + "QE1", + "", + "ActionCode1", + { + "ActionCode1": AvailableAction( + ActionType="InfoText", + ExternalRoutingCode="HealthcareProInfo", + ActionDescription="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("ActionCode1"), + action_type=ActionType("InfoText"), + action_code=ActionCode("HealthcareProInfo"), + action_description=ActionDescription( + """Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="ActionCode1", + action_code="HealthcareProInfo", + action_type="InfoText", + action_description="""Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + ), + ( + """Not actionable person with NON matching NonActionableActionRule""", + "WS3", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + ), + ( + """Not actionable person with matching but missing NonActionableActionRule, fall back to default comms""", + "QE1", + "defaultCommsCode", + "ActionCode1", + { + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + ) + }, + [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription( + """Default Speak to your healthcare professional if you think + you should be offered this vaccination.""" + ), + url_link=None, + url_label=None, + ) + ], + [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="""Default Speak to your healthcare professional if you think + you should be offered this vaccination.""", + action_url=None, + action_url_label=None, + ) + ], + ), + ], +) +def test_correct_actions_determined_from_not_actionable_action_rules( # noqa: PLR0913 + app, + test_comment, + person_icb, + default_comms_routing, + comms_routing, + actions_mapper, + expected_actions, + expected_audit_actions, + faker: Faker, +): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb=person_icb, de=True) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_actionable_routing=default_comms_routing, + actions_mapper=rule_builder.ActionsMapperFactory.build(root=actions_mapper), + iteration_rules=[ + rule_builder.DetainedEstateSuppressionRuleFactory.build(), + rule_builder.ICBNonActionableActionRuleFactory.build(comms_routing=comms_routing), + ], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_actionable)) + .and_actions(equal_to(expected_actions)) + ) + ), + test_comment, + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_actions + + +def test_no_actions_returned_when_non_actionable_actions_and_defaultcomms_not_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonActionableActions (Y rules) should not return + any actions/default actions for NonActionable status + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], de=True) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + actions_mapper={}, + iteration_rules=[rule_builder.DetainedEstateSuppressionRuleFactory.build()], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [] + expected_audit_action = [] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_actionable)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action + + +def test_actions_returned_when_non_actionable_actions_not_given_and_defaultcomms_given( + app, + faker: Faker, +): + """ + ELI-295 - Campaign config without NonActionableActions (Y rules) with default comms routing + should return default comms actions + """ + + # Given + nhs_number = NHSNumber(faker.nhs_number()) + + person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], de=True) + + campaign_configs = [ + ( + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + default_not_actionable_routing="defaultCommsCode", + actions_mapper=rule_builder.ActionsMapperFactory.build( + root={ + "defaultCommsCode": AvailableAction( + ActionType="DefaultInfoText", + ExternalRoutingCode="DefaultHealthcareProInfo", + ActionDescription="Default Speak to your healthcare professional.", + ) + } + ), + iteration_rules=[rule_builder.DetainedEstateSuppressionRuleFactory.build()], + ) + ], + ) + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with app.app_context(): + g.audit_log = AuditEvent() + + actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + + # Then + expected_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("defaultCommsCode"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription("Default Speak to your healthcare professional."), + url_link=None, + url_label=None, + ) + ] + expected_audit_action = [ + AuditAction( + internal_action_code="defaultCommsCode", + action_code="DefaultHealthcareProInfo", + action_type="DefaultInfoText", + action_description="Default Speak to your healthcare professional.", + action_url=None, + action_url_label=None, + ) + ] + assert_that( + actual, + is_eligibility_status().with_conditions( + has_items( + is_condition() + .with_condition_name(ConditionName("RSV")) + .and_status(equal_to(Status.not_actionable)) + .and_actions(equal_to(expected_actions)) + ) + ), + ) + + cond = g.audit_log.response.condition[0] + assert cond.actions == expected_audit_action From 8d45d8de3c0f6a55da1b4489e286fd80c25fad50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:27:02 +0000 Subject: [PATCH 07/61] Bump polyfactory from 2.21.0 to 2.22.1 --- updated-dependencies: - dependency-name: polyfactory dependency-version: 2.22.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 269c8ac2f..85b08edd3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -2000,14 +2000,14 @@ files = [ [[package]] name = "polyfactory" -version = "2.21.0" +version = "2.22.1" description = "Mock data generation factories" optional = false python-versions = "<4.0,>=3.8" groups = ["dev"] files = [ - {file = "polyfactory-2.21.0-py3-none-any.whl", hash = "sha256:9483b764756c8622313d99f375889b1c0d92f09affb05742d7bcfa2b5198d8c5"}, - {file = "polyfactory-2.21.0.tar.gz", hash = "sha256:a6d8dba91b2515d744cc014b5be48835633f7ccb72519a68f8801759e5b1737a"}, + {file = "polyfactory-2.22.1-py3-none-any.whl", hash = "sha256:7500ee3678d9bc25347c0a73a35d3711cfcf9c7f45ad56d0bb085e9f75ecae7a"}, + {file = "polyfactory-2.22.1.tar.gz", hash = "sha256:6c91693088c81ab8fbe22dc66cae21fd3c17f91930fe1fae5b35b030eb020d3a"}, ] [package.dependencies] @@ -3478,4 +3478,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "350611ee13ef6aefb16bb0a69766b37d5691ba38f74e6dc6f4c08be2b0223b26" +content-hash = "11117228601efe32e5694f4c71cdff7e484abbbe60588080a8733dfeee478dd8" diff --git a/pyproject.toml b/pyproject.toml index b55498847..9c9fec812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ pytest-nhsd-apim = "^5.0.0" aiohttp = "^3.12.13" awscli = "^1.37.24" awscli-local = "^0.22.0" -polyfactory = "^2.20.0" +polyfactory = "^2.22.1" pyright = "^1.1.394" brunns-matchers = "^2.9.0" localstack = "^4.1.1" From 157b671f0cc928ee33d402292e4d688e8ed6d44e Mon Sep 17 00:00:00 2001 From: eddalmond1 Date: Fri, 18 Jul 2025 11:09:29 +0100 Subject: [PATCH 08/61] Revert "Bump polyfactory from 2.21.0 to 2.22.1" (#241) --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 85b08edd3..269c8ac2f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -2000,14 +2000,14 @@ files = [ [[package]] name = "polyfactory" -version = "2.22.1" +version = "2.21.0" description = "Mock data generation factories" optional = false python-versions = "<4.0,>=3.8" groups = ["dev"] files = [ - {file = "polyfactory-2.22.1-py3-none-any.whl", hash = "sha256:7500ee3678d9bc25347c0a73a35d3711cfcf9c7f45ad56d0bb085e9f75ecae7a"}, - {file = "polyfactory-2.22.1.tar.gz", hash = "sha256:6c91693088c81ab8fbe22dc66cae21fd3c17f91930fe1fae5b35b030eb020d3a"}, + {file = "polyfactory-2.21.0-py3-none-any.whl", hash = "sha256:9483b764756c8622313d99f375889b1c0d92f09affb05742d7bcfa2b5198d8c5"}, + {file = "polyfactory-2.21.0.tar.gz", hash = "sha256:a6d8dba91b2515d744cc014b5be48835633f7ccb72519a68f8801759e5b1737a"}, ] [package.dependencies] @@ -3478,4 +3478,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "11117228601efe32e5694f4c71cdff7e484abbbe60588080a8733dfeee478dd8" +content-hash = "350611ee13ef6aefb16bb0a69766b37d5691ba38f74e6dc6f4c08be2b0223b26" diff --git a/pyproject.toml b/pyproject.toml index 9c9fec812..b55498847 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ pytest-nhsd-apim = "^5.0.0" aiohttp = "^3.12.13" awscli = "^1.37.24" awscli-local = "^0.22.0" -polyfactory = "^2.22.1" +polyfactory = "^2.20.0" pyright = "^1.1.394" brunns-matchers = "^2.9.0" localstack = "^4.1.1" From 87b1d55ae274d42206b7e603ad7c9abfb321ea57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:11:16 +0000 Subject: [PATCH 09/61] Bump aiohttp from 3.12.13 to 3.12.14 --- updated-dependencies: - dependency-name: aiohttp dependency-version: 3.12.14 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- poetry.lock | 186 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 94 insertions(+), 94 deletions(-) diff --git a/poetry.lock b/poetry.lock index 269c8ac2f..405be8852 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -14,103 +14,103 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.13" +version = "3.12.14" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6"}, - {file = "aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad"}, - {file = "aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3"}, - {file = "aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd"}, - {file = "aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5"}, - {file = "aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf"}, - {file = "aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3"}, - {file = "aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd"}, - {file = "aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:36f6c973e003dc9b0bb4e8492a643641ea8ef0e97ff7aaa5c0f53d68839357b4"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6cbfc73179bd67c229eb171e2e3745d2afd5c711ccd1e40a68b90427f282eab1"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1e8b27b2d414f7e3205aa23bb4a692e935ef877e3a71f40d1884f6e04fd7fa74"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eabded0c2b2ef56243289112c48556c395d70150ce4220d9008e6b4b3dd15690"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:003038e83f1a3ff97409999995ec02fe3008a1d675478949643281141f54751d"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b6f46613031dbc92bdcaad9c4c22c7209236ec501f9c0c5f5f0b6a689bf50f3"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c332c6bb04650d59fb94ed96491f43812549a3ba6e7a16a218e612f99f04145e"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fea41a2c931fb582cb15dc86a3037329e7b941df52b487a9f8b5aa960153cbd"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:846104f45d18fb390efd9b422b27d8f3cf8853f1218c537f36e71a385758c896"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d6c85ac7dd350f8da2520bac8205ce99df4435b399fa7f4dc4a70407073e390"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5a1ecce0ed281bec7da8550da052a6b89552db14d0a0a45554156f085a912f48"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5304d74867028cca8f64f1cc1215eb365388033c5a691ea7aa6b0dc47412f495"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:64d1f24ee95a2d1e094a4cd7a9b7d34d08db1bbcb8aa9fb717046b0a884ac294"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:119c79922a7001ca6a9e253228eb39b793ea994fd2eccb79481c64b5f9d2a055"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bb18f00396d22e2f10cd8825d671d9f9a3ba968d708a559c02a627536b36d91c"}, - {file = "aiohttp-3.12.13-cp39-cp39-win32.whl", hash = "sha256:0022de47ef63fd06b065d430ac79c6b0bd24cdae7feaf0e8c6bac23b805a23a8"}, - {file = "aiohttp-3.12.13-cp39-cp39-win_amd64.whl", hash = "sha256:29e08111ccf81b2734ae03f1ad1cb03b9615e7d8f616764f22f71209c094f122"}, - {file = "aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3"}, + {file = "aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c"}, + {file = "aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28"}, + {file = "aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b"}, + {file = "aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0"}, + {file = "aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729"}, + {file = "aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758"}, + {file = "aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5"}, + {file = "aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b8cc6b05e94d837bcd71c6531e2344e1ff0fb87abe4ad78a9261d67ef5d83eae"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1dcb015ac6a3b8facd3677597edd5ff39d11d937456702f0bb2b762e390a21b"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3779ed96105cd70ee5e85ca4f457adbce3d9ff33ec3d0ebcdf6c5727f26b21b3"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:717a0680729b4ebd7569c1dcd718c46b09b360745fd8eb12317abc74b14d14d0"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5dd3a2ef7c7e968dbbac8f5574ebeac4d2b813b247e8cec28174a2ba3627170"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4710f77598c0092239bc12c1fcc278a444e16c7032d91babf5abbf7166463f7b"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3e9f75ae842a6c22a195d4a127263dbf87cbab729829e0bd7857fb1672400b2"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f9c8d55d6802086edd188e3a7d85a77787e50d56ce3eb4757a3205fa4657922"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79b29053ff3ad307880d94562cca80693c62062a098a5776ea8ef5ef4b28d140"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23e1332fff36bebd3183db0c7a547a1da9d3b4091509f6d818e098855f2f27d3"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a564188ce831fd110ea76bcc97085dd6c625b427db3f1dbb14ca4baa1447dcbc"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a7a1b4302f70bb3ec40ca86de82def532c97a80db49cac6a6700af0de41af5ee"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1b07ccef62950a2519f9bfc1e5b294de5dd84329f444ca0b329605ea787a3de5"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:938bd3ca6259e7e48b38d84f753d548bd863e0c222ed6ee6ace3fd6752768a84"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8bc784302b6b9f163b54c4e93d7a6f09563bd01ff2b841b29ed3ac126e5040bf"}, + {file = "aiohttp-3.12.14-cp39-cp39-win32.whl", hash = "sha256:a3416f95961dd7d5393ecff99e3f41dc990fb72eda86c11f2a60308ac6dcd7a0"}, + {file = "aiohttp-3.12.14-cp39-cp39-win_amd64.whl", hash = "sha256:196858b8820d7f60578f8b47e5669b3195c21d8ab261e39b1d705346458f445f"}, + {file = "aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2"}, ] [package.dependencies] aiohappyeyeballs = ">=2.5.0" -aiosignal = ">=1.1.2" +aiosignal = ">=1.4.0" attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" @@ -122,14 +122,14 @@ speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (> [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] [package.dependencies] @@ -3478,4 +3478,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "350611ee13ef6aefb16bb0a69766b37d5691ba38f74e6dc6f4c08be2b0223b26" +content-hash = "ca918c8b9441327adf6c176055f47d8d3f9e058aa243c1c957e0f7a6a6e4159f" diff --git a/pyproject.toml b/pyproject.toml index b55498847..28d9d5b01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ pytest = "^8.4.1" pytest-asyncio = "^1.0.0" pytest-cov = "^6.0.0" pytest-nhsd-apim = "^5.0.0" -aiohttp = "^3.12.13" +aiohttp = "^3.12.14" awscli = "^1.37.24" awscli-local = "^0.22.0" polyfactory = "^2.20.0" From f8915285d8a56fc01ef7f4751c75123f0b2fd0f9 Mon Sep 17 00:00:00 2001 From: Karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 21 Jul 2025 15:15:17 +0100 Subject: [PATCH 10/61] provided appropriate values factory methods in tests (#245) --- tests/fixtures/builders/model/eligibility.py | 14 ++++++++++---- tests/fixtures/builders/model/rule.py | 4 +++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/builders/model/eligibility.py b/tests/fixtures/builders/model/eligibility.py index a1fd81f7d..6a987e0d2 100644 --- a/tests/fixtures/builders/model/eligibility.py +++ b/tests/fixtures/builders/model/eligibility.py @@ -5,23 +5,29 @@ from polyfactory.factories import DataclassFactory from eligibility_signposting_api.model import eligibility -from eligibility_signposting_api.model.eligibility import UrlLink +from eligibility_signposting_api.model.eligibility import RuleType, UrlLink class SuggestedActionFactory(DataclassFactory[eligibility.SuggestedAction]): url_link = UrlLink("https://test-example.com") +class ReasonFactory(DataclassFactory[eligibility.Reason]): + rule_type = RuleType.filter + + +class CohortResultFactory(DataclassFactory[eligibility.CohortGroupResult]): + reasons = Use(ReasonFactory.batch, size=2) + + class ConditionFactory(DataclassFactory[eligibility.Condition]): actions = Use(SuggestedActionFactory.batch, size=2) + cohort_results = Use(CohortResultFactory.batch, size=2) class EligibilityStatusFactory(DataclassFactory[eligibility.EligibilityStatus]): conditions = Use(ConditionFactory.batch, size=2) -class CohortResultFactory(DataclassFactory[eligibility.CohortGroupResult]): ... - - def random_str(length: int) -> str: return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length)) diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index f0d7010b4..bbc9c340d 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -22,7 +22,9 @@ class IterationCohortFactory(ModelFactory[rules.IterationCohort]): class IterationRuleFactory(ModelFactory[rules.IterationRule]): attribute_target = None - attribute_name = None + attribute_name = "DATE_OF_BIRTH" + operator = "Y>" + comparator = "-1" cohort_label = None rule_stop = False From 3f05f513b90dc68fbb3520e74b883fd882678316 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:42:54 +0100 Subject: [PATCH 11/61] Refactor package structure (#247) --- src/eligibility_signposting_api/app.py | 4 +- .../audit/audit_context.py | 2 +- .../common/__init__.py | 0 .../common/api-error-response-readme.md | 88 +++++++++++++++++++ .../{ => common}/api_error_response.py | 0 .../{ => common}/error_handler.py | 2 +- .../request_validator.py} | 2 +- .../{eligibility.py => eligibility_status.py} | 0 .../repos/person_repo.py | 2 +- .../calculators/eligibility_calculator.py | 26 +++--- .../services/calculators/rule_calculator.py | 28 +++--- .../services/eligibility_services.py | 6 +- .../views/eligibility.py | 66 +++++++------- ...eligibility.py => eligibility_response.py} | 0 .../test_eligibility_check.py | 2 +- .../test_eligibility_check_bdd.py | 2 +- tests/fixtures/builders/model/eligibility.py | 14 +-- .../views/response_model/eligibility.py | 12 +-- tests/fixtures/matchers/eligibility.py | 8 +- tests/integration/conftest.py | 28 +++--- .../in_process/test_eligibility_endpoint.py | 2 +- .../lambda/test_app_running_as_lambda.py | 2 +- tests/integration/repo/test_person_repo.py | 2 +- tests/unit/audit/test_audit_context.py | 2 +- tests/unit/common/__init__.py | 0 .../test_request_validator.py} | 32 +++---- tests/unit/config/__init__.py | 0 tests/unit/{ => config}/test_config.py | 0 tests/unit/model/test_status.py | 2 +- .../test_eligibility_calculator.py | 2 +- .../services/test_eligibility_services.py | 2 +- tests/unit/views/test_eligibility.py | 70 +++++++-------- 32 files changed, 250 insertions(+), 158 deletions(-) create mode 100644 src/eligibility_signposting_api/common/__init__.py create mode 100644 src/eligibility_signposting_api/common/api-error-response-readme.md rename src/eligibility_signposting_api/{ => common}/api_error_response.py (100%) rename src/eligibility_signposting_api/{ => common}/error_handler.py (89%) rename src/eligibility_signposting_api/{wrapper.py => common/request_validator.py} (98%) rename src/eligibility_signposting_api/model/{eligibility.py => eligibility_status.py} (100%) rename src/eligibility_signposting_api/views/response_model/{eligibility.py => eligibility_response.py} (100%) create mode 100644 tests/unit/common/__init__.py rename tests/unit/{test_wrapper.py => common/test_request_validator.py} (90%) create mode 100644 tests/unit/config/__init__.py rename tests/unit/{ => config}/test_config.py (100%) diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index 26be07c99..6246a807c 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -8,10 +8,10 @@ from mangum.types import LambdaContext, LambdaEvent from eligibility_signposting_api import audit, repos, services +from eligibility_signposting_api.common.error_handler import handle_exception +from eligibility_signposting_api.common.request_validator import validate_request_params from eligibility_signposting_api.config.config import config, init_logging -from eligibility_signposting_api.error_handler import handle_exception from eligibility_signposting_api.views import eligibility_blueprint -from eligibility_signposting_api.wrapper import validate_request_params init_logging() logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index b54276cbb..28a6f9072 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -18,7 +18,7 @@ RequestAuditQueryParams, ) from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( CohortGroupResult, ConditionName, IterationResult, diff --git a/src/eligibility_signposting_api/common/__init__.py b/src/eligibility_signposting_api/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/eligibility_signposting_api/common/api-error-response-readme.md b/src/eligibility_signposting_api/common/api-error-response-readme.md new file mode 100644 index 000000000..08473002c --- /dev/null +++ b/src/eligibility_signposting_api/common/api-error-response-readme.md @@ -0,0 +1,88 @@ +# How to Use the API Error Response Module + +This document outlines how to use the `api_error_response.py` module for standardized error handling within the Eligibility Signposting API. The module ensures that all API errors are consistent, logged appropriately, and conform to the FHIR `OperationOutcome` standard. + +## Core Concepts + +The error handling mechanism is built around the class `APIErrorResponse`. + +1. **`APIErrorResponse` Class**: This class is a constructor for a specific type of error. An instance of this class holds configuration for an error, such as the `HTTPStatus`, severity, and various FHIR-specific codes. +2. **Pre-defined Error Instances**: The module defines several singleton instances of for common, application-specific errors. Examples include: + - `INVALID_CATEGORY_ERROR` + - `NHS_NUMBER_MISMATCH_ERROR` + - `INTERNAL_SERVER_ERROR` +3. **`log_and_generate_response()` Method**: This is the primary method to be used. When called on an `APIErrorResponse` instance, it performs two actions: + - Logs the error with a detailed internal message. + - Generates a complete HTTP response dictionary (`statusCode`, `headers`, `body`) containing a FHIR-compliant `OperationOutcome` payload. + +## How to Use + +The primary way to handle errors is to import a pre-defined error object from `api_error_response.py` and call its `log_and_generate_response()` method. + +### 1. Handling Specific, Known Errors + +For handling validation failures or other expected error conditions, use one of the pre-defined error instances. +The `wrapper.py` module uses this pattern to validate query parameters. If a parameter is invalid, it calls the corresponding error function. + +#### Example: Invalid "category" parameter + +``` python +# wrapper.py + +from eligibility_signposting_api.api_error_response import INVALID_CATEGORY_ERROR + +def get_category_error_response(category: str) -> dict[str, Any]: + """Generates an error response for an invalid category.""" + + return INVALID_CATEGORY_ERROR.log_and_generate_response( + log_message=f"Invalid category query param: '{category}'", + diagnostics=f"{category} is not a category that is supported by the API", + location_param="category" + ) +``` + +#### Key Parameters for `log_and_generate_response()` + +- `log_message`: A detailed message for internal logging. This should contain specific information useful for debugging. +- `diagnostics`: The user-facing error message that will be included in the API response body. +- `location_param` (optional): The name of the parameter that caused the error. This helps pinpoint the issue for API consumers. + +### 2. Handling Unexpected Exceptions (Global Error Handler) + +For unexpected errors, a global exception handler in `error_handler.py` catches any unhandled exception and returns a generic 500 Internal Server Error. This prevents sensitive information from leaking in stack traces. + +``` python +# error_handler.py + +from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR + +def handle_exception(e: Exception) -> ResponseReturnValue | HTTPException: + # Generate a generic, safe response for the client + response = INTERNAL_SERVER_ERROR.log_and_generate_response( + log_message=f"An unexpected error occurred: {traceback.format_exception(e)}", + diagnostics="An unexpected error occurred." + ) + return make_response(response.get("body"), response.get("statusCode"), response.get("headers")) +``` + +### 3. Creating New Error Types + +If a new, reusable error condition is identified, you should add a new instance of `APIErrorResponse` to `api_error_response.py` +Follow the existing pattern: + +``` python +# api_error_response.py + +# ... (other error definitions) + +SOME_NEW_ERROR = APIErrorResponse( + status_code=HTTPStatus.BAD_REQUEST, + fhir_issue_code=FHIRIssueCode.VALUE, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_coding_system=FHIR_SPINE_ERROR_CODE_SYSTEM, + fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, + fhir_display_message="A new specific error message for display", +) +``` + +By centralizing error definitions, we ensure that the API provides a consistent and predictable experience for its consumers. diff --git a/src/eligibility_signposting_api/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py similarity index 100% rename from src/eligibility_signposting_api/api_error_response.py rename to src/eligibility_signposting_api/common/api_error_response.py diff --git a/src/eligibility_signposting_api/error_handler.py b/src/eligibility_signposting_api/common/error_handler.py similarity index 89% rename from src/eligibility_signposting_api/error_handler.py rename to src/eligibility_signposting_api/common/error_handler.py index 3841bf170..662e8bdda 100644 --- a/src/eligibility_signposting_api/error_handler.py +++ b/src/eligibility_signposting_api/common/error_handler.py @@ -5,7 +5,7 @@ from flask.typing import ResponseReturnValue from werkzeug.exceptions import HTTPException -from eligibility_signposting_api.api_error_response import INTERNAL_SERVER_ERROR +from eligibility_signposting_api.common.api_error_response import INTERNAL_SERVER_ERROR logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/wrapper.py b/src/eligibility_signposting_api/common/request_validator.py similarity index 98% rename from src/eligibility_signposting_api/wrapper.py rename to src/eligibility_signposting_api/common/request_validator.py index c3437afea..1bc8d9d86 100644 --- a/src/eligibility_signposting_api/wrapper.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -6,7 +6,7 @@ from mangum.types import LambdaContext, LambdaEvent -from eligibility_signposting_api.api_error_response import ( +from eligibility_signposting_api.common.api_error_response import ( INVALID_CATEGORY_ERROR, INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility_status.py similarity index 100% rename from src/eligibility_signposting_api/model/eligibility.py rename to src/eligibility_signposting_api/model/eligibility_status.py diff --git a/src/eligibility_signposting_api/repos/person_repo.py b/src/eligibility_signposting_api/repos/person_repo.py index 41ea20745..cfa18fa12 100644 --- a/src/eligibility_signposting_api/repos/person_repo.py +++ b/src/eligibility_signposting_api/repos/person_repo.py @@ -5,7 +5,7 @@ from boto3.resources.base import ServiceResource from wireup import Inject, service -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos.exceptions import NotFoundError logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index fa586a4e7..a2e4f6bd8 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -24,8 +24,8 @@ from wireup import service -from eligibility_signposting_api.model import eligibility, rules -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model import eligibility_status, rules +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, @@ -58,7 +58,7 @@ class EligibilityCalculator: person_data: Row campaign_configs: Collection[rules.CampaignConfig] - results: list[eligibility.Condition] = field(default_factory=list) + results: list[eligibility_status.Condition] = field(default_factory=list) @property def active_campaigns(self) -> list[rules.CampaignConfig]: @@ -66,7 +66,7 @@ def active_campaigns(self) -> list[rules.CampaignConfig]: def campaigns_grouped_by_condition_name( self, conditions: list[str], category: str - ) -> Iterator[tuple[eligibility.ConditionName, list[rules.CampaignConfig]]]: + ) -> Iterator[tuple[eligibility_status.ConditionName, list[rules.CampaignConfig]]]: """Generator that yields campaign groups filtered by condition names and campaign category.""" mapping = { @@ -100,9 +100,9 @@ def get_the_best_cohort_memberships( cohort_results: dict[str, CohortGroupResult], ) -> tuple[Status, list[CohortGroupResult]]: if not cohort_results: - return eligibility.Status.not_eligible, [] + return eligibility_status.Status.not_eligible, [] - best_status = eligibility.Status.best(*[result.status for result in cohort_results.values()]) + best_status = eligibility_status.Status.best(*[result.status for result in cohort_results.values()]) best_cohorts = [result for result in cohort_results.values() if result.status == best_status] best_cohorts = [ @@ -158,7 +158,7 @@ def get_action_rules_components( def evaluate_eligibility( self, include_actions: str, conditions: list[str], category: str - ) -> eligibility.EligibilityStatus: + ) -> eligibility_status.EligibilityStatus: include_actions_flag = include_actions.upper() == "Y" condition_results: dict[ConditionName, IterationResult] = {} actions: list[SuggestedAction] | None = [] @@ -186,7 +186,7 @@ def evaluate_eligibility( ), ) = max(iteration_results.items(), key=lambda item: item[1][1].status.value) else: - best_candidate = IterationResult(eligibility.Status.not_eligible, [], actions) + best_candidate = IterationResult(eligibility_status.Status.not_eligible, [], actions) best_campaign_id = None best_campaign_version = None best_active_iteration = None @@ -233,7 +233,7 @@ def evaluate_eligibility( # Consolidate all the results and return final_result = self.build_condition_results(condition_results) - return eligibility.EligibilityStatus(conditions=final_result) + return eligibility_status.EligibilityStatus(conditions=final_result) def get_iteration_results( self, actions: list[SuggestedAction] | None, campaign_group: list[CampaignConfig] @@ -406,20 +406,20 @@ def evaluate_suppression_rules( def evaluate_rules_priority_group( self, rules_group: Iterator[rules.IterationRule] - ) -> tuple[eligibility.Status, list[eligibility.Reason], bool]: + ) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]: is_rule_stop = False exclusion_reasons = [] - best_status = eligibility.Status.not_eligible + best_status = eligibility_status.Status.not_eligible for rule in rules_group: is_rule_stop = rule.rule_stop or is_rule_stop rule_calculator = RuleCalculator(person_data=self.person_data, rule=rule) status, reason = rule_calculator.evaluate_exclusion() if status.is_exclusion: - best_status = eligibility.Status.best(status, best_status) + best_status = eligibility_status.Status.best(status, best_status) exclusion_reasons.append(reason) else: - best_status = eligibility.Status.actionable + best_status = eligibility_status.Status.actionable return best_status, exclusion_reasons, is_rule_stop diff --git a/src/eligibility_signposting_api/services/calculators/rule_calculator.py b/src/eligibility_signposting_api/services/calculators/rule_calculator.py index 03641e3be..96b0dfd87 100644 --- a/src/eligibility_signposting_api/services/calculators/rule_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/rule_calculator.py @@ -6,7 +6,7 @@ from hamcrest.core.string_description import StringDescription -from eligibility_signposting_api.model import eligibility, rules +from eligibility_signposting_api.model import eligibility_status, rules from eligibility_signposting_api.services.rules.operators import OperatorRegistry Row = Collection[Mapping[str, Any]] @@ -17,15 +17,15 @@ class RuleCalculator: person_data: Row rule: rules.IterationRule - def evaluate_exclusion(self) -> tuple[eligibility.Status, eligibility.Reason]: + def evaluate_exclusion(self) -> tuple[eligibility_status.Status, eligibility_status.Reason]: """Evaluate if a particular rule excludes this person. Return the result, and the reason for the result.""" attribute_value = self.get_attribute_value() status, reason, matcher_matched = self.evaluate_rule(attribute_value) - reason = eligibility.Reason( - rule_name=eligibility.RuleName(self.rule.name), - rule_type=eligibility.RuleType(self.rule.type), - rule_priority=eligibility.RulePriority(str(self.rule.priority)), - rule_description=eligibility.RuleDescription(self.rule.description), + reason = eligibility_status.Reason( + rule_name=eligibility_status.RuleName(self.rule.name), + rule_type=eligibility_status.RuleType(self.rule.type), + rule_priority=eligibility_status.RulePriority(str(self.rule.priority)), + rule_description=eligibility_status.RuleDescription(self.rule.description), matcher_matched=matcher_matched, ) return status, reason @@ -71,7 +71,7 @@ def get_value(dictionary: Mapping[str, Any] | None, key: str) -> dict: v = dictionary.get(key, {}) if isinstance(dictionary, dict) else {} return v if isinstance(v, dict) else {} - def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status, str, bool]: + def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility_status.Status, str, bool]: """Evaluate a rule against a person data attribute. Return the result, and the reason for the result.""" matcher_class = OperatorRegistry.get(self.rule.operator) matcher = matcher_class(rule_value=self.rule.comparator) @@ -81,12 +81,12 @@ def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility.Status if matcher_matched: matcher.describe_match(attribute_value, reason) status = { - rules.RuleType.filter: eligibility.Status.not_eligible, - rules.RuleType.suppression: eligibility.Status.not_actionable, - rules.RuleType.redirect: eligibility.Status.actionable, - rules.RuleType.not_eligible_actions: eligibility.Status.not_eligible, - rules.RuleType.not_actionable_actions: eligibility.Status.not_actionable, + rules.RuleType.filter: eligibility_status.Status.not_eligible, + rules.RuleType.suppression: eligibility_status.Status.not_actionable, + rules.RuleType.redirect: eligibility_status.Status.actionable, + rules.RuleType.not_eligible_actions: eligibility_status.Status.not_eligible, + rules.RuleType.not_actionable_actions: eligibility_status.Status.not_actionable, }[self.rule.type] return status, str(reason), matcher_matched matcher.describe_mismatch(attribute_value, reason) - return eligibility.Status.actionable, str(reason), matcher_matched + return eligibility_status.Status.actionable, str(reason), matcher_matched diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 48586290b..a4ff86ac1 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -2,7 +2,7 @@ from wireup import service -from eligibility_signposting_api.model import eligibility +from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.services.calculators import eligibility_calculator as calculator @@ -32,11 +32,11 @@ def __init__( def get_eligibility_status( self, - nhs_number: eligibility.NHSNumber, + nhs_number: eligibility_status.NHSNumber, include_actions: str, conditions: list[str], category: str, - ) -> eligibility.EligibilityStatus: + ) -> eligibility_status.EligibilityStatus: """Calculate a person's eligibility for vaccination given an NHS number.""" if nhs_number: try: diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 1ce27ca39..6f279653a 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -8,18 +8,18 @@ from flask.typing import ResponseReturnValue from wireup import Injected -from eligibility_signposting_api.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.eligibility import Condition, EligibilityStatus, NHSNumber, Status +from eligibility_signposting_api.common.api_error_response import NHS_NUMBER_NOT_FOUND_ERROR +from eligibility_signposting_api.model.eligibility_status import Condition, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError -from eligibility_signposting_api.views.response_model import eligibility -from eligibility_signposting_api.views.response_model.eligibility import ProcessedSuggestion +from eligibility_signposting_api.views.response_model import eligibility_response +from eligibility_signposting_api.views.response_model.eligibility_response import ProcessedSuggestion STATUS_MAPPING = { - Status.actionable: eligibility.Status.actionable, - Status.not_actionable: eligibility.Status.not_actionable, - Status.not_eligible: eligibility.Status.not_eligible, + Status.actionable: eligibility_response.Status.actionable, + Status.not_actionable: eligibility_response.Status.not_actionable, + Status.not_eligible: eligibility_response.Status.not_eligible, } logger = logging.getLogger(__name__) @@ -56,11 +56,9 @@ def check_eligibility( except UnknownPersonError: return handle_unknown_person_error(nhs_number) else: - eligibility_response: eligibility.EligibilityResponse = build_eligibility_response(eligibility_status) + response: eligibility_response.EligibilityResponse = build_eligibility_response(eligibility_status) AuditContext.write_to_firehose(audit_service) - return make_response( - eligibility_response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK - ) + return make_response(response.model_dump(by_alias=True, mode="json", exclude_none=True), HTTPStatus.OK) def get_or_default_query_params() -> dict[str, Any]: @@ -103,7 +101,7 @@ def handle_unknown_person_error(nhs_number: NHSNumber) -> ResponseReturnValue: return make_response(response.get("body"), response.get("statusCode"), response.get("headers")) -def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility.EligibilityResponse: +def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibility_response.EligibilityResponse: """Return an object representing the API response we are going to send, given an evaluation of the person's eligibility.""" @@ -111,9 +109,9 @@ def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibi for condition in eligibility_status.conditions: suggestions = ProcessedSuggestion( # pyright: ignore[reportCallIssue] - condition=eligibility.ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue] + condition=eligibility_response.ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue] status=STATUS_MAPPING[condition.status], - statusText=eligibility.StatusText(condition.status_text), # pyright: ignore[reportCallIssue] + statusText=eligibility_response.StatusText(condition.status_text), # pyright: ignore[reportCallIssue] eligibilityCohorts=build_eligibility_cohorts(condition), # pyright: ignore[reportCallIssue] suitabilityRules=build_suitability_results(condition), # pyright: ignore[reportCallIssue] actions=build_actions(condition), @@ -122,27 +120,29 @@ def build_eligibility_response(eligibility_status: EligibilityStatus) -> eligibi processed_suggestions.append(suggestions) response_id = uuid.uuid4() - updated = eligibility.LastUpdated(datetime.now(tz=UTC)) + updated = eligibility_response.LastUpdated(datetime.now(tz=UTC)) AuditContext.add_response_details(response_id, updated) - return eligibility.EligibilityResponse( # pyright: ignore[reportCallIssue] + return eligibility_response.EligibilityResponse( # pyright: ignore[reportCallIssue] responseId=response_id, # pyright: ignore[reportCallIssue] - meta=eligibility.Meta(lastUpdated=updated), + meta=eligibility_response.Meta(lastUpdated=updated), # pyright: ignore[reportCallIssue] processedSuggestions=processed_suggestions, ) -def build_actions(condition: Condition) -> list[eligibility.Action] | None: +def build_actions(condition: Condition) -> list[eligibility_response.Action] | None: if condition.actions is not None: return [ - eligibility.Action( - actionType=eligibility.ActionType(action.action_type), - actionCode=eligibility.ActionCode(action.action_code), - description=eligibility.Description(action.action_description or ""), - urlLabel=eligibility.UrlLabel(action.url_label or ""), - urlLink=eligibility.UrlLink(str(action.url_link)) if action.url_link else eligibility.UrlLink(""), + eligibility_response.Action( + actionType=eligibility_response.ActionType(action.action_type), + actionCode=eligibility_response.ActionCode(action.action_code), + description=eligibility_response.Description(action.action_description or ""), + urlLabel=eligibility_response.UrlLabel(action.url_label or ""), + urlLink=eligibility_response.UrlLink(str(action.url_link)) + if action.url_link + else eligibility_response.UrlLink(""), ) for action in condition.actions ] @@ -150,13 +150,13 @@ def build_actions(condition: Condition) -> list[eligibility.Action] | None: return None -def build_eligibility_cohorts(condition: Condition) -> list[eligibility.EligibilityCohort]: +def build_eligibility_cohorts(condition: Condition) -> list[eligibility_response.EligibilityCohort]: """Group Iteration cohorts and make only one entry per cohort group""" return [ - eligibility.EligibilityCohort( - cohortCode=eligibility.CohortCode(cohort_result.cohort_code), - cohortText=eligibility.CohortText(cohort_result.description), + eligibility_response.EligibilityCohort( + cohortCode=eligibility_response.CohortCode(cohort_result.cohort_code), + cohortText=eligibility_response.CohortText(cohort_result.description), cohortStatus=STATUS_MAPPING[cohort_result.status], ) for cohort_result in condition.cohort_results @@ -164,7 +164,7 @@ def build_eligibility_cohorts(condition: Condition) -> list[eligibility.Eligibil ] -def build_suitability_results(condition: Condition) -> list[eligibility.SuitabilityRule]: +def build_suitability_results(condition: Condition) -> list[eligibility_response.SuitabilityRule]: """Make only one entry if there are duplicate rules""" if condition.status != Status.not_actionable: return [] @@ -178,10 +178,10 @@ def build_suitability_results(condition: Condition) -> list[eligibility.Suitabil if reason.rule_name not in unique_rule_codes and reason.rule_description: unique_rule_codes.add(reason.rule_name) suitability_results.append( - eligibility.SuitabilityRule( - ruleType=eligibility.RuleType(reason.rule_type.value), - ruleCode=eligibility.RuleCode(reason.rule_name), - ruleText=eligibility.RuleText(reason.rule_description), + eligibility_response.SuitabilityRule( + ruleType=eligibility_response.RuleType(reason.rule_type.value), + ruleCode=eligibility_response.RuleCode(reason.rule_name), + ruleText=eligibility_response.RuleText(reason.rule_description), ) ) diff --git a/src/eligibility_signposting_api/views/response_model/eligibility.py b/src/eligibility_signposting_api/views/response_model/eligibility_response.py similarity index 100% rename from src/eligibility_signposting_api/views/response_model/eligibility.py rename to src/eligibility_signposting_api/views/response_model/eligibility_response.py diff --git a/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py b/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py index 76dbd8194..817648eb9 100644 --- a/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py +++ b/tests/e2e/tests/eligibility_signposting/test_eligibility_check.py @@ -27,7 +27,7 @@ def is_api_accessible(): return response.status_code != HTTP_STATUS_NOT_FOUND -@pytest.mark.eligibility +@pytest.mark.eligibility_response class TestEligibilityCheck: """Test suite for the Eligibility Check endpoint.""" diff --git a/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py b/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py index 3bb0360bd..ceb94642a 100644 --- a/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py +++ b/tests/e2e/tests/eligibility_signposting/test_eligibility_check_bdd.py @@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).parent.parent.parent)) # Mark all tests as BDD tests -pytestmark = [pytest.mark.bdd, pytest.mark.eligibility] +pytestmark = [pytest.mark.bdd, pytest.mark.eligibility_response] # Load the scenarios from the feature file scenarios("../../features/eligibility_check/eligibility_check.feature") diff --git a/tests/fixtures/builders/model/eligibility.py b/tests/fixtures/builders/model/eligibility.py index 6a987e0d2..07f3c825c 100644 --- a/tests/fixtures/builders/model/eligibility.py +++ b/tests/fixtures/builders/model/eligibility.py @@ -4,28 +4,28 @@ from polyfactory import Use from polyfactory.factories import DataclassFactory -from eligibility_signposting_api.model import eligibility -from eligibility_signposting_api.model.eligibility import RuleType, UrlLink +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.eligibility_status import RuleType, UrlLink -class SuggestedActionFactory(DataclassFactory[eligibility.SuggestedAction]): +class SuggestedActionFactory(DataclassFactory[eligibility_status.SuggestedAction]): url_link = UrlLink("https://test-example.com") -class ReasonFactory(DataclassFactory[eligibility.Reason]): +class ReasonFactory(DataclassFactory[eligibility_status.Reason]): rule_type = RuleType.filter -class CohortResultFactory(DataclassFactory[eligibility.CohortGroupResult]): +class CohortResultFactory(DataclassFactory[eligibility_status.CohortGroupResult]): reasons = Use(ReasonFactory.batch, size=2) -class ConditionFactory(DataclassFactory[eligibility.Condition]): +class ConditionFactory(DataclassFactory[eligibility_status.Condition]): actions = Use(SuggestedActionFactory.batch, size=2) cohort_results = Use(CohortResultFactory.batch, size=2) -class EligibilityStatusFactory(DataclassFactory[eligibility.EligibilityStatus]): +class EligibilityStatusFactory(DataclassFactory[eligibility_status.EligibilityStatus]): conditions = Use(ConditionFactory.batch, size=2) diff --git a/tests/fixtures/builders/views/response_model/eligibility.py b/tests/fixtures/builders/views/response_model/eligibility.py index 061036908..3b8bff75b 100644 --- a/tests/fixtures/builders/views/response_model/eligibility.py +++ b/tests/fixtures/builders/views/response_model/eligibility.py @@ -1,23 +1,23 @@ from polyfactory import Use from polyfactory.factories.pydantic_factory import ModelFactory -from eligibility_signposting_api.views.response_model import eligibility +from eligibility_signposting_api.views.response_model import eligibility_response -class EligibilityCohortFactory(ModelFactory[eligibility.EligibilityCohort]): ... +class EligibilityCohortFactory(ModelFactory[eligibility_response.EligibilityCohort]): ... -class SuitabilityRuleFactory(ModelFactory[eligibility.SuitabilityRule]): ... +class SuitabilityRuleFactory(ModelFactory[eligibility_response.SuitabilityRule]): ... -class ActionFactory(ModelFactory[eligibility.Action]): ... +class ActionFactory(ModelFactory[eligibility_response.Action]): ... -class ProcessedSuggestionFactory(ModelFactory[eligibility.ProcessedSuggestion]): +class ProcessedSuggestionFactory(ModelFactory[eligibility_response.ProcessedSuggestion]): eligibility_cohorts = Use(EligibilityCohortFactory.batch, size=2) suitability_rules = Use(SuitabilityRuleFactory.batch, size=2) actions = Use(ActionFactory.batch, size=2) -class EligibilityResponseFactory(ModelFactory[eligibility.EligibilityResponse]): +class EligibilityResponseFactory(ModelFactory[eligibility_response.EligibilityResponse]): processed_suggestions = Use(ProcessedSuggestionFactory.batch, size=2) diff --git a/tests/fixtures/matchers/eligibility.py b/tests/fixtures/matchers/eligibility.py index 98bfb4138..731a3de76 100644 --- a/tests/fixtures/matchers/eligibility.py +++ b/tests/fixtures/matchers/eligibility.py @@ -1,7 +1,11 @@ from hamcrest.core.matcher import Matcher -from eligibility_signposting_api.model.eligibility import CohortGroupResult, Condition, EligibilityStatus, Reason -from eligibility_signposting_api.views.response_model.eligibility import Action, EligibilityCohort, SuitabilityRule +from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Condition, EligibilityStatus, Reason +from eligibility_signposting_api.views.response_model.eligibility_response import ( + Action, + EligibilityCohort, + SuitabilityRule, +) from .meta import BaseAutoMatcher diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 75cf97878..f4b0856d9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,7 @@ from httpx import RequestError from yarl import URL -from eligibility_signposting_api.model import eligibility, rules +from eligibility_signposting_api.model import eligibility_status, rules from eligibility_signposting_api.repos.campaign_repo import BucketName from eligibility_signposting_api.repos.person_repo import TableName from tests.fixtures.builders.model import rule @@ -330,9 +330,9 @@ def person_table(dynamodb_resource: ServiceResource) -> Generator[Any]: @pytest.fixture -def persisted_person(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) - date_of_birth = eligibility.DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=65)) +def persisted_person(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility_status.DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=65)) for row in ( rows := person_rows_builder(nhs_number, date_of_birth=date_of_birth, postcode="hp1", cohorts=["cohort1"]) @@ -346,9 +346,9 @@ def persisted_person(person_table: Any, faker: Faker) -> Generator[eligibility.N @pytest.fixture -def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) - date_of_birth = eligibility.DateOfBirth(faker.date_of_birth(minimum_age=77, maximum_age=77)) +def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility_status.DateOfBirth(faker.date_of_birth(minimum_age=77, maximum_age=77)) for row in ( rows := person_rows_builder( @@ -367,9 +367,9 @@ def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibil @pytest.fixture -def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) - date_of_birth = eligibility.DateOfBirth(faker.date_of_birth(minimum_age=74, maximum_age=74)) +def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) + date_of_birth = eligibility_status.DateOfBirth(faker.date_of_birth(minimum_age=74, maximum_age=74)) for row in ( rows := person_rows_builder( @@ -389,8 +389,8 @@ def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[e @pytest.fixture -def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber(faker.nhs_number()) +def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) for row in (rows := person_rows_builder(nhs_number)): person_table.put_item(Item=row) @@ -402,8 +402,8 @@ def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[el @pytest.fixture -def persisted_person_pc_sw19(person_table: Any, faker: Faker) -> Generator[eligibility.NHSNumber]: - nhs_number = eligibility.NHSNumber( +def persisted_person_pc_sw19(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: + nhs_number = eligibility_status.NHSNumber( faker.nhs_number(), ) for row in (rows := person_rows_builder(nhs_number, postcode="SW19", cohorts=["cohort1"])): diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index 229d8652c..c39c48207 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -11,7 +11,7 @@ has_key, ) -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) from eligibility_signposting_api.model.rules import CampaignConfig diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index b157fe44f..85fb305ea 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -23,7 +23,7 @@ ) from yarl import URL -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.model.rules import CampaignConfig from eligibility_signposting_api.repos.campaign_repo import BucketName diff --git a/tests/integration/repo/test_person_repo.py b/tests/integration/repo/test_person_repo.py index 7a444d20e..4dcf53383 100644 --- a/tests/integration/repo/test_person_repo.py +++ b/tests/integration/repo/test_person_repo.py @@ -4,7 +4,7 @@ from faker import Faker from hamcrest import assert_that, contains_inanyorder, has_entries -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import NotFoundError from eligibility_signposting_api.repos.person_repo import PersonRepo diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py index 25897bd9a..b4f39f416 100644 --- a/tests/unit/audit/test_audit_context.py +++ b/tests/unit/audit/test_audit_context.py @@ -9,7 +9,7 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, diff --git a/tests/unit/common/__init__.py b/tests/unit/common/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_wrapper.py b/tests/unit/common/test_request_validator.py similarity index 90% rename from tests/unit/test_wrapper.py rename to tests/unit/common/test_request_validator.py index 18ef5d477..2787c981a 100644 --- a/tests/unit/test_wrapper.py +++ b/tests/unit/common/test_request_validator.py @@ -4,8 +4,8 @@ import pytest -from eligibility_signposting_api import wrapper -from eligibility_signposting_api.wrapper import logger +from eligibility_signposting_api.common import request_validator +from eligibility_signposting_api.common.request_validator import logger @pytest.fixture(autouse=True) @@ -27,7 +27,7 @@ def setup_logging_for_tests(): ) def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log_msg, caplog): with caplog.at_level(logging.ERROR): - result = wrapper.validate_nhs_number(path_nhs, header_nhs) + result = request_validator.validate_nhs_number(path_nhs, header_nhs) assert result == expected_result @@ -59,7 +59,7 @@ def test_validate_query_params_conditions(conditions_input, is_valid_expected, e params = {"conditions": conditions_input} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid == is_valid_expected if is_valid_expected: @@ -73,7 +73,7 @@ def test_validate_query_params_conditions(conditions_input, is_valid_expected, e def test_validate_query_params_conditions_default(caplog): params = {"category": "ALL", "includeActions": "Y"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -97,7 +97,7 @@ def test_validate_query_params_conditions_default(caplog): def test_validate_query_params_category(category_input, is_valid_expected, expected_log_msg, caplog): params = {"category": category_input} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid == is_valid_expected if is_valid_expected: @@ -111,7 +111,7 @@ def test_validate_query_params_category(category_input, is_valid_expected, expec def test_validate_query_params_category_default(caplog): params = {"conditions": "ALL", "includeActions": "Y"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -136,7 +136,7 @@ def test_validate_query_params_category_default(caplog): def test_validate_query_params_include_actions(include_actions_input, is_valid_expected, expected_log_msg, caplog): params = {"includeActions": include_actions_input} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid == is_valid_expected if is_valid_expected: @@ -150,7 +150,7 @@ def test_validate_query_params_include_actions(include_actions_input, is_valid_e def test_validate_query_params_include_actions_default(caplog): params = {"conditions": "ALL", "category": "ALL"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -159,7 +159,7 @@ def test_validate_query_params_include_actions_default(caplog): def test_validate_query_params_all_valid_params(caplog): params = {"conditions": "COND1,COND2", "category": "SCREENING", "includeActions": "N"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is True assert problem is None assert not caplog.records @@ -168,7 +168,7 @@ def test_validate_query_params_all_valid_params(caplog): def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(caplog): params = {"conditions": "VALID_COND,INVALID!,ANOTHER_VALID", "category": "SCREENING", "includeActions": "N"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None assert any( @@ -180,7 +180,7 @@ def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(caplog) def test_validate_query_params_valid_conditions_invalid_category_fail_second(caplog): params = {"conditions": "CONDITION", "category": "BAD_CAT", "includeActions": "N"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None assert any( @@ -194,7 +194,7 @@ def test_validate_query_params_valid_conditions_invalid_category_fail_second(cap def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(caplog): params = {"conditions": "CONDITION", "category": "VACCINATIONS", "includeActions": "Nope"} with caplog.at_level(logging.ERROR): - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None assert any( @@ -209,7 +209,7 @@ def test_validate_query_params_returns_correct_problem_details_for_conditions_er invalid_condition = "FLU&COVID" params = {"conditions": invalid_condition} - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None @@ -247,7 +247,7 @@ def test_validate_query_params_returns_correct_problem_details_for_category_erro invalid_category = "HEALTHCHECKS" params = {"category": invalid_category} - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None @@ -282,7 +282,7 @@ def test_validate_query_params_returns_correct_problem_details_for_include_actio invalid_include_actions = "NAH" params = {"includeActions": invalid_include_actions} - is_valid, problem = wrapper.validate_query_params(params) + is_valid, problem = request_validator.validate_query_params(params) assert is_valid is False assert problem is not None diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_config.py b/tests/unit/config/test_config.py similarity index 100% rename from tests/unit/test_config.py rename to tests/unit/config/test_config.py diff --git a/tests/unit/model/test_status.py b/tests/unit/model/test_status.py index 72ba73b62..eff2b68f9 100644 --- a/tests/unit/model/test_status.py +++ b/tests/unit/model/test_status.py @@ -1,4 +1,4 @@ -from eligibility_signposting_api.model.eligibility import ConditionName, Status, StatusText +from eligibility_signposting_api.model.eligibility_status import ConditionName, Status, StatusText class TestStatus: diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index baa81b8b7..7b9c39bf5 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -11,7 +11,7 @@ from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.model import rules from eligibility_signposting_api.model import rules as rules_model -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 872347a00..504888f12 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -3,7 +3,7 @@ import pytest from hamcrest import assert_that, empty -from eligibility_signposting_api.model.eligibility import NHSNumber +from eligibility_signposting_api.model.eligibility_status import NHSNumber from eligibility_signposting_api.repos import CampaignRepo, NotFoundError, PersonRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculatorFactory diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 7174f349f..ad39c01e7 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -14,7 +14,7 @@ from wireup.integration.flask import get_app_container from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.eligibility import ( +from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, @@ -39,7 +39,7 @@ build_suitability_results, get_or_default_query_params, ) -from eligibility_signposting_api.views.response_model import eligibility +from eligibility_signposting_api.views.response_model import eligibility_response from tests.fixtures.builders.model.eligibility import ( CohortResultFactory, ConditionFactory, @@ -435,12 +435,12 @@ def test_no_suitability_rules_for_actionable(): ) ], [ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_A"), - actionCode=eligibility.ActionCode("CODE123"), - description=eligibility.Description("Some description"), - urlLink=eligibility.UrlLink("https://example.com"), - urlLabel=eligibility.UrlLabel("Learn more"), + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_A"), + actionCode=eligibility_response.ActionCode("CODE123"), + description=eligibility_response.Description("Some description"), + urlLink=eligibility_response.UrlLink("https://example.com"), + urlLabel=eligibility_response.UrlLabel("Learn more"), ) ], ), @@ -455,9 +455,9 @@ def test_no_suitability_rules_for_actionable(): ) ], [ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_B"), - actionCode=eligibility.ActionCode("CODE123"), + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_B"), + actionCode=eligibility_response.ActionCode("CODE123"), description="", urlLink="", urlLabel="", @@ -483,23 +483,23 @@ def test_build_actions(suggested_actions, expected): def test_excludes_nulls_via_build_response(client: FlaskClient): - mocked_response = eligibility.EligibilityResponse( + mocked_response = eligibility_response.EligibilityResponse( responseId=uuid4(), - meta=eligibility.Meta(lastUpdated=eligibility.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), + meta=eligibility_response.Meta(lastUpdated=eligibility_response.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), processedSuggestions=[ - eligibility.ProcessedSuggestion( - condition=eligibility.ConditionName("ConditionA"), - status=eligibility.Status.actionable, - statusText=eligibility.StatusText("Go ahead"), + eligibility_response.ProcessedSuggestion( + condition=eligibility_response.ConditionName("ConditionA"), + status=eligibility_response.Status.actionable, + statusText=eligibility_response.StatusText("Go ahead"), eligibilityCohorts=[], suitabilityRules=[], actions=[ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_A"), - actionCode=eligibility.ActionCode("CODE123"), - description=eligibility.Description(""), # Should be an empty string - urlLink=eligibility.UrlLink(""), # Should be an empty string - urlLabel=eligibility.UrlLabel(""), # Should be an empty string + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_A"), + actionCode=eligibility_response.ActionCode("CODE123"), + description=eligibility_response.Description(""), # Should be an empty string + urlLink=eligibility_response.UrlLink(""), # Should be an empty string + urlLabel=eligibility_response.UrlLabel(""), # Should be an empty string ) ], ) @@ -535,23 +535,23 @@ def test_excludes_nulls_via_build_response(client: FlaskClient): def test_build_response_include_values_that_are_not_null(client: FlaskClient): - mocked_response = eligibility.EligibilityResponse( + mocked_response = eligibility_response.EligibilityResponse( responseId=uuid4(), - meta=eligibility.Meta(lastUpdated=eligibility.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), + meta=eligibility_response.Meta(lastUpdated=eligibility_response.LastUpdated(datetime(2023, 1, 1, tzinfo=UTC))), processedSuggestions=[ - eligibility.ProcessedSuggestion( - condition=eligibility.ConditionName("ConditionA"), - status=eligibility.Status.actionable, - statusText=eligibility.StatusText("Go ahead"), + eligibility_response.ProcessedSuggestion( + condition=eligibility_response.ConditionName("ConditionA"), + status=eligibility_response.Status.actionable, + statusText=eligibility_response.StatusText("Go ahead"), eligibilityCohorts=[], suitabilityRules=[], actions=[ - eligibility.Action( - actionType=eligibility.ActionType("TYPE_A"), - actionCode=eligibility.ActionCode("CODE123"), - description=eligibility.Description("Contact GP"), - urlLink=eligibility.UrlLink("https://example.dummy/"), - urlLabel=eligibility.UrlLabel("GP contact"), + eligibility_response.Action( + actionType=eligibility_response.ActionType("TYPE_A"), + actionCode=eligibility_response.ActionCode("CODE123"), + description=eligibility_response.Description("Contact GP"), + urlLink=eligibility_response.UrlLink("https://example.dummy/"), + urlLabel=eligibility_response.UrlLabel("GP contact"), ) ], ) From 199abd61f4b127f55a844001c9097faf510b5e77 Mon Sep 17 00:00:00 2001 From: Edd Almond Date: Tue, 22 Jul 2025 12:48:26 +0100 Subject: [PATCH 12/61] eli-343 following on from suggestions from AWS Security Hub, restricting access to public internet via Internet Gateway, and adding table protection in Prod for DynamoDB --- infrastructure/modules/dynamodb/dynamodb.tf | 1 + infrastructure/stacks/networking/vpc.tf | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/infrastructure/modules/dynamodb/dynamodb.tf b/infrastructure/modules/dynamodb/dynamodb.tf index 6f8f39a80..4730d2f8d 100644 --- a/infrastructure/modules/dynamodb/dynamodb.tf +++ b/infrastructure/modules/dynamodb/dynamodb.tf @@ -2,6 +2,7 @@ resource "aws_dynamodb_table" "dynamodb_table" { name = "${terraform.workspace == "default" ? "" : "${terraform.workspace}-"}${var.project_name}-${var.environment}-${var.table_name_suffix}" billing_mode = "PAY_PER_REQUEST" hash_key = var.partition_key + deletion_protection_enabled = var.environment == "prod" attribute { name = var.partition_key diff --git a/infrastructure/stacks/networking/vpc.tf b/infrastructure/stacks/networking/vpc.tf index a3ad8a04d..e88ce7894 100644 --- a/infrastructure/stacks/networking/vpc.tf +++ b/infrastructure/stacks/networking/vpc.tf @@ -21,3 +21,8 @@ resource "aws_default_security_group" "default_vpc" { } ) } + +# EC2.172 - block internet gateway access at the account level +resource "aws_vpc_block_public_access_options" "default_vpc" { + internet_gateway_block_mode = "block-bidirectional" +} From fa5ae280123759edad1b17a5137e931a060fc2a3 Mon Sep 17 00:00:00 2001 From: Karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:48:37 +0100 Subject: [PATCH 13/61] add lambda request id in logs events (#248) * add lambda request id in logs events * linting fix * revoked custom formatter and reapplied JsonFormatter * revoked formatting * added wrapper for registering requestid * Adds unit tests for log format --------- Co-authored-by: Shweta <216860557+shweta-nhs@users.noreply.github.com> --- src/eligibility_signposting_api/app.py | 4 +- .../audit/audit_models.py | 2 +- .../config/config.py | 15 --- .../logging/__init__.py | 0 .../logging/logs_manager.py | 48 +++++++ tests/unit/logging/__init__.py | 0 tests/unit/logging/test_logs_manager.py | 120 ++++++++++++++++++ 7 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 src/eligibility_signposting_api/logging/__init__.py create mode 100644 src/eligibility_signposting_api/logging/logs_manager.py create mode 100644 tests/unit/logging/__init__.py create mode 100644 tests/unit/logging/test_logs_manager.py diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index 6246a807c..70375fafe 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -10,7 +10,8 @@ from eligibility_signposting_api import audit, repos, services from eligibility_signposting_api.common.error_handler import handle_exception from eligibility_signposting_api.common.request_validator import validate_request_params -from eligibility_signposting_api.config.config import config, init_logging +from eligibility_signposting_api.config.config import config +from eligibility_signposting_api.logging.logs_manager import add_request_id_to_logs, init_logging from eligibility_signposting_api.views import eligibility_blueprint init_logging() @@ -23,6 +24,7 @@ def main() -> None: # pragma: no cover app.run(debug=config()["log_level"] == logging.DEBUG) +@add_request_id_to_logs() @validate_request_params() def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover """Run the Flask app as an AWS Lambda.""" diff --git a/src/eligibility_signposting_api/audit/audit_models.py b/src/eligibility_signposting_api/audit/audit_models.py index 17467130f..d80409a8c 100644 --- a/src/eligibility_signposting_api/audit/audit_models.py +++ b/src/eligibility_signposting_api/audit/audit_models.py @@ -86,7 +86,7 @@ class AuditCondition(CamelCaseBaseModel): class ResponseAuditData(CamelCaseBaseModel): response_id: UUID | None = None - last_updated: str | None = None + last_updated: datetime | None = None condition: list[AuditCondition] = Field(default_factory=list) diff --git a/src/eligibility_signposting_api/config/config.py b/src/eligibility_signposting_api/config/config.py index 722e90133..49faeff6b 100644 --- a/src/eligibility_signposting_api/config/config.py +++ b/src/eligibility_signposting_api/config/config.py @@ -1,10 +1,8 @@ import logging import os -from collections.abc import Sequence from functools import cache from typing import Any, NewType -from pythonjsonlogger.json import JsonFormatter from yarl import URL from eligibility_signposting_api.repos.campaign_repo import BucketName @@ -57,16 +55,3 @@ def config() -> dict[str, Any]: "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, "log_level": log_level, } - - -def init_logging(quieten: Sequence[str] = ("asyncio", "botocore", "boto3", "mangum", "urllib3")) -> None: - log_format = "%(asctime)s %(levelname)-8s %(name)s %(module)s.py:%(funcName)s():%(lineno)d %(message)s" - formatter = JsonFormatter(log_format) - handler = logging.StreamHandler() - handler.setFormatter(formatter) - logging.root.handlers = [] # Clear any existing handlers - logging.root.setLevel(LOG_LEVEL) # Set log level - logging.root.addHandler(handler) # Add handler - - for q in quieten: - logging.getLogger(q).setLevel(logging.WARNING) diff --git a/src/eligibility_signposting_api/logging/__init__.py b/src/eligibility_signposting_api/logging/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/eligibility_signposting_api/logging/logs_manager.py b/src/eligibility_signposting_api/logging/logs_manager.py new file mode 100644 index 000000000..093c06dda --- /dev/null +++ b/src/eligibility_signposting_api/logging/logs_manager.py @@ -0,0 +1,48 @@ +import logging +from collections.abc import Callable, Sequence +from contextvars import ContextVar +from functools import wraps +from typing import Any + +from mangum.types import LambdaContext, LambdaEvent +from pythonjsonlogger.json import JsonFormatter + +from eligibility_signposting_api.config.config import LOG_LEVEL + +request_id_context_var: ContextVar[str | None] = ContextVar("request_id", default=None) + +LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s %(module)s.py:%(funcName)s():%(lineno)d %(message)s" + + +def add_request_id_to_logs() -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: + aws_request_id = request_id_context_var.set(context.aws_request_id) + try: + return func(event, context) + finally: + request_id_context_var.reset(aws_request_id) + + return wrapper + + return decorator + + +class EnrichedJsonFormatter(JsonFormatter): + def add_fields(self, log_record: dict[str, Any], record: logging.LogRecord, message_dict: dict[str, Any]) -> None: + log_record["request_id"] = request_id_context_var.get() or "-" + super().add_fields(log_record, record, message_dict) + + +def init_logging(quieten: Sequence[str] = ("asyncio", "botocore", "boto3", "mangum", "urllib3")) -> None: + formatter = EnrichedJsonFormatter(LOG_FORMAT) + handler = logging.StreamHandler() + handler.setFormatter(formatter) + + logging.root.handlers = [] # Remove default handlers + logging.root.setLevel(LOG_LEVEL) + logging.root.addHandler(handler) + + for q in quieten: + logging.getLogger(q).setLevel(logging.WARNING) diff --git a/tests/unit/logging/__init__.py b/tests/unit/logging/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/logging/test_logs_manager.py b/tests/unit/logging/test_logs_manager.py new file mode 100644 index 000000000..eb9c38a6c --- /dev/null +++ b/tests/unit/logging/test_logs_manager.py @@ -0,0 +1,120 @@ +import io +import json +import logging +import threading +from http import HTTPStatus +from unittest.mock import MagicMock, Mock + +import pytest +from mangum.types import LambdaContext + +from eligibility_signposting_api.logging.logs_manager import ( + LOG_FORMAT, + EnrichedJsonFormatter, + add_request_id_to_logs, + request_id_context_var, +) + + +def test_decorator_sets_request_id_in_context(): + test_request_id = "test-id-12345" + mock_context = MagicMock() + mock_context.aws_request_id = test_request_id + + @add_request_id_to_logs() + def decorated_handler(event, context): # noqa : ARG001 + return request_id_context_var.get() + + result = decorated_handler({}, mock_context) + + assert result == test_request_id + + +def test_decorator_preserves_function_return_value(): + expected_result = {"statusCode": 200, "body": "Success"} + mock_context = MagicMock() + mock_context.aws_request_id = "any-id" + + @add_request_id_to_logs() + def decorated_handler(event, context): # noqa : ARG001 + return expected_result + + result = decorated_handler({}, mock_context) + + assert result == expected_result + + +def test_request_id_context_is_properly_isolated(): + results = {} + + @add_request_id_to_logs() + def decorated_handler(event, context): # noqa : ARG001 + rid = request_id_context_var.get() + results[threading.current_thread().name] = rid + return rid + + def thread_func(name, rid): # noqa : ARG001 + mock_context = MagicMock(aws_request_id=rid) + decorated_handler({}, mock_context) + + threads = [ + threading.Thread(target=thread_func, name="Thread-A", args=("Thread-A", "id-A")), + threading.Thread(target=thread_func, name="Thread-B", args=("Thread-B", "id-B")), + threading.Thread(target=thread_func, name="Thread-C", args=("Thread-C", "id-C")), + ] + + for t in threads: + t.start() + for t in threads: + t.join() + + assert results["Thread-A"] == "id-A" + assert request_id_context_var.get() is None + + assert results["Thread-B"] == "id-B" + assert request_id_context_var.get() is None + + assert results["Thread-C"] == "id-C" + assert request_id_context_var.get() is None + + +@pytest.fixture +def lambda_context(): + context = Mock(spec=LambdaContext) + context.aws_request_id = "test-request-id" + return context + + +def test_enriched_json_formatter_adds_all_fields(lambda_context): + @add_request_id_to_logs() + def test_handler(event, context): # noqa : ARG001 + logger = logging.getLogger("test_logger") + logger.info("Test log inside handler") + return HTTPStatus.OK + + log_stream = io.StringIO() + handler = logging.StreamHandler(log_stream) + handler.setFormatter(EnrichedJsonFormatter(LOG_FORMAT)) + + test_logger = logging.getLogger("test_logger") + test_logger.handlers = [] + test_logger.addHandler(handler) + test_logger.setLevel(logging.INFO) + + result = test_handler({}, lambda_context) + log_output = log_stream.getvalue() + + test_logger.removeHandler(handler) + + assert result == HTTPStatus.OK + logged_json = json.loads(log_output) + + assert logged_json["request_id"] == lambda_context.aws_request_id + assert "asctime" in logged_json + assert logged_json["levelname"] == "INFO" + assert logged_json["name"] == "test_logger" + assert logged_json["module"] == "test_logs_manager" + assert logged_json["funcName"] == "test_handler" + assert "lineno" in logged_json + assert logged_json["message"] == "Test log inside handler" + assert request_id_context_var.get() is None From 6b1848d8b208fd92001efe6d68ef6273e4cb800c Mon Sep 17 00:00:00 2001 From: Robert Bailiff Date: Tue, 22 Jul 2025 17:07:39 +0100 Subject: [PATCH 14/61] Feature/rgjb aa eli 329 add xray tracing for lambda (#243) * Added xray permissions policy for lambda * Add xray vpc endpoint * Added xray to the permissions boundary * Added xray to the assumed role permissions boundary * Testing permission boundary. * testing perm bound. --------- Co-authored-by: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> --- .../assumed_role_permissions_boundary.tf | 3 +- .../stacks/api-layer/iam_policies.tf | 56 ++++++++++++------- .../iams_permissions_boundary.tf | 3 +- infrastructure/stacks/networking/locals.tf | 2 + 4 files changed, 43 insertions(+), 21 deletions(-) diff --git a/infrastructure/stacks/api-layer/assumed_role_permissions_boundary.tf b/infrastructure/stacks/api-layer/assumed_role_permissions_boundary.tf index 2fd4e8454..980bf8e61 100644 --- a/infrastructure/stacks/api-layer/assumed_role_permissions_boundary.tf +++ b/infrastructure/stacks/api-layer/assumed_role_permissions_boundary.tf @@ -33,7 +33,8 @@ data "aws_iam_policy_document" "assumed_role_permissions_boundary" { "support:*", "sqs:*", "tag:*", - "trustedadvisor:*" + "trustedadvisor:*", + "xray:*" ] resources = ["*"] diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 00b5d914f..8af65233e 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -1,7 +1,7 @@ # Read-only policy for DynamoDB data "aws_iam_policy_document" "dynamodb_read_policy_doc" { statement { - actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"] + actions = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:Scan"] resources = [module.eligibility_status_table.arn] } } @@ -16,7 +16,7 @@ resource "aws_iam_role_policy" "lambda_dynamodb_read_policy" { # Write-only policy for DynamoDB data "aws_iam_policy_document" "dynamodb_write_policy_doc" { statement { - actions = ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:BatchWriteItem"] + actions = ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:BatchWriteItem"] resources = [module.eligibility_status_table.arn] } } @@ -37,7 +37,7 @@ data "aws_iam_policy_document" "dynamo_kms_access_policy_doc" { # Attach dynamoDB write policy to external write role resource "aws_iam_role_policy" "external_dynamodb_write_policy" { - count = length(aws_iam_role.write_access_role) + count = length(aws_iam_role.write_access_role) name = "DynamoDBWriteAccess" role = aws_iam_role.write_access_role[count.index].id policy = data.aws_iam_policy_document.dynamodb_write_policy_doc.json @@ -45,7 +45,7 @@ resource "aws_iam_role_policy" "external_dynamodb_write_policy" { # Attach dynamo KMS policy to external write role resource "aws_iam_role_policy" "external_kms_access_policy" { - count = length(aws_iam_role.write_access_role) + count = length(aws_iam_role.write_access_role) name = "KMSAccessForDynamoDB" role = aws_iam_role.write_access_role[count.index].id policy = data.aws_iam_policy_document.dynamo_kms_access_policy_doc.json @@ -65,7 +65,7 @@ data "aws_iam_policy_document" "s3_rules_bucket_policy" { ] condition { test = "Bool" - values = ["true"] + values = ["true"] variable = "aws:SecureTransport" } } @@ -90,7 +90,7 @@ data "aws_iam_policy_document" "rules_s3_bucket_policy" { "${module.s3_rules_bucket.storage_bucket_arn}/*", ] principals { - type = "*" + type = "*" identifiers = ["*"] } condition { @@ -121,7 +121,7 @@ data "aws_iam_policy_document" "audit_s3_bucket_policy" { "${module.s3_audit_bucket.storage_bucket_arn}/*", ] principals { - type = "*" + type = "*" identifiers = ["*"] } condition { @@ -192,7 +192,7 @@ resource "aws_iam_role_policy_attachment" "lambda_logs_policy_attachment" { # Policy doc for S3 Audit bucket data "aws_iam_policy_document" "s3_audit_bucket_policy" { statement { - sid = "AllowSSLRequestsOnly" + sid = "AllowSSLRequestsOnly" actions = ["s3:*"] resources = [ module.s3_audit_bucket.storage_bucket_arn, @@ -200,7 +200,7 @@ data "aws_iam_policy_document" "s3_audit_bucket_policy" { ] condition { test = "Bool" - values = ["true"] + values = ["true"] variable = "aws:SecureTransport" } } @@ -222,10 +222,10 @@ data "aws_iam_policy_document" "dynamodb_kms_key_policy" { sid = "EnableIamUserPermissions" effect = "Allow" principals { - type = "AWS" + type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } - actions = ["kms:*"] + actions = ["kms:*"] resources = ["*"] } @@ -233,7 +233,7 @@ data "aws_iam_policy_document" "dynamodb_kms_key_policy" { sid = "AllowLambdaDecrypt" effect = "Allow" principals { - type = "AWS" + type = "AWS" identifiers = [aws_iam_role.eligibility_lambda_role.arn] } actions = [ @@ -260,10 +260,10 @@ data "aws_iam_policy_document" "s3_rules_kms_key_policy" { sid = "EnableIamUserPermissions" effect = "Allow" principals { - type = "AWS" + type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } - actions = ["kms:*"] + actions = ["kms:*"] resources = ["*"] } @@ -271,10 +271,10 @@ data "aws_iam_policy_document" "s3_rules_kms_key_policy" { sid = "AllowLambdaDecrypt" effect = "Allow" principals { - type = "AWS" + type = "AWS" identifiers = [aws_iam_role.eligibility_lambda_role.arn] } - actions = ["kms:Decrypt"] + actions = ["kms:Decrypt"] resources = ["*"] } } @@ -293,17 +293,17 @@ data "aws_iam_policy_document" "s3_audit_kms_key_policy" { sid = "EnableIamUserPermissions" effect = "Allow" principals { - type = "AWS" + type = "AWS" identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"] } - actions = ["kms:*"] + actions = ["kms:*"] resources = ["*"] } statement { sid = "AllowLambdaFullWrite" effect = "Allow" principals { - type = "AWS" + type = "AWS" identifiers = [aws_iam_role.eligibility_lambda_role.arn, aws_iam_role.eligibility_audit_firehose_role.arn] } actions = [ @@ -340,3 +340,21 @@ resource "aws_iam_role_policy" "lambda_firehose_policy" { role = aws_iam_role.eligibility_lambda_role.id policy = data.aws_iam_policy_document.lambda_firehose_write_policy.json } + +data "aws_iam_policy_document" "lambda_xray_tracing_permissions_policy" { + statement { + sid = "AllowLambdaToPutToXRay" + effect = "Allow" + actions = [ + "xray:PutTraceSegments", + "xray:PutTelemetryRecords" + ] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "lambda_xray_tracing_policy" { + name = "LambdaXRayWritePolicy" + role = aws_iam_role.eligibility_lambda_role.id + policy = data.aws_iam_policy_document.lambda_xray_tracing_permissions_policy.json +} diff --git a/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf b/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf index 27909c885..8d7940668 100644 --- a/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf +++ b/infrastructure/stacks/iams-developer-roles/iams_permissions_boundary.tf @@ -33,7 +33,8 @@ data "aws_iam_policy_document" "permissions_boundary" { "support:*", "sqs:*", "tag:*", - "trustedadvisor:*" + "trustedadvisor:*", + "xray:*" ] resources = ["*"] diff --git a/infrastructure/stacks/networking/locals.tf b/infrastructure/stacks/networking/locals.tf index 5acda7626..9114c1144 100644 --- a/infrastructure/stacks/networking/locals.tf +++ b/infrastructure/stacks/networking/locals.tf @@ -22,6 +22,8 @@ locals { sts = "com.amazonaws.${local.region}.sts" sqs = "com.amazonaws.${local.region}.sqs" kinesis-firehose = "com.amazonaws.${local.region}.kinesis-firehose" + xray = "com.amazonaws.${local.region}.xray" + } # VPC Gateway Endpoints From 33a630fd0711251df4d223919011c3569a8feaad Mon Sep 17 00:00:00 2001 From: Edd Almond Date: Wed, 23 Jul 2025 10:16:22 +0100 Subject: [PATCH 15/61] bugfix - Github action needs permission to modify public access block --- .../stacks/iams-developer-roles/github_actions_policies.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf index 78e909d86..f364c77f4 100644 --- a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf +++ b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf @@ -238,6 +238,7 @@ resource "aws_iam_policy" "api_infrastructure" { "ec2:ReplaceNetworkAclAssociation", "ec2:DeleteSecurityGroup", "ec2:DeleteNetworkAcl", + "ec2:ModifyVpcBlockPublicAccessOptions", # ssm "ssm:GetParameter", From 9b16ba0270d093cbc50d3dc5c1dbcf2b80789ada Mon Sep 17 00:00:00 2001 From: Edd Almond Date: Wed, 23 Jul 2025 11:14:17 +0100 Subject: [PATCH 16/61] bugfix - changing permission to be wildcard resource, as it's an account level permission --- .../stacks/iams-developer-roles/github_actions_policies.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf index f364c77f4..1227415e7 100644 --- a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf +++ b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf @@ -190,6 +190,7 @@ resource "aws_iam_policy" "api_infrastructure" { "ssm:DescribeParameters", "ec2:Describe*", "ec2:DescribeVpcs", + "ec2:ModifyVpcBlockPublicAccessOptions", # API Gateway domain and deployment "apigateway:*", # ACM for certs @@ -204,6 +205,7 @@ resource "aws_iam_policy" "api_infrastructure" { "logs:PutLogEvents", # IAM PassRole for logging role association (if needed) "iam:PassRole" + ], Resource = "*" #checkov:skip=CKV_AWS_289: Actions require wildcard resource @@ -238,7 +240,6 @@ resource "aws_iam_policy" "api_infrastructure" { "ec2:ReplaceNetworkAclAssociation", "ec2:DeleteSecurityGroup", "ec2:DeleteNetworkAcl", - "ec2:ModifyVpcBlockPublicAccessOptions", # ssm "ssm:GetParameter", From ea45743b76f06046a3fae7ba85502fa9472a6454 Mon Sep 17 00:00:00 2001 From: Karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 24 Jul 2025 09:17:41 +0100 Subject: [PATCH 17/61] Added api gateway request id, moved request id logging to app.py (#252) --- src/eligibility_signposting_api/app.py | 2 + .../logging/logs_helper.py | 29 ++++++++ .../views/eligibility.py | 7 -- tests/unit/logging/test_logs_helper.py | 70 +++++++++++++++++++ tests/unit/views/test_eligibility.py | 40 ----------- 5 files changed, 101 insertions(+), 47 deletions(-) create mode 100644 src/eligibility_signposting_api/logging/logs_helper.py create mode 100644 tests/unit/logging/test_logs_helper.py diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index 70375fafe..a8772c855 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -11,6 +11,7 @@ from eligibility_signposting_api.common.error_handler import handle_exception from eligibility_signposting_api.common.request_validator import validate_request_params from eligibility_signposting_api.config.config import config +from eligibility_signposting_api.logging.logs_helper import log_request_ids from eligibility_signposting_api.logging.logs_manager import add_request_id_to_logs, init_logging from eligibility_signposting_api.views import eligibility_blueprint @@ -25,6 +26,7 @@ def main() -> None: # pragma: no cover @add_request_id_to_logs() +@log_request_ids() @validate_request_params() def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover """Run the Flask app as an AWS Lambda.""" diff --git a/src/eligibility_signposting_api/logging/logs_helper.py b/src/eligibility_signposting_api/logging/logs_helper.py new file mode 100644 index 000000000..cb71bc911 --- /dev/null +++ b/src/eligibility_signposting_api/logging/logs_helper.py @@ -0,0 +1,29 @@ +import logging +from collections.abc import Callable +from functools import wraps +from typing import Any + +from mangum.types import LambdaContext, LambdaEvent + +logger = logging.getLogger(__name__) + + +def log_request_ids() -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: + gateway_request_id = event.get("requestContext", {}).get("requestId") + headers = event.get("headers", {}) + logger.info( + "request trace metadata", + extra={ + "x_request_id": headers.get("X-Request-ID"), + "x_correlation_id": headers.get("X-Correlation-ID"), + "gateway_request_id": gateway_request_id, + }, + ) + return func(event, context) + + return wrapper + + return decorator diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 6f279653a..802515347 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -29,13 +29,6 @@ @eligibility_blueprint.before_request def before_request() -> None: - logger.info( - "request details", - extra={ - "X-Request-ID": request.headers.get("X-Request-ID"), - "X-Correlation-ID": request.headers.get("X-Correlation-ID"), - }, - ) AuditContext.add_request_details(request) diff --git a/tests/unit/logging/test_logs_helper.py b/tests/unit/logging/test_logs_helper.py new file mode 100644 index 000000000..5594cbe36 --- /dev/null +++ b/tests/unit/logging/test_logs_helper.py @@ -0,0 +1,70 @@ +import logging +from http import HTTPStatus +from unittest.mock import Mock + +import pytest +from mangum.types import LambdaContext + + +@pytest.fixture +def lambda_context(): + context = Mock(spec=LambdaContext) + context.aws_request_id = "test-request-id" + return context + + +@pytest.mark.parametrize( + ("headers", "gateway_request_id", "expected_extra"), + [ + ( + {"X-Request-ID": "req-123", "X-Correlation-ID": "corr-abc"}, + "gw-id-999", + { + "x_request_id": "req-123", + "x_correlation_id": "corr-abc", + "gateway_request_id": "gw-id-999", + }, + ), + ( + {}, # No headers + "gw-id-000", + { + "x_request_id": None, + "x_correlation_id": None, + "gateway_request_id": "gw-id-000", + }, + ), + ( + {"X-Request-ID": "req-local"}, + None, # No requestContext (non-Gateway trigger) + { + "x_request_id": "req-local", + "x_correlation_id": None, + "gateway_request_id": None, + }, + ), + ], +) +def test_log_request_ids_decorator_logs_metadata(headers, gateway_request_id, expected_extra, lambda_context, caplog): + from eligibility_signposting_api.app import log_request_ids + + event = {"headers": headers} + if gateway_request_id is not None: + event["requestContext"] = {"requestId": gateway_request_id} + + @log_request_ids() + def test_handler(event, context): # noqa : ARG001 + logger = logging.getLogger("test_logger") + logger.info("Inside test handler") + return HTTPStatus.OK + + with caplog.at_level(logging.INFO): + test_handler(event, lambda_context) + + for record in caplog.records: + if record.message == "request trace metadata": + for key, val in expected_extra.items(): + assert getattr(record, key) == val + break + else: + pytest.fail("'request trace metadata' log not found") diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index ad39c01e7..963b1a9ab 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -586,46 +586,6 @@ def test_build_response_include_values_that_are_not_null(client: FlaskClient): assert action["urlLabel"] == "GP contact" -@pytest.mark.parametrize( - ("headers", "expected_request_id"), - [ - ({"X-Request-ID": "test-request-id-123"}, "test-request-id-123"), - ( - {"X-Request-ID": ""}, - "", - ), - ( - {}, # No headers provided - None, - ), - ], -) -def test_request_id_from_header_logging_variants( - app: Flask, client: FlaskClient, caplog, headers: dict[str, str], expected_request_id: str -): - """ - This test checks that the x-request-ID is logged so that it can be used to correlate logs - with that of the logs from api-gateway - """ - with ( - get_app_container(app).override.service(EligibilityService, new=FakeEligibilityService()), - get_app_container(app).override.service(AuditService, new=FakeAuditService()), - ): - with caplog.at_level(logging.INFO): - response = client.get("/patient-check/12345", headers=headers) - - request_id_logged = False - for record in caplog.records: - request_id = getattr(record, "X-Request-ID", None) - - if request_id == expected_request_id: - request_id_logged = True - break - - assert request_id_logged - assert response.status_code == HTTPStatus.OK - - def test_get_or_default_query_params_with_no_args(app: Flask): with app.test_request_context("/patient-check"): result = get_or_default_query_params() From de8ebac8222ae07e3eeeff3cb5f053566493c983 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:27:27 +0100 Subject: [PATCH 18/61] ELI-351 and ELI-342: Refactors and fixes Cohort Schema Mismatch (#253) * Adds campaign_evaluator and tests * Adds person_data_reader and tests * Injects person data reader and campaign processor into eligibility calculator * ELI-342 Dynamo Cohort Schema Mismatch * ELI-342: Fixes usage of person cohorts method and tests --- .../calculators/eligibility_calculator.py | 48 ++----- .../services/calculators/rule_calculator.py | 22 +--- .../services/{rules => operators}/__init__.py | 0 .../{rules => operators}/operators.py | 0 .../services/processors/__init__.py | 0 .../services/processors/campaign_evaluator.py | 42 +++++++ .../services/processors/person_data_reader.py | 26 ++++ tests/fixtures/builders/repos/person.py | 10 +- .../test_eligibility_calculator.py | 47 +------ .../unit/services/operators/test_operators.py | 2 +- tests/unit/services/processors/__init__.py | 0 .../processors/test_campaign_evaluator.py | 117 ++++++++++++++++++ .../processors/test_person_data_reader.py | 86 +++++++++++++ 13 files changed, 298 insertions(+), 102 deletions(-) rename src/eligibility_signposting_api/services/{rules => operators}/__init__.py (100%) rename src/eligibility_signposting_api/services/{rules => operators}/operators.py (100%) create mode 100644 src/eligibility_signposting_api/services/processors/__init__.py create mode 100644 src/eligibility_signposting_api/services/processors/campaign_evaluator.py create mode 100644 src/eligibility_signposting_api/services/processors/person_data_reader.py create mode 100644 tests/unit/services/processors/__init__.py create mode 100644 tests/unit/services/processors/test_campaign_evaluator.py create mode 100644 tests/unit/services/processors/test_person_data_reader.py diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index a2e4f6bd8..e73bd39dc 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING, Any from eligibility_signposting_api.audit.audit_context import AuditContext +from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator +from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader if TYPE_CHECKING: from eligibility_signposting_api.model.rules import ( @@ -58,42 +60,10 @@ class EligibilityCalculator: person_data: Row campaign_configs: Collection[rules.CampaignConfig] - results: list[eligibility_status.Condition] = field(default_factory=list) - - @property - def active_campaigns(self) -> list[rules.CampaignConfig]: - return [cc for cc in self.campaign_configs if cc.campaign_live] - - def campaigns_grouped_by_condition_name( - self, conditions: list[str], category: str - ) -> Iterator[tuple[eligibility_status.ConditionName, list[rules.CampaignConfig]]]: - """Generator that yields campaign groups filtered by condition names and campaign category.""" - - mapping = { - "ALL": {"V", "S"}, - "VACCINATIONS": {"V"}, - "SCREENING": {"S"}, - } - - allowed_types = mapping.get(category, set()) + campaign_evaluator: CampaignEvaluator = field(default_factory=CampaignEvaluator) + person_data_reader: PersonDataReader = field(default_factory=PersonDataReader) - filter_all_conditions = "ALL" in conditions - - for condition_name, campaign_group in groupby( - sorted(self.active_campaigns, key=attrgetter("target")), - key=attrgetter("target"), - ): - campaigns = list(campaign_group) - if campaigns[0].type in allowed_types and (filter_all_conditions or str(condition_name) in conditions): - yield condition_name, campaigns - - @property - def person_cohorts(self) -> set[str]: - cohorts_row: Mapping[str, dict[str, dict[str, dict[str, Any]]]] = next( - (row for row in self.person_data if row.get("ATTRIBUTE_TYPE") == "COHORTS"), - {}, - ) - return set(cohorts_row.get("COHORT_MAP", {}).get("cohorts", {}).get("M", {}).keys()) + results: list[eligibility_status.Condition] = field(default_factory=list) @staticmethod def get_the_best_cohort_memberships( @@ -164,7 +134,10 @@ def evaluate_eligibility( actions: list[SuggestedAction] | None = [] action_rule_priority, action_rule_name = None, None - for condition_name, campaign_group in self.campaigns_grouped_by_condition_name(conditions, category): + requested_grouped_campaigns = self.campaign_evaluator.get_requested_grouped_campaigns( + self.campaign_configs, conditions, category + ) + for condition_name, campaign_group in requested_grouped_campaigns: best_active_iteration: Iteration | None best_candidate: IterationResult best_campaign_id: CampaignID | None @@ -289,7 +262,8 @@ def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, Coh filter_rules, suppression_rules = self.get_rules_by_type(active_iteration) for cohort in sorted(active_iteration.iteration_cohorts, key=attrgetter("priority")): # Base Eligibility - check - if cohort.cohort_label in self.person_cohorts or cohort.is_magic_cohort: + person_cohorts = self.person_data_reader.get_person_cohorts(self.person_data) + if cohort.cohort_label in person_cohorts or cohort.is_magic_cohort: # Eligibility - check if self.is_eligible_by_filter_rules(cohort, cohort_results, filter_rules): # Actionability - evaluation diff --git a/src/eligibility_signposting_api/services/calculators/rule_calculator.py b/src/eligibility_signposting_api/services/calculators/rule_calculator.py index 96b0dfd87..4791aead6 100644 --- a/src/eligibility_signposting_api/services/calculators/rule_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/rule_calculator.py @@ -1,13 +1,14 @@ from __future__ import annotations from collections.abc import Collection, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from hamcrest.core.string_description import StringDescription from eligibility_signposting_api.model import eligibility_status, rules -from eligibility_signposting_api.services.rules.operators import OperatorRegistry +from eligibility_signposting_api.services.operators.operators import OperatorRegistry +from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader Row = Collection[Mapping[str, Any]] @@ -17,6 +18,8 @@ class RuleCalculator: person_data: Row rule: rules.IterationRule + person_data_reader: PersonDataReader = field(default_factory=PersonDataReader) + def evaluate_exclusion(self) -> tuple[eligibility_status.Status, eligibility_status.Reason]: """Evaluate if a particular rule excludes this person. Return the result, and the reason for the result.""" attribute_value = self.get_attribute_value() @@ -43,15 +46,7 @@ def get_attribute_value(self) -> str | None: (r for r in self.person_data if r.get("ATTRIBUTE_TYPE", "") == "COHORTS"), None ) if cohorts: - attr_name = ( - "COHORT_MAP" - if not self.rule.attribute_name or self.rule.attribute_name == "COHORT_LABEL" - else self.rule.attribute_name - ) - cohort_map = self.get_value(cohorts, attr_name) - cohorts_dict = self.get_value(cohort_map, "cohorts") - m_dict = self.get_value(cohorts_dict, "M") - person_cohorts: set[str] = set(m_dict.keys()) + person_cohorts = self.person_data_reader.get_person_cohorts(self.person_data) attribute_value = ",".join(person_cohorts) else: attribute_value = None @@ -66,11 +61,6 @@ def get_attribute_value(self) -> str | None: raise NotImplementedError(msg) return attribute_value - @staticmethod - def get_value(dictionary: Mapping[str, Any] | None, key: str) -> dict: - v = dictionary.get(key, {}) if isinstance(dictionary, dict) else {} - return v if isinstance(v, dict) else {} - def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility_status.Status, str, bool]: """Evaluate a rule against a person data attribute. Return the result, and the reason for the result.""" matcher_class = OperatorRegistry.get(self.rule.operator) diff --git a/src/eligibility_signposting_api/services/rules/__init__.py b/src/eligibility_signposting_api/services/operators/__init__.py similarity index 100% rename from src/eligibility_signposting_api/services/rules/__init__.py rename to src/eligibility_signposting_api/services/operators/__init__.py diff --git a/src/eligibility_signposting_api/services/rules/operators.py b/src/eligibility_signposting_api/services/operators/operators.py similarity index 100% rename from src/eligibility_signposting_api/services/rules/operators.py rename to src/eligibility_signposting_api/services/operators/operators.py diff --git a/src/eligibility_signposting_api/services/processors/__init__.py b/src/eligibility_signposting_api/services/processors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/eligibility_signposting_api/services/processors/campaign_evaluator.py b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py new file mode 100644 index 000000000..286e43e7c --- /dev/null +++ b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py @@ -0,0 +1,42 @@ +from collections.abc import Collection, Iterator +from itertools import groupby +from operator import attrgetter + +from wireup import service + +from eligibility_signposting_api.model import eligibility_status, rules + + +@service +class CampaignEvaluator: + """Filters and groups campaign configurations.""" + + def get_active_campaigns(self, campaign_configs: Collection[rules.CampaignConfig]) -> list[rules.CampaignConfig]: + return [cc for cc in campaign_configs if cc.campaign_live] + + def get_requested_grouped_campaigns( + self, campaign_configs: Collection[rules.CampaignConfig], conditions: list[str], category: str + ) -> Iterator[tuple[eligibility_status.ConditionName, list[rules.CampaignConfig]]]: + mapping = { + "ALL": {"V", "S"}, + "VACCINATIONS": {"V"}, + "SCREENING": {"S"}, + } + + allowed_types = mapping.get(category, set()) + + filter_all_conditions = "ALL" in conditions + + active_campaigns = self.get_active_campaigns(campaign_configs) + + for condition_name, campaign_group in groupby( + sorted(active_campaigns, key=attrgetter("target")), + key=attrgetter("target"), + ): + campaigns = list(campaign_group) + if ( + campaigns + and campaigns[0].type in allowed_types + and (filter_all_conditions or str(condition_name) in conditions) + ): + yield condition_name, campaigns diff --git a/src/eligibility_signposting_api/services/processors/person_data_reader.py b/src/eligibility_signposting_api/services/processors/person_data_reader.py new file mode 100644 index 000000000..20a8f6dca --- /dev/null +++ b/src/eligibility_signposting_api/services/processors/person_data_reader.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from collections.abc import Collection, Mapping +from typing import Any + +from wireup import service + +Row = Collection[Mapping[str, Any]] + + +@service +class PersonDataReader: + """Handles extracting and interpreting person data.""" + + def get_person_cohorts(self, person_data: Row) -> set[str]: + cohorts_row: Mapping[str, list[dict[str, str]]] = next( + (row for row in person_data if row.get("ATTRIBUTE_TYPE") == "COHORTS"), + {}, + ) + person_cohorts = set() + + for membership in cohorts_row.get("COHORT_MEMBERSHIPS", []): + if membership.get("COHORT_LABEL"): + person_cohorts.add(membership.get("COHORT_LABEL")) + + return person_cohorts diff --git a/tests/fixtures/builders/repos/person.py b/tests/fixtures/builders/repos/person.py index 6cc418e19..6b9772acb 100644 --- a/tests/fixtures/builders/repos/person.py +++ b/tests/fixtures/builders/repos/person.py @@ -66,13 +66,9 @@ def person_rows_builder( # noqa:PLR0913 { "NHS_NUMBER": key, "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MAP": { - "cohorts": { - "M": { - cohort: {"M": {"dateJoined": {"S": faker.past_date().strftime("%Y%m%d")}}} for cohort in cohorts - } - } - }, + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": cohort, "DATE_JOINED": faker.past_date().strftime("%Y%m%d")} for cohort in cohorts + ], }, ] rows.extend( diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 7b9c39bf5..819feb0c5 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -5,7 +5,7 @@ from faker import Faker from flask import Flask, g from freezegun import freeze_time -from hamcrest import assert_that, contains_exactly, contains_inanyorder, equal_to, has_item, has_items, is_, is_in +from hamcrest import assert_that, contains_exactly, contains_inanyorder, equal_to, has_item, has_items, is_in from pydantic import HttpUrl, ValidationError from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent @@ -95,7 +95,8 @@ def test_not_base_eligible(faker: Faker): target="RSV", iterations=[ rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")] + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], + iteration_rules=[], ) ], ) @@ -175,6 +176,7 @@ def test_only_live_campaigns_considered(faker: Faker): iterations=[ rule_builder.IterationFactory.build( iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], + iteration_rules=[], ) ], start_date=datetime.date(2025, 4, 20), @@ -218,7 +220,7 @@ def test_campaigns_with_applicable_iteration_types_in_campaign_level_considered( # Given nhs_number = NHSNumber(faker.nhs_number()) - person_rows = person_rows_builder(nhs_number) + person_rows = person_rows_builder(nhs_number, cohorts=[]) campaign_configs = [rule_builder.CampaignConfigFactory.build(target="RSV", iteration_type=iteration_type)] calculator = EligibilityCalculator(person_rows, campaign_configs) @@ -247,7 +249,7 @@ def test_campaigns_with_applicable_iteration_types_in_iteration_level_considered # Given nhs_number = NHSNumber(faker.nhs_number()) - person_rows = person_rows_builder(nhs_number) + person_rows = person_rows_builder(nhs_number, cohorts=[]) campaign_configs = [ rule_builder.CampaignConfigFactory.build( target="RSV", iterations=[rule_builder.IterationFactory.build(type=iteration_type)] @@ -935,11 +937,6 @@ def test_status_on_target_based_on_last_successful_date( Status.not_eligible, "cohort label is the default attribute name for the cohort attribute level", ), - ( - rules.RuleAttributeName("LOCATION"), - Status.actionable, - "attribute name that is not cohort label", - ), ], ) def test_status_on_cohort_attribute_level( @@ -2383,38 +2380,6 @@ def test_should_not_include_actions_when_include_actions_flag_is_false_when_stat ) -@pytest.mark.parametrize( - ("campaign_target", "campaign_type", "conditions_filter", "category_filter", "expected_result"), - [ - # Multiple matching campaigns under the same condition - ("RSV", "V", ["RSV"], "VACCINATIONS", [("RSV", "V")]), - ("RSV", "V", ["COVID"], "VACCINATIONS", []), - ("RSV", "S", ["RSV"], "ALL", [("RSV", "S")]), - ("RSV", "S", ["ALL"], "ALL", [("RSV", "S")]), - ("RSV", "S", ["RSV"], "VACCINATIONS", []), - # Multiple campaigns with different types under the same condition name - ("RSV", "V", ["RSV"], "ALL", [("RSV", "V")]), - # Campaign is live but condition not in filter (no yield) - ("FLU", "V", ["COVID", "RSV"], "ALL", []), - # Category is ALL and condition filter includes ALL (everything matches) - ("FLU", "S", ["ALL"], "ALL", [("FLU", "S")]), - # Condition filter is unknown (should not match anything) - ("COVID", "V", ["UNKNOWN"], "VACCINATIONS", []), - # Campaign with the target matching one of several condition filters - ("FLU", "V", ["COVID", "FLU"], "VACCINATIONS", [("FLU", "V")]), - ], -) -def test_campaigns_grouped_by_condition_name_filters_correctly( - campaign_target, campaign_type, conditions_filter, category_filter, expected_result -): - campaign = rule_builder.CampaignConfigFactory.build(target=campaign_target, type=campaign_type, campaign_live=True) - - calculator = EligibilityCalculator(person_data=[], campaign_configs=[campaign]) - result = list(calculator.campaigns_grouped_by_condition_name(conditions_filter, category_filter)) - - assert_that([(str(name), group[0].type) for name, group in result], is_(expected_result)) - - @pytest.mark.parametrize( ( "test_comment", diff --git a/tests/unit/services/operators/test_operators.py b/tests/unit/services/operators/test_operators.py index 1c2b2ba70..ffb5777c5 100644 --- a/tests/unit/services/operators/test_operators.py +++ b/tests/unit/services/operators/test_operators.py @@ -3,7 +3,7 @@ from hamcrest import assert_that, equal_to from eligibility_signposting_api.model.rules import RuleOperator -from eligibility_signposting_api.services.rules.operators import Operator, OperatorRegistry +from eligibility_signposting_api.services.operators.operators import Operator, OperatorRegistry # Test cases: person_data, rule_operator, rule_value, expected, test_comment cases: list[tuple[str | None, RuleOperator, str | None, bool, str]] = [] diff --git a/tests/unit/services/processors/__init__.py b/tests/unit/services/processors/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/services/processors/test_campaign_evaluator.py b/tests/unit/services/processors/test_campaign_evaluator.py new file mode 100644 index 000000000..e968e3e9e --- /dev/null +++ b/tests/unit/services/processors/test_campaign_evaluator.py @@ -0,0 +1,117 @@ +import datetime + +import pytest +from hamcrest import assert_that, is_ + +from eligibility_signposting_api.model.rules import CampaignID +from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator +from tests.fixtures.builders.model import rule + + +@pytest.fixture +def campaign_evaluator(): + return CampaignEvaluator() + + +@pytest.mark.parametrize( + ("campaign_target", "campaign_type", "conditions_filter", "category_filter", "expected_result"), + [ + ("RSV", "V", ["RSV"], "VACCINATIONS", [("RSV", "V")]), + ("RSV", "V", ["COVID"], "VACCINATIONS", []), + ("RSV", "S", ["RSV"], "ALL", [("RSV", "S")]), + ("RSV", "S", ["ALL"], "ALL", [("RSV", "S")]), + ("RSV", "S", ["RSV"], "VACCINATIONS", []), + ("RSV", "V", ["RSV"], "ALL", [("RSV", "V")]), + ("FLU", "V", ["COVID", "RSV"], "ALL", []), + ("FLU", "S", ["ALL"], "ALL", [("FLU", "S")]), + ("COVID", "V", ["UNKNOWN"], "VACCINATIONS", []), + ("FLU", "V", ["COVID", "FLU"], "VACCINATIONS", [("FLU", "V")]), + ], +) +def test_campaigns_grouped_by_condition_name_filters_correctly( # noqa: PLR0913 + campaign_evaluator, campaign_target, campaign_type, conditions_filter, category_filter, expected_result +): + campaign = rule.CampaignConfigFactory.build(target=campaign_target, type=campaign_type) + + result = campaign_evaluator.get_requested_grouped_campaigns([campaign], conditions_filter, category_filter) + assert_that([(str(name), group[0].type) for name, group in result], is_(expected_result)) + + +def test_campaigns_grouped_by_condition_name_with_no_campaigns(campaign_evaluator): + result = campaign_evaluator.get_requested_grouped_campaigns([], ["RSV"], "VACCINATIONS") + assert_that(list(result), is_([])) + + +def test_campaigns_grouped_by_condition_name_with_no_active_campaigns(campaign_evaluator): + campaign = rule.CampaignConfigFactory.build( + target="RSV", type="V", start_date=datetime.date(2025, 4, 20), end_date=datetime.date(2025, 4, 21) + ) + + result = campaign_evaluator.get_requested_grouped_campaigns([campaign], ["RSV"], "VACCINATIONS") + assert_that(list(result), is_([])) + + +@pytest.mark.parametrize( + ("category_filter", "campaign_type", "expected_count"), + [ + ("SCREENING", "S", 1), + ("SCREENING", "V", 0), + ("INVALID_CATEGORY", "S", 0), + ], +) +def test_campaigns_grouped_by_condition_name_with_various_categories( + campaign_evaluator, category_filter, campaign_type, expected_count +): + campaign = rule.CampaignConfigFactory.build(target="COVID", type=campaign_type) + result = list(campaign_evaluator.get_requested_grouped_campaigns([campaign], ["COVID"], category_filter)) + assert_that(len(result), is_(expected_count)) + if expected_count > 0: + assert_that(str(result[0][0]), is_("COVID")) + + +def test_campaigns_grouped_by_condition_name_with_empty_conditions_filter(campaign_evaluator): + campaign = rule.CampaignConfigFactory.build(target="RSV", type="V") + result = campaign_evaluator.get_requested_grouped_campaigns([campaign], [], "VACCINATIONS") + assert_that(list(result), is_([])) + + +def test_campaigns_grouped_by_condition_name_groups_multiple_campaigns_for_same_target(campaign_evaluator): + campaign1 = rule.CampaignConfigFactory.build(target="COVID", type="V", id="C1") + campaign2 = rule.CampaignConfigFactory.build(target="COVID", type="V", id="C2") + campaign3 = rule.CampaignConfigFactory.build(target="FLU", type="V", id="F1") + inactive_campaign = rule.CampaignConfigFactory.build( + target="COVID", type="V", id="C3", start_date=datetime.date(2025, 4, 20), end_date=datetime.date(2025, 4, 21) + ) + + all_campaigns = [campaign1, campaign2, campaign3, inactive_campaign] + result = list(campaign_evaluator.get_requested_grouped_campaigns(all_campaigns, ["COVID", "FLU"], "VACCINATIONS")) + + assert_that(len(result), is_(2)) + + result_dict = {str(name): campaigns for name, campaigns in result} + assert_that("COVID" in result_dict) + assert_that("FLU" in result_dict) + + assert_that(len(result_dict["COVID"]), is_(2)) + assert_that({c.id for c in result_dict["COVID"]}, is_({CampaignID("C1"), CampaignID("C2")})) + + assert_that(len(result_dict["FLU"]), is_(1)) + assert_that(result_dict["FLU"][0].id, is_(CampaignID("F1"))) + + +def test_campaign_grouping_is_affected_by_order_for_mixed_types(campaign_evaluator): + campaign_v = rule.CampaignConfigFactory.build(target="RSV", type="V") + campaign_s = rule.CampaignConfigFactory.build(target="RSV", type="S") + + evaluator_s_first = campaign_evaluator + result_s_first = list( + evaluator_s_first.get_requested_grouped_campaigns([campaign_s, campaign_v], ["RSV"], "VACCINATIONS") + ) + assert_that(result_s_first, is_([])) + + evaluator_v_first = campaign_evaluator + result_v_first = list( + evaluator_v_first.get_requested_grouped_campaigns([campaign_v, campaign_s], ["RSV"], "VACCINATIONS") + ) + assert_that(len(result_v_first), is_(1)) + assert_that(len(result_v_first[0][1]), is_(2)) diff --git a/tests/unit/services/processors/test_person_data_reader.py b/tests/unit/services/processors/test_person_data_reader.py new file mode 100644 index 000000000..2191c44b3 --- /dev/null +++ b/tests/unit/services/processors/test_person_data_reader.py @@ -0,0 +1,86 @@ +import pytest +from hamcrest import assert_that, is_ + +from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader + + +@pytest.fixture +def person_data_reader(): + return PersonDataReader() + + +def test_get_person_cohorts_empty_data(person_data_reader): + result = person_data_reader.get_person_cohorts([]) + assert_that(result, is_(set())) + + +def test_get_person_cohorts_no_cohorts_attribute_type(person_data_reader): + no_cohorts_type = [ + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "John Doe"}, + {"ATTRIBUTE_TYPE": "AGE", "VALUE": 30}, + ] + result = person_data_reader.get_person_cohorts(no_cohorts_type) + assert_that(result, is_(set())) + + +def test_get_person_cohorts_no_cohort_map_key(person_data_reader): + no_cohorts_map = [ + {"ATTRIBUTE_TYPE": "COHORTS", "OTHER_FIELD": "value"}, + ] + result = person_data_reader.get_person_cohorts(no_cohorts_map) + assert_that(result, is_(set())) + + +def test_get_person_cohorts_single_cohort(person_data_reader): + single_cohorts = [ + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "flu_65+_autumnwinter2023", "DATE_JOINED": "20231020"}], + }, + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Jane Smith"}, + ] + result = person_data_reader.get_person_cohorts(single_cohorts) + assert_that(result, is_({"flu_65+_autumnwinter2023"})) + + +def test_get_person_cohorts_multiple_cohorts(person_data_reader): + multiple_cohorts = [ + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": "COHORT_B", "DATE_JOINED": "20231020"}, + {"COHORT_LABEL": "COHORT_C", "DATE_JOINED": "20241020"}, + ], + }, + {"ATTRIBUTE_TYPE": "AGE", "VALUE": 45}, + ] + result = person_data_reader.get_person_cohorts(multiple_cohorts) + assert_that(result, is_({"COHORT_B", "COHORT_C"})) + + +def test_get_person_cohorts_mixed_data(person_data_reader): + mixed_data = [ + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": "COHORT_D", "DATE_JOINED": "20231020"}, + {"COHORT_LABEL": "COHORT_E", "DATE_JOINED": "20241020"}, + ], + }, + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Alice"}, + {"ATTRIBUTE_TYPE": "ADDRESS", "VALUE": "123 Main St"}, + ] + + result = person_data_reader.get_person_cohorts(mixed_data) + assert_that(result, is_({"COHORT_D", "COHORT_E"})) + + +def test_get_person_cohorts_with_other_attribute_types_present(person_data_reader): + data = [ + {"ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "COHORT_F", "DATE_JOINED": "20231020"}]}, + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Charlie"}, + {"ATTRIBUTE_TYPE": "AGE", "VALUE": 25}, + ] + + result = person_data_reader.get_person_cohorts(data) + assert_that(result, is_({"COHORT_F"})) From 9a7014afa5606cd8f1cbeaf181d9e78921529359 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:20:40 +0100 Subject: [PATCH 19/61] Feature/eli 369 dynamodb x ray tracing (#256) * handled none headers from request * x-ray tracing setup for dynamo, s3, firehose * enable_xray_patching env variable for lambda * sonar fixes --------- Co-authored-by: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> --- infrastructure/modules/lambda/lambda.tf | 1 + infrastructure/modules/lambda/variables.tf | 5 ++++ infrastructure/stacks/api-layer/lambda.tf | 1 + poetry.lock | 25 ++++++++++++++----- pyproject.toml | 1 + src/eligibility_signposting_api/app.py | 15 ++++++++--- .../config/config.py | 10 +++++--- .../logging/logs_helper.py | 6 ++--- .../logging/logs_manager.py | 2 +- .../logging/tracing_helper.py | 21 ++++++++++++++++ tests/unit/logging/test_logs_helper.py | 4 +-- tests/unit/logging/test_logs_manager.py | 10 ++++---- 12 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 src/eligibility_signposting_api/logging/tracing_helper.py diff --git a/infrastructure/modules/lambda/lambda.tf b/infrastructure/modules/lambda/lambda.tf index 9013c8386..f31a6e762 100644 --- a/infrastructure/modules/lambda/lambda.tf +++ b/infrastructure/modules/lambda/lambda.tf @@ -22,6 +22,7 @@ resource "aws_lambda_function" "eligibility_signposting_lambda" { KINESIS_AUDIT_STREAM_TO_S3 = var.kinesis_audit_stream_to_s3_name ENV = var.environment LOG_LEVEL = var.log_level + ENABLE_XRAY_PATCHING = var.enable_xray_patching } } diff --git a/infrastructure/modules/lambda/variables.tf b/infrastructure/modules/lambda/variables.tf index ca6d9b95d..229c1fbb4 100644 --- a/infrastructure/modules/lambda/variables.tf +++ b/infrastructure/modules/lambda/variables.tf @@ -47,3 +47,8 @@ variable "log_level" { description = "log level" type = string } + +variable "enable_xray_patching"{ + description = "flag to enable xray tracing, which puts an entry for dynamodb, s3 and firehose in trace map" + type = string +} diff --git a/infrastructure/stacks/api-layer/lambda.tf b/infrastructure/stacks/api-layer/lambda.tf index 09f56ac03..68885b6d7 100644 --- a/infrastructure/stacks/api-layer/lambda.tf +++ b/infrastructure/stacks/api-layer/lambda.tf @@ -24,5 +24,6 @@ module "eligibility_signposting_lambda_function" { eligibility_status_table_name = module.eligibility_status_table.table_name kinesis_audit_stream_to_s3_name = module.eligibility_audit_firehose_delivery_stream.firehose_stream_name log_level = "INFO" + enable_xray_patching = "true" stack_name = local.stack_name } diff --git a/poetry.lock b/poetry.lock index 405be8852..ed37ac0d5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -218,6 +218,22 @@ files = [ [package.dependencies] cryptography = "*" +[[package]] +name = "aws-xray-sdk" +version = "2.14.0" +description = "The AWS X-Ray SDK for Python (the SDK) enables Python developers to record and emit information from within their applications to the AWS X-Ray service." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "aws_xray_sdk-2.14.0-py2.py3-none-any.whl", hash = "sha256:cfbe6feea3d26613a2a869d14c9246a844285c97087ad8f296f901633554ad94"}, + {file = "aws_xray_sdk-2.14.0.tar.gz", hash = "sha256:aab843c331af9ab9ba5cefb3a303832a19db186140894a523edafc024cc0493c"}, +] + +[package.dependencies] +botocore = ">=1.11.3" +wrapt = "*" + [[package]] name = "awscli" version = "1.40.41" @@ -1531,11 +1547,8 @@ files = [ {file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"}, {file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"}, - {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"}, {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"}, {file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"}, {file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"}, @@ -3261,7 +3274,7 @@ version = "1.17.2" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, @@ -3478,4 +3491,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "ca918c8b9441327adf6c176055f47d8d3f9e058aa243c1c957e0f7a6a6e4159f" +content-hash = "e7dab798823725076f2001cb892f67cd73269205120bb323d75d3c9d755c1bb2" diff --git a/pyproject.toml b/pyproject.toml index 28d9d5b01..c2d21e645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ python-json-logger = "^3.3.0" fhir-resources = "^8.0.0" python-dateutil = "^2.9.0" pyhamcrest = "^2.1.0" +aws-xray-sdk = "2.14.0" [tool.poetry.group.dev.dependencies] ruff = "^0.11.13" diff --git a/src/eligibility_signposting_api/app.py b/src/eligibility_signposting_api/app.py index a8772c855..ffa3cd14b 100644 --- a/src/eligibility_signposting_api/app.py +++ b/src/eligibility_signposting_api/app.py @@ -1,8 +1,10 @@ import logging +import os from typing import Any import wireup.integration.flask from asgiref.wsgi import WsgiToAsgi +from aws_xray_sdk.core import patch_all from flask import Flask from mangum import Mangum from mangum.types import LambdaContext, LambdaEvent @@ -11,10 +13,14 @@ from eligibility_signposting_api.common.error_handler import handle_exception from eligibility_signposting_api.common.request_validator import validate_request_params from eligibility_signposting_api.config.config import config -from eligibility_signposting_api.logging.logs_helper import log_request_ids -from eligibility_signposting_api.logging.logs_manager import add_request_id_to_logs, init_logging +from eligibility_signposting_api.logging.logs_helper import log_request_ids_from_headers +from eligibility_signposting_api.logging.logs_manager import add_lambda_request_id_to_logger, init_logging +from eligibility_signposting_api.logging.tracing_helper import tracing_setup from eligibility_signposting_api.views import eligibility_blueprint +if os.getenv("ENABLE_XRAY_PATCHING"): + patch_all() + init_logging() logger = logging.getLogger(__name__) @@ -25,8 +31,9 @@ def main() -> None: # pragma: no cover app.run(debug=config()["log_level"] == logging.DEBUG) -@add_request_id_to_logs() -@log_request_ids() +@add_lambda_request_id_to_logger() +@tracing_setup() +@log_request_ids_from_headers() @validate_request_params() def lambda_handler(event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: # pragma: no cover """Run the Flask app as an AWS Lambda.""" diff --git a/src/eligibility_signposting_api/config/config.py b/src/eligibility_signposting_api/config/config.py index 49faeff6b..58be70258 100644 --- a/src/eligibility_signposting_api/config/config.py +++ b/src/eligibility_signposting_api/config/config.py @@ -22,6 +22,7 @@ def config() -> dict[str, Any]: rules_bucket_name = BucketName(os.getenv("RULES_BUCKET_NAME", "test-rules-bucket")) audit_bucket_name = BucketName(os.getenv("AUDIT_BUCKET_NAME", "test-audit-bucket")) aws_default_region = AwsRegion(os.getenv("AWS_DEFAULT_REGION", "eu-west-1")) + enable_xray_patching = bool(os.getenv("ENABLE_XRAY_PATCHING", "false")) kinesis_audit_stream_to_s3 = AwsKinesisFirehoseStreamName( os.getenv("KINESIS_AUDIT_STREAM_TO_S3", "test_kinesis_audit_stream_to_s3") ) @@ -39,19 +40,22 @@ def config() -> dict[str, Any]: "audit_bucket_name": audit_bucket_name, "firehose_endpoint": None, "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, + "enable_xray_patching": enable_xray_patching, "log_level": log_level, } + local_stack_endpoint = "http://localhost:4566" return { "aws_access_key_id": AwsAccessKey(os.getenv("AWS_ACCESS_KEY_ID", "dummy_key")), "aws_default_region": aws_default_region, "aws_secret_access_key": AwsSecretAccessKey(os.getenv("AWS_SECRET_ACCESS_KEY", "dummy_secret")), - "dynamodb_endpoint": URL(os.getenv("DYNAMODB_ENDPOINT", "http://localhost:4566")), + "dynamodb_endpoint": URL(os.getenv("DYNAMODB_ENDPOINT", local_stack_endpoint)), "person_table_name": person_table_name, - "s3_endpoint": URL(os.getenv("S3_ENDPOINT", "http://localhost:4566")), + "s3_endpoint": URL(os.getenv("S3_ENDPOINT", local_stack_endpoint)), "rules_bucket_name": rules_bucket_name, "audit_bucket_name": audit_bucket_name, - "firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", "http://localhost:4566")), + "firehose_endpoint": URL(os.getenv("FIREHOSE_ENDPOINT", local_stack_endpoint)), "kinesis_audit_stream_to_s3": kinesis_audit_stream_to_s3, + "enable_xray_patching": enable_xray_patching, "log_level": log_level, } diff --git a/src/eligibility_signposting_api/logging/logs_helper.py b/src/eligibility_signposting_api/logging/logs_helper.py index cb71bc911..12d8a48db 100644 --- a/src/eligibility_signposting_api/logging/logs_helper.py +++ b/src/eligibility_signposting_api/logging/logs_helper.py @@ -8,12 +8,12 @@ logger = logging.getLogger(__name__) -def log_request_ids() -> Callable: +def log_request_ids_from_headers() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: - gateway_request_id = event.get("requestContext", {}).get("requestId") - headers = event.get("headers", {}) + gateway_request_id = (event.get("requestContext") or {}).get("requestId") + headers = event.get("headers") or {} logger.info( "request trace metadata", extra={ diff --git a/src/eligibility_signposting_api/logging/logs_manager.py b/src/eligibility_signposting_api/logging/logs_manager.py index 093c06dda..6ca253ef2 100644 --- a/src/eligibility_signposting_api/logging/logs_manager.py +++ b/src/eligibility_signposting_api/logging/logs_manager.py @@ -14,7 +14,7 @@ LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s %(module)s.py:%(funcName)s():%(lineno)d %(message)s" -def add_request_id_to_logs() -> Callable: +def add_lambda_request_id_to_logger() -> Callable: def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: diff --git a/src/eligibility_signposting_api/logging/tracing_helper.py b/src/eligibility_signposting_api/logging/tracing_helper.py new file mode 100644 index 000000000..888adc507 --- /dev/null +++ b/src/eligibility_signposting_api/logging/tracing_helper.py @@ -0,0 +1,21 @@ +from collections.abc import Callable +from functools import wraps +from typing import Any + +from aws_xray_sdk.core import xray_recorder +from mangum.types import LambdaContext, LambdaEvent + + +def tracing_setup() -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None: + xray_recorder.begin_subsegment("Lambda") + try: + return func(event, context) + finally: + xray_recorder.end_subsegment() + + return wrapper + + return decorator diff --git a/tests/unit/logging/test_logs_helper.py b/tests/unit/logging/test_logs_helper.py index 5594cbe36..d11885bc9 100644 --- a/tests/unit/logging/test_logs_helper.py +++ b/tests/unit/logging/test_logs_helper.py @@ -46,13 +46,13 @@ def lambda_context(): ], ) def test_log_request_ids_decorator_logs_metadata(headers, gateway_request_id, expected_extra, lambda_context, caplog): - from eligibility_signposting_api.app import log_request_ids + from eligibility_signposting_api.app import log_request_ids_from_headers event = {"headers": headers} if gateway_request_id is not None: event["requestContext"] = {"requestId": gateway_request_id} - @log_request_ids() + @log_request_ids_from_headers() def test_handler(event, context): # noqa : ARG001 logger = logging.getLogger("test_logger") logger.info("Inside test handler") diff --git a/tests/unit/logging/test_logs_manager.py b/tests/unit/logging/test_logs_manager.py index eb9c38a6c..f78c13451 100644 --- a/tests/unit/logging/test_logs_manager.py +++ b/tests/unit/logging/test_logs_manager.py @@ -11,7 +11,7 @@ from eligibility_signposting_api.logging.logs_manager import ( LOG_FORMAT, EnrichedJsonFormatter, - add_request_id_to_logs, + add_lambda_request_id_to_logger, request_id_context_var, ) @@ -21,7 +21,7 @@ def test_decorator_sets_request_id_in_context(): mock_context = MagicMock() mock_context.aws_request_id = test_request_id - @add_request_id_to_logs() + @add_lambda_request_id_to_logger() def decorated_handler(event, context): # noqa : ARG001 return request_id_context_var.get() @@ -35,7 +35,7 @@ def test_decorator_preserves_function_return_value(): mock_context = MagicMock() mock_context.aws_request_id = "any-id" - @add_request_id_to_logs() + @add_lambda_request_id_to_logger() def decorated_handler(event, context): # noqa : ARG001 return expected_result @@ -47,7 +47,7 @@ def decorated_handler(event, context): # noqa : ARG001 def test_request_id_context_is_properly_isolated(): results = {} - @add_request_id_to_logs() + @add_lambda_request_id_to_logger() def decorated_handler(event, context): # noqa : ARG001 rid = request_id_context_var.get() results[threading.current_thread().name] = rid @@ -86,7 +86,7 @@ def lambda_context(): def test_enriched_json_formatter_adds_all_fields(lambda_context): - @add_request_id_to_logs() + @add_lambda_request_id_to_logger() def test_handler(event, context): # noqa : ARG001 logger = logging.getLogger("test_logger") logger.info("Test log inside handler") From a8667e3715f61cdce54aacd2708d5e9b312e73fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:36:26 +0000 Subject: [PATCH 20/61] Bump slackapi/slack-github-action from 2.1.0 to 2.1.1 Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 2.1.0 to 2.1.1. - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Commits](https://github.com/slackapi/slack-github-action/compare/v2.1.0...v2.1.1) --- updated-dependencies: - dependency-name: slackapi/slack-github-action dependency-version: 2.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/cicd-2-publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd-2-publish.yaml b/.github/workflows/cicd-2-publish.yaml index d41b1de16..979cd9832 100644 --- a/.github/workflows/cicd-2-publish.yaml +++ b/.github/workflows/cicd-2-publish.yaml @@ -141,7 +141,7 @@ jobs: # asset_name: lambda-${{ needs.metadata.outputs.version }}.zip # asset_content_type: application/zip - name: "Notify Slack on PR merge" - uses: slackapi/slack-github-action@v2.1.0 + uses: slackapi/slack-github-action@v2.1.1 with: webhook: ${{ secrets.SLACK_WEBHOOK_URL }} webhook-type: webhook-trigger From c47799a5d6845f99bb7ffb063786adac316135a5 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:24:01 +0100 Subject: [PATCH 21/61] ELI-351: Refactor (#254) * ELI-351: Renames rules model to campaign_config * ELI-351: Extracts Person data class * ELI-351: Adds rule processor * ELI-351: Adds tests for rule processor * ELI-351: Moves get_cohort_group_results to rule processor * ELI-351: Adds tests for rule processor * ELI-351: Adds cohort handler using Chain of responsibility pattern * ELI-351: Renames * ELI-351: Fixes lint * ELI-351: Renames evaluate_eligibility to get_eligibility_status * ELI-351: Refactoring to get better readability for chaining * ELI-351: Fix lint --- .../audit/audit_context.py | 8 +- .../model/{rules.py => campaign_config.py} | 0 .../model/person.py | 7 + .../repos/campaign_repo.py | 2 +- .../repos/person_repo.py | 6 +- .../calculators/eligibility_calculator.py | 201 ++------ .../services/calculators/rule_calculator.py | 39 +- .../services/eligibility_services.py | 2 +- .../services/operators/operators.py | 2 +- .../services/processors/campaign_evaluator.py | 9 +- .../services/processors/cohort_handler.py | 110 ++++ .../services/processors/person_data_reader.py | 23 +- .../services/processors/rule_processor.py | 162 ++++++ tests/fixtures/builders/model/rule.py | 191 +++---- tests/fixtures/builders/repos/person.py | 6 +- tests/fixtures/matchers/rules.py | 2 +- tests/integration/conftest.py | 37 +- .../in_process/test_eligibility_endpoint.py | 2 +- .../lambda/test_app_running_as_lambda.py | 2 +- tests/integration/repo/test_campaign_repo.py | 2 +- tests/integration/repo/test_person_repo.py | 2 +- tests/unit/audit/test_audit_context.py | 2 +- ...{test_rules.py => test_campaign_config.py} | 2 +- .../test_eligibility_calculator.py | 192 +++---- .../calculators/test_rule_calculator.py | 24 +- .../unit/services/operators/test_operators.py | 2 +- .../processors/test_campaign_evaluator.py | 2 +- .../processors/test_cohort_handler.py | 122 +++++ .../processors/test_person_data_reader.py | 98 ++-- .../processors/test_rule_processor.py | 472 ++++++++++++++++++ 30 files changed, 1267 insertions(+), 464 deletions(-) rename src/eligibility_signposting_api/model/{rules.py => campaign_config.py} (100%) create mode 100644 src/eligibility_signposting_api/model/person.py create mode 100644 src/eligibility_signposting_api/services/processors/cohort_handler.py create mode 100644 src/eligibility_signposting_api/services/processors/rule_processor.py rename tests/unit/model/{test_rules.py => test_campaign_config.py} (97%) create mode 100644 tests/unit/services/processors/test_cohort_handler.py create mode 100644 tests/unit/services/processors/test_rule_processor.py diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index 28a6f9072..8a0606983 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -18,6 +18,13 @@ RequestAuditQueryParams, ) from eligibility_signposting_api.audit.audit_service import AuditService +from eligibility_signposting_api.model.campaign_config import ( + CampaignID, + CampaignVersion, + Iteration, + RuleName, + RulePriority, +) from eligibility_signposting_api.model.eligibility_status import ( CohortGroupResult, ConditionName, @@ -25,7 +32,6 @@ Status, SuggestedAction, ) -from eligibility_signposting_api.model.rules import CampaignID, CampaignVersion, Iteration, RuleName, RulePriority logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/model/rules.py b/src/eligibility_signposting_api/model/campaign_config.py similarity index 100% rename from src/eligibility_signposting_api/model/rules.py rename to src/eligibility_signposting_api/model/campaign_config.py diff --git a/src/eligibility_signposting_api/model/person.py b/src/eligibility_signposting_api/model/person.py new file mode 100644 index 000000000..eaaff6c64 --- /dev/null +++ b/src/eligibility_signposting_api/model/person.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass +class Person: + data: list[dict[str, Any]] diff --git a/src/eligibility_signposting_api/repos/campaign_repo.py b/src/eligibility_signposting_api/repos/campaign_repo.py index 8a2a212fd..26b701962 100644 --- a/src/eligibility_signposting_api/repos/campaign_repo.py +++ b/src/eligibility_signposting_api/repos/campaign_repo.py @@ -5,7 +5,7 @@ from botocore.client import BaseClient from wireup import Inject, service -from eligibility_signposting_api.model.rules import CampaignConfig, Rules +from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules BucketName = NewType("BucketName", str) diff --git a/src/eligibility_signposting_api/repos/person_repo.py b/src/eligibility_signposting_api/repos/person_repo.py index cfa18fa12..9867b2844 100644 --- a/src/eligibility_signposting_api/repos/person_repo.py +++ b/src/eligibility_signposting_api/repos/person_repo.py @@ -6,6 +6,7 @@ from wireup import Inject, service from eligibility_signposting_api.model.eligibility_status import NHSNumber +from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.repos.exceptions import NotFoundError logger = logging.getLogger(__name__) @@ -35,7 +36,7 @@ def __init__(self, table: Annotated[Any, Inject(qualifier="person_table")]) -> N super().__init__() self.table = table - def get_eligibility_data(self, nhs_number: NHSNumber) -> list[dict[str, Any]]: + def get_eligibility_data(self, nhs_number: NHSNumber) -> Person: response = self.table.query(KeyConditionExpression=Key("NHS_NUMBER").eq(nhs_number)) logger.debug("response %r for %r", response, nhs_number, extra={"response": response, "nhs_number": nhs_number}) @@ -44,4 +45,5 @@ def get_eligibility_data(self, nhs_number: NHSNumber) -> list[dict[str, Any]]: raise NotFoundError(message) logger.debug("returning items %s", items, extra={"items": items}) - return items + + return Person(data=items) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index e73bd39dc..2e452b7c5 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -2,31 +2,25 @@ from _operator import attrgetter from collections import defaultdict -from collections.abc import Collection, Iterable, Iterator, Mapping from dataclasses import dataclass, field from itertools import groupby -from typing import TYPE_CHECKING, Any - -from eligibility_signposting_api.audit.audit_context import AuditContext -from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator -from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader - -if TYPE_CHECKING: - from eligibility_signposting_api.model.rules import ( - ActionsMapper, - CampaignConfig, - CampaignID, - CampaignVersion, - Iteration, - IterationCohort, - RuleName, - RulePriority, - RuleType, - ) +from typing import TYPE_CHECKING from wireup import service -from eligibility_signposting_api.model import eligibility_status, rules +from eligibility_signposting_api.audit.audit_context import AuditContext +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import ( + ActionsMapper, + CampaignConfig, + CampaignID, + CampaignVersion, + Iteration, + IterationRule, + RuleName, + RulePriority, + RuleType, +) from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, @@ -44,24 +38,29 @@ from eligibility_signposting_api.services.calculators.rule_calculator import ( RuleCalculator, ) +from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator +from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor + +if TYPE_CHECKING: + from collections.abc import Collection -Row = Collection[Mapping[str, Any]] + from eligibility_signposting_api.model.person import Person @service class EligibilityCalculatorFactory: @staticmethod - def get(person_data: Row, campaign_configs: Collection[rules.CampaignConfig]) -> EligibilityCalculator: - return EligibilityCalculator(person_data=person_data, campaign_configs=campaign_configs) + def get(person: Person, campaign_configs: Collection[CampaignConfig]) -> EligibilityCalculator: + return EligibilityCalculator(person=person, campaign_configs=campaign_configs) @dataclass class EligibilityCalculator: - person_data: Row - campaign_configs: Collection[rules.CampaignConfig] + person: Person + campaign_configs: Collection[CampaignConfig] campaign_evaluator: CampaignEvaluator = field(default_factory=CampaignEvaluator) - person_data_reader: PersonDataReader = field(default_factory=PersonDataReader) + rule_processor: RuleProcessor = field(default_factory=RuleProcessor) results: list[eligibility_status.Condition] = field(default_factory=list) @@ -88,56 +87,34 @@ def get_the_best_cohort_memberships( return best_status, best_cohorts - @staticmethod - def get_exclusion_rules( - cohort: IterationCohort, filter_rules: Iterable[rules.IterationRule] - ) -> Iterator[rules.IterationRule]: - return ( - ir - for ir in filter_rules - if ir.cohort_label is None - or cohort.cohort_label == ir.cohort_label - or (isinstance(ir.cohort_label, (list, set, tuple)) and cohort.cohort_label in ir.cohort_label) - ) - - @staticmethod - def get_rules_by_type( - active_iteration: Iteration, - ) -> tuple[tuple[rules.IterationRule, ...], tuple[rules.IterationRule, ...]]: - filter_rules, suppression_rules = ( - tuple(rule for rule in active_iteration.iteration_rules if attrgetter("type")(rule) == rule_type) - for rule_type in (rules.RuleType.filter, rules.RuleType.suppression) - ) - return filter_rules, suppression_rules - @staticmethod def get_action_rules_components( active_iteration: Iteration, rule_type: RuleType - ) -> tuple[tuple[rules.IterationRule, ...], ActionsMapper, str | None]: + ) -> tuple[tuple[IterationRule, ...], ActionsMapper, str | None]: action_rules = tuple(rule for rule in active_iteration.iteration_rules if rule.type in rule_type) routing_map = { - rules.RuleType.redirect: active_iteration.default_comms_routing, - rules.RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing, - rules.RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing, + RuleType.redirect: active_iteration.default_comms_routing, + RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing, + RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing, } default_comms = routing_map.get(rule_type) action_mapper = active_iteration.actions_mapper return action_rules, action_mapper, default_comms - def evaluate_eligibility( + def get_eligibility_status( self, include_actions: str, conditions: list[str], category: str ) -> eligibility_status.EligibilityStatus: include_actions_flag = include_actions.upper() == "Y" condition_results: dict[ConditionName, IterationResult] = {} - actions: list[SuggestedAction] | None = [] action_rule_priority, action_rule_name = None, None requested_grouped_campaigns = self.campaign_evaluator.get_requested_grouped_campaigns( self.campaign_configs, conditions, category ) for condition_name, campaign_group in requested_grouped_campaigns: + actions: list[SuggestedAction] | None = [] best_active_iteration: Iteration | None best_candidate: IterationResult best_campaign_id: CampaignID | None @@ -168,9 +145,9 @@ def evaluate_eligibility( condition_results[condition_name] = best_candidate status_to_rule_type = { - Status.actionable: rules.RuleType.redirect, - Status.not_eligible: rules.RuleType.not_eligible_actions, - Status.not_actionable: rules.RuleType.not_actionable_actions, + Status.actionable: RuleType.redirect, + Status.not_eligible: RuleType.not_eligible_actions, + Status.not_actionable: RuleType.not_actionable_actions, } if best_candidate.status in status_to_rule_type and best_active_iteration is not None: @@ -192,8 +169,6 @@ def evaluate_eligibility( # add actions to condition results condition_results[condition_name].actions = actions - # reset actions for the next condition - actions: list[SuggestedAction] | None = [] # add audit data AuditContext.append_audit_condition( @@ -216,7 +191,9 @@ def get_iteration_results( ] = {} for cc in campaign_group: active_iteration = cc.current_iteration - cohort_results: dict[str, CohortGroupResult] = self.get_cohort_results(active_iteration) + cohort_results: dict[str, CohortGroupResult] = self.rule_processor.get_cohort_group_results( + self.person, active_iteration + ) # Determine Result between cohorts - get the best status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results) @@ -242,7 +219,7 @@ def handle_action_rules( for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): rule_group_list = list(rule_group) matcher_matched_list = [ - RuleCalculator(person_data=self.person_data, rule=rule).evaluate_exclusion()[1].matcher_matched + RuleCalculator(person=self.person, rule=rule).evaluate_exclusion()[1].matcher_matched for rule in rule_group_list ] @@ -257,29 +234,6 @@ def handle_action_rules( return actions, matched_action_rule_priority, matched_action_rule_name - def get_cohort_results(self, active_iteration: rules.Iteration) -> dict[str, CohortGroupResult]: - cohort_results: dict[str, CohortGroupResult] = {} - filter_rules, suppression_rules = self.get_rules_by_type(active_iteration) - for cohort in sorted(active_iteration.iteration_cohorts, key=attrgetter("priority")): - # Base Eligibility - check - person_cohorts = self.person_data_reader.get_person_cohorts(self.person_data) - if cohort.cohort_label in person_cohorts or cohort.is_magic_cohort: - # Eligibility - check - if self.is_eligible_by_filter_rules(cohort, cohort_results, filter_rules): - # Actionability - evaluation - self.evaluate_suppression_rules(cohort, cohort_results, suppression_rules) - - # Not base eligible - elif cohort.cohort_label is not None: - cohort_results[cohort.cohort_label] = CohortGroupResult( - cohort.cohort_group, - Status.not_eligible, - [], - cohort.negative_description, - [], - ) - return cohort_results - @staticmethod def build_condition_results(condition_results: dict[ConditionName, IterationResult]) -> list[Condition]: conditions: list[Condition] = [] @@ -318,85 +272,6 @@ def build_condition_results(condition_results: dict[ConditionName, IterationResu ) return conditions - def is_eligible_by_filter_rules( - self, - cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], - filter_rules: Iterable[rules.IterationRule], - ) -> bool: - is_eligible = True - priority_getter = attrgetter("priority") - sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, filter_rules), key=priority_getter) - - for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): - status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(rule_group) - if status.is_exclusion: - if cohort.cohort_label is not None: - cohort_results[cohort.cohort_label] = CohortGroupResult( - (cohort.cohort_group), - Status.not_eligible, - [], - cohort.negative_description, - group_exclusion_reasons, - ) - is_eligible = False - break - return is_eligible - - def evaluate_suppression_rules( - self, - cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], - suppression_rules: Iterable[rules.IterationRule], - ) -> None: - is_actionable: bool = True - priority_getter = attrgetter("priority") - suppression_reasons = [] - - sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, suppression_rules), key=priority_getter) - - for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): - status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(rule_group) - if status.is_exclusion: - is_actionable = False - suppression_reasons.extend(group_exclusion_reasons) - if rule_stop: - break - - if cohort.cohort_label is not None: - key = cohort.cohort_label - if is_actionable: - cohort_results[key] = CohortGroupResult( - cohort.cohort_group, Status.actionable, [], cohort.positive_description, suppression_reasons - ) - else: - cohort_results[key] = CohortGroupResult( - cohort.cohort_group, - Status.not_actionable, - suppression_reasons, - cohort.positive_description, - suppression_reasons, - ) - - def evaluate_rules_priority_group( - self, rules_group: Iterator[rules.IterationRule] - ) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]: - is_rule_stop = False - exclusion_reasons = [] - best_status = eligibility_status.Status.not_eligible - - for rule in rules_group: - is_rule_stop = rule.rule_stop or is_rule_stop - rule_calculator = RuleCalculator(person_data=self.person_data, rule=rule) - status, reason = rule_calculator.evaluate_exclusion() - if status.is_exclusion: - best_status = eligibility_status.Status.best(status, best_status) - exclusion_reasons.append(reason) - else: - best_status = eligibility_status.Status.actionable - - return best_status, exclusion_reasons, is_rule_stop - @staticmethod def get_actions_from_comms(action_mapper: ActionsMapper, comms: str) -> list[SuggestedAction] | None: suggested_actions: list[SuggestedAction] = [] diff --git a/src/eligibility_signposting_api/services/calculators/rule_calculator.py b/src/eligibility_signposting_api/services/calculators/rule_calculator.py index 4791aead6..c9fd7f41b 100644 --- a/src/eligibility_signposting_api/services/calculators/rule_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/rule_calculator.py @@ -1,22 +1,25 @@ from __future__ import annotations -from collections.abc import Collection, Mapping from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING from hamcrest.core.string_description import StringDescription -from eligibility_signposting_api.model import eligibility_status, rules +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import IterationRule, RuleAttributeLevel, RuleType from eligibility_signposting_api.services.operators.operators import OperatorRegistry from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader -Row = Collection[Mapping[str, Any]] +if TYPE_CHECKING: + from collections.abc import Mapping + + from eligibility_signposting_api.model.person import Person @dataclass class RuleCalculator: - person_data: Row - rule: rules.IterationRule + person: Person + rule: IterationRule person_data_reader: PersonDataReader = field(default_factory=PersonDataReader) @@ -36,24 +39,24 @@ def evaluate_exclusion(self) -> tuple[eligibility_status.Status, eligibility_sta def get_attribute_value(self) -> str | None: """Pull out the correct attribute for a rule from the person's data.""" match self.rule.attribute_level: - case rules.RuleAttributeLevel.PERSON: + case RuleAttributeLevel.PERSON: person: Mapping[str, str | None] | None = next( - (r for r in self.person_data if r.get("ATTRIBUTE_TYPE", "") == "PERSON"), None + (r for r in self.person.data if r.get("ATTRIBUTE_TYPE", "") == "PERSON"), None ) attribute_value = person.get(str(self.rule.attribute_name)) if person else None - case rules.RuleAttributeLevel.COHORT: + case RuleAttributeLevel.COHORT: cohorts: Mapping[str, str | None] | None = next( - (r for r in self.person_data if r.get("ATTRIBUTE_TYPE", "") == "COHORTS"), None + (r for r in self.person.data if r.get("ATTRIBUTE_TYPE", "") == "COHORTS"), None ) if cohorts: - person_cohorts = self.person_data_reader.get_person_cohorts(self.person_data) + person_cohorts = self.person_data_reader.get_person_cohorts(self.person) attribute_value = ",".join(person_cohorts) else: attribute_value = None - case rules.RuleAttributeLevel.TARGET: + case RuleAttributeLevel.TARGET: target: Mapping[str, str | None] | None = next( - (r for r in self.person_data if r.get("ATTRIBUTE_TYPE", "") == self.rule.attribute_target), None + (r for r in self.person.data if r.get("ATTRIBUTE_TYPE", "") == self.rule.attribute_target), None ) attribute_value = target.get(str(self.rule.attribute_name)) if target else None case _: # pragma: no cover @@ -71,11 +74,11 @@ def evaluate_rule(self, attribute_value: str | None) -> tuple[eligibility_status if matcher_matched: matcher.describe_match(attribute_value, reason) status = { - rules.RuleType.filter: eligibility_status.Status.not_eligible, - rules.RuleType.suppression: eligibility_status.Status.not_actionable, - rules.RuleType.redirect: eligibility_status.Status.actionable, - rules.RuleType.not_eligible_actions: eligibility_status.Status.not_eligible, - rules.RuleType.not_actionable_actions: eligibility_status.Status.not_actionable, + RuleType.filter: eligibility_status.Status.not_eligible, + RuleType.suppression: eligibility_status.Status.not_actionable, + RuleType.redirect: eligibility_status.Status.actionable, + RuleType.not_eligible_actions: eligibility_status.Status.not_eligible, + RuleType.not_actionable_actions: eligibility_status.Status.not_actionable, }[self.rule.type] return status, str(reason), matcher_matched matcher.describe_mismatch(attribute_value, reason) diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index a4ff86ac1..465f73b08 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -55,6 +55,6 @@ def get_eligibility_status( raise UnknownPersonError from e else: calc: calculator.EligibilityCalculator = self.calculator_factory.get(person_data, campaign_configs) - return calc.evaluate_eligibility(include_actions, conditions, category) + return calc.get_eligibility_status(include_actions, conditions, category) raise UnknownPersonError # pragma: no cover diff --git a/src/eligibility_signposting_api/services/operators/operators.py b/src/eligibility_signposting_api/services/operators/operators.py index 1f9c4af85..565503093 100644 --- a/src/eligibility_signposting_api/services/operators/operators.py +++ b/src/eligibility_signposting_api/services/operators/operators.py @@ -11,7 +11,7 @@ from hamcrest.core.base_matcher import BaseMatcher from hamcrest.core.description import Description -from eligibility_signposting_api.model.rules import RuleOperator +from eligibility_signposting_api.model.campaign_config import RuleOperator logger = logging.getLogger(__name__) diff --git a/src/eligibility_signposting_api/services/processors/campaign_evaluator.py b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py index 286e43e7c..864d45c8c 100644 --- a/src/eligibility_signposting_api/services/processors/campaign_evaluator.py +++ b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py @@ -4,19 +4,20 @@ from wireup import service -from eligibility_signposting_api.model import eligibility_status, rules +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import CampaignConfig @service class CampaignEvaluator: """Filters and groups campaign configurations.""" - def get_active_campaigns(self, campaign_configs: Collection[rules.CampaignConfig]) -> list[rules.CampaignConfig]: + def get_active_campaigns(self, campaign_configs: Collection[CampaignConfig]) -> list[CampaignConfig]: return [cc for cc in campaign_configs if cc.campaign_live] def get_requested_grouped_campaigns( - self, campaign_configs: Collection[rules.CampaignConfig], conditions: list[str], category: str - ) -> Iterator[tuple[eligibility_status.ConditionName, list[rules.CampaignConfig]]]: + self, campaign_configs: Collection[CampaignConfig], conditions: list[str], category: str + ) -> Iterator[tuple[eligibility_status.ConditionName, list[CampaignConfig]]]: mapping = { "ALL": {"V", "S"}, "VACCINATIONS": {"V"}, diff --git a/src/eligibility_signposting_api/services/processors/cohort_handler.py b/src/eligibility_signposting_api/services/processors/cohort_handler.py new file mode 100644 index 000000000..6ffe18f5d --- /dev/null +++ b/src/eligibility_signposting_api/services/processors/cohort_handler.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Status + +if TYPE_CHECKING: + from collections.abc import Iterable + + from eligibility_signposting_api.model.campaign_config import IterationCohort, IterationRule + from eligibility_signposting_api.model.person import Person + from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor + + +class CohortEligibilityHandler(ABC): + """Abstract base class for eligibility/actionability handlers.""" + + def __init__(self, next_handler: CohortEligibilityHandler | None = None) -> None: + self.next_handler = next_handler + + @abstractmethod + def handle( + self, + person: Person, + cohort: IterationCohort, + cohort_results: dict[str, CohortGroupResult], + rules_processor: RuleProcessor, + ) -> None: + """Handles a part of the eligibility/actionability check or passes to the next handler.""" + + def next(self, next_handler: CohortEligibilityHandler) -> CohortEligibilityHandler: + """Sets the next handler in the chain and returns this handler for chaining.""" + self.next_handler = next_handler + return next_handler + + def pass_to_next( + self, + person: Person, + cohort: IterationCohort, + cohort_results: dict[str, CohortGroupResult], + rules_processor: RuleProcessor, + ) -> None: + """Passes the request to the next handler in the chain if one exists.""" + if self.next_handler: + self.next_handler.handle(person, cohort, cohort_results, rules_processor) + + +class BaseEligibilityHandler(CohortEligibilityHandler): + """Handles the base eligibility check (person in cohort or magic cohort).""" + + def handle( + self, + person: Person, + cohort: IterationCohort, + cohort_results: dict[str, CohortGroupResult], + rules_processor: RuleProcessor, + ) -> None: + if not rules_processor.is_base_eligible(person, cohort): + cohort_results[cohort.cohort_label] = CohortGroupResult( + cohort.cohort_group, + Status.not_eligible, + [], + cohort.negative_description, + [], + ) + return + + self.pass_to_next(person, cohort, cohort_results, rules_processor) + + +class FilterRuleHandler(CohortEligibilityHandler): + """Handles the eligibility check based on filter rules.""" + + def __init__( + self, filter_rules: Iterable[IterationRule], next_handler: CohortEligibilityHandler | None = None + ) -> None: + super().__init__(next_handler) + self.filter_rules = filter_rules + + def handle( + self, + person: Person, + cohort: IterationCohort, + cohort_results: dict[str, CohortGroupResult], + rules_processor: RuleProcessor, + ) -> None: + if not rules_processor.is_eligible(person, cohort, cohort_results, self.filter_rules): + return + + self.pass_to_next(person, cohort, cohort_results, rules_processor) + + +class SuppressionRuleHandler(CohortEligibilityHandler): + """Handles the actionability check based on suppression rules.""" + + def __init__( + self, suppression_rules: Iterable[IterationRule], next_handler: CohortEligibilityHandler | None = None + ) -> None: + super().__init__(next_handler) + self.suppression_rules = suppression_rules + + def handle( + self, + person: Person, + cohort: IterationCohort, + cohort_results: dict[str, CohortGroupResult], + rules_processor: RuleProcessor, + ) -> None: + rules_processor.is_actionable(person, cohort, cohort_results, self.suppression_rules) diff --git a/src/eligibility_signposting_api/services/processors/person_data_reader.py b/src/eligibility_signposting_api/services/processors/person_data_reader.py index 20a8f6dca..6b365ae7f 100644 --- a/src/eligibility_signposting_api/services/processors/person_data_reader.py +++ b/src/eligibility_signposting_api/services/processors/person_data_reader.py @@ -1,26 +1,25 @@ from __future__ import annotations -from collections.abc import Collection, Mapping -from typing import Any - from wireup import service -Row = Collection[Mapping[str, Any]] +from eligibility_signposting_api.model.person import Person @service class PersonDataReader: """Handles extracting and interpreting person data.""" - def get_person_cohorts(self, person_data: Row) -> set[str]: - cohorts_row: Mapping[str, list[dict[str, str]]] = next( - (row for row in person_data if row.get("ATTRIBUTE_TYPE") == "COHORTS"), - {}, - ) + def get_person_cohorts(self, person: Person) -> set[str]: + cohorts_row: Person = Person([]) + for data in person.data: + if data.get("ATTRIBUTE_TYPE") == "COHORTS": + cohorts_row.data.append(data) + person_cohorts = set() - for membership in cohorts_row.get("COHORT_MEMBERSHIPS", []): - if membership.get("COHORT_LABEL"): - person_cohorts.add(membership.get("COHORT_LABEL")) + if cohorts_row.data: + for membership in cohorts_row.data[0].get("COHORT_MEMBERSHIPS", []): + if membership.get("COHORT_LABEL"): + person_cohorts.add(membership.get("COHORT_LABEL")) return person_cohorts diff --git a/src/eligibility_signposting_api/services/processors/rule_processor.py b/src/eligibility_signposting_api/services/processors/rule_processor.py new file mode 100644 index 000000000..52d0d25dc --- /dev/null +++ b/src/eligibility_signposting_api/services/processors/rule_processor.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from itertools import groupby +from operator import attrgetter +from typing import TYPE_CHECKING + +from wireup import service + +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import Iteration, IterationCohort, IterationRule, RuleType +from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Status +from eligibility_signposting_api.services.calculators.rule_calculator import RuleCalculator +from eligibility_signposting_api.services.processors.cohort_handler import ( + BaseEligibilityHandler, + FilterRuleHandler, + SuppressionRuleHandler, +) +from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from eligibility_signposting_api.model.person import Person + + +@service +@dataclass +class RuleProcessor: + """Handles the processing and evaluation of different rules (filter, suppression) against person data.""" + + person_data_reader: PersonDataReader = field(default_factory=PersonDataReader) + + def is_base_eligible(self, person: Person, cohort: IterationCohort) -> bool: + person_cohorts = self.person_data_reader.get_person_cohorts(person) + return cohort.cohort_label in person_cohorts or cohort.is_magic_cohort + + def is_eligible( + self, + person: Person, + cohort: IterationCohort, + cohort_results: dict[str, CohortGroupResult], + filter_rules: Iterable[IterationRule], + ) -> bool: + is_eligible = True + priority_getter = attrgetter("priority") + sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, filter_rules), key=priority_getter) + + for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): + status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(person, rule_group) + if status.is_exclusion: + if cohort.cohort_label is not None: + cohort_results[cohort.cohort_label] = CohortGroupResult( + cohort.cohort_group, + Status.not_eligible, + [], + cohort.negative_description, + group_exclusion_reasons, + ) + is_eligible = False + break + return is_eligible + + def is_actionable( + self, + person: Person, + cohort: IterationCohort, + cohort_results: dict[str, CohortGroupResult], + suppression_rules: Iterable[IterationRule], + ) -> None: + is_actionable: bool = True + priority_getter = attrgetter("priority") + suppression_reasons = [] + + sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, suppression_rules), key=priority_getter) + + for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): + status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(person, rule_group) + if status.is_exclusion: + is_actionable = False + suppression_reasons.extend(group_exclusion_reasons) + if rule_stop: + break + + if cohort.cohort_label is not None: + key = cohort.cohort_label + if is_actionable: + cohort_results[key] = CohortGroupResult( + cohort.cohort_group, Status.actionable, [], cohort.positive_description, suppression_reasons + ) + else: + cohort_results[key] = CohortGroupResult( + cohort.cohort_group, + Status.not_actionable, + suppression_reasons, + cohort.positive_description, + suppression_reasons, + ) + + def evaluate_rules_priority_group( + self, person: Person, rules_group: Iterator[IterationRule] + ) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]: + is_rule_stop = False + exclusion_reasons = [] + best_status = eligibility_status.Status.not_eligible + + for rule in rules_group: + is_rule_stop = rule.rule_stop or is_rule_stop + rule_calculator = RuleCalculator(person=person, rule=rule) + status, reason = rule_calculator.evaluate_exclusion() + if status.is_exclusion: + best_status = eligibility_status.Status.best(status, best_status) + exclusion_reasons.append(reason) + else: + best_status = eligibility_status.Status.actionable + + return best_status, exclusion_reasons, is_rule_stop + + @staticmethod + def get_exclusion_rules(cohort: IterationCohort, rules: Iterable[IterationRule]) -> Iterator[IterationRule]: + return ( + ir + for ir in rules + if ir.cohort_label is None + or cohort.cohort_label == ir.cohort_label + or (isinstance(ir.cohort_label, (list, set, tuple)) and cohort.cohort_label in ir.cohort_label) + ) + + def get_cohort_group_results(self, person: Person, active_iteration: Iteration) -> dict[str, CohortGroupResult]: + cohort_results: dict[str, CohortGroupResult] = {} + filter_rules, suppression_rules = self.get_rules_by_type(active_iteration) + + cohort_base_handler = BaseEligibilityHandler() + filter_rule_handler = FilterRuleHandler(filter_rules=filter_rules) + suppression_rule_handler = SuppressionRuleHandler(suppression_rules=suppression_rules) + + cohort_base_handler.next(filter_rule_handler).next(suppression_rule_handler) + + for cohort in sorted(active_iteration.iteration_cohorts, key=attrgetter("priority")): + cohort_base_handler.handle(person, cohort, cohort_results, self) + + return cohort_results + + def get_not_base_eligible_results( + self, cohort: IterationCohort, cohort_results: dict[str, CohortGroupResult] + ) -> dict[str, CohortGroupResult]: + cohort_results[cohort.cohort_label] = CohortGroupResult( + cohort.cohort_group, + Status.not_eligible, + [], + cohort.negative_description, + [], + ) + return cohort_results + + @staticmethod + def get_rules_by_type(active_iteration: Iteration) -> tuple[tuple[IterationRule, ...], tuple[IterationRule, ...]]: + filter_rules, suppression_rules = ( + tuple(rule for rule in active_iteration.iteration_rules if attrgetter("type")(rule) == rule_type) + for rule_type in (RuleType.filter, RuleType.suppression) + ) + return filter_rules, suppression_rules diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index bbc9c340d..4cc5a0ac1 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -5,7 +5,26 @@ from polyfactory import Use from polyfactory.factories.pydantic_factory import ModelFactory -from eligibility_signposting_api.model import rules +from eligibility_signposting_api.model.campaign_config import ( + ActionsMapper, + AvailableAction, + CampaignConfig, + CohortGroup, + CohortLabel, + CommsRouting, + Description, + Iteration, + IterationCohort, + IterationRule, + RuleAttributeLevel, + RuleAttributeName, + RuleComparator, + RuleDescription, + RuleName, + RuleOperator, + RulePriority, + RuleType, +) def past_date(days_behind: int = 365) -> date: @@ -16,11 +35,11 @@ def future_date(days_ahead: int = 365) -> date: return datetime.now(tz=UTC).date() + timedelta(days=randint(1, days_ahead)) -class IterationCohortFactory(ModelFactory[rules.IterationCohort]): - priority = rules.RulePriority(0) +class IterationCohortFactory(ModelFactory[IterationCohort]): + priority = RulePriority(0) -class IterationRuleFactory(ModelFactory[rules.IterationRule]): +class IterationRuleFactory(ModelFactory[IterationRule]): attribute_target = None attribute_name = "DATE_OF_BIRTH" operator = "Y>" @@ -29,7 +48,7 @@ class IterationRuleFactory(ModelFactory[rules.IterationRule]): rule_stop = False -class AvailableActionDetailFactory(ModelFactory[rules.AvailableAction]): +class AvailableActionDetailFactory(ModelFactory[AvailableAction]): action_type = "defaultcomms" action_code = "action_code" action_description = None @@ -37,11 +56,11 @@ class AvailableActionDetailFactory(ModelFactory[rules.AvailableAction]): url_label = None -class ActionsMapperFactory(ModelFactory[rules.ActionsMapper]): +class ActionsMapperFactory(ModelFactory[ActionsMapper]): root = Use(lambda: {"defaultcomms": AvailableActionDetailFactory.build()}) -class IterationFactory(ModelFactory[rules.Iteration]): +class IterationFactory(ModelFactory[Iteration]): iteration_cohorts = Use(IterationCohortFactory.batch, size=2) iteration_rules = Use(IterationRuleFactory.batch, size=2) iteration_date = Use(past_date) @@ -49,7 +68,7 @@ class IterationFactory(ModelFactory[rules.Iteration]): actions_mapper = Use(ActionsMapperFactory.build) -class RawCampaignConfigFactory(ModelFactory[rules.CampaignConfig]): +class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) start_date = Use(past_date) @@ -58,13 +77,13 @@ class RawCampaignConfigFactory(ModelFactory[rules.CampaignConfig]): class CampaignConfigFactory(RawCampaignConfigFactory): @classmethod - def build(cls, **kwargs) -> rules.CampaignConfig: + def build(cls, **kwargs) -> CampaignConfig: """Ensure invariants are met: * no iterations with duplicate iteration dates * must have iteration active from campaign start date""" processed_kwargs = cls.process_kwargs(**kwargs) start_date: date = processed_kwargs["start_date"] - iterations: list[rules.Iteration] = processed_kwargs["iterations"] + iterations: list[Iteration] = processed_kwargs["iterations"] CampaignConfigFactory.fix_iteration_date_invariants(iterations, start_date) @@ -72,7 +91,7 @@ def build(cls, **kwargs) -> rules.CampaignConfig: return cls.__model__(**data) @staticmethod - def fix_iteration_date_invariants(iterations: list[rules.Iteration], start_date: date) -> None: + def fix_iteration_date_invariants(iterations: list[Iteration], start_date: date) -> None: iterations.sort(key=attrgetter("iteration_date")) iterations[0].iteration_date = start_date @@ -89,113 +108,113 @@ def fix_iteration_date_invariants(iterations: list[rules.Iteration], start_date: # Iteration cohort factories class MagicCohortFactory(IterationCohortFactory): - cohort_label = rules.CohortLabel("elid_all_people") - cohort_group = rules.CohortGroup("magic cohort group") - positive_description = rules.Description("magic positive description") - negative_description = rules.Description("magic negative description") + cohort_label = CohortLabel("elid_all_people") + cohort_group = CohortGroup("magic cohort group") + positive_description = Description("magic positive description") + negative_description = Description("magic negative description") priority = 1 class Rsv75RollingCohortFactory(IterationCohortFactory): - cohort_label = rules.CohortLabel("rsv_75_rolling") - cohort_group = rules.CohortGroup("rsv_age_range") - positive_description = rules.Description("rsv_age_range positive description") - negative_description = rules.Description("rsv_age_range negative description") + cohort_label = CohortLabel("rsv_75_rolling") + cohort_group = CohortGroup("rsv_age_range") + positive_description = Description("rsv_age_range positive description") + negative_description = Description("rsv_age_range negative description") priority = 2 class Rsv75to79CohortFactory(IterationCohortFactory): - cohort_label = rules.CohortLabel("rsv_75to79_2024") - cohort_group = rules.CohortGroup("rsv_age_range") - positive_description = rules.Description("rsv_age_range positive description") - negative_description = rules.Description("rsv_age_range negative description") + cohort_label = CohortLabel("rsv_75to79_2024") + cohort_group = CohortGroup("rsv_age_range") + positive_description = Description("rsv_age_range positive description") + negative_description = Description("rsv_age_range negative description") priority = 3 class RsvPretendClinicalCohortFactory(IterationCohortFactory): - cohort_label = rules.CohortLabel("rsv_pretend_clinical_cohort") - cohort_group = rules.CohortGroup("rsv_clinical_cohort") - positive_description = rules.Description("rsv_clinical_cohort positive description") - negative_description = rules.Description("rsv_clinical_cohort negative description") + cohort_label = CohortLabel("rsv_pretend_clinical_cohort") + cohort_group = CohortGroup("rsv_clinical_cohort") + positive_description = Description("rsv_clinical_cohort positive description") + negative_description = Description("rsv_clinical_cohort negative description") priority = 4 # Iteration rule factories class PersonAgeSuppressionRuleFactory(IterationRuleFactory): - type = rules.RuleType.suppression - name = rules.RuleName("Exclude too young less than 75") - description = rules.RuleDescription("Exclude too young less than 75") - priority = rules.RulePriority(10) - operator = rules.RuleOperator.year_gt - attribute_level = rules.RuleAttributeLevel.PERSON - attribute_name = rules.RuleAttributeName("DATE_OF_BIRTH") - comparator = rules.RuleComparator("-75") + type = RuleType.suppression + name = RuleName("Exclude too young less than 75") + description = RuleDescription("Exclude too young less than 75") + priority = RulePriority(10) + operator = RuleOperator.year_gt + attribute_level = RuleAttributeLevel.PERSON + attribute_name = RuleAttributeName("DATE_OF_BIRTH") + comparator = RuleComparator("-75") class PostcodeSuppressionRuleFactory(IterationRuleFactory): - type = rules.RuleType.suppression - name = rules.RuleName("Excluded postcode In SW19") - description = rules.RuleDescription("In SW19") - priority = rules.RulePriority(10) - operator = rules.RuleOperator.starts_with - attribute_level = rules.RuleAttributeLevel.PERSON - attribute_name = rules.RuleAttributeName("POSTCODE") - comparator = rules.RuleComparator("SW19") + type = RuleType.suppression + name = RuleName("Excluded postcode In SW19") + description = RuleDescription("In SW19") + priority = RulePriority(10) + operator = RuleOperator.starts_with + attribute_level = RuleAttributeLevel.PERSON + attribute_name = RuleAttributeName("POSTCODE") + comparator = RuleComparator("SW19") class DetainedEstateSuppressionRuleFactory(IterationRuleFactory): - type = rules.RuleType.suppression - name = rules.RuleName("Detained - Suppress Individuals In Detained Estates") - description = rules.RuleDescription("Suppress where individual is identified as being in a Detained Estate") - priority = rules.RulePriority(160) - attribute_level = rules.RuleAttributeLevel.PERSON - attribute_name = rules.RuleAttributeName("DE_FLAG") - operator = rules.RuleOperator.equals - comparator = rules.RuleComparator("Y") + type = RuleType.suppression + name = RuleName("Detained - Suppress Individuals In Detained Estates") + description = RuleDescription("Suppress where individual is identified as being in a Detained Estate") + priority = RulePriority(160) + attribute_level = RuleAttributeLevel.PERSON + attribute_name = RuleAttributeName("DE_FLAG") + operator = RuleOperator.equals + comparator = RuleComparator("Y") class ICBFilterRuleFactory(IterationRuleFactory): - type = rules.RuleType.filter - name = rules.RuleName("Not in QE1") - description = rules.RuleDescription("Not in QE1") - priority = rules.RulePriority(10) - operator = rules.RuleOperator.ne - attribute_level = rules.RuleAttributeLevel.PERSON - attribute_name = rules.RuleAttributeName("ICB") - comparator = rules.RuleComparator("QE1") + type = RuleType.filter + name = RuleName("Not in QE1") + description = RuleDescription("Not in QE1") + priority = RulePriority(10) + operator = RuleOperator.ne + attribute_level = RuleAttributeLevel.PERSON + attribute_name = RuleAttributeName("ICB") + comparator = RuleComparator("QE1") class ICBRedirectRuleFactory(IterationRuleFactory): - type = rules.RuleType.redirect - name = rules.RuleName("In QE1") - description = rules.RuleDescription("In QE1") - priority = rules.RulePriority(20) - operator = rules.RuleOperator.equals - attribute_level = rules.RuleAttributeLevel.PERSON - attribute_name = rules.RuleAttributeName("ICB") - comparator = rules.RuleComparator("QE1") - comms_routing = rules.CommsRouting("ActionCode1") + type = RuleType.redirect + name = RuleName("In QE1") + description = RuleDescription("In QE1") + priority = RulePriority(20) + operator = RuleOperator.equals + attribute_level = RuleAttributeLevel.PERSON + attribute_name = RuleAttributeName("ICB") + comparator = RuleComparator("QE1") + comms_routing = CommsRouting("ActionCode1") class ICBNonEligibleActionRuleFactory(IterationRuleFactory): - type = rules.RuleType.not_eligible_actions - name = rules.RuleName("In QE1") - description = rules.RuleDescription("In QE1") - priority = rules.RulePriority(20) - operator = rules.RuleOperator.equals - attribute_level = rules.RuleAttributeLevel.PERSON - attribute_name = rules.RuleAttributeName("ICB") - comparator = rules.RuleComparator("QE1") - comms_routing = rules.CommsRouting("ActionCode1") + type = RuleType.not_eligible_actions + name = RuleName("In QE1") + description = RuleDescription("In QE1") + priority = RulePriority(20) + operator = RuleOperator.equals + attribute_level = RuleAttributeLevel.PERSON + attribute_name = RuleAttributeName("ICB") + comparator = RuleComparator("QE1") + comms_routing = CommsRouting("ActionCode1") class ICBNonActionableActionRuleFactory(IterationRuleFactory): - type = rules.RuleType.not_actionable_actions - name = rules.RuleName("In QE1") - description = rules.RuleDescription("In QE1") - priority = rules.RulePriority(20) - operator = rules.RuleOperator.equals - attribute_level = rules.RuleAttributeLevel.PERSON - attribute_name = rules.RuleAttributeName("ICB") - comparator = rules.RuleComparator("QE1") - comms_routing = rules.CommsRouting("ActionCode1") + type = RuleType.not_actionable_actions + name = RuleName("In QE1") + description = RuleDescription("In QE1") + priority = RulePriority(20) + operator = RuleOperator.equals + attribute_level = RuleAttributeLevel.PERSON + attribute_name = RuleAttributeName("ICB") + comparator = RuleComparator("QE1") + comms_routing = CommsRouting("ActionCode1") diff --git a/tests/fixtures/builders/repos/person.py b/tests/fixtures/builders/repos/person.py index 6b9772acb..eb2b96d64 100644 --- a/tests/fixtures/builders/repos/person.py +++ b/tests/fixtures/builders/repos/person.py @@ -5,6 +5,7 @@ from faker import Faker +from eligibility_signposting_api.model.person import Person from tests.conftest import PersonDetailProvider Gender = Literal["0", "1", "2", "9"] # 0 - Not known, 1- Male, 2 - Female, 9 - Not specified. I know, right? @@ -27,7 +28,7 @@ def person_rows_builder( # noqa:PLR0913 de: bool | None = ..., msoa: str | None = ..., lsoa: str | None = ..., -) -> list[dict[str, Any]]: +) -> Person: faker = Faker("en_UK") faker.add_provider(PersonDetailProvider) @@ -85,4 +86,5 @@ def person_rows_builder( # noqa:PLR0913 ) shuffle(rows) - return rows + + return Person(data=rows) diff --git a/tests/fixtures/matchers/rules.py b/tests/fixtures/matchers/rules.py index 4aaadf311..d289f0b7e 100644 --- a/tests/fixtures/matchers/rules.py +++ b/tests/fixtures/matchers/rules.py @@ -1,6 +1,6 @@ from hamcrest.core.matcher import Matcher -from eligibility_signposting_api.model.rules import CampaignConfig, Iteration, IterationRule +from eligibility_signposting_api.model.campaign_config import CampaignConfig, Iteration, IterationRule from .meta import BaseAutoMatcher diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f4b0856d9..82059511c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -16,7 +16,8 @@ from httpx import RequestError from yarl import URL -from eligibility_signposting_api.model import eligibility_status, rules +from eligibility_signposting_api.model import eligibility_status +from eligibility_signposting_api.model.campaign_config import CampaignConfig, RuleType from eligibility_signposting_api.repos.campaign_repo import BucketName from eligibility_signposting_api.repos.person_repo import TableName from tests.fixtures.builders.model import rule @@ -335,7 +336,7 @@ def persisted_person(person_table: Any, faker: Faker) -> Generator[eligibility_s date_of_birth = eligibility_status.DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=65)) for row in ( - rows := person_rows_builder(nhs_number, date_of_birth=date_of_birth, postcode="hp1", cohorts=["cohort1"]) + rows := person_rows_builder(nhs_number, date_of_birth=date_of_birth, postcode="hp1", cohorts=["cohort1"]).data ): person_table.put_item(Item=row) @@ -356,7 +357,7 @@ def persisted_77yo_person(person_table: Any, faker: Faker) -> Generator[eligibil date_of_birth=date_of_birth, postcode="hp1", cohorts=["cohort1", "cohort2"], - ) + ).data ): person_table.put_item(Item=row) @@ -378,7 +379,7 @@ def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[e postcode="hp1", cohorts=["cohort_label1", "cohort_label2", "cohort_label3"], icb="QE1", - ) + ).data ): person_table.put_item(Item=row) @@ -392,7 +393,7 @@ def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[e def persisted_person_no_cohorts(person_table: Any, faker: Faker) -> Generator[eligibility_status.NHSNumber]: nhs_number = eligibility_status.NHSNumber(faker.nhs_number()) - for row in (rows := person_rows_builder(nhs_number)): + for row in (rows := person_rows_builder(nhs_number).data): person_table.put_item(Item=row) yield nhs_number @@ -406,7 +407,7 @@ def persisted_person_pc_sw19(person_table: Any, faker: Faker) -> Generator[eligi nhs_number = eligibility_status.NHSNumber( faker.nhs_number(), ) - for row in (rows := person_rows_builder(nhs_number, postcode="SW19", cohorts=["cohort1"])): + for row in (rows := person_rows_builder(nhs_number, postcode="SW19", cohorts=["cohort1"]).data): person_table.put_item(Item=row) yield nhs_number @@ -452,13 +453,13 @@ def firehose_delivery_stream(firehose_client: BaseClient, audit_bucket: BucketNa @pytest.fixture(scope="class") -def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[rules.CampaignConfig]: - campaign: rules.CampaignConfig = rule.CampaignConfigFactory.build( +def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: + campaign: CampaignConfig = rule.CampaignConfigFactory.build( target="RSV", iterations=[ rule.IterationFactory.build( iteration_rules=[ - rule.PostcodeSuppressionRuleFactory.build(type=rules.RuleType.filter), + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter), rule.PersonAgeSuppressionRuleFactory.build(), ], iteration_cohorts=[ @@ -481,13 +482,13 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato @pytest.fixture(scope="class") -def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[rules.CampaignConfig]]: +def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: """Create and upload multiple campaign configs to S3, then clean up after tests.""" campaigns, campaign_data_keys = [], [] targets = ["RSV", "COVID", "FLU"] target_rules_map = { - targets[0]: [rule.PersonAgeSuppressionRuleFactory.build(type=rules.RuleType.filter)], + targets[0]: [rule.PersonAgeSuppressionRuleFactory.build(type=RuleType.filter)], targets[1]: [rule.PersonAgeSuppressionRuleFactory.build()], targets[2]: [rule.ICBRedirectRuleFactory.build()], } @@ -526,15 +527,13 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - @pytest.fixture(scope="class") -def campaign_config_with_magic_cohort( - s3_client: BaseClient, rules_bucket: BucketName -) -> Generator[rules.CampaignConfig]: - campaign: rules.CampaignConfig = rule.CampaignConfigFactory.build( +def campaign_config_with_magic_cohort(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: + campaign: CampaignConfig = rule.CampaignConfigFactory.build( target="COVID", iterations=[ rule.IterationFactory.build( iteration_rules=[ - rule.PostcodeSuppressionRuleFactory.build(type=rules.RuleType.filter), + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter), rule.PersonAgeSuppressionRuleFactory.build(), ], iteration_cohorts=[rule.MagicCohortFactory.build(cohort_label="elid_all_people")], @@ -552,13 +551,13 @@ def campaign_config_with_magic_cohort( @pytest.fixture(scope="class") def campaign_config_with_missing_descriptions_missing_rule_text( s3_client: BaseClient, rules_bucket: BucketName -) -> Generator[rules.CampaignConfig]: - campaign: rules.CampaignConfig = rule.CampaignConfigFactory.build( +) -> Generator[CampaignConfig]: + campaign: CampaignConfig = rule.CampaignConfigFactory.build( target="FLU", iterations=[ rule.IterationFactory.build( iteration_rules=[ - rule.PostcodeSuppressionRuleFactory.build(type=rules.RuleType.filter), + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter), rule.PersonAgeSuppressionRuleFactory.build(), rule.PersonAgeSuppressionRuleFactory.build(name="Exclude 76 rolling", description=""), ], diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index c39c48207..ec911c1fb 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -11,10 +11,10 @@ has_key, ) +from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.model.eligibility_status import ( NHSNumber, ) -from eligibility_signposting_api.model.rules import CampaignConfig class TestBaseLine: diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 85fb305ea..6894e1e53 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -23,8 +23,8 @@ ) from yarl import URL +from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.model.eligibility_status import NHSNumber -from eligibility_signposting_api.model.rules import CampaignConfig from eligibility_signposting_api.repos.campaign_repo import BucketName logger = logging.getLogger(__name__) diff --git a/tests/integration/repo/test_campaign_repo.py b/tests/integration/repo/test_campaign_repo.py index 5870d1b32..96742d38a 100644 --- a/tests/integration/repo/test_campaign_repo.py +++ b/tests/integration/repo/test_campaign_repo.py @@ -5,7 +5,7 @@ from botocore.client import BaseClient from hamcrest import assert_that, has_item -from eligibility_signposting_api.model.rules import CampaignConfig +from eligibility_signposting_api.model.campaign_config import CampaignConfig from eligibility_signposting_api.repos.campaign_repo import BucketName, CampaignRepo from tests.fixtures.builders.model.rule import CampaignConfigFactory from tests.fixtures.matchers.rules import is_campaign_config, is_iteration, is_iteration_rule diff --git a/tests/integration/repo/test_person_repo.py b/tests/integration/repo/test_person_repo.py index 4dcf53383..25905e9cf 100644 --- a/tests/integration/repo/test_person_repo.py +++ b/tests/integration/repo/test_person_repo.py @@ -18,7 +18,7 @@ def test_person_found(person_table: Any, persisted_person: NHSNumber): # Then assert_that( - actual, + actual.data, contains_inanyorder( has_entries({"NHS_NUMBER": persisted_person, "ATTRIBUTE_TYPE": "PERSON"}), has_entries({"NHS_NUMBER": persisted_person, "ATTRIBUTE_TYPE": "COHORTS"}), diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py index b4f39f416..eabcdc767 100644 --- a/tests/unit/audit/test_audit_context.py +++ b/tests/unit/audit/test_audit_context.py @@ -9,6 +9,7 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.audit.audit_service import AuditService +from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignVersion, Iteration, RuleType from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, @@ -26,7 +27,6 @@ UrlLabel, UrlLink, ) -from eligibility_signposting_api.model.rules import CampaignID, CampaignVersion, Iteration, RuleType from tests.fixtures.builders.model.rule import IterationFactory diff --git a/tests/unit/model/test_rules.py b/tests/unit/model/test_campaign_config.py similarity index 97% rename from tests/unit/model/test_rules.py rename to tests/unit/model/test_campaign_config.py index 6419455d1..28f96d799 100644 --- a/tests/unit/model/test_rules.py +++ b/tests/unit/model/test_campaign_config.py @@ -5,7 +5,7 @@ from faker import Faker from hamcrest import assert_that -from eligibility_signposting_api.model.rules import IterationRule +from eligibility_signposting_api.model.campaign_config import IterationRule from tests.fixtures.builders.model.rule import IterationFactory, RawCampaignConfigFactory from tests.fixtures.matchers.rules import is_iteration_rule diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 819feb0c5..0f7798b8f 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -9,8 +9,22 @@ from pydantic import HttpUrl, ValidationError from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent -from eligibility_signposting_api.model import rules -from eligibility_signposting_api.model import rules as rules_model +from eligibility_signposting_api.model import campaign_config as rules_model +from eligibility_signposting_api.model.campaign_config import ( + ActionsMapper, + AvailableAction, + CohortLabel, + Description, + IterationCohort, + RuleAttributeLevel, + RuleAttributeName, + RuleAttributeTarget, + RuleComparator, + RuleName, + RuleOperator, + RuleStop, + RuleType, +) from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, @@ -26,7 +40,7 @@ UrlLabel, UrlLink, ) -from eligibility_signposting_api.model.rules import ActionsMapper, AvailableAction +from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator from tests.fixtures.builders.model import rule as rule_builder from tests.fixtures.builders.repos.person import person_rows_builder @@ -75,7 +89,7 @@ def test_get_action_rules_components(): # when actual_rules, actual_action_mapper, actual_default_comms = EligibilityCalculator.get_action_rules_components( - iteration, rules.RuleType.redirect + iteration, RuleType.redirect ) # then @@ -106,7 +120,7 @@ def test_not_base_eligible(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -151,7 +165,7 @@ def test_base_eligible_with_when_magic_cohort_is_present( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -201,7 +215,7 @@ def test_only_live_campaigns_considered(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -226,7 +240,7 @@ def test_campaigns_with_applicable_iteration_types_in_campaign_level_considered( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -259,7 +273,7 @@ def test_campaigns_with_applicable_iteration_types_in_iteration_level_considered calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -315,7 +329,7 @@ def test_base_eligible_and_simple_rule_includes(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -347,7 +361,7 @@ def test_base_eligible_but_simple_rule_excludes(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -394,7 +408,7 @@ def test_simple_rule_only_excludes_from_live_iteration(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -430,7 +444,7 @@ def test_rule_types_cause_correct_statuses(rule_type: rules_model.RuleType, expe calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -474,7 +488,7 @@ def test_multiple_rule_types_cause_correct_status(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -563,7 +577,7 @@ def test_rules_with_same_priority_must_all_match_to_exclude( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -616,7 +630,7 @@ def test_multiple_conditions_where_both_are_actionable(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -697,7 +711,7 @@ def test_multiple_conditions_where_all_give_unique_statuses(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("N", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("N", ["ALL"], "ALL") # Then assert_that( @@ -781,7 +795,7 @@ def test_multiple_campaigns_for_single_condition( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -828,7 +842,7 @@ def test_base_eligible_and_icb_example( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -879,28 +893,26 @@ def test_status_on_target_based_on_last_successful_date( rule_builder.IterationFactory.build( iteration_rules=[ rule_builder.IterationRuleFactory.build( - type=rules.RuleType.suppression, - name=rules.RuleName("You have already been vaccinated against RSV in the last year"), - description=rules.RuleDescription( - "Exclude anyone Completed RSV Vaccination in the last year" - ), + type=RuleType.suppression, + name=RuleName("You have already been vaccinated against RSV in the last year"), + description=RuleDescription("Exclude anyone Completed RSV Vaccination in the last year"), priority=10, - operator=rules.RuleOperator.day_gte, - attribute_level=rules.RuleAttributeLevel.TARGET, - attribute_name=rules.RuleAttributeName("LAST_SUCCESSFUL_DATE"), - comparator=rules.RuleComparator("-365"), - attribute_target=rules.RuleAttributeTarget("RSV"), + operator=RuleOperator.day_gte, + attribute_level=RuleAttributeLevel.TARGET, + attribute_name=RuleAttributeName("LAST_SUCCESSFUL_DATE"), + comparator=RuleComparator("-365"), + attribute_target=RuleAttributeTarget("RSV"), ), rule_builder.IterationRuleFactory.build( - type=rules.RuleType.suppression, - name=rules.RuleName("You have a vaccination date in the future for RSV"), - description=rules.RuleDescription("Exclude anyone with future Completed RSV Vaccination"), + type=RuleType.suppression, + name=RuleName("You have a vaccination date in the future for RSV"), + description=RuleDescription("Exclude anyone with future Completed RSV Vaccination"), priority=10, - operator=rules.RuleOperator.day_lte, - attribute_level=rules.RuleAttributeLevel.TARGET, - attribute_name=rules.RuleAttributeName("LAST_SUCCESSFUL_DATE"), - comparator=rules.RuleComparator("0"), - attribute_target=rules.RuleAttributeTarget("RSV"), + operator=RuleOperator.day_lte, + attribute_level=RuleAttributeLevel.TARGET, + attribute_name=RuleAttributeName("LAST_SUCCESSFUL_DATE"), + comparator=RuleComparator("0"), + attribute_target=RuleAttributeTarget("RSV"), ), ], iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], @@ -912,7 +924,7 @@ def test_status_on_target_based_on_last_successful_date( calculator = EligibilityCalculator(target_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -928,7 +940,7 @@ def test_status_on_target_based_on_last_successful_date( ("attribute_name", "expected_status", "test_comment"), [ ( - rules.RuleAttributeName("COHORT_LABEL"), + RuleAttributeName("COHORT_LABEL"), Status.not_eligible, "cohort label provided", ), @@ -940,17 +952,17 @@ def test_status_on_target_based_on_last_successful_date( ], ) def test_status_on_cohort_attribute_level( - attribute_name: rules.RuleAttributeName, expected_status: Status, test_comment: str, faker: Faker + attribute_name: RuleAttributeName, expected_status: Status, test_comment: str, faker: Faker ): # Given nhs_number = NHSNumber(faker.nhs_number()) - person_row: list[dict[str, Any]] = person_rows_builder( - nhs_number, cohorts=["cohort1", "covid_eligibility_complaint_list"] - ) - person_row_with_extra_items_in_cohort_row = [ - {**r, "LOCATION": "HP1"} for r in person_row if r.get("ATTRIBUTE_TYPE", "") == "COHORTS" - ] + person_row: Person = person_rows_builder(nhs_number, cohorts=["cohort1", "covid_eligibility_complaint_list"]) + + person_row_with_extra_items_in_cohort_row = Person(person_row.data) + for row in person_row_with_extra_items_in_cohort_row.data: + if row.get("ATTRIBUTE_TYPE", "") == "COHORTS": + row["LOCATION"] = "HP1" campaign_configs = [ rule_builder.CampaignConfigFactory.build( @@ -960,16 +972,16 @@ def test_status_on_cohort_attribute_level( iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], iteration_rules=[ rule_builder.IterationRuleFactory.build( - type=rules.RuleType.filter, - name=rules.RuleName("Exclude those in a complaint cohort"), - description=rules.RuleDescription( + type=RuleType.filter, + name=RuleName("Exclude those in a complaint cohort"), + description=RuleDescription( "Ensure anyone who has registered a complaint is not shown as eligible" ), priority=15, - operator=rules.RuleOperator.member_of, - attribute_level=rules.RuleAttributeLevel.COHORT, + operator=RuleOperator.member_of, + attribute_level=RuleAttributeLevel.COHORT, attribute_name=attribute_name, - comparator=rules.RuleComparator("covid_eligibility_complaint_list"), + comparator=RuleComparator("covid_eligibility_complaint_list"), ) ], ) @@ -980,7 +992,7 @@ def test_status_on_cohort_attribute_level( calculator = EligibilityCalculator(person_row_with_extra_items_in_cohort_row, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1026,7 +1038,7 @@ def test_status_if_iteration_rules_contains_cohort_label_field( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1042,7 +1054,7 @@ def test_status_if_iteration_rules_contains_cohort_label_field( ("rule_stop", "expected_reason_results", "test_comment"), # Changed expected_reasons to expected_reason_results [ ( - rules.RuleStop(True), # noqa: FBT003 + RuleStop(True), # noqa: FBT003 [ RuleDescription("reason 1"), RuleDescription("reason 2"), @@ -1050,7 +1062,7 @@ def test_status_if_iteration_rules_contains_cohort_label_field( "rule_stop is True, last rule should not run", ), ( - rules.RuleStop(False), # noqa: FBT003 + RuleStop(False), # noqa: FBT003 [ RuleDescription("reason 1"), RuleDescription("reason 2"), @@ -1061,7 +1073,7 @@ def test_status_if_iteration_rules_contains_cohort_label_field( ], ) def test_rules_stop_behavior( - rule_stop: rules.RuleStop, expected_reason_results: list[RuleDescription], test_comment: str, faker: Faker + rule_stop: RuleStop, expected_reason_results: list[RuleDescription], test_comment: str, faker: Faker ) -> None: # Given nhs_number = NHSNumber(faker.nhs_number()) @@ -1090,7 +1102,7 @@ def test_rules_stop_behavior( calculator = EligibilityCalculator(person_rows, [campaign_config]) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1191,7 +1203,7 @@ def test_eligibility_results_when_multiple_cohorts( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1285,14 +1297,14 @@ def test_cohort_groups_and_their_descriptions_when_magic_cohort_is_present( ], iteration_rules=[ # F common rule - rule_builder.DetainedEstateSuppressionRuleFactory.build(type=rules.RuleType.filter), + rule_builder.DetainedEstateSuppressionRuleFactory.build(type=RuleType.filter), # F rules for rsv_75_rolling rule_builder.ICBFilterRuleFactory.build( - type=rules.RuleType.filter, cohort_label=rules.CohortLabel("rsv_75_rolling") + type=RuleType.filter, cohort_label=CohortLabel("rsv_75_rolling") ), # S common rule rule_builder.PostcodeSuppressionRuleFactory.build( - comparator=rules.RuleComparator("SW19"), + comparator=RuleComparator("SW19"), ), ], ) @@ -1303,7 +1315,7 @@ def test_cohort_groups_and_their_descriptions_when_magic_cohort_is_present( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1356,7 +1368,7 @@ def test_cohort_groups_and_their_descriptions_when_best_status_is_not_eligible( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1444,7 +1456,7 @@ def test_cohort_groups_and_their_descriptions_and_the_collection_of_s_rules_when calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1526,7 +1538,7 @@ def test_cohort_group_and_descriptions_when_best_status_is_actionable( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1586,13 +1598,13 @@ def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_h rule_builder.IterationFactory.build( iteration_cohorts=[ rule_builder.Rsv75to79CohortFactory.build( - positive_description=rules.Description("rsv_age_range positive description 2"), - negative_description=rules.Description("rsv_age_range negative description 2"), + positive_description=Description("rsv_age_range positive description 2"), + negative_description=Description("rsv_age_range negative description 2"), priority=2, ), rule_builder.Rsv75RollingCohortFactory.build( - positive_description=rules.Description("rsv_age_range positive description 1"), - negative_description=rules.Description("rsv_age_range negative description 1"), + positive_description=Description("rsv_age_range positive description 1"), + negative_description=Description("rsv_age_range negative description 1"), priority=1, ), ], @@ -1605,7 +1617,7 @@ def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_h calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1712,7 +1724,7 @@ def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_h ) def test_cohort_group_descriptions_pick_first_non_empty_if_available( person_rows: list[dict[str, Any]], - iteration_cohorts: list[rules.IterationCohort], + iteration_cohorts: list[IterationCohort], expected_cohort_group_and_description: list[tuple[str, str]], expected_status: Status, test_comment: str, @@ -1725,7 +1737,7 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( rule_builder.IterationFactory.build( iteration_cohorts=iteration_cohorts, iteration_rules=[ - rule_builder.PostcodeSuppressionRuleFactory.build(type=rules.RuleType.filter), + rule_builder.PostcodeSuppressionRuleFactory.build(type=RuleType.filter), rule_builder.DetainedEstateSuppressionRuleFactory.build(), ], ) @@ -1736,7 +1748,7 @@ def test_cohort_group_descriptions_pick_first_non_empty_if_available( calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -1981,7 +1993,7 @@ def test_correct_actions_determined_from_redirect_r_rules( # noqa: PLR0913 calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -2026,7 +2038,7 @@ def test_cohort_label_not_supported_used_in_r_rules(test_comment: str, redirect_ ), iteration_rules=[ rule_builder.ICBRedirectRuleFactory.build( - cohort_label=rules.CohortLabel(redirect_r_rule_cohort_label) + cohort_label=CohortLabel(redirect_r_rule_cohort_label) ) ], ) @@ -2038,7 +2050,7 @@ def test_cohort_label_not_supported_used_in_r_rules(test_comment: str, redirect_ calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -2094,7 +2106,7 @@ def test_multiple_r_rules_match_with_same_priority(faker: Faker): rule_builder.ICBRedirectRuleFactory.build(comms_routing="rule_2_comms_routing"), rule_builder.ICBRedirectRuleFactory.build( priority=2, - attribute_name=rules.RuleAttributeName("ICBMismatch"), + attribute_name=RuleAttributeName("ICBMismatch"), comms_routing="rule_3_comms_routing", ), ], @@ -2107,7 +2119,7 @@ def test_multiple_r_rules_match_with_same_priority(faker: Faker): calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -2161,7 +2173,7 @@ def test_multiple_r_rules_with_same_priority_one_rule_mismatch_should_return_def rule_builder.ICBRedirectRuleFactory.build(comms_routing="rule_1_comms_routing"), rule_builder.ICBRedirectRuleFactory.build(comms_routing="rule_2_comms_routing"), rule_builder.ICBRedirectRuleFactory.build( - attribute_name=rules.RuleAttributeName("ICBMismatch"), + attribute_name=RuleAttributeName("ICBMismatch"), comms_routing="rule_3_comms_routing", ), ], @@ -2174,7 +2186,7 @@ def test_multiple_r_rules_with_same_priority_one_rule_mismatch_should_return_def calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -2248,7 +2260,7 @@ def test_only_highest_priority_rule_is_applied_and_return_actions_only_for_that_ calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") expected_actions = SuggestedAction( internal_action_code=InternalActionCode("rule_1_comms_routing"), @@ -2304,7 +2316,7 @@ def test_should_include_actions_when_include_actions_flag_is_true_when_status_is calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then assert_that( @@ -2364,7 +2376,7 @@ def test_should_not_include_actions_when_include_actions_flag_is_false_when_stat calculator = EligibilityCalculator(person_rows, campaign_configs) # When - actual = calculator.evaluate_eligibility("N", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("N", ["ALL"], "ALL") # Then assert_that( @@ -2557,7 +2569,7 @@ def test_correct_actions_determined_from_not_eligible_action_rules( # noqa: PLR with app.app_context(): g.audit_log = AuditEvent() - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") assert_that( actual, @@ -2614,7 +2626,7 @@ def test_no_actions_returned_when_non_eligible_actions_and_defaultcomms_not_give with app.app_context(): g.audit_log = AuditEvent() - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then expected_actions = [] @@ -2679,7 +2691,7 @@ def test_actions_returned_when_non_eligible_actions_not_given_and_defaultcomms_g with app.app_context(): g.audit_log = AuditEvent() - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then expected_actions = [ @@ -2886,7 +2898,7 @@ def test_correct_actions_determined_from_not_actionable_action_rules( # noqa: P with app.app_context(): g.audit_log = AuditEvent() - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") assert_that( actual, @@ -2940,7 +2952,7 @@ def test_no_actions_returned_when_non_actionable_actions_and_defaultcomms_not_gi with app.app_context(): g.audit_log = AuditEvent() - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then expected_actions = [] @@ -3005,7 +3017,7 @@ def test_actions_returned_when_non_actionable_actions_not_given_and_defaultcomms with app.app_context(): g.audit_log = AuditEvent() - actual = calculator.evaluate_eligibility("Y", ["ALL"], "ALL") + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") # Then expected_actions = [ diff --git a/tests/unit/services/calculators/test_rule_calculator.py b/tests/unit/services/calculators/test_rule_calculator.py index b8069a16a..8e42013d9 100644 --- a/tests/unit/services/calculators/test_rule_calculator.py +++ b/tests/unit/services/calculators/test_rule_calculator.py @@ -1,31 +1,27 @@ -from collections.abc import Collection, Mapping -from typing import Any - import pytest -from eligibility_signposting_api.model import rules +from eligibility_signposting_api.model.campaign_config import IterationRule, RuleAttributeLevel +from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.calculators.rule_calculator import RuleCalculator from tests.fixtures.builders.model import rule as rule_builder -Row = Collection[Mapping[str, Any]] - @pytest.mark.parametrize( ("person_data", "rule", "expected"), [ # PERSON attribute level ( - [{"ATTRIBUTE_TYPE": "PERSON", "POSTCODE": "SW19"}], + Person([{"ATTRIBUTE_TYPE": "PERSON", "POSTCODE": "SW19"}]), rule_builder.IterationRuleFactory.build( - attribute_level=rules.RuleAttributeLevel.PERSON, attribute_name="POSTCODE" + attribute_level=RuleAttributeLevel.PERSON, attribute_name="POSTCODE" ), "SW19", ), # TARGET attribute level ( - [{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20240101"}], + Person([{"ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": "20240101"}]), rule_builder.IterationRuleFactory.build( - attribute_level=rules.RuleAttributeLevel.TARGET, + attribute_level=RuleAttributeLevel.TARGET, attribute_name="LAST_SUCCESSFUL_DATE", attribute_target="RSV", ), @@ -33,17 +29,17 @@ ), # COHORT attribute level ( - [{"ATTRIBUTE_TYPE": "COHORTS", "COHORT_LABEL": ""}], + Person([{"ATTRIBUTE_TYPE": "COHORTS", "COHORT_LABEL": ""}]), rule_builder.IterationRuleFactory.build( - attribute_level=rules.RuleAttributeLevel.COHORT, attribute_name="COHORT_LABEL" + attribute_level=RuleAttributeLevel.COHORT, attribute_name="COHORT_LABEL" ), "", ), ], ) -def test_get_attribute_value_for_all_attribute_levels(person_data: Row, rule: rules.IterationRule, expected: str): +def test_get_attribute_value_for_all_attribute_levels(person_data: Person, rule: IterationRule, expected: str): # Given - calc = RuleCalculator(person_data=person_data, rule=rule) + calc = RuleCalculator(person=person_data, rule=rule) # When actual = calc.get_attribute_value() # Then diff --git a/tests/unit/services/operators/test_operators.py b/tests/unit/services/operators/test_operators.py index ffb5777c5..e1c74164e 100644 --- a/tests/unit/services/operators/test_operators.py +++ b/tests/unit/services/operators/test_operators.py @@ -2,7 +2,7 @@ from freezegun import freeze_time from hamcrest import assert_that, equal_to -from eligibility_signposting_api.model.rules import RuleOperator +from eligibility_signposting_api.model.campaign_config import RuleOperator from eligibility_signposting_api.services.operators.operators import Operator, OperatorRegistry # Test cases: person_data, rule_operator, rule_value, expected, test_comment diff --git a/tests/unit/services/processors/test_campaign_evaluator.py b/tests/unit/services/processors/test_campaign_evaluator.py index e968e3e9e..a0b59a53a 100644 --- a/tests/unit/services/processors/test_campaign_evaluator.py +++ b/tests/unit/services/processors/test_campaign_evaluator.py @@ -3,7 +3,7 @@ import pytest from hamcrest import assert_that, is_ -from eligibility_signposting_api.model.rules import CampaignID +from eligibility_signposting_api.model.campaign_config import CampaignID from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator from tests.fixtures.builders.model import rule diff --git a/tests/unit/services/processors/test_cohort_handler.py b/tests/unit/services/processors/test_cohort_handler.py new file mode 100644 index 000000000..8eb25ca32 --- /dev/null +++ b/tests/unit/services/processors/test_cohort_handler.py @@ -0,0 +1,122 @@ +from unittest.mock import Mock + +import pytest +from hamcrest import assert_that, has_length, is_ + +from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Status +from eligibility_signposting_api.model.person import Person +from eligibility_signposting_api.services.processors.cohort_handler import ( + BaseEligibilityHandler, + CohortEligibilityHandler, + FilterRuleHandler, + SuppressionRuleHandler, +) +from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor +from tests.fixtures.builders.model import rule as rule_builder + +MOCK_PERSON = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + +@pytest.fixture +def mock_rule_processor_for_handlers(): + return Mock(spec=RuleProcessor) + + +@pytest.fixture +def mock_next_handler(): + return Mock(spec=CohortEligibilityHandler) + + +def test_base_eligibility_handler_is_base_eligible(mock_rule_processor_for_handlers, mock_next_handler): + handler = BaseEligibilityHandler(next_handler=mock_next_handler) + cohort = rule_builder.IterationCohortFactory.build(cohort_label="cohort1") + cohort_results = {} + + mock_rule_processor_for_handlers.is_base_eligible.return_value = True + + handler.handle(MOCK_PERSON, cohort, cohort_results, mock_rule_processor_for_handlers) + + mock_rule_processor_for_handlers.is_base_eligible.assert_called_once_with(MOCK_PERSON, cohort) + assert_that(cohort_results, is_({})) + + mock_next_handler.handle.assert_called_once_with( + MOCK_PERSON, cohort, cohort_results, mock_rule_processor_for_handlers + ) + + +def test_base_eligibility_handler_is_not_base_eligible(mock_rule_processor_for_handlers, mock_next_handler): + handler = BaseEligibilityHandler(next_handler=mock_next_handler) + cohort = rule_builder.IterationCohortFactory.build(cohort_label="cohort1", negative_description="Not Base eligible") + cohort_results = {} + + mock_rule_processor_for_handlers.is_base_eligible.return_value = False + + handler.handle(MOCK_PERSON, cohort, cohort_results, mock_rule_processor_for_handlers) + + mock_rule_processor_for_handlers.is_base_eligible.assert_called_once_with(MOCK_PERSON, cohort) + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["cohort1"].status, is_(Status.not_eligible)) + assert_that(cohort_results["cohort1"].description, is_("Not Base eligible")) + mock_next_handler.handle.assert_not_called() + + +def test_filter_rule_handler_is_eligible(mock_rule_processor_for_handlers, mock_next_handler): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="cohort1") + cohort_results = {} + filter_rules = [Mock()] + handler = FilterRuleHandler(next_handler=mock_next_handler, filter_rules=filter_rules) + + mock_rule_processor_for_handlers.is_eligible.return_value = True + + handler.handle(MOCK_PERSON, cohort, cohort_results, mock_rule_processor_for_handlers) + + mock_rule_processor_for_handlers.is_eligible.assert_called_once_with( + MOCK_PERSON, cohort, cohort_results, filter_rules + ) + assert_that(cohort_results, is_({})) + + mock_next_handler.handle.assert_called_once_with( + MOCK_PERSON, cohort, cohort_results, mock_rule_processor_for_handlers + ) + + +def test_filter_rule_handler_is_not_eligible(mock_rule_processor_for_handlers, mock_next_handler): + filter_rules = [Mock()] + handler = FilterRuleHandler(next_handler=mock_next_handler, filter_rules=filter_rules) + cohort = rule_builder.IterationCohortFactory.build(cohort_label="cohort1", negative_description="Not Eligible") + cohort_results = {} + + mock_rule_processor_for_handlers.is_eligible.side_effect = ( + lambda p, c, cr, fr: cr.update( # noqa: ARG005 + {c.cohort_label: CohortGroupResult(c.cohort_group, Status.not_eligible, [], c.negative_description, [])} + ) + or False + ) + + handler.handle(MOCK_PERSON, cohort, cohort_results, mock_rule_processor_for_handlers) + + mock_rule_processor_for_handlers.is_eligible.assert_called_once_with( + MOCK_PERSON, cohort, cohort_results, filter_rules + ) + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["cohort1"].status, is_(Status.not_eligible)) + mock_next_handler.handle.assert_not_called() + + +def test_suppression_rule_handler_is_actionable(mock_rule_processor_for_handlers): + suppression_rules = [Mock()] + handler = SuppressionRuleHandler(suppression_rules=suppression_rules) + cohort = rule_builder.IterationCohortFactory.build(cohort_label="cohort1", positive_description="Actionable") + cohort_results = {} + + mock_rule_processor_for_handlers.is_actionable.side_effect = lambda p, c, cr, sr: cr.update( # noqa: ARG005 + {c.cohort_label: CohortGroupResult(c.cohort_group, Status.actionable, [], c.positive_description, [])} + ) + + handler.handle(MOCK_PERSON, cohort, cohort_results, mock_rule_processor_for_handlers) + + mock_rule_processor_for_handlers.is_actionable.assert_called_once_with( + MOCK_PERSON, cohort, cohort_results, suppression_rules + ) + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["cohort1"].status, is_(Status.actionable)) diff --git a/tests/unit/services/processors/test_person_data_reader.py b/tests/unit/services/processors/test_person_data_reader.py index 2191c44b3..6219cd8a3 100644 --- a/tests/unit/services/processors/test_person_data_reader.py +++ b/tests/unit/services/processors/test_person_data_reader.py @@ -1,6 +1,7 @@ import pytest from hamcrest import assert_that, is_ +from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader @@ -10,77 +11,92 @@ def person_data_reader(): def test_get_person_cohorts_empty_data(person_data_reader): - result = person_data_reader.get_person_cohorts([]) + result = person_data_reader.get_person_cohorts(Person([])) assert_that(result, is_(set())) def test_get_person_cohorts_no_cohorts_attribute_type(person_data_reader): - no_cohorts_type = [ - {"ATTRIBUTE_TYPE": "NAME", "VALUE": "John Doe"}, - {"ATTRIBUTE_TYPE": "AGE", "VALUE": 30}, - ] + no_cohorts_type = Person( + [ + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "John Doe"}, + {"ATTRIBUTE_TYPE": "AGE", "VALUE": 30}, + ] + ) result = person_data_reader.get_person_cohorts(no_cohorts_type) assert_that(result, is_(set())) def test_get_person_cohorts_no_cohort_map_key(person_data_reader): - no_cohorts_map = [ - {"ATTRIBUTE_TYPE": "COHORTS", "OTHER_FIELD": "value"}, - ] + no_cohorts_map = Person( + [ + {"ATTRIBUTE_TYPE": "COHORTS", "OTHER_FIELD": "value"}, + ] + ) result = person_data_reader.get_person_cohorts(no_cohorts_map) assert_that(result, is_(set())) def test_get_person_cohorts_single_cohort(person_data_reader): - single_cohorts = [ - { - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "flu_65+_autumnwinter2023", "DATE_JOINED": "20231020"}], - }, - {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Jane Smith"}, - ] + single_cohorts = Person( + [ + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "flu_65+_autumnwinter2023", "DATE_JOINED": "20231020"}], + }, + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Jane Smith"}, + ] + ) result = person_data_reader.get_person_cohorts(single_cohorts) assert_that(result, is_({"flu_65+_autumnwinter2023"})) def test_get_person_cohorts_multiple_cohorts(person_data_reader): - multiple_cohorts = [ - { - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [ - {"COHORT_LABEL": "COHORT_B", "DATE_JOINED": "20231020"}, - {"COHORT_LABEL": "COHORT_C", "DATE_JOINED": "20241020"}, - ], - }, - {"ATTRIBUTE_TYPE": "AGE", "VALUE": 45}, - ] + multiple_cohorts = Person( + [ + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": "COHORT_B", "DATE_JOINED": "20231020"}, + {"COHORT_LABEL": "COHORT_C", "DATE_JOINED": "20241020"}, + ], + }, + {"ATTRIBUTE_TYPE": "AGE", "VALUE": 45}, + ] + ) result = person_data_reader.get_person_cohorts(multiple_cohorts) assert_that(result, is_({"COHORT_B", "COHORT_C"})) def test_get_person_cohorts_mixed_data(person_data_reader): - mixed_data = [ - { - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [ - {"COHORT_LABEL": "COHORT_D", "DATE_JOINED": "20231020"}, - {"COHORT_LABEL": "COHORT_E", "DATE_JOINED": "20241020"}, - ], - }, - {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Alice"}, - {"ATTRIBUTE_TYPE": "ADDRESS", "VALUE": "123 Main St"}, - ] + mixed_data = Person( + [ + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": "COHORT_D", "DATE_JOINED": "20231020"}, + {"COHORT_LABEL": "COHORT_E", "DATE_JOINED": "20241020"}, + ], + }, + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Alice"}, + {"ATTRIBUTE_TYPE": "ADDRESS", "VALUE": "123 Main St"}, + ] + ) result = person_data_reader.get_person_cohorts(mixed_data) assert_that(result, is_({"COHORT_D", "COHORT_E"})) def test_get_person_cohorts_with_other_attribute_types_present(person_data_reader): - data = [ - {"ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "COHORT_F", "DATE_JOINED": "20231020"}]}, - {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Charlie"}, - {"ATTRIBUTE_TYPE": "AGE", "VALUE": 25}, - ] + data = Person( + [ + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [{"COHORT_LABEL": "COHORT_F", "DATE_JOINED": "20231020"}], + }, + {"ATTRIBUTE_TYPE": "NAME", "VALUE": "Charlie"}, + {"ATTRIBUTE_TYPE": "AGE", "VALUE": 25}, + ] + ) result = person_data_reader.get_person_cohorts(data) assert_that(result, is_({"COHORT_F"})) diff --git a/tests/unit/services/processors/test_rule_processor.py b/tests/unit/services/processors/test_rule_processor.py new file mode 100644 index 000000000..1531c3a39 --- /dev/null +++ b/tests/unit/services/processors/test_rule_processor.py @@ -0,0 +1,472 @@ +from unittest.mock import Mock, patch + +import pytest +from hamcrest import assert_that, empty, has_length, is_ + +from eligibility_signposting_api.model.campaign_config import RuleType +from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Reason, Status +from eligibility_signposting_api.model.person import Person +from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader +from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor +from tests.fixtures.builders.model import rule as rule_builder +from tests.fixtures.builders.model.eligibility import ReasonFactory + + +@pytest.fixture +def mock_person_data_reader(): + return Mock(spec=PersonDataReader) + + +@pytest.fixture +def rule_processor(mock_person_data_reader): + return RuleProcessor(mock_person_data_reader) + + +MOCK_PERSON_DATA = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + + +def test_get_exclusion_rules_no_rules(): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + rules_to_filter = [] + result = list(RuleProcessor.get_exclusion_rules(cohort, rules_to_filter)) + assert_that(result, is_([])) + + +def test_get_exclusion_rules_general_rule(): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + no_cohort_label_rule = rule_builder.IterationRuleFactory.build(cohort_label=None) + rules_to_filter = [no_cohort_label_rule] + result = list(RuleProcessor.get_exclusion_rules(cohort, rules_to_filter)) + assert_that(result, is_([no_cohort_label_rule])) + + +def test_get_exclusion_rules_matching_cohort_label(): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + matching_rule = rule_builder.IterationRuleFactory.build(cohort_label="COHORT_A") + rules_to_filter = [matching_rule] + result = list(RuleProcessor.get_exclusion_rules(cohort, rules_to_filter)) + assert_that(result, is_([matching_rule])) + + +def test_get_exclusion_rules_non_matching_cohort_label(): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + non_matching_rule = rule_builder.IterationRuleFactory.build(cohort_label="COHORT_B") + rules_to_filter = [non_matching_rule] + result = list(RuleProcessor.get_exclusion_rules(cohort, rules_to_filter)) + assert_that(result, is_([])) + + +def test_get_exclusion_rules_matching_from_list_cohort_label(): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + rule1 = rule_builder.IterationRuleFactory.build(cohort_label="COHORT_A") + rule2 = rule_builder.IterationRuleFactory.build(cohort_label="COHORT_B") + rules_to_filter = [rule1, rule2] + result = list(RuleProcessor.get_exclusion_rules(cohort, rules_to_filter)) + assert_that(result, is_([rule1])) + + +def test_get_exclusion_rules_mixed_rules(): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + no_cohort_label_rule = rule_builder.IterationRuleFactory.build(cohort_label=None, name="General") + matching_rule = rule_builder.IterationRuleFactory.build(cohort_label="COHORT_A", name="Matching") + non_matching_rule = rule_builder.IterationRuleFactory.build(cohort_label="COHORT_B", name="NonMatching") + + rules_to_filter = [no_cohort_label_rule, matching_rule, non_matching_rule] + result = list(RuleProcessor.get_exclusion_rules(cohort, rules_to_filter)) + assert_that({r.name for r in result}, is_({"General", "Matching"})) + + +@patch("eligibility_signposting_api.services.processors.rule_processor.RuleCalculator") +def test_evaluate_rules_priority_group_all_actionable(mock_rule_calculator_class, rule_processor): + mock_rule_calculator_class.return_value.evaluate_exclusion.return_value = ( + Status.actionable, + Mock(spec=Reason, matcher_matched=False), + ) + + rule1 = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter) + rule2 = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter) + rules_group = iter([rule1, rule2]) + + status, reasons, is_rule_stop = rule_processor.evaluate_rules_priority_group(MOCK_PERSON_DATA, rules_group) + + assert_that(status, is_(Status.actionable)) + assert_that(reasons, is_([])) + assert_that(is_rule_stop, is_(False)) + assert_that(mock_rule_calculator_class.call_count, is_(2)) + + +@patch("eligibility_signposting_api.services.processors.rule_processor.RuleCalculator") +def test_evaluate_rules_priority_group_one_not_eligible(mock_rule_calculator_class, rule_processor): + mock_rule_calculator_class.side_effect = [ + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(spec=Reason, matcher_matched=False)))), + Mock( + evaluate_exclusion=Mock( + return_value=( + Status.not_eligible, + ReasonFactory.build(rule_name="ExclusionReason", matcher_matched=True), + ) + ) + ), + ] + + rule1 = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter, name="Rule1") + rule2 = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter, name="Rule2") + rules_group = iter([rule1, rule2]) + + status, reasons, is_rule_stop = rule_processor.evaluate_rules_priority_group(MOCK_PERSON_DATA, rules_group) + + assert_that(status, is_(Status.actionable)) + assert_that(reasons, has_length(1)) + assert_that(reasons[0].rule_name, is_("ExclusionReason")) + assert_that(is_rule_stop, is_(False)) + assert_that(mock_rule_calculator_class.call_count, is_(2)) + + +@patch("eligibility_signposting_api.services.processors.rule_processor.RuleCalculator") +def test_evaluate_rules_priority_group_with_rule_stop(mock_rule_calculator_class, rule_processor): + mock_rule_calculator_class.side_effect = [ + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(spec=Reason, matcher_matched=False)))), + Mock( + evaluate_exclusion=Mock( + return_value=(Status.not_eligible, ReasonFactory.build(rule_name="StopReason", matcher_matched=True)) + ) + ), + ] + + rule1 = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.suppression, rule_stop=False) + rule2 = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.suppression, rule_stop=True) + rules_group = iter([rule1, rule2]) + + status, reasons, is_rule_stop = rule_processor.evaluate_rules_priority_group(MOCK_PERSON_DATA, rules_group) + + assert_that(status, is_(Status.actionable)) + assert_that(reasons, has_length(1)) + assert_that(is_rule_stop, is_(True)) + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_is_eligible_by_filter_rules_eligible( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + cohort_results = {} + filter_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter) + filter_rules = [filter_rule] + + mock_evaluate_rules_priority_group.return_value = (Status.actionable, [], False) + + is_eligible = rule_processor.is_eligible(MOCK_PERSON_DATA, cohort, cohort_results, filter_rules) + + assert_that(is_eligible, is_(True)) + assert_that(cohort_results, is_({})) + mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_is_eligible_by_filter_rules_not_eligible( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", negative_description="Not Eligible") + cohort_results = {} + filter_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter, name="F1") + filter_rules = [filter_rule] + mock_reason = ReasonFactory.build(rule_name="F1_Reason") + + mock_evaluate_rules_priority_group.return_value = (Status.not_eligible, [mock_reason], False) + + is_eligible = rule_processor.is_eligible(MOCK_PERSON_DATA, cohort, cohort_results, filter_rules) + + assert_that(is_eligible, is_(False)) + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.not_eligible)) + assert_that(cohort_results["COHORT_A"].description, is_("Not Eligible")) + assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) + mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_evaluate_suppression_rules_actionable( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", positive_description="Actionable") + cohort_results = {} + suppression_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.suppression) + suppression_rules = [suppression_rule] + + mock_evaluate_rules_priority_group.return_value = (Status.actionable, [], False) + + rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) + + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.actionable)) + assert_that(cohort_results["COHORT_A"].description, is_("Actionable")) + assert_that(cohort_results["COHORT_A"].reasons, is_([])) + assert_that(cohort_results["COHORT_A"].audit_rules, is_([])) + mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_evaluate_suppression_rules_not_actionable( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build( + cohort_label="COHORT_A", positive_description="Positive Description" + ) + cohort_results = {} + suppression_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.suppression, name="S1") + suppression_rules = [suppression_rule] + mock_reason = ReasonFactory.build(rule_name="S1_Reason") + + mock_evaluate_rules_priority_group.return_value = (Status.not_actionable, [mock_reason], False) + + rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) + + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) + assert_that(cohort_results["COHORT_A"].description, is_("Positive Description")) + assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason])) + assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) + mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_evaluate_suppression_rules_stops_on_rule_stop( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + cohort_results = {} + suppression_rule_p1 = rule_builder.IterationRuleFactory.build( + priority=1, type=RuleType.suppression, rule_stop=True, name="S1" + ) + suppression_rule_p2 = rule_builder.IterationRuleFactory.build(priority=2, type=RuleType.suppression, name="S2") + suppression_rules = [suppression_rule_p1, suppression_rule_p2] + + mock_reason_p1 = ReasonFactory.build(rule_name="S1_Reason") + mock_reason_p2 = ReasonFactory.build(rule_name="S2_Reason") + + mock_evaluate_rules_priority_group.side_effect = [ + (Status.not_actionable, [mock_reason_p1], True), + (Status.not_actionable, [mock_reason_p2], False), + ] + + rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) + + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) + assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason_p1])) + assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p1])) + mock_evaluate_rules_priority_group.assert_called_once() + mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_evaluate_suppression_rules_does_not_stop_on_rule_stop_when_status_is_actionable( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + cohort_results = {} + suppression_rule_p1 = rule_builder.IterationRuleFactory.build( + priority=1, type=RuleType.suppression, rule_stop=True, name="S1" + ) + suppression_rule_p2 = rule_builder.IterationRuleFactory.build(priority=2, type=RuleType.suppression, name="S2") + suppression_rules = [suppression_rule_p1, suppression_rule_p2] + + mock_reason_p1 = ReasonFactory.build(rule_name="S1_Reason") + mock_reason_p2 = ReasonFactory.build(rule_name="S2_Reason") + + mock_evaluate_rules_priority_group.side_effect = [ + (Status.actionable, [mock_reason_p1], True), + (Status.not_actionable, [mock_reason_p2], False), + ] + + rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) + + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) + assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason_p2])) + assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p2])) + + assert_that(mock_evaluate_rules_priority_group.call_count, is_(2)) + assert_that(mock_get_exclusion_rules.call_count, is_(1)) + + +def test_is_base_eligible(mock_person_data_reader): + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": "COHORT_A"}, + {"COHORT_LABEL": "COHORT_C"}, + ], + }, + ] + ) + + rule_processor = RuleProcessor(mock_person_data_reader) + mock_person_data_reader.get_person_cohorts.return_value = {"COHORT_A", "COHORT_C"} + + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + + assert_that(rule_processor.is_base_eligible(person, cohort), is_(True)) + mock_person_data_reader.get_person_cohorts.assert_called_once_with(person) + + +def test_is_not_base_eligible(mock_person_data_reader): + person = Person( + [ + {"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}, + { + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": "COHORT_C"}, + ], + }, + ] + ) + + rule_processor = RuleProcessor(mock_person_data_reader) + mock_person_data_reader.get_person_cohorts.return_value = {"COHORT_C"} + + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + + assert_that(rule_processor.is_base_eligible(person, cohort), is_(False)) + mock_person_data_reader.get_person_cohorts.assert_called_once_with(person) + + +def test_rules_get_group_by_types_of_rules(rule_processor): + active_iteration = rule_builder.IterationFactory.build() + iteration_rules = active_iteration.iteration_rules + iteration_rules.append(rule_builder.IterationRuleFactory.build()) + + iteration_rules[0].type = RuleType.filter + iteration_rules[1].type = RuleType.suppression + iteration_rules[2].type = RuleType.filter + + rules_by_type = rule_processor.get_rules_by_type(active_iteration) + + assert_that(len(rules_by_type), is_(2)) + + assert_that(rules_by_type[0][0].type, is_(RuleType.filter)) + assert_that(rules_by_type[0][1].type, is_(RuleType.filter)) + assert_that(rules_by_type[1][0].type, is_(RuleType.suppression)) + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_is_eligible_by_filter_rules(mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") + cohort_results = {} + filter_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter) + filter_rules = [filter_rule] + + mock_evaluate_rules_priority_group.return_value = (Status.actionable, [], False) + + is_eligible = rule_processor.is_eligible(MOCK_PERSON_DATA, cohort, cohort_results, filter_rules) + + assert_that(is_eligible, is_(True)) + assert_that(cohort_results, is_({})) + mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_is_not_eligible_by_filter_rules(mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", negative_description="Not Eligible") + cohort_results = {} + filter_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter, name="F1") + filter_rules = [filter_rule] + mock_reason = ReasonFactory.build(rule_name="F1_Reason") + + def mock_evaluate_side_effect(person, rules_group): # noqa: ARG001 + cohort_results[cohort.cohort_label] = CohortGroupResult( + cohort.cohort_group, + Status.not_eligible, + [], + cohort.negative_description, + [mock_reason], + ) + return Status.not_eligible, [mock_reason], False + + mock_evaluate_rules_priority_group.side_effect = mock_evaluate_side_effect + + is_eligible = rule_processor.is_eligible(MOCK_PERSON_DATA, cohort, cohort_results, filter_rules) + + assert_that(is_eligible, is_(False)) + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.not_eligible)) + assert_that(cohort_results["COHORT_A"].description, is_("Not Eligible")) + assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) + mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_is_actionable_by_suppression_rules( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", positive_description="Actionable") + cohort_results = {} + suppression_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.suppression) + suppression_rules = [suppression_rule] + + mock_evaluate_rules_priority_group.return_value = (Status.actionable, [], False) + + rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) + + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.actionable)) + assert_that(cohort_results["COHORT_A"].description, is_("Actionable")) + assert_that(cohort_results["COHORT_A"].reasons, is_(empty())) + assert_that(cohort_results["COHORT_A"].audit_rules, is_(empty())) + mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_is_not_actionable_by_suppression_rules( + mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor +): + cohort = rule_builder.IterationCohortFactory.build( + cohort_label="COHORT_A", positive_description="Positive Description" + ) + cohort_results = {} + suppression_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.suppression, name="S1") + suppression_rules = [suppression_rule] + mock_reason = ReasonFactory.build(rule_name="S1_Reason") + + def mock_evaluate_side_effect(person, rules_group): # noqa: ARG001 + cohort_results[cohort.cohort_label] = CohortGroupResult( + cohort.cohort_group, + Status.not_actionable, + [mock_reason], + cohort.positive_description, + [mock_reason], + ) + return Status.not_actionable, [mock_reason], False + + mock_evaluate_rules_priority_group.side_effect = mock_evaluate_side_effect + + rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) + + assert_that(cohort_results, has_length(1)) + assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) + assert_that(cohort_results["COHORT_A"].description, is_("Positive Description")) + assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason])) + assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) + mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_evaluate_rules_priority_group.assert_called_once() From 26e1e300373bae00f720e564d33b515c3bd23c27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:58:52 +0000 Subject: [PATCH 22/61] Bump botocore from 1.38.42 to 1.38.46 Bumps [botocore](https://github.com/boto/botocore) from 1.38.42 to 1.38.46. - [Commits](https://github.com/boto/botocore/compare/1.38.42...1.38.46) --- updated-dependencies: - dependency-name: botocore dependency-version: 1.38.46 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 21 ++++++++++++--------- pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/poetry.lock b/poetry.lock index ed37ac0d5..5b64d014c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -236,18 +236,18 @@ wrapt = "*" [[package]] name = "awscli" -version = "1.40.41" +version = "1.40.45" description = "Universal Command Line Environment for AWS." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "awscli-1.40.41-py3-none-any.whl", hash = "sha256:d75cc6c654418ac4d30eb996081033e90024fa7a661db8ab40de4b5a545eaa79"}, - {file = "awscli-1.40.41.tar.gz", hash = "sha256:553c3a3ba7879be18c5db219f9a710daf90d750044eb604297b25805b05ebc42"}, + {file = "awscli-1.40.45-py3-none-any.whl", hash = "sha256:017cdb820e9d1a1ff72abd968b27eea8c36f5d0a2f30dad555d027a8c53c18fe"}, + {file = "awscli-1.40.45.tar.gz", hash = "sha256:41c06b168de2bb4e573804de8034f061e1d856bd0a362609badef8f47cd33bed"}, ] [package.dependencies] -botocore = "1.38.42" +botocore = "1.38.46" colorama = ">=0.2.5,<0.4.7" docutils = ">=0.18.1,<=0.19" PyYAML = ">=3.10,<6.1" @@ -349,14 +349,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.38.42" +version = "1.38.46" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "botocore-1.38.42-py3-none-any.whl", hash = "sha256:fbbeac30c045b5c19f1c3bb063ea2b6315ce2d6fcb3d898e87d1c1846297961c"}, - {file = "botocore-1.38.42.tar.gz", hash = "sha256:3a14188e48f6e26be561164373d34150fa9cb39f7ad32cc745dcd3ab05f43683"}, + {file = "botocore-1.38.46-py3-none-any.whl", hash = "sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b"}, + {file = "botocore-1.38.46.tar.gz", hash = "sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e"}, ] [package.dependencies] @@ -1547,8 +1547,11 @@ files = [ {file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"}, {file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"}, + {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"}, + {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"}, + {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"}, {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"}, {file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"}, {file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"}, @@ -3491,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "e7dab798823725076f2001cb892f67cd73269205120bb323d75d3c9d755c1bb2" +content-hash = "4109f2a5972e40a9e5598d85db6bfe31393c43904964698fac76570460b416d2" diff --git a/pyproject.toml b/pyproject.toml index c2d21e645..42e9b9a8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ yarl = "^1.18.3" pydantic = "^2.11.7" asgiref = "^3.8.1" boto3 = "^1.37.3" -botocore = "^1.38.40" +botocore = "^1.38.46" eval-type-backport = "^0.2.2" mangum = "^0.19.0" wireup = "^2.0.0" From f34e1dd525d77f13bbdeafa0f50e3d6f51800688 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:18:51 +0000 Subject: [PATCH 23/61] Bump moto from 5.1.6 to 5.1.9 Bumps [moto](https://github.com/getmoto/moto) from 5.1.6 to 5.1.9. - [Release notes](https://github.com/getmoto/moto/releases) - [Changelog](https://github.com/getmoto/moto/blob/master/CHANGELOG.md) - [Commits](https://github.com/getmoto/moto/compare/5.1.6...5.1.9) --- updated-dependencies: - dependency-name: moto dependency-version: 5.1.9 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5b64d014c..5d18530d7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1735,14 +1735,14 @@ files = [ [[package]] name = "moto" -version = "5.1.6" +version = "5.1.9" description = "A library that allows you to easily mock out tests based on AWS infrastructure" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "moto-5.1.6-py3-none-any.whl", hash = "sha256:e4a3092bc8fe9139caa77cd34cdcbad804de4d9671e2270ea3b4d53f5c645047"}, - {file = "moto-5.1.6.tar.gz", hash = "sha256:baf7afa9d4a92f07277b29cf466d0738f25db2ed2ee12afcb1dc3f2c540beebd"}, + {file = "moto-5.1.9-py3-none-any.whl", hash = "sha256:e9ba7e4764a6088ccc34e3cc846ae719861ca202409fa865573de40a3e805b9b"}, + {file = "moto-5.1.9.tar.gz", hash = "sha256:0c4f0387b06b5d24c0ce90f8f89f31a565cc05789189c5d59b5df02594f2e371"}, ] [package.dependencies] @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "4109f2a5972e40a9e5598d85db6bfe31393c43904964698fac76570460b416d2" +content-hash = "34c22cb021f220ab8eb71b6f2aa0a721d24db3ff0bbf289c09b8de8aa1a2965f" diff --git a/pyproject.toml b/pyproject.toml index 42e9b9a8d..b326f849d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ localstack = "^4.1.1" pytest-docker = "^3.2.0" stamina = "^25.1.0" pytest-freezer = "^0.4.9" -moto = "^5.1.5" +moto = "^5.1.9" requests = "^2.31.0" jsonschema = "^4.24.0" behave = "^1.2.6" From e9127535879df40fdf125cf28698f02b49ae85f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 08:19:32 +0000 Subject: [PATCH 24/61] Bump localstack from 4.5.0 to 4.6.0 Bumps [localstack](https://github.com/localstack/localstack) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/localstack/localstack/releases) - [Commits](https://github.com/localstack/localstack/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: localstack dependency-version: 4.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- poetry.lock | 30 +++++++++++++++--------------- pyproject.toml | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5d18530d7..7a38044dc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1360,21 +1360,21 @@ referencing = ">=0.31.0" [[package]] name = "localstack" -version = "4.5.0" +version = "4.6.0" description = "LocalStack - A fully functional local Cloud stack" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "localstack-4.5.0.tar.gz", hash = "sha256:f8ebf3a9af1826c595cfe4196c6d52792152db374e437e1a574ac52aedc53a18"}, + {file = "localstack-4.6.0.tar.gz", hash = "sha256:2b0ba609816241dba507d7d7b20ca44ee598b4c730f6c56bed3e6f5472dc6c7d"}, ] [package.dependencies] localstack-core = "*" -localstack-ext = "4.5.0" +localstack-ext = "4.6.0" [package.extras] -runtime = ["localstack-core[runtime]", "localstack-ext[runtime] (==4.5.0)"] +runtime = ["localstack-core[runtime]", "localstack-ext[runtime] (==4.6.0)"] [[package]] name = "localstack-client" @@ -1395,14 +1395,14 @@ test = ["black", "coverage", "flake8", "isort", "localstack", "pytest"] [[package]] name = "localstack-core" -version = "4.5.0" +version = "4.6.0" description = "The core library and runtime of LocalStack" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "localstack_core-4.5.0-py3-none-any.whl", hash = "sha256:ab0099d840ff9e718a268315bd9965152f92241dfaf3124b7eaa1305dd58b0f6"}, - {file = "localstack_core-4.5.0.tar.gz", hash = "sha256:2930c0a67dad7f88d2690c0b8720a8b605774c6edf819593ab0328fd14a3e395"}, + {file = "localstack_core-4.6.0-py3-none-any.whl", hash = "sha256:98c1b8b6406f7b72a3906434dfb9fcf3644ff9191211a607073dd76f6f6e51a4"}, + {file = "localstack_core-4.6.0.tar.gz", hash = "sha256:e324a8ece8acb1b15a8961ea1a103a50e24e61bbbd30e608b9f5a03ff3b0b0ad"}, ] [package.dependencies] @@ -1423,21 +1423,21 @@ semver = ">=2.10" tailer = ">=0.4.1" [package.extras] -base-runtime = ["Werkzeug (>=3.1.3)", "awscrt (>=0.13.14,!=0.27.1)", "boto3 (==1.38.27)", "botocore (==1.38.27)", "cbor2 (>=5.5.0)", "dnspython (>=1.16.0)", "docker (>=6.1.1)", "hypercorn (>=0.14.4)", "jsonpatch (>=1.24)", "localstack-twisted (>=23.0)", "openapi-core (>=0.19.2)", "pyopenssl (>=23.0.0)", "readerwriterlock (>=1.0.7)", "requests-aws4auth (>=1.0)", "rolo (>=0.7)", "urllib3 (>=2.0.7)", "xmltodict (>=0.13.0)"] +base-runtime = ["Werkzeug (>=3.1.3)", "awscrt (>=0.13.14,!=0.27.1)", "boto3 (==1.38.46)", "botocore (==1.38.46)", "cbor2 (>=5.5.0)", "dnspython (>=1.16.0)", "docker (>=6.1.1)", "hypercorn (>=0.14.4)", "jsonpatch (>=1.24)", "localstack-twisted (>=23.0)", "openapi-core (>=0.19.2)", "pyopenssl (>=23.0.0)", "readerwriterlock (>=1.0.7)", "requests-aws4auth (>=1.0)", "rolo (>=0.7)", "urllib3 (>=2.0.7)", "xmltodict (>=0.13.0)"] dev = ["Cython", "coveralls (>=3.3.1)", "localstack-core[test]", "mypy", "networkx (>=2.8.4)", "openapi-spec-validator (>=0.7.1)", "pandoc", "pre-commit (>=3.5.0)", "pypandoc", "rstr (>=3.2.0)", "ruff (>=0.3.3)"] -runtime = ["airspeed-ext (>=0.6.3)", "antlr4-python3-runtime (==4.13.2)", "apispec (>=5.1.1)", "aws-sam-translator (>=1.15.1)", "awscli (>=1.37.0)", "crontab (>=0.22.6)", "cryptography (>=41.0.5)", "jpype1-ext (>=0.0.1)", "json5 (>=0.9.11)", "jsonpath-ng (>=1.6.1)", "jsonpath-rw (>=1.4.0)", "kclpy-ext (>=3.0.0)", "localstack-core[base-runtime]", "moto-ext[all] (==5.1.5.post1)", "opensearch-py (>=2.4.1)", "pymongo (>=4.2.0)", "pyopenssl (>=23.0.0)"] +runtime = ["airspeed-ext (>=0.6.3)", "antlr4-python3-runtime (==4.13.2)", "apispec (>=5.1.1)", "aws-sam-translator (>=1.15.1)", "awscli (>=1.37.0)", "crontab (>=0.22.6)", "cryptography (>=41.0.5)", "jpype1-ext (>=0.0.1)", "json5 (>=0.9.11)", "jsonpath-ng (>=1.6.1)", "jsonpath-rw (>=1.4.0)", "kclpy-ext (>=3.0.0)", "localstack-core[base-runtime]", "moto-ext[all] (==5.1.6.post2)", "opensearch-py (>=2.4.1)", "pymongo (>=4.2.0)", "pyopenssl (>=23.0.0)"] test = ["aws-cdk-lib (>=2.88.0)", "coverage[toml] (>=5.5)", "deepdiff (>=6.4.1)", "httpx[http2] (>=0.25)", "localstack-core[runtime]", "localstack-snapshot (>=0.1.1)", "pluggy (>=1.3.0)", "pytest (>=7.4.2)", "pytest-httpserver (>=1.1.2)", "pytest-rerunfailures (>=12.0)", "pytest-split (>=0.8.0)", "pytest-tinybird (>=0.5.0)", "websocket-client (>=1.7.0)"] typehint = ["boto3-stubs[acm,acm-pca,amplify,apigateway,apigatewayv2,appconfig,appconfigdata,application-autoscaling,appsync,athena,autoscaling,backup,batch,ce,cloudcontrol,cloudformation,cloudfront,cloudtrail,cloudwatch,codebuild,codecommit,codeconnections,codedeploy,codepipeline,codestar-connections,cognito-identity,cognito-idp,dms,docdb,dynamodb,dynamodbstreams,ec2,ecr,ecs,efs,eks,elasticache,elasticbeanstalk,elbv2,emr,emr-serverless,es,events,firehose,fis,glacier,glue,iam,identitystore,iot,iot-data,iotanalytics,iotwireless,kafka,kinesis,kinesisanalytics,kinesisanalyticsv2,kms,lakeformation,lambda,logs,managedblockchain,mediaconvert,mediastore,mq,mwaa,neptune,opensearch,organizations,pi,pinpoint,pipes,qldb,qldb-session,rds,rds-data,redshift,redshift-data,resource-groups,resourcegroupstaggingapi,route53,route53resolver,s3,s3control,sagemaker,sagemaker-runtime,secretsmanager,serverlessrepo,servicediscovery,ses,sesv2,sns,sqs,ssm,sso-admin,stepfunctions,sts,timestream-query,timestream-write,transcribe,verifiedpermissions,wafv2,xray]", "localstack-core[dev]"] [[package]] name = "localstack-ext" -version = "4.5.0" +version = "4.6.0" description = "Extensions for LocalStack" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "localstack_ext-4.5.0.tar.gz", hash = "sha256:7d7c30ce3edbe822a5ff3db063d323ae3360c346d6719c83447fbd9188462556"}, + {file = "localstack_ext-4.6.0.tar.gz", hash = "sha256:7b9fcd712877bd5678b394bb24e8ebdb1df9fb4c669177049c0d97e005c8615c"}, ] [package.dependencies] @@ -1445,7 +1445,7 @@ build = "*" dill = ">=0.3.2" dnslib = ">=0.9.10" dnspython = ">=1.16.0" -localstack-core = "4.5.0" +localstack-core = "4.6.0" packaging = "*" plux = ">=1.10.0" PyJWT = {version = ">=1.7.0", extras = ["crypto"]} @@ -1457,8 +1457,8 @@ windows-curses = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] package = ["python-minifier (<2.11.3)"] -runtime = ["Whoosh (>=2.7.4)", "amazon.ion (>=0.9.3)", "avro (>=1.11.0)", "aws-encryption-sdk (>=3.1.0)", "aws-json-term-matcher (>=0.1.5)", "cedarpy (>=4.1.0)", "confluent-kafka", "dirtyjson (>=1.0.7)", "distro", "dulwich (>=0.19.16)", "graphql-core (>=3.0.3)", "janus (>=0.5.0)", "jsonpatch (>=1.32)", "kafka-python", "kubernetes (>=21.7.0)", "libvirt-python", "localstack-core[runtime] (==4.5.0)", "mysql-replication", "paho-mqtt (>=1.5)", "parquet[snappy] (>=1.3.1)", "parse (>=1.19.0)", "pg8000 (>=1.10)", "postgres (>=2.2.2)", "postgresql-proxy (>=0.2.0)", "pproxy-ext (>=2.7.9)", "presto-python-client (>=0.7.0)", "pure-sasl (>=0.6.2)", "pycdlib (>=1.14.0)", "pycognito (>=2024.5.1)", "pyftpdlib (>=1.5.6)", "pyhive[hive-pure-sasl] (>=0.7.0)", "pyion2json (>=0.0.2)", "pymysql", "pyqldb (>=3.2,<4.0)", "python-dxf (>=12.1.1)", "python-snappy (>=0.6)", "readerwriterlock (>=1.0.7)", "redis (>=5.0)", "rsa (>=4.0)", "sql-metadata (>=2.6.0)", "sqlglot[rs]", "srp-ext (>=1.0.7.1)", "testing.common.database (>=1.1.0)", "thrift (>=0.10.0)", "thrift_sasl (>=0.1.0)", "tornado (>=6.0)", "websockets (>=8.1,<14)"] -test = ["PyAthena[pandas]", "aiohttp", "async-timeout", "aws-cdk-lib (>=2.88.0)", "aws-cdk.aws-cognito-identitypool-alpha", "aws_cdk.aws_neptune_alpha", "aws_cdk.aws_redshift_alpha", "aws_xray_sdk (>=2.4.2)", "awsiotsdk", "awsiotsdk", "awswrangler (>=3.5.2)", "coverage[toml] (>=5.0.0)", "deepdiff (>=5.5.0)", "gremlinpython", "jws (>=0.1.3)", "localstack-core[test] (==4.5.0)", "localstack-ext[runtime]", "msal", "msal-extensions", "msrest", "mysql-connector-python", "neo4j", "nest-asyncio (>=1.4.1)", "paramiko", "playwright", "portalocker", "pre-commit (>=3.5.0)", "pyarrow", "pymongo", "pymssql (>=2.2.8)", "pytest-httpserver (>=1.0.1)", "pytest-instafail (>=0.4.2)", "pytest-mock (>=3.14.0)", "pytest-playwright", "python-terraform", "redshift_connector", "ruff (>=0.1.0)", "stomp.py (>=8.0.1)"] +runtime = ["Whoosh (>=2.7.4)", "amazon.ion (>=0.9.3)", "avro (>=1.11.0)", "aws-encryption-sdk (>=3.1.0)", "aws-json-term-matcher (>=0.1.5)", "cedarpy (>=4.1.0)", "confluent-kafka", "dirtyjson (>=1.0.7)", "distro", "dulwich (>=0.19.16)", "graphql-core (>=3.0.3)", "janus (>=0.5.0)", "javascript", "jsonpatch (>=1.32)", "kafka-python", "kubernetes (>=21.7.0)", "libvirt-python", "localstack-core[runtime] (==4.6.0)", "mysql-replication", "paho-mqtt (>=1.5)", "parquet[snappy] (>=1.3.1)", "parse (>=1.19.0)", "pg8000 (>=1.10)", "postgres (>=2.2.2)", "postgresql-proxy (>=0.2.0)", "pproxy-ext (>=2.7.9)", "presto-python-client (>=0.7.0)", "pure-sasl (>=0.6.2)", "pycdlib (>=1.14.0)", "pycognito (>=2024.5.1)", "pyftpdlib (>=1.5.6)", "pyhive[hive-pure-sasl] (>=0.7.0)", "pyion2json (>=0.0.2)", "pymysql", "pyqldb (>=3.2,<4.0)", "python-dxf (>=12.1.1)", "python-snappy (>=0.6)", "readerwriterlock (>=1.0.7)", "redis (>=5.0)", "rsa (>=4.0)", "sql-metadata (>=2.6.0)", "sqlglot[rs]", "srp-ext (>=1.0.7.1)", "testing.common.database (>=1.1.0)", "thrift (>=0.10.0)", "thrift_sasl (>=0.1.0)", "tornado (>=6.0)", "websockets (>=8.1,<14)"] +test = ["PyAthena[pandas]", "aiohttp", "async-timeout", "aws-cdk-lib (>=2.88.0)", "aws-cdk.aws-cognito-identitypool-alpha", "aws_cdk.aws_neptune_alpha", "aws_cdk.aws_redshift_alpha", "aws_xray_sdk (>=2.4.2)", "awsiotsdk", "awsiotsdk", "awswrangler (>=3.5.2)", "coverage[toml] (>=5.0.0)", "deepdiff (>=5.5.0)", "gremlinpython", "jws (>=0.1.3)", "localstack-core[test] (==4.6.0)", "localstack-ext[runtime]", "msal", "msal-extensions", "msrest", "mysql-connector-python", "neo4j", "nest-asyncio (>=1.4.1)", "paramiko", "playwright", "portalocker", "pre-commit (>=3.5.0)", "pyarrow", "pymongo", "pymssql (>=2.2.8)", "pytest-httpserver (>=1.0.1)", "pytest-instafail (>=0.4.2)", "pytest-mock (>=3.14.0)", "pytest-playwright", "python-terraform", "redshift_connector", "ruff (>=0.1.0)", "stomp.py (>=8.0.1)"] typehint = ["boto3-stubs[acm,amplify,apigateway,apigatewayv2,appconfig,appsync,athena,autoscaling,backup,batch,bedrock,bedrock-runtime,ce,cloudcontrol,cloudformation,cloudfront,cloudtrail,cloudwatch,codecommit,cognito-identity,cognito-idp,dms,docdb,dynamodb,dynamodbstreams,ec2,ecr,ecs,efs,eks,elasticache,elasticbeanstalk,elbv2,emr,emr-serverless,es,events,firehose,fis,glacier,glue,iam,iot,iot-data,iotanalytics,iotwireless,kafka,kinesis,kinesisanalytics,kinesisanalyticsv2,kms,lakeformation,lambda,logs,mediaconvert,mediastore,mq,mwaa,neptune,opensearch,organizations,pi,qldb,qldb-session,rds,rds-data,redshift,redshift-data,resource-groups,resourcegroupstaggingapi,route53,route53resolver,s3,s3control,sagemaker,sagemaker-runtime,secretsmanager,serverlessrepo,servicediscovery,ses,sesv2,sns,sqs,ssm,sso-admin,stepfunctions,sts,timestream-query,timestream-write,transcribe,xray]", "localstack-ext[test]"] [[package]] @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "34c22cb021f220ab8eb71b6f2aa0a721d24db3ff0bbf289c09b8de8aa1a2965f" +content-hash = "3c99a4aba3ac85010ef4585588e96595534715374516e34ee0ff0f1fb5de4208" diff --git a/pyproject.toml b/pyproject.toml index b326f849d..e822f0547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ awscli-local = "^0.22.0" polyfactory = "^2.20.0" pyright = "^1.1.394" brunns-matchers = "^2.9.0" -localstack = "^4.1.1" +localstack = "^4.6.0" pytest-docker = "^3.2.0" stamina = "^25.1.0" pytest-freezer = "^0.4.9" From 9b0edb0795cda88cfa56acff08146a8461f07a1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 09:01:54 +0000 Subject: [PATCH 25/61] Bump pytest-asyncio from 1.0.0 to 1.1.0 --- updated-dependencies: - dependency-name: pytest-asyncio dependency-version: 1.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7a38044dc..011a03ea2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2531,14 +2531,14 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "1.0.0" +version = "1.1.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3"}, - {file = "pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f"}, + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, ] [package.dependencies] @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "3c99a4aba3ac85010ef4585588e96595534715374516e34ee0ff0f1fb5de4208" +content-hash = "2350628af120da594555f048a0aae61ed569ac3eb13b4e8993ebfa8d07deb750" diff --git a/pyproject.toml b/pyproject.toml index e822f0547..cb111864b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ jsonpath-rw = "^1.4.0" semver = "^3.0.4" gitpython = "^3.1.44" pytest = "^8.4.1" -pytest-asyncio = "^1.0.0" +pytest-asyncio = "^1.1.0" pytest-cov = "^6.0.0" pytest-nhsd-apim = "^5.0.0" aiohttp = "^3.12.14" From 2a9a729a871941e888975953cda916f7a469c301 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Jul 2025 09:12:36 +0000 Subject: [PATCH 26/61] Bump pytest-docker from 3.2.2 to 3.2.3 Bumps [pytest-docker](https://github.com/avast/pytest-docker) from 3.2.2 to 3.2.3. - [Release notes](https://github.com/avast/pytest-docker/releases) - [Changelog](https://github.com/avast/pytest-docker/blob/master/CHANGELOG.md) - [Commits](https://github.com/avast/pytest-docker/compare/v3.2.2...v3.2.3) --- updated-dependencies: - dependency-name: pytest-docker dependency-version: 3.2.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 011a03ea2..4aaa38ddb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2570,14 +2570,14 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-docker" -version = "3.2.2" +version = "3.2.3" description = "Simple pytest fixtures for Docker and Docker Compose based tests" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pytest_docker-3.2.2-py3-none-any.whl", hash = "sha256:2926033d48a10de611070fce17f6e67b9e81af2d8ccc59debbbf39872b8ebef9"}, - {file = "pytest_docker-3.2.2.tar.gz", hash = "sha256:58ce79f3173209634bfff8ccaed2ce5593463d5272325c912e1b52a53154f452"}, + {file = "pytest_docker-3.2.3-py3-none-any.whl", hash = "sha256:f973c35e6f2b674c8fc87e8b3354b02c15866a21994c0841a338c240a05de1eb"}, + {file = "pytest_docker-3.2.3.tar.gz", hash = "sha256:26a1c711d99ef01e86e7c9c007f69641552c1554df4fccb065b35581cca24206"}, ] [package.dependencies] @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "2350628af120da594555f048a0aae61ed569ac3eb13b4e8993ebfa8d07deb750" +content-hash = "c0a3d43fbb654c5237bb9f56ae22706768e61e5f7ad4ee85e3e3f4ea62cd342e" diff --git a/pyproject.toml b/pyproject.toml index cb111864b..a9b13a07a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ polyfactory = "^2.20.0" pyright = "^1.1.394" brunns-matchers = "^2.9.0" localstack = "^4.6.0" -pytest-docker = "^3.2.0" +pytest-docker = "^3.2.3" stamina = "^25.1.0" pytest-freezer = "^0.4.9" moto = "^5.1.9" From 6aec8fd7c8d824a5d5278ef4bee82d564bb32d41 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:46:40 +0100 Subject: [PATCH 27/61] ELI-351: Refactor (#258) * ELI-351: Refactor * ELI-351: Adds tests for action rule handler * ELI-351: Renames and fixes tests * ELI-351: Renames and fixes tests --- .../audit/audit_context.py | 35 +- .../model/eligibility_status.py | 29 +- .../calculators/eligibility_calculator.py | 208 ++---- .../processors/action_rule_handler.py | 103 +++ .../services/processors/cohort_handler.py | 12 +- .../services/processors/rule_processor.py | 18 +- tests/unit/audit/test_audit_context.py | 19 +- tests/unit/model/test_status.py | 9 +- .../test_eligibility_calculator.py | 41 -- .../processors/test_action_rule_handler.py | 656 ++++++++++++++++++ 10 files changed, 881 insertions(+), 249 deletions(-) create mode 100644 src/eligibility_signposting_api/services/processors/action_rule_handler.py create mode 100644 tests/unit/services/processors/test_action_rule_handler.py diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index 8a0606983..48f4389bf 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -18,17 +18,12 @@ RequestAuditQueryParams, ) from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.campaign_config import ( - CampaignID, - CampaignVersion, - Iteration, - RuleName, - RulePriority, -) from eligibility_signposting_api.model.eligibility_status import ( + BestIterationResult, CohortGroupResult, ConditionName, IterationResult, + MatchedActionDetail, Status, SuggestedAction, ) @@ -65,17 +60,15 @@ def add_request_details(request: Request) -> None: @staticmethod def append_audit_condition( - suggested_actions: list[SuggestedAction] | None, condition_name: ConditionName, - best_results: tuple[Iteration | None, IterationResult | None, dict[str, CohortGroupResult] | None], - campaign_details: tuple[CampaignID | None, CampaignVersion | None], - action_rule_details: tuple[RulePriority | None, RuleName | None], + best_iteration_result: BestIterationResult, + action_detail: MatchedActionDetail, ) -> None: audit_eligibility_cohorts, audit_eligibility_cohort_groups, audit_actions = [], [], [] audit_filter_rule, audit_suitability_rule, audit_action_rule = None, None, None - best_active_iteration = best_results[0] - best_candidate = best_results[1] - best_cohort_results = best_results[2] + best_active_iteration = best_iteration_result.active_iteration + best_candidate = best_iteration_result.iteration_result + best_cohort_results = best_iteration_result.cohort_results if best_cohort_results: for cohort_label, result in sorted(best_cohort_results.items(), key=lambda item: item[1].cohort_code): @@ -94,13 +87,13 @@ def append_audit_condition( audit_filter_rule = AuditContext.create_audit_filter_rule(best_candidate, result) audit_suitability_rule = AuditContext.create_audit_suitability_rule(best_candidate, result) - audit_action_rule = AuditContext.add_rule_name_and_priority_to_audit(best_candidate, action_rule_details) + audit_action_rule = AuditContext.add_rule_name_and_priority_to_audit(best_candidate, action_detail) - audit_actions = AuditContext.create_audit_actions(suggested_actions) + audit_actions = AuditContext.create_audit_actions(action_detail.actions) audit_condition = AuditCondition( - campaign_id=campaign_details[0], - campaign_version=campaign_details[1], + campaign_id=best_iteration_result.campaign_id, + campaign_version=best_iteration_result.campaign_version, iteration_id=best_active_iteration.id if best_active_iteration else None, iteration_version=best_active_iteration.version if best_active_iteration else None, condition_name=condition_name, @@ -119,15 +112,15 @@ def append_audit_condition( @staticmethod def add_rule_name_and_priority_to_audit( best_candidate: IterationResult | None, - action_rule_details: tuple[RulePriority | None, RuleName | None] | None, + action_detail: MatchedActionDetail, ) -> AuditRedirectRule | None: audit_action_rule = None if best_candidate and best_candidate.status: - if action_rule_details is None or (action_rule_details[0] is None and action_rule_details[1] is None): + if action_detail.rule_priority is None and action_detail.rule_name is None: audit_action_rule = None else: audit_action_rule = AuditRedirectRule( - rule_priority=str(action_rule_details[0]), rule_name=action_rule_details[1] + rule_priority=str(action_detail.rule_priority), rule_name=action_detail.rule_name ) return audit_action_rule diff --git a/src/eligibility_signposting_api/model/eligibility_status.py b/src/eligibility_signposting_api/model/eligibility_status.py index 61ca94a1d..4026d552a 100644 --- a/src/eligibility_signposting_api/model/eligibility_status.py +++ b/src/eligibility_signposting_api/model/eligibility_status.py @@ -4,10 +4,14 @@ from datetime import date from enum import Enum, StrEnum, auto from functools import total_ordering -from typing import NewType, Self +from typing import TYPE_CHECKING, NewType, Self from pydantic import HttpUrl +if TYPE_CHECKING: + from eligibility_signposting_api.model import campaign_config + from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignVersion, CohortLabel, Iteration + NHSNumber = NewType("NHSNumber", str) DateOfBirth = NewType("DateOfBirth", date) Postcode = NewType("Postcode", str) @@ -77,6 +81,13 @@ def get_status_text(self, condition_name: ConditionName) -> StatusText: } return status_to_text_mapping.get(self, lambda: StatusText("Unknown status provided"))() + def get_action_rule_type(self) -> RuleType: + return { + self.not_eligible: RuleType.not_eligible_actions, + self.not_actionable: RuleType.not_actionable_actions, + self.actionable: RuleType.redirect, + }[self] + @dataclass class Reason: @@ -122,6 +133,22 @@ class IterationResult: actions: list[SuggestedAction] | None +@dataclass +class BestIterationResult: + iteration_result: IterationResult + active_iteration: Iteration | None = None + campaign_id: CampaignID | None = None + campaign_version: CampaignVersion | None = None + cohort_results: dict[CohortLabel, CohortGroupResult] | None = None + + +@dataclass +class MatchedActionDetail: + rule_name: campaign_config.RuleName | None = None + rule_priority: campaign_config.RulePriority | None = None + actions: list[SuggestedAction] | None = None + + @dataclass class EligibilityStatus: """Represents a person's eligibility for vaccination.""" diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 2e452b7c5..6ea60ec55 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -1,49 +1,34 @@ from __future__ import annotations -from _operator import attrgetter from collections import defaultdict from dataclasses import dataclass, field -from itertools import groupby from typing import TYPE_CHECKING from wireup import service from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.model import eligibility_status -from eligibility_signposting_api.model.campaign_config import ( - ActionsMapper, - CampaignConfig, - CampaignID, - CampaignVersion, - Iteration, - IterationRule, - RuleName, - RulePriority, - RuleType, -) from eligibility_signposting_api.model.eligibility_status import ( - ActionCode, - ActionDescription, - ActionType, + BestIterationResult, CohortGroupResult, Condition, ConditionName, - InternalActionCode, + EligibilityStatus, IterationResult, Status, - SuggestedAction, - UrlLabel, - UrlLink, -) -from eligibility_signposting_api.services.calculators.rule_calculator import ( - RuleCalculator, ) +from eligibility_signposting_api.services.processors.action_rule_handler import ActionRuleHandler from eligibility_signposting_api.services.processors.campaign_evaluator import CampaignEvaluator from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor if TYPE_CHECKING: from collections.abc import Collection + from eligibility_signposting_api.model.campaign_config import ( + CampaignConfig, + CohortLabel, + IterationName, + ) from eligibility_signposting_api.model.person import Person @@ -61,12 +46,13 @@ class EligibilityCalculator: campaign_evaluator: CampaignEvaluator = field(default_factory=CampaignEvaluator) rule_processor: RuleProcessor = field(default_factory=RuleProcessor) + action_rule_handler: ActionRuleHandler = field(default_factory=ActionRuleHandler) results: list[eligibility_status.Condition] = field(default_factory=list) @staticmethod def get_the_best_cohort_memberships( - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], ) -> tuple[Status, list[CohortGroupResult]]: if not cohort_results: return eligibility_status.Status.not_eligible, [] @@ -87,153 +73,65 @@ def get_the_best_cohort_memberships( return best_status, best_cohorts - @staticmethod - def get_action_rules_components( - active_iteration: Iteration, rule_type: RuleType - ) -> tuple[tuple[IterationRule, ...], ActionsMapper, str | None]: - action_rules = tuple(rule for rule in active_iteration.iteration_rules if rule.type in rule_type) - - routing_map = { - RuleType.redirect: active_iteration.default_comms_routing, - RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing, - RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing, - } - - default_comms = routing_map.get(rule_type) - action_mapper = active_iteration.actions_mapper - return action_rules, action_mapper, default_comms - - def get_eligibility_status( - self, include_actions: str, conditions: list[str], category: str - ) -> eligibility_status.EligibilityStatus: + def get_eligibility_status(self, include_actions: str, conditions: list[str], category: str) -> EligibilityStatus: include_actions_flag = include_actions.upper() == "Y" condition_results: dict[ConditionName, IterationResult] = {} - action_rule_priority, action_rule_name = None, None requested_grouped_campaigns = self.campaign_evaluator.get_requested_grouped_campaigns( self.campaign_configs, conditions, category ) for condition_name, campaign_group in requested_grouped_campaigns: - actions: list[SuggestedAction] | None = [] - best_active_iteration: Iteration | None - best_candidate: IterationResult - best_campaign_id: CampaignID | None - best_campaign_version: CampaignVersion | None - best_cohort_results: dict[str, CohortGroupResult] | None - - iteration_results = self.get_iteration_results(actions, campaign_group) + best_iteration_result = self.get_best_iteration_result(campaign_group) - # Determine results between iterations - get the best - if iteration_results: - ( - best_iteration_name, - ( - best_active_iteration, - best_candidate, - best_campaign_id, - best_campaign_version, - best_cohort_results, - ), - ) = max(iteration_results.items(), key=lambda item: item[1][1].status.value) - else: - best_candidate = IterationResult(eligibility_status.Status.not_eligible, [], actions) - best_campaign_id = None - best_campaign_version = None - best_active_iteration = None - best_cohort_results = None - - condition_results[condition_name] = best_candidate - - status_to_rule_type = { - Status.actionable: RuleType.redirect, - Status.not_eligible: RuleType.not_eligible_actions, - Status.not_actionable: RuleType.not_actionable_actions, - } + matched_action_detail = self.action_rule_handler.get_actions( + self.person, + best_iteration_result.active_iteration, + best_iteration_result.iteration_result, + include_actions_flag=include_actions_flag, + ) - if best_candidate.status in status_to_rule_type and best_active_iteration is not None: - if include_actions_flag: - rule_type = status_to_rule_type[best_candidate.status] - actions, matched_action_rule_priority, matched_action_rule_name = self.handle_action_rules( - best_active_iteration, rule_type - ) - action_rule_name = matched_action_rule_name - action_rule_priority = matched_action_rule_priority - else: - actions = None + condition_results[condition_name] = best_iteration_result.iteration_result + condition_results[condition_name].actions = matched_action_detail.actions - else: - actions = None + AuditContext.append_audit_condition(condition_name, best_iteration_result, matched_action_detail) - if best_candidate.status in (Status.not_eligible, Status.not_actionable) and not include_actions_flag: - actions = None + # Consolidate all the results and return + final_result = self.build_condition_results(condition_results) + return eligibility_status.EligibilityStatus(conditions=final_result) - # add actions to condition results - condition_results[condition_name].actions = actions + def get_best_iteration_result(self, campaign_group: list[CampaignConfig]) -> BestIterationResult: + iteration_results = self.get_iteration_results(campaign_group) - # add audit data - AuditContext.append_audit_condition( - condition_results[condition_name].actions, - condition_name, - (best_active_iteration, best_candidate, best_cohort_results), - (best_campaign_id, best_campaign_version), - (action_rule_priority, action_rule_name), + if iteration_results: + (best_iteration_name, best_iteration_result) = max( + iteration_results.items(), + key=lambda item: next(iter(item[1].cohort_results.values())).status.value + # Below handles the case where there are no cohort results + if item[1].cohort_results + else -1, ) + else: + iteration_result = IterationResult(eligibility_status.Status.not_eligible, [], []) + best_iteration_result = BestIterationResult(iteration_result, None, None, None, {}) - # Consolidate all the results and return - final_result = self.build_condition_results(condition_results) - return eligibility_status.EligibilityStatus(conditions=final_result) + return best_iteration_result + + def get_iteration_results(self, campaign_group: list[CampaignConfig]) -> dict[IterationName, BestIterationResult]: + iteration_results: dict[IterationName, BestIterationResult] = {} - def get_iteration_results( - self, actions: list[SuggestedAction] | None, campaign_group: list[CampaignConfig] - ) -> dict[str, tuple[Iteration, IterationResult, CampaignID, CampaignVersion, dict[str, CohortGroupResult]]]: - iteration_results: dict[ - str, tuple[Iteration, IterationResult, CampaignID, CampaignVersion, dict[str, CohortGroupResult]] - ] = {} for cc in campaign_group: active_iteration = cc.current_iteration - cohort_results: dict[str, CohortGroupResult] = self.rule_processor.get_cohort_group_results( + cohort_results: dict[CohortLabel, CohortGroupResult] = self.rule_processor.get_cohort_group_results( self.person, active_iteration ) # Determine Result between cohorts - get the best status, best_cohorts = self.get_the_best_cohort_memberships(cohort_results) - iteration_results[active_iteration.name] = ( - active_iteration, - IterationResult(status, best_cohorts, actions), - cc.id, - cc.version, - cohort_results, + iteration_results[active_iteration.name] = BestIterationResult( + IterationResult(status, best_cohorts, []), active_iteration, cc.id, cc.version, cohort_results ) return iteration_results - def handle_action_rules( - self, best_active_iteration: Iteration, rule_type: RuleType - ) -> tuple[list[SuggestedAction] | None, RulePriority | None, RuleName | None]: - action_rules, action_mapper, default_comms = self.get_action_rules_components(best_active_iteration, rule_type) - priority_getter = attrgetter("priority") - sorted_rules_by_priority = sorted(action_rules, key=priority_getter) - - actions: list[SuggestedAction] | None = self.get_actions_from_comms(action_mapper, default_comms) # pyright: ignore[reportArgumentType] - - matched_action_rule_priority, matched_action_rule_name = None, None - for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): - rule_group_list = list(rule_group) - matcher_matched_list = [ - RuleCalculator(person=self.person, rule=rule).evaluate_exclusion()[1].matcher_matched - for rule in rule_group_list - ] - - comms_routing = rule_group_list[0].comms_routing - if comms_routing and all(matcher_matched_list): - rule_actions = self.get_actions_from_comms(action_mapper, comms_routing) - if rule_actions and len(rule_actions) > 0: - actions = rule_actions - matched_action_rule_priority = rule_group_list[0].priority - matched_action_rule_name = rule_group_list[0].name - break - - return actions, matched_action_rule_priority, matched_action_rule_name - @staticmethod def build_condition_results(condition_results: dict[ConditionName, IterationResult]) -> list[Condition]: conditions: list[Condition] = [] @@ -271,23 +169,3 @@ def build_condition_results(condition_results: dict[ConditionName, IterationResu ) ) return conditions - - @staticmethod - def get_actions_from_comms(action_mapper: ActionsMapper, comms: str) -> list[SuggestedAction] | None: - suggested_actions: list[SuggestedAction] = [] - for comm in comms.split("|"): - action = action_mapper.get(comm) - if action is not None: - suggested_actions.append( - SuggestedAction( - internal_action_code=InternalActionCode(comm), - action_type=ActionType(action.action_type), - action_code=ActionCode(action.action_code), - action_description=ActionDescription(action.action_description) - if action.action_description - else None, - url_link=UrlLink(action.url_link) if action.url_link else None, - url_label=UrlLabel(action.url_label) if action.url_label else None, - ) - ) - return suggested_actions diff --git a/src/eligibility_signposting_api/services/processors/action_rule_handler.py b/src/eligibility_signposting_api/services/processors/action_rule_handler.py new file mode 100644 index 000000000..6ff38bd05 --- /dev/null +++ b/src/eligibility_signposting_api/services/processors/action_rule_handler.py @@ -0,0 +1,103 @@ +from itertools import groupby +from operator import attrgetter + +from eligibility_signposting_api.model.campaign_config import ( + ActionsMapper, + Iteration, + IterationRule, +) +from eligibility_signposting_api.model.eligibility_status import ( + ActionCode, + ActionDescription, + ActionType, + InternalActionCode, + IterationResult, + MatchedActionDetail, + RuleType, + SuggestedAction, + UrlLabel, + UrlLink, +) +from eligibility_signposting_api.model.person import Person +from eligibility_signposting_api.services.calculators.rule_calculator import RuleCalculator + + +class ActionRuleHandler: + def get_actions( + self, + person: Person, + active_iteration: Iteration | None, + best_iteration_result: IterationResult, + *, + include_actions_flag: bool, + ) -> MatchedActionDetail: + action_detail = MatchedActionDetail() + + if active_iteration is not None and include_actions_flag: + rule_type = best_iteration_result.status.get_action_rule_type() + action_detail = self._handle(person, active_iteration, rule_type) + + return action_detail + + def _handle(self, person: Person, best_active_iteration: Iteration, rule_type: RuleType) -> MatchedActionDetail: + action_rules, action_mapper, default_comms = self._get_action_rules_components(best_active_iteration, rule_type) + + priority_getter = attrgetter("priority") + sorted_rules_by_priority = sorted(action_rules, key=priority_getter) + + actions: list[SuggestedAction] | None = self._get_actions_from_comms(action_mapper, default_comms) # pyright: ignore[reportArgumentType] + + matched_action_rule_priority, matched_action_rule_name = None, None + for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): + rule_group_list = list(rule_group) + matcher_matched_list = [ + RuleCalculator(person=person, rule=rule).evaluate_exclusion()[1].matcher_matched + for rule in rule_group_list + ] + + comms_routing = rule_group_list[0].comms_routing + if comms_routing and all(matcher_matched_list): + rule_actions = self._get_actions_from_comms(action_mapper, comms_routing) + if rule_actions and len(rule_actions) > 0: + actions = rule_actions + matched_action_rule_priority = rule_group_list[0].priority + matched_action_rule_name = rule_group_list[0].name + break + + return MatchedActionDetail(matched_action_rule_name, matched_action_rule_priority, actions) + + @staticmethod + def _get_action_rules_components( + active_iteration: Iteration, rule_type: RuleType + ) -> tuple[tuple[IterationRule, ...], ActionsMapper, str | None]: + action_rules = tuple(rule for rule in active_iteration.iteration_rules if rule.type in rule_type) + + routing_map = { + RuleType.redirect: active_iteration.default_comms_routing, + RuleType.not_eligible_actions: active_iteration.default_not_eligible_routing, + RuleType.not_actionable_actions: active_iteration.default_not_actionable_routing, + } + + default_comms = routing_map.get(rule_type) + action_mapper = active_iteration.actions_mapper + return action_rules, action_mapper, default_comms + + @staticmethod + def _get_actions_from_comms(action_mapper: ActionsMapper, comms: str) -> list[SuggestedAction] | None: + suggested_actions: list[SuggestedAction] = [] + for comm in comms.split("|"): + action = action_mapper.get(comm) + if action is not None: + suggested_actions.append( + SuggestedAction( + internal_action_code=InternalActionCode(comm), + action_type=ActionType(action.action_type), + action_code=ActionCode(action.action_code), + action_description=ActionDescription(action.action_description) + if action.action_description + else None, + url_link=UrlLink(action.url_link) if action.url_link else None, + url_label=UrlLabel(action.url_label) if action.url_label else None, + ) + ) + return suggested_actions diff --git a/src/eligibility_signposting_api/services/processors/cohort_handler.py b/src/eligibility_signposting_api/services/processors/cohort_handler.py index 6ffe18f5d..d5848e52b 100644 --- a/src/eligibility_signposting_api/services/processors/cohort_handler.py +++ b/src/eligibility_signposting_api/services/processors/cohort_handler.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from collections.abc import Iterable - from eligibility_signposting_api.model.campaign_config import IterationCohort, IterationRule + from eligibility_signposting_api.model.campaign_config import CohortLabel, IterationCohort, IterationRule from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor @@ -24,7 +24,7 @@ def handle( self, person: Person, cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], rules_processor: RuleProcessor, ) -> None: """Handles a part of the eligibility/actionability check or passes to the next handler.""" @@ -38,7 +38,7 @@ def pass_to_next( self, person: Person, cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], rules_processor: RuleProcessor, ) -> None: """Passes the request to the next handler in the chain if one exists.""" @@ -53,7 +53,7 @@ def handle( self, person: Person, cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], rules_processor: RuleProcessor, ) -> None: if not rules_processor.is_base_eligible(person, cohort): @@ -82,7 +82,7 @@ def handle( self, person: Person, cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], rules_processor: RuleProcessor, ) -> None: if not rules_processor.is_eligible(person, cohort, cohort_results, self.filter_rules): @@ -104,7 +104,7 @@ def handle( self, person: Person, cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], rules_processor: RuleProcessor, ) -> None: rules_processor.is_actionable(person, cohort, cohort_results, self.suppression_rules) diff --git a/src/eligibility_signposting_api/services/processors/rule_processor.py b/src/eligibility_signposting_api/services/processors/rule_processor.py index 52d0d25dc..05addf584 100644 --- a/src/eligibility_signposting_api/services/processors/rule_processor.py +++ b/src/eligibility_signposting_api/services/processors/rule_processor.py @@ -8,7 +8,13 @@ from wireup import service from eligibility_signposting_api.model import eligibility_status -from eligibility_signposting_api.model.campaign_config import Iteration, IterationCohort, IterationRule, RuleType +from eligibility_signposting_api.model.campaign_config import ( + CohortLabel, + Iteration, + IterationCohort, + IterationRule, + RuleType, +) from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Status from eligibility_signposting_api.services.calculators.rule_calculator import RuleCalculator from eligibility_signposting_api.services.processors.cohort_handler import ( @@ -39,7 +45,7 @@ def is_eligible( self, person: Person, cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], filter_rules: Iterable[IterationRule], ) -> bool: is_eligible = True @@ -65,7 +71,7 @@ def is_actionable( self, person: Person, cohort: IterationCohort, - cohort_results: dict[str, CohortGroupResult], + cohort_results: dict[CohortLabel, CohortGroupResult], suppression_rules: Iterable[IterationRule], ) -> None: is_actionable: bool = True @@ -126,8 +132,10 @@ def get_exclusion_rules(cohort: IterationCohort, rules: Iterable[IterationRule]) or (isinstance(ir.cohort_label, (list, set, tuple)) and cohort.cohort_label in ir.cohort_label) ) - def get_cohort_group_results(self, person: Person, active_iteration: Iteration) -> dict[str, CohortGroupResult]: - cohort_results: dict[str, CohortGroupResult] = {} + def get_cohort_group_results( + self, person: Person, active_iteration: Iteration + ) -> dict[CohortLabel, CohortGroupResult]: + cohort_results: dict[CohortLabel, CohortGroupResult] = {} filter_rules, suppression_rules = self.get_rules_by_type(active_iteration) cohort_base_handler = BaseEligibilityHandler() diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py index eabcdc767..ccd55a5f7 100644 --- a/tests/unit/audit/test_audit_context.py +++ b/tests/unit/audit/test_audit_context.py @@ -9,15 +9,17 @@ from eligibility_signposting_api.audit.audit_context import AuditContext from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.audit.audit_service import AuditService -from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignVersion, Iteration, RuleType +from eligibility_signposting_api.model.campaign_config import CampaignID, CampaignVersion, RuleType from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, + BestIterationResult, CohortGroupResult, ConditionName, InternalActionCode, IterationResult, + MatchedActionDetail, Reason, RuleDescription, RuleName, @@ -83,9 +85,7 @@ def test_add_request_details_when_headers_are_empty_sets_audit_log_on_g(app): def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): suggested_actions: list[SuggestedAction] | None condition_name: ConditionName - best_results: tuple[Iteration, IterationResult, dict[str, CohortGroupResult]] campaign_details: tuple[CampaignID | None, CampaignVersion | None] - redirect_rule_details: tuple[RulePriority | None, RuleName | None] suggested_actions = [ SuggestedAction( @@ -119,16 +119,17 @@ def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): iteration_result = IterationResult( status=Status.actionable, cohort_results=[cohort_group_result], actions=suggested_actions ) - best_results = (iteration, iteration_result, {"CohortCode1": cohort_group_result}) campaign_details = (CampaignID("CampaignID1"), CampaignVersion("CampaignVersion1")) - redirect_rule_details = (RulePriority("1"), RuleName("RedirectRuleName1")) + matched_action_detail = MatchedActionDetail(RuleName("RedirectRuleName1"), RulePriority("1"), suggested_actions) + + best_iteration_results = BestIterationResult( + iteration_result, iteration, campaign_details[0], campaign_details[1], {"CohortCode1": cohort_group_result} + ) with app.app_context(): g.audit_log = AuditEvent() - AuditContext.append_audit_condition( - suggested_actions, condition_name, best_results, campaign_details, redirect_rule_details - ) + AuditContext.append_audit_condition(condition_name, best_iteration_results, matched_action_detail) expected_audit_action = [ AuditAction( @@ -148,7 +149,7 @@ def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): assert cond.campaign_version == campaign_details[1] assert cond.iteration_id == iteration.id assert cond.iteration_version == iteration.version - assert cond.status == best_results[1].status.name + assert cond.status == "actionable" assert cond.status_text == "You should have the Condition1 vaccine" assert cond.actions == expected_audit_action assert cond.action_rule.rule_priority == "1" diff --git a/tests/unit/model/test_status.py b/tests/unit/model/test_status.py index eff2b68f9..cc3c018d9 100644 --- a/tests/unit/model/test_status.py +++ b/tests/unit/model/test_status.py @@ -1,4 +1,4 @@ -from eligibility_signposting_api.model.eligibility_status import ConditionName, Status, StatusText +from eligibility_signposting_api.model.eligibility_status import ConditionName, RuleType, Status, StatusText class TestStatus: @@ -38,3 +38,10 @@ def test_get_status_text(self): assert Status.actionable.get_status_text(ConditionName("COVID")) == StatusText( "You should have the COVID vaccine" ) + + def test_get_action_rule_type(self): + assert Status.not_eligible.get_action_rule_type() == RuleType(RuleType.not_eligible_actions) + + assert Status.not_actionable.get_action_rule_type() == RuleType(RuleType.not_actionable_actions) + + assert Status.actionable.get_action_rule_type() == RuleType(RuleType.redirect) diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 0f7798b8f..11ea20273 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -50,7 +50,6 @@ is_eligibility_status, is_reason, ) -from tests.fixtures.matchers.rules import is_iteration_rule @pytest.fixture @@ -58,46 +57,6 @@ def app(): return Flask(__name__) -class TestEligibilityCalculator: - @staticmethod - def test_get_action_rules_components(): - # Given - - iteration = rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "ActionCode1": AvailableAction( - ActionType="ActionType1", - ExternalRoutingCode="ActionCode1", - ActionDescription="ActionDescription1", - UrlLink=HttpUrl("https://www.ActionUrl1.com"), - UrlLabel="ActionLabel1", - ), - "defaultcomms": AvailableAction( - ActionType="ActionType2", - ExternalRoutingCode="defaultcomms", - ActionDescription="ActionDescription2", - UrlLink=HttpUrl("https://www.ActionUrl2.com"), - UrlLabel="ActionLabel2", - ), - } - ), - iteration_rules=[rule_builder.ICBRedirectRuleFactory.build()], - ) - - # when - actual_rules, actual_action_mapper, actual_default_comms = EligibilityCalculator.get_action_rules_components( - iteration, RuleType.redirect - ) - - # then - assert_that(actual_rules, has_item(is_iteration_rule().with_name(iteration.iteration_rules[0].name))) - assert actual_action_mapper == iteration.actions_mapper - assert actual_default_comms == iteration.default_comms_routing - - def test_not_base_eligible(faker: Faker): # Given nhs_number = NHSNumber(faker.nhs_number()) diff --git a/tests/unit/services/processors/test_action_rule_handler.py b/tests/unit/services/processors/test_action_rule_handler.py new file mode 100644 index 000000000..bc8d04ca1 --- /dev/null +++ b/tests/unit/services/processors/test_action_rule_handler.py @@ -0,0 +1,656 @@ +from unittest.mock import Mock, call, patch + +import pytest +from hamcrest import assert_that, is_ +from pydantic import HttpUrl + +from eligibility_signposting_api.model.campaign_config import AvailableAction, RuleName, RulePriority, RuleType +from eligibility_signposting_api.model.eligibility_status import ( + ActionCode, + ActionDescription, + ActionType, + InternalActionCode, + IterationResult, + MatchedActionDetail, + Status, + SuggestedAction, + UrlLabel, + UrlLink, +) +from eligibility_signposting_api.model.person import Person +from eligibility_signposting_api.services.processors.action_rule_handler import ActionRuleHandler +from tests.fixtures.builders.model import rule as rule_builder +from tests.fixtures.builders.model.rule import ActionsMapperFactory, IterationFactory + +# flake8: noqa: SLF001 + + +@pytest.fixture +def handler(): + return ActionRuleHandler() + + +MOCK_PERSON = Person([{"ATTRIBUTE_TYPE": "PERSON", "AGE": "30"}]) + +BOOK_NBS_COMMS = AvailableAction( + ActionType="ButtonAuthLink", + ExternalRoutingCode="BookNBS", + ActionDescription="Action description", + UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), + UrlLabel="Continue to booking", +) + +DEFAULT_COMMS_DETAIL = AvailableAction( + ActionType="CareCardWithText", + ExternalRoutingCode="BookLocal", + ActionDescription="You can get an RSV vaccination at your GP surgery", +) + + +def test_get_action_rules_components_redirect_type(): + iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_redirect", + default_not_eligible_routing="default_not_eligible", + default_not_actionable_routing="default_not_actionable", + actions_mapper=ActionsMapperFactory.build(), + iteration_rules=[rule_builder.ICBRedirectRuleFactory.build(name="RedirectRule")], + ) + rules_found, mapper, default_comms = ActionRuleHandler._get_action_rules_components(iteration, RuleType.redirect) + assert_that(len(rules_found), is_(1)) + assert_that(rules_found[0].name, is_(RuleName("RedirectRule"))) + assert_that(mapper, is_(iteration.actions_mapper)) + assert_that(default_comms, is_("default_redirect")) + + +def test_get_action_rules_components_not_eligible_actions_type(): + iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_redirect", + default_not_eligible_routing="default_not_eligible", + default_not_actionable_routing="default_not_actionable", + actions_mapper=ActionsMapperFactory.build(), + iteration_rules=[rule_builder.ICBNonEligibleActionRuleFactory.build(name="NonEligibleRule")], + ) + rules_found, mapper, default_comms = ActionRuleHandler._get_action_rules_components( + iteration, RuleType.not_eligible_actions + ) + assert_that(len(rules_found), is_(1)) + assert_that(rules_found[0].name, is_(RuleName("NonEligibleRule"))) + assert_that(mapper, is_(iteration.actions_mapper)) + assert_that(default_comms, is_("default_not_eligible")) + + +def test_get_action_rules_components_no_matching_rules(): + iteration = rule_builder.IterationFactory.build( + iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()] + ) + rules_found, _, _ = ActionRuleHandler._get_action_rules_components(iteration, RuleType.redirect) + assert_that(len(rules_found), is_(0)) + + +def test_get_actions_from_comms_single_comm(): + action_mapper = ActionsMapperFactory.build(root={"book_nbs": BOOK_NBS_COMMS}) + actions = ActionRuleHandler._get_actions_from_comms(action_mapper, "book_nbs") + assert_that(len(actions), is_(1)) + assert_that(actions[0].internal_action_code, is_(InternalActionCode("book_nbs"))) + assert_that(actions[0].action_code, is_(ActionCode("BookNBS"))) + + +def test_get_actions_from_comms_multiple_comms(): + action_mapper = ActionsMapperFactory.build(root={"book_nbs": BOOK_NBS_COMMS, "default_comms": DEFAULT_COMMS_DETAIL}) + actions = ActionRuleHandler._get_actions_from_comms(action_mapper, "book_nbs|default_comms") + assert_that(len(actions), is_(2)) + assert_that(actions[0].internal_action_code, is_(InternalActionCode("book_nbs"))) + assert_that(actions[1].internal_action_code, is_(InternalActionCode("default_comms"))) + + +def test_get_actions_from_comms_unknown_comm_code(): + action_mapper = ActionsMapperFactory.build(root={"book_nbs": BOOK_NBS_COMMS}) + actions = ActionRuleHandler._get_actions_from_comms(action_mapper, "book_nbs|unknown_code") + assert_that(len(actions), is_(1)) + assert_that(actions[0].internal_action_code, is_(InternalActionCode("book_nbs"))) + + +def test_get_actions_from_comms_empty_string(): + action_mapper = ActionsMapperFactory.build(root={"book_nbs": BOOK_NBS_COMMS}) + actions = ActionRuleHandler._get_actions_from_comms(action_mapper, "") + assert_that(len(actions), is_(0)) + + +def test_get_actions_from_comms_no_actions_found(): + action_mapper = ActionsMapperFactory.build(root={}) + actions = ActionRuleHandler._get_actions_from_comms(action_mapper, "unknown_code") + assert_that(len(actions), is_(0)) + + +@patch("eligibility_signposting_api.services.calculators.rule_calculator.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_actions_no_matching_rules_returns_default( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_action_code", + actions_mapper=ActionsMapperFactory.build(root={"default_action_code": DEFAULT_COMMS_DETAIL}), + iteration_rules=[], + ) + + mock_get_action_rules_components.return_value = ( + [], + active_iteration.actions_mapper, + active_iteration.default_comms_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType(DEFAULT_COMMS_DETAIL.action_type), + action_code=ActionCode(DEFAULT_COMMS_DETAIL.action_code), + action_description=ActionDescription(DEFAULT_COMMS_DETAIL.action_description), + url_link=DEFAULT_COMMS_DETAIL.url_link, + url_label=DEFAULT_COMMS_DETAIL.url_label, + ) + ], + [], + ] + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, RuleType.redirect) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("default_action_code"))) + assert_that(matched_action_detail.rule_priority, is_(None)) + assert_that(matched_action_detail.rule_name, is_(None)) + mock_get_action_rules_components.assert_called_once_with(active_iteration, RuleType.redirect) + mock_get_actions_from_comms.assert_called_once_with(active_iteration.actions_mapper, "default_action_code") + mock_rule_calculator_class.assert_not_called() + + +@patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_actions_matching_rule_overrides_default( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + matching_rule = rule_builder.ICBRedirectRuleFactory.build( + priority=10, comms_routing="rule_specific_action", name="RuleSpecificAction" + ) + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_action_code", + actions_mapper=ActionsMapperFactory.build( + root={"default_action_code": DEFAULT_COMMS_DETAIL, "rule_specific_action": BOOK_NBS_COMMS} + ), + iteration_rules=[matching_rule], + ) + mock_get_action_rules_components.return_value = ( + (matching_rule,), + active_iteration.actions_mapper, + active_iteration.default_comms_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType(DEFAULT_COMMS_DETAIL.action_type), + action_code=ActionCode(DEFAULT_COMMS_DETAIL.action_code), + action_description=ActionDescription(DEFAULT_COMMS_DETAIL.action_description), + url_link=DEFAULT_COMMS_DETAIL.url_link, + url_label=DEFAULT_COMMS_DETAIL.url_label, + ) + ], + [ + SuggestedAction( + internal_action_code=InternalActionCode("rule_specific_action"), + action_type=ActionType(BOOK_NBS_COMMS.action_type), + action_code=ActionCode(BOOK_NBS_COMMS.action_code), + action_description=ActionDescription(BOOK_NBS_COMMS.action_description), + url_link=BOOK_NBS_COMMS.url_link, + url_label=BOOK_NBS_COMMS.url_label, + ) + ], + ] + + mock_rule_instance = Mock() + mock_rule_instance.evaluate_exclusion.return_value = (Status.actionable, Mock(matcher_matched=True)) + mock_rule_calculator_class.return_value = mock_rule_instance + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, RuleType.redirect) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("rule_specific_action"))) + assert_that(matched_action_detail.rule_priority, is_(RulePriority(10))) + assert_that(matched_action_detail.rule_name, is_(RuleName("RuleSpecificAction"))) + + mock_get_action_rules_components.assert_called_once_with(active_iteration, RuleType.redirect) + assert_that(mock_get_actions_from_comms.call_count, is_(2)) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "default_action_code") + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "rule_specific_action") + mock_rule_calculator_class.assert_called_once_with(person=MOCK_PERSON, rule=matching_rule) + + +@patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_non_matching_rule_returns_default( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + non_matching_rule = rule_builder.ICBRedirectRuleFactory.build( + priority=10, comms_routing="rule_specific_action", name="RuleSpecificAction" + ) + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_action_code", + actions_mapper=ActionsMapperFactory.build( + root={"default_action_code": DEFAULT_COMMS_DETAIL, "rule_specific_action": BOOK_NBS_COMMS} + ), + iteration_rules=[non_matching_rule], + ) + rule_type = RuleType.redirect + + mock_get_action_rules_components.return_value = ( + (non_matching_rule,), + active_iteration.actions_mapper, + active_iteration.default_comms_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType(DEFAULT_COMMS_DETAIL.action_type), + action_code=ActionCode(DEFAULT_COMMS_DETAIL.action_code), + action_description=ActionDescription(DEFAULT_COMMS_DETAIL.action_description), + url_link=DEFAULT_COMMS_DETAIL.url_link, + url_label=DEFAULT_COMMS_DETAIL.url_label, + ) + ], + [ + SuggestedAction( + internal_action_code=InternalActionCode("rule_specific_action"), + action_type=ActionType(BOOK_NBS_COMMS.action_type), + action_code=ActionCode(BOOK_NBS_COMMS.action_code), + action_description=ActionDescription(BOOK_NBS_COMMS.action_description), + url_link=BOOK_NBS_COMMS.url_link, + url_label=BOOK_NBS_COMMS.url_label, + ) + ], + ] + + mock_rule_calculator_class.return_value.evaluate_exclusion.return_value = ( + Status.actionable, + Mock(matcher_matched=False), + ) + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, rule_type) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("default_action_code"))) + assert_that(matched_action_detail.rule_priority, is_(None)) + assert_that(matched_action_detail.rule_name, is_(None)) + + mock_get_action_rules_components.assert_called_once_with(active_iteration, rule_type) + assert_that(mock_get_actions_from_comms.call_count, is_(1)) + mock_get_actions_from_comms.assert_called_once_with(active_iteration.actions_mapper, "default_action_code") + mock_rule_calculator_class.assert_called_once_with(person=MOCK_PERSON, rule=non_matching_rule) + + +@patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_multiple_rules_same_priority_all_match( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + rule1 = rule_builder.ICBRedirectRuleFactory.build(priority=10, comms_routing="action_a", name="RuleA") + rule2 = rule_builder.ICBRedirectRuleFactory.build(priority=10, comms_routing="action_b", name="RuleB") + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_action_code", + actions_mapper=ActionsMapperFactory.build( + root={ + "default_action_code": DEFAULT_COMMS_DETAIL, + "action_a": BOOK_NBS_COMMS, + "action_b": DEFAULT_COMMS_DETAIL, + } + ), + iteration_rules=[rule1, rule2], + ) + + mock_get_action_rules_components.return_value = ( + (rule1, rule2), + active_iteration.actions_mapper, + active_iteration.default_comms_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType(DEFAULT_COMMS_DETAIL.action_type), + action_code=ActionCode(DEFAULT_COMMS_DETAIL.action_code), + action_description=ActionDescription(DEFAULT_COMMS_DETAIL.action_description), + url_link=DEFAULT_COMMS_DETAIL.url_link, + url_label=DEFAULT_COMMS_DETAIL.url_label, + ) + ], + [ + SuggestedAction( + internal_action_code=InternalActionCode("action_a"), + action_type=ActionType(BOOK_NBS_COMMS.action_type), + action_code=ActionCode(BOOK_NBS_COMMS.action_code), + action_description=ActionDescription(BOOK_NBS_COMMS.action_description), + url_link=BOOK_NBS_COMMS.url_link, + url_label=BOOK_NBS_COMMS.url_label, + ), + SuggestedAction( + internal_action_code=InternalActionCode("action_b"), + action_type=ActionType(DEFAULT_COMMS_DETAIL.action_type), + action_code=ActionCode(DEFAULT_COMMS_DETAIL.action_code), + action_description=ActionDescription(DEFAULT_COMMS_DETAIL.action_description), + url_link=DEFAULT_COMMS_DETAIL.url_link, + url_label=DEFAULT_COMMS_DETAIL.url_label, + ), + ], + ] + + mock_rule_calculator_class.side_effect = [ + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(matcher_matched=True)))), + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(matcher_matched=True)))), + ] + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, RuleType.redirect) + + assert_that(len(matched_action_detail.actions), is_(2)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("action_a"))) + assert_that(matched_action_detail.actions[1].internal_action_code, is_(InternalActionCode("action_b"))) + assert_that(matched_action_detail.rule_priority, is_(RulePriority(10))) + assert_that(matched_action_detail.rule_name, is_(RuleName("RuleA"))) + + assert_that(mock_rule_calculator_class.call_count, is_(2)) + assert_that(mock_get_actions_from_comms.call_count, is_(2)) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "default_action_code") + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "action_a") + + +@patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_multiple_rules_same_priority_one_mismatch( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + rule1 = rule_builder.ICBRedirectRuleFactory.build(priority=10, comms_routing="action_a", name="RuleA") + rule2 = rule_builder.ICBRedirectRuleFactory.build(priority=10, comms_routing="action_b", name="RuleB") + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_action_code", + actions_mapper=ActionsMapperFactory.build( + root={ + "default_action_code": DEFAULT_COMMS_DETAIL, + "action_a": BOOK_NBS_COMMS, + "action_b": DEFAULT_COMMS_DETAIL, + } + ), + iteration_rules=[rule1, rule2], + ) + rule_type = RuleType.redirect + + mock_get_action_rules_components.return_value = ( + (rule1, rule2), + active_iteration.actions_mapper, + active_iteration.default_comms_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription("Default Speak to your healthcare professional."), + url_link=None, + url_label=None, + ) + ] + ] + + mock_rule_calculator_class.side_effect = [ + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(matcher_matched=True)))), + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(matcher_matched=False)))), + ] + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, rule_type) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("default_action_code"))) + assert_that(matched_action_detail.rule_priority, is_(None)) + assert_that(matched_action_detail.rule_name, is_(None)) + + mock_get_action_rules_components.assert_called_once_with(active_iteration, rule_type) + assert_that(mock_get_actions_from_comms.call_count, is_(1)) + mock_get_actions_from_comms.assert_called_once_with(active_iteration.actions_mapper, "default_action_code") + assert_that( + mock_rule_calculator_class.call_args_list, + is_([call(person=MOCK_PERSON, rule=rule1), call(person=MOCK_PERSON, rule=rule2)]), + ) + + +@patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_different_priority_rules_highest_priority_wins( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + lower_priority_rule = rule_builder.ICBRedirectRuleFactory.build( + priority=20, comms_routing="action_low", name="LowP" + ) + higher_priority_rule = rule_builder.ICBRedirectRuleFactory.build( + priority=10, comms_routing="action_high", name="HighP" + ) + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_action_code", + actions_mapper=ActionsMapperFactory.build( + root={ + "default_action_code": DEFAULT_COMMS_DETAIL, + "action_low": DEFAULT_COMMS_DETAIL, + "action_high": BOOK_NBS_COMMS, + } + ), + iteration_rules=[lower_priority_rule, higher_priority_rule], + ) + rule_type = RuleType.redirect + + mock_get_action_rules_components.return_value = ( + (lower_priority_rule, higher_priority_rule), + active_iteration.actions_mapper, + active_iteration.default_comms_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription("Default Speak to your healthcare professional."), + url_link=None, + url_label=None, + ) + ], + [ + SuggestedAction( + internal_action_code=InternalActionCode("action_high"), + action_type=ActionType("ButtonAuthLink"), + action_code=ActionCode("BookNBS"), + action_description=ActionDescription("Action description"), + url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), + url_label=UrlLabel("Continue to booking"), + ) + ], + [ + SuggestedAction( + internal_action_code=InternalActionCode("action_low"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, + ) + ], + ] + + mock_rule_calculator_class.side_effect = [ + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(matcher_matched=True)))), + Mock(evaluate_exclusion=Mock(return_value=(Status.actionable, Mock(matcher_matched=True)))), + ] + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, rule_type) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("action_high"))) + assert_that(matched_action_detail.rule_priority, is_(RulePriority(10))) + assert_that(matched_action_detail.rule_name, is_(RuleName("HighP"))) + + assert_that(mock_rule_calculator_class.call_count, is_(1)) + mock_rule_calculator_class.assert_called_once_with(person=MOCK_PERSON, rule=higher_priority_rule) + assert_that(mock_get_actions_from_comms.call_count, is_(2)) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "default_action_code") + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "action_high") + + +def test_handle_no_actions_mapper_entry_for_rule_comms_returns_default(handler: ActionRuleHandler): + matching_rule = rule_builder.ICBRedirectRuleFactory.build( + priority=10, comms_routing="non_existent_action", name="RuleSpecificAction" + ) + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_action_code", + actions_mapper=ActionsMapperFactory.build(root={"default_action_code": DEFAULT_COMMS_DETAIL}), + iteration_rules=[matching_rule], + ) + rule_type = RuleType.redirect + + with ( + patch.object(ActionRuleHandler, "_get_action_rules_components") as mock_get_action_rules_components, + patch.object(ActionRuleHandler, "_get_actions_from_comms") as mock_get_actions_from_comms, + patch( + "eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator" + ) as mock_rule_calculator_class, + ): + mock_get_action_rules_components.return_value = ( + (matching_rule,), + active_iteration.actions_mapper, + active_iteration.default_comms_routing, + ) + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType("DefaultInfoText"), + action_code=ActionCode("DefaultHealthcareProInfo"), + action_description=ActionDescription("Default Speak to your healthcare professional."), + url_link=None, + url_label=None, + ) + ], + None, + ] + mock_rule_calculator_class.return_value.evaluate_exclusion.return_value = ( + Status.actionable, + Mock(matcher_matched=True), + ) + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, rule_type) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that( + matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("default_action_code")) + ) + assert_that(matched_action_detail.rule_priority, is_(RulePriority(10))) + assert_that(matched_action_detail.rule_name, is_(RuleName("RuleSpecificAction"))) + + assert_that(mock_get_actions_from_comms.call_count, is_(2)) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "default_action_code") + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "non_existent_action") + mock_rule_calculator_class.assert_called_once() + + +def test_handle_no_default_comms_and_no_matching_rule(handler: ActionRuleHandler): + active_iteration = rule_builder.IterationFactory.build( + default_comms_routing="", + actions_mapper=ActionsMapperFactory.build(root={}), + iteration_rules=[rule_builder.ICBRedirectRuleFactory.build(comms_routing="some_action")], + ) + rule_type = RuleType.redirect + + with ( + patch.object(ActionRuleHandler, "_get_action_rules_components") as mock_get_action_rules_components, + patch.object(ActionRuleHandler, "_get_actions_from_comms") as mock_get_actions_from_comms, + patch( + "eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator" + ) as mock_rule_calculator_class, + ): + mock_get_action_rules_components.return_value = ( + (rule_builder.ICBRedirectRuleFactory.build(comms_routing="some_action"),), + active_iteration.actions_mapper, + None, + ) + mock_get_actions_from_comms.side_effect = [None, None] + mock_rule_calculator_class.return_value.evaluate_exclusion.return_value = ( + Status.actionable, + Mock(matcher_matched=True), + ) + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, rule_type) + + assert_that(matched_action_detail.actions, is_(None)) + assert_that(matched_action_detail.rule_priority, is_(RulePriority(20))) + assert_that(matched_action_detail.rule_name, is_(RuleName("In QE1"))) + + assert_that(mock_get_actions_from_comms.call_count, is_(2)) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, None) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "some_action") + mock_rule_calculator_class.assert_called_once() + + +@patch.object(ActionRuleHandler, "_handle") +def test_handle_when_active_iteration_present_and_include_actions_is_true(mock_handle, handler: ActionRuleHandler): + mock_handle.side_effect = [MatchedActionDetail()] + + handler.get_actions( + MOCK_PERSON, IterationFactory.build(), IterationResult(Status.actionable, [], []), include_actions_flag=True + ) + + assert_that(mock_handle.call_count, is_(1)) + + +@patch.object(ActionRuleHandler, "_handle") +def test_handle_when_active_iteration_absent_and_include_actions_is_true(mock_handle, handler: ActionRuleHandler): + mock_handle.side_effect = [MatchedActionDetail()] + + handler.get_actions(MOCK_PERSON, None, IterationResult(Status.actionable, [], []), include_actions_flag=True) + + assert_that(mock_handle.call_count, is_(0)) + + +@patch.object(ActionRuleHandler, "_handle") +def test_handle_is_not_called_when_include_actions_is_false(mock_handle, handler: ActionRuleHandler): + mock_handle.side_effect = [MatchedActionDetail()] + + handler.get_actions( + MOCK_PERSON, IterationFactory.build(), IterationResult(Status.actionable, [], []), include_actions_flag=False + ) + + assert_that(mock_handle.call_count, is_(0)) From e7b3da7104e30604ca47f05f7ef6093f4244e379 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 08:25:59 +0000 Subject: [PATCH 28/61] Bump asgiref from 3.8.1 to 3.9.1 Bumps [asgiref](https://github.com/django/asgiref) from 3.8.1 to 3.9.1. - [Changelog](https://github.com/django/asgiref/blob/main/CHANGELOG.txt) - [Commits](https://github.com/django/asgiref/compare/3.8.1...3.9.1) --- updated-dependencies: - dependency-name: asgiref dependency-version: 3.9.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- poetry.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4aaa38ddb..3a27e8eb7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -170,18 +170,18 @@ trio = ["trio (>=0.26.1)"] [[package]] name = "asgiref" -version = "3.8.1" +version = "3.9.1" description = "ASGI specs, helper code, and adapters" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, - {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, + {file = "asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c"}, + {file = "asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142"}, ] [package.extras] -tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] [[package]] name = "attrs" @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "c0a3d43fbb654c5237bb9f56ae22706768e61e5f7ad4ee85e3e3f4ea62cd342e" +content-hash = "1adbfa30017f00f8b1d7dbf92f4e7e0330b48c39661ca2c9e03bb7c5470ad162" diff --git a/pyproject.toml b/pyproject.toml index a9b13a07a..95ca64a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ flask = {extras = ["async"], version = "^3.1.1"} httpx = "^0.28.1" yarl = "^1.18.3" pydantic = "^2.11.7" -asgiref = "^3.8.1" +asgiref = "^3.9.1" boto3 = "^1.37.3" botocore = "^1.38.46" eval-type-backport = "^0.2.2" From e7b08ec22386e9a70d0d7ced2c058791574c2f85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 08:58:25 +0000 Subject: [PATCH 29/61] Bump gitpython from 3.1.44 to 3.1.45 Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.44 to 3.1.45. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.44...3.1.45) --- updated-dependencies: - dependency-name: gitpython dependency-version: 3.1.45 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3a27e8eb7..1040bbf48 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1160,14 +1160,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.44" +version = "3.1.45" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, - {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, + {file = "gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"}, + {file = "gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c"}, ] [package.dependencies] @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "1adbfa30017f00f8b1d7dbf92f4e7e0330b48c39661ca2c9e03bb7c5470ad162" +content-hash = "426cccd3086cd332afb6f2d8ce4029199d9bdd1950111f869dc2571dc2b03aeb" diff --git a/pyproject.toml b/pyproject.toml index 95ca64a18..af183422e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ ruff = "^0.11.13" docopt = "^0.6.2" jsonpath-rw = "^1.4.0" semver = "^3.0.4" -gitpython = "^3.1.44" +gitpython = "^3.1.45" pytest = "^8.4.1" pytest-asyncio = "^1.1.0" pytest-cov = "^6.0.0" From e50899185c8a704e29340ce961f351b237a35861 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 09:17:04 +0000 Subject: [PATCH 30/61] Bump pyright from 1.1.402 to 1.1.403 Bumps [pyright](https://github.com/RobertCraigie/pyright-python) from 1.1.402 to 1.1.403. - [Release notes](https://github.com/RobertCraigie/pyright-python/releases) - [Commits](https://github.com/RobertCraigie/pyright-python/compare/v1.1.402...v1.1.403) --- updated-dependencies: - dependency-name: pyright dependency-version: 1.1.403 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 1040bbf48..93c1d59af 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2488,14 +2488,14 @@ files = [ [[package]] name = "pyright" -version = "1.1.402" +version = "1.1.403" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982"}, - {file = "pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683"}, + {file = "pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3"}, + {file = "pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104"}, ] [package.dependencies] @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "426cccd3086cd332afb6f2d8ce4029199d9bdd1950111f869dc2571dc2b03aeb" +content-hash = "b2eba2610d6598d4c6780bdbe5076a7222c00eb3ddcb9422fa8499f04385253f" diff --git a/pyproject.toml b/pyproject.toml index af183422e..d86fe4e82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ aiohttp = "^3.12.14" awscli = "^1.37.24" awscli-local = "^0.22.0" polyfactory = "^2.20.0" -pyright = "^1.1.394" +pyright = "^1.1.403" brunns-matchers = "^2.9.0" localstack = "^4.6.0" pytest-docker = "^3.2.3" From 014d138d29fa802ba8eec69c2b41d227a49dc114 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:09:39 +0100 Subject: [PATCH 31/61] ELI-351: Moves/deletes tests after refactoring (#265) * ELI-351: Moves/deletes tests after refactoring * ELI-351: Extracts EligibilityResultBuilder and adds tests * ELI-351: De-extracts EligibilityResultBuilder and moves tests to Eligibility Calculator tests * ELI-351: Removes duplicated tests * ELI-351: Removes duplicated tests #2 * ELI-351: Adds validation and audit layer to Readme --- README.md | 28 +- .../test_eligibility_calculator.py | 2794 ++--------------- .../processors/test_action_rule_handler.py | 151 +- .../processors/test_rule_processor.py | 151 +- 4 files changed, 617 insertions(+), 2507 deletions(-) diff --git a/README.md b/README.md index 28b9197d3..d27ef9560 100644 --- a/README.md +++ b/README.md @@ -189,15 +189,25 @@ graph TB direction TB App["app.py (WireUp DI)"] Config["config.py, error_handler.py"] + subgraph "Audit Layer" + direction TB + Audit["audit/audit_service.py"] + AuditModels["audit/audit_models.py"] + end + subgraph "Validation Layer" + direction TB + Validator["common/request_validator.py"] + ApiErrResp["common/api_error_response.py"] + end subgraph "Presentation Layer" direction TB View["views/eligibility.py"] - ResponseModel["views/response_model/eligibility.py"] + ResponseModel["views/response_model/eligibility_response.py"] end subgraph "Business Logic Layer" direction TB Service["services/eligibility_services.py"] - Operators["services/rules/operators.py"] + Operators["services/operators/operators.py"] end subgraph "Data Access Layer" direction TB @@ -207,24 +217,30 @@ graph TB end subgraph "Models" direction TB - ModelElig["model/eligibility.py"] - ModelRules["model/rules.py"] + ModelElig["model/eligibility_status.py"] + ModelRules["model/campaign_config.py"] end end Lambda -->|"loads"| App App -->|injects| View View -->|calls| Service + View -->|validates via| Validator + View -->|audits via| Audit + View -->|uses| RespModel + Audit -->|uses| AuditModels + Validator -->|uses| ApiErrResp + Service -->|calls| Operators Service -->|calls| PersonRepo Service -->|calls| CampaignRepo PersonRepo -->|uses| DynamoDB CampaignRepo -->|uses| S3Bucket - View -->|uses| ResponseModel App -->|reads| Config + App -->|wires| Factory + Service -->|uses| ModelElig Operators -->|uses| ModelRules - App -->|wires| Factory ``` diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 11ea20273..b1da841ff 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -3,44 +3,42 @@ import pytest from faker import Faker -from flask import Flask, g +from flask import Flask from freezegun import freeze_time -from hamcrest import assert_that, contains_exactly, contains_inanyorder, equal_to, has_item, has_items, is_in -from pydantic import HttpUrl, ValidationError +from hamcrest import assert_that, contains_exactly, contains_inanyorder, has_item, has_items, is_, is_in +from pydantic import HttpUrl -from eligibility_signposting_api.audit.audit_models import AuditAction, AuditEvent from eligibility_signposting_api.model import campaign_config as rules_model +from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( - ActionsMapper, AvailableAction, CohortLabel, Description, - IterationCohort, RuleAttributeLevel, RuleAttributeName, RuleAttributeTarget, RuleComparator, RuleName, RuleOperator, - RuleStop, RuleType, ) from eligibility_signposting_api.model.eligibility_status import ( ActionCode, ActionDescription, ActionType, + CohortGroupResult, ConditionName, DateOfBirth, InternalActionCode, + IterationResult, NHSNumber, Postcode, + Reason, RuleDescription, + RulePriority, Status, SuggestedAction, - UrlLabel, - UrlLink, ) -from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator from tests.fixtures.builders.model import rule as rule_builder from tests.fixtures.builders.repos.person import person_rows_builder @@ -48,7 +46,6 @@ is_cohort_result, is_condition, is_eligibility_status, - is_reason, ) @@ -57,39 +54,6 @@ def app(): return Flask(__name__) -def test_not_base_eligible(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"]) - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], - iteration_rules=[], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_eligible)) - ), - ) - - @pytest.mark.parametrize( ("person_cohorts", "iteration_cohorts", "status", "test_comment"), [ @@ -136,55 +100,6 @@ def test_base_eligible_with_when_magic_cohort_is_present( ) -@freeze_time("2025-04-25") -def test_only_live_campaigns_considered(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - name="Live", - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], - iteration_rules=[], - ) - ], - start_date=datetime.date(2025, 4, 20), - end_date=datetime.date(2025, 4, 30), - ), - rule_builder.CampaignConfigFactory.build( - name="No longer live", - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build(cohort_label="cohort1"), - rule_builder.IterationCohortFactory.build(cohort_label="cohort2"), - ], - ) - ], - start_date=datetime.date(2025, 4, 1), - end_date=datetime.date(2025, 4, 24), - ), - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_eligible)) - ), - ) - - @pytest.mark.parametrize( "iteration_type", ["A", "M", "S", "O"], @@ -214,123 +129,6 @@ def test_campaigns_with_applicable_iteration_types_in_campaign_level_considered( ) -@pytest.mark.parametrize( - "iteration_type", - ["A", "M", "S", "O"], -) -def test_campaigns_with_applicable_iteration_types_in_iteration_level_considered(iteration_type: str, faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=[]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", iterations=[rule_builder.IterationFactory.build(type=iteration_type)] - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(is_in([Status.actionable, Status.not_actionable, Status.not_eligible])) - ), - ), - ) - - -@pytest.mark.parametrize( - "iteration_type", - ["NA", "N", "FAKE", "F"], -) -def test_invalid_iteration_types_in_campaign_level_raises_validation_error(iteration_type: str): - with pytest.raises(ValidationError): - rule_builder.CampaignConfigFactory.build(target="RSV", iteration_type=iteration_type) - - -@pytest.mark.parametrize( - "iteration_type", - ["NA", "N", "FAKE", "F"], -) -def test_invalid_iteration_types_in_iteration_level_raises_validation_error(iteration_type: str): - with pytest.raises(ValidationError): - rule_builder.CampaignConfigFactory.build( - target="RSV", iterations=[rule_builder.IterationFactory.build(type=iteration_type)] - ) - - -def test_base_eligible_and_simple_rule_includes(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=79)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.actionable)) - ), - ) - - -def test_base_eligible_but_simple_rule_excludes(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_actionable)) - ), - ) - - @freeze_time("2025-04-25") def test_simple_rule_only_excludes_from_live_iteration(faker: Faker): # Given @@ -378,86 +176,6 @@ def test_simple_rule_only_excludes_from_live_iteration(faker: Faker): ) -@pytest.mark.parametrize( - ("rule_type", "expected_status"), - [(rules_model.RuleType.suppression, Status.not_actionable), (rules_model.RuleType.filter, Status.not_eligible)], -) -def test_rule_types_cause_correct_statuses(rule_type: rules_model.RuleType, expected_status: Status, faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(type=rule_type)], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item( - is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status).and_actions([]) - ) - ), - ) - - -def test_multiple_rule_types_cause_correct_status(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=18, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[ - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(5), type=rules_model.RuleType.suppression - ), - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(10), type=rules_model.RuleType.filter - ), - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=rules_model.RulePriority(15), type=rules_model.RuleType.suppression - ), - ], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_eligible)) - ), - ) - - @pytest.mark.parametrize( ("test_comment", "rule1", "rule2", "expected_status"), [ @@ -548,287 +266,23 @@ def test_rules_with_same_priority_must_all_match_to_exclude( ) -def test_multiple_conditions_where_both_are_actionable(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=78)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"], icb="QE1") - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={"rule_1_comms_routing": book_nbs_comms, "defaultcomms": default_comms_detail} - ), - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="COVID", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[ - rule_builder.PersonAgeSuppressionRuleFactory.build(), - rule_builder.ICBRedirectRuleFactory.build(), - ], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={"ActionCode1": book_nbs_comms, "defaultcomms": default_comms_detail} - ), - ) - ], - ), - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(Status.actionable) - .and_actions( - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), - url_link=None, - url_label=None, - ) - ] - ), - is_condition() - .with_condition_name(ConditionName("COVID")) - .and_status(Status.actionable) - .and_actions( - [ - SuggestedAction( - internal_action_code=InternalActionCode("ActionCode1"), - action_type=ActionType("ButtonAuthLink"), - action_code=ActionCode("BookNBS"), - action_description=ActionDescription("Action description"), - url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), - url_label=UrlLabel("Continue to booking"), - ) - ] - ), - ) - ), - ) - - -def test_multiple_conditions_where_all_give_unique_statuses(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=78)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="COVID", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="FLU", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort2")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("N", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(Status.actionable) - .and_actions(None), - is_condition() - .with_condition_name(ConditionName("COVID")) - .and_status(Status.not_actionable) - .and_actions(None), - is_condition() - .with_condition_name(ConditionName("FLU")) - .and_status(Status.not_eligible) - .and_actions(None), - ) - ), - ) - - -@pytest.mark.parametrize( - ("test_comment", "campaign1", "campaign2"), - [ - ( - "1st campaign allows, 2nd excludes", - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - ), - ( - "1st campaign excludes, 2nd allows", - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build(comparator="-85")], - ) - ], - ), - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()], - ) - ], - ), - ), - ], -) -def test_multiple_campaigns_for_single_condition( - test_comment: str, campaign1: rules_model.CampaignConfig, campaign2: rules_model.CampaignConfig, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=76, maximum_age=78)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - campaign_configs = [campaign1, campaign2] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - contains_exactly(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.actionable)) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("icb", "rule_type", "expected_status"), - [ - ("QE1", rules_model.RuleType.suppression, Status.actionable), - ("QWU", rules_model.RuleType.suppression, Status.not_actionable), - ("", rules_model.RuleType.suppression, Status.not_actionable), - (None, rules_model.RuleType.suppression, Status.not_actionable), - ("QE1", rules_model.RuleType.filter, Status.actionable), - ("QWU", rules_model.RuleType.filter, Status.not_eligible), - ("", rules_model.RuleType.filter, Status.not_eligible), - (None, rules_model.RuleType.filter, Status.not_eligible), - ], -) -def test_base_eligible_and_icb_example( - icb: str | None, rule_type: rules_model.RuleType, expected_status: Status, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb=icb) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[rule_builder.ICBFilterRuleFactory.build(type=rule_type)], - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - ) - - -@pytest.mark.parametrize( - ("vaccine", "last_successful_date", "expected_status", "test_comment"), - [ - ("RSV", "20240601", Status.not_actionable, "last_successful_date is a past date"), - ("RSV", "20250101", Status.not_actionable, "last_successful_date is today"), - # Below is a non-ideal situation (might be due to a data entry error), so considered as actionable. - ("RSV", "20260101", Status.actionable, "last_successful_date is a future date"), - ("RSV", "20230601", Status.actionable, "last_successful_date is a long past"), - ("RSV", "", Status.actionable, "last_successful_date is empty"), - ("RSV", None, Status.actionable, "last_successful_date is none"), - ("COVID", "20240601", Status.actionable, "No RSV row"), - ], -) -@freeze_time("2025-01-01") -def test_status_on_target_based_on_last_successful_date( - vaccine: str, last_successful_date: str, expected_status: Status, test_comment: str, faker: Faker -): +@pytest.mark.parametrize( + ("vaccine", "last_successful_date", "expected_status", "test_comment"), + [ + ("RSV", "20240601", Status.not_actionable, "last_successful_date is a past date"), + ("RSV", "20250101", Status.not_actionable, "last_successful_date is today"), + # Below is a non-ideal situation (might be due to a data entry error), so considered as actionable. + ("RSV", "20260101", Status.actionable, "last_successful_date is a future date"), + ("RSV", "20230601", Status.actionable, "last_successful_date is a long past"), + ("RSV", "", Status.actionable, "last_successful_date is empty"), + ("RSV", None, Status.actionable, "last_successful_date is none"), + ("COVID", "20240601", Status.actionable, "No RSV row"), + ], +) +@freeze_time("2025-01-01") +def test_status_on_target_based_on_last_successful_date( + vaccine: str, last_successful_date: str, expected_status: Status, test_comment: str, faker: Faker +): # Given nhs_number = NHSNumber(faker.nhs_number()) @@ -895,74 +349,6 @@ def test_status_on_target_based_on_last_successful_date( ) -@pytest.mark.parametrize( - ("attribute_name", "expected_status", "test_comment"), - [ - ( - RuleAttributeName("COHORT_LABEL"), - Status.not_eligible, - "cohort label provided", - ), - ( - None, - Status.not_eligible, - "cohort label is the default attribute name for the cohort attribute level", - ), - ], -) -def test_status_on_cohort_attribute_level( - attribute_name: RuleAttributeName, expected_status: Status, test_comment: str, faker: Faker -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_row: Person = person_rows_builder(nhs_number, cohorts=["cohort1", "covid_eligibility_complaint_list"]) - - person_row_with_extra_items_in_cohort_row = Person(person_row.data) - for row in person_row_with_extra_items_in_cohort_row.data: - if row.get("ATTRIBUTE_TYPE", "") == "COHORTS": - row["LOCATION"] = "HP1" - - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - iteration_rules=[ - rule_builder.IterationRuleFactory.build( - type=RuleType.filter, - name=RuleName("Exclude those in a complaint cohort"), - description=RuleDescription( - "Ensure anyone who has registered a complaint is not shown as eligible" - ), - priority=15, - operator=RuleOperator.member_of, - attribute_level=RuleAttributeLevel.COHORT, - attribute_name=attribute_name, - comparator=RuleComparator("covid_eligibility_complaint_list"), - ) - ], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_row_with_extra_items_in_cohort_row, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(expected_status)) - ), - test_comment, - ) - - @pytest.mark.parametrize( ("person_cohorts", "expected_status", "test_comment"), [ @@ -1010,231 +396,55 @@ def test_status_if_iteration_rules_contains_cohort_label_field( @pytest.mark.parametrize( - ("rule_stop", "expected_reason_results", "test_comment"), # Changed expected_reasons to expected_reason_results + ("person_rows", "expected_status", "expected_cohort_group_and_description", "test_comment"), [ ( - RuleStop(True), # noqa: FBT003 + person_rows_builder(nhs_number="123", cohorts=[], postcode="AC01", de=True, icb="QE1"), + Status.not_eligible, + [ + ("magic cohort group", "magic negative description"), + ("rsv_age_range", "rsv_age_range negative description"), + ], + "rsv_75_rolling is not base-eligible & magic cohort group not eligible by F rules ", + ), + ( + person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=True, icb="QE1"), + Status.not_eligible, + [ + ("magic cohort group", "magic negative description"), + ("rsv_age_range", "rsv_age_range negative description"), + ], + "all the cohorts are not-eligible by F rules", + ), + ( + person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="SW19", de=False, icb="QE1"), + Status.not_actionable, [ - RuleDescription("reason 1"), - RuleDescription("reason 2"), + ("magic cohort group", "magic positive description"), + ("rsv_age_range", "rsv_age_range positive description"), ], - "rule_stop is True, last rule should not run", + "all the cohorts are not-actionable", ), ( - RuleStop(False), # noqa: FBT003 + person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=False, icb="QE1"), + Status.actionable, [ - RuleDescription("reason 1"), - RuleDescription("reason 2"), - RuleDescription("reason 3"), + ("magic cohort group", "magic positive description"), + ("rsv_age_range", "rsv_age_range positive description"), ], - "rule_stop is False, last rule should run", - ), - ], -) -def test_rules_stop_behavior( - rule_stop: RuleStop, expected_reason_results: list[RuleDescription], test_comment: str, faker: Faker -) -> None: - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=["cohort1"]) - - # Build campaign configuration - campaign_config = rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_rules=[ - rule_builder.PersonAgeSuppressionRuleFactory.build( - priority=10, description="reason 1", rule_stop=rule_stop - ), - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=10, description="reason 2"), - rule_builder.PersonAgeSuppressionRuleFactory.build(priority=15, description="reason 3"), - ], - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build(cohort_group="cohort_group1", cohort_label="cohort1") - ], - ) - ], - ) - - calculator = EligibilityCalculator(person_rows, [campaign_config]) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_actionable)) - .and_cohort_results( - has_items( - is_cohort_result().with_reasons( - contains_inanyorder( - *[ - is_reason().with_rule_description(equal_to(result)) - for result in expected_reason_results - ] - ) - ) - ) - ) - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_cohorts", "iteration_cohorts", "expected_status", "expected_cohorts"), - [ - ( - ["covid_cohort", "flu_cohort"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.not_eligible, - ["rsv_clinical_cohort_group", "rsv_75_rolling_group"], - ), - ( - ["rsv_clinical_cohort", "rsv_75_rolling"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.actionable, - ["rsv_clinical_cohort_group"], - ), - ( - ["covid_cohort", "rsv_75_rolling"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.not_actionable, - ["rsv_75_rolling_group"], - ), - ( - ["covid_cohort", "rsv_clinical_cohort"], - ["rsv_clinical_cohort", "rsv_75_rolling"], - Status.actionable, - ["rsv_clinical_cohort_group"], - ), - ( - ["rsv_75to79_2024", "rsv_75_rolling"], - ["rsv_75to79_2024", "rsv_75_rolling"], - Status.not_actionable, - ["rsv_75_rolling_group", "rsv_75to79_2024_group"], - ), - ], -) -def test_eligibility_results_when_multiple_cohorts( - person_cohorts: list[str], - iteration_cohorts: list[str], - expected_status: Status, - expected_cohorts: list[str], - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - dob_person_less_than_75 = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=dob_person_less_than_75, cohorts=person_cohorts) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.IterationCohortFactory.build( - cohort_group=f"{cohorts}_group", - cohort_label=cohorts, - positive_description="positive description", - negative_description="negative description", - ) - for cohorts in iteration_cohorts - ], - iteration_rules=[ - rule_builder.PersonAgeSuppressionRuleFactory.build(cohort_label="rsv_75_rolling"), - rule_builder.PersonAgeSuppressionRuleFactory.build(cohort_label="rsv_75to79_2024"), - ], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(expected_status)) - .and_cohort_results( - contains_inanyorder( - *[ - is_cohort_result().with_cohort_code(equal_to(cohort_label)) - for cohort_label in expected_cohorts - ] - ) - ) - ) - ), - ) - - -@pytest.mark.parametrize( - ("person_rows", "expected_status", "expected_cohort_group_and_description", "test_comment"), - [ - ( - person_rows_builder(nhs_number="123", cohorts=[], postcode="AC01", de=True, icb="QE1"), - Status.not_eligible, - [ - ("magic cohort group", "magic negative description"), - ("rsv_age_range", "rsv_age_range negative description"), - ], - "rsv_75_rolling is not base-eligible & magic cohort group not eligible by F rules ", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=True, icb="QE1"), - Status.not_eligible, - [ - ("magic cohort group", "magic negative description"), - ("rsv_age_range", "rsv_age_range negative description"), - ], - "all the cohorts are not-eligible by F rules", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="SW19", de=False, icb="QE1"), - Status.not_actionable, - [ - ("magic cohort group", "magic positive description"), - ("rsv_age_range", "rsv_age_range positive description"), - ], - "all the cohorts are not-actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=False, icb="QE1"), - Status.actionable, - [ - ("magic cohort group", "magic positive description"), - ("rsv_age_range", "rsv_age_range positive description"), - ], - "all the cohorts are actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=False, icb="NOT_QE1"), - Status.actionable, - [("magic cohort group", "magic positive description")], - "magic_cohort is actionable, but not others", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="SW19", de=False, icb="NOT_QE1"), - Status.not_actionable, - [("magic cohort group", "magic positive description")], - "magic_cohort is not-actionable, but others are not eligible", + "all the cohorts are actionable", + ), + ( + person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="AC01", de=False, icb="NOT_QE1"), + Status.actionable, + [("magic cohort group", "magic positive description")], + "magic_cohort is actionable, but not others", + ), + ( + person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling"], postcode="SW19", de=False, icb="NOT_QE1"), + Status.not_actionable, + [("magic cohort group", "magic positive description")], + "magic_cohort is not-actionable, but others are not eligible", ), ], ) @@ -1300,111 +510,51 @@ def test_cohort_groups_and_their_descriptions_when_magic_cohort_is_present( ) -def test_cohort_groups_and_their_descriptions_when_best_status_is_not_eligible( - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=[]) - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75RollingCohortFactory.build(), - rule_builder.Rsv75to79CohortFactory.build(), - rule_builder.RsvPretendClinicalCohortFactory.build(), - ], - iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(Status.not_eligible) - .and_cohort_results( - contains_exactly( - is_cohort_result() - .with_cohort_code("rsv_age_range") - .with_description("rsv_age_range negative description"), - is_cohort_result() - .with_cohort_code("rsv_clinical_cohort") - .with_description("rsv_clinical_cohort negative description"), - ) - ) - ) - ), - ) - - @pytest.mark.parametrize( - ("person_cohorts", "expected_cohort_group_and_description_and_s_rule_names", "test_comment"), + ("person_rows", "expected_description", "test_comment"), [ ( - ["rsv_75_rolling"], - [("rsv_age_range", "rsv_age_range positive description", ["Excluded postcode In SW19"])], - "rsv_75_rolling is not-actionable, others are not-eligible", + person_rows_builder(nhs_number="123", cohorts=[]), + "rsv_age_range negative description 1", + "status - not eligible", ), ( - ["rsv_75_rolling", "rsv_75to79_2024"], - [ - ( - "rsv_age_range", - "rsv_age_range positive description", - ["Excluded postcode In SW19", "Excluded postcode In SW19"], - ) - ], - "rsv_75_rolling, rsv_75to79_2024 is not-actionable, rsv_pretend_clinical_cohort are not-eligible", + person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling", "rsv_75to79_2024"], postcode="SW19"), + "rsv_age_range positive description 1", + "status - not actionable", ), ( - ["rsv_75_rolling", "rsv_75to79_2024", "rsv_pretend_clinical_cohort"], - [ - ( - "rsv_age_range", - "rsv_age_range positive description", - ["Excluded postcode In SW19", "Excluded postcode In SW19"], - ), - ("rsv_clinical_cohort", "rsv_clinical_cohort positive description", ["Excluded postcode In SW19"]), - ], - "all are not-actionable", + person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling", "rsv_75to79_2024"], postcode="hp"), + "rsv_age_range positive description 1", + "status - actionable", + ), + ( + person_rows_builder(nhs_number="123", cohorts=["rsv_75to79_2024"], postcode="hp"), + "rsv_age_range positive description 2", + "rsv_75to79_2024 - actionable and rsv_75_rolling is not eligible", ), ], ) -def test_cohort_groups_and_their_descriptions_and_the_collection_of_s_rules_when_best_status_is_not_actionable( - person_cohorts: list[str], - expected_cohort_group_and_description_and_s_rule_names: list[tuple[str, str, list[str]]], - test_comment: str, - faker: Faker, +def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_have_different_non_empty_descriptions( + person_rows: list[dict[str, Any]], expected_description: str, test_comment: str ): # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=person_cohorts, postcode="SW19") campaign_configs = [ rule_builder.CampaignConfigFactory.build( target="RSV", iterations=[ rule_builder.IterationFactory.build( iteration_cohorts=[ - rule_builder.Rsv75RollingCohortFactory.build(), - rule_builder.Rsv75to79CohortFactory.build(), - rule_builder.RsvPretendClinicalCohortFactory.build(), + rule_builder.Rsv75to79CohortFactory.build( + positive_description=Description("rsv_age_range positive description 2"), + negative_description=Description("rsv_age_range negative description 2"), + priority=2, + ), + rule_builder.Rsv75RollingCohortFactory.build( + positive_description=Description("rsv_age_range positive description 1"), + negative_description=Description("rsv_age_range negative description 1"), + priority=1, + ), ], iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], ) @@ -1424,1592 +574,264 @@ def test_cohort_groups_and_their_descriptions_and_the_collection_of_s_rules_when has_items( is_condition() .with_condition_name(ConditionName("RSV")) - .and_status(Status.not_actionable) .and_cohort_results( contains_exactly( - *[ - is_cohort_result() - .with_cohort_code(item[0]) - .and_description(item[1]) - .and_reasons( - contains_exactly(*[is_reason().with_rule_name(rule_name) for rule_name in item[2]]) - ) - for item in expected_cohort_group_and_description_and_s_rule_names - ] + is_cohort_result().with_cohort_code("rsv_age_range").with_description(expected_description) ) - ), + ) ) ), test_comment, ) -@pytest.mark.parametrize( - ("person_cohorts", "expected_cohort_group_and_description", "test_comment"), - [ - ( - ["rsv_75_rolling"], - [("rsv_age_range", "rsv_age_range positive description")], - "rsv_75_rolling is actionable, others are not-eligible", - ), - ( - ["rsv_75_rolling", "rsv_75to79_2024"], - [("rsv_age_range", "rsv_age_range positive description")], - "rsv_75_rolling, rsv_75to79_2024 is actionable, rsv_pretend_clinical_cohort are not-eligible", - ), - ( - ["rsv_75_rolling", "rsv_75to79_2024", "rsv_pretend_clinical_cohort"], - [ - ("rsv_age_range", "rsv_age_range positive description"), - ("rsv_clinical_cohort", "rsv_clinical_cohort positive description"), - ], - "all are actionable", - ), - ], +book_nbs_comms = AvailableAction( + ActionType="ButtonAuthLink", + ExternalRoutingCode="BookNBS", + ActionDescription="Action description", + UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), + UrlLabel="Continue to booking", ) -def test_cohort_group_and_descriptions_when_best_status_is_actionable( - person_cohorts: list[str], - expected_cohort_group_and_description: list[tuple[str, str]], - test_comment: str, - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - date_of_birth = DateOfBirth(faker.date_of_birth(minimum_age=66, maximum_age=74)) - person_rows = person_rows_builder(nhs_number, date_of_birth=date_of_birth, cohorts=person_cohorts, postcode="hp") - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75RollingCohortFactory.build(), - rule_builder.Rsv75to79CohortFactory.build(), - rule_builder.RsvPretendClinicalCohortFactory.build(), - ], - iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(Status.actionable) - .and_cohort_results( - contains_exactly( - *[ - is_cohort_result().with_cohort_code(item[0]).with_description(item[1]) - for item in expected_cohort_group_and_description - ] - ) - ) - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_rows", "expected_description", "test_comment"), - [ - ( - person_rows_builder(nhs_number="123", cohorts=[]), - "rsv_age_range negative description 1", - "status - not eligible", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling", "rsv_75to79_2024"], postcode="SW19"), - "rsv_age_range positive description 1", - "status - not actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75_rolling", "rsv_75to79_2024"], postcode="hp"), - "rsv_age_range positive description 1", - "status - actionable", - ), - ( - person_rows_builder(nhs_number="123", cohorts=["rsv_75to79_2024"], postcode="hp"), - "rsv_age_range positive description 2", - "rsv_75to79_2024 - actionable and rsv_75_rolling is not eligible", - ), - ], -) -def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_have_different_non_empty_descriptions( - person_rows: list[dict[str, Any]], expected_description: str, test_comment: str -): - # Given - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[ - rule_builder.Rsv75to79CohortFactory.build( - positive_description=Description("rsv_age_range positive description 2"), - negative_description=Description("rsv_age_range negative description 2"), - priority=2, - ), - rule_builder.Rsv75RollingCohortFactory.build( - positive_description=Description("rsv_age_range positive description 1"), - negative_description=Description("rsv_age_range negative description 1"), - priority=1, - ), - ], - iteration_rules=[rule_builder.PostcodeSuppressionRuleFactory.build()], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_cohort_results( - contains_exactly( - is_cohort_result().with_cohort_code("rsv_age_range").with_description(expected_description) - ) - ) - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("person_rows", "iteration_cohorts", "expected_cohort_group_and_description", "expected_status", "test_comment"), - [ - ( - person_rows_builder("123", postcode="SW19", cohorts=[], de=False), - [rule_builder.Rsv75to79CohortFactory.build(negative_description=None, priority=2)], - [("rsv_age_range", "")], - Status.not_eligible, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="SW19", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [rule_builder.Rsv75to79CohortFactory.build(negative_description=None, priority=2)], - [("rsv_age_range", "")], - Status.not_eligible, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=True), - [rule_builder.Rsv75to79CohortFactory.build(positive_description=None, priority=2)], - [("rsv_age_range", "")], - Status.not_actionable, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [rule_builder.Rsv75to79CohortFactory.build(positive_description=None, priority=2)], - [("rsv_age_range", "")], - Status.actionable, - "if group has one cohort, with no description, expect no description", - ), - ( - person_rows_builder("123", postcode="SW19", cohorts=[], de=False), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, negative_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, negative_description="rsv age range -ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, negative_description="rsv age range -ve 2" - ), - ], - [("rsv_age_range", "rsv age range -ve 1")], - Status.not_eligible, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ( - person_rows_builder("123", postcode="SW19", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, negative_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, negative_description="rsv age range -ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, negative_description="rsv age range -ve 2" - ), - ], - [("rsv_age_range", "rsv age range -ve 1")], - Status.not_eligible, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=True), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, positive_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, positive_description="rsv age range +ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, positive_description="rsv age range +ve 2" - ), - ], - [("rsv_age_range", "rsv age range +ve 1")], - Status.not_actionable, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ( - person_rows_builder("123", postcode="HP1", cohorts=["rsv_75to79_2024", "rsv_75_rolling"], de=False), - [ - rule_builder.Rsv75to79CohortFactory.build(priority=2, positive_description=None), - rule_builder.Rsv75RollingCohortFactory.build(priority=3, positive_description="rsv age range +ve 1"), - rule_builder.Rsv75RollingCohortFactory.build( - cohort_label="rsv_75_rolling_2", priority=4, positive_description="rsv age range +ve 2" - ), - ], - [("rsv_age_range", "rsv age range +ve 1")], - Status.actionable, - "if group has more than one cohort, at least one has description, expect first non empty description", - ), - ], -) -def test_cohort_group_descriptions_pick_first_non_empty_if_available( - person_rows: list[dict[str, Any]], - iteration_cohorts: list[IterationCohort], - expected_cohort_group_and_description: list[tuple[str, str]], - expected_status: Status, - test_comment: str, -): - # Given - campaign_configs = [ - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=iteration_cohorts, - iteration_rules=[ - rule_builder.PostcodeSuppressionRuleFactory.build(type=RuleType.filter), - rule_builder.DetainedEstateSuppressionRuleFactory.build(), - ], - ) - ], - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(expected_status) - .and_cohort_results( - contains_exactly( - *[ - is_cohort_result() - .with_cohort_code(item[0]) - .with_description(item[1]) - .with_status(expected_status) - for item in expected_cohort_group_and_description - ] - ) - ) - ) - ), - test_comment, - ) - - -book_nbs_comms = AvailableAction( - ActionType="ButtonAuthLink", - ExternalRoutingCode="BookNBS", - ActionDescription="Action description", - UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), - UrlLabel="Continue to booking", -) - -default_comms_detail = AvailableAction( - ActionType="CareCardWithText", - ExternalRoutingCode="BookLocal", - ActionDescription="You can get an RSV vaccination at your GP surgery", -) - - -@pytest.mark.parametrize( - ("test_comment", "default_comms_routing", "comms_routing", "actions_mapper", "expected_actions"), - [ - ( - """Rule match: default_comms_routing present, action_mapper present, - return actions from matching comms from rule""", - "defaultcomms", - "InternalBookNBS", - {"InternalBookNBS": book_nbs_comms, "defaultcomms": default_comms_detail}, - [ - SuggestedAction( - internal_action_code=InternalActionCode("InternalBookNBS"), - action_type=ActionType("ButtonAuthLink"), - action_code=ActionCode("BookNBS"), - action_description=ActionDescription("Action description"), - url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), - url_label=UrlLabel("Continue to booking"), - ) - ], - ), - ( - """Rule match: default_comms_routing has multiple values, - comms missing in rule, all default comms should be returned in actions""", - "defaultcomms1|defaultcomms2", - None, - {"defaultcomms1": default_comms_detail, "defaultcomms2": default_comms_detail}, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms1"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), - url_link=None, - url_label=None, - ), - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms2"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), - url_link=None, - url_label=None, - ), - ], - ), - ( - """Rule match: default_comms_routing has multiple values, - comms is empty string, all default comms should be returned in actions""", - "defaultcomms1", - "", - {"defaultcomms1": default_comms_detail}, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms1"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), - url_link=None, - url_label=None, - ) - ], - ), - ( - """Rule match: default_comms_routing present, - action_mapper missing for matching comms, return default_comms in actions""", - "defaultcomms", - "InternalBookNBS", - {"defaultcomms": default_comms_detail}, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), - url_link=None, - url_label=None, - ) - ], - ), - ( - """Rule match: default_comms_routing present, - rule has an incorrect comms key, return default_comms in actions""", - "defaultcomms", - "InvalidCode", - {"defaultcomms": default_comms_detail}, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), - url_link=None, - url_label=None, - ) - ], - ), - ( - """Rule match: action_mapper present without url, - return actions from matching comms from rule""", - "defaultcomms", - "InternalBookNBS", - { - "InternalBookNBS": AvailableAction( - ActionType=book_nbs_comms.action_type, - ExternalRoutingCode=book_nbs_comms.action_code, - ActionDescription=book_nbs_comms.action_description, - ) - }, - [ - SuggestedAction( - internal_action_code=InternalActionCode("InternalBookNBS"), - action_type=ActionType(book_nbs_comms.action_type), - action_code=ActionCode(book_nbs_comms.action_code), - action_description=ActionDescription(book_nbs_comms.action_description), - url_link=None, - url_label=None, - ) - ], - ), - ( - """Rule match: default_comms_routing missing, - comms present in rule, action_mapper missing, return no actions""", - "", - "InternalBookNBS", - {}, - [], - ), - ( - """Rule match: default_comms_routing missing, but action_mapper present, - return actions from matching comms from rule""", - "", - "InternalBookNBS", - {"InternalBookNBS": book_nbs_comms}, - [ - SuggestedAction( - internal_action_code=InternalActionCode("InternalBookNBS"), - action_type=ActionType("ButtonAuthLink"), - action_code=ActionCode("BookNBS"), - action_description=ActionDescription("Action description"), - url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), - url_label=UrlLabel("Continue to booking"), - ) - ], - ), - ( - """Rule match: default_comms_routing present, - comms present in rule, but action_mapper missing, return no actions""", - "defaultcommskeywithoutactionmapper", - "InternalBookNBS", - {}, - [], - ), - ( - """Rule match: default_comms_routing has multiple values, - one of the value is invalid, valid values should be returned in actions""", - "defaultcomms1|invaliddefault", - None, - {"defaultcomms1": default_comms_detail}, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms1"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), - url_link=None, - url_label=None, - ) - ], - ), - ], -) -def test_correct_actions_determined_from_redirect_r_rules( # noqa: PLR0913 - test_comment: str, - default_comms_routing: str, - comms_routing: str, - actions_mapper: ActionsMapper, - expected_actions: list[SuggestedAction], - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb="QE1") - - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_comms_routing=default_comms_routing, - actions_mapper=rule_builder.ActionsMapperFactory.build(root=actions_mapper), - iteration_rules=[rule_builder.ICBRedirectRuleFactory.build(comms_routing=comms_routing)], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.actionable)) - .and_actions(equal_to(expected_actions)) - ) - ), - test_comment, - ) - - -@pytest.mark.parametrize( - ("test_comment", "redirect_r_rule_cohort_label"), - [ - ("cohort_label matches person cohort, result action ActionCode1", "cohort1"), - ("cohort_label NOT matches person cohort, result action ActionCode1", "cohort2"), - ], -) -def test_cohort_label_not_supported_used_in_r_rules(test_comment: str, redirect_r_rule_cohort_label: str, faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb="QE1") - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "ActionCode1": book_nbs_comms, - "defaultcomms": default_comms_detail, - } - ), - iteration_rules=[ - rule_builder.ICBRedirectRuleFactory.build( - cohort_label=CohortLabel(redirect_r_rule_cohort_label) - ) - ], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.actionable)) - .and_actions( - equal_to( - [ - SuggestedAction( - internal_action_code=InternalActionCode("ActionCode1"), - action_type=ActionType("ButtonAuthLink"), - action_code=ActionCode("BookNBS"), - action_description=ActionDescription("Action description"), - url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), - url_label=UrlLabel("Continue to booking"), - ) - ] - ) - ) - ) - ), - test_comment, - ) - - -def test_multiple_r_rules_match_with_same_priority(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb="QE1") - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "rule_1_comms_routing": book_nbs_comms, - "rule_2_comms_routing": book_nbs_comms, - "rule_3_comms_routing": book_nbs_comms, - "defaultcomms": default_comms_detail, - } - ), - iteration_rules=[ - rule_builder.ICBRedirectRuleFactory.build(comms_routing="rule_1_comms_routing"), - rule_builder.ICBRedirectRuleFactory.build(comms_routing="rule_2_comms_routing"), - rule_builder.ICBRedirectRuleFactory.build( - priority=2, - attribute_name=RuleAttributeName("ICBMismatch"), - comms_routing="rule_3_comms_routing", - ), - ], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.actionable)) - .and_actions( - equal_to( - [ - SuggestedAction( - internal_action_code=InternalActionCode("rule_1_comms_routing"), - action_type=ActionType("ButtonAuthLink"), - action_code=ActionCode("BookNBS"), - action_description=ActionDescription("Action description"), - url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), - url_label=UrlLabel("Continue to booking"), - ) - ] - ) - ) - ) - ), - ) - - -def test_multiple_r_rules_with_same_priority_one_rule_mismatch_should_return_default_comms(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb="QE1") - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "rule_1_comms_routing": book_nbs_comms, - "rule_2_comms_routing": book_nbs_comms, - "rule_3_comms_routing": book_nbs_comms, - "defaultcomms": default_comms_detail, - } - ), - iteration_rules=[ - rule_builder.ICBRedirectRuleFactory.build(comms_routing="rule_1_comms_routing"), - rule_builder.ICBRedirectRuleFactory.build(comms_routing="rule_2_comms_routing"), - rule_builder.ICBRedirectRuleFactory.build( - attribute_name=RuleAttributeName("ICBMismatch"), - comms_routing="rule_3_comms_routing", - ), - ], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.actionable)) - .and_actions( - equal_to( - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultcomms"), - action_type=ActionType("CareCardWithText"), - action_code=ActionCode("BookLocal"), - action_description=ActionDescription( - "You can get an RSV vaccination at your GP surgery" - ), - url_link=None, - url_label=None, - ) - ] - ) - ) - ) - ), - ) - - -def test_only_highest_priority_rule_is_applied_and_return_actions_only_for_that_rule(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb="QE1") - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "rule_1_comms_routing": AvailableAction( - ActionType="ButtonAuthLink", - ExternalRoutingCode="BookNBS", - ActionDescription="Action description", - ), - "rule_2_comms_routing": AvailableAction( - ActionType="AuthLink", - ExternalRoutingCode="BookNBS", - ActionDescription="Action description", - UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), - UrlLabel="Continue to booking", - ), - "defaultcomms": default_comms_detail, - } - ), - iteration_rules=[ - rule_builder.ICBRedirectRuleFactory.build(priority=2, comms_routing="rule_2_comms_routing"), - rule_builder.ICBRedirectRuleFactory.build(priority=1, comms_routing="rule_1_comms_routing"), - ], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - expected_actions = SuggestedAction( - internal_action_code=InternalActionCode("rule_1_comms_routing"), - action_type=ActionType("ButtonAuthLink"), - action_code=ActionCode("BookNBS"), - action_description=ActionDescription("Action description"), - url_link=None, - url_label=None, - ) - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.actionable)) - .and_actions(equal_to([expected_actions])) - ) - ), - ) - - -def test_should_include_actions_when_include_actions_flag_is_true_when_status_is_actionable(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb="QE1") - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "book_nbs": book_nbs_comms, - "defaultcomms": default_comms_detail, - } - ), - iteration_rules=[ - rule_builder.ICBRedirectRuleFactory.build(priority=2, comms_routing="book_nbs"), - ], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.actionable)) - .and_actions( - equal_to( - [ - SuggestedAction( - internal_action_code=InternalActionCode("book_nbs"), - action_type=ActionType("ButtonAuthLink"), - action_code=ActionCode("BookNBS"), - action_description=ActionDescription("Action description"), - url_link=UrlLink(HttpUrl("https://www.nhs.uk/book-rsv")), - url_label=UrlLabel("Continue to booking"), - ) - ] - ) - ) - ) - ), - ) - - -def test_should_not_include_actions_when_include_actions_flag_is_false_when_status_is_actionable(faker: Faker): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb="QE1") - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_comms_routing="defaultcomms", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "book_nbs": book_nbs_comms, - "defaultcomms": default_comms_detail, - } - ), - iteration_rules=[ - rule_builder.ICBRedirectRuleFactory.build(priority=2, comms_routing="book_nbs"), - ], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - actual = calculator.get_eligibility_status("N", ["ALL"], "ALL") - - # Then - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.actionable)) - .and_actions(equal_to(None)) - ) - ), - ) - - -@pytest.mark.parametrize( - ( - "test_comment", - "person_icb", - "default_comms_routing", - "comms_routing", - "actions_mapper", - "expected_actions", - "expected_audit_actions", - "expected_rule_priority", - "expected_rule_name", - ), - [ - ( - """Not eligible person with matching NonEligibleActionRule""", - "QE1", - "", - "ActionCode1", - { - "ActionCode1": AvailableAction( - ActionType="InfoText", - ExternalRoutingCode="HealthcareProInfo", - ActionDescription="""Speak to your healthcare professional if you think - you should be offered this vaccination.""", - ) - }, - [ - SuggestedAction( - internal_action_code=InternalActionCode("ActionCode1"), - action_type=ActionType("InfoText"), - action_code=ActionCode("HealthcareProInfo"), - action_description=ActionDescription( - """Speak to your healthcare professional if you think - you should be offered this vaccination.""" - ), - url_link=None, - url_label=None, - ) - ], - [ - AuditAction( - internal_action_code="ActionCode1", - action_code="HealthcareProInfo", - action_type="InfoText", - action_description="""Speak to your healthcare professional if you think - you should be offered this vaccination.""", - action_url=None, - action_url_label=None, - ) - ], - "20", - "In QE1", - ), - ( - """Not eligible person with NON matching NonEligibleActionRule""", - "WS3", - "defaultCommsCode", - "ActionCode1", - { - "defaultCommsCode": AvailableAction( - ActionType="DefaultInfoText", - ExternalRoutingCode="DefaultHealthcareProInfo", - ActionDescription="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - ) - }, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultCommsCode"), - action_type=ActionType("DefaultInfoText"), - action_code=ActionCode("DefaultHealthcareProInfo"), - action_description=ActionDescription( - """Default Speak to your healthcare professional if you think - you should be offered this vaccination.""" - ), - url_link=None, - url_label=None, - ) - ], - [ - AuditAction( - internal_action_code="defaultCommsCode", - action_code="DefaultHealthcareProInfo", - action_type="DefaultInfoText", - action_description="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - action_url=None, - action_url_label=None, - ) - ], - None, - None, - ), - ( - """Not eligible person with matching but missing NonEligibleActionRule, fall back to default comms""", - "QE1", - "defaultCommsCode", - "ActionCode1", - { - "defaultCommsCode": AvailableAction( - ActionType="DefaultInfoText", - ExternalRoutingCode="DefaultHealthcareProInfo", - ActionDescription="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - ) - }, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultCommsCode"), - action_type=ActionType("DefaultInfoText"), - action_code=ActionCode("DefaultHealthcareProInfo"), - action_description=ActionDescription( - """Default Speak to your healthcare professional if you think - you should be offered this vaccination.""" - ), - url_link=None, - url_label=None, - ) - ], - [ - AuditAction( - internal_action_code="defaultCommsCode", - action_code="DefaultHealthcareProInfo", - action_type="DefaultInfoText", - action_description="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - action_url=None, - action_url_label=None, - ) - ], - "20", - "In QE1", - ), - ], +default_comms_detail = AvailableAction( + ActionType="CareCardWithText", + ExternalRoutingCode="BookLocal", + ActionDescription="You can get an RSV vaccination at your GP surgery", ) -def test_correct_actions_determined_from_not_eligible_action_rules( # noqa: PLR0913 - app, - test_comment, - person_icb, - default_comms_routing, - comms_routing, - actions_mapper, - expected_actions, - expected_audit_actions, - expected_rule_priority, - expected_rule_name, - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"], icb=person_icb) - - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_not_eligible_routing=default_comms_routing, - actions_mapper=rule_builder.ActionsMapperFactory.build(root=actions_mapper), - iteration_rules=[ - rule_builder.ICBNonEligibleActionRuleFactory.build(comms_routing=comms_routing) - ], - ) - ], - ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - with app.app_context(): - g.audit_log = AuditEvent() - - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_eligible)) - .and_actions(equal_to(expected_actions)) - ) - ), - test_comment, - ) - - cond = g.audit_log.response.condition[0] - assert cond.actions == expected_audit_actions - - assert getattr(cond.action_rule, "rule_priority", None) == expected_rule_priority - assert getattr(cond.action_rule, "rule_name", None) == expected_rule_name - - -def test_no_actions_returned_when_non_eligible_actions_and_defaultcomms_not_given( - app, - faker: Faker, -): - """ - ELI-295 - Campaign config without NonEligibleActions (X rules) should not return - any actions/default actions for NonEligible status - """ - # Given - nhs_number = NHSNumber(faker.nhs_number()) - person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"]) +class TestEligibilityResultBuilder: + def test_build_condition_results_empty_input(self): + condition_results = {} + result = EligibilityCalculator.build_condition_results(condition_results) + assert_that(result, is_([])) - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - actions_mapper={}, - iteration_rules=[], - ) - ], + def test_build_condition_results_single_condition_single_cohort_actionable(self): + cohort_group_results = [CohortGroupResult("COHORT_A", Status.actionable, [], "Cohort A Description", [])] + suggested_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - with app.app_context(): - g.audit_log = AuditEvent() - - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - expected_actions = [] - expected_audit_action = [] - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_eligible)) - .and_actions(equal_to(expected_actions)) - ) - ), - ) - - cond = g.audit_log.response.condition[0] - assert cond.actions == expected_audit_action + ] + iteration_result = IterationResult(Status.actionable, cohort_group_results, suggested_actions) + condition_results = {ConditionName("RSV"): iteration_result} -def test_actions_returned_when_non_eligible_actions_not_given_and_defaultcomms_given( - app, - faker: Faker, -): - """ - ELI-295 - Campaign config without NonEligibleActions (X rules) but with default comms routing - should return the default comms actions - """ + result = EligibilityCalculator.build_condition_results(condition_results) - # Given - nhs_number = NHSNumber(faker.nhs_number()) + assert_that(len(result), is_(1)) + assert_that(result[0].condition_name, is_(ConditionName("RSV"))) + assert_that(result[0].status, is_(Status.actionable)) + assert_that(result[0].actions, is_(suggested_actions)) + assert_that(result[0].status_text, is_(Status.actionable.get_status_text(ConditionName("RSV")))) - person_rows = person_rows_builder(nhs_number, cohorts=["NotEligibleCohort"]) + assert_that(len(result[0].cohort_results), is_(1)) + deduplicated_cohort = result[0].cohort_results[0] + assert_that(deduplicated_cohort.cohort_code, is_("COHORT_A")) + assert_that(deduplicated_cohort.status, is_(Status.actionable)) + assert_that(deduplicated_cohort.reasons, is_([])) + assert_that(deduplicated_cohort.description, is_("Cohort A Description")) + assert_that(deduplicated_cohort.audit_rules, is_([])) - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_not_eligible_routing="defaultCommsCode", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "defaultCommsCode": AvailableAction( - ActionType="DefaultInfoText", - ExternalRoutingCode="DefaultHealthcareProInfo", - ActionDescription="Default Speak to your healthcare professional.", - ) - } - ), - iteration_rules=[], - ) - ], + def test_build_condition_results_single_condition_single_cohort_not_eligible_with_reasons(self): + cohort_group_results = [CohortGroupResult("COHORT_A", Status.not_eligible, [], "Cohort A Description", [])] + suggested_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, ) - ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - with app.app_context(): - g.audit_log = AuditEvent() - - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - expected_actions = [ + ] + iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) + + condition_results = {ConditionName("RSV"): iteration_result} + + result = EligibilityCalculator.build_condition_results(condition_results) + + assert_that(len(result), is_(1)) + assert_that(result[0].condition_name, is_(ConditionName("RSV"))) + assert_that(result[0].status, is_(Status.not_eligible)) + assert_that(result[0].actions, is_(suggested_actions)) + assert_that(result[0].status_text, is_(Status.not_eligible.get_status_text(ConditionName("RSV")))) + + assert_that(len(result[0].cohort_results), is_(1)) + deduplicated_cohort = result[0].cohort_results[0] + assert_that(deduplicated_cohort.cohort_code, is_("COHORT_A")) + assert_that(deduplicated_cohort.status, is_(Status.not_eligible)) + assert_that(deduplicated_cohort.reasons, is_([])) + assert_that(deduplicated_cohort.description, is_("Cohort A Description")) + assert_that(deduplicated_cohort.audit_rules, is_([])) + + def test_build_condition_results_single_condition_multiple_cohorts_same_cohort_code_same_status(self): + reason_1 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 1"), + RulePriority("1"), + RuleDescription("Filter Rule Description 2"), + matcher_matched=True, + ) + reason_2 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 2"), + RulePriority("2"), + RuleDescription("Filter Rule Description 2"), + matcher_matched=True, + ) + cohort_group_results = [ + CohortGroupResult("COHORT_A", Status.not_eligible, [reason_1], "", []), + # The below description will be picked up as the first one is empty + CohortGroupResult("COHORT_A", Status.not_eligible, [reason_2], "Cohort A Description 2", []), + CohortGroupResult("COHORT_A", Status.not_eligible, [], "Cohort A Description 3", []), + ] + suggested_actions = [ SuggestedAction( - internal_action_code=InternalActionCode("defaultCommsCode"), - action_type=ActionType("DefaultInfoText"), - action_code=ActionCode("DefaultHealthcareProInfo"), - action_description=ActionDescription("Default Speak to your healthcare professional."), + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), url_link=None, url_label=None, ) ] - expected_audit_action = [ - AuditAction( - internal_action_code="defaultCommsCode", - action_code="DefaultHealthcareProInfo", - action_type="DefaultInfoText", - action_description="Default Speak to your healthcare professional.", - action_url=None, - action_url_label=None, + iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) + + condition_results = {ConditionName("RSV"): iteration_result} + + result = EligibilityCalculator.build_condition_results(condition_results) + + assert_that(len(result), is_(1)) + condition = result[0] + assert_that(len(condition.cohort_results), is_(1)) + + deduplicated_cohort = condition.cohort_results[0] + assert_that(deduplicated_cohort.cohort_code, is_("COHORT_A")) + assert_that(deduplicated_cohort.status, is_(Status.not_eligible)) + assert_that(deduplicated_cohort.reasons, contains_inanyorder(reason_1, reason_2)) + assert_that(deduplicated_cohort.description, is_("Cohort A Description 2")) + assert_that(deduplicated_cohort.audit_rules, is_([])) + + def test_build_condition_results_multiple_cohorts_different_cohort_code_same_status(self): + reason_1 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 1"), + RulePriority("1"), + RuleDescription("Filter Rule Description 2"), + matcher_matched=True, + ) + reason_2 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 2"), + RulePriority("2"), + RuleDescription("Filter Rule Description 2"), + matcher_matched=True, + ) + cohort_group_results = [ + CohortGroupResult("COHORT_X", Status.not_eligible, [reason_1], "Cohort X Description", []), + CohortGroupResult("COHORT_Y", Status.not_eligible, [reason_2], "Cohort Y Description", []), + ] + suggested_actions = [ + SuggestedAction( + internal_action_code=InternalActionCode("default_action_code"), + action_type=ActionType("CareCardWithText"), + action_code=ActionCode("BookLocal"), + action_description=ActionDescription("You can get an RSV vaccination at your GP surgery"), + url_link=None, + url_label=None, ) ] - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_eligible)) - .and_actions(equal_to(expected_actions)) - ) - ), - ) - - cond = g.audit_log.response.condition[0] - assert cond.actions == expected_audit_action - - -@pytest.mark.parametrize( - ( - "test_comment", - "person_icb", - "default_comms_routing", - "comms_routing", - "actions_mapper", - "expected_actions", - "expected_audit_actions", - ), - [ - ( - """Not actionable person with matching NonActionableActionRule""", - "QE1", - "", - "ActionCode1", - { - "ActionCode1": AvailableAction( - ActionType="InfoText", - ExternalRoutingCode="HealthcareProInfo", - ActionDescription="""Speak to your healthcare professional if you think - you should be offered this vaccination.""", - ) - }, - [ - SuggestedAction( - internal_action_code=InternalActionCode("ActionCode1"), - action_type=ActionType("InfoText"), - action_code=ActionCode("HealthcareProInfo"), - action_description=ActionDescription( - """Speak to your healthcare professional if you think - you should be offered this vaccination.""" - ), - url_link=None, - url_label=None, - ) - ], - [ - AuditAction( - internal_action_code="ActionCode1", - action_code="HealthcareProInfo", - action_type="InfoText", - action_description="""Speak to your healthcare professional if you think - you should be offered this vaccination.""", - action_url=None, - action_url_label=None, - ) - ], - ), - ( - """Not actionable person with NON matching NonActionableActionRule""", - "WS3", - "defaultCommsCode", - "ActionCode1", - { - "defaultCommsCode": AvailableAction( - ActionType="DefaultInfoText", - ExternalRoutingCode="DefaultHealthcareProInfo", - ActionDescription="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - ) - }, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultCommsCode"), - action_type=ActionType("DefaultInfoText"), - action_code=ActionCode("DefaultHealthcareProInfo"), - action_description=ActionDescription( - """Default Speak to your healthcare professional if you think - you should be offered this vaccination.""" - ), - url_link=None, - url_label=None, - ) - ], - [ - AuditAction( - internal_action_code="defaultCommsCode", - action_code="DefaultHealthcareProInfo", - action_type="DefaultInfoText", - action_description="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - action_url=None, - action_url_label=None, - ) - ], - ), - ( - """Not actionable person with matching but missing NonActionableActionRule, fall back to default comms""", - "QE1", - "defaultCommsCode", - "ActionCode1", - { - "defaultCommsCode": AvailableAction( - ActionType="DefaultInfoText", - ExternalRoutingCode="DefaultHealthcareProInfo", - ActionDescription="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - ) - }, - [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultCommsCode"), - action_type=ActionType("DefaultInfoText"), - action_code=ActionCode("DefaultHealthcareProInfo"), - action_description=ActionDescription( - """Default Speak to your healthcare professional if you think - you should be offered this vaccination.""" - ), - url_link=None, - url_label=None, - ) - ], - [ - AuditAction( - internal_action_code="defaultCommsCode", - action_code="DefaultHealthcareProInfo", - action_type="DefaultInfoText", - action_description="""Default Speak to your healthcare professional if you think - you should be offered this vaccination.""", - action_url=None, - action_url_label=None, - ) - ], - ), - ], -) -def test_correct_actions_determined_from_not_actionable_action_rules( # noqa: PLR0913 - app, - test_comment, - person_icb, - default_comms_routing, - comms_routing, - actions_mapper, - expected_actions, - expected_audit_actions, - faker: Faker, -): - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], icb=person_icb, de=True) + iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_not_actionable_routing=default_comms_routing, - actions_mapper=rule_builder.ActionsMapperFactory.build(root=actions_mapper), - iteration_rules=[ - rule_builder.DetainedEstateSuppressionRuleFactory.build(), - rule_builder.ICBNonActionableActionRuleFactory.build(comms_routing=comms_routing), - ], - ) - ], - ) - ) - ] + condition_results = {ConditionName("RSV"): iteration_result} - calculator = EligibilityCalculator(person_rows, campaign_configs) + result = EligibilityCalculator.build_condition_results(condition_results) - # When - with app.app_context(): - g.audit_log = AuditEvent() - - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_actionable)) - .and_actions(equal_to(expected_actions)) - ) - ), - test_comment, - ) + assert_that(len(result), is_(1)) + condition = result[0] + assert_that(len(condition.cohort_results), is_(2)) - cond = g.audit_log.response.condition[0] - assert cond.actions == expected_audit_actions + expected_deduplicated_cohorts = [ + CohortGroupResult("COHORT_X", Status.not_eligible, [reason_1], "Cohort X Description", []), + CohortGroupResult("COHORT_Y", Status.not_eligible, [reason_2], "Cohort Y Description", []), + ] + assert_that(condition.cohort_results, contains_inanyorder(*expected_deduplicated_cohorts)) + + def test_build_condition_results_cohorts_status_not_matching_iteration_status(self): + reason_1 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 1"), + RulePriority("1"), + RuleDescription("Matching"), + matcher_matched=True, + ) + reason_2 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 2"), + RulePriority("2"), + RuleDescription("Not matching"), + matcher_matched=True, + ) + cohort_group_results = [ + CohortGroupResult("COHORT_X", Status.not_eligible, [reason_1], "Cohort X Description", []), + CohortGroupResult("COHORT_Y", Status.not_actionable, [reason_2], "Cohort Y Description", []), + ] + iteration_result = IterationResult(Status.not_eligible, cohort_group_results, []) -def test_no_actions_returned_when_non_actionable_actions_and_defaultcomms_not_given( - app, - faker: Faker, -): - """ - ELI-295 - Campaign config without NonActionableActions (Y rules) should not return - any actions/default actions for NonActionable status - """ + condition_results = {ConditionName("RSV"): iteration_result} - # Given - nhs_number = NHSNumber(faker.nhs_number()) + result = EligibilityCalculator.build_condition_results(condition_results) - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], de=True) + assert_that(len(result), is_(1)) + condition = result[0] + assert_that(len(condition.cohort_results), is_(1)) + assert_that(condition.cohort_results[0].cohort_code, is_("COHORT_X")) + assert_that(condition.cohort_results[0].status, is_(Status.not_eligible)) - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - actions_mapper={}, - iteration_rules=[rule_builder.DetainedEstateSuppressionRuleFactory.build()], - ) - ], - ) + def test_build_condition_results_multiple_conditions(self): + reason_1 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 1"), + RulePriority("1"), + RuleDescription("Filter Rule Description 2"), + matcher_matched=True, ) - ] - - calculator = EligibilityCalculator(person_rows, campaign_configs) - - # When - with app.app_context(): - g.audit_log = AuditEvent() - - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") - - # Then - expected_actions = [] - expected_audit_action = [] - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_actionable)) - .and_actions(equal_to(expected_actions)) - ) - ), + reason_2 = Reason( + RuleType.filter, + eligibility_status.RuleName("Filter Rule 2"), + RulePriority("2"), + RuleDescription("Filter Rule Description 2"), + matcher_matched=True, ) + cohort_group_result1 = [CohortGroupResult("RSV_COHORT", Status.not_eligible, [reason_1], "RSV Desc", [])] + cohort_group_result2 = [CohortGroupResult("COVID_COHORT", Status.not_actionable, [reason_2], "Covid Desc", [])] - cond = g.audit_log.response.condition[0] - assert cond.actions == expected_audit_action - - -def test_actions_returned_when_non_actionable_actions_not_given_and_defaultcomms_given( - app, - faker: Faker, -): - """ - ELI-295 - Campaign config without NonActionableActions (Y rules) with default comms routing - should return default comms actions - """ - - # Given - nhs_number = NHSNumber(faker.nhs_number()) - - person_rows = person_rows_builder(nhs_number, cohorts=["cohort1"], de=True) - - campaign_configs = [ - ( - rule_builder.CampaignConfigFactory.build( - target="RSV", - iterations=[ - rule_builder.IterationFactory.build( - iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], - default_not_actionable_routing="defaultCommsCode", - actions_mapper=rule_builder.ActionsMapperFactory.build( - root={ - "defaultCommsCode": AvailableAction( - ActionType="DefaultInfoText", - ExternalRoutingCode="DefaultHealthcareProInfo", - ActionDescription="Default Speak to your healthcare professional.", - ) - } - ), - iteration_rules=[rule_builder.DetainedEstateSuppressionRuleFactory.build()], - ) - ], - ) - ) - ] + iteration_result1 = IterationResult(Status.not_eligible, cohort_group_result1, []) - calculator = EligibilityCalculator(person_rows, campaign_configs) + iteration_result2 = IterationResult(Status.not_actionable, cohort_group_result2, []) - # When - with app.app_context(): - g.audit_log = AuditEvent() + condition_results = { + ConditionName("RSV"): iteration_result1, + ConditionName("COVID"): iteration_result2, + } - actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") + result = EligibilityCalculator.build_condition_results(condition_results) - # Then - expected_actions = [ - SuggestedAction( - internal_action_code=InternalActionCode("defaultCommsCode"), - action_type=ActionType("DefaultInfoText"), - action_code=ActionCode("DefaultHealthcareProInfo"), - action_description=ActionDescription("Default Speak to your healthcare professional."), - url_link=None, - url_label=None, - ) - ] - expected_audit_action = [ - AuditAction( - internal_action_code="defaultCommsCode", - action_code="DefaultHealthcareProInfo", - action_type="DefaultInfoText", - action_description="Default Speak to your healthcare professional.", - action_url=None, - action_url_label=None, - ) - ] - assert_that( - actual, - is_eligibility_status().with_conditions( - has_items( - is_condition() - .with_condition_name(ConditionName("RSV")) - .and_status(equal_to(Status.not_actionable)) - .and_actions(equal_to(expected_actions)) - ) - ), - ) + rsv = next((c for c in result if c.condition_name == ConditionName("RSV")), None) + assert_that(rsv.status, is_(Status.not_eligible)) + assert_that(len(rsv.cohort_results), is_(1)) + assert_that(rsv.cohort_results[0].cohort_code, is_("RSV_COHORT")) + assert_that(rsv.cohort_results[0].reasons, is_([reason_1])) - cond = g.audit_log.response.condition[0] - assert cond.actions == expected_audit_action + covid = next((c for c in result if c.condition_name == ConditionName("COVID")), None) + assert_that(covid.status, is_(Status.not_actionable)) + assert_that(len(covid.cohort_results), is_(1)) + assert_that(covid.cohort_results[0].cohort_code, is_("COVID_COHORT")) + assert_that(covid.cohort_results[0].reasons, is_([reason_2])) diff --git a/tests/unit/services/processors/test_action_rule_handler.py b/tests/unit/services/processors/test_action_rule_handler.py index bc8d04ca1..13e9d4592 100644 --- a/tests/unit/services/processors/test_action_rule_handler.py +++ b/tests/unit/services/processors/test_action_rule_handler.py @@ -79,6 +79,23 @@ def test_get_action_rules_components_not_eligible_actions_type(): assert_that(default_comms, is_("default_not_eligible")) +def test_get_action_rules_components_not_actionable_actions_type(): + iteration = rule_builder.IterationFactory.build( + default_comms_routing="default_redirect", + default_not_eligible_routing="default_not_eligible", + default_not_actionable_routing="default_not_actionable", + actions_mapper=ActionsMapperFactory.build(), + iteration_rules=[rule_builder.ICBNonActionableActionRuleFactory.build(name="NonActionableRule")], + ) + rules_found, mapper, default_comms = ActionRuleHandler._get_action_rules_components( + iteration, RuleType.not_actionable_actions + ) + assert_that(len(rules_found), is_(1)) + assert_that(rules_found[0].name, is_(RuleName("NonActionableRule"))) + assert_that(mapper, is_(iteration.actions_mapper)) + assert_that(default_comms, is_("default_not_actionable")) + + def test_get_action_rules_components_no_matching_rules(): iteration = rule_builder.IterationFactory.build( iteration_rules=[rule_builder.PersonAgeSuppressionRuleFactory.build()] @@ -171,7 +188,7 @@ def test_handle_actions_no_matching_rules_returns_default( @patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") @patch.object(ActionRuleHandler, "_get_actions_from_comms") @patch.object(ActionRuleHandler, "_get_action_rules_components") -def test_handle_actions_matching_rule_overrides_default( +def test_handle_actions_matching_redirect_rule_overrides_default( mock_get_action_rules_components, mock_get_actions_from_comms, mock_rule_calculator_class, @@ -234,6 +251,138 @@ def test_handle_actions_matching_rule_overrides_default( mock_rule_calculator_class.assert_called_once_with(person=MOCK_PERSON, rule=matching_rule) +@patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_actions_matching_not_eligible_rule_overrides_default( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + matching_rule = rule_builder.ICBNonEligibleActionRuleFactory.build( + priority=10, comms_routing="rule_specific_action", name="RuleSpecificAction" + ) + active_iteration = rule_builder.IterationFactory.build( + default_not_eligible_routing="default_not_eligible", + actions_mapper=ActionsMapperFactory.build( + root={"default_not_eligible": DEFAULT_COMMS_DETAIL, "rule_specific_action": BOOK_NBS_COMMS} + ), + iteration_rules=[matching_rule], + ) + mock_get_action_rules_components.return_value = ( + (matching_rule,), + active_iteration.actions_mapper, + active_iteration.default_not_eligible_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_not_eligible"), + action_type=ActionType(DEFAULT_COMMS_DETAIL.action_type), + action_code=ActionCode(DEFAULT_COMMS_DETAIL.action_code), + action_description=ActionDescription(DEFAULT_COMMS_DETAIL.action_description), + url_link=DEFAULT_COMMS_DETAIL.url_link, + url_label=DEFAULT_COMMS_DETAIL.url_label, + ) + ], + [ + SuggestedAction( + internal_action_code=InternalActionCode("rule_specific_action"), + action_type=ActionType(BOOK_NBS_COMMS.action_type), + action_code=ActionCode(BOOK_NBS_COMMS.action_code), + action_description=ActionDescription(BOOK_NBS_COMMS.action_description), + url_link=BOOK_NBS_COMMS.url_link, + url_label=BOOK_NBS_COMMS.url_label, + ) + ], + ] + + mock_rule_instance = Mock() + mock_rule_instance.evaluate_exclusion.return_value = (Status.actionable, Mock(matcher_matched=True)) + mock_rule_calculator_class.return_value = mock_rule_instance + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, RuleType.not_eligible_actions) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("rule_specific_action"))) + assert_that(matched_action_detail.rule_priority, is_(RulePriority(10))) + assert_that(matched_action_detail.rule_name, is_(RuleName("RuleSpecificAction"))) + + mock_get_action_rules_components.assert_called_once_with(active_iteration, RuleType.not_eligible_actions) + assert_that(mock_get_actions_from_comms.call_count, is_(2)) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "default_not_eligible") + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "rule_specific_action") + mock_rule_calculator_class.assert_called_once_with(person=MOCK_PERSON, rule=matching_rule) + + +@patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") +@patch.object(ActionRuleHandler, "_get_actions_from_comms") +@patch.object(ActionRuleHandler, "_get_action_rules_components") +def test_handle_actions_matching_not_actionable_rule_overrides_default( + mock_get_action_rules_components, + mock_get_actions_from_comms, + mock_rule_calculator_class, + handler: ActionRuleHandler, +): + matching_rule = rule_builder.ICBNonActionableActionRuleFactory.build( + priority=10, comms_routing="rule_specific_action", name="RuleSpecificAction" + ) + active_iteration = rule_builder.IterationFactory.build( + default_not_actionable_routing="default_not_actionable", + actions_mapper=ActionsMapperFactory.build( + root={"default_not_actionable": DEFAULT_COMMS_DETAIL, "rule_specific_action": BOOK_NBS_COMMS} + ), + iteration_rules=[matching_rule], + ) + mock_get_action_rules_components.return_value = ( + (matching_rule,), + active_iteration.actions_mapper, + active_iteration.default_not_actionable_routing, + ) + + mock_get_actions_from_comms.side_effect = [ + [ + SuggestedAction( + internal_action_code=InternalActionCode("default_not_actionable"), + action_type=ActionType(DEFAULT_COMMS_DETAIL.action_type), + action_code=ActionCode(DEFAULT_COMMS_DETAIL.action_code), + action_description=ActionDescription(DEFAULT_COMMS_DETAIL.action_description), + url_link=DEFAULT_COMMS_DETAIL.url_link, + url_label=DEFAULT_COMMS_DETAIL.url_label, + ) + ], + [ + SuggestedAction( + internal_action_code=InternalActionCode("rule_specific_action"), + action_type=ActionType(BOOK_NBS_COMMS.action_type), + action_code=ActionCode(BOOK_NBS_COMMS.action_code), + action_description=ActionDescription(BOOK_NBS_COMMS.action_description), + url_link=BOOK_NBS_COMMS.url_link, + url_label=BOOK_NBS_COMMS.url_label, + ) + ], + ] + + mock_rule_instance = Mock() + mock_rule_instance.evaluate_exclusion.return_value = (Status.actionable, Mock(matcher_matched=True)) + mock_rule_calculator_class.return_value = mock_rule_instance + + matched_action_detail = handler._handle(MOCK_PERSON, active_iteration, RuleType.not_actionable_actions) + + assert_that(len(matched_action_detail.actions), is_(1)) + assert_that(matched_action_detail.actions[0].internal_action_code, is_(InternalActionCode("rule_specific_action"))) + assert_that(matched_action_detail.rule_priority, is_(RulePriority(10))) + assert_that(matched_action_detail.rule_name, is_(RuleName("RuleSpecificAction"))) + + mock_get_action_rules_components.assert_called_once_with(active_iteration, RuleType.not_actionable_actions) + assert_that(mock_get_actions_from_comms.call_count, is_(2)) + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "default_not_actionable") + mock_get_actions_from_comms.assert_any_call(active_iteration.actions_mapper, "rule_specific_action") + mock_rule_calculator_class.assert_called_once_with(person=MOCK_PERSON, rule=matching_rule) + + @patch("eligibility_signposting_api.services.processors.action_rule_handler.RuleCalculator") @patch.object(ActionRuleHandler, "_get_actions_from_comms") @patch.object(ActionRuleHandler, "_get_action_rules_components") diff --git a/tests/unit/services/processors/test_rule_processor.py b/tests/unit/services/processors/test_rule_processor.py index 1531c3a39..b97dea167 100644 --- a/tests/unit/services/processors/test_rule_processor.py +++ b/tests/unit/services/processors/test_rule_processor.py @@ -1,10 +1,10 @@ from unittest.mock import Mock, patch import pytest -from hamcrest import assert_that, empty, has_length, is_ +from hamcrest import assert_that, empty, is_ -from eligibility_signposting_api.model.campaign_config import RuleType -from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Reason, Status +from eligibility_signposting_api.model.campaign_config import CohortLabel, RuleType +from eligibility_signposting_api.model.eligibility_status import CohortGroupResult, Reason, RuleName, Status from eligibility_signposting_api.model.person import Person from eligibility_signposting_api.services.processors.person_data_reader import PersonDataReader from eligibility_signposting_api.services.processors.rule_processor import RuleProcessor @@ -116,8 +116,8 @@ def test_evaluate_rules_priority_group_one_not_eligible(mock_rule_calculator_cla status, reasons, is_rule_stop = rule_processor.evaluate_rules_priority_group(MOCK_PERSON_DATA, rules_group) assert_that(status, is_(Status.actionable)) - assert_that(reasons, has_length(1)) - assert_that(reasons[0].rule_name, is_("ExclusionReason")) + assert_that(len(reasons), is_(1)) + assert_that(reasons[0].rule_name, is_(RuleName("ExclusionReason"))) assert_that(is_rule_stop, is_(False)) assert_that(mock_rule_calculator_class.call_count, is_(2)) @@ -140,7 +140,7 @@ def test_evaluate_rules_priority_group_with_rule_stop(mock_rule_calculator_class status, reasons, is_rule_stop = rule_processor.evaluate_rules_priority_group(MOCK_PERSON_DATA, rules_group) assert_that(status, is_(Status.actionable)) - assert_that(reasons, has_length(1)) + assert_that(len(reasons), is_(1)) assert_that(is_rule_stop, is_(True)) @@ -180,7 +180,7 @@ def test_is_eligible_by_filter_rules_not_eligible( is_eligible = rule_processor.is_eligible(MOCK_PERSON_DATA, cohort, cohort_results, filter_rules) assert_that(is_eligible, is_(False)) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.not_eligible)) assert_that(cohort_results["COHORT_A"].description, is_("Not Eligible")) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) @@ -202,7 +202,7 @@ def test_evaluate_suppression_rules_actionable( rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.actionable)) assert_that(cohort_results["COHORT_A"].description, is_("Actionable")) assert_that(cohort_results["COHORT_A"].reasons, is_([])) @@ -228,7 +228,7 @@ def test_evaluate_suppression_rules_not_actionable( rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) assert_that(cohort_results["COHORT_A"].description, is_("Positive Description")) assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason])) @@ -260,7 +260,7 @@ def test_evaluate_suppression_rules_stops_on_rule_stop( rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason_p1])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p1])) @@ -291,7 +291,7 @@ def test_evaluate_suppression_rules_does_not_stop_on_rule_stop_when_status_is_ac rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason_p2])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p2])) @@ -405,7 +405,7 @@ def mock_evaluate_side_effect(person, rules_group): # noqa: ARG001 is_eligible = rule_processor.is_eligible(MOCK_PERSON_DATA, cohort, cohort_results, filter_rules) assert_that(is_eligible, is_(False)) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.not_eligible)) assert_that(cohort_results["COHORT_A"].description, is_("Not Eligible")) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) @@ -427,7 +427,7 @@ def test_is_actionable_by_suppression_rules( rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.actionable)) assert_that(cohort_results["COHORT_A"].description, is_("Actionable")) assert_that(cohort_results["COHORT_A"].reasons, is_(empty())) @@ -463,10 +463,133 @@ def mock_evaluate_side_effect(person, rules_group): # noqa: ARG001 rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) - assert_that(cohort_results, has_length(1)) + assert_that(len(cohort_results), is_(1)) assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) assert_that(cohort_results["COHORT_A"].description, is_("Positive Description")) assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) mock_evaluate_rules_priority_group.assert_called_once() + + +@patch.object(RuleProcessor, "get_rules_by_type") +@patch("eligibility_signposting_api.services.processors.rule_processor.BaseEligibilityHandler") +@patch("eligibility_signposting_api.services.processors.rule_processor.FilterRuleHandler") +@patch("eligibility_signposting_api.services.processors.rule_processor.SuppressionRuleHandler") +def test_get_cohort_group_results( + mock_suppression_handler_class, + mock_filter_handler_class, + mock_base_handler_class, + mock_get_rules_by_type, + rule_processor, +): + mock_base_handler_instance = mock_base_handler_class.return_value + mock_filter_handler_instance = mock_filter_handler_class.return_value + mock_suppression_handler_instance = mock_suppression_handler_class.return_value + + mock_base_handler_instance.next.return_value = mock_filter_handler_instance + mock_filter_handler_instance.next.return_value = mock_suppression_handler_instance + + cohort_a = rule_builder.IterationCohortFactory.build( + cohort_label="COHORT_A", priority=1, cohort_group="common_cohort" + ) + cohort_b = rule_builder.IterationCohortFactory.build( + cohort_label="COHORT_B", priority=2, cohort_group="common_cohort" + ) + active_iteration = rule_builder.IterationFactory.build( + iteration_cohorts=[cohort_a, cohort_b], + iteration_rules=[ + rule_builder.IterationRuleFactory.build(type=RuleType.filter, priority=1), + rule_builder.IterationRuleFactory.build(type=RuleType.suppression, priority=1), + ], + ) + + filter_rules = (rule_builder.IterationRuleFactory.build(type=RuleType.filter),) + suppression_rules = (rule_builder.IterationRuleFactory.build(type=RuleType.suppression),) + mock_get_rules_by_type.return_value = (filter_rules, suppression_rules) + + def mock_handle_side_effect(person, cohort, cohort_results_dict, rule_processor_instance): # noqa: ARG001 + if cohort.cohort_label == CohortLabel("COHORT_A"): + cohort_results_dict[CohortLabel("COHORT_A")] = CohortGroupResult( + cohort_code=cohort.cohort_group, + status=Status.actionable, + reasons=[], + description="Cohort A Description", + audit_rules=[], + ) + elif cohort.cohort_label == CohortLabel("COHORT_B"): + cohort_results_dict[CohortLabel("COHORT_B")] = CohortGroupResult( + cohort_code=cohort.cohort_group, + status=Status.not_eligible, + reasons=[], + description="Cohort B Description", + audit_rules=[], + ) + + mock_base_handler_instance.handle.side_effect = mock_handle_side_effect + + result = rule_processor.get_cohort_group_results(MOCK_PERSON_DATA, active_iteration) + + mock_get_rules_by_type.assert_called_once_with(active_iteration) + + mock_base_handler_class.assert_called_once_with() + mock_filter_handler_class.assert_called_once_with(filter_rules=filter_rules) + mock_suppression_handler_class.assert_called_once_with(suppression_rules=suppression_rules) + + mock_base_handler_instance.next.assert_called_once_with(mock_filter_handler_instance) + mock_filter_handler_instance.next.assert_called_once_with(mock_suppression_handler_instance) + + assert_that(mock_base_handler_instance.handle.call_count, is_(2)) + calls = mock_base_handler_instance.handle.call_args_list + assert_that(calls[0].args[1], is_(cohort_a)) + assert_that(calls[1].args[1], is_(cohort_b)) + + assert_that(len(result), is_(2)) + expected_result = { + CohortLabel("COHORT_A"): CohortGroupResult( + cohort_code=cohort_a.cohort_group, + status=Status.actionable, + reasons=[], + description="Cohort A Description", + audit_rules=[], + ), + CohortLabel("COHORT_B"): CohortGroupResult( + cohort_code=cohort_b.cohort_group, + status=Status.not_eligible, + reasons=[], + description="Cohort B Description", + audit_rules=[], + ), + } + assert_that(result, is_(expected_result)) + + assert_that(result[CohortLabel("COHORT_A")].status, is_(Status.actionable)) + assert_that(result[CohortLabel("COHORT_B")].status, is_(Status.not_eligible)) + + assert_that(result[CohortLabel("COHORT_A")].status, is_(Status.actionable)) + assert_that(result[CohortLabel("COHORT_B")].status, is_(Status.not_eligible)) + + +@patch.object(RuleProcessor, "get_rules_by_type", return_value=((), ())) +@patch("eligibility_signposting_api.services.processors.rule_processor.BaseEligibilityHandler") +@patch("eligibility_signposting_api.services.processors.rule_processor.FilterRuleHandler") +@patch("eligibility_signposting_api.services.processors.rule_processor.SuppressionRuleHandler") +def test_get_cohort_group_results_no_rules_no_cohorts( + mock_suppression_handler_class, + mock_filter_handler_class, + mock_base_handler_class, + mock_get_rules_by_type, + rule_processor, +): + mock_base_handler_instance = mock_base_handler_class.return_value + active_iteration = rule_builder.IterationFactory.build(iteration_cohorts=[], iteration_rules=[]) + + result = rule_processor.get_cohort_group_results(MOCK_PERSON_DATA, active_iteration) + + mock_get_rules_by_type.assert_called_once_with(active_iteration) + mock_base_handler_class.assert_called_once_with() + mock_filter_handler_class.assert_called_once_with(filter_rules=()) + mock_suppression_handler_class.assert_called_once_with(suppression_rules=()) + + mock_base_handler_instance.handle.assert_not_called() + assert_that(result, is_({})) From 9422b533d4bafc6a9799cfeec9121c31943ab29e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 13:08:33 +0000 Subject: [PATCH 32/61] Bump aiohttp from 3.12.14 to 3.12.15 --- updated-dependencies: - dependency-name: aiohttp dependency-version: 3.12.15 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- poetry.lock | 176 ++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 89 insertions(+), 89 deletions(-) diff --git a/poetry.lock b/poetry.lock index 93c1d59af..b00cd166d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,98 +14,98 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.14" +version = "3.12.15" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248"}, - {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb"}, - {file = "aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61"}, - {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8"}, - {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3"}, - {file = "aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c"}, - {file = "aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db"}, - {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597"}, - {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393"}, - {file = "aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe"}, - {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0"}, - {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28"}, - {file = "aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b"}, - {file = "aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced"}, - {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22"}, - {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a"}, - {file = "aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660"}, - {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425"}, - {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0"}, - {file = "aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729"}, - {file = "aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338"}, - {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767"}, - {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e"}, - {file = "aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd"}, - {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3"}, - {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758"}, - {file = "aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5"}, - {file = "aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa"}, - {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b8cc6b05e94d837bcd71c6531e2344e1ff0fb87abe4ad78a9261d67ef5d83eae"}, - {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1dcb015ac6a3b8facd3677597edd5ff39d11d937456702f0bb2b762e390a21b"}, - {file = "aiohttp-3.12.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3779ed96105cd70ee5e85ca4f457adbce3d9ff33ec3d0ebcdf6c5727f26b21b3"}, - {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:717a0680729b4ebd7569c1dcd718c46b09b360745fd8eb12317abc74b14d14d0"}, - {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5dd3a2ef7c7e968dbbac8f5574ebeac4d2b813b247e8cec28174a2ba3627170"}, - {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4710f77598c0092239bc12c1fcc278a444e16c7032d91babf5abbf7166463f7b"}, - {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3e9f75ae842a6c22a195d4a127263dbf87cbab729829e0bd7857fb1672400b2"}, - {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f9c8d55d6802086edd188e3a7d85a77787e50d56ce3eb4757a3205fa4657922"}, - {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79b29053ff3ad307880d94562cca80693c62062a098a5776ea8ef5ef4b28d140"}, - {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23e1332fff36bebd3183db0c7a547a1da9d3b4091509f6d818e098855f2f27d3"}, - {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a564188ce831fd110ea76bcc97085dd6c625b427db3f1dbb14ca4baa1447dcbc"}, - {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a7a1b4302f70bb3ec40ca86de82def532c97a80db49cac6a6700af0de41af5ee"}, - {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1b07ccef62950a2519f9bfc1e5b294de5dd84329f444ca0b329605ea787a3de5"}, - {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:938bd3ca6259e7e48b38d84f753d548bd863e0c222ed6ee6ace3fd6752768a84"}, - {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8bc784302b6b9f163b54c4e93d7a6f09563bd01ff2b841b29ed3ac126e5040bf"}, - {file = "aiohttp-3.12.14-cp39-cp39-win32.whl", hash = "sha256:a3416f95961dd7d5393ecff99e3f41dc990fb72eda86c11f2a60308ac6dcd7a0"}, - {file = "aiohttp-3.12.14-cp39-cp39-win_amd64.whl", hash = "sha256:196858b8820d7f60578f8b47e5669b3195c21d8ab261e39b1d705346458f445f"}, - {file = "aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, + {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, + {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, + {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, + {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, + {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, + {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, + {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, + {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, + {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, + {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, + {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, ] [package.dependencies] @@ -3494,4 +3494,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "b2eba2610d6598d4c6780bdbe5076a7222c00eb3ddcb9422fa8499f04385253f" +content-hash = "bb28d2f45eb708ec5921e1e6c528344ef2251dea6f3becae5cc7d61c8f82e3c8" diff --git a/pyproject.toml b/pyproject.toml index d86fe4e82..15ec5c917 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ pytest = "^8.4.1" pytest-asyncio = "^1.1.0" pytest-cov = "^6.0.0" pytest-nhsd-apim = "^5.0.0" -aiohttp = "^3.12.14" +aiohttp = "^3.12.15" awscli = "^1.37.24" awscli-local = "^0.22.0" polyfactory = "^2.20.0" From bea92fb3d5823f248990e7d3d37491142b5cb668 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 4 Aug 2025 13:53:36 +0100 Subject: [PATCH 33/61] trying an approach to ensure correct version of python used in lambda build (#271) --- scripts/dependencies.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/dependencies.sh b/scripts/dependencies.sh index 1977ab67a..9a35eb4e2 100755 --- a/scripts/dependencies.sh +++ b/scripts/dependencies.sh @@ -2,11 +2,19 @@ set -euo pipefail +# Use the python from PATH (set by setup-python) +PYTHON_BIN="${PYTHON_BIN:-python}" + + if ! [ -x "$(command -v poetry)" ]; then if ! [ -x "$(command -v pipx)" ]; then - python -m pip install --user pipx --isolated - python -m pipx ensurepath + $PYTHON_BIN -m pip install --user pipx --isolated + $PYTHON_BIN -m pipx ensurepath fi - pipx install poetry + pipx install poetry --python $PYTHON_BIN fi + +# Ensure poetry uses the correct python environment +poetry env use $PYTHON_BIN + poetry self add poetry-plugin-lambda-build@2.1.0 poetry-plugin-export@1.9.0 From f1a50ed5f3cdf6c31d0d7e887ce432bb15ffd4a8 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 4 Aug 2025 15:18:37 +0100 Subject: [PATCH 34/61] ELI-311: Campaign Config Data Type Changes (#269) --- .../audit/audit_models.py | 4 ++-- .../model/campaign_config.py | 10 +++++----- tests/unit/audit/test_audit_context.py | 4 ++-- tests/unit/model/test_campaign_config.py | 20 +++++++++++++++++++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/eligibility_signposting_api/audit/audit_models.py b/src/eligibility_signposting_api/audit/audit_models.py index d80409a8c..5ce4e598f 100644 --- a/src/eligibility_signposting_api/audit/audit_models.py +++ b/src/eligibility_signposting_api/audit/audit_models.py @@ -70,9 +70,9 @@ class AuditAction(CamelCaseBaseModel): class AuditCondition(CamelCaseBaseModel): campaign_id: str | None = None - campaign_version: str | None = None + campaign_version: int | None = None iteration_id: str | None = None - iteration_version: str | None = None + iteration_version: int | None = None condition_name: str | None = None status: str | None = None status_text: str | None = None diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index b7409baea..cc67fbc98 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -17,10 +17,10 @@ from pydantic import SerializationInfo CampaignName = NewType("CampaignName", str) -CampaignVersion = NewType("CampaignVersion", str) +CampaignVersion = NewType("CampaignVersion", int) CampaignID = NewType("CampaignID", str) IterationName = NewType("IterationName", str) -IterationVersion = NewType("IterationVersion", str) +IterationVersion = NewType("IterationVersion", int) IterationID = NewType("IterationID", str) IterationDate = NewType("IterationDate", date) RuleName = NewType("RuleName", str) @@ -185,9 +185,9 @@ class CampaignConfig(BaseModel): name: CampaignName = Field(..., alias="Name") type: Literal["V", "S"] = Field(..., alias="Type") target: Literal["COVID", "FLU", "MMR", "RSV"] = Field(..., alias="Target") - manager: str | None = Field(None, alias="Manager") - approver: str | None = Field(None, alias="Approver") - reviewer: str | None = Field(None, alias="Reviewer") + manager: list[str] | None = Field(None, alias="Manager") + approver: list[str] | None = Field(None, alias="Approver") + reviewer: list[str] | None = Field(None, alias="Reviewer") iteration_frequency: Literal["X", "D", "W", "M", "Q", "A"] = Field(..., alias="IterationFrequency") iteration_type: Literal["A", "M", "S", "O"] = Field(..., alias="IterationType") iteration_time: str | None = Field(None, alias="IterationTime") diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py index ccd55a5f7..40abf9c80 100644 --- a/tests/unit/audit/test_audit_context.py +++ b/tests/unit/audit/test_audit_context.py @@ -99,7 +99,7 @@ def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): ] condition_name = ConditionName("Condition1") - iteration = IterationFactory.build() + iteration = IterationFactory.build(version=12345) audit_rules = [ Reason( rule_type=RuleType.filter, @@ -119,7 +119,7 @@ def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): iteration_result = IterationResult( status=Status.actionable, cohort_results=[cohort_group_result], actions=suggested_actions ) - campaign_details = (CampaignID("CampaignID1"), CampaignVersion("CampaignVersion1")) + campaign_details = (CampaignID("CampaignID1"), CampaignVersion(123)) matched_action_detail = MatchedActionDetail(RuleName("RedirectRuleName1"), RulePriority("1"), suggested_actions) best_iteration_results = BestIterationResult( diff --git a/tests/unit/model/test_campaign_config.py b/tests/unit/model/test_campaign_config.py index 28f96d799..bab1aa102 100644 --- a/tests/unit/model/test_campaign_config.py +++ b/tests/unit/model/test_campaign_config.py @@ -92,3 +92,23 @@ def test_iteration_rule_deserialisation(rule_stop: str, expected): # Then assert_that(actual, is_iteration_rule().with_rule_stop(expected)) + + +@pytest.mark.parametrize( + ("field_name", "value"), + [ + ("manager", "manager@test.com"), + ("approver", "approver@test.com"), + ("reviewer", "reviewer@test.com"), + ], +) +def test_campaign_should_accept_list_of_strings_for_different_role_emails(field_name: str, value: str): + # Given + kwargs = {field_name: value} + + # When, Then + with pytest.raises( + ValueError, + match=rf"1 validation error for CampaignConfig\n{field_name}\n\s+Input should be a valid list.*", + ): + RawCampaignConfigFactory.build(**kwargs) From 0aa1ccede7b694d7513cdba60e1448b2bd94db38 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:57:32 +0100 Subject: [PATCH 35/61] eli-285 and eli-349 adding cloudwatch alarms for a) security and b) ops - API Gateway and Lambda execution --- .../stacks/api-layer/cloudwatch_alarms.tf | 402 ++++++++++++++++++ .../github_actions_policies.tf | 49 +++ 2 files changed, 451 insertions(+) create mode 100644 infrastructure/stacks/api-layer/cloudwatch_alarms.tf diff --git a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf new file mode 100644 index 000000000..196b85522 --- /dev/null +++ b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf @@ -0,0 +1,402 @@ +locals { + # Security alarms based on CloudTrail custom metrics + cloudwatch_alarm_config = { + UnauthorizedApiCalls = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Unauthorized API calls detected - immediate alert on any occurrence" + actions_enabled = true + } + ConsoleAuthenticationFailures = { + threshold = 3 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Multiple console authentication failures detected within 5 minutes" + actions_enabled = true + } + CloudTrailConfigChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "CloudTrail configuration changes detected - immediate alert" + actions_enabled = true + } + VPCChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "VPC configuration changes detected" + actions_enabled = true + } + AWSConfigChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "AWS Config service changes detected" + actions_enabled = true + } + ModificationOfCMKs = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "KMS Customer Managed Key modifications detected - critical security alert" + actions_enabled = true + } + UnsuccessfulSwitchRole = { + threshold = 5 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 900 + statistic = "Sum" + alarm_description = "Multiple unsuccessful role switch attempts detected within 15 minutes" + actions_enabled = true + } + ConsoleLoginNoMFA = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Console login without MFA detected - security policy violation" + actions_enabled = true + } + RootAccountUsage = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Root account usage detected - immediate critical alert" + actions_enabled = true + } + SecurityGroupChange = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Security group changes detected" + actions_enabled = true + } + RouteTableChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Route table changes detected" + actions_enabled = true + } + IAMPolicyChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "IAM policy changes detected - immediate security alert" + actions_enabled = true + } + s3BucketPolicyChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "S3 bucket policy changes detected" + actions_enabled = true + } + ChangesToNetworkGateways = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Network gateway changes detected" + actions_enabled = true + } + ChangesToNACLs = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "Network ACL changes detected" + actions_enabled = true + } + KMSKeyPolicyChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "KMS key policy changes detected - critical security alert" + actions_enabled = true + } + s3PublicAccessChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "S3 public access changes detected - potential data exposure risk" + actions_enabled = true + } + CloudWatchAlarmChanges = { + threshold = 1 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 300 + statistic = "Sum" + alarm_description = "CloudWatch alarm configuration changes detected" + actions_enabled = true + } + LambdaFunctionChanges = { + threshold = 2 + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + period = 600 + statistic = "Sum" + alarm_description = "Multiple Lambda function changes detected within 10 minutes" + actions_enabled = true + } + } + + # API Gateway alarm configuration + api_gateway_alarm_config = { + "5XXError" = { + metric_name = "5XXError" + namespace = "AWS/ApiGateway" + statistic = "Sum" + threshold = 0 + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + period = 300 + alarm_description = "API Gateway 5XX errors detected - critical server-side issues" + severity = "critical" + treat_missing_data = "notBreaching" + } + "4XXError" = { + metric_name = "4XXError" + namespace = "AWS/ApiGateway" + statistic = "Sum" + threshold = 50 # Adjust based on expected traffic + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + period = 300 + alarm_description = "High rate of API Gateway 4XX errors - client-side issues or auth problems" + severity = "high" + treat_missing_data = "notBreaching" + } + "LatencyP95" = { + metric_name = "Latency" + namespace = "AWS/ApiGateway" + statistic = "Average" # Use Average for ExtendedStatistic + extended_statistic = "p95" + threshold = 1000 + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + period = 300 + alarm_description = "API Gateway P95 latency > 1000ms - performance degradation" + severity = "high" + treat_missing_data = "notBreaching" + } + "IntegrationLatencyP95" = { + metric_name = "IntegrationLatency" + namespace = "AWS/ApiGateway" + statistic = "Average" # Use Average for ExtendedStatistic + extended_statistic = "p95" + threshold = 900 + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + period = 300 + alarm_description = "API Gateway backend (Lambda) P95 latency > 900ms - backend performance issues" + severity = "high" + treat_missing_data = "notBreaching" + } + "CountDrop" = { + metric_name = "Count" + namespace = "AWS/ApiGateway" + statistic = "Sum" + threshold = 10 # Minimum expected requests per 5min - adjust when live + comparison_operator = "LessThanThreshold" + evaluation_periods = 2 + period = 300 + alarm_description = "API Gateway request volume drop - possible outage (enable when service is live)" + severity = "high" + treat_missing_data = "breaching" # Missing data could indicate outage + actions_enabled = false # Disable until service is live + } + } + + # Lambda alarm configuration + lambda_alarm_config = { + "Errors" = { + metric_name = "Errors" + namespace = "AWS/Lambda" + statistic = "Sum" + threshold = 0 + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + period = 300 + alarm_description = "Lambda invocation errors detected - critical function failures" + severity = "critical" + treat_missing_data = "notBreaching" + } + "Throttles" = { + metric_name = "Throttles" + namespace = "AWS/Lambda" + statistic = "Sum" + threshold = 0 + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + period = 300 + alarm_description = "Lambda throttling detected - concurrency limits reached" + severity = "critical" + treat_missing_data = "notBreaching" + } + "Duration" = { + metric_name = "Duration" + namespace = "AWS/Lambda" + statistic = "Average" + threshold = 27000 # 90% of 30s timeout (adjust based on actual timeout) + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + period = 300 + alarm_description = "Lambda duration approaching timeout - function performance warning" + severity = "warning" + treat_missing_data = "notBreaching" + } + "InvocationsDrop" = { + metric_name = "Invocations" + namespace = "AWS/Lambda" + statistic = "Sum" + threshold = 5 # Minimum expected invocations per 5min - adjust when live + comparison_operator = "LessThanThreshold" + evaluation_periods = 2 + period = 300 + alarm_description = "Lambda invocation volume drop - possible outage (enable when service is live)" + severity = "high" + treat_missing_data = "breaching" # Missing data could indicate outage + actions_enabled = false # Disable until service is live + } + } +} + +# SNS Topic for CloudWatch Alarms +resource "aws_sns_topic" "cloudwatch_alarms" { + name = "cloudwatch-security-alarms" + + tags = { + Environment = var.environment + Purpose = "security-alerting" + ManagedBy = "terraform" + } +} + +# Security Alarms (CloudTrail-based) +resource "aws_cloudwatch_metric_alarm" "cloudtrail_custom_metric_alarms" { + for_each = local.cloudwatch_alarm_config + + alarm_name = "SecurityAlert-${each.key}" + alarm_description = each.value.alarm_description + actions_enabled = each.value.actions_enabled + metric_name = each.key + namespace = "security" + statistic = each.value.statistic + period = each.value.period + evaluation_periods = each.value.evaluation_periods + threshold = each.value.threshold + comparison_operator = each.value.comparison_operator + + # Treat missing data as not breaching (common for security metrics) + treat_missing_data = "notBreaching" + + # Add standard tags for organization + tags = { + Environment = "production" + AlertType = "security" + Severity = contains(["RootAccountUsage", "ModificationOfCMKs", "KMSKeyPolicyChanges", "ConsoleLoginNoMFA"], each.key) ? "critical" : "high" + ManagedBy = "terraform" + } + + alarm_actions = [aws_sns_topic.cloudwatch_alarms.arn] +} + +# API Gateway CloudWatch Alarms +resource "aws_cloudwatch_metric_alarm" "api_gateway_alarms" { + for_each = local.api_gateway_alarm_config + + alarm_name = "APIGateway-${each.key}" + alarm_description = each.value.alarm_description + actions_enabled = lookup(each.value, "actions_enabled", true) + metric_name = each.value.metric_name + namespace = each.value.namespace + statistic = lookup(each.value, "extended_statistic", null) == null ? each.value.statistic : null + extended_statistic = lookup(each.value, "extended_statistic", null) + period = each.value.period + evaluation_periods = each.value.evaluation_periods + threshold = each.value.threshold + comparison_operator = each.value.comparison_operator + treat_missing_data = each.value.treat_missing_data + + # Add dimensions for API Gateway + dimensions = { + ApiName = "eligibility-signposting-api" + } + + tags = { + Environment = var.environment + AlertType = "performance" + Service = "api-gateway" + Severity = each.value.severity + ManagedBy = "terraform" + } + + alarm_actions = [aws_sns_topic.cloudwatch_alarms.arn] +} + +# Lambda CloudWatch Alarms +resource "aws_cloudwatch_metric_alarm" "lambda_alarms" { + # checkov:skip=CKV_AWS_319: Disabling some alarms until service is live + for_each = local.lambda_alarm_config + + alarm_name = "Lambda-${each.key}" + alarm_description = each.value.alarm_description + actions_enabled = lookup(each.value, "actions_enabled", true) + metric_name = each.value.metric_name + namespace = each.value.namespace + statistic = each.value.statistic + period = each.value.period + evaluation_periods = each.value.evaluation_periods + threshold = each.value.threshold + comparison_operator = each.value.comparison_operator + treat_missing_data = each.value.treat_missing_data + + # Add dimensions for Lambda + dimensions = { + FunctionName = module.eligibility_signposting_lambda_function.aws_lambda_function_name + } + + tags = { + Environment = var.environment + AlertType = "performance" + Service = "lambda" + Severity = each.value.severity + ManagedBy = "terraform" + } + + alarm_actions = [aws_sns_topic.cloudwatch_alarms.arn] +} diff --git a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf index 1227415e7..6b69b5025 100644 --- a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf +++ b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf @@ -475,6 +475,50 @@ resource "aws_iam_policy" "firehose_readonly" { tags = merge(local.tags, { Name = "firehose-describe-access" }) } +resource "aws_iam_policy" "cloudwatch_alarms" { + name = "cloudwatch-alarms-management" + description = "Allow GitHub Actions to manage CloudWatch alarms and SNS topics" + path = "/service-policies/" + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Action = [ + # CloudWatch Alarms management + "cloudwatch:PutMetricAlarm", + "cloudwatch:DeleteAlarms", + "cloudwatch:DescribeAlarms", + "cloudwatch:DescribeAlarmsForMetric", + "cloudwatch:ListTagsForResource", + "cloudwatch:TagResource", + "cloudwatch:UntagResource", + # SNS Topic management for alarm notifications + "sns:CreateTopic", + "sns:DeleteTopic", + "sns:GetTopicAttributes", + "sns:SetTopicAttributes", + "sns:ListTopics", + "sns:ListTagsForResource", + "sns:TagResource", + "sns:UntagResource", + "sns:Subscribe", + "sns:Unsubscribe", + "sns:ListSubscriptions", + "sns:ListSubscriptionsByTopic" + ], + Resource = [ + "arn:aws:cloudwatch:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:alarm:*", + "arn:aws:sns:${var.default_aws_region}:${data.aws_caller_identity.current.account_id}:cloudwatch-security-alarms*" + ] + } + ] + }) + + tags = merge(local.tags, { Name = "cloudwatch-alarms-management" }) +} + # Attach the policies to the role resource "aws_iam_role_policy_attachment" "terraform_state" { role = aws_iam_role.github_actions.name @@ -520,3 +564,8 @@ resource "aws_iam_role_policy_attachment" "firehose_readonly_attach" { role = aws_iam_role.github_actions.name policy_arn = aws_iam_policy.firehose_readonly.arn } + +resource "aws_iam_role_policy_attachment" "cloudwatch_alarms" { + role = aws_iam_role.github_actions.name + policy_arn = aws_iam_policy.cloudwatch_alarms.arn +} From 28a295817b6dee97deb750be184304795d4260ae Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:59:56 +0100 Subject: [PATCH 36/61] eli-285 - disabling action on API calls as our internal security are triggering this --- infrastructure/stacks/api-layer/cloudwatch_alarms.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf index 196b85522..a3ed27834 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf @@ -8,7 +8,7 @@ locals { period = 300 statistic = "Sum" alarm_description = "Unauthorized API calls detected - immediate alert on any occurrence" - actions_enabled = true + actions_enabled = false # Disabling as cloudhealth role is triggering this alarm } ConsoleAuthenticationFailures = { threshold = 3 From 1ad50a965cd701dbb1f75ab236bbc07b4cb748fe Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:08:11 +0100 Subject: [PATCH 37/61] eli-285 and 349 adding kms for sns, checkov skip for disabled alarms --- .../stacks/api-layer/cloudwatch_alarms.tf | 16 ++++++ .../stacks/api-layer/iam_policies.tf | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf index a3ed27834..6ec0d2209 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf @@ -299,6 +299,8 @@ locals { resource "aws_sns_topic" "cloudwatch_alarms" { name = "cloudwatch-security-alarms" + kms_master_key_id = aws_kms_key.sns_encryption_key.id + tags = { Environment = var.environment Purpose = "security-alerting" @@ -306,8 +308,21 @@ resource "aws_sns_topic" "cloudwatch_alarms" { } } +resource "aws_kms_key" "sns_encryption_key" { + description = "KMS key for encrypting CloudWatch alarms SNS topic" + deletion_window_in_days = 7 + + tags = { + Name = "cloudwatch-alarms-sns-encryption-key" + Environment = var.environment + Purpose = "sns-encryption" + ManagedBy = "terraform" + } +} + # Security Alarms (CloudTrail-based) resource "aws_cloudwatch_metric_alarm" "cloudtrail_custom_metric_alarms" { + # checkov:skip=CKV_AWS_319: Disabling some alarms until service is live for_each = local.cloudwatch_alarm_config alarm_name = "SecurityAlert-${each.key}" @@ -337,6 +352,7 @@ resource "aws_cloudwatch_metric_alarm" "cloudtrail_custom_metric_alarms" { # API Gateway CloudWatch Alarms resource "aws_cloudwatch_metric_alarm" "api_gateway_alarms" { + # checkov:skip=CKV_AWS_319: Disabling some alarms until service is live for_each = local.api_gateway_alarm_config alarm_name = "APIGateway-${each.key}" diff --git a/infrastructure/stacks/api-layer/iam_policies.tf b/infrastructure/stacks/api-layer/iam_policies.tf index 8af65233e..5f384895c 100644 --- a/infrastructure/stacks/api-layer/iam_policies.tf +++ b/infrastructure/stacks/api-layer/iam_policies.tf @@ -358,3 +358,52 @@ resource "aws_iam_role_policy" "lambda_xray_tracing_policy" { role = aws_iam_role.eligibility_lambda_role.id policy = data.aws_iam_policy_document.lambda_xray_tracing_permissions_policy.json } + +# KMS Key Policy for SNS encryption +resource "aws_kms_key_policy" "sns_encryption_key_policy" { + key_id = aws_kms_key.sns_encryption_key.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableIAMRootPermissions" + Effect = "Allow" + Principal = { + AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" + } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowCloudWatchAlarmsAccess" + Effect = "Allow" + Principal = { + Service = "cloudwatch.amazonaws.com" + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + }, + { + Sid = "AllowSNSServiceAccess" + Effect = "Allow" + Principal = { + Service = "sns.amazonaws.com" + } + Action = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:ReEncrypt*", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + Resource = "*" + } + ] + }) +} From 03fcfd9cd66bc9e2022a8c18784b33106fe8c7bc Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Tue, 5 Aug 2025 17:11:05 +0100 Subject: [PATCH 38/61] work in progress --- .../services/processors/rule_processor.py | 15 +++++- tests/integration/conftest.py | 34 +++++++++++++ .../in_process/test_eligibility_endpoint.py | 51 +++++++++++++++++++ .../processors/test_rule_processor.py | 41 +++++++++++++++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/eligibility_signposting_api/services/processors/rule_processor.py b/src/eligibility_signposting_api/services/processors/rule_processor.py index 05addf584..2e924faca 100644 --- a/src/eligibility_signposting_api/services/processors/rule_processor.py +++ b/src/eligibility_signposting_api/services/processors/rule_processor.py @@ -78,9 +78,22 @@ def is_actionable( priority_getter = attrgetter("priority") suppression_reasons = [] - sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, suppression_rules), key=priority_getter) + sorted_rules_by_priority = sorted(suppression_rules, key=priority_getter) for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): + group_rules = list(rule_group) + cohort_specific_rules = [rule for rule in group_rules if rule.cohort_label is not None] + matching_specific_rules = [ + rule for rule in cohort_specific_rules if rule.cohort_label == cohort.cohort_label + ] + if cohort_specific_rules and not matching_specific_rules: + continue + + applicable_rules = list(self.get_exclusion_rules(cohort, group_rules)) + + if not applicable_rules: + continue + status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(person, rule_group) if status.is_exclusion: is_actionable = False diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 82059511c..0acedd6d2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -480,6 +480,40 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def campaign_config_with_and_rule(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: + campaign: CampaignConfig = rule.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule.IterationFactory.build( + iteration_rules=[ + rule.PostcodeSuppressionRuleFactory.build(cohort_label="cohort2",), + rule.PersonAgeSuppressionRuleFactory.build(), + ], + iteration_cohorts=[ + rule.IterationCohortFactory.build( + cohort_label="cohort1", + cohort_group="cohort_group1", + positive_description="positive_description", + negative_description="negative_description", + ), + rule.IterationCohortFactory.build( + cohort_label="cohort2", + cohort_group="cohort_group2", + positive_description="positive_description", + negative_description="negative_description", + ) + ], + ) + ], + ) + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + s3_client.put_object( + Bucket=rules_bucket, Key=f"{campaign.name}.json", Body=json.dumps(campaign_data), ContentType="application/json" + ) + yield campaign + s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") + @pytest.fixture(scope="class") def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index ec911c1fb..b92717c2f 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -238,6 +238,57 @@ def test_actionable( ) + def test_actionable_with_and_rule( + self, + client: FlaskClient, + persisted_person: NHSNumber, + campaign_config_with_and_rule: CampaignConfig, # noqa: ARG002 + ): + # Given + + # When + response = client.get(f"/patient-check/{persisted_person}?includeActions=Y") + + # Then + assert_that( + response, + is_response() + .with_status_code(HTTPStatus.OK) + .and_text( + is_json_that( + has_entry( + "processedSuggestions", + equal_to( + [ + { + "condition": "RSV", + "status": "Actionable", + "eligibilityCohorts": [ + { + "cohortCode": "cohort_group1", + "cohortStatus": "Actionable", + "cohortText": "positive_description", + } + ], + "actions": [ + { + "actionCode": "action_code", + "actionType": "defaultcomms", + "description": "", + "urlLabel": "", + "urlLink": "", + } + ], + "suitabilityRules": [], + "statusText": "You should have the RSV vaccine", + } + ] + ), + ) + ) + ), + ) + class TestMagicCohortResponse: def test_not_eligible_by_rule_when_only_magic_cohort_is_present( self, diff --git a/tests/unit/services/processors/test_rule_processor.py b/tests/unit/services/processors/test_rule_processor.py index b97dea167..09dabb34e 100644 --- a/tests/unit/services/processors/test_rule_processor.py +++ b/tests/unit/services/processors/test_rule_processor.py @@ -144,6 +144,47 @@ def test_evaluate_rules_priority_group_with_rule_stop(mock_rule_calculator_class assert_that(is_rule_stop, is_(True)) +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +def test_general_rule_should_not_evaluate_in_isolation_without_matching_specific_rule( + mock_evaluate_rules_priority_group, + rule_processor, +): + # Person is in COHORT_B + cohort = rule_builder.IterationCohortFactory.build( + cohort_label="COHORT_B", + positive_description="Eligible" + ) + cohort_results = {} + + # Rule 1: cohort-specific to COHORT_A — should be filtered out + rule_specific = rule_builder.IterationRuleFactory.build( + priority=510, + type=RuleType.suppression, + cohort_label="COHORT_A", + name="SPECIFIC_RULE" + ) + + # Rule 2: General rule + rule_general = rule_builder.IterationRuleFactory.build( + priority=510, + type=RuleType.suppression, + cohort_label=None, + name="GENERAL_RULE" + ) + + suppression_rules = [rule_specific, rule_general] + + # Act + rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) + + # ❌ BUG: General rule should not be evaluated in isolation. + mock_evaluate_rules_priority_group.assert_not_called() + + # Cohort remains actionable + assert_that(cohort_results["COHORT_B"].status, is_(Status.actionable)) + + + @patch.object(RuleProcessor, "evaluate_rules_priority_group") @patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 def test_is_eligible_by_filter_rules_eligible( From 4e18992efe9596f2be137327f1ec5ae793062f5c Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:13:20 +0100 Subject: [PATCH 39/61] eli-285 enable kms key rotation --- infrastructure/stacks/api-layer/cloudwatch_alarms.tf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf index 6ec0d2209..4c252a38d 100644 --- a/infrastructure/stacks/api-layer/cloudwatch_alarms.tf +++ b/infrastructure/stacks/api-layer/cloudwatch_alarms.tf @@ -311,6 +311,8 @@ resource "aws_sns_topic" "cloudwatch_alarms" { resource "aws_kms_key" "sns_encryption_key" { description = "KMS key for encrypting CloudWatch alarms SNS topic" deletion_window_in_days = 7 + enable_key_rotation = true + tags = { Name = "cloudwatch-alarms-sns-encryption-key" From fdb4f0eb4c981760dda3918ed73eb27fb1a3e81c Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:18:29 +0100 Subject: [PATCH 40/61] eli-285 get rid of false flag gitleak --- scripts/config/gitleaks.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index 175e20678..66a3d7e94 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -17,4 +17,4 @@ regexes = [ [allowlist] paths = ['''.terraform.lock.hcl''', '''poetry.lock''', '''yarn.lock'''] -stopwords = ['''dummy_key''', '''dummy_secret''', '''192.0.0.1'''] +stopwords = ['''dummy_key''', '''dummy_secret''', '''192.0.0.1''', '''prance = "^25.4.8.0"''', '''25.4.8.0'''] From 8b80c513895e4e2268c91c6fb957dcbf39d39e16 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:17:13 +0100 Subject: [PATCH 41/61] eli-388 adding access log permissions for audit buckets --- infrastructure/modules/s3/s3.tf | 45 ++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/s3/s3.tf b/infrastructure/modules/s3/s3.tf index e0138c065..8dc3c8744 100644 --- a/infrastructure/modules/s3/s3.tf +++ b/infrastructure/modules/s3/s3.tf @@ -105,6 +105,49 @@ data "aws_iam_policy_document" "access_logs_s3_bucket_policy" { variable = "aws:SecureTransport" } } + + # Allow S3 Log Delivery service to write access logs + statement { + sid = "S3ServerAccessLogsPolicy" + effect = "Allow" + principals { + type = "Service" + identifiers = ["logging.s3.amazonaws.com"] + } + actions = [ + "s3:PutObject" + ] + resources = [ + "${aws_s3_bucket.storage_bucket_access_logs.arn}/*" + ] + condition { + test = "ArnEquals" + variable = "aws:SourceArn" + values = [aws_s3_bucket.storage_bucket.arn] + } + } + + # Allow S3 Log Delivery service to check bucket location and get bucket ACL + statement { + sid = "S3ServerAccessLogsDeliveryRootAccess" + effect = "Allow" + principals { + type = "Service" + identifiers = ["logging.s3.amazonaws.com"] + } + actions = [ + "s3:GetBucketAcl", + "s3:ListBucket" + ] + resources = [ + aws_s3_bucket.storage_bucket_access_logs.arn + ] + condition { + test = "ArnEquals" + variable = "aws:SourceArn" + values = [aws_s3_bucket.storage_bucket.arn] + } + } } resource "aws_s3_bucket_server_side_encryption_configuration" "storage_bucket_access_logs_server_side_encryption_config" { @@ -112,7 +155,7 @@ resource "aws_s3_bucket_server_side_encryption_configuration" "storage_bucket_ac rule { apply_server_side_encryption_by_default { - sse_algorithm = "aws:kms" + sse_algorithm = "aws:kms" kms_master_key_id = aws_kms_key.storage_bucket_cmk.arn } } From d73dea5418ffc7a8e15c19844ed7fcc01f4aa1f2 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:20:45 +0100 Subject: [PATCH 42/61] eli-386 blocking s3 public access at account level --- infrastructure/stacks/api-layer/s3_buckets.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/stacks/api-layer/s3_buckets.tf b/infrastructure/stacks/api-layer/s3_buckets.tf index a1c554575..1c5ecb801 100644 --- a/infrastructure/stacks/api-layer/s3_buckets.tf +++ b/infrastructure/stacks/api-layer/s3_buckets.tf @@ -16,3 +16,8 @@ module "s3_audit_bucket" { stack_name = local.stack_name workspace = terraform.workspace } + +resource "aws_s3_account_public_access_block" "block_public_access" { + block_public_acls = true + block_public_policy = true +} From 81bb0716a79a111ad4dee37ee854910b42df3c2d Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 09:52:37 +0100 Subject: [PATCH 43/61] ELI-376: Audit record should log multiple F and S rules (#275) * ELI-376: Audit record should log multiple F and S rules * ELI-376: Fixing int test --- .../audit/audit_context.py | 73 +++++--- .../audit/audit_models.py | 4 +- .../calculators/eligibility_calculator.py | 76 ++++---- .../views/eligibility.py | 20 +-- tests/fixtures/builders/model/eligibility.py | 12 +- tests/integration/conftest.py | 27 ++- .../lambda/test_app_running_as_lambda.py | 62 ++++--- tests/unit/audit/test_audit_context.py | 45 ++++- .../test_eligibility_calculator.py | 112 +++--------- tests/unit/views/test_eligibility.py | 167 +----------------- 10 files changed, 238 insertions(+), 360 deletions(-) diff --git a/src/eligibility_signposting_api/audit/audit_context.py b/src/eligibility_signposting_api/audit/audit_context.py index 48f4389bf..58bb49578 100644 --- a/src/eligibility_signposting_api/audit/audit_context.py +++ b/src/eligibility_signposting_api/audit/audit_context.py @@ -24,6 +24,7 @@ ConditionName, IterationResult, MatchedActionDetail, + Reason, Status, SuggestedAction, ) @@ -63,9 +64,9 @@ def append_audit_condition( condition_name: ConditionName, best_iteration_result: BestIterationResult, action_detail: MatchedActionDetail, + cohort_results: list[CohortGroupResult], ) -> None: audit_eligibility_cohorts, audit_eligibility_cohort_groups, audit_actions = [], [], [] - audit_filter_rule, audit_suitability_rule, audit_action_rule = None, None, None best_active_iteration = best_iteration_result.active_iteration best_candidate = best_iteration_result.iteration_result best_cohort_results = best_iteration_result.cohort_results @@ -83,9 +84,15 @@ def append_audit_condition( ) ) - if result.audit_rules and best_candidate: - audit_filter_rule = AuditContext.create_audit_filter_rule(best_candidate, result) - audit_suitability_rule = AuditContext.create_audit_suitability_rule(best_candidate, result) + filter_audit_rules, suitability_audit_rules = [], [] + for result in cohort_results: + if result.status.name == Status.not_eligible.name: + filter_audit_rules.extend(result.audit_rules) + if result.status.name == Status.not_actionable.name: + suitability_audit_rules.extend(result.audit_rules) + + audit_filter_rule = AuditContext.create_audit_filter_rule(filter_audit_rules) + audit_suitability_rule = AuditContext.create_audit_suitability_rule(suitability_audit_rules) audit_action_rule = AuditContext.add_rule_name_and_priority_to_audit(best_candidate, action_detail) @@ -153,24 +160,46 @@ def create_audit_actions(suggested_actions: list[SuggestedAction] | None) -> lis return audit_actions @staticmethod - def create_audit_suitability_rule( - best_candidate: IterationResult, result: CohortGroupResult - ) -> AuditSuitabilityRule | None: - audit_suitability_rule = None - if best_candidate.status and best_candidate.status.name == Status.not_actionable.name: - audit_suitability_rule = AuditSuitabilityRule( - rule_priority=result.audit_rules[0].rule_priority, - rule_name=result.audit_rules[0].rule_name, - rule_message=result.audit_rules[0].rule_description, + def create_audit_suitability_rule(reasons: list[Reason]) -> list[AuditSuitabilityRule] | None: + unique_reasons = AuditContext.deduplicate_reasons(reasons) + + suitability_audit = [ + AuditSuitabilityRule( + rule_priority=rule.rule_priority, + rule_name=rule.rule_name, + rule_message=rule.rule_description, ) - return audit_suitability_rule + for rule in unique_reasons + ] + + return suitability_audit if suitability_audit else None @staticmethod - def create_audit_filter_rule(best_candidate: IterationResult, result: CohortGroupResult) -> AuditFilterRule | None: - audit_filter_rule = None - if best_candidate.status and best_candidate.status.name == Status.not_eligible.name: - audit_filter_rule = AuditFilterRule( - rule_priority=result.audit_rules[0].rule_priority, - rule_name=result.audit_rules[0].rule_name, - ) - return audit_filter_rule + def create_audit_filter_rule(reasons: list[Reason]) -> list[AuditFilterRule] | None: + unique_reasons = AuditContext.deduplicate_reasons(reasons) + + filter_audit = [ + AuditFilterRule(rule_priority=rule.rule_priority, rule_name=rule.rule_name) for rule in unique_reasons + ] + + return filter_audit if len(filter_audit) > 0 else None + + @staticmethod + def deduplicate_reasons(reasons: list[Reason]) -> list[Reason]: + unique_rule_codes = set() + deduplicated_reasons = [] + + for reason in reasons: + if reason.rule_name not in unique_rule_codes and reason.rule_description: + unique_rule_codes.add(reason.rule_name) + deduplicated_reasons.append( + Reason( + reason.rule_type, + reason.rule_name, + reason.rule_priority, + reason.rule_description, + reason.matcher_matched, + ) + ) + + return deduplicated_reasons diff --git a/src/eligibility_signposting_api/audit/audit_models.py b/src/eligibility_signposting_api/audit/audit_models.py index 5ce4e598f..2f1b0ee2d 100644 --- a/src/eligibility_signposting_api/audit/audit_models.py +++ b/src/eligibility_signposting_api/audit/audit_models.py @@ -78,8 +78,8 @@ class AuditCondition(CamelCaseBaseModel): status_text: str | None = None eligibility_cohorts: list[AuditEligibilityCohorts] | None = None eligibility_cohort_groups: list[AuditEligibilityCohortGroups] | None = None - filter_rules: AuditFilterRule | None = None - suitability_rules: AuditSuitabilityRule | None = None + filter_rules: list[AuditFilterRule] | None = None + suitability_rules: list[AuditSuitabilityRule] | None = None action_rule: AuditRedirectRule | None = None actions: list[AuditAction] | None = Field(default_factory=list) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 6ea60ec55..bfbcb5a2f 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -76,6 +76,7 @@ def get_the_best_cohort_memberships( def get_eligibility_status(self, include_actions: str, conditions: list[str], category: str) -> EligibilityStatus: include_actions_flag = include_actions.upper() == "Y" condition_results: dict[ConditionName, IterationResult] = {} + final_result = [] requested_grouped_campaigns = self.campaign_evaluator.get_requested_grouped_campaigns( self.campaign_configs, conditions, category @@ -93,10 +94,17 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca condition_results[condition_name] = best_iteration_result.iteration_result condition_results[condition_name].actions = matched_action_detail.actions - AuditContext.append_audit_condition(condition_name, best_iteration_result, matched_action_detail) + condition_result = self.build_condition_results(condition_results[condition_name], condition_name) + final_result.append(condition_result) + + AuditContext.append_audit_condition( + condition_name, + best_iteration_result, + matched_action_detail, + condition_results[condition_name].cohort_results, + ) # Consolidate all the results and return - final_result = self.build_condition_results(condition_results) return eligibility_status.EligibilityStatus(conditions=final_result) def get_best_iteration_result(self, campaign_group: list[CampaignConfig]) -> BestIterationResult: @@ -133,39 +141,39 @@ def get_iteration_results(self, campaign_group: list[CampaignConfig]) -> dict[It return iteration_results @staticmethod - def build_condition_results(condition_results: dict[ConditionName, IterationResult]) -> list[Condition]: - conditions: list[Condition] = [] - # iterate over conditions - for condition_name, active_iteration_result in condition_results.items(): - grouped_cohort_results = defaultdict(list) - # iterate over cohorts and group them by status and cohort_group - for cohort_result in active_iteration_result.cohort_results: - if active_iteration_result.status == cohort_result.status: - grouped_cohort_results[cohort_result.cohort_code].append(cohort_result) - - # deduplicate grouped cohort results by cohort_code - deduplicated_cohort_results = [ - CohortGroupResult( + def build_condition_results(iteration_result: IterationResult, condition_name: ConditionName) -> Condition: + grouped_cohort_results = defaultdict(list) + + for cohort_result in iteration_result.cohort_results: + if iteration_result.status == cohort_result.status: + grouped_cohort_results[cohort_result.cohort_code].append(cohort_result) + + deduplicated_cohort_results = [] + + for group_cohort_code, group in grouped_cohort_results.items(): + if group: + unique_rule_codes = set() + deduplicated_reasons = [] + for cohort in group: + for reason in cohort.reasons: + if reason.rule_name not in unique_rule_codes and reason.rule_description: + unique_rule_codes.add(reason.rule_name) + deduplicated_reasons.append(reason) + + non_empty_description = next((c.description for c in group if c.description), group[0].description) + cohort_group_result = CohortGroupResult( cohort_code=group_cohort_code, status=group[0].status, - # Flatten all reasons from the group - reasons=[reason for cohort in group for reason in cohort.reasons], - # get the first nonempty description - description=next((c.description for c in group if c.description), group[0].description), + reasons=deduplicated_reasons, + description=non_empty_description, audit_rules=[], ) - for group_cohort_code, group in grouped_cohort_results.items() - if group - ] - - # return condition with cohort results - conditions.append( - Condition( - condition_name=condition_name, - status=active_iteration_result.status, - cohort_results=list(deduplicated_cohort_results), - actions=condition_results[condition_name].actions, - status_text=active_iteration_result.status.get_status_text(condition_name), - ) - ) - return conditions + deduplicated_cohort_results.append(cohort_group_result) + + return Condition( + condition_name=condition_name, + status=iteration_result.status, + cohort_results=list(deduplicated_cohort_results), + actions=iteration_result.actions, + status_text=iteration_result.status.get_status_text(condition_name), + ) diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 802515347..7dee05144 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -162,20 +162,18 @@ def build_suitability_results(condition: Condition) -> list[eligibility_response if condition.status != Status.not_actionable: return [] - unique_rule_codes = set() suitability_results = [] for cohort_result in condition.cohort_results: if cohort_result.status == Status.not_actionable: - for reason in cohort_result.reasons: - if reason.rule_name not in unique_rule_codes and reason.rule_description: - unique_rule_codes.add(reason.rule_name) - suitability_results.append( - eligibility_response.SuitabilityRule( - ruleType=eligibility_response.RuleType(reason.rule_type.value), - ruleCode=eligibility_response.RuleCode(reason.rule_name), - ruleText=eligibility_response.RuleText(reason.rule_description), - ) - ) + suitability_results.extend( + eligibility_response.SuitabilityRule( + ruleType=eligibility_response.RuleType(reason.rule_type.value), + ruleCode=eligibility_response.RuleCode(reason.rule_name), + ruleText=eligibility_response.RuleText(reason.rule_description), + ) + for reason in cohort_result.reasons + if reason.rule_description + ) return suitability_results diff --git a/tests/fixtures/builders/model/eligibility.py b/tests/fixtures/builders/model/eligibility.py index 07f3c825c..894b61c47 100644 --- a/tests/fixtures/builders/model/eligibility.py +++ b/tests/fixtures/builders/model/eligibility.py @@ -5,7 +5,13 @@ from polyfactory.factories import DataclassFactory from eligibility_signposting_api.model import eligibility_status -from eligibility_signposting_api.model.eligibility_status import RuleType, UrlLink +from eligibility_signposting_api.model.eligibility_status import ( + RuleDescription, + RuleName, + RulePriority, + RuleType, + UrlLink, +) class SuggestedActionFactory(DataclassFactory[eligibility_status.SuggestedAction]): @@ -14,6 +20,10 @@ class SuggestedActionFactory(DataclassFactory[eligibility_status.SuggestedAction class ReasonFactory(DataclassFactory[eligibility_status.Reason]): rule_type = RuleType.filter + rule_name = RuleName("name") + rule_priority = RulePriority("1") + rule_description = RuleDescription("description") + matcher_matched = False class CohortResultFactory(DataclassFactory[eligibility_status.CohortGroupResult]): diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 82059511c..f324245db 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -17,7 +17,10 @@ from yarl import URL from eligibility_signposting_api.model import eligibility_status -from eligibility_signposting_api.model.campaign_config import CampaignConfig, RuleType +from eligibility_signposting_api.model.campaign_config import ( + CampaignConfig, + RuleType, +) from eligibility_signposting_api.repos.campaign_repo import BucketName from eligibility_signposting_api.repos.person_repo import TableName from tests.fixtures.builders.model import rule @@ -376,8 +379,8 @@ def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[e rows := person_rows_builder( nhs_number, date_of_birth=date_of_birth, - postcode="hp1", - cohorts=["cohort_label1", "cohort_label2", "cohort_label3"], + postcode="SW19", + cohorts=["cohort_label1", "cohort_label2", "cohort_label3", "cohort_label4"], icb="QE1", ).data ): @@ -488,8 +491,14 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - targets = ["RSV", "COVID", "FLU"] target_rules_map = { - targets[0]: [rule.PersonAgeSuppressionRuleFactory.build(type=RuleType.filter)], - targets[1]: [rule.PersonAgeSuppressionRuleFactory.build()], + targets[0]: [ + rule.PersonAgeSuppressionRuleFactory.build(type=RuleType.filter, description="TOO YOUNG"), + rule.PostcodeSuppressionRuleFactory.build(type=RuleType.filter, priority=8, cohort_label="cohort_label4"), + ], + targets[1]: [ + rule.PersonAgeSuppressionRuleFactory.build(description="TOO YOUNG"), + rule.PostcodeSuppressionRuleFactory.build(priority=12, cohort_label="cohort_label2"), + ], targets[2]: [rule.ICBRedirectRuleFactory.build()], } @@ -507,7 +516,13 @@ def multiple_campaign_configs(s3_client: BaseClient, rules_bucket: BucketName) - cohort_group=f"cohort_group{i + 1}", positive_description=f"positive_desc_{i + 1}", negative_description=f"negative_desc_{i + 1}", - ) + ), + rule.IterationCohortFactory.build( + cohort_label="cohort_label4", + cohort_group="cohort_group4", + positive_description="positive_desc_4", + negative_description="negative_desc_4", + ), ], ) ], diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index 6894e1e53..b4cb61744 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -242,11 +242,13 @@ def test_given_nhs_number_in_path_matches_with_nhs_number_in_headers_and_check_i } ], "filterRules": None, - "suitabilityRules": { - "rulePriority": "10", - "ruleName": "Exclude too young less than 75", - "ruleMessage": "Exclude too young less than 75", - }, + "suitabilityRules": [ + { + "rulePriority": "10", + "ruleName": "Exclude too young less than 75", + "ruleMessage": "Exclude too young less than 75", + } + ], "actionRule": None, "actions": [], } @@ -459,15 +461,18 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # "conditionName": rsv_campaign.target, "status": "not_eligible", "statusText": "We do not believe you can have it", - "eligibilityCohorts": [{"cohortCode": "cohort_label1", "cohortStatus": "not_eligible"}], + "eligibilityCohorts": [ + {"cohortCode": "cohort_label1", "cohortStatus": "not_eligible"}, + {"cohortCode": "cohort_label4", "cohortStatus": "not_eligible"}, + ], "eligibilityCohortGroups": [ - { - "cohortCode": "cohort_group1", - "cohortText": "negative_desc_1", - "cohortStatus": "not_eligible", - } + {"cohortCode": "cohort_group1", "cohortText": "negative_desc_1", "cohortStatus": "not_eligible"}, + {"cohortCode": "cohort_group4", "cohortText": "negative_desc_4", "cohortStatus": "not_eligible"}, + ], + "filterRules": [ + {"rulePriority": "10", "ruleName": "Exclude too young less than 75"}, + {"rulePriority": "8", "ruleName": "Excluded postcode In SW19"}, ], - "filterRules": {"rulePriority": "10", "ruleName": "Exclude too young less than 75"}, "suitabilityRules": None, "actionRule": None, "actions": [], @@ -480,20 +485,19 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # "conditionName": covid_campaign.target, "status": "not_actionable", "statusText": f"You should have the {covid_campaign.target} vaccine", - "eligibilityCohorts": [{"cohortCode": "cohort_label2", "cohortStatus": "not_actionable"}], + "eligibilityCohorts": [ + {"cohortCode": "cohort_label2", "cohortStatus": "not_actionable"}, + {"cohortCode": "cohort_label4", "cohortStatus": "not_actionable"}, + ], "eligibilityCohortGroups": [ - { - "cohortCode": "cohort_group2", - "cohortText": "positive_desc_2", - "cohortStatus": "not_actionable", - } + {"cohortCode": "cohort_group2", "cohortText": "positive_desc_2", "cohortStatus": "not_actionable"}, + {"cohortCode": "cohort_group4", "cohortText": "positive_desc_4", "cohortStatus": "not_actionable"}, ], "filterRules": None, - "suitabilityRules": { - "rulePriority": "10", - "ruleName": "Exclude too young less than 75", - "ruleMessage": "Exclude too young less than 75", - }, + "suitabilityRules": [ + {"rulePriority": "10", "ruleName": "Exclude too young less than 75", "ruleMessage": "TOO YOUNG"}, + {"rulePriority": "12", "ruleName": "Excluded postcode In SW19", "ruleMessage": "In SW19"}, + ], "actionRule": None, "actions": [], }, @@ -505,13 +509,13 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # "conditionName": flu_campaign.target, "status": "actionable", "statusText": f"You should have the {flu_campaign.target} vaccine", - "eligibilityCohorts": [{"cohortCode": "cohort_label3", "cohortStatus": "actionable"}], + "eligibilityCohorts": [ + {"cohortCode": "cohort_label3", "cohortStatus": "actionable"}, + {"cohortCode": "cohort_label4", "cohortStatus": "actionable"}, + ], "eligibilityCohortGroups": [ - { - "cohortCode": "cohort_group3", - "cohortText": "positive_desc_3", - "cohortStatus": "actionable", - } + {"cohortCode": "cohort_group3", "cohortText": "positive_desc_3", "cohortStatus": "actionable"}, + {"cohortCode": "cohort_group4", "cohortText": "positive_desc_4", "cohortStatus": "actionable"}, ], "filterRules": None, "suitabilityRules": None, diff --git a/tests/unit/audit/test_audit_context.py b/tests/unit/audit/test_audit_context.py index 40abf9c80..05d78bd8e 100644 --- a/tests/unit/audit/test_audit_context.py +++ b/tests/unit/audit/test_audit_context.py @@ -129,7 +129,9 @@ def test_append_audit_condition_adds_condition_to_audit_log_on_g(app): with app.app_context(): g.audit_log = AuditEvent() - AuditContext.append_audit_condition(condition_name, best_iteration_results, matched_action_detail) + AuditContext.append_audit_condition( + condition_name, best_iteration_results, matched_action_detail, [cohort_group_result] + ) expected_audit_action = [ AuditAction( @@ -191,3 +193,44 @@ def test_write_to_firehose_calls_audit_service_with_correct_data_from_g(app): assert g.audit_log.response.last_updated == last_updated mock_audit_service.audit.assert_called_once_with(g.audit_log.model_dump(by_alias=True)) + + +def test_no_duplicates_returns_same_list(): + reasons = [ + Reason(RuleType("F"), RuleName("code1"), RulePriority("1"), RuleDescription("desc1"), matcher_matched=True), + Reason(RuleType("S"), RuleName("code2"), RulePriority("2"), RuleDescription("desc2"), matcher_matched=False), + ] + expected = reasons + assert AuditContext.deduplicate_reasons(reasons) == expected + + +def test_duplicates_are_removed(): + reasons = [ + Reason(RuleType("F"), RuleName("code1"), RulePriority("1"), RuleDescription("desc1"), matcher_matched=True), + Reason(RuleType("S"), RuleName("code1"), RulePriority("2"), RuleDescription("desc2"), matcher_matched=False), + Reason(RuleType("R"), RuleName("code3"), RulePriority("3"), RuleDescription("desc3"), matcher_matched=True), + ] + expected = [ + Reason(RuleType("F"), RuleName("code1"), RulePriority("1"), RuleDescription("desc1"), matcher_matched=True), + Reason(RuleType("R"), RuleName("code3"), RulePriority("3"), RuleDescription("desc3"), matcher_matched=True), + ] + assert AuditContext.deduplicate_reasons(reasons) == expected + + +def test_empty_list_returns_empty_list(): + reasons = [] + expected = [] + assert AuditContext.deduplicate_reasons(reasons) == expected + + +def test_reasons_with_no_description_are_filtered_out(): + reasons = [ + Reason(RuleType("F"), RuleName("code1"), RulePriority("1"), RuleDescription("desc1"), matcher_matched=True), + Reason(RuleType("S"), RuleName("code2"), RulePriority("2"), None, matcher_matched=False), + Reason(RuleType("R"), RuleName("code3"), RulePriority("3"), RuleDescription("desc3"), matcher_matched=True), + ] + expected = [ + Reason(RuleType("F"), RuleName("code1"), RulePriority("1"), RuleDescription("desc1"), matcher_matched=True), + Reason(RuleType("R"), RuleName("code3"), RulePriority("3"), RuleDescription("desc3"), matcher_matched=True), + ] + assert AuditContext.deduplicate_reasons(reasons) == expected diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index b1da841ff..60f90d841 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -601,11 +601,6 @@ def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_h class TestEligibilityResultBuilder: - def test_build_condition_results_empty_input(self): - condition_results = {} - result = EligibilityCalculator.build_condition_results(condition_results) - assert_that(result, is_([])) - def test_build_condition_results_single_condition_single_cohort_actionable(self): cohort_group_results = [CohortGroupResult("COHORT_A", Status.actionable, [], "Cohort A Description", [])] suggested_actions = [ @@ -620,18 +615,15 @@ def test_build_condition_results_single_condition_single_cohort_actionable(self) ] iteration_result = IterationResult(Status.actionable, cohort_group_results, suggested_actions) - condition_results = {ConditionName("RSV"): iteration_result} - - result = EligibilityCalculator.build_condition_results(condition_results) + result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) - assert_that(len(result), is_(1)) - assert_that(result[0].condition_name, is_(ConditionName("RSV"))) - assert_that(result[0].status, is_(Status.actionable)) - assert_that(result[0].actions, is_(suggested_actions)) - assert_that(result[0].status_text, is_(Status.actionable.get_status_text(ConditionName("RSV")))) + assert_that(result.condition_name, is_(ConditionName("RSV"))) + assert_that(result.status, is_(Status.actionable)) + assert_that(result.actions, is_(suggested_actions)) + assert_that(result.status_text, is_(Status.actionable.get_status_text(ConditionName("RSV")))) - assert_that(len(result[0].cohort_results), is_(1)) - deduplicated_cohort = result[0].cohort_results[0] + assert_that(len(result.cohort_results), is_(1)) + deduplicated_cohort = result.cohort_results[0] assert_that(deduplicated_cohort.cohort_code, is_("COHORT_A")) assert_that(deduplicated_cohort.status, is_(Status.actionable)) assert_that(deduplicated_cohort.reasons, is_([])) @@ -652,18 +644,15 @@ def test_build_condition_results_single_condition_single_cohort_not_eligible_wit ] iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) - condition_results = {ConditionName("RSV"): iteration_result} + result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) - result = EligibilityCalculator.build_condition_results(condition_results) + assert_that(result.condition_name, is_(ConditionName("RSV"))) + assert_that(result.status, is_(Status.not_eligible)) + assert_that(result.actions, is_(suggested_actions)) + assert_that(result.status_text, is_(Status.not_eligible.get_status_text(ConditionName("RSV")))) - assert_that(len(result), is_(1)) - assert_that(result[0].condition_name, is_(ConditionName("RSV"))) - assert_that(result[0].status, is_(Status.not_eligible)) - assert_that(result[0].actions, is_(suggested_actions)) - assert_that(result[0].status_text, is_(Status.not_eligible.get_status_text(ConditionName("RSV")))) - - assert_that(len(result[0].cohort_results), is_(1)) - deduplicated_cohort = result[0].cohort_results[0] + assert_that(len(result.cohort_results), is_(1)) + deduplicated_cohort = result.cohort_results[0] assert_that(deduplicated_cohort.cohort_code, is_("COHORT_A")) assert_that(deduplicated_cohort.status, is_(Status.not_eligible)) assert_that(deduplicated_cohort.reasons, is_([])) @@ -703,15 +692,11 @@ def test_build_condition_results_single_condition_multiple_cohorts_same_cohort_c ] iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) - condition_results = {ConditionName("RSV"): iteration_result} - - result = EligibilityCalculator.build_condition_results(condition_results) + result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) - assert_that(len(result), is_(1)) - condition = result[0] - assert_that(len(condition.cohort_results), is_(1)) + assert_that(len(result.cohort_results), is_(1)) - deduplicated_cohort = condition.cohort_results[0] + deduplicated_cohort = result.cohort_results[0] assert_that(deduplicated_cohort.cohort_code, is_("COHORT_A")) assert_that(deduplicated_cohort.status, is_(Status.not_eligible)) assert_that(deduplicated_cohort.reasons, contains_inanyorder(reason_1, reason_2)) @@ -749,19 +734,15 @@ def test_build_condition_results_multiple_cohorts_different_cohort_code_same_sta ] iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) - condition_results = {ConditionName("RSV"): iteration_result} + result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) - result = EligibilityCalculator.build_condition_results(condition_results) - - assert_that(len(result), is_(1)) - condition = result[0] - assert_that(len(condition.cohort_results), is_(2)) + assert_that(len(result.cohort_results), is_(2)) expected_deduplicated_cohorts = [ CohortGroupResult("COHORT_X", Status.not_eligible, [reason_1], "Cohort X Description", []), CohortGroupResult("COHORT_Y", Status.not_eligible, [reason_2], "Cohort Y Description", []), ] - assert_that(condition.cohort_results, contains_inanyorder(*expected_deduplicated_cohorts)) + assert_that(result.cohort_results, contains_inanyorder(*expected_deduplicated_cohorts)) def test_build_condition_results_cohorts_status_not_matching_iteration_status(self): reason_1 = Reason( @@ -785,53 +766,8 @@ def test_build_condition_results_cohorts_status_not_matching_iteration_status(se iteration_result = IterationResult(Status.not_eligible, cohort_group_results, []) - condition_results = {ConditionName("RSV"): iteration_result} - - result = EligibilityCalculator.build_condition_results(condition_results) - - assert_that(len(result), is_(1)) - condition = result[0] - assert_that(len(condition.cohort_results), is_(1)) - assert_that(condition.cohort_results[0].cohort_code, is_("COHORT_X")) - assert_that(condition.cohort_results[0].status, is_(Status.not_eligible)) - - def test_build_condition_results_multiple_conditions(self): - reason_1 = Reason( - RuleType.filter, - eligibility_status.RuleName("Filter Rule 1"), - RulePriority("1"), - RuleDescription("Filter Rule Description 2"), - matcher_matched=True, - ) - reason_2 = Reason( - RuleType.filter, - eligibility_status.RuleName("Filter Rule 2"), - RulePriority("2"), - RuleDescription("Filter Rule Description 2"), - matcher_matched=True, - ) - cohort_group_result1 = [CohortGroupResult("RSV_COHORT", Status.not_eligible, [reason_1], "RSV Desc", [])] - cohort_group_result2 = [CohortGroupResult("COVID_COHORT", Status.not_actionable, [reason_2], "Covid Desc", [])] - - iteration_result1 = IterationResult(Status.not_eligible, cohort_group_result1, []) - - iteration_result2 = IterationResult(Status.not_actionable, cohort_group_result2, []) - - condition_results = { - ConditionName("RSV"): iteration_result1, - ConditionName("COVID"): iteration_result2, - } - - result = EligibilityCalculator.build_condition_results(condition_results) - - rsv = next((c for c in result if c.condition_name == ConditionName("RSV")), None) - assert_that(rsv.status, is_(Status.not_eligible)) - assert_that(len(rsv.cohort_results), is_(1)) - assert_that(rsv.cohort_results[0].cohort_code, is_("RSV_COHORT")) - assert_that(rsv.cohort_results[0].reasons, is_([reason_1])) + result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) - covid = next((c for c in result if c.condition_name == ConditionName("COVID")), None) - assert_that(covid.status, is_(Status.not_actionable)) - assert_that(len(covid.cohort_results), is_(1)) - assert_that(covid.cohort_results[0].cohort_code, is_("COVID_COHORT")) - assert_that(covid.cohort_results[0].reasons, is_([reason_2])) + assert_that(len(result.cohort_results), is_(1)) + assert_that(result.cohort_results[0].cohort_code, is_("COHORT_X")) + assert_that(result.cohort_results[0].status, is_(Status.not_eligible)) diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 963b1a9ab..04d223236 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -22,11 +22,6 @@ Condition, EligibilityStatus, NHSNumber, - Reason, - RuleDescription, - RuleName, - RulePriority, - RuleType, Status, SuggestedAction, UrlLabel, @@ -45,7 +40,7 @@ ConditionFactory, EligibilityStatusFactory, ) -from tests.fixtures.matchers.eligibility import is_eligibility_cohort, is_suitability_rule +from tests.fixtures.matchers.eligibility import is_eligibility_cohort logger = logging.getLogger(__name__) @@ -253,166 +248,6 @@ def test_build_eligibility_cohorts_results_consider_only_cohorts_groups_that_has ) -def test_build_suitability_results_with_deduplication(): - condition: Condition = ConditionFactory.build( - status=Status.not_actionable, - cohort_results=[ - CohortResultFactory.build( - cohort_code="cohort_group1", - status=Status.not_actionable, - reasons=[ - Reason( - rule_type=RuleType.suppression, - rule_name=RuleName("Exclude too young less than 75"), - rule_description=RuleDescription("your age is greater than 75"), - matcher_matched=False, - rule_priority=RulePriority(1), - ), - Reason( - rule_type=RuleType.suppression, - rule_name=RuleName("Exclude too young less than 75"), - rule_description=RuleDescription("your age is greater than 75"), - matcher_matched=False, - rule_priority=RulePriority(1), - ), - Reason( - rule_type=RuleType.suppression, - rule_name=RuleName("Exclude more than 100"), - rule_description=RuleDescription("your age is greater than 100"), - matcher_matched=False, - rule_priority=RulePriority(1), - ), - ], - ), - CohortResultFactory.build( - cohort_code="cohort_group2", - status=Status.not_actionable, - reasons=[ - Reason( - rule_type=RuleType.suppression, - rule_name=RuleName("Exclude too young less than 75"), - rule_description=RuleDescription("your age is greater than 75"), - matcher_matched=False, - rule_priority=RulePriority(1), - ) - ], - ), - CohortResultFactory.build( - cohort_code="cohort_group3", - status=Status.not_eligible, - reasons=[ - Reason( - rule_type=RuleType.filter, - rule_name=RuleName("Exclude is present in sw1"), - rule_description=RuleDescription("your a member of sw1"), - matcher_matched=False, - rule_priority=RulePriority(1), - ) - ], - ), - CohortResultFactory.build( - cohort_code="cohort_group4", - description="", - status=Status.not_actionable, - reasons=[ - Reason( - rule_type=RuleType.filter, - rule_name=RuleName("Already vaccinated"), - rule_description=RuleDescription("you have already vaccinated"), - matcher_matched=False, - rule_priority=RulePriority(1), - ) - ], - ), - ], - ) - - results = build_suitability_results(condition) - - assert_that( - results, - contains_exactly( - is_suitability_rule() - .with_rule_code("Exclude too young less than 75") - .and_rule_text("your age is greater than 75"), - is_suitability_rule().with_rule_code("Exclude more than 100").and_rule_text("your age is greater than 100"), - is_suitability_rule().with_rule_code("Already vaccinated").and_rule_text("you have already vaccinated"), - ), - ) - - -def test_build_suitability_results_when_rule_text_is_empty_or_null(): - condition: Condition = ConditionFactory.build( - status=Status.not_actionable, - cohort_results=[ - CohortResultFactory.build( - cohort_code="cohort_group1", - status=Status.not_actionable, - reasons=[ - Reason( - rule_type=RuleType.suppression, - rule_name=RuleName("Exclude too young less than 75"), - rule_description=RuleDescription("your age is greater than 75"), - matcher_matched=False, - rule_priority=RulePriority(1), - ), - Reason( - rule_type=RuleType.suppression, - rule_name=RuleName("Exclude more than 100"), - rule_description=RuleDescription(""), - matcher_matched=False, - rule_priority=RulePriority(1), - ), - Reason( - rule_type=RuleType.suppression, - rule_name=RuleName("Exclude more than 100"), - matcher_matched=False, - rule_description=None, - rule_priority=RulePriority(1), - ), - ], - ), - CohortResultFactory.build( - cohort_code="cohort_group2", - status=Status.not_actionable, - reasons=[ - Reason( - rule_type=RuleType.filter, - rule_name=RuleName("Exclude is present in sw1"), - rule_description=RuleDescription(""), - matcher_matched=False, - rule_priority=RulePriority(1), - ) - ], - ), - CohortResultFactory.build( - cohort_code="cohort_group3", - status=Status.not_actionable, - reasons=[ - Reason( - rule_type=RuleType.filter, - rule_name=RuleName("Exclude is present in sw1"), - rule_description=None, - matcher_matched=False, - rule_priority=RulePriority(1), - ) - ], - ), - ], - ) - - results = build_suitability_results(condition) - - assert_that( - results, - contains_exactly( - is_suitability_rule() - .with_rule_code("Exclude too young less than 75") - .and_rule_text("your age is greater than 75") - ), - ) - - def test_no_suitability_rules_for_actionable(): condition = ConditionFactory.build(status=Status.actionable, cohort_results=[]) From 570765abbe0c555a08299526235da4e4a5a0e448 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:16:51 +0100 Subject: [PATCH 44/61] ELI-150: campaign config validation (#264) * validations - wip * iteration validation * iteration rules * campaign config validation * made BUC tests bit more clear * Renaming for clarity. * lint and formatting fixes. * wip * Integration Rules Test * Actions mapper validator * Iterations BUC * available_actions tests * lint fixes * lint fixes * Bump asgiref from 3.8.1 to 3.9.1 Bumps [asgiref](https://github.com/django/asgiref) from 3.8.1 to 3.9.1. - [Changelog](https://github.com/django/asgiref/blob/main/CHANGELOG.txt) - [Commits](https://github.com/django/asgiref/compare/3.8.1...3.9.1) --- updated-dependencies: - dependency-name: asgiref dependency-version: 3.9.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump gitpython from 3.1.44 to 3.1.45 Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.44 to 3.1.45. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.44...3.1.45) --- updated-dependencies: - dependency-name: gitpython dependency-version: 3.1.45 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Bump pyright from 1.1.402 to 1.1.403 Bumps [pyright](https://github.com/RobertCraigie/pyright-python) from 1.1.402 to 1.1.403. - [Release notes](https://github.com/RobertCraigie/pyright-python/releases) - [Commits](https://github.com/RobertCraigie/pyright-python/compare/v1.1.402...v1.1.403) --- updated-dependencies: - dependency-name: pyright dependency-version: 1.1.403 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * ELI-351: Moves/deletes tests after refactoring (#265) * ELI-351: Moves/deletes tests after refactoring * ELI-351: Extracts EligibilityResultBuilder and adds tests * ELI-351: De-extracts EligibilityResultBuilder and moves tests to Eligibility Calculator tests * ELI-351: Removes duplicated tests * ELI-351: Removes duplicated tests #2 * ELI-351: Adds validation and audit layer to Readme * wip - has failing tests * test fixed and lint error fixed * warning fixed * rules validation added * test commit * tests updated w.r.t to datatype changes from main * updated output message * arguments added to app.py * sonar fix * sonar fix * sonar fix --------- Signed-off-by: dependabot[bot] Co-authored-by: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Shweta <216860557+shweta-nhs@users.noreply.github.com> --- src/rules_validation_api/README.md | 26 ++ src/rules_validation_api/__init__.py | 0 src/rules_validation_api/app.py | 29 +++ .../validators/__init__.py | 0 .../validators/actions_mapper_validator.py | 13 + .../validators/available_action_validator.py | 5 + .../validators/campaign_config_validator.py | 11 + .../validators/iteration_rules_validator.py | 5 + .../validators/iteration_validator.py | 37 +++ .../validators/rules_validator.py | 11 + tests/test_data/test_config/test_config.json | 10 +- tests/unit/validation/__init__.py | 0 tests/unit/validation/conftest.py | 69 +++++ .../test_actions_mapper_validator.py | 49 ++++ .../test_available_action_validator.py | 55 ++++ .../test_campaign_config_validator.py | 235 ++++++++++++++++++ .../test_iteration_rules_validator.py | 223 +++++++++++++++++ .../validation/test_iteration_validator.py | 192 ++++++++++++++ tests/unit/validation/test_rule_validator.py | 17 ++ 19 files changed, 982 insertions(+), 5 deletions(-) create mode 100644 src/rules_validation_api/README.md create mode 100644 src/rules_validation_api/__init__.py create mode 100644 src/rules_validation_api/app.py create mode 100644 src/rules_validation_api/validators/__init__.py create mode 100644 src/rules_validation_api/validators/actions_mapper_validator.py create mode 100644 src/rules_validation_api/validators/available_action_validator.py create mode 100644 src/rules_validation_api/validators/campaign_config_validator.py create mode 100644 src/rules_validation_api/validators/iteration_rules_validator.py create mode 100644 src/rules_validation_api/validators/iteration_validator.py create mode 100644 src/rules_validation_api/validators/rules_validator.py create mode 100644 tests/unit/validation/__init__.py create mode 100644 tests/unit/validation/conftest.py create mode 100644 tests/unit/validation/test_actions_mapper_validator.py create mode 100644 tests/unit/validation/test_available_action_validator.py create mode 100644 tests/unit/validation/test_campaign_config_validator.py create mode 100644 tests/unit/validation/test_iteration_rules_validator.py create mode 100644 tests/unit/validation/test_iteration_validator.py create mode 100644 tests/unit/validation/test_rule_validator.py diff --git a/src/rules_validation_api/README.md b/src/rules_validation_api/README.md new file mode 100644 index 000000000..68d314d1a --- /dev/null +++ b/src/rules_validation_api/README.md @@ -0,0 +1,26 @@ +# 🧪 Campaign-config Validation + +This Python script is designed to validate a campaign configuration JSON file. + +## 🛠 Requirements + +- Python 3.13 +- `rules_validation_api` must be installed and accessible +- Campaign configuration JSON file to verify + +## Steps to verify + +- Get to the `rules_validation_api` folder +- Run `python app.py --config_path ` + +## Results + +- `On success`: + + ```text + "Valid config" is printed + +- `On Failure`: + + ```text + "Errors" is printed diff --git a/src/rules_validation_api/__init__.py b/src/rules_validation_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/rules_validation_api/app.py b/src/rules_validation_api/app.py new file mode 100644 index 000000000..feb9464ba --- /dev/null +++ b/src/rules_validation_api/app.py @@ -0,0 +1,29 @@ +import argparse +import json +import sys +from pathlib import Path + +from rules_validation_api.validators.rules_validator import RulesValidation + +GREEN = "\033[92m" # pragma: no cover +RESET = "\033[0m" # pragma: no cover +YELLOW = "\033[93m" # pragma: no cover +RED = "\033[91m" # pragma: no cover + + +def main() -> None: # pragma: no cover + parser = argparse.ArgumentParser(description="Validate campaign configuration.") + parser.add_argument("--config_path", required=True, help="Path to the campaign config JSON file") + args = parser.parse_args() + + try: + with Path(args.config_path).open() as file: + json_data = json.load(file) + RulesValidation(**json_data) + sys.stdout.write(f"{GREEN}Valid Config{RESET}\n") + except ValueError as e: + sys.stderr.write(f"{YELLOW}Validation Error:{RESET} {RED}{e}{RESET}\n") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/rules_validation_api/validators/__init__.py b/src/rules_validation_api/validators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/rules_validation_api/validators/actions_mapper_validator.py b/src/rules_validation_api/validators/actions_mapper_validator.py new file mode 100644 index 000000000..a6eafaf87 --- /dev/null +++ b/src/rules_validation_api/validators/actions_mapper_validator.py @@ -0,0 +1,13 @@ +from pydantic import model_validator + +from eligibility_signposting_api.model.campaign_config import ActionsMapper + + +class ActionsMapperValidator(ActionsMapper): + @model_validator(mode="after") + def validate_keys(self) -> "ActionsMapperValidator": + invalid_keys = [key for key in self.root if key is None or key == ""] + if invalid_keys: + msg = f"Invalid keys found in ActionsMapper: {invalid_keys}" + raise ValueError(msg) + return self diff --git a/src/rules_validation_api/validators/available_action_validator.py b/src/rules_validation_api/validators/available_action_validator.py new file mode 100644 index 000000000..c8bf788d4 --- /dev/null +++ b/src/rules_validation_api/validators/available_action_validator.py @@ -0,0 +1,5 @@ +from eligibility_signposting_api.model.campaign_config import AvailableAction + + +class AvailableActionValidation(AvailableAction): + pass diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py new file mode 100644 index 000000000..f25d740ff --- /dev/null +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -0,0 +1,11 @@ +from pydantic import field_validator + +from eligibility_signposting_api.model.campaign_config import CampaignConfig, Iteration +from rules_validation_api.validators.iteration_validator import IterationValidation + + +class CampaignConfigValidation(CampaignConfig): + @classmethod + @field_validator("iterations") + def validate_iterations(cls, iterations: list[Iteration]) -> list[IterationValidation]: + return [IterationValidation(**i.model_dump()) for i in iterations] diff --git a/src/rules_validation_api/validators/iteration_rules_validator.py b/src/rules_validation_api/validators/iteration_rules_validator.py new file mode 100644 index 000000000..95d8dce66 --- /dev/null +++ b/src/rules_validation_api/validators/iteration_rules_validator.py @@ -0,0 +1,5 @@ +from eligibility_signposting_api.model.campaign_config import IterationRule + + +class IterationRuleValidation(IterationRule): + pass diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py new file mode 100644 index 000000000..3bfd3fec5 --- /dev/null +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -0,0 +1,37 @@ +import typing + +from pydantic import ValidationError, field_validator, model_validator +from pydantic_core import InitErrorDetails + +from eligibility_signposting_api.model.campaign_config import ActionsMapper, Iteration, IterationRule +from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator +from rules_validation_api.validators.iteration_rules_validator import IterationRuleValidation + + +class IterationValidation(Iteration): + @classmethod + @field_validator("iteration_rules") + def validate_iterations(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: + return [IterationRuleValidation(**i.model_dump()) for i in iteration_rules] + + @classmethod + @field_validator("actions_mapper", mode="after") + def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper: + ActionsMapperValidator.model_validate(action_mapper.model_dump()) + return action_mapper + + @model_validator(mode="after") + def validate_default_comms_routing_in_actions_mapper(self) -> typing.Self: + default_routing = self.default_comms_routing + actions_mapper = self.actions_mapper.root.keys() + + if default_routing and (not actions_mapper or default_routing not in actions_mapper): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_mapper, + ctx={"error": f"Missing entry for DefaultCommsRouting '{default_routing}' in ActionsMapper"}, + ) + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=[error]) + + return self diff --git a/src/rules_validation_api/validators/rules_validator.py b/src/rules_validation_api/validators/rules_validator.py new file mode 100644 index 000000000..8d52a545b --- /dev/null +++ b/src/rules_validation_api/validators/rules_validator.py @@ -0,0 +1,11 @@ +from pydantic import field_validator + +from eligibility_signposting_api.model.campaign_config import CampaignConfig, Rules +from rules_validation_api.validators.campaign_config_validator import CampaignConfigValidation + + +class RulesValidation(Rules): + @classmethod + @field_validator("campaign_config") + def validate_campaign_config(cls, campaign_config: CampaignConfig) -> CampaignConfig: + return CampaignConfigValidation(**campaign_config.model_dump()) diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index 643cbce2a..fe7b41ed6 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -5,9 +5,9 @@ "Name": "Test Config", "Type": "V", "Target": "RSV", - "Manager": "person@test.com", - "Approver": "person@test.com", - "Reviewer": "person@test.com", + "Manager": ["person@test.com"], + "Approver": ["person@test.com"], + "Reviewer": ["person@test.com"], "IterationFrequency": "X", "IterationType": "M", "IterationTime": "07:00:00", @@ -20,8 +20,8 @@ "DefaultNotEligibleRouting": "INTERNALCONTACTGP1", "ActionsMapper": { "INTERNALCONTACTGP1": {"ExternalRoutingCode": "CONTACTGP","ActionDescription":"Contact GP Text1 description", "ActionType":"text1"}, - "INTERNALCONTACTGP2": {"ExternalRoutingCode": "CONTACTGP","ActionDescription":"Contact GP Link description", "ActionType":"link", "UrlLink": "link123", "UrlLabel": "link label"}, - "INTERNALTESCO": {"ExternalRoutingCode": "TESCO","ActionDescription":"Tesco description", "ActionType":"link", "UrlLink": "tesco link", "UrlLabel": "link label"}, + "INTERNALCONTACTGP2": {"ExternalRoutingCode": "CONTACTGP","ActionDescription":"Contact GP Link description", "ActionType":"link", "UrlLink": "https://www.link123.example", "UrlLabel": "link label"}, + "INTERNALTESCO": {"ExternalRoutingCode": "TESCO","ActionDescription":"Tesco description", "ActionType":"link", "UrlLink": "https://www.tesco_link.example", "UrlLabel": "link label"}, "INTERNALFINDWALKIN": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"}, "XRULEID1": {"ExternalRoutingCode": "FINDWALKIN","ActionDescription":"Find walkin description", "ActionType":"button"}, diff --git a/tests/unit/validation/__init__.py b/tests/unit/validation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/validation/conftest.py b/tests/unit/validation/conftest.py new file mode 100644 index 000000000..efd625215 --- /dev/null +++ b/tests/unit/validation/conftest.py @@ -0,0 +1,69 @@ +import pytest + + +@pytest.fixture +def valid_campaign_config_with_only_mandatory_fields(): + return { + "ID": "CAMP001", + "Version": 1, + "Name": "Spring Campaign", + "Type": "V", + "Target": "COVID", + "IterationFrequency": "M", + "IterationType": "A", + "StartDate": "20250101", + "EndDate": "20250331", + "Iterations": [ + { + "ID": "ITER001", + "Version": 1, + "Name": "Mid-January Push", + "IterationDate": "20250101", + "IterationNumber": 1, + "ApprovalMinimum": 10, + "ApprovalMaximum": 100, + "Type": "A", + "DefaultCommsRouting": "BOOK_NBS", + "DefaultNotEligibleRouting": "RouteB", + "DefaultNotActionableRouting": "RouteC", + "IterationCohorts": [], + "IterationRules": [], + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + }, + } + ], + } + + +@pytest.fixture +def valid_iteration_rule_with_only_mandatory_fields(): + return { + "Type": "F", + "Name": "Assure only already vaccinated taken from magic cohort", + "Description": "Exclude anyone who has NOT been given a dose of RSV Vaccination from the magic cohort", + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100, + } + + +@pytest.fixture +def valid_available_action(): + return { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } diff --git a/tests/unit/validation/test_actions_mapper_validator.py b/tests/unit/validation/test_actions_mapper_validator.py new file mode 100644 index 000000000..e14989e90 --- /dev/null +++ b/tests/unit/validation/test_actions_mapper_validator.py @@ -0,0 +1,49 @@ +import pytest +from pydantic import ValidationError + +from eligibility_signposting_api.model.campaign_config import AvailableAction +from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator + + +@pytest.fixture +def valid_available_action(): + return { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + + +class TestBUCValidations: + def make_action(self, data: dict) -> AvailableAction: + return AvailableAction(**data) + + def test_valid_actions_mapper(self, valid_available_action): + data = { + "action1": self.make_action(valid_available_action), + "action2": self.make_action({**valid_available_action, "ExternalRoutingCode": "AltCode"}), + } + mapper = ActionsMapperValidator(root=data) + + expected_action_count = 2 + assert isinstance(mapper, ActionsMapperValidator) + assert len(mapper.root) == expected_action_count + + def test_invalid_actions_mapper_empty_key(self, valid_available_action): + data = {"": self.make_action(valid_available_action), "action2": self.make_action(valid_available_action)} + with pytest.raises(ValidationError) as exc_info: + ActionsMapperValidator(root=data) + assert "Invalid keys found in ActionsMapper" in str(exc_info.value) + assert "['']" in str(exc_info.value) + + @pytest.mark.parametrize("bad_key", [""]) + def test_invalid_keys_parametrized(self, bad_key, valid_available_action): + data = { + bad_key: self.make_action(valid_available_action), + "valid_key": self.make_action(valid_available_action), + } + with pytest.raises(ValidationError) as exc_info: + ActionsMapperValidator(root=data) + assert "Invalid keys found in ActionsMapper" in str(exc_info.value) diff --git a/tests/unit/validation/test_available_action_validator.py b/tests/unit/validation/test_available_action_validator.py new file mode 100644 index 000000000..468ead7b9 --- /dev/null +++ b/tests/unit/validation/test_available_action_validator.py @@ -0,0 +1,55 @@ +import copy + +import pytest +from pydantic import ValidationError + +from rules_validation_api.validators.available_action_validator import AvailableActionValidation + + +# 🔍 Mandatory Fields +class TestMandatoryFieldsSchemaValidations: + def test_valid_minimal_input(self, valid_available_action): + data = copy.deepcopy(valid_available_action) + data.pop("ActionDescription") + data.pop("UrlLink") + data.pop("UrlLabel") + action = AvailableActionValidation(**data) + assert action.action_type == "ButtonWithAuthLink" + assert action.action_code == "BookNBS" + assert action.action_description is None + assert action.url_link is None + assert action.url_label is None + + def test_missing_required_fields(self, valid_available_action): + data = copy.deepcopy(valid_available_action) + data.pop("ActionType") + data.pop("ExternalRoutingCode") + with pytest.raises(ValidationError) as exc_info: + AvailableActionValidation(**data) + error_msg = str(exc_info.value) + assert "ActionType" in error_msg + assert "ExternalRoutingCode" in error_msg + + +# 🔍 Optional Fields +class TestOptionalFieldsSchemaValidations: + def test_valid_full_input(self, valid_available_action): + action = AvailableActionValidation(**valid_available_action) + assert action.action_type == "ButtonWithAuthLink" + assert action.action_code == "BookNBS" + assert action.action_description == "" + assert str(action.url_link) == "http://www.nhs.uk/book-rsv" + assert action.url_label == "Continue to booking" + + def test_empty_string_is_valid_for_optional_fields(self, valid_available_action): + action = AvailableActionValidation(**valid_available_action) + assert action.action_description == "" + assert action.url_label == "Continue to booking" + + @pytest.mark.parametrize("bad_url", ["not-a-url", "ftp://bad", "123"]) + def test_invalid_url_raises_validation_error(self, valid_available_action, bad_url): + data = copy.deepcopy(valid_available_action) + data["UrlLink"] = bad_url + with pytest.raises(ValidationError) as exc_info: + AvailableActionValidation(**data) + assert "UrlLink" in str(exc_info.value) diff --git a/tests/unit/validation/test_campaign_config_validator.py b/tests/unit/validation/test_campaign_config_validator.py new file mode 100644 index 000000000..61bb75ca7 --- /dev/null +++ b/tests/unit/validation/test_campaign_config_validator.py @@ -0,0 +1,235 @@ +import pytest +from pydantic import ValidationError + +from rules_validation_api.validators.campaign_config_validator import CampaignConfigValidation + + +class TestMandatoryFieldsSchemaValidations: + def test_campaign_config_with_only_mandatory_fields_configuration( + self, valid_campaign_config_with_only_mandatory_fields + ): + try: + CampaignConfigValidation(**valid_campaign_config_with_only_mandatory_fields) + except ValidationError as e: + pytest.fail(f"Unexpected error during model instantiation: {e}") + + @pytest.mark.parametrize( + "mandatory_field", + [ + "ID", + "Version", + "Name", + "Type", + "Target", + "IterationFrequency", + "IterationType", + "StartDate", + "EndDate", + "Iterations", + ], + ) + def test_missing_mandatory_fields(self, mandatory_field, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data.pop(mandatory_field, None) # Simulate missing field + with pytest.raises(ValidationError): + CampaignConfigValidation(**data) + + # ID + @pytest.mark.parametrize("id_value", ["CAMP001", "12345", "X001"]) + def test_valid_id(self, id_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "ID": id_value} + model = CampaignConfigValidation(**data) + assert model.id == id_value + + # Version + @pytest.mark.parametrize("version_value", [1, 2, 100]) + def test_valid_version(self, version_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Version": version_value} + model = CampaignConfigValidation(**data) + assert model.version == version_value + + # Name + @pytest.mark.parametrize("name_value", ["Spring Campaign", "COVID-Alert", "Mass Outreach"]) + def test_valid_name(self, name_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Name": name_value} + model = CampaignConfigValidation(**data) + assert model.name == name_value + + # Type + @pytest.mark.parametrize("type_value", ["V", "S"]) + def test_valid_type(self, type_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Type": type_value} + model = CampaignConfigValidation(**data) + assert model.type == type_value + + @pytest.mark.parametrize("type_value", ["X", "", None]) + def test_invalid_type(self, type_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Type": type_value} + with pytest.raises(ValidationError): + CampaignConfigValidation(**data) + + # Target + @pytest.mark.parametrize("target_value", ["COVID", "FLU", "MMR", "RSV"]) + def test_valid_target(self, target_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Target": target_value} + model = CampaignConfigValidation(**data) + assert model.target == target_value + + @pytest.mark.parametrize("target_value", ["XYZ", "ABC", "", None]) + def test_invalid_target(self, target_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Target": target_value} + with pytest.raises(ValidationError): + CampaignConfigValidation(**data) + + # IterationFrequency + @pytest.mark.parametrize("freq_value", ["X", "D", "W", "M", "Q", "A"]) + def test_valid_iteration_frequency(self, freq_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "IterationFrequency": freq_value} + model = CampaignConfigValidation(**data) + assert model.iteration_frequency == freq_value + + @pytest.mark.parametrize("freq_value", ["Z", "", None]) + def test_invalid_iteration_frequency(self, freq_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "IterationFrequency": freq_value} + with pytest.raises(ValidationError): + CampaignConfigValidation(**data) + + # IterationType + @pytest.mark.parametrize("iter_type", ["A", "M", "S", "O"]) + def test_valid_iteration_type(self, iter_type, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "IterationType": iter_type} + model = CampaignConfigValidation(**data) + assert model.iteration_type == iter_type + + @pytest.mark.parametrize("iter_type", ["B", "", None]) + def test_invalid_iteration_type(self, iter_type, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "IterationType": iter_type} + with pytest.raises(ValidationError): + CampaignConfigValidation(**data) + + # StartDate + @pytest.mark.parametrize( + "start_date", + [ + "", # empty string + "invalid-date", # malformed value + ], + ) + def test_invalid_start_date(self, start_date, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["StartDate"] = start_date + + with pytest.raises(ValidationError) as exc_info: + CampaignConfigValidation(**data) + + errors = exc_info.value.errors() + for error in errors: + assert error["loc"][0] == "StartDate" + + # EndDates + @pytest.mark.parametrize( + "end_date", + [ + "", # empty string + "31032025", # malformed value + ], + ) + def test_invalid_end_date(self, end_date, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["EndDate"] = end_date + + with pytest.raises(ValidationError) as exc_info: + CampaignConfigValidation(**data) + + errors = exc_info.value.errors() + for error in errors: + assert error["loc"][0] == "EndDate" + + +class TestOptionalFieldsSchemaValidations: + @pytest.mark.parametrize("manager", [["alice"], ["bob"], ["carol"]]) + def test_manager_field(self, manager, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Manager": manager} + model = CampaignConfigValidation(**data) + assert model.manager == manager + + @pytest.mark.parametrize("approver", [["alice"], ["bob"], ["carol"]]) + def test_approver_field(self, approver, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Approver": approver} + model = CampaignConfigValidation(**data) + assert model.approver == approver + + @pytest.mark.parametrize("reviewer", [["alice"], ["bob"], ["carol"]]) + def test_reviewer_field(self, reviewer, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Reviewer": reviewer} + model = CampaignConfigValidation(**data) + assert model.reviewer == reviewer + + @pytest.mark.parametrize("iteration_time", ["14:00", "09:30", "18:45"]) + def test_iteration_time_field(self, iteration_time, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "IterationTime": iteration_time} + model = CampaignConfigValidation(**data) + assert model.iteration_time == iteration_time + + @pytest.mark.parametrize("routing", ["email", "sms", "push"]) + def test_default_comms_routing_field(self, routing, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "DefaultCommsRouting": routing} + model = CampaignConfigValidation(**data) + assert model.default_comms_routing == routing + + @pytest.mark.parametrize("min_approval", [0, 1, 2]) + def test_approval_minimum_field(self, min_approval, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "ApprovalMinimum": min_approval} + model = CampaignConfigValidation(**data) + assert model.approval_minimum == min_approval + + @pytest.mark.parametrize("max_approval", [5, 10, 15]) + def test_approval_maximum_field(self, max_approval, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "ApprovalMaximum": max_approval} + model = CampaignConfigValidation(**data) + assert model.approval_maximum == max_approval + + +class TestBUCValidations: + # StartDate and EndDates + @pytest.mark.parametrize( + ("start_date", "end_date"), + [ + ("20250101", "20250331"), # valid range + ("20250601", "20250630"), # valid short range + ("20250101", "20250101"), # same day + ], + ) + def test_valid_start_and_end_dates_and_iteration_dates_relation( + self, start_date, end_date, valid_campaign_config_with_only_mandatory_fields + ): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["StartDate"] = start_date + data["EndDate"] = end_date + data["Iterations"][0]["IterationDate"] = "20241231" + CampaignConfigValidation(**data) + + @pytest.mark.parametrize( + ("start_date", "end_date"), + [ + ("20241230", "20250101"), # campaign start date is after the iteration date + ("20250331", "20250101"), # end before start + ], + ) + def test_invalid_start_and_end_dates_and_iteration_dates_relation( + self, start_date, end_date, valid_campaign_config_with_only_mandatory_fields + ): + data = valid_campaign_config_with_only_mandatory_fields.copy() + data["StartDate"] = start_date + data["EndDate"] = end_date + data["Iterations"][0]["IterationDate"] = "20241231" + with pytest.raises(ValidationError): + CampaignConfigValidation(**data) + + # Iteration + def test_validate_iterations_non_empty(self, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields, "Iterations": []} + with pytest.raises(ValidationError) as error: + CampaignConfigValidation(**data) + errors = error.value.errors() + assert any(e["loc"][-1] == "Iterations" for e in errors), "Expected validation error on 'Iterations'" diff --git a/tests/unit/validation/test_iteration_rules_validator.py b/tests/unit/validation/test_iteration_rules_validator.py new file mode 100644 index 000000000..af7405cce --- /dev/null +++ b/tests/unit/validation/test_iteration_rules_validator.py @@ -0,0 +1,223 @@ +import pytest +from pydantic import ValidationError + +from rules_validation_api.validators.iteration_validator import IterationRuleValidation + + +class TestMandatoryFieldsSchemaValidations: + def test_campaign_config_with_only_mandatory_fields_configuration( + self, valid_iteration_rule_with_only_mandatory_fields + ): + try: + IterationRuleValidation(**valid_iteration_rule_with_only_mandatory_fields) + except ValidationError as e: + pytest.fail(f"Unexpected error during model instantiation: {e}") + + @pytest.mark.parametrize( + "mandatory_field", + ["Type", "Name", "Description", "Priority", "AttributeLevel", "Operator", "Comparator"], + ) + def test_missing_mandatory_fields(self, mandatory_field, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data.pop(mandatory_field, None) # Simulate missing field + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + assert mandatory_field.lower() + + @pytest.mark.parametrize("type_value", ["F", "S", "R", "X", "Y"]) + def test_valid_type(self, type_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Type"] = type_value + result = IterationRuleValidation(**data) + assert result.type.value == type_value + + @pytest.mark.parametrize("type_value", ["Z", 123, None]) + def test_invalid_type(self, type_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Type"] = type_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + @pytest.mark.parametrize("name_value", ["", "ValidName", "Test_Rule_01"]) + def test_valid_name(self, name_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Name"] = name_value + result = IterationRuleValidation(**data) + assert result.name == name_value + + @pytest.mark.parametrize("name_value", [None, 42]) + def test_invalid_name(self, name_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Name"] = name_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + @pytest.mark.parametrize("description_value", ["", "A rule description", "Sample text"]) + def test_valid_description(self, description_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Description"] = description_value + result = IterationRuleValidation(**data) + assert result.description == description_value + + @pytest.mark.parametrize("description_value", [None]) + def test_invalid_description(self, description_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Description"] = description_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + @pytest.mark.parametrize("priority_value", [-1, -5, 1, 100, 999]) + def test_valid_priority(self, priority_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Priority"] = priority_value + result = IterationRuleValidation(**data) + assert result.priority == priority_value + + @pytest.mark.parametrize("priority_value", ["high", None]) + def test_invalid_priority(self, priority_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Priority"] = priority_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + @pytest.mark.parametrize("attribute_level", ["PERSON", "TARGET", "COHORT"]) + def test_valid_attribute_level(self, attribute_level, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeLevel"] = attribute_level + result = IterationRuleValidation(**data) + assert result.attribute_level == attribute_level + + @pytest.mark.parametrize("attribute_level", ["", None, 42, "basic", "BASIC"]) + def test_invalid_attribute_level(self, attribute_level, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeLevel"] = attribute_level + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + @pytest.mark.parametrize("operator_value", ["=", "!=", ">", "<=", "contains", "is_true"]) + def test_valid_operator(self, operator_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Operator"] = operator_value + result = IterationRuleValidation(**data) + assert result.operator.value == operator_value + + @pytest.mark.parametrize("operator_value", ["approx", "", None]) + def test_invalid_operator(self, operator_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Operator"] = operator_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + @pytest.mark.parametrize("comparator_value", ["status", "true", "0"]) + def test_valid_comparator(self, comparator_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Comparator"] = comparator_value + result = IterationRuleValidation(**data) + assert result.comparator == comparator_value + + @pytest.mark.parametrize("comparator_value", [None, 123]) + def test_invalid_comparator(self, comparator_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["Comparator"] = comparator_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + +class TestOptionalFieldsSchemaValidations: + # AttributeName + @pytest.mark.parametrize("attr_name", ["status", "user_type", None]) + def test_valid_attribute_name(self, attr_name, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeName"] = attr_name + result = IterationRuleValidation(**data) + assert result.attribute_name == attr_name + + @pytest.mark.parametrize("attr_name", [123, {}, []]) + def test_invalid_attribute_name(self, attr_name, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeName"] = attr_name + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + # CohortLabel + @pytest.mark.parametrize("label", ["Cohort_A", "Segment_2025", None, ""]) + def test_valid_cohort_label(self, label, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["CohortLabel"] = label + result = IterationRuleValidation(**data) + assert result.cohort_label == label + + @pytest.mark.parametrize("label", [123, [], {}]) + def test_invalid_cohort_label(self, label, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["CohortLabel"] = label + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + # AttributeTarget + @pytest.mark.parametrize("target", ["target_value", None]) + def test_valid_attribute_target(self, target, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeTarget"] = target + result = IterationRuleValidation(**data) + assert result.attribute_target == target + + @pytest.mark.parametrize("target", [123, [], {}]) + def test_invalid_attribute_target(self, target, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeTarget"] = target + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + # RuleStop + @pytest.mark.parametrize("rule_stop_value", [True, False, "Y", "N", "YES", "NO", "YEAH", "ONE"]) + def test_valid_rule_stop(self, rule_stop_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["RuleStop"] = rule_stop_value + result = IterationRuleValidation(**data) + assert isinstance(result.rule_stop, bool) + + @pytest.mark.parametrize("rule_stop_value", [{}, None]) + def test_invalid_rule_stop(self, rule_stop_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["RuleStop"] = rule_stop_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + # CommsRouting + @pytest.mark.parametrize("routing_value", ["route_A", None]) + def test_valid_comms_routing(self, routing_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["CommsRouting"] = routing_value + result = IterationRuleValidation(**data) + assert result.comms_routing == routing_value + + @pytest.mark.parametrize("routing_value", [123, [], {}]) + def test_invalid_comms_routing(self, routing_value, valid_iteration_rule_with_only_mandatory_fields): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["CommsRouting"] = routing_value + with pytest.raises(ValidationError): + IterationRuleValidation(**data) + + +class TestBUCValidations: + @pytest.mark.parametrize( + ("rule_stop_input", "expected_bool"), + [ + (True, True), + (False, False), + ("Y", True), + ("N", False), + ("YES", False), + ("NO", False), + ("YEAH", False), + ("ONE", False), + ], + ) + def test_rule_stop_boolean_resolution( + self, rule_stop_input, expected_bool, valid_iteration_rule_with_only_mandatory_fields + ): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["RuleStop"] = rule_stop_input + result = IterationRuleValidation(**data) + assert result.rule_stop is expected_bool diff --git a/tests/unit/validation/test_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py new file mode 100644 index 000000000..8e58e5a38 --- /dev/null +++ b/tests/unit/validation/test_iteration_validator.py @@ -0,0 +1,192 @@ +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from rules_validation_api.validators.iteration_validator import IterationValidation + + +class TestMandatoryFieldsSchemaValidations: + def test_campaign_config_with_only_mandatory_fields_configuration( + self, valid_campaign_config_with_only_mandatory_fields + ): + try: + IterationValidation(**(valid_campaign_config_with_only_mandatory_fields["Iterations"][0])) + except ValidationError as e: + pytest.fail(f"Unexpected error during model instantiation: {e}") + + @pytest.mark.parametrize( + "mandatory_field", + [ + "ID", + "Version", + "Name", + "IterationDate", + "Type", + "DefaultCommsRouting", + "DefaultNotEligibleRouting", + "DefaultNotActionableRouting", + "IterationCohorts", + "IterationRules", + "ActionsMapper", + ], + ) + def test_missing_mandatory_fields(self, mandatory_field, valid_campaign_config_with_only_mandatory_fields): + data = valid_campaign_config_with_only_mandatory_fields["Iterations"][0].copy() + data.pop(mandatory_field, None) # Simulate missing field + with pytest.raises(ValidationError): + IterationValidation(**data) + assert mandatory_field.lower() + + # ID + @pytest.mark.parametrize("id_value", ["ITER001", "X123", "IT01"]) + def test_valid_id(self, id_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "ID": id_value} + model = IterationValidation(**data) + assert model.id == id_value + + # Version + @pytest.mark.parametrize("version_value", [1, 2, 100]) + def test_valid_version(self, version_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "Version": version_value} + model = IterationValidation(**data) + assert model.version == version_value + + # Name + @pytest.mark.parametrize("name_value", ["Mid-January Push", "Spring Surge", "Early Outreach"]) + def test_valid_name(self, name_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "Name": name_value} + model = IterationValidation(**data) + assert model.name == name_value + + # IterationDate + @pytest.mark.parametrize("date_value", ["20250101", "20250215", "20250301"]) + def test_valid_iteration_date(self, date_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "IterationDate": date_value} + model = IterationValidation(**data) + expected_date = datetime.strptime(str(date_value), "%Y%m%d").replace(tzinfo=UTC).date() + assert model.iteration_date == expected_date, f"Expected {expected_date}, got {model.iteration_date}" + + # Type + @pytest.mark.parametrize("type_value", ["A", "M", "S", "O"]) + def test_valid_type(self, type_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "Type": type_value} + model = IterationValidation(**data) + assert model.type == type_value + + @pytest.mark.parametrize("type_value", ["", "Z", None]) + def test_invalid_type(self, type_value, valid_campaign_config_with_only_mandatory_fields): + data = {**valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "Type": type_value} + with pytest.raises(ValidationError): + IterationValidation(**data) + + # DefaultCommsRouting + @pytest.mark.parametrize("routing_value", ["BOOK_NBS"]) + def test_valid_default_comms_routing(self, routing_value, valid_campaign_config_with_only_mandatory_fields): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultCommsRouting": routing_value, + } + model = IterationValidation(**data) + assert model.default_comms_routing == routing_value + + # DefaultNotEligibleRouting + @pytest.mark.parametrize("routing_value", ["RouteB", "NotEligComm", "NoComms"]) + def test_valid_default_not_eligible_routing(self, routing_value, valid_campaign_config_with_only_mandatory_fields): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": routing_value, + } + model = IterationValidation(**data) + assert model.default_not_eligible_routing == routing_value + + # DefaultNotActionableRouting + @pytest.mark.parametrize("routing_value", ["RouteC", "HoldComm", "Inactive"]) + def test_valid_default_not_actionable_routing( + self, routing_value, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": routing_value, + } + model = IterationValidation(**data) + assert model.default_not_actionable_routing == routing_value + + def test_invalid_actions_mapper_empty_key( + self, valid_campaign_config_with_only_mandatory_fields, valid_available_action + ): + actions_mapper = {"": valid_available_action, "action2": valid_available_action} + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "ActionsMapper": actions_mapper, + } + with pytest.raises(ValidationError): + IterationValidation(**data) + + +class TestOptionalFieldsSchemaValidations: + @pytest.mark.parametrize("iteration_number", [1, 5, 10]) + def test_iteration_number(self, iteration_number, valid_campaign_config_with_only_mandatory_fields): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "IterationNumber": iteration_number, + } + model = IterationValidation(**data) + assert model.iteration_number == iteration_number + + @pytest.mark.parametrize("approval_minimum", [0, 25, 99]) + def test_approval_minimum(self, approval_minimum, valid_campaign_config_with_only_mandatory_fields): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "ApprovalMinimum": approval_minimum, + } + model = IterationValidation(**data) + assert model.approval_minimum == approval_minimum + + @pytest.mark.parametrize("approval_maximum", [100, 250, 999]) + def test_approval_maximum(self, approval_maximum, valid_campaign_config_with_only_mandatory_fields): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "ApprovalMaximum": approval_maximum, + } + model = IterationValidation(**data) + assert model.approval_maximum == approval_maximum + + +class TestIterationCohortsSchemaValidations: + def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_routing_key( + self, valid_campaign_config_with_only_mandatory_fields + ): + expected_action = { + "ExternalRoutingCode": "BookLocal", + "ActionDescription": "##Getting the vaccine\n" + "You can get an RSV vaccination at your GP surgery.\n" + "Your GP surgery may contact you about getting the RSV vaccine. " + "This may be by letter, text, phone call, email or through the NHS App. " + "You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText", + } + + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultCommsRouting": "BOOK_LOCAL", + "ActionsMapper": {"BOOK_LOCAL": expected_action}, + } + IterationValidation(**data) + + def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_default_routing_key( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultCommsRouting": "BOOK_LOCAL", + "ActionsMapper": {}, + } # Missing BOOK_LOCAL in ActionsMapper + + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" + ) diff --git a/tests/unit/validation/test_rule_validator.py b/tests/unit/validation/test_rule_validator.py new file mode 100644 index 000000000..a24419824 --- /dev/null +++ b/tests/unit/validation/test_rule_validator.py @@ -0,0 +1,17 @@ +import pytest +from pydantic import ValidationError + +from rules_validation_api.validators.rules_validator import RulesValidation + + +def test_valid_campaign_config(valid_campaign_config_with_only_mandatory_fields): + config_data = {"campaign_config": valid_campaign_config_with_only_mandatory_fields} + validated = RulesValidation(**config_data) + assert validated.campaign_config.name is not None + + +def test_invalid_campaign_config_missing_field(): + invalid_data = {} + + with pytest.raises(ValidationError): + RulesValidation(**invalid_data) From da2429322ed6b580a8254ed038914135cd8c0431 Mon Sep 17 00:00:00 2001 From: Edd Almond <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:09:03 +0100 Subject: [PATCH 45/61] eli-386 adding github permissions to make account level public access block changes --- .../iams-developer-roles/github_actions_policies.tf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf index 6b69b5025..c349b9989 100644 --- a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf +++ b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf @@ -164,6 +164,14 @@ resource "aws_iam_policy" "s3_management" { "arn:aws:s3:::*eligibility-signposting-api-${var.environment}-truststore-access-logs", "arn:aws:s3:::*eligibility-signposting-api-${var.environment}-truststore-access-logs/*", ] + }, + { + Effect = "Allow", + Action = [ + "s3:GetAccountPublicAccessBlock", + "s3:PutAccountPublicAccessBlock" + ], + Resource = "*" } ] }) From a5f5f1e1368bf3a603a3e34beb3f748d2cdea82d Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Thu, 7 Aug 2025 11:10:26 +0100 Subject: [PATCH 46/61] all tests passing --- .../services/processors/rule_processor.py | 4 +++- tests/integration/conftest.py | 7 ++++-- .../in_process/test_eligibility_endpoint.py | 2 +- .../processors/test_rule_processor.py | 22 +++++-------------- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/rule_processor.py b/src/eligibility_signposting_api/services/processors/rule_processor.py index 2e924faca..47227ec15 100644 --- a/src/eligibility_signposting_api/services/processors/rule_processor.py +++ b/src/eligibility_signposting_api/services/processors/rule_processor.py @@ -94,7 +94,9 @@ def is_actionable( if not applicable_rules: continue - status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(person, rule_group) + status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group( + person, iter(applicable_rules) + ) if status.is_exclusion: is_actionable = False suppression_reasons.extend(group_exclusion_reasons) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0acedd6d2..3c57b5028 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -480,6 +480,7 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato yield campaign s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") + @pytest.fixture(scope="class") def campaign_config_with_and_rule(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = rule.CampaignConfigFactory.build( @@ -487,7 +488,9 @@ def campaign_config_with_and_rule(s3_client: BaseClient, rules_bucket: BucketNam iterations=[ rule.IterationFactory.build( iteration_rules=[ - rule.PostcodeSuppressionRuleFactory.build(cohort_label="cohort2",), + rule.PostcodeSuppressionRuleFactory.build( + cohort_label="cohort2", + ), rule.PersonAgeSuppressionRuleFactory.build(), ], iteration_cohorts=[ @@ -502,7 +505,7 @@ def campaign_config_with_and_rule(s3_client: BaseClient, rules_bucket: BucketNam cohort_group="cohort_group2", positive_description="positive_description", negative_description="negative_description", - ) + ), ], ) ], diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index b92717c2f..5290563f1 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -237,7 +237,6 @@ def test_actionable( ), ) - def test_actionable_with_and_rule( self, client: FlaskClient, @@ -289,6 +288,7 @@ def test_actionable_with_and_rule( ), ) + class TestMagicCohortResponse: def test_not_eligible_by_rule_when_only_magic_cohort_is_present( self, diff --git a/tests/unit/services/processors/test_rule_processor.py b/tests/unit/services/processors/test_rule_processor.py index 09dabb34e..32b789254 100644 --- a/tests/unit/services/processors/test_rule_processor.py +++ b/tests/unit/services/processors/test_rule_processor.py @@ -150,26 +150,17 @@ def test_general_rule_should_not_evaluate_in_isolation_without_matching_specific rule_processor, ): # Person is in COHORT_B - cohort = rule_builder.IterationCohortFactory.build( - cohort_label="COHORT_B", - positive_description="Eligible" - ) + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_B", positive_description="Eligible") cohort_results = {} # Rule 1: cohort-specific to COHORT_A — should be filtered out rule_specific = rule_builder.IterationRuleFactory.build( - priority=510, - type=RuleType.suppression, - cohort_label="COHORT_A", - name="SPECIFIC_RULE" + priority=510, type=RuleType.suppression, cohort_label="COHORT_A", name="SPECIFIC_RULE" ) # Rule 2: General rule rule_general = rule_builder.IterationRuleFactory.build( - priority=510, - type=RuleType.suppression, - cohort_label=None, - name="GENERAL_RULE" + priority=510, type=RuleType.suppression, cohort_label=None, name="GENERAL_RULE" ) suppression_rules = [rule_specific, rule_general] @@ -184,7 +175,6 @@ def test_general_rule_should_not_evaluate_in_isolation_without_matching_specific assert_that(cohort_results["COHORT_B"].status, is_(Status.actionable)) - @patch.object(RuleProcessor, "evaluate_rules_priority_group") @patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 def test_is_eligible_by_filter_rules_eligible( @@ -305,8 +295,8 @@ def test_evaluate_suppression_rules_stops_on_rule_stop( assert_that(cohort_results["COHORT_A"].status, is_(Status.not_actionable)) assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason_p1])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p1])) - mock_evaluate_rules_priority_group.assert_called_once() - mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + assert_that(mock_evaluate_rules_priority_group.call_count, is_(1)) + mock_get_exclusion_rules.assert_called_once_with(cohort, [suppression_rule_p1]) @patch.object(RuleProcessor, "evaluate_rules_priority_group") @@ -338,7 +328,7 @@ def test_evaluate_suppression_rules_does_not_stop_on_rule_stop_when_status_is_ac assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p2])) assert_that(mock_evaluate_rules_priority_group.call_count, is_(2)) - assert_that(mock_get_exclusion_rules.call_count, is_(1)) + assert_that(mock_get_exclusion_rules.call_count, is_(2)) def test_is_base_eligible(mock_person_data_reader): From 53713b568361b6f3d6dcc2bfa602ee130a8ed771 Mon Sep 17 00:00:00 2001 From: eddalmond1 <102675624+eddalmond1@users.noreply.github.com> Date: Thu, 7 Aug 2025 11:37:06 +0100 Subject: [PATCH 47/61] Revert "eli-386 blocking s3 public access at account level" --- infrastructure/stacks/api-layer/s3_buckets.tf | 5 ----- .../iams-developer-roles/github_actions_policies.tf | 8 -------- 2 files changed, 13 deletions(-) diff --git a/infrastructure/stacks/api-layer/s3_buckets.tf b/infrastructure/stacks/api-layer/s3_buckets.tf index 1c5ecb801..a1c554575 100644 --- a/infrastructure/stacks/api-layer/s3_buckets.tf +++ b/infrastructure/stacks/api-layer/s3_buckets.tf @@ -16,8 +16,3 @@ module "s3_audit_bucket" { stack_name = local.stack_name workspace = terraform.workspace } - -resource "aws_s3_account_public_access_block" "block_public_access" { - block_public_acls = true - block_public_policy = true -} diff --git a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf index c349b9989..6b69b5025 100644 --- a/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf +++ b/infrastructure/stacks/iams-developer-roles/github_actions_policies.tf @@ -164,14 +164,6 @@ resource "aws_iam_policy" "s3_management" { "arn:aws:s3:::*eligibility-signposting-api-${var.environment}-truststore-access-logs", "arn:aws:s3:::*eligibility-signposting-api-${var.environment}-truststore-access-logs/*", ] - }, - { - Effect = "Allow", - Action = [ - "s3:GetAccountPublicAccessBlock", - "s3:PutAccountPublicAccessBlock" - ], - Resource = "*" } ] }) From 4fb471173b65650ed150c8e197658a03f33c5dbb Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Thu, 7 Aug 2025 11:38:22 +0100 Subject: [PATCH 48/61] extracting method for readability --- .../services/processors/rule_processor.py | 21 +++++++++---------- .../processors/test_rule_processor.py | 8 +++---- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/rule_processor.py b/src/eligibility_signposting_api/services/processors/rule_processor.py index 47227ec15..adfdf9c16 100644 --- a/src/eligibility_signposting_api/services/processors/rule_processor.py +++ b/src/eligibility_signposting_api/services/processors/rule_processor.py @@ -82,20 +82,11 @@ def is_actionable( for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): group_rules = list(rule_group) - cohort_specific_rules = [rule for rule in group_rules if rule.cohort_label is not None] - matching_specific_rules = [ - rule for rule in cohort_specific_rules if rule.cohort_label == cohort.cohort_label - ] - if cohort_specific_rules and not matching_specific_rules: - continue - - applicable_rules = list(self.get_exclusion_rules(cohort, group_rules)) - - if not applicable_rules: + if self._should_skip_rule_group(cohort, group_rules): continue status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group( - person, iter(applicable_rules) + person, iter(group_rules) ) if status.is_exclusion: is_actionable = False @@ -118,6 +109,14 @@ def is_actionable( suppression_reasons, ) + @staticmethod + def _should_skip_rule_group(cohort: IterationCohort, group_rules: list[IterationRule]) -> bool: + cohort_specific_rules = [rule for rule in group_rules if rule.cohort_label is not None] + matching_specific_rules = [ + rule for rule in cohort_specific_rules if rule.cohort_label == cohort.cohort_label + ] + return bool(cohort_specific_rules and not matching_specific_rules) + def evaluate_rules_priority_group( self, person: Person, rules_group: Iterator[IterationRule] ) -> tuple[eligibility_status.Status, list[eligibility_status.Reason], bool]: diff --git a/tests/unit/services/processors/test_rule_processor.py b/tests/unit/services/processors/test_rule_processor.py index 32b789254..3471b92bf 100644 --- a/tests/unit/services/processors/test_rule_processor.py +++ b/tests/unit/services/processors/test_rule_processor.py @@ -153,12 +153,13 @@ def test_general_rule_should_not_evaluate_in_isolation_without_matching_specific cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_B", positive_description="Eligible") cohort_results = {} - # Rule 1: cohort-specific to COHORT_A — should be filtered out + # Rule 1: Non-matching rule cohort-specific to COHORT_A — should not be evaluated rule_specific = rule_builder.IterationRuleFactory.build( priority=510, type=RuleType.suppression, cohort_label="COHORT_A", name="SPECIFIC_RULE" ) - # Rule 2: General rule + # Rule 2: Matching general rule of the same priority as cohort-specific rule + # - should also not be evaluated rule_general = rule_builder.IterationRuleFactory.build( priority=510, type=RuleType.suppression, cohort_label=None, name="GENERAL_RULE" ) @@ -168,9 +169,8 @@ def test_general_rule_should_not_evaluate_in_isolation_without_matching_specific # Act rule_processor.is_actionable(MOCK_PERSON_DATA, cohort, cohort_results, suppression_rules) - # ❌ BUG: General rule should not be evaluated in isolation. + # None of the rules should be evaluated mock_evaluate_rules_priority_group.assert_not_called() - # Cohort remains actionable assert_that(cohort_results["COHORT_B"].status, is_(Status.actionable)) From 7d63860bec2935f1efb4da188b8c21b81e283959 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:02:23 +0100 Subject: [PATCH 49/61] ELI-399: Fixing Future Iteration.StartDate Resulting in 500 Error (#282) * ELI-399: Fixing Future Iteration.StartDate Resulting in 500 Error * ELI-399: Adds empty rules to fix flakiness --- .../calculators/eligibility_calculator.py | 35 +++--- .../test_eligibility_calculator.py | 102 +++++++++++++++--- 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index bfbcb5a2f..985613fcb 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections import defaultdict from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -31,6 +32,8 @@ ) from eligibility_signposting_api.model.person import Person +logger = logging.getLogger(__name__) + @service class EligibilityCalculatorFactory: @@ -84,6 +87,9 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca for condition_name, campaign_group in requested_grouped_campaigns: best_iteration_result = self.get_best_iteration_result(campaign_group) + if best_iteration_result is None: + continue + matched_action_detail = self.action_rule_handler.get_actions( self.person, best_iteration_result.active_iteration, @@ -107,20 +113,19 @@ def get_eligibility_status(self, include_actions: str, conditions: list[str], ca # Consolidate all the results and return return eligibility_status.EligibilityStatus(conditions=final_result) - def get_best_iteration_result(self, campaign_group: list[CampaignConfig]) -> BestIterationResult: + def get_best_iteration_result(self, campaign_group: list[CampaignConfig]) -> BestIterationResult | None: iteration_results = self.get_iteration_results(campaign_group) - if iteration_results: - (best_iteration_name, best_iteration_result) = max( - iteration_results.items(), - key=lambda item: next(iter(item[1].cohort_results.values())).status.value - # Below handles the case where there are no cohort results - if item[1].cohort_results - else -1, - ) - else: - iteration_result = IterationResult(eligibility_status.Status.not_eligible, [], []) - best_iteration_result = BestIterationResult(iteration_result, None, None, None, {}) + if not iteration_results: + return None + + (best_iteration_name, best_iteration_result) = max( + iteration_results.items(), + key=lambda item: next(iter(item[1].cohort_results.values())).status.value + # Below handles the case where there are no cohort results + if item[1].cohort_results + else -1, + ) return best_iteration_result @@ -128,7 +133,11 @@ def get_iteration_results(self, campaign_group: list[CampaignConfig]) -> dict[It iteration_results: dict[IterationName, BestIterationResult] = {} for cc in campaign_group: - active_iteration = cc.current_iteration + try: + active_iteration = cc.current_iteration + except StopIteration: + logger.info("Skipping campaign ID %s as no active iteration was found.", cc.id) + continue cohort_results: dict[CohortLabel, CohortGroupResult] = self.rule_processor.get_cohort_group_results( self.person, active_iteration ) diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 60f90d841..610b88437 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import Any import pytest @@ -6,12 +7,10 @@ from flask import Flask from freezegun import freeze_time from hamcrest import assert_that, contains_exactly, contains_inanyorder, has_item, has_items, is_, is_in -from pydantic import HttpUrl from eligibility_signposting_api.model import campaign_config as rules_model from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( - AvailableAction, CohortLabel, Description, RuleAttributeLevel, @@ -585,19 +584,94 @@ def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_h ) -book_nbs_comms = AvailableAction( - ActionType="ButtonAuthLink", - ExternalRoutingCode="BookNBS", - ActionDescription="Action description", - UrlLink=HttpUrl("https://www.nhs.uk/book-rsv"), - UrlLabel="Continue to booking", -) +@freeze_time("2025-04-25") +def test_no_active_iteration_returns_empty_conditions_with_single_active_campaign(faker: Faker): + # Given + person_rows = person_rows_builder(NHSNumber(faker.nhs_number())) + campaign_configs = [ + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + name="inactive iteration", + iteration_rules=[], + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + ) + ], + ) + ] + # Need to set the iteration date to override CampaignConfigFactory.fix_iteration_date_invariants behavior + campaign_configs[0].iterations[0].iteration_date = datetime.date(2025, 5, 10) -default_comms_detail = AvailableAction( - ActionType="CareCardWithText", - ExternalRoutingCode="BookLocal", - ActionDescription="You can get an RSV vaccination at your GP surgery", -) + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") + + # Then + assert_that(actual, is_eligibility_status().with_conditions([])) + + +@pytest.mark.usefixtures("caplog") +@freeze_time("2025-04-25") +def test_returns_no_condition_data_for_campaign_without_active_iteration(faker: Faker, caplog): + # Given + person_rows = person_rows_builder(NHSNumber(faker.nhs_number())) + campaign_configs = [ + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + name="inactive iteration", + iteration_rules=[], + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + ) + ], + ), + rule_builder.CampaignConfigFactory.build( + target="COVID", + iterations=[ + rule_builder.IterationFactory.build( + name="active iteration", + iteration_rules=[], + iteration_cohorts=[rule_builder.IterationCohortFactory.build(cohort_label="cohort1")], + ) + ], + ), + ] + # Need to set the iteration date to override CampaignConfigFactory.fix_iteration_date_invariants behavior + rsv_campaign = campaign_configs[0] + rsv_campaign.iterations[0].iteration_date = datetime.date(2025, 5, 10) + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + with caplog.at_level(logging.INFO): + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") + + # Then + condition_names = [condition.condition_name for condition in actual.conditions] + + assert ConditionName("RSV") not in condition_names + assert ConditionName("COVID") in condition_names + assert f"Skipping campaign ID {rsv_campaign.id} as no active iteration was found." in caplog.text + + +@freeze_time("2025-04-25") +def test_no_active_campaign(faker: Faker): + # Given + person_rows = person_rows_builder(NHSNumber(faker.nhs_number())) + campaign_configs = [rule_builder.CampaignConfigFactory.build()] + # Need to set the campaign dates to override CampaignConfigFactory.fix_iteration_date_invariants behavior + campaign_configs[0].start_date = datetime.date(2025, 5, 10) + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + actual = calculator.get_eligibility_status("Y", ["ALL"], "ALL") + + # Then + assert_that(actual, is_eligibility_status().with_conditions([])) class TestEligibilityResultBuilder: From b66a8dc3e574e8258e2ae42aa0809ff0780cd4bf Mon Sep 17 00:00:00 2001 From: TOEL2 Date: Thu, 7 Aug 2025 16:37:29 +0100 Subject: [PATCH 50/61] applying to filter rules and adding test --- .../services/processors/rule_processor.py | 16 ++-- .../processors/test_rule_processor.py | 93 ++++++++++++------- 2 files changed, 70 insertions(+), 39 deletions(-) diff --git a/src/eligibility_signposting_api/services/processors/rule_processor.py b/src/eligibility_signposting_api/services/processors/rule_processor.py index adfdf9c16..8b49d2778 100644 --- a/src/eligibility_signposting_api/services/processors/rule_processor.py +++ b/src/eligibility_signposting_api/services/processors/rule_processor.py @@ -50,10 +50,13 @@ def is_eligible( ) -> bool: is_eligible = True priority_getter = attrgetter("priority") - sorted_rules_by_priority = sorted(self.get_exclusion_rules(cohort, filter_rules), key=priority_getter) + sorted_rules_by_priority = sorted(filter_rules, key=priority_getter) for _, rule_group in groupby(sorted_rules_by_priority, key=priority_getter): - status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(person, rule_group) + group_rules = list(rule_group) + if self._should_skip_rule_group(cohort, group_rules): + continue + status, group_exclusion_reasons, _ = self.evaluate_rules_priority_group(person, iter(group_rules)) if status.is_exclusion: if cohort.cohort_label is not None: cohort_results[cohort.cohort_label] = CohortGroupResult( @@ -65,6 +68,7 @@ def is_eligible( ) is_eligible = False break + return is_eligible def is_actionable( @@ -85,9 +89,7 @@ def is_actionable( if self._should_skip_rule_group(cohort, group_rules): continue - status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group( - person, iter(group_rules) - ) + status, group_exclusion_reasons, rule_stop = self.evaluate_rules_priority_group(person, iter(group_rules)) if status.is_exclusion: is_actionable = False suppression_reasons.extend(group_exclusion_reasons) @@ -112,9 +114,7 @@ def is_actionable( @staticmethod def _should_skip_rule_group(cohort: IterationCohort, group_rules: list[IterationRule]) -> bool: cohort_specific_rules = [rule for rule in group_rules if rule.cohort_label is not None] - matching_specific_rules = [ - rule for rule in cohort_specific_rules if rule.cohort_label == cohort.cohort_label - ] + matching_specific_rules = [rule for rule in cohort_specific_rules if rule.cohort_label == cohort.cohort_label] return bool(cohort_specific_rules and not matching_specific_rules) def evaluate_rules_priority_group( diff --git a/tests/unit/services/processors/test_rule_processor.py b/tests/unit/services/processors/test_rule_processor.py index 3471b92bf..592322770 100644 --- a/tests/unit/services/processors/test_rule_processor.py +++ b/tests/unit/services/processors/test_rule_processor.py @@ -145,7 +145,7 @@ def test_evaluate_rules_priority_group_with_rule_stop(mock_rule_calculator_class @patch.object(RuleProcessor, "evaluate_rules_priority_group") -def test_general_rule_should_not_evaluate_in_isolation_without_matching_specific_rule( +def test_general_suppression_rule_should_not_evaluate_in_isolation_without_matching_specific_rule( mock_evaluate_rules_priority_group, rule_processor, ): @@ -176,9 +176,38 @@ def test_general_rule_should_not_evaluate_in_isolation_without_matching_specific @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +def test_general_filter_rule_should_not_evaluate_in_isolation_without_matching_specific_rule( + mock_evaluate_rules_priority_group, + rule_processor, +): + # Person is in COHORT_B + cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_B", positive_description="Eligible") + cohort_results = {} + + # Rule 1: Non-matching rule cohort-specific to COHORT_A — should not be evaluated + rule_specific = rule_builder.IterationRuleFactory.build( + priority=510, type=RuleType.filter, cohort_label="COHORT_A", name="SPECIFIC_RULE" + ) + + # Rule 2: Matching general rule of the same priority as cohort-specific rule + # - should also not be evaluated + rule_general = rule_builder.IterationRuleFactory.build( + priority=510, type=RuleType.filter, cohort_label=None, name="GENERAL_RULE" + ) + + filter_rules = [rule_specific, rule_general] + + # Act + rule_processor.is_eligible(MOCK_PERSON_DATA, cohort, cohort_results, filter_rules) + + # None of the rules should be evaluated + mock_evaluate_rules_priority_group.assert_not_called() + + +@patch.object(RuleProcessor, "evaluate_rules_priority_group") +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_is_eligible_by_filter_rules_eligible( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") cohort_results = {} @@ -191,14 +220,14 @@ def test_is_eligible_by_filter_rules_eligible( assert_that(is_eligible, is_(True)) assert_that(cohort_results, is_({})) - mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, filter_rules) mock_evaluate_rules_priority_group.assert_called_once() @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_is_eligible_by_filter_rules_not_eligible( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", negative_description="Not Eligible") cohort_results = {} @@ -215,14 +244,14 @@ def test_is_eligible_by_filter_rules_not_eligible( assert_that(cohort_results["COHORT_A"].status, is_(Status.not_eligible)) assert_that(cohort_results["COHORT_A"].description, is_("Not Eligible")) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) - mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, filter_rules) mock_evaluate_rules_priority_group.assert_called_once() @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_evaluate_suppression_rules_actionable( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", positive_description="Actionable") cohort_results = {} @@ -238,14 +267,14 @@ def test_evaluate_suppression_rules_actionable( assert_that(cohort_results["COHORT_A"].description, is_("Actionable")) assert_that(cohort_results["COHORT_A"].reasons, is_([])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([])) - mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, suppression_rules) mock_evaluate_rules_priority_group.assert_called_once() @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_evaluate_suppression_rules_not_actionable( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build( cohort_label="COHORT_A", positive_description="Positive Description" @@ -264,14 +293,14 @@ def test_evaluate_suppression_rules_not_actionable( assert_that(cohort_results["COHORT_A"].description, is_("Positive Description")) assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) - mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, suppression_rules) mock_evaluate_rules_priority_group.assert_called_once() @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_evaluate_suppression_rules_stops_on_rule_stop( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") cohort_results = {} @@ -296,13 +325,13 @@ def test_evaluate_suppression_rules_stops_on_rule_stop( assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason_p1])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p1])) assert_that(mock_evaluate_rules_priority_group.call_count, is_(1)) - mock_get_exclusion_rules.assert_called_once_with(cohort, [suppression_rule_p1]) + mock_should_skip_rule_group.assert_called_once_with(cohort, [suppression_rule_p1]) @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_evaluate_suppression_rules_does_not_stop_on_rule_stop_when_status_is_actionable( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") cohort_results = {} @@ -328,7 +357,7 @@ def test_evaluate_suppression_rules_does_not_stop_on_rule_stop_when_status_is_ac assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason_p2])) assert_that(mock_evaluate_rules_priority_group.call_count, is_(2)) - assert_that(mock_get_exclusion_rules.call_count, is_(2)) + assert_that(mock_should_skip_rule_group.call_count, is_(2)) def test_is_base_eligible(mock_person_data_reader): @@ -395,8 +424,8 @@ def test_rules_get_group_by_types_of_rules(rule_processor): @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 -def test_is_eligible_by_filter_rules(mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor): +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) +def test_is_eligible_by_filter_rules(mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A") cohort_results = {} filter_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter) @@ -408,13 +437,15 @@ def test_is_eligible_by_filter_rules(mock_get_exclusion_rules, mock_evaluate_rul assert_that(is_eligible, is_(True)) assert_that(cohort_results, is_({})) - mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, filter_rules) mock_evaluate_rules_priority_group.assert_called_once() @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 -def test_is_not_eligible_by_filter_rules(mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor): +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) +def test_is_not_eligible_by_filter_rules( + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor +): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", negative_description="Not Eligible") cohort_results = {} filter_rule = rule_builder.IterationRuleFactory.build(priority=1, type=RuleType.filter, name="F1") @@ -440,14 +471,14 @@ def mock_evaluate_side_effect(person, rules_group): # noqa: ARG001 assert_that(cohort_results["COHORT_A"].status, is_(Status.not_eligible)) assert_that(cohort_results["COHORT_A"].description, is_("Not Eligible")) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) - mock_get_exclusion_rules.assert_called_once_with(cohort, filter_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, filter_rules) mock_evaluate_rules_priority_group.assert_called_once() @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_is_actionable_by_suppression_rules( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build(cohort_label="COHORT_A", positive_description="Actionable") cohort_results = {} @@ -463,14 +494,14 @@ def test_is_actionable_by_suppression_rules( assert_that(cohort_results["COHORT_A"].description, is_("Actionable")) assert_that(cohort_results["COHORT_A"].reasons, is_(empty())) assert_that(cohort_results["COHORT_A"].audit_rules, is_(empty())) - mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, suppression_rules) mock_evaluate_rules_priority_group.assert_called_once() @patch.object(RuleProcessor, "evaluate_rules_priority_group") -@patch.object(RuleProcessor, "get_exclusion_rules", side_effect=lambda cohort, rules_to_filter: rules_to_filter) # noqa: ARG005 +@patch.object(RuleProcessor, "_should_skip_rule_group", return_value=False) def test_is_not_actionable_by_suppression_rules( - mock_get_exclusion_rules, mock_evaluate_rules_priority_group, rule_processor + mock_should_skip_rule_group, mock_evaluate_rules_priority_group, rule_processor ): cohort = rule_builder.IterationCohortFactory.build( cohort_label="COHORT_A", positive_description="Positive Description" @@ -499,7 +530,7 @@ def mock_evaluate_side_effect(person, rules_group): # noqa: ARG001 assert_that(cohort_results["COHORT_A"].description, is_("Positive Description")) assert_that(cohort_results["COHORT_A"].reasons, is_([mock_reason])) assert_that(cohort_results["COHORT_A"].audit_rules, is_([mock_reason])) - mock_get_exclusion_rules.assert_called_once_with(cohort, suppression_rules) + mock_should_skip_rule_group.assert_called_once_with(cohort, suppression_rules) mock_evaluate_rules_priority_group.assert_called_once() From 1e5511404d5f2fa723471a4ab0f7b48693023050 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 10:37:11 +0100 Subject: [PATCH 51/61] ELI-397: Fixing nhs number missing from path error to be FHIR compliant (#284) --- .../common/api_error_response.py | 12 + .../common/request_validator.py | 7 + tests/unit/common/test_request_validator.py | 612 ++++++++++-------- 3 files changed, 361 insertions(+), 270 deletions(-) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index 9b81a740c..cbb07e8ca 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -22,11 +22,13 @@ class FHIRIssueCode(str, Enum): FORBIDDEN = "forbidden" PROCESSING = "processing" VALUE = "value" + INVALID = "invalid" class FHIRSpineErrorCode(str, Enum): INVALID_NHS_NUMBER = "INVALID_NHS_NUMBER" INVALID_PARAMETER = "INVALID_PARAMETER" + BAD_REQUEST = "BAD_REQUEST" INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" REFERENCE_NOT_FOUND = "REFERENCE_NOT_FOUND" @@ -145,3 +147,13 @@ def log_and_generate_response( fhir_error_code=FHIRSpineErrorCode.INVALID_NHS_NUMBER, fhir_display_message="The provided NHS number does not match the record.", ) + + +NHS_NUMBER_MISSING_ERROR = APIErrorResponse( + status_code=HTTPStatus.BAD_REQUEST, + fhir_issue_code=FHIRIssueCode.INVALID, + fhir_issue_severity=FHIRIssueSeverity.ERROR, + fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + fhir_error_code=FHIRSpineErrorCode.BAD_REQUEST, + fhir_display_message="Bad Request", +) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index 1bc8d9d86..8cf5fbe40 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -11,6 +11,7 @@ INVALID_CONDITION_FORMAT_ERROR, INVALID_INCLUDE_ACTIONS_ERROR, NHS_NUMBER_MISMATCH_ERROR, + NHS_NUMBER_MISSING_ERROR, ) from eligibility_signposting_api.config.contants import NHS_NUMBER_HEADER @@ -59,6 +60,12 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None path_nhs_no = event.get("pathParameters", {}).get("id") header_nhs_no = event.get("headers", {}).get(NHS_NUMBER_HEADER) + if not path_nhs_no: + message = "Missing required NHS Number from path parameters" + return NHS_NUMBER_MISSING_ERROR.log_and_generate_response( + log_message=message, diagnostics=message, location_param="id" + ) + if not validate_nhs_number(path_nhs_no, header_nhs_no): message = f"NHS Number {path_nhs_no or ''} does not match the header NHS Number {header_nhs_no or ''}" return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response( diff --git a/tests/unit/common/test_request_validator.py b/tests/unit/common/test_request_validator.py index 2787c981a..99a09d40e 100644 --- a/tests/unit/common/test_request_validator.py +++ b/tests/unit/common/test_request_validator.py @@ -1,6 +1,7 @@ import json import logging from http import HTTPStatus +from unittest.mock import MagicMock import pytest @@ -15,299 +16,370 @@ def setup_logging_for_tests(): logger.addHandler(logging.NullHandler()) -@pytest.mark.parametrize( - ("path_nhs", "header_nhs", "expected_result", "expected_log_msg"), - [ - (None, None, False, "NHS number is not present"), - ("1234567890", None, False, "NHS number is not present"), - (None, "1234567890", False, "NHS number is not present"), - ("1234567890", "0987654321", False, "NHS number mismatch"), - ("1234567890", "1234567890", True, None), - ], -) -def test_validate_nhs_number(path_nhs, header_nhs, expected_result, expected_log_msg, caplog): - with caplog.at_level(logging.ERROR): - result = request_validator.validate_nhs_number(path_nhs, header_nhs) - - assert result == expected_result - - if expected_log_msg: - assert any(expected_log_msg in record.message for record in caplog.records) - else: +class TestValidateNHSNumber: + @pytest.mark.parametrize( + ("path_nhs", "header_nhs", "expected_result", "expected_log_msg"), + [ + (None, None, False, "NHS number is not present"), + ("1234567890", None, False, "NHS number is not present"), + (None, "1234567890", False, "NHS number is not present"), + ("1234567890", "0987654321", False, "NHS number mismatch"), + ("1234567890", "1234567890", True, None), + ], + ) + def test_validate_nhs_number(self, path_nhs, header_nhs, expected_result, expected_log_msg, caplog): + with caplog.at_level(logging.ERROR): + result = request_validator.validate_nhs_number(path_nhs, header_nhs) + + assert result == expected_result + + if expected_log_msg: + assert any(expected_log_msg in record.message for record in caplog.records) + else: + assert not caplog.records + + +class TestValidateRequestParams: + def test_validate_request_params_success(self, caplog): + mock_handler = MagicMock() + mock_handler.__name__ = "mock_handler" + + mock_event_valid = { + "pathParameters": {"id": "1234567890"}, + "headers": {"nhs-login-nhs-number": "1234567890"}, + } + mock_context = {} + + decorator = request_validator.validate_request_params() + wrapped_handler = decorator(mock_handler) + with caplog.at_level(logging.INFO): + wrapped_handler(mock_event_valid, mock_context) + + assert any("NHS numbers from the request" in record.message for record in caplog.records) + assert not any(record.levelname == "ERROR" for record in caplog.records) + + def test_validate_request_params_nhs_mismatch(self, caplog): + mock_handler = MagicMock() + mock_context = {} + event = { + "pathParameters": {"id": "0987654321"}, + "headers": {"nhs-login-nhs-number": "1234567890"}, + } + + decorator = request_validator.validate_request_params() + wrapped_handler = decorator(mock_handler) + + with caplog.at_level(logging.ERROR): + response = wrapped_handler(event, mock_context) + + mock_handler.assert_not_called() + + assert response is not None + assert response["statusCode"] == HTTPStatus.FORBIDDEN + response_body = json.loads(response["body"]) + issue = response_body["issue"][0] + assert issue["code"] == "forbidden" + assert issue["diagnostics"] == ("NHS Number 0987654321 does not match the header NHS Number 1234567890") + + def test_validate_request_params_nhs_missing_in_path(self, caplog): + mock_handler = MagicMock() + mock_context = {} + event = { + "headers": {"nhs-login-nhs-number": "1234567890"}, + } + + decorator = request_validator.validate_request_params() + wrapped_handler = decorator(mock_handler) + + with caplog.at_level(logging.ERROR): + response = wrapped_handler(event, mock_context) + + mock_handler.assert_not_called() + + assert response is not None + assert response["statusCode"] == HTTPStatus.BAD_REQUEST + response_body = json.loads(response["body"]) + issue = response_body["issue"][0] + assert issue["code"] == "invalid" + assert issue["severity"] == "error" + assert issue["details"]["coding"][0]["code"] == "BAD_REQUEST" + assert issue["details"]["coding"][0]["display"] == "Bad Request" + assert issue["diagnostics"] == "Missing required NHS Number from path parameters" + assert issue["location"][0] == "parameters/id" + assert any( + (record.levelname == "ERROR" and "Missing required NHS Number from path parameters" in record.message) + for record in caplog.records + ) + + +class TestValidateQueryParameters: + @pytest.mark.parametrize( + ("conditions_input", "is_valid_expected", "expected_log_msg"), + [ + ("ALL", True, None), + ("COVID", True, None), + ("covid19", True, None), + ("FLU,MMR", True, None), + (" RSV , COVID19", True, None), + (" condition_with_spaces ", False, "Invalid condition query param: ' condition_with_spaces '"), + ("CONDITION_A,ANOTHER_ONE,123ABC", False, "Invalid condition query param: 'CONDITION_A'"), + ("condition1,", False, "Invalid condition query param: ''"), + (",condition2", False, "Invalid condition query param: ''"), + ("condition-invalid", False, "Invalid condition query param: 'condition-invalid'"), + ("condition with spaces", False, "Invalid condition query param: 'condition with spaces'"), + ("condition!", False, "Invalid condition query param: 'condition!'"), + ("condition@#$", False, "Invalid condition query param: 'condition@#$'"), + ], + ) + def test_validate_query_params_conditions(self, conditions_input, is_valid_expected, expected_log_msg, caplog): + params = {"conditions": conditions_input} + + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + + assert is_valid == is_valid_expected + if is_valid_expected: + assert problem is None + assert not caplog.records + else: + assert problem is not None + assert any( + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records + ) + + def test_validate_query_params_conditions_default(self, caplog): + params = {"category": "ALL", "includeActions": "Y"} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid is True + assert problem is None assert not caplog.records - -@pytest.mark.parametrize( - ("conditions_input", "is_valid_expected", "expected_log_msg"), - [ - ("ALL", True, None), - ("COVID", True, None), - ("covid19", True, None), - ("FLU,MMR", True, None), - (" RSV , COVID19", True, None), - (" condition_with_spaces ", False, "Invalid condition query param: ' condition_with_spaces '"), - ("CONDITION_A,ANOTHER_ONE,123ABC", False, "Invalid condition query param: 'CONDITION_A'"), - ("condition1,", False, "Invalid condition query param: ''"), - (",condition2", False, "Invalid condition query param: ''"), - ("condition-invalid", False, "Invalid condition query param: 'condition-invalid'"), - ("condition with spaces", False, "Invalid condition query param: 'condition with spaces'"), - ("condition!", False, "Invalid condition query param: 'condition!'"), - ("condition@#$", False, "Invalid condition query param: 'condition@#$'"), - ], -) -def test_validate_query_params_conditions(conditions_input, is_valid_expected, expected_log_msg, caplog): - params = {"conditions": conditions_input} - - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - - assert is_valid == is_valid_expected - if is_valid_expected: + @pytest.mark.parametrize( + ("category_input", "is_valid_expected", "expected_log_msg"), + [ + ("VACCINATIONS", True, None), + ("SCREENING", True, None), + ("ALL", True, None), + ("vaccinations", True, None), + ("screening", True, None), + ("all", True, None), + (" VACCINATIONS ", True, None), + ("OTHER_CATEGORY ", False, "Invalid category query param: 'OTHER_CATEGORY '"), + ("invalid!", False, "Invalid category query param: 'invalid!'"), + ("VACCINATION", False, "Invalid category query param: 'VACCINATION'"), + ], + ) + def test_validate_query_params_category(self, category_input, is_valid_expected, expected_log_msg, caplog): + params = {"category": category_input} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid == is_valid_expected + + if is_valid_expected: + assert problem is None + assert not caplog.records + else: + assert problem is not None + assert any( + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records + ) + + def test_validate_query_params_category_default(self, caplog): + params = {"conditions": "ALL", "includeActions": "Y"} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid is True assert problem is None assert not caplog.records - else: - assert problem is not None - assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) - -def test_validate_query_params_conditions_default(caplog): - params = {"category": "ALL", "includeActions": "Y"} - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - assert is_valid is True - assert problem is None - assert not caplog.records - - -@pytest.mark.parametrize( - ("category_input", "is_valid_expected", "expected_log_msg"), - [ - ("VACCINATIONS", True, None), - ("SCREENING", True, None), - ("ALL", True, None), - ("vaccinations", True, None), - ("screening", True, None), - ("all", True, None), - (" VACCINATIONS ", True, None), - ("OTHER_CATEGORY ", False, "Invalid category query param: 'OTHER_CATEGORY '"), - ("invalid!", False, "Invalid category query param: 'invalid!'"), - ("VACCINATION", False, "Invalid category query param: 'VACCINATION'"), - ], -) -def test_validate_query_params_category(category_input, is_valid_expected, expected_log_msg, caplog): - params = {"category": category_input} - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - assert is_valid == is_valid_expected - - if is_valid_expected: + @pytest.mark.parametrize( + ("include_actions_input", "is_valid_expected", "expected_log_msg"), + [ + ("Y", True, None), + ("N", True, None), + ("y", True, None), + ("n", True, None), + ("n ", True, None), + ("TRUE", False, "Invalid include actions query param: 'TRUE'"), + ("YES", False, "Invalid include actions query param: 'YES'"), + ("0", False, "Invalid include actions query param: '0'"), + ("1", False, "Invalid include actions query param: '1'"), + ("", False, "Invalid include actions query param: ''"), + (" ", False, "Invalid include actions query param: ' '"), + ], + ) + def test_validate_query_params_include_actions( + self, include_actions_input, is_valid_expected, expected_log_msg, caplog + ): + params = {"includeActions": include_actions_input} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid == is_valid_expected + + if is_valid_expected: + assert problem is None + assert not caplog.records + else: + assert problem is not None + assert any( + (record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records + ) + + def test_validate_query_params_include_actions_default(self, caplog): + params = {"conditions": "ALL", "category": "ALL"} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid is True assert problem is None assert not caplog.records - else: - assert problem is not None - assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) - -def test_validate_query_params_category_default(caplog): - params = {"conditions": "ALL", "includeActions": "Y"} - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - assert is_valid is True - assert problem is None - assert not caplog.records - - -@pytest.mark.parametrize( - ("include_actions_input", "is_valid_expected", "expected_log_msg"), - [ - ("Y", True, None), - ("N", True, None), - ("y", True, None), - ("n", True, None), - ("n ", True, None), - ("TRUE", False, "Invalid include actions query param: 'TRUE'"), - ("YES", False, "Invalid include actions query param: 'YES'"), - ("0", False, "Invalid include actions query param: '0'"), - ("1", False, "Invalid include actions query param: '1'"), - ("", False, "Invalid include actions query param: ''"), - (" ", False, "Invalid include actions query param: ' '"), - ], -) -def test_validate_query_params_include_actions(include_actions_input, is_valid_expected, expected_log_msg, caplog): - params = {"includeActions": include_actions_input} - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - assert is_valid == is_valid_expected - - if is_valid_expected: + def test_validate_query_params_all_valid_params(self, caplog): + params = {"conditions": "COND1,COND2", "category": "SCREENING", "includeActions": "N"} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid is True assert problem is None assert not caplog.records - else: - assert problem is not None - assert any((record.levelname == "ERROR" and expected_log_msg in record.message) for record in caplog.records) - - -def test_validate_query_params_include_actions_default(caplog): - params = {"conditions": "ALL", "category": "ALL"} - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - assert is_valid is True - assert problem is None - assert not caplog.records - - -def test_validate_query_params_all_valid_params(caplog): - params = {"conditions": "COND1,COND2", "category": "SCREENING", "includeActions": "N"} - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - assert is_valid is True - assert problem is None - assert not caplog.records + def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(self, caplog): + params = {"conditions": "VALID_COND,INVALID!,ANOTHER_VALID", "category": "SCREENING", "includeActions": "N"} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid is False + assert problem is not None + assert any( + (record.levelname == "ERROR" and "Invalid condition query param: " in record.message) + for record in caplog.records + ) + + def test_validate_query_params_valid_conditions_invalid_category_fail_second(self, caplog): + params = {"conditions": "CONDITION", "category": "BAD_CAT", "includeActions": "N"} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid is False + assert problem is not None + assert any( + (record.levelname == "ERROR" and "Invalid category query param: " in record.message) + for record in caplog.records + ) + error_logs = [r for r in caplog.records if r.levelname == "ERROR"] + assert len(error_logs) == 1 + + def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(self, caplog): + params = {"conditions": "CONDITION", "category": "VACCINATIONS", "includeActions": "Nope"} + with caplog.at_level(logging.ERROR): + is_valid, problem = request_validator.validate_query_params(params) + assert is_valid is False + assert problem is not None + assert any( + (record.levelname == "ERROR" and "Invalid include actions query param: " in record.message) + for record in caplog.records + ) + error_logs = [r for r in caplog.records if r.levelname == "ERROR"] + assert len(error_logs) == 1 -def test_validate_query_params_mixed_valid_invalid_conditions_fail_first(caplog): - params = {"conditions": "VALID_COND,INVALID!,ANOTHER_VALID", "category": "SCREENING", "includeActions": "N"} - with caplog.at_level(logging.ERROR): - is_valid, problem = request_validator.validate_query_params(params) - assert is_valid is False - assert problem is not None - assert any( - (record.levelname == "ERROR" and "Invalid condition query param: " in record.message) - for record in caplog.records - ) - + def test_validate_query_params_returns_correct_problem_details_for_conditions_error(self): + invalid_condition = "FLU&COVID" + params = {"conditions": invalid_condition} -def test_validate_query_params_valid_conditions_invalid_category_fail_second(caplog): - params = {"conditions": "CONDITION", "category": "BAD_CAT", "includeActions": "N"} - with caplog.at_level(logging.ERROR): is_valid, problem = request_validator.validate_query_params(params) - assert is_valid is False - assert problem is not None - assert any( - (record.levelname == "ERROR" and "Invalid category query param: " in record.message) - for record in caplog.records - ) - error_logs = [r for r in caplog.records if r.levelname == "ERROR"] - assert len(error_logs) == 1 + assert is_valid is False + assert problem is not None + assert problem["statusCode"] == HTTPStatus.BAD_REQUEST + assert problem["headers"]["Content-Type"] == "application/fhir+json" + + response_body = json.loads(problem["body"]) + + assert response_body["resourceType"] == "OperationOutcome" + assert "id" in response_body + assert "meta" in response_body + assert "lastUpdated" in response_body["meta"] + + assert len(response_body["issue"]) == 1 + issue = response_body["issue"][0] + + assert issue["severity"] == "error" + assert issue["code"] == "value" + assert issue["diagnostics"] == ( + f"{invalid_condition} should be a single or comma separated list of condition " + f"strings with no other punctuation or special characters" + ) + assert issue["location"] == ["parameters/conditions"] + assert "details" in issue + assert "coding" in issue["details"] + assert len(issue["details"]["coding"]) == 1 + coding = issue["details"]["coding"][0] + + assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" + assert coding["code"] == "INVALID_PARAMETER" + assert coding["display"] == "The given conditions were not in the expected format." + + def test_validate_query_params_returns_correct_problem_details_for_category_error(self): + invalid_category = "HEALTHCHECKS" + params = {"category": invalid_category} -def test_validate_query_params_valid_conditions_category_invalid_actions_fail_third(caplog): - params = {"conditions": "CONDITION", "category": "VACCINATIONS", "includeActions": "Nope"} - with caplog.at_level(logging.ERROR): is_valid, problem = request_validator.validate_query_params(params) - assert is_valid is False - assert problem is not None - assert any( - (record.levelname == "ERROR" and "Invalid include actions query param: " in record.message) - for record in caplog.records - ) - error_logs = [r for r in caplog.records if r.levelname == "ERROR"] - assert len(error_logs) == 1 - - -def test_validate_query_params_returns_correct_problem_details_for_conditions_error(): - invalid_condition = "FLU&COVID" - params = {"conditions": invalid_condition} - - is_valid, problem = request_validator.validate_query_params(params) - - assert is_valid is False - assert problem is not None - assert problem["statusCode"] == HTTPStatus.BAD_REQUEST - assert problem["headers"]["Content-Type"] == "application/fhir+json" - - response_body = json.loads(problem["body"]) - - assert response_body["resourceType"] == "OperationOutcome" - assert "id" in response_body - assert "meta" in response_body - assert "lastUpdated" in response_body["meta"] - - assert len(response_body["issue"]) == 1 - issue = response_body["issue"][0] - - assert issue["severity"] == "error" - assert issue["code"] == "value" - assert issue["diagnostics"] == ( - f"{invalid_condition} should be a single or comma separated list of condition " - f"strings with no other punctuation or special characters" - ) - assert issue["location"] == ["parameters/conditions"] - assert "details" in issue - assert "coding" in issue["details"] - assert len(issue["details"]["coding"]) == 1 - coding = issue["details"]["coding"][0] - - assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" - assert coding["code"] == "INVALID_PARAMETER" - assert coding["display"] == "The given conditions were not in the expected format." - - -def test_validate_query_params_returns_correct_problem_details_for_category_error(): - invalid_category = "HEALTHCHECKS" - params = {"category": invalid_category} - - is_valid, problem = request_validator.validate_query_params(params) - - assert is_valid is False - assert problem is not None - assert problem["statusCode"] == HTTPStatus.UNPROCESSABLE_ENTITY - assert problem["headers"]["Content-Type"] == "application/fhir+json" - - response_body = json.loads(problem["body"]) - - assert response_body["resourceType"] == "OperationOutcome" - assert "id" in response_body - assert "meta" in response_body - assert "lastUpdated" in response_body["meta"] - - assert len(response_body["issue"]) == 1 - issue = response_body["issue"][0] - - assert issue["severity"] == "error" - assert issue["code"] == "value" - assert issue["diagnostics"] == f"{invalid_category} is not a category that is supported by the API" - assert issue["location"] == ["parameters/category"] - assert "details" in issue - assert "coding" in issue["details"] - assert len(issue["details"]["coding"]) == 1 - coding = issue["details"]["coding"][0] - - assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" - assert coding["code"] == "INVALID_PARAMETER" - assert coding["display"] == "The supplied category was not recognised by the API." + assert is_valid is False + assert problem is not None + assert problem["statusCode"] == HTTPStatus.UNPROCESSABLE_ENTITY + assert problem["headers"]["Content-Type"] == "application/fhir+json" -def test_validate_query_params_returns_correct_problem_details_for_include_actions_error(): - invalid_include_actions = "NAH" - params = {"includeActions": invalid_include_actions} + response_body = json.loads(problem["body"]) - is_valid, problem = request_validator.validate_query_params(params) + assert response_body["resourceType"] == "OperationOutcome" + assert "id" in response_body + assert "meta" in response_body + assert "lastUpdated" in response_body["meta"] - assert is_valid is False - assert problem is not None - assert problem["statusCode"] == HTTPStatus.UNPROCESSABLE_ENTITY - assert problem["headers"]["Content-Type"] == "application/fhir+json" + assert len(response_body["issue"]) == 1 + issue = response_body["issue"][0] - response_body = json.loads(problem["body"]) + assert issue["severity"] == "error" + assert issue["code"] == "value" + assert issue["diagnostics"] == f"{invalid_category} is not a category that is supported by the API" + assert issue["location"] == ["parameters/category"] + assert "details" in issue + assert "coding" in issue["details"] + assert len(issue["details"]["coding"]) == 1 + coding = issue["details"]["coding"][0] - assert response_body["resourceType"] == "OperationOutcome" - assert "id" in response_body - assert "meta" in response_body - assert "lastUpdated" in response_body["meta"] + assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" + assert coding["code"] == "INVALID_PARAMETER" + assert coding["display"] == "The supplied category was not recognised by the API." - assert len(response_body["issue"]) == 1 - issue = response_body["issue"][0] + def test_validate_query_params_returns_correct_problem_details_for_include_actions_error(self): + invalid_include_actions = "NAH" + params = {"includeActions": invalid_include_actions} - assert issue["severity"] == "error" - assert issue["code"] == "value" - assert issue["diagnostics"] == f"{invalid_include_actions} is not a value that is supported by the API" - assert issue["location"] == ["parameters/includeActions"] - assert "details" in issue - assert "coding" in issue["details"] - assert len(issue["details"]["coding"]) == 1 - coding = issue["details"]["coding"][0] + is_valid, problem = request_validator.validate_query_params(params) - assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" - assert coding["code"] == "INVALID_PARAMETER" - assert coding["display"] == "The supplied value was not recognised by the API." + assert is_valid is False + assert problem is not None + assert problem["statusCode"] == HTTPStatus.UNPROCESSABLE_ENTITY + assert problem["headers"]["Content-Type"] == "application/fhir+json" + + response_body = json.loads(problem["body"]) + + assert response_body["resourceType"] == "OperationOutcome" + assert "id" in response_body + assert "meta" in response_body + assert "lastUpdated" in response_body["meta"] + + assert len(response_body["issue"]) == 1 + issue = response_body["issue"][0] + + assert issue["severity"] == "error" + assert issue["code"] == "value" + assert issue["diagnostics"] == f"{invalid_include_actions} is not a value that is supported by the API" + assert issue["location"] == ["parameters/includeActions"] + assert "details" in issue + assert "coding" in issue["details"] + assert len(issue["details"]["coding"]) == 1 + coding = issue["details"]["coding"][0] + + assert coding["system"] == "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" + assert coding["code"] == "INVALID_PARAMETER" + assert coding["display"] == "The supplied value was not recognised by the API." From 2e7b2ddd1ea38b4f63a9ece7c4d71a4edc14bf59 Mon Sep 17 00:00:00 2001 From: SeanSteberis <103416906+seansteberisal@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:16:51 +0100 Subject: [PATCH 52/61] Added new tests updated poetry version --- .tool-versions | 2 +- .../storyTestConfigs/AUTO_RSV_ELI-365-1.json | 451 ++++++++++++++++++ .../storyTestData/AUTO_RSV_ELI-320-12.json | 60 --- .../storyTestData/AUTO_RSV_ELI-365_023.json | 10 +- .../storyTestData/AUTO_RSV_ELI-365_024.json | 47 ++ .../storyTestData/AUTO_RSV_ELI-365_025.json | 47 ++ .../storyTestData/AUTO_RSV_ELI-365_026.json | 47 ++ .../storyTestData/AUTO_RSV_ELI-365_027.json | 47 ++ .../AUTO_RSV_ELI-320-12.json | 78 --- .../AUTO_RSV_ELI-365_023.json | 5 + .../AUTO_RSV_ELI-365_024.json | 44 ++ .../AUTO_RSV_ELI-365_025.json | 44 ++ .../AUTO_RSV_ELI-365_026.json | 44 ++ .../AUTO_RSV_ELI-365_027.json | 44 ++ 14 files changed, 829 insertions(+), 141 deletions(-) create mode 100644 tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json delete mode 100644 tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-320-12.json create mode 100644 tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json create mode 100644 tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json create mode 100644 tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json create mode 100644 tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json delete mode 100644 tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-320-12.json create mode 100644 tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json create mode 100644 tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json create mode 100644 tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json create mode 100644 tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json diff --git a/.tool-versions b/.tool-versions index 4ee8707ce..d7ea39018 100644 --- a/.tool-versions +++ b/.tool-versions @@ -3,7 +3,7 @@ terraform 1.12.1 pre-commit 4.2.0 vale 3.11.2 -poetry 2.1.3 +poetry 2.1.4 act 0.2.77 # ============================================================================== diff --git a/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json b/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json new file mode 100644 index 000000000..8a4d67fe2 --- /dev/null +++ b/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json @@ -0,0 +1,451 @@ +{ + "CampaignConfig": { + "ID": "8fcb742b-45fa-4e0d-8f2f-9c2efb1f46d0", + "Version": 1, + "Name": "EliD RSV example config", + "Type": "V", + "Target": "RSV", + "Manager": "example@nhs.net", + "Approver": "example@nhs.net", + "Reviewer": "example@nhs.net", + "IterationFrequency": "X", + "IterationType": "O", + "IterationTime": "07:00:00", + "StartDate": "20250717", + "EndDate": "20350717", + "ApprovalMinimum": 0, + "ApprovalMaximum": 0, + "DefaultCommsRouting": "PLACEHOLDER_COMMS_ROUTING", + "Iterations": [ + { + "ID": "8fcb742b-45fa-4e0d-8f2f-9c2efb1f46d1", + "DefaultCommsRouting": "BOOK_LOCAL|HELP_SUPPORT", + "DefaultNotActionableRouting": "", + "DefaultNotEligibleRouting": "CHECK_CORRECT_X", + "Version": 1, + "Name": "EliD RSV example config", + "IterationDate": "20250717", + "IterationNumber": 1, + "CommsType": "I", + "ApprovalMinimum": 0, + "ApprovalMaximum": 0, + "Type": "O", + "IterationCohorts": [ + { + "CohortLabel": "rsv_75to79", + "CohortGroup": "rsv_age", + "PositiveDescription": "are aged 75 to 79 years old", + "NegativeDescription": "are not aged 75 to 79 years old", + "Priority": 0 + }, + { + "CohortLabel": "rsv_80_since_02_Sept_2024", + "CohortGroup": "rsv_age_catchup", + "PositiveDescription": "turned 80 after 1st September 2024", + "NegativeDescription": "did not turn 80 after 1 September 2024", + "Priority": 10 + }, + { + "CohortLabel": "elid_all_people", + "CohortGroup": "magic_cohort", + "PositiveDescription": "", + "NegativeDescription": "", + "Priority": 20 + } + ], + "IterationRules": [ + { + "Type": "F", + "Name": "Remove from magic cohort unless already vaccinated or have future booking", + "Description": "Remove anyone NOT already vaccinated within the last 25 years and do not have a future booking from the magic cohort", + "Operator": "Y<=", + "Comparator": "-25[[NVL:18000101]]", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100 + }, + { + "Type": "F", + "Name": "Remove from magic cohort unless already vaccinated or have future booking", + "Description": "Remove anyone without a future booking from magic cohort", + "Operator": "D<", + "Comparator": "0[[NVL:18000101]]", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100 + }, + { + "Type": "F", + "Name": "Remove under 75 Years on day of execution", + "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", + "Priority": 120, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-75", + "CohortLabel": "rsv_75to79" + }, + { + "Type": "F", + "Name": "Remove over 80 on day of execution", + "Description": "Exclude anyone who turned 80 before 2nd September 2024", + "Priority": 130, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "<", + "Comparator": "19440902", + "CohortLabel": "rsv_75to79" + }, + { + "Type": "F", + "Name": "Remove under 75 Years on day of execution", + "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", + "Priority": 140, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-75", + "CohortLabel": "rsv_80_since_02_Sept_2024" + }, + { + "Type": "F", + "Name": "Remove over 80 on day of execution", + "Description": "Exclude anyone who turned 80 before 2nd September 2024", + "Priority": 150, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "<", + "Comparator": "19440902", + "CohortLabel": "rsv_80_since_02_Sept_2024" + }, + { + "Type": "F", + "Name": "Remove from rsv 80 cohort if already vaccinated", + "Description": "Remove anyone already vaccinated from 80 cohort", + "Operator": "Y>=", + "Comparator": "-25", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "rsv_80_since_02_Sept_2024", + "Priority": 160 + }, + { + "Type": "F", + "Name": "Remove from rsv 80 cohort if future booking", + "Description": "Remove anyone with a future booking from RSV 80 cohort", + "Operator": "D>=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CohortLabel": "rsv_80_since_02_Sept_2024", + "Priority": 170 + }, + { + "Type": "F", + "Name": "Remove from rsv 75-79 cohort if already vaccinated", + "Description": "Remove anyone already vaccinated from 75-79 cohort", + "Operator": "Y>=", + "Comparator": "-25", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "rsv_75to79", + "Priority": 180 + }, + { + "Type": "F", + "Name": "Remove from rsv 75-79 cohort if future booking", + "Description": "Remove anyone with a future booking from RSV 75-79 cohort", + "Operator": "D>=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CohortLabel": "rsv_75to79", + "Priority": 190 + }, + { + "Type": "S", + "Name": "Already Vaccinated", + "Description": "## You've had your RSV vaccination\n\n We believe you had your vaccination.", + "Priority": 200, + "AttributeLevel": "TARGET", + "AttributeTarget": "RSV", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "Y>=", + "Comparator": "-25", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "Other Setting", + "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", + "Priority": 510, + "AttributeLevel": "PERSON", + "AttributeName": "CARE_HOME_FLAG", + "Operator": "=", + "Comparator": "Y", + "CohortLabel": "rsv_80_since_02_Sept_2024", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "Other Setting", + "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", + "Priority": 520, + "AttributeLevel": "PERSON", + "AttributeName": "DE_FLAG", + "Operator": "=", + "Comparator": "Y", + "CohortLabel": "rsv_80_since_02_Sept_2024", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "Other Setting", + "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", + "Priority": 530, + "AttributeLevel": "PERSON", + "AttributeName": "13Q_FLAG", + "Operator": "=", + "Comparator": "Y", + "CohortLabel": "rsv_80_since_02_Sept_2024", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "Other Setting", + "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", + "Priority": 540, + "AttributeLevel": "PERSON", + "AttributeName": "CARE_HOME_FLAG", + "Operator": "=", + "Comparator": "Y", + "CohortLabel": "rsv_75to79", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "Other Setting with no future booking", + "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", + "Priority": 550, + "AttributeLevel": "PERSON", + "AttributeName": "DE_FLAG", + "Operator": "=", + "Comparator": "Y", + "CohortLabel": "rsv_75to79", + "RuleStop": "Y" + }, + { + "Type": "S", + "Name": "Other Setting", + "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", + "Priority": 560, + "AttributeLevel": "PERSON", + "AttributeName": "13Q_FLAG", + "Operator": "=", + "Comparator": "Y", + "CohortLabel": "rsv_75to79", + "RuleStop": "Y" + }, + { + "Type": "R", + "Name": "Actionable Future Booked NBS Appointment", + "Description": "Amend NBS future booking", + "Priority": 1000, + "Operator": "D>=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CommsRouting": "AMEND_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked NBS Appointment", + "Description": "Amend NBS future booking", + "Priority": 1000, + "Operator": "=", + "Comparator": "NBS", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_PROVIDER", + "CommsRouting": "AMEND_NBS" + }, + { + "Type": "R", + "Name": "Actionable Future Booked Local Appointment", + "Description": "Amend local future booking", + "Priority": 1100, + "Operator": "D>=", + "Comparator": "0", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "BOOKED_APPOINTMENT_DATE", + "CommsRouting": "MANAGE_LOCAL" + }, + { + "Type": "R", + "Name": "Within CP Expansion ICB not 80 plus", + "Description": "Book an appointment on NBS as within CP expansion", + "Priority": 1200, + "Operator": "in", + "Comparator": "QH8,QJG", + "AttributeLevel": "PERSON", + "AttributeName": "ICB", + "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" + }, + { + "Type": "R", + "Name": "Within CP Expansion ICB not 80 plus", + "Description": "Book an appointment on NBS as within CP expansion", + "Priority": 1200, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-80", + "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" + }, + { + "Type": "R", + "Name": "Within CP Expansion Local Authority", + "Description": "Book an appointment on NBS as within CP expansion", + "Priority": 1300, + "Operator": "in", + "Comparator": "E08000028,E08000031,E08000025,E06000016,E06000008,E07000117,E07000120,E08000011,E08000012,E07000122,E07000123,E08000014,E07000126,E08000013,E07000127,E08000015,E07000128", + "AttributeLevel": "PERSON", + "AttributeName": "LOCAL_AUTHORITY", + "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" + }, + { + "Type": "R", + "Name": "Within CP Expansion ICB not 80 plus", + "Description": "Book an appointment on NBS as within CP expansion", + "Priority": 1300, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-80", + "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" + }, + { + "Type": "Y", + "Name": "Already vaccinated default text", + "Description": "Already vaccinated default text", + "Priority": 3000, + "AttributeLevel": "TARGET", + "AttributeTarget": "RSV", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "Operator": "Y>=", + "Comparator": "-25", + "CommsRouting": "CHECK_CORRECT_ALREADY_VACCINATED" + }, + { + "Type": "Y", + "Name": "Other setting default text", + "Description": "Other setting default text", + "Priority": 3100, + "AttributeLevel": "PERSON", + "AttributeName": "CARE_HOME_FLAG", + "Operator": "=", + "Comparator": "Y", + "CommsRouting": "CHECK_CORRECT_OTHER_SETTING" + }, + { + "Type": "Y", + "Name": "Other setting default text", + "Description": "Other setting default text", + "Priority": 3200, + "AttributeLevel": "PERSON", + "AttributeName": "DE_FLAG", + "Operator": "=", + "Comparator": "Y", + "CommsRouting": "CHECK_CORRECT_OTHER_SETTING" + }, + { + "Type": "Y", + "Name": "Other setting default text", + "Description": "Other setting default text", + "Priority": 3300, + "AttributeLevel": "PERSON", + "AttributeName": "13Q_FLAG", + "Operator": "=", + "Comparator": "Y", + "CommsRouting": "CHECK_CORRECT_OTHER_SETTING" + } + ], + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking" + }, + "AMEND_NBS": { + "ExternalRoutingCode": "AmendNBS", + "ActionDescription": "## You have an RSV vaccination appointment\n You can view, change or cancel your appointment below.", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Manage your appointment" + }, + "CONTACT_GP": { + "ExternalRoutingCode": "ContactGP", + "ActionDescription": "Contact your GP", + "ActionType": "InfoText", + "UrlLink": null, + "UrlLabel": "" + }, + "BOOK_LOCAL": { + "ExternalRoutingCode": "BookLocal", + "ActionDescription": "## Getting the vaccine\n\n You can get an RSV vaccination at your GP surgery.\n Your GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText", + "UrlLink": null, + "UrlLabel": "" + }, + "MANAGE_LOCAL": { + "ExternalRoutingCode": "ManageLocal", + "ActionDescription": "## You have an RSV vaccination appointment\n\n Contact your healthcare provider to change or cancel your appointment.", + "ActionType": "CardWithText", + "UrlLink": null, + "UrlLabel": "" + }, + "HELP_SUPPORT": { + "ExternalRoutingCode": "HelpSupportInfo", + "ActionDescription": "## If you think this is incorrect\n\n If you have already had this vaccination or your personal details are wrong, visit our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", + "ActionType": "InfoText", + "UrlLink": null, + "UrlLabel": "" + }, + "CHECK_CORRECT_X": { + "ExternalRoutingCode": "HealthcareProInfo", + "ActionDescription": "## If you think this is incorrect\n\n Speak to your healthcare professional if you think you should be offered this vaccine.\n\nFor anything else, visit our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", + "ActionType": "InfoText", + "UrlLink": null, + "UrlLabel": "" + }, + "CHECK_CORRECT_ALREADY_VACCINATED": { + "ExternalRoutingCode": "AlreadyVaccinatedInfo", + "ActionDescription": "## If you think this is incorrect\n\n If you believe you've not been vaccinated against RSV, speak to your healthcare professional.\n\nFor anything else please see our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", + "ActionType": "InfoText", + "UrlLink": null, + "UrlLabel": "" + }, + "CHECK_CORRECT_OTHER_SETTING": { + "ExternalRoutingCode": "ManagedSettingInfo", + "ActionDescription": "## If you think this is incorrect\n\n If you have already had this vaccination or your personal details are wrong, visit our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", + "ActionType": "InfoText", + "UrlLink": null, + "UrlLabel": "" + } + } + } + ] + } +} diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-320-12.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-320-12.json deleted file mode 100644 index 186f1a402..000000000 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-320-12.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "scenario_name": "ELI-320 - Multiple Category Campaigns - Category=VACCINATIONS,screening", - "request_headers": { - "nhs-login-nhs-number": "9990032012" - }, - "query_params": { - "category": "VACCINATIONS,screening" - }, - "config_filenames": ["AUTO_RSV_ELI-320-COVID.json","AUTO_RSV_ELI-320-MMR.json", "AUTO_RSV_ELI-320-SCREENING-1.json", "AUTO_RSV_ELI-320-SCREENING-2.json"], - "data": [ - { - "NHS_NUMBER": "9990032012", - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [ - { - "COHORT_LABEL": "covid_cohort", - "DATE_JOINED": "20231020" - }, - { - "COHORT_LABEL": "rsv_cohort", - "DATE_JOINED": "20231020" - }, - { - "COHORT_LABEL": "FLU_screening_cohort", - "DATE_JOINED": "20231020" - } - ] - }, - { - "NHS_NUMBER": "9990032012", - "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "19500601", - "GENDER": "0", - "POSTCODE": "SG8 6EG", - "POSTCODE_SECTOR": "SG86", - "POSTCODE_OUTCODE": "SG8", - "MSOA": "E02003792", - "LSOA": "E01018267", - "GP_PRACTICE_CODE": "D81046", - "PCN": "U75549", - "ICB": "QUE", - "COMMISSIONING_REGION": "Y61", - "13Q_FLAG": "N", - "CARE_HOME_FLAG": "N", - "DE_FLAG": "N" - }, - { - "NHS_NUMBER": "9990032012", - "ATTRIBUTE_TYPE": "RSV", - "BOOKED_APPOINTMENT_DATE": "<>", - "LAST_SUCCESSFUL_DATE": "<>" - }, - { - "NHS_NUMBER": "9990032012", - "ATTRIBUTE_TYPE": "COVID", - "BOOKED_APPOINTMENT_DATE": "<>", - "LAST_SUCCESSFUL_DATE": "<>" - } - ] -} diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_023.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_023.json index 6091129e0..a8d19465a 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_023.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_023.json @@ -3,7 +3,9 @@ "request_headers": { "nhs-login-nhs-number": "9900036523" }, - "config_filenames": ["AUTO_RSV_ELI-365.json"], + "config_filenames": [ + "AUTO_RSV_ELI-365.json" + ], "data": [ { "NHS_NUMBER": "9900036523", @@ -12,6 +14,10 @@ { "COHORT_LABEL": "rsv_80_since_02_Sept_2024", "DATE_JOINED": "20231020" + }, + { + "COHORT_LABEL": "rsv_75to79", + "DATE_JOINED": "20231020" } ] }, @@ -38,7 +44,7 @@ "NHS_NUMBER": "9900036523", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, - "BOOKED_APPOINTMENT_DATE" : null, + "BOOKED_APPOINTMENT_DATE": null, "BOOKED_APPOINTMENT_PROVIDER": null } ] diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json new file mode 100644 index 000000000..73ba7b9e6 --- /dev/null +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json @@ -0,0 +1,47 @@ +{ + "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 after 1st September 2024 - ICB", + "request_headers": { + "nhs-login-nhs-number": "9900036524" + }, + "config_filenames": [ + "AUTO_RSV_ELI-365.json" + ], + "data": [ + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + { + "COHORT_LABEL": "rsv_80_since_02_Sept_2024", + "DATE_JOINED": "20231020" + } + ] + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "SG8 6EG", + "POSTCODE_SECTOR": "SG86", + "POSTCODE_OUTCODE": "SG8", + "MSOA": "E02003792", + "LSOA": "E01018267", + "GP_PRACTICE_CODE": "D81046", + "PCN": "U75549", + "ICB": "QH8", + "LOCAL_AUTHORITY": "ZZ8000011", + "COMMISSIONING_REGION": "Y61", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N" + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "RSV", + "LAST_SUCCESSFUL_DATE": null, + "BOOKED_APPOINTMENT_DATE": null, + "BOOKED_APPOINTMENT_PROVIDER": null + } + ] +} diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json new file mode 100644 index 000000000..9c98d32a6 --- /dev/null +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json @@ -0,0 +1,47 @@ +{ + "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 after 1st September 2024 - Local Authority", + "request_headers": { + "nhs-login-nhs-number": "9900036524" + }, + "config_filenames": [ + "AUTO_RSV_ELI-365.json" + ], + "data": [ + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + { + "COHORT_LABEL": "rsv_80_since_02_Sept_2024", + "DATE_JOINED": "20231020" + } + ] + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "SG8 6EG", + "POSTCODE_SECTOR": "SG86", + "POSTCODE_OUTCODE": "SG8", + "MSOA": "E02003792", + "LSOA": "E01018267", + "GP_PRACTICE_CODE": "D81046", + "PCN": "U75549", + "ICB": "zz1", + "LOCAL_AUTHORITY": "E08000014", + "COMMISSIONING_REGION": "Y61", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N" + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "RSV", + "LAST_SUCCESSFUL_DATE": null, + "BOOKED_APPOINTMENT_DATE": null, + "BOOKED_APPOINTMENT_PROVIDER": null + } + ] +} diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json new file mode 100644 index 000000000..a76b66383 --- /dev/null +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json @@ -0,0 +1,47 @@ +{ + "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 before 1st September 2024 - Local Authority", + "request_headers": { + "nhs-login-nhs-number": "9900036524" + }, + "config_filenames": [ + "AUTO_RSV_ELI-365.json" + ], + "data": [ + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + { + "COHORT_LABEL": "rsv_80_since_02_Sept_2024", + "DATE_JOINED": "20231020" + } + ] + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "SG8 6EG", + "POSTCODE_SECTOR": "SG86", + "POSTCODE_OUTCODE": "SG8", + "MSOA": "E02003792", + "LSOA": "E01018267", + "GP_PRACTICE_CODE": "D81046", + "PCN": "U75549", + "ICB": "zz1", + "LOCAL_AUTHORITY": "E08000014", + "COMMISSIONING_REGION": "Y61", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N" + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "RSV", + "LAST_SUCCESSFUL_DATE": null, + "BOOKED_APPOINTMENT_DATE": null, + "BOOKED_APPOINTMENT_PROVIDER": null + } + ] +} diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json new file mode 100644 index 000000000..d1204cf06 --- /dev/null +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json @@ -0,0 +1,47 @@ +{ + "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 before 1st September 2024 - ICB", + "request_headers": { + "nhs-login-nhs-number": "9900036524" + }, + "config_filenames": [ + "AUTO_RSV_ELI-365.json" + ], + "data": [ + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + { + "COHORT_LABEL": "rsv_80_since_02_Sept_2024", + "DATE_JOINED": "20231020" + } + ] + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "SG8 6EG", + "POSTCODE_SECTOR": "SG86", + "POSTCODE_OUTCODE": "SG8", + "MSOA": "E02003792", + "LSOA": "E01018267", + "GP_PRACTICE_CODE": "D81046", + "PCN": "U75549", + "ICB": "QH8", + "LOCAL_AUTHORITY": "ZZ8000014", + "COMMISSIONING_REGION": "Y61", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N" + }, + { + "NHS_NUMBER": "9900036524", + "ATTRIBUTE_TYPE": "RSV", + "LAST_SUCCESSFUL_DATE": null, + "BOOKED_APPOINTMENT_DATE": null, + "BOOKED_APPOINTMENT_PROVIDER": null + } + ] +} diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-320-12.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-320-12.json deleted file mode 100644 index c57312555..000000000 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-320-12.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "meta": { - "lastUpdated": "2025-07-15T14:52:52.785698+00:00" - }, - "processedSuggestions": [ - { - "actions": [], - "condition": "COVID", - "eligibilityCohorts": [ - { - "cohortCode": "covid_cohort_group", - "cohortStatus": "NotActionable", - "cohortText": "You are currently in a covid cohort" - } - ], - "status": "NotActionable", - "statusText": "You should have the COVID vaccine", - "suitabilityRules": [ - { - "ruleCode": "AlreadyVaccinated", - "ruleText": "##You've had your COVID vaccination\nWe believe you already had your COVID vaccination.", - "ruleType": "S" - } - ] - }, - { - "actions": [ - { - "actionCode": "AmendNBS", - "actionType": "ButtonWithAuthLink", - "description": "##You have an flu screening appointment\nYou can view, change or cancel your appointment below.", - "urlLabel": "Manage your appointment", - "urlLink": "http://www.nhs.uk/book-bs" - } - ], - "condition": "FLU", - "eligibilityCohorts": [ - { - "cohortCode": "FLU_screening_cohort_group", - "cohortStatus": "Actionable", - "cohortText": "You are currently in an flu SCREENING cohort" - } - ], - "status": "Actionable", - "statusText": "You should have the FLU vaccine", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "MMR", - "eligibilityCohorts": [ - { - "cohortCode": "mmr_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an mmr cohort" - } - ], - "status": "NotEligible", - "statusText": "We do not believe you can have it", - "suitabilityRules": [] - }, - { - "actions": [], - "condition": "RSV", - "eligibilityCohorts": [ - { - "cohortCode": "rsv_screening_cohort_group", - "cohortStatus": "NotEligible", - "cohortText": "You are not currently in an RSV SCREENING cohort" - } - ], - "status": "NotEligible", - "statusText": "We do not believe you can have it", - "suitabilityRules": [] - } - ], - "responseId": "5c8d1cb3-8326-40b1-93ad-1b7fa24c2595" -} diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_023.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_023.json index a4f4656b8..fe6f609a4 100644 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_023.json +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_023.json @@ -22,6 +22,11 @@ ], "condition": "RSV", "eligibilityCohorts": [ + { + "cohortCode": "rsv_age", + "cohortStatus": "Actionable", + "cohortText": "are aged 75 to 79 years old" + }, { "cohortCode": "rsv_age_catchup", "cohortStatus": "Actionable", diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json new file mode 100644 index 000000000..00ab027ec --- /dev/null +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json @@ -0,0 +1,44 @@ +{ + "meta": { + "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + }, + "processedSuggestions": [ + { + "actions": [ + { + "actionCode": "BookLocal", + "actionType": "InfoText", + "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" + }, + { + "actionCode": "BookNBS", + "actionType": "ButtonWithAuthLink", + "description": "", + "urlLabel": "Continue to booking", + "urlLink": "http://www.nhs.uk/book-rsv" + }, + { + "actionCode": "HelpSupportInfo", + "actionType": "InfoText", + "description": "## CONTENT TBC\n\nBlah blah blah.", + "urlLabel": "", + "urlLink": "" + } + ], + "condition": "RSV", + "eligibilityCohorts": [ + { + "cohortCode": "rsv_age_catchup", + "cohortStatus": "Actionable", + "cohortText": "turned 80 after 1st September 2024" + } + ], + "status": "Actionable", + "statusText": "You should have the RSV vaccine", + "suitabilityRules": [] + } + ], + "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" +} diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json new file mode 100644 index 000000000..00ab027ec --- /dev/null +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json @@ -0,0 +1,44 @@ +{ + "meta": { + "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + }, + "processedSuggestions": [ + { + "actions": [ + { + "actionCode": "BookLocal", + "actionType": "InfoText", + "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" + }, + { + "actionCode": "BookNBS", + "actionType": "ButtonWithAuthLink", + "description": "", + "urlLabel": "Continue to booking", + "urlLink": "http://www.nhs.uk/book-rsv" + }, + { + "actionCode": "HelpSupportInfo", + "actionType": "InfoText", + "description": "## CONTENT TBC\n\nBlah blah blah.", + "urlLabel": "", + "urlLink": "" + } + ], + "condition": "RSV", + "eligibilityCohorts": [ + { + "cohortCode": "rsv_age_catchup", + "cohortStatus": "Actionable", + "cohortText": "turned 80 after 1st September 2024" + } + ], + "status": "Actionable", + "statusText": "You should have the RSV vaccine", + "suitabilityRules": [] + } + ], + "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" +} diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json new file mode 100644 index 000000000..00ab027ec --- /dev/null +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json @@ -0,0 +1,44 @@ +{ + "meta": { + "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + }, + "processedSuggestions": [ + { + "actions": [ + { + "actionCode": "BookLocal", + "actionType": "InfoText", + "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" + }, + { + "actionCode": "BookNBS", + "actionType": "ButtonWithAuthLink", + "description": "", + "urlLabel": "Continue to booking", + "urlLink": "http://www.nhs.uk/book-rsv" + }, + { + "actionCode": "HelpSupportInfo", + "actionType": "InfoText", + "description": "## CONTENT TBC\n\nBlah blah blah.", + "urlLabel": "", + "urlLink": "" + } + ], + "condition": "RSV", + "eligibilityCohorts": [ + { + "cohortCode": "rsv_age_catchup", + "cohortStatus": "Actionable", + "cohortText": "turned 80 after 1st September 2024" + } + ], + "status": "Actionable", + "statusText": "You should have the RSV vaccine", + "suitabilityRules": [] + } + ], + "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" +} diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json new file mode 100644 index 000000000..00ab027ec --- /dev/null +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json @@ -0,0 +1,44 @@ +{ + "meta": { + "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + }, + "processedSuggestions": [ + { + "actions": [ + { + "actionCode": "BookLocal", + "actionType": "InfoText", + "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" + }, + { + "actionCode": "BookNBS", + "actionType": "ButtonWithAuthLink", + "description": "", + "urlLabel": "Continue to booking", + "urlLink": "http://www.nhs.uk/book-rsv" + }, + { + "actionCode": "HelpSupportInfo", + "actionType": "InfoText", + "description": "## CONTENT TBC\n\nBlah blah blah.", + "urlLabel": "", + "urlLink": "" + } + ], + "condition": "RSV", + "eligibilityCohorts": [ + { + "cohortCode": "rsv_age_catchup", + "cohortStatus": "Actionable", + "cohortText": "turned 80 after 1st September 2024" + } + ], + "status": "Actionable", + "statusText": "You should have the RSV vaccine", + "suitabilityRules": [] + } + ], + "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" +} From bb4aee55f24f06c6e2866825286188ab5a39c87b Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:18:59 +0100 Subject: [PATCH 53/61] ELI-399: Fixing start date validation (#287) * ELI-399: Fixing start date validation * ELI-399: Fixing annotation --- .../model/campaign_config.py | 15 ------ .../validators/campaign_config_validator.py | 20 +++++++- tests/integration/conftest.py | 47 ++++++++++++++++++- .../lambda/test_app_running_as_lambda.py | 38 +++++++++++++++ tests/unit/model/test_campaign_config.py | 14 ------ 5 files changed, 103 insertions(+), 31 deletions(-) diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index cc67fbc98..989f2e53d 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -228,21 +228,6 @@ def check_no_overlapping_iterations(self) -> typing.Self: raise ValueError(message) return self - @model_validator(mode="after") - def check_has_iteration_from_start(self) -> typing.Self: - iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_date")) - if first_iteration := next(iter(iterations_by_date), None): - if first_iteration.iteration_date > self.start_date: - message = ( - f"campaign {self.id} starts on {self.start_date}, " - f"1st iteration starts later - {first_iteration.iteration_date}" - ) - raise ValueError(message) - return self - # Should never happen, since we are constraining self.iterations with a min_length of 1 - message = f"campaign {self.id} has no iterations." - raise ValueError(message) - @cached_property def campaign_live(self) -> bool: today = datetime.now(tz=UTC).date() diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index f25d740ff..61e7df691 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -1,4 +1,7 @@ -from pydantic import field_validator +import typing +from operator import attrgetter + +from pydantic import field_validator, model_validator from eligibility_signposting_api.model.campaign_config import CampaignConfig, Iteration from rules_validation_api.validators.iteration_validator import IterationValidation @@ -9,3 +12,18 @@ class CampaignConfigValidation(CampaignConfig): @field_validator("iterations") def validate_iterations(cls, iterations: list[Iteration]) -> list[IterationValidation]: return [IterationValidation(**i.model_dump()) for i in iterations] + + @model_validator(mode="after") + def check_has_iteration_from_start(self) -> typing.Self: + iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_date")) + if first_iteration := next(iter(iterations_by_date), None): + if first_iteration.iteration_date > self.start_date: + message = ( + f"campaign {self.id} starts on {self.start_date}, " + f"1st iteration starts later - {first_iteration.iteration_date}" + ) + raise ValueError(message) + return self + # Should never happen, since we are constraining self.iterations with a min_length of 1 + message = f"campaign {self.id} has no iterations." + raise ValueError(message) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8b1070f0f..59d696121 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,3 +1,4 @@ +import datetime import json import logging import os @@ -19,7 +20,9 @@ from eligibility_signposting_api.model import eligibility_status from eligibility_signposting_api.model.campaign_config import ( CampaignConfig, + EndDate, RuleType, + StartDate, ) from eligibility_signposting_api.repos.campaign_repo import BucketName from eligibility_signposting_api.repos.person_repo import TableName @@ -380,7 +383,7 @@ def persisted_person_all_cohorts(person_table: Any, faker: Faker) -> Generator[e nhs_number, date_of_birth=date_of_birth, postcode="SW19", - cohorts=["cohort_label1", "cohort_label2", "cohort_label3", "cohort_label4"], + cohorts=["cohort_label1", "cohort_label2", "cohort_label3", "cohort_label4", "cohort_label5"], icb="QE1", ).data ): @@ -484,6 +487,48 @@ def campaign_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generato s3_client.delete_object(Bucket=rules_bucket, Key=f"{campaign.name}.json") +@pytest.fixture(scope="class") +def inactive_iteration_config(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[list[CampaignConfig]]: + campaigns, campaign_data_keys = [], [] + + target_iteration_dates = { + "start_date": ("RSV", datetime.date(2025, 1, 1)), # Active Iteration Date + "start_date_plus_one_day": ("COVID", datetime.date(2025, 1, 2)), # Active Iteration Date + "today": ("FLU", datetime.date(2025, 8, 8)), # Active Iteration Date + "tomorrow": ("MMR", datetime.date(2025, 8, 9)), # Inactive Iteration Date + } + + for target, data in target_iteration_dates.items(): + campaign = rule.CampaignConfigFactory.build( + id=f"campaign_{target}", + target=data[0], + type="V", + iterations=[ + rule.IterationFactory.build( + iteration_rules=[rule.PersonAgeSuppressionRuleFactory.build()], + iteration_cohorts=[rule.IterationCohortFactory.build(cohort_label="cohort_label1")], + ) + ], + ) + + campaign.start_date = StartDate(datetime.date(2025, 1, 1)) + campaign.end_date = EndDate(datetime.date(2026, 1, 1)) + campaign.iterations[0].iteration_date = data[1] + + campaign_data = {"CampaignConfig": campaign.model_dump(by_alias=True)} + key = f"{campaign.name}.json" + s3_client.put_object( + Bucket=rules_bucket, Key=key, Body=json.dumps(campaign_data), ContentType="application/json" + ) + campaigns.append(campaign) + campaign_data_keys.append(key) + + yield campaigns + + for key in campaign_data_keys: + s3_client.delete_object(Bucket=rules_bucket, Key=key) + + @pytest.fixture(scope="class") def campaign_config_with_and_rule(s3_client: BaseClient, rules_bucket: BucketName) -> Generator[CampaignConfig]: campaign: CampaignConfig = rule.CampaignConfigFactory.build( diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index b4cb61744..f4dcfc95e 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -10,6 +10,7 @@ from brunns.matchers.data import json_matching as is_json_that from brunns.matchers.response import is_response from faker import Faker +from freezegun import freeze_time from hamcrest import ( assert_that, contains_exactly, @@ -540,3 +541,40 @@ def test_given_person_has_unique_status_for_different_conditions_with_audit( # assert_that(audit_data["response"]["responseId"], is_not(equal_to(""))) assert_that(audit_data["response"]["lastUpdated"], is_not(equal_to(""))) assert_that(audit_data["response"]["condition"], contains_inanyorder(*expected_conditions)) + + +@freeze_time("2025-08-08") +def test_no_active_iteration_returns_empty_processed_suggestions( + lambda_client: BaseClient, # noqa:ARG001 + persisted_person_all_cohorts: NHSNumber, + inactive_iteration_config: list[CampaignConfig], # noqa:ARG001 + api_gateway_endpoint: URL, +): + invoke_url = f"{api_gateway_endpoint}/patient-check/{persisted_person_all_cohorts}" + response = httpx.get( + invoke_url, + headers={ + "nhs-login-nhs-number": str(persisted_person_all_cohorts), + "x_request_id": "x_request_id", + "x_correlation_id": "x_correlation_id", + "nhsd_end_user_organisation_ods": "nhsd_end_user_organisation_ods", + "nhsd_application_id": "nhsd_application_id", + }, + params={"includeActions": "Y", "category": "VACCINATIONS", "conditions": "COVID,FLU,RSV"}, + timeout=10, + ) + + assert_that( + response, + is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))), + ) + + body = response.json() + assert_that( + body["processedSuggestions"], + contains_inanyorder( + has_entries("condition", "COVID"), + has_entries("condition", "RSV"), + has_entries("condition", "FLU"), + ), + ) diff --git a/tests/unit/model/test_campaign_config.py b/tests/unit/model/test_campaign_config.py index bab1aa102..dbeebc73f 100644 --- a/tests/unit/model/test_campaign_config.py +++ b/tests/unit/model/test_campaign_config.py @@ -52,20 +52,6 @@ def test_iteration_with_overlapping_start_dates_not_allowed(faker: Faker): RawCampaignConfigFactory.build(start_date=start_date, iterations=[iteration1, iteration2]) -def test_iteration_must_have_active_iteration_from_its_start(faker: Faker): - # Given - start_date = faker.date_object() - iteration = IterationFactory.build(iteration_date=start_date + relativedelta(days=1)) - - # When, Then - with pytest.raises( - ValueError, - match=r"1 validation error for CampaignConfig\n" - r".*1st iteration starts later", - ): - RawCampaignConfigFactory.build(start_date=start_date, iterations=[iteration]) - - @pytest.mark.parametrize( ("rule_stop", "expected"), [ From dca12d85c8a54463c57d88b58b050cdc62c96b65 Mon Sep 17 00:00:00 2001 From: Karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:47:51 +0100 Subject: [PATCH 54/61] ELI-328: cohort validations (#281) * test action mapper doesn't accept invalid actions * Attribute level and name relations when it is cohort * added iteration_cohorts_validation * chainging the validations * fix * fix * fix lint * fix lint * lint fixes * lint fixes * test fixes * lint fixes * Removed defaultcomms from iteration level of test config. * Reorder feilds in config. * Update to config. * default comm routing validation * unit tests default comm routing validation * default comm routing validation in rules * lint fixes * test data fixed * grouped model validators --------- Co-authored-by: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> --- .../validators/actions_mapper_validator.py | 4 +- .../validators/campaign_config_validator.py | 2 +- .../validators/iteration_cohort_validator.py | 5 + .../validators/iteration_rules_validator.py | 17 +- .../validators/iteration_validator.py | 149 +++++++++-- .../validators/rules_validator.py | 2 +- tests/test_data/test_config/test_config.json | 29 +-- tests/unit/validation/conftest.py | 16 +- .../test_actions_mapper_validator.py | 25 +- .../test_iteration_cohorts_validator.py | 65 +++++ .../test_iteration_rules_validator.py | 58 +++-- .../validation/test_iteration_validator.py | 242 +++++++++++++++++- 12 files changed, 529 insertions(+), 85 deletions(-) create mode 100644 src/rules_validation_api/validators/iteration_cohort_validator.py create mode 100644 tests/unit/validation/test_iteration_cohorts_validator.py diff --git a/src/rules_validation_api/validators/actions_mapper_validator.py b/src/rules_validation_api/validators/actions_mapper_validator.py index a6eafaf87..200b056d1 100644 --- a/src/rules_validation_api/validators/actions_mapper_validator.py +++ b/src/rules_validation_api/validators/actions_mapper_validator.py @@ -3,9 +3,9 @@ from eligibility_signposting_api.model.campaign_config import ActionsMapper -class ActionsMapperValidator(ActionsMapper): +class ActionsMapperValidation(ActionsMapper): @model_validator(mode="after") - def validate_keys(self) -> "ActionsMapperValidator": + def validate_keys(self) -> "ActionsMapperValidation": invalid_keys = [key for key in self.root if key is None or key == ""] if invalid_keys: msg = f"Invalid keys found in ActionsMapper: {invalid_keys}" diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index 61e7df691..94b007346 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -8,8 +8,8 @@ class CampaignConfigValidation(CampaignConfig): - @classmethod @field_validator("iterations") + @classmethod def validate_iterations(cls, iterations: list[Iteration]) -> list[IterationValidation]: return [IterationValidation(**i.model_dump()) for i in iterations] diff --git a/src/rules_validation_api/validators/iteration_cohort_validator.py b/src/rules_validation_api/validators/iteration_cohort_validator.py new file mode 100644 index 000000000..32e1a4b3a --- /dev/null +++ b/src/rules_validation_api/validators/iteration_cohort_validator.py @@ -0,0 +1,5 @@ +from eligibility_signposting_api.model.campaign_config import IterationCohort + + +class IterationCohortValidation(IterationCohort): + pass diff --git a/src/rules_validation_api/validators/iteration_rules_validator.py b/src/rules_validation_api/validators/iteration_rules_validator.py index 95d8dce66..341a08c1f 100644 --- a/src/rules_validation_api/validators/iteration_rules_validator.py +++ b/src/rules_validation_api/validators/iteration_rules_validator.py @@ -1,5 +1,18 @@ -from eligibility_signposting_api.model.campaign_config import IterationRule +from typing import Self + +from pydantic import model_validator + +from eligibility_signposting_api.model.campaign_config import IterationRule, RuleAttributeLevel, RuleAttributeName class IterationRuleValidation(IterationRule): - pass + @model_validator(mode="after") + def check_cohort_attribute_name(self) -> Self: + if ( + self.attribute_level == RuleAttributeLevel.COHORT + and self.attribute_name + and self.attribute_name != RuleAttributeName("COHORT_LABEL") + ): + msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" + raise ValueError(msg) + return self diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 3bfd3fec5..c16286ab2 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -1,37 +1,152 @@ import typing -from pydantic import ValidationError, field_validator, model_validator +from pydantic import Field, ValidationError, field_validator, model_validator from pydantic_core import InitErrorDetails -from eligibility_signposting_api.model.campaign_config import ActionsMapper, Iteration, IterationRule -from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator +from eligibility_signposting_api.model.campaign_config import ( + ActionsMapper, + Iteration, + IterationCohort, + IterationRule, + RuleType, +) +from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidation +from rules_validation_api.validators.iteration_cohort_validator import IterationCohortValidation from rules_validation_api.validators.iteration_rules_validator import IterationRuleValidation class IterationValidation(Iteration): - @classmethod + iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts") + iteration_rules: list[IterationRule] = Field(..., alias="IterationRules") + actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper") + @field_validator("iteration_rules") - def validate_iterations(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: + @classmethod + def validate_iteration_rules(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: return [IterationRuleValidation(**i.model_dump()) for i in iteration_rules] + @field_validator("iteration_cohorts") @classmethod + def validate_iteration_cohorts(cls, iteration_cohorts: list[IterationCohort]) -> list[IterationCohortValidation]: + return [IterationCohortValidation(**i.model_dump()) for i in iteration_cohorts] + @field_validator("actions_mapper", mode="after") + @classmethod def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper: - ActionsMapperValidator.model_validate(action_mapper.model_dump()) + ActionsMapperValidation.model_validate(action_mapper.model_dump()) return action_mapper @model_validator(mode="after") + def action_mapper_validation(self) -> typing.Self: + all_errors = [] + + for validator in [ + self.validate_default_comms_routing_in_actions_mapper, + self.validate_default_not_eligible_routing_in_actions_mapper, + self.validate_default_not_actionable_routing_in_actions_mapper, + self.validate_iteration_rules_against_actions_mapper, + ]: + try: + validator() + except ValidationError as ve: + all_errors.extend(ve.errors(include_input=False)) + + if all_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=all_errors) + + return self + def validate_default_comms_routing_in_actions_mapper(self) -> typing.Self: - default_routing = self.default_comms_routing - actions_mapper = self.actions_mapper.root.keys() - - if default_routing and (not actions_mapper or default_routing not in actions_mapper): - error = InitErrorDetails( - type="value_error", - loc=("actions_mapper",), - input=actions_mapper, - ctx={"error": f"Missing entry for DefaultCommsRouting '{default_routing}' in ActionsMapper"}, - ) - raise ValidationError.from_exception_data(title="IterationValidation", line_errors=[error]) + default_routes = self.default_comms_routing + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for routing in default_routes.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_keys, + ctx={"error": f"Missing entry for DefaultCommsRouting '{cleaned_routing}' in ActionsMapper"}, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) + + return self + + def validate_default_not_eligible_routing_in_actions_mapper(self) -> typing.Self: + default_not_eligibile_routes = self.default_not_eligible_routing + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for routing in default_not_eligibile_routes.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_keys, + ctx={"error": f"Missing entry for DefaultNotEligibleRouting '{cleaned_routing}' in ActionsMapper"}, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) + + return self + + def validate_default_not_actionable_routing_in_actions_mapper(self) -> typing.Self: + default_not_actionable_routes = self.default_not_actionable_routing + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for routing in default_not_actionable_routes.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_keys, + ctx={ + "error": f"Missing entry for DefaultNotActionableRouting '{cleaned_routing}' in ActionsMapper" + }, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) + + return self + + def validate_iteration_rules_against_actions_mapper(self) -> typing.Self: + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for rule in self.iteration_rules: + if ( + rule.type + in [ + RuleType.redirect, + RuleType.not_actionable_actions, + RuleType.not_eligible_actions, + ] + and rule.comms_routing + ): + for routing in rule.comms_routing.split("|"): + cleaned_routing = routing.strip() + if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("iteration_rules",), + input=actions_keys, + ctx={"error": f"Missing entry for CommsRouting '{cleaned_routing}' in ActionsMapper"}, + ) + line_errors.append(error) + + if line_errors: + raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) return self diff --git a/src/rules_validation_api/validators/rules_validator.py b/src/rules_validation_api/validators/rules_validator.py index 8d52a545b..cacb143d0 100644 --- a/src/rules_validation_api/validators/rules_validator.py +++ b/src/rules_validation_api/validators/rules_validator.py @@ -5,7 +5,7 @@ class RulesValidation(Rules): - @classmethod @field_validator("campaign_config") + @classmethod def validate_campaign_config(cls, campaign_config: CampaignConfig) -> CampaignConfig: return CampaignConfigValidation(**campaign_config.model_dump()) diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index fe7b41ed6..8f9cb1445 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -8,13 +8,24 @@ "Manager": ["person@test.com"], "Approver": ["person@test.com"], "Reviewer": ["person@test.com"], + "StartDate": "20250101", + "EndDate": "20260101", + "ApprovalMinimum": 1, + "ApprovalMaximum": 5000000, "IterationFrequency": "X", "IterationType": "M", "IterationTime": "07:00:00", - "DefaultCommsRouting": "Default_Comms_1", "Iterations": [ { "ID": "id_100", + "Version": "1", + "Name": "Test Config", + "Type": "M", + "IterationDate": "20250101", + "IterationNumber": 1, + "CommsType": "R", + "ApprovalMinimum": 1, + "ApprovalMaximum": 5000000, "DefaultCommsRouting": "INTERNALCONTACTGP1", "DefaultNotActionableRouting": "INTERNALCONTACTGP1", "DefaultNotEligibleRouting": "INTERNALCONTACTGP1", @@ -119,20 +130,8 @@ "Comparator": "19000101", "CommsRouting": "YRULEID1|INTERNALTESCO" } - ], - "Version": "1", - "Name": "Test Config", - "Type": "M", - "IterationDate": "20250101", - "IterationNumber": 1, - "CommsType": "R", - "ApprovalMinimum": 1, - "ApprovalMaximum": 5000000 + ] } - ], - "StartDate": "20250101", - "EndDate": "20260101", - "ApprovalMinimum": 1, - "ApprovalMaximum": 5000000 + ] } } diff --git a/tests/unit/validation/conftest.py b/tests/unit/validation/conftest.py index efd625215..cb711c20d 100644 --- a/tests/unit/validation/conftest.py +++ b/tests/unit/validation/conftest.py @@ -23,20 +23,12 @@ def valid_campaign_config_with_only_mandatory_fields(): "ApprovalMinimum": 10, "ApprovalMaximum": 100, "Type": "A", - "DefaultCommsRouting": "BOOK_NBS", - "DefaultNotEligibleRouting": "RouteB", - "DefaultNotActionableRouting": "RouteC", + "DefaultCommsRouting": "", + "DefaultNotEligibleRouting": "", + "DefaultNotActionableRouting": "", "IterationCohorts": [], "IterationRules": [], - "ActionsMapper": { - "BOOK_NBS": { - "ExternalRoutingCode": "BookNBS", - "ActionDescription": "", - "ActionType": "ButtonWithAuthLink", - "UrlLink": "http://www.nhs.uk/book-rsv", - "UrlLabel": "Continue to booking", - } - }, + "ActionsMapper": {}, } ], } diff --git a/tests/unit/validation/test_actions_mapper_validator.py b/tests/unit/validation/test_actions_mapper_validator.py index e14989e90..89af4958f 100644 --- a/tests/unit/validation/test_actions_mapper_validator.py +++ b/tests/unit/validation/test_actions_mapper_validator.py @@ -2,7 +2,7 @@ from pydantic import ValidationError from eligibility_signposting_api.model.campaign_config import AvailableAction -from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator +from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidation @pytest.fixture @@ -25,16 +25,31 @@ def test_valid_actions_mapper(self, valid_available_action): "action1": self.make_action(valid_available_action), "action2": self.make_action({**valid_available_action, "ExternalRoutingCode": "AltCode"}), } - mapper = ActionsMapperValidator(root=data) + mapper = ActionsMapperValidation(root=data) expected_action_count = 2 - assert isinstance(mapper, ActionsMapperValidator) + assert isinstance(mapper, ActionsMapperValidation) assert len(mapper.root) == expected_action_count + @pytest.mark.parametrize( + "invalid_action", + [ + {"action1": ""}, + {"action1": "invalid_action"}, + {"action3": None}, + {"action1": "", "action3": None}, + {"action1": "invalid_action", "action2": ""}, + ], + ) + def test_if_exception_raised_when_adding_invalid_actions_to_action_mapper(self, invalid_action): + data = {"": invalid_action} + with pytest.raises(ValidationError): + ActionsMapperValidation(root=data) + def test_invalid_actions_mapper_empty_key(self, valid_available_action): data = {"": self.make_action(valid_available_action), "action2": self.make_action(valid_available_action)} with pytest.raises(ValidationError) as exc_info: - ActionsMapperValidator(root=data) + ActionsMapperValidation(root=data) assert "Invalid keys found in ActionsMapper" in str(exc_info.value) assert "['']" in str(exc_info.value) @@ -45,5 +60,5 @@ def test_invalid_keys_parametrized(self, bad_key, valid_available_action): "valid_key": self.make_action(valid_available_action), } with pytest.raises(ValidationError) as exc_info: - ActionsMapperValidator(root=data) + ActionsMapperValidation(root=data) assert "Invalid keys found in ActionsMapper" in str(exc_info.value) diff --git a/tests/unit/validation/test_iteration_cohorts_validator.py b/tests/unit/validation/test_iteration_cohorts_validator.py new file mode 100644 index 000000000..2b8c2ac4c --- /dev/null +++ b/tests/unit/validation/test_iteration_cohorts_validator.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError + +from rules_validation_api.validators.iteration_cohort_validator import IterationCohortValidation + + +class TestMandatoryFieldsSchemaValidations: + def test_missing_cohort_label_raises_error(self): + data = {"CohortGroup": "rsv_age_rolling"} + with pytest.raises(ValidationError) as exc_info: + IterationCohortValidation(**data) + assert "CohortLabel" in str(exc_info.value) + + def test_missing_cohort_group_raises_error(self): + data = {"CohortLabel": "rsv_75_rolling"} + with pytest.raises(ValidationError) as exc_info: + IterationCohortValidation(**data) + assert "CohortGroup" in str(exc_info.value) + + def test_valid_with_only_mandatory_fields(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling"} + cohort = IterationCohortValidation(**data) + assert cohort.cohort_label == "rsv_75_rolling" + assert cohort.cohort_group == "rsv_age_rolling" + + +class TestOptionalFieldsSchemaValidations: + def test_positive_description_can_be_none(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "PositiveDescription": None} + cohort = IterationCohortValidation(**data) + assert cohort.positive_description is None + + def test_negative_description_can_be_none(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "NegativeDescription": None} + cohort = IterationCohortValidation(**data) + assert cohort.negative_description is None + + def test_priority_can_be_none(self): + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "Priority": None} + cohort = IterationCohortValidation(**data) + assert cohort.priority is None + + def test_positive_description_accepts_valid_value(self): + data = { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_rolling", + "PositiveDescription": "Eligible for benefits", + } + cohort = IterationCohortValidation(**data) + assert cohort.positive_description == "Eligible for benefits" + + def test_negative_description_accepts_valid_value(self): + data = { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_rolling", + "NegativeDescription": "Not eligible", + } + cohort = IterationCohortValidation(**data) + assert cohort.negative_description == "Not eligible" + + def test_priority_accepts_valid_value(self): + cohort_priority = 10 + data = {"CohortLabel": "rsv_75_rolling", "CohortGroup": "rsv_age_rolling", "Priority": cohort_priority} + cohort = IterationCohortValidation(**data) + assert cohort.priority == cohort_priority diff --git a/tests/unit/validation/test_iteration_rules_validator.py b/tests/unit/validation/test_iteration_rules_validator.py index af7405cce..fd544528e 100644 --- a/tests/unit/validation/test_iteration_rules_validator.py +++ b/tests/unit/validation/test_iteration_rules_validator.py @@ -84,6 +84,7 @@ def test_invalid_priority(self, priority_value, valid_iteration_rule_with_only_m def test_valid_attribute_level(self, attribute_level, valid_iteration_rule_with_only_mandatory_fields): data = valid_iteration_rule_with_only_mandatory_fields.copy() data["AttributeLevel"] = attribute_level + data["AttributeName"] = None # Ignoring the validation constraint btw AttributeLevel and AttributeName result = IterationRuleValidation(**data) assert result.attribute_level == attribute_level @@ -91,6 +92,7 @@ def test_valid_attribute_level(self, attribute_level, valid_iteration_rule_with_ def test_invalid_attribute_level(self, attribute_level, valid_iteration_rule_with_only_mandatory_fields): data = valid_iteration_rule_with_only_mandatory_fields.copy() data["AttributeLevel"] = attribute_level + data["AttributeName"] = None # Ignoring the validation constraint btw AttributeLevel and AttributeName with pytest.raises(ValidationError): IterationRuleValidation(**data) @@ -122,6 +124,27 @@ def test_invalid_comparator(self, comparator_value, valid_iteration_rule_with_on with pytest.raises(ValidationError): IterationRuleValidation(**data) + @pytest.mark.parametrize( + ("rule_stop_input", "expected_bool"), + [ + (True, True), + (False, False), + ("Y", True), + ("N", False), + ("YES", False), + ("NO", False), + ("YEAH", False), + ("ONE", False), + ], + ) + def test_rule_stop_boolean_resolution( + self, rule_stop_input, expected_bool, valid_iteration_rule_with_only_mandatory_fields + ): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["RuleStop"] = rule_stop_input + result = IterationRuleValidation(**data) + assert result.rule_stop is expected_bool + class TestOptionalFieldsSchemaValidations: # AttributeName @@ -201,23 +224,24 @@ def test_invalid_comms_routing(self, routing_value, valid_iteration_rule_with_on class TestBUCValidations: - @pytest.mark.parametrize( - ("rule_stop_input", "expected_bool"), - [ - (True, True), - (False, False), - ("Y", True), - ("N", False), - ("YES", False), - ("NO", False), - ("YEAH", False), - ("ONE", False), - ], - ) - def test_rule_stop_boolean_resolution( - self, rule_stop_input, expected_bool, valid_iteration_rule_with_only_mandatory_fields + @pytest.mark.parametrize("attribute_name", [None, "", "COHORT_LABEL"]) + def test_valid_when_attribute_level_is_cohort_then_attribute_name_should_be_none_or_cohort_label( + self, attribute_name, valid_iteration_rule_with_only_mandatory_fields ): data = valid_iteration_rule_with_only_mandatory_fields.copy() - data["RuleStop"] = rule_stop_input + data["AttributeLevel"] = "COHORT" + data["AttributeName"] = attribute_name result = IterationRuleValidation(**data) - assert result.rule_stop is expected_bool + assert result.attribute_name == attribute_name + + @pytest.mark.parametrize("attribute_name", ["LAST_SUCCESSFUL_DATE", "cohort_label"]) + def test_invalid_when_attribute_level_is_cohort_but_attribute_name_is_neither_none_nor_cohort_label( + self, attribute_name, valid_iteration_rule_with_only_mandatory_fields + ): + data = valid_iteration_rule_with_only_mandatory_fields.copy() + data["AttributeLevel"] = "COHORT" + data["AttributeName"] = attribute_name + with pytest.raises(ValidationError) as error: + IterationRuleValidation(**data) + msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" + assert msg in str(error.value) diff --git a/tests/unit/validation/test_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py index 8e58e5a38..bc2d54633 100644 --- a/tests/unit/validation/test_iteration_validator.py +++ b/tests/unit/validation/test_iteration_validator.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime +from typing import ClassVar import pytest from pydantic import ValidationError @@ -86,28 +87,55 @@ def test_valid_default_comms_routing(self, routing_value, valid_campaign_config_ data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultCommsRouting": routing_value, + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + }, } model = IterationValidation(**data) assert model.default_comms_routing == routing_value # DefaultNotEligibleRouting - @pytest.mark.parametrize("routing_value", ["RouteB", "NotEligComm", "NoComms"]) + @pytest.mark.parametrize("routing_value", ["", "BOOK_NBS"]) def test_valid_default_not_eligible_routing(self, routing_value, valid_campaign_config_with_only_mandatory_fields): data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotEligibleRouting": routing_value, + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + }, } model = IterationValidation(**data) assert model.default_not_eligible_routing == routing_value # DefaultNotActionableRouting - @pytest.mark.parametrize("routing_value", ["RouteC", "HoldComm", "Inactive"]) + @pytest.mark.parametrize("routing_value", ["", "BOOK_NBS"]) def test_valid_default_not_actionable_routing( self, routing_value, valid_campaign_config_with_only_mandatory_fields ): data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotActionableRouting": routing_value, + "ActionsMapper": { + "BOOK_NBS": { + "ExternalRoutingCode": "BookNBS", + "ActionDescription": "", + "ActionType": "ButtonWithAuthLink", + "UrlLink": "http://www.nhs.uk/book-rsv", + "UrlLabel": "Continue to booking", + } + }, } model = IterationValidation(**data) assert model.default_not_actionable_routing == routing_value @@ -154,25 +182,51 @@ def test_approval_maximum(self, approval_maximum, valid_campaign_config_with_onl class TestIterationCohortsSchemaValidations: + book_local_1_action: ClassVar[dict] = { + "ExternalRoutingCode": "BookLocal_1", + "ActionDescription": "##Getting the vaccine\n" + "You can get an RSV vaccination at your GP surgery.\n" + "Your GP surgery may contact you about getting the RSV vaccine. " + "This may be by letter, text, phone call, email or through the NHS App. " + "You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText", + } + + book_local_2_action: ClassVar[dict] = { + "ExternalRoutingCode": "BookLocal_2", + "ActionDescription": "##Getting the vaccine\n" + "You can get an RSV vaccination at your GP surgery.\n" + "Your GP surgery may contact you about getting the RSV vaccine. " + "This may be by letter, text, phone call, email or through the NHS App. " + "You do not need to wait to be contacted before booking your vaccination.", + "ActionType": "InfoText", + } + def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_routing_key( self, valid_campaign_config_with_only_mandatory_fields ): - expected_action = { - "ExternalRoutingCode": "BookLocal", - "ActionDescription": "##Getting the vaccine\n" - "You can get an RSV vaccination at your GP surgery.\n" - "Your GP surgery may contact you about getting the RSV vaccine. " - "This may be by letter, text, phone call, email or through the NHS App. " - "You do not need to wait to be contacted before booking your vaccination.", - "ActionType": "InfoText", + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action, "BOOK_LOCAL_2": self.book_local_2_action}, } + IterationValidation(**data) + def test_invalid_iteration_if_actions_mapper_has_doesnt_have_entries_for_every_default_not_default_routing_keys( + self, valid_campaign_config_with_only_mandatory_fields + ): data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], - "DefaultCommsRouting": "BOOK_LOCAL", - "ActionsMapper": {"BOOK_LOCAL": expected_action}, + "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, } - IterationValidation(**data) + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL_2" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL_2 entry in ActionsMapper" + ) def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_default_routing_key( self, valid_campaign_config_with_only_mandatory_fields @@ -190,3 +244,165 @@ def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_defau assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL" in str(e["msg"]) for e in errors), ( "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" ) + + def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_not_eligible_routing_key( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action, "BOOK_LOCAL_2": self.book_local_2_action}, + } + IterationValidation(**data) + + def test_invalid_iteration_if_actions_mapper_has_doesnt_have_entries_for_every_default_not_eligible_routing_keys( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, + } + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL_2" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL_2 entry in ActionsMapper" + ) + + def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_default_not_eligible_routing( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": "BOOK_LOCAL", + "ActionsMapper": {}, + } # Missing BOOK_LOCAL in ActionsMapper + + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" + ) + + def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_not_actionable_routing_key( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action, "BOOK_LOCAL_2": self.book_local_2_action}, + } + IterationValidation(**data) + + def test_invalid_iteration_if_actions_mapper_has_doesnt_have_entries_for_every_default_not_actionable_routing_keys( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, + } + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL_2" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL_2 entry in ActionsMapper" + ) + + def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_default_not_actionable_routing( + self, valid_campaign_config_with_only_mandatory_fields + ): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL", + "ActionsMapper": {}, + } # Missing BOOK_LOCAL in ActionsMapper + + with pytest.raises(ValidationError) as error: + IterationValidation(**data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "actions_mapper" and "BOOK_LOCAL" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" + ) + + @pytest.mark.parametrize("rule_type", ["R", "X", "Y", "F"]) + @pytest.mark.parametrize( + ("default_routing", "actions_mapper"), + [ + ("BOOK_LOCAL_1|BOOK_LOCAL_2", {"BOOK_LOCAL_1": book_local_1_action, "BOOK_LOCAL_2": book_local_2_action}), + ("BOOK_LOCAL_1", {"BOOK_LOCAL_1": book_local_1_action}), + ("", {"BOOK_LOCAL_1": book_local_1_action}), + ], + ) + def test_valid_iteration_if_actions_mapper_exists_for_rule_routing( + self, valid_campaign_config_with_only_mandatory_fields, rule_type, default_routing, actions_mapper + ): + iteration_rule = { + "Type": rule_type, + "Name": "Test Rule", + "Description": "Test rule description", + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100, + "CommsRouting": default_routing, + } + + iteration_data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "IterationRules": [iteration_rule], + "ActionsMapper": actions_mapper, + } + + iteration = IterationValidation(**iteration_data) + assert iteration is not None, ( + f"Expected iteration to be valid for rule type '{rule_type}' with routing '{default_routing}'" + ) + + @pytest.mark.parametrize("rule_type", ["R", "X", "Y"]) + @pytest.mark.parametrize( + ("default_routing", "actions_mapper"), + [ + ("BOOK_LOCAL_1|BOOK_LOCAL_2", {"BOOK_LOCAL_2": book_local_2_action}), + ("BOOK_LOCAL_1", {"BOOK_LOCAL_2": book_local_2_action}), + ], + ) + def test_invalid_iteration_if_actions_mapper_exists_for_rule_routing( + self, valid_campaign_config_with_only_mandatory_fields, rule_type, default_routing, actions_mapper + ): + iteration_rule = { + "Type": rule_type, + "Name": "Test Rule", + "Description": "Test rule description", + "Operator": "is_empty", + "Comparator": "", + "AttributeTarget": "RSV", + "AttributeLevel": "TARGET", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "elid_all_people", + "Priority": 100, + "CommsRouting": default_routing, + } + + iteration_data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "IterationRules": [iteration_rule], + "ActionsMapper": actions_mapper, + } + + with pytest.raises(ValidationError) as error: + IterationValidation(**iteration_data) + + errors = error.value.errors() + assert any(e["loc"][-1] == "iteration_rules" and "BOOK_LOCAL_1" in str(e["msg"]) for e in errors), ( + "Expected validation error for missing BOOK_LOCAL entry in ActionsMapper" + ) From 86a27ce0f4dd6870646370d2bac477bd1715c50f Mon Sep 17 00:00:00 2001 From: SeanSteberis <103416906+seansteberisal@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:26:24 +0100 Subject: [PATCH 55/61] fixed new tests --- .../storyTestConfigs/AUTO_RSV_ELI-365-1.json | 6 ++-- .../storyTestData/AUTO_RSV_ELI-365_025.json | 8 ++--- .../storyTestData/AUTO_RSV_ELI-365_026.json | 8 ++--- .../storyTestData/AUTO_RSV_ELI-365_027.json | 8 ++--- .../AUTO_RSV_ELI-365_024.json | 6 ++-- .../AUTO_RSV_ELI-365_025.json | 6 ++-- .../AUTO_RSV_ELI-365_026.json | 35 +++++++------------ .../AUTO_RSV_ELI-365_027.json | 35 +++++++------------ 8 files changed, 47 insertions(+), 65 deletions(-) diff --git a/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json b/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json index 8a4d67fe2..78f94df3c 100644 --- a/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json +++ b/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json @@ -5,9 +5,9 @@ "Name": "EliD RSV example config", "Type": "V", "Target": "RSV", - "Manager": "example@nhs.net", - "Approver": "example@nhs.net", - "Reviewer": "example@nhs.net", + "Manager": ["person1@nhs.net"], + "Approver": ["person1@nhs.net"], + "Reviewer": ["person1@nhs.net"], "IterationFrequency": "X", "IterationType": "O", "IterationTime": "07:00:00", diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json index 9c98d32a6..f44d057a9 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json @@ -1,14 +1,14 @@ { "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 after 1st September 2024 - Local Authority", "request_headers": { - "nhs-login-nhs-number": "9900036524" + "nhs-login-nhs-number": "9900036525" }, "config_filenames": [ "AUTO_RSV_ELI-365.json" ], "data": [ { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036525", "ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [ { @@ -18,7 +18,7 @@ ] }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036525", "ATTRIBUTE_TYPE": "PERSON", "DATE_OF_BIRTH": "<>", "GENDER": "0", @@ -37,7 +37,7 @@ "DE_FLAG": "N" }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036525", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, "BOOKED_APPOINTMENT_DATE": null, diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json index a76b66383..b6e371b2a 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json @@ -1,14 +1,14 @@ { "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 before 1st September 2024 - Local Authority", "request_headers": { - "nhs-login-nhs-number": "9900036524" + "nhs-login-nhs-number": "9900036526" }, "config_filenames": [ "AUTO_RSV_ELI-365.json" ], "data": [ { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036526", "ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [ { @@ -18,7 +18,7 @@ ] }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036526", "ATTRIBUTE_TYPE": "PERSON", "DATE_OF_BIRTH": "<>", "GENDER": "0", @@ -37,7 +37,7 @@ "DE_FLAG": "N" }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036526", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, "BOOKED_APPOINTMENT_DATE": null, diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json index d1204cf06..fd2b78f95 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json @@ -1,14 +1,14 @@ { "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 before 1st September 2024 - ICB", "request_headers": { - "nhs-login-nhs-number": "9900036524" + "nhs-login-nhs-number": "9900036527" }, "config_filenames": [ "AUTO_RSV_ELI-365.json" ], "data": [ { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036527", "ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [ { @@ -18,7 +18,7 @@ ] }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036527", "ATTRIBUTE_TYPE": "PERSON", "DATE_OF_BIRTH": "<>", "GENDER": "0", @@ -37,7 +37,7 @@ "DE_FLAG": "N" }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036527", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, "BOOKED_APPOINTMENT_DATE": null, diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json index 00ab027ec..c0bb698d4 100644 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_024.json @@ -1,6 +1,6 @@ { "meta": { - "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + "lastUpdated": "" }, "processedSuggestions": [ { @@ -31,7 +31,7 @@ "eligibilityCohorts": [ { "cohortCode": "rsv_age_catchup", - "cohortStatus": "Actionable", + "cohortStatus": "Actionable", "cohortText": "turned 80 after 1st September 2024" } ], @@ -40,5 +40,5 @@ "suitabilityRules": [] } ], - "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" + "responseId": "" } diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json index 00ab027ec..c0bb698d4 100644 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_025.json @@ -1,6 +1,6 @@ { "meta": { - "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + "lastUpdated": "" }, "processedSuggestions": [ { @@ -31,7 +31,7 @@ "eligibilityCohorts": [ { "cohortCode": "rsv_age_catchup", - "cohortStatus": "Actionable", + "cohortStatus": "Actionable", "cohortText": "turned 80 after 1st September 2024" } ], @@ -40,5 +40,5 @@ "suitabilityRules": [] } ], - "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" + "responseId": "" } diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json index 00ab027ec..d7ca2432b 100644 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json @@ -1,44 +1,35 @@ { "meta": { - "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + "lastUpdated": "" }, "processedSuggestions": [ { "actions": [ { - "actionCode": "BookLocal", + "actionCode": "HealthcareProInfo", "actionType": "InfoText", - "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", - "urlLabel": "", - "urlLink": "" - }, - { - "actionCode": "BookNBS", - "actionType": "ButtonWithAuthLink", - "description": "", - "urlLabel": "Continue to booking", - "urlLink": "http://www.nhs.uk/book-rsv" - }, - { - "actionCode": "HelpSupportInfo", - "actionType": "InfoText", - "description": "## CONTENT TBC\n\nBlah blah blah.", + "description": "## If you think this is incorrect\n\nSpeak to your healthcare professional if you think you should be offered this vaccine.\n\nFor anything else, visit our help and support page. (ADD LINK)", "urlLabel": "", "urlLink": "" } ], "condition": "RSV", "eligibilityCohorts": [ + { + "cohortCode": "rsv_age", + "cohortStatus": "NotEligible", + "cohortText": "are not aged 75 to 79 years old" + }, { "cohortCode": "rsv_age_catchup", - "cohortStatus": "Actionable", - "cohortText": "turned 80 after 1st September 2024" + "cohortStatus": "NotEligible", + "cohortText": "did not turn 80 after 1 September 2024" } ], - "status": "Actionable", - "statusText": "You should have the RSV vaccine", + "status": "NotEligible", + "statusText": "We do not believe you can have it", "suitabilityRules": [] } ], - "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" + "responseId": "" } diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json index 00ab027ec..d7ca2432b 100644 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json @@ -1,44 +1,35 @@ { "meta": { - "lastUpdated": "2025-07-29T16:27:41.967804+00:00" + "lastUpdated": "" }, "processedSuggestions": [ { "actions": [ { - "actionCode": "BookLocal", + "actionCode": "HealthcareProInfo", "actionType": "InfoText", - "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", - "urlLabel": "", - "urlLink": "" - }, - { - "actionCode": "BookNBS", - "actionType": "ButtonWithAuthLink", - "description": "", - "urlLabel": "Continue to booking", - "urlLink": "http://www.nhs.uk/book-rsv" - }, - { - "actionCode": "HelpSupportInfo", - "actionType": "InfoText", - "description": "## CONTENT TBC\n\nBlah blah blah.", + "description": "## If you think this is incorrect\n\nSpeak to your healthcare professional if you think you should be offered this vaccine.\n\nFor anything else, visit our help and support page. (ADD LINK)", "urlLabel": "", "urlLink": "" } ], "condition": "RSV", "eligibilityCohorts": [ + { + "cohortCode": "rsv_age", + "cohortStatus": "NotEligible", + "cohortText": "are not aged 75 to 79 years old" + }, { "cohortCode": "rsv_age_catchup", - "cohortStatus": "Actionable", - "cohortText": "turned 80 after 1st September 2024" + "cohortStatus": "NotEligible", + "cohortText": "did not turn 80 after 1 September 2024" } ], - "status": "Actionable", - "statusText": "You should have the RSV vaccine", + "status": "NotEligible", + "statusText": "We do not believe you can have it", "suitabilityRules": [] } ], - "responseId": "ad09ee8c-ee37-4a7f-8fde-22aec6c90d89" + "responseId": "" } From 754b6a3729bf647b3b8d98552f9f511641e42da9 Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:27:26 +0100 Subject: [PATCH 56/61] ELI-404: Fix Error message returned for authorisation failure (#289) * ELI-404: Fix Error message returned for authorisation failure * ELI-404: Fix sonar --- .../common/api_error_response.py | 18 +++++------------- .../common/request_validator.py | 8 +++----- .../lambda/test_app_running_as_lambda.py | 13 ++++++------- tests/unit/common/test_request_validator.py | 4 +++- 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/eligibility_signposting_api/common/api_error_response.py b/src/eligibility_signposting_api/common/api_error_response.py index cbb07e8ca..afdbfe962 100644 --- a/src/eligibility_signposting_api/common/api_error_response.py +++ b/src/eligibility_signposting_api/common/api_error_response.py @@ -26,7 +26,7 @@ class FHIRIssueCode(str, Enum): class FHIRSpineErrorCode(str, Enum): - INVALID_NHS_NUMBER = "INVALID_NHS_NUMBER" + ACCESS_DENIED = "ACCESS_DENIED" INVALID_PARAMETER = "INVALID_PARAMETER" BAD_REQUEST = "BAD_REQUEST" INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" @@ -34,19 +34,18 @@ class FHIRSpineErrorCode(str, Enum): class APIErrorResponse: - def __init__( # noqa: PLR0913 + def __init__( self, status_code: HTTPStatus, fhir_issue_code: FHIRIssueCode, fhir_issue_severity: FHIRIssueSeverity, - fhir_coding_system: str, fhir_error_code: str, fhir_display_message: str, ) -> None: self.status_code = status_code self.fhir_issue_code = fhir_issue_code self.fhir_issue_severity = fhir_issue_severity - self.fhir_coding_system = fhir_coding_system + self.fhir_coding_system = "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1" self.fhir_error_code = fhir_error_code self.fhir_display_message = fhir_display_message @@ -96,7 +95,6 @@ def log_and_generate_response( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, fhir_issue_code=FHIRIssueCode.VALUE, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The supplied value was not recognised by the API.", ) @@ -105,7 +103,6 @@ def log_and_generate_response( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, fhir_issue_code=FHIRIssueCode.VALUE, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The supplied category was not recognised by the API.", ) @@ -114,7 +111,6 @@ def log_and_generate_response( status_code=HTTPStatus.BAD_REQUEST, fhir_issue_code=FHIRIssueCode.VALUE, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INVALID_PARAMETER, fhir_display_message="The given conditions were not in the expected format.", ) @@ -123,7 +119,6 @@ def log_and_generate_response( status_code=HTTPStatus.NOT_FOUND, fhir_issue_code=FHIRIssueCode.PROCESSING, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.REFERENCE_NOT_FOUND, fhir_display_message="The given NHS number was not found in our datasets. " "This could be because the number is incorrect or " @@ -134,7 +129,6 @@ def log_and_generate_response( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, fhir_issue_code=FHIRIssueCode.PROCESSING, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.INTERNAL_SERVER_ERROR, fhir_display_message="An unexpected internal server error occurred.", ) @@ -143,9 +137,8 @@ def log_and_generate_response( status_code=HTTPStatus.FORBIDDEN, fhir_issue_code=FHIRIssueCode.FORBIDDEN, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", - fhir_error_code=FHIRSpineErrorCode.INVALID_NHS_NUMBER, - fhir_display_message="The provided NHS number does not match the record.", + fhir_error_code=FHIRSpineErrorCode.ACCESS_DENIED, + fhir_display_message="Access has been denied to process this request.", ) @@ -153,7 +146,6 @@ def log_and_generate_response( status_code=HTTPStatus.BAD_REQUEST, fhir_issue_code=FHIRIssueCode.INVALID, fhir_issue_severity=FHIRIssueSeverity.ERROR, - fhir_coding_system="https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", fhir_error_code=FHIRSpineErrorCode.BAD_REQUEST, fhir_display_message="Bad Request", ) diff --git a/src/eligibility_signposting_api/common/request_validator.py b/src/eligibility_signposting_api/common/request_validator.py index 8cf5fbe40..9375fd729 100644 --- a/src/eligibility_signposting_api/common/request_validator.py +++ b/src/eligibility_signposting_api/common/request_validator.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) -condition_pattern = re.compile(r"^\s*[a-zA-Z0-9]+\s*$", re.IGNORECASE) +condition_pattern = re.compile(r"^\s*[a-z0-9]+\s*$", re.IGNORECASE) category_pattern = re.compile(r"^\s*(VACCINATIONS|SCREENING|ALL)\s*$", re.IGNORECASE) include_actions_pattern = re.compile(r"^\s*([YN])\s*$", re.IGNORECASE) @@ -67,10 +67,8 @@ def wrapper(event: LambdaEvent, context: LambdaContext) -> dict[str, Any] | None ) if not validate_nhs_number(path_nhs_no, header_nhs_no): - message = f"NHS Number {path_nhs_no or ''} does not match the header NHS Number {header_nhs_no or ''}" - return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response( - log_message=message, diagnostics=message, location_param="id" - ) + message = "You are not authorised to request information for the supplied NHS Number" + return NHS_NUMBER_MISMATCH_ERROR.log_and_generate_response(log_message=message, diagnostics=message) query_params = event.get("queryStringParameters") if query_params: diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index f4dcfc95e..c54ea08c2 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -304,14 +304,13 @@ def test_given_nhs_number_in_path_does_not_match_with_nhs_number_in_headers_resu has_entries( severity="error", code="forbidden", - diagnostics=f"NHS Number {persisted_person} does " - f"not match the header NHS Number 123{persisted_person!s}", + diagnostics="You are not authorised to request information for the supplied NHS Number", details={ "coding": [ { "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", - "code": "INVALID_NHS_NUMBER", - "display": "The provided NHS number does not match the record.", + "code": "ACCESS_DENIED", + "display": "Access has been denied to process this request.", } ] }, @@ -351,13 +350,13 @@ def test_given_nhs_number_not_present_in_headers_results_in_error_response( has_entries( severity="error", code="forbidden", - diagnostics=f"NHS Number {persisted_person} does not match the header NHS Number ", + diagnostics="You are not authorised to request information for the supplied NHS Number", details={ "coding": [ { "system": "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", - "code": "INVALID_NHS_NUMBER", - "display": "The provided NHS number does not match the record.", + "code": "ACCESS_DENIED", + "display": "Access has been denied to process this request.", } ] }, diff --git a/tests/unit/common/test_request_validator.py b/tests/unit/common/test_request_validator.py index 99a09d40e..0ec6726d5 100644 --- a/tests/unit/common/test_request_validator.py +++ b/tests/unit/common/test_request_validator.py @@ -79,7 +79,9 @@ def test_validate_request_params_nhs_mismatch(self, caplog): response_body = json.loads(response["body"]) issue = response_body["issue"][0] assert issue["code"] == "forbidden" - assert issue["diagnostics"] == ("NHS Number 0987654321 does not match the header NHS Number 1234567890") + assert issue["details"]["coding"][0]["code"] == "ACCESS_DENIED" + assert issue["details"]["coding"][0]["display"] == "Access has been denied to process this request." + assert issue["diagnostics"] == "You are not authorised to request information for the supplied NHS Number" def test_validate_request_params_nhs_missing_in_path(self, caplog): mock_handler = MagicMock() From f5bbaecb074170495071e1652fdf37cd377cedee Mon Sep 17 00:00:00 2001 From: SeanSteberis <103416906+seansteberisal@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:41:04 +0100 Subject: [PATCH 57/61] update packages --- poetry.lock | 1423 +++++++++-------- .../unicode_data/15.1.0/charmap.json.gz | Bin 0 -> 21735 bytes 2 files changed, 760 insertions(+), 663 deletions(-) create mode 100644 tests/e2e/.hypothesis/unicode_data/15.1.0/charmap.json.gz diff --git a/poetry.lock b/poetry.lock index 963595b81..5cc4300fb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -14,103 +14,103 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.13" +version = "3.12.15" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6"}, - {file = "aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad"}, - {file = "aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3"}, - {file = "aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd"}, - {file = "aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5"}, - {file = "aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf"}, - {file = "aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3"}, - {file = "aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd"}, - {file = "aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:36f6c973e003dc9b0bb4e8492a643641ea8ef0e97ff7aaa5c0f53d68839357b4"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6cbfc73179bd67c229eb171e2e3745d2afd5c711ccd1e40a68b90427f282eab1"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1e8b27b2d414f7e3205aa23bb4a692e935ef877e3a71f40d1884f6e04fd7fa74"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eabded0c2b2ef56243289112c48556c395d70150ce4220d9008e6b4b3dd15690"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:003038e83f1a3ff97409999995ec02fe3008a1d675478949643281141f54751d"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b6f46613031dbc92bdcaad9c4c22c7209236ec501f9c0c5f5f0b6a689bf50f3"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c332c6bb04650d59fb94ed96491f43812549a3ba6e7a16a218e612f99f04145e"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fea41a2c931fb582cb15dc86a3037329e7b941df52b487a9f8b5aa960153cbd"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:846104f45d18fb390efd9b422b27d8f3cf8853f1218c537f36e71a385758c896"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d6c85ac7dd350f8da2520bac8205ce99df4435b399fa7f4dc4a70407073e390"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5a1ecce0ed281bec7da8550da052a6b89552db14d0a0a45554156f085a912f48"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5304d74867028cca8f64f1cc1215eb365388033c5a691ea7aa6b0dc47412f495"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:64d1f24ee95a2d1e094a4cd7a9b7d34d08db1bbcb8aa9fb717046b0a884ac294"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:119c79922a7001ca6a9e253228eb39b793ea994fd2eccb79481c64b5f9d2a055"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bb18f00396d22e2f10cd8825d671d9f9a3ba968d708a559c02a627536b36d91c"}, - {file = "aiohttp-3.12.13-cp39-cp39-win32.whl", hash = "sha256:0022de47ef63fd06b065d430ac79c6b0bd24cdae7feaf0e8c6bac23b805a23a8"}, - {file = "aiohttp-3.12.13-cp39-cp39-win_amd64.whl", hash = "sha256:29e08111ccf81b2734ae03f1ad1cb03b9615e7d8f616764f22f71209c094f122"}, - {file = "aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, + {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, + {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, + {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, + {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, + {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, + {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, + {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, + {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, + {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, + {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, + {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, ] [package.dependencies] aiohappyeyeballs = ">=2.5.0" -aiosignal = ">=1.1.2" +aiosignal = ">=1.4.0" attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" @@ -122,14 +122,14 @@ speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (> [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] [package.dependencies] @@ -149,14 +149,14 @@ files = [ [[package]] name = "anyio" -version = "4.9.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" +version = "4.10.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, - {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, + {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, + {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, ] [package.dependencies] @@ -164,8 +164,6 @@ idna = ">=2.8" sniffio = ">=1.1" [package.extras] -doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -190,18 +188,18 @@ test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock [[package]] name = "asgiref" -version = "3.8.1" +version = "3.9.1" description = "ASGI specs, helper code, and adapters" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, - {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, + {file = "asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c"}, + {file = "asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142"}, ] [package.extras] -tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] [[package]] name = "attrs" @@ -225,14 +223,14 @@ tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" a [[package]] name = "authlib" -version = "1.6.0" +version = "1.6.1" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "authlib-1.6.0-py2.py3-none-any.whl", hash = "sha256:91685589498f79e8655e8a8947431ad6288831d643f11c55c2143ffcc738048d"}, - {file = "authlib-1.6.0.tar.gz", hash = "sha256:4367d32031b7af175ad3a323d571dc7257b7099d55978087ceae4a0d88cd3210"}, + {file = "authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e"}, + {file = "authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd"}, ] [package.dependencies] @@ -240,18 +238,18 @@ cryptography = "*" [[package]] name = "awscli" -version = "1.40.41" +version = "1.42.6" description = "Universal Command Line Environment for AWS." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "awscli-1.40.41-py3-none-any.whl", hash = "sha256:d75cc6c654418ac4d30eb996081033e90024fa7a661db8ab40de4b5a545eaa79"}, - {file = "awscli-1.40.41.tar.gz", hash = "sha256:553c3a3ba7879be18c5db219f9a710daf90d750044eb604297b25805b05ebc42"}, + {file = "awscli-1.42.6-py3-none-any.whl", hash = "sha256:87e0ad43fa50c12cc6839e8312904c202ded71be6070129654069738bf25b428"}, + {file = "awscli-1.42.6.tar.gz", hash = "sha256:b81900dac64df01291dae0e42da5d411b6a24a21c0da391c1fae6ca1e9554d57"}, ] [package.dependencies] -botocore = "1.38.42" +botocore = "1.40.6" colorama = ">=0.2.5,<0.4.7" docutils = ">=0.18.1,<=0.19" PyYAML = ">=3.10,<6.1" @@ -260,13 +258,14 @@ s3transfer = ">=0.13.0,<0.14.0" [[package]] name = "awscli-local" -version = "0.22.0" +version = "0.22.2" description = "Thin wrapper around the \"aws\" command line interface for use with LocalStack" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "awscli-local-0.22.0.tar.gz", hash = "sha256:3807cf2ee4bbdd4df4dfc8bef027f25bde523dcaf8119720f677ed95ebba66a4"}, + {file = "awscli_local-0.22.2-py3-none-any.whl", hash = "sha256:1901ebef343ba8cbde06f9f6406b5415ef53e93296718c2c31c9d49b0b09bd9d"}, + {file = "awscli_local-0.22.2.tar.gz", hash = "sha256:07c532c372753bf5f15426451dc91d6eec9de8779748049329a9a882bdac8a0b"}, ] [package.dependencies] @@ -312,24 +311,29 @@ lxml = ["lxml"] [[package]] name = "behave" -version = "1.2.6" +version = "1.3.0" description = "behave is behaviour-driven development, Python style" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["dev"] files = [ - {file = "behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"}, - {file = "behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86"}, + {file = "behave-1.3.0-py2.py3-none-any.whl", hash = "sha256:657ee8c167af716e6ab7b817100bb427a2674440d431751af47af9ffd8a90b57"}, + {file = "behave-1.3.0.tar.gz", hash = "sha256:b32ec1a1ed67f23adc007c1cb7ee31ac1c939638d30c8e3e27a00d9ddb063c09"}, ] [package.dependencies] -parse = ">=1.8.2" -parse-type = ">=0.4.2" -six = ">=1.11" +colorama = ">=0.3.7" +cucumber-expressions = {version = ">=17.1.0", markers = "python_version >= \"3.8\""} +cucumber-tag-expressions = ">=4.1.0" +parse = ">=1.18.0" +parse-type = ">=0.6.0" +six = ">=1.15.0" [package.extras] -develop = ["coverage", "invoke (>=0.21.0)", "modernize (>=0.5)", "path.py (>=8.1.2)", "pathlib", "pycmd", "pylint", "pytest (>=3.0)", "pytest-cov", "tox"] -docs = ["sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6)"] +develop = ["PyHamcrest (<2.0) ; python_version < \"3.0\"", "PyHamcrest (>=2.0.2) ; python_version >= \"3.0\"", "build (>=0.5.1)", "coverage (>=5.0)", "invoke (>=1.7.0) ; python_version >= \"3.6\"", "invoke (>=1.7.0,<2.0) ; python_version < \"3.6\"", "mock (<4.0) ; python_version < \"3.6\"", "mock (>=4.0) ; python_version >= \"3.6\"", "modernize (>=0.5)", "path (>=13.1.0) ; python_version >= \"3.5\"", "path.py (>=11.5.0) ; python_version < \"3.5\"", "pathlib ; python_version <= \"3.4\"", "pycmd", "pylint", "pytest (>=4.2,<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-cov", "pytest-html (>=1.19.0,<2.0) ; python_version < \"3.0\"", "pytest-html (>=2.0) ; python_version >= \"3.0\"", "ruff ; python_version >= \"3.7\"", "tox (>=3.28.0,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0) ; python_version < \"3.7\"", "virtualenv (>=20.26.6) ; python_version >= \"3.7\""] +docs = ["furo (>=2024.04.27) ; python_version >= \"3.8\"", "sphinx (>=1.6,<4.4) ; python_version < \"3.7\"", "sphinx (>=7.3.7) ; python_version >= \"3.7\"", "sphinx-copybutton (>=0.5.2) ; python_version >= \"3.7\"", "sphinxcontrib-applehelp (>=1.0.8) ; python_version >= \"3.7\"", "sphinxcontrib-htmlhelp (>=2.0.5) ; python_version >= \"3.7\""] +formatters = ["behave-html-formatter (>=0.9.10) ; python_version >= \"3.6\"", "behave-html-pretty-formatter (>=1.9.1) ; python_version >= \"3.6\""] +testing = ["PyHamcrest (<2.0) ; python_version < \"3.0\"", "PyHamcrest (>=2.0.2) ; python_version >= \"3.0\"", "assertpy (>=1.1)", "chardet", "freezegun (>=1.5.1) ; python_version > \"3.7\"", "mock (<4.0) ; python_version < \"3.6\"", "mock (>=4.0) ; python_version >= \"3.6\"", "path (>=13.1.0) ; python_version >= \"3.5\"", "path.py (>=11.5.0,<13.0) ; python_version < \"3.5\"", "pathlib ; python_version <= \"3.4\"", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0,<2.0) ; python_version < \"3.0\"", "pytest-html (>=2.0) ; python_version >= \"3.0\""] [[package]] name = "blinker" @@ -345,18 +349,18 @@ files = [ [[package]] name = "boto3" -version = "1.38.42" +version = "1.40.6" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "boto3-1.38.42-py3-none-any.whl", hash = "sha256:a9b4c7021bf5adee985523fc87db27a7200de161c094cb8f709b93a81797dc8a"}, - {file = "boto3-1.38.42.tar.gz", hash = "sha256:2cb783c668ae4f2a86b6497b47251b9baf9a16db8fff863b57eae683276b9e1f"}, + {file = "boto3-1.40.6-py3-none-any.whl", hash = "sha256:7685085b8ea679dd858330834c16364c61c5830edb892c5864fb12ed68798708"}, + {file = "boto3-1.40.6.tar.gz", hash = "sha256:3f2375b7576de603085d7b7491cf2c4ab5c57d12965a5ce295821cdd4d10e950"}, ] [package.dependencies] -botocore = ">=1.38.42,<1.39.0" +botocore = ">=1.40.6,<1.41.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.13.0,<0.14.0" @@ -365,14 +369,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.38.42" +version = "1.40.6" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "botocore-1.38.42-py3-none-any.whl", hash = "sha256:fbbeac30c045b5c19f1c3bb063ea2b6315ce2d6fcb3d898e87d1c1846297961c"}, - {file = "botocore-1.38.42.tar.gz", hash = "sha256:3a14188e48f6e26be561164373d34150fa9cb39f7ad32cc745dcd3ab05f43683"}, + {file = "botocore-1.40.6-py3-none-any.whl", hash = "sha256:b253f107617791a29ca81c6f0fca20ac1afdba01869eb43017022bbbb8387f34"}, + {file = "botocore-1.40.6.tar.gz", hash = "sha256:e132eac967a7526596e31ca571d7f1ef9676c6a9c4f1322446768994ccab64aa"}, ] [package.dependencies] @@ -420,14 +424,14 @@ files = [ [[package]] name = "build" -version = "1.2.2.post1" +version = "1.3.0" description = "A simple, correct Python build frontend" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5"}, - {file = "build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7"}, + {file = "build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4"}, + {file = "build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397"}, ] [package.dependencies] @@ -436,11 +440,8 @@ packaging = ">=19.1" pyproject_hooks = "*" [package.extras] -docs = ["furo (>=2023.08.17)", "sphinx (>=7.0,<8.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)", "sphinx-issues (>=3.0.0)"] -test = ["build[uv,virtualenv]", "filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0) ; python_version < \"3.10\"", "setuptools (>=56.0.0) ; python_version == \"3.10\"", "setuptools (>=56.0.0) ; python_version == \"3.11\"", "setuptools (>=67.8.0) ; python_version >= \"3.12\"", "wheel (>=0.36.0)"] -typing = ["build[uv]", "importlib-metadata (>=5.1)", "mypy (>=1.9.0,<1.10.0)", "tomli", "typing-extensions (>=3.7.4.3)"] uv = ["uv (>=0.1.18)"] -virtualenv = ["virtualenv (>=20.0.35)"] +virtualenv = ["virtualenv (>=20.11) ; python_version < \"3.10\"", "virtualenv (>=20.17) ; python_version >= \"3.10\" and python_version < \"3.14\"", "virtualenv (>=20.31) ; python_version >= \"3.14\""] [[package]] name = "cachetools" @@ -456,14 +457,14 @@ files = [ [[package]] name = "certifi" -version = "2025.6.15" +version = "2025.8.3" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, - {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, ] [[package]] @@ -561,104 +562,91 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.4.2" +version = "3.4.3" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main", "dev"] files = [ - {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, - {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, - {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, - {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, - {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, - {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, - {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, - {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, - {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, - {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"}, + {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"}, + {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"}, + {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"}, + {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"}, + {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"}, + {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"}, + {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, + {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, ] [[package]] @@ -690,79 +678,100 @@ files = [ [[package]] name = "coverage" -version = "7.9.1" +version = "7.10.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "coverage-7.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc94d7c5e8423920787c33d811c0be67b7be83c705f001f7180c7b186dcf10ca"}, - {file = "coverage-7.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:16aa0830d0c08a2c40c264cef801db8bc4fc0e1892782e45bcacbd5889270509"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf95981b126f23db63e9dbe4cf65bd71f9a6305696fa5e2262693bc4e2183f5b"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f05031cf21699785cd47cb7485f67df619e7bcdae38e0fde40d23d3d0210d3c3"}, - {file = "coverage-7.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb4fbcab8764dc072cb651a4bcda4d11fb5658a1d8d68842a862a6610bd8cfa3"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0f16649a7330ec307942ed27d06ee7e7a38417144620bb3d6e9a18ded8a2d3e5"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cea0a27a89e6432705fffc178064503508e3c0184b4f061700e771a09de58187"}, - {file = "coverage-7.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e980b53a959fa53b6f05343afbd1e6f44a23ed6c23c4b4c56c6662bbb40c82ce"}, - {file = "coverage-7.9.1-cp310-cp310-win32.whl", hash = "sha256:70760b4c5560be6ca70d11f8988ee6542b003f982b32f83d5ac0b72476607b70"}, - {file = "coverage-7.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a66e8f628b71f78c0e0342003d53b53101ba4e00ea8dabb799d9dba0abbbcebe"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582"}, - {file = "coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d"}, - {file = "coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250"}, - {file = "coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c"}, - {file = "coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32"}, - {file = "coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125"}, - {file = "coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626"}, - {file = "coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8"}, - {file = "coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898"}, - {file = "coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d"}, - {file = "coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74"}, - {file = "coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e"}, - {file = "coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631"}, - {file = "coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86"}, - {file = "coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751"}, - {file = "coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67"}, - {file = "coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643"}, - {file = "coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a"}, - {file = "coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0"}, - {file = "coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029"}, - {file = "coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f"}, - {file = "coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10"}, - {file = "coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363"}, - {file = "coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7"}, - {file = "coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c"}, - {file = "coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951"}, - {file = "coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58"}, - {file = "coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71"}, - {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55"}, - {file = "coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b"}, - {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7"}, - {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385"}, - {file = "coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed"}, - {file = "coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d"}, - {file = "coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244"}, - {file = "coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514"}, - {file = "coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c"}, - {file = "coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec"}, + {file = "coverage-7.10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53808194afdf948c462215e9403cca27a81cf150d2f9b386aee4dab614ae2ffe"}, + {file = "coverage-7.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f4d1b837d1abf72187a61645dbf799e0d7705aa9232924946e1f57eb09a3bf00"}, + {file = "coverage-7.10.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2a90dd4505d3cc68b847ab10c5ee81822a968b5191664e8a0801778fa60459fa"}, + {file = "coverage-7.10.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d52989685ff5bf909c430e6d7f6550937bc6d6f3e6ecb303c97a86100efd4596"}, + {file = "coverage-7.10.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdb558a1d97345bde3a9f4d3e8d11c9e5611f748646e9bb61d7d612a796671b5"}, + {file = "coverage-7.10.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c9e6331a8f09cb1fc8bda032752af03c366870b48cce908875ba2620d20d0ad4"}, + {file = "coverage-7.10.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:992f48bf35b720e174e7fae916d943599f1a66501a2710d06c5f8104e0756ee1"}, + {file = "coverage-7.10.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c5595fc4ad6a39312c786ec3326d7322d0cf10e3ac6a6df70809910026d67cfb"}, + {file = "coverage-7.10.3-cp310-cp310-win32.whl", hash = "sha256:9e92fa1f2bd5a57df9d00cf9ce1eb4ef6fccca4ceabec1c984837de55329db34"}, + {file = "coverage-7.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:b96524d6e4a3ce6a75c56bb15dbd08023b0ae2289c254e15b9fbdddf0c577416"}, + {file = "coverage-7.10.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f2ff2e2afdf0d51b9b8301e542d9c21a8d084fd23d4c8ea2b3a1b3c96f5f7397"}, + {file = "coverage-7.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18ecc5d1b9a8c570f6c9b808fa9a2b16836b3dd5414a6d467ae942208b095f85"}, + {file = "coverage-7.10.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1af4461b25fe92889590d438905e1fc79a95680ec2a1ff69a591bb3fdb6c7157"}, + {file = "coverage-7.10.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3966bc9a76b09a40dc6063c8b10375e827ea5dfcaffae402dd65953bef4cba54"}, + {file = "coverage-7.10.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:205a95b87ef4eb303b7bc5118b47b6b6604a644bcbdb33c336a41cfc0a08c06a"}, + {file = "coverage-7.10.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b3801b79fb2ad61e3c7e2554bab754fc5f105626056980a2b9cf3aef4f13f84"}, + {file = "coverage-7.10.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0dc69c60224cda33d384572da945759756e3f06b9cdac27f302f53961e63160"}, + {file = "coverage-7.10.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a83d4f134bab2c7ff758e6bb1541dd72b54ba295ced6a63d93efc2e20cb9b124"}, + {file = "coverage-7.10.3-cp311-cp311-win32.whl", hash = "sha256:54e409dd64e5302b2a8fdf44ec1c26f47abd1f45a2dcf67bd161873ee05a59b8"}, + {file = "coverage-7.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:30c601610a9b23807c5e9e2e442054b795953ab85d525c3de1b1b27cebeb2117"}, + {file = "coverage-7.10.3-cp311-cp311-win_arm64.whl", hash = "sha256:dabe662312a97958e932dee056f2659051d822552c0b866823e8ba1c2fe64770"}, + {file = "coverage-7.10.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:449c1e2d3a84d18bd204258a897a87bc57380072eb2aded6a5b5226046207b42"}, + {file = "coverage-7.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d4f9ce50b9261ad196dc2b2e9f1fbbee21651b54c3097a25ad783679fd18294"}, + {file = "coverage-7.10.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dd4564207b160d0d45c36a10bc0a3d12563028e8b48cd6459ea322302a156d7"}, + {file = "coverage-7.10.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ca3c9530ee072b7cb6a6ea7b640bcdff0ad3b334ae9687e521e59f79b1d0437"}, + {file = "coverage-7.10.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6df359e59fa243c9925ae6507e27f29c46698359f45e568fd51b9315dbbe587"}, + {file = "coverage-7.10.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a181e4c2c896c2ff64c6312db3bda38e9ade2e1aa67f86a5628ae85873786cea"}, + {file = "coverage-7.10.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a374d4e923814e8b72b205ef6b3d3a647bb50e66f3558582eda074c976923613"}, + {file = "coverage-7.10.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:daeefff05993e5e8c6e7499a8508e7bd94502b6b9a9159c84fd1fe6bce3151cb"}, + {file = "coverage-7.10.3-cp312-cp312-win32.whl", hash = "sha256:187ecdcac21f9636d570e419773df7bd2fda2e7fa040f812e7f95d0bddf5f79a"}, + {file = "coverage-7.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:4a50ad2524ee7e4c2a95e60d2b0b83283bdfc745fe82359d567e4f15d3823eb5"}, + {file = "coverage-7.10.3-cp312-cp312-win_arm64.whl", hash = "sha256:c112f04e075d3495fa3ed2200f71317da99608cbb2e9345bdb6de8819fc30571"}, + {file = "coverage-7.10.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b99e87304ffe0eb97c5308447328a584258951853807afdc58b16143a530518a"}, + {file = "coverage-7.10.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4af09c7574d09afbc1ea7da9dcea23665c01f3bc1b1feb061dac135f98ffc53a"}, + {file = "coverage-7.10.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:488e9b50dc5d2aa9521053cfa706209e5acf5289e81edc28291a24f4e4488f46"}, + {file = "coverage-7.10.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:913ceddb4289cbba3a310704a424e3fb7aac2bc0c3a23ea473193cb290cf17d4"}, + {file = "coverage-7.10.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1f91cbc78c7112ab84ed2a8defbccd90f888fcae40a97ddd6466b0bec6ae8a"}, + {file = "coverage-7.10.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0bac054d45af7cd938834b43a9878b36ea92781bcb009eab040a5b09e9927e3"}, + {file = "coverage-7.10.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fe72cbdd12d9e0f4aca873fa6d755e103888a7f9085e4a62d282d9d5b9f7928c"}, + {file = "coverage-7.10.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1e2e927ab3eadd7c244023927d646e4c15c65bb2ac7ae3c3e9537c013700d21"}, + {file = "coverage-7.10.3-cp313-cp313-win32.whl", hash = "sha256:24d0c13de473b04920ddd6e5da3c08831b1170b8f3b17461d7429b61cad59ae0"}, + {file = "coverage-7.10.3-cp313-cp313-win_amd64.whl", hash = "sha256:3564aae76bce4b96e2345cf53b4c87e938c4985424a9be6a66ee902626edec4c"}, + {file = "coverage-7.10.3-cp313-cp313-win_arm64.whl", hash = "sha256:f35580f19f297455f44afcd773c9c7a058e52eb6eb170aa31222e635f2e38b87"}, + {file = "coverage-7.10.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07009152f497a0464ffdf2634586787aea0e69ddd023eafb23fc38267db94b84"}, + {file = "coverage-7.10.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd2ba5f0c7e7e8cc418be2f0c14c4d9e3f08b8fb8e4c0f83c2fe87d03eb655e"}, + {file = "coverage-7.10.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1ae22b97003c74186e034a93e4f946c75fad8c0ce8d92fbbc168b5e15ee2841f"}, + {file = "coverage-7.10.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb329f1046888a36b1dc35504d3029e1dd5afe2196d94315d18c45ee380f67d5"}, + {file = "coverage-7.10.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce01048199a91f07f96ca3074b0c14021f4fe7ffd29a3e6a188ac60a5c3a4af8"}, + {file = "coverage-7.10.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:08b989a06eb9dfacf96d42b7fb4c9a22bafa370d245dc22fa839f2168c6f9fa1"}, + {file = "coverage-7.10.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:669fe0d4e69c575c52148511029b722ba8d26e8a3129840c2ce0522e1452b256"}, + {file = "coverage-7.10.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3262d19092771c83f3413831d9904b1ccc5f98da5de4ffa4ad67f5b20c7aaf7b"}, + {file = "coverage-7.10.3-cp313-cp313t-win32.whl", hash = "sha256:cc0ee4b2ccd42cab7ee6be46d8a67d230cb33a0a7cd47a58b587a7063b6c6b0e"}, + {file = "coverage-7.10.3-cp313-cp313t-win_amd64.whl", hash = "sha256:03db599f213341e2960430984e04cf35fb179724e052a3ee627a068653cf4a7c"}, + {file = "coverage-7.10.3-cp313-cp313t-win_arm64.whl", hash = "sha256:46eae7893ba65f53c71284585a262f083ef71594f05ec5c85baf79c402369098"}, + {file = "coverage-7.10.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:bce8b8180912914032785850d8f3aacb25ec1810f5f54afc4a8b114e7a9b55de"}, + {file = "coverage-7.10.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07790b4b37d56608536f7c1079bd1aa511567ac2966d33d5cec9cf520c50a7c8"}, + {file = "coverage-7.10.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e79367ef2cd9166acedcbf136a458dfe9a4a2dd4d1ee95738fb2ee581c56f667"}, + {file = "coverage-7.10.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:419d2a0f769f26cb1d05e9ccbc5eab4cb5d70231604d47150867c07822acbdf4"}, + {file = "coverage-7.10.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee221cf244757cdc2ac882e3062ab414b8464ad9c884c21e878517ea64b3fa26"}, + {file = "coverage-7.10.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c2079d8cdd6f7373d628e14b3357f24d1db02c9dc22e6a007418ca7a2be0435a"}, + {file = "coverage-7.10.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bd8df1f83c0703fa3ca781b02d36f9ec67ad9cb725b18d486405924f5e4270bd"}, + {file = "coverage-7.10.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b4e25e0fa335c8aa26e42a52053f3786a61cc7622b4d54ae2dad994aa754fec"}, + {file = "coverage-7.10.3-cp314-cp314-win32.whl", hash = "sha256:d7c3d02c2866deb217dce664c71787f4b25420ea3eaf87056f44fb364a3528f5"}, + {file = "coverage-7.10.3-cp314-cp314-win_amd64.whl", hash = "sha256:9c8916d44d9e0fe6cdb2227dc6b0edd8bc6c8ef13438bbbf69af7482d9bb9833"}, + {file = "coverage-7.10.3-cp314-cp314-win_arm64.whl", hash = "sha256:1007d6a2b3cf197c57105cc1ba390d9ff7f0bee215ced4dea530181e49c65ab4"}, + {file = "coverage-7.10.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ebc8791d346410d096818788877d675ca55c91db87d60e8f477bd41c6970ffc6"}, + {file = "coverage-7.10.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f4e4d8e75f6fd3c6940ebeed29e3d9d632e1f18f6fb65d33086d99d4d073241"}, + {file = "coverage-7.10.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:24581ed69f132b6225a31b0228ae4885731cddc966f8a33fe5987288bdbbbd5e"}, + {file = "coverage-7.10.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec151569ddfccbf71bac8c422dce15e176167385a00cd86e887f9a80035ce8a5"}, + {file = "coverage-7.10.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ae8e7c56290b908ee817200c0b65929b8050bc28530b131fe7c6dfee3e7d86b"}, + {file = "coverage-7.10.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fb742309766d7e48e9eb4dc34bc95a424707bc6140c0e7d9726e794f11b92a0"}, + {file = "coverage-7.10.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c65e2a5b32fbe1e499f1036efa6eb9cb4ea2bf6f7168d0e7a5852f3024f471b1"}, + {file = "coverage-7.10.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d48d2cb07d50f12f4f18d2bb75d9d19e3506c26d96fffabf56d22936e5ed8f7c"}, + {file = "coverage-7.10.3-cp314-cp314t-win32.whl", hash = "sha256:dec0d9bc15ee305e09fe2cd1911d3f0371262d3cfdae05d79515d8cb712b4869"}, + {file = "coverage-7.10.3-cp314-cp314t-win_amd64.whl", hash = "sha256:424ea93a323aa0f7f01174308ea78bde885c3089ec1bef7143a6d93c3e24ef64"}, + {file = "coverage-7.10.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f5983c132a62d93d71c9ef896a0b9bf6e6828d8d2ea32611f58684fba60bba35"}, + {file = "coverage-7.10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:da749daa7e141985487e1ff90a68315b0845930ed53dc397f4ae8f8bab25b551"}, + {file = "coverage-7.10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3126fb6a47d287f461d9b1aa5d1a8c97034d1dffb4f452f2cf211289dae74ef"}, + {file = "coverage-7.10.3-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3da794db13cc27ca40e1ec8127945b97fab78ba548040047d54e7bfa6d442dca"}, + {file = "coverage-7.10.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4e27bebbd184ef8d1c1e092b74a2b7109dcbe2618dce6e96b1776d53b14b3fe8"}, + {file = "coverage-7.10.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fd4ee2580b9fefbd301b4f8f85b62ac90d1e848bea54f89a5748cf132782118"}, + {file = "coverage-7.10.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6999920bdd73259ce11cabfc1307484f071ecc6abdb2ca58d98facbcefc70f16"}, + {file = "coverage-7.10.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c3623f929db885fab100cb88220a5b193321ed37e03af719efdbaf5d10b6e227"}, + {file = "coverage-7.10.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:25b902c5e15dea056485d782e420bb84621cc08ee75d5131ecb3dbef8bd1365f"}, + {file = "coverage-7.10.3-cp39-cp39-win32.whl", hash = "sha256:f930a4d92b004b643183451fe9c8fe398ccf866ed37d172ebaccfd443a097f61"}, + {file = "coverage-7.10.3-cp39-cp39-win_amd64.whl", hash = "sha256:08e638a93c8acba13c7842953f92a33d52d73e410329acd472280d2a21a6c0e1"}, + {file = "coverage-7.10.3-py3-none-any.whl", hash = "sha256:416a8d74dc0adfd33944ba2f405897bab87b7e9e84a391e09d241956bd953ce1"}, + {file = "coverage-7.10.3.tar.gz", hash = "sha256:812ba9250532e4a823b070b0420a36499859542335af3dca8f47fc6aa1a05619"}, ] [package.extras] @@ -770,49 +779,49 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "45.0.4" +version = "45.0.6" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" groups = ["dev"] files = [ - {file = "cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1"}, - {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999"}, - {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750"}, - {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2"}, - {file = "cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257"}, - {file = "cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8"}, - {file = "cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad"}, - {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6"}, - {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872"}, - {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4"}, - {file = "cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97"}, - {file = "cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58"}, - {file = "cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862"}, - {file = "cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d"}, - {file = "cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57"}, + {file = "cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42"}, + {file = "cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05"}, + {file = "cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453"}, + {file = "cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159"}, + {file = "cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec"}, + {file = "cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016"}, + {file = "cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3"}, + {file = "cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9"}, + {file = "cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02"}, + {file = "cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043"}, + {file = "cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719"}, ] [package.dependencies] @@ -825,9 +834,37 @@ nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8 pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==45.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "cucumber-expressions" +version = "18.0.1" +description = "Cucumber Expressions - a simpler alternative to Regular Expressions" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["dev"] +files = [ + {file = "cucumber_expressions-18.0.1-py3-none-any.whl", hash = "sha256:86230d503cdda7ef35a1f2072a882d7d57c740aa4c163c82b07f039b6bc60c42"}, + {file = "cucumber_expressions-18.0.1.tar.gz", hash = "sha256:86ce41bf28ee520408416f38022e5a083d815edf04a0bd1dae46d474ca597c60"}, +] + +[[package]] +name = "cucumber-tag-expressions" +version = "6.2.0" +description = "Provides a tag-expression parser and evaluation logic for cucumber/behave" +optional = false +python-versions = ">=2.7" +groups = ["dev"] +files = [ + {file = "cucumber_tag_expressions-6.2.0-py2.py3-none-any.whl", hash = "sha256:f94404b656831c56a3815da5305ac097003884d2ae64fa51f5f4fad82d97e583"}, + {file = "cucumber_tag_expressions-6.2.0.tar.gz", hash = "sha256:b60aa2cdbf9ac43e28d9b0e4fd49edf9f09d5d941257d2912f5228f9d166c023"}, +] + +[package.extras] +develop = ["backports.shutil_which ; python_version <= \"3.3\"", "build (>=0.5.1)", "coverage", "invoke (>=1.7.3)", "path (>=13.1.0) ; python_version >= \"3.5\"", "path.py (>=11.5.0) ; python_version < \"3.5\"", "pathlib ; python_version <= \"3.4\"", "pycmd", "pylint", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)", "ruff", "setuptools", "setuptools-scm", "six (>=1.16.0)", "tox (>=4.26,<4.27)", "twine (>=1.13.0)", "wheel"] +testing = ["PyYAML (>=5.4.1)", "pathlib ; python_version <= \"3.4\"", "pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)"] + [[package]] name = "decorator" version = "5.2.1" @@ -946,14 +983,14 @@ tests = ["pytest"] [[package]] name = "faker" -version = "37.4.0" +version = "37.5.3" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "faker-37.4.0-py3-none-any.whl", hash = "sha256:cb81c09ebe06c32a10971d1bbdb264bb0e22b59af59548f011ac4809556ce533"}, - {file = "faker-37.4.0.tar.gz", hash = "sha256:7f69d579588c23d5ce671f3fa872654ede0e67047820255f43a4aa1925b89780"}, + {file = "faker-37.5.3-py3-none-any.whl", hash = "sha256:386fe9d5e6132a915984bf887fcebcc72d6366a25dd5952905b31b141a17016d"}, + {file = "faker-37.5.3.tar.gz", hash = "sha256:8315d8ff4d6f4f588bd42ffe63abd599886c785073e26a44707e10eeba5713dc"}, ] [package.dependencies] @@ -961,42 +998,42 @@ tzdata = "*" [[package]] name = "fhir-core" -version = "1.0.1" +version = "1.1.4" description = "FHIR Core library" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "fhir_core-1.0.1-py2.py3-none-any.whl", hash = "sha256:199af6d68dc85cd09c947ec6ecb02b109a3d116ef016d1b4903ec22c36bbe03a"}, - {file = "fhir_core-1.0.1.tar.gz", hash = "sha256:1f1b04027053e5a844f69d00bda6acfced555697778fa1a0cf58d38fd18ef39b"}, + {file = "fhir_core-1.1.4-py2.py3-none-any.whl", hash = "sha256:66d81639f9a45e646cf21cae492d68d89550a22409bf68a9b292b789e0d0061d"}, + {file = "fhir_core-1.1.4.tar.gz", hash = "sha256:d6549665c32f6b710da19d1309851d5d5f8902af899925623d6f4441eb1f2176"}, ] [package.dependencies] pydantic = ">=2.7.4,<3.0" [package.extras] -dev = ["Jinja2 (==2.11.1)", "MarkupSafe (==1.1.1)", "PyYAML (>=6.0.1)", "black", "certifi", "colorlog (==2.10.0)", "coverage", "fhirspec", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "types-PyYAML", "types-requests", "types-simplejson", "zest-releaser[recommended]"] -test = ["PyYAML (>=6.0.1)", "black", "coverage", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "pytest-runner", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "types-PyYAML", "types-requests", "types-simplejson"] +dev = ["Jinja2 (==2.11.1)", "MarkupSafe (==1.1.1)", "PyYAML (>=6.0.1)", "black (>=23.0,<24.0) ; python_version >= \"3.7\"", "certifi", "colorlog (==2.10.0)", "coverage", "fhirspec", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "types-PyYAML", "types-requests", "types-simplejson", "zest-releaser[recommended]"] +test = ["PyYAML (>=6.0.1)", "black (>=23.0,<24.0) ; python_version >= \"3.7\"", "coverage", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "pytest-runner", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "types-PyYAML", "types-requests", "types-simplejson"] [[package]] name = "fhir-resources" -version = "8.0.0" +version = "8.1.0" description = "FHIR Resources as Model Class" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "fhir.resources-8.0.0-py2.py3-none-any.whl", hash = "sha256:9c46d6d79c6d6629c3bea6f244bcc6e8e0e4d15757a675f19d9d1c05c9ab2199"}, - {file = "fhir.resources-8.0.0.tar.gz", hash = "sha256:84dac3af31eaf90d5b0386cac21d26c50e6fb1526d68b88a2c42d112978e9cf9"}, + {file = "fhir_resources-8.1.0-py2.py3-none-any.whl", hash = "sha256:4370a5b6b35f278705328368bf79b3a17db91025fd4ec896fb963edd44ecc5de"}, + {file = "fhir_resources-8.1.0.tar.gz", hash = "sha256:8d64a717f37ea50bde97c1b8ff3fd969a6074df99c167183a273abe4da8bbfa5"}, ] [package.dependencies] -fhir-core = ">=1.0.0" +fhir-core = ">=1.1.3" [package.extras] all = ["PyYAML (>=5.4.1)", "lxml"] -dev = ["Jinja2 (==2.11.1)", "MarkupSafe (==1.1.1)", "black", "certifi", "colorlog (==2.10.0)", "coverage", "fhirspec", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "typed-ast (>=1.5.4)", "types-PyYAML", "types-requests", "types-simplejson", "zest-releaser[recommended]"] -test = ["PyYAML (>=5.4.1)", "black", "coverage", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "pytest-runner", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "typed-ast (>=1.5.4)", "types-PyYAML", "types-requests", "types-simplejson"] +dev = ["Jinja2 (==3.1.6)", "MarkupSafe (==2.1.5)", "black (>=23.0,<24.0) ; python_version >= \"3.7\"", "certifi", "colorlog (==2.10.0)", "coverage", "fhirspec (>=0.6.0)", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "typed-ast (>=1.5.4)", "types-PyYAML", "types-requests", "types-simplejson", "zest-releaser[recommended]"] +test = ["PyYAML (>=5.4.1)", "black (>=23.0,<24.0) ; python_version >= \"3.7\"", "coverage", "flake8 (==6.0)", "flake8-bugbear (>=22.12.6)", "flake8-isort (>=6.0.0)", "importlib-metadata (>=5.2.0)", "isort (>=5.11.4)", "lxml", "mypy", "pytest (>5.4.0) ; python_version >= \"3.6\"", "pytest-cov (>=2.10.0) ; python_version >= \"3.6\"", "pytest-runner", "requests (==2.23.0) ; python_version < \"3.10\"", "setuptools (==65.6.3) ; python_version >= \"3.7\"", "typed-ast (>=1.5.4)", "types-PyYAML", "types-requests", "types-simplejson"] xml = ["lxml"] yaml = ["PyYAML (>=5.4.1)"] @@ -1039,14 +1076,14 @@ files = [ [[package]] name = "freezegun" -version = "1.5.2" +version = "1.5.5" description = "Let your Python tests travel through time" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "freezegun-1.5.2-py3-none-any.whl", hash = "sha256:5aaf3ba229cda57afab5bd311f0108d86b6fb119ae89d2cd9c43ec8c1733c85b"}, - {file = "freezegun-1.5.2.tar.gz", hash = "sha256:a54ae1d2f9c02dbf42e02c18a3ab95ab4295818b549a34dac55592d72a905181"}, + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, ] [package.dependencies] @@ -1199,14 +1236,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.44" +version = "3.1.45" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, - {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, + {file = "gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"}, + {file = "gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c"}, ] [package.dependencies] @@ -1307,14 +1344,14 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "hypothesis" -version = "6.137.1" +version = "6.137.2" description = "A library for property-based testing" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "hypothesis-6.137.1-py3-none-any.whl", hash = "sha256:7cbda6a98ed4d32aad31a5fc5bff5e119b9275fe2579a7b08863cba313a4b9be"}, - {file = "hypothesis-6.137.1.tar.gz", hash = "sha256:b086e644456da79ad460fdaf8fbf90a41a661e8a4076232dd4ea64cfbc0d0529"}, + {file = "hypothesis-6.137.2-py3-none-any.whl", hash = "sha256:7258a288e9a4d1bac34a9403a6039056113b8b125e3974d88d7773cdb052164f"}, + {file = "hypothesis-6.137.2.tar.gz", hash = "sha256:e98bcf06bc0f8497e91f255b2549263283be266e8f299e9b2effd7dc075978f6"}, ] [package.dependencies] @@ -1476,6 +1513,25 @@ decorator = "*" ply = "*" six = "*" +[[package]] +name = "jsonpickle" +version = "4.1.1" +description = "jsonpickle encodes/decodes any Python object to/from JSON" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91"}, + {file = "jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1"}, +] + +[package.extras] +cov = ["pytest-cov"] +dev = ["black", "pyupgrade"] +docs = ["furo", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +packaging = ["build", "setuptools (>=61.2)", "setuptools_scm[toml] (>=6.0)", "twine"] +testing = ["PyYAML", "atheris (>=2.3.0,<2.4.0) ; python_version < \"3.12\"", "bson", "ecdsa", "feedparser", "gmpy2", "numpy", "pandas", "pymongo", "pytest (>=6.0,!=8.1.*)", "pytest-benchmark", "pytest-benchmark[histogram]", "pytest-checkdocs (>=1.2.3)", "pytest-enabler (>=1.0.1)", "pytest-ruff (>=0.2.1)", "scikit-learn", "scipy (>=1.9.3) ; python_version > \"3.10\"", "scipy ; python_version <= \"3.10\"", "simplejson", "sqlalchemy", "ujson"] + [[package]] name = "jsonpointer" version = "3.0.0" @@ -1592,21 +1648,21 @@ files = [ [[package]] name = "localstack" -version = "4.5.0" +version = "4.7.0" description = "LocalStack - A fully functional local Cloud stack" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "localstack-4.5.0.tar.gz", hash = "sha256:f8ebf3a9af1826c595cfe4196c6d52792152db374e437e1a574ac52aedc53a18"}, + {file = "localstack-4.7.0.tar.gz", hash = "sha256:c798c31ca241873f6ea6b935c7d06a6f7f7b9fe306e9cd296e3c1ee4adacdb27"}, ] [package.dependencies] localstack-core = "*" -localstack-ext = "4.5.0" +localstack-ext = "4.7.0" [package.extras] -runtime = ["localstack-core[runtime]", "localstack-ext[runtime] (==4.5.0)"] +runtime = ["localstack-core[runtime]", "localstack-ext[runtime] (==4.7.0)"] [[package]] name = "localstack-client" @@ -1627,14 +1683,14 @@ test = ["black", "coverage", "flake8", "isort", "localstack", "pytest"] [[package]] name = "localstack-core" -version = "4.5.0" +version = "4.7.0" description = "The core library and runtime of LocalStack" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "localstack_core-4.5.0-py3-none-any.whl", hash = "sha256:ab0099d840ff9e718a268315bd9965152f92241dfaf3124b7eaa1305dd58b0f6"}, - {file = "localstack_core-4.5.0.tar.gz", hash = "sha256:2930c0a67dad7f88d2690c0b8720a8b605774c6edf819593ab0328fd14a3e395"}, + {file = "localstack_core-4.7.0-py3-none-any.whl", hash = "sha256:6692d8fab21f105626e235e0bfec96df3b39b0f0e6156882ac7773a13404677b"}, + {file = "localstack_core-4.7.0.tar.gz", hash = "sha256:cd72f6779d4e76c2427c814a31e145f6d9e6dd3737a4d3e9283e6b690ea6b114"}, ] [package.dependencies] @@ -1645,6 +1701,7 @@ cryptography = "*" dill = "0.3.6" dnslib = ">=0.9.10" dnspython = ">=1.16.0" +jsonpickle = "4.1.1" plux = ">=1.10" psutil = ">=5.4.8" python-dotenv = ">=0.19.1" @@ -1655,21 +1712,21 @@ semver = ">=2.10" tailer = ">=0.4.1" [package.extras] -base-runtime = ["Werkzeug (>=3.1.3)", "awscrt (>=0.13.14,!=0.27.1)", "boto3 (==1.38.27)", "botocore (==1.38.27)", "cbor2 (>=5.5.0)", "dnspython (>=1.16.0)", "docker (>=6.1.1)", "hypercorn (>=0.14.4)", "jsonpatch (>=1.24)", "localstack-twisted (>=23.0)", "openapi-core (>=0.19.2)", "pyopenssl (>=23.0.0)", "readerwriterlock (>=1.0.7)", "requests-aws4auth (>=1.0)", "rolo (>=0.7)", "urllib3 (>=2.0.7)", "xmltodict (>=0.13.0)"] +base-runtime = ["Werkzeug (>=3.1.3)", "awscrt (>=0.13.14,!=0.27.1)", "boto3 (==1.39.14)", "botocore (==1.39.14)", "cbor2 (>=5.5.0)", "dnspython (>=1.16.0)", "docker (>=6.1.1)", "hypercorn (>=0.14.4)", "jsonpatch (>=1.24)", "localstack-twisted (>=23.0)", "openapi-core (>=0.19.2)", "pyopenssl (>=23.0.0)", "readerwriterlock (>=1.0.7)", "requests-aws4auth (>=1.0)", "rolo (>=0.7)", "urllib3 (>=2.0.7)", "xmltodict (>=0.13.0)"] dev = ["Cython", "coveralls (>=3.3.1)", "localstack-core[test]", "mypy", "networkx (>=2.8.4)", "openapi-spec-validator (>=0.7.1)", "pandoc", "pre-commit (>=3.5.0)", "pypandoc", "rstr (>=3.2.0)", "ruff (>=0.3.3)"] -runtime = ["airspeed-ext (>=0.6.3)", "antlr4-python3-runtime (==4.13.2)", "apispec (>=5.1.1)", "aws-sam-translator (>=1.15.1)", "awscli (>=1.37.0)", "crontab (>=0.22.6)", "cryptography (>=41.0.5)", "jpype1-ext (>=0.0.1)", "json5 (>=0.9.11)", "jsonpath-ng (>=1.6.1)", "jsonpath-rw (>=1.4.0)", "kclpy-ext (>=3.0.0)", "localstack-core[base-runtime]", "moto-ext[all] (==5.1.5.post1)", "opensearch-py (>=2.4.1)", "pymongo (>=4.2.0)", "pyopenssl (>=23.0.0)"] +runtime = ["airspeed-ext (>=0.6.3)", "antlr4-python3-runtime (==4.13.2)", "apispec (>=5.1.1)", "aws-sam-translator (>=1.15.1)", "awscli (==1.41.14)", "crontab (>=0.22.6)", "cryptography (>=41.0.5)", "jpype1-ext (>=0.0.1)", "json5 (>=0.9.11)", "jsonpath-ng (>=1.6.1)", "jsonpath-rw (>=1.4.0)", "kclpy-ext (>=3.0.0)", "localstack-core[base-runtime]", "moto-ext[all] (==5.1.6.post2)", "opensearch-py (>=2.4.1)", "pymongo (>=4.2.0)", "pyopenssl (>=23.0.0)"] test = ["aws-cdk-lib (>=2.88.0)", "coverage[toml] (>=5.5)", "deepdiff (>=6.4.1)", "httpx[http2] (>=0.25)", "localstack-core[runtime]", "localstack-snapshot (>=0.1.1)", "pluggy (>=1.3.0)", "pytest (>=7.4.2)", "pytest-httpserver (>=1.1.2)", "pytest-rerunfailures (>=12.0)", "pytest-split (>=0.8.0)", "pytest-tinybird (>=0.5.0)", "websocket-client (>=1.7.0)"] typehint = ["boto3-stubs[acm,acm-pca,amplify,apigateway,apigatewayv2,appconfig,appconfigdata,application-autoscaling,appsync,athena,autoscaling,backup,batch,ce,cloudcontrol,cloudformation,cloudfront,cloudtrail,cloudwatch,codebuild,codecommit,codeconnections,codedeploy,codepipeline,codestar-connections,cognito-identity,cognito-idp,dms,docdb,dynamodb,dynamodbstreams,ec2,ecr,ecs,efs,eks,elasticache,elasticbeanstalk,elbv2,emr,emr-serverless,es,events,firehose,fis,glacier,glue,iam,identitystore,iot,iot-data,iotanalytics,iotwireless,kafka,kinesis,kinesisanalytics,kinesisanalyticsv2,kms,lakeformation,lambda,logs,managedblockchain,mediaconvert,mediastore,mq,mwaa,neptune,opensearch,organizations,pi,pinpoint,pipes,qldb,qldb-session,rds,rds-data,redshift,redshift-data,resource-groups,resourcegroupstaggingapi,route53,route53resolver,s3,s3control,sagemaker,sagemaker-runtime,secretsmanager,serverlessrepo,servicediscovery,ses,sesv2,sns,sqs,ssm,sso-admin,stepfunctions,sts,timestream-query,timestream-write,transcribe,verifiedpermissions,wafv2,xray]", "localstack-core[dev]"] [[package]] name = "localstack-ext" -version = "4.5.0" +version = "4.7.0" description = "Extensions for LocalStack" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "localstack_ext-4.5.0.tar.gz", hash = "sha256:7d7c30ce3edbe822a5ff3db063d323ae3360c346d6719c83447fbd9188462556"}, + {file = "localstack_ext-4.7.0.tar.gz", hash = "sha256:38b7826c0c9b3fa4a01dbd8335ff2b5d4af1b7cf663a241fa76c31761d80dc5e"}, ] [package.dependencies] @@ -1677,7 +1734,7 @@ build = "*" dill = ">=0.3.2" dnslib = ">=0.9.10" dnspython = ">=1.16.0" -localstack-core = "4.5.0" +localstack-core = "4.7.0" packaging = "*" plux = ">=1.10.0" PyJWT = {version = ">=1.7.0", extras = ["crypto"]} @@ -1689,8 +1746,8 @@ windows-curses = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] package = ["python-minifier (<2.11.3)"] -runtime = ["Whoosh (>=2.7.4)", "amazon.ion (>=0.9.3)", "avro (>=1.11.0)", "aws-encryption-sdk (>=3.1.0)", "aws-json-term-matcher (>=0.1.5)", "cedarpy (>=4.1.0)", "confluent-kafka", "dirtyjson (>=1.0.7)", "distro", "dulwich (>=0.19.16)", "graphql-core (>=3.0.3)", "janus (>=0.5.0)", "jsonpatch (>=1.32)", "kafka-python", "kubernetes (>=21.7.0)", "libvirt-python", "localstack-core[runtime] (==4.5.0)", "mysql-replication", "paho-mqtt (>=1.5)", "parquet[snappy] (>=1.3.1)", "parse (>=1.19.0)", "pg8000 (>=1.10)", "postgres (>=2.2.2)", "postgresql-proxy (>=0.2.0)", "pproxy-ext (>=2.7.9)", "presto-python-client (>=0.7.0)", "pure-sasl (>=0.6.2)", "pycdlib (>=1.14.0)", "pycognito (>=2024.5.1)", "pyftpdlib (>=1.5.6)", "pyhive[hive-pure-sasl] (>=0.7.0)", "pyion2json (>=0.0.2)", "pymysql", "pyqldb (>=3.2,<4.0)", "python-dxf (>=12.1.1)", "python-snappy (>=0.6)", "readerwriterlock (>=1.0.7)", "redis (>=5.0)", "rsa (>=4.0)", "sql-metadata (>=2.6.0)", "sqlglot[rs]", "srp-ext (>=1.0.7.1)", "testing.common.database (>=1.1.0)", "thrift (>=0.10.0)", "thrift_sasl (>=0.1.0)", "tornado (>=6.0)", "websockets (>=8.1,<14)"] -test = ["PyAthena[pandas]", "aiohttp", "async-timeout", "aws-cdk-lib (>=2.88.0)", "aws-cdk.aws-cognito-identitypool-alpha", "aws_cdk.aws_neptune_alpha", "aws_cdk.aws_redshift_alpha", "aws_xray_sdk (>=2.4.2)", "awsiotsdk", "awsiotsdk", "awswrangler (>=3.5.2)", "coverage[toml] (>=5.0.0)", "deepdiff (>=5.5.0)", "gremlinpython", "jws (>=0.1.3)", "localstack-core[test] (==4.5.0)", "localstack-ext[runtime]", "msal", "msal-extensions", "msrest", "mysql-connector-python", "neo4j", "nest-asyncio (>=1.4.1)", "paramiko", "playwright", "portalocker", "pre-commit (>=3.5.0)", "pyarrow", "pymongo", "pymssql (>=2.2.8)", "pytest-httpserver (>=1.0.1)", "pytest-instafail (>=0.4.2)", "pytest-mock (>=3.14.0)", "pytest-playwright", "python-terraform", "redshift_connector", "ruff (>=0.1.0)", "stomp.py (>=8.0.1)"] +runtime = ["Whoosh (>=2.7.4)", "alembic (>=1.16.0)", "amazon.ion (>=0.9.3)", "avro (>=1.11.0)", "aws-encryption-sdk (>=3.1.0)", "aws-json-term-matcher (>=0.1.5)", "cedarpy (>=4.1.0)", "confluent-kafka", "dirtyjson (>=1.0.7)", "distro", "dulwich (>=0.19.16)", "graphql-core (>=3.0.3)", "janus (>=0.5.0)", "javascript", "jsonpatch (>=1.32)", "kafka-python", "kubernetes (>=21.7.0)", "libvirt-python", "localstack-core[runtime] (==4.7.0)", "mysql-replication", "opentelemetry-api", "opentelemetry-propagator-aws-xray", "opentelemetry-sdk", "paho-mqtt (>=1.5)", "parquet[snappy] (>=1.3.1)", "parse (>=1.19.0)", "pg8000 (>=1.10)", "postgres (>=2.2.2)", "postgresql-proxy (>=0.2.0)", "pproxy-ext (>=2.7.9)", "presto-python-client (>=0.7.0)", "pure-sasl (>=0.6.2)", "pycdlib (>=1.14.0)", "pycognito (>=2024.5.1)", "pyftpdlib (>=1.5.6)", "pyhive[hive-pure-sasl] (>=0.7.0)", "pyion2json (>=0.0.2)", "pymysql", "pyqldb (>=3.2,<4.0)", "python-dxf (>=12.1.1)", "python-snappy (>=0.6)", "readerwriterlock (>=1.0.7)", "redis (>=5.0)", "rsa (>=4.0)", "sql-metadata (>=2.6.0)", "sqlalchemy (>=2.0.0)", "sqlglot[rs]", "srp-ext (>=1.0.7.1)", "testing.common.database (>=1.1.0)", "thrift (>=0.10.0)", "thrift_sasl (>=0.1.0)", "tornado (>=6.0)", "websockets (>=8.1,<14)"] +test = ["PyAthena[pandas]", "aiohttp", "async-timeout", "aws-cdk-lib (>=2.88.0)", "aws-cdk.aws-cognito-identitypool-alpha", "aws_cdk.aws_neptune_alpha", "aws_cdk.aws_redshift_alpha", "aws_xray_sdk (>=2.4.2)", "awsiotsdk", "awsiotsdk", "awswrangler (>=3.5.2)", "coverage[toml] (>=5.0.0)", "deepdiff (>=5.5.0)", "gremlinpython", "jws (>=0.1.3)", "localstack-core[test] (==4.7.0)", "localstack-ext[runtime]", "msal", "msal-extensions", "msrest", "mysql-connector-python", "neo4j", "nest-asyncio (>=1.4.1)", "paramiko", "playwright", "portalocker", "pre-commit (>=3.5.0)", "pyarrow", "pymongo", "pymssql (>=2.2.8)", "pytest-httpserver (>=1.0.1)", "pytest-instafail (>=0.4.2)", "pytest-mock (>=3.14.0)", "pytest-playwright", "python-terraform", "redshift_connector", "ruff (>=0.1.0)", "stomp.py (>=8.0.1)"] typehint = ["boto3-stubs[acm,amplify,apigateway,apigatewayv2,appconfig,appsync,athena,autoscaling,backup,batch,bedrock,bedrock-runtime,ce,cloudcontrol,cloudformation,cloudfront,cloudtrail,cloudwatch,codecommit,cognito-identity,cognito-idp,dms,docdb,dynamodb,dynamodbstreams,ec2,ecr,ecs,efs,eks,elasticache,elasticbeanstalk,elbv2,emr,emr-serverless,es,events,firehose,fis,glacier,glue,iam,iot,iot-data,iotanalytics,iotwireless,kafka,kinesis,kinesisanalytics,kinesisanalyticsv2,kms,lakeformation,lambda,logs,mediaconvert,mediastore,mq,mwaa,neptune,opensearch,organizations,pi,qldb,qldb-session,rds,rds-data,redshift,redshift-data,resource-groups,resourcegroupstaggingapi,route53,route53resolver,s3,s3control,sagemaker,sagemaker-runtime,secretsmanager,serverlessrepo,servicediscovery,ses,sesv2,sns,sqs,ssm,sso-admin,stepfunctions,sts,timestream-query,timestream-write,transcribe,xray]", "localstack-ext[test]"] [[package]] @@ -1779,8 +1836,11 @@ files = [ {file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"}, {file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"}, + {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"}, + {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"}, + {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"}, {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"}, {file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"}, {file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"}, @@ -1856,14 +1916,14 @@ typing-extensions = "*" [[package]] name = "markdown-it-py" -version = "3.0.0" +version = "4.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, ] [package.dependencies] @@ -1871,13 +1931,12 @@ mdurl = ">=0.1,<1.0" [package.extras] benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] +plugins = ["mdit-py-plugins (>=0.5.0)"] profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] [[package]] name = "markupsafe" @@ -1964,14 +2023,14 @@ files = [ [[package]] name = "moto" -version = "5.1.6" +version = "5.1.9" description = "A library that allows you to easily mock out tests based on AWS infrastructure" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "moto-5.1.6-py3-none-any.whl", hash = "sha256:e4a3092bc8fe9139caa77cd34cdcbad804de4d9671e2270ea3b4d53f5c645047"}, - {file = "moto-5.1.6.tar.gz", hash = "sha256:baf7afa9d4a92f07277b29cf466d0738f25db2ed2ee12afcb1dc3f2c540beebd"}, + {file = "moto-5.1.9-py3-none-any.whl", hash = "sha256:e9ba7e4764a6088ccc34e3cc846ae719861ca202409fa865573de40a3e805b9b"}, + {file = "moto-5.1.9.tar.gz", hash = "sha256:0c4f0387b06b5d24c0ce90f8f89f31a565cc05789189c5d59b5df02594f2e371"}, ] [package.dependencies] @@ -2010,122 +2069,122 @@ xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] [[package]] name = "multidict" -version = "6.5.0" +version = "6.6.4" description = "multidict implementation" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "multidict-6.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2e118a202904623b1d2606d1c8614e14c9444b59d64454b0c355044058066469"}, - {file = "multidict-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a42995bdcaff4e22cb1280ae7752c3ed3fbb398090c6991a2797a4a0e5ed16a9"}, - {file = "multidict-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2261b538145723ca776e55208640fffd7ee78184d223f37c2b40b9edfe0e818a"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e5b19f8cd67235fab3e195ca389490415d9fef5a315b1fa6f332925dc924262"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:177b081e4dec67c3320b16b3aa0babc178bbf758553085669382c7ec711e1ec8"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d30a2cc106a7d116b52ee046207614db42380b62e6b1dd2a50eba47c5ca5eb1"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a72933bc308d7a64de37f0d51795dbeaceebdfb75454f89035cdfc6a74cfd129"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d109e663d032280ef8ef62b50924b2e887d5ddf19e301844a6cb7e91a172a6"}, - {file = "multidict-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b555329c9894332401f03b9a87016f0b707b6fccd4706793ec43b4a639e75869"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6994bad9d471ef2156f2b6850b51e20ee409c6b9deebc0e57be096be9faffdce"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b15f817276c96cde9060569023808eec966bd8da56a97e6aa8116f34ddab6534"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b4bf507c991db535a935b2127cf057a58dbc688c9f309c72080795c63e796f58"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:60c3f8f13d443426c55f88cf3172547bbc600a86d57fd565458b9259239a6737"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a10227168a24420c158747fc201d4279aa9af1671f287371597e2b4f2ff21879"}, - {file = "multidict-6.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e3b1425fe54ccfde66b8cfb25d02be34d5dfd2261a71561ffd887ef4088b4b69"}, - {file = "multidict-6.5.0-cp310-cp310-win32.whl", hash = "sha256:b4e47ef51237841d1087e1e1548071a6ef22e27ed0400c272174fa585277c4b4"}, - {file = "multidict-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:63b3b24fadc7067282c88fae5b2f366d5b3a7c15c021c2838de8c65a50eeefb4"}, - {file = "multidict-6.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:8b2d61afbafc679b7eaf08e9de4fa5d38bd5dc7a9c0a577c9f9588fb49f02dbb"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8b4bf6bb15a05796a07a248084e3e46e032860c899c7a9b981030e61368dba95"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46bb05d50219655c42a4b8fcda9c7ee658a09adbb719c48e65a20284e36328ea"}, - {file = "multidict-6.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54f524d73f4d54e87e03c98f6af601af4777e4668a52b1bd2ae0a4d6fc7b392b"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529b03600466480ecc502000d62e54f185a884ed4570dee90d9a273ee80e37b5"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69ad681ad7c93a41ee7005cc83a144b5b34a3838bcf7261e2b5356057b0f78de"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe9fada8bc0839466b09fa3f6894f003137942984843ec0c3848846329a36ae"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f94c6ea6405fcf81baef1e459b209a78cda5442e61b5b7a57ede39d99b5204a0"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca75ad8a39ed75f079a8931435a5b51ee4c45d9b32e1740f99969a5d1cc2ee"}, - {file = "multidict-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4c08f3a2a6cc42b414496017928d95898964fed84b1b2dace0c9ee763061f9"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:046a7540cfbb4d5dc846a1fd9843f3ba980c6523f2e0c5b8622b4a5c94138ae6"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64306121171d988af77d74be0d8c73ee1a69cf6f96aea7fa6030c88f32a152dd"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b4ac1dd5eb0ecf6f7351d5a9137f30a83f7182209c5d37f61614dfdce5714853"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bab4a8337235365f4111a7011a1f028826ca683834ebd12de4b85e2844359c36"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a05b5604c5a75df14a63eeeca598d11b2c3745b9008539b70826ea044063a572"}, - {file = "multidict-6.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67c4a640952371c9ca65b6a710598be246ef3be5ca83ed38c16a7660d3980877"}, - {file = "multidict-6.5.0-cp311-cp311-win32.whl", hash = "sha256:fdeae096ca36c12d8aca2640b8407a9d94e961372c68435bef14e31cce726138"}, - {file = "multidict-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:e2977ef8b7ce27723ee8c610d1bd1765da4f3fbe5a64f9bf1fd3b4770e31fbc0"}, - {file = "multidict-6.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:82d0cf0ea49bae43d9e8c3851e21954eff716259ff42da401b668744d1760bcb"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1bb986c8ea9d49947bc325c51eced1ada6d8d9b4c5b15fd3fcdc3c93edef5a74"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:03c0923da300120830fc467e23805d63bbb4e98b94032bd863bc7797ea5fa653"}, - {file = "multidict-6.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4c78d5ec00fdd35c91680ab5cf58368faad4bd1a8721f87127326270248de9bc"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadc3cb78be90a887f8f6b73945b840da44b4a483d1c9750459ae69687940c97"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b02e1ca495d71e07e652e4cef91adae3bf7ae4493507a263f56e617de65dafc"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7fe92a62326eef351668eec4e2dfc494927764a0840a1895cff16707fceffcd3"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7673ee4f63879ecd526488deb1989041abcb101b2d30a9165e1e90c489f3f7fb"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa097ae2a29f573de7e2d86620cbdda5676d27772d4ed2669cfa9961a0d73955"}, - {file = "multidict-6.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:300da0fa4f8457d9c4bd579695496116563409e676ac79b5e4dca18e49d1c308"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a19bd108c35877b57393243d392d024cfbfdefe759fd137abb98f6fc910b64c"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f32a1777465a35c35ddbbd7fc1293077938a69402fcc59e40b2846d04a120dd"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9cc1e10c14ce8112d1e6d8971fe3cdbe13e314f68bea0e727429249d4a6ce164"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e95c5e07a06594bdc288117ca90e89156aee8cb2d7c330b920d9c3dd19c05414"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:40ff26f58323795f5cd2855e2718a1720a1123fb90df4553426f0efd76135462"}, - {file = "multidict-6.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:76803a29fd71869a8b59c2118c9dcfb3b8f9c8723e2cce6baeb20705459505cf"}, - {file = "multidict-6.5.0-cp312-cp312-win32.whl", hash = "sha256:df7ecbc65a53a2ce1b3a0c82e6ad1a43dcfe7c6137733f9176a92516b9f5b851"}, - {file = "multidict-6.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ec1c3fbbb0b655a6540bce408f48b9a7474fd94ed657dcd2e890671fefa7743"}, - {file = "multidict-6.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:2d24a00d34808b22c1f15902899b9d82d0faeca9f56281641c791d8605eacd35"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53d92df1752df67a928fa7f884aa51edae6f1cf00eeb38cbcf318cf841c17456"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:680210de2c38eef17ce46b8df8bf2c1ece489261a14a6e43c997d49843a27c99"}, - {file = "multidict-6.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e279259bcb936732bfa1a8eec82b5d2352b3df69d2fa90d25808cfc403cee90a"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1c185fc1069781e3fc8b622c4331fb3b433979850392daa5efbb97f7f9959bb"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6bb5f65ff91daf19ce97f48f63585e51595539a8a523258b34f7cef2ec7e0617"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8646b4259450c59b9286db280dd57745897897284f6308edbdf437166d93855"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d245973d4ecc04eea0a8e5ebec7882cf515480036e1b48e65dffcfbdf86d00be"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a133e7ddc9bc7fb053733d0ff697ce78c7bf39b5aec4ac12857b6116324c8d75"}, - {file = "multidict-6.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80d696fa38d738fcebfd53eec4d2e3aeb86a67679fd5e53c325756682f152826"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20d30c9410ac3908abbaa52ee5967a754c62142043cf2ba091e39681bd51d21a"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c65068cc026f217e815fa519d8e959a7188e94ec163ffa029c94ca3ef9d4a73"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e355ac668a8c3e49c2ca8daa4c92f0ad5b705d26da3d5af6f7d971e46c096da7"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08db204213d0375a91a381cae0677ab95dd8c67a465eb370549daf6dbbf8ba10"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ffa58e3e215af8f6536dc837a990e456129857bb6fd546b3991be470abd9597a"}, - {file = "multidict-6.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e86eb90015c6f21658dbd257bb8e6aa18bdb365b92dd1fba27ec04e58cdc31b"}, - {file = "multidict-6.5.0-cp313-cp313-win32.whl", hash = "sha256:f34a90fbd9959d0f857323bd3c52b3e6011ed48f78d7d7b9e04980b8a41da3af"}, - {file = "multidict-6.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fcb2aa79ac6aef8d5b709bbfc2fdb1d75210ba43038d70fbb595b35af470ce06"}, - {file = "multidict-6.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:6dcee5e7e92060b4bb9bb6f01efcbb78c13d0e17d9bc6eec71660dd71dc7b0c2"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:cbbc88abea2388fde41dd574159dec2cda005cb61aa84950828610cb5010f21a"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70b599f70ae6536e5976364d3c3cf36f40334708bd6cebdd1e2438395d5e7676"}, - {file = "multidict-6.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:828bab777aa8d29d59700018178061854e3a47727e0611cb9bec579d3882de3b"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9695fc1462f17b131c111cf0856a22ff154b0480f86f539d24b2778571ff94d"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b5ac6ebaf5d9814b15f399337ebc6d3a7f4ce9331edd404e76c49a01620b68d"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84a51e3baa77ded07be4766a9e41d977987b97e49884d4c94f6d30ab6acaee14"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de67f79314d24179e9b1869ed15e88d6ba5452a73fc9891ac142e0ee018b5d6"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17f78a52c214481d30550ec18208e287dfc4736f0c0148208334b105fd9e0887"}, - {file = "multidict-6.5.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2966d0099cb2e2039f9b0e73e7fd5eb9c85805681aa2a7f867f9d95b35356921"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:86fb42ed5ed1971c642cc52acc82491af97567534a8e381a8d50c02169c4e684"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:4e990cbcb6382f9eae4ec720bcac6a1351509e6fc4a5bb70e4984b27973934e6"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d99a59d64bb1f7f2117bec837d9e534c5aeb5dcedf4c2b16b9753ed28fdc20a3"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e8ef15cc97c9890212e1caf90f0d63f6560e1e101cf83aeaf63a57556689fb34"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b8a09aec921b34bd8b9f842f0bcfd76c6a8c033dc5773511e15f2d517e7e1068"}, - {file = "multidict-6.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff07b504c23b67f2044533244c230808a1258b3493aaf3ea2a0785f70b7be461"}, - {file = "multidict-6.5.0-cp313-cp313t-win32.whl", hash = "sha256:9232a117341e7e979d210e41c04e18f1dc3a1d251268df6c818f5334301274e1"}, - {file = "multidict-6.5.0-cp313-cp313t-win_amd64.whl", hash = "sha256:44cb5c53fb2d4cbcee70a768d796052b75d89b827643788a75ea68189f0980a1"}, - {file = "multidict-6.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:51d33fafa82640c0217391d4ce895d32b7e84a832b8aee0dcc1b04d8981ec7f4"}, - {file = "multidict-6.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c0078358470da8dc90c37456f4a9cde9f86200949a048d53682b9cd21e5bbf2b"}, - {file = "multidict-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5cc7968b7d1bf8b973c307d38aa3a2f2c783f149bcac855944804252f1df5105"}, - {file = "multidict-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad73a60e11aa92f1f2c9330efdeaac4531b719fc568eb8d312fd4112f34cc18"}, - {file = "multidict-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3233f21abdcd180b2624eb6988a1e1287210e99bca986d8320afca5005d85844"}, - {file = "multidict-6.5.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bee5c0b79fca78fd2ab644ca4dc831ecf793eb6830b9f542ee5ed2c91bc35a0e"}, - {file = "multidict-6.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e053a4d690f4352ce46583080fefade9a903ce0fa9d820db1be80bdb9304fa2f"}, - {file = "multidict-6.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42bdee30424c1f4dcda96e07ac60e2a4ede8a89f8ae2f48b5e4ccc060f294c52"}, - {file = "multidict-6.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58b2ded1a7982cf7b8322b0645713a0086b2b3cf5bb9f7c01edfc1a9f98d20dc"}, - {file = "multidict-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f805b8b951d1fadc5bc18c3c93e509608ac5a883045ee33bc22e28806847c20"}, - {file = "multidict-6.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2540395b63723da748f850568357a39cd8d8d4403ca9439f9fcdad6dd423c780"}, - {file = "multidict-6.5.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:c96aedff25f4e47b6697ba048b2c278f7caa6df82c7c3f02e077bcc8d47b4b76"}, - {file = "multidict-6.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e80de5ad995de210fd02a65c2350649b8321d09bd2e44717eaefb0f5814503e8"}, - {file = "multidict-6.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:6cb9bcedd9391b313e5ec2fb3aa07c03e050550e7b9e4646c076d5c24ba01532"}, - {file = "multidict-6.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a7d130ed7a112e25ab47309962ecafae07d073316f9d158bc7b3936b52b80121"}, - {file = "multidict-6.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:95750a9a9741cd1855d1b6cb4c6031ae01c01ad38d280217b64bfae986d39d56"}, - {file = "multidict-6.5.0-cp39-cp39-win32.whl", hash = "sha256:7f78caf409914f108f4212b53a9033abfdc2cbab0647e9ac3a25bb0f21ab43d2"}, - {file = "multidict-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220c74009507e847a3a6fc5375875f2a2e05bd9ce28cf607be0e8c94600f4472"}, - {file = "multidict-6.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:d98f4ac9c1ede7e9d04076e2e6d967e15df0079a6381b297270f6bcab661195e"}, - {file = "multidict-6.5.0-py3-none-any.whl", hash = "sha256:5634b35f225977605385f56153bd95a7133faffc0ffe12ad26e10517537e8dfc"}, - {file = "multidict-6.5.0.tar.gz", hash = "sha256:942bd8002492ba819426a8d7aefde3189c1b87099cdf18aaaefefcf7f3f7b6d2"}, + {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f"}, + {file = "multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb"}, + {file = "multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0"}, + {file = "multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987"}, + {file = "multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f"}, + {file = "multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f"}, + {file = "multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0"}, + {file = "multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729"}, + {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c"}, + {file = "multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb"}, + {file = "multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:105245cc6b76f51e408451a844a54e6823bbd5a490ebfe5bdfc79798511ceded"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cbbc54e58b34c3bae389ef00046be0961f30fef7cb0dd9c7756aee376a4f7683"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:56c6b3652f945c9bc3ac6c8178cd93132b8d82dd581fcbc3a00676c51302bc1a"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b95494daf857602eccf4c18ca33337dd2be705bccdb6dddbfc9d513e6addb9d9"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5b1413361cef15340ab9dc61523e653d25723e82d488ef7d60a12878227ed50"}, + {file = "multidict-6.6.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e167bf899c3d724f9662ef00b4f7fef87a19c22b2fead198a6f68b263618df52"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aaea28ba20a9026dfa77f4b80369e51cb767c61e33a2d4043399c67bd95fb7c6"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8c91cdb30809a96d9ecf442ec9bc45e8cfaa0f7f8bdf534e082c2443a196727e"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1a0ccbfe93ca114c5d65a2471d52d8829e56d467c97b0e341cf5ee45410033b3"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:55624b3f321d84c403cb7d8e6e982f41ae233d85f85db54ba6286f7295dc8a9c"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4a1fb393a2c9d202cb766c76208bd7945bc194eba8ac920ce98c6e458f0b524b"}, + {file = "multidict-6.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:43868297a5759a845fa3a483fb4392973a95fb1de891605a3728130c52b8f40f"}, + {file = "multidict-6.6.4-cp311-cp311-win32.whl", hash = "sha256:ed3b94c5e362a8a84d69642dbeac615452e8af9b8eb825b7bc9f31a53a1051e2"}, + {file = "multidict-6.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:d8c112f7a90d8ca5d20213aa41eac690bb50a76da153e3afb3886418e61cb22e"}, + {file = "multidict-6.6.4-cp311-cp311-win_arm64.whl", hash = "sha256:3bb0eae408fa1996d87247ca0d6a57b7fc1dcf83e8a5c47ab82c558c250d4adf"}, + {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8"}, + {file = "multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3"}, + {file = "multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c"}, + {file = "multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802"}, + {file = "multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24"}, + {file = "multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793"}, + {file = "multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e"}, + {file = "multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364"}, + {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e"}, + {file = "multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657"}, + {file = "multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a"}, + {file = "multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812"}, + {file = "multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a"}, + {file = "multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69"}, + {file = "multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf"}, + {file = "multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605"}, + {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb"}, + {file = "multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e"}, + {file = "multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45"}, + {file = "multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0"}, + {file = "multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92"}, + {file = "multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e"}, + {file = "multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4"}, + {file = "multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad"}, + {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:af7618b591bae552b40dbb6f93f5518328a949dac626ee75927bba1ecdeea9f4"}, + {file = "multidict-6.6.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b6819f83aef06f560cb15482d619d0e623ce9bf155115150a85ab11b8342a665"}, + {file = "multidict-6.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d09384e75788861e046330308e7af54dd306aaf20eb760eb1d0de26b2bea2cb"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a59c63061f1a07b861c004e53869eb1211ffd1a4acbca330e3322efa6dd02978"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350f6b0fe1ced61e778037fdc7613f4051c8baf64b1ee19371b42a3acdb016a0"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c5cbac6b55ad69cb6aa17ee9343dfbba903118fd530348c330211dc7aa756d1"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630f70c32b8066ddfd920350bc236225814ad94dfa493fe1910ee17fe4365cbb"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d4916a81697faec6cb724a273bd5457e4c6c43d82b29f9dc02c5542fd21fc9"}, + {file = "multidict-6.6.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e42332cf8276bb7645d310cdecca93a16920256a5b01bebf747365f86a1675b"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f3be27440f7644ab9a13a6fc86f09cdd90b347c3c5e30c6d6d860de822d7cb53"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:21f216669109e02ef3e2415ede07f4f8987f00de8cdfa0cc0b3440d42534f9f0"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d9890d68c45d1aeac5178ded1d1cccf3bc8d7accf1f976f79bf63099fb16e4bd"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:edfdcae97cdc5d1a89477c436b61f472c4d40971774ac4729c613b4b133163cb"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0b2e886624be5773e69cf32bcb8534aecdeb38943520b240fed3d5596a430f2f"}, + {file = "multidict-6.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:be5bf4b3224948032a845d12ab0f69f208293742df96dc14c4ff9b09e508fc17"}, + {file = "multidict-6.6.4-cp39-cp39-win32.whl", hash = "sha256:10a68a9191f284fe9d501fef4efe93226e74df92ce7a24e301371293bd4918ae"}, + {file = "multidict-6.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:ee25f82f53262f9ac93bd7e58e47ea1bdcc3393cef815847e397cba17e284210"}, + {file = "multidict-6.6.4-cp39-cp39-win_arm64.whl", hash = "sha256:f9867e55590e0855bcec60d4f9a092b69476db64573c9fe17e92b0c50614c16a"}, + {file = "multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c"}, + {file = "multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd"}, ] [[package]] @@ -2265,14 +2324,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "plux" -version = "1.12.1" +version = "1.13.0" description = "A dynamic code loading framework for building pluggable Python distributions" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "plux-1.12.1-py3-none-any.whl", hash = "sha256:b4aa4e67329f2fcd73fb28096a8a9304f5912ee6cce39994eac567e8eec65488"}, - {file = "plux-1.12.1.tar.gz", hash = "sha256:1ed44a6edbb7343f4711ff75ddaf87bed066c53625d41b63a0b4edd3f77792ba"}, + {file = "plux-1.13.0-py3-none-any.whl", hash = "sha256:0b68cb35fc3bf4b2a760ef6892ea734a1c0d241e2f403540005fa26436773a62"}, + {file = "plux-1.13.0.tar.gz", hash = "sha256:0358a618883be270cf8dd6b5ae48e633a7fecd386cff6347c8560116f4688a75"}, ] [package.extras] @@ -2292,14 +2351,14 @@ files = [ [[package]] name = "polyfactory" -version = "2.21.0" +version = "2.22.1" description = "Mock data generation factories" optional = false python-versions = "<4.0,>=3.8" groups = ["dev"] files = [ - {file = "polyfactory-2.21.0-py3-none-any.whl", hash = "sha256:9483b764756c8622313d99f375889b1c0d92f09affb05742d7bcfa2b5198d8c5"}, - {file = "polyfactory-2.21.0.tar.gz", hash = "sha256:a6d8dba91b2515d744cc014b5be48835633f7ccb72519a68f8801759e5b1737a"}, + {file = "polyfactory-2.22.1-py3-none-any.whl", hash = "sha256:7500ee3678d9bc25347c0a73a35d3711cfcf9c7f45ad56d0bb085e9f75ecae7a"}, + {file = "polyfactory-2.22.1.tar.gz", hash = "sha256:6c91693088c81ab8fbe22dc66cae21fd3c17f91930fe1fae5b35b030eb020d3a"}, ] [package.dependencies] @@ -2685,14 +2744,14 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.9.1" +version = "2.10.1" description = "Settings management using Pydantic" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"}, - {file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"}, + {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, + {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, ] [package.dependencies] @@ -2709,14 +2768,14 @@ yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [package.extras] @@ -2806,14 +2865,14 @@ docs = ["furo (>=2022.3.4,<2023.0.0)", "myst-parser (>=0.17)", "sphinx (>=4.3.0, [[package]] name = "pyright" -version = "1.1.402" +version = "1.1.403" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982"}, - {file = "pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683"}, + {file = "pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3"}, + {file = "pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104"}, ] [package.dependencies] @@ -2849,14 +2908,14 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests [[package]] name = "pytest-asyncio" -version = "1.0.0" +version = "1.1.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3"}, - {file = "pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f"}, + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, ] [package.dependencies] @@ -2888,14 +2947,14 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-docker" -version = "3.2.2" +version = "3.2.3" description = "Simple pytest fixtures for Docker and Docker Compose based tests" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "pytest_docker-3.2.2-py3-none-any.whl", hash = "sha256:2926033d48a10de611070fce17f6e67b9e81af2d8ccc59debbbf39872b8ebef9"}, - {file = "pytest_docker-3.2.2.tar.gz", hash = "sha256:58ce79f3173209634bfff8ccaed2ce5593463d5272325c912e1b52a53154f452"}, + {file = "pytest_docker-3.2.3-py3-none-any.whl", hash = "sha256:f973c35e6f2b674c8fc87e8b3354b02c15866a21994c0841a338c240a05de1eb"}, + {file = "pytest_docker-3.2.3.tar.gz", hash = "sha256:26a1c711d99ef01e86e7c9c007f69641552c1554df4fccb065b35581cca24206"}, ] [package.dependencies] @@ -2924,14 +2983,14 @@ pytest = ">=3.6" [[package]] name = "pytest-nhsd-apim" -version = "5.0.0" +version = "5.0.3" description = "Pytest plugin accessing NHSDigital's APIM proxies" optional = false python-versions = "<4.0,>=3.8" groups = ["dev"] files = [ - {file = "pytest_nhsd_apim-5.0.0-py3-none-any.whl", hash = "sha256:ef79d99221e403e2198c3f206655f85ff59a9111549b03367f39b1cfe444828d"}, - {file = "pytest_nhsd_apim-5.0.0.tar.gz", hash = "sha256:7107b78ac0162727dccb8f51ca19ee339d86cdbcb97ae1948ff19e840d2ad932"}, + {file = "pytest_nhsd_apim-5.0.3-py3-none-any.whl", hash = "sha256:1aa230a207fec792ae77959fdb0e0950daa9608a099e0033d00d164c7e18862f"}, + {file = "pytest_nhsd_apim-5.0.3.tar.gz", hash = "sha256:9fe29e2088194da994b1a506ebf5d0883030ec04fc7423e13f254667654b444e"}, ] [package.dependencies] @@ -3113,14 +3172,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.25.7" +version = "0.25.8" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "responses-0.25.7-py3-none-any.whl", hash = "sha256:92ca17416c90fe6b35921f52179bff29332076bb32694c0df02dcac2c6bc043c"}, - {file = "responses-0.25.7.tar.gz", hash = "sha256:8ebae11405d7a5df79ab6fd54277f6f2bc29b2d002d0dd2d5c632594d1ddcedb"}, + {file = "responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c"}, + {file = "responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4"}, ] [package.dependencies] @@ -3160,14 +3219,14 @@ files = [ [[package]] name = "rich" -version = "14.0.0" +version = "14.1.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["main", "dev"] files = [ - {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, - {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, + {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, + {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, ] [package.dependencies] @@ -3179,129 +3238,167 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "rpds-py" -version = "0.25.1" +version = "0.27.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "rpds_py-0.25.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f4ad628b5174d5315761b67f212774a32f5bad5e61396d38108bd801c0a8f5d9"}, - {file = "rpds_py-0.25.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c742af695f7525e559c16f1562cf2323db0e3f0fbdcabdf6865b095256b2d40"}, - {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:605ffe7769e24b1800b4d024d24034405d9404f0bc2f55b6db3362cd34145a6f"}, - {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ccc6f3ddef93243538be76f8e47045b4aad7a66a212cd3a0f23e34469473d36b"}, - {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f70316f760174ca04492b5ab01be631a8ae30cadab1d1081035136ba12738cfa"}, - {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e1dafef8df605fdb46edcc0bf1573dea0d6d7b01ba87f85cd04dc855b2b4479e"}, - {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0701942049095741a8aeb298a31b203e735d1c61f4423511d2b1a41dcd8a16da"}, - {file = "rpds_py-0.25.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e87798852ae0b37c88babb7f7bbbb3e3fecc562a1c340195b44c7e24d403e380"}, - {file = "rpds_py-0.25.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3bcce0edc1488906c2d4c75c94c70a0417e83920dd4c88fec1078c94843a6ce9"}, - {file = "rpds_py-0.25.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e2f6a2347d3440ae789505693a02836383426249d5293541cd712e07e7aecf54"}, - {file = "rpds_py-0.25.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4fd52d3455a0aa997734f3835cbc4c9f32571345143960e7d7ebfe7b5fbfa3b2"}, - {file = "rpds_py-0.25.1-cp310-cp310-win32.whl", hash = "sha256:3f0b1798cae2bbbc9b9db44ee068c556d4737911ad53a4e5093d09d04b3bbc24"}, - {file = "rpds_py-0.25.1-cp310-cp310-win_amd64.whl", hash = "sha256:3ebd879ab996537fc510a2be58c59915b5dd63bccb06d1ef514fee787e05984a"}, - {file = "rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d"}, - {file = "rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255"}, - {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2"}, - {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0"}, - {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f"}, - {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7"}, - {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd"}, - {file = "rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65"}, - {file = "rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f"}, - {file = "rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d"}, - {file = "rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042"}, - {file = "rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc"}, - {file = "rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4"}, - {file = "rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4"}, - {file = "rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c"}, - {file = "rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b"}, - {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa"}, - {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda"}, - {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309"}, - {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b"}, - {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea"}, - {file = "rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65"}, - {file = "rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c"}, - {file = "rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd"}, - {file = "rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb"}, - {file = "rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe"}, - {file = "rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192"}, - {file = "rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728"}, - {file = "rpds_py-0.25.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:659d87430a8c8c704d52d094f5ba6fa72ef13b4d385b7e542a08fc240cb4a559"}, - {file = "rpds_py-0.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:68f6f060f0bbdfb0245267da014d3a6da9be127fe3e8cc4a68c6f833f8a23bb1"}, - {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:083a9513a33e0b92cf6e7a6366036c6bb43ea595332c1ab5c8ae329e4bcc0a9c"}, - {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:816568614ecb22b18a010c7a12559c19f6fe993526af88e95a76d5a60b8b75fb"}, - {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c6564c0947a7f52e4792983f8e6cf9bac140438ebf81f527a21d944f2fd0a40"}, - {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c4a128527fe415d73cf1f70a9a688d06130d5810be69f3b553bf7b45e8acf79"}, - {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a49e1d7a4978ed554f095430b89ecc23f42014a50ac385eb0c4d163ce213c325"}, - {file = "rpds_py-0.25.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d74ec9bc0e2feb81d3f16946b005748119c0f52a153f6db6a29e8cd68636f295"}, - {file = "rpds_py-0.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3af5b4cc10fa41e5bc64e5c198a1b2d2864337f8fcbb9a67e747e34002ce812b"}, - {file = "rpds_py-0.25.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79dc317a5f1c51fd9c6a0c4f48209c6b8526d0524a6904fc1076476e79b00f98"}, - {file = "rpds_py-0.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1521031351865e0181bc585147624d66b3b00a84109b57fcb7a779c3ec3772cd"}, - {file = "rpds_py-0.25.1-cp313-cp313-win32.whl", hash = "sha256:5d473be2b13600b93a5675d78f59e63b51b1ba2d0476893415dfbb5477e65b31"}, - {file = "rpds_py-0.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:a7b74e92a3b212390bdce1d93da9f6488c3878c1d434c5e751cbc202c5e09500"}, - {file = "rpds_py-0.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:dd326a81afe332ede08eb39ab75b301d5676802cdffd3a8f287a5f0b694dc3f5"}, - {file = "rpds_py-0.25.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a58d1ed49a94d4183483a3ce0af22f20318d4a1434acee255d683ad90bf78129"}, - {file = "rpds_py-0.25.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f251bf23deb8332823aef1da169d5d89fa84c89f67bdfb566c49dea1fccfd50d"}, - {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd586bfa270c1103ece2109314dd423df1fa3d9719928b5d09e4840cec0d72"}, - {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d273f136e912aa101a9274c3145dcbddbe4bac560e77e6d5b3c9f6e0ed06d34"}, - {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:666fa7b1bd0a3810a7f18f6d3a25ccd8866291fbbc3c9b912b917a6715874bb9"}, - {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:921954d7fbf3fccc7de8f717799304b14b6d9a45bbeec5a8d7408ccbf531faf5"}, - {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d86373ff19ca0441ebeb696ef64cb58b8b5cbacffcda5a0ec2f3911732a194"}, - {file = "rpds_py-0.25.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c8980cde3bb8575e7c956a530f2c217c1d6aac453474bf3ea0f9c89868b531b6"}, - {file = "rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8eb8c84ecea987a2523e057c0d950bcb3f789696c0499290b8d7b3107a719d78"}, - {file = "rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e43a005671a9ed5a650f3bc39e4dbccd6d4326b24fb5ea8be5f3a43a6f576c72"}, - {file = "rpds_py-0.25.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:58f77c60956501a4a627749a6dcb78dac522f249dd96b5c9f1c6af29bfacfb66"}, - {file = "rpds_py-0.25.1-cp313-cp313t-win32.whl", hash = "sha256:2cb9e5b5e26fc02c8a4345048cd9998c2aca7c2712bd1b36da0c72ee969a3523"}, - {file = "rpds_py-0.25.1-cp313-cp313t-win_amd64.whl", hash = "sha256:401ca1c4a20cc0510d3435d89c069fe0a9ae2ee6495135ac46bdd49ec0495763"}, - {file = "rpds_py-0.25.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ce4c8e485a3c59593f1a6f683cf0ea5ab1c1dc94d11eea5619e4fb5228b40fbd"}, - {file = "rpds_py-0.25.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d8222acdb51a22929c3b2ddb236b69c59c72af4019d2cba961e2f9add9b6e634"}, - {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4593c4eae9b27d22df41cde518b4b9e4464d139e4322e2127daa9b5b981b76be"}, - {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd035756830c712b64725a76327ce80e82ed12ebab361d3a1cdc0f51ea21acb0"}, - {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:114a07e85f32b125404f28f2ed0ba431685151c037a26032b213c882f26eb908"}, - {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dec21e02e6cc932538b5203d3a8bd6aa1480c98c4914cb88eea064ecdbc6396a"}, - {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09eab132f41bf792c7a0ea1578e55df3f3e7f61888e340779b06050a9a3f16e9"}, - {file = "rpds_py-0.25.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c98f126c4fc697b84c423e387337d5b07e4a61e9feac494362a59fd7a2d9ed80"}, - {file = "rpds_py-0.25.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0e6a327af8ebf6baba1c10fadd04964c1965d375d318f4435d5f3f9651550f4a"}, - {file = "rpds_py-0.25.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc120d1132cff853ff617754196d0ac0ae63befe7c8498bd67731ba368abe451"}, - {file = "rpds_py-0.25.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:140f61d9bed7839446bdd44852e30195c8e520f81329b4201ceead4d64eb3a9f"}, - {file = "rpds_py-0.25.1-cp39-cp39-win32.whl", hash = "sha256:9c006f3aadeda131b438c3092124bd196b66312f0caa5823ef09585a669cf449"}, - {file = "rpds_py-0.25.1-cp39-cp39-win_amd64.whl", hash = "sha256:a61d0b2c7c9a0ae45732a77844917b427ff16ad5464b4d4f5e4adb955f582890"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b24bf3cd93d5b6ecfbedec73b15f143596c88ee249fa98cefa9a9dc9d92c6f28"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:0eb90e94f43e5085623932b68840b6f379f26db7b5c2e6bcef3179bd83c9330f"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d50e4864498a9ab639d6d8854b25e80642bd362ff104312d9770b05d66e5fb13"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c9409b47ba0650544b0bb3c188243b83654dfe55dcc173a86832314e1a6a35d"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:796ad874c89127c91970652a4ee8b00d56368b7e00d3477f4415fe78164c8000"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85608eb70a659bf4c1142b2781083d4b7c0c4e2c90eff11856a9754e965b2540"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4feb9211d15d9160bc85fa72fed46432cdc143eb9cf6d5ca377335a921ac37b"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ccfa689b9246c48947d31dd9d8b16d89a0ecc8e0e26ea5253068efb6c542b76e"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:3c5b317ecbd8226887994852e85de562f7177add602514d4ac40f87de3ae45a8"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:454601988aab2c6e8fd49e7634c65476b2b919647626208e376afcd22019eeb8"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1c0c434a53714358532d13539272db75a5ed9df75a4a090a753ac7173ec14e11"}, - {file = "rpds_py-0.25.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f73ce1512e04fbe2bc97836e89830d6b4314c171587a99688082d090f934d20a"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf"}, - {file = "rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:50f2c501a89c9a5f4e454b126193c5495b9fb441a75b298c60591d8a2eb92e1b"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7d779b325cc8238227c47fbc53964c8cc9a941d5dbae87aa007a1f08f2f77b23"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:036ded36bedb727beeabc16dc1dad7cb154b3fa444e936a03b67a86dc6a5066e"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245550f5a1ac98504147cba96ffec8fabc22b610742e9150138e5d60774686d7"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff7c23ba0a88cb7b104281a99476cccadf29de2a0ef5ce864959a52675b1ca83"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e37caa8cdb3b7cf24786451a0bdb853f6347b8b92005eeb64225ae1db54d1c2b"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2f48ab00181600ee266a095fe815134eb456163f7d6699f525dee471f312cf"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e5fc7484fa7dce57e25063b0ec9638ff02a908304f861d81ea49273e43838c1"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d3c10228d6cf6fe2b63d2e7985e94f6916fa46940df46b70449e9ff9297bd3d1"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:5d9e40f32745db28c1ef7aad23f6fc458dc1e29945bd6781060f0d15628b8ddf"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:35a8d1a24b5936b35c5003313bc177403d8bdef0f8b24f28b1c4a255f94ea992"}, - {file = "rpds_py-0.25.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:6099263f526efff9cf3883dfef505518730f7a7a93049b1d90d42e50a22b4793"}, - {file = "rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3"}, + {file = "rpds_py-0.27.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:130c1ffa5039a333f5926b09e346ab335f0d4ec393b030a18549a7c7e7c2cea4"}, + {file = "rpds_py-0.27.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a4cf32a26fa744101b67bfd28c55d992cd19438aff611a46cac7f066afca8fd4"}, + {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64a0fe3f334a40b989812de70160de6b0ec7e3c9e4a04c0bbc48d97c5d3600ae"}, + {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a0ff7ee28583ab30a52f371b40f54e7138c52ca67f8ca17ccb7ccf0b383cb5f"}, + {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15ea4d2e182345dd1b4286593601d766411b43f868924afe297570658c31a62b"}, + {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36184b44bf60a480863e51021c26aca3dfe8dd2f5eeabb33622b132b9d8b8b54"}, + {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b78430703cfcf5f5e86eb74027a1ed03a93509273d7c705babb547f03e60016"}, + {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:dbd749cff1defbde270ca346b69b3baf5f1297213ef322254bf2a28537f0b046"}, + {file = "rpds_py-0.27.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bde37765564cd22a676dd8101b657839a1854cfaa9c382c5abf6ff7accfd4ae"}, + {file = "rpds_py-0.27.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1d66f45b9399036e890fb9c04e9f70c33857fd8f58ac8db9f3278cfa835440c3"}, + {file = "rpds_py-0.27.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d85d784c619370d9329bbd670f41ff5f2ae62ea4519761b679d0f57f0f0ee267"}, + {file = "rpds_py-0.27.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5df559e9e7644d9042f626f2c3997b555f347d7a855a15f170b253f6c5bfe358"}, + {file = "rpds_py-0.27.0-cp310-cp310-win32.whl", hash = "sha256:b8a4131698b6992b2a56015f51646711ec5d893a0b314a4b985477868e240c87"}, + {file = "rpds_py-0.27.0-cp310-cp310-win_amd64.whl", hash = "sha256:cbc619e84a5e3ab2d452de831c88bdcad824414e9c2d28cd101f94dbdf26329c"}, + {file = "rpds_py-0.27.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dbc2ab5d10544eb485baa76c63c501303b716a5c405ff2469a1d8ceffaabf622"}, + {file = "rpds_py-0.27.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7ec85994f96a58cf7ed288caa344b7fe31fd1d503bdf13d7331ead5f70ab60d5"}, + {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:190d7285cd3bb6d31d37a0534d7359c1ee191eb194c511c301f32a4afa5a1dd4"}, + {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c10d92fb6d7fd827e44055fcd932ad93dac6a11e832d51534d77b97d1d85400f"}, + {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd2c1d27ebfe6a015cfa2005b7fe8c52d5019f7bbdd801bc6f7499aab9ae739e"}, + {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4790c9d5dd565ddb3e9f656092f57268951398cef52e364c405ed3112dc7c7c1"}, + {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4300e15e7d03660f04be84a125d1bdd0e6b2f674bc0723bc0fd0122f1a4585dc"}, + {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:59195dc244fc183209cf8a93406889cadde47dfd2f0a6b137783aa9c56d67c85"}, + {file = "rpds_py-0.27.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fae4a01ef8c4cb2bbe92ef2063149596907dc4a881a8d26743b3f6b304713171"}, + {file = "rpds_py-0.27.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e3dc8d4ede2dbae6c0fc2b6c958bf51ce9fd7e9b40c0f5b8835c3fde44f5807d"}, + {file = "rpds_py-0.27.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3782fb753aa825b4ccabc04292e07897e2fd941448eabf666856c5530277626"}, + {file = "rpds_py-0.27.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:887ab1f12b0d227e9260558a4a2320024b20102207ada65c43e1ffc4546df72e"}, + {file = "rpds_py-0.27.0-cp311-cp311-win32.whl", hash = "sha256:5d6790ff400254137b81b8053b34417e2c46921e302d655181d55ea46df58cf7"}, + {file = "rpds_py-0.27.0-cp311-cp311-win_amd64.whl", hash = "sha256:e24d8031a2c62f34853756d9208eeafa6b940a1efcbfe36e8f57d99d52bb7261"}, + {file = "rpds_py-0.27.0-cp311-cp311-win_arm64.whl", hash = "sha256:08680820d23df1df0a0260f714d12966bc6c42d02e8055a91d61e03f0c47dda0"}, + {file = "rpds_py-0.27.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:19c990fdf5acecbf0623e906ae2e09ce1c58947197f9bced6bbd7482662231c4"}, + {file = "rpds_py-0.27.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6c27a7054b5224710fcfb1a626ec3ff4f28bcb89b899148c72873b18210e446b"}, + {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09965b314091829b378b60607022048953e25f0b396c2b70e7c4c81bcecf932e"}, + {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:14f028eb47f59e9169bfdf9f7ceafd29dd64902141840633683d0bad5b04ff34"}, + {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6168af0be75bba990a39f9431cdfae5f0ad501f4af32ae62e8856307200517b8"}, + {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab47fe727c13c09d0e6f508e3a49e545008e23bf762a245b020391b621f5b726"}, + {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa01b3d5e3b7d97efab65bd3d88f164e289ec323a8c033c5c38e53ee25c007e"}, + {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:6c135708e987f46053e0a1246a206f53717f9fadfba27174a9769ad4befba5c3"}, + {file = "rpds_py-0.27.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc327f4497b7087d06204235199daf208fd01c82d80465dc5efa4ec9df1c5b4e"}, + {file = "rpds_py-0.27.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e57906e38583a2cba67046a09c2637e23297618dc1f3caddbc493f2be97c93f"}, + {file = "rpds_py-0.27.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f4f69d7a4300fbf91efb1fb4916421bd57804c01ab938ab50ac9c4aa2212f03"}, + {file = "rpds_py-0.27.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b4c4fbbcff474e1e5f38be1bf04511c03d492d42eec0babda5d03af3b5589374"}, + {file = "rpds_py-0.27.0-cp312-cp312-win32.whl", hash = "sha256:27bac29bbbf39601b2aab474daf99dbc8e7176ca3389237a23944b17f8913d97"}, + {file = "rpds_py-0.27.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a06aa1197ec0281eb1d7daf6073e199eb832fe591ffa329b88bae28f25f5fe5"}, + {file = "rpds_py-0.27.0-cp312-cp312-win_arm64.whl", hash = "sha256:e14aab02258cb776a108107bd15f5b5e4a1bbaa61ef33b36693dfab6f89d54f9"}, + {file = "rpds_py-0.27.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:443d239d02d9ae55b74015234f2cd8eb09e59fbba30bf60baeb3123ad4c6d5ff"}, + {file = "rpds_py-0.27.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8a7acf04fda1f30f1007f3cc96d29d8cf0a53e626e4e1655fdf4eabc082d367"}, + {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d0f92b78cfc3b74a42239fdd8c1266f4715b573204c234d2f9fc3fc7a24f185"}, + {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ce4ed8e0c7dbc5b19352b9c2c6131dd23b95fa8698b5cdd076307a33626b72dc"}, + {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fde355b02934cc6b07200cc3b27ab0c15870a757d1a72fd401aa92e2ea3c6bfe"}, + {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13bbc4846ae4c993f07c93feb21a24d8ec637573d567a924b1001e81c8ae80f9"}, + {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be0744661afbc4099fef7f4e604e7f1ea1be1dd7284f357924af12a705cc7d5c"}, + {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:069e0384a54f427bd65d7fda83b68a90606a3835901aaff42185fcd94f5a9295"}, + {file = "rpds_py-0.27.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4bc262ace5a1a7dc3e2eac2fa97b8257ae795389f688b5adf22c5db1e2431c43"}, + {file = "rpds_py-0.27.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fe6e18e5c8581f0361b35ae575043c7029d0a92cb3429e6e596c2cdde251432"}, + {file = "rpds_py-0.27.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d93ebdb82363d2e7bec64eecdc3632b59e84bd270d74fe5be1659f7787052f9b"}, + {file = "rpds_py-0.27.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0954e3a92e1d62e83a54ea7b3fdc9efa5d61acef8488a8a3d31fdafbfb00460d"}, + {file = "rpds_py-0.27.0-cp313-cp313-win32.whl", hash = "sha256:2cff9bdd6c7b906cc562a505c04a57d92e82d37200027e8d362518df427f96cd"}, + {file = "rpds_py-0.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc79d192fb76fc0c84f2c58672c17bbbc383fd26c3cdc29daae16ce3d927e8b2"}, + {file = "rpds_py-0.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b3a5c8089eed498a3af23ce87a80805ff98f6ef8f7bdb70bd1b7dae5105f6ac"}, + {file = "rpds_py-0.27.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:90fb790138c1a89a2e58c9282fe1089638401f2f3b8dddd758499041bc6e0774"}, + {file = "rpds_py-0.27.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010c4843a3b92b54373e3d2291a7447d6c3fc29f591772cc2ea0e9f5c1da434b"}, + {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9ce7a9e967afc0a2af7caa0d15a3e9c1054815f73d6a8cb9225b61921b419bd"}, + {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa0bf113d15e8abdfee92aa4db86761b709a09954083afcb5bf0f952d6065fdb"}, + {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb91d252b35004a84670dfeafadb042528b19842a0080d8b53e5ec1128e8f433"}, + {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db8a6313dbac934193fc17fe7610f70cd8181c542a91382531bef5ed785e5615"}, + {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce96ab0bdfcef1b8c371ada2100767ace6804ea35aacce0aef3aeb4f3f499ca8"}, + {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:7451ede3560086abe1aa27dcdcf55cd15c96b56f543fb12e5826eee6f721f858"}, + {file = "rpds_py-0.27.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:32196b5a99821476537b3f7732432d64d93a58d680a52c5e12a190ee0135d8b5"}, + {file = "rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a029be818059870664157194e46ce0e995082ac49926f1423c1f058534d2aaa9"}, + {file = "rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3841f66c1ffdc6cebce8aed64e36db71466f1dc23c0d9a5592e2a782a3042c79"}, + {file = "rpds_py-0.27.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:42894616da0fc0dcb2ec08a77896c3f56e9cb2f4b66acd76fc8992c3557ceb1c"}, + {file = "rpds_py-0.27.0-cp313-cp313t-win32.whl", hash = "sha256:b1fef1f13c842a39a03409e30ca0bf87b39a1e2a305a9924deadb75a43105d23"}, + {file = "rpds_py-0.27.0-cp313-cp313t-win_amd64.whl", hash = "sha256:183f5e221ba3e283cd36fdfbe311d95cd87699a083330b4f792543987167eff1"}, + {file = "rpds_py-0.27.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f3cd110e02c5bf17d8fb562f6c9df5c20e73029d587cf8602a2da6c5ef1e32cb"}, + {file = "rpds_py-0.27.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d0e09cf4863c74106b5265c2c310f36146e2b445ff7b3018a56799f28f39f6f"}, + {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f689ab822f9b5eb6dfc69893b4b9366db1d2420f7db1f6a2adf2a9ca15ad64"}, + {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e36c80c49853b3ffda7aa1831bf175c13356b210c73128c861f3aa93c3cc4015"}, + {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6de6a7f622860af0146cb9ee148682ff4d0cea0b8fd3ad51ce4d40efb2f061d0"}, + {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4045e2fc4b37ec4b48e8907a5819bdd3380708c139d7cc358f03a3653abedb89"}, + {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da162b718b12c4219eeeeb68a5b7552fbc7aadedf2efee440f88b9c0e54b45d"}, + {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:0665be515767dc727ffa5f74bd2ef60b0ff85dad6bb8f50d91eaa6b5fb226f51"}, + {file = "rpds_py-0.27.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203f581accef67300a942e49a37d74c12ceeef4514874c7cede21b012613ca2c"}, + {file = "rpds_py-0.27.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7873b65686a6471c0037139aa000d23fe94628e0daaa27b6e40607c90e3f5ec4"}, + {file = "rpds_py-0.27.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:249ab91ceaa6b41abc5f19513cb95b45c6f956f6b89f1fe3d99c81255a849f9e"}, + {file = "rpds_py-0.27.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2f184336bc1d6abfaaa1262ed42739c3789b1e3a65a29916a615307d22ffd2e"}, + {file = "rpds_py-0.27.0-cp314-cp314-win32.whl", hash = "sha256:d3c622c39f04d5751408f5b801ecb527e6e0a471b367f420a877f7a660d583f6"}, + {file = "rpds_py-0.27.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf824aceaeffff029ccfba0da637d432ca71ab21f13e7f6f5179cd88ebc77a8a"}, + {file = "rpds_py-0.27.0-cp314-cp314-win_arm64.whl", hash = "sha256:86aca1616922b40d8ac1b3073a1ead4255a2f13405e5700c01f7c8d29a03972d"}, + {file = "rpds_py-0.27.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:341d8acb6724c0c17bdf714319c393bb27f6d23d39bc74f94221b3e59fc31828"}, + {file = "rpds_py-0.27.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6b96b0b784fe5fd03beffff2b1533dc0d85e92bab8d1b2c24ef3a5dc8fac5669"}, + {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c431bfb91478d7cbe368d0a699978050d3b112d7f1d440a41e90faa325557fd"}, + {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e222a44ae9f507d0f2678ee3dd0c45ec1e930f6875d99b8459631c24058aec"}, + {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:184f0d7b342967f6cda94a07d0e1fae177d11d0b8f17d73e06e36ac02889f303"}, + {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a00c91104c173c9043bc46f7b30ee5e6d2f6b1149f11f545580f5d6fdff42c0b"}, + {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7a37dd208f0d658e0487522078b1ed68cd6bce20ef4b5a915d2809b9094b410"}, + {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92f3b3ec3e6008a1fe00b7c0946a170f161ac00645cde35e3c9a68c2475e8156"}, + {file = "rpds_py-0.27.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1b3db5fae5cbce2131b7420a3f83553d4d89514c03d67804ced36161fe8b6b2"}, + {file = "rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5355527adaa713ab693cbce7c1e0ec71682f599f61b128cf19d07e5c13c9b1f1"}, + {file = "rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fcc01c57ce6e70b728af02b2401c5bc853a9e14eb07deda30624374f0aebfe42"}, + {file = "rpds_py-0.27.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3001013dae10f806380ba739d40dee11db1ecb91684febb8406a87c2ded23dae"}, + {file = "rpds_py-0.27.0-cp314-cp314t-win32.whl", hash = "sha256:0f401c369186a5743694dd9fc08cba66cf70908757552e1f714bfc5219c655b5"}, + {file = "rpds_py-0.27.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1dca5507fa1337f75dcd5070218b20bc68cf8844271c923c1b79dfcbc20391"}, + {file = "rpds_py-0.27.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e0d7151a1bd5d0a203a5008fc4ae51a159a610cb82ab0a9b2c4d80241745582e"}, + {file = "rpds_py-0.27.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42ccc57ff99166a55a59d8c7d14f1a357b7749f9ed3584df74053fd098243451"}, + {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e377e4cf8795cdbdff75b8f0223d7b6c68ff4fef36799d88ccf3a995a91c0112"}, + {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79af163a4b40bbd8cfd7ca86ec8b54b81121d3b213b4435ea27d6568bcba3e9d"}, + {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2eff8ee57c5996b0d2a07c3601fb4ce5fbc37547344a26945dd9e5cbd1ed27a"}, + {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7cf9bc4508efb18d8dff6934b602324eb9f8c6644749627ce001d6f38a490889"}, + {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05284439ebe7d9f5f5a668d4d8a0a1d851d16f7d47c78e1fab968c8ad30cab04"}, + {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:1321bce595ad70e80f97f998db37356b2e22cf98094eba6fe91782e626da2f71"}, + {file = "rpds_py-0.27.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:737005088449ddd3b3df5a95476ee1c2c5c669f5c30eed909548a92939c0e12d"}, + {file = "rpds_py-0.27.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9b2a4e17bfd68536c3b801800941c95a1d4a06e3cada11c146093ba939d9638d"}, + {file = "rpds_py-0.27.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dc6b0d5a1ea0318ef2def2b6a55dccf1dcaf77d605672347271ed7b829860765"}, + {file = "rpds_py-0.27.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4c3f8a0d4802df34fcdbeb3dfe3a4d8c9a530baea8fafdf80816fcaac5379d83"}, + {file = "rpds_py-0.27.0-cp39-cp39-win32.whl", hash = "sha256:699c346abc73993962cac7bb4f02f58e438840fa5458a048d3a178a7a670ba86"}, + {file = "rpds_py-0.27.0-cp39-cp39-win_amd64.whl", hash = "sha256:be806e2961cd390a89d6c3ce8c2ae34271cfcd05660f716257838bb560f1c3b6"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:46f48482c1a4748ab2773f75fffbdd1951eb59794e32788834b945da857c47a8"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:419dd9c98bcc9fb0242be89e0c6e922df333b975d4268faa90d58499fd9c9ebe"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d42a0ef2bdf6bc81e1cc2d49d12460f63c6ae1423c4f4851b828e454ccf6f1"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e39169ac6aae06dd79c07c8a69d9da867cef6a6d7883a0186b46bb46ccfb0c3"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:935afcdea4751b0ac918047a2df3f720212892347767aea28f5b3bf7be4f27c0"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8de567dec6d451649a781633d36f5c7501711adee329d76c095be2178855b042"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:555ed147cbe8c8f76e72a4c6cd3b7b761cbf9987891b9448808148204aed74a5"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:d2cc2b34f9e1d31ce255174da82902ad75bd7c0d88a33df54a77a22f2ef421ee"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb0702c12983be3b2fab98ead349ac63a98216d28dda6f518f52da5498a27a1b"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ba783541be46f27c8faea5a6645e193943c17ea2f0ffe593639d906a327a9bcc"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:2406d034635d1497c596c40c85f86ecf2bf9611c1df73d14078af8444fe48031"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dea0808153f1fbbad772669d906cddd92100277533a03845de6893cadeffc8be"}, + {file = "rpds_py-0.27.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d2a81bdcfde4245468f7030a75a37d50400ac2455c3a4819d9d550c937f90ab5"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e6491658dd2569f05860bad645569145c8626ac231877b0fb2d5f9bcb7054089"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:bec77545d188f8bdd29d42bccb9191682a46fb2e655e3d1fb446d47c55ac3b8d"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a4aebf8ca02bbb90a9b3e7a463bbf3bee02ab1c446840ca07b1695a68ce424"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44524b96481a4c9b8e6c46d6afe43fa1fb485c261e359fbe32b63ff60e3884d8"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45d04a73c54b6a5fd2bab91a4b5bc8b426949586e61340e212a8484919183859"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:343cf24de9ed6c728abefc5d5c851d5de06497caa7ac37e5e65dd572921ed1b5"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aed8118ae20515974650d08eb724150dc2e20c2814bcc307089569995e88a14"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:af9d4fd79ee1cc8e7caf693ee02737daabfc0fcf2773ca0a4735b356c8ad6f7c"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f0396e894bd1e66c74ecbc08b4f6a03dc331140942c4b1d345dd131b68574a60"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:59714ab0a5af25d723d8e9816638faf7f4254234decb7d212715c1aa71eee7be"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:88051c3b7d5325409f433c5a40328fcb0685fc04e5db49ff936e910901d10114"}, + {file = "rpds_py-0.27.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:181bc29e59e5e5e6e9d63b143ff4d5191224d355e246b5a48c88ce6b35c4e466"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9ad08547995a57e74fea6abaf5940d399447935faebbd2612b3b0ca6f987946b"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:61490d57e82e23b45c66f96184237994bfafa914433b8cd1a9bb57fecfced59d"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7cf5e726b6fa977e428a61880fb108a62f28b6d0c7ef675b117eaff7076df49"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc662bc9375a6a394b62dfd331874c434819f10ee3902123200dbcf116963f89"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:299a245537e697f28a7511d01038c310ac74e8ea213c0019e1fc65f52c0dcb23"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be3964f7312ea05ed283b20f87cb533fdc555b2e428cc7be64612c0b2124f08c"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33ba649a6e55ae3808e4c39e01580dc9a9b0d5b02e77b66bb86ef117922b1264"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:81f81bbd7cdb4bdc418c09a73809abeda8f263a6bf8f9c7f93ed98b5597af39d"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11e8e28c0ba0373d052818b600474cfee2fafa6c9f36c8587d217b13ee28ca7d"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:e3acb9c16530362aeaef4e84d57db357002dc5cbfac9a23414c3e73c08301ab2"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:2e307cb5f66c59ede95c00e93cd84190a5b7f3533d7953690b2036780622ba81"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:f09c9d4c26fa79c1bad927efb05aca2391350b8e61c38cbc0d7d3c814e463124"}, + {file = "rpds_py-0.27.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:af22763a0a1eff106426a6e1f13c4582e0d0ad89c1493ab6c058236174cd6c6a"}, + {file = "rpds_py-0.27.0.tar.gz", hash = "sha256:8b23cf252f180cda89220b378d917180f29d313cd6a07b2431c0d3b776aae86f"}, ] [[package]] @@ -3345,7 +3442,7 @@ description = "C version of reader, parser and emitter for ruamel.yaml derived f optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"CPython\" and python_version < \"3.14\"" +markers = "platform_python_implementation == \"CPython\" and python_version == \"3.13\"" files = [ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, @@ -3425,14 +3522,14 @@ files = [ [[package]] name = "s3transfer" -version = "0.13.0" +version = "0.13.1" description = "An Amazon S3 Transfer Manager" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:0148ef34d6dd964d0d8cf4311b2b21c474693e57c2e069ec708ce043d2b527be"}, - {file = "s3transfer-0.13.0.tar.gz", hash = "sha256:f5e6db74eb7776a37208001113ea7aa97695368242b364d73e91c981ac522177"}, + {file = "s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724"}, + {file = "s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf"}, ] [package.dependencies] @@ -3443,14 +3540,14 @@ crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "schemathesis" -version = "4.0.21" +version = "4.0.25" description = "Property-based testing framework for Open API and GraphQL based apps" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "schemathesis-4.0.21-py3-none-any.whl", hash = "sha256:329ad303f0d119e3d8f187e48479027c9de27c6d04cb7112fc15867e5486c08d"}, - {file = "schemathesis-4.0.21.tar.gz", hash = "sha256:2004f6b0bba6508ec48377d00d22aca2eeaa0d1fc9613b6f0c8dd1f52c69c1a8"}, + {file = "schemathesis-4.0.25-py3-none-any.whl", hash = "sha256:c0ef7eeb7ac188e5de133ffad139cea81b7297ff71df64a404fcc4632ceef6e2"}, + {file = "schemathesis-4.0.25.tar.gz", hash = "sha256:0cde28c167e562385c59c878667bdc1fb6348ce8125279f12dc85f1da081a0f9"}, ] [package.dependencies] @@ -3707,26 +3804,26 @@ files = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20250708" +version = "2.9.0.20250809" description = "Typing stubs for python-dateutil" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "types_python_dateutil-2.9.0.20250708-py3-none-any.whl", hash = "sha256:4d6d0cc1cc4d24a2dc3816024e502564094497b713f7befda4d5bc7a8e3fd21f"}, - {file = "types_python_dateutil-2.9.0.20250708.tar.gz", hash = "sha256:ccdbd75dab2d6c9696c350579f34cffe2c281e4c5f27a585b2a2438dd1d5c8ab"}, + {file = "types_python_dateutil-2.9.0.20250809-py3-none-any.whl", hash = "sha256:768890cac4f2d7fd9e0feb6f3217fce2abbfdfc0cadd38d11fba325a815e4b9f"}, + {file = "types_python_dateutil-2.9.0.20250809.tar.gz", hash = "sha256:69cbf8d15ef7a75c3801d65d63466e46ac25a0baa678d89d0a137fc31a608cc1"}, ] [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.14.1" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, - {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] [[package]] @@ -3863,14 +3960,14 @@ files = [ [[package]] name = "wireup" -version = "2.0.0" +version = "2.0.1" description = "Python Dependency Injection Library" optional = false python-versions = "<4.0,>=3.8" groups = ["main"] files = [ - {file = "wireup-2.0.0-py3-none-any.whl", hash = "sha256:cef5408d3ae0eb612de46ee860e52f4d3afca7df3f28c3c2c75d229c5d52c9a0"}, - {file = "wireup-2.0.0.tar.gz", hash = "sha256:57b49512356ca9fc9a2fa9a34a1057a8caeff7a28ea10f8fdcd1f9811cef7f39"}, + {file = "wireup-2.0.1-py3-none-any.whl", hash = "sha256:436c892d796ccebdc2235be8be7f8c81565ad357819692cb68a00fb1db501652"}, + {file = "wireup-2.0.1.tar.gz", hash = "sha256:c4e0e8860e5aa6389f67ab1a4d1fd9cbaf137bdeff1bbf48f88715501078b2e6"}, ] [package.dependencies] diff --git a/tests/e2e/.hypothesis/unicode_data/15.1.0/charmap.json.gz b/tests/e2e/.hypothesis/unicode_data/15.1.0/charmap.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..d9cff3e81732ee78adb53b4e70ca6bd601d90db4 GIT binary patch literal 21735 zcmbT7Wl&vBw5D+hZh_!#!QI`1y9N*LPOuQ%Ik>wgxVr@n?s{sd_}jf4aZ1qB83=I-F^$^FgL+1AAyO37%nda&K)B?YktC_!A+ zddGtd<83wYnGPY?8)<9rZ9o9FhPO~*L#cu$>eu#sh3NA(Z_mq|Q^3nUM&Luhi)Ewe zX4m5GlJTlQ;Ip}W&*ORCrAxF+{7^6N;__|ED7Z%W%PNlgucyZiiLsjE4(5uMxxklw z$C)o+mq490fTzLZDQgom1H?}kDfxNSpI(#K;Y`pqHl z?0jrNjuhHwOEJ+0WE?FN`}RBb>=s{m_bW;OGpR0ms{Yv03UO$(pslmv!(AaNDT_r>Y0g z(7g58dc0I!dR0jhewnh{^>I+(lRQDCt4#Y|b#c?}lQ+GgtZ<79^1S44(VohfXiR5C4Sv1v=7d zxiz&T#hv_8`AplY)j%F)7Lc59GZFW--M0e2!zr$04P8zHJAn6gW$;sJwdHn}om~H8 z^AoC}-bt2s4=?a9sj>W}-fDt(TH)96RrSiW+U8`ouM$hxI6YZUzrIDTs;}}5EPmqY zZo8d`<+N^;+41rN`|gZgnX|5(czJz6p5l2V-Dvie-)I;V{<6{VE2FwO*>fYUBPBV( z9pSJ0rzfV%iTOutD?S0(q31{XoOtXhXMLlU4TaT&n@^i@oIYWkIM`DG7lU!t*Jp${ zvQyx#ToR+hzKBi(dDz*65GPwd(oU^h6lDbJ^^MDgjjlE6ip}f}5&(?4>dC{mB`a>6 zjmYOBu8S2-R-&HWlvvjH{;WI@?NE-D7n%Uaehwx^yJw;xV$IpcM)JC}=8K6Y8WOn^ zw<4A9mX?i#2QvM64_ixOW-EEW%%WZW_Ue+|9^C&SSzx)KJ==xj=A7E@5FTQe(eWzjK;s}M zULj-QKm$)55Vo-6xbkI7Ut%fvyJyQ8Cw5+mh~UH7QG*-#C7EUH)av^Vdw2h`(lk(L zJ(<^{s}}HBNEZcrYE-U#Bh3X@qTWxkPMGM(w=UHe->>sfZ0m#i0|Hyzb5ITq1P1#5 z2DHdm$ZQBax17#D*K^HPYux{8p8neN6xfu)^<^hwy*2f>UE7!hYoybIBWyblFnj|B zxJR(wwOCihc;Qtf^SGg&Me0!lPM!V5`+g?B<@meiY~Qh9#;BOn2lB{dCPD; z6>gotsY~^CitEgxYbD!`Hk{v=6u-ATACM5~%p$FL$Ag+RD(gD9X+gce8p=TG=yFdO zde_gXpq|U(mfRbXbe&4(V(n`^uGgNU5(nUUspjh3#*R^>ny&@w-6wPL9o;B3w<)df z&dktgBDBNm+4``c)WZP*|CYw^Zo*No&lM@~#fJfO2y*k!PSH+RV~$w~*0TP$K#kLL ze|}&2XW5mLiuqLaW8tO}&Fhto*|5GGx!+kUFzQEMx5Tzzj>UFfI-~HIgjR#^+3j^* zo;L`ps8^|VHUwxcR}SR-hRQNGpEX)5Em~z{Lxy$nQ6L<6n#G;2$FBbehczw z(MK(=WUDTgmW-#D%Hx8}WWS3Sj~oBjH_#i9>Y{CP+cO39)MMmya`rp2iPJ08Pz%cQ6_-JGN{edX)_=Iebeq+^ry-JWU!0-aP5R7VmZ6_zY``p8L12 zGIp~nwQF-TJ5IQ&qK}8yFG(=fF6e$u_UMj;x1H=_kd&{I`MkH%+P!=Fqpd0J;Gv_T z*4nmnfiTLl;nB_ed|HlFSC0D?q|7jAeMUD|p9Q#lt11wJ1*YNegD%``j+>H__pP!J zmjp{PnD{g*XTaNH;^r9vZPnK`u9sRxr$#t0cHOOl+_^JyBFR%pk1s`FaM~z8@7ryV7}nbMsY=XJ8Lv?n${VYPDDOH}OF^lN7ee(t>TUev$5@Nbzy;j{a7!Rr ztH>h}2tB#gwtTK{G-&85_!q?IuEU$|UrnC@ z5xsn^wPz6G5=(PBNS0!Dyr)T2RTpy)=25XLT~*Ag@kFrn@7aFTp=!H^YygebJF@nr z`DxOUc6kV$bZqtx+j~~nH|xtLc`x2-;>uqUL1|5ccEo-Uug~*ugV(LGuT$#}FQ|cJ z_jF6c5HBxh;qj#&0g)DF0GnG)bKyM=f8JgL2llo9m|$zt{hCW?$IsgxYvB$-9G3_d z$Z=&AnT0RF)=K^E-lfY5F7Gg-^=)_;@>fak{&Z5~;br!vZ4v${O~e`4fj(y?sEn;E z>QwY<60DOHS48hvPa-Qnc7g$#UJG%k7<^MQp6Y|B##+RHi!Qy55A}hL0}veJsz=1w zz1cFSf%{{SeL(lEPt9qM(ul95AyVv~Z-6zE)0n~HX87w+*{jS(kL?Yl@0E)z&1hsQ zUL=ofZ}igLi?I4$Su*vft3qyTE`Qd(P@7n&X#N!H2gc69T}`JjgxlVNdK68F!CNgU3dGyw)7e0IpLe21xlB))c}- z7>w;Tb90;PYwS4icn4A(yB^J|ufCr)C1+JXCmsWV^WO&EN48A1)N$lT)(#9uf8ir` zi56Vn4z|v-j7eJ?k(@d4M_rTMugV{ncx#zsp9u$f9eSVD{{o|fywYBLyx4L(4#GQr zzG=i-2pJC>?{r%|1Uxj48GmIw1p8^8k3!|b6wSQ@oD4gmFGx4 z%2mIg_C0Z*hwDsW|K0k=%CjE?lJ(-`A9srpyRW2|OAHJB~}vUmxi(YED6#}7mv-u+w?j{HXlFrPE7mU&bLIS*OJ2&<p?nEb_-#yQ*jofo#b#{-l*MKXPV9^uSu29d>mwor0s<>P3XtM?K zer2yp?vb7g0t5get~XUw#?xKed|ur`+f<040`M}~O-`S2&I~Ydl)mu+>`XEP4dMVt=`vWP?5xn}z9jIsA{@oQScmK1HGlNzY-EwvFU~UU|IVaco za0*gJ7avD#yuBg>Sve(uUT^AXvTRi49OVfP=8d=e07OcKJBC)Yc3;pY*8?SbKMoTtyj9*?b%6{1zKL3+Ig#& zy#PV)|M2XgI$HVKuxikn+rdT8@L z$>mC9wZthEv4=r3^<8^hXF&VYG1yS@kg(>k&cmXIs)FhjKf7qUo9e`;JK)=qTqo6Y znO&DLv(dJ%PV4-X?+cUBlg(lx07$0pWB-OCg5iQwUz>9J5??X1lB{}sfr9;Sq~kgS(N138FYVtAfGctB zOx+BZ-;f2`$2!%E%Qf9^lC~R+H_k6cd}SXLIoI86KPpk=fHt>(3$`u40ogBO2&@N4qk+o&60TjK{i62qa_ znEkTr{+@nVV^i3|z-SB0R?`Ui2<>IpgDTa& zm>%2Y=f5Q)+tsA9m*c^|AItRPQUew?GK+%paxm1sJpWo`CASfb(=~T`Oo`qpc4)PO z{$e>8x7!{A=W=N3@`la?`ar+`-P&HqI9+t6@8Q_J>L>U|;aea2#{{;8scE{mD||oK z)`#oK1t_f7M%VzxdD|a1)=rd!)1lK;7W29)z5Jmwa3&h`ieen0eZO8XVQ`=n%}7sM z`mdnJ=%|euyo_|Ef|MmAfdivB^73?!<(Xh2P5}jtxF-S?*bl+_Fyb%v7W4f1$Oyj~V_IbU7HSDcq~x71Z6AO8O0omkxp>~+CI*kH|QV) zPzC&uhLE4*?0Fj%!;nm}<r22D47zKb8}-gzP_hx+q? zwXNevI^*+a42wZ$olwngsJu#LC=XgTt;RmJkgI~mG&uXqQA<>-28KkVd|fGW@egMN zF)US#;3&qI#PI^s4nDd?IosXF=2c?oeQZ^ z40sxehX+0I749pvST~gvF|G^QukReVkvupxI+>;rl;_-6w6V1(?vPdv4)7 zog|;X6{7@*LbZn+R*EF~OSC2VX9xMXWJB$fQ=e3nXgwh06QK_?t`p2MC>yP9H$iI# zr~?_mlr`s2BQ&B0vGS&_`47a^tU>Z0Xnbt_7+HWp>Gl$esl0I_{Qtyfa4#lTc*C8> zx?fF<0u40?4JULa3cjKYn>8G)2>5XskrH}CWj3F0JErlcji&LqG$PR9C&wow%qE5OZp`2?`*|8@HcB_%xvy{FyGXNsX4-R z$y0_ne7cnB&AN05Y5CM8bCFqR8&WAx={(WO-M<=ROLe5wHBI)-iNHVq4n@Mv(MH8= z+KxW*t2AWuNUK35c2f`nI{MiFMDJQ2Rsa_NM)v){H7Vdv+pKaV{B*Wa82>ky`RF>& zw}9-=SKOGs!`*_szOM)m8Xd0f?azjHHhtA~H9vUDe;eu3Hh-~RSpkvUFn-z&Z&-7Z zt=YI{nQ1A06U!R>I9kj)l1uIzeI zKSr=@_gK?xT(GAa$GLpQyun+}E%RrM%ndb<&c17GvQ z*sRghV59N+%r0f${!3=_U4y;NABDnwn=i?WI}M~Zf~(5K8}vkVecu}Y@R6g*H`1k1 z?-AQJO1wDE_7LG-dR!LgA5;f1)8&#^~5ku5vm@`ov?^`vB3jk?Gcs}SRHf&^nEo$uvjZp^{d_|ODKNFB^4+5*(1 z_nxJ7wx=qYX~f`(!F=KZ8_ksUNIO8fm4GY(DKe4DxIH}y>+ zI})p^1B0}A`uk!b-(nw0ufRczNrm`A8`9vOO9x2NdtwLOg;CvJu!q*$!PJ(nDoC&r zb_79%`>C}^|6^$^zPFrNhS%f^N&Ya1AjvP#LXv>%F|@1{YqSR-g7#X2mG3e+D;;9# zL#U`jp?4+lNg(K`sLLYgh>}163`n+ZLyugkz!97Ca(hAtay+pl&=9hQ`>Cqu5&W!R zPygR!NS0j4$Uy!`+MJCYLBkwVuYzKI#bG9T$ixh_bcmb+S_TfP7H`ZL%_88R6EH)d z7$Z*6;TZju3jxPmHKY2UVgHO|w;qI6j7(t0VxyHdGZU#($=|=UJJApT6<3_-; zLt7#J&nL?xyva#P$GYNwvU_tOU~PZ+(*jf1y{hm*j^bEMot5Bcl+VV;E^AcMn7$(R zh-DLc4sV0^h4p4bh#w+1l^Nz2C-^CAj>+DnDs|p`Bz;X+2x&?Q`N|atGe#`o3TO6Q zaJD5zEr2@^p^+G#@LM|2ZcIdBS&X5_4@NoZY7ko}6SxTG;Q&ae7vir(U2K#I-AAUZ z5X?%w8jbL#U;U8^7UpU8(TE#=~eRl-gQi=o`Sl+uQ4nH6b&<;RA^O~lX=epA{_wQ^7BIR z0qbQBLax{$A7C1V?U&>rOiT17^jI}3vc9G8Ar3V4BF3#ZN%L{-(%%rfvzoS^q1ViR z4bg8ZN(Sdi-yn?}A~vlm2B4%;NaMMPO{a zN>l=F_3Fj2GcT@?J8Yy%&2ueG)L^rB^;2^AzVxHND1G?8q~4|r-1>~}x4`Y!;FnMc z${jw4b=kPJ$0G3fH9hChz7d^_nsEG#b;G#6$2~CorR%JF;57~+cs8|ia$4K7`-%?H zxFb5&Dt*+lwNfQb#kag|$#7_eB>i;N6-nNBGkKfTmb#;Rcj469)At$%A^OJ{eJ%Yb z`q%!y8V2u96Z#-z-EaTO-@DVvdhq`&|3@YI+2kO26q3^o{8vS{k|4d@y zh5K%4-PrtJ|lN_O;-UYLyLkgA}AZl?R6kjEMaC5*ue)#n9=DQWXTPJD!(tFHO z=qtwX>7}i;^1sww*Zr&&jprr6 zvu5JV@)dr?yaQjl@_mgP9^dxn|JsATRI__~>ii<`wI>KcmI|ClFOSPl8@cAtT+|v- z5chX#WhCNMfXI2(=GT&rL ztwc$`u||zjVWwBgtXPkpbr_DJn>L3u++sirA8z|Z701eLH6pn5+Q1OI298)1gsu%> zVC*K4X+Qe)W4sY#p+8ECqYIA%?{|puD2a`~Tbr6G^-Pi8#>*AF3dbxiMf`puMr{8+ zXzjmoR8zyc6Ob346RE!-5_3teUZ>s|L-ra%=E?N)SVIiABTxG8@_lV`UTreamv2v6 zJ=jT@>vk3obPwb$q6P9OUx6;q)?C7!=mKe2D9)5fF1f=&GD4co^jO^y;lbaewxh$f zHlsjimza~1NQbwSA~C`1iz5co@!EfM;LM*snk)joB#2FmU!H--rIf ziBq?DpmQP98yFc8h5C}2K!aAJ><}sVvyiB>9{rcGupB$OCmTB8{2aDymF}*Sm7=61 zM5>xY3eZj|Pf}`}w(oU*fP68I8(ae0KRZz;76o6wx0RAyW=6%)($T=5C}g8T@xUOd zmTqFUVn!+t6-(5=gD`)f8s|$n3y5l@E~Km|AJHQ2jpuNfDx0PIODX}G<@=M`sB^u& z9-n_0x5;2~SiYVPAo;FgGCAVh_2G+k8V37!uIxa;3y!Yu!as^&V{gq*sus(5mckwU zsjQ6$4W>rB6Tbnso(*FUzgN=@&*Xx4{odZ+v@0A;WMMxWH{+H3rg-`hf532k>mgqY zofJ(_G8&h;_MK@Bpofq>&~TYa5?IJi`(oycuE`uOqBs2E?!f`+${)qoYzXULR{>0xa5k*DObR5S86_|N>wPkNt ze?S=1);t=$95Kx;f*+=$VOSPgOf`QBci@41snGcu$VMPE!Dnz$#eFHw!zRa%)7Zzm zxhLun4mfC*)YfGG+5g#>F3=?>_)kOR6t2klCLe}}H$#U|BFSD3hP#w;6)pQweW^O7o@#8h zD7wTwXi8H>ptrFS$#Qsb4lneKAkS#G0g+Eiay|P;*{7k+U@YxBwuz+THCI%lO+!^3x|6Q2xNNfN`WM_`qp4PZ#P7#1Od zd=u4+EC?F4EegYB3ShrY^u>m;%#LGA?I2mJY$!QGvW}tkE(%n5 zsML#XBl_6G1N@nrSwIE3XWv#-VIb9p+FFn^#W3 zE~-(v#gTE5ioY8GW&Use76dmJA1RdrZoIc%WN?fx$bvw^8+D5{jC~r`zEaYPAPFFn zW%9O1LVY>U#)6;e<`8NK3Sv=V_k-zN57kAeDG?fkMc?Xpy#kr#5<~@~@`;8|=fGY} zQurMPQMuE9;Mk(QVH8dX<=o>muU<_+U=PwRr?!^2I9UfkDSm7x`cx$Oxxbf)_htx9 z>Iq!Q>Ntt!ph|yCezwwT<@$qg+t3=vs$~-yf6Hpa4l@vSMHe>g4U-cLh3aZetuojT zYF8jZL~Yb~Trj*St98JMKE^e3fGOe$Rm&19+4#*xGact~YU(by91YLr#=DEJS^wK^ zMP6PpN42N9u`KpY z?1y5ddj-SHXOrs3i1Pl$tYl)Esz0lPzmDfsQFsrPIL|!(z@oKvfa`>o3u)pM(IYAO z<~h09@w2ePO?Z{MTjzB^24-+^ttauM_Es5;Y}JqJ78lXgZBEKQ9pcas`~!EKSkKvZ zM7B;I$})g<`p0pAV`~j*3rPAQQ_@gVqDLY=ZIs;_z7F~sV_VT5yxLk6SHLHhvu}2aDGfw@TK;qOb+1X zUkz6l6@sSHupMKEGzpK!Qw zp(QfPz%jv~CN$j0VF{lkSy)bb`!aNfxg8Y^!2p5}B0>tdCQgOh(shbn6V`<$W`uo2 zoj5H*(Jd0zn^uSui%mVz$I@@il-)QuzL?X)HeSRhv5oNV7K!d3eAJ^BO-td?j^cF9 z-~?o_9sIJ;w6m`vm!p?8&j1GY49{}?eGq~&_%)>cAd9Xb8o%|HmSzvwweU@0tsj%D zdTGF$no1J|K6imx$z868dn753v4(p@8=ual57aql55K$y)(HRH{k}Rb1Rcy!sj%-( zj@(nusKJq%LDL92xOFVXi)z5mPS_w$3W6@GLq#787^IL#uNB-9aZ?BURByN^R>~{m zXW+l|a?|g3;7&Q!7QFC9Nl~>(;ma%3_p$;M&1R>Q3zvuZxM`Xl>+h^HT#YJCj!qFL z=VT}88wD{k)QnyiqWUV$1GD$CV}|AV8M52}J-ov!68!PDsg|x1wmC-8OGTa8s}zVO ze7yMhtxa+JR4YKy@NB>UanggK8gR^b_*UL+S-;aOEzN4co%LkcVN%wa5Z#RhrqnO% zg^z3$`nbH0eE*gUmWLm;rAvV*!(z=3K5GvS!tMZ68M!RKY)Z62{-GG4P1%uXStMP&?zm6@ZiQQYfH_i^S;bLU`il`dR`^ z%igo+8n#z&`r=l9@H%2jAGfr3J;{Yg&H->rO1%t>XBwjVgO zh*2o&W=)!)us)O>8k3o`!Ti(w*4_;crqF1j*^mD8DcJq|U{J`fTXr*c$BAw@e`o+2 zk%(}d+FK`==r*O0^z49qJ^4?fta9CFecJF`p$}f%yY~~&cj76sRG%i_szAJM9{~f? zt4?%>ruX*+TRP_aqc-VzmH{l;XW_R6d?*vT804qCe={N;-*~zM^3S4vPG<)z)r&gq zkaLx7Q6m=cznWa>m{0agPl}t$i}FEFr<*4L?{5dBbz{4GMzIK9-xM()Bx+tQTKR^$5Z!1AVe1){YqIlZb|Nb#B%H z!Py=GS2#I9cGW#OH9Ja4Ej<6l*~-`r6FXvU^6UULxs!>GABGur)t@MbEIVU6%24{F zoU(X9rXYZ1MsDcV%WcskZs|%miwYBX|8Pc}_oBt$`y47m986$rM7-{WxLZItx_5JW zaC7SY)rkMb3lZ~P#wyVt_T>qFxs?~h9##ySM3g9v3(LC%D%IVt>lYqd za$V3-97}A=Wnz&^wv#l%$UON*dy^ z{Cwo$SLr`lSh#i`2E215T%_BCF&+Frk?{r6E357KC#m{SZDo8c5TKGi2%q)2Y9
  • 16ZxE)n=|s#DG5x--e*_^+bv-=mWUM@6qO zVH`I!s)zjIX<7)?Mx{_%N(DlcG%(vtfJMtY*?_dd0ZvO&SR-kld*Q2&0;&Po(r$Cr zGcVoi0<-}WL@tr7QnK32A1&Kr>NqP3rWIuAPn?fNo#b5ucRc4rHjZJv$N4vzl#iKH z>-eKf0o2BgBf}t8L&U7kwckjLHO%eRuSlJxah>zyDx+zxM($S&KYQ&&6>4yQEcq4d zYZU8W#-&Tx39J8}PL##EjifyJj;oeOUr5?E`=nTTE7?GERdF|x+dh)}L=}5-Qq~g? zHp#u&7UoMDUu_pbYA59Ti^8npa+2Gl`q4M{#7YFaih<+TieVxa->{5wU9Vv4r($-znJy4R`__A8n!Z!ZZre^+j4>CF9HFsH#z8N^O-upLL(@rRp+24vkG9BaU`s6g@HBE5wDB9v9D*E%wi7GH@cCs1Q zBF17E|3-}LYpUX~D}{1l&1j0@Tq=fgVomIcFwzMaMp~DQ(H4dk1}8-*F%m@T76->i zNHKOs=oU$YMG#c#QtX6&z^Bw7@s~o!W0Wd({%jvnPBGvg0T=KAn^LBH=SMk7`BhLk z`iTELuBJ1li%{q#<{u4lQ7PoKC`iF<-q?MCFWEg-m&S2-q#Wrz*037^hMExkCFX#} zG&;K=Xvql=P6|~u6ojd#5%EDy7_Z8Fqq7>|TV7sD zf}zF_qM{xC9_OWruG<>UkASJ6$__0hz7O|fbg%eYT@+70-TC+IbRLPLeHmkKC^I7W z`kX86fCtks%X+n|LO%^dIMsTrJ5QluoLpFCptu9{dg_1=wEn>Bw(ZCDk6X0wHH~vh(Tm;N2icQYfH6TYq7o_(7jP;1#jNAQo;-a*tKD%iYkwshy9mCODaR zYrt=TMVi1#1jPbRzAYm{iSvZHuq#68mjv1O-4{e*%gncq#(NThhqlWbk1K178##%Y0TtcB~mBd2lfa|^HFhGZ~_*u0~y{ zD(R&~Oy24Y*ix3-RJkA>X2vurG)KU1kmxODO+!b$G1K5{h3?qg3$ZO8+Vav!J~1*qp!4)isq)^urNL5 zBEfK8Xd@W8Qr;YB{lb@$l##8RZLwKAhnrDyR4jG?CjcM+$y>SHTqI}inA{_6$6Rws z+-Pw-DMaKR<%pyE^n$sl4SkYB`}E2u=C6WT_M)kwjLQOw`H*t4CFyVGIclFaUkWi` zpXE@hgY<{6H>AJv#w?L+0U7}~2TlNIm?tzF{F#=%n~+%|Y!$Iiij$CEUnmVX23L>% zq*9zqz^-J;wbB&e8?*&Edm6wsF4|W`60SL0N-SDJCki};b7iqR8~kaEB*C;2T6bS; z45eY$cKl56)<-?6sO+4c6Ea%*g%%a4or6?QB1Xr!N`tO9Q!AK0x!g{DV^(NEOC|)c zz|zh7mASXjoMywkCQ<0W2Z)TB6y#H>H`RbkB44)XrAADZsx(2omH+VTtM%K;VbIK~ zF|y2!8MayCz2h7Nu)dVsS$xD@$716_llb77$&{wq29O)gqOGP${7s#({0?k{oo1(q zaBpbQy2PcOW@m_!Z)ll~d~RGLc!Ml5#W>J}3Y7Zh!j^3`jvD*iHl;qU_I_NI!e8yh zU&Sr9i74Pv3)@VsKwq=mWeu_IQ2(zOUKZas2}C5pXY|*b1_bk z!+U;g2-kZ`tbw`&b_SMEyulwUp65;j$IaL=A(%EeHWrEt9jIq5gBfK1e4HohKB5Yf z5B_Zw5$GfJcApt2D)9F*khskAN)0P-pirPNgC0Xo0-)24kqmKq7w6wGgW4s&7?j<6 z_HMcJ2mfELKM?XewxOi*zbT^L27i!-IfdgigS$ct@{|0-^|R?`HVaNfA-(mdUZ@Z# zH|SnN`YpV_zg%q5-0aSt5z?#QSwyr9QhD(|{zi~v1zag2%%Lc-VC2?6c8zqATsV$D z65TYdEs&OXQ`K0Jvse7nAbY)_o#O;vtDN#I|MjS*xg!(Lin;(l)+h*G^2hBv8;Ui; zg~Is32eY}Tsy8ckeE1(I=SSV=T3IMpf~y$>^6;hZFPxvwDHbqqBA4^ zr4^o7-)sH>M*o1Df9VZO&T?7JKcx@eoSP)UKM)z~7qs0ajx_$0%QUgP58vMwwO(1n z7L|4=e-87L)0f!3K?22C@kI#snGlp<&F`a->3_iuV>xDoe7Hm_bdWES-)Lr76J8GH zJG!xuDIS-tYfqnzhF$qnu($KiEX0RYEgazf#wK)OjhLX6)WZLbPk6x!3JAjnz&tt> zz_o}qyU@>YBz*g%z>V$={AUgSlg%JC48arW)zmcuRM8F1F~gKcW|7kcD5m^-oyZDJ z(8xU{q!nPe1^-)%2;0zqCy#(Jl)nS>qg>2En)@GeL;5$5kq?)i7ndbJOjnbB5j$p` zftwsEiHF<2xAOx$KTdiPt_8Te7NuB05FXvK5FngGsTgw*4&SE`*1w%=b+_HzUM=Z$DmsPziNSuJx;WcUs5* zjkE>8xuDpO9+^&IEE?ckRQybjEUUoyLaVk;4Uek8C$3(b4+kX*->X6ZdYm0nmiFOU7cKy_xgAiMW#X6&wQuORkC^67Hm4C@; z|ALw+I&e3Y56v~%z+>sSx=GDG&Sl|+T`!1+{O!P514in6`s{g~EE+{o(vOa!d(LuK zTsiJNIBy4v_oywU=u0YnJewj-6+e5m&Q)1EOhR~S3>C2{R9QPpl6h)O5wR&%SvyV& zdul8Zv8hy9J4qUP@>^{GduvrQZt_;QY+2tS`ssYiHf_bR!++K}uX&wL>E33I#Nx!7 zso~$?zX2$I*Ui^YId014G>>A8=l+^uq+;v!2*z12pXV4Z{SJoBe!lbtDk(0=My0p- zvx%>eO5EY`rO+{Y`W>R809>gs%rCQrQh%6Vehc43YQon^LGj>$Mynw1=*%x4xwV;W z$E6IScYXY)C@J>zF}Kh~$q2`jt{OWTdVn|p=FBH_;Ff#T*YjFYoIob*K&JoFphn`@ z$e?B8c2S9ueZ5~KcWEHjmG=@YIx8Lez*NNhB5|^hn3-L2up^P@zfVWBYzeBLC&!a7 z!z*Iws`$(mmPw@6_!0Ue`SJ#)=MbExZ#hBG@|*LUVx#8{#}C-K)*q{;fdemC98M9Q zz>sAWW;Mb81I=;rr$wiP1Kx{zzb8w+3tAiKFXVU|Le}}7sQF(KX6fs>MEjdd)M!xsW>tv`uIHe z|05a8d9(rY*geIO#sC(~75Jpba_j7esaK(Qo>R0Q&lfQ$N=rMAS(iX%OHKP_ifr_8 z0NE_(lbNt^AoXGVUsSC@a^(Jh&XR}_uAQyG=6H} z(nm-HWBBm@uJRRq7?g)&l*}e4O>y8^<+I|oqH4fiB_4Xr) zt1&mLlr*bsAZ5pd+&}Oc#>XvDm0D=-CX-#zD4? zp7Pgi=(BLG^nQc!whA7XFvOouYTw-G0EG6QMM&wi!(XF$BA+v!j7b)4j9U_aY%k(l z*yGd#PM~~)?39g^%LQMNsHshXpOaBuG+A!2pgdAgxKGZ^Q7gwnyN%JXkXOKO?ERV{ z65xn(H`3TYWgJMr&ukZ?td0*{^r6dBYyzi5jDiBjmk5mW8~IkMA1>dc{}3j%N1?ri z7CZ+Z8iXFDp0|6zdH3{2#62se6p@9~i@Hs(7W+rapx0X!F(?nrTQ96zulesMj1TkB z?MD*&?#?})YF(atd+sSsU^a~QS|k$jhw6G!68f%c@9(neEy%_KM*{7&J{DzM{QC9k%89~rp7eJx(>eOd*eZ@rXy+2PW&gS74#oJ)W;mm2Z+Talk;Gn zx;z1cDui*TKGAcq7|bqY0YOjimP`df zC0Np@nD_o7?Ej5sLLGjQ09*UB&M*X3jHCb>A+=*0g1pF?RGG;4?L=7-H#>uok8@iq zBWii`V;*yn?S-B|7s*0*<0j8H_sp<(Chx_Zk-nppC{!)3;{Oq zYxHrQT7-|ya_ia>o8f*!YI63@4~}|cM5tW`)Vx_4{Do^9`I;+pHYRfMo67b5U@57_ z#Pn{hdXu7?UhSs=nd2YKtZxV|i>a~!l4#4bFg(jQo0)L)-yLWu!fCTz-O|;H;Lr}f z(+g9lx4r;a$w^A|*{=H|F=2f$6utzB>Losj6^mqQP;YUj@-hotzACKCU}YWA5=&N( zuvL8I&{8~H4~OX)QluE}%(UUB56AIH99fB}vQ>0HS578TG$eU_HK9Cr{uOsrRUlO+ z4~n)`GObBHsYjY6;2d2rB9b%7FzG!2=_G6|QBOEpvIK9|h%70J(9tK_&3*|(FPT|R}YML>E-#DRr3(p2W-+8T*VH;G+ z)9JHgRq4ra!<^!K(WOW1yL?~~=Ti7Z>5y}Q-83-xnMT5IYXr#-HU%pbjN+slsYd*u zko>qMU3oC7n7pbS@_|+$V;*Xx)ixXH&)~0PDOU0yRWomOBkPP z315sC(;o~icFa>Er`WUOo2W(bldRKQE9?FamZ~hxsW#xnV&@U!6x;|QjUwSkebA1Q zC>o@o2wE?QzaYpRN=7#(D=nL%ylku@MX#ckYu`-$zc zz*l#|=Atmw0qYz(F{X1d=QtUTIS`H67jvvuQS2-ed0%)`X z42e;Z_8T;o0+PR#8yH$KjTE#I5Fp<=P+8$h$rZ#{@zB%+;qGT}Exl3O z5P#wRjO;J~UB?Z;u^?pt`}F(dVpw+A2;yU9`#ux-N*xK1%#bH_anOzII%WFQ|IM<< zCBD8po2|2x@VOezQqKJ|oAbR+ExK1TW)Cy5=a+bogMI_i2RLqjw_5&K&EE7BQ&%d@Uu!u^g`=rB9(LcTCC6zZaeGt8{ zUxg^xY9%!%6>Z?2`%^)iopzs@#D4SV)GN`;z|M!q9V?O?K4qj>V3%m0CL|g6U8+>p zsbW|=9^u;Nf1FhEQucI6vP&{pn_tb=7fPD$vIJ|(>JdY#k8C-lxk-5M)?(squ*RaH zRvVk^!xOYAz8t_>zoDd<%P{`5e}w)LfJ7${1W!`Gjo3I+p!sBX)?2Bb3!RWD+1LkL zf*-EHik^QOHthjR^A@J?ie_>U#^wfl_oiCc4b8ObtY^Cz5b!5Y?ay)soFbX(pbL4K z?}#WHW-bvU3WqAUxnkP4uo7Cu2dJ&@Nc+N2*QEVENmiEhDz?3UoB%0xYL7nSVIhBn zEZ8x&d}R3CC@DzPQnxu%z7u+bB&YnL4246gNw;LkDCG&9N6;SEMuIq`=6J>Ccti3@ z^Zf~j`9C^m2h>l9w4#Y>0)7KUJX{MzPxQ4@oiogf2_pzlCs%2JXThg#$b#KlMh4B5`KtCo z&5cWFz~zlFd%1+bNDjDbP%5b>emyKBn|$s4Dz*|qD@tRW18(NJgo~p9e@ZNMDyFnI zFF>w&r-dkNT>Hku+z)hI-?K-LuLt*m*b$F!y|?yUwo`l){T9YzH5J1OHlsOIag9Eq zrD?|jw_&Y6&>^{Ctq^Kc)urVFq5VAPvJC{LlcLLaJoAxo94Mg>KR$5;#0ehg3-R3W{)ssiL6>&JtV9)(Aft19|0vgvAm4sQWMcEf6i~^2 zL{#g54#>7G^@so29c-#sY&6^*tZe*4lnyxsv`VfZ8G$=AsNX!jiJocyV`IkY@?4{4QP9~ueY2RhdN z8o3H(%@i&5`TTo+O@9M^(1EoS2RtC7MBOdr6FPS36c&+?6BngXKW^>5Jo6njfHo5M z;ACE0oh<3bkNO;IR22|Ev(lOcRcl+iFZt-bsZV{b_7+}3w1>mYlOv;^5Wg9|H?XlkGr1u7MR zzvtiQ?jV(j z!iSIkvg6b|BOJeE4(akYLP^IeB=xMv;+=1%y5r(&!BeeZ48O_!@j6Ai-*ug2RS^L) zX8|E+0W2h|z3zq+wKa!DHN(_=-d|g=Y|u1soZB8v2vu4wyxAz|vJ*9w^JR<^va;c*);1cpx*PIG26q=&8YfyL9S{S~h#_$! z{FR2Ct}tj%!JW#mP#anP-0ph!hYgO@88(C%B=(*Aojk`Axbn)G)Mbg(!`)PXGxwx3 z_oo2Gr+GntQARXUL*wzZ`Evuxia7VU$-yAU&Oo{?ptkuroaJLPpGf<7TRoQspYKO0 z#gH$|@L!liv9SBt^t@lt!iM^bGsI{A)^h6#QqRnIn_e&x3x6(KC zymqNOC4QjQE@4w0LurDcBxKu9$h2eVJ9qK$(9_xyH=0sr!Ch664?ggFGQ+GpaNNkOT4; zHSz|D-oJ^jM1B~6kh`K?9nM&j_V!JZj!=voR2U-UYAUru zDDMH#@KEuRu}JB#RWOI-4W>aaWp}8oAFFD)D9XgdIGam!5g4QhD47$5^Vs<2);BjG zx7#G2JSJ!)KcngUIu#48dr$-H@y8<8wdQnA=`Losx@d(>+e?~P)1)&n zU+lcr&`JBN;-ST_D_UJg>sX9t%R-pYcy7~0OG;YBr9wZ~&hDh;6ip>(pxg8pp_N8w zI&B)YEyE|@(##3NAH+b1 zi>Fobg_I>-xVz+OTl3$UD`O7VVoayOGfeuLy9b$53(8Lz=hSsMrmQ106?4 z4xNv;X|F66^Hxm^Sh@%^pNF(Qyy46o_WM_FIP-p+(LPJrw@XjHa#07fP|HuC_xdk* zuNShBmhz7h7>N{$IZ=8iSH2ld)7N^;Uc5M8+f*8}F|S<~u*Of);*lS!_+mvHdrTXf zq>VMk4ep?=438iW|Fs8p_kVD?o%9r-;jD@eY)yOmcK zbAxiv2fPtOc?@L68_EJ0P%(xvmaOe3UWE<3*H)zd&I6ncQU$ZXU&oaqk@uVOdL=S; zQ}>iBYHH3*a9X8`&mLsg9%ZB)c*KrlKwl#nLvvxR#lq3cV{MBJ%Z0U)!zw+hlE5=Z zpXhFHrX+8!xMU<>CjhU@YdU#Lrzr^c3Q`cO@aHCU@O-L;IFbaS@(0Ip3M)L|a$-M7iT)*e@0>pl|i z2pQWR_HuXf1n&O$>$>>sy0M@kj)vm()O_>r#3({BWHa4Bo9=J2zv=!a`x9#6gO3-D1gl={-Ifi?$VMs1BpN2dBC5w8y5Zi0!epQhv53fB*1_G*89-cW*0J>8Y%q;^}AM zgrE1Ea#&W4p7i%w_&|8e+M!Q8r)B!YpU*tmU%J1enm_K34pMPQ zy>SUtN}8Fcu}*GtncS=BHc~V-%Or5h$Kb^8=bPs_UL6A?90zzDUEhC6gGSXzT|>hv zCae;s2vh$jCWO>{kbsY+GZ9*dK*~9z!JN^s#|ULh+Vd5(ND)pw#worn&S6$cN#(rh zYN_u7RlxZjIBi-RWopoFEu)oOfJN;`#`a?1~Gzo5dg$@;UJJdHbq1lqLWS0NhRPq{}#ZtHvLW9IOp1u{a3tvjr_c3=Os7q z(j7;BLQ{T1Ly^fQe@ElgDdcSfR;Pv0TwpfRsOaASW=i-Uyh2@wyN)Pvm&d3h$-FCS z+hG}XVcI&_e!IAS`$k^FejKSJNfqO0-O8bR$uQ;+f7d|Tt?|WIsRVvRs27y6yj!j8 zK=UjAZXnlaDEn>z8(v#tHjtX-xakLE#AhT1(tcYQ4;`$&D|-%p?Km9V!ds;kHf;6n!WxLIZ%5>i!o8=h;be`Iu)RU^Le>TAZx~ff z|4}*)1gD9j22jhJedY);sBBeqs_=?W(OKeX5+Iu5=Lt4x+R_9X+HK` z(gR%3IJNG-GS^(ko%1DP?^jSv6%qd_O9Vh6F=Z51p%GzfxeL4C>}CEw9P0;}Vl~gR zNKayW1;OZP)LTTb+B90N3V^ZTmt*aN1;oaB*a5z1+j0_H;MhO06=VB+!2(_H`hw?E zEVw+Cjj{J;y@Yzgv>w5NN%Gv4fsMI;dN$?RFTA?d$g>6_xoTdvDD@WG zSA+LsxS$nM=g_#I6+h4Ct-<+}&0YnX1`9R~d*(VlbIu+`EPcykUv8O3@60P$M8K3~ zteR*vyFrgiF-mT#8QXfsW)=C{XokIZDX1GgjZ9 z=ewx@&hvb;fQ(|pAL-UU6@v}hF=0EVg68y$FUP_Gw`zKyE66<<>vq8yE)()xR1kYG zmhDU^<|)j4c?^fK=~u92GWNV3TP8?szXB_ZvEmo|-HiPz*2}z#Zl*x`52|uzIQEOY zgm`%c^wi7lsvzoMEY(bdxIX9lRRI#<{zLzNGIB7@$Q96w!~ADeFa*k>Fntv?4aQF8 z$v;UZ-n4ueOBF}%0G8o4)Z?qZeX`8`V+ZO(vD5M5kc>Lcd|rHJ&EEED>;ItFqb5K0 zTp?Sp5m+JQ^#Eb`F=IF=!^Jg?rEtZwMd&Be>fPZ3;i{0R@|Y^woFIfxF~Z*$a=;@p z+3(kN*L;X?}HV-Dd%65(SH${~&LF^}*ek+67@K4g%f zu}9>iN940dgdUQ#M+3Y@pP61iQoVk%c4{Xnn|~Ni$xrrA?R3iaNWdZaxjMwh;t-!o z>y>Mx(ZkU!56bqapmfBi;?T9j{)YP->~FZg$^NGMo9xe(W{`1*#xna8>X9fOG?pjj zX^%+KBVx^nf%h~|R5FD>%@dX6iAwWCC3&LKnp=`*Dd+QuP!rnLgc$snU%Pf_v2JTd z*Tmih6zY7P;rsu&;VwBOOE&fEZ6t}t9>8Dm;g4T5VZ1*~7?`XG}-ceaD8p!+jaqv@(eb1hHg ziYVHTh(T>t(;<+R;3C!mYfZBpIP*ptq04x>%4ij$lUioCme{2Q9^Wf!Y4&chtmSf9 z!L=9zTh4CVOcmbep*mOx*;j{1Mk==P?%#hiCQ;!PdL(Ko(w#&KmovDKz@@A#nE%Sj zs(oSjpj8;zjTKag>{>0gcGgNMJp+g^zL`K*iRl9ij=bz0C+Ipu!a1Mv&L^Tdsr!}4 zrbyvc5*ZUlrzQ|Vsq4RbV)>mO9%ix}w5v)GNp>4^bp^R{j=X)vQVol%>$y33{=+9g-3f^#vHGDckh66B4flhHA=AO+f5Z z2>aWjm!)3tkGB31n4nYnFL@|Y8$WP5ANiaQCcL8@!9$Rn@B;@qN=N5A8`X*SR-J`< z`HwWps}EMV)As&HV*StW*xg|q#c>FdxP0B;pVVrUtyjx6;}Y`Ie?cuJ-YQP^!O#AP zkL-e<+50~FjqO*TEXX=t5Otw+0vgM-Z-td!#CpGi&uh@$b;0H}Xg?3z&w~Yy$16xo zdvw`eCX!>>pHmz&LtHavsF6$Kd$r2GB`zLubkrWhBj0*#II6|<^sT$cIPSMyhwR%K zVyJmI8=XDg*@IQkj4c9JO%rF0YlEqZ{B4)tV&P8^)lp0bky6D;!Lb<5l0}lN{8><% z#0`nOmB*Bc!Y9p@);)gN>`l}f;GChZO6C353^k( zibbN7yLedY=_n@QlMGcY?=~bI6g5&+Blz~si-G!#cWbUgQXwwY*+CJVrs^*6)S9J)b*E)D z?#%>?XXDdEez}gsZVR5> z6whuVv&oFSSnp=)d} z&}_tba6X1x(la^c?xvZ13cISA947fvtj85U#a@Oqe?Gs1HSNyw(w-LdC&h?%joqW1 zhXaoNBThnAZ7x&R%dIMUhJ)?=+$AL2$8gqXr!Ses+GnT0$MBeeEUsar#L3z@tZ6e$ znL0?BYEx|qYi&Rc!)YoQR8Z+AF0n@w2GeQ2awU}WnOa@gWSdF^d04GsZHeyU5`C~5 ziHn}nsZ)(7&{W+ImsVs&jd1Wf2qJkCF7hgyoiL42q-ILVMheL01b}8tcD^DYs?lK> z&>_3pG0+=YL9>(k&UqiP%Tfb-BfM5%ips=GCg2(3wwaLU1iO#qH8L#bfayLB)GV72 z`K+q)TLneIyL4?GqZp%df(prFn#p6vhLd!-JjdOkQ?A*` zmaM{inw@HNx}A7VvrkU52TM|fIQ-6JkcaquZ`6NNC5YmcBubNdyo3X?J)c*W=`bQ* z?IZq3r67DiNeOzv9t~kv5u;)E#@$;w;&ca+ZnR_2OaFwuZ12sv+Nhj?-%DIrN@Z|} zNDR^a1(9nnbR-`@cd7L5dIsc_0XZM=Z3+tKBS(dH-b@i6e-#g87fL2DAr|)dx;l7X zdxuBz{fb3dryD4AR3PcR!nQ|1@?%$F7u>QqNnHSm zWup5J0P}416E1Rff{;u@No$W2^NIDov-K+joem?mw>7x@gznh!dvT8LMm;Yo+`lJi zJps+Et&jhYk0Zo93={l49p)hG5Y(f(RB1q5)e=cb0`{Fse@oErr3|#}$?$Z%WFKY! R-1GeL{{f}k-j<8(005VEXOaK_ literal 0 HcmV?d00001 From 03d4cfb909fb34e81b84638f7b5e06d9fbb4c807 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Tue, 12 Aug 2025 09:58:50 +0100 Subject: [PATCH 58/61] Bugfix to change response grouping from name to priority (#286) * changed grouping from name to priority * changed grouping from name to priority, type * sonar code complexity fix * sonar code complexity fix * updated names in the code for better clarity * regroup the suitability tests * fix - ordering regroup the suitability tests * test for cohort groups --------- Co-authored-by: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> --- .../model/eligibility_status.py | 1 + .../calculators/eligibility_calculator.py | 74 +++++-- .../views/eligibility.py | 25 +-- tests/fixtures/builders/model/eligibility.py | 1 + .../test_eligibility_calculator.py | 208 +++++++++++++++++- 5 files changed, 258 insertions(+), 51 deletions(-) diff --git a/src/eligibility_signposting_api/model/eligibility_status.py b/src/eligibility_signposting_api/model/eligibility_status.py index 4026d552a..9cc8809fc 100644 --- a/src/eligibility_signposting_api/model/eligibility_status.py +++ b/src/eligibility_signposting_api/model/eligibility_status.py @@ -113,6 +113,7 @@ class Condition: condition_name: ConditionName status: Status cohort_results: list[CohortGroupResult] + suitability_rules: list[Reason] status_text: StatusText actions: list[SuggestedAction] | None = None diff --git a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py index 985613fcb..66cba4664 100644 --- a/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py +++ b/src/eligibility_signposting_api/services/calculators/eligibility_calculator.py @@ -3,6 +3,7 @@ import logging from collections import defaultdict from dataclasses import dataclass, field +from itertools import chain from typing import TYPE_CHECKING from wireup import service @@ -16,6 +17,7 @@ ConditionName, EligibilityStatus, IterationResult, + Reason, Status, ) 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 condition_results[condition_name] = best_iteration_result.iteration_result condition_results[condition_name].actions = matched_action_detail.actions - condition_result = self.build_condition_results(condition_results[condition_name], condition_name) - final_result.append(condition_result) + condition: Condition = self.build_condition( + iteration_result=condition_results[condition_name], condition_name=condition_name + ) + final_result.append(condition) AuditContext.append_audit_condition( condition_name, @@ -150,39 +154,61 @@ def get_iteration_results(self, campaign_group: list[CampaignConfig]) -> dict[It return iteration_results @staticmethod - def build_condition_results(iteration_result: IterationResult, condition_name: ConditionName) -> Condition: + def build_condition(iteration_result: IterationResult, condition_name: ConditionName) -> Condition: grouped_cohort_results = defaultdict(list) for cohort_result in iteration_result.cohort_results: if iteration_result.status == cohort_result.status: grouped_cohort_results[cohort_result.cohort_code].append(cohort_result) - deduplicated_cohort_results = [] - - for group_cohort_code, group in grouped_cohort_results.items(): - if group: - unique_rule_codes = set() - deduplicated_reasons = [] - for cohort in group: - for reason in cohort.reasons: - if reason.rule_name not in unique_rule_codes and reason.rule_description: - unique_rule_codes.add(reason.rule_name) - deduplicated_reasons.append(reason) - - non_empty_description = next((c.description for c in group if c.description), group[0].description) - cohort_group_result = CohortGroupResult( - cohort_code=group_cohort_code, - status=group[0].status, - reasons=deduplicated_reasons, - description=non_empty_description, - audit_rules=[], - ) - deduplicated_cohort_results.append(cohort_group_result) + deduplicated_cohort_results: list[CohortGroupResult] = EligibilityCalculator.deduplicate_cohort_results( + grouped_cohort_results + ) + + overall_deduplicated_reasons_for_condition = EligibilityCalculator.deduplicate_reasons( + deduplicated_cohort_results + ) return Condition( condition_name=condition_name, status=iteration_result.status, cohort_results=list(deduplicated_cohort_results), + suitability_rules=list(overall_deduplicated_reasons_for_condition), actions=iteration_result.actions, status_text=iteration_result.status.get_status_text(condition_name), ) + + @staticmethod + def deduplicate_cohort_results( + grouped_cohort_results: dict[str, list[CohortGroupResult]], + ) -> list[CohortGroupResult]: + results = [] + + for cohort_code, group_results in grouped_cohort_results.items(): + if not group_results: + continue + + deduped_reasons: list[Reason] = EligibilityCalculator.deduplicate_reasons(group_results) + + description = next((c.description for c in group_results if c.description), group_results[0].description) + + results.append( + CohortGroupResult( + cohort_code=cohort_code, + status=group_results[0].status, + reasons=list(deduped_reasons), + description=description, + audit_rules=[], + ) + ) + + return results + + @staticmethod + def deduplicate_reasons(group_results: list[CohortGroupResult]) -> list[Reason]: + all_reasons = chain.from_iterable(group_result.reasons for group_result in group_results) + deduped = {} + for reason in all_reasons: + key = (reason.rule_type, reason.rule_priority) + deduped.setdefault(key, reason) + return list(deduped.values()) diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index 7dee05144..383d73d13 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -158,22 +158,15 @@ def build_eligibility_cohorts(condition: Condition) -> list[eligibility_response def build_suitability_results(condition: Condition) -> list[eligibility_response.SuitabilityRule]: - """Make only one entry if there are duplicate rules""" if condition.status != Status.not_actionable: return [] - suitability_results = [] - - for cohort_result in condition.cohort_results: - if cohort_result.status == Status.not_actionable: - suitability_results.extend( - eligibility_response.SuitabilityRule( - ruleType=eligibility_response.RuleType(reason.rule_type.value), - ruleCode=eligibility_response.RuleCode(reason.rule_name), - ruleText=eligibility_response.RuleText(reason.rule_description), - ) - for reason in cohort_result.reasons - if reason.rule_description - ) - - return suitability_results + return [ + eligibility_response.SuitabilityRule( + ruleType=eligibility_response.RuleType(reason.rule_type.value), + ruleCode=eligibility_response.RuleCode(reason.rule_name), + ruleText=eligibility_response.RuleText(reason.rule_description), + ) + for reason in condition.suitability_rules + if reason.rule_description + ] diff --git a/tests/fixtures/builders/model/eligibility.py b/tests/fixtures/builders/model/eligibility.py index 894b61c47..7406bc38e 100644 --- a/tests/fixtures/builders/model/eligibility.py +++ b/tests/fixtures/builders/model/eligibility.py @@ -33,6 +33,7 @@ class CohortResultFactory(DataclassFactory[eligibility_status.CohortGroupResult] class ConditionFactory(DataclassFactory[eligibility_status.Condition]): actions = Use(SuggestedActionFactory.batch, size=2) cohort_results = Use(CohortResultFactory.batch, size=2) + suitability_rules = Use(ReasonFactory.batch, size=2) class EligibilityStatusFactory(DataclassFactory[eligibility_status.EligibilityStatus]): diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 610b88437..37a13e8ff 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -26,6 +26,7 @@ ActionDescription, ActionType, CohortGroupResult, + Condition, ConditionName, DateOfBirth, InternalActionCode, @@ -40,6 +41,7 @@ ) from eligibility_signposting_api.services.calculators.eligibility_calculator import EligibilityCalculator from tests.fixtures.builders.model import rule as rule_builder +from tests.fixtures.builders.model.eligibility import ReasonFactory from tests.fixtures.builders.repos.person import person_rows_builder from tests.fixtures.matchers.eligibility import ( is_cohort_result, @@ -689,7 +691,7 @@ def test_build_condition_results_single_condition_single_cohort_actionable(self) ] iteration_result = IterationResult(Status.actionable, cohort_group_results, suggested_actions) - result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) + result = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) assert_that(result.condition_name, is_(ConditionName("RSV"))) assert_that(result.status, is_(Status.actionable)) @@ -703,6 +705,7 @@ def test_build_condition_results_single_condition_single_cohort_actionable(self) assert_that(deduplicated_cohort.reasons, is_([])) assert_that(deduplicated_cohort.description, is_("Cohort A Description")) assert_that(deduplicated_cohort.audit_rules, is_([])) + assert_that(result.suitability_rules, is_([])) def test_build_condition_results_single_condition_single_cohort_not_eligible_with_reasons(self): cohort_group_results = [CohortGroupResult("COHORT_A", Status.not_eligible, [], "Cohort A Description", [])] @@ -718,7 +721,7 @@ def test_build_condition_results_single_condition_single_cohort_not_eligible_wit ] iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) - result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) + result = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) assert_that(result.condition_name, is_(ConditionName("RSV"))) assert_that(result.status, is_(Status.not_eligible)) @@ -732,17 +735,18 @@ def test_build_condition_results_single_condition_single_cohort_not_eligible_wit assert_that(deduplicated_cohort.reasons, is_([])) assert_that(deduplicated_cohort.description, is_("Cohort A Description")) assert_that(deduplicated_cohort.audit_rules, is_([])) + assert_that(result.suitability_rules, is_([])) def test_build_condition_results_single_condition_multiple_cohorts_same_cohort_code_same_status(self): reason_1 = Reason( - RuleType.filter, + RuleType.suppression, eligibility_status.RuleName("Filter Rule 1"), RulePriority("1"), RuleDescription("Filter Rule Description 2"), matcher_matched=True, ) reason_2 = Reason( - RuleType.filter, + RuleType.suppression, eligibility_status.RuleName("Filter Rule 2"), RulePriority("2"), RuleDescription("Filter Rule Description 2"), @@ -766,7 +770,7 @@ def test_build_condition_results_single_condition_multiple_cohorts_same_cohort_c ] iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) - result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) + result: Condition = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) assert_that(len(result.cohort_results), is_(1)) @@ -776,17 +780,18 @@ def test_build_condition_results_single_condition_multiple_cohorts_same_cohort_c assert_that(deduplicated_cohort.reasons, contains_inanyorder(reason_1, reason_2)) assert_that(deduplicated_cohort.description, is_("Cohort A Description 2")) assert_that(deduplicated_cohort.audit_rules, is_([])) + assert_that(result.suitability_rules, contains_inanyorder(reason_1, reason_2)) def test_build_condition_results_multiple_cohorts_different_cohort_code_same_status(self): reason_1 = Reason( - RuleType.filter, + RuleType.suppression, eligibility_status.RuleName("Filter Rule 1"), RulePriority("1"), RuleDescription("Filter Rule Description 2"), matcher_matched=True, ) reason_2 = Reason( - RuleType.filter, + RuleType.suppression, eligibility_status.RuleName("Filter Rule 2"), RulePriority("2"), RuleDescription("Filter Rule Description 2"), @@ -808,7 +813,7 @@ def test_build_condition_results_multiple_cohorts_different_cohort_code_same_sta ] iteration_result = IterationResult(Status.not_eligible, cohort_group_results, suggested_actions) - result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) + result = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) assert_that(len(result.cohort_results), is_(2)) @@ -820,14 +825,14 @@ def test_build_condition_results_multiple_cohorts_different_cohort_code_same_sta def test_build_condition_results_cohorts_status_not_matching_iteration_status(self): reason_1 = Reason( - RuleType.filter, + RuleType.suppression, eligibility_status.RuleName("Filter Rule 1"), RulePriority("1"), RuleDescription("Matching"), matcher_matched=True, ) reason_2 = Reason( - RuleType.filter, + RuleType.suppression, eligibility_status.RuleName("Filter Rule 2"), RulePriority("2"), RuleDescription("Not matching"), @@ -840,8 +845,189 @@ def test_build_condition_results_cohorts_status_not_matching_iteration_status(se iteration_result = IterationResult(Status.not_eligible, cohort_group_results, []) - result = EligibilityCalculator.build_condition_results(iteration_result, ConditionName("RSV")) + result = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) assert_that(len(result.cohort_results), is_(1)) assert_that(result.cohort_results[0].cohort_code, is_("COHORT_X")) assert_that(result.cohort_results[0].status, is_(Status.not_eligible)) + + +@pytest.mark.parametrize( + ("reason_1", "reason_2", "reason_3", "expected_reasons"), + [ + # Same rule name, type, and priority, different description + ( + ReasonFactory.build(rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_description="description2", matcher_matched=True), + ReasonFactory.build(rule_description="description3", matcher_matched=True), + [ReasonFactory.build(rule_description="description1", matcher_matched=True)], + ), + # Different rule name, same type, same priority + ( + ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_name="Supress Rule 2", rule_description="description2", matcher_matched=True), + ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description3", matcher_matched=True), + [ReasonFactory.build(rule_name="Supress Rule 1", rule_description="description1", matcher_matched=True)], + ), + # Same rule name, same type, different priority + ( + ReasonFactory.build(rule_priority="1", rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_priority="2", rule_description="description2", matcher_matched=True), + ReasonFactory.build(rule_priority="1", rule_description="description3", matcher_matched=True), + [ + ReasonFactory.build(rule_priority="1", rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_priority="2", rule_description="description2", matcher_matched=True), + ], + ), + # Same rule name, same priority, different type + ( + ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True), + ReasonFactory.build(rule_type=RuleType.filter, rule_description="description2", matcher_matched=True), + ReasonFactory.build(rule_type=RuleType.suppression, rule_description="description3", matcher_matched=True), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, rule_description="description1", matcher_matched=True + ), + ReasonFactory.build(rule_type=RuleType.filter, rule_description="description2", matcher_matched=True), + ], + ), + ], +) +def test_build_condition_results_grouping_reasons(reason_1, reason_2, reason_3, expected_reasons): + cohort_group_results = [ + CohortGroupResult( + "COHORT_X", + Status.not_actionable, + [reason_1, reason_3], + "Cohort X Description", + [], + ), + CohortGroupResult( + "COHORT_Y", + Status.not_actionable, + [reason_2, reason_3], + "Cohort Y Description", + [], + ), + ] + + iteration_result = IterationResult(Status.not_actionable, cohort_group_results, []) + + result: Condition = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) + + assert_that(result.suitability_rules, contains_inanyorder(*expected_reasons)) + + +@pytest.mark.parametrize( + ("reason_2", "expected_reasons"), + [ + # Same rule name, type, and priority, different description + ( + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ) + ], + ), + # Different rule name + ( + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Matching", + rule_name="Supress Rule 2", + rule_priority="1", + matcher_matched=True, + ), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ) + ], + ), + # Different priority + ( + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Matching", + rule_name="Supress Rule 1", + rule_priority="2", + matcher_matched=True, + ), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ), + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Matching", + rule_name="Supress Rule 1", + rule_priority="2", + matcher_matched=True, + ), + ], + ), + # Different type + ( + ReasonFactory.build( + rule_type=RuleType.filter, + rule_description="Matching", + rule_name="Supress Rule 1", + rule_priority="2", + matcher_matched=True, + ), + [ + ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ), + ReasonFactory.build( + rule_type=RuleType.filter, + rule_description="Matching", + rule_name="Supress Rule 1", + rule_priority="2", + matcher_matched=True, + ), + ], + ), + ], +) +def test_build_condition_results_single_cohort(reason_2, expected_reasons): + reason_1 = ReasonFactory.build( + rule_type=RuleType.suppression, + rule_description="Not matching", + rule_name="Supress Rule 1", + rule_priority="1", + matcher_matched=True, + ) + + cohort_group_results = [ + CohortGroupResult("COHORT_Y", Status.not_actionable, [reason_1, reason_2], "Cohort Y Description", []) + ] + + iteration_result = IterationResult(Status.not_actionable, cohort_group_results, []) + result = EligibilityCalculator.build_condition(iteration_result, ConditionName("RSV")) + + assert_that(len(result.cohort_results), is_(1)) + assert_that(result.cohort_results[0].reasons, contains_inanyorder(*expected_reasons)) From 5775932f7ea864b5a297235e2ab3b804081bffec Mon Sep 17 00:00:00 2001 From: Shweta <216860557+shweta-nhs@users.noreply.github.com> Date: Tue, 12 Aug 2025 10:56:38 +0100 Subject: [PATCH 59/61] ELI-397: AWS api gateway to handle bad request param error (#291) --- .../stacks/api-layer/patient_check.tf | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/infrastructure/stacks/api-layer/patient_check.tf b/infrastructure/stacks/api-layer/patient_check.tf index a0cf18a8e..030e69a65 100644 --- a/infrastructure/stacks/api-layer/patient_check.tf +++ b/infrastructure/stacks/api-layer/patient_check.tf @@ -1,4 +1,3 @@ - resource "aws_api_gateway_request_validator" "patient_check_validator" { rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id name = "validate-path-params" @@ -27,12 +26,12 @@ resource "aws_api_gateway_method" "get_patient_check" { } resource "aws_api_gateway_integration" "get_patient_check" { - rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id - resource_id = aws_api_gateway_resource.patient.id - http_method = aws_api_gateway_method.get_patient_check.http_method + rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id + resource_id = aws_api_gateway_resource.patient.id + http_method = aws_api_gateway_method.get_patient_check.http_method integration_http_method = "POST" # Needed for lambda proxy integration - type = "AWS_PROXY" - uri = module.eligibility_signposting_lambda_function.aws_lambda_invoke_arn + type = "AWS_PROXY" + uri = module.eligibility_signposting_lambda_function.aws_lambda_invoke_arn depends_on = [ aws_api_gateway_method.get_patient_check @@ -47,3 +46,43 @@ resource "aws_lambda_permission" "get_patient_check" { source_arn = "${module.eligibility_signposting_api_gateway.execution_arn}/*/*" } + +resource "aws_api_gateway_gateway_response" "bad_request_parameters" { + rest_api_id = module.eligibility_signposting_api_gateway.rest_api_id + response_type = "BAD_REQUEST_PARAMETERS" + status_code = "400" + + response_templates = { + "application/json" = jsonencode({ + resourceType = "OperationOutcome" + id = "$context.requestId" + meta = { + lastUpdated = "$context.requestTime" + } + issue = [ + { + severity = "error" + code = "invalid" + details = { + coding = [ + { + system = "https://fhir.nhs.uk/STU3/ValueSet/Spine-ErrorOrWarningCode-1", + code = "BAD_REQUEST", + display = "Bad Request" + } + ] + } + diagnostics = "Missing required NHS Number from path parameters", + location = [ + "parameters/id" + ] + } + ] + }) + } + + response_parameters = { + "gatewayresponse.header.Access-Control-Allow-Origin" = "'*'" + "gatewayresponse.header.Content-Type" = "'application/fhir+json'" + } +} From 8a9d7ee825b83b349acfad49dd2ebab81dd6cc5a Mon Sep 17 00:00:00 2001 From: SeanSteberis <103416906+seansteberisal@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:10:03 +0100 Subject: [PATCH 60/61] Changes to tests added 1 more test fixed 365 config to work with all test cases --- .../storyTestConfigs/AUTO_RSV_ELI-365-1.json | 451 ------------------ .../storyTestConfigs/AUTO_RSV_ELI-365.json | 22 + .../storyTestData/AUTO_RSV_ELI-365_024.json | 16 +- .../storyTestData/AUTO_RSV_ELI-365_025.json | 16 +- .../storyTestData/AUTO_RSV_ELI-365_026.json | 16 +- .../storyTestData/AUTO_RSV_ELI-365_027.json | 16 +- .../storyTestData/AUTO_RSV_ELI-365_028.json | 47 ++ .../AUTO_RSV_ELI-365_026.json | 24 +- .../AUTO_RSV_ELI-365_027.json | 24 +- .../AUTO_RSV_ELI-365_028.json | 37 ++ 10 files changed, 164 insertions(+), 505 deletions(-) delete mode 100644 tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json create mode 100644 tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_028.json create mode 100644 tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_028.json diff --git a/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json b/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json deleted file mode 100644 index 78f94df3c..000000000 --- a/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365-1.json +++ /dev/null @@ -1,451 +0,0 @@ -{ - "CampaignConfig": { - "ID": "8fcb742b-45fa-4e0d-8f2f-9c2efb1f46d0", - "Version": 1, - "Name": "EliD RSV example config", - "Type": "V", - "Target": "RSV", - "Manager": ["person1@nhs.net"], - "Approver": ["person1@nhs.net"], - "Reviewer": ["person1@nhs.net"], - "IterationFrequency": "X", - "IterationType": "O", - "IterationTime": "07:00:00", - "StartDate": "20250717", - "EndDate": "20350717", - "ApprovalMinimum": 0, - "ApprovalMaximum": 0, - "DefaultCommsRouting": "PLACEHOLDER_COMMS_ROUTING", - "Iterations": [ - { - "ID": "8fcb742b-45fa-4e0d-8f2f-9c2efb1f46d1", - "DefaultCommsRouting": "BOOK_LOCAL|HELP_SUPPORT", - "DefaultNotActionableRouting": "", - "DefaultNotEligibleRouting": "CHECK_CORRECT_X", - "Version": 1, - "Name": "EliD RSV example config", - "IterationDate": "20250717", - "IterationNumber": 1, - "CommsType": "I", - "ApprovalMinimum": 0, - "ApprovalMaximum": 0, - "Type": "O", - "IterationCohorts": [ - { - "CohortLabel": "rsv_75to79", - "CohortGroup": "rsv_age", - "PositiveDescription": "are aged 75 to 79 years old", - "NegativeDescription": "are not aged 75 to 79 years old", - "Priority": 0 - }, - { - "CohortLabel": "rsv_80_since_02_Sept_2024", - "CohortGroup": "rsv_age_catchup", - "PositiveDescription": "turned 80 after 1st September 2024", - "NegativeDescription": "did not turn 80 after 1 September 2024", - "Priority": 10 - }, - { - "CohortLabel": "elid_all_people", - "CohortGroup": "magic_cohort", - "PositiveDescription": "", - "NegativeDescription": "", - "Priority": 20 - } - ], - "IterationRules": [ - { - "Type": "F", - "Name": "Remove from magic cohort unless already vaccinated or have future booking", - "Description": "Remove anyone NOT already vaccinated within the last 25 years and do not have a future booking from the magic cohort", - "Operator": "Y<=", - "Comparator": "-25[[NVL:18000101]]", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "LAST_SUCCESSFUL_DATE", - "CohortLabel": "elid_all_people", - "Priority": 100 - }, - { - "Type": "F", - "Name": "Remove from magic cohort unless already vaccinated or have future booking", - "Description": "Remove anyone without a future booking from magic cohort", - "Operator": "D<", - "Comparator": "0[[NVL:18000101]]", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "BOOKED_APPOINTMENT_DATE", - "CohortLabel": "elid_all_people", - "Priority": 100 - }, - { - "Type": "F", - "Name": "Remove under 75 Years on day of execution", - "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", - "Priority": 120, - "AttributeLevel": "PERSON", - "AttributeName": "DATE_OF_BIRTH", - "Operator": "Y>", - "Comparator": "-75", - "CohortLabel": "rsv_75to79" - }, - { - "Type": "F", - "Name": "Remove over 80 on day of execution", - "Description": "Exclude anyone who turned 80 before 2nd September 2024", - "Priority": 130, - "AttributeLevel": "PERSON", - "AttributeName": "DATE_OF_BIRTH", - "Operator": "<", - "Comparator": "19440902", - "CohortLabel": "rsv_75to79" - }, - { - "Type": "F", - "Name": "Remove under 75 Years on day of execution", - "Description": "Ensure anyone who has a PDS date of birth which determines their age to be less than 75 years is filtered out.", - "Priority": 140, - "AttributeLevel": "PERSON", - "AttributeName": "DATE_OF_BIRTH", - "Operator": "Y>", - "Comparator": "-75", - "CohortLabel": "rsv_80_since_02_Sept_2024" - }, - { - "Type": "F", - "Name": "Remove over 80 on day of execution", - "Description": "Exclude anyone who turned 80 before 2nd September 2024", - "Priority": 150, - "AttributeLevel": "PERSON", - "AttributeName": "DATE_OF_BIRTH", - "Operator": "<", - "Comparator": "19440902", - "CohortLabel": "rsv_80_since_02_Sept_2024" - }, - { - "Type": "F", - "Name": "Remove from rsv 80 cohort if already vaccinated", - "Description": "Remove anyone already vaccinated from 80 cohort", - "Operator": "Y>=", - "Comparator": "-25", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "LAST_SUCCESSFUL_DATE", - "CohortLabel": "rsv_80_since_02_Sept_2024", - "Priority": 160 - }, - { - "Type": "F", - "Name": "Remove from rsv 80 cohort if future booking", - "Description": "Remove anyone with a future booking from RSV 80 cohort", - "Operator": "D>=", - "Comparator": "0", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "BOOKED_APPOINTMENT_DATE", - "CohortLabel": "rsv_80_since_02_Sept_2024", - "Priority": 170 - }, - { - "Type": "F", - "Name": "Remove from rsv 75-79 cohort if already vaccinated", - "Description": "Remove anyone already vaccinated from 75-79 cohort", - "Operator": "Y>=", - "Comparator": "-25", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "LAST_SUCCESSFUL_DATE", - "CohortLabel": "rsv_75to79", - "Priority": 180 - }, - { - "Type": "F", - "Name": "Remove from rsv 75-79 cohort if future booking", - "Description": "Remove anyone with a future booking from RSV 75-79 cohort", - "Operator": "D>=", - "Comparator": "0", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "BOOKED_APPOINTMENT_DATE", - "CohortLabel": "rsv_75to79", - "Priority": 190 - }, - { - "Type": "S", - "Name": "Already Vaccinated", - "Description": "## You've had your RSV vaccination\n\n We believe you had your vaccination.", - "Priority": 200, - "AttributeLevel": "TARGET", - "AttributeTarget": "RSV", - "AttributeName": "LAST_SUCCESSFUL_DATE", - "Operator": "Y>=", - "Comparator": "-25", - "RuleStop": "Y" - }, - { - "Type": "S", - "Name": "Other Setting", - "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", - "Priority": 510, - "AttributeLevel": "PERSON", - "AttributeName": "CARE_HOME_FLAG", - "Operator": "=", - "Comparator": "Y", - "CohortLabel": "rsv_80_since_02_Sept_2024", - "RuleStop": "Y" - }, - { - "Type": "S", - "Name": "Other Setting", - "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", - "Priority": 520, - "AttributeLevel": "PERSON", - "AttributeName": "DE_FLAG", - "Operator": "=", - "Comparator": "Y", - "CohortLabel": "rsv_80_since_02_Sept_2024", - "RuleStop": "Y" - }, - { - "Type": "S", - "Name": "Other Setting", - "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", - "Priority": 530, - "AttributeLevel": "PERSON", - "AttributeName": "13Q_FLAG", - "Operator": "=", - "Comparator": "Y", - "CohortLabel": "rsv_80_since_02_Sept_2024", - "RuleStop": "Y" - }, - { - "Type": "S", - "Name": "Other Setting", - "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", - "Priority": 540, - "AttributeLevel": "PERSON", - "AttributeName": "CARE_HOME_FLAG", - "Operator": "=", - "Comparator": "Y", - "CohortLabel": "rsv_75to79", - "RuleStop": "Y" - }, - { - "Type": "S", - "Name": "Other Setting with no future booking", - "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", - "Priority": 550, - "AttributeLevel": "PERSON", - "AttributeName": "DE_FLAG", - "Operator": "=", - "Comparator": "Y", - "CohortLabel": "rsv_75to79", - "RuleStop": "Y" - }, - { - "Type": "S", - "Name": "Other Setting", - "Description": "## Getting the vaccine\n\n We believe you're living in a setting where care is provided.\n\n Speak to a member of staff where you live about getting the RSV vaccine.", - "Priority": 560, - "AttributeLevel": "PERSON", - "AttributeName": "13Q_FLAG", - "Operator": "=", - "Comparator": "Y", - "CohortLabel": "rsv_75to79", - "RuleStop": "Y" - }, - { - "Type": "R", - "Name": "Actionable Future Booked NBS Appointment", - "Description": "Amend NBS future booking", - "Priority": 1000, - "Operator": "D>=", - "Comparator": "0", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "BOOKED_APPOINTMENT_DATE", - "CommsRouting": "AMEND_NBS" - }, - { - "Type": "R", - "Name": "Actionable Future Booked NBS Appointment", - "Description": "Amend NBS future booking", - "Priority": 1000, - "Operator": "=", - "Comparator": "NBS", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "BOOKED_APPOINTMENT_PROVIDER", - "CommsRouting": "AMEND_NBS" - }, - { - "Type": "R", - "Name": "Actionable Future Booked Local Appointment", - "Description": "Amend local future booking", - "Priority": 1100, - "Operator": "D>=", - "Comparator": "0", - "AttributeTarget": "RSV", - "AttributeLevel": "TARGET", - "AttributeName": "BOOKED_APPOINTMENT_DATE", - "CommsRouting": "MANAGE_LOCAL" - }, - { - "Type": "R", - "Name": "Within CP Expansion ICB not 80 plus", - "Description": "Book an appointment on NBS as within CP expansion", - "Priority": 1200, - "Operator": "in", - "Comparator": "QH8,QJG", - "AttributeLevel": "PERSON", - "AttributeName": "ICB", - "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" - }, - { - "Type": "R", - "Name": "Within CP Expansion ICB not 80 plus", - "Description": "Book an appointment on NBS as within CP expansion", - "Priority": 1200, - "AttributeLevel": "PERSON", - "AttributeName": "DATE_OF_BIRTH", - "Operator": "Y>", - "Comparator": "-80", - "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" - }, - { - "Type": "R", - "Name": "Within CP Expansion Local Authority", - "Description": "Book an appointment on NBS as within CP expansion", - "Priority": 1300, - "Operator": "in", - "Comparator": "E08000028,E08000031,E08000025,E06000016,E06000008,E07000117,E07000120,E08000011,E08000012,E07000122,E07000123,E08000014,E07000126,E08000013,E07000127,E08000015,E07000128", - "AttributeLevel": "PERSON", - "AttributeName": "LOCAL_AUTHORITY", - "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" - }, - { - "Type": "R", - "Name": "Within CP Expansion ICB not 80 plus", - "Description": "Book an appointment on NBS as within CP expansion", - "Priority": 1300, - "AttributeLevel": "PERSON", - "AttributeName": "DATE_OF_BIRTH", - "Operator": "Y>", - "Comparator": "-80", - "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" - }, - { - "Type": "Y", - "Name": "Already vaccinated default text", - "Description": "Already vaccinated default text", - "Priority": 3000, - "AttributeLevel": "TARGET", - "AttributeTarget": "RSV", - "AttributeName": "LAST_SUCCESSFUL_DATE", - "Operator": "Y>=", - "Comparator": "-25", - "CommsRouting": "CHECK_CORRECT_ALREADY_VACCINATED" - }, - { - "Type": "Y", - "Name": "Other setting default text", - "Description": "Other setting default text", - "Priority": 3100, - "AttributeLevel": "PERSON", - "AttributeName": "CARE_HOME_FLAG", - "Operator": "=", - "Comparator": "Y", - "CommsRouting": "CHECK_CORRECT_OTHER_SETTING" - }, - { - "Type": "Y", - "Name": "Other setting default text", - "Description": "Other setting default text", - "Priority": 3200, - "AttributeLevel": "PERSON", - "AttributeName": "DE_FLAG", - "Operator": "=", - "Comparator": "Y", - "CommsRouting": "CHECK_CORRECT_OTHER_SETTING" - }, - { - "Type": "Y", - "Name": "Other setting default text", - "Description": "Other setting default text", - "Priority": 3300, - "AttributeLevel": "PERSON", - "AttributeName": "13Q_FLAG", - "Operator": "=", - "Comparator": "Y", - "CommsRouting": "CHECK_CORRECT_OTHER_SETTING" - } - ], - "ActionsMapper": { - "BOOK_NBS": { - "ExternalRoutingCode": "BookNBS", - "ActionDescription": "", - "ActionType": "ButtonWithAuthLink", - "UrlLink": "http://www.nhs.uk/book-rsv", - "UrlLabel": "Continue to booking" - }, - "AMEND_NBS": { - "ExternalRoutingCode": "AmendNBS", - "ActionDescription": "## You have an RSV vaccination appointment\n You can view, change or cancel your appointment below.", - "ActionType": "ButtonWithAuthLink", - "UrlLink": "http://www.nhs.uk/book-rsv", - "UrlLabel": "Manage your appointment" - }, - "CONTACT_GP": { - "ExternalRoutingCode": "ContactGP", - "ActionDescription": "Contact your GP", - "ActionType": "InfoText", - "UrlLink": null, - "UrlLabel": "" - }, - "BOOK_LOCAL": { - "ExternalRoutingCode": "BookLocal", - "ActionDescription": "## Getting the vaccine\n\n You can get an RSV vaccination at your GP surgery.\n Your GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", - "ActionType": "InfoText", - "UrlLink": null, - "UrlLabel": "" - }, - "MANAGE_LOCAL": { - "ExternalRoutingCode": "ManageLocal", - "ActionDescription": "## You have an RSV vaccination appointment\n\n Contact your healthcare provider to change or cancel your appointment.", - "ActionType": "CardWithText", - "UrlLink": null, - "UrlLabel": "" - }, - "HELP_SUPPORT": { - "ExternalRoutingCode": "HelpSupportInfo", - "ActionDescription": "## If you think this is incorrect\n\n If you have already had this vaccination or your personal details are wrong, visit our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", - "ActionType": "InfoText", - "UrlLink": null, - "UrlLabel": "" - }, - "CHECK_CORRECT_X": { - "ExternalRoutingCode": "HealthcareProInfo", - "ActionDescription": "## If you think this is incorrect\n\n Speak to your healthcare professional if you think you should be offered this vaccine.\n\nFor anything else, visit our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", - "ActionType": "InfoText", - "UrlLink": null, - "UrlLabel": "" - }, - "CHECK_CORRECT_ALREADY_VACCINATED": { - "ExternalRoutingCode": "AlreadyVaccinatedInfo", - "ActionDescription": "## If you think this is incorrect\n\n If you believe you've not been vaccinated against RSV, speak to your healthcare professional.\n\nFor anything else please see our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", - "ActionType": "InfoText", - "UrlLink": null, - "UrlLabel": "" - }, - "CHECK_CORRECT_OTHER_SETTING": { - "ExternalRoutingCode": "ManagedSettingInfo", - "ActionDescription": "## If you think this is incorrect\n\n If you have already had this vaccination or your personal details are wrong, visit our [help and support page](https://digital.nhs.uk/services/eligibility-data-product-elid).", - "ActionType": "InfoText", - "UrlLink": null, - "UrlLabel": "" - } - } - } - ] - } -} diff --git a/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365.json b/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365.json index 0385102c8..534320670 100644 --- a/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365.json +++ b/tests/e2e/data/configs/storyTestConfigs/AUTO_RSV_ELI-365.json @@ -301,6 +301,17 @@ "AttributeName": "ICB", "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" }, + { + "Type": "R", + "Name": "Within CP Expansion ICB not 80 plus", + "Description": "Book an appointment on NBS as within CP expansion", + "Priority": 1200, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-80", + "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" + }, { "Type": "R", "Name": "Within CP Expansion Local Authority", @@ -312,6 +323,17 @@ "AttributeName": "LOCAL_AUTHORITY", "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" }, + { + "Type": "R", + "Name": "Within CP Expansion ICB not 80 plus", + "Description": "Book an appointment on NBS as within CP expansion", + "Priority": 1300, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "Y>", + "Comparator": "-80", + "CommsRouting": "BOOK_LOCAL|BOOK_NBS|HELP_SUPPORT" + }, { "Type": "Y", "Name": "Already vaccinated default text", diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json index 73ba7b9e6..13b4135b9 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_024.json @@ -1,14 +1,14 @@ { - "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 after 1st September 2024 - ICB", + "scenario_name": "RSV - Actionable - In 3 actions - under 80 - Local Authority", "request_headers": { - "nhs-login-nhs-number": "9900036524" + "nhs-login-nhs-number": "9900036526" }, "config_filenames": [ "AUTO_RSV_ELI-365.json" ], "data": [ { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036526", "ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [ { @@ -18,9 +18,9 @@ ] }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036526", "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "<>", + "DATE_OF_BIRTH": "<>", "GENDER": "0", "POSTCODE": "SG8 6EG", "POSTCODE_SECTOR": "SG86", @@ -29,15 +29,15 @@ "LSOA": "E01018267", "GP_PRACTICE_CODE": "D81046", "PCN": "U75549", - "ICB": "QH8", - "LOCAL_AUTHORITY": "ZZ8000011", + "ICB": "zz1", + "LOCAL_AUTHORITY": "E08000014", "COMMISSIONING_REGION": "Y61", "13Q_FLAG": "N", "CARE_HOME_FLAG": "N", "DE_FLAG": "N" }, { - "NHS_NUMBER": "9900036524", + "NHS_NUMBER": "9900036526", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, "BOOKED_APPOINTMENT_DATE": null, diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json index f44d057a9..80a5ec4e2 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_025.json @@ -1,14 +1,14 @@ { - "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 after 1st September 2024 - Local Authority", + "scenario_name": "RSV - Actionable - In 3 actions - under 80 - ICB", "request_headers": { - "nhs-login-nhs-number": "9900036525" + "nhs-login-nhs-number": "9900036527" }, "config_filenames": [ "AUTO_RSV_ELI-365.json" ], "data": [ { - "NHS_NUMBER": "9900036525", + "NHS_NUMBER": "9900036527", "ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [ { @@ -18,9 +18,9 @@ ] }, { - "NHS_NUMBER": "9900036525", + "NHS_NUMBER": "9900036527", "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "<>", + "DATE_OF_BIRTH": "<>", "GENDER": "0", "POSTCODE": "SG8 6EG", "POSTCODE_SECTOR": "SG86", @@ -29,15 +29,15 @@ "LSOA": "E01018267", "GP_PRACTICE_CODE": "D81046", "PCN": "U75549", - "ICB": "zz1", - "LOCAL_AUTHORITY": "E08000014", + "ICB": "QH8", + "LOCAL_AUTHORITY": "ZZ8000014", "COMMISSIONING_REGION": "Y61", "13Q_FLAG": "N", "CARE_HOME_FLAG": "N", "DE_FLAG": "N" }, { - "NHS_NUMBER": "9900036525", + "NHS_NUMBER": "9900036527", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, "BOOKED_APPOINTMENT_DATE": null, diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json index b6e371b2a..892196765 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_026.json @@ -1,14 +1,14 @@ { - "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 before 1st September 2024 - Local Authority", + "scenario_name": "RSV - Actionable - 2 actions - 80 or over - ICB", "request_headers": { - "nhs-login-nhs-number": "9900036526" + "nhs-login-nhs-number": "9900036524" }, "config_filenames": [ "AUTO_RSV_ELI-365.json" ], "data": [ { - "NHS_NUMBER": "9900036526", + "NHS_NUMBER": "9900036524", "ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [ { @@ -18,9 +18,9 @@ ] }, { - "NHS_NUMBER": "9900036526", + "NHS_NUMBER": "9900036524", "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "<>", + "DATE_OF_BIRTH": "<>", "GENDER": "0", "POSTCODE": "SG8 6EG", "POSTCODE_SECTOR": "SG86", @@ -29,15 +29,15 @@ "LSOA": "E01018267", "GP_PRACTICE_CODE": "D81046", "PCN": "U75549", - "ICB": "zz1", - "LOCAL_AUTHORITY": "E08000014", + "ICB": "QH8", + "LOCAL_AUTHORITY": "ZZ8000011", "COMMISSIONING_REGION": "Y61", "13Q_FLAG": "N", "CARE_HOME_FLAG": "N", "DE_FLAG": "N" }, { - "NHS_NUMBER": "9900036526", + "NHS_NUMBER": "9900036524", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, "BOOKED_APPOINTMENT_DATE": null, diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json index fd2b78f95..0c90eab68 100644 --- a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_027.json @@ -1,14 +1,14 @@ { - "scenario_name": "RSV - Actionable - In rsv_80_since_02_Sept_2024 - over 80 before 1st September 2024 - ICB", + "scenario_name": "RSV - Actionable - 2 actions - 80 or over - Local Authority", "request_headers": { - "nhs-login-nhs-number": "9900036527" + "nhs-login-nhs-number": "9900036525" }, "config_filenames": [ "AUTO_RSV_ELI-365.json" ], "data": [ { - "NHS_NUMBER": "9900036527", + "NHS_NUMBER": "9900036525", "ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": [ { @@ -18,9 +18,9 @@ ] }, { - "NHS_NUMBER": "9900036527", + "NHS_NUMBER": "9900036525", "ATTRIBUTE_TYPE": "PERSON", - "DATE_OF_BIRTH": "<>", + "DATE_OF_BIRTH": "<>", "GENDER": "0", "POSTCODE": "SG8 6EG", "POSTCODE_SECTOR": "SG86", @@ -29,15 +29,15 @@ "LSOA": "E01018267", "GP_PRACTICE_CODE": "D81046", "PCN": "U75549", - "ICB": "QH8", - "LOCAL_AUTHORITY": "ZZ8000014", + "ICB": "zz1", + "LOCAL_AUTHORITY": "E08000014", "COMMISSIONING_REGION": "Y61", "13Q_FLAG": "N", "CARE_HOME_FLAG": "N", "DE_FLAG": "N" }, { - "NHS_NUMBER": "9900036527", + "NHS_NUMBER": "9900036525", "ATTRIBUTE_TYPE": "RSV", "LAST_SUCCESSFUL_DATE": null, "BOOKED_APPOINTMENT_DATE": null, diff --git a/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_028.json b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_028.json new file mode 100644 index 000000000..083693b2c --- /dev/null +++ b/tests/e2e/data/dynamoDB/storyTestData/AUTO_RSV_ELI-365_028.json @@ -0,0 +1,47 @@ +{ + "scenario_name": "RSV - Actionable - 2 actions - 80 or over - No ICB or Local Authority", + "request_headers": { + "nhs-login-nhs-number": "9900036528" + }, + "config_filenames": [ + "AUTO_RSV_ELI-365.json" + ], + "data": [ + { + "NHS_NUMBER": "9900036528", + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + { + "COHORT_LABEL": "rsv_80_since_02_Sept_2024", + "DATE_JOINED": "20231020" + } + ] + }, + { + "NHS_NUMBER": "9900036528", + "ATTRIBUTE_TYPE": "PERSON", + "DATE_OF_BIRTH": "<>", + "GENDER": "0", + "POSTCODE": "SG8 6EG", + "POSTCODE_SECTOR": "SG86", + "POSTCODE_OUTCODE": "SG8", + "MSOA": "E02003792", + "LSOA": "E01018267", + "GP_PRACTICE_CODE": "D81046", + "PCN": "U75549", + "ICB": "zz1", + "LOCAL_AUTHORITY": "ZZ8000014", + "COMMISSIONING_REGION": "Y61", + "13Q_FLAG": "N", + "CARE_HOME_FLAG": "N", + "DE_FLAG": "N" + }, + { + "NHS_NUMBER": "9900036528", + "ATTRIBUTE_TYPE": "RSV", + "LAST_SUCCESSFUL_DATE": null, + "BOOKED_APPOINTMENT_DATE": null, + "BOOKED_APPOINTMENT_PROVIDER": null + } + ] +} diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json index d7ca2432b..2800f4974 100644 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_026.json @@ -6,28 +6,30 @@ { "actions": [ { - "actionCode": "HealthcareProInfo", + "actionCode": "BookLocal", "actionType": "InfoText", - "description": "## If you think this is incorrect\n\nSpeak to your healthcare professional if you think you should be offered this vaccine.\n\nFor anything else, visit our help and support page. (ADD LINK)", + "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" + }, + { + "actionCode": "HelpSupportInfo", + "actionType": "InfoText", + "description": "## CONTENT TBC\n\nBlah blah blah.", "urlLabel": "", "urlLink": "" } ], "condition": "RSV", "eligibilityCohorts": [ - { - "cohortCode": "rsv_age", - "cohortStatus": "NotEligible", - "cohortText": "are not aged 75 to 79 years old" - }, { "cohortCode": "rsv_age_catchup", - "cohortStatus": "NotEligible", - "cohortText": "did not turn 80 after 1 September 2024" + "cohortStatus": "Actionable", + "cohortText": "turned 80 after 1st September 2024" } ], - "status": "NotEligible", - "statusText": "We do not believe you can have it", + "status": "Actionable", + "statusText": "You should have the RSV vaccine", "suitabilityRules": [] } ], diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json index d7ca2432b..2800f4974 100644 --- a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_027.json @@ -6,28 +6,30 @@ { "actions": [ { - "actionCode": "HealthcareProInfo", + "actionCode": "BookLocal", "actionType": "InfoText", - "description": "## If you think this is incorrect\n\nSpeak to your healthcare professional if you think you should be offered this vaccine.\n\nFor anything else, visit our help and support page. (ADD LINK)", + "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" + }, + { + "actionCode": "HelpSupportInfo", + "actionType": "InfoText", + "description": "## CONTENT TBC\n\nBlah blah blah.", "urlLabel": "", "urlLink": "" } ], "condition": "RSV", "eligibilityCohorts": [ - { - "cohortCode": "rsv_age", - "cohortStatus": "NotEligible", - "cohortText": "are not aged 75 to 79 years old" - }, { "cohortCode": "rsv_age_catchup", - "cohortStatus": "NotEligible", - "cohortText": "did not turn 80 after 1 September 2024" + "cohortStatus": "Actionable", + "cohortText": "turned 80 after 1st September 2024" } ], - "status": "NotEligible", - "statusText": "We do not believe you can have it", + "status": "Actionable", + "statusText": "You should have the RSV vaccine", "suitabilityRules": [] } ], diff --git a/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_028.json b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_028.json new file mode 100644 index 000000000..2800f4974 --- /dev/null +++ b/tests/e2e/data/responses/storyTestResponses/AUTO_RSV_ELI-365_028.json @@ -0,0 +1,37 @@ +{ + "meta": { + "lastUpdated": "" + }, + "processedSuggestions": [ + { + "actions": [ + { + "actionCode": "BookLocal", + "actionType": "InfoText", + "description": "##Getting the vaccine\n\nYou can get an RSV vaccination at your GP surgery.\nYour GP surgery may contact you about getting the RSV vaccine. This may be by letter, text, phone call, email or through the NHS App. You do not need to wait to be contacted before booking your vaccination.", + "urlLabel": "", + "urlLink": "" + }, + { + "actionCode": "HelpSupportInfo", + "actionType": "InfoText", + "description": "## CONTENT TBC\n\nBlah blah blah.", + "urlLabel": "", + "urlLink": "" + } + ], + "condition": "RSV", + "eligibilityCohorts": [ + { + "cohortCode": "rsv_age_catchup", + "cohortStatus": "Actionable", + "cohortText": "turned 80 after 1st September 2024" + } + ], + "status": "Actionable", + "statusText": "You should have the RSV vaccine", + "suitabilityRules": [] + } + ], + "responseId": "" +} From 9ea07d6925d4f5908bff84e59cd67efbfafa93ca Mon Sep 17 00:00:00 2001 From: SeanSteberis <103416906+seansteberisal@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:25:52 +0100 Subject: [PATCH 61/61] linting and formatting update package updates --- .tool-versions | 1 + poetry.lock | 108 +++++++++++++++++- tests/e2e/tests/conftest.py | 3 +- tests/e2e/tests/test_in_progress.py | 4 +- tests/e2e/tests/test_regression_tests.py | 4 +- tests/e2e/tests/test_smoke_tests.py | 4 +- tests/e2e/tests/test_story_tests.py | 4 +- .../e2e/tests/test_vita_integration_tests.py | 4 +- 8 files changed, 123 insertions(+), 9 deletions(-) diff --git a/.tool-versions b/.tool-versions index d7ea39018..2bb2a6b76 100644 --- a/.tool-versions +++ b/.tool-versions @@ -5,6 +5,7 @@ pre-commit 4.2.0 vale 3.11.2 poetry 2.1.4 act 0.2.77 +nodejs 22.18.0 # ============================================================================== # The section below is reserved for Docker image versions. diff --git a/poetry.lock b/poetry.lock index b00cd166d..641bd9b07 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1343,6 +1343,24 @@ rpds-py = ">=0.7.1" format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] +[[package]] +name = "jsonschema-path" +version = "0.3.4" +description = "JSONSchema Spec with object-oriented paths" +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["dev"] +files = [ + {file = "jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8"}, + {file = "jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001"}, +] + +[package.dependencies] +pathable = ">=0.4.1,<0.5.0" +PyYAML = ">=5.1" +referencing = "<0.37.0" +requests = ">=2.31.0,<3.0.0" + [[package]] name = "jsonschema-specifications" version = "2025.4.1" @@ -1358,6 +1376,30 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "lazy-object-proxy" +version = "1.11.0" +description = "A fast and thorough lazy object proxy." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "lazy_object_proxy-1.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:132bc8a34f2f2d662a851acfd1b93df769992ed1b81e2b1fda7db3e73b0d5a18"}, + {file = "lazy_object_proxy-1.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:01261a3afd8621a1accb5682df2593dc7ec7d21d38f411011a5712dcd418fbed"}, + {file = "lazy_object_proxy-1.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:090935756cc041e191f22f4f9c7fd4fe9a454717067adf5b1bbd2ce3046b556e"}, + {file = "lazy_object_proxy-1.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:76ec715017f06410f57df442c1a8d66e6b5f7035077785b129817f5ae58810a4"}, + {file = "lazy_object_proxy-1.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a9f39098e93a63618a79eef2889ae3cf0605f676cd4797fdfd49fcd7ddc318b"}, + {file = "lazy_object_proxy-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ee13f67f4fcd044ef27bfccb1c93d39c100046fec1fad6e9a1fcdfd17492aeb3"}, + {file = "lazy_object_proxy-1.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4c84eafd8dd15ea16f7d580758bc5c2ce1f752faec877bb2b1f9f827c329cd"}, + {file = "lazy_object_proxy-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:d2503427bda552d3aefcac92f81d9e7ca631e680a2268cbe62cd6a58de6409b7"}, + {file = "lazy_object_proxy-1.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0613116156801ab3fccb9e2b05ed83b08ea08c2517fdc6c6bc0d4697a1a376e3"}, + {file = "lazy_object_proxy-1.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:bb03c507d96b65f617a6337dedd604399d35face2cdf01526b913fb50c4cb6e8"}, + {file = "lazy_object_proxy-1.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28c174db37946f94b97a97b579932ff88f07b8d73a46b6b93322b9ac06794a3b"}, + {file = "lazy_object_proxy-1.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:d662f0669e27704495ff1f647070eb8816931231c44e583f4d0701b7adf6272f"}, + {file = "lazy_object_proxy-1.11.0-py3-none-any.whl", hash = "sha256:a56a5093d433341ff7da0e89f9b486031ccd222ec8e52ec84d0ec1cdc819674b"}, + {file = "lazy_object_proxy-1.11.0.tar.gz", hash = "sha256:18874411864c9fbbbaa47f9fc1dd7aea754c86cfde21278ef427639d1dd78e9c"}, +] + [[package]] name = "localstack" version = "4.6.0" @@ -1911,6 +1953,41 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "openapi-schema-validator" +version = "0.6.3" +description = "OpenAPI schema validation for Python" +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["dev"] +files = [ + {file = "openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3"}, + {file = "openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee"}, +] + +[package.dependencies] +jsonschema = ">=4.19.1,<5.0.0" +jsonschema-specifications = ">=2023.5.2" +rfc3339-validator = "*" + +[[package]] +name = "openapi-spec-validator" +version = "0.7.2" +description = "OpenAPI 2.0 (aka Swagger) and OpenAPI 3 spec validator" +optional = false +python-versions = "<4.0.0,>=3.8.0" +groups = ["dev"] +files = [ + {file = "openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60"}, + {file = "openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734"}, +] + +[package.dependencies] +jsonschema = ">=4.18.0,<5.0.0" +jsonschema-path = ">=0.3.1,<0.4.0" +lazy-object-proxy = ">=1.7.1,<2.0.0" +openapi-schema-validator = ">=0.6.0,<0.7.0" + [[package]] name = "orderedmultidict" version = "1.0.1" @@ -1971,6 +2048,18 @@ develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0) ; pyt docs = ["Sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6.0)"] testing = ["pytest (<5.0) ; python_version < \"3.0\"", "pytest (>=5.0) ; python_version >= \"3.0\"", "pytest-html (>=1.19.0)"] +[[package]] +name = "pathable" +version = "0.4.4" +description = "Object-oriented paths" +optional = false +python-versions = "<4.0.0,>=3.7.0" +groups = ["dev"] +files = [ + {file = "pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2"}, + {file = "pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2"}, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2797,6 +2886,21 @@ urllib3 = ">=1.25.10,<3.0" [package.extras] tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli ; python_version < \"3.11\"", "tomli-w", "types-PyYAML", "types-requests"] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["dev"] +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "rich" version = "14.0.0" @@ -3494,4 +3598,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "bb28d2f45eb708ec5921e1e6c528344ef2251dea6f3becae5cc7d61c8f82e3c8" +content-hash = "8e6e98116871c55e44eddc6f7312d0e01b268dfdb0b410d55d9a30035ac98a07" diff --git a/tests/e2e/tests/conftest.py b/tests/e2e/tests/conftest.py index 30dd5808e..7be5cad4e 100644 --- a/tests/e2e/tests/conftest.py +++ b/tests/e2e/tests/conftest.py @@ -1,9 +1,7 @@ -import json import logging import os from pathlib import Path -import boto3 import pytest from dotenv import load_dotenv @@ -25,6 +23,7 @@ logger = logging.getLogger(__name__) + @pytest.fixture(scope="session") def eligibility_client(): return EligibilityApiClient(BASE_URL, cert_dir="certs") diff --git a/tests/e2e/tests/test_in_progress.py b/tests/e2e/tests/test_in_progress.py index 4204f4519..f3a6a5917 100644 --- a/tests/e2e/tests/test_in_progress.py +++ b/tests/e2e/tests/test_in_progress.py @@ -16,7 +16,9 @@ @pytest.mark.parametrize(("filename", "scenario"), param_list, ids=id_list) def test_run_in_progress_tests(filename, scenario, eligibility_client, get_scenario_params): - nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params(scenario, config_path) + nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params( + scenario, config_path + ) actual_response = eligibility_client.make_request( nhs_number, headers=request_headers, query_params=query_params, strict_ssl=False diff --git a/tests/e2e/tests/test_regression_tests.py b/tests/e2e/tests/test_regression_tests.py index d4a17f288..e9a0ba88c 100644 --- a/tests/e2e/tests/test_regression_tests.py +++ b/tests/e2e/tests/test_regression_tests.py @@ -17,7 +17,9 @@ @pytest.mark.functionale2eregression @pytest.mark.parametrize(("filename", "scenario"), param_list, ids=id_list) def test_run_regression_tests(filename, scenario, eligibility_client, get_scenario_params): - nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params(scenario, config_path) + nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params( + scenario, config_path + ) actual_response = eligibility_client.make_request(nhs_number, headers=request_headers, strict_ssl=False) expected_response = all_expected_responses.get(filename).get("response_items", {}) diff --git a/tests/e2e/tests/test_smoke_tests.py b/tests/e2e/tests/test_smoke_tests.py index 8b2271cf3..278fd8b52 100644 --- a/tests/e2e/tests/test_smoke_tests.py +++ b/tests/e2e/tests/test_smoke_tests.py @@ -17,7 +17,9 @@ @pytest.mark.sandboxtests @pytest.mark.parametrize(("filename", "scenario"), param_list, ids=id_list) def test_run_smoke_case(filename, scenario, eligibility_client, get_scenario_params): - nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params(scenario, config_path) + nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params( + scenario, config_path + ) actual_response = eligibility_client.make_request( nhs_number, headers=request_headers, query_params=query_params, strict_ssl=False diff --git a/tests/e2e/tests/test_story_tests.py b/tests/e2e/tests/test_story_tests.py index 541fb7c77..689c51161 100644 --- a/tests/e2e/tests/test_story_tests.py +++ b/tests/e2e/tests/test_story_tests.py @@ -17,7 +17,9 @@ @pytest.mark.functionale2eregression @pytest.mark.parametrize(("filename", "scenario"), param_list, ids=id_list) def test_run_story_test_cases(filename, scenario, eligibility_client, get_scenario_params): - nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params(scenario, config_path) + nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params( + scenario, config_path + ) actual_response = eligibility_client.make_request( nhs_number=nhs_number, headers=request_headers, query_params=query_params, strict_ssl=False diff --git a/tests/e2e/tests/test_vita_integration_tests.py b/tests/e2e/tests/test_vita_integration_tests.py index 5162ee839..63175fbc9 100644 --- a/tests/e2e/tests/test_vita_integration_tests.py +++ b/tests/e2e/tests/test_vita_integration_tests.py @@ -17,7 +17,9 @@ @pytest.mark.functionale2eregression @pytest.mark.parametrize(("filename", "scenario"), param_list, ids=id_list) def test_run_story_test_cases(filename, scenario, eligibility_client, get_scenario_params): - nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params(scenario, config_path) + nhs_number, config_filenames, request_headers, query_params, expected_response_code = get_scenario_params( + scenario, config_path + ) actual_response = eligibility_client.make_request( nhs_number=nhs_number, headers=request_headers, query_params=query_params, strict_ssl=False