Coverage for server / services / student / student_assignment.py: 96%
665 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1from statistics import mean
2from bson.objectid import ObjectId
3from fastapi import HTTPException, Request
4from pymongo import ReturnDocument
5from pymongo.errors import DuplicateKeyError
6from server.connection.database import db
7from server.utilities.user_id_helper import to_user_id, enrollment_id_conditions
9# from server.models.question import Question
10from typing import Optional
11from server.models.assignment_submit import (
12 AssignmentSubmitRequest,
13 ViolationReportRequest,
14)
15from datetime import datetime, timezone, timedelta
16from server.utilities.helpers import serialized_response_object
17from server.utilities.gradebook import (
18 select_canonical_submission,
19 resolve_cell_grade,
20 extract_live_question_ids,
21 recalculate_submitted_grade,
22)
23from server.services.common.question_bank import fetch_questions_by_ids
24from server.services.common.answer_checking import (
25 is_answer_correct,
26 needs_manual_marking,
27 normalize_free_response_answer,
28 resolve_correct_answer,
29 score_question,
30)
31from random import shuffle
32from server.utilities.error_detail import safe_detail
33from server.utilities.graph_data_checker import student_prompt_correct_answer
36# Browser-lockdown violation threshold (see
37# docs/implementation/browser_lockdown_violation_tracking.md in the client repo).
38# Violations 1-2 are recorded normally; the 3rd (count == MAX) is both recorded
39# AND the one that tells the UI to auto-submit the attempt now — 3 is a hard cap,
40# not "one more than the last free one".
41MAX_LOCKDOWN_VIOLATIONS = 3
44# SEC-5 — the threshold is only meaningful if the SERVER refuses further work.
45#
46# Reaching MAX_LOCKDOWN_VIOLATIONS used to do nothing but set `auto_submit` in a
47# response body, i.e. it *asked the UI* to submit. A student who ignored that ask
48# — devtools, a replayed request, a blocked JS handler — could keep saving
49# answers and re-fetching questions indefinitely, so the lockdown was advisory
50# and trivially bypassed by exactly the population it exists to constrain.
51#
52# Deliberately NOT applied to answers/submit: the whole point of the threshold is
53# that the attempt gets submitted, and gating submit too would strand the
54# student's work in an attempt they could neither continue nor close.
55NOT_OPEN_YET = "This assignment is not open yet."
58def _assert_assignment_open(assignment: dict | None) -> None:
59 """Raise 403 while the assignment's ``date_open`` is still in the future.
61 Added by Allan Ninal — 2026-10-03 (EI-3293).
62 WHAT: nothing on the server compared ``date_open`` with the clock — only the
63 student app hid a not-yet-open assignment ("Not Yet Available"), so a
64 student calling the API directly could fetch its questions, save and
65 submit before it opened. ``date_close`` was already enforced.
66 WHY: the open date is part of the assessment's integrity, so it is enforced
67 where the questions are served and answers are written: questions
68 fetch, answers save and answers submit. The assignment list and its
69 details stay readable, so the app can still show when it opens.
70 A missing ``date_open`` means "open"; a naive datetime is UTC, as for
71 ``date_close``.
72 """
73 date_open = (assignment or {}).get("date_open")
74 if not date_open:
75 return
76 if date_open.tzinfo is None:
77 date_open = date_open.replace(tzinfo=timezone.utc)
78 if datetime.now(timezone.utc) < date_open:
79 raise HTTPException(status_code=403, detail=NOT_OPEN_YET)
82def _assert_lockdown_not_breached(submission: dict) -> None:
83 """Raise 403 when this attempt has already breached the lockdown threshold.
85 Callers must pass an UNSUBMITTED attempt; once `is_submitted` is true the
86 attempt is closed by its own guards and reading it back (results, review) is
87 legitimate.
88 """
89 if submission.get("violation_count", 0) >= MAX_LOCKDOWN_VIOLATIONS:
90 raise HTTPException(
91 status_code=403,
92 detail=(
93 "This attempt was closed after reaching the maximum number of "
94 "browser-lockdown violations and can no longer be edited. "
95 "Submit the attempt or contact your teacher."
96 ),
97 )
100# The violation `type` strings the client (useAssessmentLockdown.js) actually
101# sends. Used to bucket the per-type breakdown on the submission document —
102# anything outside this set (a stale client build, a hand-crafted request) is
103# grouped under "unknown" rather than trusted verbatim as a Mongo field name:
104# `type` is client-controlled, and `$inc`-ing a field built from raw untrusted
105# input (`f"violation_counts.{type}"`) would let a dotted value target an
106# arbitrary nested path under violation_counts. Update this set when a new
107# violation type is added client-side, or it will only ever show up as "unknown"
108# in the breakdown.
109KNOWN_LOCKDOWN_VIOLATION_TYPES = frozenset(
110 {
111 "fullscreen_exit",
112 "tab_hidden",
113 "window_blur",
114 "clipboard_blocked",
115 "blocked_shortcut",
116 }
117)
120def _normalize_violation_type(raw_type: Optional[str]) -> str:
121 """Maps a client-reported violation type to a safe, bounded breakdown key."""
122 return raw_type if raw_type in KNOWN_LOCKDOWN_VIOLATION_TYPES else "unknown"
125# Defensive bound on the cross-DB question-bank $in fan-out. Far above the
126# 100-question model cap, so it only ever guards a corrupt/abusive id set.
127_MAX_QUESTION_FETCH = 500
130async def random_question_fetch(difficulty, classification, assignment_questions):
131 """
132 Fetch a random question matching difficulty and classification criteria.
134 Args:
135 difficulty (str): Difficulty level of the question
136 classification (str): Classification/category of the question
137 assignment_questions (list): List of question IDs already in the assignment
139 Returns:
140 dict | None: Random question that matches criteria and isn't in assignment,
141 or None if no matching question found
142 """
143 pipeline = [
144 {
145 "$match": {
146 "difficulty": difficulty.title(),
147 "classification": classification,
148 }
149 },
150 {"$sample": {"size": 20}},
151 ]
152 result = await db["question_collection"].aggregate(pipeline).to_list(length=None)
153 if not result:
154 return None
156 for question in result:
157 if question["_id"] not in assignment_questions:
158 return question
160 return None
163def to_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
164 """Ensure MongoDB datetime is UTC-aware."""
165 if not dt:
166 return None
167 return (
168 dt.replace(tzinfo=timezone.utc)
169 if dt.tzinfo is None
170 else dt.astimezone(timezone.utc)
171 )
174def format_grade(value) -> str:
175 try:
176 num = float(value)
177 if num.is_integer():
178 return str(int(num))
179 return f"{num:.2f}"
180 except (TypeError, ValueError):
181 return "0"
184class StudentAssignmentsService:
185 """
186 Service class for managing assignment-related operations.
188 Handles creation, retrieval, updating, and deletion of assignments,
189 as well as submission and sharing functionality.
190 """
192 def __init__(self):
193 pass
195 async def assignments_fetch(self, class_code: str, request: Request) -> dict:
196 user_id = to_user_id(request.state.user_details["uuid"])
197 email = request.state.user_details.get("email")
198 now = datetime.now(timezone.utc)
200 # Step 1: Validate class
201 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students.
202 class_doc = await db["class_collection"].find_one(
203 {"class_code": class_code, "deleted": {"$ne": True}},
204 {"_id": 1, "students._id": 1, "students.email": 1},
205 )
207 if not class_doc:
208 return {
209 "message": "No assignments available for this class.",
210 "assignments": [],
211 }
213 # Step 2: Validate enrollment. Match by _id OR email (same fallback
214 # class_statistics_fetch/all_classes_fetch use) — a duplicate/stale
215 # user document sharing this student's email can end up as the _id on
216 # a class's enrollment record instead of the live account's.
217 if not any(
218 student["_id"] == user_id or (email and student.get("email") == email)
219 for student in class_doc.get("students", [])
220 ):
221 return {
222 "message": "No assignments available for this class.",
223 "assignments": [],
224 }
226 class_id = class_doc["_id"]
228 # Step 3: Fetch assignments
229 assignments = (
230 await db["assignments_collection"]
231 .aggregate(
232 [
233 {
234 "$match": {
235 "assigned_class": {"$in": [class_id]},
236 "deleted": {"$ne": True},
237 }
238 },
239 {
240 "$project": {
241 "_id": 1,
242 "assigned_class": 1,
243 "title": 1,
244 "questions": 1,
245 "description": 1,
246 "due_date": 1,
247 "date_open": 1,
248 "date_close": 1,
249 "type": 1,
250 "category": 1,
251 "created_at": 1,
252 "instructions": 1,
253 "rubric": 1,
254 }
255 },
256 ]
257 )
258 .to_list(length=None)
259 )
261 if not assignments:
262 return {"message": "Assignments fetched successfully", "assignments": []}
264 assignment_ids = [a["_id"] for a in assignments]
266 # Step 4: Fetch submissions. Read the fields the shared resolver needs so
267 # this list and the teacher gradebook agree on which attempt to show and
268 # how to render it (EI-1195).
269 submissions = (
270 await db["submission_collection"]
271 .find(
272 {"student_id": user_id, "assignment_id": {"$in": assignment_ids}},
273 {
274 "assignment_id": 1,
275 "grade": 1,
276 "is_submitted": 1,
277 "review_status": 1,
278 "date_submitted": 1,
279 "date_updated": 1,
280 "date_created": 1,
281 # Needed only to recalculate the grade against the assignment's
282 # current question list — see recalculate_submitted_grade.
283 "questions": 1,
284 "last_student_answers": 1,
285 },
286 )
287 .to_list(length=None)
288 )
290 # EI-1195: group duplicate submissions per assignment, then pick ONE
291 # canonical doc — identical selection to the teacher gradebook.
292 submissions_by_assignment: dict = {}
293 for sub in submissions:
294 submissions_by_assignment.setdefault(sub["assignment_id"], []).append(sub)
296 # Step 5: Apply grade rules via the shared resolver so a graded assignment
297 # shows the same number here as in the gradebook, and ungraded states
298 # (missed / incomplete / pending) match too.
299 for assignment in assignments:
300 assignment_id = assignment["_id"]
301 canonical = select_canonical_submission(
302 submissions_by_assignment.get(assignment_id, [])
303 )
304 # A question the teacher removed after grading must stop
305 # contributing to (or costing) the score shown here too — same
306 # recalculation the gradebook and class average apply, so this list
307 # can't drift from either (EI-1195).
308 if canonical is not None:
309 recalculated_grade = recalculate_submitted_grade(
310 canonical,
311 extract_live_question_ids(assignment.get("questions", [])),
312 )
313 if recalculated_grade is not None:
314 canonical = {**canonical, "grade": recalculated_grade}
315 resolved = resolve_cell_grade(canonical, assignment.get("date_close"), now)
317 if resolved["grade"] is not None:
318 assignment["grade"] = format_grade(resolved["grade"])
319 else:
320 assignment["grade"] = resolved["status"]
322 return {
323 "message": "Assignments fetched successfully",
324 "assignments": serialized_response_object(assignments),
325 }
327 async def class_grade_average_fetch(
328 self, class_code: str, request: Request
329 ) -> dict:
330 """
331 Compute the student's total average grade across all assignments in a
332 specific class. Mirrors `assignments_fetch` (same enrollment check and the
333 same shared `select_canonical_submission`/`resolve_cell_grade` resolution
334 used by the teacher gradebook) so this average agrees with the per-assignment
335 grades shown in the class assignment list and the teacher's gradebook (EI-1195).
337 The response lists every assignment in the class with its resolved grade
338 (or None) and status label, but only assignments resolved as numerically
339 "graded" contribute to the `average`/`total_graded` — missed/incomplete/
340 pending/rejected assignments are excluded from the average, same as the
341 teacher gradebook's per-student average.
342 """
343 user_id = to_user_id(request.state.user_details["uuid"])
344 email = request.state.user_details.get("email")
345 now = datetime.now(timezone.utc)
347 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students.
348 class_doc = await db["class_collection"].find_one(
349 {"class_code": class_code, "deleted": {"$ne": True}},
350 {"_id": 1, "students._id": 1, "students.email": 1},
351 )
353 # Modified by Allan Ninal — 2026-10-03 (Zephyr EI-T709).
354 # WAS: 200 "No assignments available for this class." with a null average,
355 # for an unknown class code AND for a class the student is not enrolled in
356 # — indistinguishable from a real class with nothing graded yet. Both are
357 # now 404 with the same detail, so the answer does not reveal whether a
358 # class exists that the caller is not in. The SPA's rejected reducer
359 # (StudentClassGrade.js) already resets to the same empty state.
360 if not class_doc:
361 raise HTTPException(status_code=404, detail="Class not found.")
363 if not any(
364 student["_id"] == user_id or (email and student.get("email") == email)
365 for student in class_doc.get("students", [])
366 ):
367 raise HTTPException(status_code=404, detail="Class not found.")
369 class_id = class_doc["_id"]
371 assignments = (
372 await db["assignments_collection"]
373 .find(
374 {"assigned_class": {"$in": [class_id]}, "deleted": {"$ne": True}},
375 # "questions" (the assignment's CURRENT refs) is needed to recalculate
376 # a submission's grade against a question the teacher may have since
377 # removed — see _recalculate_submitted_grade.
378 {"_id": 1, "title": 1, "type": 1, "date_close": 1, "questions": 1},
379 )
380 .to_list(length=None)
381 )
383 if not assignments:
384 return {
385 "message": "Grade average fetched successfully",
386 "average": None,
387 "total_graded": 0,
388 "total_assignments": 0,
389 "status_counts": {
390 "graded": 0,
391 "missed": 0,
392 "incomplete": 0,
393 "pending": 0,
394 "rejected": 0,
395 },
396 "assignments": [],
397 }
399 assignment_ids = [a["_id"] for a in assignments]
401 submissions = (
402 await db["submission_collection"]
403 .find(
404 {"student_id": user_id, "assignment_id": {"$in": assignment_ids}},
405 {
406 "assignment_id": 1,
407 "grade": 1,
408 "is_submitted": 1,
409 "review_status": 1,
410 "date_submitted": 1,
411 "date_updated": 1,
412 "date_created": 1,
413 # Needed only to recalculate the grade against the assignment's
414 # current question list — see _recalculate_submitted_grade.
415 "questions": 1,
416 "last_student_answers": 1,
417 },
418 )
419 .to_list(length=None)
420 )
422 submissions_by_assignment: dict = {}
423 for sub in submissions:
424 submissions_by_assignment.setdefault(sub["assignment_id"], []).append(sub)
426 grade_values = []
427 assignment_summaries = []
428 for assignment in assignments:
429 canonical = select_canonical_submission(
430 submissions_by_assignment.get(assignment["_id"], [])
431 )
432 # A question the teacher removed after grading must stop
433 # contributing to (or costing) the score shown here — recalculate
434 # against the assignment's current questions before resolving the
435 # display grade, rather than trusting the stored `grade` verbatim.
436 recalculated_grade = (
437 recalculate_submitted_grade(
438 canonical,
439 extract_live_question_ids(assignment.get("questions", [])),
440 )
441 if canonical
442 else None
443 )
444 if canonical and recalculated_grade is not None:
445 canonical = {**canonical, "grade": recalculated_grade}
446 resolved = resolve_cell_grade(canonical, assignment.get("date_close"), now)
447 if resolved["grade"] is not None:
448 grade_values.append(resolved["grade"])
450 # status label mirrors the shared resolver's states, and the class
451 # assignment list's Grade column, so a non-numeric entry here always
452 # explains why that assignment was left out of the average:
453 # "graded" | "missed" | "incomplete" | "pending" | "rejected"
454 assignment_summaries.append(
455 {
456 "assignment_id": str(assignment["_id"]),
457 "title": assignment.get("title"),
458 "type": assignment.get("type"),
459 "date_close": (
460 assignment.get("date_close").isoformat()
461 if assignment.get("date_close")
462 else None
463 ),
464 "grade": (
465 round(resolved["grade"], 2)
466 if resolved["grade"] is not None
467 else None
468 ),
469 "status": resolved["status"],
470 }
471 )
473 average_grade = round(mean(grade_values), 2) if grade_values else None
475 # Precomputed so the frontend can render a status breakdown chart without
476 # re-deriving counts from `assignments` itself.
477 status_counts = {
478 "graded": 0,
479 "missed": 0,
480 "incomplete": 0,
481 "pending": 0,
482 "rejected": 0,
483 }
484 for summary in assignment_summaries:
485 status_counts[summary["status"]] = (
486 status_counts.get(summary["status"], 0) + 1
487 )
489 return {
490 "message": "Grade average fetched successfully",
491 "average": average_grade,
492 "total_graded": len(grade_values),
493 "total_assignments": len(assignments),
494 "status_counts": status_counts,
495 "assignments": assignment_summaries,
496 }
498 async def fetch_specific_assignment(
499 self, assignment_uuid: str, request: Request
500 ) -> dict:
501 """
502 Fetch a specific assignment for a student if the student belongs to any of the assignment's assigned classes.
503 Now returns only the total number of questions instead of the full question details.
504 Also includes remaining attempts and time already used, based on the student's submission history.
505 """
506 if not ObjectId.is_valid(assignment_uuid):
507 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
509 assignment_id = ObjectId(assignment_uuid)
510 student_id = to_user_id(request.state.user_details.get("uuid"))
511 email = request.state.user_details.get("email")
513 try:
514 # Fetch assignment
515 base_assignment = await db["assignments_collection"].find_one(
516 {"_id": assignment_id, "deleted": {"$ne": True}}
517 )
519 if not base_assignment:
520 raise HTTPException(status_code=404, detail="Assignment not found")
522 # Check if student belongs to any assigned class
523 assigned_class_ids = base_assignment.get("assigned_class", [])
524 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students.
525 class_with_student = await db["class_collection"].find_one(
526 {
527 "_id": {"$in": assigned_class_ids},
528 "deleted": {"$ne": True},
529 "students": {
530 "$elemMatch": {
531 "$or": enrollment_id_conditions(student_id, email)
532 }
533 },
534 }
535 )
537 if not class_with_student:
538 raise HTTPException(
539 status_code=403, detail="You do not have access to this assignment"
540 )
542 # Aggregate total question count
543 pipeline = [
544 {"$match": {"_id": assignment_id}},
545 {
546 "$addFields": {
547 "_id": {"$toString": "$_id"},
548 "totalQuestions": {
549 "$cond": {
550 "if": {"$isArray": "$questions"},
551 "then": {"$size": "$questions"},
552 "else": 0,
553 }
554 },
555 }
556 },
557 {"$project": {"questions": 0}},
558 ]
559 result = (
560 await db["assignments_collection"].aggregate(pipeline).to_list(length=1)
561 )
563 if not result:
564 raise HTTPException(
565 status_code=404, detail="Assignment data aggregation failed"
566 )
568 assignment_data = result[0]
569 total_questions = assignment_data.pop("totalQuestions", 0)
571 # Fetch existing submission
572 submission = await db["submission_collection"].find_one(
573 {"assignment_id": assignment_id, "student_id": student_id}
574 )
576 allowed_attempts = base_assignment.get("settings", {}).get(
577 "allowed_attempts", 1
578 )
580 if submission:
581 used_attempts = submission.get("total_attempts", 0)
582 remaining_attempts = max(allowed_attempts - used_attempts, 0)
583 time_used = self._format_duration_hhmmss(
584 self._elapsed_seconds(submission)
585 )
586 else:
587 remaining_attempts = allowed_attempts
588 time_used = self._format_duration_hhmmss(0)
590 return {
591 "assignment": {
592 "details": serialized_response_object(assignment_data),
593 "totalQuestions": total_questions,
594 "remainingAttempts": remaining_attempts,
595 "timeUsed": time_used,
596 }
597 }
599 except HTTPException:
600 raise
601 except Exception as e:
602 raise HTTPException(
603 status_code=500, detail=safe_detail(e, "An unexpected error occurred")
604 )
606 async def fetch_assignment_questions(
607 self, assignment_uuid: str, request: Request
608 ) -> dict:
609 """
610 Fetches and returns the questions and related information for a specific assignment based on the student's request.
611 Args:
612 assignment_uuid (str): The UUID of the assignment to fetch.
613 request (Request): The FastAPI request object containing user details.
614 Returns:
615 dict: A dictionary containing assignment details, student answers, and remaining time.
616 Raises:
617 HTTPException: If the assignment ID is invalid, the assignment is not found,
618 the student is not enrolled, or time/attempt limits are exceeded.
619 """
620 try:
621 assignment_id = self._validate_object_id(
622 assignment_uuid, "Invalid assignment ID format"
623 )
624 student_id = to_user_id(request.state.user_details.get("uuid"))
625 email = request.state.user_details.get("email")
627 assignment = await self._get_assignment(assignment_id)
628 await self._verify_enrollment(assignment, student_id, email)
629 _assert_assignment_open(assignment)
631 settings = assignment.get("settings", {})
632 shuffle_questions = settings.get("shuffle_questions", False)
633 shuffle_choices = settings.get("shuffle_choices", False)
634 allowed_attempts = settings.get("allowed_attempts", 1)
635 time_allowed_value = settings.get("time_allowed")
636 allowed_duration = (
637 self._parse_time(time_allowed_value) if time_allowed_value else None
638 )
640 now_utc = datetime.now(timezone.utc)
641 submission = await db["submission_collection"].find_one(
642 {"assignment_id": assignment_id, "student_id": student_id}
643 )
645 if submission:
646 # SEC-5: refuse to hand back the question set for an attempt that
647 # already breached the lockdown threshold — otherwise a student who
648 # ignored the auto-submit could simply reload and carry on.
649 # Scoped to unsubmitted attempts so reviewing a finished one still
650 # works.
651 if not submission.get("is_submitted", False):
652 _assert_lockdown_not_breached(submission)
654 # Check if submission's questions match assignment's questions count
655 assignment_question_count = len(assignment.get("questions", []))
656 submission_question_count = len(submission.get("questions", []))
658 # Re-sync a still-in-progress attempt whenever its stored question
659 # count no longer matches the assignment's current one — either
660 # direction. A teacher can add a question after the student started
661 # (the original bug this handled) or REMOVE one, and a removed
662 # question must stop being served just as much as a missing one
663 # must start being served. A submitted/graded attempt is never
664 # touched here — its snapshot is historical record, not a live view.
665 if (
666 submission_question_count != assignment_question_count
667 and not submission.get("is_submitted", False)
668 ):
669 submission = await self._resync_submission_questions(
670 submission,
671 assignment,
672 student_id,
673 shuffle_questions,
674 shuffle_choices,
675 now_utc,
676 )
678 return await self._handle_existing_submission(
679 submission, allowed_attempts, allowed_duration, now_utc, assignment
680 )
681 else:
682 return await self._create_new_submission(
683 assignment,
684 assignment_id,
685 student_id,
686 shuffle_questions,
687 shuffle_choices,
688 allowed_duration,
689 now_utc,
690 )
692 except HTTPException:
693 raise
694 except Exception as e:
695 raise HTTPException(
696 status_code=500, detail=safe_detail(e, "An unexpected error occurred")
697 )
699 def _validate_object_id(self, value: str, error_msg: str) -> ObjectId:
700 """
701 Validates and converts a string to an ObjectId.
702 Args:
703 value (str): The string to convert.
704 error_msg (str): The error message to raise in case of invalid ID.
705 Returns:
706 ObjectId: A valid ObjectId instance.
707 Raises:
708 HTTPException: If the provided ID string is invalid.
709 """
710 if not ObjectId.is_valid(value):
711 raise HTTPException(status_code=400, detail=error_msg)
712 return ObjectId(value)
714 async def _get_assignment(self, assignment_id: ObjectId) -> dict:
715 """
716 Retrieves the assignment document from the database.
717 Args:
718 assignment_id (ObjectId): The ID of the assignment to retrieve.
719 Returns:
720 dict: The assignment document.
721 Raises:
722 HTTPException: If the assignment is not found or is marked as deleted.
723 """
724 assignment = await db["assignments_collection"].find_one(
725 {"_id": assignment_id, "deleted": {"$ne": True}}
726 )
727 if not assignment:
728 raise HTTPException(status_code=404, detail="Assignment not found")
729 return assignment
731 async def _verify_enrollment(
732 self, assignment: dict, student_id: ObjectId, email: str = None
733 ):
734 """
735 Verifies that the student is enrolled in one of the classes assigned to the assignment.
736 Args:
737 assignment (dict): The assignment document.
738 student_id (ObjectId): The ID of the student.
739 email (str, optional): The student's email, used as a fallback match — some
740 enrollment records carry a duplicate/stale account _id instead of the
741 live account's, so relying on _id alone can miss a real enrollment.
742 Raises:
743 HTTPException: If the student is not enrolled in the assigned classes.
744 """
745 class_ids = assignment.get("assigned_class", [])
746 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students.
747 is_enrolled = await db["class_collection"].find_one(
748 {
749 "_id": {"$in": class_ids},
750 "deleted": {"$ne": True},
751 "students": {
752 "$elemMatch": {"$or": enrollment_id_conditions(student_id, email)}
753 },
754 }
755 )
756 if not is_enrolled:
757 raise HTTPException(
758 status_code=403, detail="You do not have access to this assignment"
759 )
761 async def _has_live_enrolled_class(
762 self, assignment_id: ObjectId, student_id, email: str = None
763 ) -> bool:
764 """True when the assignment exists and one of its assigned classes is a live
765 (not soft-deleted) class the student belongs to."""
766 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students.
767 assignment = await db["assignments_collection"].find_one(
768 {"_id": assignment_id}, {"assigned_class": 1}
769 )
770 if not assignment:
771 return False
772 live_class = await db["class_collection"].find_one(
773 {
774 "_id": {"$in": assignment.get("assigned_class") or []},
775 "deleted": {"$ne": True},
776 "students": {
777 "$elemMatch": {"$or": enrollment_id_conditions(student_id, email)}
778 },
779 },
780 {"_id": 1},
781 )
782 return live_class is not None
784 def _parse_time(self, time_str: str) -> timedelta:
785 """
786 Parses a time string (HH:MM:SS) into a timedelta object.
787 Args:
788 time_str (str): The time string to parse.
789 Returns:
790 timedelta: The parsed duration.
791 """
792 h, m, s = map(int, time_str.split(":"))
793 return timedelta(hours=h, minutes=m, seconds=s)
795 def _format_duration_hhmmss(self, total_seconds: int) -> str:
796 """
797 Formats a duration in seconds as an "HH:MM:SS" string.
798 Args:
799 total_seconds (int): The duration in seconds.
800 Returns:
801 str: The formatted duration.
802 """
803 total_seconds = max(int(total_seconds), 0)
804 return "{:02}:{:02}:{:02}".format(
805 total_seconds // 3600, (total_seconds % 3600) // 60, total_seconds % 60
806 )
808 def _elapsed_seconds(self, submission: dict) -> int:
809 """
810 Computes how long a student has spent on a submission: from when the
811 attempt started (date_created) to when it was submitted (date_submitted),
812 or to now if the attempt is still in progress.
813 Args:
814 submission (dict): The submission document.
815 Returns:
816 int: Elapsed time in seconds, floored at 0.
817 """
818 date_created = submission.get("date_created")
819 if not date_created:
820 return 0
822 if date_created.tzinfo is None:
823 date_created = date_created.replace(tzinfo=timezone.utc)
825 end_time = submission.get("date_submitted") or datetime.now(timezone.utc)
826 if end_time.tzinfo is None:
827 end_time = end_time.replace(tzinfo=timezone.utc)
829 return int((end_time - date_created).total_seconds())
831 async def _resync_submission_questions(
832 self,
833 submission,
834 assignment,
835 student_id,
836 shuffle_questions,
837 shuffle_choices,
838 now_utc,
839 ):
840 """
841 Re-syncs an in-progress submission's questions with the assignment's CURRENT
842 question refs — in either direction. Handles both a question added to the
843 assignment after the student's submission was created (the original case
844 here), and a question the teacher REMOVED from the assignment or deleted
845 from the question bank since — that question must stop being served on the
846 student's next fetch, not linger forever in their stored snapshot.
848 Args:
849 submission (dict): The existing submission document.
850 assignment (dict): The assignment document.
851 student_id (ObjectId): The student's ID.
852 shuffle_questions (bool): Whether to shuffle questions.
853 shuffle_choices (bool): Whether to shuffle choices.
854 now_utc (datetime): Current UTC time.
856 Returns:
857 dict: The updated submission document.
858 """
859 # Get all question IDs from assignment
860 question_ids = self._extract_question_ids(assignment.get("questions", []))
862 # Fetch all questions from the bank
863 fetched_questions = await self._fetch_questions_from_bank(question_ids)
865 # Prepare questions with proper formatting
866 points_overrides = assignment.get("question_points_overrides") or {}
867 questions = self._prepare_questions(
868 fetched_questions,
869 question_ids,
870 shuffle_choices,
871 shuffle_questions,
872 points_overrides,
873 )
875 # Build a map of existing answers by question ID — prefer submitted_answers as the source of truth
876 existing_answers = {
877 str(ans.get("questionId")): ans
878 for ans in submission.get("submitted_answers", [])
879 }
881 existing_count = len(submission.get("questions", []))
882 if len(questions) == existing_count:
883 return submission
885 # `resolved_all` distinguishes a trustworthy reconciliation from a
886 # transient one: True means every id the assignment CURRENTLY references
887 # resolved from some bank, so `questions` is a complete, accurate picture
888 # of "what the assignment looks like right now" — safe to persist even
889 # when it's SHORTER than the stored submission (the teacher genuinely
890 # removed a question). False means some referenced id didn't resolve
891 # anywhere (a dangling/deleted question id, or a transient cross-DB
892 # fetch issue) — in that case only ever GROW the stored list, never
893 # shrink from an admittedly-incomplete fetch. This also still stops the
894 # write-amplification loop the original guard called out: a
895 # permanently-dangling id keeps `resolved_all` False forever, so a
896 # same-or-shorter result there keeps returning early instead of
897 # re-writing on every fetch.
898 resolved_all = len(questions) == len(question_ids)
899 if not resolved_all and len(questions) <= existing_count:
900 return submission
902 # Generate re-synced answer slots, preserving student progress where available
903 new_answers = []
904 for q in questions:
905 q_id = str(q["_id"])
906 if q_id in existing_answers:
907 new_answers.append(existing_answers[q_id])
908 else:
909 new_answers.append(
910 {
911 "questionId": q["_id"],
912 "questionType": q["questionType"],
913 "answer": "",
914 "isFlagged": False,
915 "category": q.get("category"),
916 }
917 )
919 # Update submission in database
920 await db["submission_collection"].update_one(
921 {"_id": submission["_id"]},
922 {
923 "$set": {
924 "questions": questions,
925 "submitted_answers": new_answers,
926 "total_questions": len(questions),
927 "date_updated": now_utc,
928 }
929 },
930 )
932 # Return updated submission
933 submission["questions"] = questions
934 submission["submitted_answers"] = new_answers
935 submission["total_questions"] = len(questions)
936 return submission
938 async def _handle_existing_submission(
939 self, submission, allowed_attempts, allowed_duration, now_utc, assignment
940 ):
941 """
942 Processes an existing submission and checks for time/attempt constraints.
944 Behavior based on submission status:
945 - If is_submitted=False: Resume in-progress attempt; do NOT increment total_attempts.
946 - If is_submitted=True: Start new attempt only when total_attempts < allowed_attempts;
947 reset answers/timer without incrementing (assignment_submit increments on completion).
948 The question list is rebuilt from the assignment's CURRENT `questions` refs (same
949 as _create_new_submission) rather than reusing the previous attempt's stored
950 snapshot — otherwise a question the teacher removed/deleted after that attempt
951 keeps reappearing on every later attempt.
953 total_attempts = number of *completed* (submitted) attempts.
955 Args:
956 submission (dict): The existing submission document.
957 allowed_attempts (int): The maximum allowed attempts.
958 allowed_duration (timedelta): The total allowed duration.
959 now_utc (datetime): The current UTC time.
960 assignment (dict): The assignment document.
961 Returns:
962 dict: A response dictionary containing assignment data and remaining time.
963 Raises:
964 HTTPException: If all allowed attempts are exhausted or time has expired.
965 """
966 is_submitted = submission.get("is_submitted", False)
967 total_attempts = submission.get("total_attempts", 0)
969 settings = assignment.get("settings", {})
970 shuffle_questions = settings.get("shuffle_questions", False)
971 shuffle_choices = settings.get("shuffle_choices", False)
973 if is_submitted:
974 # All completed attempts used — cannot start another attempt.
975 if total_attempts >= allowed_attempts:
976 raise HTTPException(
977 status_code=403,
978 detail="You have exceeded the allowed number of attempts",
979 )
981 # Starting a new attempt after a completed submission.
982 # Reset submitted_answers and timer; do NOT increment total_attempts here —
983 # assignment_submit increments it upon completion. Rebuild `questions` fresh
984 # from the assignment's current refs (mirrors _create_new_submission) instead
985 # of reusing submission["questions"] — that stored snapshot is frozen from
986 # whenever this student's FIRST attempt began, so it silently kept including
987 # a question the teacher later removed from the assignment or deleted from
988 # the question bank.
989 question_ids = self._extract_question_ids(assignment.get("questions", []))
990 fetched_questions = await self._fetch_questions_from_bank(question_ids)
991 points_overrides = assignment.get("question_points_overrides") or {}
992 questions = self._prepare_questions(
993 fetched_questions,
994 question_ids,
995 shuffle_choices,
996 shuffle_questions,
997 points_overrides,
998 )
999 default_answers = self._generate_default_answers(questions)
1001 await db["submission_collection"].update_one(
1002 {"_id": submission["_id"]},
1003 {
1004 "$set": {
1005 "questions": questions,
1006 "submitted_answers": default_answers,
1007 "total_questions": len(questions),
1008 "is_submitted": False,
1009 "date_updated": now_utc,
1010 "date_created": now_utc,
1011 # New attempt: browser-lockdown violations are scoped per
1012 # attempt (see report_violation), so they reset here too.
1013 "violation_count": 0,
1014 "violation_counts": {},
1015 }
1016 },
1017 )
1018 return await self._prepare_response(
1019 assignment,
1020 questions,
1021 default_answers,
1022 allowed_duration,
1023 is_submitted=False,
1024 )
1026 # Continuing an in-progress attempt (is_submitted=False).
1027 # Do NOT increment total_attempts on resume — only completed submits count.
1028 date_created = submission.get("date_created", now_utc)
1029 if date_created.tzinfo is None:
1030 date_created = date_created.replace(tzinfo=timezone.utc)
1032 if allowed_duration is not None:
1033 elapsed = now_utc - date_created
1034 remaining = allowed_duration - elapsed
1035 if remaining.total_seconds() <= 0:
1036 raise HTTPException(
1037 status_code=403, detail="Time has expired for this assignment"
1038 )
1039 else:
1040 remaining = None
1042 await db["submission_collection"].update_one(
1043 {"_id": submission["_id"]}, {"$set": {"date_updated": now_utc}}
1044 )
1046 current_answers = submission.get("submitted_answers", [])
1047 return await self._prepare_response(
1048 assignment,
1049 submission["questions"],
1050 current_answers,
1051 remaining,
1052 is_submitted,
1053 )
1055 async def _create_new_submission(
1056 self,
1057 assignment,
1058 assignment_id,
1059 student_id,
1060 shuffle_questions,
1061 shuffle_choices,
1062 allowed_duration,
1063 now_utc,
1064 ):
1065 """
1066 Creates a new submission record for a student and prepares the question list.
1067 Args:
1068 assignment (dict): The assignment document.
1069 assignment_id (ObjectId): The ID of the assignment.
1070 student_id (ObjectId): The ID of the student.
1071 shuffle_questions (bool): Whether to shuffle the question order.
1072 shuffle_choices (bool): Whether to shuffle the choices within each question.
1073 allowed_duration (timedelta): The time allowed for the assignment.
1074 now_utc (datetime): The current time in UTC.
1075 Returns:
1076 dict: A response dictionary containing the new assignment and student answer data.
1077 """
1078 question_ids = self._extract_question_ids(assignment.get("questions", []))
1079 fetched_questions = await self._fetch_questions_from_bank(question_ids)
1081 points_overrides = assignment.get("question_points_overrides") or {}
1082 questions = self._prepare_questions(
1083 fetched_questions,
1084 question_ids,
1085 shuffle_choices,
1086 shuffle_questions,
1087 points_overrides,
1088 )
1089 default_answers = self._generate_default_answers(questions)
1091 submission_data = {
1092 "assignment_id": assignment_id,
1093 "student_id": student_id,
1094 "questions": questions,
1095 "submitted_answers": default_answers,
1096 "total_score": 0,
1097 "total_correct_answers": 0,
1098 "total_answers_submitted": 0,
1099 "total_questions": len(default_answers),
1100 "remarks": "",
1101 "total_attempts": 0,
1102 "is_submitted": False,
1103 "date_created": now_utc,
1104 "date_submitted": None,
1105 "date_updated": None,
1106 # Browser-lockdown violation tracking for this attempt — see report_violation.
1107 "violation_count": 0,
1108 "violation_counts": {},
1109 }
1111 # EI-1195: idempotent create. An unconditional insert_one raced with a
1112 # concurrent "start" (double-click / retry / multi-device) produced two
1113 # submission docs for the same (student, assignment). Upsert on that key
1114 # inserts only when absent, so a concurrent start is a no-op instead of a
1115 # duplicate (also backed by the unique index).
1116 try:
1117 result = await db["submission_collection"].update_one(
1118 {"student_id": student_id, "assignment_id": assignment_id},
1119 {"$setOnInsert": submission_data},
1120 upsert=True,
1121 )
1122 except DuplicateKeyError:
1123 # Modified by Allan Ninal — 2026-09-24 (EI-1195 follow-up).
1124 # WHAT: catch the duplicate-key error the upsert can still raise and
1125 # fall through to the "serve the stored submission" branch below.
1126 # WHY: an upsert is NOT atomic against a unique index. Two concurrent
1127 # starts can both find no document and both attempt the insert;
1128 # one wins and the other gets E11000 from
1129 # `uniq_submission_student_assignment`. MongoDB documents this and
1130 # puts the retry on the application.
1131 #
1132 # Nothing here handled it, so the error hit the catch-all in
1133 # `fetch_assignment_questions` and came back as
1134 # `500 "An unexpected error occurred: {str(e)}"` — which pastes the
1135 # database name, the collection, the index name and BOTH key values
1136 # into the response body.
1137 #
1138 # A 500 is also just the wrong answer. Losing this race is benign:
1139 # the student's other tab/device already started the attempt, and
1140 # the correct response is the one the branch below already
1141 # produces — serve the stored questions and answers so they resume
1142 # the attempt that exists. Setting `result = None` routes the
1143 # raced path into exactly the same code as losing the race without
1144 # an exception, so there is one behaviour, not two.
1145 result = None
1146 # If we LOST the concurrent-start race (the doc already existed, so nothing
1147 # was inserted), serve the STORED questions/answers — on a shuffle-enabled
1148 # assignment our locally-generated ordering would differ from what was
1149 # persisted and what the student resumes into. (EI-1195 review follow-up.)
1150 if result is None or getattr(result, "upserted_id", None) is None:
1151 existing = await db["submission_collection"].find_one(
1152 {"student_id": student_id, "assignment_id": assignment_id}
1153 )
1154 if existing:
1155 return await self._prepare_response(
1156 assignment,
1157 existing.get("questions", questions),
1158 existing.get("submitted_answers", default_answers),
1159 allowed_duration,
1160 is_submitted=existing.get("is_submitted", False),
1161 )
1162 return await self._prepare_response(
1163 assignment, questions, default_answers, allowed_duration, is_submitted=False
1164 )
1166 def _extract_question_ids(self, raw_questions):
1167 """
1168 Extracts and validates ObjectIds from the assignment's question list.
1169 Handles multiple formats:
1170 - Dict with 'id' field: {"id": ObjectId/str, ...}
1171 - Dict with '_id' field: {"_id": ObjectId/str, ...}
1172 - String ID: "507f1f77bcf86cd799439011"
1173 - ObjectId directly
1175 Args:
1176 raw_questions (list): A list of question IDs or dicts with 'id'/'_id'.
1177 Returns:
1178 list: A list of valid ObjectId instances.
1179 """
1180 question_ids = []
1181 for q in raw_questions:
1182 try:
1183 if isinstance(q, dict):
1184 # Try 'id' field first, then '_id' as fallback
1185 qid = q.get("id") or q.get("_id")
1186 elif isinstance(q, ObjectId):
1187 qid = q
1188 else:
1189 qid = q
1191 if qid is None:
1192 continue
1194 # Convert to string for validation
1195 qid_str = str(qid)
1196 if ObjectId.is_valid(qid_str):
1197 question_ids.append(ObjectId(qid_str))
1198 except Exception:
1199 # Skip invalid entries
1200 continue
1202 return question_ids
1204 async def _fetch_questions_from_bank(self, question_ids):
1205 """Map ``str(question id) -> question document``, across all three banks.
1207 Modified by Allan Ninal — 2026-09-23
1208 WHAT: the three-tier lookup (teacher_questionbank -> the admin-staff
1209 global_questionbank -> the legacy question_collection) moved to
1210 server/services/common/question_bank.py; this now delegates to it.
1211 WHY: server/services/common/assignments.py was reading
1212 db["question_collection"] DIRECTLY for /v1/assignments/view and
1213 /review — a collection that exists in no database on this cluster —
1214 so both routes answered 200 with "questions": [] for every
1215 assignment. Rather than write a second copy of this lookup there,
1216 the one that already worked moved to a shared module. Behaviour
1217 here is unchanged, including the fan-out cap; the method is kept
1218 under its own name because its callers and tests patch it.
1219 """
1220 return await fetch_questions_by_ids(question_ids)
1222 def _prepare_questions(
1223 self,
1224 question_map,
1225 question_ids,
1226 shuffle_choices,
1227 shuffle_questions,
1228 points_overrides=None,
1229 ):
1230 """
1231 Prepares a list of question dictionaries with optional shuffling of choices and questions.
1232 Args:
1233 question_map (dict): Dictionary mapping question IDs to question documents.
1234 question_ids (list): Ordered list of question ObjectIds from the assignment.
1235 shuffle_choices (bool): Whether to shuffle choices in each question.
1236 shuffle_questions (bool): Whether to shuffle the order of questions.
1237 points_overrides (dict | None): Question id string -> points, from the
1238 assignment's `question_points_overrides` (a teacher's per-assignment
1239 point edit). Wins over the question bank document's own `points`.
1240 Returns:
1241 list: A list of sanitized question dictionaries for the assignment.
1242 """
1243 shufflable_types = [
1244 "checkbox",
1245 "multiple-choice",
1246 "drag-and-drop",
1247 "drop-down-menu",
1248 ]
1249 questions = []
1251 # Iterate through questions in the order specified by the assignment
1252 for qid in question_ids:
1253 q = question_map.get(str(qid))
1254 if not q:
1255 continue
1257 # Get choices and make a copy to avoid modifying the original.
1258 # Free-response questions store `choices: null`, so coalesce to []
1259 # (list(None) would raise → student questions-fetch 500).
1260 choices = list(q.get("choices") or [])
1261 qtype = q.get("questionType", "").lower()
1263 # Shuffle choices if setting is enabled and question type supports it
1264 if shuffle_choices and qtype in shufflable_types:
1265 if qtype == "drop-down-menu":
1266 # For drop-down menus, shuffle items within each choice
1267 for choice in choices:
1268 if isinstance(choice.get("items"), list):
1269 shuffle(choice["items"])
1270 else:
1271 # For other types, shuffle the choices array
1272 shuffle(choices)
1274 questions.append(
1275 {
1276 "_id": q["_id"],
1277 "points": (points_overrides or {}).get(
1278 str(q["_id"]), q.get("points")
1279 ),
1280 "choices": choices,
1281 "question": q.get("question"),
1282 "questionType": qtype,
1283 "correctAnswer": q.get("correctAnswer"),
1284 "category": q.get("category"),
1285 "groups": q.get("groups"),
1286 "rows": q.get("rows"),
1287 "rowHeaderLabel": q.get("rowHeaderLabel"),
1288 }
1289 )
1291 # Shuffle questions if setting is enabled
1292 if shuffle_questions:
1293 shuffle(questions)
1295 return questions
1297 def _generate_default_answers(self, questions):
1298 """
1299 Generates a list of default answers for the given questions.
1300 Args:
1301 questions (list): A list of question documents.
1302 Returns:
1303 list: A list of answer dictionaries with default values.
1304 """
1305 return [
1306 {
1307 "questionId": q["_id"],
1308 "questionType": q["questionType"],
1309 "answer": "",
1310 "isFlagged": False,
1311 "category": q["category"],
1312 }
1313 for q in questions
1314 ]
1316 async def _prepare_response(
1317 self,
1318 assignment,
1319 questions,
1320 answers,
1321 remaining_time: Optional[timedelta],
1322 is_submitted: bool = False,
1323 ):
1324 """
1325 Prepares the final response payload for the assignment, including formatted time and answers.
1326 Args:
1327 assignment (dict): The assignment document.
1328 questions (list): The list of question dictionaries.
1329 answers (list): The list of student's answers.
1330 remaining_time (Optional[timedelta]): Time remaining to complete the assignment, or
1331 None when the assignment has no time limit ("unlimited time").
1332 is_submitted (bool): Whether the submission has been submitted. If True, student_answers will be excluded.
1333 Returns:
1334 dict: A response dictionary containing assignment details, questions, answers, and time.
1335 """
1336 if remaining_time is None:
1337 remaining_str = None
1338 else:
1339 remaining_str = self._format_duration_hhmmss(remaining_time.total_seconds())
1341 transformed_answers = [
1342 {
1343 "questionId": str(ans.get("questionId")),
1344 "questionType": ans.get("questionType"),
1345 "answer": ans.get("answer", ""),
1346 "isFlagged": ans.get("isFlagged", False),
1347 "category": ans.get("category"),
1348 }
1349 for ans in answers
1350 ]
1352 cleaned_assignment = dict(assignment)
1353 cleaned_assignment.pop("questions", None)
1355 sanitized_questions = []
1356 for q in questions:
1357 item = {k: v for k, v in q.items() if k != "correctAnswer"}
1358 # Graph / Graph-Multiple-Select need the authored axes (and GMS dots).
1359 # Send a prompt copy — never the answer-key drawing.
1360 prompt = student_prompt_correct_answer(q)
1361 if prompt is not None:
1362 item["correctAnswer"] = prompt
1363 sanitized_questions.append(item)
1365 response = {
1366 "assignment": {
1367 "details": serialized_response_object(cleaned_assignment),
1368 "questions": serialized_response_object(sanitized_questions),
1369 "remaining_time": remaining_str,
1370 }
1371 }
1373 # Only include student_answers if the submission has not been submitted
1374 if not is_submitted:
1375 response["assignment"]["student_answers"] = transformed_answers
1377 return response
1379 async def assignment_submit(
1380 self,
1381 assignment_uuid: str,
1382 submit_request: AssignmentSubmitRequest,
1383 request: Request,
1384 ) -> dict:
1385 """
1386 Submits and evaluates a student's answers for a specific assignment.
1388 This method performs the following steps:
1389 1. Validates whether the student is authorized to submit the assignment by checking class membership.
1390 2. Retrieves the existing submission record for the student and assignment.
1391 3. Stores the raw submitted answers in the 'submitted_answers' field for audit trail.
1392 4. Updates the submission's answers by matching submitted responses with existing question IDs while preserving order.
1393 5. Calculates the total score, correctness per question, and evaluation details.
1394 6. Updates the submission document with the evaluated results, submitted answers, and marks it as submitted.
1396 The submission document will contain:
1397 - answers: Evaluated answers with scoring information
1398 - submitted_answers: Raw student submissions without evaluation (for audit trail)
1400 Args:
1401 assignment_uuid (str): The UUID of the assignment to be submitted.
1402 submit_request (AssignmentSubmitRequest): The student's submitted answers and flags.
1403 request (Request): The current HTTP request, used to extract student identity.
1405 Returns:
1406 dict: A response indicating success, along with scoring details:
1407 - message (str): Status message.
1408 - totalScore (int): Total points earned.
1409 - totalCorrectAnswers (int): Number of correctly answered questions.
1410 - totalQuestions (int): Total number of questions in the assignment.
1411 - totalAnswersSubmitted (int): Number of answers the student attempted.
1412 - details (list): Breakdown of scoring per question, including correct answer, student answer, and points earned.
1414 Raises:
1415 HTTPException: If the student is unauthorized, the assignment is not found, or no submission record exists.
1416 """
1417 if not ObjectId.is_valid(assignment_uuid):
1418 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
1420 student_id = to_user_id(request.state.user_details["uuid"])
1421 email = request.state.user_details.get("email")
1422 assignment_oid = ObjectId(assignment_uuid)
1423 student_oid = ObjectId(student_id)
1425 # Step 1: Validate assignment & student authorization. Matches by
1426 # student _id OR email (falls back to email because a duplicate/stale
1427 # account sharing this student's email can end up as the _id on a
1428 # class's enrollment record instead of the live account's).
1429 pipeline = [
1430 {"$match": {"_id": assignment_oid}},
1431 {
1432 "$lookup": {
1433 "from": "class_collection",
1434 "let": {"assigned_class_ids": "$assigned_class"},
1435 "pipeline": [
1436 {
1437 "$match": {
1438 "$expr": {
1439 "$and": [
1440 {"$in": ["$_id", "$$assigned_class_ids"]},
1441 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students.
1442 {"$ne": ["$deleted", True]},
1443 {
1444 "$or": [
1445 {"$in": [student_oid, "$students._id"]},
1446 {
1447 "$in": [
1448 email or "",
1449 "$students.email",
1450 ]
1451 },
1452 ]
1453 },
1454 ]
1455 }
1456 }
1457 }
1458 ],
1459 "as": "authorized_classes",
1460 }
1461 },
1462 {"$match": {"authorized_classes.0": {"$exists": True}}},
1463 {
1464 "$lookup": {
1465 "from": "submission_collection",
1466 "let": {"assignment_id": "$_id", "student_id": student_oid},
1467 "pipeline": [
1468 {
1469 "$match": {
1470 "$expr": {
1471 "$and": [
1472 {"$eq": ["$assignment_id", "$$assignment_id"]},
1473 {"$eq": ["$student_id", "$$student_id"]},
1474 ]
1475 }
1476 }
1477 }
1478 ],
1479 "as": "submission",
1480 }
1481 },
1482 {"$unwind": {"path": "$submission", "preserveNullAndEmptyArrays": False}},
1483 ]
1485 assignment_data = (
1486 await db["assignments_collection"].aggregate(pipeline).to_list(length=1)
1487 )
1488 if not assignment_data:
1489 raise HTTPException(
1490 status_code=403, detail="Unauthorized or assignment not found."
1491 )
1493 assignment = assignment_data[0]
1494 submission = assignment["submission"]
1496 # Step 2: Not open yet, or already closed
1497 _assert_assignment_open(assignment)
1498 date_close = assignment.get("date_close")
1499 is_late_flag = False
1500 if date_close:
1501 if date_close.tzinfo is None:
1502 date_close = date_close.replace(tzinfo=timezone.utc)
1503 if datetime.now(timezone.utc) > date_close:
1504 settings_doc = assignment.get("settings", {})
1505 allow_late = settings_doc.get("allow_late_submissions", False)
1506 if not allow_late:
1507 raise HTTPException(
1508 status_code=403,
1509 detail="This assignment is already closed and no longer accepts submissions.",
1510 )
1511 # Late submission allowed — check for duplicate pending
1512 existing_submission = assignment.get("submission", {})
1513 if existing_submission.get("review_status") == "pending":
1514 raise HTTPException(
1515 status_code=409,
1516 detail="A late submission is already pending teacher review.",
1517 )
1518 is_late_flag = True
1520 # Step 2b: Guard against re-submit once all allowed attempts are exhausted.
1521 # total_attempts counts *completed* (submitted) attempts; is_submitted marks the
1522 # current attempt as done. A re-submit is only valid when the current attempt is
1523 # still in-progress (is_submitted=False); once submitted, the student must open a
1524 # new attempt via fetch_assignment_questions (which resets is_submitted to False).
1525 allowed_attempts = assignment.get("settings", {}).get("allowed_attempts", 1)
1526 if (
1527 submission.get("is_submitted", False)
1528 and submission.get("total_attempts", 0) >= allowed_attempts
1529 ):
1530 raise HTTPException(
1531 status_code=403, detail="No remaining attempts for this assignment."
1532 )
1534 # Step 2c: Elapsed-time check — same gate used in _handle_existing_submission.
1535 # A missing/empty time_allowed means the assignment has no time limit.
1536 time_allowed_str = assignment.get("settings", {}).get("time_allowed")
1537 if time_allowed_str:
1538 try:
1539 allowed_duration = self._parse_time(time_allowed_str)
1540 except (ValueError, AttributeError):
1541 allowed_duration = None
1543 if allowed_duration is not None:
1544 date_created = submission.get("date_created")
1545 if date_created:
1546 if date_created.tzinfo is None:
1547 date_created = date_created.replace(tzinfo=timezone.utc)
1548 elapsed = datetime.now(timezone.utc) - date_created
1549 if elapsed > allowed_duration:
1550 raise HTTPException(
1551 status_code=403,
1552 detail="Time has expired for this assignment",
1553 )
1555 # Step 3: Prepare submitted answers and store raw submission
1556 answer_map = {
1557 ans.questionId: {"answer": ans.studentAnswer, "isFlagged": ans.isFlagged}
1558 for ans in submit_request.answers
1559 }
1561 updated_answers = []
1562 submitted_answers = []
1563 for ans in submission.get("submitted_answers", []):
1564 q_id = str(ans["questionId"])
1566 # Store the raw submitted answer with same format as answers field
1567 if q_id in answer_map:
1568 update = answer_map[q_id]
1570 # Create submitted_answers entry matching answers format
1571 submitted_answers.append(
1572 {
1573 "questionId": ans["questionId"],
1574 "questionType": ans.get("questionType"),
1575 "answer": update["answer"],
1576 "isFlagged": update["isFlagged"],
1577 "category": ans.get("category"),
1578 }
1579 )
1581 # Update the answer for evaluation
1582 ans["answer"] = update["answer"]
1583 ans["isFlagged"] = update["isFlagged"]
1584 else:
1585 # If question wasn't answered, store empty answer in submitted_answers
1586 submitted_answers.append(
1587 {
1588 "questionId": ans["questionId"],
1589 "questionType": ans.get("questionType"),
1590 "answer": ans.get("answer", ""),
1591 "isFlagged": ans.get("isFlagged", False),
1592 "category": ans.get("category"),
1593 }
1594 )
1596 updated_answers.append(ans)
1598 # Step 4: Fetch questions from the submission's questions field
1599 questions_in_submission = submission.get("questions", [])
1600 question_doc_map = {
1601 str(question["_id"]): question for question in questions_in_submission
1602 }
1604 total_score = 0
1605 correct_count = 0
1606 # Points and questions held for a person to mark, kept out of both the score
1607 # and the total it is measured against.
1608 pending_points = 0
1609 pending_count = 0
1610 total_answers_submitted = sum(
1611 1
1612 for a in submit_request.answers
1613 if self.is_meaningful_answer(a.studentAnswer)
1614 )
1616 scored_details = []
1617 student_answers = []
1618 for ans in updated_answers:
1619 q_id = str(ans["questionId"])
1620 student_answer = ans.get("answer", "")
1621 is_flagged = ans.get("isFlagged", False)
1622 q_doc = question_doc_map.get(q_id)
1624 if not q_doc:
1625 continue # skip if the question doc doesn't exist
1627 correct_answer = resolve_correct_answer(q_doc)
1628 max_points = q_doc.get("points", 0)
1630 # Scoring. group_results is non-None only for Single-Stimulus
1631 # (partial credit per group) — every other type keeps the existing
1632 # binary max_points-or-0 behavior.
1633 is_correct, earned_points, group_results = score_question(
1634 student_answer,
1635 correct_answer,
1636 q_doc.get("questionType"),
1637 max_points,
1638 graph_fingerprint=(q_doc.get("correctAnswer") or {}).get(
1639 "graphFingerprint"
1640 ),
1641 unordered=(q_doc.get("correctAnswer") or {}).get("unordered", False),
1642 )
1644 # A written answer the auto-grader could not confirm is HELD for a person
1645 # to mark, not scored zero. It contributes nothing to the score and nothing
1646 # to the total it is measured against, so the grade shown is an honest
1647 # grade of the work that has actually been marked.
1648 pending_marking = needs_manual_marking(
1649 q_doc.get("questionType"), is_correct
1650 )
1651 if pending_marking:
1652 earned_points = 0
1653 pending_points += max_points
1654 pending_count += 1
1655 elif is_correct:
1656 total_score += earned_points
1657 correct_count += 1
1658 elif earned_points:
1659 # Partial credit (some groups correct, not all) still counts
1660 # toward the score even though the question as a whole isn't
1661 # "correct".
1662 total_score += earned_points
1664 scored_details.append(
1665 {
1666 "questionId": q_id,
1667 "questionType": q_doc.get("questionType"),
1668 "points": max_points,
1669 "isFlagged": is_flagged,
1670 "correctAnswer": correct_answer,
1671 }
1672 )
1674 sa_entry = {
1675 "questionId": q_id,
1676 "questionType": ans.get("questionType"),
1677 "isFlagged": ans.get("isFlagged"),
1678 "answer": student_answer,
1679 # None, not False. False means "a person or the grader decided this is
1680 # wrong"; None means "nobody has decided yet". The client already
1681 # distinguishes the two — it tests for undefined/null separately from
1682 # false before it shows a verdict — so a held answer renders as awaiting
1683 # marking rather than as a mistake the student made.
1684 "isCorrect": None if pending_marking else is_correct,
1685 "earnedPoints": earned_points,
1686 }
1687 if pending_marking:
1688 sa_entry["needsMarking"] = True
1689 if group_results is not None:
1690 sa_entry["groupResults"] = group_results
1691 student_answers.append(sa_entry)
1693 # Calculate max score. Questions still waiting to be marked are excluded from
1694 # BOTH sides: scoring a student against points nobody has awarded yet would
1695 # report a low grade that is really just an unfinished one, and it would settle
1696 # to a different number later without anything saying why.
1697 max_score = sum(q.get("points", 0) for q in questions_in_submission)
1698 markable_score = max_score - pending_points
1699 score_percentage = (
1700 (total_score / markable_score * 100) if markable_score > 0 else 0
1701 )
1702 passing_grade = assignment.get("passing_grade", 75) # default is 75
1703 remarks = "passed" if score_percentage >= passing_grade else "failed"
1705 # Step 5: Update submission with remarks and submitted answers.
1706 # Increment total_attempts to record this completed attempt.
1707 set_payload: dict = {
1708 "grade": score_percentage,
1709 "total_score": total_score,
1710 "total_correct_answers": correct_count,
1711 "total_answers_submitted": total_answers_submitted,
1712 "total_questions": len(updated_answers),
1713 "submitted_answers": submitted_answers,
1714 "last_submitted_answers": submitted_answers,
1715 "last_student_answers": student_answers,
1716 "is_submitted": True,
1717 "date_submitted": datetime.now(timezone.utc),
1718 "remarks": remarks,
1719 # How much of this submission nobody has marked yet. Written to the
1720 # submission rather than recomputed on read so the gradebook and the
1721 # student's own view agree without each re-deriving it.
1722 "pending_marking_count": pending_count,
1723 "pending_marking_points": pending_points,
1724 }
1726 if is_late_flag:
1727 set_payload["is_late"] = True
1728 set_payload["review_status"] = "pending"
1729 set_payload["pre_penalty_grade"] = score_percentage
1730 set_payload["remarks"] = "pending_review"
1732 update_result = await db["submission_collection"].update_one(
1733 {"_id": submission["_id"]},
1734 {
1735 "$inc": {"total_attempts": 1},
1736 "$set": set_payload,
1737 },
1738 )
1740 if update_result.matched_count == 0:
1741 raise HTTPException(status_code=404, detail="Submission record not found.")
1743 if is_late_flag:
1744 return {
1745 "message": "Your late submission was received and is awaiting teacher approval.",
1746 "reviewStatus": "pending",
1747 "isLate": True,
1748 }
1750 return {
1751 "message": "Submission evaluated and recorded successfully.",
1752 "grade": score_percentage,
1753 "totalScore": total_score,
1754 "totalCorrectAnswers": correct_count,
1755 "totalQuestions": len(updated_answers),
1756 "totalAnswersSubmitted": total_answers_submitted,
1757 "details": scored_details,
1758 "studentAnswers": student_answers,
1759 "remarks": remarks,
1760 }
1762 async def report_violation(
1763 self,
1764 assignment_uuid: str,
1765 violation: ViolationReportRequest,
1766 request: Request,
1767 ) -> dict:
1768 """
1769 Records one browser-lockdown violation against the student's current
1770 (in-progress) attempt at this assignment, and reports whether the
1771 max-violations threshold has been exceeded.
1773 See docs/implementation/browser_lockdown_violation_tracking.md (client repo)
1774 for the full feature spec. The backend is the source of truth for the
1775 running count — the client reports every violation as it happens and reads
1776 `threshold_exceeded`/`auto_submit` off this response, rather than counting
1777 client-side, so the count survives a refresh/reconnect mid-attempt.
1779 The count lives on the submission document — one per (student, assignment)
1780 attempt (see fetch_assignment_questions / _handle_existing_submission) —
1781 and is reset to 0 whenever a fresh attempt starts, so violations are scoped
1782 per attempt, not per assignment lifetime.
1784 Args:
1785 assignment_uuid (str): The assignment's ObjectId as a string.
1786 violation (ViolationReportRequest): The reported violation; `type` is
1787 informational only (stored for debugging, not scored).
1788 request (Request): The current HTTP request, used to extract student identity.
1790 Returns:
1791 dict:
1792 - violation_count (int): The running total for this attempt, capped
1793 at max_violations — see auto_submit below.
1794 - max_violations (int): The configured threshold (3).
1795 - threshold_exceeded (bool): True once violation_count reaches
1796 max_violations.
1797 - auto_submit (bool): True exactly once — on the violation that FIRST
1798 reaches the threshold (the 3rd) — telling the client to submit now.
1799 False on every violation before or after that one, so a client
1800 racing a few more reports in before its own auto-submit completes
1801 is never told to submit twice.
1802 - violation_breakdown (dict): Running per-type counts for this
1803 attempt, e.g. `{"tab_hidden": 2, "fullscreen_exit": 1}` — every
1804 key is one of KNOWN_LOCKDOWN_VIOLATION_TYPES, or "unknown" for a
1805 type this backend doesn't recognize. Sums to violation_count, so
1806 the UI can show the student every one of up to 3 violations
1807 that's actually being tracked, not just a bare total.
1809 Raises:
1810 HTTPException:
1811 - 400: If the assignment UUID is not a valid ObjectId.
1812 - 404: If no submission exists yet for this student/assignment (the
1813 student must have opened the assignment via questions/fetch first).
1814 """
1815 assignment_id = self._validate_object_id(
1816 assignment_uuid, "Invalid assignment ID format"
1817 )
1818 student_id = to_user_id(request.state.user_details["uuid"])
1820 submission = await db["submission_collection"].find_one(
1821 {"assignment_id": assignment_id, "student_id": student_id},
1822 {"_id": 1, "is_submitted": 1, "violation_count": 1, "violation_counts": 1},
1823 )
1824 if not submission:
1825 raise HTTPException(status_code=404, detail="Submission not found")
1827 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students.
1828 if not await self._has_live_enrolled_class(
1829 assignment_id, student_id, request.state.user_details.get("email")
1830 ):
1831 raise HTTPException(status_code=404, detail="Submission not found")
1833 # Once this attempt is submitted — whether the student did it, or a prior
1834 # violation already reached the threshold and told the client to — there is
1835 # nothing left to auto-submit. Report the stored state as-is rather than
1836 # keep incrementing a closed attempt.
1837 if submission.get("is_submitted", False):
1838 count = submission.get("violation_count", 0)
1839 return {
1840 "violation_count": count,
1841 "max_violations": MAX_LOCKDOWN_VIOLATIONS,
1842 "threshold_exceeded": count >= MAX_LOCKDOWN_VIOLATIONS,
1843 "auto_submit": False,
1844 "violation_breakdown": submission.get("violation_counts") or {},
1845 }
1847 violation_type = _normalize_violation_type(violation.type)
1848 updated = await db["submission_collection"].find_one_and_update(
1849 {"_id": submission["_id"]},
1850 {
1851 "$inc": {
1852 "violation_count": 1,
1853 f"violation_counts.{violation_type}": 1,
1854 },
1855 "$set": {
1856 "last_violation_type": violation.type,
1857 "last_violation_at": datetime.now(timezone.utc),
1858 },
1859 },
1860 projection={"violation_count": 1, "violation_counts": 1},
1861 return_document=ReturnDocument.AFTER,
1862 )
1863 count = updated.get("violation_count", 0) if updated else 0
1864 breakdown = updated.get("violation_counts") or {} if updated else {}
1866 return {
1867 "violation_count": count,
1868 "max_violations": MAX_LOCKDOWN_VIOLATIONS,
1869 "threshold_exceeded": count >= MAX_LOCKDOWN_VIOLATIONS,
1870 "auto_submit": count == MAX_LOCKDOWN_VIOLATIONS,
1871 "violation_breakdown": breakdown,
1872 }
1874 def is_meaningful_answer(self, answer):
1875 if isinstance(answer, str):
1876 return answer.strip() != ""
1877 if isinstance(answer, dict):
1878 # Graph / Graph-Multiple-Select exports are objects, not strings.
1879 if answer.get("format") == "Graph2D" or isinstance(
1880 answer.get("data"), dict
1881 ):
1882 return True
1883 if any(
1884 key in answer and str(answer[key]).strip() != ""
1885 for key in ["selected", "answer"]
1886 ):
1887 return True
1888 return False
1889 if isinstance(answer, list):
1890 for item in answer:
1891 if isinstance(item, str) and item.strip() != "":
1892 return True
1893 if isinstance(item, dict):
1894 # Check for 'selected' or 'answer' fields
1895 if any(
1896 key in item and str(item[key]).strip() != ""
1897 for key in ["selected", "answer"]
1898 ):
1899 return True
1900 return False
1902 def _normalize_free_response_answer(self, value) -> str:
1903 """Canonicalizes a free-response answer for comparison.
1905 Thin wrapper — see server.services.common.answer_checking.normalize_free_response_answer
1906 for the canonical implementation, shared with teacher-side reporting.
1907 """
1908 return normalize_free_response_answer(value)
1910 def _is_correct(
1911 self,
1912 student_answer,
1913 correct_answer,
1914 question_id,
1915 question_type=None,
1916 graph_fingerprint=None,
1917 ):
1918 """Compare student_answer and correct_answer to determine correctness.
1920 Thin wrapper — see server.services.common.answer_checking.is_answer_correct
1921 for the canonical implementation, shared with teacher-side reporting
1922 (Item Analysis, live submission view) so a submission is never graded
1923 one way and reported another. Graph questions use fingerprint +
1924 geometric checkGraphData via that same shared function.
1925 """
1926 return is_answer_correct(
1927 student_answer,
1928 correct_answer,
1929 question_type,
1930 graph_fingerprint=graph_fingerprint,
1931 )
1933 async def assignment_answers_save(
1934 self,
1935 assignment_uuid: str,
1936 submit_request: AssignmentSubmitRequest,
1937 request: Request,
1938 ) -> dict:
1939 """
1940 Updates the answers and submitted_answers fields for a student's submission.
1942 This method finds the student's submission document by matching the provided `assignment_uuid`
1943 and `student_id`, and then updates both the answers and submitted_answers fields based on the
1944 data received in the `submit_request`. Each answer update includes the question ID, the student's
1945 answer, and the optional flag status.
1947 If the submitted_answers field doesn't exist, it will be created with the same format as answers.
1949 Args:
1950 assignment_uuid (str): The unique identifier for the assignment.
1951 submit_request (AssignmentSubmitRequest): A Pydantic model containing the answers to be updated.
1952 request (Request): The HTTP request object containing user details, specifically the student ID.
1954 Returns:
1955 dict: A dictionary with a message and the updated answers list.
1957 Raises:
1958 Exception: If the submission document is not found for the given assignment and student.
1959 """
1960 assignment_uuid = self._validate_object_id(
1961 assignment_uuid, "Invalid assignment ID format"
1962 )
1963 student_id = to_user_id(request.state.user_details["uuid"])
1965 # Fetch current submission
1966 submission = await db["submission_collection"].find_one(
1967 {
1968 "assignment_id": ObjectId(assignment_uuid),
1969 "student_id": ObjectId(student_id),
1970 }
1971 )
1973 if not submission:
1974 raise HTTPException(status_code=404, detail="Submission not found")
1976 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students.
1977 # Answers exactly as for a student with no submission here.
1978 if not await self._has_live_enrolled_class(
1979 ObjectId(assignment_uuid),
1980 student_id,
1981 request.state.user_details.get("email"),
1982 ):
1983 raise HTTPException(status_code=404, detail="Submission not found")
1985 # Integrity guards (EI-3271): a draft save must not be able to tamper with
1986 # answers after the current attempt is submitted, or after the assignment
1987 # has closed — mirroring the gates in assignment_submit.
1988 if submission.get("is_submitted", False):
1989 raise HTTPException(
1990 status_code=403,
1991 detail="This attempt has already been submitted and can no longer be edited.",
1992 )
1994 # SEC-5: a breached attempt is over. Without this, MAX_LOCKDOWN_VIOLATIONS
1995 # was advisory — the client was merely *told* to auto-submit.
1996 _assert_lockdown_not_breached(submission)
1998 assignment_doc = await db["assignments_collection"].find_one(
1999 {"_id": ObjectId(assignment_uuid)}
2000 )
2001 # A soft-deleted assignment is no longer available to save against
2002 # (consistent with the deleted-guard on the other assignment reads).
2003 if assignment_doc and assignment_doc.get("deleted") is True:
2004 raise HTTPException(
2005 status_code=403,
2006 detail="This assignment is no longer available.",
2007 )
2008 _assert_assignment_open(assignment_doc)
2009 date_close = assignment_doc.get("date_close") if assignment_doc else None
2010 if date_close:
2011 if date_close.tzinfo is None:
2012 date_close = date_close.replace(tzinfo=timezone.utc)
2013 if datetime.now(timezone.utc) > date_close:
2014 raise HTTPException(
2015 status_code=403,
2016 detail="This assignment is already closed and no longer accepts submissions.",
2017 )
2019 # Create answer map from request
2020 answer_map = {
2021 ans.questionId: {"answer": ans.studentAnswer, "isFlagged": ans.isFlagged}
2022 for ans in submit_request.answers
2023 }
2025 # Update submitted_answers field
2026 submitted_answers = []
2027 for ans in submission.get("submitted_answers", []):
2028 q_id = str(ans["questionId"])
2029 if q_id in answer_map:
2030 update = answer_map[q_id]
2031 submitted_answers.append(
2032 {
2033 "questionId": ans["questionId"],
2034 "questionType": ans.get("questionType"),
2035 "answer": update["answer"],
2036 "isFlagged": (
2037 update["isFlagged"]
2038 if update["isFlagged"] is not None
2039 else ans.get("isFlagged", False)
2040 ),
2041 "category": ans.get("category"),
2042 }
2043 )
2044 else:
2045 # Preserve existing answer if not updated
2046 existing_submitted = None
2047 if "submitted_answers" in submission:
2048 for sub_ans in submission["submitted_answers"]:
2049 if str(sub_ans.get("questionId")) == q_id:
2050 existing_submitted = sub_ans
2051 break
2053 if existing_submitted:
2054 submitted_answers.append(existing_submitted)
2055 else:
2056 submitted_answers.append(
2057 {
2058 "questionId": ans["questionId"],
2059 "questionType": ans.get("questionType"),
2060 "answer": ans.get("answer", ""),
2061 "isFlagged": ans.get("isFlagged", False),
2062 "category": ans.get("category"),
2063 }
2064 )
2066 result = await db["submission_collection"].update_one(
2067 {
2068 "assignment_id": ObjectId(assignment_uuid),
2069 "student_id": ObjectId(student_id),
2070 },
2071 {
2072 "$set": {
2073 "submitted_answers": submitted_answers,
2074 "date_updated": datetime.now(timezone.utc),
2075 }
2076 },
2077 )
2079 if result.matched_count == 0:
2080 raise HTTPException(status_code=404, detail="Submission not found")
2082 return {
2083 "message": "Answers saved successfully",
2084 "answers": serialized_response_object(submitted_answers),
2085 }
2087 async def fetch_submission_details(
2088 self,
2089 assignment_uuid: str,
2090 request: Request,
2091 ) -> dict:
2092 """
2093 Fetch detailed submission data for a given assignment and student.
2095 This method performs the following:
2096 - Validates if the student is enrolled in a class assigned to the given assignment.
2097 - Retrieves the submission document for the assignment and student.
2098 - Calculates the remaining time allowed for the submission.
2099 - Scores the student's answers against the correct answers.
2100 - Returns detailed feedback including score, correctness, and flagged status.
2101 - Determines isSubmitted status based on the existence of submitted_answers field.
2103 Args:
2104 assignment_uuid (str): The unique identifier of the assignment.
2105 request (Request): The FastAPI request object containing the student's details.
2107 Returns:
2108 dict: A dictionary containing:
2109 - message (str): A success message.
2110 - _id (str): The submission document ID.
2111 - isSubmitted (bool): True if submitted_answers field exists, False otherwise.
2112 - remaining_time (str): Time left in HH:MM:SS format.
2113 - totalScore (int): Total score the student earned.
2114 - totalPoints (int): Sum of every question's max point value (denominator for totalScore).
2115 - totalCorrectAnswers (int): Number of correct answers.
2116 - totalQuestions (int): Number of questions answered.
2117 - totalAnswersSubmitted (int): Number of meaningful answers submitted.
2118 - totalAttemptsUsed (int): Number of attempts the student has used.
2119 - totalAttemptsAllowed (int): Max attempts allowed for this assignment (denominator for totalAttemptsUsed).
2120 - settings (dict): The assignment's settings sub-document as stored (time_allowed,
2121 allowed_attempts, show_score_after_submit, show_correct_answers_after_submit,
2122 shuffle_questions, shuffle_choices, allow_calculator, allow_feedback_after_submit,
2123 allow_late_submissions, ...) — same shape as fetch_specific_assignment's
2124 details.settings. Lets the FE key correctness-indicator UI off the actual
2125 show_correct_answers_after_submit flag instead of inferring it from whether
2126 correctAnswer/isCorrect happen to be present below.
2127 - details (List[dict]): Per-question feedback including:
2128 - questionId (str)
2129 - questionType (str)
2130 - points (int)
2131 - isFlagged (bool)
2132 - correctAnswer (Any)
2133 - studentAnswer (dict):
2134 - answer (Any)
2135 - isCorrect (bool)
2136 - earnedPoints (int)
2138 Raises:
2139 HTTPException:
2140 - 403: If the student is unauthorized or the assignment is not found.
2141 - 400: If `time_allowed` is improperly formatted, or `date_created` is missing
2142 while `time_allowed` is configured. A missing/empty `time_allowed` is not an
2143 error — it means the assignment has no time limit and `remaining_time` is None.
2145 Example:
2146 {
2147 "message": "Successfully fetched submission summary.",
2148 "_id": "64c5f1234567890abcdef123",
2149 "isSubmitted": True,
2150 "grade": "75",
2151 "remarks": "passed",
2152 "totalAttemptsUsed": "1",
2153 "totalAttemptsAllowed": "3",
2154 "remainingTime": "00:10:15",
2155 "settings": {
2156 "time_allowed": "01:00:00",
2157 "allowed_attempts": 3,
2158 "show_score_after_submit": True,
2159 "show_correct_answers_after_submit": True
2160 },
2161 "totalScore": "15",
2162 "totalPoints": "20",
2163 "totalCorrectAnswers": "3",
2164 "totalQuestions": "5",
2165 "totalAnswersSubmitted": "4",
2166 "details": [
2167 {
2168 "questionId": "64c5f...",
2169 "questionType": "multiple-choice",
2170 "points": 5,
2171 "isFlagged": False,
2172 "correctAnswer": ["B"],
2173 "studentAnswer": {
2174 "answer": ["B"],
2175 "isCorrect": True,
2176 "earnedPoints": 5
2177 }
2178 },
2179 ...
2180 ]
2181 }
2182 """
2183 if not ObjectId.is_valid(assignment_uuid):
2184 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
2186 student_id = to_user_id(request.state.user_details["uuid"])
2187 email = request.state.user_details.get("email")
2188 assignment_oid = ObjectId(assignment_uuid)
2189 student_oid = ObjectId(student_id)
2191 # Step 1: Verify assignment exists and student is enrolled (authorization only).
2192 # Keeping the submission lookup separate so a missing submission returns 200
2193 # rather than collapsing both failures into an opaque 403. Matches by
2194 # student _id OR email (a duplicate/stale account sharing this
2195 # student's email can end up as the _id on a class's enrollment
2196 # record instead of the live account's).
2197 auth_pipeline = [
2198 {"$match": {"_id": assignment_oid}},
2199 {
2200 "$lookup": {
2201 "from": "class_collection",
2202 "let": {"assigned_class_ids": "$assigned_class"},
2203 "pipeline": [
2204 {
2205 "$match": {
2206 "$expr": {
2207 "$and": [
2208 {"$in": ["$_id", "$$assigned_class_ids"]},
2209 # EI-3386 (Allan Ninal, 2026-10-04): an assignment of deleted classes only is closed to students.
2210 {"$ne": ["$deleted", True]},
2211 {
2212 "$or": [
2213 {"$in": [student_oid, "$students._id"]},
2214 {
2215 "$in": [
2216 email or "",
2217 "$students.email",
2218 ]
2219 },
2220 ]
2221 },
2222 ]
2223 }
2224 }
2225 }
2226 ],
2227 "as": "authorized_classes",
2228 }
2229 },
2230 {"$match": {"authorized_classes.0": {"$exists": True}}},
2231 ]
2233 auth_results = (
2234 await db["assignments_collection"].aggregate(auth_pipeline).to_list(1)
2235 )
2236 if not auth_results:
2237 raise HTTPException(
2238 status_code=403, detail="Unauthorized or assignment not found."
2239 )
2241 assignment = auth_results[0]
2243 # Step 2: Fetch submission separately — absence is not an auth failure.
2244 submission = await db["submission_collection"].find_one(
2245 {
2246 "assignment_id": assignment_oid,
2247 "student_id": student_oid,
2248 }
2249 )
2251 if not submission:
2252 return {"isSubmitted": False}
2254 # A missing/empty time_allowed means the assignment has no time limit —
2255 # report no remaining-time value instead of erroring.
2256 time_allowed_str = assignment.get("settings", {}).get("time_allowed")
2257 if time_allowed_str:
2258 try:
2259 hours, minutes, seconds = map(int, time_allowed_str.split(":"))
2260 time_allowed_delta = timedelta(
2261 hours=hours, minutes=minutes, seconds=seconds
2262 )
2263 except ValueError:
2264 raise HTTPException(
2265 status_code=400,
2266 detail="Invalid time_allowed format (expected HH:MM:SS).",
2267 )
2269 # Ensure date_created is present and timezone-aware
2270 date_created = submission.get("date_created")
2271 if not date_created:
2272 raise HTTPException(
2273 status_code=400, detail="Submission missing date_created field."
2274 )
2276 if date_created.tzinfo is None:
2277 date_created = date_created.replace(tzinfo=timezone.utc)
2279 # Calculate remaining time
2280 now_utc = datetime.now(timezone.utc)
2281 end_time = date_created + time_allowed_delta
2282 remaining_seconds = max((end_time - now_utc).total_seconds(), 0)
2284 formatted_remaining = (
2285 f"{int(remaining_seconds) // 3600:02}:"
2286 f"{(int(remaining_seconds) % 3600) // 60:02}:"
2287 f"{int(remaining_seconds) % 60:02}"
2288 )
2289 else:
2290 formatted_remaining = None
2292 # Build question map for scored_details display (question text, choices, correct answers)
2293 question_map = {str(q["_id"]): q for q in submission.get("questions", [])}
2295 last_submitted = submission.get("last_submitted_answers")
2296 last_student_ans = submission.get("last_student_answers")
2298 scored_details = []
2299 student_answers = []
2300 total_score = 0
2301 correct_count = 0
2302 total_answers_submitted = 0
2304 settings = assignment.get("settings", {})
2305 show_correct = settings.get("show_correct_answers_after_submit", False)
2306 is_already_submitted = submission.get("is_submitted", False)
2307 # EI-1210: suppress correct-answer/score reveal while the submission is pending
2308 # teacher approval — the grade is hidden too; leaking totalScore/correctAnswer
2309 # would let a student infer their result before the teacher decides.
2310 _pending_review_status = submission.get("review_status", "none") == "pending"
2311 # Reveal correct answers only when the submission is completed AND the setting allows it
2312 # AND the submission is not awaiting late-submission approval.
2313 reveal_answers = (
2314 is_already_submitted and show_correct and not _pending_review_status
2315 )
2317 if last_submitted and last_student_ans is not None:
2318 # A previous attempt was fully submitted. The per-answer scoring
2319 # (last_student_answers, written by assignment_submit) never changes —
2320 # re-derived from it rather than re-run — but WHICH questions still
2321 # count toward the grade can change: if the teacher removes a question
2322 # from the assignment after grading, it must stop being counted (and
2323 # stop being shown) on every later view of this submission, so
2324 # total_score/correct_count/total_answers_submitted/grade below are
2325 # recalculated from the surviving answers only, instead of trusting
2326 # submission["total_score"]/["grade"] verbatim.
2327 live_question_ids = extract_live_question_ids(
2328 assignment.get("questions", [])
2329 )
2331 # Build scored_details for question display (text + choices come from question_map).
2332 # Reveal correctAnswer/isCorrect/earnedPoints only when reveal_answers is True.
2333 for ans in last_submitted:
2334 q_id = str(ans.get("questionId"))
2335 if q_id not in live_question_ids:
2336 continue
2337 q_doc = question_map.get(q_id)
2338 if not q_doc:
2339 continue
2340 detail: dict = {
2341 "_id": q_id,
2342 "question": q_doc.get("question"),
2343 "choices": q_doc.get("choices"),
2344 "questionType": q_doc.get("questionType"),
2345 "points": q_doc.get("points", 0),
2346 "isFlagged": ans.get("isFlagged", False),
2347 "groups": q_doc.get("groups"),
2348 "rows": q_doc.get("rows"),
2349 "rowHeaderLabel": q_doc.get("rowHeaderLabel"),
2350 }
2351 if reveal_answers:
2352 correct_answer = q_doc.get("correctAnswer", {}).get("answers")
2353 detail["correctAnswer"] = {
2354 "content": correct_answer,
2355 "answerDetails": q_doc.get("correctAnswer", {}).get(
2356 "answerDetails"
2357 ),
2358 }
2359 scored_details.append(detail)
2361 # For student_answers, include isCorrect/earnedPoints only when reveal_answers is True.
2362 # Also re-tallies total_score/correct_count/total_answers_submitted from
2363 # the surviving (non-deleted) answers — same accumulation rule as the
2364 # live-recompute branch below (is_correct → full credit, elif
2365 # earned_points → partial credit).
2366 for sa in last_student_ans:
2367 q_id = str(sa.get("questionId"))
2368 if q_id not in live_question_ids:
2369 continue
2370 sa_entry: dict = {
2371 "questionId": sa.get("questionId"),
2372 "questionType": sa.get("questionType"),
2373 "isFlagged": sa.get("isFlagged"),
2374 "answer": sa.get("answer"),
2375 }
2376 # Whether a person still has to read this answer is NOT part of
2377 # revealing the answer key, so it is sent either way. A student is
2378 # entitled to know their work is waiting to be marked — without it, a
2379 # held question looks exactly like one they got wrong.
2380 if sa.get("needsMarking"):
2381 sa_entry["needsMarking"] = True
2382 if reveal_answers:
2383 sa_entry["isCorrect"] = sa.get("isCorrect")
2384 sa_entry["earnedPoints"] = sa.get("earnedPoints")
2385 if sa.get("markFeedback"):
2386 sa_entry["markFeedback"] = sa.get("markFeedback")
2387 if "groupResults" in sa:
2388 sa_entry["groupResults"] = sa.get("groupResults")
2389 student_answers.append(sa_entry)
2391 if sa.get("isCorrect"):
2392 total_score += sa.get("earnedPoints") or 0
2393 correct_count += 1
2394 elif sa.get("earnedPoints"):
2395 total_score += sa.get("earnedPoints") or 0
2397 if self.is_meaningful_answer(sa.get("answer")):
2398 total_answers_submitted += 1
2400 else:
2401 # No completed submission yet — recalculate live from current submitted_answers.
2402 # Do NOT reveal correctAnswer/isCorrect/earnedPoints for in-progress attempts.
2403 for ans in submission.get("submitted_answers", []):
2404 q_id = str(ans.get("questionId"))
2405 q_doc = question_map.get(q_id)
2406 if not q_doc:
2407 continue
2409 student_answer = ans.get("answer", "")
2410 correct_answer = resolve_correct_answer(q_doc)
2411 max_points = q_doc.get("points", 0)
2412 is_flagged = ans.get("isFlagged", False)
2414 is_correct, earned_points, group_results = score_question(
2415 student_answer,
2416 correct_answer,
2417 q_doc.get("questionType"),
2418 max_points,
2419 graph_fingerprint=(q_doc.get("correctAnswer") or {}).get(
2420 "graphFingerprint"
2421 ),
2422 unordered=(q_doc.get("correctAnswer") or {}).get(
2423 "unordered", False
2424 ),
2425 )
2427 if is_correct:
2428 total_score += earned_points
2429 correct_count += 1
2430 elif earned_points:
2431 total_score += earned_points
2433 if self.is_meaningful_answer(student_answer):
2434 total_answers_submitted += 1
2436 detail = {
2437 "_id": q_id,
2438 "question": q_doc.get("question"),
2439 "choices": q_doc.get("choices"),
2440 "questionType": q_doc.get("questionType"),
2441 "points": max_points,
2442 "isFlagged": is_flagged,
2443 "groups": q_doc.get("groups"),
2444 "rows": q_doc.get("rows"),
2445 "rowHeaderLabel": q_doc.get("rowHeaderLabel"),
2446 }
2447 if reveal_answers:
2448 detail["correctAnswer"] = {
2449 "content": correct_answer,
2450 "answerDetails": q_doc.get("correctAnswer", {}).get(
2451 "answerDetails"
2452 ),
2453 }
2454 scored_details.append(detail)
2456 sa_entry = {
2457 "questionId": q_id,
2458 "questionType": ans.get("questionType"),
2459 "isFlagged": ans.get("isFlagged"),
2460 "answer": student_answer,
2461 }
2462 if reveal_answers:
2463 sa_entry["isCorrect"] = is_correct
2464 sa_entry["earnedPoints"] = earned_points
2465 if group_results is not None:
2466 sa_entry["groupResults"] = group_results
2467 student_answers.append(sa_entry)
2469 # Sum of each question's point value — the denominator for totalScore,
2470 # so the UI can render "earned / possible" instead of just the raw score.
2471 # Computed here (rather than only right before the response) because a
2472 # fully-submitted attempt's grade below is recalculated against it too.
2473 total_points = sum(q.get("points", 0) or 0 for q in scored_details)
2475 # For a fully-submitted attempt, recalculate the grade against the
2476 # assignment's CURRENT question list rather than trusting the stored
2477 # value, which goes stale the moment a question is removed after grading.
2478 if last_submitted and last_student_ans is not None:
2479 grade = (total_score / total_points * 100) if total_points > 0 else 0
2480 else:
2481 grade = submission.get("grade", 0)
2482 total_attempts = submission.get("total_attempts", 0)
2484 # READ-ONLY: this GET must NOT mutate submission state. It previously
2485 # flipped is_submitted=True once the timer expired (remaining_seconds==0),
2486 # which marked an unsubmitted DRAFT as "submitted" — conflating draft vs
2487 # final submission and presenting a false "already submitted / attempt
2488 # used" state after a student saved a draft and clicked Back
2489 # (EI-3271 / EI-TC-3614). Time-expiry submission is handled by the take
2490 # path (_handle_existing_submission) + the FE timer via the real submit
2491 # endpoint; a status read must not consume/close an attempt.
2493 if isinstance(grade, float) and grade.is_integer():
2494 grade_str = str(int(grade)) # Convert to int then str → "100"
2495 else:
2496 grade_str = str(grade) # Leave as is → e.g. "89.5" or "92"
2498 is_late = submission.get("is_late", False)
2499 review_status = submission.get("review_status", "none")
2500 is_pending_review = review_status == "pending"
2502 # The stored `remarks` field is a "passed"/"failed" verdict computed once,
2503 # against the assignment's passing_grade at submit time (or at late-submission
2504 # approval time), and persisted on the submission document. If a teacher edits
2505 # the assignment's passing_grade afterward, that stored verdict goes stale —
2506 # recompute it live against the current passing_grade instead of trusting the
2507 # stored value. Other remarks values ("rejected", a custom rejection reason)
2508 # are left untouched since they aren't pass/fail verdicts.
2509 stored_remarks = submission.get("remarks")
2510 if stored_remarks in ("passed", "failed") and isinstance(grade, (int, float)):
2511 current_passing_grade = assignment.get("passing_grade", 75)
2512 remarks = "passed" if grade >= current_passing_grade else "failed"
2513 else:
2514 remarks = stored_remarks
2516 response = {
2517 "message": "Successfully fetched submission summary.",
2518 "_id": str(submission["_id"]),
2519 "isSubmitted": bool(submission.get("last_submitted_answers"))
2520 or submission.get("is_submitted", False),
2521 "grade": None if is_pending_review else grade_str,
2522 "remarks": "Pending teacher approval" if is_pending_review else remarks,
2523 "totalAttemptsUsed": str(total_attempts),
2524 "totalAttemptsAllowed": str(settings.get("allowed_attempts", 0)),
2525 "remainingTime": formatted_remaining,
2526 "isLate": is_late,
2527 "reviewStatus": review_status,
2528 # Assignment settings (same shape as fetch_specific_assignment's
2529 # details.settings) so the FE can key UI decisions — e.g. whether to
2530 # render a Correct/Incorrect indicator — off the actual setting
2531 # instead of inferring it from field presence in `details`/`studentAnswers`.
2532 "settings": settings,
2533 # How many answers on this submission a person has still to mark. Sent so
2534 # the student can be told their grade is not final yet, rather than being
2535 # shown a number that will move later with no explanation.
2536 "pendingMarkingCount": submission.get("pending_marking_count", 0) or 0,
2537 }
2539 # EI-1210: suppress totalScore and totalCorrectAnswers while pending review
2540 # so the student cannot infer their result before teacher approval.
2541 if not is_pending_review and assignment.get("settings", {}).get(
2542 "show_score_after_submit", False
2543 ):
2544 response["totalScore"] = str(total_score)
2546 if not is_pending_review and assignment.get("settings", {}).get(
2547 "show_correct_answers_after_submit", False
2548 ):
2549 response["totalCorrectAnswers"] = str(correct_count)
2551 response.update(
2552 {
2553 # len(scored_details) rather than the stored total_questions — the
2554 # latter is frozen from submit time and goes stale the moment a
2555 # question is removed from the assignment afterward.
2556 "totalQuestions": str(len(scored_details)),
2557 "totalAnswersSubmitted": str(total_answers_submitted),
2558 "totalPoints": str(total_points),
2559 "details": scored_details,
2560 "studentAnswers": student_answers,
2561 "teacherComments": {
2562 qid: entry.get("comment", "")
2563 for qid, entry in (submission.get("teacher_comments") or {}).items()
2564 },
2565 }
2566 )
2568 return response