diff --git a/src/eligibility_signposting_api/model/campaign_config.py b/src/eligibility_signposting_api/model/campaign_config.py index c2bdd6a8f..ce71cf188 100644 --- a/src/eligibility_signposting_api/model/campaign_config.py +++ b/src/eligibility_signposting_api/model/campaign_config.py @@ -4,7 +4,7 @@ import re import typing from collections import Counter -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, time from enum import StrEnum from functools import cached_property from operator import attrgetter @@ -33,6 +33,7 @@ IterationVersion = NewType("IterationVersion", int) IterationID = NewType("IterationID", str) IterationDate = NewType("IterationDate", date) +IterationTime = NewType("IterationTime", time) RuleName = NewType("RuleName", str) RuleDescription = NewType("RuleDescription", str) RulePriority = NewType("RulePriority", int) @@ -50,6 +51,38 @@ RuleText = NewType("RuleText", str) +class DateUtil: + @staticmethod + def parse_date_yyyymmdd(v: str | date) -> date: + if isinstance(v, date): + return v + v_str = str(v) + if not re.fullmatch(r"\d{8}", v_str): + msg = f"Invalid format: {v_str}. Must be YYYYMMDD." + raise ValueError(msg) + try: + return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007 + except ValueError as err: + msg = f"Invalid date value: {v_str}." + raise ValueError(msg) from err + + @staticmethod + def parse_time_hhmmss(v: str | time | None) -> time | None: + if not v: + return None + if isinstance(v, time): + return v + v_str = str(v).strip() + if re.fullmatch(r"^\d{2}:\d{2}:\d{2}$", v_str): + try: + return datetime.strptime(v_str, "%H:%M:%S").time() # noqa: DTZ007 + except ValueError as err: + msg = f"Invalid time value: {v_str}." + raise ValueError(msg) from err + msg = f"Invalid format: {v_str}. Must be HH:MM:SS." + raise ValueError(msg) + + class RuleType(StrEnum): filter = "F" suppression = "S" @@ -262,6 +295,7 @@ class Iteration(BaseModel): version: IterationVersion = Field(..., alias="Version") name: IterationName = Field(..., alias="Name") iteration_date: IterationDate = Field(..., alias="IterationDate") + iteration_time: IterationTime | None = Field(default=None, alias="IterationTime") iteration_number: int | None = Field(None, alias="IterationNumber") approval_minimum: int | None = Field(None, alias="ApprovalMinimum") approval_maximum: int | None = Field(None, alias="ApprovalMaximum") @@ -277,35 +311,49 @@ class Iteration(BaseModel): model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"} - def __init__(self, **data: dict[str, typing.Any]) -> None: - super().__init__(**data) - # Ensure each rule knows its parent iteration - for rule in self.iteration_rules: - rule.set_parent(self) + @model_validator(mode="after") + def _link_parent_to_iteration_rules(self) -> typing.Self: + for iteration in self.iteration_rules: + iteration.set_parent(self) + return self @field_validator("iteration_date", mode="before") @classmethod def parse_dates(cls, v: str | date) -> date: - if isinstance(v, date): - return v - - v_str = str(v) + return DateUtil.parse_date_yyyymmdd(v) - if not re.fullmatch(r"\d{8}", v_str): - msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits." - raise ValueError(msg) - - try: - return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007 - except ValueError as err: - msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format." - raise ValueError(msg) from err + @field_validator("iteration_time", mode="before") + @classmethod + def parse_times(cls, v: str | time) -> time | None: + return DateUtil.parse_time_hhmmss(v) @field_serializer("iteration_date", when_used="always") @staticmethod def serialize_dates(v: date, _info: SerializationInfo) -> str: return v.strftime("%Y%m%d") + @field_serializer("iteration_time", when_used="always") + @staticmethod + def serialize_time(v: time, _info: SerializationInfo) -> str | None: + return v.strftime("%H:%M:%S") if v else None + + _parent: CampaignConfig | None = PrivateAttr(default=None) + + def set_parent(self, parent: CampaignConfig) -> None: + self._parent = parent + + @cached_property + def iteration_datetime(self) -> datetime: + if self.iteration_time: + iteration_time = self.iteration_time + elif self._parent: + iteration_time = self._parent.iteration_time + else: + msg = f"No iteration_time and no parent linked for iteration {self.id}" + raise ValueError(msg) + + return datetime.combine(self.iteration_date, iteration_time).replace(tzinfo=UTC) + def __str__(self) -> str: return json.dumps(self.model_dump(by_alias=True), indent=2) @@ -321,7 +369,7 @@ class CampaignConfig(BaseModel): 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") + iteration_time: IterationTime = Field(default=IterationTime(time(0, 0, 0)), alias="IterationTime") default_comms_routing: str | None = Field(None, alias="DefaultCommsRouting") start_date: StartDate = Field(..., alias="StartDate") end_date: EndDate = Field(..., alias="EndDate") @@ -331,29 +379,33 @@ class CampaignConfig(BaseModel): model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"} + @model_validator(mode="after") + def _link_parent_to_iterations(self) -> typing.Self: + for iteration in self.iterations: + iteration.set_parent(self) + + return self + @field_validator("start_date", "end_date", mode="before") @classmethod def parse_dates(cls, v: str | date) -> date: - if isinstance(v, date): - return v - - v_str = str(v) - - if not re.fullmatch(r"\d{8}", v_str): - msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits." - raise ValueError(msg) + return DateUtil.parse_date_yyyymmdd(v) - try: - return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007 - except ValueError as err: - msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format." - raise ValueError(msg) from err + @field_validator("iteration_time", mode="before") + @classmethod + def parse_times(cls, v: str | time) -> time | None: + return DateUtil.parse_time_hhmmss(v) @field_serializer("start_date", "end_date", when_used="always") @staticmethod def serialize_dates(v: date, _info: SerializationInfo) -> str: return v.strftime("%Y%m%d") + @field_serializer("iteration_time", when_used="always") + @staticmethod + def serialize_time(v: time, _info: SerializationInfo) -> str | None: + return v.strftime("%H:%M:%S") if v else None + @model_validator(mode="after") def check_start_and_end_dates_sensible(self) -> typing.Self: if self.start_date > self.end_date: diff --git a/src/eligibility_signposting_api/services/processors/campaign_evaluator.py b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py index 9d1a9aad1..248c1c967 100644 --- a/src/eligibility_signposting_api/services/processors/campaign_evaluator.py +++ b/src/eligibility_signposting_api/services/processors/campaign_evaluator.py @@ -31,7 +31,7 @@ def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConf for cc in active_campaigns: try: - valid_items.append((cc.current_iteration.iteration_date, cc)) + valid_items.append((cc.current_iteration.iteration_datetime, cc)) except StopIteration: logger.info( "Skipping campaign ID %s as no active iteration was found.", @@ -41,13 +41,15 @@ def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConf if not valid_items: latest_campaign = None else: - max_date = max(item[0] for item in valid_items) - cc_with_max_iteration_date: list[CampaignConfig] = [item[1] for item in valid_items if item[0] == max_date] + max_date_time = max(item[0] for item in valid_items) + cc_with_max_iteration_date: list[CampaignConfig] = [ + item[1] for item in valid_items if item[0] == max_date_time + ] if len(cc_with_max_iteration_date) > 1: err_msg = ( f"Ambiguous result: '{len(cc_with_max_iteration_date)}' active iterations " f"for target {cc_with_max_iteration_date[0].target} " - f"found for date '{max_date}' " + f"found for datetime '{max_date_time}' " f"across campaign(s) {[cc.id for cc in cc_with_max_iteration_date]}" ) raise ValueError(err_msg) diff --git a/src/rules_validation_api/app.py b/src/rules_validation_api/app.py index 43aa6530f..0d990ff3c 100644 --- a/src/rules_validation_api/app.py +++ b/src/rules_validation_api/app.py @@ -3,6 +3,8 @@ import logging import sys from collections import defaultdict +from datetime import UTC, datetime +from operator import attrgetter from pathlib import Path from pydantic import ValidationError @@ -74,18 +76,60 @@ def main() -> None: # pragma: no cover def display_current_iteration(result: RulesValidation) -> None: - no_of_iterations = 0 - try: - no_of_iterations = len(result.campaign_config.iterations) - current = result.campaign_config.current_iteration - except StopIteration: - current = None - if current is None: - sys.stdout.write(f"{YELLOW}No active iteration could be determined{RESET}\n") - sys.stdout.write(f"{YELLOW}Total iterations configured: {RESET}{GREEN}{no_of_iterations}{RESET}\n") + config = result.campaign_config + iterations = config.iterations + is_campaign_live = config.campaign_live + today = datetime.now(tz=UTC).date() + + no_of_iterations = len(iterations) + is_campaign_expired = config.end_date < today + + # ---- Current Iteration ---- + if is_campaign_live: + sys.stdout.write(f"{YELLOW}Campaign is {RESET}{GREEN}LIVE{RESET}\n") + try: + current = config.current_iteration + if current: + sys.stdout.write( + f"{YELLOW}Current active Iteration Number: {RESET}{GREEN}{current.iteration_number}{RESET}\n" + ) + tz = current.iteration_datetime.tzinfo + sys.stdout.write( + f"{YELLOW}Current active Iteration's date&time: " + f"{RESET}{GREEN}{current.iteration_datetime} ({tz}){RESET}\n" + ) + except StopIteration: + sys.stdout.write(f"{YELLOW}No active iteration could be determined{RESET}\n") + else: - sys.stdout.write(f"{YELLOW}Current Iteration Number: {RESET}{GREEN}{current.iteration_number}{RESET}\n") - sys.stdout.write(f"{YELLOW}Total iterations configured: {RESET}{GREEN}{no_of_iterations}{RESET}\n") + sys.stdout.write(f"{YELLOW}Campaign is {RESET}{GREEN}NOT LIVE{RESET} ") + + if is_campaign_expired: + sys.stdout.write(f"{YELLOW}[EXPIRED on {config.end_date}]{RESET}\n") + else: + sys.stdout.write(f"{YELLOW}[To be STARTED on {RESET}{GREEN}{config.start_date}{RESET}{YELLOW}]{RESET}\n") + + # ---- Next Iteration ---- + if not is_campaign_expired: + sorted_iterations = sorted(iterations, key=attrgetter("iteration_date")) + + try: + next_iteration = next((i for i in sorted_iterations if i.iteration_date > today), None) + + if next_iteration: + sys.stdout.write( + f"{YELLOW}Next active Iteration Number: {RESET}{GREEN}{next_iteration.iteration_number}{RESET}\n" + ) + tz = next_iteration.iteration_datetime.tzinfo + sys.stdout.write( + f"{YELLOW}Next active Iteration's date&time: " + f"{RESET}{GREEN}{next_iteration.iteration_datetime} ({tz}){RESET}\n" + ) + except StopIteration: + sys.stdout.write(f"{YELLOW}No next active iteration could be determined{RESET}\n") + + # ---- Total Iterations ---- + sys.stdout.write(f"{YELLOW}Total iterations configured: {RESET}{GREEN}{no_of_iterations}{RESET}\n") if __name__ == "__main__": # pragma: no cover diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index b30e77a34..8cf05faf1 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -34,6 +34,15 @@ def validate_iterations_have_unique_id(self) -> typing.Self: raise ValueError(msg) return self + @model_validator(mode="after") + def validate_iterations_have_unique_number(self) -> typing.Self: + numbers = [iteration.iteration_number for iteration in self.iterations] + duplicates = {i_id for i_id, count in Counter(numbers).items() if count > 1} + if duplicates: + msg = f"Iterations contain duplicate numbers: {', '.join(str(i) for i in duplicates)}" + raise ValueError(msg) + return self + @model_validator(mode="after") def validate_campaign_has_iteration_within_schedule(self) -> typing.Self: errors: list[str] = [] diff --git a/tests/fixtures/builders/model/rule.py b/tests/fixtures/builders/model/rule.py index 2793ea032..a7bdf2cbc 100644 --- a/tests/fixtures/builders/model/rule.py +++ b/tests/fixtures/builders/model/rule.py @@ -1,4 +1,4 @@ -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, date, datetime, time, timedelta from operator import attrgetter from random import randint @@ -16,6 +16,7 @@ Iteration, IterationCohort, IterationRule, + IterationTime, RuleAttributeLevel, RuleAttributeName, RuleComparator, @@ -89,6 +90,7 @@ class IterationFactory(ModelFactory[Iteration]): default_comms_routing = "defaultcomms" actions_mapper = Use(ActionsMapperFactory.build) rules_mapper = None + iteration_time = None class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): @@ -96,6 +98,7 @@ class RawCampaignConfigFactory(ModelFactory[CampaignConfig]): id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy" start_date = Use(past_date) end_date = Use(future_date) + iteration_time = IterationTime(time(0, 0, 0)) class CampaignConfigFactory(RawCampaignConfigFactory): diff --git a/tests/integration/in_process/test_eligibility_endpoint.py b/tests/integration/in_process/test_eligibility_endpoint.py index bc6fe2693..a7ce2aea3 100644 --- a/tests/integration/in_process/test_eligibility_endpoint.py +++ b/tests/integration/in_process/test_eligibility_endpoint.py @@ -33,15 +33,15 @@ from tests.integration.conftest import UNIQUE_CONSUMER_HEADER -def today(): +def today() -> date: return datetime.now(UTC).date() -def yesterday(): +def yesterday() -> date: return datetime.now(UTC).date() - timedelta(days=1) -def tomorrow(): +def tomorrow() -> date: return datetime.now(UTC).date() + timedelta(days=1) @@ -1532,11 +1532,10 @@ def test_if_multiple_active_iterations_with_same_iteration_datetime_for_the_same ) ), ) - err_msg = ( "Ambiguous result: '2' active iterations " "for target RSV " - f"found for date '{previous_day}' " + f"found for datetime '{previous_day} 00:00:00+00:00' " "across campaign(s) ['RSV_campaign_id_1', 'RSV_campaign_id_2']" ) assert any(err_msg in message for message in caplog.messages), ( diff --git a/tests/test_data/test_config/test_config_v1.3.0.json b/tests/test_data/test_config/test_config_v1.3.0.json new file mode 100644 index 000000000..ae53e9506 --- /dev/null +++ b/tests/test_data/test_config/test_config_v1.3.0.json @@ -0,0 +1,372 @@ +{ + "CampaignConfig": { + "ID": "id_100", + "Version": "1", + "Name": "Test Config", + "Type": "V", + "Target": "RSV", + "Manager": [ + "person@test.com" + ], + "Approver": [ + "person@test.com" + ], + "Reviewer": [ + "person@test.com" + ], + "StartDate": "20270101", + "EndDate": "20270104", + "ApprovalMinimum": 1, + "ApprovalMaximum": 5000000, + "IterationFrequency": "X", + "IterationType": "M", + "IterationTime": "07:00:00", + "Iterations": [ + { + "ID": "id_100", + "Version": "1", + "Name": "Test Config", + "Type": "M", + "IterationDate": "20270103", + "IterationTime": "07:00:00", + "IterationNumber": 1, + "CommsType": "R", + "ApprovalMinimum": 1, + "ApprovalMaximum": 5000000, + "DefaultCommsRouting": "INTERNALCONTACTGP1", + "DefaultNotActionableRouting": "INTERNALCONTACTGP1", + "DefaultNotEligibleRouting": "INTERNALCONTACTGP1", + "StatusText": { + "NotEligible": "You are not eligible to take RSV vaccines", + "NotActionable": "You have taken RSV vaccine in the last 90 days", + "Actionable": "You can take RSV vaccine" + }, + "ActionsMapper": { + "INTERNALCONTACTGP1": { + "ExternalRoutingCode": "CONTACTGP", + "ActionDescription": "Contact GP Text1 description", + "ActionType": "text1" + }, + "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" + }, + "YRULEID1": { + "ExternalRoutingCode": "FINDWALKIN", + "ActionDescription": "Find walkin description", + "ActionType": "button" + } + }, + "IterationCohorts": [ + { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_range", + "PositiveDescription": "You are currently aged 75 to 79", + "NegativeDescription": "You are not currently aged 75 to 79", + "Priority": 0, + "Virtual": "N" + }, + { + "CohortLabel": "rsv_75to79_2024", + "CohortGroup": "rsv_catch_up_age_range", + "PositiveDescription": "You turned 80 after 1 September 2024, so are eligible for the RSV vaccine until 31 August 2025", + "NegativeDescription": "You did not turn 80 after 1 September 2024 and get vaccinated by 31 August 2025", + "Priority": 10 + }, + { + "CohortLabel": "virtual_rsv_80_since_02_Sept_2024", + "CohortGroup": "rsv_age_catchup", + "PositiveDescription": "You turned 80 after 1 September 2024, so are eligible for the RSV vaccine until 31 August 2025", + "NegativeDescription": "You did not turn 80 after 1 September 2024 and get vaccinated by 31 August 2025", + "Priority": 101, + "Virtual": "Y" + } + ], + "IterationRules": [ + { + "Type": "F", + "Name": "Test Rule", + "Description": "Test Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "=", + "Comparator": "19000101" + }, + { + "Type": "F", + "Name": "Test Rule", + "Description": "Test Rule Desc", + "Priority": 30, + "AttributeLevel": "PERSON", + "AttributeName": "place of birth", + "Operator": "=", + "Comparator": "london" + }, + { + "Type": "S", + "Name": "Already Vaccinated", + "Description": "Already Vaccinated|You have already been Vaccinated", + "Priority": 30, + "AttributeLevel": "TARGET", + "AttributeTarget": "RSV", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "rsv_75to79_2024", + "Operator": "is_not_null", + "Comparator": "" + }, + { + "Type": "S", + "Name": "In Supressed Cohort", + "Description": "In Supressed Cohort|You Are In Supressed Cohort", + "Priority": 40, + "AttributeLevel": "COHORT", + "AttributeName": "COHORT_LABEL", + "Operator": "=", + "Comparator": "rsv_75to79_2024" + }, + { + "Type": "R", + "Name": "Test Redirect Rule", + "Description": "Test Redirect Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "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" + } + ], + "RulesMapper": { + "ALREADY_JABBED": { + "RuleNames": [ + "Already Vaccinated" + ], + "RuleCode": "Already Jabbed", + "RuleText": "Already Vaccinated|You have already been Vaccinated" + }, + "OTHER_SETTINGS": { + "RuleNames": [ + "In Supressed Cohort" + ], + "RuleCode": "Present in Supressed Cohort" + } + } + }, + { + "ID": "id_101", + "Version": "1", + "Name": "Test Config", + "Type": "M", + "IterationDate": "20270102", + "IterationNumber": 2, + "CommsType": "R", + "ApprovalMinimum": 1, + "ApprovalMaximum": 5000000, + "DefaultCommsRouting": "INTERNALCONTACTGP1", + "DefaultNotActionableRouting": "INTERNALCONTACTGP1", + "DefaultNotEligibleRouting": "INTERNALCONTACTGP1", + "StatusText": { + "NotEligible": "You are not eligible to take RSV vaccines", + "NotActionable": "You have taken RSV vaccine in the last 90 days", + "Actionable": "You can take RSV vaccine" + }, + "ActionsMapper": { + "INTERNALCONTACTGP1": { + "ExternalRoutingCode": "CONTACTGP", + "ActionDescription": "Contact GP Text1 description", + "ActionType": "text1" + }, + "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" + }, + "YRULEID1": { + "ExternalRoutingCode": "FINDWALKIN", + "ActionDescription": "Find walkin description", + "ActionType": "button" + } + }, + "IterationCohorts": [ + { + "CohortLabel": "rsv_75_rolling", + "CohortGroup": "rsv_age_range", + "PositiveDescription": "You are currently aged 75 to 79", + "NegativeDescription": "You are not currently aged 75 to 79", + "Priority": 0, + "Virtual": "N" + }, + { + "CohortLabel": "rsv_75to79_2024", + "CohortGroup": "rsv_catch_up_age_range", + "PositiveDescription": "You turned 80 after 1 September 2024, so are eligible for the RSV vaccine until 31 August 2025", + "NegativeDescription": "You did not turn 80 after 1 September 2024 and get vaccinated by 31 August 2025", + "Priority": 10 + }, + { + "CohortLabel": "virtual_rsv_80_since_02_Sept_2024", + "CohortGroup": "rsv_age_catchup", + "PositiveDescription": "You turned 80 after 1 September 2024, so are eligible for the RSV vaccine until 31 August 2025", + "NegativeDescription": "You did not turn 80 after 1 September 2024 and get vaccinated by 31 August 2025", + "Priority": 101, + "Virtual": "Y" + } + ], + "IterationRules": [ + { + "Type": "F", + "Name": "Test Rule", + "Description": "Test Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "Operator": "=", + "Comparator": "19000101" + }, + { + "Type": "F", + "Name": "Test Rule", + "Description": "Test Rule Desc", + "Priority": 30, + "AttributeLevel": "PERSON", + "AttributeName": "place of birth", + "Operator": "=", + "Comparator": "london" + }, + { + "Type": "S", + "Name": "Already Vaccinated", + "Description": "Already Vaccinated|You have already been Vaccinated", + "Priority": 30, + "AttributeLevel": "TARGET", + "AttributeTarget": "RSV", + "AttributeName": "LAST_SUCCESSFUL_DATE", + "CohortLabel": "rsv_75to79_2024", + "Operator": "is_not_null", + "Comparator": "" + }, + { + "Type": "S", + "Name": "In Supressed Cohort", + "Description": "In Supressed Cohort|You Are In Supressed Cohort", + "Priority": 40, + "AttributeLevel": "COHORT", + "AttributeName": "COHORT_LABEL", + "Operator": "=", + "Comparator": "rsv_75to79_2024" + }, + { + "Type": "R", + "Name": "Test Redirect Rule", + "Description": "Test Redirect Rule Desc", + "Priority": 20, + "AttributeLevel": "PERSON", + "AttributeName": "DATE_OF_BIRTH", + "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" + } + ], + "RulesMapper": { + "ALREADY_JABBED": { + "RuleNames": [ + "Already Vaccinated" + ], + "RuleCode": "Already Jabbed", + "RuleText": "Already Vaccinated|You have already been Vaccinated" + }, + "OTHER_SETTINGS": { + "RuleNames": [ + "In Supressed Cohort" + ], + "RuleCode": "Present in Supressed Cohort" + } + } + } + ] + } +} diff --git a/tests/unit/validation/test_app.py b/tests/unit/validation/test_app.py index 0c88bea80..8fba4d977 100644 --- a/tests/unit/validation/test_app.py +++ b/tests/unit/validation/test_app.py @@ -1,6 +1,7 @@ import sys +from datetime import UTC, datetime, timedelta from io import StringIO -from unittest.mock import Mock, PropertyMock +from unittest.mock import Mock, PropertyMock, patch from pydantic import BaseModel, ValidationError @@ -84,6 +85,8 @@ def test_no_current_iteration(): # iterations must be a list, not a Mock result.campaign_config.iterations = [] + result.campaign_config.end_date = datetime.now(UTC).date() + timedelta(days=1) + # current_iteration should raise StopIteration type(result.campaign_config).current_iteration = PropertyMock(side_effect=StopIteration) @@ -104,11 +107,13 @@ def test_current_iteration_exists(): # Arrange mock_iteration = Mock() mock_iteration.iteration_number = 7 + mock_iteration.iteration_date = datetime.now(UTC).date() - timedelta(days=1) result = Mock() result.campaign_config = Mock() result.campaign_config.iterations = [mock_iteration] + result.campaign_config.end_date = datetime.now(UTC).date() + timedelta(days=1) type(result.campaign_config).current_iteration = PropertyMock(return_value=mock_iteration) @@ -119,5 +124,122 @@ def test_current_iteration_exists(): sys.stdout = sys.__stdout__ - assert "Current Iteration Number:" in captured.getvalue() + assert "Current active Iteration Number:" in captured.getvalue() assert "7" in captured.getvalue() + + +def test_next_iteration_exists(): + # Given + today = datetime.now(UTC).date() + + # Setup + next_mock = Mock() + next_mock.iteration_number = 8 + next_mock.iteration_date = today + timedelta(days=5) + next_mock.iteration_datetime = datetime.combine( + next_mock.iteration_date, + datetime.min.time(), + tzinfo=UTC, + ) + + result = Mock() + result.campaign_config.end_date = today + timedelta(days=10) + result.campaign_config.iterations = [next_mock] + result.campaign_config.campaign_live = False # To focus only on Next Iteration output + + captured = StringIO() + sys.stdout = captured + + # When + display_current_iteration(result) + sys.stdout = sys.__stdout__ + output = captured.getvalue() + + # Then + assert "Next active Iteration Number:" in output + assert "8" in output + assert str(today + timedelta(days=5)) in output + + +def test_campaign_expired_and_no_next_iteration(): + """Covers: is_campaign_expired = True, next iteration logic skipped.""" + today = datetime.now(UTC).date() + + result = Mock() + config = result.campaign_config + config.campaign_live = False + config.end_date = today - timedelta(days=1) # Expired + config.iterations = [] + + captured = StringIO() + with patch("sys.stdout", new=captured): + display_current_iteration(result) + + output = captured.getvalue() + assert "NOT LIVE" in output + assert "EXPIRED on" in output + assert "Next active Iteration Number" not in output + + +def test_campaign_to_be_started(): + """Covers: is_campaign_expired = False, campaign_live = False.""" + today = datetime.now(UTC).date() + + result = Mock() + config = result.campaign_config + config.campaign_live = False + config.end_date = today + timedelta(days=10) + config.start_date = today + timedelta(days=2) + config.iterations = [] + + captured = StringIO() + with patch("sys.stdout", new=captured): + display_current_iteration(result) + + output = captured.getvalue() + assert "NOT LIVE" in output + assert "To be STARTED on" in output + + +def test_next_iteration_stop_iteration_exception(): + """ + Covers the 'except StopIteration' block in the Next Iteration section. + This triggers if the generator inside next() raises StopIteration explicitly. + """ + today = datetime.now(UTC).date() + + result = Mock() + config = result.campaign_config + config.campaign_live = False + config.end_date = today + timedelta(days=10) + config.iterations = [Mock(iteration_date=today + timedelta(days=5))] + + captured = StringIO() + with patch("sys.stdout", new=captured), patch("rules_validation_api.app.next", side_effect=StopIteration): + display_current_iteration(result) + + output = captured.getvalue() + assert "No next active iteration could be determined" in output + + +def test_next_iteration_is_none(): + today = datetime.now(UTC).date() + + result = Mock() + config = result.campaign_config + config.campaign_live = False + config.end_date = today + timedelta(days=10) + + past_iteration = Mock() + past_iteration.iteration_date = today - timedelta(days=5) + config.iterations = [past_iteration] + + captured = StringIO() + with patch("sys.stdout", new=captured): + display_current_iteration(result) + + output = captured.getvalue() + + assert "Next active Iteration Number" not in output + assert "Total iterations configured:" in output + assert "1" in output diff --git a/tests/unit/validation/test_campaign_config_validator.py b/tests/unit/validation/test_campaign_config_validator.py index 216369a98..4edf9ba8e 100644 --- a/tests/unit/validation/test_campaign_config_validator.py +++ b/tests/unit/validation/test_campaign_config_validator.py @@ -1,3 +1,5 @@ +from datetime import datetime + import pytest from pydantic import ValidationError @@ -165,11 +167,11 @@ def test_reviewer_field(self, reviewer, valid_campaign_config_with_only_mandator model = CampaignConfigValidation(**data) assert model.reviewer == reviewer - @pytest.mark.parametrize("iteration_time", ["14:00", "09:30", "18:45"]) + @pytest.mark.parametrize("iteration_time", ["14:00:00", "09:30:00", "18:45:00"]) 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 + assert model.iteration_time == datetime.strptime(iteration_time, "%H:%M:%S").time() # noqa: DTZ007 @pytest.mark.parametrize("routing", ["email", "sms", "push"]) def test_default_comms_routing_field(self, routing, valid_campaign_config_with_only_mandatory_fields): diff --git a/tests/unit/validation/test_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py index 3c58c33c9..0c65e50cf 100644 --- a/tests/unit/validation/test_iteration_validator.py +++ b/tests/unit/validation/test_iteration_validator.py @@ -5,6 +5,7 @@ import pytest from pydantic import ValidationError +from rules_validation_api.validators.campaign_config_validator import CampaignConfigValidation from rules_validation_api.validators.iteration_validator import IterationValidation @@ -181,6 +182,15 @@ def test_approval_maximum(self, approval_maximum, valid_campaign_config_with_onl model = IterationValidation(**data) assert model.approval_maximum == approval_maximum + @pytest.mark.parametrize("iteration_time", ["14:00:00", "09:30:00", "18:45:00"]) + def test_iteration_time_field(self, iteration_time, valid_campaign_config_with_only_mandatory_fields): + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "IterationTime": iteration_time, + } + model = IterationValidation(**data) + assert model.iteration_time == datetime.strptime(iteration_time, "%H:%M:%S").time() # noqa: DTZ007 + class TestBUCValidations: book_local_1_action: ClassVar[dict] = { @@ -504,3 +514,47 @@ def test_invalid_iteration_collects_errors_if_iteration_rules_have_invalid_data( # Assert messages contain the expected text assert "AttributeName must be set" in errors[0]["msg"] assert "AttributeName must be set" in errors[1]["msg"] + + @pytest.mark.parametrize( + ("iteration_time_input", "default_time_iteration_input", "expected_date_time"), + [ + # Case 1: Iteration time overrides default + ("14:30:00", "09:00:00", datetime(2025, 1, 2, 14, 30, 0, tzinfo=UTC)), + # Case 2: Iteration time is missing, so it uses campaign config iteration_time + (None, "09:00:00", datetime(2025, 1, 2, 9, 0, 0, tzinfo=UTC)), + # Case 3: Both are the same + ("10:00:00", "10:00:00", datetime(2025, 1, 2, 10, 0, 0, tzinfo=UTC)), + # Case 4: Both are None, falls back to default value (12 AM) in campaign config iteration_time + (None, None, datetime(2025, 1, 2, 0, 0, 0, tzinfo=UTC)), + ], + ) + def test_iteration_full_datetime_validation( + self, + valid_campaign_config_with_only_mandatory_fields, + valid_iteration_with_only_mandatory_fields, + iteration_time_input, + default_time_iteration_input, + expected_date_time, + ): + # Given + iteration_data = valid_iteration_with_only_mandatory_fields.copy() + iteration_data["IterationTime"] = iteration_time_input + iteration_data["IterationDate"] = "20250102" # between campaign start_date and end_date + + data = valid_campaign_config_with_only_mandatory_fields.copy() + + if default_time_iteration_input: + data["iteration_time"] = default_time_iteration_input + + data["Iterations"] = [iteration_data] + + # When + config = CampaignConfigValidation(**data) + + # Then + result = config.iterations[0].iteration_datetime + + assert result == expected_date_time, ( + f"Failed! Input: {iteration_time_input}, Default: {default_time_iteration_input}. " + f"Expected {expected_date_time} but got {result}" + )