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
« 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"""
5import logging
6from dataclasses import dataclass
7from typing import Any, Optional
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)
20logger = logging.getLogger(__name__)
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"
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 )
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 )
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 )
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 )
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 )
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 )
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 )
133 async def is_enabled(self, feature_key: str, ctx: TargetingContext) -> bool:
134 result = await self.evaluate(feature_key, ctx)
135 return result.enabled
137 async def is_maintenance_mode(self, ctx: TargetingContext) -> bool:
138 return await self.is_enabled("platform.maintenance_mode", ctx)
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 )
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
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.
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".
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 )
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
208feature_evaluation_service = FeatureEvaluationService()