Coverage for server / services / growthbook / registry.py: 89%

87 statements  

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

1""" 

2Canonical GrowthBook feature keys and per-plan enablement matrix. 

3 

4Keys must match feature IDs in GrowthBook exactly. 

5Tier exam access follows product UI spec (Free / Basic / Intermediate / Premium). 

6""" 

7 

8from dataclasses import dataclass 

9from typing import FrozenSet, Literal, Optional 

10 

11from server.validators.feature_enum import PLAN_TIER_ORDER, PlanEnum, normalize_plan_type 

12 

13Audience = Literal["platform", "teacher", "student", "staff"] 

14 

15_ALL_TIERS: FrozenSet[str] = frozenset( 

16 {PlanEnum.FREE.value, PlanEnum.BASIC.value, PlanEnum.INTERMEDIATE.value, PlanEnum.PREMIUM.value} 

17) 

18_INTERMEDIATE_UP: FrozenSet[str] = frozenset( 

19 {PlanEnum.INTERMEDIATE.value, PlanEnum.PREMIUM.value} 

20) 

21_BASIC_UP: FrozenSet[str] = frozenset( 

22 {PlanEnum.BASIC.value, PlanEnum.INTERMEDIATE.value, PlanEnum.PREMIUM.value} 

23) 

24_PREMIUM_ONLY: FrozenSet[str] = frozenset({PlanEnum.PREMIUM.value}) 

25 

26 

27@dataclass(frozen=True) 

28class FeatureDefinition: 

29 key: str 

30 description: str 

31 audience: Audience 

32 enabled_plans: FrozenSet[str] 

33 default_enabled: bool = True 

34 

35 

36def _plan_index(plan: str) -> int: 

37 normalized = normalize_plan_type(plan) 

38 return PLAN_TIER_ORDER.index(normalized) 

39 

40 

41def plan_meets_minimum(current_plan: str, minimum_plan: PlanEnum) -> bool: 

42 """Cumulative tier check (used for legacy/simple flags).""" 

43 try: 

44 return _plan_index(current_plan) >= _plan_index(minimum_plan.value) 

45 except ValueError: 

46 return False 

47 

48 

49def _staar_tsi_plans(slot: int) -> FrozenSet[str]: 

50 if slot == 1: 

51 return _ALL_TIERS 

52 if slot in (2, 3): 

53 return _BASIC_UP 

54 if slot in (4, 5): 

55 return _INTERMEDIATE_UP 

56 if slot == 6: 

57 return _PREMIUM_ONLY 

58 return frozenset() 

59 

60 

61def _sat_act_plans(slot: int) -> FrozenSet[str]: 

62 if slot == 1: 

63 return _ALL_TIERS 

64 if slot == 2: 

65 return _BASIC_UP 

66 if slot in (3, 4, 5): 

67 return _INTERMEDIATE_UP 

68 if slot == 6: 

69 return _PREMIUM_ONLY 

70 return frozenset() 

71 

72 

73def _build_exam_features() -> dict[str, FeatureDefinition]: 

74 features: dict[str, FeatureDefinition] = {} 

75 for slot in range(1, 7): 

76 features[f"teacher.exam.staar.{slot}"] = FeatureDefinition( 

77 key=f"teacher.exam.staar.{slot}", 

78 description=f"Erudition STAAR Math Practice Test {slot}", 

79 audience="teacher", 

80 enabled_plans=_staar_tsi_plans(slot), 

81 ) 

82 features[f"teacher.exam.tsi.{slot}"] = FeatureDefinition( 

83 key=f"teacher.exam.tsi.{slot}", 

84 description=f"Erudition TSI Math Practice Test {slot}", 

85 audience="teacher", 

86 enabled_plans=_staar_tsi_plans(slot), 

87 ) 

88 features[f"teacher.exam.sat.{slot}"] = FeatureDefinition( 

89 key=f"teacher.exam.sat.{slot}", 

90 description=f"Erudition SAT Math Practice Test {slot}", 

91 audience="teacher", 

92 enabled_plans=_sat_act_plans(slot), 

93 ) 

94 features[f"teacher.exam.act.{slot}"] = FeatureDefinition( 

95 key=f"teacher.exam.act.{slot}", 

96 description=f"Erudition ACT Math Practice Test {slot}", 

97 audience="teacher", 

98 enabled_plans=_sat_act_plans(slot), 

99 ) 

100 return features 

101 

102 

103FEATURE_REGISTRY: dict[str, FeatureDefinition] = { 

104 **_build_exam_features(), 

105 "platform.maintenance_mode": FeatureDefinition( 

106 key="platform.maintenance_mode", 

107 description="Global maintenance — HTTP 503 on gated routes when on.", 

108 audience="platform", 

109 enabled_plans=frozenset(), 

110 default_enabled=False, 

111 ), 

112 "teacher.item_analysis": FeatureDefinition( 

113 key="teacher.item_analysis", 

114 description="Item Analysis tab (Intermediate and Premium).", 

115 audience="teacher", 

116 enabled_plans=_INTERMEDIATE_UP, 

117 ), 

118 "teacher.teacher_made_tests.limit_10": FeatureDefinition( 

119 key="teacher.teacher_made_tests.limit_10", 

120 description="Up to 10 teacher-made assignments per class (quota enforced in domain code).", 

121 audience="teacher", 

122 enabled_plans=_INTERMEDIATE_UP, 

123 ), 

124 "teacher.teacher_made_tests.unlimited": FeatureDefinition( 

125 key="teacher.teacher_made_tests.unlimited", 

126 description="Unlimited teacher-made tests (Premium only; no quota cap).", 

127 audience="teacher", 

128 enabled_plans=_PREMIUM_ONLY, 

129 ), 

130 "teacher.dashboard": FeatureDefinition( 

131 key="teacher.dashboard", 

132 description="Teacher Data Dashboard.", 

133 audience="teacher", 

134 enabled_plans=frozenset({PlanEnum.PREMIUM.value}), 

135 ), 

136 "teacher.detailed_item_analysis": FeatureDefinition( 

137 key="teacher.detailed_item_analysis", 

138 description="Alias for teacher.item_analysis.", 

139 audience="teacher", 

140 enabled_plans=_INTERMEDIATE_UP, 

141 ), 

142 "teacher.teacher_made_tests": FeatureDefinition( 

143 key="teacher.teacher_made_tests", 

144 description="Alias for teacher.teacher_made_tests.limit_10.", 

145 audience="teacher", 

146 enabled_plans=_INTERMEDIATE_UP, 

147 ), 

148 "teacher.create_own_questions": FeatureDefinition( 

149 key="teacher.create_own_questions", 

150 description="Create new questions in the teacher question bank.", 

151 audience="teacher", 

152 enabled_plans=_INTERMEDIATE_UP, 

153 ), 

154 "teacher.bulk_upload_questions": FeatureDefinition( 

155 key="teacher.bulk_upload_questions", 

156 description="Import questions in bulk from a file (CSV today; documents later).", 

157 audience="teacher", 

158 enabled_plans=_INTERMEDIATE_UP, 

159 ), 

160 "teacher.erudition_tests_library": FeatureDefinition( 

161 key="teacher.erudition_tests_library", 

162 description="Access Erudition tests library — staff-created questions/tests (Intermediate and Premium).", 

163 audience="teacher", 

164 enabled_plans=_INTERMEDIATE_UP, 

165 ), 

166 "student.assignment_access": FeatureDefinition( 

167 key="student.assignment_access", 

168 description="Student assignment access.", 

169 audience="student", 

170 enabled_plans=_ALL_TIERS, 

171 ), 

172 "student.assignment_submission": FeatureDefinition( 

173 key="student.assignment_submission", 

174 description="Student assignment submission.", 

175 audience="student", 

176 enabled_plans=_ALL_TIERS, 

177 ), 

178 "student.assignment_review": FeatureDefinition( 

179 key="student.assignment_review", 

180 description="Student assignment review.", 

181 audience="student", 

182 enabled_plans=_ALL_TIERS, 

183 ), 

184 "student.theme_management": FeatureDefinition( 

185 key="student.theme_management", 

186 description="Student theme management.", 

187 audience="student", 

188 enabled_plans=_BASIC_UP, 

189 ), 

190} 

191 

192 

193# Renamed in GrowthBook (2026): teacher.global_staff_tests → teacher.erudition_tests_library 

194FEATURE_KEY_ALIASES: dict[str, str] = { 

195 "teacher.global_staff_tests": "teacher.erudition_tests_library", 

196} 

197 

198 

199def resolve_feature_key(feature_key: Optional[str]) -> Optional[str]: 

200 """Map legacy feature keys to their canonical registry/GrowthBook id.""" 

201 if not feature_key or not str(feature_key).strip(): 

202 return None 

203 normalized = str(feature_key).strip().lower() 

204 return FEATURE_KEY_ALIASES.get(normalized, normalized) 

205 

206 

207def get_feature_definition(feature_key: str) -> Optional[FeatureDefinition]: 

208 canonical = resolve_feature_key(feature_key) or feature_key 

209 return FEATURE_REGISTRY.get(canonical) 

210 

211 

212def plan_allows_feature(feature_key: str, plan: str) -> bool: 

213 """True when the district plan includes this feature (tier UI matrix).""" 

214 canonical = resolve_feature_key(feature_key) or feature_key 

215 definition = get_feature_definition(canonical) 

216 if definition is None: 

217 return False 

218 normalized = normalize_plan_type(plan) 

219 if normalized not in FEATURE_PLAN_MATRIX.get(canonical, definition.enabled_plans): 

220 return False 

221 return definition.default_enabled 

222 

223 

224FEATURE_PLAN_MATRIX: dict[str, FrozenSet[str]] = { 

225 key: definition.enabled_plans for key, definition in FEATURE_REGISTRY.items() 

226} 

227 

228 

229def registry_default_enabled(feature_key: str, plan: str) -> bool: 

230 return plan_allows_feature(feature_key, plan) 

231 

232 

233def all_feature_keys_for_audience(audience: Audience) -> list[str]: 

234 return [ 

235 definition.key 

236 for definition in FEATURE_REGISTRY.values() 

237 if definition.audience == audience 

238 ] 

239 

240 

241def features_for_plan(plan: str) -> list[str]: 

242 """All feature keys enabled for a plan.""" 

243 normalized = normalize_plan_type(plan) 

244 return [ 

245 key 

246 for key, plans in FEATURE_PLAN_MATRIX.items() 

247 if normalized in plans and FEATURE_REGISTRY[key].default_enabled 

248 ] 

249 

250 

251def min_plan_for_feature(feature_key: str) -> Optional[str]: 

252 """Lowest tier that enables the feature (for display).""" 

253 canonical = resolve_feature_key(feature_key) or feature_key 

254 plans = FEATURE_PLAN_MATRIX.get(canonical, frozenset()) 

255 if not plans: 

256 return None 

257 for tier in PLAN_TIER_ORDER: 

258 if tier in plans: 

259 return tier 

260 return None