Skip to content

Commit 3640df8

Browse files
Validate that a given campaign config has an iteration staring on or before the campaign itself.
1 parent 9ced404 commit 3640df8

5 files changed

Lines changed: 75 additions & 42 deletions

File tree

src/eligibility_signposting_api/model/rules.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ class CampaignConfig(BaseModel):
147147
end_date: EndDate = Field(..., alias="EndDate")
148148
approval_minimum: int | None = Field(None, alias="ApprovalMinimum")
149149
approval_maximum: int | None = Field(None, alias="ApprovalMaximum")
150-
iterations: list[Iteration] = Field(..., alias="Iterations")
150+
iterations: list[Iteration] = Field(..., min_length=1, alias="Iterations")
151151

152152
model_config = {"populate_by_name": True, "arbitrary_types_allowed": True, "extra": "ignore"}
153153

@@ -172,16 +172,31 @@ def check_no_overlapping_iterations(self) -> typing.Self:
172172
raise ValueError(message)
173173
return self
174174

175+
@model_validator(mode="after")
176+
def check_has_iteration_from_start(self) -> typing.Self:
177+
iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_date"))
178+
if first_iteration := next(iter(iterations_by_date), None):
179+
if first_iteration.iteration_date > self.start_date:
180+
message = (
181+
f"campaign {self.id} starts on {self.start_date}, "
182+
f"1st iteration starts later - {first_iteration.iteration_date}"
183+
)
184+
raise ValueError(message)
185+
return self
186+
# Should never happen, since we are constraining self.iterations with a min_length of 1
187+
message = f"campaign {self.id} has no iterations."
188+
raise ValueError(message)
189+
175190
@cached_property
176191
def campaign_live(self) -> bool:
177192
today = datetime.now(tz=UTC).date()
178193
return self.start_date <= today <= self.end_date
179194

180195
@cached_property
181-
def current_iteration(self) -> Iteration | None:
196+
def current_iteration(self) -> Iteration:
182197
today = datetime.now(tz=UTC).date()
183198
iterations_by_date_descending = sorted(self.iterations, key=attrgetter("iteration_date"), reverse=True)
184-
return next((i for i in iterations_by_date_descending if i.iteration_date <= today), None)
199+
return next(i for i in iterations_by_date_descending if i.iteration_date <= today)
185200

186201

187202
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/test_rules.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,34 @@
11
import pytest
2+
from dateutil.relativedelta import relativedelta
23
from faker import Faker
34

4-
from tests.fixtures.builders.model.rule import CampaignConfigFactory, IterationFactory
5+
from tests.fixtures.builders.model.rule import IterationFactory, RawCampaignConfigFactory
56

67

78
def test_iteration_with_overlapping_start_dates_not_allowed(faker: Faker):
89
# Given
9-
iteration_date = faker.date("%Y%m%d")
10-
iteration1 = IterationFactory.build(iteration_date=iteration_date)
11-
iteration2 = IterationFactory.build(iteration_date=iteration_date)
10+
start_date = faker.date_object()
11+
iteration1 = IterationFactory.build(iteration_date=start_date)
12+
iteration2 = IterationFactory.build(iteration_date=start_date)
1213

1314
# When, Then
1415
with pytest.raises(
1516
ValueError,
1617
match=r"1 validation error for CampaignConfig\n"
1718
r".*2 iterations with iteration date",
1819
):
19-
CampaignConfigFactory.build(iterations=[iteration1, iteration2])
20+
RawCampaignConfigFactory.build(start_date=start_date, iterations=[iteration1, iteration2])
21+
22+
23+
def test_iteration_must_have_active_iteration_from_its_start(faker: Faker):
24+
# Given
25+
start_date = faker.date_object()
26+
iteration = IterationFactory.build(iteration_date=start_date + relativedelta(days=1))
27+
28+
# When, Then
29+
with pytest.raises(
30+
ValueError,
31+
match=r"1 validation error for CampaignConfig\n"
32+
r".*1st iteration starts later",
33+
):
34+
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)