Coverage for server / services / growthbook / evaluation.py: 79%

71 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1""" 

2Feature evaluation — GrowthBook plan rules (district tier only; no per-district overrides). 

3""" 

4 

5import logging 

6from dataclasses import dataclass 

7from typing import Any, Optional 

8 

9from server.services.growthbook.client import growthbook_client 

10from server.services.growthbook.config import EvalMode, get_growthbook_settings 

11from server.services.growthbook.context import TargetingContext 

12from server.services.growthbook.overrides import FeatureSource 

13from server.services.growthbook.registry import ( 

14 get_feature_definition, 

15 plan_allows_feature, 

16 registry_default_enabled, 

17 resolve_feature_key, 

18) 

19 

20logger = logging.getLogger(__name__) 

21 

22 

23@dataclass 

24class FeatureEvaluationResult: 

25 feature_key: str 

26 enabled: bool 

27 source: FeatureSource 

28 growthbook_value: Optional[bool] = None 

29 legacy_value: Optional[bool] = None 

30 plan: str = "free" 

31 

32 

33class FeatureEvaluationService: 

34 async def evaluate( 

35 self, 

36 feature_key: str, 

37 ctx: TargetingContext, 

38 ) -> FeatureEvaluationResult: 

39 feature_key = resolve_feature_key(feature_key) or feature_key 

40 definition = get_feature_definition(feature_key) 

41 in_growthbook = growthbook_client.has_feature(feature_key) 

42 if definition is None and not in_growthbook: 

43 return FeatureEvaluationResult( 

44 feature_key=feature_key, 

45 enabled=False, 

46 source=FeatureSource.PLAN_DEFAULT, 

47 plan=ctx.plan, 

48 ) 

49 

50 settings = get_growthbook_settings() 

51 gb_ready = settings.growthbook_enabled and growthbook_client.is_ready 

52 gb_enabled: Optional[bool] = None 

53 if gb_ready and in_growthbook: 

54 gb_enabled = await growthbook_client.eval_boolean(feature_key, ctx) 

55 legacy_enabled = ( 

56 registry_default_enabled(feature_key, ctx.plan) 

57 if definition is not None 

58 else False 

59 ) 

60 

61 if settings.feature_flag_eval_mode == EvalMode.SHADOW: 

62 if gb_enabled is not None and gb_enabled != legacy_enabled: 

63 logger.info( 

64 "growthbook_shadow_mismatch", 

65 extra={ 

66 "feature_key": feature_key, 

67 "district_id": ctx.district_id, 

68 "school_id": ctx.school_id, 

69 "user_id": ctx.user_id, 

70 "plan": ctx.plan, 

71 "growthbook": gb_enabled, 

72 "legacy": legacy_enabled, 

73 }, 

74 ) 

75 enabled = legacy_enabled if definition is not None else bool(gb_enabled) 

76 return FeatureEvaluationResult( 

77 feature_key=feature_key, 

78 enabled=enabled, 

79 source=FeatureSource.PLAN_DEFAULT, 

80 growthbook_value=gb_enabled, 

81 legacy_value=legacy_enabled, 

82 plan=ctx.plan, 

83 ) 

84 

85 if settings.feature_flag_eval_mode == EvalMode.LEGACY: 

86 if definition is None: 

87 return FeatureEvaluationResult( 

88 feature_key=feature_key, 

89 enabled=False, 

90 source=FeatureSource.PLAN_DEFAULT, 

91 plan=ctx.plan, 

92 ) 

93 if not plan_allows_feature(feature_key, ctx.plan): 

94 return FeatureEvaluationResult( 

95 feature_key=feature_key, 

96 enabled=False, 

97 source=FeatureSource.PLAN_DEFAULT, 

98 plan=ctx.plan, 

99 ) 

100 return FeatureEvaluationResult( 

101 feature_key=feature_key, 

102 enabled=legacy_enabled, 

103 source=FeatureSource.PLAN_DEFAULT, 

104 legacy_value=legacy_enabled, 

105 plan=ctx.plan, 

106 ) 

107 

108 if gb_ready and in_growthbook and gb_enabled is not None: 

109 return FeatureEvaluationResult( 

110 feature_key=feature_key, 

111 enabled=gb_enabled, 

112 source=FeatureSource.GROWTHBOOK, 

113 growthbook_value=gb_enabled, 

114 plan=ctx.plan, 

115 ) 

116 

117 if definition is not None and not plan_allows_feature(feature_key, ctx.plan): 

118 return FeatureEvaluationResult( 

119 feature_key=feature_key, 

120 enabled=False, 

121 source=FeatureSource.PLAN_DEFAULT, 

122 plan=ctx.plan, 

123 ) 

124 

125 return FeatureEvaluationResult( 

126 feature_key=feature_key, 

127 enabled=legacy_enabled, 

128 source=FeatureSource.PLAN_DEFAULT, 

129 growthbook_value=gb_enabled, 

130 plan=ctx.plan, 

131 ) 

132 

133 async def is_enabled(self, feature_key: str, ctx: TargetingContext) -> bool: 

134 result = await self.evaluate(feature_key, ctx) 

135 return result.enabled 

136 

137 async def is_maintenance_mode(self, ctx: TargetingContext) -> bool: 

138 return await self.is_enabled("platform.maintenance_mode", ctx) 

139 

140 async def evaluate_all_for_context( 

141 self, 

142 ctx: TargetingContext, 

143 *, 

144 audience: Optional[str] = None, 

145 ) -> list[dict[str, Any]]: 

146 from server.services.growthbook.catalog import ( 

147 _audience_as_feature_type, 

148 _catalog_keys, 

149 ) 

150 

151 rows: list[dict[str, Any]] = [] 

152 for key in _catalog_keys(): 

153 definition = get_feature_definition(key) 

154 if ( 

155 audience is not None 

156 and _audience_as_feature_type(key, definition) != audience 

157 ): 

158 continue 

159 evaluation = await self.evaluate(key, ctx) 

160 rows.append( 

161 { 

162 "feature_key": key, 

163 "enabled": evaluation.enabled, 

164 "source": evaluation.source.value, 

165 } 

166 ) 

167 return rows 

168 

169 def disabled_catalog( 

170 self, *, audience: Optional[str] = None 

171 ) -> list[dict[str, Any]]: 

172 """The same catalog ``evaluate_all_for_context`` returns, entirely off. 

173 

174 For a caller with no district there is nothing to evaluate against — no 

175 plan, no override, no GrowthBook rule — so every answer is "off". This 

176 returns that answer in the SAME SHAPE as a real evaluation rather than 

177 an error or an empty list, because the shape is the contract: callers 

178 iterate the catalog and read ``enabled``, and a short list reads as 

179 "these features do not exist" rather than "you are not entitled". 

180 

181 An empty list would be actively misleading here: `features: []` is what 

182 this endpoint returned during the catalog-swap incident, i.e. the 

183 signature of a REGRESSION, not of an unentitled caller. 

184 """ 

185 from server.services.growthbook.catalog import ( 

186 _audience_as_feature_type, 

187 _catalog_keys, 

188 ) 

189 

190 rows: list[dict[str, Any]] = [] 

191 for key in _catalog_keys(): 

192 definition = get_feature_definition(key) 

193 if ( 

194 audience is not None 

195 and _audience_as_feature_type(key, definition) != audience 

196 ): 

197 continue 

198 rows.append( 

199 { 

200 "feature_key": key, 

201 "enabled": False, 

202 "source": FeatureSource.NO_SCOPE.value, 

203 } 

204 ) 

205 return rows 

206 

207 

208feature_evaluation_service = FeatureEvaluationService()