diff --git a/src/eligibility_signposting_api/services/processors/rule_processor.py b/src/eligibility_signposting_api/services/processors/rule_processor.py index fa067a88..ab827590 100644 --- a/src/eligibility_signposting_api/services/processors/rule_processor.py +++ b/src/eligibility_signposting_api/services/processors/rule_processor.py @@ -39,9 +39,13 @@ class RuleProcessor: def is_base_eligible(self, person: Person, cohort: IterationCohort) -> bool: if cohort.is_virtual_cohort: - for row in person.data: - if row.get("ATTRIBUTE_TYPE", "") == "COHORTS": - row["COHORT_MEMBERSHIPS"].append({"COHORT_LABEL": cohort.cohort_label}) + cohorts_data = next((row for row in person.data if row.get("ATTRIBUTE_TYPE") == "COHORTS"), None) + + if cohorts_data is None: + cohorts_data = {"ATTRIBUTE_TYPE": "COHORTS", "COHORT_MEMBERSHIPS": []} + person.data.append(cohorts_data) + + cohorts_data.setdefault("COHORT_MEMBERSHIPS", []).append({"COHORT_LABEL": cohort.cohort_label}) person_cohorts = self.person_data_reader.get_person_cohorts(person) diff --git a/tests/fixtures/builders/repos/person.py b/tests/fixtures/builders/repos/person.py index eb2b96d6..30dc18e3 100644 --- a/tests/fixtures/builders/repos/person.py +++ b/tests/fixtures/builders/repos/person.py @@ -18,7 +18,7 @@ def person_rows_builder( # noqa:PLR0913 gender: Gender | None = ..., postcode: str | None = ..., cohorts: Sequence[str] | None = ..., - vaccines: Sequence[tuple[str, date]] | None = ..., + vaccines: dict[str, dict[str, str | None]] | None = ..., icb: str | None = ..., gp_practice: str | None = ..., pcn: str | None = ..., @@ -36,8 +36,6 @@ def person_rows_builder( # noqa:PLR0913 date_of_birth = date_of_birth if date_of_birth is not ... else faker.date_of_birth(minimum_age=18, maximum_age=99) gender = gender if gender is not ... else choice(get_args(Gender)) postcode = postcode if postcode is not ... else faker.postcode() - cohorts = cohorts if cohorts is not ... else ["cohort-a", "cohort-b"] - vaccines = vaccines if vaccines is not ... else [("RSV", faker.past_date("-5y")), ("COVID", faker.past_date("-5y"))] icb = icb if icb is not ... else faker.icb() gp_practice = gp_practice if gp_practice is not ... else faker.gp_practice() pcn = pcn if pcn is not ... else faker.pcn() @@ -64,26 +62,28 @@ def person_rows_builder( # noqa:PLR0913 "MSOA": msoa, "LSOA": lsoa, }, - { - "NHS_NUMBER": key, - "ATTRIBUTE_TYPE": "COHORTS", - "COHORT_MEMBERSHIPS": [ - {"COHORT_LABEL": cohort, "DATE_JOINED": faker.past_date().strftime("%Y%m%d")} for cohort in cohorts - ], - }, ] - rows.extend( - { - "NHS_NUMBER": key, - "ATTRIBUTE_TYPE": vaccine, - "LAST_SUCCESSFUL_DATE": ( - last_successful_date.strftime("%Y%m%d") if last_successful_date else last_successful_date - ), - "OPTOUT": choice(["Y", "N"]), - "LAST_INVITE_DATE": faker.past_date("-5y").strftime("%Y%m%d"), - } - for vaccine, last_successful_date in vaccines - ) + + if cohorts is not ... and cohorts: + rows.append( + { + "NHS_NUMBER": key, + "ATTRIBUTE_TYPE": "COHORTS", + "COHORT_MEMBERSHIPS": [ + {"COHORT_LABEL": cohort, "DATE_JOINED": faker.past_date().strftime("%Y%m%d")} for cohort in cohorts + ], + } + ) + + if vaccines is not ... and vaccines: + rows.extend( + { + "NHS_NUMBER": key, + "ATTRIBUTE_TYPE": vaccine_name, + **details, + } + for vaccine_name, details in vaccines.items() + ) shuffle(rows) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index e154f6ac..f404f556 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -409,7 +409,7 @@ def person_with_all_data(person_table: Any, faker: Faker) -> Generator[eligibili gender="0", postcode="SW18", cohorts=["cohort_label1", "cohort_label2"], - vaccines=[("RSV", None)], + vaccines={"RSV": {"LAST_SUCCESSFUL_DATE": None}}, icb="QE1", gp_practice="C81002", pcn="U78207", diff --git a/tests/integration/repo/test_person_repo.py b/tests/integration/repo/test_person_repo.py index 40d33f4b..f2d085cf 100644 --- a/tests/integration/repo/test_person_repo.py +++ b/tests/integration/repo/test_person_repo.py @@ -22,8 +22,6 @@ def test_person_found(person_table: Any, persisted_person: NHSNumber): contains_inanyorder( has_entries({"NHS_NUMBER": persisted_person, "ATTRIBUTE_TYPE": "PERSON"}), has_entries({"NHS_NUMBER": persisted_person, "ATTRIBUTE_TYPE": "COHORTS"}), - has_entries({"NHS_NUMBER": persisted_person, "ATTRIBUTE_TYPE": "COVID"}), - has_entries({"NHS_NUMBER": persisted_person, "ATTRIBUTE_TYPE": "RSV"}), ), ) diff --git a/tests/unit/services/calculators/test_eligibility_calculator.py b/tests/unit/services/calculators/test_eligibility_calculator.py index 255d8665..be3bef10 100644 --- a/tests/unit/services/calculators/test_eligibility_calculator.py +++ b/tests/unit/services/calculators/test_eligibility_calculator.py @@ -329,16 +329,7 @@ def test_status_on_target_based_on_last_successful_date( nhs_number = NHSNumber(faker.nhs_number()) target_rows = person_rows_builder( - nhs_number, - cohorts=["cohort1"], - vaccines=[ - ( - vaccine, - datetime.datetime.strptime(last_successful_date, "%Y%m%d").replace(tzinfo=datetime.UTC) - if last_successful_date - else None, - ) - ], + nhs_number, cohorts=["cohort1"], vaccines={vaccine: {"LAST_SUCCESSFUL_DATE": last_successful_date}} ) campaign_configs = [ @@ -728,7 +719,7 @@ def test_cohort_group_descriptions_are_selected_based_on_priority_when_cohorts_h @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())) + person_rows = person_rows_builder(NHSNumber(faker.nhs_number()), cohorts=[]) campaign_configs = [ rule_builder.CampaignConfigFactory.build( target="RSV", @@ -757,7 +748,7 @@ def test_no_active_iteration_returns_empty_conditions_with_single_active_campaig @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())) + person_rows = person_rows_builder(NHSNumber(faker.nhs_number()), cohorts=[]) campaign_configs = [ rule_builder.CampaignConfigFactory.build( target="RSV", @@ -801,7 +792,7 @@ def test_returns_no_condition_data_for_campaign_without_active_iteration(faker: @freeze_time("2025-04-25") def test_no_active_campaign(faker: Faker): # Given - person_rows = person_rows_builder(NHSNumber(faker.nhs_number())) + person_rows = person_rows_builder(NHSNumber(faker.nhs_number()), cohorts=[]) 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) @@ -824,7 +815,7 @@ def test_eligibility_status_replaces_tokens_with_attribute_data(faker: Faker): nhs_number, date_of_birth=date_of_birth, cohorts=["cohort_1", "cohort_2", "cohort_3"], - vaccines=[("RSV", datetime.date(2024, 1, 3))], + vaccines={"RSV": {"LAST_SUCCESSFUL_DATE": datetime.date(2024, 1, 3).strftime("%Y%m%d")}}, icb="QE1", gp_practice=None, ) @@ -1161,6 +1152,109 @@ def test_multiple_virtual_cohorts(faker: Faker): ) +@freeze_time("2025-10-02") +def test_virtual_cohorts_when_person_has_no_existing_cohorts(faker: Faker): + # Given + nhs_number = NHSNumber(faker.nhs_number()) + date_of_birth = DateOfBirth(datetime.date(1980, 10, 2)) + person_rows = person_rows_builder( + nhs_number, + date_of_birth=date_of_birth, + cohorts=[], + vaccines={ + "RSV": { + "LAST_SUCCESSFUL_DATE": datetime.date(2025, 9, 25).strftime("%Y%m%d"), + "BOOKED_APPOINTMENT_DATE": datetime.date(2025, 10, 9).strftime("%Y%m%d"), + }, + }, + ) + campaign_configs = [ + rule_builder.CampaignConfigFactory.build( + target="RSV", + iterations=[ + rule_builder.IterationFactory.build( + iteration_cohorts=[ + rule_builder.IterationCohortFactory.build( + cohort_label="rsv_75to79", + cohort_group="rsv_age", + positive_description="In rsv_75to79", + negative_description="Out rsv_75to79", + priority=0, + ), + rule_builder.IterationCohortFactory.build( + cohort_label="rsv_80_since_02_Sept_2024", + cohort_group="rsv_age_catchup", + positive_description="In rsv_80_since_02_Sept_2024", + negative_description="Out rsv_80_since_02_Sept_2024", + priority=10, + ), + rule_builder.IterationCohortFactory.build( + cohort_label="elid_all_people", + cohort_group="magic_cohort", + positive_description="In elid_all_people", + negative_description="Out elid_all_people", + priority=20, + virtual="Y", + ), + ], + iteration_rules=[ + rule_builder.PersonAgeSuppressionRuleFactory.build( + attribute_level=RuleAttributeLevel.TARGET, + attribute_name="LAST_SUCCESSFUL_DATE", + attribute_target="RSV", + cohort_label="elid_all_people", + comparator="-25[[NVL:18000101]]", + description="Remove anyone NOT already vaccinated within the last 25 years", + name="Remove from magic cohort unless already vaccinated or have future booking", + operator=RuleOperator.year_lte, + priority=100, + type=RuleType.filter, + ), + rule_builder.PersonAgeSuppressionRuleFactory.build( + attribute_level=RuleAttributeLevel.TARGET, + attribute_name="BOOKED_APPOINTMENT_DATE", + attribute_target="RSV", + cohort_label="elid_all_people", + comparator="0[[NVL:18000101]]", + description="Remove anyone without a future booking from magic cohort", + name="Remove from magic cohort unless already vaccinated or have future booking", + operator=RuleOperator.day_lt, + priority=110, + type=RuleType.filter, + ), + rule_builder.PersonAgeSuppressionRuleFactory.build( + attribute_level=RuleAttributeLevel.TARGET, + attribute_name="LAST_SUCCESSFUL_DATE", + attribute_target="RSV", + comparator="-25[[NVL:18000101]]", + description="## You've had your RSV vaccination\n\nWe believe you had your vaccination.", + name="Already Vaccinated", + operator=RuleOperator.year_gte, + priority=200, + rule_stop=True, + type=RuleType.suppression, + ), + ], + ) + ], + ) + ] + + calculator = EligibilityCalculator(person_rows, campaign_configs) + + # When + 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(Status.not_actionable), + ) + ), + ) + + def test_regardless_of_final_status_audit_all_types_of_cohort_status_rules(faker: Faker): # Given nhs_number = NHSNumber(faker.nhs_number()) @@ -1277,7 +1371,10 @@ def test_eligibility_status_with_invalid_tokens_raises_attribute_error(faker: Fa date_of_birth = DateOfBirth(datetime.date(2025, 5, 10)) person_rows = person_rows_builder( - nhs_number, date_of_birth=date_of_birth, cohorts=["cohort_1"], vaccines=[("RSV", datetime.date(2024, 1, 3))] + nhs_number, + date_of_birth=date_of_birth, + cohorts=["cohort_1"], + vaccines={"RSV": {"LAST_SUCCESSFUL_DATE": datetime.date(2024, 1, 3).strftime("%Y%m%d")}}, ) target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.LAST_SUCCESSFUL_DATE:INVALID_DATE_FORMAT(%d %B %Y)]]" # noqa: S105 @@ -1309,7 +1406,10 @@ def test_eligibility_status_with_invalid_person_attribute_name_raises_value_erro date_of_birth = DateOfBirth(datetime.date(2025, 5, 10)) person_rows = person_rows_builder( - nhs_number, date_of_birth=date_of_birth, cohorts=["cohort_1"], vaccines=[("RSV", datetime.date(2024, 1, 3))] + nhs_number, + date_of_birth=date_of_birth, + cohorts=["cohort_1"], + vaccines={"RSV": {"LAST_SUCCESSFUL_DATE": datetime.date(2024, 1, 3).strftime("%Y%m%d")}}, ) target_attribute_token = "LAST_SUCCESSFUL_DATE: [[TARGET.RSV.ICECREAM]]" # noqa: S105