diff --git a/README.md b/README.md index cb5cfd99..eaea0df4 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ The following software packages, or their equivalents, are expected to be instal | `AWS_SECRET_ACCESS_KEY` | `dummy_secret` | AWS Secret Access Key | | `DYNAMODB_ENDPOINT` | `http://localhost:4566` | Endpoint for the app to access DynamoDB | | `LOG_LEVEL` | `WARNING` | Logging level. Must be one of `DEBUG`, `INFO`, `WARNING`, `ERROR` or `CRITICAL` as per [Logging Levels](https://docs.python.org/3/library/logging.html#logging-levels) | +| `RULES_BUCKET_NAME` | `test-rules-bucket` | AWS S3 bucket from which to read rules. | ## Usage diff --git a/src/eligibility_signposting_api/model/eligibility.py b/src/eligibility_signposting_api/model/eligibility.py index 98cf8db6..8a558cf4 100644 --- a/src/eligibility_signposting_api/model/eligibility.py +++ b/src/eligibility_signposting_api/model/eligibility.py @@ -1,14 +1,26 @@ +from dataclasses import dataclass from datetime import date +from enum import Enum, auto from typing import NewType -from pydantic import BaseModel - NHSNumber = NewType("NHSNumber", str) DateOfBirth = NewType("DateOfBirth", date) Postcode = NewType("Postcode", str) +ConditionName = NewType("ConditionName", str) + + +class Status(Enum): + not_eligible = auto() + not_actionable = auto() + actionable = auto() + + +@dataclass +class Condition: + condition_name: ConditionName + status: Status -class EligibilityStatus(BaseModel): - eligible: bool - reasons: list[dict] - actions: list[dict] +@dataclass +class EligibilityStatus: + conditions: list[Condition] diff --git a/src/eligibility_signposting_api/services/eligibility_services.py b/src/eligibility_signposting_api/services/eligibility_services.py index 1fad4b83..92a3b796 100644 --- a/src/eligibility_signposting_api/services/eligibility_services.py +++ b/src/eligibility_signposting_api/services/eligibility_services.py @@ -5,7 +5,7 @@ from dateutil.relativedelta import relativedelta from wireup import service -from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber +from eligibility_signposting_api.model.eligibility import Condition, ConditionName, EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.model.rules import CampaignConfig, IterationRule, RuleAttributeLevel, RuleOperator from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError, RulesRepo @@ -49,17 +49,21 @@ def get_eligibility_status(self, nhs_number: NHSNumber | None = None) -> Eligibi def evaluate_eligibility( campaign_configs: list[CampaignConfig], person_data: list[dict[str, Any]] ) -> EligibilityStatus: - eligible, reasons, actions = True, [], [] - for iteration_rule in [ - iteration_rule - for campaign_config in campaign_configs - for iteration in campaign_config.iterations - for iteration_rule in iteration.iteration_rules - ]: - if EligibilityService.evaluate_exclusion(iteration_rule, person_data): - eligible = False + conditions: dict[ConditionName, Condition] = {} + for campaign_config in campaign_configs: + condition_name = ConditionName(campaign_config.target) + condition = conditions.setdefault( + condition_name, Condition(condition_name=condition_name, status=Status.actionable) + ) + for iteration_rule in [ + iteration_rule + for iteration in campaign_config.iterations + for iteration_rule in iteration.iteration_rules + ]: + if EligibilityService.evaluate_exclusion(iteration_rule, person_data): + condition.status = Status.not_actionable - return EligibilityStatus(eligible=eligible, reasons=reasons, actions=actions) + return EligibilityStatus(conditions=list(conditions.values())) @staticmethod def evaluate_exclusion(iteration_rule: IterationRule, person_data: list[dict[str, Any]]) -> bool: diff --git a/src/eligibility_signposting_api/views/eligibility.py b/src/eligibility_signposting_api/views/eligibility.py index e7d2bbbc..31601d19 100644 --- a/src/eligibility_signposting_api/views/eligibility.py +++ b/src/eligibility_signposting_api/views/eligibility.py @@ -1,18 +1,30 @@ import logging +import uuid +from datetime import UTC, datetime from http import HTTPStatus -from fhir.resources.R4B.bundle import Bundle, BundleEntry -from fhir.resources.R4B.guidanceresponse import GuidanceResponse -from fhir.resources.R4B.location import Location from fhir.resources.R4B.operationoutcome import OperationOutcome, OperationOutcomeIssue -from fhir.resources.R4B.requestgroup import RequestGroup -from fhir.resources.R4B.task import Task from flask import Blueprint, make_response from flask.typing import ResponseReturnValue from wireup import Injected -from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber +from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber, Status from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from eligibility_signposting_api.views.response_models import ( + ConditionName, + EligibilityResponse, + LastUpdated, + Meta, + ProcessedSuggestion, + StatusText, +) +from eligibility_signposting_api.views.response_models import Status as ResponseStatus + +STATUS_MAPPING = { + Status.actionable: ResponseStatus.actionable, + Status.not_actionable: ResponseStatus.not_actionable, + Status.not_eligible: ResponseStatus.not_eligible, +} logger = logging.getLogger(__name__) @@ -38,20 +50,23 @@ def check_eligibility(nhs_number: NHSNumber, eligibility_service: Injected[Eligi ) return make_response(problem.model_dump(by_alias=True), HTTPStatus.NOT_FOUND) else: - bundle = build_bundle(eligibility_status) - return make_response(bundle.model_dump(by_alias=True), HTTPStatus.OK) - - -def build_bundle(_eligibility_status: EligibilityStatus) -> Bundle: - return Bundle( # pyright: ignore[reportCallIssue] - id="dummy-bundle", - type="collection", - entry=[ - BundleEntry( # pyright: ignore[reportCallIssue] - resource=GuidanceResponse(id="dummy-guidance-response", status="requested", moduleCodeableConcept={}) # pyright: ignore[reportCallIssue] - ), - BundleEntry(resource=RequestGroup(id="dummy-request-group", intent="proposal", status="requested")), # pyright: ignore[reportCallIssue] - BundleEntry(resource=Task(id="dummy-task", intent="proposal", status="requested")), # pyright: ignore[reportCallIssue] - BundleEntry(resource=Location(id="dummy-location")), # pyright: ignore[reportCallIssue] + eligibility_response = build_eligibility_response(eligibility_status) + return make_response(eligibility_response.model_dump(by_alias=True), HTTPStatus.OK) + + +def build_eligibility_response(eligibility_status: EligibilityStatus) -> EligibilityResponse: + return EligibilityResponse( # pyright: ignore[reportCallIssue] + response_id=uuid.uuid4(), # pyright: ignore[reportCallIssue] + meta=Meta(last_updated=LastUpdated(datetime.now(tz=UTC))), # pyright: ignore[reportCallIssue] + processed_suggestions=[ # pyright: ignore[reportCallIssue] + ProcessedSuggestion( # pyright: ignore[reportCallIssue] + condition_name=ConditionName(condition.condition_name), # pyright: ignore[reportCallIssue] + status=STATUS_MAPPING[condition.status], + status_text=StatusText(f"{condition.status}"), # pyright: ignore[reportCallIssue] + eligibility_cohorts=[], # pyright: ignore[reportCallIssue] + suitability_rules=[], # pyright: ignore[reportCallIssue] + actions=[], + ) + for condition in eligibility_status.conditions ], ) diff --git a/src/eligibility_signposting_api/views/response_models.py b/src/eligibility_signposting_api/views/response_models.py new file mode 100644 index 00000000..0e382d54 --- /dev/null +++ b/src/eligibility_signposting_api/views/response_models.py @@ -0,0 +1,82 @@ +from datetime import datetime +from enum import Enum +from typing import NewType + +from pydantic import UUID4, BaseModel, Field, HttpUrl, SerializationInfo, field_serializer + +LastUpdated = NewType("LastUpdated", datetime) +ConditionName = NewType("ConditionName", str) +StatusText = NewType("StatusText", str) +ActionType = NewType("ActionType", str) +ActionCode = NewType("ActionCode", str) +Description = NewType("Description", str) +RuleCode = NewType("RuleCode", str) +RuleText = NewType("RuleText", str) +CohortCode = NewType("CohortCode", str) +CohortText = NewType("CohortText", str) + + +class Status(str, Enum): + not_eligible = "NotEligible" + not_actionable = "NotActionable" + actionable = "Actionable" + + +class RuleType(str, Enum): + filter = "F" + suppression = "S" + redirect = "R" + + +class EligibilityCohort(BaseModel): + cohort_code: CohortCode = Field(..., alias="cohortCode") + cohort_text: CohortText = Field(..., alias="cohortText") + cohort_status: Status = Field(..., alias="cohortStatus") + + model_config = {"populate_by_name": True} + + +class SuitabilityRule(BaseModel): + type: RuleType = Field(..., alias="ruleType") + rule_code: RuleCode = Field(..., alias="ruleCode") + rule_text: RuleText = Field(..., alias="ruleText") + + model_config = {"populate_by_name": True} + + +class Action(BaseModel): + action_type: ActionType = Field(..., alias="actionType") + action_code: ActionCode = Field(..., alias="actionCode") + description: Description + url_link: HttpUrl = Field(..., alias="urlLink") + + model_config = {"populate_by_name": True} + + +class ProcessedSuggestion(BaseModel): + condition_name: ConditionName = Field(..., alias="condition") + status: Status + status_text: StatusText = Field(..., alias="statusText") + eligibility_cohorts: list[EligibilityCohort] = Field(..., alias="eligibilityCohorts") + suitability_rules: list[SuitabilityRule] = Field(..., alias="suitabilityRules") + actions: list[Action] + + model_config = {"populate_by_name": True} + + +class Meta(BaseModel): + last_updated: LastUpdated = Field(..., alias="lastUpdated") + + model_config = {"populate_by_name": True} + + @field_serializer("last_updated") + def serialize_last_updated(self, last_updated: LastUpdated, _info: SerializationInfo) -> str: + return last_updated.isoformat() + + +class EligibilityResponse(BaseModel): + response_id: UUID4 = Field(..., alias="responseId") + meta: Meta + processed_suggestions: list[ProcessedSuggestion] = Field(..., alias="processedSuggestions") + + model_config = {"populate_by_name": True} diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index fc17f8d2..d2be2686 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -3,7 +3,7 @@ from brunns.matchers.data import json_matching as is_json_that from brunns.matchers.werkzeug import is_werkzeug_response as is_response from flask.testing import FlaskClient -from hamcrest import assert_that, has_entries +from hamcrest import assert_that, has_entries, has_key from eligibility_signposting_api.model.eligibility import DateOfBirth, NHSNumber, Postcode from eligibility_signposting_api.model.rules import CampaignConfig @@ -23,7 +23,7 @@ def test_nhs_number_given( # Then assert_that( response, - is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_entries(resourceType="Bundle"))), + is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_key("processedSuggestions"))), ) diff --git a/tests/integration/lambda/test_app_running_as_lambda.py b/tests/integration/lambda/test_app_running_as_lambda.py index c904b384..61a6ac08 100644 --- a/tests/integration/lambda/test_app_running_as_lambda.py +++ b/tests/integration/lambda/test_app_running_as_lambda.py @@ -10,7 +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 hamcrest import assert_that, contains_exactly, contains_string, has_entries, has_item +from hamcrest import assert_that, contains_exactly, contains_string, has_entries, has_item, has_key from yarl import URL from eligibility_signposting_api.model.eligibility import DateOfBirth, NHSNumber, Postcode @@ -62,7 +62,7 @@ def test_install_and_call_lambda_flask( logger.info(response_payload) assert_that( response_payload, - has_entries(statusCode=HTTPStatus.OK, body=is_json_that(has_entries(resourceType="Bundle"))), + has_entries(statusCode=HTTPStatus.OK, body=is_json_that(has_key("processedSuggestions"))), ) assert_that(log_output, contains_string("person_data")) @@ -83,7 +83,7 @@ def test_install_and_call_flask_lambda_over_http( # Then assert_that( response, - is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_entries(resourceType="Bundle"))), + is_response().with_status_code(HTTPStatus.OK).and_body(is_json_that(has_key("processedSuggestions"))), ) diff --git a/tests/unit/services/test_eligibility_services.py b/tests/unit/services/test_eligibility_services.py index 3fdc0d74..505a51a1 100644 --- a/tests/unit/services/test_eligibility_services.py +++ b/tests/unit/services/test_eligibility_services.py @@ -2,17 +2,16 @@ from unittest.mock import MagicMock import pytest -from brunns.matchers.object import false, true from dateutil.relativedelta import relativedelta from faker import Faker -from hamcrest import assert_that +from hamcrest import assert_that, empty, has_item -from eligibility_signposting_api.model.eligibility import DateOfBirth, NHSNumber, Postcode +from eligibility_signposting_api.model.eligibility import ConditionName, DateOfBirth, NHSNumber, Postcode, Status from eligibility_signposting_api.model.rules import RuleAttributeLevel, RuleOperator, RuleType from eligibility_signposting_api.repos import EligibilityRepo, NotFoundError, RulesRepo from eligibility_signposting_api.services import EligibilityService, UnknownPersonError from tests.utils.builders import CampaignConfigFactory, IterationFactory, IterationRuleFactory -from tests.utils.matchers.eligibility import is_eligibility_status +from tests.utils.matchers.eligibility import is_condition, is_eligibility_status @pytest.fixture(scope="session") @@ -31,7 +30,7 @@ def test_eligibility_service_returns_from_repo(): actual = ps.get_eligibility_status(NHSNumber("1234567890")) # Then - assert_that(actual, is_eligibility_status().with_eligible(true())) + assert_that(actual, is_eligibility_status().with_conditions(empty())) def test_eligibility_service_for_nonexistent_nhs_number(): @@ -99,7 +98,12 @@ def test_simple_rule_eligible(faker: Faker): actual = ps.get_eligibility_status(NHSNumber(nhs_number)) # Then - assert_that(actual, is_eligibility_status().with_eligible(true())) + assert_that( + actual, + is_eligibility_status().with_conditions( + has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.actionable)) + ), + ) def test_simple_rule_ineligible(faker: Faker): @@ -155,7 +159,12 @@ def test_simple_rule_ineligible(faker: Faker): actual = ps.get_eligibility_status(NHSNumber(nhs_number)) # Then - assert_that(actual, is_eligibility_status().with_eligible(false())) + assert_that( + actual, + is_eligibility_status().with_conditions( + has_item(is_condition().with_condition_name(ConditionName("RSV")).and_status(Status.not_actionable)) + ), + ) def test_equals_rule(): diff --git a/tests/unit/views/test_eligibility.py b/tests/unit/views/test_eligibility.py index 10561790..614d5a75 100644 --- a/tests/unit/views/test_eligibility.py +++ b/tests/unit/views/test_eligibility.py @@ -5,11 +5,12 @@ from brunns.matchers.werkzeug import is_werkzeug_response as is_response from flask import Flask from flask.testing import FlaskClient -from hamcrest import assert_that, contains_exactly, has_entries +from hamcrest import assert_that, contains_exactly, has_entries, has_key from wireup.integration.flask import get_app_container from eligibility_signposting_api.model.eligibility import EligibilityStatus, NHSNumber from eligibility_signposting_api.services import EligibilityService, UnknownPersonError +from tests.utils.builders import EligibilityStatusFactory logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ def __init__(self): pass def get_eligibility_status(self, _: NHSNumber | None = None) -> EligibilityStatus: - return EligibilityStatus(eligible=True, reasons=[], actions=[]) + return EligibilityStatusFactory.build() class FakeUnknownPersonEligibilityService(EligibilityService): @@ -47,7 +48,7 @@ def test_nhs_number_given(app: Flask, client: FlaskClient): # Then assert_that( response, - is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_entries(resourceType="Bundle"))), + is_response().with_status_code(HTTPStatus.OK).and_text(is_json_that(has_key("processedSuggestions"))), ) diff --git a/tests/utils/builders.py b/tests/utils/builders.py index a9bcfcef..73ee770f 100644 --- a/tests/utils/builders.py +++ b/tests/utils/builders.py @@ -2,9 +2,18 @@ import string from polyfactory import Use +from polyfactory.factories import DataclassFactory from polyfactory.factories.pydantic_factory import ModelFactory +from eligibility_signposting_api.model.eligibility import Condition, EligibilityStatus from eligibility_signposting_api.model.rules import CampaignConfig, Iteration, IterationCohort, IterationRule +from eligibility_signposting_api.views.response_models import ( + Action, + EligibilityCohort, + EligibilityResponse, + ProcessedSuggestion, + SuitabilityRule, +) class IterationCohortFactory(ModelFactory[IterationCohort]): ... @@ -22,5 +31,31 @@ class CampaignConfigFactory(ModelFactory[CampaignConfig]): iterations = Use(IterationFactory.batch, size=2) +class EligibilityCohortFactory(ModelFactory[EligibilityCohort]): ... + + +class SuitabilityRuleFactory(ModelFactory[SuitabilityRule]): ... + + +class ActionFactory(ModelFactory[Action]): ... + + +class ProcessedSuggestionFactory(ModelFactory[ProcessedSuggestion]): + eligibility_cohorts = Use(EligibilityCohortFactory.batch, size=2) + suitability_rules = Use(SuitabilityRuleFactory.batch, size=2) + actions = Use(ActionFactory.batch, size=2) + + +class EligibilityResponseFactory(ModelFactory[EligibilityResponse]): + processed_suggestions = Use(ProcessedSuggestionFactory.batch, size=2) + + +class ConditionFactory(DataclassFactory[Condition]): ... + + +class EligibilityStatusFactory(DataclassFactory[EligibilityStatus]): + condition = Use(ConditionFactory.batch, size=2) + + def random_str(length: int) -> str: return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length)) # noqa: S311 diff --git a/tests/utils/matchers/eligibility.py b/tests/utils/matchers/eligibility.py index ea18527d..117992a6 100644 --- a/tests/utils/matchers/eligibility.py +++ b/tests/utils/matchers/eligibility.py @@ -1,6 +1,6 @@ from hamcrest.core.matcher import Matcher -from eligibility_signposting_api.model.eligibility import EligibilityStatus +from eligibility_signposting_api.model.eligibility import Condition, EligibilityStatus from .meta import BaseAutoMatcher @@ -8,5 +8,12 @@ class EligibilityStatusMatcher(BaseAutoMatcher[EligibilityStatus]): ... +class ConditionMatcher(BaseAutoMatcher[Condition]): ... + + def is_eligibility_status() -> Matcher[EligibilityStatus]: return EligibilityStatusMatcher() + + +def is_condition() -> Matcher[Condition]: + return ConditionMatcher()