Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
f02fb28
ELI-615 | campaign having recent - active start_date supersedes the o…
Karthikeyannhs Feb 27, 2026
582308a
ELI-615 | more linting
Karthikeyannhs Feb 27, 2026
f8d4987
ELI-615 | revert commit
Karthikeyannhs Mar 2, 2026
039d79c
ELI-615 | wip
Karthikeyannhs Mar 2, 2026
d62bca8
ELI-615 | wip
Karthikeyannhs Mar 2, 2026
7a68a62
ELI-615 | wip
Karthikeyannhs Mar 3, 2026
f85348b
Bump werkzeug from 3.1.5 to 3.1.6
dependabot[bot] Feb 26, 2026
a67ab1a
Updated not_member_of operator to NotMemberOf (#594)
oneeb-nhs Mar 2, 2026
b8e4e34
Added vulture to workflows (#585)
robbailiff2 Mar 3, 2026
1589520
ELI-615 | modified iterations_result to iteration result
Karthikeyannhs Mar 3, 2026
e97651d
ELI-615 | fix - naming issues | handle stop iter exception
Karthikeyannhs Mar 3, 2026
5aecbef
ELI-615 | campaign_configs - fixture updated | test case fixed
Karthikeyannhs Mar 3, 2026
29e566f
ELI-615 | fix flaky tests do to fixture scope
Karthikeyannhs Mar 3, 2026
5e2cf1c
ELI-615 | fix flaky tests - removed best status test
Karthikeyannhs Mar 3, 2026
4679ca1
ELI-615 | used raw campagin config for tests using iteration dates
Karthikeyannhs Mar 3, 2026
a1ec3f6
ELI-615 | fix - campaign group is used correctly
Karthikeyannhs Mar 3, 2026
ae0d2c7
ELI-615 | fix test_campaigns_grouped_by_condition_name_filters_correctly
Karthikeyannhs Mar 3, 2026
39ad107
ELI-615 | fix tests
Karthikeyannhs Mar 3, 2026
da3657b
ELI-615 | linting
Karthikeyannhs Mar 4, 2026
35d9638
ELI-615 | renamed best_iteration_result to iteration_result_summary
Karthikeyannhs Mar 4, 2026
5d6d092
ELI-615 | add more test cases - it tests
Karthikeyannhs Mar 4, 2026
e7ba9cc
Merge branch 'main' into ELI-615/multi-campaign-target-collision
Karthikeyannhs Mar 4, 2026
7def478
ELI-615 | test commit - try git leaks ignore
Karthikeyannhs Mar 4, 2026
d924d1c
Merge branch 'main' into ELI-615/multi-campaign-target-collision
Karthikeyannhs Mar 4, 2026
750a2c5
updated iteration time
oneeb-nhs Mar 4, 2026
55bdb2d
Eli 615 : fix - multi campaign target collision (#593)
Karthikeyannhs Mar 4, 2026
f786f89
ELI-674 | fix - vs code alignment anomalies
Karthikeyannhs Mar 4, 2026
a02f622
ELI-674 |wip - updated iteration_datetime property
Karthikeyannhs Mar 4, 2026
c50c037
ELI-674 - test_iteration_full_datetime_validation checks for datetime
Karthikeyannhs Mar 5, 2026
046faf4
updated iteration time
oneeb-nhs Mar 5, 2026
905a5d8
updated test cases for iteration_time
oneeb-nhs Mar 5, 2026
41be3ef
ELI-674 - fixed flaky test, which was due to iteration/campaign factory
Karthikeyannhs Mar 5, 2026
82e1171
ELI-674 - tweaks to validator
Karthikeyannhs Mar 6, 2026
3dd7db1
ELI-674 - tweaks to validator
Karthikeyannhs Mar 6, 2026
f83f492
ELI-674 - tweaks to validator
Karthikeyannhs Mar 6, 2026
48fee79
ELI-674 - tweaks to validator
Karthikeyannhs Mar 6, 2026
6d8e848
ELI-674 - tweaks to validator
Karthikeyannhs Mar 6, 2026
51abd89
reverted default_iteration_time to iteration_time
oneeb-nhs Mar 6, 2026
cc9873a
ELI-674 - fix errors
Karthikeyannhs Mar 8, 2026
fcc1fec
ELI-674 - new test case for validators
Karthikeyannhs Mar 8, 2026
de258e9
ELI-674 - linting
Karthikeyannhs Mar 9, 2026
47f65ab
ELI-674 - fixed unit tests
Karthikeyannhs Mar 9, 2026
65bc1e5
ELI-674 - fixed integration tests
Karthikeyannhs Mar 9, 2026
584b4c0
Create *.instructions.md
eddalmond1 Mar 4, 2026
9e6ce50
eja - refining instruction files using Github best practice prompt
eddalmond1 Mar 4, 2026
5940f3e
eja - fixing secret scan and vale
eddalmond1 Mar 4, 2026
c81afdd
added ignore for gitleaks as well as an allow for the file
eddalmond1 Mar 4, 2026
525c9e5
Eli 615 : fix - multi campaign target collision (#593)
Karthikeyannhs Mar 4, 2026
f053d99
eli-537 enabling WAF blocks
eddalmond1 Feb 27, 2026
48a0e9a
eli-537 deleting dev deployment
eddalmond1 Feb 27, 2026
a966f73
eli-537 added US to permitted geos, for preprod only, to allow github…
eddalmond1 Mar 2, 2026
fe2cbff
eli-537 amending rate limit
eddalmond1 Mar 3, 2026
deb6ba1
eli-537 minor changes based on initial review
eddalmond1 Mar 3, 2026
75b47b6
eli-537 removing Production from description as we also deploy to Pre…
eddalmond1 Mar 3, 2026
7414f0a
eli-537 adding ignore for gitleaks
eddalmond1 Mar 4, 2026
84d4b6b
Bump authlib from 1.6.6 to 1.6.7
dependabot[bot] Mar 5, 2026
10c66a4
ELI-615 | campaign having recent - active start_date supersedes the o…
Karthikeyannhs Feb 27, 2026
e72c9c3
ELI-615 | wip
Karthikeyannhs Mar 2, 2026
859b2b3
Added vulture to workflows (#585)
robbailiff2 Mar 3, 2026
dafe918
ELI-615 | campaign_configs - fixture updated | test case fixed
Karthikeyannhs Mar 3, 2026
73d584b
ELI-615 | renamed best_iteration_result to iteration_result_summary
Karthikeyannhs Mar 4, 2026
eb639c0
ELI-674 - revert - rebasing
Karthikeyannhs Mar 9, 2026
317201c
Merge branch 'main' into eli-674/support-active-iterations-at-specifi…
Karthikeyannhs Mar 9, 2026
b1af90d
Merge branch 'main' into eli-674/support-active-iterations-at-specifi…
robbailiff2 Mar 9, 2026
1a34211
ELI-674 - pytest for rules_validation/app.py
Karthikeyannhs Mar 9, 2026
e191e4a
ELI-674 - updated comments for sonar fix
Karthikeyannhs Mar 9, 2026
b4efff2
ELI-674 - sonar suppression
Karthikeyannhs Mar 9, 2026
62d3476
Revert "ELI-674 - sonar suppression"
Karthikeyannhs Mar 9, 2026
8826fa8
ELI-674 - linting suppression
Karthikeyannhs Mar 9, 2026
04ca579
ELI-674 - incorporate review comments - use @before for set_parent me…
Karthikeyannhs Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 85 additions & 33 deletions src/eligibility_signposting_api/model/campaign_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import re
import typing
from collections import Counter
from datetime import UTC, date, datetime
from datetime import UTC, date, datetime, time
from enum import StrEnum
from functools import cached_property
from operator import attrgetter
Expand Down Expand Up @@ -33,6 +33,7 @@
IterationVersion = NewType("IterationVersion", int)
IterationID = NewType("IterationID", str)
IterationDate = NewType("IterationDate", date)
IterationTime = NewType("IterationTime", time)
RuleName = NewType("RuleName", str)
RuleDescription = NewType("RuleDescription", str)
RulePriority = NewType("RulePriority", int)
Expand All @@ -50,6 +51,38 @@
RuleText = NewType("RuleText", str)


class DateUtil:
@staticmethod
def parse_date_yyyymmdd(v: str | date) -> date:
if isinstance(v, date):
return v
v_str = str(v)
if not re.fullmatch(r"\d{8}", v_str):
msg = f"Invalid format: {v_str}. Must be YYYYMMDD."
raise ValueError(msg)
try:
return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007
except ValueError as err:
msg = f"Invalid date value: {v_str}."
raise ValueError(msg) from err

@staticmethod
def parse_time_hhmmss(v: str | time | None) -> time | None:
if not v:
return None
if isinstance(v, time):
return v
v_str = str(v).strip()
if re.fullmatch(r"^\d{2}:\d{2}:\d{2}$", v_str):
try:
return datetime.strptime(v_str, "%H:%M:%S").time() # noqa: DTZ007
except ValueError as err:
msg = f"Invalid time value: {v_str}."
raise ValueError(msg) from err
msg = f"Invalid format: {v_str}. Must be HH:MM:SS."
raise ValueError(msg)


class RuleType(StrEnum):
filter = "F"
suppression = "S"
Expand Down Expand Up @@ -262,6 +295,7 @@ class Iteration(BaseModel):
version: IterationVersion = Field(..., alias="Version")
name: IterationName = Field(..., alias="Name")
iteration_date: IterationDate = Field(..., alias="IterationDate")
iteration_time: IterationTime | None = Field(default=None, alias="IterationTime")
iteration_number: int | None = Field(None, alias="IterationNumber")
approval_minimum: int | None = Field(None, alias="ApprovalMinimum")
approval_maximum: int | None = Field(None, alias="ApprovalMaximum")
Expand All @@ -277,35 +311,49 @@ class Iteration(BaseModel):

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

def __init__(self, **data: dict[str, typing.Any]) -> None:
super().__init__(**data)
# Ensure each rule knows its parent iteration
for rule in self.iteration_rules:
rule.set_parent(self)
@model_validator(mode="after")
def _link_parent_to_iteration_rules(self) -> typing.Self:
for iteration in self.iteration_rules:
iteration.set_parent(self)
return self

@field_validator("iteration_date", mode="before")
@classmethod
def parse_dates(cls, v: str | date) -> date:
if isinstance(v, date):
return v

v_str = str(v)
return DateUtil.parse_date_yyyymmdd(v)

if not re.fullmatch(r"\d{8}", v_str):
msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits."
raise ValueError(msg)

try:
return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007
except ValueError as err:
msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format."
raise ValueError(msg) from err
@field_validator("iteration_time", mode="before")
@classmethod
def parse_times(cls, v: str | time) -> time | None:
return DateUtil.parse_time_hhmmss(v)

@field_serializer("iteration_date", when_used="always")
@staticmethod
def serialize_dates(v: date, _info: SerializationInfo) -> str:
return v.strftime("%Y%m%d")

@field_serializer("iteration_time", when_used="always")
@staticmethod
def serialize_time(v: time, _info: SerializationInfo) -> str | None:
return v.strftime("%H:%M:%S") if v else None

_parent: CampaignConfig | None = PrivateAttr(default=None)

def set_parent(self, parent: CampaignConfig) -> None:
self._parent = parent

@cached_property
def iteration_datetime(self) -> datetime:
if self.iteration_time:
iteration_time = self.iteration_time
elif self._parent:
iteration_time = self._parent.iteration_time
else:
msg = f"No iteration_time and no parent linked for iteration {self.id}"
raise ValueError(msg)

return datetime.combine(self.iteration_date, iteration_time).replace(tzinfo=UTC)

def __str__(self) -> str:
return json.dumps(self.model_dump(by_alias=True), indent=2)

Expand All @@ -321,7 +369,7 @@ class CampaignConfig(BaseModel):
reviewer: list[str] | None = Field(None, alias="Reviewer")
iteration_frequency: Literal["X", "D", "W", "M", "Q", "A"] = Field(..., alias="IterationFrequency")
iteration_type: Literal["A", "M", "S", "O"] = Field(..., alias="IterationType")
iteration_time: str | None = Field(None, alias="IterationTime")
iteration_time: IterationTime = Field(default=IterationTime(time(0, 0, 0)), alias="IterationTime")
default_comms_routing: str | None = Field(None, alias="DefaultCommsRouting")
start_date: StartDate = Field(..., alias="StartDate")
end_date: EndDate = Field(..., alias="EndDate")
Expand All @@ -331,29 +379,33 @@ class CampaignConfig(BaseModel):

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

@model_validator(mode="after")
def _link_parent_to_iterations(self) -> typing.Self:
for iteration in self.iterations:
iteration.set_parent(self)

return self

@field_validator("start_date", "end_date", mode="before")
@classmethod
def parse_dates(cls, v: str | date) -> date:
if isinstance(v, date):
return v

v_str = str(v)

if not re.fullmatch(r"\d{8}", v_str):
msg = f"Invalid format: {v_str}. Must be YYYYMMDD with 8 digits."
raise ValueError(msg)
return DateUtil.parse_date_yyyymmdd(v)

try:
return datetime.strptime(v_str, "%Y%m%d").date() # noqa: DTZ007
except ValueError as err:
msg = f"Invalid date value: {v_str}. Must be a valid calendar date in YYYYMMDD format."
raise ValueError(msg) from err
@field_validator("iteration_time", mode="before")
@classmethod
def parse_times(cls, v: str | time) -> time | None:
return DateUtil.parse_time_hhmmss(v)

@field_serializer("start_date", "end_date", when_used="always")
@staticmethod
def serialize_dates(v: date, _info: SerializationInfo) -> str:
return v.strftime("%Y%m%d")

@field_serializer("iteration_time", when_used="always")
@staticmethod
def serialize_time(v: time, _info: SerializationInfo) -> str | None:
return v.strftime("%H:%M:%S") if v else None

@model_validator(mode="after")
def check_start_and_end_dates_sensible(self) -> typing.Self:
if self.start_date > self.end_date:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConf

for cc in active_campaigns:
try:
valid_items.append((cc.current_iteration.iteration_date, cc))
valid_items.append((cc.current_iteration.iteration_datetime, cc))
except StopIteration:
logger.info(
"Skipping campaign ID %s as no active iteration was found.",
Expand All @@ -41,13 +41,15 @@ def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConf
if not valid_items:
latest_campaign = None
else:
max_date = max(item[0] for item in valid_items)
cc_with_max_iteration_date: list[CampaignConfig] = [item[1] for item in valid_items if item[0] == max_date]
max_date_time = max(item[0] for item in valid_items)
cc_with_max_iteration_date: list[CampaignConfig] = [
item[1] for item in valid_items if item[0] == max_date_time
]
if len(cc_with_max_iteration_date) > 1:
err_msg = (
f"Ambiguous result: '{len(cc_with_max_iteration_date)}' active iterations "
f"for target {cc_with_max_iteration_date[0].target} "
f"found for date '{max_date}' "
f"found for datetime '{max_date_time}' "
f"across campaign(s) {[cc.id for cc in cc_with_max_iteration_date]}"
)
raise ValueError(err_msg)
Expand Down
66 changes: 55 additions & 11 deletions src/rules_validation_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import logging
import sys
from collections import defaultdict
from datetime import UTC, datetime
from operator import attrgetter
from pathlib import Path

from pydantic import ValidationError
Expand Down Expand Up @@ -74,18 +76,60 @@ def main() -> None: # pragma: no cover


def display_current_iteration(result: RulesValidation) -> None:
no_of_iterations = 0
try:
no_of_iterations = len(result.campaign_config.iterations)
current = result.campaign_config.current_iteration
except StopIteration:
current = None
if current is None:
sys.stdout.write(f"{YELLOW}No active iteration could be determined{RESET}\n")
sys.stdout.write(f"{YELLOW}Total iterations configured: {RESET}{GREEN}{no_of_iterations}{RESET}\n")
config = result.campaign_config
iterations = config.iterations
is_campaign_live = config.campaign_live
today = datetime.now(tz=UTC).date()

no_of_iterations = len(iterations)
is_campaign_expired = config.end_date < today

# ---- Current Iteration ----
if is_campaign_live:
sys.stdout.write(f"{YELLOW}Campaign is {RESET}{GREEN}LIVE{RESET}\n")
try:
current = config.current_iteration
if current:
sys.stdout.write(
f"{YELLOW}Current active Iteration Number: {RESET}{GREEN}{current.iteration_number}{RESET}\n"
)
tz = current.iteration_datetime.tzinfo
sys.stdout.write(
f"{YELLOW}Current active Iteration's date&time: "
f"{RESET}{GREEN}{current.iteration_datetime} ({tz}){RESET}\n"
)
except StopIteration:
sys.stdout.write(f"{YELLOW}No active iteration could be determined{RESET}\n")

else:
sys.stdout.write(f"{YELLOW}Current Iteration Number: {RESET}{GREEN}{current.iteration_number}{RESET}\n")
sys.stdout.write(f"{YELLOW}Total iterations configured: {RESET}{GREEN}{no_of_iterations}{RESET}\n")
sys.stdout.write(f"{YELLOW}Campaign is {RESET}{GREEN}NOT LIVE{RESET} ")

if is_campaign_expired:
sys.stdout.write(f"{YELLOW}[EXPIRED on {config.end_date}]{RESET}\n")
else:
sys.stdout.write(f"{YELLOW}[To be STARTED on {RESET}{GREEN}{config.start_date}{RESET}{YELLOW}]{RESET}\n")

# ---- Next Iteration ----
if not is_campaign_expired:
sorted_iterations = sorted(iterations, key=attrgetter("iteration_date"))

try:
next_iteration = next((i for i in sorted_iterations if i.iteration_date > today), None)

if next_iteration:
sys.stdout.write(
f"{YELLOW}Next active Iteration Number: {RESET}{GREEN}{next_iteration.iteration_number}{RESET}\n"
)
tz = next_iteration.iteration_datetime.tzinfo
sys.stdout.write(
f"{YELLOW}Next active Iteration's date&time: "
f"{RESET}{GREEN}{next_iteration.iteration_datetime} ({tz}){RESET}\n"
)
except StopIteration:
sys.stdout.write(f"{YELLOW}No next active iteration could be determined{RESET}\n")

# ---- Total Iterations ----
sys.stdout.write(f"{YELLOW}Total iterations configured: {RESET}{GREEN}{no_of_iterations}{RESET}\n")


if __name__ == "__main__": # pragma: no cover
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ def validate_iterations_have_unique_id(self) -> typing.Self:
raise ValueError(msg)
return self

@model_validator(mode="after")
def validate_iterations_have_unique_number(self) -> typing.Self:
numbers = [iteration.iteration_number for iteration in self.iterations]
duplicates = {i_id for i_id, count in Counter(numbers).items() if count > 1}
if duplicates:
msg = f"Iterations contain duplicate numbers: {', '.join(str(i) for i in duplicates)}"
raise ValueError(msg)
return self

@model_validator(mode="after")
def validate_campaign_has_iteration_within_schedule(self) -> typing.Self:
errors: list[str] = []
Expand Down
5 changes: 4 additions & 1 deletion tests/fixtures/builders/model/rule.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import UTC, date, datetime, timedelta
from datetime import UTC, date, datetime, time, timedelta
from operator import attrgetter
from random import randint

Expand All @@ -16,6 +16,7 @@
Iteration,
IterationCohort,
IterationRule,
IterationTime,
RuleAttributeLevel,
RuleAttributeName,
RuleComparator,
Expand Down Expand Up @@ -89,13 +90,15 @@ class IterationFactory(ModelFactory[Iteration]):
default_comms_routing = "defaultcomms"
actions_mapper = Use(ActionsMapperFactory.build)
rules_mapper = None
iteration_time = None


class RawCampaignConfigFactory(ModelFactory[CampaignConfig]):
iterations = Use(IterationFactory.batch, size=2)
id = "42-hi5tch-hi5kers-gu5ide-t2o-t3he-gal6axy"
start_date = Use(past_date)
end_date = Use(future_date)
iteration_time = IterationTime(time(0, 0, 0))


class CampaignConfigFactory(RawCampaignConfigFactory):
Expand Down
9 changes: 4 additions & 5 deletions tests/integration/in_process/test_eligibility_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@
from tests.integration.conftest import UNIQUE_CONSUMER_HEADER


def today():
def today() -> date:
return datetime.now(UTC).date()


def yesterday():
def yesterday() -> date:
return datetime.now(UTC).date() - timedelta(days=1)


def tomorrow():
def tomorrow() -> date:
return datetime.now(UTC).date() + timedelta(days=1)


Expand Down Expand Up @@ -1532,11 +1532,10 @@ def test_if_multiple_active_iterations_with_same_iteration_datetime_for_the_same
)
),
)

err_msg = (
"Ambiguous result: '2' active iterations "
"for target RSV "
f"found for date '{previous_day}' "
f"found for datetime '{previous_day} 00:00:00+00:00' "
"across campaign(s) ['RSV_campaign_id_1', 'RSV_campaign_id_2']"
)
assert any(err_msg in message for message in caplog.messages), (
Expand Down
Loading
Loading