Coverage for server / app.py: 92%

181 statements  

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

1# Standard Library Imports 

2from contextlib import asynccontextmanager 

3from datetime import datetime 

4from pathlib import Path 

5import os 

6 

7# FastAPI and Starlette 

8from fastapi import FastAPI, HTTPException, Request, status 

9from fastapi.middleware.cors import CORSMiddleware 

10from fastapi.responses import FileResponse, HTMLResponse 

11from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint 

12from starlette.responses import Response 

13from fastapi.exceptions import RequestValidationError 

14from fastapi.responses import JSONResponse 

15 

16# Monitoring and Profiling 

17from prometheus_client import make_asgi_app, Counter, Histogram 

18from pyinstrument import Profiler 

19 

20# Rate Limiting 

21from slowapi import Limiter, _rate_limit_exceeded_handler 

22from slowapi.util import get_remote_address 

23from slowapi.errors import RateLimitExceeded 

24 

25# Database 

26from server.connection.database import create_initial_users, init_db 

27from server.services.growthbook.cache import targeting_context_cache 

28from server.services.growthbook.client import growthbook_client 

29 

30# Utilities 

31from server.utilities.file_handler import FileHandler 

32 

33# Route Imports - Admin 

34# from server.routes.admin.admin_account import router as AdminAccounts 

35 

36# Staff 

37# from server.routes.staff.staff_accounts import router as StaffAccounts 

38# from server.routes.staff.staff_questions import router as StaffQuestion 

39# from server.routes.staff.staff_dashboard import router as StaffDashboard 

40# from server.routes.staff.staff_districts import router as StaffDistricts 

41# from server.routes.staff.staff_theme import router as StaffTheme 

42# from server.routes.staff.staff_schools import router as StaffSchools 

43# from server.routes.staff.staff_features import router as StaffFeatures 

44# from server.routes.staff.staff_assignment import router as StaffAssignment 

45 

46# Route Imports - Teacher 

47from server.routes.teacher.teacher_account import router as TeacherAccounts 

48from server.routes.teacher.teacher_classes import router as TeacherClasses 

49from server.routes.teacher.teacher_question import router as TeacherQuestion 

50from server.routes.teacher.teacher_question_import import ( 

51 router as TeacherQuestionImport, 

52) 

53from server.routes.teacher.teacher_dashboard import router as TeacherDashboard 

54from server.routes.teacher.teacher_assignment import router as TeacherAssignments 

55from server.routes.teacher.teacher_theme import router as TeacherTheme 

56from server.routes.teacher.teacher_students import router as TeacherStudents 

57from server.routes.teacher.teacher_features import router as TeacherFeatures 

58 

59# Route Imports - Student 

60from server.routes.student.student_account import router as StudentAccounts 

61from server.routes.student.student_classes import router as StudentClasses 

62from server.routes.student.student_assignments import router as StudentAssignments 

63from server.routes.student.student_dashboard import router as StudentDashboard 

64from server.routes.student.student_dashboard_ws import router as StudentDashboardWs 

65from server.routes.student.student_theme import router as StudentTheme 

66from server.routes.student.student_features import router as StudentFeatures 

67 

68from server.routes.common.health import router as HealthRouter 

69from server.routes.common.assignments import router as AssignmentRouter 

70from server.routes.common.users import router as UserRouter 

71from server.routes.common.auth0_webhook import router as Auth0WebhookRouter 

72 

73# Auth0 

74from server.routes.auth0.auth_routes import router as AuthRouter 

75 

76# Lifespan 

77 

78 

79# Initialize the database connection when app starts up 

80# Create initial admin/staff users if they don't exist 

81# Yield control back to FastAPI to handle requests 

82@asynccontextmanager 

83async def lifespan(app: FastAPI): 

84 await init_db() 

85 await create_initial_users() 

86 await growthbook_client.start() 

87 await targeting_context_cache.connect() 

88 yield 

89 await growthbook_client.stop() 

90 await targeting_context_cache.close() 

91 

92 

93# Create the main FastAPI application instance 

94# - title: Name of the API service shown in documentation 

95# - description: Overview of what the APIs provide 

96# - version: Current API version number 

97# - lifespan: Async context manager that handles database initialization 

98# and initial user creation when app starts up 

99def _read_version() -> str: 

100 try: 

101 with open("version.txt", "r") as f: 

102 return f.read().strip() 

103 except OSError: 

104 return "0.0.0.0" 

105 

106 

107app = FastAPI( 

108 title="Erudition APIs", 

109 description="These APIs encompass the entire Erudition Platform, serving both the admin portal and user portal.", 

110 version=_read_version(), 

111 lifespan=lifespan, 

112) 

113 

114# Rate limiting — limiter is defined in server/rate_limit.py so route modules 

115# can import it for @limiter.limit(...) without a circular import. 

116from server.rate_limit import limiter, rate_limit_exceeded_handler # noqa: E402 

117 

118app.state.limiter = limiter 

119app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) 

120 

121# CORS: Read allowed origins from environment variable (comma-separated) 

122allowed_origins = [ 

123 origin.strip() 

124 for origin in os.environ.get("ALLOWED_ORIGINS", "http://localhost:3000").split(",") 

125] 

126 

127app.add_middleware( 

128 CORSMiddleware, 

129 allow_origins=allowed_origins, 

130 allow_credentials=True, 

131 allow_methods=["*"], 

132 allow_headers=["*"], 

133) 

134 

135 

136@app.middleware("http") 

137async def add_no_store_cache_headers( 

138 request: Request, call_next: RequestResponseEndpoint 

139): 

140 """Prevent browser/proxy caching of per-user, dynamic API responses. 

141 

142 Every endpoint returns user-scoped data (classes, assignments, submissions, 

143 etc.) and ships no cache validators, so a cached copy could serve stale data 

144 after a write (e.g. a class list still showing a pre-rename title). Force a 

145 fresh fetch on every request. 

146 """ 

147 response = await call_next(request) 

148 response.headers["Cache-Control"] = "no-store" 

149 return response 

150 

151 

152# Custom exception handler for RequestValidationError 

153@app.exception_handler(RequestValidationError) 

154async def validation_exception_handler(request: Request, exc: RequestValidationError): 

155 """ 

156 Handle Pydantic validation errors and return appropriate status codes. 

157 

158 Password validation errors return 400 Bad Request (RDTNMVPQ-58 requirement). 

159 Email validation errors return 400 Bad Request (RDTNMVPQ-51 requirement). 

160 Theme ID validation errors return 400 Bad Request (RDTNMVPQ-45 requirement). 

161 Color mode validation errors return 400 Bad Request (RDTNMVPQ-45 requirement). 

162 Other validation errors return 422 Unprocessable Entity. 

163 

164 Security: Error responses are sanitized to prevent information disclosure. 

165 User input, field paths, and internal validation details are NOT exposed. 

166 

167 Args: 

168 request: The incoming request 

169 exc: The validation error exception 

170 

171 Returns: 

172 JSONResponse: 400 for password/email/theme validation errors, 422 for other validation errors 

173 """ 

174 errors = exc.errors() 

175 is_password_validation_error = False 

176 is_email_validation_error = False 

177 is_theme_validation_error = False 

178 password_error_message = None 

179 email_error_message = None 

180 theme_error_message = None 

181 

182 # RDTNMVPQ-45: Check if this is a theme endpoint request 

183 # This helps catch JSON decode errors and other validation errors on theme endpoints 

184 request_path = str(request.url.path).lower() 

185 is_theme_endpoint = ( 

186 "/theme/apply" in request_path or "/theme/update" in request_path 

187 ) 

188 # EI-TC-982: Teacher class endpoints return 400 (not 422) for payload/param 

189 # validation errors so QA test cases can assert a uniform Bad Request status. 

190 is_teacher_class_endpoint = "/v1/teacher/class/" in request_path 

191 # EI-TC-995: Teacher account endpoints (login/find/picture/update) follow the 

192 # same convention — malformed payloads return 400 (not 422) for QA parity. 

193 is_teacher_account_endpoint = "/v1/teacher/account/" in request_path 

194 # Question IMPORT endpoints only. Scoped deliberately to ".../question/import/": 

195 # the wider "/v1/teacher/question/" prefix would flip the existing question 

196 # endpoints from 422 to 400, which three unit tests assert and QA relies on. 

197 is_teacher_question_import_endpoint = "/v1/teacher/question/import/" in request_path 

198 # EI-3455: the class grade-average endpoint's `class_code` bound (ClassCodePath, 

199 # EI-TC-984) already rejects a malformed code, but FastAPI's default 422 doesn't 

200 # match this API's "malformed input -> 400" convention. Scoped to this one route 

201 # (not the wider "/v1/student/assignment/" prefix) the same way EI-TC-995 avoided 

202 # flipping sibling question endpoints above — the other class_code routes under 

203 # this prefix aren't part of this fix. 

204 is_student_grade_average_endpoint = ( 

205 "/v1/student/assignment/" in request_path 

206 and request_path.endswith("/grade/average/fetch") 

207 ) 

208 

209 for error in errors: 

210 error_type = error.get("type", "") 

211 # Get the field location to determine which field failed validation 

212 field_loc = error.get("loc", []) 

213 field_name = field_loc[-1] if field_loc else None 

214 error_msg = error.get("msg", "") 

215 error_msg_lower = error_msg.lower() 

216 

217 # RDTNMVPQ-51: Check for email validation errors FIRST (field-based detection) 

218 # This catches all email validation errors regardless of source 

219 if field_name == "email" and not is_email_validation_error: 

220 is_email_validation_error = True 

221 # Return generic, secure message without exposing user input or internal details 

222 email_error_message = ( 

223 "Invalid email format. Please provide a valid email address." 

224 ) 

225 

226 # RDTNMVPQ-45: Check for theme_id validation errors (field-based detection) 

227 # This catches PydanticObjectId validation errors and custom validator errors 

228 elif field_name == "theme_id" and not is_theme_validation_error: 

229 is_theme_validation_error = True 

230 # Provide user-friendly message based on error type 

231 if "required" in error_msg_lower or "missing" in error_msg_lower: 

232 theme_error_message = "Theme ID is required" 

233 elif ( 

234 "pydanticobjectid" in error_msg_lower 

235 or "objectid" in error_msg_lower 

236 or "id must be" in error_msg_lower 

237 ): 

238 theme_error_message = "Invalid Theme ID" 

239 elif "empty" in error_msg_lower: 

240 theme_error_message = "Theme ID is required" 

241 elif "length" in error_msg_lower or "exceed" in error_msg_lower: 

242 theme_error_message = "Invalid Theme ID" 

243 elif "invalid" in error_msg_lower or "characters" in error_msg_lower: 

244 theme_error_message = "Invalid Theme ID" 

245 else: 

246 theme_error_message = "Invalid Theme ID" 

247 

248 # RDTNMVPQ-45: Check for color_mode validation errors (field-based detection) 

249 elif field_name == "color_mode" and not is_theme_validation_error: 

250 is_theme_validation_error = True 

251 if "required" in error_msg_lower or "missing" in error_msg_lower: 

252 theme_error_message = "color_mode is required and cannot be empty" 

253 else: 

254 theme_error_message = "color_mode must be one of: light, dark" 

255 

256 # RDTNMVPQ-45: Handle JSON decode errors and other validation errors on theme endpoints 

257 # This catches cases where null/invalid JSON is sent for theme_id 

258 elif is_theme_endpoint and not is_theme_validation_error: 

259 # #375 (Allan Ninal, 2026-10-04): this catch-all mapped ANY missing / 

260 # required / none / null error on a theme endpoint to "Theme ID is 

261 # required", so a missing colors.secondary was reported as a missing 

262 # theme_id. It now speaks for theme_id only: an error whose loc names 

263 # theme_id, or a whole-body error on /theme/apply (undecodable JSON, 

264 # no body; apply's only id field is theme_id). Any other field's 

265 # missing error names that field (below), in the handler's style. 

266 names_theme_id = "theme_id" in field_loc 

267 is_apply_body_error = "/theme/apply" in request_path and all( 

268 not isinstance(part, str) or part == "body" for part in field_loc 

269 ) 

270 if names_theme_id or is_apply_body_error: 

271 if error_type == "json_invalid" or "json" in error_msg_lower: 

272 is_theme_validation_error = True 

273 theme_error_message = "Theme ID is required" 

274 elif ( 

275 error_type == "missing" 

276 or "required" in error_msg_lower 

277 or "missing" in error_msg_lower 

278 ): 

279 is_theme_validation_error = True 

280 theme_error_message = "Theme ID is required" 

281 elif "none" in error_msg_lower or "null" in error_msg_lower: 

282 is_theme_validation_error = True 

283 theme_error_message = "Theme ID is required" 

284 elif error_type == "missing": 

285 is_theme_validation_error = True 

286 dotted = ".".join(str(part) for part in field_loc if part != "body") 

287 theme_error_message = f"{dotted or 'request body'} is required" 

288 

289 # Handle ValueError in the ctx field for password validation (RDTNMVPQ-58) 

290 if "ctx" in error and "error" in error["ctx"]: 

291 if isinstance(error["ctx"]["error"], ValueError): 

292 ctx_error_msg = str(error["ctx"]["error"]) 

293 

294 # Check if this is a password validation error (RDTNMVPQ-58) 

295 if ( 

296 "Password must be at least" in ctx_error_msg 

297 or "Password must not exceed" in ctx_error_msg 

298 or "Password cannot be empty" in ctx_error_msg 

299 or "Passwords do not match" in ctx_error_msg 

300 or ("password" in ctx_error_msg.lower() and field_name != "email") 

301 ): 

302 is_password_validation_error = True 

303 if not password_error_message: 

304 password_error_message = ctx_error_msg 

305 

306 # Return 400 for password validation errors with clean format (RDTNMVPQ-58 requirement) 

307 if is_password_validation_error: 

308 return JSONResponse( 

309 status_code=status.HTTP_400_BAD_REQUEST, 

310 content={"detail": password_error_message or "Password validation failed"}, 

311 ) 

312 

313 # Return 400 for email validation errors with secure format (RDTNMVPQ-51 requirement) 

314 # Security: Do NOT expose user input, field paths, or internal validation details 

315 if is_email_validation_error: 

316 return JSONResponse( 

317 status_code=status.HTTP_400_BAD_REQUEST, 

318 content={ 

319 "detail": email_error_message 

320 or "Invalid email format. Please provide a valid email address." 

321 }, 

322 ) 

323 

324 # Return 400 for theme validation errors (RDTNMVPQ-45 requirement) 

325 # Handles theme_id and color_mode validation with user-friendly messages 

326 if is_theme_validation_error: 

327 return JSONResponse( 

328 status_code=status.HTTP_400_BAD_REQUEST, 

329 content={"detail": theme_error_message or "Invalid theme data"}, 

330 ) 

331 

332 # Return 422 for other validation errors with sanitized format 

333 # Security: Sanitize error details to prevent information disclosure 

334 sanitized_errors = [] 

335 for error in errors: 

336 sanitized_error = { 

337 "type": error.get("type", "validation_error"), 

338 "msg": error.get("msg", "Validation failed"), 

339 } 

340 # Only include field name, not full path or user input 

341 field_loc = error.get("loc", []) 

342 if field_loc: 

343 sanitized_error["field"] = ( 

344 field_loc[-1] if isinstance(field_loc[-1], str) else str(field_loc[-1]) 

345 ) 

346 sanitized_errors.append(sanitized_error) 

347 

348 response_status = ( 

349 status.HTTP_400_BAD_REQUEST 

350 if ( 

351 is_teacher_class_endpoint 

352 or is_teacher_account_endpoint 

353 or is_teacher_question_import_endpoint 

354 or is_student_grade_average_endpoint 

355 ) 

356 else status.HTTP_422_UNPROCESSABLE_CONTENT 

357 ) 

358 return JSONResponse( 

359 status_code=response_status, 

360 content={"detail": sanitized_errors}, 

361 ) 

362 

363 

364metrics_app = make_asgi_app() 

365app.mount("/metrics", metrics_app) 

366# app.add_middleware(PyInstrumentMiddleWare) 

367 

368 

369@app.get("/health") 

370async def health_check(): 

371 return {"staus": "Server is up and running"} 

372 

373 

374@app.get( 

375 "/v1/health", 

376 tags=["Server Health Check"], 

377 description="Check if the server is up and running", 

378 summary="Check if the server is up and running", 

379) 

380async def read_root(): 

381 version = "0.0.0.0" 

382 if os.path.exists("version.txt"): 

383 with open("version.txt", "r") as f: 

384 version = f.read().strip() 

385 return {"status": "Server is up and running", "version": version} 

386 

387 

388# Admin Routes 

389# app.include_router(AdminAccounts, tags=["Admin-Accounts"], prefix="/v1/admin/account") 

390 

391# Staff Routes 

392# app.include_router(StaffAccounts, tags=["Staff-Accounts"], prefix="/v1/staff/account") 

393# app.include_router(StaffDashboard, tags=["Staff-Dashboard"], prefix="/v1/staff/dashboard") 

394# app.include_router(StaffDistricts, tags=["Staff-Districts"], prefix="/v1/staff/districts") 

395# app.include_router(StaffFeatures, tags=["Staff-Features"], prefix="/v1/staff/features") 

396# app.include_router(StaffSchools, tags=["Staff-Schools"], prefix="/v1/staff/school") 

397# app.include_router(StaffQuestion, tags=["Staff-Question"], prefix="/v1/staff/question") 

398# app.include_router(StaffAssignment, tags=["Staff-Assignment"], prefix="/v1/staff/assignment") 

399# app.include_router(StaffTheme, tags=["Staff-Theme"], prefix="/v1/staff/account/theme") 

400 

401# Auth0 Routes 

402# Readiness probe at /v1/health/ready. Mounted under the same prefix as the 

403# liveness endpoint above but as a separate path, so /v1/health keeps answering 

404# its static literal — the deploy workflow's HEALTH_ENDPOINT points at it and 

405# rolls back on failure. 

406app.include_router(HealthRouter, prefix="/v1/health") 

407 

408app.include_router(AuthRouter, tags=["Auth"], prefix="/v1/auth") 

409 

410# Teacher Routes 

411app.include_router( 

412 TeacherAccounts, tags=["Teacher-Accounts"], prefix="/v1/teacher/account" 

413) 

414app.include_router( 

415 TeacherDashboard, tags=["Teacher-Dashboard"], prefix="/v1/teacher/dashboard" 

416) 

417app.include_router(TeacherClasses, tags=["Teacher-Classes"], prefix="/v1/teacher/class") 

418app.include_router( 

419 TeacherQuestion, tags=["Teacher-Question"], prefix="/v1/teacher/question" 

420) 

421app.include_router( 

422 TeacherQuestionImport, 

423 tags=["Teacher-Question-Import"], 

424 prefix="/v1/teacher/question/import", 

425) 

426app.include_router( 

427 TeacherAssignments, tags=["Teacher-Assignments"], prefix="/v1/teacher/assignment" 

428) 

429app.include_router( 

430 TeacherTheme, tags=["Teacher-Theme"], prefix="/v1/teacher/account/theme" 

431) 

432app.include_router( 

433 TeacherStudents, tags=["Teacher-Students"], prefix="/v1/teacher/students" 

434) 

435app.include_router( 

436 TeacherFeatures, tags=["Teacher-Features"], prefix="/v1/teacher/features" 

437) 

438 

439# Student Routes 

440app.include_router( 

441 StudentAccounts, tags=["Student-Accounts"], prefix="/v1/student/account" 

442) 

443app.include_router( 

444 StudentDashboard, tags=["Student-Dashboard"], prefix="/v1/student/dashboard" 

445) 

446app.include_router( 

447 StudentDashboardWs, tags=["Student-Dashboard-WS"], prefix="/v1/student/dashboard" 

448) 

449app.include_router( 

450 StudentClasses, tags=["Student-Classes"], prefix="/v1/student/classes" 

451) 

452app.include_router( 

453 StudentAssignments, tags=["Student-Assignments"], prefix="/v1/student/assignment" 

454) 

455app.include_router( 

456 StudentTheme, tags=["Student-Theme"], prefix="/v1/student/account/theme" 

457) 

458app.include_router( 

459 StudentFeatures, tags=["Student-Features"], prefix="/v1/student/features" 

460) 

461 

462# Common Routes 

463app.include_router(UserRouter, tags=["Users"], prefix="/v1/users") 

464app.include_router(AssignmentRouter, tags=["Assignments"], prefix="/v1/assignments") 

465 

466# Webhook Routes 

467app.include_router(Auth0WebhookRouter, tags=["Webhooks"], prefix="/v1/webhooks")