Coverage for server / routes / student / student_features.py: 86%
42 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
1from typing import Annotated
3from fastapi import APIRouter, Depends, HTTPException, Request, status
5from server.authentication.auth0_bearer import Auth0Bearer
6from server.services.growthbook import (
7 feature_evaluation_service,
8 get_targeting_context,
9 resolve_targeting_context,
10)
11from server.services.growthbook.catalog import feature_exists
12from server.services.growthbook.overrides import FeatureSource
13from server.services.growthbook.organization import (
14 build_district_plan_payload,
15 build_organization_payload,
16 build_school_payload,
17)
18from server.validators.feature_enum import PLAN_DISPLAY_NAMES
20router = APIRouter()
23def _effective_features_response(ctx, features: list) -> dict:
24 plan = ctx.plan
25 return {
26 "district_id": ctx.district_id,
27 "school_id": ctx.school_id,
28 "current_plan": plan,
29 "plan_name": PLAN_DISPLAY_NAMES.get(plan, plan.title()),
30 "features": features,
31 }
34def _no_scope_response(ctx) -> dict:
35 """What a student with no district sees: the catalog, entirely off."""
36 return {
37 "district_id": None,
38 "school_id": ctx.school_id,
39 "current_plan": None,
40 "plan_name": None,
41 "features": feature_evaluation_service.disabled_catalog(audience="student"),
42 }
45@router.get(
46 "/effective",
47 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))],
48 status_code=status.HTTP_200_OK,
49 summary="Effective feature flags for the authenticated student",
50)
51async def effective_features(request: Request) -> dict:
52 """Report this student's entitlements. Never errors for want of scope.
54 THIS IS A READ, AND IT IS THE ONLY THING THIS CHANGE RELAXES. A student
55 whose profile carries no school_id used to get 403 here, which is wrong on
56 two counts.
58 It is wrong by HTTP semantics: 403 means the server understood and REFUSES
59 (RFC 9110 15.5.4) — a decision of "no". Nothing was refused here; we simply
60 could not evaluate, because the account has no district to evaluate
61 against. That is a data-completeness condition, not an authorization one.
63 It is wrong by the conventions of this endpoint's own domain. Every major
64 flag platform — and OpenFeature's specification, requirement 1.4.10 —
65 requires evaluation to return DEFAULTS rather than error when the targeting
66 context is incomplete; OpenFeature names this exact condition
67 TARGETING_KEY_MISSING. A flag read that errors forces every client into an
68 error state for what is a routine, expected case.
70 It was also actively harmful here. The SPA deliberately fails OPEN when this
71 call errors, so that a transient outage does not lock a paying district out
72 of what it bought. An unprovisioned student hit that path *permanently*, and
73 was handed a fully unlocked UI in which every single action then 403'd. The
74 worst of both answers. Returning "everything off" removes the error state
75 for that population, so the fail-open is left doing only the job it was
76 written for.
78 ENFORCEMENT IS UNCHANGED. require_feature() still calls
79 resolve_targeting_context(require_district=True) and still refuses a
80 scope-less student on every gated route. This endpoint reports; it does not
81 grant. All four student features are gated that way, so a student reading
82 "off" here and a student being refused there are the same answer.
83 """
84 ctx = await resolve_targeting_context(request, require_district=False)
85 if not ctx.district_id:
86 return _no_scope_response(ctx)
88 features = await feature_evaluation_service.evaluate_all_for_context(
89 ctx,
90 audience="student",
91 )
92 return _effective_features_response(ctx, features)
95@router.get(
96 "/district/features",
97 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))],
98 status_code=status.HTTP_200_OK,
99 summary="List effective district features for the authenticated student",
100 include_in_schema=False,
101)
102async def list_district_features(request: Request) -> dict:
103 return await effective_features(request)
106@router.get(
107 "/district/features/{feature_name}/status",
108 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))],
109 status_code=status.HTTP_200_OK,
110 summary="Get effective status for a single feature",
111)
112async def feature_status(
113 request: Request,
114 feature_name: Annotated[
115 str, "GrowthBook feature key, e.g. student.assignment_access"
116 ],
117) -> dict:
118 if not feature_exists(feature_name):
119 raise HTTPException(
120 status_code=status.HTTP_404_NOT_FOUND,
121 detail=f"Unknown feature key: {feature_name}",
122 )
123 # An account with no district is not REFUSED this answer — there is simply
124 # nothing to evaluate against, so the honest answer is "off" (ADR-002 #3;
125 # OpenFeature 1.4.10 requires defaults, not an error, for an incomplete
126 # targeting context). Enforcement is unchanged: require_feature() still
127 # refuses a scope-less caller on every gated route.
128 ctx = await resolve_targeting_context(request, require_district=False)
129 if not ctx.district_id:
130 return {
131 "feature_key": feature_name,
132 "enabled": False,
133 "source": FeatureSource.NO_SCOPE.value,
134 }
136 result = await feature_evaluation_service.evaluate(feature_name, ctx)
137 return {
138 "feature_key": result.feature_key,
139 "enabled": result.enabled,
140 "source": result.source.value,
141 "plan": result.plan,
142 }
145@router.get(
146 "/organization",
147 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))],
148 status_code=status.HTTP_200_OK,
149 summary="School and district plan for the authenticated student",
150)
151async def organization_scope(request: Request) -> dict:
152 return await build_organization_payload(request)
155@router.get(
156 "/school",
157 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))],
158 status_code=status.HTTP_200_OK,
159 summary="School details for the authenticated student",
160)
161async def school_scope(request: Request) -> dict:
162 return await build_school_payload(request)
165@router.get(
166 "/district/plan",
167 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))],
168 status_code=status.HTTP_200_OK,
169 summary="District subscription plan for the authenticated student",
170)
171async def district_plan_scope(request: Request) -> dict:
172 return await build_district_plan_payload(request)