Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 18 additions & 6 deletions src/eligibility_signposting_api/model/eligibility.py
Original file line number Diff line number Diff line change
@@ -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]
26 changes: 15 additions & 11 deletions src/eligibility_signposting_api/services/eligibility_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
57 changes: 36 additions & 21 deletions src/eligibility_signposting_api/views/eligibility.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -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
],
)
82 changes: 82 additions & 0 deletions src/eligibility_signposting_api/views/response_models.py
Original file line number Diff line number Diff line change
@@ -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}
4 changes: 2 additions & 2 deletions tests/integration/in_process/test_eligibility_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))),
)


Expand Down
6 changes: 3 additions & 3 deletions tests/integration/lambda/test_app_running_as_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand All @@ -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"))),
)


Expand Down
23 changes: 16 additions & 7 deletions tests/unit/services/test_eligibility_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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():
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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():
Expand Down
7 changes: 4 additions & 3 deletions tests/unit/views/test_eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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):
Expand Down Expand Up @@ -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"))),
)


Expand Down
Loading