Skip to content

Commit 4694022

Browse files
Merge pull request #101 from NHSDigital/spike/campaign-config-validation
ELI-150 - Some campaign config validation
2 parents 25d2f0d + f951543 commit 4694022

File tree

6 files changed

+134
-38
lines changed

6 files changed

+134
-38
lines changed

src/eligibility_signposting_api/model/rules.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from __future__ import annotations
22

33
import typing
4+
from collections import Counter
45
from datetime import UTC, date, datetime
56
from enum import StrEnum
67
from functools import cached_property
78
from operator import attrgetter
89
from typing import Literal, NewType
910

10-
from pydantic import BaseModel, Field, field_serializer, field_validator
11+
from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
1112

1213
if typing.TYPE_CHECKING: # pragma: no cover
1314
from pydantic import SerializationInfo
@@ -146,7 +147,7 @@ class CampaignConfig(BaseModel):
146147
end_date: EndDate = Field(..., alias="EndDate")
147148
approval_minimum: int | None = Field(None, alias="ApprovalMinimum")
148149
approval_maximum: int | None = Field(None, alias="ApprovalMaximum")
149-
iterations: list[Iteration] = Field(..., alias="Iterations")
150+
iterations: list[Iteration] = Field(..., min_length=1, alias="Iterations")
150151

151152
model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"}
152153

@@ -162,16 +163,47 @@ def parse_dates(cls, v: str | date) -> date:
162163
def serialize_dates(v: date, _info: SerializationInfo) -> str:
163164
return v.strftime("%Y%m%d")
164165

166+
@model_validator(mode="after")
167+
def check_start_and_end_dates_sensible(self) -> typing.Self:
168+
if self.start_date > self.end_date:
169+
message = f"start date {self.start_date} after end date {self.end_date}"
170+
raise ValueError(message)
171+
return self
172+
173+
@model_validator(mode="after")
174+
def check_no_overlapping_iterations(self) -> typing.Self:
175+
iterations_by_date = Counter([i.iteration_date for i in self.iterations])
176+
if multiple_found := next(((d, c) for d, c in iterations_by_date.most_common() if c > 1), None):
177+
iteration_date, count = multiple_found
178+
message = f"{count} iterations with iteration date {iteration_date} in campaign {self.id}"
179+
raise ValueError(message)
180+
return self
181+
182+
@model_validator(mode="after")
183+
def check_has_iteration_from_start(self) -> typing.Self:
184+
iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_date"))
185+
if first_iteration := next(iter(iterations_by_date), None):
186+
if first_iteration.iteration_date > self.start_date:
187+
message = (
188+
f"campaign {self.id} starts on {self.start_date}, "
189+
f"1st iteration starts later - {first_iteration.iteration_date}"
190+
)
191+
raise ValueError(message)
192+
return self
193+
# Should never happen, since we are constraining self.iterations with a min_length of 1
194+
message = f"campaign {self.id} has no iterations."
195+
raise ValueError(message)
196+
165197
@cached_property
166198
def campaign_live(self) -> bool:
167199
today = datetime.now(tz=UTC).date()
168200
return self.start_date <= today <= self.end_date
169201

170202
@cached_property
171-
def current_iteration(self) -> Iteration | None:
203+
def current_iteration(self) -> Iteration:
172204
today = datetime.now(tz=UTC).date()
173205
iterations_by_date_descending = sorted(self.iterations, key=attrgetter("iteration_date"), reverse=True)
174-
return next((i for i in iterations_by_date_descending if i.iteration_date <= today), None)
206+
return next(i for i in iterations_by_date_descending if i.iteration_date <= today)
175207

176208

177209
class Rules(BaseModel):

src/eligibility_signposting_api/services/calculators/eligibility_calculator.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class EligibilityCalculator:
3232

3333
@property
3434
def active_campaigns(self) -> list[rules.CampaignConfig]:
35-
return [cc for cc in self.campaign_configs if cc.campaign_live and cc.current_iteration]
35+
return [cc for cc in self.campaign_configs if cc.campaign_live]
3636

3737
@property
3838
def campaigns_grouped_by_condition_name(
@@ -71,11 +71,8 @@ def get_the_base_eligible_campaigns(self, campaign_group: list[rules.CampaignCon
7171
return base_eligible_campaigns
7272
return []
7373

74-
def check_base_eligibility(self, iteration: rules.Iteration | None) -> bool:
74+
def check_base_eligibility(self, iteration: rules.Iteration) -> bool:
7575
"""Return cohorts for which person is base eligible."""
76-
77-
if not iteration:
78-
return False # pragma: no cover
7976
iteration_cohorts: set[str] = {
8077
cohort.cohort_label for cohort in iteration.iteration_cohorts if cohort.cohort_label
8178
}
@@ -100,7 +97,7 @@ def evaluate_eligibility_by_iteration_rules(
10097

10198
status_with_reasons: dict[eligibility.Status, list[eligibility.Reason]] = defaultdict()
10299

103-
for iteration in [cc.current_iteration for cc in campaign_group if cc.current_iteration]:
100+
for iteration in [cc.current_iteration for cc in campaign_group]:
104101
# Until we see a worse status, we assume someone is actionable for this iteration.
105102
worst_status = eligibility.Status.actionable
106103
exclusion_reasons, actionable_reasons = [], []

tests/fixtures/builders/model/rule.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import UTC, date, datetime, timedelta
2+
from operator import attrgetter
23
from random import randint
34

45
from polyfactory import Use
@@ -27,13 +28,44 @@ class IterationFactory(ModelFactory[rules.Iteration]):
2728
iteration_date = Use(past_date)
2829

2930

30-
class CampaignConfigFactory(ModelFactory[rules.CampaignConfig]):
31+
class RawCampaignConfigFactory(ModelFactory[rules.CampaignConfig]):
3132
iterations = Use(IterationFactory.batch, size=2)
3233

3334
start_date = Use(past_date)
3435
end_date = Use(future_date)
3536

3637

38+
class CampaignConfigFactory(RawCampaignConfigFactory):
39+
@classmethod
40+
def build(cls, **kwargs) -> rules.CampaignConfig:
41+
"""Ensure invariants are met:
42+
* no iterations with duplicate iteration dates
43+
* must have iteration active from campaign start date"""
44+
processed_kwargs = cls.process_kwargs(**kwargs)
45+
start_date: date = processed_kwargs["start_date"]
46+
iterations: list[rules.Iteration] = processed_kwargs["iterations"]
47+
48+
CampaignConfigFactory.fix_iteration_date_invariants(iterations, start_date)
49+
50+
data = super().build(**processed_kwargs).dict()
51+
return cls.__model__(**data)
52+
53+
@staticmethod
54+
def fix_iteration_date_invariants(iterations: list[rules.Iteration], start_date: date) -> None:
55+
iterations.sort(key=attrgetter("iteration_date"))
56+
iterations[0].iteration_date = start_date
57+
58+
seen: set[date] = set()
59+
previous: date = iterations[0].iteration_date
60+
for iteration in iterations:
61+
current = iteration.iteration_date if iteration.iteration_date >= previous else previous + timedelta(days=1)
62+
while current in seen:
63+
current += timedelta(days=1)
64+
seen.add(current)
65+
iteration.iteration_date = current
66+
previous = current
67+
68+
3769
class PersonAgeSuppressionRuleFactory(IterationRuleFactory):
3870
type = rules.RuleType.suppression
3971
name = rules.RuleName("Exclude too young less than 75")

tests/unit/model/__init__.py

Whitespace-only changes.

tests/unit/model/test_rules.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
from dateutil.relativedelta import relativedelta
3+
from faker import Faker
4+
5+
from tests.fixtures.builders.model.rule import IterationFactory, RawCampaignConfigFactory
6+
7+
8+
def test_campaign_must_have_at_least_one_iteration():
9+
# Given
10+
11+
# When, Then
12+
with pytest.raises(
13+
ValueError,
14+
match=r"1 validation error for CampaignConfig\n"
15+
r"iterations\n"
16+
r".*List should have at least 1 item",
17+
):
18+
RawCampaignConfigFactory.build(iterations=[])
19+
20+
21+
def test_campaign_start_date_must_be_before_end_date(faker: Faker):
22+
# Given
23+
start_date = faker.date_object()
24+
end_date = start_date - relativedelta(days=1)
25+
26+
# When, Then
27+
with pytest.raises(
28+
ValueError,
29+
match=r"1 validation error for CampaignConfig\n"
30+
r".*start date .* after end date",
31+
):
32+
RawCampaignConfigFactory.build(start_date=start_date, end_date=end_date)
33+
34+
35+
def test_iteration_with_overlapping_start_dates_not_allowed(faker: Faker):
36+
# Given
37+
start_date = faker.date_object()
38+
iteration1 = IterationFactory.build(iteration_date=start_date)
39+
iteration2 = IterationFactory.build(iteration_date=start_date)
40+
41+
# When, Then
42+
with pytest.raises(
43+
ValueError,
44+
match=r"1 validation error for CampaignConfig\n"
45+
r".*2 iterations with iteration date",
46+
):
47+
RawCampaignConfigFactory.build(start_date=start_date, iterations=[iteration1, iteration2])
48+
49+
50+
def test_iteration_must_have_active_iteration_from_its_start(faker: Faker):
51+
# Given
52+
start_date = faker.date_object()
53+
iteration = IterationFactory.build(iteration_date=start_date + relativedelta(days=1))
54+
55+
# When, Then
56+
with pytest.raises(
57+
ValueError,
58+
match=r"1 validation error for CampaignConfig\n"
59+
r".*1st iteration starts later",
60+
):
61+
RawCampaignConfigFactory.build(start_date=start_date, iterations=[iteration])

tests/unit/services/calculators/test_eligibility_calculator.py

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44
from faker import Faker
55
from freezegun import freeze_time
6-
from hamcrest import assert_that, contains_exactly, empty, has_item, has_items
6+
from hamcrest import assert_that, contains_exactly, has_item, has_items
77

88
from eligibility_signposting_api.model import rules
99
from eligibility_signposting_api.model import rules as rules_model
@@ -247,32 +247,6 @@ def test_simple_rule_only_excludes_from_live_iteration(faker: Faker):
247247
)
248248

249249

250-
@freeze_time("2025-04-25")
251-
def test_campaign_with_no_active_iteration_not_considered(faker: Faker):
252-
# Given
253-
nhs_number = NHSNumber(faker.nhs_number())
254-
255-
person_rows = person_rows_builder(nhs_number)
256-
campaign_configs = [
257-
rule_builder.CampaignConfigFactory.build(
258-
target="RSV",
259-
iterations=[
260-
rule_builder.IterationFactory.build(
261-
iteration_date=rules_model.IterationDate(datetime.date(2025, 4, 26)),
262-
)
263-
],
264-
)
265-
]
266-
267-
calculator = EligibilityCalculator(person_rows, campaign_configs)
268-
269-
# When
270-
actual = calculator.evaluate_eligibility()
271-
272-
# Then
273-
assert_that(actual, is_eligibility_status().with_conditions(empty()))
274-
275-
276250
@pytest.mark.parametrize(
277251
("rule_type", "expected_status"),
278252
[

0 commit comments

Comments
 (0)