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

1from typing import Annotated 

2 

3from fastapi import APIRouter, Depends, HTTPException, Request, status 

4 

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 

19 

20router = APIRouter() 

21 

22 

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 } 

32 

33 

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 } 

43 

44 

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. 

53 

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. 

57 

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. 

62 

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. 

69 

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. 

77 

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) 

87 

88 features = await feature_evaluation_service.evaluate_all_for_context( 

89 ctx, 

90 audience="student", 

91 ) 

92 return _effective_features_response(ctx, features) 

93 

94 

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) 

104 

105 

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 } 

135 

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 } 

143 

144 

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) 

153 

154 

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) 

163 

164 

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)