11import typing
2+ from datetime import datetime
23
3- from django .core .validators import MaxValueValidator
4+ from django .core .validators import MaxValueValidator , MinValueValidator
45from django .db import models
6+ from django .db .models import Q , QuerySet
57from django .utils import timezone
8+ from django_lifecycle import ( # type: ignore[import-untyped]
9+ BEFORE_CREATE ,
10+ LifecycleModelMixin ,
11+ hook ,
12+ )
613
714from audit .constants import (
815 RELEASE_PIPELINE_CREATED_MESSAGE ,
@@ -39,6 +46,7 @@ class StageActionType(models.TextChoices):
3946 "UPDATE_FEATURE_VALUE_FOR_SEGMENT" ,
4047 "Update Feature Value for a specific segment" ,
4148 )
49+ PHASED_ROLLOUT = ("PHASED_ROLLOUT" , "Create Phased Rollout" )
4250
4351
4452class ReleasePipeline (
@@ -72,7 +80,7 @@ def publish(self, published_by: FFAdminUser) -> None:
7280 self .published_by = published_by
7381 self .save ()
7482
75- def unpublish (self , unpublished_by : FFAdminUser ) -> None :
83+ def unpublish (self ) -> None :
7684 if self .published_at is None :
7785 raise InvalidPipelineStateError ("Pipeline is not published." )
7886 self .published_at = None
@@ -95,10 +103,25 @@ def get_delete_log_message(
95103 ) -> typing .Optional [str ]:
96104 return RELEASE_PIPELINE_DELETED_MESSAGE % self .name
97105
106+ def get_feature_versions_in_pipeline_qs (
107+ self ,
108+ ) -> QuerySet [EnvironmentFeatureVersion ]:
109+ base_qs = EnvironmentFeatureVersion .objects .filter (
110+ pipeline_stage__pipeline = self
111+ )
112+ phased_rollout_action_filter = Q (phased_rollout_state__isnull = False ) & Q (
113+ phased_rollout_state__is_rollout_complete = False
114+ )
115+ all_other_action_filters = Q (published_at__isnull = True )
116+ qs : QuerySet [EnvironmentFeatureVersion ] = base_qs .filter (
117+ all_other_action_filters | phased_rollout_action_filter
118+ )
119+ return qs
120+
98121 def has_feature_in_flight (self ) -> bool :
99- has_feature_in_flight : bool = EnvironmentFeatureVersion . objects . filter (
100- published_at__isnull = True , pipeline_stage__in = self .stages . all ()
101- ). exists ()
122+ has_feature_in_flight : bool = (
123+ self .get_feature_versions_in_pipeline_qs (). exists ()
124+ )
102125 return has_feature_in_flight
103126
104127 def _get_project (self ) -> Project :
@@ -134,6 +157,37 @@ def get_next_stage(self) -> "PipelineStage | None":
134157 .first ()
135158 )
136159
160+ def get_phased_rollout_action (self ) -> "PipelineStageAction | None" :
161+ return self .actions .filter (action_type = StageActionType .PHASED_ROLLOUT ).first ()
162+
163+ def get_in_stage_feature_versions_qs (self ) -> QuerySet [EnvironmentFeatureVersion ]:
164+ phased_rollout_action_filter = Q (
165+ phased_rollout_state__isnull = False ,
166+ phased_rollout_state__is_rollout_complete = False ,
167+ )
168+ all_other_action_filters = Q (
169+ published_at__isnull = True , phased_rollout_state__isnull = True
170+ )
171+
172+ return self .environment_feature_versions .filter (
173+ all_other_action_filters | phased_rollout_action_filter
174+ )
175+
176+ def get_completed_feature_versions_qs (
177+ self , completed_after : datetime = timezone .now ()
178+ ) -> QuerySet [EnvironmentFeatureVersion ]:
179+ phased_rollout_action_filter = Q (
180+ phased_rollout_state__is_rollout_complete = True ,
181+ phased_rollout_state__last_updated_at__gte = completed_after ,
182+ )
183+ all_other_action_filters = Q (
184+ published_at__gte = completed_after , phased_rollout_state__isnull = True
185+ )
186+
187+ return self .environment_feature_versions .filter (
188+ all_other_action_filters | phased_rollout_action_filter
189+ )
190+
137191
138192class PipelineStageTrigger (models .Model ):
139193 trigger_type = models .CharField (
@@ -162,3 +216,56 @@ class PipelineStageAction(models.Model):
162216 related_name = "actions" ,
163217 on_delete = models .CASCADE ,
164218 )
219+
220+
221+ class PhasedRolloutState (LifecycleModelMixin , models .Model ): # type: ignore[misc]
222+ initial_split = models .FloatField (
223+ validators = [
224+ MinValueValidator (0.0 ),
225+ MaxValueValidator (100.0 ),
226+ ]
227+ )
228+ increase_by = models .FloatField (
229+ validators = [
230+ MinValueValidator (0.0 ),
231+ MaxValueValidator (100.0 ),
232+ ]
233+ )
234+ increase_every = models .DurationField ()
235+ current_split = models .FloatField (
236+ validators = [
237+ MinValueValidator (0.0 ),
238+ MaxValueValidator (100.0 ),
239+ ]
240+ )
241+ rollout_segment = models .ForeignKey (
242+ "segments.Segment" ,
243+ related_name = "phased_rollout_state" ,
244+ on_delete = models .SET_NULL ,
245+ null = True ,
246+ blank = True ,
247+ )
248+ is_rollout_complete = models .BooleanField (default = False )
249+ last_updated_at = models .DateTimeField (auto_now = True )
250+
251+ @hook (BEFORE_CREATE ) # type: ignore[misc]
252+ def set_initial_split (self ) -> None :
253+ if self .current_split is None :
254+ self .current_split = self .initial_split
255+
256+ def increase_split (self ) -> float :
257+ self .current_split = min (self .current_split + self .increase_by , 100.0 )
258+ self .save ()
259+
260+ # Update the segment value
261+ condition = self .rollout_segment .rules .first ().conditions .first () # type: ignore[union-attr]
262+ assert condition
263+ condition .value = str (self .current_split )
264+ condition .save ()
265+ return self .current_split
266+
267+ def complete_rollout (self ) -> None :
268+ assert self .rollout_segment is not None
269+ self .rollout_segment .delete ()
270+ self .is_rollout_complete = True
271+ self .save ()
0 commit comments