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
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1from typing import Annotated, Optional
3from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
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
21router = APIRouter()
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 }
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 )
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)
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 }
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 }
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)
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)
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)
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 )