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
« 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.
4Keys must match feature IDs in GrowthBook exactly.
5Tier exam access follows product UI spec (Free / Basic / Intermediate / Premium).
6"""
8from dataclasses import dataclass
9from typing import FrozenSet, Literal, Optional
11from server.validators.feature_enum import PLAN_TIER_ORDER, PlanEnum, normalize_plan_type
13Audience = Literal["platform", "teacher", "student", "staff"]
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})
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
36def _plan_index(plan: str) -> int:
37 normalized = normalize_plan_type(plan)
38 return PLAN_TIER_ORDER.index(normalized)
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
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()
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()
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
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}
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}
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)
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)
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
224FEATURE_PLAN_MATRIX: dict[str, FrozenSet[str]] = {
225 key: definition.enabled_plans for key, definition in FEATURE_REGISTRY.items()
226}
229def registry_default_enabled(feature_key: str, plan: str) -> bool:
230 return plan_allows_feature(feature_key, plan)
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 ]
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 ]
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