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
« 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)
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
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)
49def _id_match_forms(raw_id) -> list:
50 """The same id in both shapes Mongo may be holding it in.
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.
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
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)
73def _question_object_ids(assignment) -> list:
74 """ObjectIds of an assignment's questions.
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)
86class AssignmentsService:
87 """
88 Service class for managing assignment-related operations.
90 Handles creation, retrieval, updating, and deletion of assignments,
91 as well as submission and sharing functionality.
92 """
94 def __init__(self):
95 pass
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))
105 async def assignment_fetch(self, assignment_uuid: str, request: Request) -> dict:
106 """
107 Retrieve a specific assignment with its questions.
109 Args:
110 assignment_uuid (str): Unique identifier of the assignment
111 request (Request): The incoming request object containing user context
113 Returns:
114 dict: Assignment details including associated questions
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()
124 if not fetched_assignment:
125 raise HTTPException(status_code=404, detail="Assignment not found")
127 fetched_assignment = fetched_assignment[0]
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)
145 fetched_assignment = fetched_assignment.model_dump(mode="json")
146 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions))
148 fetched_assignment["questions"] = all_questions
149 return {"Assignment": fetched_assignment}
151 except HTTPException:
152 raise
153 except Exception as e:
154 raise HTTPException(status_code=500, detail=safe_detail(e))
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.
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
174 Returns:
175 dict: Next question details
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")
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")
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 )
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")
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 )
267 if new_question:
268 # Add the new question to the assignment
269 fetched_assignment.questions.append(
270 QuestionModel(id=new_question["_id"])
271 )
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
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))
288 async def assignment_answer_update(
289 self, submission: Submission, request: Request
290 ) -> dict:
291 """
292 Record a student's submission for an assignment.
294 Args:
295 submission (Submission): Student's submission details
296 request (Request): The incoming request object containing student context
298 Returns:
299 dict: Submission confirmation and details
301 Raises:
302 HTTPException: If assignment not found or submission fails
303 """
304 assignment_uuid = submission.assignment_id
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")
315 try:
316 student_id = to_user_id(request.state.user_details["uuid"])
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()
323 if not fetched_assignment:
324 raise HTTPException(status_code=404, detail="Assignment not found")
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()
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))
351 async def assignment_share(self, share_request: ShareRequest, request: Request):
352 """
353 Share an assignment with other users.
355 Args:
356 share_request (ShareRequest): Sharing details
357 request (Request): The incoming request object containing teacher context
359 Returns:
360 dict: Share confirmation and details
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))
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.
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.
390 Args:
391 assignment_uuid (str): Unique identifier of the assignment to analyze
392 request (Request): The incoming request object containing teacher authentication details
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
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
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 )
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 )
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 )
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 )
456 if not submission_docs:
457 return self._get_empty_analytics()
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)
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))
479 def _get_empty_analytics(self) -> dict:
480 """
481 Generate a template of zeroed analytics when no submissions exist.
483 This helper method provides a consistent response structure even when
484 there are no submissions to analyze, avoiding null values in the response.
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 }
500 def _calculate_analytics(self, submission_scores: list[float]) -> dict:
501 """
502 Calculate comprehensive statistical analytics for a set of submission scores.
504 This method performs detailed statistical calculations including basic statistics
505 and advanced distribution metrics using linear interpolation for percentiles.
507 Args:
508 submission_scores (list[float]): Pre-sorted list of submission scores
510 Returns:
511 dict: Calculated statistics with all values converted to strings and rounded
512 to 2 decimal places where appropriate
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)
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
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)
539 def calculate_percentile(percentile_value: float) -> float:
540 """
541 Calculate exact percentile using linear interpolation method.
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
548 Args:
549 percentile_value (float): Desired percentile (0-100)
551 Returns:
552 float: Interpolated value at specified percentile
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")
560 # Handle edge case for 100th percentile
561 if percentile_value == 100:
562 return submission_scores[-1]
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)
569 # Return exact value if position is an integer
570 if lower_index == upper_index:
571 return submission_scores[lower_index]
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 )
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 }
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.
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
607 Returns:
608 dict: Updated assignment details and success message
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 )
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 )
646 # Update assignment details
647 update_data = updated_assignment.model_dump(exclude_unset=True)
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 )
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)
686 # Only the settings keys sent change (see utilities/assignment_update.py).
687 set_settings_per_key(update_data)
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)
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
724 async def assignment_delete(self, assignment_uuid: str, request: Request):
725 """
726 Delete a specific assignment.
728 Args:
729 assignment_uuid (str): Unique identifier of the assignment to delete
730 request (Request): The incoming request object containing teacher context
732 Returns:
733 dict: Deletion confirmation message
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")
745 try:
746 teacher_id = to_user_id(request.state.user_details["uuid"])
747 assignment = await Assignment.find_one({"_id": ObjectId(assignment_uuid)})
749 if not assignment:
750 raise HTTPException(status_code=404, detail="Assignment not found")
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"}
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))
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.
779 Args:
780 submission_id (str): Unique identifier of the submission
781 request (Request): The incoming request object containing user context
783 Returns:
784 dict: Submission details
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"])
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 )
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 )
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()
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 )
814 fetched_assignment = fetched_assignment[0]
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)
832 fetched_assignment = fetched_assignment.model_dump(mode="json")
833 fetched_assignment["submission"] = submission.model_dump(mode="json")
835 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions))
837 fetched_assignment["questions"] = all_questions
838 return {"Assignment": fetched_assignment}
840 except HTTPException:
841 raise
842 except Exception as e:
843 raise HTTPException(status_code=500, detail=safe_detail(e))
845 async def create_new_assignment(self, new_assigment: Assignment, request: Request):
846 """
847 Create a new assignment for a teacher.
849 Args:
850 new_assigment (Assignment): Assignment details to be created
851 request (Request): The incoming request object containing teacher context
853 Returns:
854 dict: Created assignment details and success message
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))
899 async def assignment_view_fetch(self, assignment_uuid: str, request: Request):
900 """
901 Retrieve a specific assignment with its questions.
903 Args:
904 assignment_uuid (str): Unique identifier of the assignment
905 request (Request): The incoming request object containing user context
907 Returns:
908 dict: Assignment details including associated questions
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")
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()
944 if not fetched_assignment:
945 raise HTTPException(status_code=404, detail="Assignment not found")
947 fetched_assignment = fetched_assignment[0]
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)
965 fetched_assignment = fetched_assignment.model_dump(mode="json")
966 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions))
968 fetched_assignment["questions"] = all_questions
969 return {"Assignment": fetched_assignment}
971 except HTTPException:
972 raise
973 except Exception as e:
974 raise HTTPException(status_code=500, detail=safe_detail(e))
976 async def assignment_review_fetch(self, submission_id: str, request: Request):
977 """Retrieve one submission for review.
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).
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")
996 try:
997 caller_uuid = request.state.user_details["uuid"]
998 role = (request.state.user_details.get("role") or "").lower()
1000 submission = await Submission.find_one({"_id": ObjectId(submission_id)})
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 )
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 )
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 )
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()
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 )
1062 fetched_assignment = fetched_assignment[0]
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)
1080 fetched_assignment = fetched_assignment.model_dump(mode="json")
1081 fetched_assignment["submission"] = submission.model_dump(mode="json")
1083 all_questions = json.loads(model_parser.JSONEncoder().encode(all_questions))
1085 fetched_assignment["questions"] = all_questions
1086 return {"Assignment": fetched_assignment}
1088 except HTTPException:
1089 raise
1090 except Exception as e:
1091 raise HTTPException(status_code=500, detail=safe_detail(e))