Coverage for server / services / student / student_assignment.py: 96%

665 statements  

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

1from statistics import mean 

2from bson.objectid import ObjectId 

3from fastapi import HTTPException, Request 

4from pymongo import ReturnDocument 

5from pymongo.errors import DuplicateKeyError 

6from server.connection.database import db 

7from server.utilities.user_id_helper import to_user_id, enrollment_id_conditions 

8 

9# from server.models.question import Question 

10from typing import Optional 

11from server.models.assignment_submit import ( 

12 AssignmentSubmitRequest, 

13 ViolationReportRequest, 

14) 

15from datetime import datetime, timezone, timedelta 

16from server.utilities.helpers import serialized_response_object 

17from server.utilities.gradebook import ( 

18 select_canonical_submission, 

19 resolve_cell_grade, 

20 extract_live_question_ids, 

21 recalculate_submitted_grade, 

22) 

23from server.services.common.question_bank import fetch_questions_by_ids 

24from server.services.common.answer_checking import ( 

25 is_answer_correct, 

26 needs_manual_marking, 

27 normalize_free_response_answer, 

28 resolve_correct_answer, 

29 score_question, 

30) 

31from random import shuffle 

32from server.utilities.error_detail import safe_detail 

33from server.utilities.graph_data_checker import student_prompt_correct_answer 

34 

35 

36# Browser-lockdown violation threshold (see 

37# docs/implementation/browser_lockdown_violation_tracking.md in the client repo). 

38# Violations 1-2 are recorded normally; the 3rd (count == MAX) is both recorded 

39# AND the one that tells the UI to auto-submit the attempt now — 3 is a hard cap, 

40# not "one more than the last free one". 

41MAX_LOCKDOWN_VIOLATIONS = 3 

42 

43 

44# SEC-5 — the threshold is only meaningful if the SERVER refuses further work. 

45# 

46# Reaching MAX_LOCKDOWN_VIOLATIONS used to do nothing but set `auto_submit` in a 

47# response body, i.e. it *asked the UI* to submit. A student who ignored that ask 

48# — devtools, a replayed request, a blocked JS handler — could keep saving 

49# answers and re-fetching questions indefinitely, so the lockdown was advisory 

50# and trivially bypassed by exactly the population it exists to constrain. 

51# 

52# Deliberately NOT applied to answers/submit: the whole point of the threshold is 

53# that the attempt gets submitted, and gating submit too would strand the 

54# student's work in an attempt they could neither continue nor close. 

55NOT_OPEN_YET = "This assignment is not open yet." 

56 

57 

58def _assert_assignment_open(assignment: dict | None) -> None: 

59 """Raise 403 while the assignment's ``date_open`` is still in the future. 

60 

61 Added by Allan Ninal — 2026-10-03 (EI-3293). 

62 WHAT: nothing on the server compared ``date_open`` with the clock — only the 

63 student app hid a not-yet-open assignment ("Not Yet Available"), so a 

64 student calling the API directly could fetch its questions, save and 

65 submit before it opened. ``date_close`` was already enforced. 

66 WHY: the open date is part of the assessment's integrity, so it is enforced 

67 where the questions are served and answers are written: questions 

68 fetch, answers save and answers submit. The assignment list and its 

69 details stay readable, so the app can still show when it opens. 

70 A missing ``date_open`` means "open"; a naive datetime is UTC, as for 

71 ``date_close``. 

72 """ 

73 date_open = (assignment or {}).get("date_open") 

74 if not date_open: 

75 return 

76 if date_open.tzinfo is None: 

77 date_open = date_open.replace(tzinfo=timezone.utc) 

78 if datetime.now(timezone.utc) < date_open: 

79 raise HTTPException(status_code=403, detail=NOT_OPEN_YET) 

80 

81 

82def _assert_lockdown_not_breached(submission: dict) -> None: 

83 """Raise 403 when this attempt has already breached the lockdown threshold. 

84 

85 Callers must pass an UNSUBMITTED attempt; once `is_submitted` is true the 

86 attempt is closed by its own guards and reading it back (results, review) is 

87 legitimate. 

88 """ 

89 if submission.get("violation_count", 0) >= MAX_LOCKDOWN_VIOLATIONS: 

90 raise HTTPException( 

91 status_code=403, 

92 detail=( 

93 "This attempt was closed after reaching the maximum number of " 

94 "browser-lockdown violations and can no longer be edited. " 

95 "Submit the attempt or contact your teacher." 

96 ), 

97 ) 

98 

99 

100# The violation `type` strings the client (useAssessmentLockdown.js) actually 

101# sends. Used to bucket the per-type breakdown on the submission document — 

102# anything outside this set (a stale client build, a hand-crafted request) is 

103# grouped under "unknown" rather than trusted verbatim as a Mongo field name: 

104# `type` is client-controlled, and `$inc`-ing a field built from raw untrusted 

105# input (`f"violation_counts.{type}"`) would let a dotted value target an 

106# arbitrary nested path under violation_counts. Update this set when a new 

107# violation type is added client-side, or it will only ever show up as "unknown" 

108# in the breakdown. 

109KNOWN_LOCKDOWN_VIOLATION_TYPES = frozenset( 

110 { 

111 "fullscreen_exit", 

112 "tab_hidden", 

113 "window_blur", 

114 "clipboard_blocked", 

115 "blocked_shortcut", 

116 } 

117) 

118 

119 

120def _normalize_violation_type(raw_type: Optional[str]) -> str: 

121 """Maps a client-reported violation type to a safe, bounded breakdown key.""" 

122 return raw_type if raw_type in KNOWN_LOCKDOWN_VIOLATION_TYPES else "unknown" 

123 

124 

125# Defensive bound on the cross-DB question-bank $in fan-out. Far above the 

126# 100-question model cap, so it only ever guards a corrupt/abusive id set. 

127_MAX_QUESTION_FETCH = 500 

128 

129 

130async def random_question_fetch(difficulty, classification, assignment_questions): 

131 """ 

132 Fetch a random question matching difficulty and classification criteria. 

133 

134 Args: 

135 difficulty (str): Difficulty level of the question 

136 classification (str): Classification/category of the question 

137 assignment_questions (list): List of question IDs already in the assignment 

138 

139 Returns: 

140 dict | None: Random question that matches criteria and isn't in assignment, 

141 or None if no matching question found 

142 """ 

143 pipeline = [ 

144 { 

145 "$match": { 

146 "difficulty": difficulty.title(), 

147 "classification": classification, 

148 } 

149 }, 

150 {"$sample": {"size": 20}}, 

151 ] 

152 result = await db["question_collection"].aggregate(pipeline).to_list(length=None) 

153 if not result: 

154 return None 

155 

156 for question in result: 

157 if question["_id"] not in assignment_questions: 

158 return question 

159 

160 return None 

161 

162 

163def to_utc_aware(dt: Optional[datetime]) -> Optional[datetime]: 

164 """Ensure MongoDB datetime is UTC-aware.""" 

165 if not dt: 

166 return None 

167 return ( 

168 dt.replace(tzinfo=timezone.utc) 

169 if dt.tzinfo is None 

170 else dt.astimezone(timezone.utc) 

171 ) 

172 

173 

174def format_grade(value) -> str: 

175 try: 

176 num = float(value) 

177 if num.is_integer(): 

178 return str(int(num)) 

179 return f"{num:.2f}" 

180 except (TypeError, ValueError): 

181 return "0" 

182 

183 

184class StudentAssignmentsService: 

185 """ 

186 Service class for managing assignment-related operations. 

187 

188 Handles creation, retrieval, updating, and deletion of assignments, 

189 as well as submission and sharing functionality. 

190 """ 

191 

192 def __init__(self): 

193 pass 

194 

195 async def assignments_fetch(self, class_code: str, request: Request) -> dict: 

196 user_id = to_user_id(request.state.user_details["uuid"]) 

197 email = request.state.user_details.get("email") 

198 now = datetime.now(timezone.utc) 

199 

200 # Step 1: Validate class 

201 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

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

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

204 {"_id": 1, "students._id": 1, "students.email": 1}, 

205 ) 

206 

207 if not class_doc: 

208 return { 

209 "message": "No assignments available for this class.", 

210 "assignments": [], 

211 } 

212 

213 # Step 2: Validate enrollment. Match by _id OR email (same fallback 

214 # class_statistics_fetch/all_classes_fetch use) — a duplicate/stale 

215 # user document sharing this student's email can end up as the _id on 

216 # a class's enrollment record instead of the live account's. 

217 if not any( 

218 student["_id"] == user_id or (email and student.get("email") == email) 

219 for student in class_doc.get("students", []) 

220 ): 

221 return { 

222 "message": "No assignments available for this class.", 

223 "assignments": [], 

224 } 

225 

226 class_id = class_doc["_id"] 

227 

228 # Step 3: Fetch assignments 

229 assignments = ( 

230 await db["assignments_collection"] 

231 .aggregate( 

232 [ 

233 { 

234 "$match": { 

235 "assigned_class": {"$in": [class_id]}, 

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

237 } 

238 }, 

239 { 

240 "$project": { 

241 "_id": 1, 

242 "assigned_class": 1, 

243 "title": 1, 

244 "questions": 1, 

245 "description": 1, 

246 "due_date": 1, 

247 "date_open": 1, 

248 "date_close": 1, 

249 "type": 1, 

250 "category": 1, 

251 "created_at": 1, 

252 "instructions": 1, 

253 "rubric": 1, 

254 } 

255 }, 

256 ] 

257 ) 

258 .to_list(length=None) 

259 ) 

260 

261 if not assignments: 

262 return {"message": "Assignments fetched successfully", "assignments": []} 

263 

264 assignment_ids = [a["_id"] for a in assignments] 

265 

266 # Step 4: Fetch submissions. Read the fields the shared resolver needs so 

267 # this list and the teacher gradebook agree on which attempt to show and 

268 # how to render it (EI-1195). 

269 submissions = ( 

270 await db["submission_collection"] 

271 .find( 

272 {"student_id": user_id, "assignment_id": {"$in": assignment_ids}}, 

273 { 

274 "assignment_id": 1, 

275 "grade": 1, 

276 "is_submitted": 1, 

277 "review_status": 1, 

278 "date_submitted": 1, 

279 "date_updated": 1, 

280 "date_created": 1, 

281 # Needed only to recalculate the grade against the assignment's 

282 # current question list — see recalculate_submitted_grade. 

283 "questions": 1, 

284 "last_student_answers": 1, 

285 }, 

286 ) 

287 .to_list(length=None) 

288 ) 

289 

290 # EI-1195: group duplicate submissions per assignment, then pick ONE 

291 # canonical doc — identical selection to the teacher gradebook. 

292 submissions_by_assignment: dict = {} 

293 for sub in submissions: 

294 submissions_by_assignment.setdefault(sub["assignment_id"], []).append(sub) 

295 

296 # Step 5: Apply grade rules via the shared resolver so a graded assignment 

297 # shows the same number here as in the gradebook, and ungraded states 

298 # (missed / incomplete / pending) match too. 

299 for assignment in assignments: 

300 assignment_id = assignment["_id"] 

301 canonical = select_canonical_submission( 

302 submissions_by_assignment.get(assignment_id, []) 

303 ) 

304 # A question the teacher removed after grading must stop 

305 # contributing to (or costing) the score shown here too — same 

306 # recalculation the gradebook and class average apply, so this list 

307 # can't drift from either (EI-1195). 

308 if canonical is not None: 

309 recalculated_grade = recalculate_submitted_grade( 

310 canonical, 

311 extract_live_question_ids(assignment.get("questions", [])), 

312 ) 

313 if recalculated_grade is not None: 

314 canonical = {**canonical, "grade": recalculated_grade} 

315 resolved = resolve_cell_grade(canonical, assignment.get("date_close"), now) 

316 

317 if resolved["grade"] is not None: 

318 assignment["grade"] = format_grade(resolved["grade"]) 

319 else: 

320 assignment["grade"] = resolved["status"] 

321 

322 return { 

323 "message": "Assignments fetched successfully", 

324 "assignments": serialized_response_object(assignments), 

325 } 

326 

327 async def class_grade_average_fetch( 

328 self, class_code: str, request: Request 

329 ) -> dict: 

330 """ 

331 Compute the student's total average grade across all assignments in a 

332 specific class. Mirrors `assignments_fetch` (same enrollment check and the 

333 same shared `select_canonical_submission`/`resolve_cell_grade` resolution 

334 used by the teacher gradebook) so this average agrees with the per-assignment 

335 grades shown in the class assignment list and the teacher's gradebook (EI-1195). 

336 

337 The response lists every assignment in the class with its resolved grade 

338 (or None) and status label, but only assignments resolved as numerically 

339 "graded" contribute to the `average`/`total_graded` — missed/incomplete/ 

340 pending/rejected assignments are excluded from the average, same as the 

341 teacher gradebook's per-student average. 

342 """ 

343 user_id = to_user_id(request.state.user_details["uuid"]) 

344 email = request.state.user_details.get("email") 

345 now = datetime.now(timezone.utc) 

346 

347 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

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

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

350 {"_id": 1, "students._id": 1, "students.email": 1}, 

351 ) 

352 

353 # Modified by Allan Ninal — 2026-10-03 (Zephyr EI-T709). 

354 # WAS: 200 "No assignments available for this class." with a null average, 

355 # for an unknown class code AND for a class the student is not enrolled in 

356 # — indistinguishable from a real class with nothing graded yet. Both are 

357 # now 404 with the same detail, so the answer does not reveal whether a 

358 # class exists that the caller is not in. The SPA's rejected reducer 

359 # (StudentClassGrade.js) already resets to the same empty state. 

360 if not class_doc: 

361 raise HTTPException(status_code=404, detail="Class not found.") 

362 

363 if not any( 

364 student["_id"] == user_id or (email and student.get("email") == email) 

365 for student in class_doc.get("students", []) 

366 ): 

367 raise HTTPException(status_code=404, detail="Class not found.") 

368 

369 class_id = class_doc["_id"] 

370 

371 assignments = ( 

372 await db["assignments_collection"] 

373 .find( 

374 {"assigned_class": {"$in": [class_id]}, "deleted": {"$ne": True}}, 

375 # "questions" (the assignment's CURRENT refs) is needed to recalculate 

376 # a submission's grade against a question the teacher may have since 

377 # removed — see _recalculate_submitted_grade. 

378 {"_id": 1, "title": 1, "type": 1, "date_close": 1, "questions": 1}, 

379 ) 

380 .to_list(length=None) 

381 ) 

382 

383 if not assignments: 

384 return { 

385 "message": "Grade average fetched successfully", 

386 "average": None, 

387 "total_graded": 0, 

388 "total_assignments": 0, 

389 "status_counts": { 

390 "graded": 0, 

391 "missed": 0, 

392 "incomplete": 0, 

393 "pending": 0, 

394 "rejected": 0, 

395 }, 

396 "assignments": [], 

397 } 

398 

399 assignment_ids = [a["_id"] for a in assignments] 

400 

401 submissions = ( 

402 await db["submission_collection"] 

403 .find( 

404 {"student_id": user_id, "assignment_id": {"$in": assignment_ids}}, 

405 { 

406 "assignment_id": 1, 

407 "grade": 1, 

408 "is_submitted": 1, 

409 "review_status": 1, 

410 "date_submitted": 1, 

411 "date_updated": 1, 

412 "date_created": 1, 

413 # Needed only to recalculate the grade against the assignment's 

414 # current question list — see _recalculate_submitted_grade. 

415 "questions": 1, 

416 "last_student_answers": 1, 

417 }, 

418 ) 

419 .to_list(length=None) 

420 ) 

421 

422 submissions_by_assignment: dict = {} 

423 for sub in submissions: 

424 submissions_by_assignment.setdefault(sub["assignment_id"], []).append(sub) 

425 

426 grade_values = [] 

427 assignment_summaries = [] 

428 for assignment in assignments: 

429 canonical = select_canonical_submission( 

430 submissions_by_assignment.get(assignment["_id"], []) 

431 ) 

432 # A question the teacher removed after grading must stop 

433 # contributing to (or costing) the score shown here — recalculate 

434 # against the assignment's current questions before resolving the 

435 # display grade, rather than trusting the stored `grade` verbatim. 

436 recalculated_grade = ( 

437 recalculate_submitted_grade( 

438 canonical, 

439 extract_live_question_ids(assignment.get("questions", [])), 

440 ) 

441 if canonical 

442 else None 

443 ) 

444 if canonical and recalculated_grade is not None: 

445 canonical = {**canonical, "grade": recalculated_grade} 

446 resolved = resolve_cell_grade(canonical, assignment.get("date_close"), now) 

447 if resolved["grade"] is not None: 

448 grade_values.append(resolved["grade"]) 

449 

450 # status label mirrors the shared resolver's states, and the class 

451 # assignment list's Grade column, so a non-numeric entry here always 

452 # explains why that assignment was left out of the average: 

453 # "graded" | "missed" | "incomplete" | "pending" | "rejected" 

454 assignment_summaries.append( 

455 { 

456 "assignment_id": str(assignment["_id"]), 

457 "title": assignment.get("title"), 

458 "type": assignment.get("type"), 

459 "date_close": ( 

460 assignment.get("date_close").isoformat() 

461 if assignment.get("date_close") 

462 else None 

463 ), 

464 "grade": ( 

465 round(resolved["grade"], 2) 

466 if resolved["grade"] is not None 

467 else None 

468 ), 

469 "status": resolved["status"], 

470 } 

471 ) 

472 

473 average_grade = round(mean(grade_values), 2) if grade_values else None 

474 

475 # Precomputed so the frontend can render a status breakdown chart without 

476 # re-deriving counts from `assignments` itself. 

477 status_counts = { 

478 "graded": 0, 

479 "missed": 0, 

480 "incomplete": 0, 

481 "pending": 0, 

482 "rejected": 0, 

483 } 

484 for summary in assignment_summaries: 

485 status_counts[summary["status"]] = ( 

486 status_counts.get(summary["status"], 0) + 1 

487 ) 

488 

489 return { 

490 "message": "Grade average fetched successfully", 

491 "average": average_grade, 

492 "total_graded": len(grade_values), 

493 "total_assignments": len(assignments), 

494 "status_counts": status_counts, 

495 "assignments": assignment_summaries, 

496 } 

497 

498 async def fetch_specific_assignment( 

499 self, assignment_uuid: str, request: Request 

500 ) -> dict: 

501 """ 

502 Fetch a specific assignment for a student if the student belongs to any of the assignment's assigned classes. 

503 Now returns only the total number of questions instead of the full question details. 

504 Also includes remaining attempts and time already used, based on the student's submission history. 

505 """ 

506 if not ObjectId.is_valid(assignment_uuid): 

507 raise HTTPException(status_code=400, detail="Invalid assignment ID format") 

508 

509 assignment_id = ObjectId(assignment_uuid) 

510 student_id = to_user_id(request.state.user_details.get("uuid")) 

511 email = request.state.user_details.get("email") 

512 

513 try: 

514 # Fetch assignment 

515 base_assignment = await db["assignments_collection"].find_one( 

516 {"_id": assignment_id, "deleted": {"$ne": True}} 

517 ) 

518 

519 if not base_assignment: 

520 raise HTTPException(status_code=404, detail="Assignment not found") 

521 

522 # Check if student belongs to any assigned class 

523 assigned_class_ids = base_assignment.get("assigned_class", []) 

524 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students. 

525 class_with_student = await db["class_collection"].find_one( 

526 { 

527 "_id": {"$in": assigned_class_ids}, 

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

529 "students": { 

530 "$elemMatch": { 

531 "$or": enrollment_id_conditions(student_id, email) 

532 } 

533 }, 

534 } 

535 ) 

536 

537 if not class_with_student: 

538 raise HTTPException( 

539 status_code=403, detail="You do not have access to this assignment" 

540 ) 

541 

542 # Aggregate total question count 

543 pipeline = [ 

544 {"$match": {"_id": assignment_id}}, 

545 { 

546 "$addFields": { 

547 "_id": {"$toString": "$_id"}, 

548 "totalQuestions": { 

549 "$cond": { 

550 "if": {"$isArray": "$questions"}, 

551 "then": {"$size": "$questions"}, 

552 "else": 0, 

553 } 

554 }, 

555 } 

556 }, 

557 {"$project": {"questions": 0}}, 

558 ] 

559 result = ( 

560 await db["assignments_collection"].aggregate(pipeline).to_list(length=1) 

561 ) 

562 

563 if not result: 

564 raise HTTPException( 

565 status_code=404, detail="Assignment data aggregation failed" 

566 ) 

567 

568 assignment_data = result[0] 

569 total_questions = assignment_data.pop("totalQuestions", 0) 

570 

571 # Fetch existing submission 

572 submission = await db["submission_collection"].find_one( 

573 {"assignment_id": assignment_id, "student_id": student_id} 

574 ) 

575 

576 allowed_attempts = base_assignment.get("settings", {}).get( 

577 "allowed_attempts", 1 

578 ) 

579 

580 if submission: 

581 used_attempts = submission.get("total_attempts", 0) 

582 remaining_attempts = max(allowed_attempts - used_attempts, 0) 

583 time_used = self._format_duration_hhmmss( 

584 self._elapsed_seconds(submission) 

585 ) 

586 else: 

587 remaining_attempts = allowed_attempts 

588 time_used = self._format_duration_hhmmss(0) 

589 

590 return { 

591 "assignment": { 

592 "details": serialized_response_object(assignment_data), 

593 "totalQuestions": total_questions, 

594 "remainingAttempts": remaining_attempts, 

595 "timeUsed": time_used, 

596 } 

597 } 

598 

599 except HTTPException: 

600 raise 

601 except Exception as e: 

602 raise HTTPException( 

603 status_code=500, detail=safe_detail(e, "An unexpected error occurred") 

604 ) 

605 

606 async def fetch_assignment_questions( 

607 self, assignment_uuid: str, request: Request 

608 ) -> dict: 

609 """ 

610 Fetches and returns the questions and related information for a specific assignment based on the student's request. 

611 Args: 

612 assignment_uuid (str): The UUID of the assignment to fetch. 

613 request (Request): The FastAPI request object containing user details. 

614 Returns: 

615 dict: A dictionary containing assignment details, student answers, and remaining time. 

616 Raises: 

617 HTTPException: If the assignment ID is invalid, the assignment is not found, 

618 the student is not enrolled, or time/attempt limits are exceeded. 

619 """ 

620 try: 

621 assignment_id = self._validate_object_id( 

622 assignment_uuid, "Invalid assignment ID format" 

623 ) 

624 student_id = to_user_id(request.state.user_details.get("uuid")) 

625 email = request.state.user_details.get("email") 

626 

627 assignment = await self._get_assignment(assignment_id) 

628 await self._verify_enrollment(assignment, student_id, email) 

629 _assert_assignment_open(assignment) 

630 

631 settings = assignment.get("settings", {}) 

632 shuffle_questions = settings.get("shuffle_questions", False) 

633 shuffle_choices = settings.get("shuffle_choices", False) 

634 allowed_attempts = settings.get("allowed_attempts", 1) 

635 time_allowed_value = settings.get("time_allowed") 

636 allowed_duration = ( 

637 self._parse_time(time_allowed_value) if time_allowed_value else None 

638 ) 

639 

640 now_utc = datetime.now(timezone.utc) 

641 submission = await db["submission_collection"].find_one( 

642 {"assignment_id": assignment_id, "student_id": student_id} 

643 ) 

644 

645 if submission: 

646 # SEC-5: refuse to hand back the question set for an attempt that 

647 # already breached the lockdown threshold — otherwise a student who 

648 # ignored the auto-submit could simply reload and carry on. 

649 # Scoped to unsubmitted attempts so reviewing a finished one still 

650 # works. 

651 if not submission.get("is_submitted", False): 

652 _assert_lockdown_not_breached(submission) 

653 

654 # Check if submission's questions match assignment's questions count 

655 assignment_question_count = len(assignment.get("questions", [])) 

656 submission_question_count = len(submission.get("questions", [])) 

657 

658 # Re-sync a still-in-progress attempt whenever its stored question 

659 # count no longer matches the assignment's current one — either 

660 # direction. A teacher can add a question after the student started 

661 # (the original bug this handled) or REMOVE one, and a removed 

662 # question must stop being served just as much as a missing one 

663 # must start being served. A submitted/graded attempt is never 

664 # touched here — its snapshot is historical record, not a live view. 

665 if ( 

666 submission_question_count != assignment_question_count 

667 and not submission.get("is_submitted", False) 

668 ): 

669 submission = await self._resync_submission_questions( 

670 submission, 

671 assignment, 

672 student_id, 

673 shuffle_questions, 

674 shuffle_choices, 

675 now_utc, 

676 ) 

677 

678 return await self._handle_existing_submission( 

679 submission, allowed_attempts, allowed_duration, now_utc, assignment 

680 ) 

681 else: 

682 return await self._create_new_submission( 

683 assignment, 

684 assignment_id, 

685 student_id, 

686 shuffle_questions, 

687 shuffle_choices, 

688 allowed_duration, 

689 now_utc, 

690 ) 

691 

692 except HTTPException: 

693 raise 

694 except Exception as e: 

695 raise HTTPException( 

696 status_code=500, detail=safe_detail(e, "An unexpected error occurred") 

697 ) 

698 

699 def _validate_object_id(self, value: str, error_msg: str) -> ObjectId: 

700 """ 

701 Validates and converts a string to an ObjectId. 

702 Args: 

703 value (str): The string to convert. 

704 error_msg (str): The error message to raise in case of invalid ID. 

705 Returns: 

706 ObjectId: A valid ObjectId instance. 

707 Raises: 

708 HTTPException: If the provided ID string is invalid. 

709 """ 

710 if not ObjectId.is_valid(value): 

711 raise HTTPException(status_code=400, detail=error_msg) 

712 return ObjectId(value) 

713 

714 async def _get_assignment(self, assignment_id: ObjectId) -> dict: 

715 """ 

716 Retrieves the assignment document from the database. 

717 Args: 

718 assignment_id (ObjectId): The ID of the assignment to retrieve. 

719 Returns: 

720 dict: The assignment document. 

721 Raises: 

722 HTTPException: If the assignment is not found or is marked as deleted. 

723 """ 

724 assignment = await db["assignments_collection"].find_one( 

725 {"_id": assignment_id, "deleted": {"$ne": True}} 

726 ) 

727 if not assignment: 

728 raise HTTPException(status_code=404, detail="Assignment not found") 

729 return assignment 

730 

731 async def _verify_enrollment( 

732 self, assignment: dict, student_id: ObjectId, email: str = None 

733 ): 

734 """ 

735 Verifies that the student is enrolled in one of the classes assigned to the assignment. 

736 Args: 

737 assignment (dict): The assignment document. 

738 student_id (ObjectId): The ID of the student. 

739 email (str, optional): The student's email, used as a fallback match — some 

740 enrollment records carry a duplicate/stale account _id instead of the 

741 live account's, so relying on _id alone can miss a real enrollment. 

742 Raises: 

743 HTTPException: If the student is not enrolled in the assigned classes. 

744 """ 

745 class_ids = assignment.get("assigned_class", []) 

746 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students. 

747 is_enrolled = await db["class_collection"].find_one( 

748 { 

749 "_id": {"$in": class_ids}, 

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

751 "students": { 

752 "$elemMatch": {"$or": enrollment_id_conditions(student_id, email)} 

753 }, 

754 } 

755 ) 

756 if not is_enrolled: 

757 raise HTTPException( 

758 status_code=403, detail="You do not have access to this assignment" 

759 ) 

760 

761 async def _has_live_enrolled_class( 

762 self, assignment_id: ObjectId, student_id, email: str = None 

763 ) -> bool: 

764 """True when the assignment exists and one of its assigned classes is a live 

765 (not soft-deleted) class the student belongs to.""" 

766 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students. 

767 assignment = await db["assignments_collection"].find_one( 

768 {"_id": assignment_id}, {"assigned_class": 1} 

769 ) 

770 if not assignment: 

771 return False 

772 live_class = await db["class_collection"].find_one( 

773 { 

774 "_id": {"$in": assignment.get("assigned_class") or []}, 

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

776 "students": { 

777 "$elemMatch": {"$or": enrollment_id_conditions(student_id, email)} 

778 }, 

779 }, 

780 {"_id": 1}, 

781 ) 

782 return live_class is not None 

783 

784 def _parse_time(self, time_str: str) -> timedelta: 

785 """ 

786 Parses a time string (HH:MM:SS) into a timedelta object. 

787 Args: 

788 time_str (str): The time string to parse. 

789 Returns: 

790 timedelta: The parsed duration. 

791 """ 

792 h, m, s = map(int, time_str.split(":")) 

793 return timedelta(hours=h, minutes=m, seconds=s) 

794 

795 def _format_duration_hhmmss(self, total_seconds: int) -> str: 

796 """ 

797 Formats a duration in seconds as an "HH:MM:SS" string. 

798 Args: 

799 total_seconds (int): The duration in seconds. 

800 Returns: 

801 str: The formatted duration. 

802 """ 

803 total_seconds = max(int(total_seconds), 0) 

804 return "{:02}:{:02}:{:02}".format( 

805 total_seconds // 3600, (total_seconds % 3600) // 60, total_seconds % 60 

806 ) 

807 

808 def _elapsed_seconds(self, submission: dict) -> int: 

809 """ 

810 Computes how long a student has spent on a submission: from when the 

811 attempt started (date_created) to when it was submitted (date_submitted), 

812 or to now if the attempt is still in progress. 

813 Args: 

814 submission (dict): The submission document. 

815 Returns: 

816 int: Elapsed time in seconds, floored at 0. 

817 """ 

818 date_created = submission.get("date_created") 

819 if not date_created: 

820 return 0 

821 

822 if date_created.tzinfo is None: 

823 date_created = date_created.replace(tzinfo=timezone.utc) 

824 

825 end_time = submission.get("date_submitted") or datetime.now(timezone.utc) 

826 if end_time.tzinfo is None: 

827 end_time = end_time.replace(tzinfo=timezone.utc) 

828 

829 return int((end_time - date_created).total_seconds()) 

830 

831 async def _resync_submission_questions( 

832 self, 

833 submission, 

834 assignment, 

835 student_id, 

836 shuffle_questions, 

837 shuffle_choices, 

838 now_utc, 

839 ): 

840 """ 

841 Re-syncs an in-progress submission's questions with the assignment's CURRENT 

842 question refs — in either direction. Handles both a question added to the 

843 assignment after the student's submission was created (the original case 

844 here), and a question the teacher REMOVED from the assignment or deleted 

845 from the question bank since — that question must stop being served on the 

846 student's next fetch, not linger forever in their stored snapshot. 

847 

848 Args: 

849 submission (dict): The existing submission document. 

850 assignment (dict): The assignment document. 

851 student_id (ObjectId): The student's ID. 

852 shuffle_questions (bool): Whether to shuffle questions. 

853 shuffle_choices (bool): Whether to shuffle choices. 

854 now_utc (datetime): Current UTC time. 

855 

856 Returns: 

857 dict: The updated submission document. 

858 """ 

859 # Get all question IDs from assignment 

860 question_ids = self._extract_question_ids(assignment.get("questions", [])) 

861 

862 # Fetch all questions from the bank 

863 fetched_questions = await self._fetch_questions_from_bank(question_ids) 

864 

865 # Prepare questions with proper formatting 

866 points_overrides = assignment.get("question_points_overrides") or {} 

867 questions = self._prepare_questions( 

868 fetched_questions, 

869 question_ids, 

870 shuffle_choices, 

871 shuffle_questions, 

872 points_overrides, 

873 ) 

874 

875 # Build a map of existing answers by question ID — prefer submitted_answers as the source of truth 

876 existing_answers = { 

877 str(ans.get("questionId")): ans 

878 for ans in submission.get("submitted_answers", []) 

879 } 

880 

881 existing_count = len(submission.get("questions", [])) 

882 if len(questions) == existing_count: 

883 return submission 

884 

885 # `resolved_all` distinguishes a trustworthy reconciliation from a 

886 # transient one: True means every id the assignment CURRENTLY references 

887 # resolved from some bank, so `questions` is a complete, accurate picture 

888 # of "what the assignment looks like right now" — safe to persist even 

889 # when it's SHORTER than the stored submission (the teacher genuinely 

890 # removed a question). False means some referenced id didn't resolve 

891 # anywhere (a dangling/deleted question id, or a transient cross-DB 

892 # fetch issue) — in that case only ever GROW the stored list, never 

893 # shrink from an admittedly-incomplete fetch. This also still stops the 

894 # write-amplification loop the original guard called out: a 

895 # permanently-dangling id keeps `resolved_all` False forever, so a 

896 # same-or-shorter result there keeps returning early instead of 

897 # re-writing on every fetch. 

898 resolved_all = len(questions) == len(question_ids) 

899 if not resolved_all and len(questions) <= existing_count: 

900 return submission 

901 

902 # Generate re-synced answer slots, preserving student progress where available 

903 new_answers = [] 

904 for q in questions: 

905 q_id = str(q["_id"]) 

906 if q_id in existing_answers: 

907 new_answers.append(existing_answers[q_id]) 

908 else: 

909 new_answers.append( 

910 { 

911 "questionId": q["_id"], 

912 "questionType": q["questionType"], 

913 "answer": "", 

914 "isFlagged": False, 

915 "category": q.get("category"), 

916 } 

917 ) 

918 

919 # Update submission in database 

920 await db["submission_collection"].update_one( 

921 {"_id": submission["_id"]}, 

922 { 

923 "$set": { 

924 "questions": questions, 

925 "submitted_answers": new_answers, 

926 "total_questions": len(questions), 

927 "date_updated": now_utc, 

928 } 

929 }, 

930 ) 

931 

932 # Return updated submission 

933 submission["questions"] = questions 

934 submission["submitted_answers"] = new_answers 

935 submission["total_questions"] = len(questions) 

936 return submission 

937 

938 async def _handle_existing_submission( 

939 self, submission, allowed_attempts, allowed_duration, now_utc, assignment 

940 ): 

941 """ 

942 Processes an existing submission and checks for time/attempt constraints. 

943 

944 Behavior based on submission status: 

945 - If is_submitted=False: Resume in-progress attempt; do NOT increment total_attempts. 

946 - If is_submitted=True: Start new attempt only when total_attempts < allowed_attempts; 

947 reset answers/timer without incrementing (assignment_submit increments on completion). 

948 The question list is rebuilt from the assignment's CURRENT `questions` refs (same 

949 as _create_new_submission) rather than reusing the previous attempt's stored 

950 snapshot — otherwise a question the teacher removed/deleted after that attempt 

951 keeps reappearing on every later attempt. 

952 

953 total_attempts = number of *completed* (submitted) attempts. 

954 

955 Args: 

956 submission (dict): The existing submission document. 

957 allowed_attempts (int): The maximum allowed attempts. 

958 allowed_duration (timedelta): The total allowed duration. 

959 now_utc (datetime): The current UTC time. 

960 assignment (dict): The assignment document. 

961 Returns: 

962 dict: A response dictionary containing assignment data and remaining time. 

963 Raises: 

964 HTTPException: If all allowed attempts are exhausted or time has expired. 

965 """ 

966 is_submitted = submission.get("is_submitted", False) 

967 total_attempts = submission.get("total_attempts", 0) 

968 

969 settings = assignment.get("settings", {}) 

970 shuffle_questions = settings.get("shuffle_questions", False) 

971 shuffle_choices = settings.get("shuffle_choices", False) 

972 

973 if is_submitted: 

974 # All completed attempts used — cannot start another attempt. 

975 if total_attempts >= allowed_attempts: 

976 raise HTTPException( 

977 status_code=403, 

978 detail="You have exceeded the allowed number of attempts", 

979 ) 

980 

981 # Starting a new attempt after a completed submission. 

982 # Reset submitted_answers and timer; do NOT increment total_attempts here — 

983 # assignment_submit increments it upon completion. Rebuild `questions` fresh 

984 # from the assignment's current refs (mirrors _create_new_submission) instead 

985 # of reusing submission["questions"] — that stored snapshot is frozen from 

986 # whenever this student's FIRST attempt began, so it silently kept including 

987 # a question the teacher later removed from the assignment or deleted from 

988 # the question bank. 

989 question_ids = self._extract_question_ids(assignment.get("questions", [])) 

990 fetched_questions = await self._fetch_questions_from_bank(question_ids) 

991 points_overrides = assignment.get("question_points_overrides") or {} 

992 questions = self._prepare_questions( 

993 fetched_questions, 

994 question_ids, 

995 shuffle_choices, 

996 shuffle_questions, 

997 points_overrides, 

998 ) 

999 default_answers = self._generate_default_answers(questions) 

1000 

1001 await db["submission_collection"].update_one( 

1002 {"_id": submission["_id"]}, 

1003 { 

1004 "$set": { 

1005 "questions": questions, 

1006 "submitted_answers": default_answers, 

1007 "total_questions": len(questions), 

1008 "is_submitted": False, 

1009 "date_updated": now_utc, 

1010 "date_created": now_utc, 

1011 # New attempt: browser-lockdown violations are scoped per 

1012 # attempt (see report_violation), so they reset here too. 

1013 "violation_count": 0, 

1014 "violation_counts": {}, 

1015 } 

1016 }, 

1017 ) 

1018 return await self._prepare_response( 

1019 assignment, 

1020 questions, 

1021 default_answers, 

1022 allowed_duration, 

1023 is_submitted=False, 

1024 ) 

1025 

1026 # Continuing an in-progress attempt (is_submitted=False). 

1027 # Do NOT increment total_attempts on resume — only completed submits count. 

1028 date_created = submission.get("date_created", now_utc) 

1029 if date_created.tzinfo is None: 

1030 date_created = date_created.replace(tzinfo=timezone.utc) 

1031 

1032 if allowed_duration is not None: 

1033 elapsed = now_utc - date_created 

1034 remaining = allowed_duration - elapsed 

1035 if remaining.total_seconds() <= 0: 

1036 raise HTTPException( 

1037 status_code=403, detail="Time has expired for this assignment" 

1038 ) 

1039 else: 

1040 remaining = None 

1041 

1042 await db["submission_collection"].update_one( 

1043 {"_id": submission["_id"]}, {"$set": {"date_updated": now_utc}} 

1044 ) 

1045 

1046 current_answers = submission.get("submitted_answers", []) 

1047 return await self._prepare_response( 

1048 assignment, 

1049 submission["questions"], 

1050 current_answers, 

1051 remaining, 

1052 is_submitted, 

1053 ) 

1054 

1055 async def _create_new_submission( 

1056 self, 

1057 assignment, 

1058 assignment_id, 

1059 student_id, 

1060 shuffle_questions, 

1061 shuffle_choices, 

1062 allowed_duration, 

1063 now_utc, 

1064 ): 

1065 """ 

1066 Creates a new submission record for a student and prepares the question list. 

1067 Args: 

1068 assignment (dict): The assignment document. 

1069 assignment_id (ObjectId): The ID of the assignment. 

1070 student_id (ObjectId): The ID of the student. 

1071 shuffle_questions (bool): Whether to shuffle the question order. 

1072 shuffle_choices (bool): Whether to shuffle the choices within each question. 

1073 allowed_duration (timedelta): The time allowed for the assignment. 

1074 now_utc (datetime): The current time in UTC. 

1075 Returns: 

1076 dict: A response dictionary containing the new assignment and student answer data. 

1077 """ 

1078 question_ids = self._extract_question_ids(assignment.get("questions", [])) 

1079 fetched_questions = await self._fetch_questions_from_bank(question_ids) 

1080 

1081 points_overrides = assignment.get("question_points_overrides") or {} 

1082 questions = self._prepare_questions( 

1083 fetched_questions, 

1084 question_ids, 

1085 shuffle_choices, 

1086 shuffle_questions, 

1087 points_overrides, 

1088 ) 

1089 default_answers = self._generate_default_answers(questions) 

1090 

1091 submission_data = { 

1092 "assignment_id": assignment_id, 

1093 "student_id": student_id, 

1094 "questions": questions, 

1095 "submitted_answers": default_answers, 

1096 "total_score": 0, 

1097 "total_correct_answers": 0, 

1098 "total_answers_submitted": 0, 

1099 "total_questions": len(default_answers), 

1100 "remarks": "", 

1101 "total_attempts": 0, 

1102 "is_submitted": False, 

1103 "date_created": now_utc, 

1104 "date_submitted": None, 

1105 "date_updated": None, 

1106 # Browser-lockdown violation tracking for this attempt — see report_violation. 

1107 "violation_count": 0, 

1108 "violation_counts": {}, 

1109 } 

1110 

1111 # EI-1195: idempotent create. An unconditional insert_one raced with a 

1112 # concurrent "start" (double-click / retry / multi-device) produced two 

1113 # submission docs for the same (student, assignment). Upsert on that key 

1114 # inserts only when absent, so a concurrent start is a no-op instead of a 

1115 # duplicate (also backed by the unique index). 

1116 try: 

1117 result = await db["submission_collection"].update_one( 

1118 {"student_id": student_id, "assignment_id": assignment_id}, 

1119 {"$setOnInsert": submission_data}, 

1120 upsert=True, 

1121 ) 

1122 except DuplicateKeyError: 

1123 # Modified by Allan Ninal — 2026-09-24 (EI-1195 follow-up). 

1124 # WHAT: catch the duplicate-key error the upsert can still raise and 

1125 # fall through to the "serve the stored submission" branch below. 

1126 # WHY: an upsert is NOT atomic against a unique index. Two concurrent 

1127 # starts can both find no document and both attempt the insert; 

1128 # one wins and the other gets E11000 from 

1129 # `uniq_submission_student_assignment`. MongoDB documents this and 

1130 # puts the retry on the application. 

1131 # 

1132 # Nothing here handled it, so the error hit the catch-all in 

1133 # `fetch_assignment_questions` and came back as 

1134 # `500 "An unexpected error occurred: {str(e)}"` — which pastes the 

1135 # database name, the collection, the index name and BOTH key values 

1136 # into the response body. 

1137 # 

1138 # A 500 is also just the wrong answer. Losing this race is benign: 

1139 # the student's other tab/device already started the attempt, and 

1140 # the correct response is the one the branch below already 

1141 # produces — serve the stored questions and answers so they resume 

1142 # the attempt that exists. Setting `result = None` routes the 

1143 # raced path into exactly the same code as losing the race without 

1144 # an exception, so there is one behaviour, not two. 

1145 result = None 

1146 # If we LOST the concurrent-start race (the doc already existed, so nothing 

1147 # was inserted), serve the STORED questions/answers — on a shuffle-enabled 

1148 # assignment our locally-generated ordering would differ from what was 

1149 # persisted and what the student resumes into. (EI-1195 review follow-up.) 

1150 if result is None or getattr(result, "upserted_id", None) is None: 

1151 existing = await db["submission_collection"].find_one( 

1152 {"student_id": student_id, "assignment_id": assignment_id} 

1153 ) 

1154 if existing: 

1155 return await self._prepare_response( 

1156 assignment, 

1157 existing.get("questions", questions), 

1158 existing.get("submitted_answers", default_answers), 

1159 allowed_duration, 

1160 is_submitted=existing.get("is_submitted", False), 

1161 ) 

1162 return await self._prepare_response( 

1163 assignment, questions, default_answers, allowed_duration, is_submitted=False 

1164 ) 

1165 

1166 def _extract_question_ids(self, raw_questions): 

1167 """ 

1168 Extracts and validates ObjectIds from the assignment's question list. 

1169 Handles multiple formats: 

1170 - Dict with 'id' field: {"id": ObjectId/str, ...} 

1171 - Dict with '_id' field: {"_id": ObjectId/str, ...} 

1172 - String ID: "507f1f77bcf86cd799439011" 

1173 - ObjectId directly 

1174 

1175 Args: 

1176 raw_questions (list): A list of question IDs or dicts with 'id'/'_id'. 

1177 Returns: 

1178 list: A list of valid ObjectId instances. 

1179 """ 

1180 question_ids = [] 

1181 for q in raw_questions: 

1182 try: 

1183 if isinstance(q, dict): 

1184 # Try 'id' field first, then '_id' as fallback 

1185 qid = q.get("id") or q.get("_id") 

1186 elif isinstance(q, ObjectId): 

1187 qid = q 

1188 else: 

1189 qid = q 

1190 

1191 if qid is None: 

1192 continue 

1193 

1194 # Convert to string for validation 

1195 qid_str = str(qid) 

1196 if ObjectId.is_valid(qid_str): 

1197 question_ids.append(ObjectId(qid_str)) 

1198 except Exception: 

1199 # Skip invalid entries 

1200 continue 

1201 

1202 return question_ids 

1203 

1204 async def _fetch_questions_from_bank(self, question_ids): 

1205 """Map ``str(question id) -> question document``, across all three banks. 

1206 

1207 Modified by Allan Ninal — 2026-09-23 

1208 WHAT: the three-tier lookup (teacher_questionbank -> the admin-staff 

1209 global_questionbank -> the legacy question_collection) moved to 

1210 server/services/common/question_bank.py; this now delegates to it. 

1211 WHY: server/services/common/assignments.py was reading 

1212 db["question_collection"] DIRECTLY for /v1/assignments/view and 

1213 /review — a collection that exists in no database on this cluster — 

1214 so both routes answered 200 with "questions": [] for every 

1215 assignment. Rather than write a second copy of this lookup there, 

1216 the one that already worked moved to a shared module. Behaviour 

1217 here is unchanged, including the fan-out cap; the method is kept 

1218 under its own name because its callers and tests patch it. 

1219 """ 

1220 return await fetch_questions_by_ids(question_ids) 

1221 

1222 def _prepare_questions( 

1223 self, 

1224 question_map, 

1225 question_ids, 

1226 shuffle_choices, 

1227 shuffle_questions, 

1228 points_overrides=None, 

1229 ): 

1230 """ 

1231 Prepares a list of question dictionaries with optional shuffling of choices and questions. 

1232 Args: 

1233 question_map (dict): Dictionary mapping question IDs to question documents. 

1234 question_ids (list): Ordered list of question ObjectIds from the assignment. 

1235 shuffle_choices (bool): Whether to shuffle choices in each question. 

1236 shuffle_questions (bool): Whether to shuffle the order of questions. 

1237 points_overrides (dict | None): Question id string -> points, from the 

1238 assignment's `question_points_overrides` (a teacher's per-assignment 

1239 point edit). Wins over the question bank document's own `points`. 

1240 Returns: 

1241 list: A list of sanitized question dictionaries for the assignment. 

1242 """ 

1243 shufflable_types = [ 

1244 "checkbox", 

1245 "multiple-choice", 

1246 "drag-and-drop", 

1247 "drop-down-menu", 

1248 ] 

1249 questions = [] 

1250 

1251 # Iterate through questions in the order specified by the assignment 

1252 for qid in question_ids: 

1253 q = question_map.get(str(qid)) 

1254 if not q: 

1255 continue 

1256 

1257 # Get choices and make a copy to avoid modifying the original. 

1258 # Free-response questions store `choices: null`, so coalesce to [] 

1259 # (list(None) would raise → student questions-fetch 500). 

1260 choices = list(q.get("choices") or []) 

1261 qtype = q.get("questionType", "").lower() 

1262 

1263 # Shuffle choices if setting is enabled and question type supports it 

1264 if shuffle_choices and qtype in shufflable_types: 

1265 if qtype == "drop-down-menu": 

1266 # For drop-down menus, shuffle items within each choice 

1267 for choice in choices: 

1268 if isinstance(choice.get("items"), list): 

1269 shuffle(choice["items"]) 

1270 else: 

1271 # For other types, shuffle the choices array 

1272 shuffle(choices) 

1273 

1274 questions.append( 

1275 { 

1276 "_id": q["_id"], 

1277 "points": (points_overrides or {}).get( 

1278 str(q["_id"]), q.get("points") 

1279 ), 

1280 "choices": choices, 

1281 "question": q.get("question"), 

1282 "questionType": qtype, 

1283 "correctAnswer": q.get("correctAnswer"), 

1284 "category": q.get("category"), 

1285 "groups": q.get("groups"), 

1286 "rows": q.get("rows"), 

1287 "rowHeaderLabel": q.get("rowHeaderLabel"), 

1288 } 

1289 ) 

1290 

1291 # Shuffle questions if setting is enabled 

1292 if shuffle_questions: 

1293 shuffle(questions) 

1294 

1295 return questions 

1296 

1297 def _generate_default_answers(self, questions): 

1298 """ 

1299 Generates a list of default answers for the given questions. 

1300 Args: 

1301 questions (list): A list of question documents. 

1302 Returns: 

1303 list: A list of answer dictionaries with default values. 

1304 """ 

1305 return [ 

1306 { 

1307 "questionId": q["_id"], 

1308 "questionType": q["questionType"], 

1309 "answer": "", 

1310 "isFlagged": False, 

1311 "category": q["category"], 

1312 } 

1313 for q in questions 

1314 ] 

1315 

1316 async def _prepare_response( 

1317 self, 

1318 assignment, 

1319 questions, 

1320 answers, 

1321 remaining_time: Optional[timedelta], 

1322 is_submitted: bool = False, 

1323 ): 

1324 """ 

1325 Prepares the final response payload for the assignment, including formatted time and answers. 

1326 Args: 

1327 assignment (dict): The assignment document. 

1328 questions (list): The list of question dictionaries. 

1329 answers (list): The list of student's answers. 

1330 remaining_time (Optional[timedelta]): Time remaining to complete the assignment, or 

1331 None when the assignment has no time limit ("unlimited time"). 

1332 is_submitted (bool): Whether the submission has been submitted. If True, student_answers will be excluded. 

1333 Returns: 

1334 dict: A response dictionary containing assignment details, questions, answers, and time. 

1335 """ 

1336 if remaining_time is None: 

1337 remaining_str = None 

1338 else: 

1339 remaining_str = self._format_duration_hhmmss(remaining_time.total_seconds()) 

1340 

1341 transformed_answers = [ 

1342 { 

1343 "questionId": str(ans.get("questionId")), 

1344 "questionType": ans.get("questionType"), 

1345 "answer": ans.get("answer", ""), 

1346 "isFlagged": ans.get("isFlagged", False), 

1347 "category": ans.get("category"), 

1348 } 

1349 for ans in answers 

1350 ] 

1351 

1352 cleaned_assignment = dict(assignment) 

1353 cleaned_assignment.pop("questions", None) 

1354 

1355 sanitized_questions = [] 

1356 for q in questions: 

1357 item = {k: v for k, v in q.items() if k != "correctAnswer"} 

1358 # Graph / Graph-Multiple-Select need the authored axes (and GMS dots). 

1359 # Send a prompt copy — never the answer-key drawing. 

1360 prompt = student_prompt_correct_answer(q) 

1361 if prompt is not None: 

1362 item["correctAnswer"] = prompt 

1363 sanitized_questions.append(item) 

1364 

1365 response = { 

1366 "assignment": { 

1367 "details": serialized_response_object(cleaned_assignment), 

1368 "questions": serialized_response_object(sanitized_questions), 

1369 "remaining_time": remaining_str, 

1370 } 

1371 } 

1372 

1373 # Only include student_answers if the submission has not been submitted 

1374 if not is_submitted: 

1375 response["assignment"]["student_answers"] = transformed_answers 

1376 

1377 return response 

1378 

1379 async def assignment_submit( 

1380 self, 

1381 assignment_uuid: str, 

1382 submit_request: AssignmentSubmitRequest, 

1383 request: Request, 

1384 ) -> dict: 

1385 """ 

1386 Submits and evaluates a student's answers for a specific assignment. 

1387 

1388 This method performs the following steps: 

1389 1. Validates whether the student is authorized to submit the assignment by checking class membership. 

1390 2. Retrieves the existing submission record for the student and assignment. 

1391 3. Stores the raw submitted answers in the 'submitted_answers' field for audit trail. 

1392 4. Updates the submission's answers by matching submitted responses with existing question IDs while preserving order. 

1393 5. Calculates the total score, correctness per question, and evaluation details. 

1394 6. Updates the submission document with the evaluated results, submitted answers, and marks it as submitted. 

1395 

1396 The submission document will contain: 

1397 - answers: Evaluated answers with scoring information 

1398 - submitted_answers: Raw student submissions without evaluation (for audit trail) 

1399 

1400 Args: 

1401 assignment_uuid (str): The UUID of the assignment to be submitted. 

1402 submit_request (AssignmentSubmitRequest): The student's submitted answers and flags. 

1403 request (Request): The current HTTP request, used to extract student identity. 

1404 

1405 Returns: 

1406 dict: A response indicating success, along with scoring details: 

1407 - message (str): Status message. 

1408 - totalScore (int): Total points earned. 

1409 - totalCorrectAnswers (int): Number of correctly answered questions. 

1410 - totalQuestions (int): Total number of questions in the assignment. 

1411 - totalAnswersSubmitted (int): Number of answers the student attempted. 

1412 - details (list): Breakdown of scoring per question, including correct answer, student answer, and points earned. 

1413 

1414 Raises: 

1415 HTTPException: If the student is unauthorized, the assignment is not found, or no submission record exists. 

1416 """ 

1417 if not ObjectId.is_valid(assignment_uuid): 

1418 raise HTTPException(status_code=400, detail="Invalid assignment ID format") 

1419 

1420 student_id = to_user_id(request.state.user_details["uuid"]) 

1421 email = request.state.user_details.get("email") 

1422 assignment_oid = ObjectId(assignment_uuid) 

1423 student_oid = ObjectId(student_id) 

1424 

1425 # Step 1: Validate assignment & student authorization. Matches by 

1426 # student _id OR email (falls back to email because a duplicate/stale 

1427 # account sharing this student's email can end up as the _id on a 

1428 # class's enrollment record instead of the live account's). 

1429 pipeline = [ 

1430 {"$match": {"_id": assignment_oid}}, 

1431 { 

1432 "$lookup": { 

1433 "from": "class_collection", 

1434 "let": {"assigned_class_ids": "$assigned_class"}, 

1435 "pipeline": [ 

1436 { 

1437 "$match": { 

1438 "$expr": { 

1439 "$and": [ 

1440 {"$in": ["$_id", "$$assigned_class_ids"]}, 

1441 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students. 

1442 {"$ne": ["$deleted", True]}, 

1443 { 

1444 "$or": [ 

1445 {"$in": [student_oid, "$students._id"]}, 

1446 { 

1447 "$in": [ 

1448 email or "", 

1449 "$students.email", 

1450 ] 

1451 }, 

1452 ] 

1453 }, 

1454 ] 

1455 } 

1456 } 

1457 } 

1458 ], 

1459 "as": "authorized_classes", 

1460 } 

1461 }, 

1462 {"$match": {"authorized_classes.0": {"$exists": True}}}, 

1463 { 

1464 "$lookup": { 

1465 "from": "submission_collection", 

1466 "let": {"assignment_id": "$_id", "student_id": student_oid}, 

1467 "pipeline": [ 

1468 { 

1469 "$match": { 

1470 "$expr": { 

1471 "$and": [ 

1472 {"$eq": ["$assignment_id", "$$assignment_id"]}, 

1473 {"$eq": ["$student_id", "$$student_id"]}, 

1474 ] 

1475 } 

1476 } 

1477 } 

1478 ], 

1479 "as": "submission", 

1480 } 

1481 }, 

1482 {"$unwind": {"path": "$submission", "preserveNullAndEmptyArrays": False}}, 

1483 ] 

1484 

1485 assignment_data = ( 

1486 await db["assignments_collection"].aggregate(pipeline).to_list(length=1) 

1487 ) 

1488 if not assignment_data: 

1489 raise HTTPException( 

1490 status_code=403, detail="Unauthorized or assignment not found." 

1491 ) 

1492 

1493 assignment = assignment_data[0] 

1494 submission = assignment["submission"] 

1495 

1496 # Step 2: Not open yet, or already closed 

1497 _assert_assignment_open(assignment) 

1498 date_close = assignment.get("date_close") 

1499 is_late_flag = False 

1500 if date_close: 

1501 if date_close.tzinfo is None: 

1502 date_close = date_close.replace(tzinfo=timezone.utc) 

1503 if datetime.now(timezone.utc) > date_close: 

1504 settings_doc = assignment.get("settings", {}) 

1505 allow_late = settings_doc.get("allow_late_submissions", False) 

1506 if not allow_late: 

1507 raise HTTPException( 

1508 status_code=403, 

1509 detail="This assignment is already closed and no longer accepts submissions.", 

1510 ) 

1511 # Late submission allowed — check for duplicate pending 

1512 existing_submission = assignment.get("submission", {}) 

1513 if existing_submission.get("review_status") == "pending": 

1514 raise HTTPException( 

1515 status_code=409, 

1516 detail="A late submission is already pending teacher review.", 

1517 ) 

1518 is_late_flag = True 

1519 

1520 # Step 2b: Guard against re-submit once all allowed attempts are exhausted. 

1521 # total_attempts counts *completed* (submitted) attempts; is_submitted marks the 

1522 # current attempt as done. A re-submit is only valid when the current attempt is 

1523 # still in-progress (is_submitted=False); once submitted, the student must open a 

1524 # new attempt via fetch_assignment_questions (which resets is_submitted to False). 

1525 allowed_attempts = assignment.get("settings", {}).get("allowed_attempts", 1) 

1526 if ( 

1527 submission.get("is_submitted", False) 

1528 and submission.get("total_attempts", 0) >= allowed_attempts 

1529 ): 

1530 raise HTTPException( 

1531 status_code=403, detail="No remaining attempts for this assignment." 

1532 ) 

1533 

1534 # Step 2c: Elapsed-time check — same gate used in _handle_existing_submission. 

1535 # A missing/empty time_allowed means the assignment has no time limit. 

1536 time_allowed_str = assignment.get("settings", {}).get("time_allowed") 

1537 if time_allowed_str: 

1538 try: 

1539 allowed_duration = self._parse_time(time_allowed_str) 

1540 except (ValueError, AttributeError): 

1541 allowed_duration = None 

1542 

1543 if allowed_duration is not None: 

1544 date_created = submission.get("date_created") 

1545 if date_created: 

1546 if date_created.tzinfo is None: 

1547 date_created = date_created.replace(tzinfo=timezone.utc) 

1548 elapsed = datetime.now(timezone.utc) - date_created 

1549 if elapsed > allowed_duration: 

1550 raise HTTPException( 

1551 status_code=403, 

1552 detail="Time has expired for this assignment", 

1553 ) 

1554 

1555 # Step 3: Prepare submitted answers and store raw submission 

1556 answer_map = { 

1557 ans.questionId: {"answer": ans.studentAnswer, "isFlagged": ans.isFlagged} 

1558 for ans in submit_request.answers 

1559 } 

1560 

1561 updated_answers = [] 

1562 submitted_answers = [] 

1563 for ans in submission.get("submitted_answers", []): 

1564 q_id = str(ans["questionId"]) 

1565 

1566 # Store the raw submitted answer with same format as answers field 

1567 if q_id in answer_map: 

1568 update = answer_map[q_id] 

1569 

1570 # Create submitted_answers entry matching answers format 

1571 submitted_answers.append( 

1572 { 

1573 "questionId": ans["questionId"], 

1574 "questionType": ans.get("questionType"), 

1575 "answer": update["answer"], 

1576 "isFlagged": update["isFlagged"], 

1577 "category": ans.get("category"), 

1578 } 

1579 ) 

1580 

1581 # Update the answer for evaluation 

1582 ans["answer"] = update["answer"] 

1583 ans["isFlagged"] = update["isFlagged"] 

1584 else: 

1585 # If question wasn't answered, store empty answer in submitted_answers 

1586 submitted_answers.append( 

1587 { 

1588 "questionId": ans["questionId"], 

1589 "questionType": ans.get("questionType"), 

1590 "answer": ans.get("answer", ""), 

1591 "isFlagged": ans.get("isFlagged", False), 

1592 "category": ans.get("category"), 

1593 } 

1594 ) 

1595 

1596 updated_answers.append(ans) 

1597 

1598 # Step 4: Fetch questions from the submission's questions field 

1599 questions_in_submission = submission.get("questions", []) 

1600 question_doc_map = { 

1601 str(question["_id"]): question for question in questions_in_submission 

1602 } 

1603 

1604 total_score = 0 

1605 correct_count = 0 

1606 # Points and questions held for a person to mark, kept out of both the score 

1607 # and the total it is measured against. 

1608 pending_points = 0 

1609 pending_count = 0 

1610 total_answers_submitted = sum( 

1611 1 

1612 for a in submit_request.answers 

1613 if self.is_meaningful_answer(a.studentAnswer) 

1614 ) 

1615 

1616 scored_details = [] 

1617 student_answers = [] 

1618 for ans in updated_answers: 

1619 q_id = str(ans["questionId"]) 

1620 student_answer = ans.get("answer", "") 

1621 is_flagged = ans.get("isFlagged", False) 

1622 q_doc = question_doc_map.get(q_id) 

1623 

1624 if not q_doc: 

1625 continue # skip if the question doc doesn't exist 

1626 

1627 correct_answer = resolve_correct_answer(q_doc) 

1628 max_points = q_doc.get("points", 0) 

1629 

1630 # Scoring. group_results is non-None only for Single-Stimulus 

1631 # (partial credit per group) — every other type keeps the existing 

1632 # binary max_points-or-0 behavior. 

1633 is_correct, earned_points, group_results = score_question( 

1634 student_answer, 

1635 correct_answer, 

1636 q_doc.get("questionType"), 

1637 max_points, 

1638 graph_fingerprint=(q_doc.get("correctAnswer") or {}).get( 

1639 "graphFingerprint" 

1640 ), 

1641 unordered=(q_doc.get("correctAnswer") or {}).get("unordered", False), 

1642 ) 

1643 

1644 # A written answer the auto-grader could not confirm is HELD for a person 

1645 # to mark, not scored zero. It contributes nothing to the score and nothing 

1646 # to the total it is measured against, so the grade shown is an honest 

1647 # grade of the work that has actually been marked. 

1648 pending_marking = needs_manual_marking( 

1649 q_doc.get("questionType"), is_correct 

1650 ) 

1651 if pending_marking: 

1652 earned_points = 0 

1653 pending_points += max_points 

1654 pending_count += 1 

1655 elif is_correct: 

1656 total_score += earned_points 

1657 correct_count += 1 

1658 elif earned_points: 

1659 # Partial credit (some groups correct, not all) still counts 

1660 # toward the score even though the question as a whole isn't 

1661 # "correct". 

1662 total_score += earned_points 

1663 

1664 scored_details.append( 

1665 { 

1666 "questionId": q_id, 

1667 "questionType": q_doc.get("questionType"), 

1668 "points": max_points, 

1669 "isFlagged": is_flagged, 

1670 "correctAnswer": correct_answer, 

1671 } 

1672 ) 

1673 

1674 sa_entry = { 

1675 "questionId": q_id, 

1676 "questionType": ans.get("questionType"), 

1677 "isFlagged": ans.get("isFlagged"), 

1678 "answer": student_answer, 

1679 # None, not False. False means "a person or the grader decided this is 

1680 # wrong"; None means "nobody has decided yet". The client already 

1681 # distinguishes the two — it tests for undefined/null separately from 

1682 # false before it shows a verdict — so a held answer renders as awaiting 

1683 # marking rather than as a mistake the student made. 

1684 "isCorrect": None if pending_marking else is_correct, 

1685 "earnedPoints": earned_points, 

1686 } 

1687 if pending_marking: 

1688 sa_entry["needsMarking"] = True 

1689 if group_results is not None: 

1690 sa_entry["groupResults"] = group_results 

1691 student_answers.append(sa_entry) 

1692 

1693 # Calculate max score. Questions still waiting to be marked are excluded from 

1694 # BOTH sides: scoring a student against points nobody has awarded yet would 

1695 # report a low grade that is really just an unfinished one, and it would settle 

1696 # to a different number later without anything saying why. 

1697 max_score = sum(q.get("points", 0) for q in questions_in_submission) 

1698 markable_score = max_score - pending_points 

1699 score_percentage = ( 

1700 (total_score / markable_score * 100) if markable_score > 0 else 0 

1701 ) 

1702 passing_grade = assignment.get("passing_grade", 75) # default is 75 

1703 remarks = "passed" if score_percentage >= passing_grade else "failed" 

1704 

1705 # Step 5: Update submission with remarks and submitted answers. 

1706 # Increment total_attempts to record this completed attempt. 

1707 set_payload: dict = { 

1708 "grade": score_percentage, 

1709 "total_score": total_score, 

1710 "total_correct_answers": correct_count, 

1711 "total_answers_submitted": total_answers_submitted, 

1712 "total_questions": len(updated_answers), 

1713 "submitted_answers": submitted_answers, 

1714 "last_submitted_answers": submitted_answers, 

1715 "last_student_answers": student_answers, 

1716 "is_submitted": True, 

1717 "date_submitted": datetime.now(timezone.utc), 

1718 "remarks": remarks, 

1719 # How much of this submission nobody has marked yet. Written to the 

1720 # submission rather than recomputed on read so the gradebook and the 

1721 # student's own view agree without each re-deriving it. 

1722 "pending_marking_count": pending_count, 

1723 "pending_marking_points": pending_points, 

1724 } 

1725 

1726 if is_late_flag: 

1727 set_payload["is_late"] = True 

1728 set_payload["review_status"] = "pending" 

1729 set_payload["pre_penalty_grade"] = score_percentage 

1730 set_payload["remarks"] = "pending_review" 

1731 

1732 update_result = await db["submission_collection"].update_one( 

1733 {"_id": submission["_id"]}, 

1734 { 

1735 "$inc": {"total_attempts": 1}, 

1736 "$set": set_payload, 

1737 }, 

1738 ) 

1739 

1740 if update_result.matched_count == 0: 

1741 raise HTTPException(status_code=404, detail="Submission record not found.") 

1742 

1743 if is_late_flag: 

1744 return { 

1745 "message": "Your late submission was received and is awaiting teacher approval.", 

1746 "reviewStatus": "pending", 

1747 "isLate": True, 

1748 } 

1749 

1750 return { 

1751 "message": "Submission evaluated and recorded successfully.", 

1752 "grade": score_percentage, 

1753 "totalScore": total_score, 

1754 "totalCorrectAnswers": correct_count, 

1755 "totalQuestions": len(updated_answers), 

1756 "totalAnswersSubmitted": total_answers_submitted, 

1757 "details": scored_details, 

1758 "studentAnswers": student_answers, 

1759 "remarks": remarks, 

1760 } 

1761 

1762 async def report_violation( 

1763 self, 

1764 assignment_uuid: str, 

1765 violation: ViolationReportRequest, 

1766 request: Request, 

1767 ) -> dict: 

1768 """ 

1769 Records one browser-lockdown violation against the student's current 

1770 (in-progress) attempt at this assignment, and reports whether the 

1771 max-violations threshold has been exceeded. 

1772 

1773 See docs/implementation/browser_lockdown_violation_tracking.md (client repo) 

1774 for the full feature spec. The backend is the source of truth for the 

1775 running count — the client reports every violation as it happens and reads 

1776 `threshold_exceeded`/`auto_submit` off this response, rather than counting 

1777 client-side, so the count survives a refresh/reconnect mid-attempt. 

1778 

1779 The count lives on the submission document — one per (student, assignment) 

1780 attempt (see fetch_assignment_questions / _handle_existing_submission) — 

1781 and is reset to 0 whenever a fresh attempt starts, so violations are scoped 

1782 per attempt, not per assignment lifetime. 

1783 

1784 Args: 

1785 assignment_uuid (str): The assignment's ObjectId as a string. 

1786 violation (ViolationReportRequest): The reported violation; `type` is 

1787 informational only (stored for debugging, not scored). 

1788 request (Request): The current HTTP request, used to extract student identity. 

1789 

1790 Returns: 

1791 dict: 

1792 - violation_count (int): The running total for this attempt, capped 

1793 at max_violations — see auto_submit below. 

1794 - max_violations (int): The configured threshold (3). 

1795 - threshold_exceeded (bool): True once violation_count reaches 

1796 max_violations. 

1797 - auto_submit (bool): True exactly once — on the violation that FIRST 

1798 reaches the threshold (the 3rd) — telling the client to submit now. 

1799 False on every violation before or after that one, so a client 

1800 racing a few more reports in before its own auto-submit completes 

1801 is never told to submit twice. 

1802 - violation_breakdown (dict): Running per-type counts for this 

1803 attempt, e.g. `{"tab_hidden": 2, "fullscreen_exit": 1}` — every 

1804 key is one of KNOWN_LOCKDOWN_VIOLATION_TYPES, or "unknown" for a 

1805 type this backend doesn't recognize. Sums to violation_count, so 

1806 the UI can show the student every one of up to 3 violations 

1807 that's actually being tracked, not just a bare total. 

1808 

1809 Raises: 

1810 HTTPException: 

1811 - 400: If the assignment UUID is not a valid ObjectId. 

1812 - 404: If no submission exists yet for this student/assignment (the 

1813 student must have opened the assignment via questions/fetch first). 

1814 """ 

1815 assignment_id = self._validate_object_id( 

1816 assignment_uuid, "Invalid assignment ID format" 

1817 ) 

1818 student_id = to_user_id(request.state.user_details["uuid"]) 

1819 

1820 submission = await db["submission_collection"].find_one( 

1821 {"assignment_id": assignment_id, "student_id": student_id}, 

1822 {"_id": 1, "is_submitted": 1, "violation_count": 1, "violation_counts": 1}, 

1823 ) 

1824 if not submission: 

1825 raise HTTPException(status_code=404, detail="Submission not found") 

1826 

1827 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students. 

1828 if not await self._has_live_enrolled_class( 

1829 assignment_id, student_id, request.state.user_details.get("email") 

1830 ): 

1831 raise HTTPException(status_code=404, detail="Submission not found") 

1832 

1833 # Once this attempt is submitted — whether the student did it, or a prior 

1834 # violation already reached the threshold and told the client to — there is 

1835 # nothing left to auto-submit. Report the stored state as-is rather than 

1836 # keep incrementing a closed attempt. 

1837 if submission.get("is_submitted", False): 

1838 count = submission.get("violation_count", 0) 

1839 return { 

1840 "violation_count": count, 

1841 "max_violations": MAX_LOCKDOWN_VIOLATIONS, 

1842 "threshold_exceeded": count >= MAX_LOCKDOWN_VIOLATIONS, 

1843 "auto_submit": False, 

1844 "violation_breakdown": submission.get("violation_counts") or {}, 

1845 } 

1846 

1847 violation_type = _normalize_violation_type(violation.type) 

1848 updated = await db["submission_collection"].find_one_and_update( 

1849 {"_id": submission["_id"]}, 

1850 { 

1851 "$inc": { 

1852 "violation_count": 1, 

1853 f"violation_counts.{violation_type}": 1, 

1854 }, 

1855 "$set": { 

1856 "last_violation_type": violation.type, 

1857 "last_violation_at": datetime.now(timezone.utc), 

1858 }, 

1859 }, 

1860 projection={"violation_count": 1, "violation_counts": 1}, 

1861 return_document=ReturnDocument.AFTER, 

1862 ) 

1863 count = updated.get("violation_count", 0) if updated else 0 

1864 breakdown = updated.get("violation_counts") or {} if updated else {} 

1865 

1866 return { 

1867 "violation_count": count, 

1868 "max_violations": MAX_LOCKDOWN_VIOLATIONS, 

1869 "threshold_exceeded": count >= MAX_LOCKDOWN_VIOLATIONS, 

1870 "auto_submit": count == MAX_LOCKDOWN_VIOLATIONS, 

1871 "violation_breakdown": breakdown, 

1872 } 

1873 

1874 def is_meaningful_answer(self, answer): 

1875 if isinstance(answer, str): 

1876 return answer.strip() != "" 

1877 if isinstance(answer, dict): 

1878 # Graph / Graph-Multiple-Select exports are objects, not strings. 

1879 if answer.get("format") == "Graph2D" or isinstance( 

1880 answer.get("data"), dict 

1881 ): 

1882 return True 

1883 if any( 

1884 key in answer and str(answer[key]).strip() != "" 

1885 for key in ["selected", "answer"] 

1886 ): 

1887 return True 

1888 return False 

1889 if isinstance(answer, list): 

1890 for item in answer: 

1891 if isinstance(item, str) and item.strip() != "": 

1892 return True 

1893 if isinstance(item, dict): 

1894 # Check for 'selected' or 'answer' fields 

1895 if any( 

1896 key in item and str(item[key]).strip() != "" 

1897 for key in ["selected", "answer"] 

1898 ): 

1899 return True 

1900 return False 

1901 

1902 def _normalize_free_response_answer(self, value) -> str: 

1903 """Canonicalizes a free-response answer for comparison. 

1904 

1905 Thin wrapper — see server.services.common.answer_checking.normalize_free_response_answer 

1906 for the canonical implementation, shared with teacher-side reporting. 

1907 """ 

1908 return normalize_free_response_answer(value) 

1909 

1910 def _is_correct( 

1911 self, 

1912 student_answer, 

1913 correct_answer, 

1914 question_id, 

1915 question_type=None, 

1916 graph_fingerprint=None, 

1917 ): 

1918 """Compare student_answer and correct_answer to determine correctness. 

1919 

1920 Thin wrapper — see server.services.common.answer_checking.is_answer_correct 

1921 for the canonical implementation, shared with teacher-side reporting 

1922 (Item Analysis, live submission view) so a submission is never graded 

1923 one way and reported another. Graph questions use fingerprint + 

1924 geometric checkGraphData via that same shared function. 

1925 """ 

1926 return is_answer_correct( 

1927 student_answer, 

1928 correct_answer, 

1929 question_type, 

1930 graph_fingerprint=graph_fingerprint, 

1931 ) 

1932 

1933 async def assignment_answers_save( 

1934 self, 

1935 assignment_uuid: str, 

1936 submit_request: AssignmentSubmitRequest, 

1937 request: Request, 

1938 ) -> dict: 

1939 """ 

1940 Updates the answers and submitted_answers fields for a student's submission. 

1941 

1942 This method finds the student's submission document by matching the provided `assignment_uuid` 

1943 and `student_id`, and then updates both the answers and submitted_answers fields based on the 

1944 data received in the `submit_request`. Each answer update includes the question ID, the student's 

1945 answer, and the optional flag status. 

1946 

1947 If the submitted_answers field doesn't exist, it will be created with the same format as answers. 

1948 

1949 Args: 

1950 assignment_uuid (str): The unique identifier for the assignment. 

1951 submit_request (AssignmentSubmitRequest): A Pydantic model containing the answers to be updated. 

1952 request (Request): The HTTP request object containing user details, specifically the student ID. 

1953 

1954 Returns: 

1955 dict: A dictionary with a message and the updated answers list. 

1956 

1957 Raises: 

1958 Exception: If the submission document is not found for the given assignment and student. 

1959 """ 

1960 assignment_uuid = self._validate_object_id( 

1961 assignment_uuid, "Invalid assignment ID format" 

1962 ) 

1963 student_id = to_user_id(request.state.user_details["uuid"]) 

1964 

1965 # Fetch current submission 

1966 submission = await db["submission_collection"].find_one( 

1967 { 

1968 "assignment_id": ObjectId(assignment_uuid), 

1969 "student_id": ObjectId(student_id), 

1970 } 

1971 ) 

1972 

1973 if not submission: 

1974 raise HTTPException(status_code=404, detail="Submission not found") 

1975 

1976 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students. 

1977 # Answers exactly as for a student with no submission here. 

1978 if not await self._has_live_enrolled_class( 

1979 ObjectId(assignment_uuid), 

1980 student_id, 

1981 request.state.user_details.get("email"), 

1982 ): 

1983 raise HTTPException(status_code=404, detail="Submission not found") 

1984 

1985 # Integrity guards (EI-3271): a draft save must not be able to tamper with 

1986 # answers after the current attempt is submitted, or after the assignment 

1987 # has closed — mirroring the gates in assignment_submit. 

1988 if submission.get("is_submitted", False): 

1989 raise HTTPException( 

1990 status_code=403, 

1991 detail="This attempt has already been submitted and can no longer be edited.", 

1992 ) 

1993 

1994 # SEC-5: a breached attempt is over. Without this, MAX_LOCKDOWN_VIOLATIONS 

1995 # was advisory — the client was merely *told* to auto-submit. 

1996 _assert_lockdown_not_breached(submission) 

1997 

1998 assignment_doc = await db["assignments_collection"].find_one( 

1999 {"_id": ObjectId(assignment_uuid)} 

2000 ) 

2001 # A soft-deleted assignment is no longer available to save against 

2002 # (consistent with the deleted-guard on the other assignment reads). 

2003 if assignment_doc and assignment_doc.get("deleted") is True: 

2004 raise HTTPException( 

2005 status_code=403, 

2006 detail="This assignment is no longer available.", 

2007 ) 

2008 _assert_assignment_open(assignment_doc) 

2009 date_close = assignment_doc.get("date_close") if assignment_doc else None 

2010 if date_close: 

2011 if date_close.tzinfo is None: 

2012 date_close = date_close.replace(tzinfo=timezone.utc) 

2013 if datetime.now(timezone.utc) > date_close: 

2014 raise HTTPException( 

2015 status_code=403, 

2016 detail="This assignment is already closed and no longer accepts submissions.", 

2017 ) 

2018 

2019 # Create answer map from request 

2020 answer_map = { 

2021 ans.questionId: {"answer": ans.studentAnswer, "isFlagged": ans.isFlagged} 

2022 for ans in submit_request.answers 

2023 } 

2024 

2025 # Update submitted_answers field 

2026 submitted_answers = [] 

2027 for ans in submission.get("submitted_answers", []): 

2028 q_id = str(ans["questionId"]) 

2029 if q_id in answer_map: 

2030 update = answer_map[q_id] 

2031 submitted_answers.append( 

2032 { 

2033 "questionId": ans["questionId"], 

2034 "questionType": ans.get("questionType"), 

2035 "answer": update["answer"], 

2036 "isFlagged": ( 

2037 update["isFlagged"] 

2038 if update["isFlagged"] is not None 

2039 else ans.get("isFlagged", False) 

2040 ), 

2041 "category": ans.get("category"), 

2042 } 

2043 ) 

2044 else: 

2045 # Preserve existing answer if not updated 

2046 existing_submitted = None 

2047 if "submitted_answers" in submission: 

2048 for sub_ans in submission["submitted_answers"]: 

2049 if str(sub_ans.get("questionId")) == q_id: 

2050 existing_submitted = sub_ans 

2051 break 

2052 

2053 if existing_submitted: 

2054 submitted_answers.append(existing_submitted) 

2055 else: 

2056 submitted_answers.append( 

2057 { 

2058 "questionId": ans["questionId"], 

2059 "questionType": ans.get("questionType"), 

2060 "answer": ans.get("answer", ""), 

2061 "isFlagged": ans.get("isFlagged", False), 

2062 "category": ans.get("category"), 

2063 } 

2064 ) 

2065 

2066 result = await db["submission_collection"].update_one( 

2067 { 

2068 "assignment_id": ObjectId(assignment_uuid), 

2069 "student_id": ObjectId(student_id), 

2070 }, 

2071 { 

2072 "$set": { 

2073 "submitted_answers": submitted_answers, 

2074 "date_updated": datetime.now(timezone.utc), 

2075 } 

2076 }, 

2077 ) 

2078 

2079 if result.matched_count == 0: 

2080 raise HTTPException(status_code=404, detail="Submission not found") 

2081 

2082 return { 

2083 "message": "Answers saved successfully", 

2084 "answers": serialized_response_object(submitted_answers), 

2085 } 

2086 

2087 async def fetch_submission_details( 

2088 self, 

2089 assignment_uuid: str, 

2090 request: Request, 

2091 ) -> dict: 

2092 """ 

2093 Fetch detailed submission data for a given assignment and student. 

2094 

2095 This method performs the following: 

2096 - Validates if the student is enrolled in a class assigned to the given assignment. 

2097 - Retrieves the submission document for the assignment and student. 

2098 - Calculates the remaining time allowed for the submission. 

2099 - Scores the student's answers against the correct answers. 

2100 - Returns detailed feedback including score, correctness, and flagged status. 

2101 - Determines isSubmitted status based on the existence of submitted_answers field. 

2102 

2103 Args: 

2104 assignment_uuid (str): The unique identifier of the assignment. 

2105 request (Request): The FastAPI request object containing the student's details. 

2106 

2107 Returns: 

2108 dict: A dictionary containing: 

2109 - message (str): A success message. 

2110 - _id (str): The submission document ID. 

2111 - isSubmitted (bool): True if submitted_answers field exists, False otherwise. 

2112 - remaining_time (str): Time left in HH:MM:SS format. 

2113 - totalScore (int): Total score the student earned. 

2114 - totalPoints (int): Sum of every question's max point value (denominator for totalScore). 

2115 - totalCorrectAnswers (int): Number of correct answers. 

2116 - totalQuestions (int): Number of questions answered. 

2117 - totalAnswersSubmitted (int): Number of meaningful answers submitted. 

2118 - totalAttemptsUsed (int): Number of attempts the student has used. 

2119 - totalAttemptsAllowed (int): Max attempts allowed for this assignment (denominator for totalAttemptsUsed). 

2120 - settings (dict): The assignment's settings sub-document as stored (time_allowed, 

2121 allowed_attempts, show_score_after_submit, show_correct_answers_after_submit, 

2122 shuffle_questions, shuffle_choices, allow_calculator, allow_feedback_after_submit, 

2123 allow_late_submissions, ...) — same shape as fetch_specific_assignment's 

2124 details.settings. Lets the FE key correctness-indicator UI off the actual 

2125 show_correct_answers_after_submit flag instead of inferring it from whether 

2126 correctAnswer/isCorrect happen to be present below. 

2127 - details (List[dict]): Per-question feedback including: 

2128 - questionId (str) 

2129 - questionType (str) 

2130 - points (int) 

2131 - isFlagged (bool) 

2132 - correctAnswer (Any) 

2133 - studentAnswer (dict): 

2134 - answer (Any) 

2135 - isCorrect (bool) 

2136 - earnedPoints (int) 

2137 

2138 Raises: 

2139 HTTPException: 

2140 - 403: If the student is unauthorized or the assignment is not found. 

2141 - 400: If `time_allowed` is improperly formatted, or `date_created` is missing 

2142 while `time_allowed` is configured. A missing/empty `time_allowed` is not an 

2143 error — it means the assignment has no time limit and `remaining_time` is None. 

2144 

2145 Example: 

2146 { 

2147 "message": "Successfully fetched submission summary.", 

2148 "_id": "64c5f1234567890abcdef123", 

2149 "isSubmitted": True, 

2150 "grade": "75", 

2151 "remarks": "passed", 

2152 "totalAttemptsUsed": "1", 

2153 "totalAttemptsAllowed": "3", 

2154 "remainingTime": "00:10:15", 

2155 "settings": { 

2156 "time_allowed": "01:00:00", 

2157 "allowed_attempts": 3, 

2158 "show_score_after_submit": True, 

2159 "show_correct_answers_after_submit": True 

2160 }, 

2161 "totalScore": "15", 

2162 "totalPoints": "20", 

2163 "totalCorrectAnswers": "3", 

2164 "totalQuestions": "5", 

2165 "totalAnswersSubmitted": "4", 

2166 "details": [ 

2167 { 

2168 "questionId": "64c5f...", 

2169 "questionType": "multiple-choice", 

2170 "points": 5, 

2171 "isFlagged": False, 

2172 "correctAnswer": ["B"], 

2173 "studentAnswer": { 

2174 "answer": ["B"], 

2175 "isCorrect": True, 

2176 "earnedPoints": 5 

2177 } 

2178 }, 

2179 ... 

2180 ] 

2181 } 

2182 """ 

2183 if not ObjectId.is_valid(assignment_uuid): 

2184 raise HTTPException(status_code=400, detail="Invalid assignment ID format") 

2185 

2186 student_id = to_user_id(request.state.user_details["uuid"]) 

2187 email = request.state.user_details.get("email") 

2188 assignment_oid = ObjectId(assignment_uuid) 

2189 student_oid = ObjectId(student_id) 

2190 

2191 # Step 1: Verify assignment exists and student is enrolled (authorization only). 

2192 # Keeping the submission lookup separate so a missing submission returns 200 

2193 # rather than collapsing both failures into an opaque 403. Matches by 

2194 # student _id OR email (a duplicate/stale account sharing this 

2195 # student's email can end up as the _id on a class's enrollment 

2196 # record instead of the live account's). 

2197 auth_pipeline = [ 

2198 {"$match": {"_id": assignment_oid}}, 

2199 { 

2200 "$lookup": { 

2201 "from": "class_collection", 

2202 "let": {"assigned_class_ids": "$assigned_class"}, 

2203 "pipeline": [ 

2204 { 

2205 "$match": { 

2206 "$expr": { 

2207 "$and": [ 

2208 {"$in": ["$_id", "$$assigned_class_ids"]}, 

2209 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students. 

2210 {"$ne": ["$deleted", True]}, 

2211 { 

2212 "$or": [ 

2213 {"$in": [student_oid, "$students._id"]}, 

2214 { 

2215 "$in": [ 

2216 email or "", 

2217 "$students.email", 

2218 ] 

2219 }, 

2220 ] 

2221 }, 

2222 ] 

2223 } 

2224 } 

2225 } 

2226 ], 

2227 "as": "authorized_classes", 

2228 } 

2229 }, 

2230 {"$match": {"authorized_classes.0": {"$exists": True}}}, 

2231 ] 

2232 

2233 auth_results = ( 

2234 await db["assignments_collection"].aggregate(auth_pipeline).to_list(1) 

2235 ) 

2236 if not auth_results: 

2237 raise HTTPException( 

2238 status_code=403, detail="Unauthorized or assignment not found." 

2239 ) 

2240 

2241 assignment = auth_results[0] 

2242 

2243 # Step 2: Fetch submission separately — absence is not an auth failure. 

2244 submission = await db["submission_collection"].find_one( 

2245 { 

2246 "assignment_id": assignment_oid, 

2247 "student_id": student_oid, 

2248 } 

2249 ) 

2250 

2251 if not submission: 

2252 return {"isSubmitted": False} 

2253 

2254 # A missing/empty time_allowed means the assignment has no time limit — 

2255 # report no remaining-time value instead of erroring. 

2256 time_allowed_str = assignment.get("settings", {}).get("time_allowed") 

2257 if time_allowed_str: 

2258 try: 

2259 hours, minutes, seconds = map(int, time_allowed_str.split(":")) 

2260 time_allowed_delta = timedelta( 

2261 hours=hours, minutes=minutes, seconds=seconds 

2262 ) 

2263 except ValueError: 

2264 raise HTTPException( 

2265 status_code=400, 

2266 detail="Invalid time_allowed format (expected HH:MM:SS).", 

2267 ) 

2268 

2269 # Ensure date_created is present and timezone-aware 

2270 date_created = submission.get("date_created") 

2271 if not date_created: 

2272 raise HTTPException( 

2273 status_code=400, detail="Submission missing date_created field." 

2274 ) 

2275 

2276 if date_created.tzinfo is None: 

2277 date_created = date_created.replace(tzinfo=timezone.utc) 

2278 

2279 # Calculate remaining time 

2280 now_utc = datetime.now(timezone.utc) 

2281 end_time = date_created + time_allowed_delta 

2282 remaining_seconds = max((end_time - now_utc).total_seconds(), 0) 

2283 

2284 formatted_remaining = ( 

2285 f"{int(remaining_seconds) // 3600:02}:" 

2286 f"{(int(remaining_seconds) % 3600) // 60:02}:" 

2287 f"{int(remaining_seconds) % 60:02}" 

2288 ) 

2289 else: 

2290 formatted_remaining = None 

2291 

2292 # Build question map for scored_details display (question text, choices, correct answers) 

2293 question_map = {str(q["_id"]): q for q in submission.get("questions", [])} 

2294 

2295 last_submitted = submission.get("last_submitted_answers") 

2296 last_student_ans = submission.get("last_student_answers") 

2297 

2298 scored_details = [] 

2299 student_answers = [] 

2300 total_score = 0 

2301 correct_count = 0 

2302 total_answers_submitted = 0 

2303 

2304 settings = assignment.get("settings", {}) 

2305 show_correct = settings.get("show_correct_answers_after_submit", False) 

2306 is_already_submitted = submission.get("is_submitted", False) 

2307 # EI-1210: suppress correct-answer/score reveal while the submission is pending 

2308 # teacher approval — the grade is hidden too; leaking totalScore/correctAnswer 

2309 # would let a student infer their result before the teacher decides. 

2310 _pending_review_status = submission.get("review_status", "none") == "pending" 

2311 # Reveal correct answers only when the submission is completed AND the setting allows it 

2312 # AND the submission is not awaiting late-submission approval. 

2313 reveal_answers = ( 

2314 is_already_submitted and show_correct and not _pending_review_status 

2315 ) 

2316 

2317 if last_submitted and last_student_ans is not None: 

2318 # A previous attempt was fully submitted. The per-answer scoring 

2319 # (last_student_answers, written by assignment_submit) never changes — 

2320 # re-derived from it rather than re-run — but WHICH questions still 

2321 # count toward the grade can change: if the teacher removes a question 

2322 # from the assignment after grading, it must stop being counted (and 

2323 # stop being shown) on every later view of this submission, so 

2324 # total_score/correct_count/total_answers_submitted/grade below are 

2325 # recalculated from the surviving answers only, instead of trusting 

2326 # submission["total_score"]/["grade"] verbatim. 

2327 live_question_ids = extract_live_question_ids( 

2328 assignment.get("questions", []) 

2329 ) 

2330 

2331 # Build scored_details for question display (text + choices come from question_map). 

2332 # Reveal correctAnswer/isCorrect/earnedPoints only when reveal_answers is True. 

2333 for ans in last_submitted: 

2334 q_id = str(ans.get("questionId")) 

2335 if q_id not in live_question_ids: 

2336 continue 

2337 q_doc = question_map.get(q_id) 

2338 if not q_doc: 

2339 continue 

2340 detail: dict = { 

2341 "_id": q_id, 

2342 "question": q_doc.get("question"), 

2343 "choices": q_doc.get("choices"), 

2344 "questionType": q_doc.get("questionType"), 

2345 "points": q_doc.get("points", 0), 

2346 "isFlagged": ans.get("isFlagged", False), 

2347 "groups": q_doc.get("groups"), 

2348 "rows": q_doc.get("rows"), 

2349 "rowHeaderLabel": q_doc.get("rowHeaderLabel"), 

2350 } 

2351 if reveal_answers: 

2352 correct_answer = q_doc.get("correctAnswer", {}).get("answers") 

2353 detail["correctAnswer"] = { 

2354 "content": correct_answer, 

2355 "answerDetails": q_doc.get("correctAnswer", {}).get( 

2356 "answerDetails" 

2357 ), 

2358 } 

2359 scored_details.append(detail) 

2360 

2361 # For student_answers, include isCorrect/earnedPoints only when reveal_answers is True. 

2362 # Also re-tallies total_score/correct_count/total_answers_submitted from 

2363 # the surviving (non-deleted) answers — same accumulation rule as the 

2364 # live-recompute branch below (is_correct → full credit, elif 

2365 # earned_points → partial credit). 

2366 for sa in last_student_ans: 

2367 q_id = str(sa.get("questionId")) 

2368 if q_id not in live_question_ids: 

2369 continue 

2370 sa_entry: dict = { 

2371 "questionId": sa.get("questionId"), 

2372 "questionType": sa.get("questionType"), 

2373 "isFlagged": sa.get("isFlagged"), 

2374 "answer": sa.get("answer"), 

2375 } 

2376 # Whether a person still has to read this answer is NOT part of 

2377 # revealing the answer key, so it is sent either way. A student is 

2378 # entitled to know their work is waiting to be marked — without it, a 

2379 # held question looks exactly like one they got wrong. 

2380 if sa.get("needsMarking"): 

2381 sa_entry["needsMarking"] = True 

2382 if reveal_answers: 

2383 sa_entry["isCorrect"] = sa.get("isCorrect") 

2384 sa_entry["earnedPoints"] = sa.get("earnedPoints") 

2385 if sa.get("markFeedback"): 

2386 sa_entry["markFeedback"] = sa.get("markFeedback") 

2387 if "groupResults" in sa: 

2388 sa_entry["groupResults"] = sa.get("groupResults") 

2389 student_answers.append(sa_entry) 

2390 

2391 if sa.get("isCorrect"): 

2392 total_score += sa.get("earnedPoints") or 0 

2393 correct_count += 1 

2394 elif sa.get("earnedPoints"): 

2395 total_score += sa.get("earnedPoints") or 0 

2396 

2397 if self.is_meaningful_answer(sa.get("answer")): 

2398 total_answers_submitted += 1 

2399 

2400 else: 

2401 # No completed submission yet — recalculate live from current submitted_answers. 

2402 # Do NOT reveal correctAnswer/isCorrect/earnedPoints for in-progress attempts. 

2403 for ans in submission.get("submitted_answers", []): 

2404 q_id = str(ans.get("questionId")) 

2405 q_doc = question_map.get(q_id) 

2406 if not q_doc: 

2407 continue 

2408 

2409 student_answer = ans.get("answer", "") 

2410 correct_answer = resolve_correct_answer(q_doc) 

2411 max_points = q_doc.get("points", 0) 

2412 is_flagged = ans.get("isFlagged", False) 

2413 

2414 is_correct, earned_points, group_results = score_question( 

2415 student_answer, 

2416 correct_answer, 

2417 q_doc.get("questionType"), 

2418 max_points, 

2419 graph_fingerprint=(q_doc.get("correctAnswer") or {}).get( 

2420 "graphFingerprint" 

2421 ), 

2422 unordered=(q_doc.get("correctAnswer") or {}).get( 

2423 "unordered", False 

2424 ), 

2425 ) 

2426 

2427 if is_correct: 

2428 total_score += earned_points 

2429 correct_count += 1 

2430 elif earned_points: 

2431 total_score += earned_points 

2432 

2433 if self.is_meaningful_answer(student_answer): 

2434 total_answers_submitted += 1 

2435 

2436 detail = { 

2437 "_id": q_id, 

2438 "question": q_doc.get("question"), 

2439 "choices": q_doc.get("choices"), 

2440 "questionType": q_doc.get("questionType"), 

2441 "points": max_points, 

2442 "isFlagged": is_flagged, 

2443 "groups": q_doc.get("groups"), 

2444 "rows": q_doc.get("rows"), 

2445 "rowHeaderLabel": q_doc.get("rowHeaderLabel"), 

2446 } 

2447 if reveal_answers: 

2448 detail["correctAnswer"] = { 

2449 "content": correct_answer, 

2450 "answerDetails": q_doc.get("correctAnswer", {}).get( 

2451 "answerDetails" 

2452 ), 

2453 } 

2454 scored_details.append(detail) 

2455 

2456 sa_entry = { 

2457 "questionId": q_id, 

2458 "questionType": ans.get("questionType"), 

2459 "isFlagged": ans.get("isFlagged"), 

2460 "answer": student_answer, 

2461 } 

2462 if reveal_answers: 

2463 sa_entry["isCorrect"] = is_correct 

2464 sa_entry["earnedPoints"] = earned_points 

2465 if group_results is not None: 

2466 sa_entry["groupResults"] = group_results 

2467 student_answers.append(sa_entry) 

2468 

2469 # Sum of each question's point value — the denominator for totalScore, 

2470 # so the UI can render "earned / possible" instead of just the raw score. 

2471 # Computed here (rather than only right before the response) because a 

2472 # fully-submitted attempt's grade below is recalculated against it too. 

2473 total_points = sum(q.get("points", 0) or 0 for q in scored_details) 

2474 

2475 # For a fully-submitted attempt, recalculate the grade against the 

2476 # assignment's CURRENT question list rather than trusting the stored 

2477 # value, which goes stale the moment a question is removed after grading. 

2478 if last_submitted and last_student_ans is not None: 

2479 grade = (total_score / total_points * 100) if total_points > 0 else 0 

2480 else: 

2481 grade = submission.get("grade", 0) 

2482 total_attempts = submission.get("total_attempts", 0) 

2483 

2484 # READ-ONLY: this GET must NOT mutate submission state. It previously 

2485 # flipped is_submitted=True once the timer expired (remaining_seconds==0), 

2486 # which marked an unsubmitted DRAFT as "submitted" — conflating draft vs 

2487 # final submission and presenting a false "already submitted / attempt 

2488 # used" state after a student saved a draft and clicked Back 

2489 # (EI-3271 / EI-TC-3614). Time-expiry submission is handled by the take 

2490 # path (_handle_existing_submission) + the FE timer via the real submit 

2491 # endpoint; a status read must not consume/close an attempt. 

2492 

2493 if isinstance(grade, float) and grade.is_integer(): 

2494 grade_str = str(int(grade)) # Convert to int then str → "100" 

2495 else: 

2496 grade_str = str(grade) # Leave as is → e.g. "89.5" or "92" 

2497 

2498 is_late = submission.get("is_late", False) 

2499 review_status = submission.get("review_status", "none") 

2500 is_pending_review = review_status == "pending" 

2501 

2502 # The stored `remarks` field is a "passed"/"failed" verdict computed once, 

2503 # against the assignment's passing_grade at submit time (or at late-submission 

2504 # approval time), and persisted on the submission document. If a teacher edits 

2505 # the assignment's passing_grade afterward, that stored verdict goes stale — 

2506 # recompute it live against the current passing_grade instead of trusting the 

2507 # stored value. Other remarks values ("rejected", a custom rejection reason) 

2508 # are left untouched since they aren't pass/fail verdicts. 

2509 stored_remarks = submission.get("remarks") 

2510 if stored_remarks in ("passed", "failed") and isinstance(grade, (int, float)): 

2511 current_passing_grade = assignment.get("passing_grade", 75) 

2512 remarks = "passed" if grade >= current_passing_grade else "failed" 

2513 else: 

2514 remarks = stored_remarks 

2515 

2516 response = { 

2517 "message": "Successfully fetched submission summary.", 

2518 "_id": str(submission["_id"]), 

2519 "isSubmitted": bool(submission.get("last_submitted_answers")) 

2520 or submission.get("is_submitted", False), 

2521 "grade": None if is_pending_review else grade_str, 

2522 "remarks": "Pending teacher approval" if is_pending_review else remarks, 

2523 "totalAttemptsUsed": str(total_attempts), 

2524 "totalAttemptsAllowed": str(settings.get("allowed_attempts", 0)), 

2525 "remainingTime": formatted_remaining, 

2526 "isLate": is_late, 

2527 "reviewStatus": review_status, 

2528 # Assignment settings (same shape as fetch_specific_assignment's 

2529 # details.settings) so the FE can key UI decisions — e.g. whether to 

2530 # render a Correct/Incorrect indicator — off the actual setting 

2531 # instead of inferring it from field presence in `details`/`studentAnswers`. 

2532 "settings": settings, 

2533 # How many answers on this submission a person has still to mark. Sent so 

2534 # the student can be told their grade is not final yet, rather than being 

2535 # shown a number that will move later with no explanation. 

2536 "pendingMarkingCount": submission.get("pending_marking_count", 0) or 0, 

2537 } 

2538 

2539 # EI-1210: suppress totalScore and totalCorrectAnswers while pending review 

2540 # so the student cannot infer their result before teacher approval. 

2541 if not is_pending_review and assignment.get("settings", {}).get( 

2542 "show_score_after_submit", False 

2543 ): 

2544 response["totalScore"] = str(total_score) 

2545 

2546 if not is_pending_review and assignment.get("settings", {}).get( 

2547 "show_correct_answers_after_submit", False 

2548 ): 

2549 response["totalCorrectAnswers"] = str(correct_count) 

2550 

2551 response.update( 

2552 { 

2553 # len(scored_details) rather than the stored total_questions — the 

2554 # latter is frozen from submit time and goes stale the moment a 

2555 # question is removed from the assignment afterward. 

2556 "totalQuestions": str(len(scored_details)), 

2557 "totalAnswersSubmitted": str(total_answers_submitted), 

2558 "totalPoints": str(total_points), 

2559 "details": scored_details, 

2560 "studentAnswers": student_answers, 

2561 "teacherComments": { 

2562 qid: entry.get("comment", "") 

2563 for qid, entry in (submission.get("teacher_comments") or {}).items() 

2564 }, 

2565 } 

2566 ) 

2567 

2568 return response