Skip to content

Commit 21b42df

Browse files
authored
Merge branch 'main' into bug/ELI-404
2 parents 3b2946c + dca12d8 commit 21b42df

12 files changed

Lines changed: 529 additions & 85 deletions

src/rules_validation_api/validators/actions_mapper_validator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
from eligibility_signposting_api.model.campaign_config import ActionsMapper
44

55

6-
class ActionsMapperValidator(ActionsMapper):
6+
class ActionsMapperValidation(ActionsMapper):
77
@model_validator(mode="after")
8-
def validate_keys(self) -> "ActionsMapperValidator":
8+
def validate_keys(self) -> "ActionsMapperValidation":
99
invalid_keys = [key for key in self.root if key is None or key == ""]
1010
if invalid_keys:
1111
msg = f"Invalid keys found in ActionsMapper: {invalid_keys}"

src/rules_validation_api/validators/campaign_config_validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99

1010
class CampaignConfigValidation(CampaignConfig):
11-
@classmethod
1211
@field_validator("iterations")
12+
@classmethod
1313
def validate_iterations(cls, iterations: list[Iteration]) -> list[IterationValidation]:
1414
return [IterationValidation(**i.model_dump()) for i in iterations]
1515

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from eligibility_signposting_api.model.campaign_config import IterationCohort
2+
3+
4+
class IterationCohortValidation(IterationCohort):
5+
pass
Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
from eligibility_signposting_api.model.campaign_config import IterationRule
1+
from typing import Self
2+
3+
from pydantic import model_validator
4+
5+
from eligibility_signposting_api.model.campaign_config import IterationRule, RuleAttributeLevel, RuleAttributeName
26

37

48
class IterationRuleValidation(IterationRule):
5-
pass
9+
@model_validator(mode="after")
10+
def check_cohort_attribute_name(self) -> Self:
11+
if (
12+
self.attribute_level == RuleAttributeLevel.COHORT
13+
and self.attribute_name
14+
and self.attribute_name != RuleAttributeName("COHORT_LABEL")
15+
):
16+
msg = "When attribute_level is COHORT, attribute_name must be COHORT_LABEL or None (default:COHORT_LABEL)"
17+
raise ValueError(msg)
18+
return self
Lines changed: 132 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,152 @@
11
import typing
22

3-
from pydantic import ValidationError, field_validator, model_validator
3+
from pydantic import Field, ValidationError, field_validator, model_validator
44
from pydantic_core import InitErrorDetails
55

6-
from eligibility_signposting_api.model.campaign_config import ActionsMapper, Iteration, IterationRule
7-
from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator
6+
from eligibility_signposting_api.model.campaign_config import (
7+
ActionsMapper,
8+
Iteration,
9+
IterationCohort,
10+
IterationRule,
11+
RuleType,
12+
)
13+
from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidation
14+
from rules_validation_api.validators.iteration_cohort_validator import IterationCohortValidation
815
from rules_validation_api.validators.iteration_rules_validator import IterationRuleValidation
916

1017

1118
class IterationValidation(Iteration):
12-
@classmethod
19+
iteration_cohorts: list[IterationCohort] = Field(..., alias="IterationCohorts")
20+
iteration_rules: list[IterationRule] = Field(..., alias="IterationRules")
21+
actions_mapper: ActionsMapper = Field(..., alias="ActionsMapper")
22+
1323
@field_validator("iteration_rules")
14-
def validate_iterations(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]:
24+
@classmethod
25+
def validate_iteration_rules(cls, iteration_rules: list[IterationRule]) -> list[IterationRuleValidation]:
1526
return [IterationRuleValidation(**i.model_dump()) for i in iteration_rules]
1627

28+
@field_validator("iteration_cohorts")
1729
@classmethod
30+
def validate_iteration_cohorts(cls, iteration_cohorts: list[IterationCohort]) -> list[IterationCohortValidation]:
31+
return [IterationCohortValidation(**i.model_dump()) for i in iteration_cohorts]
32+
1833
@field_validator("actions_mapper", mode="after")
34+
@classmethod
1935
def transform_actions_mapper(cls, action_mapper: ActionsMapper) -> ActionsMapper:
20-
ActionsMapperValidator.model_validate(action_mapper.model_dump())
36+
ActionsMapperValidation.model_validate(action_mapper.model_dump())
2137
return action_mapper
2238

2339
@model_validator(mode="after")
40+
def action_mapper_validation(self) -> typing.Self:
41+
all_errors = []
42+
43+
for validator in [
44+
self.validate_default_comms_routing_in_actions_mapper,
45+
self.validate_default_not_eligible_routing_in_actions_mapper,
46+
self.validate_default_not_actionable_routing_in_actions_mapper,
47+
self.validate_iteration_rules_against_actions_mapper,
48+
]:
49+
try:
50+
validator()
51+
except ValidationError as ve:
52+
all_errors.extend(ve.errors(include_input=False))
53+
54+
if all_errors:
55+
raise ValidationError.from_exception_data(title="IterationValidation", line_errors=all_errors)
56+
57+
return self
58+
2459
def validate_default_comms_routing_in_actions_mapper(self) -> typing.Self:
25-
default_routing = self.default_comms_routing
26-
actions_mapper = self.actions_mapper.root.keys()
27-
28-
if default_routing and (not actions_mapper or default_routing not in actions_mapper):
29-
error = InitErrorDetails(
30-
type="value_error",
31-
loc=("actions_mapper",),
32-
input=actions_mapper,
33-
ctx={"error": f"Missing entry for DefaultCommsRouting '{default_routing}' in ActionsMapper"},
34-
)
35-
raise ValidationError.from_exception_data(title="IterationValidation", line_errors=[error])
60+
default_routes = self.default_comms_routing
61+
actions_keys = list(self.actions_mapper.root.keys())
62+
line_errors = []
63+
64+
for routing in default_routes.split("|"):
65+
cleaned_routing = routing.strip()
66+
if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys):
67+
error = InitErrorDetails(
68+
type="value_error",
69+
loc=("actions_mapper",),
70+
input=actions_keys,
71+
ctx={"error": f"Missing entry for DefaultCommsRouting '{cleaned_routing}' in ActionsMapper"},
72+
)
73+
line_errors.append(error)
74+
75+
if line_errors:
76+
raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors)
77+
78+
return self
79+
80+
def validate_default_not_eligible_routing_in_actions_mapper(self) -> typing.Self:
81+
default_not_eligibile_routes = self.default_not_eligible_routing
82+
actions_keys = list(self.actions_mapper.root.keys())
83+
line_errors = []
84+
85+
for routing in default_not_eligibile_routes.split("|"):
86+
cleaned_routing = routing.strip()
87+
if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys):
88+
error = InitErrorDetails(
89+
type="value_error",
90+
loc=("actions_mapper",),
91+
input=actions_keys,
92+
ctx={"error": f"Missing entry for DefaultNotEligibleRouting '{cleaned_routing}' in ActionsMapper"},
93+
)
94+
line_errors.append(error)
95+
96+
if line_errors:
97+
raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors)
98+
99+
return self
100+
101+
def validate_default_not_actionable_routing_in_actions_mapper(self) -> typing.Self:
102+
default_not_actionable_routes = self.default_not_actionable_routing
103+
actions_keys = list(self.actions_mapper.root.keys())
104+
line_errors = []
105+
106+
for routing in default_not_actionable_routes.split("|"):
107+
cleaned_routing = routing.strip()
108+
if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys):
109+
error = InitErrorDetails(
110+
type="value_error",
111+
loc=("actions_mapper",),
112+
input=actions_keys,
113+
ctx={
114+
"error": f"Missing entry for DefaultNotActionableRouting '{cleaned_routing}' in ActionsMapper"
115+
},
116+
)
117+
line_errors.append(error)
118+
119+
if line_errors:
120+
raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors)
121+
122+
return self
123+
124+
def validate_iteration_rules_against_actions_mapper(self) -> typing.Self:
125+
actions_keys = list(self.actions_mapper.root.keys())
126+
line_errors = []
127+
128+
for rule in self.iteration_rules:
129+
if (
130+
rule.type
131+
in [
132+
RuleType.redirect,
133+
RuleType.not_actionable_actions,
134+
RuleType.not_eligible_actions,
135+
]
136+
and rule.comms_routing
137+
):
138+
for routing in rule.comms_routing.split("|"):
139+
cleaned_routing = routing.strip()
140+
if cleaned_routing and (not actions_keys or cleaned_routing not in actions_keys):
141+
error = InitErrorDetails(
142+
type="value_error",
143+
loc=("iteration_rules",),
144+
input=actions_keys,
145+
ctx={"error": f"Missing entry for CommsRouting '{cleaned_routing}' in ActionsMapper"},
146+
)
147+
line_errors.append(error)
148+
149+
if line_errors:
150+
raise ValidationError.from_exception_data(title="IterationValidation", line_errors=line_errors)
36151

37152
return self

src/rules_validation_api/validators/rules_validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
class RulesValidation(Rules):
8-
@classmethod
98
@field_validator("campaign_config")
9+
@classmethod
1010
def validate_campaign_config(cls, campaign_config: CampaignConfig) -> CampaignConfig:
1111
return CampaignConfigValidation(**campaign_config.model_dump())

tests/test_data/test_config/test_config.json

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,24 @@
88
"Manager": ["person@test.com"],
99
"Approver": ["person@test.com"],
1010
"Reviewer": ["person@test.com"],
11+
"StartDate": "20250101",
12+
"EndDate": "20260101",
13+
"ApprovalMinimum": 1,
14+
"ApprovalMaximum": 5000000,
1115
"IterationFrequency": "X",
1216
"IterationType": "M",
1317
"IterationTime": "07:00:00",
14-
"DefaultCommsRouting": "Default_Comms_1",
1518
"Iterations": [
1619
{
1720
"ID": "id_100",
21+
"Version": "1",
22+
"Name": "Test Config",
23+
"Type": "M",
24+
"IterationDate": "20250101",
25+
"IterationNumber": 1,
26+
"CommsType": "R",
27+
"ApprovalMinimum": 1,
28+
"ApprovalMaximum": 5000000,
1829
"DefaultCommsRouting": "INTERNALCONTACTGP1",
1930
"DefaultNotActionableRouting": "INTERNALCONTACTGP1",
2031
"DefaultNotEligibleRouting": "INTERNALCONTACTGP1",
@@ -119,20 +130,8 @@
119130
"Comparator": "19000101",
120131
"CommsRouting": "YRULEID1|INTERNALTESCO"
121132
}
122-
],
123-
"Version": "1",
124-
"Name": "Test Config",
125-
"Type": "M",
126-
"IterationDate": "20250101",
127-
"IterationNumber": 1,
128-
"CommsType": "R",
129-
"ApprovalMinimum": 1,
130-
"ApprovalMaximum": 5000000
133+
]
131134
}
132-
],
133-
"StartDate": "20250101",
134-
"EndDate": "20260101",
135-
"ApprovalMinimum": 1,
136-
"ApprovalMaximum": 5000000
135+
]
137136
}
138137
}

tests/unit/validation/conftest.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,12 @@ def valid_campaign_config_with_only_mandatory_fields():
2323
"ApprovalMinimum": 10,
2424
"ApprovalMaximum": 100,
2525
"Type": "A",
26-
"DefaultCommsRouting": "BOOK_NBS",
27-
"DefaultNotEligibleRouting": "RouteB",
28-
"DefaultNotActionableRouting": "RouteC",
26+
"DefaultCommsRouting": "",
27+
"DefaultNotEligibleRouting": "",
28+
"DefaultNotActionableRouting": "",
2929
"IterationCohorts": [],
3030
"IterationRules": [],
31-
"ActionsMapper": {
32-
"BOOK_NBS": {
33-
"ExternalRoutingCode": "BookNBS",
34-
"ActionDescription": "",
35-
"ActionType": "ButtonWithAuthLink",
36-
"UrlLink": "http://www.nhs.uk/book-rsv",
37-
"UrlLabel": "Continue to booking",
38-
}
39-
},
31+
"ActionsMapper": {},
4032
}
4133
],
4234
}

tests/unit/validation/test_actions_mapper_validator.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pydantic import ValidationError
33

44
from eligibility_signposting_api.model.campaign_config import AvailableAction
5-
from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidator
5+
from rules_validation_api.validators.actions_mapper_validator import ActionsMapperValidation
66

77

88
@pytest.fixture
@@ -25,16 +25,31 @@ def test_valid_actions_mapper(self, valid_available_action):
2525
"action1": self.make_action(valid_available_action),
2626
"action2": self.make_action({**valid_available_action, "ExternalRoutingCode": "AltCode"}),
2727
}
28-
mapper = ActionsMapperValidator(root=data)
28+
mapper = ActionsMapperValidation(root=data)
2929

3030
expected_action_count = 2
31-
assert isinstance(mapper, ActionsMapperValidator)
31+
assert isinstance(mapper, ActionsMapperValidation)
3232
assert len(mapper.root) == expected_action_count
3333

34+
@pytest.mark.parametrize(
35+
"invalid_action",
36+
[
37+
{"action1": ""},
38+
{"action1": "invalid_action"},
39+
{"action3": None},
40+
{"action1": "", "action3": None},
41+
{"action1": "invalid_action", "action2": ""},
42+
],
43+
)
44+
def test_if_exception_raised_when_adding_invalid_actions_to_action_mapper(self, invalid_action):
45+
data = {"": invalid_action}
46+
with pytest.raises(ValidationError):
47+
ActionsMapperValidation(root=data)
48+
3449
def test_invalid_actions_mapper_empty_key(self, valid_available_action):
3550
data = {"": self.make_action(valid_available_action), "action2": self.make_action(valid_available_action)}
3651
with pytest.raises(ValidationError) as exc_info:
37-
ActionsMapperValidator(root=data)
52+
ActionsMapperValidation(root=data)
3853
assert "Invalid keys found in ActionsMapper" in str(exc_info.value)
3954
assert "['']" in str(exc_info.value)
4055

@@ -45,5 +60,5 @@ def test_invalid_keys_parametrized(self, bad_key, valid_available_action):
4560
"valid_key": self.make_action(valid_available_action),
4661
}
4762
with pytest.raises(ValidationError) as exc_info:
48-
ActionsMapperValidator(root=data)
63+
ActionsMapperValidation(root=data)
4964
assert "Invalid keys found in ActionsMapper" in str(exc_info.value)

0 commit comments

Comments
 (0)