Coverage for server / services / growthbook / teacher_assignment_quota.py: 71%

112 statements  

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

1""" 

2Teacher-made assignment creation quota from GrowthBook entitlements. 

3 

4Each teacher may create up to 10 assignments **per class** when 

5teacher.teacher_made_tests.limit_10 is enabled (unlimited per class on premium). 

6""" 

7 

8from dataclasses import dataclass 

9from typing import Any, Optional 

10 

11from bson import ObjectId 

12from fastapi import HTTPException, Request, status 

13 

14from server.connection.database import db 

15from server.services.growthbook.context import TargetingContext, resolve_targeting_context 

16from server.services.growthbook.evaluation import feature_evaluation_service 

17 

18TEACHER_MADE_ASSIGNMENT_LIMIT = 10 

19LIMIT_10_KEY = "teacher.teacher_made_tests.limit_10" 

20UNLIMITED_KEY = "teacher.teacher_made_tests.unlimited" 

21QUOTA_SCOPE = "per_class" 

22 

23 

24def _teacher_id_from_request(request: Request) -> str: 

25 user_details = request.state.user_details 

26 return str(user_details.get("mongodb_id") or user_details.get("uuid") or "") 

27 

28 

29def _created_by_values(teacher_id: str) -> list[Any]: 

30 """Match assignments stored with created_by as string or ObjectId.""" 

31 teacher_id_str = str(teacher_id) 

32 values: list[Any] = [teacher_id_str] 

33 if ObjectId.is_valid(teacher_id_str): 

34 values.append(ObjectId(teacher_id_str)) 

35 return values 

36 

37 

38def _class_id_values(class_id: str) -> list[Any]: 

39 class_id_str = str(class_id) 

40 values: list[Any] = [class_id_str] 

41 if ObjectId.is_valid(class_id_str): 

42 values.append(ObjectId(class_id_str)) 

43 return values 

44 

45 

46async def resolve_class_scope( 

47 *, 

48 class_code: Optional[str] = None, 

49 class_id: Optional[str] = None, 

50) -> tuple[Optional[str], Optional[str]]: 

51 """Return (class_id, class_code) from explicit id or class_code lookup.""" 

52 if class_id: 

53 class_id_str = str(class_id) 

54 if class_code: 

55 return class_id_str, class_code 

56 doc = await db["class_collection"].find_one( 

57 {"_id": ObjectId(class_id_str), "deleted": {"$ne": True}} 

58 ) 

59 return class_id_str, doc.get("class_code") if doc else None 

60 

61 if class_code: 

62 doc = await db["class_collection"].find_one( 

63 {"class_code": class_code, "deleted": {"$ne": True}} 

64 ) 

65 if not doc: 

66 raise HTTPException( 

67 status_code=status.HTTP_404_NOT_FOUND, 

68 detail=f"Class with code {class_code} not found", 

69 ) 

70 return str(doc["_id"]), class_code 

71 

72 return None, None 

73 

74 

75@dataclass(frozen=True) 

76class TeacherAssignmentQuota: 

77 can_create: bool 

78 max_assignments: Optional[int] 

79 current_count: int 

80 remaining: Optional[int] 

81 enabled_feature: Optional[str] 

82 scope: str = QUOTA_SCOPE 

83 class_id: Optional[str] = None 

84 class_code: Optional[str] = None 

85 

86 def to_dict(self) -> dict[str, Any]: 

87 return { 

88 "scope": self.scope, 

89 "class_id": self.class_id, 

90 "class_code": self.class_code, 

91 "can_create": self.can_create, 

92 "max_assignments_per_class": self.max_assignments, 

93 "max_assignments": self.max_assignments, 

94 "current_count": self.current_count, 

95 "remaining": self.remaining, 

96 "enabled_feature": self.enabled_feature, 

97 } 

98 

99 

100async def count_teacher_created_assignments( 

101 teacher_id: str, 

102 *, 

103 class_id: Optional[str] = None, 

104) -> int: 

105 """Count live assignments created by the teacher for one class.""" 

106 if not teacher_id: 

107 return 0 

108 

109 query: dict[str, Any] = { 

110 "deleted": {"$ne": True}, 

111 "created_by": {"$in": _created_by_values(teacher_id)}, 

112 } 

113 if class_id: 

114 query["assigned_class"] = {"$in": _class_id_values(class_id)} 

115 

116 return await db["assignments_collection"].count_documents(query) 

117 

118 

119async def is_teacher_made_assignment_access_enabled(ctx: TargetingContext) -> bool: 

120 """True when the district plan includes teacher-made assignment entitlements.""" 

121 if await feature_evaluation_service.is_enabled(UNLIMITED_KEY, ctx): 

122 return True 

123 return await feature_evaluation_service.is_enabled(LIMIT_10_KEY, ctx) 

124 

125 

126async def resolve_teacher_assignment_quota(ctx: TargetingContext) -> tuple[bool, Optional[int], Optional[str]]: 

127 """ 

128 Return (can_create, max_assignments_per_class, enabled_feature_key). 

129 

130 max_assignments is None when unlimited. 

131 """ 

132 if await feature_evaluation_service.is_enabled(UNLIMITED_KEY, ctx): 

133 return True, None, UNLIMITED_KEY 

134 

135 if await feature_evaluation_service.is_enabled(LIMIT_10_KEY, ctx): 

136 return True, TEACHER_MADE_ASSIGNMENT_LIMIT, LIMIT_10_KEY 

137 

138 return False, 0, None 

139 

140 

141async def get_teacher_assignment_quota( 

142 ctx: TargetingContext, 

143 teacher_id: str, 

144 *, 

145 class_id: Optional[str] = None, 

146 class_code: Optional[str] = None, 

147) -> TeacherAssignmentQuota: 

148 can_create, max_assignments, enabled_feature = await resolve_teacher_assignment_quota(ctx) 

149 

150 if not can_create: 

151 return TeacherAssignmentQuota( 

152 can_create=False, 

153 max_assignments=0, 

154 current_count=0, 

155 remaining=0, 

156 enabled_feature=enabled_feature, 

157 class_id=class_id, 

158 class_code=class_code, 

159 ) 

160 

161 current_count = 0 

162 if class_id: 

163 current_count = await count_teacher_created_assignments( 

164 teacher_id, 

165 class_id=class_id, 

166 ) 

167 

168 if max_assignments is None: 

169 return TeacherAssignmentQuota( 

170 can_create=True, 

171 max_assignments=None, 

172 current_count=current_count, 

173 remaining=None, 

174 enabled_feature=enabled_feature, 

175 class_id=class_id, 

176 class_code=class_code, 

177 ) 

178 

179 if not class_id: 

180 return TeacherAssignmentQuota( 

181 can_create=True, 

182 max_assignments=max_assignments, 

183 current_count=0, 

184 remaining=max_assignments, 

185 enabled_feature=enabled_feature, 

186 class_id=class_id, 

187 class_code=class_code, 

188 ) 

189 

190 remaining = max(max_assignments - current_count, 0) 

191 return TeacherAssignmentQuota( 

192 can_create=current_count < max_assignments, 

193 max_assignments=max_assignments, 

194 current_count=current_count, 

195 remaining=remaining, 

196 enabled_feature=enabled_feature, 

197 class_id=class_id, 

198 class_code=class_code, 

199 ) 

200 

201 

202async def build_teacher_assignment_quota_payload( 

203 request: Request, 

204 *, 

205 class_code: Optional[str] = None, 

206 class_id: Optional[str] = None, 

207) -> dict[str, Any]: 

208 ctx = await resolve_targeting_context(request, require_district=True) 

209 teacher_id = _teacher_id_from_request(request) 

210 resolved_class_id, resolved_class_code = await resolve_class_scope( 

211 class_code=class_code, 

212 class_id=class_id, 

213 ) 

214 return ( 

215 await get_teacher_assignment_quota( 

216 ctx, 

217 teacher_id, 

218 class_id=resolved_class_id, 

219 class_code=resolved_class_code, 

220 ) 

221 ).to_dict() 

222 

223 

224async def enforce_teacher_assignment_quota( 

225 request: Request, 

226 assigned_class_ids: Optional[list[Any]] = None, 

227) -> None: 

228 """Raise 403 when the teacher cannot create another assignment for a target class.""" 

229 ctx = await resolve_targeting_context(request, require_district=True) 

230 teacher_id = _teacher_id_from_request(request) 

231 

232 if not assigned_class_ids: 

233 quota = await get_teacher_assignment_quota(ctx, teacher_id) 

234 if not quota.can_create and quota.max_assignments == 0: 

235 raise HTTPException( 

236 status_code=status.HTTP_403_FORBIDDEN, 

237 detail={ 

238 "message": "Teacher-made assignment creation is not included in your district plan.", 

239 "reason": "teacher_made_assignments_not_enabled", 

240 "hint": ( 

241 f"Enable {LIMIT_10_KEY} or {UNLIMITED_KEY} for this district." 

242 ), 

243 "quota": quota.to_dict(), 

244 }, 

245 ) 

246 return 

247 

248 seen_classes: set[str] = set() 

249 for raw_class_id in assigned_class_ids: 

250 class_id = str(raw_class_id) 

251 if class_id in seen_classes: 

252 continue 

253 seen_classes.add(class_id) 

254 

255 class_doc = await db["class_collection"].find_one( 

256 {"_id": ObjectId(class_id) if ObjectId.is_valid(class_id) else class_id} 

257 ) 

258 class_code = class_doc.get("class_code") if class_doc else None 

259 quota = await get_teacher_assignment_quota( 

260 ctx, 

261 teacher_id, 

262 class_id=class_id, 

263 class_code=class_code, 

264 ) 

265 

266 if not quota.can_create and quota.max_assignments == 0: 

267 raise HTTPException( 

268 status_code=status.HTTP_403_FORBIDDEN, 

269 detail={ 

270 "message": "Teacher-made assignment creation is not included in your district plan.", 

271 "reason": "teacher_made_assignments_not_enabled", 

272 "hint": ( 

273 f"Enable {LIMIT_10_KEY} or {UNLIMITED_KEY} for this district." 

274 ), 

275 "quota": quota.to_dict(), 

276 }, 

277 ) 

278 

279 if quota.max_assignments is not None and quota.current_count >= quota.max_assignments: 

280 raise HTTPException( 

281 status_code=status.HTTP_403_FORBIDDEN, 

282 detail={ 

283 "message": ( 

284 f"Teacher-made assignment limit reached for this class " 

285 f"({quota.max_assignments} per class)." 

286 ), 

287 "reason": "teacher_made_assignment_limit_reached", 

288 "hint": ( 

289 "Upgrade to a plan with unlimited teacher-made assignments, " 

290 "or delete an existing assignment in this class before creating another." 

291 ), 

292 "quota": quota.to_dict(), 

293 }, 

294 ) 

295 

296 

297async def enforce_teacher_made_assignment_access(request: Request) -> None: 

298 """Raise 403 when teacher-made assignments are not included in the district plan.""" 

299 ctx = await resolve_targeting_context(request, require_district=True) 

300 if await is_teacher_made_assignment_access_enabled(ctx): 

301 return 

302 

303 raise HTTPException( 

304 status_code=status.HTTP_403_FORBIDDEN, 

305 detail={ 

306 "message": "Teacher-made assignments are not included in your district plan.", 

307 "reason": "teacher_made_assignments_not_enabled", 

308 "hint": ( 

309 f"Enable {LIMIT_10_KEY} or {UNLIMITED_KEY} for this district." 

310 ), 

311 }, 

312 )