11from __future__ import annotations
22
33import json
4- import re
54import typing
65from collections import Counter
7- from datetime import UTC , date , datetime , time
6+ from datetime import date , datetime , time
87from enum import StrEnum
98from functools import cached_property
109from operator import attrgetter
2120 model_validator ,
2221)
2322
23+ from eligibility_signposting_api .common .date_util import (
24+ UK_TIMEZONE ,
25+ datetime_with_uk_timezone ,
26+ now_uk ,
27+ parse_date_yyyymmdd ,
28+ parse_time_hhmmss ,
29+ )
2430from eligibility_signposting_api .config .constants import ALLOWED_CONDITIONS , RULE_STOP_DEFAULT
2531
2632if typing .TYPE_CHECKING : # pragma: no cover
2733 from pydantic import SerializationInfo
2834
35+
2936CampaignName = NewType ("CampaignName" , str )
3037CampaignVersion = NewType ("CampaignVersion" , int )
3138CampaignID = NewType ("CampaignID" , str )
5158RuleText = NewType ("RuleText" , str )
5259
5360
54- class DateUtil :
55- @staticmethod
56- def parse_date_yyyymmdd (v : str | date ) -> date :
57- if isinstance (v , date ):
58- return v
59- v_str = str (v )
60- if not re .fullmatch (r"\d{8}" , v_str ):
61- msg = f"Invalid format: { v_str } . Must be YYYYMMDD."
62- raise ValueError (msg )
63- try :
64- return datetime .strptime (v_str , "%Y%m%d" ).date () # noqa: DTZ007
65- except ValueError as err :
66- msg = f"Invalid date value: { v_str } ."
67- raise ValueError (msg ) from err
68-
69- @staticmethod
70- def parse_time_hhmmss (v : str | time | None ) -> time | None :
71- if not v :
72- return None
73- if isinstance (v , time ):
74- return v
75- v_str = str (v ).strip ()
76- if re .fullmatch (r"^\d{2}:\d{2}:\d{2}$" , v_str ):
77- try :
78- return datetime .strptime (v_str , "%H:%M:%S" ).time () # noqa: DTZ007
79- except ValueError as err :
80- msg = f"Invalid time value: { v_str } ."
81- raise ValueError (msg ) from err
82- msg = f"Invalid format: { v_str } . Must be HH:MM:SS."
83- raise ValueError (msg )
84-
85-
8661class RuleType (StrEnum ):
8762 filter = "F"
8863 suppression = "S"
@@ -294,8 +269,12 @@ class Iteration(BaseModel):
294269 id : IterationID = Field (..., alias = "ID" )
295270 version : IterationVersion = Field (..., alias = "Version" )
296271 name : IterationName = Field (..., alias = "Name" )
297- iteration_date : IterationDate = Field (..., alias = "IterationDate" )
298- iteration_time : IterationTime | None = Field (default = None , alias = "IterationTime" )
272+ iteration_date : IterationDate = Field (
273+ ..., alias = "IterationDate" , description = "Iteration start date in Europe/London time Zone"
274+ )
275+ iteration_time : IterationTime | None = Field (
276+ default = None , alias = "IterationTime" , description = "Iteration start time in Europe/London time Zone"
277+ )
299278 iteration_number : int | None = Field (None , alias = "IterationNumber" )
300279 approval_minimum : int | None = Field (None , alias = "ApprovalMinimum" )
301280 approval_maximum : int | None = Field (None , alias = "ApprovalMaximum" )
@@ -319,13 +298,14 @@ def _link_parent_to_iteration_rules(self) -> typing.Self:
319298
320299 @field_validator ("iteration_date" , mode = "before" )
321300 @classmethod
322- def parse_dates (cls , v : str | date ) -> date :
323- return DateUtil .parse_date_yyyymmdd (v )
301+ def parse_dates_as_uk_local (cls , v : str | date ) -> date :
302+ parsed_date = parse_date_yyyymmdd (v )
303+ return datetime .combine (parsed_date , time .min ).replace (tzinfo = UK_TIMEZONE ).date ()
324304
325305 @field_validator ("iteration_time" , mode = "before" )
326306 @classmethod
327- def parse_times (cls , v : str | time ) -> time | None :
328- return DateUtil . parse_time_hhmmss (v )
307+ def parse_times_as_uk_local (cls , v : str | time ) -> time | None :
308+ return parse_time_hhmmss (v )
329309
330310 @field_serializer ("iteration_date" , when_used = "always" )
331311 @staticmethod
@@ -344,6 +324,9 @@ def set_parent(self, parent: CampaignConfig) -> None:
344324
345325 @cached_property
346326 def iteration_datetime (self ) -> datetime :
327+ """iteration_datetime is the datetime of the iteration,
328+ including the iteration_time if set, otherwise the parent's iteration_time.
329+ the return type is datetime in Europe/London time zone."""
347330 if self .iteration_time :
348331 iteration_time = self .iteration_time
349332 elif self ._parent :
@@ -352,7 +335,7 @@ def iteration_datetime(self) -> datetime:
352335 msg = f"No iteration_time and no parent linked for iteration { self .id } "
353336 raise ValueError (msg )
354337
355- return datetime .combine (self .iteration_date , iteration_time ). replace ( tzinfo = UTC )
338+ return datetime_with_uk_timezone ( datetime .combine (self .iteration_date , iteration_time ))
356339
357340 def __str__ (self ) -> str :
358341 return json .dumps (self .model_dump (by_alias = True ), indent = 2 )
@@ -369,10 +352,14 @@ class CampaignConfig(BaseModel):
369352 reviewer : list [str ] | None = Field (None , alias = "Reviewer" )
370353 iteration_frequency : Literal ["X" , "D" , "W" , "M" , "Q" , "A" ] = Field (..., alias = "IterationFrequency" )
371354 iteration_type : Literal ["A" , "M" , "S" , "O" ] = Field (..., alias = "IterationType" )
372- iteration_time : IterationTime = Field (default = IterationTime (time (0 , 0 , 0 )), alias = "IterationTime" )
355+ iteration_time : IterationTime = Field (
356+ default = IterationTime (time (0 , 0 , 0 )),
357+ alias = "IterationTime" ,
358+ description = "Default Iteration start time in Europe/London time Zone" ,
359+ )
373360 default_comms_routing : str | None = Field (None , alias = "DefaultCommsRouting" )
374- start_date : StartDate = Field (..., alias = "StartDate" )
375- end_date : EndDate = Field (..., alias = "EndDate" )
361+ start_date : StartDate = Field (..., alias = "StartDate" , description = "Campaign start date in Europe/London time Zone" )
362+ end_date : EndDate = Field (..., alias = "EndDate" , description = "Campaign end date in Europe/London time Zone" )
376363 approval_minimum : int | None = Field (None , alias = "ApprovalMinimum" )
377364 approval_maximum : int | None = Field (None , alias = "ApprovalMaximum" )
378365 iterations : list [Iteration ] = Field (..., min_length = 1 , alias = "Iterations" )
@@ -388,13 +375,14 @@ def _link_parent_to_iterations(self) -> typing.Self:
388375
389376 @field_validator ("start_date" , "end_date" , mode = "before" )
390377 @classmethod
391- def parse_dates (cls , v : str | date ) -> date :
392- return DateUtil .parse_date_yyyymmdd (v )
378+ def parse_dates_as_uk_local (cls , v : str | date ) -> date :
379+ parsed_date = parse_date_yyyymmdd (v )
380+ return datetime .combine (parsed_date , time .min ).replace (tzinfo = UK_TIMEZONE ).date ()
393381
394382 @field_validator ("iteration_time" , mode = "before" )
395383 @classmethod
396- def parse_times (cls , v : str | time ) -> time | None :
397- return DateUtil . parse_time_hhmmss (v )
384+ def parse_times_as_uk_local (cls , v : str | time ) -> time | None :
385+ return parse_time_hhmmss (v )
398386
399387 @field_serializer ("start_date" , "end_date" , when_used = "always" )
400388 @staticmethod
@@ -435,14 +423,12 @@ def check_no_overlapping_iterations(self) -> typing.Self:
435423
436424 @cached_property
437425 def campaign_live (self ) -> bool :
438- today = datetime .now (tz = UTC ).date ()
439- return self .start_date <= today <= self .end_date
426+ return self .start_date <= now_uk ().date () <= self .end_date
440427
441428 @cached_property
442429 def current_iteration (self ) -> Iteration :
443- now = datetime .now (tz = UTC )
444430 iterations_by_date_descending = sorted (self .iterations , key = attrgetter ("iteration_datetime" ), reverse = True )
445- return next (i for i in iterations_by_date_descending if i .iteration_datetime <= now )
431+ return next (i for i in iterations_by_date_descending if i .iteration_datetime <= now_uk () )
446432
447433 def __str__ (self ) -> str :
448434 return json .dumps (self .model_dump (by_alias = True ), indent = 2 )
0 commit comments