From be1381832457518ed6c2ffbbccee242a0691cd97 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 13:56:43 +0100 Subject: [PATCH 01/21] test action mapper doesn't accept invalid actions --- .../validation/test_actions_mapper_validator.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/validation/test_actions_mapper_validator.py b/tests/unit/validation/test_actions_mapper_validator.py index e14989e90..b688a9ee3 100644 --- a/tests/unit/validation/test_actions_mapper_validator.py +++ b/tests/unit/validation/test_actions_mapper_validator.py @@ -31,6 +31,21 @@ def test_valid_actions_mapper(self, valid_available_action): assert isinstance(mapper, ActionsMapperValidator) 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): + ActionsMapperValidator(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: From 4511b171b5cfd49a6c4ffcc691b0a55954eac702 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:42:40 +0100 Subject: [PATCH 02/21] Attribute level and name relations when it is cohort --- .../validators/iteration_rules_validator.py | 17 +++++- .../test_iteration_rules_validator.py | 57 +++++++++++++------ 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/rules_validation_api/validators/iteration_rules_validator.py b/src/rules_validation_api/validators/iteration_rules_validator.py index 95d8dce66..31cf0e994 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." + raise ValueError(msg) + return self diff --git a/tests/unit/validation/test_iteration_rules_validator.py b/tests/unit/validation/test_iteration_rules_validator.py index af7405cce..1a853bee8 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,23 @@ 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) + assert "When attribute_level is COHORT, attribute_name must be COHORT_LABEL." in str(error.value) From 3d4b26ebc52758867f4a94c2372a90f73f146856 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:10:53 +0100 Subject: [PATCH 03/21] added iteration_cohorts_validation --- .../validators/iteration_cohort_validator.py | 5 ++ .../validators/iteration_validator.py | 10 ++- .../test_iteration_cohorts_validator.py | 65 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 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/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_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 3bfd3fec5..ed97c52f4 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -3,17 +3,23 @@ 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 eligibility_signposting_api.model.campaign_config import ActionsMapper, Iteration, IterationCohort, IterationRule from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator +from rules_validation_api.validators.iteration_cohort_validator import IterationCohortValidation 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]: + def validate_iteration_rules(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: return [IterationRuleValidation(**i.model_dump()) for i in iteration_rules] + @classmethod + @field_validator("iteration_cohorts") + def validate_iteration_cohorts(cls, iteration_cohorts: list[IterationCohort]) -> list[IterationCohortValidation]: + return [IterationCohortValidation(**i.model_dump()) for i in iteration_cohorts] + @classmethod @field_validator("actions_mapper", mode="after") def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper: 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 From d02cb00572372d7602ed830f944f30b2cdf84fa1 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:09:22 +0100 Subject: [PATCH 04/21] chainging the validations --- .../validators/actions_mapper_validator.py | 4 ++-- .../validators/campaign_config_validator.py | 4 +++- .../validators/iteration_validator.py | 10 +++++++--- .../validators/rules_validator.py | 4 +++- .../unit/validation/test_actions_mapper_validator.py | 12 ++++++------ 5 files changed, 21 insertions(+), 13 deletions(-) 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 f25d740ff..0ce080502 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -1,10 +1,12 @@ -from pydantic import field_validator +from pydantic import Field, field_validator from eligibility_signposting_api.model.campaign_config import CampaignConfig, Iteration from rules_validation_api.validators.iteration_validator import IterationValidation class CampaignConfigValidation(CampaignConfig): + iterations: list[Iteration] = Field(..., min_length=1, alias="Iterations") + @classmethod @field_validator("iterations") def validate_iterations(cls, iterations: list[Iteration]) -> list[IterationValidation]: diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index ed97c52f4..1c1ad73e4 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -1,15 +1,19 @@ 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, IterationCohort, IterationRule -from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator +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): + iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts") + iteration_rules: list[IterationRule] = Field(..., alias="IterationRules") + actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper") + @classmethod @field_validator("iteration_rules") def validate_iteration_rules(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: @@ -23,7 +27,7 @@ def validate_iteration_cohorts(cls, iteration_cohorts: list[IterationCohort]) -> @classmethod @field_validator("actions_mapper", mode="after") 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") diff --git a/src/rules_validation_api/validators/rules_validator.py b/src/rules_validation_api/validators/rules_validator.py index 8d52a545b..ea59ac645 100644 --- a/src/rules_validation_api/validators/rules_validator.py +++ b/src/rules_validation_api/validators/rules_validator.py @@ -1,10 +1,12 @@ -from pydantic import field_validator +from pydantic import Field, 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): + campaign_config: CampaignConfig = Field(..., alias="CampaignConfig") + @classmethod @field_validator("campaign_config") def validate_campaign_config(cls, campaign_config: CampaignConfig) -> CampaignConfig: diff --git a/tests/unit/validation/test_actions_mapper_validator.py b/tests/unit/validation/test_actions_mapper_validator.py index b688a9ee3..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,10 +25,10 @@ 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( @@ -44,12 +44,12 @@ def test_valid_actions_mapper(self, valid_available_action): def test_if_exception_raised_when_adding_invalid_actions_to_action_mapper(self, invalid_action): data = {"": invalid_action} with pytest.raises(ValidationError): - ActionsMapperValidator(root=data) + 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) @@ -60,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) From b97650d7ed79fd68376ac494005dc641fc8339f7 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:45:54 +0100 Subject: [PATCH 05/21] fix --- .../validators/campaign_config_validator.py | 3 --- src/rules_validation_api/validators/iteration_validator.py | 5 ++--- src/rules_validation_api/validators/rules_validator.py | 3 --- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index 0ce080502..cc40a2f0d 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -5,9 +5,6 @@ class CampaignConfigValidation(CampaignConfig): - iterations: list[Iteration] = Field(..., min_length=1, alias="Iterations") - - @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_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 1c1ad73e4..53499a777 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -14,17 +14,16 @@ class IterationValidation(Iteration): iteration_rules: list[IterationRule] = Field(..., alias="IterationRules") actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper") - @classmethod @field_validator("iteration_rules") def validate_iteration_rules(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]: return [IterationRuleValidation(**i.model_dump()) for i in iteration_rules] - @classmethod + @field_validator("iteration_cohorts") def validate_iteration_cohorts(cls, iteration_cohorts: list[IterationCohort]) -> list[IterationCohortValidation]: return [IterationCohortValidation(**i.model_dump()) for i in iteration_cohorts] - @classmethod + @field_validator("actions_mapper", mode="after") def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper: ActionsMapperValidation.model_validate(action_mapper.model_dump()) diff --git a/src/rules_validation_api/validators/rules_validator.py b/src/rules_validation_api/validators/rules_validator.py index ea59ac645..3aec8b87e 100644 --- a/src/rules_validation_api/validators/rules_validator.py +++ b/src/rules_validation_api/validators/rules_validator.py @@ -5,9 +5,6 @@ class RulesValidation(Rules): - campaign_config: CampaignConfig = Field(..., alias="CampaignConfig") - - @classmethod @field_validator("campaign_config") def validate_campaign_config(cls, campaign_config: CampaignConfig) -> CampaignConfig: return CampaignConfigValidation(**campaign_config.model_dump()) From 758235cef165605cb8568d25932dee468d0181c4 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:46:37 +0100 Subject: [PATCH 06/21] fix --- .../validators/campaign_config_validator.py | 2 +- src/rules_validation_api/validators/iteration_validator.py | 2 -- src/rules_validation_api/validators/rules_validator.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index cc40a2f0d..09d905f22 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -1,4 +1,4 @@ -from pydantic import Field, field_validator +from pydantic import field_validator from eligibility_signposting_api.model.campaign_config import CampaignConfig, Iteration from rules_validation_api.validators.iteration_validator import IterationValidation diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 53499a777..55c384546 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -18,12 +18,10 @@ class IterationValidation(Iteration): 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") 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") def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper: ActionsMapperValidation.model_validate(action_mapper.model_dump()) diff --git a/src/rules_validation_api/validators/rules_validator.py b/src/rules_validation_api/validators/rules_validator.py index 3aec8b87e..a687abb8d 100644 --- a/src/rules_validation_api/validators/rules_validator.py +++ b/src/rules_validation_api/validators/rules_validator.py @@ -1,4 +1,4 @@ -from pydantic import Field, field_validator +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 From 826f3b2ee8267603e1d6d692d32cedcf04dd2e2e Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:02:59 +0100 Subject: [PATCH 07/21] fix lint --- .../validators/campaign_config_validator.py | 1 + src/rules_validation_api/validators/iteration_validator.py | 3 +++ src/rules_validation_api/validators/rules_validator.py | 1 + 3 files changed, 5 insertions(+) diff --git a/src/rules_validation_api/validators/campaign_config_validator.py b/src/rules_validation_api/validators/campaign_config_validator.py index 09d905f22..a5bbf0af1 100644 --- a/src/rules_validation_api/validators/campaign_config_validator.py +++ b/src/rules_validation_api/validators/campaign_config_validator.py @@ -6,5 +6,6 @@ class CampaignConfigValidation(CampaignConfig): @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_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 55c384546..f28d60596 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -15,14 +15,17 @@ class IterationValidation(Iteration): actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper") @field_validator("iteration_rules") + @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: ActionsMapperValidation.model_validate(action_mapper.model_dump()) return action_mapper diff --git a/src/rules_validation_api/validators/rules_validator.py b/src/rules_validation_api/validators/rules_validator.py index a687abb8d..cacb143d0 100644 --- a/src/rules_validation_api/validators/rules_validator.py +++ b/src/rules_validation_api/validators/rules_validator.py @@ -6,5 +6,6 @@ class RulesValidation(Rules): @field_validator("campaign_config") + @classmethod def validate_campaign_config(cls, campaign_config: CampaignConfig) -> CampaignConfig: return CampaignConfigValidation(**campaign_config.model_dump()) From fc1869e4e6554e72a4b8be4ac00644cb3570c06a Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Thu, 7 Aug 2025 23:02:59 +0100 Subject: [PATCH 08/21] fix lint --- .../validators/iteration_rules_validator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rules_validation_api/validators/iteration_rules_validator.py b/src/rules_validation_api/validators/iteration_rules_validator.py index 31cf0e994..add6ab1bd 100644 --- a/src/rules_validation_api/validators/iteration_rules_validator.py +++ b/src/rules_validation_api/validators/iteration_rules_validator.py @@ -13,6 +13,7 @@ def check_cohort_attribute_name(self) -> Self: and self.attribute_name and self.attribute_name != RuleAttributeName("COHORT_LABEL") ): - msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL." + msg = ("When attribute_level is COHORT," + " attribute_name must be COHORT_LABEL or None (default value is COHORT_LABEL).") raise ValueError(msg) return self From c8bb5a5f2e916feb2160e9950b5b74c56a74869f Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:29:05 +0100 Subject: [PATCH 09/21] lint fixes --- .../validators/iteration_rules_validator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rules_validation_api/validators/iteration_rules_validator.py b/src/rules_validation_api/validators/iteration_rules_validator.py index add6ab1bd..4065053e9 100644 --- a/src/rules_validation_api/validators/iteration_rules_validator.py +++ b/src/rules_validation_api/validators/iteration_rules_validator.py @@ -13,7 +13,6 @@ def check_cohort_attribute_name(self) -> Self: 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 value is COHORT_LABEL).") + msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL" raise ValueError(msg) return self From ad10cb787ee9b9a1b7e13f7bf0bd4021d1d16147 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:43:55 +0100 Subject: [PATCH 10/21] lint fixes --- .../validators/iteration_rules_validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules_validation_api/validators/iteration_rules_validator.py b/src/rules_validation_api/validators/iteration_rules_validator.py index 4065053e9..341a08c1f 100644 --- a/src/rules_validation_api/validators/iteration_rules_validator.py +++ b/src/rules_validation_api/validators/iteration_rules_validator.py @@ -13,6 +13,6 @@ def check_cohort_attribute_name(self) -> Self: 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" + msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" raise ValueError(msg) return self From 3b58fd5ba7767c59068d479d9c70533efb78d0a5 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:48:57 +0100 Subject: [PATCH 11/21] test fixes --- tests/unit/validation/test_iteration_rules_validator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/validation/test_iteration_rules_validator.py b/tests/unit/validation/test_iteration_rules_validator.py index 1a853bee8..20cbc1c5e 100644 --- a/tests/unit/validation/test_iteration_rules_validator.py +++ b/tests/unit/validation/test_iteration_rules_validator.py @@ -243,4 +243,5 @@ def test_invalid_when_attribute_level_is_cohort_but_attribute_name_is_neither_no data["AttributeName"] = attribute_name with pytest.raises(ValidationError) as error: IterationRuleValidation(**data) - assert "When attribute_level is COHORT, attribute_name must be COHORT_LABEL." in str(error.value) + assert ("When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" + in str(error.value)) From 4f302a3b7a7d8d4390e9bcf58bf7b2988cfdb624 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:52:02 +0100 Subject: [PATCH 12/21] lint fixes --- tests/unit/validation/test_iteration_rules_validator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/validation/test_iteration_rules_validator.py b/tests/unit/validation/test_iteration_rules_validator.py index 20cbc1c5e..fd544528e 100644 --- a/tests/unit/validation/test_iteration_rules_validator.py +++ b/tests/unit/validation/test_iteration_rules_validator.py @@ -243,5 +243,5 @@ def test_invalid_when_attribute_level_is_cohort_but_attribute_name_is_neither_no data["AttributeName"] = attribute_name with pytest.raises(ValidationError) as error: IterationRuleValidation(**data) - assert ("When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" - in str(error.value)) + msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)" + assert msg in str(error.value) From afa0a481d9b8895a5592db05cf2a65ab09ad5c75 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 11:09:56 +0100 Subject: [PATCH 13/21] Removed defaultcomms from iteration level of test config. --- tests/test_data/test_config/test_config.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index fe7b41ed6..517d4a90a 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -11,7 +11,6 @@ "IterationFrequency": "X", "IterationType": "M", "IterationTime": "07:00:00", - "DefaultCommsRouting": "Default_Comms_1", "Iterations": [ { "ID": "id_100", From 679bb65f1ccc0a5a374527595f68d15277fcabee Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 11:29:49 +0100 Subject: [PATCH 14/21] Reorder feilds in config. --- tests/test_data/test_config/test_config.json | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index 517d4a90a..8f9cb1445 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -8,12 +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", "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", @@ -118,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 + ] } } From 1a9a0dd0fd3104598149e193e9b9ffe7046ac904 Mon Sep 17 00:00:00 2001 From: ayeshalshukri1-nhs <112615598+ayeshalshukri1-nhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 11:30:52 +0100 Subject: [PATCH 15/21] Update to config. --- tests/test_data/test_config/test_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index 8f9cb1445..ca2f03c42 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -21,7 +21,7 @@ "Version": "1", "Name": "Test Config", "Type": "M", - "IterationDate": "20250101", + "IterationDate": "20250102", "IterationNumber": 1, "CommsType": "R", "ApprovalMinimum": 1, From 1ab0de5460fb7370ca7b39b1400af7151c5d5127 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 11:51:37 +0100 Subject: [PATCH 16/21] default comm routing validation --- .../validators/iteration_validator.py | 29 ++++++---- .../validation/test_iteration_validator.py | 54 +++++++++++++++++-- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index f28d60596..26e3750a9 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -32,16 +32,23 @@ def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper @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]) + default_routes = self.default_comms_routing + actions_keys = list(self.actions_mapper.root.keys()) + line_errors = [] + + for routing in default_routes.split("|"): + routing = routing.strip() + if routing and (not actions_keys or routing not in actions_keys): + error = InitErrorDetails( + type="value_error", + loc=("actions_mapper",), + input=actions_keys, + ctx={"error": f"Missing entry for DefaultCommsRouting '{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/tests/unit/validation/test_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py index 8e58e5a38..c115cacfb 100644 --- a/tests/unit/validation/test_iteration_validator.py +++ b/tests/unit/validation/test_iteration_validator.py @@ -157,8 +157,8 @@ 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", + book_local_1_action = { + "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. " @@ -167,13 +167,59 @@ def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_ro "ActionType": "InfoText", } + book_local_2_action = { + "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", + } + 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": book_local_1_action, "BOOK_LOCAL_2": book_local_2_action}, } IterationValidation(**data) + def test_invalid_iteration_if_actions_mapper_has_one_one_entry_for_the_provided_multiple_default_routing_key( + self, valid_campaign_config_with_only_mandatory_fields + ): + book_local_1_action = { + "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 = { + "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", + } + + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": 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_routing_key( self, valid_campaign_config_with_only_mandatory_fields ): From 8d46e260dfb7112454abd17fff907fc918a37373 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:13:35 +0100 Subject: [PATCH 17/21] unit tests default comm routing validation --- .../validators/iteration_validator.py | 51 ++++- tests/unit/validation/conftest.py | 16 +- .../validation/test_iteration_validator.py | 191 ++++++++++++++++-- 3 files changed, 229 insertions(+), 29 deletions(-) diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 26e3750a9..98d69f9e8 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -37,13 +37,13 @@ def validate_default_comms_routing_in_actions_mapper(self) -> typing.Self: line_errors = [] for routing in default_routes.split("|"): - routing = routing.strip() - if routing and (not actions_keys or routing not in actions_keys): + 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 '{routing}' in ActionsMapper"}, + ctx={"error": f"Missing entry for DefaultCommsRouting '{cleaned_routing}' in ActionsMapper"}, ) line_errors.append(error) @@ -52,3 +52,48 @@ def validate_default_comms_routing_in_actions_mapper(self) -> typing.Self: return self + @model_validator(mode="after") + 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 + + @model_validator(mode="after") + 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 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_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py index c115cacfb..0809fa4f5 100644 --- a/tests/unit/validation/test_iteration_validator.py +++ b/tests/unit/validation/test_iteration_validator.py @@ -86,28 +86,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 @@ -170,10 +197,10 @@ def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_ro book_local_2_action = { "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.", + "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", } @@ -184,7 +211,50 @@ def test_valid_iteration_if_actions_mapper_has_entry_for_the_provided_default_ro } IterationValidation(**data) - def test_invalid_iteration_if_actions_mapper_has_one_one_entry_for_the_provided_multiple_default_routing_key( + 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 + ): + book_local_1_action = { + "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", + } + + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": 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_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" + ) + + 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 ): book_local_1_action = { @@ -200,16 +270,36 @@ def test_invalid_iteration_if_actions_mapper_has_one_one_entry_for_the_provided_ book_local_2_action = { "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.", + "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", + "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action, "BOOK_LOCAL_2": 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 + ): + book_local_1_action = { + "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", + } + + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action}, } with pytest.raises(ValidationError) as error: @@ -220,12 +310,85 @@ def test_invalid_iteration_if_actions_mapper_has_one_one_entry_for_the_provided_ "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( + 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], - "DefaultCommsRouting": "BOOK_LOCAL", + "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 + ): + book_local_1_action = { + "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 = { + "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", + } + + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action, "BOOK_LOCAL_2": 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 + ): + book_local_1_action = { + "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", + } + + data = { + **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], + "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", + "ActionsMapper": {"BOOK_LOCAL_1": 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 From ae6730bb5caecaa975ddbd1025f1a301e75abdb2 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:58:19 +0100 Subject: [PATCH 18/21] default comm routing validation in rules --- .../validators/iteration_validator.py | 35 ++- .../validation/test_iteration_validator.py | 199 +++++++++--------- 2 files changed, 137 insertions(+), 97 deletions(-) diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 98d69f9e8..e4968c310 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -3,7 +3,13 @@ from pydantic import Field, ValidationError, field_validator, model_validator from pydantic_core import InitErrorDetails -from eligibility_signposting_api.model.campaign_config import ActionsMapper, Iteration, IterationCohort, IterationRule +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 @@ -97,3 +103,30 @@ def validate_default_not_actionable_routing_in_actions_mapper(self) -> typing.Se raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors) return self + + @model_validator(mode="after") + 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, + ]: + 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/tests/unit/validation/test_iteration_validator.py b/tests/unit/validation/test_iteration_validator.py index 0809fa4f5..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 @@ -181,53 +182,43 @@ 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 ): - book_local_1_action = { - "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 = { - "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", - } - data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", - "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action, "BOOK_LOCAL_2": book_local_2_action}, + "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 ): - book_local_1_action = { - "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", - } - data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultCommsRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", - "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action}, + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, } with pytest.raises(ValidationError) as error: IterationValidation(**data) @@ -257,50 +248,20 @@ def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_defau 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 ): - book_local_1_action = { - "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 = { - "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", - } - data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", - "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action, "BOOK_LOCAL_2": book_local_2_action}, + "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 ): - book_local_1_action = { - "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", - } - data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotEligibleRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", - "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action}, + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, } with pytest.raises(ValidationError) as error: IterationValidation(**data) @@ -330,50 +291,20 @@ def test_invalid_iteration_if_actions_mapper_has_no_entry_for_the_provided_defau 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 ): - book_local_1_action = { - "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 = { - "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", - } - data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", - "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action, "BOOK_LOCAL_2": book_local_2_action}, + "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 ): - book_local_1_action = { - "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", - } - data = { **valid_campaign_config_with_only_mandatory_fields["Iterations"][0], "DefaultNotActionableRouting": "BOOK_LOCAL_1|BOOK_LOCAL_2", - "ActionsMapper": {"BOOK_LOCAL_1": book_local_1_action}, + "ActionsMapper": {"BOOK_LOCAL_1": self.book_local_1_action}, } with pytest.raises(ValidationError) as error: IterationValidation(**data) @@ -399,3 +330,79 @@ 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" ) + + @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 e762fa79d6f3091e0c4a9d5b2c179c58fc635281 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:03:48 +0100 Subject: [PATCH 19/21] lint fixes --- .../validators/iteration_validator.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index e4968c310..3ca216513 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -110,11 +110,15 @@ def validate_iteration_rules_against_actions_mapper(self) -> typing.Self: line_errors = [] for rule in self.iteration_rules: - if rule.type in [ - RuleType.redirect, - RuleType.not_actionable_actions, - RuleType.not_eligible_actions, - ]: + 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): From 8be8c83d67192fc82554870df959fbbec83921ee Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:09:24 +0100 Subject: [PATCH 20/21] test data fixed --- tests/test_data/test_config/test_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_data/test_config/test_config.json b/tests/test_data/test_config/test_config.json index ca2f03c42..8f9cb1445 100644 --- a/tests/test_data/test_config/test_config.json +++ b/tests/test_data/test_config/test_config.json @@ -21,7 +21,7 @@ "Version": "1", "Name": "Test Config", "Type": "M", - "IterationDate": "20250102", + "IterationDate": "20250101", "IterationNumber": 1, "CommsType": "R", "ApprovalMinimum": 1, From 4f6727d4d0b3ec657532b2e09450f6288b3f0f54 Mon Sep 17 00:00:00 2001 From: karthikeyannhs <174426205+Karthikeyannhs@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:26:44 +0100 Subject: [PATCH 21/21] grouped model validators --- .../validators/iteration_validator.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/rules_validation_api/validators/iteration_validator.py b/src/rules_validation_api/validators/iteration_validator.py index 3ca216513..c16286ab2 100644 --- a/src/rules_validation_api/validators/iteration_validator.py +++ b/src/rules_validation_api/validators/iteration_validator.py @@ -37,6 +37,25 @@ def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper 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_routes = self.default_comms_routing actions_keys = list(self.actions_mapper.root.keys()) @@ -58,7 +77,6 @@ def validate_default_comms_routing_in_actions_mapper(self) -> typing.Self: return self - @model_validator(mode="after") 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()) @@ -80,7 +98,6 @@ def validate_default_not_eligible_routing_in_actions_mapper(self) -> typing.Self return self - @model_validator(mode="after") 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()) @@ -104,7 +121,6 @@ def validate_default_not_actionable_routing_in_actions_mapper(self) -> typing.Se return self - @model_validator(mode="after") def validate_iteration_rules_against_actions_mapper(self) -> typing.Self: actions_keys = list(self.actions_mapper.root.keys()) line_errors = []