Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
84 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
13b13d0
enabled conversion of datetime to utc
oneeb-nhs Mar 9, 2026
30add33
ELI-716 | more test cases for time
Karthikeyannhs Mar 9, 2026
9dd79e9
ELI-716 | created start_time_utc, end_time_utc, iteration_datetime_utc
Karthikeyannhs Mar 10, 2026
6541d67
ELI-716 - Dateutil are functions now, not inside classes - review com…
Karthikeyannhs Mar 10, 2026
fa52abf
ELI-716 - iteration_datetime_utc is use for ordering current_iteration
Karthikeyannhs Mar 10, 2026
bdacdd0
ELI-716 - current iteration code updated
Karthikeyannhs Mar 10, 2026
75b247a
Merge branch 'main' into eli-716/convert-time-values-from-utc-to-gmt-bst
Karthikeyannhs Mar 11, 2026
85fecb0
ELI-716 | updated testcases and more linting
Karthikeyannhs Mar 11, 2026
e27a42e
ELI-716 | sonar suppression
Karthikeyannhs Mar 11, 2026
b58bf2d
ELI-716 | wip
Karthikeyannhs Mar 11, 2026
0770e6b
ELI-716 | wip
Karthikeyannhs Mar 11, 2026
2dde122
ELI-716 | wip
Karthikeyannhs Mar 11, 2026
6e7229a
ELI-716 | wip
Karthikeyannhs Mar 11, 2026
27d8cf0
ELI-716 | renames variables
Karthikeyannhs Mar 11, 2026
7a01436
ELI-716 | duplicated removed - sonar
Karthikeyannhs Mar 12, 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
55 changes: 55 additions & 0 deletions src/eligibility_signposting_api/common/date_util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import re
from collections.abc import Callable
from datetime import date, datetime, time
from zoneinfo import ZoneInfo


def _parse_with_format[T](
value: str,
regex: str,
fmt: str,
error_info: tuple[str, str],
transform: Callable[[datetime], T],
) -> T:
"""Shared logic for regex validation and datetime parsing."""
label, expected_format = error_info

if not re.fullmatch(regex, value):
msg = f"Invalid format: {value}. Must be {expected_format}."
raise ValueError(msg)
try:
dt = datetime.strptime(value, fmt) # noqa: DTZ007
return transform(dt)
except ValueError as err:
msg = f"Invalid {label} value: {value}."
raise ValueError(msg) from err


def convert_from_uk_to_utc(value: datetime | date) -> datetime:
if isinstance(value, date) and not isinstance(value, datetime):
value = datetime.combine(value, time.min)

uk = ZoneInfo("Europe/London")
utc = ZoneInfo("UTC")

if value.tzinfo is None:
value = value.replace(tzinfo=uk)
return value.astimezone(utc)


def parse_date_yyyymmdd(v: str | date) -> date:
if isinstance(v, date):
return v
# Pass the last two strings as a single tuple inside parentheses
return _parse_with_format(str(v), r"\d{8}", "%Y%m%d", ("date", "YYYYMMDD"), lambda dt: dt.date())


def parse_time_hhmmss(v: str | time | None) -> time | None:
if v is None:
return None
if isinstance(v, time):
return v
# Pass the last two strings as a single tuple inside parentheses
return _parse_with_format(
str(v).strip(), r"^\d{2}:\d{2}:\d{2}$", "%H:%M:%S", ("time", "HH:MM:SS"), lambda dt: dt.time()
)
51 changes: 34 additions & 17 deletions src/eligibility_signposting_api/model/campaign_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
model_validator,
)

from eligibility_signposting_api.common.date_util import convert_from_uk_to_utc, parse_date_yyyymmdd, parse_time_hhmmss
from eligibility_signposting_api.config.constants import ALLOWED_CONDITIONS, RULE_STOP_DEFAULT

if typing.TYPE_CHECKING: # pragma: no cover
Expand Down Expand Up @@ -294,8 +295,12 @@ class Iteration(BaseModel):
id: IterationID = Field(..., alias="ID")
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_date: IterationDate = Field(
..., alias="IterationDate", description="Iteration start date in Europe/London time Zone"
)
iteration_time: IterationTime | None = Field(
default=None, alias="IterationTime", description="Iteration start time in Europe/London time Zone"
)
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 @@ -320,12 +325,12 @@ def _link_parent_to_iteration_rules(self) -> typing.Self:
@field_validator("iteration_date", mode="before")
@classmethod
def parse_dates(cls, v: str | date) -> date:
return DateUtil.parse_date_yyyymmdd(v)
return parse_date_yyyymmdd(v)

@field_validator("iteration_time", mode="before")
@classmethod
def parse_times(cls, v: str | time) -> time | None:
return DateUtil.parse_time_hhmmss(v)
return parse_time_hhmmss(v)

@field_serializer("iteration_date", when_used="always")
@staticmethod
Expand All @@ -343,7 +348,7 @@ def set_parent(self, parent: CampaignConfig) -> None:
self._parent = parent

@cached_property
def iteration_datetime(self) -> datetime:
def iteration_datetime_utc(self) -> datetime:
if self.iteration_time:
iteration_time = self.iteration_time
elif self._parent:
Expand All @@ -352,7 +357,7 @@ def iteration_datetime(self) -> datetime:
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)
return convert_from_uk_to_utc(datetime.combine(self.iteration_date, iteration_time))

def __str__(self) -> str:
return json.dumps(self.model_dump(by_alias=True), indent=2)
Expand All @@ -369,32 +374,43 @@ 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: IterationTime = Field(default=IterationTime(time(0, 0, 0)), alias="IterationTime")
iteration_time: IterationTime = Field(
default=IterationTime(time(0, 0, 0)),
alias="IterationTime",
description="Default Iteration start time in Europe/London time Zone",
)
default_comms_routing: str | None = Field(None, alias="DefaultCommsRouting")
start_date: StartDate = Field(..., alias="StartDate")
end_date: EndDate = Field(..., alias="EndDate")
start_date: StartDate = Field(..., alias="StartDate", description="Campaign start date in Europe/London time Zone")
end_date: EndDate = Field(..., alias="EndDate", description="Campaign end date in Europe/London time Zone")
approval_minimum: int | None = Field(None, alias="ApprovalMinimum")
approval_maximum: int | None = Field(None, alias="ApprovalMaximum")
iterations: list[Iteration] = Field(..., min_length=1, alias="Iterations")

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

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

return self
@cached_property
def start_date_utc(self) -> datetime:
return convert_from_uk_to_utc(datetime.combine(self.start_date, time.min))

@cached_property
def end_date_utc(self) -> datetime:
return convert_from_uk_to_utc(datetime.combine(self.end_date, time.min))

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

@field_validator("iteration_time", mode="before")
@classmethod
def parse_times(cls, v: str | time) -> time | None:
return DateUtil.parse_time_hhmmss(v)
return parse_time_hhmmss(v)

@field_serializer("start_date", "end_date", when_used="always")
@staticmethod
Expand Down Expand Up @@ -436,13 +452,14 @@ def check_no_overlapping_iterations(self) -> typing.Self:
@cached_property
def campaign_live(self) -> bool:
today = datetime.now(tz=UTC).date()
return self.start_date <= today <= self.end_date
today_midnight = datetime.combine(today, time.min, tzinfo=UTC)
return self.start_date_utc <= today_midnight <= self.end_date_utc

@cached_property
def current_iteration(self) -> Iteration:
now = datetime.now(tz=UTC)
iterations_by_date_descending = sorted(self.iterations, key=attrgetter("iteration_datetime"), reverse=True)
return next(i for i in iterations_by_date_descending if i.iteration_datetime <= now)
iterations_by_date_descending = sorted(self.iterations, key=attrgetter("iteration_datetime_utc"), reverse=True)
return next(i for i in iterations_by_date_descending if i.iteration_datetime_utc <= now)

def __str__(self) -> str:
return json.dumps(self.model_dump(by_alias=True), indent=2)
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_datetime, cc))
valid_items.append((cc.current_iteration.iteration_datetime_utc, cc))
except StopIteration:
logger.info(
"Skipping campaign ID %s as no active iteration was found.",
Expand All @@ -42,19 +42,19 @@ def get_campaign_with_latest_iteration(self, active_campaigns: list[CampaignConf
latest_campaign = None
else:
max_date_time = max(item[0] for item in valid_items)
cc_with_max_iteration_date: list[CampaignConfig] = [
cc_with_max_iteration_date_time: list[CampaignConfig] = [
item[1] for item in valid_items if item[0] == max_date_time
]
if len(cc_with_max_iteration_date) > 1:
if len(cc_with_max_iteration_date_time) > 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"Ambiguous result: '{len(cc_with_max_iteration_date_time)}' active iterations "
f"for target {cc_with_max_iteration_date_time[0].target} "
f"found for datetime '{max_date_time}' "
f"across campaign(s) {[cc.id for cc in cc_with_max_iteration_date]}"
f"across campaign(s) {[cc.id for cc in cc_with_max_iteration_date_time]}"
)
raise ValueError(err_msg)

latest_campaign = cc_with_max_iteration_date[0]
latest_campaign = cc_with_max_iteration_date_time[0]

return latest_campaign

Expand Down
8 changes: 4 additions & 4 deletions src/rules_validation_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ def display_current_iteration(result: RulesValidation) -> None:
sys.stdout.write(
f"{YELLOW}Current active Iteration Number: {RESET}{GREEN}{current.iteration_number}{RESET}\n"
)
tz = current.iteration_datetime.tzinfo
tz = current.iteration_datetime_utc.tzinfo
sys.stdout.write(
f"{YELLOW}Current active Iteration's date&time: "
f"{RESET}{GREEN}{current.iteration_datetime} ({tz}){RESET}\n"
f"{RESET}{GREEN}{current.iteration_datetime_utc} ({tz}){RESET}\n"
)
except StopIteration:
sys.stdout.write(f"{YELLOW}No active iteration could be determined{RESET}\n")
Expand All @@ -120,10 +120,10 @@ def display_current_iteration(result: RulesValidation) -> None:
sys.stdout.write(
f"{YELLOW}Next active Iteration Number: {RESET}{GREEN}{next_iteration.iteration_number}{RESET}\n"
)
tz = next_iteration.iteration_datetime.tzinfo
tz = next_iteration.iteration_datetime_utc.tzinfo
sys.stdout.write(
f"{YELLOW}Next active Iteration's date&time: "
f"{RESET}{GREEN}{next_iteration.iteration_datetime} ({tz}){RESET}\n"
f"{RESET}{GREEN}{next_iteration.iteration_datetime_utc} ({tz}){RESET}\n"
)
except StopIteration:
sys.stdout.write(f"{YELLOW}No next active iteration could be determined{RESET}\n")
Expand Down
10 changes: 7 additions & 3 deletions src/rules_validation_api/validators/campaign_config_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,19 @@ def validate_iterations_have_unique_number(self) -> typing.Self:
@model_validator(mode="after")
def validate_campaign_has_iteration_within_schedule(self) -> typing.Self:
errors: list[str] = []
iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_date"))
for iteration in self.iterations:
if iteration._parent is None: # noqa : SLF001
iteration.set_parent(self)

iterations_by_date = sorted(self.iterations, key=attrgetter("iteration_datetime_utc"))

for idx, iteration in enumerate(iterations_by_date):
if iteration.iteration_date < self.start_date:
if iteration.iteration_datetime_utc < self.start_date_utc:
errors.append(
f"CampaignConfig.Iterations.{idx}.IterationDate : "
f"Starts before campaign start date {self.start_date} [type=invalid]"
)
if iteration.iteration_date > self.end_date:
if iteration.iteration_datetime_utc > self.end_date_utc:
errors.append(
f"CampaignConfig.Iterations.{idx}.IterationDate : "
f"Starts after campaign end date {self.end_date} [type=invalid]"
Expand Down
Loading
Loading