Coverage for server / services / teacher / teacher_assignment.py: 94%

910 statements  

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

1"""Teacher assignment service. 

2 

3Modified by Allan Ninal — 2026-08-12 

4 

5`analytics_student_submission_fetch` now resolves the grade through the canonical 

6gradebook resolver (server/utilities/gradebook.py) instead of formatting the stored 

7value directly, which returned the literal string "None" for an ungraded submission 

8and leaked a rejected late submission's provisional grade. 

9""" 

10 

11import math 

12from pymongo import ReturnDocument 

13from bson.objectid import ObjectId 

14from fastapi import HTTPException, Request, status 

15from server.connection.database import db, staff_admin_db 

16from server.models.assignment import ( 

17 MAX_ASSIGNMENT_QUESTIONS, 

18 Assignment, 

19 QuestionModel, 

20 Submission, 

21 UpdateAssignment, 

22) 

23 

24# from server.models.question import Question 

25from server.models.sharerequests import ShareRequest 

26from server.utilities.assignment_update import ( 

27 REUSED_GLOBAL_LOCKED_DETAIL, 

28 changes_title_or_questions, 

29 dates_inverted, 

30 is_reused_global_copy, 

31 set_settings_per_key, 

32) 

33from server.utilities import model_parser 

34from datetime import datetime, timedelta, timezone 

35from server.utilities.helpers import serialized_response_object 

36from server.utilities.gradebook import resolve_cell_grade, recalculate_submitted_grade 

37import re 

38from bson.errors import InvalidId 

39from server.utilities.user_id_helper import to_user_id 

40from server.utilities.html_sanitizer import strip_html 

41from server.services.common.question_bank import pick_adaptive_question 

42from server.services.student.student_assignment import StudentAssignmentsService 

43from server.services.common.answer_checking import ( 

44 is_answer_correct, 

45 MANUALLY_MARKED_TYPES, 

46 MFE_FORMULA_RE, 

47 resolve_correct_answer, 

48 score_question, 

49) 

50from server.services.growthbook.context import resolve_targeting_context 

51from server.services.growthbook.global_assignment_gate import ( 

52 enforce_global_assignment_access, 

53 global_assignment_access_meta, 

54) 

55from server.services.growthbook.teacher_assignment_quota import ( 

56 enforce_teacher_assignment_quota, 

57 _teacher_id_from_request, 

58 _created_by_values, 

59) 

60from server.utilities.error_detail import safe_detail 

61from pymongo.errors import DuplicateKeyError 

62from server.utilities.assignment_dedupe import ( 

63 compute_dedupe_key, 

64 resolve_duplicate_create, 

65) 

66 

67 

68class TeacherAssignmentsService: 

69 """ 

70 Service class for managing assignment-related operations. 

71 

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

73 as well as submission and sharing functionality. 

74 """ 

75 

76 def __init__(self): 

77 pass 

78 

79 async def create(self, new_assigment: Assignment, request: Request): 

80 """ 

81 Create a new assignment for a teacher. 

82 

83 Args: 

84 new_assigment (Assignment): Assignment details to be created 

85 request (Request): The incoming request object containing teacher context 

86 

87 Returns: 

88 dict: Created assignment details and success message 

89 

90 Raises: 

91 HTTPException: 

92 - 422 if assigned_class is empty or null 

93 - 500 if creation fails 

94 """ 

95 try: 

96 # Validate assigned_class is not empty or null 

97 if ( 

98 not new_assigment.assigned_class 

99 or len(new_assigment.assigned_class) == 0 

100 ): 

101 raise HTTPException( 

102 status_code=400, detail="Assigned Class is required." 

103 ) 

104 

105 await enforce_teacher_assignment_quota( 

106 request, new_assigment.assigned_class 

107 ) 

108 

109 teacher_id = _teacher_id_from_request(request) 

110 new_assigment.created_by = teacher_id 

111 new_assigment.created_at = datetime.now(timezone.utc) 

112 # new_assigment.copy_of = ObjectId(new_assigment.copy_of) 

113 # EI-3450 / EI-3451 — the same guard as the common create path, because both 

114 # routes insert into assignments_collection and a teacher can reach either. 

115 new_assigment.dedupe_key = compute_dedupe_key( 

116 created_by=teacher_id, 

117 assigned_class=new_assigment.assigned_class, 

118 title=new_assigment.title, 

119 date_open=new_assigment.date_open, 

120 date_close=new_assigment.date_close, 

121 ) 

122 try: 

123 await new_assigment.insert() 

124 except DuplicateKeyError: 

125 existing = await resolve_duplicate_create(new_assigment.dedupe_key) 

126 return { 

127 "detail": "Successfully Created Assignment", 

128 "new_assignment": serialized_response_object(existing.model_dump()), 

129 } 

130 return { 

131 "detail": "Successfully Created Assignment", 

132 "new_assignment": serialized_response_object( 

133 new_assigment.model_dump() 

134 ), 

135 } 

136 except HTTPException: 

137 raise 

138 except Exception as e: 

139 raise HTTPException(status_code=500, detail=safe_detail(e)) 

140 

141 async def create_staff_assignment( 

142 self, new_assigment: Assignment, request: Request 

143 ): 

144 """ 

145 Create a teacher class assignment from a staff/global template (reuse flow). 

146 

147 Unlike staff-portal global bank create, this persists to 

148 ``assignments_collection`` with ``from: "staff"`` and an optional 

149 ``copy_of`` reference to the source global assignment. Reused assignments 

150 may share the global template's title — uniqueness is enforced only on 

151 the global bank, not on per-class teacher copies. 

152 """ 

153 try: 

154 if ( 

155 not new_assigment.assigned_class 

156 or len(new_assigment.assigned_class) == 0 

157 ): 

158 raise HTTPException( 

159 status_code=status.HTTP_400_BAD_REQUEST, 

160 detail="Assigned Class is required.", 

161 ) 

162 

163 await enforce_teacher_assignment_quota( 

164 request, new_assigment.assigned_class 

165 ) 

166 

167 teacher_id = _teacher_id_from_request(request) 

168 new_assigment.created_by = teacher_id 

169 new_assigment.created_at = datetime.now(timezone.utc) 

170 # EI-3450 / EI-3451 — the reuse flow inserts into the same collection and is 

171 # just as retryable as the other two create paths, so it gets the same guard. 

172 # The key is scoped to (teacher, class, title, dates), which leaves the 

173 # documented behaviour above intact: reusing one global template across 

174 # DIFFERENT classes keeps producing separate assignments, because the class 

175 # set is part of the key. Only re-sending the same reuse into the same class 

176 # on the same dates collides — and that is the duplicate we want to stop. 

177 new_assigment.dedupe_key = compute_dedupe_key( 

178 created_by=teacher_id, 

179 assigned_class=new_assigment.assigned_class, 

180 title=new_assigment.title, 

181 date_open=new_assigment.date_open, 

182 date_close=new_assigment.date_close, 

183 ) 

184 try: 

185 await new_assigment.insert() 

186 except DuplicateKeyError: 

187 existing = await resolve_duplicate_create(new_assigment.dedupe_key) 

188 return { 

189 "detail": "Successfully Created Assignment", 

190 "new_assignment": serialized_response_object( 

191 {**existing.model_dump(), "from": "staff"} 

192 ), 

193 } 

194 

195 await db["assignments_collection"].update_one( 

196 {"_id": new_assigment.id}, 

197 {"$set": {"from": "staff"}}, 

198 ) 

199 

200 return { 

201 "detail": "Successfully Created Assignment", 

202 "new_assignment": serialized_response_object( 

203 {**new_assigment.model_dump(), "from": "staff"} 

204 ), 

205 } 

206 except HTTPException: 

207 raise 

208 except Exception as e: 

209 raise HTTPException(status_code=500, detail=safe_detail(e)) 

210 

211 async def fetch_all(self, class_code: str, request: Request): 

212 """ 

213 Fetch all assignments assigned to a class with the given class_code. 

214 

215 Args: 

216 class_code (str): The class code to filter assignments. 

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

218 

219 Returns: 

220 dict: A response with assignment details. 

221 

222 Raises: 

223 HTTPException (401): If the user is not authenticated. 

224 HTTPException (404): If the class with the given class_code does not exist, 

225 or belongs to another teacher. 

226 HTTPException (500): If an internal server error occurs. 

227 """ 

228 try: 

229 teacher_id = str(request.state.user_details["uuid"]) 

230 

231 # Added by Allan Ninal — 2026-10-03 (EI-T250). 

232 # The docstring promised 404 for an unknown class, but an unknown class 

233 # code (or another teacher's) fell through the pipeline to 200 "No 

234 # assignments found", which reads as a real class with no assignments. 

235 # Same ownership lookup analytics_summary_fetch uses. 

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

237 {"class_code": class_code, "teacher._id": ObjectId(teacher_id)}, 

238 {"_id": 1}, 

239 ) 

240 if not class_doc: 

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

242 

243 pipeline = [ 

244 # Match the class with the given class_code 

245 {"$match": {"class_code": class_code}}, 

246 # Lookup assignments where assigned_class matches the class _id 

247 { 

248 "$lookup": { 

249 "from": "assignments_collection", 

250 "localField": "_id", 

251 "foreignField": "assigned_class", 

252 "as": "assignments", 

253 } 

254 }, 

255 # Unwind assignments to filter only those created by the teacher 

256 {"$unwind": "$assignments"}, 

257 {"$match": {"assignments.created_by": teacher_id}}, 

258 # Group back assignments into a list 

259 {"$group": {"_id": "$_id", "assignments": {"$push": "$assignments"}}}, 

260 # Project the required fields 

261 {"$project": {"_id": 0, "assignments": 1}}, 

262 ] 

263 

264 results = await db["class_collection"].aggregate(pipeline).to_list(None) 

265 

266 if not results: 

267 return { 

268 "detail": "No assignments found for the given class code", 

269 "assignments": [], 

270 } 

271 

272 return { 

273 "detail": "Successfully fetched assignments", 

274 "assignments": ( 

275 serialized_response_object(results[0]["assignments"]) 

276 if results 

277 else [] 

278 ), 

279 } 

280 except HTTPException: 

281 raise 

282 except Exception as e: 

283 raise HTTPException(status_code=500, detail=safe_detail(e)) 

284 

285 async def staff_assignments_fetch(self, request: Request): 

286 """ 

287 Fetch all global assignments that are not marked as deleted. 

288 Tiered practice globals include feature_key gating metadata per row. 

289 """ 

290 try: 

291 if staff_admin_db is None: 

292 raise HTTPException( 

293 status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 

294 detail="Staff admin database is not configured.", 

295 ) 

296 

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

298 

299 pipeline = [ 

300 {"$match": {"deleted": False}}, 

301 { 

302 "$project": { 

303 "_id": 1, 

304 "title": 1, 

305 "type": 1, 

306 "format": 1, 

307 "feature_key": 1, 

308 "questions": 1, 

309 "created_at": 1, 

310 "instructions": 1, 

311 "rubric": 1, 

312 } 

313 }, 

314 ] 

315 

316 cursor = staff_admin_db["global_assignments"].aggregate(pipeline) 

317 assignments = await cursor.to_list(length=None) 

318 

319 enriched = [] 

320 for assignment in assignments: 

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

322 access = await global_assignment_access_meta(assignment, ctx) 

323 row = serialized_response_object(assignment) 

324 row.update(access) 

325 enriched.append(row) 

326 

327 return { 

328 "detail": "Successfully fetched assignments", 

329 "assignments": enriched, 

330 } 

331 

332 except HTTPException: 

333 raise 

334 except Exception as e: 

335 raise HTTPException(status_code=500, detail=safe_detail(e)) 

336 

337 _NOT_DELETED_FILTER = { 

338 "$or": [{"deleted": False}, {"deleted": {"$exists": False}}], 

339 } 

340 

341 @staticmethod 

342 def _assignment_question_ids(assignment_doc) -> list: 

343 """Extract ordered ObjectId question IDs stored on an assignment. 

344 

345 Modified by Allan Ninal — 2026-09-23 (EI-T115) 

346 WHAT: take an Assignment DOCUMENT as well as a raw dict, and read a 

347 QuestionModel's `.id` the way the dict branch reads "id". 

348 WHY: next_fetch holds a Document whose `questions` are QuestionModel 

349 objects. The old body sent those down the `else` branch, where 

350 `ObjectId.is_valid(str(QuestionModel(...)))` is False, so every 

351 question was silently DROPPED — the picker would have been handed 

352 an empty exclusion list and could re-serve a question the student 

353 had already answered. Existing dict callers are unaffected. 

354 """ 

355 questions = ( 

356 assignment_doc.get("questions", []) 

357 if isinstance(assignment_doc, dict) 

358 else (getattr(assignment_doc, "questions", None) or []) 

359 ) 

360 question_ids = [] 

361 for entry in questions: 

362 raw_id = ( 

363 entry.get("id") 

364 if isinstance(entry, dict) 

365 else getattr(entry, "id", entry) 

366 ) 

367 if ObjectId.is_valid(str(raw_id)): 

368 question_ids.append(ObjectId(str(raw_id))) 

369 return question_ids 

370 

371 async def _fetch_questions_for_assignment( 

372 self, question_ids: list, points_overrides: dict | None = None 

373 ) -> list: 

374 """ 

375 Resolve assignment questions from teacher_questionbank first, then 

376 global_questionbank for staff-authored items. 

377 

378 `points_overrides` (question id string -> points), when given, wins over 

379 the question document's own `points` — this is how a teacher's per- 

380 assignment point edit (assignments_collection.question_points_overrides) 

381 is applied without mutating the shared question document. 

382 """ 

383 if not question_ids: 

384 return [] 

385 

386 teacher_docs = ( 

387 await db["teacher_questionbank"] 

388 .find({"_id": {"$in": question_ids}, **self._NOT_DELETED_FILTER}) 

389 .to_list(length=None) 

390 ) 

391 question_map = {str(doc["_id"]): doc for doc in teacher_docs} 

392 

393 missing_ids = [qid for qid in question_ids if str(qid) not in question_map] 

394 if missing_ids and staff_admin_db is not None: 

395 global_docs = ( 

396 await staff_admin_db["global_questionbank"] 

397 .find({"_id": {"$in": missing_ids}, **self._NOT_DELETED_FILTER}) 

398 .to_list(length=None) 

399 ) 

400 for doc in global_docs: 

401 question_map[str(doc["_id"])] = doc 

402 

403 ordered_questions = [] 

404 for qid in question_ids: 

405 doc = question_map.get(str(qid)) 

406 if doc: 

407 question = {**doc, "_id": str(doc["_id"])} 

408 override = (points_overrides or {}).get(str(qid)) 

409 if override is not None: 

410 question["points"] = override 

411 ordered_questions.append(question) 

412 return ordered_questions 

413 

414 async def staff_specific_assignment_fetch( 

415 self, assignment_uuid: str, request: Request 

416 ): 

417 """ 

418 Fetch a specific global assignment by its UUID if it is not marked as deleted. 

419 Args: 

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

421 request (Request): The incoming FastAPI request object. 

422 Returns: 

423 dict: A response containing a detail message and the assignment object. 

424 Raises: 

425 HTTPException: 

426 - 404 if no assignment is found with the given UUID. 

427 - 500 if an unexpected error occurs. 

428 """ 

429 if not ObjectId.is_valid(assignment_uuid): 

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

431 

432 assignment_id = ObjectId(assignment_uuid) 

433 

434 try: 

435 if staff_admin_db is None: 

436 raise HTTPException( 

437 status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 

438 detail="Staff admin database is not configured.", 

439 ) 

440 

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

442 

443 fetched_assignment = await staff_admin_db["global_assignments"].find_one( 

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

445 ) 

446 

447 if not fetched_assignment: 

448 raise HTTPException( 

449 status_code=404, 

450 detail="Assignment not found or already marked as deleted", 

451 ) 

452 

453 await enforce_global_assignment_access(fetched_assignment, ctx) 

454 

455 question_ids = self._assignment_question_ids(fetched_assignment) 

456 questions = await self._fetch_questions_for_assignment(question_ids) 

457 

458 assignment_data = dict(fetched_assignment) 

459 assignment_data["_id"] = str(assignment_data["_id"]) 

460 

461 return { 

462 "assignment": { 

463 "details": serialized_response_object(assignment_data), 

464 "questions": [serialized_response_object(q) for q in questions], 

465 } 

466 } 

467 

468 except HTTPException: 

469 raise 

470 except Exception as e: 

471 raise HTTPException( 

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

473 ) 

474 

475 async def fetch_specific_assignment( 

476 self, assignment_uuid: str, request: Request 

477 ) -> dict: 

478 """ 

479 Fetch a specific assignment created by the authenticated teacher. 

480 

481 This method retrieves an assignment by its unique identifier and ensures that 

482 the requesting teacher has access to it. It also populates the related questions 

483 from the `global_questionbank` collection and formats ObjectId fields as strings. 

484 

485 Args: 

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

487 request (Request): The HTTP request object, which includes user details. 

488 

489 Returns: 

490 dict: A dictionary containing separate assignment details and associated questions. 

491 

492 Raises: 

493 HTTPException 400: If the provided assignment UUID is invalid. 

494 HTTPException 404: If the assignment is not found or the teacher does not have access. 

495 HTTPException 500: If an unexpected error occurs during processing. 

496 """ 

497 # Validate ObjectId 

498 if not ObjectId.is_valid(assignment_uuid): 

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

500 

501 assignment_id = ObjectId(assignment_uuid) 

502 

503 try: 

504 # Ensure user details exist in request state 

505 teacher_id = str(request.state.user_details.get("uuid")) 

506 

507 # Ensure assignment is not marked as deleted 

508 fetched_assignment = await db["assignments_collection"].find_one( 

509 { 

510 "_id": ObjectId(assignment_id), 

511 "created_by": teacher_id, 

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

513 } 

514 ) 

515 if not fetched_assignment: 

516 raise HTTPException( 

517 status_code=404, 

518 detail="Assignment not found or already marked as deleted", 

519 ) 

520 

521 question_ids = self._assignment_question_ids(fetched_assignment) 

522 points_overrides = fetched_assignment.get("question_points_overrides") or {} 

523 questions = await self._fetch_questions_for_assignment( 

524 question_ids, points_overrides 

525 ) 

526 

527 assignment_data = { 

528 **fetched_assignment, 

529 "_id": str(fetched_assignment["_id"]), 

530 } 

531 assignment_data.pop("questions", None) 

532 

533 return { 

534 "assignment": { 

535 "details": serialized_response_object(assignment_data), 

536 "questions": [serialized_response_object(q) for q in questions], 

537 } 

538 } 

539 

540 except HTTPException: 

541 raise 

542 except Exception as e: 

543 raise HTTPException( 

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

545 ) 

546 

547 async def fetch(self, assignment_uuid: str, request: Request) -> dict: 

548 """ 

549 Retrieve a specific assignment with its questions. 

550 

551 The assignment must belong to the requesting teacher. 

552 Questions associated with the assignment are fetched in a single query. 

553 

554 Parameters 

555 ---------- 

556 assignment_uuid : str 

557 The unique identifier of the assignment to retrieve 

558 request : Request 

559 The FastAPI request object containing authenticated teacher details 

560 

561 Returns 

562 ------- 

563 dict 

564 A dictionary containing: 

565 - Assignment: dict 

566 - All assignment fields except question_ids 

567 - questions: list[dict] 

568 The full details of each question in the assignment 

569 

570 Raises 

571 ------ 

572 HTTPException 

573 400 - When the assignment_uuid is not a valid MongoDB ObjectId 

574 404 - When the assignment is not found or teacher lacks access 

575 500 - When an unexpected server error occurs 

576 

577 Examples 

578 -------- 

579 >>> response = await teacher_service.fetch("507f1f77bcf86cd799439011", request) 

580 >>> print(response) 

581 { 

582 "Assignment": { 

583 "id": "507f1f77bcf86cd799439011", 

584 "title": "Math Quiz", 

585 "questions": [ 

586 {"id": "507f1f77bcf86cd799439012", "text": "What is 2+2?", ...}, 

587 ... 

588 ], 

589 ... 

590 } 

591 } 

592 """ 

593 # Validate ObjectId first 

594 try: 

595 assignment_id = ObjectId(assignment_uuid) 

596 except Exception: 

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

598 

599 try: 

600 # Fetch assignment and verify teacher access 

601 fetched_assignment = await Assignment.find_one( 

602 { 

603 "_id": assignment_id, 

604 "teacher_id": str(request.state.user_details["uuid"]), 

605 } 

606 ) 

607 

608 if not fetched_assignment: 

609 raise HTTPException( 

610 status_code=404, 

611 detail="Assignment not found or you don't have access to this assignment", 

612 ) 

613 

614 # Fetch questions in a single query 

615 raw_questions = ( 

616 await db["question_collection"] 

617 .find({"_id": {"$in": fetched_assignment.question_ids}}) 

618 .to_list(None) 

619 ) 

620 

621 # Parse questions to remove dates and format IDs 

622 questions = model_parser.parse_response(raw_questions, exclude_dates=True) 

623 

624 return { 

625 "Assignment": { 

626 **fetched_assignment.model_dump(exclude={"question_ids"}), 

627 "questions": questions, 

628 } 

629 } 

630 

631 except HTTPException: 

632 raise 

633 except Exception as e: 

634 raise HTTPException( 

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

636 ) 

637 

638 async def next_fetch( 

639 self, 

640 request: Request, 

641 assignment_uuid: str, 

642 prev_difficulty: str, 

643 prev_remarks: str, 

644 question_classification: str, 

645 ): 

646 """ 

647 Get next question for adaptive testing based on previous performance. 

648 

649 Args: 

650 request (Request): The incoming request object 

651 assignment_uuid (str): Unique identifier of the assignment 

652 prev_difficulty (str): Difficulty of previous question 

653 prev_remarks (str): Performance remarks on previous question 

654 question_classification (str): Classification of questions to select from 

655 

656 Returns: 

657 dict: Next question details 

658 

659 Raises: 

660 HTTPException: If assignment not found or no suitable question available 

661 """ 

662 # Modified by Allan Ninal — 2026-09-23 (EI-T401) 

663 # WHAT: reject a malformed id before ObjectId() sees it. 

664 # WHY: ObjectId("invalid-uuid-@@@") raises bson.errors.InvalidId. There is no 

665 # try/except here at all, so it escaped as an unhandled 500 instead of a 

666 # controlled 400 — same defect the common twin had. 

667 if not ObjectId.is_valid(assignment_uuid): 

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

669 

670 # Retrieve the assignment 

671 fetched_assignment = await Assignment.find_one( 

672 {"_id": ObjectId(assignment_uuid)} 

673 ) 

674 if not fetched_assignment: 

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

676 

677 # Modified by Allan Ninal — 2026-09-23 (EI-T115) 

678 # WHAT: refuse to serve a question into an assignment already at the cap. 

679 # WHY: Beanie does not re-validate on save, so a 101st question would be 

680 # written and then break `validate_questions` on every LOAD — bricking 

681 # the assignment for every reader, not just this call. 

682 if len(fetched_assignment.questions or []) >= MAX_ASSIGNMENT_QUESTIONS: 

683 raise HTTPException( 

684 status_code=400, 

685 detail=( 

686 "maximum number of questions allowed is " 

687 f"{MAX_ASSIGNMENT_QUESTIONS}" 

688 ), 

689 ) 

690 

691 # Determine new difficulty based on previous difficulty and remarks. 

692 # The rungs must be real stored values: fetch_random_question does 

693 # difficulty.title(), and the bank holds Easy / Average / Advance. 

694 # This ladder previously topped out at "hard" -> "Hard", which matches 

695 # ZERO questions, so a student who answered an Average question correctly 

696 # got HTTP 400 mid-assignment instead of a harder question. 

697 if prev_difficulty == "easy" and prev_remarks == "correct": 

698 new_difficulty = "average" 

699 elif prev_difficulty == "easy" and prev_remarks == "incorrect": 

700 new_difficulty = "easy" 

701 elif prev_difficulty == "average" and prev_remarks == "incorrect": 

702 new_difficulty = "easy" 

703 elif prev_difficulty == "average" and prev_remarks == "correct": 

704 new_difficulty = "advance" 

705 elif prev_difficulty == "advance" and prev_remarks == "incorrect": 

706 new_difficulty = "average" 

707 elif prev_difficulty == "advance" and prev_remarks == "correct": 

708 new_difficulty = "advance" 

709 else: 

710 raise HTTPException(status_code=400, detail="Something went wrong") 

711 

712 # Modified by Allan Ninal — 2026-09-23 (EI-T115 / EI-T400 / EI-T401) 

713 # WHAT: read and append `questions` instead of the non-existent `question_ids`. 

714 # WHY: the field was renamed on 2025-03-25 (dc62c45) and this path was missed, 

715 # so every call raised AttributeError -> 500, happy path included. The 

716 # appended item keeps the {id, category, topic} shape because item 

717 # analysis below does q["id"] and would break on a bare id string. 

718 # Modified by Allan Ninal — 2026-09-23 (EI-T115) — see the twin in 

719 # server/services/common/assignments.py for the full reasoning: the old 

720 # picker read a collection that exists in no database, so this endpoint 

721 # could never serve a question. Scoped to the assignment creator's own 

722 # bank plus the curated global bank, soft-deleted rows excluded. 

723 new_question = await pick_adaptive_question( 

724 difficulty=new_difficulty, 

725 classification=question_classification, 

726 exclude_ids=self._assignment_question_ids(fetched_assignment), 

727 creator_id=fetched_assignment.created_by, 

728 ) 

729 

730 if new_question: 

731 # Add the new question to the assignment 

732 fetched_assignment.questions.append(QuestionModel(id=new_question["_id"])) 

733 

734 # Update the assignment in the database 

735 await fetched_assignment.save() 

736 question_id = new_question["_id"] 

737 del new_question["_id"] 

738 new_question["id"] = str(question_id) 

739 return new_question # Return the new question 

740 

741 raise HTTPException( 

742 status_code=400, detail="Something wrong fetching a new question." 

743 ) 

744 

745 async def answer_update( 

746 self, student_assignment_response: Submission, request: Request 

747 ): 

748 """ 

749 Record a student's submission for an assignment. 

750 

751 Args: 

752 student_assignment_response (Submission): Student's submission details 

753 request (Request): The incoming request object containing student context 

754 

755 Returns: 

756 dict: Submission confirmation and details 

757 

758 Raises: 

759 HTTPException: If assignment not found or submission fails 

760 """ 

761 assignment_uuid = student_assignment_response.assignment_id 

762 

763 # Modified by Allan Ninal — 2026-09-23 (EI-T116) 

764 # WHAT: reject a malformed assignment id before ObjectId() sees it. 

765 # WHY: ObjectId("not-a-valid-oid") raises bson.errors.InvalidId, which 

766 # the catch-all below re-raised as 500 with the raw bson error text 

767 # as the detail — a server-fault status plus an internal exception 

768 # message, for what is simply a bad request. Same guard already 

769 # used elsewhere in this file (e.g. next_fetch). 

770 if not ObjectId.is_valid(assignment_uuid): 

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

772 

773 try: 

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

775 

776 # check if an Assignment exist with a given assignment_uuid 

777 fetched_assignment = await Assignment.find( 

778 {"_id": ObjectId(assignment_uuid)} 

779 ).to_list() 

780 

781 if not fetched_assignment: 

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

783 

784 # Modified by Allan Ninal — 2026-09-23 (EI-T116) 

785 # WHAT: str(student_id) — Submission.student_id is declared 

786 # Optional[str], but `to_user_id` returns a raw bson.ObjectId 

787 # whenever the caller's uuid is ObjectId-shaped (every real 

788 # account). Assigning it directly bypasses Beanie/pydantic 

789 # validation (no validate_assignment on this model), so the 

790 # insert succeeded with an ObjectId sitting in a str field. 

791 # WHY: the response below serialises `student_assignment_response` 

792 # through FastAPI's jsonable_encoder -> model_dump(), which 

793 # CANNOT serialise a bare ObjectId and raised 

794 # PydanticSerializationError — AFTER the insert had already 

795 # committed. The caller was told 500 for a submission that was, 

796 # in fact, recorded: a retry-on-error client would duplicate 

797 # it. Verified live on QA: the DB row existed with 

798 # student_id: ObjectId(...) despite the 500 response. 

799 student_assignment_response.student_id = str(student_id) 

800 await student_assignment_response.insert() 

801 

802 return { 

803 "detail": "Successfully Recorded Response", 

804 "assignment_response": student_assignment_response, 

805 } 

806 except HTTPException: 

807 # Let deliberate HTTP errors through; without this the method's own 

808 # 400/403/404 was swallowed by the catch-all and re-thrown as a 500, 

809 # which the frontend renders as a maintenance dialog. 

810 raise 

811 except Exception as e: 

812 raise HTTPException(status_code=500, detail=safe_detail(e)) 

813 

814 async def share(self, share_request: ShareRequest, request: Request): 

815 """ 

816 Share an assignment with other users. 

817 

818 Args: 

819 share_request (ShareRequest): Sharing details 

820 request (Request): The incoming request object containing teacher context 

821 

822 Returns: 

823 dict: Share confirmation and details 

824 

825 Raises: 

826 HTTPException: If sharing fails 

827 """ 

828 try: 

829 teacher_id = str(request.state.user_details["uuid"]) 

830 share_request.sender_id = teacher_id 

831 share_request = await share_request.save() 

832 return { 

833 "detail": "Successfully Shared Assignment", 

834 "share_request": share_request, 

835 } 

836 except Exception as e: 

837 raise HTTPException(status_code=500, detail=safe_detail(e)) 

838 

839 async def analytics_summary_fetch( 

840 self, class_code: str, assignment_uuid: str, request: Request 

841 ) -> dict: 

842 """ 

843 Retrieve detailed analytics for a specific assignment created by a teacher. 

844 

845 Args: 

846 class_code (str): Code identifying the class. 

847 assignment_uuid (str): UUID of the assignment. 

848 request (Request): HTTP request containing teacher context. 

849 

850 Returns: 

851 dict: Analytics summary with assignment details, stats, and category breakdown. 

852 

853 Raises: 

854 HTTPException: 

855 - 404 if assignment/class is not found or access is denied. 

856 - 400 for validation errors. 

857 - 500 for unexpected server errors. 

858 """ 

859 try: 

860 assignment_oid = ObjectId(assignment_uuid) 

861 except (InvalidId, TypeError): 

862 raise HTTPException( 

863 status_code=status.HTTP_400_BAD_REQUEST, 

864 detail="Assignment UUID is not a valid ObjectId.", 

865 ) 

866 try: 

867 teacher_id = str(request.state.user_details["uuid"]) 

868 assignment_oid = ObjectId(assignment_uuid) 

869 

870 # Ensure assignment belongs to teacher 

871 assignment = await self._get_teacher_assignment(assignment_uuid, teacher_id) 

872 if not assignment: 

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

874 

875 # Retrieve class and filter active/enrolled students 

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

877 {"class_code": class_code, "teacher._id": ObjectId(teacher_id)} 

878 ) 

879 if not class_doc: 

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

881 

882 enrolled_student_ids = [ 

883 student["_id"] 

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

885 if student.get("status") != "REMOVED" 

886 ] 

887 if not enrolled_student_ids: 

888 return self._build_empty_response(assignment) 

889 

890 # Fetch SUBMITTED submissions from enrolled students. The is_submitted 

891 # filter matches the item-analysis path and is required for correctness: 

892 # total_submissions feeds both the "X of Y" card and (since EI-188 #182) 

893 # the pass/fail denominator + the category-% denominator, so counting 

894 # in-progress (is_submitted=False) rows would inflate failures and 

895 # deflate topic %. (EI-188 r2) 

896 # EI-1210: exclude pending/rejected late submissions so they don't 

897 # skew averages. Legacy docs (no review_status field) and approved late 

898 # work still pass the $nin filter. 

899 submissions = ( 

900 await db["submission_collection"] 

901 .find( 

902 { 

903 "assignment_id": assignment_oid, 

904 "student_id": {"$in": enrolled_student_ids}, 

905 "is_submitted": True, 

906 "review_status": {"$nin": ["pending", "rejected"]}, 

907 } 

908 ) 

909 .to_list(length=None) 

910 ) 

911 

912 total_questions = len(assignment.questions or []) 

913 total_submissions = len(submissions) 

914 total_enrolled_students = len(enrolled_student_ids) 

915 

916 if not submissions: 

917 return self._build_empty_response( 

918 assignment, 

919 total_questions, 

920 total_submissions, 

921 total_enrolled_students, 

922 ) 

923 

924 grades = self._extract_valid_grades(submissions) 

925 total_passed, total_failed = self._count_pass_fail( 

926 grades, float(assignment.passing_grade or 0), total_submissions 

927 ) 

928 scores = self._extract_valid_scores(submissions) 

929 

930 analytics = self._calculate_analytics(scores) 

931 analytics.update( 

932 { 

933 "total_passed": f"{total_passed}", 

934 "total_failed": f"{total_failed}", 

935 "total_submissions": f"{total_submissions} of {total_enrolled_students}", 

936 "total_questions": f"{total_questions}", 

937 } 

938 ) 

939 

940 categories = self._build_category_stats( 

941 questions=assignment.questions or [], 

942 submissions=submissions, 

943 is_staar=(assignment.type == "STAAR"), 

944 total_submissions=total_submissions, 

945 ) 

946 

947 return { 

948 "assignment_details": self._serialize_assignment_details(assignment), 

949 "assignment_summary": analytics, 

950 "categories": categories, 

951 } 

952 except HTTPException as http_exc: 

953 raise http_exc 

954 except Exception as error: 

955 raise HTTPException(status_code=500, detail=safe_detail(error)) 

956 

957 def _build_empty_response( 

958 self, 

959 assignment, 

960 total_questions: int = 0, 

961 total_submissions: int = 0, 

962 total_enrolled_students: int = 0, 

963 ) -> dict: 

964 return { 

965 "assignment_details": self._serialize_assignment_details(assignment), 

966 "assignment_summary": { 

967 **self._get_empty_analytics(), 

968 "total_passed": "0", 

969 "total_failed": "0", 

970 "total_submissions": f"{total_submissions} of {total_enrolled_students}", 

971 "total_questions": f"{total_questions}", 

972 }, 

973 "categories": [], 

974 } 

975 

976 def _calculate_analytics(self, scores: list[float]) -> dict: 

977 """ 

978 Compute detailed statistics on a list of submission scores. 

979 Args: 

980 scores (list[float]): List of numeric scores from submissions (must be sorted). 

981 Returns: 

982 dict: Dictionary of calculated score metrics including min, max, range, mean, 

983 median, quartiles, and standard deviation, formatted as strings. 

984 Returns empty analytics dict if scores list is empty. 

985 """ 

986 if not scores: 

987 return self._get_empty_analytics() 

988 

989 total = len(scores) 

990 minimum_score = scores[0] 

991 maximum_score = scores[-1] 

992 mean_score = sum(scores) / total 

993 score_range = maximum_score - minimum_score 

994 variance = sum((s - mean_score) ** 2 for s in scores) / total 

995 std_dev_score = math.sqrt(variance) 

996 

997 return { 

998 "minimum": self._format_score(minimum_score), 

999 "maximum": self._format_score(maximum_score), 

1000 "range": self._format_score(score_range), 

1001 "mean": self._format_score(mean_score), 

1002 "median": self._format_score(self._percentile(scores, 50)), 

1003 "first_quartile": self._format_score(self._percentile(scores, 25)), 

1004 "third_quartile": self._format_score(self._percentile(scores, 75)), 

1005 "std_dev": self._format_score(std_dev_score), 

1006 } 

1007 

1008 def _get_empty_analytics(self, default_value: str = "0.0") -> dict: 

1009 """ 

1010 Generate a dictionary with zeroed-out or default-value analytics fields. 

1011 Args: 

1012 default_value (str): The default string to assign to all analytics fields (default is "0.0"). 

1013 Returns: 

1014 dict: Dictionary with all analytics metrics set to the default_value. 

1015 """ 

1016 metrics = [ 

1017 "minimum", 

1018 "maximum", 

1019 "range", 

1020 "mean", 

1021 "median", 

1022 "first_quartile", 

1023 "third_quartile", 

1024 "std_dev", 

1025 ] 

1026 return {metric: default_value for metric in metrics} 

1027 

1028 def _format_score(self, value: float | int, digits: int = 1) -> str: 

1029 """ 

1030 Format a numeric score by rounding to a specified number of decimal places. 

1031 Args: 

1032 value (float | int): Numeric score to format. 

1033 digits (int): Number of decimal places to round to (default is 1). 

1034 Returns: 

1035 str: The rounded score as a string. 

1036 Raises: 

1037 TypeError: If the input value is not an int or float. 

1038 """ 

1039 if not isinstance(value, (int, float)): 

1040 raise TypeError(f"Score must be a number, got {type(value).__name__}") 

1041 return f"{round(value, digits):.{digits}f}" 

1042 

1043 def _percentile(self, sorted_scores: list[float], percentile: float) -> float: 

1044 """ 

1045 Calculate the score at a given percentile from a sorted list using linear interpolation. 

1046 Args: 

1047 sorted_scores (list[float]): Sorted list of numeric scores. 

1048 percentile (float): Percentile to compute (0 <= percentile <= 100). 

1049 Returns: 

1050 float: Interpolated score at the requested percentile. 

1051 Raises: 

1052 ValueError: If sorted_scores is empty or percentile is out of valid range. 

1053 """ 

1054 if not sorted_scores: 

1055 raise ValueError("The scores list cannot be empty.") 

1056 if not 0 <= percentile <= 100: 

1057 raise ValueError("Percentile must be between 0 and 100.") 

1058 

1059 if percentile == 100: 

1060 return sorted_scores[-1] 

1061 

1062 pos = (len(sorted_scores) - 1) * (percentile / 100) 

1063 lower_index = math.floor(pos) 

1064 upper_index = math.ceil(pos) 

1065 lower_value = sorted_scores[lower_index] 

1066 upper_value = sorted_scores[upper_index] 

1067 

1068 if lower_index == upper_index: 

1069 return lower_value 

1070 

1071 # Linear interpolation 

1072 weight = pos - lower_index 

1073 return lower_value + weight * (upper_value - lower_value) 

1074 

1075 async def _get_teacher_assignment(self, assignment_uuid: str, teacher_id: str): 

1076 """ 

1077 Fetch an assignment document created by a specific teacher. 

1078 Args: 

1079 assignment_uuid (str): The assignment's unique identifier. 

1080 teacher_id (str): The teacher's unique identifier. 

1081 Returns: 

1082 Assignment or None: The assignment document if found and owned by the teacher; otherwise None. 

1083 """ 

1084 return await Assignment.find_one( 

1085 {"_id": ObjectId(assignment_uuid), "created_by": str(teacher_id)} 

1086 ) 

1087 

1088 async def _authorize_teacher_student_access( 

1089 self, class_code: str, assignment_uuid: str, student_id: str, teacher_id: str 

1090 ) -> Assignment: 

1091 """ 

1092 Shared ownership/enrollment guard for the "Individual Answers" analytics 

1093 surface (submission fetch + comment add/update/delete): the assignment 

1094 must be owned by the requesting teacher, the class must be owned by the 

1095 requesting teacher, and the target student must be currently enrolled 

1096 in that class. 

1097 

1098 Args: 

1099 class_code (str): Code of the class the assignment/student are scoped to. 

1100 assignment_uuid (str): The assignment's ObjectId string (already validated). 

1101 student_id (str): The target student's ObjectId string (already validated). 

1102 teacher_id (str): The requesting teacher's id. 

1103 

1104 Returns: 

1105 Assignment: The owned assignment document. 

1106 

1107 Raises: 

1108 HTTPException 404: If the assignment/class is not owned by the teacher, 

1109 or the student is not (currently) enrolled in the class. 

1110 """ 

1111 assignment = await self._get_teacher_assignment(assignment_uuid, teacher_id) 

1112 if not assignment: 

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

1114 

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

1116 { 

1117 "class_code": class_code, 

1118 "teacher._id": ObjectId(teacher_id), 

1119 } 

1120 ) 

1121 if not class_doc: 

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

1123 

1124 student_oid = ObjectId(student_id) 

1125 is_enrolled = any( 

1126 student.get("_id") == student_oid and student.get("status") != "REMOVED" 

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

1128 ) 

1129 if not is_enrolled: 

1130 raise HTTPException( 

1131 status_code=404, detail="Student not found in this class." 

1132 ) 

1133 

1134 return assignment 

1135 

1136 def _extract_valid_scores(self, submissions: list) -> list[float]: 

1137 """ 

1138 Extract and sort valid numeric total scores from a list of submissions. 

1139 Args: 

1140 submissions (list): List of submission documents. 

1141 Returns: 

1142 list[float]: Sorted list of valid total scores. 

1143 Raises: 

1144 HTTPException: 400 if any total_score has invalid format or type. 

1145 """ 

1146 try: 

1147 return sorted( 

1148 [ 

1149 float(sub["total_score"]) 

1150 for sub in submissions 

1151 if isinstance(sub.get("total_score"), (int, float)) 

1152 ] 

1153 ) 

1154 except Exception: 

1155 raise HTTPException( 

1156 status_code=400, detail="Invalid score format in submissions" 

1157 ) 

1158 

1159 def _extract_valid_grades(self, submissions: list) -> list[float]: 

1160 """ 

1161 Extract and sort valid numeric total grades from a list of submissions. 

1162 Args: 

1163 submissions (list): List of submission documents. 

1164 Returns: 

1165 list[float]: Sorted list of valid total grades. 

1166 Raises: 

1167 HTTPException: 400 if any grade has invalid format or type. 

1168 """ 

1169 try: 

1170 return sorted( 

1171 [ 

1172 float(sub["grade"]) 

1173 for sub in submissions 

1174 if isinstance(sub.get("grade"), (int, float)) 

1175 ] 

1176 ) 

1177 except Exception: 

1178 raise HTTPException( 

1179 status_code=400, detail="Invalid grade format in submissions" 

1180 ) 

1181 

1182 def _count_pass_fail( 

1183 self, 

1184 grades: list[float], 

1185 passing_grade: float, 

1186 total_submissions: int | None = None, 

1187 ) -> tuple[int, int]: 

1188 """ 

1189 Count the number of passing and failing submissions based on a passing grade threshold. 

1190 Args: 

1191 grades (list[float]): List of numeric grades from gradable submissions. 

1192 passing_grade (float): Grade threshold to pass. 

1193 total_submissions (int | None): Total submission count. When given, 

1194 failures are derived from it so passed + failed == total_submissions 

1195 — otherwise a submission with a null/missing grade would silently 

1196 vanish from the pass/fail breakdown (the page showed "0 passed, 

1197 0 failed" for N real submissions). Defaults to len(grades) for 

1198 back-compat. (EI-188) 

1199 Returns: 

1200 tuple[int, int]: Number of passing and failing submissions. 

1201 """ 

1202 total_passed = sum(1 for s in grades if s >= passing_grade) 

1203 denominator = ( 

1204 total_submissions if total_submissions is not None else len(grades) 

1205 ) 

1206 total_failed = denominator - total_passed 

1207 return total_passed, total_failed 

1208 

1209 def _build_category_stats( 

1210 self, questions: list, submissions: list, is_staar: bool, total_submissions: int 

1211 ) -> list[dict]: 

1212 """ 

1213 Build statistics for question categories or topics including counts and percentage correct. 

1214 Args: 

1215 questions (list): List of question objects. 

1216 submissions (list): List of submission documents containing answers. 

1217 is_staar (bool): Whether the assignment is of type 'STAAR' (affects label key). 

1218 total_submissions (int): Total number of submissions. 

1219 Returns: 

1220 list[dict]: List of dictionaries containing category/topic, question count, and percentage correct. 

1221 """ 

1222 label_key = "category" if is_staar else "topic" 

1223 label_counter = {} 

1224 label_correct_counts = {} 

1225 

1226 for q in questions: 

1227 label = getattr(q, label_key, None) 

1228 if label: 

1229 label_counter[label] = label_counter.get(label, 0) + 1 

1230 label_correct_counts[label] = 0 

1231 

1232 for sub in submissions: 

1233 # Per-answer correctness is persisted under `last_student_answers` 

1234 # (each entry: {questionId, isCorrect, ...}) — there is NO top-level 

1235 # `answers`/`is_correct` field, so the previous read silently scored 

1236 # every topic 0%. (EI-188) 

1237 for ans in sub.get("last_student_answers", []): 

1238 if not ans.get("isCorrect"): 

1239 continue 

1240 qid = ans.get("questionId") 

1241 question = next((q for q in questions if str(q.id) == str(qid)), None) 

1242 if question: 

1243 label = getattr(question, label_key, None) 

1244 if label in label_correct_counts: 

1245 label_correct_counts[label] += 1 

1246 

1247 return [ 

1248 { 

1249 "topic": label, 

1250 "questions": str(count), 

1251 # % correct = correct answers / total attempts at this topic, where 

1252 # total attempts = submissions × questions-in-topic. Dividing by 

1253 # total_submissions alone over-counts (could exceed 100%) for 

1254 # multi-question topics. (EI-188) 

1255 "correct": ( 

1256 f"{round((label_correct_counts[label] / (total_submissions * count)) * 100)}%" 

1257 if total_submissions and count 

1258 else "0%" 

1259 ), 

1260 } 

1261 for label, count in sorted(label_counter.items()) 

1262 ] 

1263 

1264 def _serialize_assignment_details(self, assignment: Assignment) -> dict: 

1265 """ 

1266 Serialize assignment details into a dictionary format suitable for JSON response. 

1267 Args: 

1268 assignment (Assignment): Assignment object to serialize. 

1269 Returns: 

1270 dict: Dictionary with assignment title, description, type, open and close dates as ISO strings. 

1271 """ 

1272 return { 

1273 "title": assignment.title, 

1274 "description": assignment.description, 

1275 "type": assignment.type, 

1276 "date_open": ( 

1277 assignment.date_open.isoformat() if assignment.date_open else None 

1278 ), 

1279 "date_close": ( 

1280 assignment.date_close.isoformat() if assignment.date_close else None 

1281 ), 

1282 } 

1283 

1284 async def analytics_item_analysis_fetch( 

1285 self, class_code: str, assignment_uuid: str, request: Request 

1286 ): 

1287 """ 

1288 Perform item analysis on an assignment by aggregating student answers and comparing them with correct answers. 

1289 

1290 This method is used by a teacher to fetch analysis data of a given assignment. It ensures that the assignment 

1291 belongs to the requesting teacher, gathers all student submissions for that assignment, and calculates per-question 

1292 statistics such as total correct and incorrect answers, as well as the frequency of each student-selected option. 

1293 

1294 Args: 

1295 assignment_uuid (str): The UUID string of the assignment to analyze. 

1296 request (Request): The FastAPI request object, which contains authenticated teacher details. 

1297 

1298 Returns: 

1299 dict: A dictionary with the key `"item_analysis"` containing a list of analysis results for each question. 

1300 Each result includes: 

1301 - question type 

1302 - category 

1303 - student expectation 

1304 - correct answer (for multiple-choice and checkbox) 

1305 - total correct and incorrect counts 

1306 - a breakdown of student-selected answers 

1307 

1308 Example: 

1309 { 

1310 "total_submissions": 10, 

1311 "item_analysis": [ 

1312 { 

1313 "_id": "questionId1", 

1314 "question_type": "multiple-choice", 

1315 "category": "Math", 

1316 "student_expectation": "Apply multiplication", 

1317 "correct_answer": "C", 

1318 "points": "2", 

1319 "total_correct": 10, 

1320 "total_incorrect": 5, 

1321 "student_answers": [ 

1322 {"letter": "A", "text": "3", "total": 2}, 

1323 {"letter": "B", "text": "5", "total": 1}, 

1324 {"letter": "C", "text": "6", "total": 10}, 

1325 {"letter": "D", "text": "9", "total": 2} 

1326 ] 

1327 }, 

1328 ... 

1329 ] 

1330 } 

1331 """ 

1332 try: 

1333 assignment_oid = ObjectId(assignment_uuid) 

1334 except (InvalidId, TypeError): 

1335 raise HTTPException( 

1336 status_code=status.HTTP_400_BAD_REQUEST, 

1337 detail="Assignment UUID is not a valid ObjectId.", 

1338 ) 

1339 try: 

1340 teacher_id = str(request.state.user_details["uuid"]) 

1341 assignment_oid = ObjectId(assignment_uuid) 

1342 

1343 # Fetch assignment created by the teacher 

1344 assignment = await self._fetch_assignment(assignment_oid, teacher_id) 

1345 # Modified by Allan Ninal — 2026-10-03 (EI-T276). 

1346 # WAS: return {"item_analysis": []} with 200. An unknown assignment, or 

1347 # another teacher's, looked like a real assignment with no answers yet. 

1348 # analytics_summary_fetch answers 404 for the same lookup. 

1349 if not assignment: 

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

1351 

1352 # Extract all question IDs from the assignment 

1353 question_ids = [q["id"] for q in assignment["questions"]] 

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

1355 

1356 # Fetch student UUIDs currently enrolled in the class 

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

1358 {"class_code": class_code, "teacher._id": ObjectId(teacher_id)} 

1359 ) 

1360 if not class_doc: 

1361 return {"item_analysis": []} 

1362 

1363 enrolled_student_ids = [ 

1364 student["_id"] 

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

1366 if student.get("status") != "REMOVED" 

1367 ] 

1368 if not enrolled_student_ids: 

1369 return {"item_analysis": []} 

1370 

1371 # Fetch only completed submissions from enrolled students. 

1372 # In-progress submissions carry default empty answer slots (is_submitted=False), 

1373 # which would otherwise be counted as incorrect and skew the analysis. 

1374 # EI-1210: exclude pending/rejected late submissions so they don't skew item analysis. 

1375 submissions = ( 

1376 await db["submission_collection"] 

1377 .find( 

1378 { 

1379 "assignment_id": assignment_oid, 

1380 "student_id": {"$in": enrolled_student_ids}, 

1381 "is_submitted": True, 

1382 "review_status": {"$nin": ["pending", "rejected"]}, 

1383 } 

1384 ) 

1385 .to_list(length=None) 

1386 ) 

1387 

1388 # Group relevant submissions by question 

1389 submissions_map = self._group_filtered_submissions_by_question(submissions) 

1390 

1391 # Fetch teacher's questionbank and map questions by ID 

1392 questionbank_map = await self._fetch_questionbank_map(question_ids) 

1393 

1394 total_enrolled_students = len(enrolled_student_ids) 

1395 

1396 # Analyze each question 

1397 return { 

1398 "total_submissions": f"{len(submissions)}/{total_enrolled_students}", 

1399 "item_analysis": [ 

1400 self._analyze_question( 

1401 str(qid), 

1402 submissions_map, 

1403 questionbank_map, 

1404 total_enrolled_students, 

1405 points_overrides, 

1406 ) 

1407 for qid in question_ids 

1408 ], 

1409 } 

1410 except HTTPException as http_exc: 

1411 raise http_exc 

1412 except Exception as error: 

1413 raise HTTPException(status_code=500, detail=safe_detail(error)) 

1414 

1415 def _group_filtered_submissions_by_question(self, submissions: list) -> dict: 

1416 """ 

1417 Group answers from provided submissions (already filtered by student enrollment) by question ID. 

1418 

1419 Submissions persist student responses under ``submitted_answers`` (each entry carries 

1420 ``questionId`` and ``answer``); there is no top-level ``answers`` field, so reading it 

1421 yielded an empty map and reported 0 correct/0 incorrect for every question. 

1422 """ 

1423 submissions_map = {} 

1424 for sub in submissions: 

1425 for ans in sub.get("submitted_answers", []): 

1426 qid = str(ans.get("questionId")) 

1427 if qid: 

1428 submissions_map.setdefault(qid, []).append(ans) 

1429 return submissions_map 

1430 

1431 async def _fetch_assignment(self, assignment_oid: ObjectId, teacher_id: str): 

1432 """Fetch the assignment created by the given teacher.""" 

1433 # `deleted: {$ne: True}` (not `== False`) so assignments created before the 

1434 # `deleted` field existed (key absent) still match — otherwise Item Analysis 

1435 # returns no assignment → empty table (EI-3390). Matches the other reads. 

1436 return await db["assignments_collection"].find_one( 

1437 { 

1438 "_id": assignment_oid, 

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

1440 "created_by": str(teacher_id), 

1441 } 

1442 ) 

1443 

1444 async def _group_submissions_by_question(self, assignment_oid: ObjectId) -> dict: 

1445 """ 

1446 Group answers from all submissions by question ID. 

1447 Returns a map of question ID to list of answer objects. 

1448 """ 

1449 submissions = ( 

1450 await db["submission_collection"] 

1451 .find({"assignment_id": assignment_oid}) 

1452 .to_list(length=None) 

1453 ) 

1454 submissions_map = {} 

1455 

1456 for sub in submissions: 

1457 for ans in sub.get("submitted_answers", []): 

1458 qid = str(ans.get("questionId")) 

1459 if qid: 

1460 submissions_map.setdefault(qid, []).append(ans) 

1461 

1462 return submissions_map 

1463 

1464 async def _fetch_questionbank_map(self, question_ids: list) -> dict: 

1465 """Fetch question documents and map them by ID, checking the same banks 

1466 the student-side resolver uses (so global-exam / legacy questions resolve): 

1467 1. teacher_questionbank 

1468 2. global_questionbank (staff_admin_db) — STAAR/global practice exams 

1469 3. question_collection (legacy) 

1470 

1471 Previously this queried only ``teacher_questionbank``; for a re-used 

1472 global exam none of the ids matched, so every question fell through to 

1473 ``_get_empty_stats`` and Item Analysis rendered all "NA" / 0%. 

1474 """ 

1475 question_map: dict = {} 

1476 

1477 teacher_docs = ( 

1478 await db["teacher_questionbank"] 

1479 .find({"_id": {"$in": question_ids}}) 

1480 .to_list(length=None) 

1481 ) 

1482 for q in teacher_docs: 

1483 question_map[str(q["_id"])] = q 

1484 

1485 missing = [qid for qid in question_ids if str(qid) not in question_map] 

1486 

1487 if missing and staff_admin_db is not None: 

1488 global_docs = ( 

1489 await staff_admin_db["global_questionbank"] 

1490 .find({"_id": {"$in": missing}}) 

1491 .to_list(length=None) 

1492 ) 

1493 for q in global_docs: 

1494 question_map[str(q["_id"])] = q 

1495 missing = [qid for qid in question_ids if str(qid) not in question_map] 

1496 

1497 if missing: 

1498 legacy_docs = ( 

1499 await db["question_collection"] 

1500 .find({"_id": {"$in": missing}}) 

1501 .to_list(length=None) 

1502 ) 

1503 for q in legacy_docs: 

1504 question_map[str(q["_id"])] = q 

1505 

1506 return question_map 

1507 

1508 def _analyze_question( 

1509 self, 

1510 qid: str, 

1511 submissions_map: dict, 

1512 questionbank_map: dict, 

1513 total_enrolled: int, 

1514 points_overrides: dict | None = None, 

1515 as_percentage: bool = True, 

1516 ) -> dict: 

1517 """ 

1518 Perform item analysis on a single question using the total number of enrolled students for percentage calculation. 

1519 """ 

1520 question = questionbank_map.get(qid) 

1521 

1522 if not question: 

1523 return self._get_empty_stats(qid) 

1524 

1525 question_type = question.get("questionType", "NA").lower() 

1526 # category / studentExpectation are OPTIONAL and are often null/empty on 

1527 # global-exam (STAAR) questions, while `questionTopic` and `teksCode` 

1528 # carry the same classification. Fall back to those so the columns show 

1529 # a real value (e.g. "Percent") instead of "NA" when answers exist. 

1530 # (`.get(k, "NA")` alone returns the stored None/"" — use `or`.) 

1531 category = question.get("category") or question.get("questionTopic") or "NA" 

1532 student_expectation = ( 

1533 question.get("studentExpectation") or question.get("teksCode") or "NA" 

1534 ) 

1535 # A teacher's per-assignment point edit (assignments_collection. 

1536 # question_points_overrides) wins over the question bank's own points. 

1537 points_override = (points_overrides or {}).get(qid) 

1538 points = str( 

1539 points_override 

1540 if points_override is not None 

1541 else question.get("points", "NA") 

1542 ) 

1543 # Free-response questions store `choices: null`; coalesce to [] so the 

1544 # downstream enumerate(choices) doesn't crash (enumerate(None) → 500). 

1545 choices = question.get("choices") or [] 

1546 correct_answer_doc = question.get("correctAnswer") or {} 

1547 # resolve_correct_answer returns `groups` instead of `correctAnswer.answers` 

1548 # for Single-Stimulus (which has none at the top level) — every 

1549 # other type is unaffected. 

1550 correct_answers = resolve_correct_answer(question) 

1551 graph_fingerprint = correct_answer_doc.get("graphFingerprint") 

1552 unordered = correct_answer_doc.get("unordered", False) 

1553 

1554 # correct_texts feeds the displayed "correct answer" text/letter only; 

1555 # the pass/fail check below compares against the raw correct_answers so 

1556 # Item Analysis scores a submission exactly the same way the gradebook 

1557 # does (see is_answer_correct — the single shared comparator). 

1558 correct_letter, _correct_texts = self._get_correct_answers( 

1559 question_type, correct_answers, choices 

1560 ) 

1561 student_answers = self._init_student_answers(question_type, choices) 

1562 

1563 total_correct, total_incorrect = 0, 0 

1564 

1565 for submission in submissions_map.get(qid, []): 

1566 student_answer = submission.get("answer") 

1567 if is_answer_correct( 

1568 student_answer, 

1569 correct_answers, 

1570 question_type, 

1571 graph_fingerprint=graph_fingerprint, 

1572 unordered=unordered, 

1573 ): 

1574 total_correct += 1 

1575 else: 

1576 total_incorrect += 1 

1577 

1578 if question_type in ["multiple-choice", "checkbox"]: 

1579 self._update_student_answer_counts(student_answers, student_answer) 

1580 

1581 def format_value(value: int) -> str | int: 

1582 if as_percentage: 

1583 # Match the live rounded format ("0.0%", not "0%") so the zero-enrolled 

1584 # fallback renders identically to every other row. (EI-3390) 

1585 return ( 

1586 f"{round((value / total_enrolled) * 100, 2)}%" 

1587 if total_enrolled 

1588 else "0.0%" 

1589 ) 

1590 return f"{value}" 

1591 

1592 for option in student_answers: 

1593 option["total"] = format_value(option["total"]) 

1594 

1595 return { 

1596 "_id": qid, 

1597 "question_type": question_type, 

1598 "category": category, 

1599 "student_expectation": student_expectation, 

1600 "correct_answer": ( 

1601 correct_letter 

1602 if question_type in ["multiple-choice", "checkbox"] 

1603 else "NA" 

1604 ), 

1605 "points": points, 

1606 "total_correct": format_value(total_correct), 

1607 "total_incorrect": format_value(total_incorrect), 

1608 "student_answers": student_answers, 

1609 } 

1610 

1611 def _get_empty_stats(self, qid: str) -> dict: 

1612 """Return a default analysis result when the question is not found.""" 

1613 return { 

1614 "_id": qid, 

1615 "question_type": "NA", 

1616 "category": "NA", 

1617 "student_expectation": "NA", 

1618 "correct_answer": "NA", 

1619 "points": "NA", 

1620 # Percentage strings to match the live _analyze_question path (the FE 

1621 # renders these directly). Use the same rounded "0.0%" format the live 

1622 # rows use so a not-found question doesn't display "0%" next to "0.0%" 

1623 # rows. (EI-188 r2; format normalized in EI-3390) 

1624 "total_correct": "0.0%", 

1625 "total_incorrect": "0.0%", 

1626 "student_answers": [ 

1627 {"letter": "NA", "text": "NA", "total": "0.0%"} for _ in range(4) 

1628 ], 

1629 } 

1630 

1631 def _get_correct_answers( 

1632 self, question_type: str, correct_answers, choices: list 

1633 ) -> tuple: 

1634 """Extract and clean correct answers depending on the question type. 

1635 

1636 ``correct_answers`` is the question's ``correctAnswer.answers`` and is not 

1637 always the list each branch assumes: checkbox can store a bare string 

1638 (which iterated char-by-char into ``['x','','=',...]``), and 

1639 drag-and-drop / drop-down-menu can be null (``for ans in None`` → 

1640 ``TypeError`` → Item Analysis 500). Each list branch now coerces 

1641 defensively so every question type renders instead of crashing. 

1642 """ 

1643 correct_letter = "NA" 

1644 correct_texts = [] 

1645 

1646 if question_type == "multiple-choice" and correct_answers: 

1647 first = ( 

1648 correct_answers[0] 

1649 if isinstance(correct_answers, list) 

1650 else correct_answers 

1651 ) 

1652 correct_text = first if isinstance(first, str) else first.get("answer", "") 

1653 correct_letter = self.choice_text_to_letter(correct_text, choices) 

1654 correct_texts = [self.clean_html(correct_text)] 

1655 

1656 elif question_type == "checkbox" and correct_answers: 

1657 answers = ( 

1658 correct_answers 

1659 if isinstance(correct_answers, list) 

1660 else [correct_answers] 

1661 ) 

1662 correct_texts = [self.clean_html(ans) for ans in answers] 

1663 correct_letters = [ 

1664 self.choice_text_to_letter(ans, choices) for ans in answers 

1665 ] 

1666 correct_letter = ( 

1667 ", ".join(filter(None, correct_letters)) if correct_letters else "NA" 

1668 ) 

1669 

1670 elif question_type == "free-response" and correct_answers: 

1671 # `correct_answers` is a LIST (from correctAnswer.answers) — iterate it 

1672 # like the checkbox branch; clean_html on the whole list would stringify 

1673 # to "['...']" and never match a student answer (item-analysis would 

1674 # score every free-response 0%). (EI-188 r2) 

1675 answers = ( 

1676 correct_answers 

1677 if isinstance(correct_answers, list) 

1678 else [correct_answers] 

1679 ) 

1680 correct_texts = [ 

1681 self.clean_html(ans.get("answer", "") if isinstance(ans, dict) else ans) 

1682 for ans in answers 

1683 ] 

1684 

1685 elif question_type in ["drag-and-drop", "drop-down-menu"]: 

1686 answers = correct_answers if isinstance(correct_answers, list) else [] 

1687 correct_texts = [ 

1688 self.clean_html(ans.get("answer", "") if isinstance(ans, dict) else ans) 

1689 for ans in answers 

1690 ] 

1691 

1692 elif question_type == "graph" and correct_answers: 

1693 answers = ( 

1694 correct_answers 

1695 if isinstance(correct_answers, list) 

1696 else [correct_answers] 

1697 ) 

1698 correct_texts = [ 

1699 (ans.get("answer") if isinstance(ans, dict) else ans) for ans in answers 

1700 ] 

1701 

1702 elif isinstance(correct_answers, str): 

1703 correct_texts = [self.clean_html(correct_answers)] 

1704 

1705 return correct_letter, correct_texts 

1706 

1707 def _init_student_answers(self, question_type: str, choices: list) -> list: 

1708 """Initialize the list of student answer choices.""" 

1709 if question_type in ["multiple-choice", "checkbox"]: 

1710 return [ 

1711 { 

1712 "letter": chr(ord("A") + idx), 

1713 "text": choice.get("text", ""), 

1714 "total": 0, 

1715 } 

1716 for idx, choice in enumerate(choices) 

1717 ] 

1718 return [{"letter": "NA", "text": "NA", "total": 0} for _ in range(4)] 

1719 

1720 async def analytics_student_submission_fetch( 

1721 self, class_code: str, assignment_uuid: str, student_id: str, request: Request 

1722 ) -> dict: 

1723 """ 

1724 Fetch a single enrolled student's submission for a teacher-made assignment, 

1725 for teacher review (the "Individual Answers" analytics tab). 

1726 

1727 Mirrors StudentAssignmentsService.fetch_submission_details — same submission 

1728 document, same per-question snapshot stored under `questions`, and the same 

1729 scoring helpers (`_is_correct` / `is_meaningful_answer`) — but scoped to an 

1730 arbitrary enrolled student instead of the caller, and with correct answers / 

1731 scores always revealed. The assignment's `show_correct_answers_after_submit` 

1732 / `show_score_after_submit` settings gate what STUDENTS see; they are not an 

1733 access-control mechanism for the assignment's own teacher. 

1734 

1735 Args: 

1736 class_code (str): Code of the class the assignment/student are scoped to. 

1737 assignment_uuid (str): The assignment's ObjectId string. 

1738 student_id (str): The target student's ObjectId string. 

1739 request (Request): FastAPI request carrying the authenticated teacher's details. 

1740 

1741 Returns: 

1742 dict: { 

1743 "_id": str, 

1744 "assignmentDetails": { 

1745 "title": str, "description": str, "type": str, 

1746 "date_open": str | None, "date_close": str | None, 

1747 }, 

1748 "remainingTime": str | None, # "HH:MM:SS" left, or None if unlimited time 

1749 "isSubmitted": bool, 

1750 "grade": str | None, # None when ungraded / pending / rejected 

1751 "gradeStatus": str, # gradebook status: graded|missed|incomplete|pending|rejected 

1752 "remarks": str | None, 

1753 "isLate": bool, 

1754 "reviewStatus": str, 

1755 "totalAttemptsUsed": str, 

1756 "totalAttemptsAllowed": str, 

1757 "studentScore": str, # points the student actually earned 

1758 "totalScore": str, # max points possible (sum of question points) 

1759 "totalCorrectAnswers": str, 

1760 "totalQuestions": str, 

1761 "totalAnswersSubmitted": str, 

1762 "details": [ 

1763 { 

1764 "_id": str, "question": Any, "choices": list | None, 

1765 "questionType": str, "points": int, "isFlagged": bool, 

1766 "correctAnswer": {"content": Any, "answerDetails": Any}, 

1767 }, 

1768 ... 

1769 ], 

1770 "studentAnswers": [ 

1771 { 

1772 "questionId": str, "questionType": str, "isFlagged": bool, 

1773 "answer": Any, "isCorrect": bool, "earnedPoints": int, 

1774 }, 

1775 ... 

1776 ], 

1777 "teacherComments": {"<questionId>": "comment text", ...}, 

1778 } 

1779 When the student has no submission document yet: 

1780 {"isSubmitted": False, "assignmentDetails": {...}, "teacherComments": {}}. 

1781 

1782 Raises: 

1783 HTTPException: 

1784 - 400: If `assignment_uuid` or `student_id` is not a valid ObjectId, 

1785 or `settings.time_allowed` is configured but improperly formatted. 

1786 A missing/empty `time_allowed` is not an error — it means the 

1787 assignment has no time limit and `remainingTime` is None. 

1788 - 404: If the assignment/class is not owned by the teacher, or the 

1789 student is not (currently) enrolled in the class. 

1790 """ 

1791 if not ObjectId.is_valid(assignment_uuid): 

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

1793 if not ObjectId.is_valid(student_id): 

1794 raise HTTPException(status_code=400, detail="Invalid student ID format") 

1795 

1796 teacher_id = str(request.state.user_details["uuid"]) 

1797 assignment_oid = ObjectId(assignment_uuid) 

1798 student_oid = ObjectId(student_id) 

1799 

1800 assignment = await self._authorize_teacher_student_access( 

1801 class_code, assignment_uuid, student_id, teacher_id 

1802 ) 

1803 

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

1805 { 

1806 "assignment_id": assignment_oid, 

1807 "student_id": student_oid, 

1808 } 

1809 ) 

1810 if not submission: 

1811 return { 

1812 "isSubmitted": False, 

1813 "assignmentDetails": self._serialize_assignment_details(assignment), 

1814 "teacherComments": {}, 

1815 } 

1816 

1817 # Time left in the student's attempt window — same computation as 

1818 # StudentAssignmentsService.fetch_submission_details. A missing/empty 

1819 # time_allowed means the assignment has no time limit. 

1820 time_allowed_str = assignment.settings.time_allowed 

1821 if time_allowed_str: 

1822 try: 

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

1824 time_allowed_delta = timedelta( 

1825 hours=hours, minutes=minutes, seconds=seconds 

1826 ) 

1827 except ValueError: 

1828 raise HTTPException( 

1829 status_code=400, 

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

1831 ) 

1832 

1833 date_created = submission.get("date_created") 

1834 if date_created and date_created.tzinfo is None: 

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

1836 

1837 if date_created: 

1838 now_utc = datetime.now(timezone.utc) 

1839 end_time = date_created + time_allowed_delta 

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

1841 else: 

1842 remaining_seconds = 0 

1843 

1844 formatted_remaining = ( 

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

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

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

1848 ) 

1849 else: 

1850 formatted_remaining = None 

1851 

1852 student_service = StudentAssignmentsService() 

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

1854 # A teacher's per-assignment point edit (assignments_collection. 

1855 # question_points_overrides) wins over the points snapshotted onto the 

1856 # submission at attempt-start time, so editing points is reflected here 

1857 # immediately without needing to touch the submission document. 

1858 points_overrides = assignment.question_points_overrides or {} 

1859 

1860 last_submitted = submission.get("last_submitted_answers") 

1861 last_student_ans = submission.get("last_student_answers") 

1862 

1863 scored_details: list = [] 

1864 student_answers: list = [] 

1865 

1866 if last_submitted and last_student_ans is not None: 

1867 # A previous attempt was fully submitted — reuse the stored, 

1868 # already-scored data, but exclude any question the teacher has 

1869 # since removed from the assignment: it must stop being shown (and 

1870 # stop counting toward the score) here, exactly like the student's 

1871 # own submission review (StudentAssignmentsService. 

1872 # fetch_submission_details) — otherwise the two views could show 

1873 # different totals for the same submission. 

1874 live_question_ids = { 

1875 str(q.id) if hasattr(q, "id") else str(q) 

1876 for q in (assignment.questions or []) 

1877 } 

1878 

1879 total_score = 0 

1880 correct_count = 0 

1881 total_answers_submitted = 0 

1882 

1883 for ans in last_submitted: 

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

1885 if q_id not in live_question_ids: 

1886 continue 

1887 q_doc = question_map.get(q_id) 

1888 if not q_doc: 

1889 continue 

1890 scored_details.append( 

1891 { 

1892 "_id": q_id, 

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

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

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

1896 # NOT points_overrides here: total_score/earnedPoints above are 

1897 # historical values locked in at submit time against the points 

1898 # snapshotted onto THIS submission. Substituting today's override 

1899 # into just the denominator (while the numerator stays historical) 

1900 # produced impossible scores like "200.0 / 37" when a question's 

1901 # points were lowered after grading. The override still applies 

1902 # prospectively — to new submissions (student_assignment.py 

1903 # _prepare_questions) and to in-progress/live-scored attempts 

1904 # below, where earned points are derived from it directly. 

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

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

1907 "correctAnswer": { 

1908 "content": q_doc.get("correctAnswer", {}).get("answers"), 

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

1910 "answerDetails" 

1911 ), 

1912 }, 

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

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

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

1916 } 

1917 ) 

1918 

1919 for sa in last_student_ans: 

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

1921 if q_id not in live_question_ids: 

1922 continue 

1923 sa_out = { 

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

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

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

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

1928 "isCorrect": sa.get("isCorrect"), 

1929 "earnedPoints": sa.get("earnedPoints"), 

1930 } 

1931 if "groupResults" in sa: 

1932 sa_out["groupResults"] = sa.get("groupResults") 

1933 student_answers.append(sa_out) 

1934 

1935 if sa.get("isCorrect"): 

1936 total_score += sa.get("earnedPoints") or 0 

1937 correct_count += 1 

1938 elif sa.get("earnedPoints"): 

1939 total_score += sa.get("earnedPoints") or 0 

1940 

1941 if student_service.is_meaningful_answer(sa.get("answer")): 

1942 total_answers_submitted += 1 

1943 else: 

1944 # No completed submission yet — score live from the in-progress submitted_answers. 

1945 total_score = 0 

1946 correct_count = 0 

1947 total_answers_submitted = 0 

1948 

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

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

1951 q_doc = question_map.get(q_id) 

1952 if not q_doc: 

1953 continue 

1954 

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

1956 correct_answer = resolve_correct_answer(q_doc) 

1957 max_points = points_overrides.get(q_id, q_doc.get("points", 0)) 

1958 

1959 is_correct, earned_points, group_results = score_question( 

1960 student_answer, 

1961 correct_answer, 

1962 q_doc.get("questionType"), 

1963 max_points, 

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

1965 "graphFingerprint" 

1966 ), 

1967 unordered=(q_doc.get("correctAnswer") or {}).get( 

1968 "unordered", False 

1969 ), 

1970 ) 

1971 

1972 if is_correct: 

1973 total_score += earned_points 

1974 correct_count += 1 

1975 elif earned_points: 

1976 total_score += earned_points 

1977 if student_service.is_meaningful_answer(student_answer): 

1978 total_answers_submitted += 1 

1979 

1980 scored_details.append( 

1981 { 

1982 "_id": q_id, 

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

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

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

1986 "points": max_points, 

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

1988 "correctAnswer": { 

1989 "content": correct_answer, 

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

1991 "answerDetails" 

1992 ), 

1993 }, 

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

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

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

1997 } 

1998 ) 

1999 

2000 sa_entry = { 

2001 "questionId": q_id, 

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

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

2004 "answer": student_answer, 

2005 "isCorrect": is_correct, 

2006 "earnedPoints": earned_points, 

2007 } 

2008 if group_results is not None: 

2009 sa_entry["groupResults"] = group_results 

2010 student_answers.append(sa_entry) 

2011 

2012 # Grade goes through the CANONICAL resolver (server/utilities/gradebook.py), 

2013 # the same one the gradebook and the student's own view use. This endpoint 

2014 # previously formatted submission["grade"] directly, which broke two of the 

2015 # resolver's documented rules: 

2016 # 

2017 # * an UNGRADED submission stores grade=None, and str(None) is the literal 

2018 # "None" — the teacher's Individual Answers tab rendered "NaN%". 

2019 # * a REJECTED late submission must never show its provisional grade "on 

2020 # either view" (EI-1195 / EI-1210); the raw value was returned anyway. 

2021 # 

2022 # `gradeStatus` is exposed alongside so the UI can say "missed" / 

2023 # "incomplete" / "pending" / "rejected" instead of showing nothing. 

2024 grade_source = submission 

2025 if last_submitted and last_student_ans is not None: 

2026 recalculated_grade = recalculate_submitted_grade( 

2027 submission, 

2028 { 

2029 str(q.id) if hasattr(q, "id") else str(q) 

2030 for q in (assignment.questions or []) 

2031 }, 

2032 ) 

2033 if recalculated_grade is not None: 

2034 grade_source = {**submission, "grade": recalculated_grade} 

2035 resolved_grade = resolve_cell_grade( 

2036 grade_source, getattr(assignment, "date_close", None) 

2037 ) 

2038 grade_value = resolved_grade["grade"] 

2039 if grade_value is None: 

2040 grade_str = None 

2041 elif float(grade_value).is_integer(): 

2042 grade_str = str(int(grade_value)) 

2043 else: 

2044 grade_str = str(grade_value) 

2045 

2046 # Sum of each question's point value — the max-points denominator, 

2047 # so the UI can render "earned / possible" instead of just the raw score. 

2048 total_points = sum(q.get("points", 0) or 0 for q in scored_details) 

2049 

2050 return { 

2051 "_id": str(submission["_id"]), 

2052 "assignmentDetails": self._serialize_assignment_details(assignment), 

2053 "remainingTime": formatted_remaining, 

2054 "isSubmitted": bool(submission.get("last_submitted_answers")) 

2055 or submission.get("is_submitted", False), 

2056 "grade": grade_str, 

2057 "gradeStatus": resolved_grade["status"], 

2058 "remarks": submission.get("remarks"), 

2059 "isLate": submission.get("is_late", False), 

2060 "reviewStatus": submission.get("review_status", "none"), 

2061 "totalAttemptsUsed": str(submission.get("total_attempts", 0)), 

2062 "totalAttemptsAllowed": str(assignment.settings.allowed_attempts), 

2063 "studentScore": str(total_score), 

2064 "totalScore": str(total_points), 

2065 "totalCorrectAnswers": str(correct_count), 

2066 # len(scored_details) rather than the stored total_questions — the 

2067 # latter is frozen from submit time and goes stale the moment a 

2068 # question is removed from the assignment afterward. 

2069 "totalQuestions": str(len(scored_details)), 

2070 "totalAnswersSubmitted": str(total_answers_submitted), 

2071 "details": scored_details, 

2072 "studentAnswers": student_answers, 

2073 "teacherComments": { 

2074 qid: entry.get("comment", "") 

2075 for qid, entry in (submission.get("teacher_comments") or {}).items() 

2076 }, 

2077 } 

2078 

2079 def _validate_comment_path_ids( 

2080 self, assignment_uuid: str, student_id: str, question_id: str 

2081 ) -> None: 

2082 """Shared 400 guard for the three comment endpoints (add/update/delete).""" 

2083 if not ObjectId.is_valid(assignment_uuid): 

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

2085 if not ObjectId.is_valid(student_id): 

2086 raise HTTPException(status_code=400, detail="Invalid student ID format") 

2087 if not ObjectId.is_valid(question_id): 

2088 raise HTTPException(status_code=400, detail="Invalid question ID format") 

2089 

2090 async def _find_submission_for_comment( 

2091 self, assignment_oid: ObjectId, student_oid: ObjectId, question_id: str 

2092 ): 

2093 """ 

2094 Fetch the submission (id + this one question's existing comment, if any) — 

2095 used by add/update/delete to check state before mutating, since add must 

2096 reject an existing comment and update/delete must reject a missing one. 

2097 """ 

2098 return await db["submission_collection"].find_one( 

2099 {"assignment_id": assignment_oid, "student_id": student_oid}, 

2100 {"_id": 1, f"teacher_comments.{question_id}": 1}, 

2101 ) 

2102 

2103 async def add_student_answer_comment( 

2104 self, 

2105 class_code: str, 

2106 assignment_uuid: str, 

2107 student_id: str, 

2108 question_id: str, 

2109 comment: str, 

2110 request: Request, 

2111 ) -> dict: 

2112 """ 

2113 Add a new teacher comment on a specific student's answer to a specific 

2114 question. Fails if a comment already exists for that question — call 

2115 update_student_answer_comment instead. At most one comment per 

2116 (submission, question), stored under `submission.teacher_comments.<question_id>`. 

2117 

2118 Args: 

2119 class_code (str): Code of the class the assignment/student are scoped to. 

2120 assignment_uuid (str): The assignment's ObjectId string. 

2121 student_id (str): The target student's ObjectId string. 

2122 question_id (str): The question's ObjectId string. 

2123 comment (str): Comment text (already validated/trimmed non-blank by the route). 

2124 request (Request): FastAPI request carrying the authenticated teacher's details. 

2125 

2126 Returns: 

2127 dict: {"submission_id": str, "questionId": str, "comment": str, "updatedAt": str (ISO 8601)}. 

2128 

2129 Raises: 

2130 HTTPException: 

2131 - 400: If `assignment_uuid` / `student_id` / `question_id` is not a valid ObjectId. 

2132 - 404: If the assignment/class is not owned by the teacher, the student is 

2133 not enrolled in the class, or the student has no submission yet. 

2134 - 409: If a comment already exists for this question. 

2135 """ 

2136 self._validate_comment_path_ids(assignment_uuid, student_id, question_id) 

2137 

2138 teacher_id = str(request.state.user_details["uuid"]) 

2139 await self._authorize_teacher_student_access( 

2140 class_code, assignment_uuid, student_id, teacher_id 

2141 ) 

2142 

2143 assignment_oid = ObjectId(assignment_uuid) 

2144 student_oid = ObjectId(student_id) 

2145 

2146 existing = await self._find_submission_for_comment( 

2147 assignment_oid, student_oid, question_id 

2148 ) 

2149 if not existing: 

2150 raise HTTPException( 

2151 status_code=404, detail="Submission not found for this student." 

2152 ) 

2153 if (existing.get("teacher_comments") or {}).get(question_id): 

2154 raise HTTPException( 

2155 status_code=409, 

2156 detail="A comment already exists for this question. Use update instead.", 

2157 ) 

2158 

2159 return await self._set_student_answer_comment( 

2160 assignment_oid, student_oid, question_id, comment, teacher_id 

2161 ) 

2162 

2163 async def update_student_answer_comment( 

2164 self, 

2165 class_code: str, 

2166 assignment_uuid: str, 

2167 student_id: str, 

2168 question_id: str, 

2169 comment: str, 

2170 request: Request, 

2171 ) -> dict: 

2172 """ 

2173 Overwrite an existing teacher comment on a specific student's answer to 

2174 a specific question. Fails if no comment exists yet for that question — 

2175 call add_student_answer_comment instead. 

2176 

2177 Args: 

2178 class_code (str): Code of the class the assignment/student are scoped to. 

2179 assignment_uuid (str): The assignment's ObjectId string. 

2180 student_id (str): The target student's ObjectId string. 

2181 question_id (str): The question's ObjectId string. 

2182 comment (str): Comment text (already validated/trimmed non-blank by the route). 

2183 request (Request): FastAPI request carrying the authenticated teacher's details. 

2184 

2185 Returns: 

2186 dict: {"submission_id": str, "questionId": str, "comment": str, "updatedAt": str (ISO 8601)}. 

2187 

2188 Raises: 

2189 HTTPException: 

2190 - 400: If `assignment_uuid` / `student_id` / `question_id` is not a valid ObjectId. 

2191 - 404: If the assignment/class is not owned by the teacher, the student is not 

2192 enrolled in the class, the student has no submission yet, or no comment 

2193 exists yet for this question. 

2194 """ 

2195 self._validate_comment_path_ids(assignment_uuid, student_id, question_id) 

2196 

2197 teacher_id = str(request.state.user_details["uuid"]) 

2198 await self._authorize_teacher_student_access( 

2199 class_code, assignment_uuid, student_id, teacher_id 

2200 ) 

2201 

2202 assignment_oid = ObjectId(assignment_uuid) 

2203 student_oid = ObjectId(student_id) 

2204 

2205 existing = await self._find_submission_for_comment( 

2206 assignment_oid, student_oid, question_id 

2207 ) 

2208 if not existing: 

2209 raise HTTPException( 

2210 status_code=404, detail="Submission not found for this student." 

2211 ) 

2212 if not (existing.get("teacher_comments") or {}).get(question_id): 

2213 raise HTTPException( 

2214 status_code=404, 

2215 detail="No existing comment for this question. Use add instead.", 

2216 ) 

2217 

2218 return await self._set_student_answer_comment( 

2219 assignment_oid, student_oid, question_id, comment, teacher_id 

2220 ) 

2221 

2222 async def _set_student_answer_comment( 

2223 self, 

2224 assignment_oid: ObjectId, 

2225 student_oid: ObjectId, 

2226 question_id: str, 

2227 comment: str, 

2228 teacher_id: str, 

2229 ) -> dict: 

2230 """Shared write path for add/update — both $set the same shape once existence is checked. 

2231 

2232 `comment` is plain text (a MUI `TextField`, not a rich-text editor — see 

2233 CommentBox.jsx in eruditiontx-client-mvp), so `strip_html` is used rather than 

2234 `sanitize_rich_text`: there's no legitimate formatting markup to preserve here, 

2235 only script/HTML that a teacher happened to type. EI-3446. 

2236 """ 

2237 now = datetime.now(timezone.utc) 

2238 sanitized_comment = strip_html(comment) 

2239 

2240 result = await db["submission_collection"].find_one_and_update( 

2241 {"assignment_id": assignment_oid, "student_id": student_oid}, 

2242 { 

2243 "$set": { 

2244 f"teacher_comments.{question_id}": { 

2245 "comment": sanitized_comment, 

2246 "commented_by": teacher_id, 

2247 "updated_at": now, 

2248 } 

2249 } 

2250 }, 

2251 projection={"_id": 1}, 

2252 return_document=ReturnDocument.AFTER, 

2253 ) 

2254 

2255 return { 

2256 "submission_id": str(result["_id"]), 

2257 "questionId": question_id, 

2258 "comment": sanitized_comment, 

2259 "updatedAt": now.isoformat(), 

2260 } 

2261 

2262 async def delete_student_answer_comment( 

2263 self, 

2264 class_code: str, 

2265 assignment_uuid: str, 

2266 student_id: str, 

2267 question_id: str, 

2268 request: Request, 

2269 ) -> dict: 

2270 """ 

2271 Delete the teacher's comment on a specific student's answer to a specific question. 

2272 

2273 Args: 

2274 class_code (str): Code of the class the assignment/student are scoped to. 

2275 assignment_uuid (str): The assignment's ObjectId string. 

2276 student_id (str): The target student's ObjectId string. 

2277 question_id (str): The question's ObjectId string. 

2278 request (Request): FastAPI request carrying the authenticated teacher's details. 

2279 

2280 Returns: 

2281 dict: {"submission_id": str, "questionId": str, "deleted": True}. 

2282 

2283 Raises: 

2284 HTTPException: 

2285 - 400: If `assignment_uuid` / `student_id` / `question_id` is not a valid ObjectId. 

2286 - 404: If the assignment/class is not owned by the teacher, the student is not 

2287 enrolled in the class, the student has no submission yet, or no comment 

2288 exists for this question. 

2289 """ 

2290 self._validate_comment_path_ids(assignment_uuid, student_id, question_id) 

2291 

2292 teacher_id = str(request.state.user_details["uuid"]) 

2293 await self._authorize_teacher_student_access( 

2294 class_code, assignment_uuid, student_id, teacher_id 

2295 ) 

2296 

2297 assignment_oid = ObjectId(assignment_uuid) 

2298 student_oid = ObjectId(student_id) 

2299 

2300 existing = await self._find_submission_for_comment( 

2301 assignment_oid, student_oid, question_id 

2302 ) 

2303 if not existing: 

2304 raise HTTPException( 

2305 status_code=404, detail="Submission not found for this student." 

2306 ) 

2307 if not (existing.get("teacher_comments") or {}).get(question_id): 

2308 raise HTTPException( 

2309 status_code=404, detail="No comment found for this question." 

2310 ) 

2311 

2312 result = await db["submission_collection"].find_one_and_update( 

2313 {"assignment_id": assignment_oid, "student_id": student_oid}, 

2314 {"$unset": {f"teacher_comments.{question_id}": ""}}, 

2315 projection={"_id": 1}, 

2316 return_document=ReturnDocument.AFTER, 

2317 ) 

2318 

2319 return { 

2320 "submission_id": str(result["_id"]), 

2321 "questionId": question_id, 

2322 "deleted": True, 

2323 } 

2324 

2325 def _update_student_answer_counts(self, student_answers: list, student_answer): 

2326 """Update the counts of how often each option was selected.""" 

2327 if isinstance(student_answer, list): 

2328 for a in student_answer: 

2329 for option in student_answers: 

2330 if self.clean_html(option["text"]) == self.clean_html(a): 

2331 option["total"] += 1 

2332 else: 

2333 for option in student_answers: 

2334 if self.clean_html(option["text"]) == self.clean_html(student_answer): 

2335 option["total"] += 1 

2336 

2337 def clean_html(self, text) -> str: 

2338 """Strip HTML tags from an answer value. 

2339 

2340 Answer values are not always plain strings: checkbox / drag-and-drop / 

2341 global questions store each answer as a ``{"id":..., "answer":...}`` dict. 

2342 Passing such a dict to ``re.sub`` raised ``TypeError`` and 500'd the whole 

2343 Item Analysis. Normalize dicts to their ``answer`` and coerce any other 

2344 non-string to text before stripping tags. 

2345 

2346 Math-formula spans (``<span class="mfe-formula" data-latex="..."> 

2347 ...rendered markup...</span>``) are collapsed to their ``data-latex`` 

2348 source first — a plain tag-strip keeps every inner text node, so two 

2349 formulas that differ only in structure (e.g. an exponent vs. an inline 

2350 multiplier) rendered to the same visible characters and were counted/ 

2351 matched as the same choice. See MultipleChoiceOption.jsx / 

2352 getChoiceComparisonKey on the client for the same fix. 

2353 """ 

2354 if isinstance(text, dict): 

2355 text = text.get("answer", "") 

2356 if not isinstance(text, str): 

2357 text = "" if text is None else str(text) 

2358 text = MFE_FORMULA_RE.sub(lambda m: m.group(1), text) 

2359 return re.sub(r"<.*?>", "", text).strip() 

2360 

2361 def choice_text_to_letter(self, text: str, choices: list) -> str: 

2362 """Convert correct answer text to its corresponding letter (A, B, C...).""" 

2363 clean_text = self.clean_html(text) 

2364 for idx, choice in enumerate(choices): 

2365 if self.clean_html(choice.get("text", "")) == clean_text: 

2366 return chr(ord("A") + idx) 

2367 return None 

2368 

2369 async def mark_student_answer( 

2370 self, 

2371 class_code: str, 

2372 assignment_uuid: str, 

2373 student_id: str, 

2374 question_id: str, 

2375 points: float, 

2376 feedback: str | None, 

2377 request: Request, 

2378 ) -> dict: 

2379 """ 

2380 Mark ONE student's answer to ONE written question by hand. 

2381 

2382 This is deliberately not `update_question_points`. That route changes what a 

2383 question is worth for EVERY student in the assignment; marking is the opposite — 

2384 one student's answer, awarded on its merits, leaving everyone else untouched. 

2385 

2386 Partial credit is the point: a written answer is usually part right, and a 

2387 correct/incorrect toggle would throw that away. 

2388 

2389 Grades are recomputed over the questions that have actually been MARKED. An 

2390 answer still waiting on a person contributes to neither the score nor the total, 

2391 so the grade reported is an honest grade of marked work rather than a low one 

2392 that silently rises later. 

2393 

2394 `pre_penalty_grade` is rewritten too whenever the submission carries one. It is 

2395 what `approve_late_submission` subtracts the late penalty from, and leaving it 

2396 stale is how a regrade gets silently discarded: mark first, approve second, and 

2397 the approval would overwrite `grade` from a number calculated before the marking. 

2398 

2399 Args: 

2400 class_code (str): Code of the class the assignment/student are scoped to. 

2401 assignment_uuid (str): The assignment's ObjectId string. 

2402 student_id (str): The target student's ObjectId string. 

2403 question_id (str): The question's ObjectId string. 

2404 points (float): Points awarded, 0 to the question's own point value. 

2405 feedback (str | None): Optional note shown to the student with the mark. 

2406 request (Request): FastAPI request carrying the authenticated teacher. 

2407 

2408 Returns: 

2409 dict: the new per-answer and submission-level numbers. 

2410 

2411 Raises: 

2412 HTTPException: 

2413 - 400: bad ObjectId, a question type that is not marked by hand, or 

2414 points outside 0..the question's value. 

2415 - 404: assignment/class not owned by the teacher, student not enrolled, 

2416 no submission, or the question is not part of this submission. 

2417 """ 

2418 self._validate_comment_path_ids(assignment_uuid, student_id, question_id) 

2419 

2420 teacher_id = str(request.state.user_details["uuid"]) 

2421 await self._authorize_teacher_student_access( 

2422 class_code, assignment_uuid, student_id, teacher_id 

2423 ) 

2424 

2425 assignment_oid = ObjectId(assignment_uuid) 

2426 student_oid = ObjectId(student_id) 

2427 

2428 submission = await self._find_submission_for_comment( 

2429 assignment_oid, student_oid, question_id 

2430 ) 

2431 if not submission: 

2432 raise HTTPException( 

2433 status_code=404, detail="Submission not found for this student." 

2434 ) 

2435 

2436 answers = submission.get("last_student_answers") or [] 

2437 entry = next( 

2438 (a for a in answers if str(a.get("questionId")) == question_id), None 

2439 ) 

2440 if entry is None: 

2441 raise HTTPException( 

2442 status_code=404, detail="That question is not part of this submission." 

2443 ) 

2444 

2445 if entry.get("questionType") not in MANUALLY_MARKED_TYPES: 

2446 # Reweighting a multiple-choice question is what update_question_points is 

2447 # for. Awarding it arbitrary points here would leave `isCorrect` saying one 

2448 # thing and the score saying another. 

2449 raise HTTPException( 

2450 status_code=400, 

2451 detail="Only written responses are marked by hand.", 

2452 ) 

2453 

2454 questions = submission.get("questions") or [] 

2455 points_by_question = { 

2456 str(q.get("_id")): (q.get("points", 0) or 0) for q in questions 

2457 } 

2458 max_points = points_by_question.get(question_id, 0) 

2459 if points < 0 or points > max_points: 

2460 raise HTTPException( 

2461 status_code=400, 

2462 detail=f"Award between 0 and {max_points} points for this question.", 

2463 ) 

2464 

2465 entry["earnedPoints"] = points 

2466 # Full marks is the only thing that counts as "correct". Partial credit keeps 

2467 # isCorrect False and still adds to the score, exactly as automatic partial 

2468 # credit already behaves for multi-part questions. 

2469 entry["isCorrect"] = points >= max_points and max_points > 0 

2470 entry.pop("needsMarking", None) 

2471 entry["markedBy"] = teacher_id 

2472 entry["markedAt"] = datetime.now(timezone.utc) 

2473 if feedback is not None: 

2474 entry["markFeedback"] = feedback 

2475 

2476 totals = self._recount_marked_submission(answers, points_by_question) 

2477 

2478 update: dict = { 

2479 "last_student_answers": answers, 

2480 "total_score": totals["total_score"], 

2481 "grade": totals["grade"], 

2482 "total_correct_answers": totals["correct_count"], 

2483 "pending_marking_count": totals["pending_count"], 

2484 "pending_marking_points": totals["pending_points"], 

2485 } 

2486 if "pre_penalty_grade" in submission: 

2487 update["pre_penalty_grade"] = totals["grade"] 

2488 # A submission still awaiting late-submission approval keeps its own remarks — 

2489 # "pending_review" is about the teacher approving the LATENESS, and marking an 

2490 # answer must not quietly resolve that. 

2491 if submission.get("review_status") not in ("pending", "rejected"): 

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

2493 {"_id": assignment_oid} 

2494 ) 

2495 passing_grade = (assignment or {}).get("passing_grade", 75) 

2496 update["remarks"] = ( 

2497 "passed" if totals["grade"] >= passing_grade else "failed" 

2498 ) 

2499 

2500 await db["submission_collection"].update_one( 

2501 {"_id": submission["_id"]}, {"$set": update} 

2502 ) 

2503 

2504 return { 

2505 "submission_id": str(submission["_id"]), 

2506 "questionId": question_id, 

2507 "earnedPoints": points, 

2508 "maxPoints": max_points, 

2509 "isCorrect": entry["isCorrect"], 

2510 "feedback": entry.get("markFeedback"), 

2511 "grade": totals["grade"], 

2512 "totalScore": totals["total_score"], 

2513 "pendingMarkingCount": totals["pending_count"], 

2514 "markedAt": entry["markedAt"].isoformat(), 

2515 } 

2516 

2517 @staticmethod 

2518 def _recount_marked_submission(answers: list, points_by_question: dict) -> dict: 

2519 """Total a submission from its answers, ignoring anything not yet marked. 

2520 

2521 Held questions are left out of the score AND out of the total it is divided by, 

2522 which is what makes a partly-marked submission report the grade of the work that 

2523 has been marked rather than a low grade that later moves on its own. 

2524 """ 

2525 total_score = 0.0 

2526 correct_count = 0 

2527 pending_points = 0.0 

2528 pending_count = 0 

2529 

2530 for answer in answers: 

2531 question_points = ( 

2532 points_by_question.get(str(answer.get("questionId")), 0) or 0 

2533 ) 

2534 if answer.get("needsMarking"): 

2535 pending_points += question_points 

2536 pending_count += 1 

2537 continue 

2538 total_score += answer.get("earnedPoints", 0) or 0 

2539 if answer.get("isCorrect"): 

2540 correct_count += 1 

2541 

2542 markable = sum(points_by_question.values()) - pending_points 

2543 return { 

2544 "total_score": total_score, 

2545 "grade": (total_score / markable * 100) if markable > 0 else 0, 

2546 "correct_count": correct_count, 

2547 "pending_points": pending_points, 

2548 "pending_count": pending_count, 

2549 } 

2550 

2551 async def update_question_points( 

2552 self, assignment_uuid: str, question_id: str, points: float, request: Request 

2553 ) -> dict: 

2554 """ 

2555 Override the point value of one question within a teacher's assignment. 

2556 

2557 This edits ONLY assignments_collection.question_points_overrides — the 

2558 underlying question document in teacher_questionbank / global_questionbank 

2559 (the "main" question record) is never touched, so the same question used 

2560 in other assignments is unaffected. 

2561 

2562 Args: 

2563 assignment_uuid (str): The assignment's ObjectId string. 

2564 question_id (str): The question's ObjectId string (must be one of 

2565 this assignment's questions). 

2566 points (float): New point value for this question, within this 

2567 assignment only (>= 0). 

2568 request (Request): FastAPI request carrying the authenticated teacher's details. 

2569 

2570 Returns: 

2571 dict: {"assignment_id": str, "questionId": str, "points": float, "updatedAt": str (ISO 8601)}. 

2572 

2573 Raises: 

2574 HTTPException: 

2575 - 400: If `assignment_uuid` / `question_id` is not a valid ObjectId. 

2576 - 404: If the assignment is not owned by the teacher (or is 

2577 deleted), or the question is not part of this assignment. 

2578 """ 

2579 if not ObjectId.is_valid(assignment_uuid): 

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

2581 if not ObjectId.is_valid(question_id): 

2582 raise HTTPException(status_code=400, detail="Invalid question ID format") 

2583 

2584 teacher_id = str(request.state.user_details["uuid"]) 

2585 assignment_oid = ObjectId(assignment_uuid) 

2586 

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

2588 { 

2589 "_id": assignment_oid, 

2590 "created_by": teacher_id, 

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

2592 } 

2593 ) 

2594 if not assignment: 

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

2596 

2597 question_ids = self._assignment_question_ids(assignment) 

2598 if ObjectId(question_id) not in question_ids: 

2599 raise HTTPException( 

2600 status_code=404, detail="Question not found in this assignment." 

2601 ) 

2602 

2603 now = datetime.now(timezone.utc) 

2604 await db["assignments_collection"].update_one( 

2605 {"_id": assignment_oid}, 

2606 { 

2607 "$set": { 

2608 f"question_points_overrides.{question_id}": points, 

2609 "updated_at": now, 

2610 "updated_by": teacher_id, 

2611 } 

2612 }, 

2613 ) 

2614 

2615 recounted = await self._recount_submissions_for_question( 

2616 assignment_oid, 

2617 question_id, 

2618 points, 

2619 float(assignment.get("passing_grade") or 0), 

2620 ) 

2621 

2622 return { 

2623 "assignment_id": assignment_uuid, 

2624 "questionId": question_id, 

2625 "points": points, 

2626 "updatedAt": now.isoformat(), 

2627 "submissionsRecounted": recounted, 

2628 } 

2629 

2630 async def _recount_submissions_for_question( 

2631 self, 

2632 assignment_oid: ObjectId, 

2633 question_id: str, 

2634 new_points: float, 

2635 passing_grade: float, 

2636 ) -> int: 

2637 """ 

2638 Re-score every already-graded submission for this assignment after a 

2639 teacher edits one question's per-assignment point value, so 

2640 total_score / grade / pass-fail stay correct immediately instead of 

2641 going stale until some other regrade event touches the document. 

2642 

2643 Only the edited question's contribution changes — every other 

2644 question's earned points are left untouched. Only fully-submitted 

2645 attempts (the same `last_submitted_answers` + `last_student_answers` 

2646 pair analytics_student_submission_fetch treats as "graded") are 

2647 recounted; in-progress attempts have no stored score to correct — they 

2648 are scored live on every read and already pick up the latest points. 

2649 

2650 Args: 

2651 assignment_oid (ObjectId): The assignment's _id. 

2652 question_id (str): The edited question's id (string form, as 

2653 stored in submission.questions[]._id and 

2654 last_student_answers[].questionId). 

2655 new_points (float): The new point value for this question. 

2656 passing_grade (float): The assignment's passing_grade, used to 

2657 recompute `remarks` ("passed"/"failed") for the new grade — 

2658 same convention as approve_late_submission. 

2659 

2660 Returns: 

2661 int: Number of submissions whose stored score/grade changed. 

2662 """ 

2663 submissions = ( 

2664 await db["submission_collection"] 

2665 .find({"assignment_id": assignment_oid}) 

2666 .to_list(length=None) 

2667 ) 

2668 

2669 updated_count = 0 

2670 for sub in submissions: 

2671 last_submitted = sub.get("last_submitted_answers") 

2672 last_student_ans = sub.get("last_student_answers") 

2673 if not last_submitted or last_student_ans is None: 

2674 continue # not a graded attempt — nothing stored to recount 

2675 

2676 questions = sub.get("questions", []) 

2677 old_points = None 

2678 for q in questions: 

2679 if str(q.get("_id")) == question_id: 

2680 old_points = q.get("points", 0) or 0 

2681 break 

2682 if old_points is None: 

2683 continue # this question isn't part of this submission's snapshot 

2684 

2685 answer_entry = next( 

2686 ( 

2687 a 

2688 for a in last_student_ans 

2689 if str(a.get("questionId")) == question_id 

2690 ), 

2691 None, 

2692 ) 

2693 if answer_entry is None: 

2694 continue 

2695 

2696 is_correct = bool(answer_entry.get("isCorrect")) 

2697 old_earned = answer_entry.get("earnedPoints", 0) or 0 

2698 new_earned = new_points if is_correct else 0 

2699 

2700 if old_points == new_points: 

2701 continue # no actual change for this submission 

2702 

2703 total_points_old = sum((q.get("points", 0) or 0) for q in questions) 

2704 total_points_new = total_points_old - old_points + new_points 

2705 

2706 old_total_score = sub.get("total_score", 0) or 0 

2707 new_total_score = old_total_score - old_earned + new_earned 

2708 new_grade = ( 

2709 (new_total_score / total_points_new * 100) if total_points_new else 0 

2710 ) 

2711 new_remarks = "passed" if new_grade >= passing_grade else "failed" 

2712 

2713 # Keep the per-question snapshot and per-answer earnedPoints 

2714 # internally consistent with the new aggregate numbers, not just 

2715 # total_score/grade in isolation. 

2716 for q in questions: 

2717 if str(q.get("_id")) == question_id: 

2718 q["points"] = new_points 

2719 break 

2720 for a in last_student_ans: 

2721 if str(a.get("questionId")) == question_id: 

2722 a["earnedPoints"] = new_earned 

2723 break 

2724 

2725 recount: dict = { 

2726 "questions": questions, 

2727 "last_student_answers": last_student_ans, 

2728 "total_score": new_total_score, 

2729 "grade": new_grade, 

2730 "remarks": new_remarks, 

2731 } 

2732 # A late submission's grade is NOT `grade` — approve_late_submission 

2733 # recomputes it as `pre_penalty_grade - late_penalty`, and prefers 

2734 # pre_penalty_grade whenever the key exists. Writing only `grade` here left 

2735 # that stale, so recounting a pending late submission and THEN approving it 

2736 # silently threw the recount away: the approval overwrote `grade` with a 

2737 # number derived from the point value the teacher had just changed. The 

2738 # order a teacher happens to click in should not decide whether their edit 

2739 # survives. 

2740 if "pre_penalty_grade" in sub: 

2741 recount["pre_penalty_grade"] = new_grade 

2742 

2743 await db["submission_collection"].update_one( 

2744 {"_id": sub["_id"]}, {"$set": recount} 

2745 ) 

2746 updated_count += 1 

2747 

2748 return updated_count 

2749 

2750 async def update( 

2751 self, 

2752 assignment_uuid: str, 

2753 updated_assignment: UpdateAssignment, 

2754 request: Request, 

2755 ): 

2756 """ 

2757 Update details of an existing assignment. 

2758 

2759 Args: 

2760 assignment_uuid (str): Unique identifier of the assignment. 

2761 updated_assignment (UpdateAssignment): Updated assignment details. 

2762 request (Request): The incoming request object containing teacher context. 

2763 

2764 Returns: 

2765 dict: Updated assignment details and success message. 

2766 

2767 Raises: 

2768 HTTPException: If assignment not found or update fails. 

2769 """ 

2770 # Modified by Allan Ninal — a malformed id 500'd. ObjectId() raises 

2771 # bson.errors.InvalidId for anything that is not 24 hex chars, and the only 

2772 # handler below is a bare `except Exception` that turns it into a 500 

2773 # "Internal Server Error". A client sending a bad id gets a server-fault 

2774 # status for what is plainly a bad request, and every occurrence is logged 

2775 # as an unhandled exception. Guarded before the try, matching the sibling 

2776 # student service (student_assignment.py:305). 

2777 if not ObjectId.is_valid(assignment_uuid): 

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

2779 

2780 try: 

2781 teacher_id = str(request.state.user_details["uuid"]) 

2782 

2783 assignment_oid = ObjectId(assignment_uuid) 

2784 

2785 # Set audit fields 

2786 updated_assignment.updated_by = teacher_id 

2787 updated_assignment.updated_at = datetime.now(timezone.utc) 

2788 

2789 # Ensure assignment is not marked as deleted 

2790 fetched_assignment = await db["assignments_collection"].find_one( 

2791 { 

2792 "_id": assignment_oid, 

2793 "created_by": teacher_id, 

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

2795 } 

2796 ) 

2797 if not fetched_assignment: 

2798 raise HTTPException( 

2799 status_code=404, 

2800 detail="Assignment not found or already marked as deleted", 

2801 ) 

2802 

2803 # Convert to dictionary and exclude unset values 

2804 update_data = updated_assignment.model_dump(exclude_unset=True) 

2805 

2806 # Added by Allan Ninal — 2026-10-03. Same rule the common update route has 

2807 # had since 2026-09-24: CREATE refuses date_close < date_open (422) but this 

2808 # route accepted it (200, measured on QA 0.0.0.425), and an inverted range 

2809 # fails the document's own validation on load. Resolve each date against 

2810 # the stored one, since an update may send only one of them. 

2811 if dates_inverted(update_data, fetched_assignment): 

2812 raise HTTPException( 

2813 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

2814 detail="date_open must be before date_close", 

2815 ) 

2816 

2817 # EI-3437 (Allan Ninal, 2026-10-03): a reused global assignment keeps its title and questions. 

2818 if is_reused_global_copy(fetched_assignment) and changes_title_or_questions( 

2819 update_data, fetched_assignment 

2820 ): 

2821 raise HTTPException( 

2822 status_code=status.HTTP_403_FORBIDDEN, 

2823 detail=REUSED_GLOBAL_LOCKED_DETAIL, 

2824 ) 

2825 # A resent, unchanged title/questions is a no-op: keep the stored values. 

2826 if is_reused_global_copy(fetched_assignment): 

2827 update_data.pop("title", None) 

2828 update_data.pop("questions", None) 

2829 

2830 # Only the settings keys sent change (see utilities/assignment_update.py). 

2831 set_settings_per_key(update_data) 

2832 

2833 # Perform update 

2834 result = await db["assignments_collection"].update_one( 

2835 {"_id": assignment_oid, "created_by": teacher_id}, {"$set": update_data} 

2836 ) 

2837 

2838 # Check if the document was updated 

2839 if result.modified_count == 0: 

2840 raise HTTPException( 

2841 status_code=404, 

2842 detail="Assignment not found or the teacher has no access to this assignment", 

2843 ) 

2844 

2845 # Fetch updated assignment 

2846 updated_assignment_doc = await db["assignments_collection"].find_one( 

2847 {"_id": assignment_oid} 

2848 ) 

2849 if not updated_assignment_doc: 

2850 raise HTTPException( 

2851 status_code=404, detail="Assignment not found after update" 

2852 ) 

2853 

2854 updated_assignment_dict = { 

2855 "_id": str(updated_assignment_doc["_id"]), 

2856 **UpdateAssignment(**updated_assignment_doc).model_dump(), 

2857 } 

2858 

2859 return { 

2860 "detail": "Successfully updated assignment", 

2861 "updated_assignment": serialized_response_object( 

2862 updated_assignment_dict 

2863 ), 

2864 } 

2865 

2866 except HTTPException as e: 

2867 raise e 

2868 except Exception as exc: 

2869 # `as e` with e never read: the cause was discarded and replaced by a bare 

2870 # "Internal Server Error", so a failed delete left nothing to diagnose it 

2871 # with. Chained instead, which keeps the response identical and puts the 

2872 # original traceback in the log. 

2873 raise HTTPException( 

2874 status_code=500, 

2875 detail="Internal Server Error", 

2876 ) from exc 

2877 

2878 async def delete(self, assignment_uuid: str, request: Request): 

2879 """ 

2880 Soft delete a specific assignment by setting 'deleted' field to True. 

2881 

2882 Args: 

2883 assignment_uuid (str): Unique identifier of the assignment to delete. 

2884 request (Request): The incoming request object containing teacher context. 

2885 

2886 Returns: 

2887 dict: Soft deletion confirmation message. 

2888 

2889 Raises: 

2890 HTTPException: If assignment not found or deletion unauthorized. 

2891 """ 

2892 # Modified by Allan Ninal — same malformed-id 500 as update() above; verified 

2893 # against QA (DELETE with a non-ObjectId returned 500, not 400). 

2894 if not ObjectId.is_valid(assignment_uuid): 

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

2896 

2897 try: 

2898 teacher_id = str(request.state.user_details["uuid"]) 

2899 assignment_oid = ObjectId(assignment_uuid) 

2900 

2901 # Attempt to update the document in one query 

2902 result = await db["assignments_collection"].update_one( 

2903 { 

2904 "_id": assignment_oid, 

2905 "created_by": teacher_id, 

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

2907 }, 

2908 { 

2909 "$set": { 

2910 "deleted": True, 

2911 "deleted_at": datetime.now(timezone.utc), 

2912 "deleted_by": teacher_id, 

2913 "category": "trashed", 

2914 } 

2915 }, 

2916 ) 

2917 

2918 if result.matched_count == 0: 

2919 raise HTTPException( 

2920 status_code=404, 

2921 detail="Assignment not found or already marked as deleted", 

2922 ) 

2923 

2924 return { 

2925 "detail": "Assignment marked as deleted successfully", 

2926 "assignment_id": assignment_uuid, 

2927 } 

2928 

2929 except HTTPException: 

2930 raise 

2931 except Exception as e: 

2932 raise HTTPException(status_code=500, detail=safe_detail(e)) 

2933 

2934 # ----------------------------------------------------------------------- 

2935 # EI-1210: Late-submission teacher-approval methods 

2936 # ----------------------------------------------------------------------- 

2937 

2938 async def list_pending_late_submissions( 

2939 self, assignment_uuid: str, request: Request 

2940 ) -> dict: 

2941 """ 

2942 List all late submissions that are pending teacher review for a given assignment. 

2943 

2944 GET /v1/teacher/assignment/{assignment_uuid}/late-submissions/pending/fetch 

2945 

2946 Ownership: the assignment must have been created by the requesting teacher. 

2947 Returns 404 (not 403) when the assignment is not owned — matches the analytics convention. 

2948 Returns 400 for an invalid ObjectId. 

2949 """ 

2950 if not ObjectId.is_valid(assignment_uuid): 

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

2952 

2953 teacher_id = _teacher_id_from_request(request) 

2954 assignment_oid = ObjectId(assignment_uuid) 

2955 

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

2957 { 

2958 "_id": assignment_oid, 

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

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

2961 } 

2962 ) 

2963 if not assignment: 

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

2965 

2966 pending_submissions = ( 

2967 await db["submission_collection"] 

2968 .find( 

2969 { 

2970 "assignment_id": assignment_oid, 

2971 "review_status": "pending", 

2972 } 

2973 ) 

2974 .to_list(length=None) 

2975 ) 

2976 

2977 # Batch-join student names in ONE query instead of a find_one per row 

2978 # (EI-1210 review fix: the per-submission lookup was an N+1). 

2979 student_ids = [ 

2980 s["student_id"] for s in pending_submissions if s.get("student_id") 

2981 ] 

2982 name_map: dict = {} 

2983 if student_ids: 

2984 # Modified by Allan Ninal — 2026-09-24 (EI-T73). 

2985 # WAS: db["users"] — a collection that does not exist. Users live in 

2986 # `user_collection`: the Auth0 reconciliation worker creates and 

2987 # soft-deletes real rows there, the identity-sync worker reads it, 

2988 # and so does common/users.py. `db["users"]` appeared exactly ONCE in 

2989 # the whole codebase, right here. 

2990 # EFFECT: name_map was always empty, so every pending late submission 

2991 # came back with "student_name": null. No error, no log — the 

2992 # endpoint answered 200 with the student silently unidentified, which 

2993 # is precisely what EI-T73 asks for ("each identified by its 

2994 # submission identifier and student"). 

2995 user_docs = ( 

2996 await db["user_collection"] 

2997 .find( 

2998 {"_id": {"$in": student_ids}}, 

2999 {"first_name": 1, "last_name": 1}, 

3000 ) 

3001 .to_list(length=None) 

3002 ) 

3003 name_map = { 

3004 u["_id"]: f"{u.get('first_name', '')} {u.get('last_name', '')}".strip() 

3005 for u in user_docs 

3006 } 

3007 

3008 pending_list = [] 

3009 for sub in pending_submissions: 

3010 student_id = sub.get("student_id") 

3011 student_name = name_map.get(student_id) if student_id else None 

3012 

3013 pending_list.append( 

3014 { 

3015 "submission_id": str(sub["_id"]), 

3016 "student_id": str(student_id) if student_id else None, 

3017 "student_name": student_name, 

3018 "date_submitted": ( 

3019 sub.get("date_submitted").isoformat() 

3020 if sub.get("date_submitted") 

3021 else None 

3022 ), 

3023 "pre_penalty_grade": sub.get("pre_penalty_grade"), 

3024 "total_score": sub.get("total_score"), 

3025 } 

3026 ) 

3027 

3028 return { 

3029 "assignment_uuid": assignment_uuid, 

3030 "pending": pending_list, 

3031 } 

3032 

3033 async def approve_late_submission( 

3034 self, 

3035 submission_id: str, 

3036 late_penalty: int, 

3037 request: Request, 

3038 ) -> dict: 

3039 """ 

3040 Approve a pending late submission, applying an optional late penalty. 

3041 

3042 POST /v1/teacher/assignment/late-submission/{submission_id}/approve 

3043 

3044 Ownership: resolved via submission -> assignment -> created_by check. 

3045 Returns 400 for invalid ObjectId, 404 if not owned, 409 if not pending. 

3046 """ 

3047 if not ObjectId.is_valid(submission_id): 

3048 raise HTTPException(status_code=400, detail="Invalid submission ID format") 

3049 

3050 teacher_id = _teacher_id_from_request(request) 

3051 submission_oid = ObjectId(submission_id) 

3052 

3053 sub = await db["submission_collection"].find_one({"_id": submission_oid}) 

3054 if not sub: 

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

3056 

3057 assignment_id = sub.get("assignment_id") 

3058 if not assignment_id: 

3059 raise HTTPException( 

3060 status_code=404, detail="Submission has no associated assignment." 

3061 ) 

3062 

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

3064 { 

3065 "_id": assignment_id, 

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

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

3068 } 

3069 ) 

3070 if not assignment: 

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

3072 

3073 pre_penalty_grade = sub.get("pre_penalty_grade", sub.get("grade", 0)) or 0 

3074 final_grade = max(0.0, float(pre_penalty_grade) - float(late_penalty)) 

3075 passing_grade = float(assignment.get("passing_grade", 75)) 

3076 final_remarks = "passed" if final_grade >= passing_grade else "failed" 

3077 

3078 now_utc = datetime.now(timezone.utc) 

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

3080 {"_id": submission_oid, "review_status": "pending"}, 

3081 { 

3082 "$set": { 

3083 "review_status": "approved", 

3084 "late_penalty": late_penalty, 

3085 "grade": final_grade, 

3086 "remarks": final_remarks, 

3087 "reviewed_by": teacher_id, 

3088 "reviewed_at": now_utc, 

3089 } 

3090 }, 

3091 ) 

3092 

3093 if result.matched_count == 0: 

3094 raise HTTPException( 

3095 status_code=409, detail="Submission is not pending review." 

3096 ) 

3097 

3098 return { 

3099 "submission_id": submission_id, 

3100 "review_status": "approved", 

3101 "grade": final_grade, 

3102 "late_penalty": late_penalty, 

3103 } 

3104 

3105 async def reject_late_submission( 

3106 self, 

3107 submission_id: str, 

3108 reason: str | None, 

3109 request: Request, 

3110 ) -> dict: 

3111 """ 

3112 Reject a pending late submission. 

3113 

3114 POST /v1/teacher/assignment/late-submission/{submission_id}/reject 

3115 

3116 Ownership: resolved via submission -> assignment -> created_by check. 

3117 Returns 400 for invalid ObjectId, 404 if not owned, 409 if not pending. 

3118 """ 

3119 if not ObjectId.is_valid(submission_id): 

3120 raise HTTPException(status_code=400, detail="Invalid submission ID format") 

3121 

3122 teacher_id = _teacher_id_from_request(request) 

3123 submission_oid = ObjectId(submission_id) 

3124 

3125 sub = await db["submission_collection"].find_one({"_id": submission_oid}) 

3126 if not sub: 

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

3128 

3129 assignment_id = sub.get("assignment_id") 

3130 if not assignment_id: 

3131 raise HTTPException( 

3132 status_code=404, detail="Submission has no associated assignment." 

3133 ) 

3134 

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

3136 { 

3137 "_id": assignment_id, 

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

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

3140 } 

3141 ) 

3142 if not assignment: 

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

3144 

3145 # EI-3448: `reason` is shown verbatim to the STUDENT in their own submission 

3146 # remarks (unlike the teacher-only comment field) — a plain-text rejection 

3147 # reason (LateRejectRequest.reason, a MUI TextField), so strip_html rather 

3148 # than sanitize_rich_text, same reasoning as EI-3446/EI-3447. A pure-markup 

3149 # reason (e.g. only a <script> tag) collapses to "" after stripping — treat 

3150 # that the same as no reason at all, not a blank remarks value. 

3151 sanitized_reason = strip_html(reason) if reason else None 

3152 remarks_value = sanitized_reason if sanitized_reason else "rejected" 

3153 now_utc = datetime.now(timezone.utc) 

3154 # EI-1210 review fix: rejecting frees the consumed attempt so the student 

3155 # is not stranded (with the default allowed_attempts=1 a rejection would 

3156 # otherwise permanently lock them out via the attempt-cap guard). Clearing 

3157 # is_submitted + decrementing total_attempts lets them fix and resubmit; 

3158 # review_status stays "rejected" (shown as "Rejected") until they do. 

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

3160 {"_id": submission_oid, "review_status": "pending"}, 

3161 { 

3162 "$set": { 

3163 "review_status": "rejected", 

3164 "remarks": remarks_value, 

3165 "reviewed_by": teacher_id, 

3166 "reviewed_at": now_utc, 

3167 "is_submitted": False, 

3168 }, 

3169 "$inc": {"total_attempts": -1}, 

3170 }, 

3171 ) 

3172 

3173 if result.matched_count == 0: 

3174 raise HTTPException( 

3175 status_code=409, detail="Submission is not pending review." 

3176 ) 

3177 

3178 return { 

3179 "submission_id": submission_id, 

3180 "review_status": "rejected", 

3181 } 

3182 

3183 async def submission_fetch(self, submission_id: str, request: Request): 

3184 try: 

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

3186 

3187 # Fetch the submission by submission_id and student_id 

3188 submission = await Submission.find_one( 

3189 {"_id": ObjectId(submission_id), "student_id": student_id} 

3190 ) 

3191 

3192 if not submission: 

3193 raise HTTPException( 

3194 status_code=404, 

3195 detail="Submission not found or the student has no access to this submission", 

3196 ) 

3197 

3198 # Fetch the assignment using the assignment_id from the submission 

3199 fetched_assignment = await Assignment.find( 

3200 {"_id": ObjectId(submission.assignment_id)} 

3201 ).to_list() 

3202 

3203 if not fetched_assignment: 

3204 raise HTTPException( 

3205 status_code=404, 

3206 detail="Assignment not found or the student has no access to this assignment", 

3207 ) 

3208 

3209 fetched_assignment = fetched_assignment[0] 

3210 

3211 # Fetch all questions related to the assignment 

3212 all_questions = ( 

3213 await db["question_collection"] 

3214 .find({"_id": {"$in": fetched_assignment.question_ids}}) 

3215 .to_list(None) 

3216 ) 

3217 

3218 fetched_assignment = fetched_assignment.model_dump() 

3219 fetched_assignment["submission"] = submission.model_dump() 

3220 

3221 del fetched_assignment["question_ids"] 

3222 del fetched_assignment["submission_ids"] 

3223 all_questions = model_parser.parse_response( 

3224 all_questions, exclude_dates=True 

3225 ) 

3226 

3227 fetched_assignment["questions"] = all_questions 

3228 return {"Assignment": fetched_assignment} 

3229 

3230 except HTTPException: 

3231 # Let deliberate HTTP errors through; without this the method's own 

3232 # 400/403/404 was swallowed by the catch-all and re-thrown as a 500, 

3233 # which the frontend renders as a maintenance dialog. 

3234 raise 

3235 except Exception as e: 

3236 raise HTTPException(status_code=500, detail=safe_detail(e))