Coverage for server / app.py: 92%
181 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
1# Standard Library Imports
2from contextlib import asynccontextmanager
3from datetime import datetime
4from pathlib import Path
5import os
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
16# Monitoring and Profiling
17from prometheus_client import make_asgi_app, Counter, Histogram
18from pyinstrument import Profiler
20# Rate Limiting
21from slowapi import Limiter, _rate_limit_exceeded_handler
22from slowapi.util import get_remote_address
23from slowapi.errors import RateLimitExceeded
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
30# Utilities
31from server.utilities.file_handler import FileHandler
33# Route Imports - Admin
34# from server.routes.admin.admin_account import router as AdminAccounts
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
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
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
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
73# Auth0
74from server.routes.auth0.auth_routes import router as AuthRouter
76# Lifespan
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()
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"
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)
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
118app.state.limiter = limiter
119app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
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]
127app.add_middleware(
128 CORSMiddleware,
129 allow_origins=allowed_origins,
130 allow_credentials=True,
131 allow_methods=["*"],
132 allow_headers=["*"],
133)
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.
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
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.
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.
164 Security: Error responses are sanitized to prevent information disclosure.
165 User input, field paths, and internal validation details are NOT exposed.
167 Args:
168 request: The incoming request
169 exc: The validation error exception
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
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 )
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()
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 )
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"
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"
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"
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"])
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
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 )
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 )
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 )
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)
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 )
364metrics_app = make_asgi_app()
365app.mount("/metrics", metrics_app)
366# app.add_middleware(PyInstrumentMiddleWare)
369@app.get("/health")
370async def health_check():
371 return {"staus": "Server is up and running"}
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}
388# Admin Routes
389# app.include_router(AdminAccounts, tags=["Admin-Accounts"], prefix="/v1/admin/account")
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")
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")
408app.include_router(AuthRouter, tags=["Auth"], prefix="/v1/auth")
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)
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)
462# Common Routes
463app.include_router(UserRouter, tags=["Users"], prefix="/v1/users")
464app.include_router(AssignmentRouter, tags=["Assignments"], prefix="/v1/assignments")
466# Webhook Routes
467app.include_router(Auth0WebhookRouter, tags=["Webhooks"], prefix="/v1/webhooks")