Coverage for server / routes / teacher / teacher_features.py: 69%

45 statements  

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

1from typing import Annotated, Optional 

2 

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

4 

5from server.authentication.auth0_bearer import Auth0Bearer 

6from server.services.growthbook import feature_evaluation_service, get_targeting_context 

7from server.services.growthbook.context import resolve_targeting_context 

8from server.services.growthbook.overrides import FeatureSource 

9from server.services.growthbook.catalog import feature_exists 

10from server.services.growthbook.organization import ( 

11 build_district_plan_payload, 

12 build_organization_payload, 

13 build_school_payload, 

14) 

15from server.services.growthbook.teacher_assignment_quota import ( 

16 build_teacher_assignment_quota_payload, 

17) 

18from server.validators.feature_enum import PLAN_DISPLAY_NAMES 

19from server.validators.class_code_validator import ClassCodeQuery 

20 

21router = APIRouter() 

22 

23 

24def _effective_features_response(ctx, features: list, *, teacher_made_assignments: dict) -> dict: 

25 plan = ctx.plan 

26 return { 

27 "district_id": ctx.district_id, 

28 "school_id": ctx.school_id, 

29 "current_plan": plan, 

30 "plan_name": PLAN_DISPLAY_NAMES.get(plan, plan.title()), 

31 "teacher_made_assignments": teacher_made_assignments, 

32 "features": features, 

33 } 

34 

35 

36@router.get( 

37 "/effective", 

38 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

39 status_code=status.HTTP_200_OK, 

40 summary="Effective feature flags for the authenticated teacher", 

41) 

42async def effective_features(request: Request) -> dict: 

43 ctx = await get_targeting_context(request) 

44 features = await feature_evaluation_service.evaluate_all_for_context( 

45 ctx, 

46 audience="teacher", 

47 ) 

48 teacher_made_assignments = await build_teacher_assignment_quota_payload(request) 

49 return _effective_features_response( 

50 ctx, 

51 features, 

52 teacher_made_assignments=teacher_made_assignments, 

53 ) 

54 

55 

56@router.get( 

57 "/district/features", 

58 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

59 status_code=status.HTTP_200_OK, 

60 summary="List effective district features for the authenticated teacher", 

61 include_in_schema=False, 

62) 

63async def list_district_features(request: Request) -> dict: 

64 return await effective_features(request) 

65 

66 

67@router.get( 

68 "/district/features/{feature_name}/status", 

69 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

70 status_code=status.HTTP_200_OK, 

71 summary="Get effective status for a single feature", 

72) 

73async def feature_status( 

74 request: Request, 

75 feature_name: Annotated[ 

76 str, 

77 "GrowthBook feature key, e.g. teacher.teacher_made_tests.limit_10", 

78 ], 

79) -> dict: 

80 if not feature_exists(feature_name): 

81 raise HTTPException( 

82 status_code=status.HTTP_404_NOT_FOUND, 

83 detail=f"Unknown feature key: {feature_name}", 

84 ) 

85 # An account with no district is not REFUSED this answer — there is simply 

86 # nothing to evaluate against, so the honest answer is "off" (ADR-002 #3; 

87 # OpenFeature 1.4.10 requires defaults, not an error, for an incomplete 

88 # targeting context). Enforcement is unchanged: require_feature() still 

89 # refuses a scope-less caller on every gated route. 

90 ctx = await resolve_targeting_context(request, require_district=False) 

91 if not ctx.district_id: 

92 return { 

93 "feature_key": feature_name, 

94 "enabled": False, 

95 "source": FeatureSource.NO_SCOPE.value, 

96 } 

97 

98 result = await feature_evaluation_service.evaluate(feature_name, ctx) 

99 return { 

100 "feature_key": result.feature_key, 

101 "enabled": result.enabled, 

102 "source": result.source.value, 

103 "plan": result.plan, 

104 } 

105 

106 

107@router.get( 

108 "/organization", 

109 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

110 status_code=status.HTTP_200_OK, 

111 summary="School and district plan for the authenticated teacher", 

112) 

113async def organization_scope(request: Request) -> dict: 

114 return await build_organization_payload(request) 

115 

116 

117@router.get( 

118 "/school", 

119 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

120 status_code=status.HTTP_200_OK, 

121 summary="School details for the authenticated teacher", 

122) 

123async def school_scope(request: Request) -> dict: 

124 return await build_school_payload(request) 

125 

126 

127@router.get( 

128 "/district/plan", 

129 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

130 status_code=status.HTTP_200_OK, 

131 summary="District subscription plan for the authenticated teacher", 

132) 

133async def district_plan_scope(request: Request) -> dict: 

134 return await build_district_plan_payload(request) 

135 

136 

137@router.get( 

138 "/teacher-made-assignments/quota", 

139 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

140 status_code=status.HTTP_200_OK, 

141 summary="Teacher-made assignment creation quota per class", 

142) 

143async def teacher_made_assignment_quota( 

144 request: Request, 

145 class_code: ClassCodeQuery = None, 

146 class_id: Annotated[ 

147 Optional[str], 

148 Query(description="Class id (alternative to class_code)"), 

149 ] = None, 

150) -> dict: 

151 return await build_teacher_assignment_quota_payload( 

152 request, 

153 class_code=class_code, 

154 class_id=class_id, 

155 )