Coverage for server / routes / teacher / teacher_question_import.py: 86%

130 statements  

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

1"""Teacher question-import routes. 

2 

3Thin proxy over the Question Ingest service. The frontend talks only to this API; the 

4ingest service is not reachable from a browser. 

5 

6Nothing here writes to the question bank. Commit returns validated payloads which are 

7then created through TeacherQuestionService, so imported questions go through exactly 

8the same path — and the same _validate_question_data — as hand-authored ones. 

9 

10Developer: Allan Ninal 

11""" 

12 

13import logging 

14 

15from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile 

16from pydantic import BaseModel, Field 

17 

18from server.authentication.auth0_bearer import Auth0Bearer 

19from server.rate_limit import limiter 

20from server.services.growthbook import require_feature 

21from server.services.teacher import question_dedup, question_import 

22from server.services.teacher.teacher_question import TeacherQuestionService 

23from server.utilities.user_id_helper import to_user_id 

24 

25logger = logging.getLogger(__name__) 

26router = APIRouter() 

27teacher_question_service = TeacherQuestionService() 

28 

29def _teacher_id(request: Request): 

30 return to_user_id(request.state.user_details["uuid"]) 

31 

32 

33def _import_owner(request: Request) -> str: 

34 """The id the ingest service scopes an import job to. 

35 

36 Stringified because a local-JWT teacher's id is an ObjectId while an Auth0 one is 

37 already a string; the ingest service keys on the text either way. 

38 """ 

39 return str(_teacher_id(request)) 

40 

41 

42_TEACHER_ONLY = [ 

43 Depends(Auth0Bearer(access_levels=["teacher"])), 

44 Depends(require_feature("teacher.bulk_upload_questions")), 

45] 

46 

47 

48# Fields the ingest service adds for provenance, which are NOT part of any 

49# teacher_questionbank template. `_validate_question_data` rejects every key a template 

50# does not declare, so leaving them in the payload failed the whole commit with 

51# "Unexpected fields found: source" — every import, for every file. They are split off 

52# here and handed to `create` as arguments instead, so the provenance is still recorded. 

53_PROVENANCE_FIELDS = ("source", "questionId") 

54 

55 

56def _split_provenance(payload: dict) -> tuple[dict, dict]: 

57 """Return (payload without provenance fields, the provenance fields it carried).""" 

58 provenance = {key: payload[key] for key in _PROVENANCE_FIELDS if key in payload} 

59 if not provenance: 

60 return payload, {} 

61 return {k: v for k, v in payload.items() if k not in provenance}, provenance 

62 

63 

64# Texas grade levels, and the difficulty vocabulary both banks accept. Checked HERE, at 

65# upload, because the alternative is a batch that parses for half a minute and then 

66# fails whole at commit — every question rejected over one value the teacher typed on 

67# the first screen. 

68_GRADE_RANGE = range(3, 13) 

69_DIFFICULTIES = {"easy", "average", "advance"} 

70 

71 

72def _assert_batch_defaults(grade_level: int | None, difficulty: str | None) -> None: 

73 if grade_level is not None and grade_level not in _GRADE_RANGE: 

74 raise HTTPException( 

75 status_code=422, 

76 detail=f"grade_level must be between 3 and 12; got {grade_level}", 

77 ) 

78 if difficulty is not None and difficulty.strip() and difficulty.strip().lower() not in _DIFFICULTIES: 

79 raise HTTPException( 

80 status_code=422, 

81 detail=( 

82 f"difficulty must be one of Easy, Average, Advance; got {difficulty!r}" 

83 ), 

84 ) 

85 

86 

87def _upload_filename(filename: str | None, question_type: str) -> str: 

88 """The name the ingest service reads the format from. 

89 

90 Defaulting a missing name to "upload.csv" is safe for a spreadsheet and wrong for 

91 anything else: the ingest service picks its reader by suffix, so a nameless PDF 

92 would be read as a CSV, and "Auto-detect" — which it accepts for PDFs only — would 

93 be refused outright. 

94 """ 

95 if filename and filename.strip(): 

96 return filename 

97 return "upload.pdf" if question_type == "Auto-detect" else "upload.csv" 

98 

99 

100def _reraise(exc: question_import.IngestRejected) -> HTTPException: 

101 """Pass the ingest service's rejection through with its own status and detail. 

102 

103 Never collapse these to 500 — the detail names the row and column that failed, and 

104 that is the whole value of it to a teacher. Never turn them into 401 either: a 

105 non-/auth/ 401 makes the SPA refresh and redirect to "/", losing the import. 

106 """ 

107 return HTTPException(status_code=exc.status_code, detail=exc.detail) 

108 

109 

110@router.post( 

111 "/upload", 

112 dependencies=_TEACHER_ONLY, 

113 summary="Upload a question file and start an import job", 

114) 

115@limiter.limit("10/minute") 

116async def upload_question_file( 

117 request: Request, 

118 file: UploadFile = File(...), 

119 question_type: str = Form(...), 

120 # Applied to every question in the file. A document states neither: a STAAR 

121 # released-item PDF names the TEKS, the points and the answer for each item, but a 

122 # grade appears nowhere in it and a difficulty is a judgement, not a fact. Without 

123 # these two the import parses fine and then cannot be saved at all. 

124 grade_level: int | None = Form(default=None), 

125 difficulty: str | None = Form(default=None), 

126) -> dict: 

127 """Hand the file to the ingest service and return its job id. 

128 

129 Returns immediately; the client polls the status endpoint. Holding the connection 

130 for the duration of the parse is what loses work when a connection drops. 

131 """ 

132 _assert_batch_defaults(grade_level, difficulty) 

133 content = await file.read() 

134 try: 

135 return await question_import.create_import( 

136 filename=_upload_filename(file.filename, question_type), 

137 content=content, 

138 question_type=question_type, 

139 owner=_import_owner(request), 

140 grade_level=grade_level, 

141 difficulty=difficulty, 

142 ) 

143 except question_import.IngestRejected as exc: 

144 raise _reraise(exc) from exc 

145 except question_import.IngestUnavailable as exc: 

146 logger.error("question ingest unavailable on upload: %s", exc) 

147 raise HTTPException( 

148 status_code=503, 

149 detail="the question import service is unavailable; nothing was imported", 

150 ) from exc 

151 

152 

153class PastedQuestion(BaseModel): 

154 """One question typed or pasted by a teacher, rather than uploaded as a file.""" 

155 

156 text: str = Field(..., min_length=1, max_length=50_000) 

157 question_type: str 

158 

159 

160@router.post( 

161 "/text", 

162 dependencies=_TEACHER_ONLY, 

163 summary="Add a single question by pasting it, and start an import job", 

164) 

165@limiter.limit("20/minute") 

166async def upload_question_text(request: Request, body: PastedQuestion) -> dict: 

167 """Hand pasted text to the ingest service and return its job id. 

168 

169 Returns the same shape as a file upload on purpose: the client polls, reviews and 

170 commits through the endpoints it already uses. A teacher adding one question should 

171 not meet a different flow from a teacher adding forty. 

172 

173 The rate limit is higher than the upload one (20 vs 10). Pasting single questions is 

174 a natural burst -- someone adding five in a row is ordinary use, not abuse -- while 

175 each request is far cheaper than a file. 

176 """ 

177 try: 

178 return await question_import.create_import_from_text( 

179 text=body.text, question_type=body.question_type, owner=_import_owner(request) 

180 ) 

181 except question_import.IngestRejected as exc: 

182 raise _reraise(exc) from exc 

183 except question_import.IngestUnavailable as exc: 

184 logger.error("question ingest unavailable on paste: %s", exc) 

185 raise HTTPException( 

186 status_code=503, 

187 detail="the question import service is unavailable; nothing was imported", 

188 ) from exc 

189 

190 

191@router.get( 

192 "/{job_id}/status", 

193 dependencies=_TEACHER_ONLY, 

194 summary="Progress and per-row errors for an import job", 

195) 

196async def import_status(request: Request, job_id: str) -> dict: 

197 try: 

198 return await question_import.get_import(job_id, owner=_import_owner(request)) 

199 except question_import.IngestRejected as exc: 

200 raise _reraise(exc) from exc 

201 except question_import.IngestUnavailable as exc: 

202 raise HTTPException( 

203 status_code=503, detail="the question import service is unavailable" 

204 ) from exc 

205 

206 

207@router.get( 

208 "/{job_id}/questions", 

209 dependencies=_TEACHER_ONLY, 

210 summary="Proposed questions for review — nothing has been saved", 

211) 

212async def import_questions(request: Request, job_id: str) -> dict: 

213 """Return the parsed questions, each flagged if it duplicates one already owned. 

214 

215 Flagged HERE, not just skipped at commit, so the teacher sees what will be skipped 

216 while they can still act on it — remove it, or edit it into something new. 

217 """ 

218 try: 

219 payload = await question_import.get_import_questions( 

220 job_id, owner=_import_owner(request) 

221 ) 

222 except question_import.IngestRejected as exc: 

223 raise _reraise(exc) from exc 

224 except question_import.IngestUnavailable as exc: 

225 raise HTTPException( 

226 status_code=503, detail="the question import service is unavailable" 

227 ) from exc 

228 

229 questions = payload.get("questions") or [] 

230 if questions: 

231 try: 

232 duplicate_indices, _ = await question_dedup.mark_duplicates( 

233 _teacher_id(request), questions 

234 ) 

235 for index in duplicate_indices: 

236 questions[index]["duplicate"] = True 

237 payload["duplicates"] = len(duplicate_indices) 

238 except Exception: # noqa: BLE001 - review must still work without the check 

239 # Report it as UNKNOWN rather than zero. "0 duplicates" is a plausible 

240 # answer and would be read as "nothing is a duplicate" — the one wrong 

241 # answer that looks right. 

242 logger.exception("duplicate check failed for import %s", job_id) 

243 payload["duplicates"] = None 

244 

245 return payload 

246 

247 

248@router.post( 

249 "/{job_id}/commit", 

250 dependencies=_TEACHER_ONLY, 

251 summary="Approve a reviewed import and create the questions", 

252) 

253@limiter.limit("10/minute") 

254async def commit_import(request: Request, job_id: str, body: dict) -> dict: 

255 """Create the reviewed questions in the teacher's question bank. 

256 

257 Questions are created through TeacherQuestionService.create — the same path a 

258 hand-authored question takes — so imports obey identical validation rather than a 

259 parallel write path that could drift from it. 

260 

261 Every payload is validated BEFORE any is written. That way the common failure 

262 (a payload the API's validator rejects) leaves the bank untouched, instead of 

263 stopping halfway with some questions saved and no clear record of which. 

264 """ 

265 try: 

266 result = await question_import.commit_import( 

267 job_id, body, owner=_import_owner(request) 

268 ) 

269 except question_import.IngestRejected as exc: 

270 raise _reraise(exc) from exc 

271 except question_import.IngestUnavailable as exc: 

272 logger.error("question ingest unavailable on commit: %s", exc) 

273 raise HTTPException( 

274 status_code=503, 

275 detail="the question import service is unavailable; nothing was committed", 

276 ) from exc 

277 

278 payloads = result.get("payloads") or [] 

279 if not payloads: 

280 raise HTTPException( 

281 status_code=422, 

282 detail="the import produced no questions to create", 

283 ) 

284 

285 # Duplicates are skipped, not rejected: the rest of the file still imports. 

286 # NOT wrapped in try/except, unlike the review step. If the check itself fails we 

287 # would be choosing between blocking this import and silently creating the exact 

288 # duplicates this exists to prevent — and only one of those is recoverable by the 

289 # teacher. Matches the staff implementation. 

290 teacher_id = _teacher_id(request) 

291 duplicate_indices, _ = await question_dedup.mark_duplicates(teacher_id, payloads) 

292 skipped = set(duplicate_indices) 

293 

294 # Provenance is split off before validation and before the payloads are reused in 

295 # phase 2, so both phases see exactly the same dict. Splitting per phase would let 

296 # phase 1 pass and phase 2 fail on a payload phase 1 never saw. 

297 provenance = [] 

298 for index, payload in enumerate(payloads): 

299 payloads[index], carried = _split_provenance(payload) 

300 provenance.append(carried) 

301 

302 # Phase 1 — validate everything, write nothing. 

303 problems = [] 

304 for index, payload in enumerate(payloads, start=1): 

305 if index - 1 in skipped: 

306 continue 

307 try: 

308 await teacher_question_service._validate_question_data(payload) 

309 except ValueError as exc: 

310 problems.append({"question": index, "message": str(exc)}) 

311 

312 if problems: 

313 # A failure here means the ingest service accepted something this API rejects — 

314 # the two validators have diverged. Surface it plainly rather than writing a 

315 # partial batch. 

316 logger.warning( 

317 "import %s: %d payload(s) rejected by teacher validation", job_id, len(problems) 

318 ) 

319 raise HTTPException( 

320 status_code=422, 

321 detail={ 

322 "message": ( 

323 f"{len(problems)} of {len(payloads)} questions failed validation; " 

324 "no questions were created" 

325 ), 

326 "problems": problems, 

327 }, 

328 ) 

329 

330 # Phase 2 — write. Validation already passed, so a failure here is infrastructural. 

331 created, failed = [], [] 

332 for index, payload in enumerate(payloads, start=1): 

333 if index - 1 in skipped: 

334 continue 

335 try: 

336 # `questionId` is deliberately not forwarded: the teacher bank has no such 

337 # field on any template and its serializer would never return it, so storing 

338 # it would write something no read path can ever show. 

339 question = await teacher_question_service.create( 

340 request, payload, source=provenance[index - 1].get("source") 

341 ) 

342 created.append(question) 

343 except Exception as exc: # noqa: BLE001 - report, never lose the tally 

344 logger.exception("import %s: question %d failed to create", job_id, index) 

345 failed.append({"question": index, "message": f"{type(exc).__name__}: {exc}"}) 

346 

347 return { 

348 "job_id": job_id, 

349 "state": result.get("state"), 

350 "created": len(created), 

351 "skipped_duplicates": len(skipped), 

352 "failed": len(failed), 

353 # Named explicitly so a partial write can never be mistaken for a clean run. 

354 "partial": bool(failed and created), 

355 "failures": failed, 

356 "questions": created, 

357 }