11from __future__ import annotations
22
33import typing
4+ from collections import Counter
45from datetime import UTC , date , datetime
56from enum import StrEnum
67from functools import cached_property
78from operator import attrgetter
89from 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
1213if 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
177209class Rules (BaseModel ):
0 commit comments