Coverage for server / services / common / assignments.py: 97%

292 statements  

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

1import json 

2import math 

3from datetime import datetime, timezone 

4from bson.objectid import ObjectId 

5from fastapi import HTTPException, Request, status 

6from server.connection.database import db 

7from server.models.assignment import ( 

8 MAX_ASSIGNMENT_QUESTIONS, 

9 Assignment, 

10 QuestionModel, 

11 Submission, 

12 UpdateAssignment, 

13) 

14 

15# from server.models.question import Question 

16from server.models.sharerequests import ShareRequest 

17from server.services.common.question_bank import ( 

18 pick_adaptive_question, 

19 question_object_ids, 

20 resolve_assignment_questions, 

21) 

22from server.utilities.assignment_update import ( 

23 REUSED_GLOBAL_LOCKED_DETAIL, 

24 changes_title_or_questions, 

25 dates_inverted, 

26 is_reused_global_copy, 

27 set_settings_per_key, 

28) 

29from server.utilities import model_parser 

30 

31# _created_by_values lives beside the assignment-quota logic that first needed 

32# it. Imported rather than duplicated: the string-or-ObjectId ambiguity it 

33# handles is a property of the stored data, and two copies of that rule would 

34# drift. 

35from server.services.growthbook.teacher_assignment_quota import _created_by_values 

36from server.utilities.user_id_helper import to_user_id 

37from server.services.growthbook.teacher_assignment_quota import ( 

38 _teacher_id_from_request, 

39 enforce_teacher_assignment_quota, 

40) 

41from server.utilities.error_detail import safe_detail 

42from pymongo.errors import DuplicateKeyError 

43from server.utilities.assignment_dedupe import ( 

44 compute_dedupe_key, 

45 resolve_duplicate_create, 

46) 

47 

48 

49def _id_match_forms(raw_id) -> list: 

50 """The same id in both shapes Mongo may be holding it in. 

51 

52 Ownership columns in this database are inconsistent by history: `created_by` 

53 is a string on most assignments and an ObjectId on thousands of older ones, 

54 and `student_id` is an ObjectId on every submission written by the student 

55 submit path while the shared answer service writes it as a string. A filter 

56 that matches only one shape silently misses the other. 

57 

58 Developer: Allan Ninal — 2026-09-23 (EI-T117) 

59 """ 

60 forms = [str(raw_id)] 

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

62 forms.append(ObjectId(str(raw_id))) 

63 return forms 

64 

65 

66def _matches_caller(stored_id, caller_uuid) -> bool: 

67 """True when a stored owner id refers to the caller, in either shape.""" 

68 if stored_id is None: 

69 return False 

70 return str(stored_id) == str(caller_uuid) 

71 

72 

73def _question_object_ids(assignment) -> list: 

74 """ObjectIds of an assignment's questions. 

75 

76 Modified by Allan Ninal — 2026-09-23 

77 WHAT: delegates to server/services/common/question_bank.question_object_ids. 

78 WHY: the same extraction had grown a third copy (here, in the teacher service 

79 and in the student service). One implementation now, so a shape the 

80 model allows cannot be handled in one place and silently dropped in 

81 another. Kept under its old name because callers and tests import it. 

82 """ 

83 return question_object_ids(assignment) 

84 

85 

86class AssignmentsService: 

87 """ 

88 Service class for managing assignment-related operations. 

89 

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

91 as well as submission and sharing functionality. 

92 """ 

93 

94 def __init__(self): 

95 pass 

96 

97 async def assignment_create(self, submission: Submission, request: Request) -> dict: 

98 """Create a new assignment submission""" 

99 try: 

100 result = await submission.save() 

101 return {"submission": result} 

102 except Exception as e: 

103 raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) 

104 

105 async def assignment_fetch(self, assignment_uuid: str, request: Request) -> dict: 

106 """ 

107 Retrieve a specific assignment with its questions. 

108 

109 Args: 

110 assignment_uuid (str): Unique identifier of the assignment 

111 request (Request): The incoming request object containing user context 

112 

113 Returns: 

114 dict: Assignment details including associated questions 

115 

116 Raises: 

117 HTTPException: If assignment not found or retrieval fails 

118 """ 

119 try: 

120 fetched_assignment = await Assignment.find( 

121 {"_id": ObjectId(assignment_uuid)} 

122 ).to_list() 

123 

124 if not fetched_assignment: 

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

126 

127 fetched_assignment = fetched_assignment[0] 

128 

129 # Fetch all questions related to the assignment 

130 # Modified by Allan Ninal — 2026-09-23 

131 # WHAT: resolve from the LIVE banks (teacher_questionbank, then the 

132 # admin-staff global_questionbank) and shape the rows here, 

133 # instead of reading db["question_collection"] and passing the 

134 # result to model_parser.parse_response. 

135 # WHY: two dead dependencies stacked. (1) `question_collection` exists 

136 # in NO database on this cluster, so this answered 200 with 

137 # "questions": [] for every assignment, however many it held — 

138 # verified live on QA. (2) parse_response reads 

139 # res["question_type"] and expects the legacy schema, which a 

140 # real teacher_questionbank document does not carry, so pointing 

141 # (1) at the live bank alone would have traded the empty list for 

142 # KeyError -> 500. Both halves had to go together. 

143 all_questions = await resolve_assignment_questions(fetched_assignment) 

144 

145 fetched_assignment = fetched_assignment.model_dump(mode="json") 

146 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions)) 

147 

148 fetched_assignment["questions"] = all_questions 

149 return {"Assignment": fetched_assignment} 

150 

151 except HTTPException: 

152 raise 

153 except Exception as e: 

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

155 

156 async def assignment_adaptive_fetch( 

157 self, 

158 request: Request, 

159 assignment_uuid: str, 

160 prev_difficulty: str, 

161 prev_remarks: str, 

162 question_classification: str, 

163 ): 

164 """ 

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

166 

167 Args: 

168 request (Request): The incoming request object 

169 assignment_uuid (str): Unique identifier of the assignment 

170 prev_difficulty (str): Difficulty of previous question 

171 prev_remarks (str): Performance remarks on previous question 

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

173 

174 Returns: 

175 dict: Next question details 

176 

177 Raises: 

178 HTTPException(400): malformed assignment id, an unreachable difficulty 

179 rung, an assignment already at MAX_ASSIGNMENT_QUESTIONS, or no 

180 question matching the requested classification 

181 HTTPException(404): assignment not found 

182 """ 

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

184 # WHAT: reject a malformed id before it reaches ObjectId(). 

185 # WHY: ObjectId("invalid-uuid-@@@") raises bson.errors.InvalidId, which the 

186 # catch-all below re-raised as 500 str(e) — a server-fault status, plus 

187 # the raw exception text, for what is simply a bad request. 

188 if not ObjectId.is_valid(assignment_uuid): 

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

190 

191 try: 

192 # Retrieve the assignment 

193 fetched_assignment = await Assignment.find_one( 

194 {"_id": ObjectId(assignment_uuid)} 

195 ) 

196 if not fetched_assignment: 

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

198 

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

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

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

202 # written happily and then break `validate_questions` on every LOAD — 

203 # bricking the whole assignment, not just this call. 

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

205 raise HTTPException( 

206 status_code=400, 

207 detail=( 

208 "maximum number of questions allowed is " 

209 f"{MAX_ASSIGNMENT_QUESTIONS}" 

210 ), 

211 ) 

212 

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

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

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

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

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

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

219 # Rungs must be real stored values (Easy / Average / Advance); 

220 # "hard" matched zero questions and 400'd the student. 

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

222 new_difficulty = "average" 

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

224 new_difficulty = "easy" 

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

226 new_difficulty = "easy" 

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

228 new_difficulty = "advance" 

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

230 new_difficulty = "average" 

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

232 new_difficulty = "advance" 

233 else: 

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

235 

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

237 # WHAT: read and append `questions`; the exclusion list goes through 

238 # _question_object_ids (already used by assignment_view_fetch). 

239 # WHY: `question_ids` was renamed to `questions` on 2025-03-25 (dc62c45) 

240 # and this path was missed, so EVERY call — happy path included — 

241 # raised AttributeError into the catch-all and answered 500. The 

242 # endpoint has been dead since. The appended item keeps the 

243 # {id, category, topic} shape: a bare id string is allowed by the 

244 # model but breaks consumers that do q["id"] 

245 # (server/services/teacher/teacher_assignment.py, item analysis). 

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

247 # WHAT: pick from the REAL banks — the assignment creator's own 

248 # questions, then the curated global bank — excluding 

249 # soft-deleted rows and anything already on the assignment. 

250 # WHY: the old picker read db["question_collection"] matching a 

251 # `classification` field. That collection exists in NO database 

252 # on this cluster and no question carries that field, so it 

253 # could never return anything: the endpoint answered 400 

254 # "Something wrong fetching a new question." for every input 

255 # (500 before PR #345). The live banks key on `assignmentType`, 

256 # which is what `question_classification` actually selects. 

257 # Scoped to the creator + global so it can never serve another 

258 # teacher's private questions (1,166 distinct authors in that 

259 # bank), and `deleted` is filtered (18,646 rows are deleted). 

260 new_question = await pick_adaptive_question( 

261 difficulty=new_difficulty, 

262 classification=question_classification, 

263 exclude_ids=_question_object_ids(fetched_assignment), 

264 creator_id=fetched_assignment.created_by, 

265 ) 

266 

267 if new_question: 

268 # Add the new question to the assignment 

269 fetched_assignment.questions.append( 

270 QuestionModel(id=new_question["_id"]) 

271 ) 

272 

273 # Update the assignment in the database 

274 await fetched_assignment.save() 

275 question_id = new_question["_id"] 

276 del new_question["_id"] 

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

278 return new_question # Return the new question 

279 

280 raise HTTPException( 

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

282 ) 

283 except HTTPException: 

284 raise 

285 except Exception as e: 

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

287 

288 async def assignment_answer_update( 

289 self, submission: Submission, request: Request 

290 ) -> dict: 

291 """ 

292 Record a student's submission for an assignment. 

293 

294 Args: 

295 submission (Submission): Student's submission details 

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

297 

298 Returns: 

299 dict: Submission confirmation and details 

300 

301 Raises: 

302 HTTPException: If assignment not found or submission fails 

303 """ 

304 assignment_uuid = submission.assignment_id 

305 

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

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

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

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

310 # text as the detail. Same guard already used elsewhere in this 

311 # file (e.g. assignment_adaptive_fetch, assignment_view_fetch). 

312 if not ObjectId.is_valid(assignment_uuid): 

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

314 

315 try: 

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

317 

318 # check if an Assignment exist with a given assignment_uuid 

319 fetched_assignment = await Assignment.find( 

320 {"_id": ObjectId(assignment_uuid)} 

321 ).to_list() 

322 

323 if not fetched_assignment: 

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

325 

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

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

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

329 # for any ObjectId-shaped caller uuid (every real account). 

330 # Direct attribute assignment bypasses Beanie/pydantic 

331 # validation here (no validate_assignment on this model). 

332 # WHY: the twin defect to teacher_assignment.py::answer_update — 

333 # same Submission model, same route family 

334 # ("the shared answer service", POST /v1/assignments/answer). 

335 # The insert succeeds, then FastAPI's response serialisation 

336 # crashes on the un-coerced ObjectId — after the write already 

337 # committed, so the caller is told 500 for a submission that 

338 # was, in fact, recorded. 

339 submission.student_id = str(student_id) 

340 await submission.insert() 

341 

342 return { 

343 "detail": "Successfully Recorded Response", 

344 "assignment_response": submission, 

345 } 

346 except HTTPException: 

347 raise 

348 except Exception as e: 

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

350 

351 async def assignment_share(self, share_request: ShareRequest, request: Request): 

352 """ 

353 Share an assignment with other users. 

354 

355 Args: 

356 share_request (ShareRequest): Sharing details 

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

358 

359 Returns: 

360 dict: Share confirmation and details 

361 

362 Raises: 

363 HTTPException: If sharing fails 

364 """ 

365 try: 

366 # ShareRequest.sender_id is Optional[str]. to_user_id returns an ObjectId 

367 # for local-JWT ids, and save() then failed validation, so every valid 

368 # share answered 500 (Allan Ninal, 2026-10-04; found by the EI-T765 control). 

369 share_request.sender_id = str( 

370 to_user_id(request.state.user_details["uuid"]) 

371 ) 

372 share_request = await share_request.save() 

373 return { 

374 "detail": "Successfully Shared Assignment", 

375 "share_request": share_request, 

376 } 

377 except Exception as e: 

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

379 

380 async def assignment_analytics_fetch( 

381 self, assignment_uuid: str, request: Request 

382 ) -> dict: 

383 """ 

384 Get detailed analytics for a specific assignment's submissions. 

385 

386 This method performs comprehensive statistical analysis on student submission scores, 

387 including measures of central tendency (mean, median) and dispersion (range, std dev). 

388 Only the teacher who created the assignment can access these analytics. 

389 

390 Args: 

391 assignment_uuid (str): Unique identifier of the assignment to analyze 

392 request (Request): The incoming request object containing teacher authentication details 

393 

394 Returns: 

395 dict: Comprehensive analytics including: 

396 - minimum: Lowest score achieved 

397 - maximum: Highest score achieved 

398 - range: Difference between highest and lowest scores 

399 - mean: Arithmetic average of all scores 

400 - median: Middle value when scores are ordered 

401 - first_quartile: 25th percentile score 

402 - third_quartile: 75th percentile score 

403 - std_dev: Standard deviation indicating score spread 

404 

405 Raises: 

406 HTTPException(404): If assignment not found or teacher lacks access 

407 HTTPException(400): If there's a validation error in score calculations 

408 HTTPException(500): For unexpected server errors 

409 """ 

410 # EI-3286: malformed assignment_uuid (e.g. "invalid-uuid-@@@") used 

411 # to leak as 500 because `bson.errors.InvalidId` was caught by the 

412 # generic `except Exception` block below. Reject up front with a 

413 # controlled 400 + clean message — same intent as the 

414 # ObjectId-validation pattern used elsewhere in the codebase. 

415 from bson.errors import InvalidId 

416 

417 try: 

418 assignment_object_id = ObjectId(assignment_uuid) 

419 except (InvalidId, TypeError): 

420 raise HTTPException( 

421 status_code=400, 

422 detail="Invalid assignment ID format", 

423 ) 

424 

425 try: 

426 # Verify teacher access. The owner is persisted in `created_by` (the 

427 # Assignment model has no `teacher_id` field, so the previously-used 

428 # `teacher_id` filter never matched any document -> analytics 404'd for 

429 # every real assignment). `created_by` exists across historical data in 

430 # both str and ObjectId form, so match either. (EI-3279) 

431 uuid_str = request.state.user_details["uuid"] 

432 owner_ids = [uuid_str] 

433 if ObjectId.is_valid(uuid_str): 

434 owner_ids.append(ObjectId(uuid_str)) 

435 assignment = await Assignment.find_one( 

436 {"_id": assignment_object_id, "created_by": {"$in": owner_ids}} 

437 ) 

438 

439 if not assignment: 

440 raise HTTPException( 

441 status_code=404, 

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

443 ) 

444 

445 # Fetch submission scores directly (projection) rather than parsing full 

446 # Submission models: the model requires a non-empty `answers` list, but real 

447 # submissions store `submitted_answers` and have no `answers` field, so 

448 # model parsing 400'd analytics for every real assignment. We only need 

449 # total_score for the distribution stats. (EI-3279) 

450 submission_docs = ( 

451 await db["submission_collection"] 

452 .find({"assignment_id": assignment_uuid}, {"total_score": 1}) 

453 .to_list(None) 

454 ) 

455 

456 if not submission_docs: 

457 return self._get_empty_analytics() 

458 

459 # Calculate statistics. EXCLUDE submissions with a null/missing 

460 # total_score rather than coercing them to 0 (`or 0`) — an ungraded 

461 # submission counted as a real 0 distorts the mean/median/quartiles 

462 # downward. (EI-188) If none have a numeric score, fall back to empty. 

463 submission_scores = sorted( 

464 float(doc["total_score"]) 

465 for doc in submission_docs 

466 if isinstance(doc.get("total_score"), (int, float)) 

467 ) 

468 if not submission_scores: 

469 return self._get_empty_analytics() 

470 return self._calculate_analytics(submission_scores) 

471 

472 except HTTPException: 

473 raise 

474 except ValueError as validation_error: 

475 raise HTTPException(status_code=400, detail=str(validation_error)) 

476 except Exception as error: 

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

478 

479 def _get_empty_analytics(self) -> dict: 

480 """ 

481 Generate a template of zeroed analytics when no submissions exist. 

482 

483 This helper method provides a consistent response structure even when 

484 there are no submissions to analyze, avoiding null values in the response. 

485 

486 Returns: 

487 dict: Analytics template with all values set to "0" 

488 """ 

489 return { 

490 "minimum": "0", 

491 "maximum": "0", 

492 "range": "0", 

493 "mean": "0", 

494 "median": "0", 

495 "first_quartile": "0", 

496 "third_quartile": "0", 

497 "std_dev": "0", 

498 } 

499 

500 def _calculate_analytics(self, submission_scores: list[float]) -> dict: 

501 """ 

502 Calculate comprehensive statistical analytics for a set of submission scores. 

503 

504 This method performs detailed statistical calculations including basic statistics 

505 and advanced distribution metrics using linear interpolation for percentiles. 

506 

507 Args: 

508 submission_scores (list[float]): Pre-sorted list of submission scores 

509 

510 Returns: 

511 dict: Calculated statistics with all values converted to strings and rounded 

512 to 2 decimal places where appropriate 

513 

514 Implementation Details: 

515 - Basic statistics are calculated directly from the sorted scores 

516 - Standard deviation uses the population formula (not sample) 

517 - Percentiles are calculated using linear interpolation for more accurate results 

518 - All numeric results are converted to strings for consistent API response 

519 """ 

520 # Store total number of submissions for repeated use 

521 total_submissions = len(submission_scores) 

522 

523 # Calculate basic descriptive statistics 

524 lowest_score = min(submission_scores) 

525 highest_score = max(submission_scores) 

526 score_range = highest_score - lowest_score 

527 average_score = sum(submission_scores) / total_submissions 

528 

529 # Calculate population standard deviation 

530 # 1. Calculate squared differences from mean 

531 # 2. Find average of squared differences (variance) 

532 # 3. Take square root for standard deviation 

533 squared_differences_sum = sum( 

534 (score - average_score) ** 2 for score in submission_scores 

535 ) 

536 variance = squared_differences_sum / total_submissions 

537 standard_deviation = math.sqrt(variance) 

538 

539 def calculate_percentile(percentile_value: float) -> float: 

540 """ 

541 Calculate exact percentile using linear interpolation method. 

542 

543 This nested function handles percentile calculation using the following steps: 

544 1. Validates percentile value is between 0-100 

545 2. Calculates exact position in sorted array 

546 3. Interpolates between adjacent values for non-integer positions 

547 

548 Args: 

549 percentile_value (float): Desired percentile (0-100) 

550 

551 Returns: 

552 float: Interpolated value at specified percentile 

553 

554 Raises: 

555 ValueError: If percentile_value is not between 0 and 100 

556 """ 

557 if not 0 <= percentile_value <= 100: 

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

559 

560 # Handle edge case for 100th percentile 

561 if percentile_value == 100: 

562 return submission_scores[-1] 

563 

564 # Calculate exact position in the array 

565 position = (total_submissions - 1) * (percentile_value / 100) 

566 lower_index = math.floor(position) 

567 upper_index = math.ceil(position) 

568 

569 # Return exact value if position is an integer 

570 if lower_index == upper_index: 

571 return submission_scores[lower_index] 

572 

573 # Interpolate between adjacent values 

574 decimal_part = position - lower_index 

575 return ( 

576 submission_scores[lower_index] 

577 + (submission_scores[upper_index] - submission_scores[lower_index]) 

578 * decimal_part 

579 ) 

580 

581 # Return formatted results with consistent string formatting 

582 return { 

583 "minimum": str(lowest_score), 

584 "maximum": str(highest_score), 

585 "range": str(score_range), 

586 "mean": str(round(average_score, 2)), 

587 "median": str(round(calculate_percentile(50), 2)), 

588 "first_quartile": str(round(calculate_percentile(25), 2)), 

589 "third_quartile": str(round(calculate_percentile(75), 2)), 

590 "std_dev": str(round(standard_deviation, 2)), 

591 } 

592 

593 async def assignment_update( 

594 self, 

595 assignment_uuid: str, 

596 updated_assignment: UpdateAssignment, 

597 request: Request, 

598 ): 

599 """ 

600 Update details of an existing assignment. 

601 

602 Args: 

603 assignment_uuid (str): Unique identifier of the assignment 

604 updated_assignment (UpdateAssignment): Updated assignment details 

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

606 

607 Returns: 

608 dict: Updated assignment details and success message 

609 

610 Raises: 

611 HTTPException: If assignment not found or update fails 

612 """ 

613 # Added by Allan Ninal — 2026-10-03 (EI-T416). 

614 # A malformed id made ObjectId() raise bson InvalidId inside the try, and 

615 # the catch-all below answered 500 for what is simply a bad request. Same 

616 # guard as assignment_view_fetch / assignment_analytics_fetch in this file. 

617 if not ObjectId.is_valid(assignment_uuid): 

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

619 try: 

620 teacher_id = to_user_id(request.state.user_details["uuid"]) 

621 # Fetch the existing assignment 

622 # Modified by Allan Ninal — 2026-09-24 (EI-T120). 

623 # WAS: {"_id": ..., "teacher_id": teacher_id} 

624 # `teacher_id` is not a field on Assignment. The owner is `created_by` 

625 # — that is what assignment_create writes and what the other fifteen 

626 # owner filters in this service layer use. Filtering on a field the 

627 # document does not have matched NOTHING, so update answered 404 

628 # "Assignment not found or the teacher has no access to this 

629 # assignment" for the teacher's own assignment, every time. 

630 # `_id_match_forms` (this file, used the same way at the review 

631 # authorisation check) covers the string/ObjectId split that ownership 

632 # columns carry by history. 

633 existing_assignment = await db["assignments_collection"].find_one( 

634 { 

635 "_id": ObjectId(assignment_uuid), 

636 "created_by": {"$in": _id_match_forms(teacher_id)}, 

637 } 

638 ) 

639 

640 if not existing_assignment: 

641 raise HTTPException( 

642 status_code=404, 

643 detail="Assignment not found or the teacher has no access to this assignment", 

644 ) 

645 

646 # Update assignment details 

647 update_data = updated_assignment.model_dump(exclude_unset=True) 

648 

649 # Added by Allan Ninal — 2026-09-24. 

650 # `Assignment` enforces date_open < date_close in a model validator, 

651 # and CREATE honours it (422). `UpdateAssignment` carries no such 

652 # validator, so UPDATE accepted an inverted range and wrote it. The 

653 # document then failed its own validation on LOAD, which bricked the 

654 # assignment outright — measured on QA 0.0.0.374: 

655 # POST /create date_close < date_open -> 422 (correctly refused) 

656 # PUT /update date_close < date_open -> 200 "Successfully updated" 

657 # GET /view/{id} -> 500 

658 # DELETE /delete/{id} -> 500 <- not even removable 

659 # Only a further (unvalidated) update could rescue it. 

660 # 

661 # Validating the PAYLOAD alone is not enough: an update may set just 

662 # one of the two dates, so the stored value supplies the other. Both 

663 # are resolved against the merged result before anything is written. 

664 # (Modified 2026-10-03: dates_inverted() also makes the stored — naive — 

665 # date timezone-aware before comparing; comparing it with an aware 

666 # request date raised TypeError, i.e. a 500, when only one was sent.) 

667 if dates_inverted(update_data, existing_assignment): 

668 raise HTTPException( 

669 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 

670 detail="date_open must be before date_close", 

671 ) 

672 

673 # EI-3437 (Allan Ninal, 2026-10-03): a reused global assignment keeps its title and questions. 

674 if is_reused_global_copy( 

675 existing_assignment 

676 ) and changes_title_or_questions(update_data, existing_assignment): 

677 raise HTTPException( 

678 status_code=status.HTTP_403_FORBIDDEN, 

679 detail=REUSED_GLOBAL_LOCKED_DETAIL, 

680 ) 

681 # A resent, unchanged title/questions is a no-op: keep the stored values. 

682 if is_reused_global_copy(existing_assignment): 

683 update_data.pop("title", None) 

684 update_data.pop("questions", None) 

685 

686 # Only the settings keys sent change (see utilities/assignment_update.py). 

687 set_settings_per_key(update_data) 

688 

689 await db["assignments_collection"].update_one( 

690 {"_id": ObjectId(assignment_uuid)}, 

691 {"$set": update_data}, 

692 ) 

693 # Fetch the updated assignment 

694 raw_assignment = await db["assignments_collection"].find_one( 

695 {"_id": ObjectId(assignment_uuid)} 

696 ) 

697 if not raw_assignment: 

698 raise HTTPException( 

699 status_code=404, detail="Updated assignment not found" 

700 ) 

701 updated_assignment = UpdateAssignment(**raw_assignment) 

702 

703 updated_assignment_dict = { 

704 "id": str(raw_assignment["_id"]), 

705 **updated_assignment.model_dump(), 

706 } 

707 return { 

708 "detail": "Successfully updated assignment", 

709 "updated_assignment": updated_assignment_dict, 

710 } 

711 except HTTPException as e: 

712 raise e 

713 except Exception as e: 

714 # Modified by Allan Ninal — 2026-09-23 

715 # WHAT: chain the original exception (`from e`). 

716 # WHY: it was caught, discarded and replaced with a bare 500, so the 

717 # cause never reached the logs. Also what the PR lint gate flags 

718 # (F841, unused `e`) on any file this branch touches. 

719 raise HTTPException( 

720 status_code=500, 

721 detail="Internal Server Error", 

722 ) from e 

723 

724 async def assignment_delete(self, assignment_uuid: str, request: Request): 

725 """ 

726 Delete a specific assignment. 

727 

728 Args: 

729 assignment_uuid (str): Unique identifier of the assignment to delete 

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

731 

732 Returns: 

733 dict: Deletion confirmation message 

734 

735 Raises: 

736 HTTPException: If assignment not found or deletion unauthorized 

737 """ 

738 # EI-T775 / EI-T418: reject a malformed id before ObjectId() — same 

739 # guard as view / analytics / review. Without this, bson.errors.InvalidId 

740 # falls into the catch-all and answers 500 with a generic unexpected-error 

741 # detail for what is simply a bad request. 

742 if not ObjectId.is_valid(assignment_uuid): 

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

744 

745 try: 

746 teacher_id = to_user_id(request.state.user_details["uuid"]) 

747 assignment = await Assignment.find_one({"_id": ObjectId(assignment_uuid)}) 

748 

749 if not assignment: 

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

751 

752 # Modified by Allan Ninal — 2026-09-24 (EI-T121). 

753 # WAS: `assignment.teacher_id == teacher_id`. Assignment has no 

754 # `teacher_id` attribute, so this raised 

755 # AttributeError: 'Assignment' object has no attribute 'teacher_id' 

756 # into the catch-all and the endpoint answered 500 — even for the 

757 # teacher who owns the assignment. Same ghost field as the update 

758 # path above; the owner is `created_by`. 

759 if str(assignment.created_by) in { 

760 str(form) for form in _id_match_forms(teacher_id) 

761 }: 

762 await assignment.delete() 

763 return {"detail": "Assignment deleted successfully"} 

764 

765 raise HTTPException( 

766 status_code=403, detail="Not authorized to delete this assignment" 

767 ) 

768 except HTTPException: 

769 raise 

770 except Exception as e: 

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

772 

773 async def assignment_submission_fetch( 

774 self, submission_id: str, request: Request 

775 ) -> dict: 

776 """ 

777 Retrieve a specific submission with its associated assignment and questions. 

778 

779 Args: 

780 submission_id (str): Unique identifier of the submission 

781 request (Request): The incoming request object containing user context 

782 

783 Returns: 

784 dict: Submission details 

785 

786 Raises: 

787 HTTPException: If submission not found or retrieval fails 

788 """ 

789 try: 

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

791 

792 # Fetch the submission by submission_id and student_id 

793 submission = await Submission.find_one( 

794 {"_id": ObjectId(submission_id), "student_id": student_id} 

795 ) 

796 

797 if not submission: 

798 raise HTTPException( 

799 status_code=404, 

800 detail="Submission not found or the student has no access to this submission", 

801 ) 

802 

803 # Fetch the assignment using the assignment_id from the submission 

804 fetched_assignment = await Assignment.find( 

805 {"_id": ObjectId(submission.assignment_id)} 

806 ).to_list() 

807 

808 if not fetched_assignment: 

809 raise HTTPException( 

810 status_code=404, 

811 detail="Assignment not found or the student has no access to this assignment", 

812 ) 

813 

814 fetched_assignment = fetched_assignment[0] 

815 

816 # Fetch all questions related to the assignment 

817 # Modified by Allan Ninal — 2026-09-23 

818 # WHAT: resolve from the LIVE banks (teacher_questionbank, then the 

819 # admin-staff global_questionbank) and shape the rows here, 

820 # instead of reading db["question_collection"] and passing the 

821 # result to model_parser.parse_response. 

822 # WHY: two dead dependencies stacked. (1) `question_collection` exists 

823 # in NO database on this cluster, so this answered 200 with 

824 # "questions": [] for every assignment, however many it held — 

825 # verified live on QA. (2) parse_response reads 

826 # res["question_type"] and expects the legacy schema, which a 

827 # real teacher_questionbank document does not carry, so pointing 

828 # (1) at the live bank alone would have traded the empty list for 

829 # KeyError -> 500. Both halves had to go together. 

830 all_questions = await resolve_assignment_questions(fetched_assignment) 

831 

832 fetched_assignment = fetched_assignment.model_dump(mode="json") 

833 fetched_assignment["submission"] = submission.model_dump(mode="json") 

834 

835 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions)) 

836 

837 fetched_assignment["questions"] = all_questions 

838 return {"Assignment": fetched_assignment} 

839 

840 except HTTPException: 

841 raise 

842 except Exception as e: 

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

844 

845 async def create_new_assignment(self, new_assigment: Assignment, request: Request): 

846 """ 

847 Create a new assignment for a teacher. 

848 

849 Args: 

850 new_assigment (Assignment): Assignment details to be created 

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

852 

853 Returns: 

854 dict: Created assignment details and success message 

855 

856 Raises: 

857 HTTPException: If creation fails 

858 """ 

859 try: 

860 user_id = _teacher_id_from_request(request) 

861 await enforce_teacher_assignment_quota( 

862 request, new_assigment.assigned_class 

863 ) 

864 new_assigment.created_by = user_id 

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

866 # EI-3450 / EI-3451 — see server/utilities/assignment_dedupe.py. Set on the 

867 # server from the request's own fields; a client cannot supply or spoof it. 

868 new_assigment.dedupe_key = compute_dedupe_key( 

869 created_by=user_id, 

870 assigned_class=new_assigment.assigned_class, 

871 title=new_assigment.title, 

872 date_open=new_assigment.date_open, 

873 date_close=new_assigment.date_close, 

874 ) 

875 try: 

876 await new_assigment.insert() 

877 except DuplicateKeyError: 

878 # The unique index refused this insert, so an identical assignment is 

879 # already there. Within the dedupe window this is a retry or a race and 

880 # the original is returned unchanged; past it, resolve_duplicate_create 

881 # raises 409 rather than silently handing back an older assignment. 

882 existing = await resolve_duplicate_create(new_assigment.dedupe_key) 

883 return { 

884 "detail": "Successfully Created Assignment", 

885 "new_assignment": existing, 

886 } 

887 return { 

888 "detail": "Successfully Created Assignment", 

889 "new_assignment": new_assigment, 

890 } 

891 except HTTPException: 

892 # resolve_duplicate_create's 409 must reach the client as a 409. Without this 

893 # the catch-all below would relabel it 500 — the same swallow-and-mislabel 

894 # that made the original duplicate bug so hard to see. 

895 raise 

896 except Exception as e: 

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

898 

899 async def assignment_view_fetch(self, assignment_uuid: str, request: Request): 

900 """ 

901 Retrieve a specific assignment with its questions. 

902 

903 Args: 

904 assignment_uuid (str): Unique identifier of the assignment 

905 request (Request): The incoming request object containing user context 

906 

907 Returns: 

908 dict: Assignment details including associated questions 

909 

910 Raises: 

911 HTTPException: If assignment not found or retrieval fails 

912 """ 

913 # Modified by Allan Ninal — a malformed id 500'd here too (verified on QA). 

914 # ObjectId() raises bson.errors.InvalidId, and the handler below re-raises it 

915 # as a 500 whose detail is str(e) — so the client got a server-fault status 

916 # AND the raw exception text for what is simply a bad request. 

917 if not ObjectId.is_valid(assignment_uuid): 

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

919 

920 try: 

921 # EI-SEC-011 (view). This filtered on _id alone and never read 

922 # `request` for authorization, so any authenticated teacher received 

923 # any teacher's assignment in full — while the route docstring 

924 # advertised a 403 no code path could produce. update and delete on 

925 # this same router were fixed under EI-SEC-011; the read path was 

926 # missed. Same shape as EI-SEC-010: writes guarded, reads open. 

927 # 

928 # Scoping the query itself, rather than fetching then comparing, 

929 # drops a non-owner into the existing not-found branch below: no new 

930 # error path, and no way to tell another teacher's assignment apart 

931 # from one that does not exist. 

932 # 

933 # _created_by_values matches documents storing created_by as a string 

934 # AND as an ObjectId. Matching one form only would 404 the rightful 

935 # owner wherever the other form was written. 

936 caller_id = to_user_id(request.state.user_details["uuid"]) 

937 fetched_assignment = await Assignment.find( 

938 { 

939 "_id": ObjectId(assignment_uuid), 

940 "created_by": {"$in": _created_by_values(caller_id)}, 

941 } 

942 ).to_list() 

943 

944 if not fetched_assignment: 

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

946 

947 fetched_assignment = fetched_assignment[0] 

948 

949 # Fetch all questions related to the assignment 

950 # Modified by Allan Ninal — 2026-09-23 

951 # WHAT: resolve from the LIVE banks (teacher_questionbank, then the 

952 # admin-staff global_questionbank) and shape the rows here, 

953 # instead of reading db["question_collection"] and passing the 

954 # result to model_parser.parse_response. 

955 # WHY: two dead dependencies stacked. (1) `question_collection` exists 

956 # in NO database on this cluster, so this answered 200 with 

957 # "questions": [] for every assignment, however many it held — 

958 # verified live on QA. (2) parse_response reads 

959 # res["question_type"] and expects the legacy schema, which a 

960 # real teacher_questionbank document does not carry, so pointing 

961 # (1) at the live bank alone would have traded the empty list for 

962 # KeyError -> 500. Both halves had to go together. 

963 all_questions = await resolve_assignment_questions(fetched_assignment) 

964 

965 fetched_assignment = fetched_assignment.model_dump(mode="json") 

966 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions)) 

967 

968 fetched_assignment["questions"] = all_questions 

969 return {"Assignment": fetched_assignment} 

970 

971 except HTTPException: 

972 raise 

973 except Exception as e: 

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

975 

976 async def assignment_review_fetch(self, submission_id: str, request: Request): 

977 """Retrieve one submission for review. 

978 

979 A STUDENT may review their own submission. A TEACHER may review a 

980 submission for an assignment they created — the route's dependency 

981 allows `["teacher", "student"]`, but the lookup used to be scoped to 

982 `student_id == caller` for everyone, so a teacher got 404 on every 

983 submission that was not literally their own (EI-T117). 

984 

985 Both refusals answer the SAME 404 as "not found", so a caller cannot 

986 use this endpoint to discover which submission ids exist. 

987 """ 

988 # Modified by Allan Ninal — 2026-09-23 (EI-T117) 

989 # WHAT: reject a malformed submission id up front. 

990 # WHY: ObjectId() raises bson.errors.InvalidId, which the catch-all 

991 # below re-raised as 500 with the raw bson text. Same guard used 

992 # throughout these services. 

993 if not ObjectId.is_valid(submission_id): 

994 raise HTTPException(status_code=400, detail="Invalid submission ID format") 

995 

996 try: 

997 caller_uuid = request.state.user_details["uuid"] 

998 role = (request.state.user_details.get("role") or "").lower() 

999 

1000 submission = await Submission.find_one({"_id": ObjectId(submission_id)}) 

1001 

1002 if not submission: 

1003 raise HTTPException( 

1004 status_code=404, 

1005 detail="Submission not found or you do not have access to this submission", 

1006 ) 

1007 

1008 # Modified by Allan Ninal — 2026-09-23 (EI-T117) 

1009 # WHAT: authorise by ROLE instead of scoping every caller to 

1010 # `student_id == caller`. 

1011 # WHY: this route accepts teachers AND students, but the old filter 

1012 # meant a teacher could only ever read a submission whose 

1013 # student_id was their own id — i.e. never. EI-T117 ("Submission 

1014 # is retrieved for review by its submission identifier") failed 

1015 # on that 404 when run as a teacher. 

1016 # 

1017 # A teacher is authorised against the ASSIGNMENT's owner, not the 

1018 # submission's student, so this does not become "any teacher may read 

1019 # any submission" (that would trade an over-restriction for an 

1020 # OWASP API1 BOLA hole). 

1021 # 

1022 # Both ids are matched in str AND ObjectId form because both shapes 

1023 # exist in real data: `created_by` is a string on 15,166 assignments 

1024 # and an ObjectId on 3,115 (the EI-3279 lesson), and `student_id` is 

1025 # an ObjectId on every one of the 7,901 rows written by the student 

1026 # submit path while the shared answer service now writes it as a 

1027 # string (EI-T116). Matching one form only would silently miss the 

1028 # other. 

1029 if not _matches_caller(submission.student_id, caller_uuid): 

1030 if role != "teacher": 

1031 raise HTTPException( 

1032 status_code=404, 

1033 detail="Submission not found or you do not have access to this submission", 

1034 ) 

1035 

1036 assignment_id = submission.assignment_id 

1037 owned = None 

1038 if ObjectId.is_valid(str(assignment_id)): 

1039 owned = await Assignment.find_one( 

1040 { 

1041 "_id": ObjectId(str(assignment_id)), 

1042 "created_by": {"$in": _id_match_forms(caller_uuid)}, 

1043 } 

1044 ) 

1045 if not owned: 

1046 raise HTTPException( 

1047 status_code=404, 

1048 detail="Submission not found or you do not have access to this submission", 

1049 ) 

1050 

1051 # Fetch the assignment using the assignment_id from the submission 

1052 fetched_assignment = await Assignment.find( 

1053 {"_id": ObjectId(submission.assignment_id)} 

1054 ).to_list() 

1055 

1056 if not fetched_assignment: 

1057 raise HTTPException( 

1058 status_code=404, 

1059 detail="Assignment not found or the student has no access to this assignment", 

1060 ) 

1061 

1062 fetched_assignment = fetched_assignment[0] 

1063 

1064 # Fetch all questions related to the assignment 

1065 # Modified by Allan Ninal — 2026-09-23 

1066 # WHAT: resolve from the LIVE banks (teacher_questionbank, then the 

1067 # admin-staff global_questionbank) and shape the rows here, 

1068 # instead of reading db["question_collection"] and passing the 

1069 # result to model_parser.parse_response. 

1070 # WHY: two dead dependencies stacked. (1) `question_collection` exists 

1071 # in NO database on this cluster, so this answered 200 with 

1072 # "questions": [] for every assignment, however many it held — 

1073 # verified live on QA. (2) parse_response reads 

1074 # res["question_type"] and expects the legacy schema, which a 

1075 # real teacher_questionbank document does not carry, so pointing 

1076 # (1) at the live bank alone would have traded the empty list for 

1077 # KeyError -> 500. Both halves had to go together. 

1078 all_questions = await resolve_assignment_questions(fetched_assignment) 

1079 

1080 fetched_assignment = fetched_assignment.model_dump(mode="json") 

1081 fetched_assignment["submission"] = submission.model_dump(mode="json") 

1082 

1083 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions)) 

1084 

1085 fetched_assignment["questions"] = all_questions 

1086 return {"Assignment": fetched_assignment} 

1087 

1088 except HTTPException: 

1089 raise 

1090 except Exception as e: 

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