Coverage for server / services / teacher / teacher_assignment.py: 94%
910 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""Teacher assignment service.
3Modified by Allan Ninal — 2026-08-12
5`analytics_student_submission_fetch` now resolves the grade through the canonical
6gradebook resolver (server/utilities/gradebook.py) instead of formatting the stored
7value directly, which returned the literal string "None" for an ungraded submission
8and leaked a rejected late submission's provisional grade.
9"""
11import math
12from pymongo import ReturnDocument
13from bson.objectid import ObjectId
14from fastapi import HTTPException, Request, status
15from server.connection.database import db, staff_admin_db
16from server.models.assignment import (
17 MAX_ASSIGNMENT_QUESTIONS,
18 Assignment,
19 QuestionModel,
20 Submission,
21 UpdateAssignment,
22)
24# from server.models.question import Question
25from server.models.sharerequests import ShareRequest
26from server.utilities.assignment_update import (
27 REUSED_GLOBAL_LOCKED_DETAIL,
28 changes_title_or_questions,
29 dates_inverted,
30 is_reused_global_copy,
31 set_settings_per_key,
32)
33from server.utilities import model_parser
34from datetime import datetime, timedelta, timezone
35from server.utilities.helpers import serialized_response_object
36from server.utilities.gradebook import resolve_cell_grade, recalculate_submitted_grade
37import re
38from bson.errors import InvalidId
39from server.utilities.user_id_helper import to_user_id
40from server.utilities.html_sanitizer import strip_html
41from server.services.common.question_bank import pick_adaptive_question
42from server.services.student.student_assignment import StudentAssignmentsService
43from server.services.common.answer_checking import (
44 is_answer_correct,
45 MANUALLY_MARKED_TYPES,
46 MFE_FORMULA_RE,
47 resolve_correct_answer,
48 score_question,
49)
50from server.services.growthbook.context import resolve_targeting_context
51from server.services.growthbook.global_assignment_gate import (
52 enforce_global_assignment_access,
53 global_assignment_access_meta,
54)
55from server.services.growthbook.teacher_assignment_quota import (
56 enforce_teacher_assignment_quota,
57 _teacher_id_from_request,
58 _created_by_values,
59)
60from server.utilities.error_detail import safe_detail
61from pymongo.errors import DuplicateKeyError
62from server.utilities.assignment_dedupe import (
63 compute_dedupe_key,
64 resolve_duplicate_create,
65)
68class TeacherAssignmentsService:
69 """
70 Service class for managing assignment-related operations.
72 Handles creation, retrieval, updating, and deletion of assignments,
73 as well as submission and sharing functionality.
74 """
76 def __init__(self):
77 pass
79 async def create(self, new_assigment: Assignment, request: Request):
80 """
81 Create a new assignment for a teacher.
83 Args:
84 new_assigment (Assignment): Assignment details to be created
85 request (Request): The incoming request object containing teacher context
87 Returns:
88 dict: Created assignment details and success message
90 Raises:
91 HTTPException:
92 - 422 if assigned_class is empty or null
93 - 500 if creation fails
94 """
95 try:
96 # Validate assigned_class is not empty or null
97 if (
98 not new_assigment.assigned_class
99 or len(new_assigment.assigned_class) == 0
100 ):
101 raise HTTPException(
102 status_code=400, detail="Assigned Class is required."
103 )
105 await enforce_teacher_assignment_quota(
106 request, new_assigment.assigned_class
107 )
109 teacher_id = _teacher_id_from_request(request)
110 new_assigment.created_by = teacher_id
111 new_assigment.created_at = datetime.now(timezone.utc)
112 # new_assigment.copy_of = ObjectId(new_assigment.copy_of)
113 # EI-3450 / EI-3451 — the same guard as the common create path, because both
114 # routes insert into assignments_collection and a teacher can reach either.
115 new_assigment.dedupe_key = compute_dedupe_key(
116 created_by=teacher_id,
117 assigned_class=new_assigment.assigned_class,
118 title=new_assigment.title,
119 date_open=new_assigment.date_open,
120 date_close=new_assigment.date_close,
121 )
122 try:
123 await new_assigment.insert()
124 except DuplicateKeyError:
125 existing = await resolve_duplicate_create(new_assigment.dedupe_key)
126 return {
127 "detail": "Successfully Created Assignment",
128 "new_assignment": serialized_response_object(existing.model_dump()),
129 }
130 return {
131 "detail": "Successfully Created Assignment",
132 "new_assignment": serialized_response_object(
133 new_assigment.model_dump()
134 ),
135 }
136 except HTTPException:
137 raise
138 except Exception as e:
139 raise HTTPException(status_code=500, detail=safe_detail(e))
141 async def create_staff_assignment(
142 self, new_assigment: Assignment, request: Request
143 ):
144 """
145 Create a teacher class assignment from a staff/global template (reuse flow).
147 Unlike staff-portal global bank create, this persists to
148 ``assignments_collection`` with ``from: "staff"`` and an optional
149 ``copy_of`` reference to the source global assignment. Reused assignments
150 may share the global template's title — uniqueness is enforced only on
151 the global bank, not on per-class teacher copies.
152 """
153 try:
154 if (
155 not new_assigment.assigned_class
156 or len(new_assigment.assigned_class) == 0
157 ):
158 raise HTTPException(
159 status_code=status.HTTP_400_BAD_REQUEST,
160 detail="Assigned Class is required.",
161 )
163 await enforce_teacher_assignment_quota(
164 request, new_assigment.assigned_class
165 )
167 teacher_id = _teacher_id_from_request(request)
168 new_assigment.created_by = teacher_id
169 new_assigment.created_at = datetime.now(timezone.utc)
170 # EI-3450 / EI-3451 — the reuse flow inserts into the same collection and is
171 # just as retryable as the other two create paths, so it gets the same guard.
172 # The key is scoped to (teacher, class, title, dates), which leaves the
173 # documented behaviour above intact: reusing one global template across
174 # DIFFERENT classes keeps producing separate assignments, because the class
175 # set is part of the key. Only re-sending the same reuse into the same class
176 # on the same dates collides — and that is the duplicate we want to stop.
177 new_assigment.dedupe_key = compute_dedupe_key(
178 created_by=teacher_id,
179 assigned_class=new_assigment.assigned_class,
180 title=new_assigment.title,
181 date_open=new_assigment.date_open,
182 date_close=new_assigment.date_close,
183 )
184 try:
185 await new_assigment.insert()
186 except DuplicateKeyError:
187 existing = await resolve_duplicate_create(new_assigment.dedupe_key)
188 return {
189 "detail": "Successfully Created Assignment",
190 "new_assignment": serialized_response_object(
191 {**existing.model_dump(), "from": "staff"}
192 ),
193 }
195 await db["assignments_collection"].update_one(
196 {"_id": new_assigment.id},
197 {"$set": {"from": "staff"}},
198 )
200 return {
201 "detail": "Successfully Created Assignment",
202 "new_assignment": serialized_response_object(
203 {**new_assigment.model_dump(), "from": "staff"}
204 ),
205 }
206 except HTTPException:
207 raise
208 except Exception as e:
209 raise HTTPException(status_code=500, detail=safe_detail(e))
211 async def fetch_all(self, class_code: str, request: Request):
212 """
213 Fetch all assignments assigned to a class with the given class_code.
215 Args:
216 class_code (str): The class code to filter assignments.
217 request (Request): The FastAPI request object containing user authentication details.
219 Returns:
220 dict: A response with assignment details.
222 Raises:
223 HTTPException (401): If the user is not authenticated.
224 HTTPException (404): If the class with the given class_code does not exist,
225 or belongs to another teacher.
226 HTTPException (500): If an internal server error occurs.
227 """
228 try:
229 teacher_id = str(request.state.user_details["uuid"])
231 # Added by Allan Ninal — 2026-10-03 (EI-T250).
232 # The docstring promised 404 for an unknown class, but an unknown class
233 # code (or another teacher's) fell through the pipeline to 200 "No
234 # assignments found", which reads as a real class with no assignments.
235 # Same ownership lookup analytics_summary_fetch uses.
236 class_doc = await db["class_collection"].find_one(
237 {"class_code": class_code, "teacher._id": ObjectId(teacher_id)},
238 {"_id": 1},
239 )
240 if not class_doc:
241 raise HTTPException(status_code=404, detail="Class not found.")
243 pipeline = [
244 # Match the class with the given class_code
245 {"$match": {"class_code": class_code}},
246 # Lookup assignments where assigned_class matches the class _id
247 {
248 "$lookup": {
249 "from": "assignments_collection",
250 "localField": "_id",
251 "foreignField": "assigned_class",
252 "as": "assignments",
253 }
254 },
255 # Unwind assignments to filter only those created by the teacher
256 {"$unwind": "$assignments"},
257 {"$match": {"assignments.created_by": teacher_id}},
258 # Group back assignments into a list
259 {"$group": {"_id": "$_id", "assignments": {"$push": "$assignments"}}},
260 # Project the required fields
261 {"$project": {"_id": 0, "assignments": 1}},
262 ]
264 results = await db["class_collection"].aggregate(pipeline).to_list(None)
266 if not results:
267 return {
268 "detail": "No assignments found for the given class code",
269 "assignments": [],
270 }
272 return {
273 "detail": "Successfully fetched assignments",
274 "assignments": (
275 serialized_response_object(results[0]["assignments"])
276 if results
277 else []
278 ),
279 }
280 except HTTPException:
281 raise
282 except Exception as e:
283 raise HTTPException(status_code=500, detail=safe_detail(e))
285 async def staff_assignments_fetch(self, request: Request):
286 """
287 Fetch all global assignments that are not marked as deleted.
288 Tiered practice globals include feature_key gating metadata per row.
289 """
290 try:
291 if staff_admin_db is None:
292 raise HTTPException(
293 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
294 detail="Staff admin database is not configured.",
295 )
297 ctx = await resolve_targeting_context(request, require_district=True)
299 pipeline = [
300 {"$match": {"deleted": False}},
301 {
302 "$project": {
303 "_id": 1,
304 "title": 1,
305 "type": 1,
306 "format": 1,
307 "feature_key": 1,
308 "questions": 1,
309 "created_at": 1,
310 "instructions": 1,
311 "rubric": 1,
312 }
313 },
314 ]
316 cursor = staff_admin_db["global_assignments"].aggregate(pipeline)
317 assignments = await cursor.to_list(length=None)
319 enriched = []
320 for assignment in assignments:
321 assignment["questions"] = len(assignment.get("questions", []))
322 access = await global_assignment_access_meta(assignment, ctx)
323 row = serialized_response_object(assignment)
324 row.update(access)
325 enriched.append(row)
327 return {
328 "detail": "Successfully fetched assignments",
329 "assignments": enriched,
330 }
332 except HTTPException:
333 raise
334 except Exception as e:
335 raise HTTPException(status_code=500, detail=safe_detail(e))
337 _NOT_DELETED_FILTER = {
338 "$or": [{"deleted": False}, {"deleted": {"$exists": False}}],
339 }
341 @staticmethod
342 def _assignment_question_ids(assignment_doc) -> list:
343 """Extract ordered ObjectId question IDs stored on an assignment.
345 Modified by Allan Ninal — 2026-09-23 (EI-T115)
346 WHAT: take an Assignment DOCUMENT as well as a raw dict, and read a
347 QuestionModel's `.id` the way the dict branch reads "id".
348 WHY: next_fetch holds a Document whose `questions` are QuestionModel
349 objects. The old body sent those down the `else` branch, where
350 `ObjectId.is_valid(str(QuestionModel(...)))` is False, so every
351 question was silently DROPPED — the picker would have been handed
352 an empty exclusion list and could re-serve a question the student
353 had already answered. Existing dict callers are unaffected.
354 """
355 questions = (
356 assignment_doc.get("questions", [])
357 if isinstance(assignment_doc, dict)
358 else (getattr(assignment_doc, "questions", None) or [])
359 )
360 question_ids = []
361 for entry in questions:
362 raw_id = (
363 entry.get("id")
364 if isinstance(entry, dict)
365 else getattr(entry, "id", entry)
366 )
367 if ObjectId.is_valid(str(raw_id)):
368 question_ids.append(ObjectId(str(raw_id)))
369 return question_ids
371 async def _fetch_questions_for_assignment(
372 self, question_ids: list, points_overrides: dict | None = None
373 ) -> list:
374 """
375 Resolve assignment questions from teacher_questionbank first, then
376 global_questionbank for staff-authored items.
378 `points_overrides` (question id string -> points), when given, wins over
379 the question document's own `points` — this is how a teacher's per-
380 assignment point edit (assignments_collection.question_points_overrides)
381 is applied without mutating the shared question document.
382 """
383 if not question_ids:
384 return []
386 teacher_docs = (
387 await db["teacher_questionbank"]
388 .find({"_id": {"$in": question_ids}, **self._NOT_DELETED_FILTER})
389 .to_list(length=None)
390 )
391 question_map = {str(doc["_id"]): doc for doc in teacher_docs}
393 missing_ids = [qid for qid in question_ids if str(qid) not in question_map]
394 if missing_ids and staff_admin_db is not None:
395 global_docs = (
396 await staff_admin_db["global_questionbank"]
397 .find({"_id": {"$in": missing_ids}, **self._NOT_DELETED_FILTER})
398 .to_list(length=None)
399 )
400 for doc in global_docs:
401 question_map[str(doc["_id"])] = doc
403 ordered_questions = []
404 for qid in question_ids:
405 doc = question_map.get(str(qid))
406 if doc:
407 question = {**doc, "_id": str(doc["_id"])}
408 override = (points_overrides or {}).get(str(qid))
409 if override is not None:
410 question["points"] = override
411 ordered_questions.append(question)
412 return ordered_questions
414 async def staff_specific_assignment_fetch(
415 self, assignment_uuid: str, request: Request
416 ):
417 """
418 Fetch a specific global assignment by its UUID if it is not marked as deleted.
419 Args:
420 assignment_uuid (str): The UUID of the assignment to fetch.
421 request (Request): The incoming FastAPI request object.
422 Returns:
423 dict: A response containing a detail message and the assignment object.
424 Raises:
425 HTTPException:
426 - 404 if no assignment is found with the given UUID.
427 - 500 if an unexpected error occurs.
428 """
429 if not ObjectId.is_valid(assignment_uuid):
430 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
432 assignment_id = ObjectId(assignment_uuid)
434 try:
435 if staff_admin_db is None:
436 raise HTTPException(
437 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
438 detail="Staff admin database is not configured.",
439 )
441 ctx = await resolve_targeting_context(request, require_district=True)
443 fetched_assignment = await staff_admin_db["global_assignments"].find_one(
444 {"_id": assignment_id, "deleted": {"$ne": True}}
445 )
447 if not fetched_assignment:
448 raise HTTPException(
449 status_code=404,
450 detail="Assignment not found or already marked as deleted",
451 )
453 await enforce_global_assignment_access(fetched_assignment, ctx)
455 question_ids = self._assignment_question_ids(fetched_assignment)
456 questions = await self._fetch_questions_for_assignment(question_ids)
458 assignment_data = dict(fetched_assignment)
459 assignment_data["_id"] = str(assignment_data["_id"])
461 return {
462 "assignment": {
463 "details": serialized_response_object(assignment_data),
464 "questions": [serialized_response_object(q) for q in questions],
465 }
466 }
468 except HTTPException:
469 raise
470 except Exception as e:
471 raise HTTPException(
472 status_code=500, detail=safe_detail(e, "An unexpected error occurred")
473 )
475 async def fetch_specific_assignment(
476 self, assignment_uuid: str, request: Request
477 ) -> dict:
478 """
479 Fetch a specific assignment created by the authenticated teacher.
481 This method retrieves an assignment by its unique identifier and ensures that
482 the requesting teacher has access to it. It also populates the related questions
483 from the `global_questionbank` collection and formats ObjectId fields as strings.
485 Args:
486 assignment_uuid (str): The unique identifier of the assignment.
487 request (Request): The HTTP request object, which includes user details.
489 Returns:
490 dict: A dictionary containing separate assignment details and associated questions.
492 Raises:
493 HTTPException 400: If the provided assignment UUID is invalid.
494 HTTPException 404: If the assignment is not found or the teacher does not have access.
495 HTTPException 500: If an unexpected error occurs during processing.
496 """
497 # Validate ObjectId
498 if not ObjectId.is_valid(assignment_uuid):
499 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
501 assignment_id = ObjectId(assignment_uuid)
503 try:
504 # Ensure user details exist in request state
505 teacher_id = str(request.state.user_details.get("uuid"))
507 # Ensure assignment is not marked as deleted
508 fetched_assignment = await db["assignments_collection"].find_one(
509 {
510 "_id": ObjectId(assignment_id),
511 "created_by": teacher_id,
512 "deleted": {"$ne": True},
513 }
514 )
515 if not fetched_assignment:
516 raise HTTPException(
517 status_code=404,
518 detail="Assignment not found or already marked as deleted",
519 )
521 question_ids = self._assignment_question_ids(fetched_assignment)
522 points_overrides = fetched_assignment.get("question_points_overrides") or {}
523 questions = await self._fetch_questions_for_assignment(
524 question_ids, points_overrides
525 )
527 assignment_data = {
528 **fetched_assignment,
529 "_id": str(fetched_assignment["_id"]),
530 }
531 assignment_data.pop("questions", None)
533 return {
534 "assignment": {
535 "details": serialized_response_object(assignment_data),
536 "questions": [serialized_response_object(q) for q in questions],
537 }
538 }
540 except HTTPException:
541 raise
542 except Exception as e:
543 raise HTTPException(
544 status_code=500, detail=safe_detail(e, "An unexpected error occurred")
545 )
547 async def fetch(self, assignment_uuid: str, request: Request) -> dict:
548 """
549 Retrieve a specific assignment with its questions.
551 The assignment must belong to the requesting teacher.
552 Questions associated with the assignment are fetched in a single query.
554 Parameters
555 ----------
556 assignment_uuid : str
557 The unique identifier of the assignment to retrieve
558 request : Request
559 The FastAPI request object containing authenticated teacher details
561 Returns
562 -------
563 dict
564 A dictionary containing:
565 - Assignment: dict
566 - All assignment fields except question_ids
567 - questions: list[dict]
568 The full details of each question in the assignment
570 Raises
571 ------
572 HTTPException
573 400 - When the assignment_uuid is not a valid MongoDB ObjectId
574 404 - When the assignment is not found or teacher lacks access
575 500 - When an unexpected server error occurs
577 Examples
578 --------
579 >>> response = await teacher_service.fetch("507f1f77bcf86cd799439011", request)
580 >>> print(response)
581 {
582 "Assignment": {
583 "id": "507f1f77bcf86cd799439011",
584 "title": "Math Quiz",
585 "questions": [
586 {"id": "507f1f77bcf86cd799439012", "text": "What is 2+2?", ...},
587 ...
588 ],
589 ...
590 }
591 }
592 """
593 # Validate ObjectId first
594 try:
595 assignment_id = ObjectId(assignment_uuid)
596 except Exception:
597 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
599 try:
600 # Fetch assignment and verify teacher access
601 fetched_assignment = await Assignment.find_one(
602 {
603 "_id": assignment_id,
604 "teacher_id": str(request.state.user_details["uuid"]),
605 }
606 )
608 if not fetched_assignment:
609 raise HTTPException(
610 status_code=404,
611 detail="Assignment not found or you don't have access to this assignment",
612 )
614 # Fetch questions in a single query
615 raw_questions = (
616 await db["question_collection"]
617 .find({"_id": {"$in": fetched_assignment.question_ids}})
618 .to_list(None)
619 )
621 # Parse questions to remove dates and format IDs
622 questions = model_parser.parse_response(raw_questions, exclude_dates=True)
624 return {
625 "Assignment": {
626 **fetched_assignment.model_dump(exclude={"question_ids"}),
627 "questions": questions,
628 }
629 }
631 except HTTPException:
632 raise
633 except Exception as e:
634 raise HTTPException(
635 status_code=500, detail=safe_detail(e, "An unexpected error occurred")
636 )
638 async def next_fetch(
639 self,
640 request: Request,
641 assignment_uuid: str,
642 prev_difficulty: str,
643 prev_remarks: str,
644 question_classification: str,
645 ):
646 """
647 Get next question for adaptive testing based on previous performance.
649 Args:
650 request (Request): The incoming request object
651 assignment_uuid (str): Unique identifier of the assignment
652 prev_difficulty (str): Difficulty of previous question
653 prev_remarks (str): Performance remarks on previous question
654 question_classification (str): Classification of questions to select from
656 Returns:
657 dict: Next question details
659 Raises:
660 HTTPException: If assignment not found or no suitable question available
661 """
662 # Modified by Allan Ninal — 2026-09-23 (EI-T401)
663 # WHAT: reject a malformed id before ObjectId() sees it.
664 # WHY: ObjectId("invalid-uuid-@@@") raises bson.errors.InvalidId. There is no
665 # try/except here at all, so it escaped as an unhandled 500 instead of a
666 # controlled 400 — same defect the common twin had.
667 if not ObjectId.is_valid(assignment_uuid):
668 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
670 # Retrieve the assignment
671 fetched_assignment = await Assignment.find_one(
672 {"_id": ObjectId(assignment_uuid)}
673 )
674 if not fetched_assignment:
675 raise HTTPException(status_code=404, detail="Assignment not found")
677 # Modified by Allan Ninal — 2026-09-23 (EI-T115)
678 # WHAT: refuse to serve a question into an assignment already at the cap.
679 # WHY: Beanie does not re-validate on save, so a 101st question would be
680 # written and then break `validate_questions` on every LOAD — bricking
681 # the assignment for every reader, not just this call.
682 if len(fetched_assignment.questions or []) >= MAX_ASSIGNMENT_QUESTIONS:
683 raise HTTPException(
684 status_code=400,
685 detail=(
686 "maximum number of questions allowed is "
687 f"{MAX_ASSIGNMENT_QUESTIONS}"
688 ),
689 )
691 # Determine new difficulty based on previous difficulty and remarks.
692 # The rungs must be real stored values: fetch_random_question does
693 # difficulty.title(), and the bank holds Easy / Average / Advance.
694 # This ladder previously topped out at "hard" -> "Hard", which matches
695 # ZERO questions, so a student who answered an Average question correctly
696 # got HTTP 400 mid-assignment instead of a harder question.
697 if prev_difficulty == "easy" and prev_remarks == "correct":
698 new_difficulty = "average"
699 elif prev_difficulty == "easy" and prev_remarks == "incorrect":
700 new_difficulty = "easy"
701 elif prev_difficulty == "average" and prev_remarks == "incorrect":
702 new_difficulty = "easy"
703 elif prev_difficulty == "average" and prev_remarks == "correct":
704 new_difficulty = "advance"
705 elif prev_difficulty == "advance" and prev_remarks == "incorrect":
706 new_difficulty = "average"
707 elif prev_difficulty == "advance" and prev_remarks == "correct":
708 new_difficulty = "advance"
709 else:
710 raise HTTPException(status_code=400, detail="Something went wrong")
712 # Modified by Allan Ninal — 2026-09-23 (EI-T115 / EI-T400 / EI-T401)
713 # WHAT: read and append `questions` instead of the non-existent `question_ids`.
714 # WHY: the field was renamed on 2025-03-25 (dc62c45) and this path was missed,
715 # so every call raised AttributeError -> 500, happy path included. The
716 # appended item keeps the {id, category, topic} shape because item
717 # analysis below does q["id"] and would break on a bare id string.
718 # Modified by Allan Ninal — 2026-09-23 (EI-T115) — see the twin in
719 # server/services/common/assignments.py for the full reasoning: the old
720 # picker read a collection that exists in no database, so this endpoint
721 # could never serve a question. Scoped to the assignment creator's own
722 # bank plus the curated global bank, soft-deleted rows excluded.
723 new_question = await pick_adaptive_question(
724 difficulty=new_difficulty,
725 classification=question_classification,
726 exclude_ids=self._assignment_question_ids(fetched_assignment),
727 creator_id=fetched_assignment.created_by,
728 )
730 if new_question:
731 # Add the new question to the assignment
732 fetched_assignment.questions.append(QuestionModel(id=new_question["_id"]))
734 # Update the assignment in the database
735 await fetched_assignment.save()
736 question_id = new_question["_id"]
737 del new_question["_id"]
738 new_question["id"] = str(question_id)
739 return new_question # Return the new question
741 raise HTTPException(
742 status_code=400, detail="Something wrong fetching a new question."
743 )
745 async def answer_update(
746 self, student_assignment_response: Submission, request: Request
747 ):
748 """
749 Record a student's submission for an assignment.
751 Args:
752 student_assignment_response (Submission): Student's submission details
753 request (Request): The incoming request object containing student context
755 Returns:
756 dict: Submission confirmation and details
758 Raises:
759 HTTPException: If assignment not found or submission fails
760 """
761 assignment_uuid = student_assignment_response.assignment_id
763 # Modified by Allan Ninal — 2026-09-23 (EI-T116)
764 # WHAT: reject a malformed assignment id before ObjectId() sees it.
765 # WHY: ObjectId("not-a-valid-oid") raises bson.errors.InvalidId, which
766 # the catch-all below re-raised as 500 with the raw bson error text
767 # as the detail — a server-fault status plus an internal exception
768 # message, for what is simply a bad request. Same guard already
769 # used elsewhere in this file (e.g. next_fetch).
770 if not ObjectId.is_valid(assignment_uuid):
771 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
773 try:
774 student_id = to_user_id(request.state.user_details["uuid"])
776 # check if an Assignment exist with a given assignment_uuid
777 fetched_assignment = await Assignment.find(
778 {"_id": ObjectId(assignment_uuid)}
779 ).to_list()
781 if not fetched_assignment:
782 raise HTTPException(status_code=404, detail="Assignment not found")
784 # Modified by Allan Ninal — 2026-09-23 (EI-T116)
785 # WHAT: str(student_id) — Submission.student_id is declared
786 # Optional[str], but `to_user_id` returns a raw bson.ObjectId
787 # whenever the caller's uuid is ObjectId-shaped (every real
788 # account). Assigning it directly bypasses Beanie/pydantic
789 # validation (no validate_assignment on this model), so the
790 # insert succeeded with an ObjectId sitting in a str field.
791 # WHY: the response below serialises `student_assignment_response`
792 # through FastAPI's jsonable_encoder -> model_dump(), which
793 # CANNOT serialise a bare ObjectId and raised
794 # PydanticSerializationError — AFTER the insert had already
795 # committed. The caller was told 500 for a submission that was,
796 # in fact, recorded: a retry-on-error client would duplicate
797 # it. Verified live on QA: the DB row existed with
798 # student_id: ObjectId(...) despite the 500 response.
799 student_assignment_response.student_id = str(student_id)
800 await student_assignment_response.insert()
802 return {
803 "detail": "Successfully Recorded Response",
804 "assignment_response": student_assignment_response,
805 }
806 except HTTPException:
807 # Let deliberate HTTP errors through; without this the method's own
808 # 400/403/404 was swallowed by the catch-all and re-thrown as a 500,
809 # which the frontend renders as a maintenance dialog.
810 raise
811 except Exception as e:
812 raise HTTPException(status_code=500, detail=safe_detail(e))
814 async def share(self, share_request: ShareRequest, request: Request):
815 """
816 Share an assignment with other users.
818 Args:
819 share_request (ShareRequest): Sharing details
820 request (Request): The incoming request object containing teacher context
822 Returns:
823 dict: Share confirmation and details
825 Raises:
826 HTTPException: If sharing fails
827 """
828 try:
829 teacher_id = str(request.state.user_details["uuid"])
830 share_request.sender_id = teacher_id
831 share_request = await share_request.save()
832 return {
833 "detail": "Successfully Shared Assignment",
834 "share_request": share_request,
835 }
836 except Exception as e:
837 raise HTTPException(status_code=500, detail=safe_detail(e))
839 async def analytics_summary_fetch(
840 self, class_code: str, assignment_uuid: str, request: Request
841 ) -> dict:
842 """
843 Retrieve detailed analytics for a specific assignment created by a teacher.
845 Args:
846 class_code (str): Code identifying the class.
847 assignment_uuid (str): UUID of the assignment.
848 request (Request): HTTP request containing teacher context.
850 Returns:
851 dict: Analytics summary with assignment details, stats, and category breakdown.
853 Raises:
854 HTTPException:
855 - 404 if assignment/class is not found or access is denied.
856 - 400 for validation errors.
857 - 500 for unexpected server errors.
858 """
859 try:
860 assignment_oid = ObjectId(assignment_uuid)
861 except (InvalidId, TypeError):
862 raise HTTPException(
863 status_code=status.HTTP_400_BAD_REQUEST,
864 detail="Assignment UUID is not a valid ObjectId.",
865 )
866 try:
867 teacher_id = str(request.state.user_details["uuid"])
868 assignment_oid = ObjectId(assignment_uuid)
870 # Ensure assignment belongs to teacher
871 assignment = await self._get_teacher_assignment(assignment_uuid, teacher_id)
872 if not assignment:
873 raise HTTPException(status_code=404, detail="Assignment not found.")
875 # Retrieve class and filter active/enrolled students
876 class_doc = await db["class_collection"].find_one(
877 {"class_code": class_code, "teacher._id": ObjectId(teacher_id)}
878 )
879 if not class_doc:
880 raise HTTPException(status_code=404, detail="Class not found.")
882 enrolled_student_ids = [
883 student["_id"]
884 for student in class_doc.get("students", [])
885 if student.get("status") != "REMOVED"
886 ]
887 if not enrolled_student_ids:
888 return self._build_empty_response(assignment)
890 # Fetch SUBMITTED submissions from enrolled students. The is_submitted
891 # filter matches the item-analysis path and is required for correctness:
892 # total_submissions feeds both the "X of Y" card and (since EI-188 #182)
893 # the pass/fail denominator + the category-% denominator, so counting
894 # in-progress (is_submitted=False) rows would inflate failures and
895 # deflate topic %. (EI-188 r2)
896 # EI-1210: exclude pending/rejected late submissions so they don't
897 # skew averages. Legacy docs (no review_status field) and approved late
898 # work still pass the $nin filter.
899 submissions = (
900 await db["submission_collection"]
901 .find(
902 {
903 "assignment_id": assignment_oid,
904 "student_id": {"$in": enrolled_student_ids},
905 "is_submitted": True,
906 "review_status": {"$nin": ["pending", "rejected"]},
907 }
908 )
909 .to_list(length=None)
910 )
912 total_questions = len(assignment.questions or [])
913 total_submissions = len(submissions)
914 total_enrolled_students = len(enrolled_student_ids)
916 if not submissions:
917 return self._build_empty_response(
918 assignment,
919 total_questions,
920 total_submissions,
921 total_enrolled_students,
922 )
924 grades = self._extract_valid_grades(submissions)
925 total_passed, total_failed = self._count_pass_fail(
926 grades, float(assignment.passing_grade or 0), total_submissions
927 )
928 scores = self._extract_valid_scores(submissions)
930 analytics = self._calculate_analytics(scores)
931 analytics.update(
932 {
933 "total_passed": f"{total_passed}",
934 "total_failed": f"{total_failed}",
935 "total_submissions": f"{total_submissions} of {total_enrolled_students}",
936 "total_questions": f"{total_questions}",
937 }
938 )
940 categories = self._build_category_stats(
941 questions=assignment.questions or [],
942 submissions=submissions,
943 is_staar=(assignment.type == "STAAR"),
944 total_submissions=total_submissions,
945 )
947 return {
948 "assignment_details": self._serialize_assignment_details(assignment),
949 "assignment_summary": analytics,
950 "categories": categories,
951 }
952 except HTTPException as http_exc:
953 raise http_exc
954 except Exception as error:
955 raise HTTPException(status_code=500, detail=safe_detail(error))
957 def _build_empty_response(
958 self,
959 assignment,
960 total_questions: int = 0,
961 total_submissions: int = 0,
962 total_enrolled_students: int = 0,
963 ) -> dict:
964 return {
965 "assignment_details": self._serialize_assignment_details(assignment),
966 "assignment_summary": {
967 **self._get_empty_analytics(),
968 "total_passed": "0",
969 "total_failed": "0",
970 "total_submissions": f"{total_submissions} of {total_enrolled_students}",
971 "total_questions": f"{total_questions}",
972 },
973 "categories": [],
974 }
976 def _calculate_analytics(self, scores: list[float]) -> dict:
977 """
978 Compute detailed statistics on a list of submission scores.
979 Args:
980 scores (list[float]): List of numeric scores from submissions (must be sorted).
981 Returns:
982 dict: Dictionary of calculated score metrics including min, max, range, mean,
983 median, quartiles, and standard deviation, formatted as strings.
984 Returns empty analytics dict if scores list is empty.
985 """
986 if not scores:
987 return self._get_empty_analytics()
989 total = len(scores)
990 minimum_score = scores[0]
991 maximum_score = scores[-1]
992 mean_score = sum(scores) / total
993 score_range = maximum_score - minimum_score
994 variance = sum((s - mean_score) ** 2 for s in scores) / total
995 std_dev_score = math.sqrt(variance)
997 return {
998 "minimum": self._format_score(minimum_score),
999 "maximum": self._format_score(maximum_score),
1000 "range": self._format_score(score_range),
1001 "mean": self._format_score(mean_score),
1002 "median": self._format_score(self._percentile(scores, 50)),
1003 "first_quartile": self._format_score(self._percentile(scores, 25)),
1004 "third_quartile": self._format_score(self._percentile(scores, 75)),
1005 "std_dev": self._format_score(std_dev_score),
1006 }
1008 def _get_empty_analytics(self, default_value: str = "0.0") -> dict:
1009 """
1010 Generate a dictionary with zeroed-out or default-value analytics fields.
1011 Args:
1012 default_value (str): The default string to assign to all analytics fields (default is "0.0").
1013 Returns:
1014 dict: Dictionary with all analytics metrics set to the default_value.
1015 """
1016 metrics = [
1017 "minimum",
1018 "maximum",
1019 "range",
1020 "mean",
1021 "median",
1022 "first_quartile",
1023 "third_quartile",
1024 "std_dev",
1025 ]
1026 return {metric: default_value for metric in metrics}
1028 def _format_score(self, value: float | int, digits: int = 1) -> str:
1029 """
1030 Format a numeric score by rounding to a specified number of decimal places.
1031 Args:
1032 value (float | int): Numeric score to format.
1033 digits (int): Number of decimal places to round to (default is 1).
1034 Returns:
1035 str: The rounded score as a string.
1036 Raises:
1037 TypeError: If the input value is not an int or float.
1038 """
1039 if not isinstance(value, (int, float)):
1040 raise TypeError(f"Score must be a number, got {type(value).__name__}")
1041 return f"{round(value, digits):.{digits}f}"
1043 def _percentile(self, sorted_scores: list[float], percentile: float) -> float:
1044 """
1045 Calculate the score at a given percentile from a sorted list using linear interpolation.
1046 Args:
1047 sorted_scores (list[float]): Sorted list of numeric scores.
1048 percentile (float): Percentile to compute (0 <= percentile <= 100).
1049 Returns:
1050 float: Interpolated score at the requested percentile.
1051 Raises:
1052 ValueError: If sorted_scores is empty or percentile is out of valid range.
1053 """
1054 if not sorted_scores:
1055 raise ValueError("The scores list cannot be empty.")
1056 if not 0 <= percentile <= 100:
1057 raise ValueError("Percentile must be between 0 and 100.")
1059 if percentile == 100:
1060 return sorted_scores[-1]
1062 pos = (len(sorted_scores) - 1) * (percentile / 100)
1063 lower_index = math.floor(pos)
1064 upper_index = math.ceil(pos)
1065 lower_value = sorted_scores[lower_index]
1066 upper_value = sorted_scores[upper_index]
1068 if lower_index == upper_index:
1069 return lower_value
1071 # Linear interpolation
1072 weight = pos - lower_index
1073 return lower_value + weight * (upper_value - lower_value)
1075 async def _get_teacher_assignment(self, assignment_uuid: str, teacher_id: str):
1076 """
1077 Fetch an assignment document created by a specific teacher.
1078 Args:
1079 assignment_uuid (str): The assignment's unique identifier.
1080 teacher_id (str): The teacher's unique identifier.
1081 Returns:
1082 Assignment or None: The assignment document if found and owned by the teacher; otherwise None.
1083 """
1084 return await Assignment.find_one(
1085 {"_id": ObjectId(assignment_uuid), "created_by": str(teacher_id)}
1086 )
1088 async def _authorize_teacher_student_access(
1089 self, class_code: str, assignment_uuid: str, student_id: str, teacher_id: str
1090 ) -> Assignment:
1091 """
1092 Shared ownership/enrollment guard for the "Individual Answers" analytics
1093 surface (submission fetch + comment add/update/delete): the assignment
1094 must be owned by the requesting teacher, the class must be owned by the
1095 requesting teacher, and the target student must be currently enrolled
1096 in that class.
1098 Args:
1099 class_code (str): Code of the class the assignment/student are scoped to.
1100 assignment_uuid (str): The assignment's ObjectId string (already validated).
1101 student_id (str): The target student's ObjectId string (already validated).
1102 teacher_id (str): The requesting teacher's id.
1104 Returns:
1105 Assignment: The owned assignment document.
1107 Raises:
1108 HTTPException 404: If the assignment/class is not owned by the teacher,
1109 or the student is not (currently) enrolled in the class.
1110 """
1111 assignment = await self._get_teacher_assignment(assignment_uuid, teacher_id)
1112 if not assignment:
1113 raise HTTPException(status_code=404, detail="Assignment not found.")
1115 class_doc = await db["class_collection"].find_one(
1116 {
1117 "class_code": class_code,
1118 "teacher._id": ObjectId(teacher_id),
1119 }
1120 )
1121 if not class_doc:
1122 raise HTTPException(status_code=404, detail="Class not found.")
1124 student_oid = ObjectId(student_id)
1125 is_enrolled = any(
1126 student.get("_id") == student_oid and student.get("status") != "REMOVED"
1127 for student in class_doc.get("students", [])
1128 )
1129 if not is_enrolled:
1130 raise HTTPException(
1131 status_code=404, detail="Student not found in this class."
1132 )
1134 return assignment
1136 def _extract_valid_scores(self, submissions: list) -> list[float]:
1137 """
1138 Extract and sort valid numeric total scores from a list of submissions.
1139 Args:
1140 submissions (list): List of submission documents.
1141 Returns:
1142 list[float]: Sorted list of valid total scores.
1143 Raises:
1144 HTTPException: 400 if any total_score has invalid format or type.
1145 """
1146 try:
1147 return sorted(
1148 [
1149 float(sub["total_score"])
1150 for sub in submissions
1151 if isinstance(sub.get("total_score"), (int, float))
1152 ]
1153 )
1154 except Exception:
1155 raise HTTPException(
1156 status_code=400, detail="Invalid score format in submissions"
1157 )
1159 def _extract_valid_grades(self, submissions: list) -> list[float]:
1160 """
1161 Extract and sort valid numeric total grades from a list of submissions.
1162 Args:
1163 submissions (list): List of submission documents.
1164 Returns:
1165 list[float]: Sorted list of valid total grades.
1166 Raises:
1167 HTTPException: 400 if any grade has invalid format or type.
1168 """
1169 try:
1170 return sorted(
1171 [
1172 float(sub["grade"])
1173 for sub in submissions
1174 if isinstance(sub.get("grade"), (int, float))
1175 ]
1176 )
1177 except Exception:
1178 raise HTTPException(
1179 status_code=400, detail="Invalid grade format in submissions"
1180 )
1182 def _count_pass_fail(
1183 self,
1184 grades: list[float],
1185 passing_grade: float,
1186 total_submissions: int | None = None,
1187 ) -> tuple[int, int]:
1188 """
1189 Count the number of passing and failing submissions based on a passing grade threshold.
1190 Args:
1191 grades (list[float]): List of numeric grades from gradable submissions.
1192 passing_grade (float): Grade threshold to pass.
1193 total_submissions (int | None): Total submission count. When given,
1194 failures are derived from it so passed + failed == total_submissions
1195 — otherwise a submission with a null/missing grade would silently
1196 vanish from the pass/fail breakdown (the page showed "0 passed,
1197 0 failed" for N real submissions). Defaults to len(grades) for
1198 back-compat. (EI-188)
1199 Returns:
1200 tuple[int, int]: Number of passing and failing submissions.
1201 """
1202 total_passed = sum(1 for s in grades if s >= passing_grade)
1203 denominator = (
1204 total_submissions if total_submissions is not None else len(grades)
1205 )
1206 total_failed = denominator - total_passed
1207 return total_passed, total_failed
1209 def _build_category_stats(
1210 self, questions: list, submissions: list, is_staar: bool, total_submissions: int
1211 ) -> list[dict]:
1212 """
1213 Build statistics for question categories or topics including counts and percentage correct.
1214 Args:
1215 questions (list): List of question objects.
1216 submissions (list): List of submission documents containing answers.
1217 is_staar (bool): Whether the assignment is of type 'STAAR' (affects label key).
1218 total_submissions (int): Total number of submissions.
1219 Returns:
1220 list[dict]: List of dictionaries containing category/topic, question count, and percentage correct.
1221 """
1222 label_key = "category" if is_staar else "topic"
1223 label_counter = {}
1224 label_correct_counts = {}
1226 for q in questions:
1227 label = getattr(q, label_key, None)
1228 if label:
1229 label_counter[label] = label_counter.get(label, 0) + 1
1230 label_correct_counts[label] = 0
1232 for sub in submissions:
1233 # Per-answer correctness is persisted under `last_student_answers`
1234 # (each entry: {questionId, isCorrect, ...}) — there is NO top-level
1235 # `answers`/`is_correct` field, so the previous read silently scored
1236 # every topic 0%. (EI-188)
1237 for ans in sub.get("last_student_answers", []):
1238 if not ans.get("isCorrect"):
1239 continue
1240 qid = ans.get("questionId")
1241 question = next((q for q in questions if str(q.id) == str(qid)), None)
1242 if question:
1243 label = getattr(question, label_key, None)
1244 if label in label_correct_counts:
1245 label_correct_counts[label] += 1
1247 return [
1248 {
1249 "topic": label,
1250 "questions": str(count),
1251 # % correct = correct answers / total attempts at this topic, where
1252 # total attempts = submissions × questions-in-topic. Dividing by
1253 # total_submissions alone over-counts (could exceed 100%) for
1254 # multi-question topics. (EI-188)
1255 "correct": (
1256 f"{round((label_correct_counts[label] / (total_submissions * count)) * 100)}%"
1257 if total_submissions and count
1258 else "0%"
1259 ),
1260 }
1261 for label, count in sorted(label_counter.items())
1262 ]
1264 def _serialize_assignment_details(self, assignment: Assignment) -> dict:
1265 """
1266 Serialize assignment details into a dictionary format suitable for JSON response.
1267 Args:
1268 assignment (Assignment): Assignment object to serialize.
1269 Returns:
1270 dict: Dictionary with assignment title, description, type, open and close dates as ISO strings.
1271 """
1272 return {
1273 "title": assignment.title,
1274 "description": assignment.description,
1275 "type": assignment.type,
1276 "date_open": (
1277 assignment.date_open.isoformat() if assignment.date_open else None
1278 ),
1279 "date_close": (
1280 assignment.date_close.isoformat() if assignment.date_close else None
1281 ),
1282 }
1284 async def analytics_item_analysis_fetch(
1285 self, class_code: str, assignment_uuid: str, request: Request
1286 ):
1287 """
1288 Perform item analysis on an assignment by aggregating student answers and comparing them with correct answers.
1290 This method is used by a teacher to fetch analysis data of a given assignment. It ensures that the assignment
1291 belongs to the requesting teacher, gathers all student submissions for that assignment, and calculates per-question
1292 statistics such as total correct and incorrect answers, as well as the frequency of each student-selected option.
1294 Args:
1295 assignment_uuid (str): The UUID string of the assignment to analyze.
1296 request (Request): The FastAPI request object, which contains authenticated teacher details.
1298 Returns:
1299 dict: A dictionary with the key `"item_analysis"` containing a list of analysis results for each question.
1300 Each result includes:
1301 - question type
1302 - category
1303 - student expectation
1304 - correct answer (for multiple-choice and checkbox)
1305 - total correct and incorrect counts
1306 - a breakdown of student-selected answers
1308 Example:
1309 {
1310 "total_submissions": 10,
1311 "item_analysis": [
1312 {
1313 "_id": "questionId1",
1314 "question_type": "multiple-choice",
1315 "category": "Math",
1316 "student_expectation": "Apply multiplication",
1317 "correct_answer": "C",
1318 "points": "2",
1319 "total_correct": 10,
1320 "total_incorrect": 5,
1321 "student_answers": [
1322 {"letter": "A", "text": "3", "total": 2},
1323 {"letter": "B", "text": "5", "total": 1},
1324 {"letter": "C", "text": "6", "total": 10},
1325 {"letter": "D", "text": "9", "total": 2}
1326 ]
1327 },
1328 ...
1329 ]
1330 }
1331 """
1332 try:
1333 assignment_oid = ObjectId(assignment_uuid)
1334 except (InvalidId, TypeError):
1335 raise HTTPException(
1336 status_code=status.HTTP_400_BAD_REQUEST,
1337 detail="Assignment UUID is not a valid ObjectId.",
1338 )
1339 try:
1340 teacher_id = str(request.state.user_details["uuid"])
1341 assignment_oid = ObjectId(assignment_uuid)
1343 # Fetch assignment created by the teacher
1344 assignment = await self._fetch_assignment(assignment_oid, teacher_id)
1345 # Modified by Allan Ninal — 2026-10-03 (EI-T276).
1346 # WAS: return {"item_analysis": []} with 200. An unknown assignment, or
1347 # another teacher's, looked like a real assignment with no answers yet.
1348 # analytics_summary_fetch answers 404 for the same lookup.
1349 if not assignment:
1350 raise HTTPException(status_code=404, detail="Assignment not found.")
1352 # Extract all question IDs from the assignment
1353 question_ids = [q["id"] for q in assignment["questions"]]
1354 points_overrides = assignment.get("question_points_overrides") or {}
1356 # Fetch student UUIDs currently enrolled in the class
1357 class_doc = await db["class_collection"].find_one(
1358 {"class_code": class_code, "teacher._id": ObjectId(teacher_id)}
1359 )
1360 if not class_doc:
1361 return {"item_analysis": []}
1363 enrolled_student_ids = [
1364 student["_id"]
1365 for student in class_doc.get("students", [])
1366 if student.get("status") != "REMOVED"
1367 ]
1368 if not enrolled_student_ids:
1369 return {"item_analysis": []}
1371 # Fetch only completed submissions from enrolled students.
1372 # In-progress submissions carry default empty answer slots (is_submitted=False),
1373 # which would otherwise be counted as incorrect and skew the analysis.
1374 # EI-1210: exclude pending/rejected late submissions so they don't skew item analysis.
1375 submissions = (
1376 await db["submission_collection"]
1377 .find(
1378 {
1379 "assignment_id": assignment_oid,
1380 "student_id": {"$in": enrolled_student_ids},
1381 "is_submitted": True,
1382 "review_status": {"$nin": ["pending", "rejected"]},
1383 }
1384 )
1385 .to_list(length=None)
1386 )
1388 # Group relevant submissions by question
1389 submissions_map = self._group_filtered_submissions_by_question(submissions)
1391 # Fetch teacher's questionbank and map questions by ID
1392 questionbank_map = await self._fetch_questionbank_map(question_ids)
1394 total_enrolled_students = len(enrolled_student_ids)
1396 # Analyze each question
1397 return {
1398 "total_submissions": f"{len(submissions)}/{total_enrolled_students}",
1399 "item_analysis": [
1400 self._analyze_question(
1401 str(qid),
1402 submissions_map,
1403 questionbank_map,
1404 total_enrolled_students,
1405 points_overrides,
1406 )
1407 for qid in question_ids
1408 ],
1409 }
1410 except HTTPException as http_exc:
1411 raise http_exc
1412 except Exception as error:
1413 raise HTTPException(status_code=500, detail=safe_detail(error))
1415 def _group_filtered_submissions_by_question(self, submissions: list) -> dict:
1416 """
1417 Group answers from provided submissions (already filtered by student enrollment) by question ID.
1419 Submissions persist student responses under ``submitted_answers`` (each entry carries
1420 ``questionId`` and ``answer``); there is no top-level ``answers`` field, so reading it
1421 yielded an empty map and reported 0 correct/0 incorrect for every question.
1422 """
1423 submissions_map = {}
1424 for sub in submissions:
1425 for ans in sub.get("submitted_answers", []):
1426 qid = str(ans.get("questionId"))
1427 if qid:
1428 submissions_map.setdefault(qid, []).append(ans)
1429 return submissions_map
1431 async def _fetch_assignment(self, assignment_oid: ObjectId, teacher_id: str):
1432 """Fetch the assignment created by the given teacher."""
1433 # `deleted: {$ne: True}` (not `== False`) so assignments created before the
1434 # `deleted` field existed (key absent) still match — otherwise Item Analysis
1435 # returns no assignment → empty table (EI-3390). Matches the other reads.
1436 return await db["assignments_collection"].find_one(
1437 {
1438 "_id": assignment_oid,
1439 "deleted": {"$ne": True},
1440 "created_by": str(teacher_id),
1441 }
1442 )
1444 async def _group_submissions_by_question(self, assignment_oid: ObjectId) -> dict:
1445 """
1446 Group answers from all submissions by question ID.
1447 Returns a map of question ID to list of answer objects.
1448 """
1449 submissions = (
1450 await db["submission_collection"]
1451 .find({"assignment_id": assignment_oid})
1452 .to_list(length=None)
1453 )
1454 submissions_map = {}
1456 for sub in submissions:
1457 for ans in sub.get("submitted_answers", []):
1458 qid = str(ans.get("questionId"))
1459 if qid:
1460 submissions_map.setdefault(qid, []).append(ans)
1462 return submissions_map
1464 async def _fetch_questionbank_map(self, question_ids: list) -> dict:
1465 """Fetch question documents and map them by ID, checking the same banks
1466 the student-side resolver uses (so global-exam / legacy questions resolve):
1467 1. teacher_questionbank
1468 2. global_questionbank (staff_admin_db) — STAAR/global practice exams
1469 3. question_collection (legacy)
1471 Previously this queried only ``teacher_questionbank``; for a re-used
1472 global exam none of the ids matched, so every question fell through to
1473 ``_get_empty_stats`` and Item Analysis rendered all "NA" / 0%.
1474 """
1475 question_map: dict = {}
1477 teacher_docs = (
1478 await db["teacher_questionbank"]
1479 .find({"_id": {"$in": question_ids}})
1480 .to_list(length=None)
1481 )
1482 for q in teacher_docs:
1483 question_map[str(q["_id"])] = q
1485 missing = [qid for qid in question_ids if str(qid) not in question_map]
1487 if missing and staff_admin_db is not None:
1488 global_docs = (
1489 await staff_admin_db["global_questionbank"]
1490 .find({"_id": {"$in": missing}})
1491 .to_list(length=None)
1492 )
1493 for q in global_docs:
1494 question_map[str(q["_id"])] = q
1495 missing = [qid for qid in question_ids if str(qid) not in question_map]
1497 if missing:
1498 legacy_docs = (
1499 await db["question_collection"]
1500 .find({"_id": {"$in": missing}})
1501 .to_list(length=None)
1502 )
1503 for q in legacy_docs:
1504 question_map[str(q["_id"])] = q
1506 return question_map
1508 def _analyze_question(
1509 self,
1510 qid: str,
1511 submissions_map: dict,
1512 questionbank_map: dict,
1513 total_enrolled: int,
1514 points_overrides: dict | None = None,
1515 as_percentage: bool = True,
1516 ) -> dict:
1517 """
1518 Perform item analysis on a single question using the total number of enrolled students for percentage calculation.
1519 """
1520 question = questionbank_map.get(qid)
1522 if not question:
1523 return self._get_empty_stats(qid)
1525 question_type = question.get("questionType", "NA").lower()
1526 # category / studentExpectation are OPTIONAL and are often null/empty on
1527 # global-exam (STAAR) questions, while `questionTopic` and `teksCode`
1528 # carry the same classification. Fall back to those so the columns show
1529 # a real value (e.g. "Percent") instead of "NA" when answers exist.
1530 # (`.get(k, "NA")` alone returns the stored None/"" — use `or`.)
1531 category = question.get("category") or question.get("questionTopic") or "NA"
1532 student_expectation = (
1533 question.get("studentExpectation") or question.get("teksCode") or "NA"
1534 )
1535 # A teacher's per-assignment point edit (assignments_collection.
1536 # question_points_overrides) wins over the question bank's own points.
1537 points_override = (points_overrides or {}).get(qid)
1538 points = str(
1539 points_override
1540 if points_override is not None
1541 else question.get("points", "NA")
1542 )
1543 # Free-response questions store `choices: null`; coalesce to [] so the
1544 # downstream enumerate(choices) doesn't crash (enumerate(None) → 500).
1545 choices = question.get("choices") or []
1546 correct_answer_doc = question.get("correctAnswer") or {}
1547 # resolve_correct_answer returns `groups` instead of `correctAnswer.answers`
1548 # for Single-Stimulus (which has none at the top level) — every
1549 # other type is unaffected.
1550 correct_answers = resolve_correct_answer(question)
1551 graph_fingerprint = correct_answer_doc.get("graphFingerprint")
1552 unordered = correct_answer_doc.get("unordered", False)
1554 # correct_texts feeds the displayed "correct answer" text/letter only;
1555 # the pass/fail check below compares against the raw correct_answers so
1556 # Item Analysis scores a submission exactly the same way the gradebook
1557 # does (see is_answer_correct — the single shared comparator).
1558 correct_letter, _correct_texts = self._get_correct_answers(
1559 question_type, correct_answers, choices
1560 )
1561 student_answers = self._init_student_answers(question_type, choices)
1563 total_correct, total_incorrect = 0, 0
1565 for submission in submissions_map.get(qid, []):
1566 student_answer = submission.get("answer")
1567 if is_answer_correct(
1568 student_answer,
1569 correct_answers,
1570 question_type,
1571 graph_fingerprint=graph_fingerprint,
1572 unordered=unordered,
1573 ):
1574 total_correct += 1
1575 else:
1576 total_incorrect += 1
1578 if question_type in ["multiple-choice", "checkbox"]:
1579 self._update_student_answer_counts(student_answers, student_answer)
1581 def format_value(value: int) -> str | int:
1582 if as_percentage:
1583 # Match the live rounded format ("0.0%", not "0%") so the zero-enrolled
1584 # fallback renders identically to every other row. (EI-3390)
1585 return (
1586 f"{round((value / total_enrolled) * 100, 2)}%"
1587 if total_enrolled
1588 else "0.0%"
1589 )
1590 return f"{value}"
1592 for option in student_answers:
1593 option["total"] = format_value(option["total"])
1595 return {
1596 "_id": qid,
1597 "question_type": question_type,
1598 "category": category,
1599 "student_expectation": student_expectation,
1600 "correct_answer": (
1601 correct_letter
1602 if question_type in ["multiple-choice", "checkbox"]
1603 else "NA"
1604 ),
1605 "points": points,
1606 "total_correct": format_value(total_correct),
1607 "total_incorrect": format_value(total_incorrect),
1608 "student_answers": student_answers,
1609 }
1611 def _get_empty_stats(self, qid: str) -> dict:
1612 """Return a default analysis result when the question is not found."""
1613 return {
1614 "_id": qid,
1615 "question_type": "NA",
1616 "category": "NA",
1617 "student_expectation": "NA",
1618 "correct_answer": "NA",
1619 "points": "NA",
1620 # Percentage strings to match the live _analyze_question path (the FE
1621 # renders these directly). Use the same rounded "0.0%" format the live
1622 # rows use so a not-found question doesn't display "0%" next to "0.0%"
1623 # rows. (EI-188 r2; format normalized in EI-3390)
1624 "total_correct": "0.0%",
1625 "total_incorrect": "0.0%",
1626 "student_answers": [
1627 {"letter": "NA", "text": "NA", "total": "0.0%"} for _ in range(4)
1628 ],
1629 }
1631 def _get_correct_answers(
1632 self, question_type: str, correct_answers, choices: list
1633 ) -> tuple:
1634 """Extract and clean correct answers depending on the question type.
1636 ``correct_answers`` is the question's ``correctAnswer.answers`` and is not
1637 always the list each branch assumes: checkbox can store a bare string
1638 (which iterated char-by-char into ``['x','','=',...]``), and
1639 drag-and-drop / drop-down-menu can be null (``for ans in None`` →
1640 ``TypeError`` → Item Analysis 500). Each list branch now coerces
1641 defensively so every question type renders instead of crashing.
1642 """
1643 correct_letter = "NA"
1644 correct_texts = []
1646 if question_type == "multiple-choice" and correct_answers:
1647 first = (
1648 correct_answers[0]
1649 if isinstance(correct_answers, list)
1650 else correct_answers
1651 )
1652 correct_text = first if isinstance(first, str) else first.get("answer", "")
1653 correct_letter = self.choice_text_to_letter(correct_text, choices)
1654 correct_texts = [self.clean_html(correct_text)]
1656 elif question_type == "checkbox" and correct_answers:
1657 answers = (
1658 correct_answers
1659 if isinstance(correct_answers, list)
1660 else [correct_answers]
1661 )
1662 correct_texts = [self.clean_html(ans) for ans in answers]
1663 correct_letters = [
1664 self.choice_text_to_letter(ans, choices) for ans in answers
1665 ]
1666 correct_letter = (
1667 ", ".join(filter(None, correct_letters)) if correct_letters else "NA"
1668 )
1670 elif question_type == "free-response" and correct_answers:
1671 # `correct_answers` is a LIST (from correctAnswer.answers) — iterate it
1672 # like the checkbox branch; clean_html on the whole list would stringify
1673 # to "['...']" and never match a student answer (item-analysis would
1674 # score every free-response 0%). (EI-188 r2)
1675 answers = (
1676 correct_answers
1677 if isinstance(correct_answers, list)
1678 else [correct_answers]
1679 )
1680 correct_texts = [
1681 self.clean_html(ans.get("answer", "") if isinstance(ans, dict) else ans)
1682 for ans in answers
1683 ]
1685 elif question_type in ["drag-and-drop", "drop-down-menu"]:
1686 answers = correct_answers if isinstance(correct_answers, list) else []
1687 correct_texts = [
1688 self.clean_html(ans.get("answer", "") if isinstance(ans, dict) else ans)
1689 for ans in answers
1690 ]
1692 elif question_type == "graph" and correct_answers:
1693 answers = (
1694 correct_answers
1695 if isinstance(correct_answers, list)
1696 else [correct_answers]
1697 )
1698 correct_texts = [
1699 (ans.get("answer") if isinstance(ans, dict) else ans) for ans in answers
1700 ]
1702 elif isinstance(correct_answers, str):
1703 correct_texts = [self.clean_html(correct_answers)]
1705 return correct_letter, correct_texts
1707 def _init_student_answers(self, question_type: str, choices: list) -> list:
1708 """Initialize the list of student answer choices."""
1709 if question_type in ["multiple-choice", "checkbox"]:
1710 return [
1711 {
1712 "letter": chr(ord("A") + idx),
1713 "text": choice.get("text", ""),
1714 "total": 0,
1715 }
1716 for idx, choice in enumerate(choices)
1717 ]
1718 return [{"letter": "NA", "text": "NA", "total": 0} for _ in range(4)]
1720 async def analytics_student_submission_fetch(
1721 self, class_code: str, assignment_uuid: str, student_id: str, request: Request
1722 ) -> dict:
1723 """
1724 Fetch a single enrolled student's submission for a teacher-made assignment,
1725 for teacher review (the "Individual Answers" analytics tab).
1727 Mirrors StudentAssignmentsService.fetch_submission_details — same submission
1728 document, same per-question snapshot stored under `questions`, and the same
1729 scoring helpers (`_is_correct` / `is_meaningful_answer`) — but scoped to an
1730 arbitrary enrolled student instead of the caller, and with correct answers /
1731 scores always revealed. The assignment's `show_correct_answers_after_submit`
1732 / `show_score_after_submit` settings gate what STUDENTS see; they are not an
1733 access-control mechanism for the assignment's own teacher.
1735 Args:
1736 class_code (str): Code of the class the assignment/student are scoped to.
1737 assignment_uuid (str): The assignment's ObjectId string.
1738 student_id (str): The target student's ObjectId string.
1739 request (Request): FastAPI request carrying the authenticated teacher's details.
1741 Returns:
1742 dict: {
1743 "_id": str,
1744 "assignmentDetails": {
1745 "title": str, "description": str, "type": str,
1746 "date_open": str | None, "date_close": str | None,
1747 },
1748 "remainingTime": str | None, # "HH:MM:SS" left, or None if unlimited time
1749 "isSubmitted": bool,
1750 "grade": str | None, # None when ungraded / pending / rejected
1751 "gradeStatus": str, # gradebook status: graded|missed|incomplete|pending|rejected
1752 "remarks": str | None,
1753 "isLate": bool,
1754 "reviewStatus": str,
1755 "totalAttemptsUsed": str,
1756 "totalAttemptsAllowed": str,
1757 "studentScore": str, # points the student actually earned
1758 "totalScore": str, # max points possible (sum of question points)
1759 "totalCorrectAnswers": str,
1760 "totalQuestions": str,
1761 "totalAnswersSubmitted": str,
1762 "details": [
1763 {
1764 "_id": str, "question": Any, "choices": list | None,
1765 "questionType": str, "points": int, "isFlagged": bool,
1766 "correctAnswer": {"content": Any, "answerDetails": Any},
1767 },
1768 ...
1769 ],
1770 "studentAnswers": [
1771 {
1772 "questionId": str, "questionType": str, "isFlagged": bool,
1773 "answer": Any, "isCorrect": bool, "earnedPoints": int,
1774 },
1775 ...
1776 ],
1777 "teacherComments": {"<questionId>": "comment text", ...},
1778 }
1779 When the student has no submission document yet:
1780 {"isSubmitted": False, "assignmentDetails": {...}, "teacherComments": {}}.
1782 Raises:
1783 HTTPException:
1784 - 400: If `assignment_uuid` or `student_id` is not a valid ObjectId,
1785 or `settings.time_allowed` is configured but improperly formatted.
1786 A missing/empty `time_allowed` is not an error — it means the
1787 assignment has no time limit and `remainingTime` is None.
1788 - 404: If the assignment/class is not owned by the teacher, or the
1789 student is not (currently) enrolled in the class.
1790 """
1791 if not ObjectId.is_valid(assignment_uuid):
1792 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
1793 if not ObjectId.is_valid(student_id):
1794 raise HTTPException(status_code=400, detail="Invalid student ID format")
1796 teacher_id = str(request.state.user_details["uuid"])
1797 assignment_oid = ObjectId(assignment_uuid)
1798 student_oid = ObjectId(student_id)
1800 assignment = await self._authorize_teacher_student_access(
1801 class_code, assignment_uuid, student_id, teacher_id
1802 )
1804 submission = await db["submission_collection"].find_one(
1805 {
1806 "assignment_id": assignment_oid,
1807 "student_id": student_oid,
1808 }
1809 )
1810 if not submission:
1811 return {
1812 "isSubmitted": False,
1813 "assignmentDetails": self._serialize_assignment_details(assignment),
1814 "teacherComments": {},
1815 }
1817 # Time left in the student's attempt window — same computation as
1818 # StudentAssignmentsService.fetch_submission_details. A missing/empty
1819 # time_allowed means the assignment has no time limit.
1820 time_allowed_str = assignment.settings.time_allowed
1821 if time_allowed_str:
1822 try:
1823 hours, minutes, seconds = map(int, time_allowed_str.split(":"))
1824 time_allowed_delta = timedelta(
1825 hours=hours, minutes=minutes, seconds=seconds
1826 )
1827 except ValueError:
1828 raise HTTPException(
1829 status_code=400,
1830 detail="Invalid time_allowed format (expected HH:MM:SS).",
1831 )
1833 date_created = submission.get("date_created")
1834 if date_created and date_created.tzinfo is None:
1835 date_created = date_created.replace(tzinfo=timezone.utc)
1837 if date_created:
1838 now_utc = datetime.now(timezone.utc)
1839 end_time = date_created + time_allowed_delta
1840 remaining_seconds = max((end_time - now_utc).total_seconds(), 0)
1841 else:
1842 remaining_seconds = 0
1844 formatted_remaining = (
1845 f"{int(remaining_seconds) // 3600:02}:"
1846 f"{(int(remaining_seconds) % 3600) // 60:02}:"
1847 f"{int(remaining_seconds) % 60:02}"
1848 )
1849 else:
1850 formatted_remaining = None
1852 student_service = StudentAssignmentsService()
1853 question_map = {str(q["_id"]): q for q in submission.get("questions", [])}
1854 # A teacher's per-assignment point edit (assignments_collection.
1855 # question_points_overrides) wins over the points snapshotted onto the
1856 # submission at attempt-start time, so editing points is reflected here
1857 # immediately without needing to touch the submission document.
1858 points_overrides = assignment.question_points_overrides or {}
1860 last_submitted = submission.get("last_submitted_answers")
1861 last_student_ans = submission.get("last_student_answers")
1863 scored_details: list = []
1864 student_answers: list = []
1866 if last_submitted and last_student_ans is not None:
1867 # A previous attempt was fully submitted — reuse the stored,
1868 # already-scored data, but exclude any question the teacher has
1869 # since removed from the assignment: it must stop being shown (and
1870 # stop counting toward the score) here, exactly like the student's
1871 # own submission review (StudentAssignmentsService.
1872 # fetch_submission_details) — otherwise the two views could show
1873 # different totals for the same submission.
1874 live_question_ids = {
1875 str(q.id) if hasattr(q, "id") else str(q)
1876 for q in (assignment.questions or [])
1877 }
1879 total_score = 0
1880 correct_count = 0
1881 total_answers_submitted = 0
1883 for ans in last_submitted:
1884 q_id = str(ans.get("questionId"))
1885 if q_id not in live_question_ids:
1886 continue
1887 q_doc = question_map.get(q_id)
1888 if not q_doc:
1889 continue
1890 scored_details.append(
1891 {
1892 "_id": q_id,
1893 "question": q_doc.get("question"),
1894 "choices": q_doc.get("choices"),
1895 "questionType": q_doc.get("questionType"),
1896 # NOT points_overrides here: total_score/earnedPoints above are
1897 # historical values locked in at submit time against the points
1898 # snapshotted onto THIS submission. Substituting today's override
1899 # into just the denominator (while the numerator stays historical)
1900 # produced impossible scores like "200.0 / 37" when a question's
1901 # points were lowered after grading. The override still applies
1902 # prospectively — to new submissions (student_assignment.py
1903 # _prepare_questions) and to in-progress/live-scored attempts
1904 # below, where earned points are derived from it directly.
1905 "points": q_doc.get("points", 0),
1906 "isFlagged": ans.get("isFlagged", False),
1907 "correctAnswer": {
1908 "content": q_doc.get("correctAnswer", {}).get("answers"),
1909 "answerDetails": q_doc.get("correctAnswer", {}).get(
1910 "answerDetails"
1911 ),
1912 },
1913 "groups": q_doc.get("groups"),
1914 "rows": q_doc.get("rows"),
1915 "rowHeaderLabel": q_doc.get("rowHeaderLabel"),
1916 }
1917 )
1919 for sa in last_student_ans:
1920 q_id = str(sa.get("questionId"))
1921 if q_id not in live_question_ids:
1922 continue
1923 sa_out = {
1924 "questionId": sa.get("questionId"),
1925 "questionType": sa.get("questionType"),
1926 "isFlagged": sa.get("isFlagged"),
1927 "answer": sa.get("answer"),
1928 "isCorrect": sa.get("isCorrect"),
1929 "earnedPoints": sa.get("earnedPoints"),
1930 }
1931 if "groupResults" in sa:
1932 sa_out["groupResults"] = sa.get("groupResults")
1933 student_answers.append(sa_out)
1935 if sa.get("isCorrect"):
1936 total_score += sa.get("earnedPoints") or 0
1937 correct_count += 1
1938 elif sa.get("earnedPoints"):
1939 total_score += sa.get("earnedPoints") or 0
1941 if student_service.is_meaningful_answer(sa.get("answer")):
1942 total_answers_submitted += 1
1943 else:
1944 # No completed submission yet — score live from the in-progress submitted_answers.
1945 total_score = 0
1946 correct_count = 0
1947 total_answers_submitted = 0
1949 for ans in submission.get("submitted_answers", []):
1950 q_id = str(ans.get("questionId"))
1951 q_doc = question_map.get(q_id)
1952 if not q_doc:
1953 continue
1955 student_answer = ans.get("answer", "")
1956 correct_answer = resolve_correct_answer(q_doc)
1957 max_points = points_overrides.get(q_id, q_doc.get("points", 0))
1959 is_correct, earned_points, group_results = score_question(
1960 student_answer,
1961 correct_answer,
1962 q_doc.get("questionType"),
1963 max_points,
1964 graph_fingerprint=(q_doc.get("correctAnswer") or {}).get(
1965 "graphFingerprint"
1966 ),
1967 unordered=(q_doc.get("correctAnswer") or {}).get(
1968 "unordered", False
1969 ),
1970 )
1972 if is_correct:
1973 total_score += earned_points
1974 correct_count += 1
1975 elif earned_points:
1976 total_score += earned_points
1977 if student_service.is_meaningful_answer(student_answer):
1978 total_answers_submitted += 1
1980 scored_details.append(
1981 {
1982 "_id": q_id,
1983 "question": q_doc.get("question"),
1984 "choices": q_doc.get("choices"),
1985 "questionType": q_doc.get("questionType"),
1986 "points": max_points,
1987 "isFlagged": ans.get("isFlagged", False),
1988 "correctAnswer": {
1989 "content": correct_answer,
1990 "answerDetails": q_doc.get("correctAnswer", {}).get(
1991 "answerDetails"
1992 ),
1993 },
1994 "groups": q_doc.get("groups"),
1995 "rows": q_doc.get("rows"),
1996 "rowHeaderLabel": q_doc.get("rowHeaderLabel"),
1997 }
1998 )
2000 sa_entry = {
2001 "questionId": q_id,
2002 "questionType": ans.get("questionType"),
2003 "isFlagged": ans.get("isFlagged"),
2004 "answer": student_answer,
2005 "isCorrect": is_correct,
2006 "earnedPoints": earned_points,
2007 }
2008 if group_results is not None:
2009 sa_entry["groupResults"] = group_results
2010 student_answers.append(sa_entry)
2012 # Grade goes through the CANONICAL resolver (server/utilities/gradebook.py),
2013 # the same one the gradebook and the student's own view use. This endpoint
2014 # previously formatted submission["grade"] directly, which broke two of the
2015 # resolver's documented rules:
2016 #
2017 # * an UNGRADED submission stores grade=None, and str(None) is the literal
2018 # "None" — the teacher's Individual Answers tab rendered "NaN%".
2019 # * a REJECTED late submission must never show its provisional grade "on
2020 # either view" (EI-1195 / EI-1210); the raw value was returned anyway.
2021 #
2022 # `gradeStatus` is exposed alongside so the UI can say "missed" /
2023 # "incomplete" / "pending" / "rejected" instead of showing nothing.
2024 grade_source = submission
2025 if last_submitted and last_student_ans is not None:
2026 recalculated_grade = recalculate_submitted_grade(
2027 submission,
2028 {
2029 str(q.id) if hasattr(q, "id") else str(q)
2030 for q in (assignment.questions or [])
2031 },
2032 )
2033 if recalculated_grade is not None:
2034 grade_source = {**submission, "grade": recalculated_grade}
2035 resolved_grade = resolve_cell_grade(
2036 grade_source, getattr(assignment, "date_close", None)
2037 )
2038 grade_value = resolved_grade["grade"]
2039 if grade_value is None:
2040 grade_str = None
2041 elif float(grade_value).is_integer():
2042 grade_str = str(int(grade_value))
2043 else:
2044 grade_str = str(grade_value)
2046 # Sum of each question's point value — the max-points denominator,
2047 # so the UI can render "earned / possible" instead of just the raw score.
2048 total_points = sum(q.get("points", 0) or 0 for q in scored_details)
2050 return {
2051 "_id": str(submission["_id"]),
2052 "assignmentDetails": self._serialize_assignment_details(assignment),
2053 "remainingTime": formatted_remaining,
2054 "isSubmitted": bool(submission.get("last_submitted_answers"))
2055 or submission.get("is_submitted", False),
2056 "grade": grade_str,
2057 "gradeStatus": resolved_grade["status"],
2058 "remarks": submission.get("remarks"),
2059 "isLate": submission.get("is_late", False),
2060 "reviewStatus": submission.get("review_status", "none"),
2061 "totalAttemptsUsed": str(submission.get("total_attempts", 0)),
2062 "totalAttemptsAllowed": str(assignment.settings.allowed_attempts),
2063 "studentScore": str(total_score),
2064 "totalScore": str(total_points),
2065 "totalCorrectAnswers": str(correct_count),
2066 # len(scored_details) rather than the stored total_questions — the
2067 # latter is frozen from submit time and goes stale the moment a
2068 # question is removed from the assignment afterward.
2069 "totalQuestions": str(len(scored_details)),
2070 "totalAnswersSubmitted": str(total_answers_submitted),
2071 "details": scored_details,
2072 "studentAnswers": student_answers,
2073 "teacherComments": {
2074 qid: entry.get("comment", "")
2075 for qid, entry in (submission.get("teacher_comments") or {}).items()
2076 },
2077 }
2079 def _validate_comment_path_ids(
2080 self, assignment_uuid: str, student_id: str, question_id: str
2081 ) -> None:
2082 """Shared 400 guard for the three comment endpoints (add/update/delete)."""
2083 if not ObjectId.is_valid(assignment_uuid):
2084 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
2085 if not ObjectId.is_valid(student_id):
2086 raise HTTPException(status_code=400, detail="Invalid student ID format")
2087 if not ObjectId.is_valid(question_id):
2088 raise HTTPException(status_code=400, detail="Invalid question ID format")
2090 async def _find_submission_for_comment(
2091 self, assignment_oid: ObjectId, student_oid: ObjectId, question_id: str
2092 ):
2093 """
2094 Fetch the submission (id + this one question's existing comment, if any) —
2095 used by add/update/delete to check state before mutating, since add must
2096 reject an existing comment and update/delete must reject a missing one.
2097 """
2098 return await db["submission_collection"].find_one(
2099 {"assignment_id": assignment_oid, "student_id": student_oid},
2100 {"_id": 1, f"teacher_comments.{question_id}": 1},
2101 )
2103 async def add_student_answer_comment(
2104 self,
2105 class_code: str,
2106 assignment_uuid: str,
2107 student_id: str,
2108 question_id: str,
2109 comment: str,
2110 request: Request,
2111 ) -> dict:
2112 """
2113 Add a new teacher comment on a specific student's answer to a specific
2114 question. Fails if a comment already exists for that question — call
2115 update_student_answer_comment instead. At most one comment per
2116 (submission, question), stored under `submission.teacher_comments.<question_id>`.
2118 Args:
2119 class_code (str): Code of the class the assignment/student are scoped to.
2120 assignment_uuid (str): The assignment's ObjectId string.
2121 student_id (str): The target student's ObjectId string.
2122 question_id (str): The question's ObjectId string.
2123 comment (str): Comment text (already validated/trimmed non-blank by the route).
2124 request (Request): FastAPI request carrying the authenticated teacher's details.
2126 Returns:
2127 dict: {"submission_id": str, "questionId": str, "comment": str, "updatedAt": str (ISO 8601)}.
2129 Raises:
2130 HTTPException:
2131 - 400: If `assignment_uuid` / `student_id` / `question_id` is not a valid ObjectId.
2132 - 404: If the assignment/class is not owned by the teacher, the student is
2133 not enrolled in the class, or the student has no submission yet.
2134 - 409: If a comment already exists for this question.
2135 """
2136 self._validate_comment_path_ids(assignment_uuid, student_id, question_id)
2138 teacher_id = str(request.state.user_details["uuid"])
2139 await self._authorize_teacher_student_access(
2140 class_code, assignment_uuid, student_id, teacher_id
2141 )
2143 assignment_oid = ObjectId(assignment_uuid)
2144 student_oid = ObjectId(student_id)
2146 existing = await self._find_submission_for_comment(
2147 assignment_oid, student_oid, question_id
2148 )
2149 if not existing:
2150 raise HTTPException(
2151 status_code=404, detail="Submission not found for this student."
2152 )
2153 if (existing.get("teacher_comments") or {}).get(question_id):
2154 raise HTTPException(
2155 status_code=409,
2156 detail="A comment already exists for this question. Use update instead.",
2157 )
2159 return await self._set_student_answer_comment(
2160 assignment_oid, student_oid, question_id, comment, teacher_id
2161 )
2163 async def update_student_answer_comment(
2164 self,
2165 class_code: str,
2166 assignment_uuid: str,
2167 student_id: str,
2168 question_id: str,
2169 comment: str,
2170 request: Request,
2171 ) -> dict:
2172 """
2173 Overwrite an existing teacher comment on a specific student's answer to
2174 a specific question. Fails if no comment exists yet for that question —
2175 call add_student_answer_comment instead.
2177 Args:
2178 class_code (str): Code of the class the assignment/student are scoped to.
2179 assignment_uuid (str): The assignment's ObjectId string.
2180 student_id (str): The target student's ObjectId string.
2181 question_id (str): The question's ObjectId string.
2182 comment (str): Comment text (already validated/trimmed non-blank by the route).
2183 request (Request): FastAPI request carrying the authenticated teacher's details.
2185 Returns:
2186 dict: {"submission_id": str, "questionId": str, "comment": str, "updatedAt": str (ISO 8601)}.
2188 Raises:
2189 HTTPException:
2190 - 400: If `assignment_uuid` / `student_id` / `question_id` is not a valid ObjectId.
2191 - 404: If the assignment/class is not owned by the teacher, the student is not
2192 enrolled in the class, the student has no submission yet, or no comment
2193 exists yet for this question.
2194 """
2195 self._validate_comment_path_ids(assignment_uuid, student_id, question_id)
2197 teacher_id = str(request.state.user_details["uuid"])
2198 await self._authorize_teacher_student_access(
2199 class_code, assignment_uuid, student_id, teacher_id
2200 )
2202 assignment_oid = ObjectId(assignment_uuid)
2203 student_oid = ObjectId(student_id)
2205 existing = await self._find_submission_for_comment(
2206 assignment_oid, student_oid, question_id
2207 )
2208 if not existing:
2209 raise HTTPException(
2210 status_code=404, detail="Submission not found for this student."
2211 )
2212 if not (existing.get("teacher_comments") or {}).get(question_id):
2213 raise HTTPException(
2214 status_code=404,
2215 detail="No existing comment for this question. Use add instead.",
2216 )
2218 return await self._set_student_answer_comment(
2219 assignment_oid, student_oid, question_id, comment, teacher_id
2220 )
2222 async def _set_student_answer_comment(
2223 self,
2224 assignment_oid: ObjectId,
2225 student_oid: ObjectId,
2226 question_id: str,
2227 comment: str,
2228 teacher_id: str,
2229 ) -> dict:
2230 """Shared write path for add/update — both $set the same shape once existence is checked.
2232 `comment` is plain text (a MUI `TextField`, not a rich-text editor — see
2233 CommentBox.jsx in eruditiontx-client-mvp), so `strip_html` is used rather than
2234 `sanitize_rich_text`: there's no legitimate formatting markup to preserve here,
2235 only script/HTML that a teacher happened to type. EI-3446.
2236 """
2237 now = datetime.now(timezone.utc)
2238 sanitized_comment = strip_html(comment)
2240 result = await db["submission_collection"].find_one_and_update(
2241 {"assignment_id": assignment_oid, "student_id": student_oid},
2242 {
2243 "$set": {
2244 f"teacher_comments.{question_id}": {
2245 "comment": sanitized_comment,
2246 "commented_by": teacher_id,
2247 "updated_at": now,
2248 }
2249 }
2250 },
2251 projection={"_id": 1},
2252 return_document=ReturnDocument.AFTER,
2253 )
2255 return {
2256 "submission_id": str(result["_id"]),
2257 "questionId": question_id,
2258 "comment": sanitized_comment,
2259 "updatedAt": now.isoformat(),
2260 }
2262 async def delete_student_answer_comment(
2263 self,
2264 class_code: str,
2265 assignment_uuid: str,
2266 student_id: str,
2267 question_id: str,
2268 request: Request,
2269 ) -> dict:
2270 """
2271 Delete the teacher's comment on a specific student's answer to a specific question.
2273 Args:
2274 class_code (str): Code of the class the assignment/student are scoped to.
2275 assignment_uuid (str): The assignment's ObjectId string.
2276 student_id (str): The target student's ObjectId string.
2277 question_id (str): The question's ObjectId string.
2278 request (Request): FastAPI request carrying the authenticated teacher's details.
2280 Returns:
2281 dict: {"submission_id": str, "questionId": str, "deleted": True}.
2283 Raises:
2284 HTTPException:
2285 - 400: If `assignment_uuid` / `student_id` / `question_id` is not a valid ObjectId.
2286 - 404: If the assignment/class is not owned by the teacher, the student is not
2287 enrolled in the class, the student has no submission yet, or no comment
2288 exists for this question.
2289 """
2290 self._validate_comment_path_ids(assignment_uuid, student_id, question_id)
2292 teacher_id = str(request.state.user_details["uuid"])
2293 await self._authorize_teacher_student_access(
2294 class_code, assignment_uuid, student_id, teacher_id
2295 )
2297 assignment_oid = ObjectId(assignment_uuid)
2298 student_oid = ObjectId(student_id)
2300 existing = await self._find_submission_for_comment(
2301 assignment_oid, student_oid, question_id
2302 )
2303 if not existing:
2304 raise HTTPException(
2305 status_code=404, detail="Submission not found for this student."
2306 )
2307 if not (existing.get("teacher_comments") or {}).get(question_id):
2308 raise HTTPException(
2309 status_code=404, detail="No comment found for this question."
2310 )
2312 result = await db["submission_collection"].find_one_and_update(
2313 {"assignment_id": assignment_oid, "student_id": student_oid},
2314 {"$unset": {f"teacher_comments.{question_id}": ""}},
2315 projection={"_id": 1},
2316 return_document=ReturnDocument.AFTER,
2317 )
2319 return {
2320 "submission_id": str(result["_id"]),
2321 "questionId": question_id,
2322 "deleted": True,
2323 }
2325 def _update_student_answer_counts(self, student_answers: list, student_answer):
2326 """Update the counts of how often each option was selected."""
2327 if isinstance(student_answer, list):
2328 for a in student_answer:
2329 for option in student_answers:
2330 if self.clean_html(option["text"]) == self.clean_html(a):
2331 option["total"] += 1
2332 else:
2333 for option in student_answers:
2334 if self.clean_html(option["text"]) == self.clean_html(student_answer):
2335 option["total"] += 1
2337 def clean_html(self, text) -> str:
2338 """Strip HTML tags from an answer value.
2340 Answer values are not always plain strings: checkbox / drag-and-drop /
2341 global questions store each answer as a ``{"id":..., "answer":...}`` dict.
2342 Passing such a dict to ``re.sub`` raised ``TypeError`` and 500'd the whole
2343 Item Analysis. Normalize dicts to their ``answer`` and coerce any other
2344 non-string to text before stripping tags.
2346 Math-formula spans (``<span class="mfe-formula" data-latex="...">
2347 ...rendered markup...</span>``) are collapsed to their ``data-latex``
2348 source first — a plain tag-strip keeps every inner text node, so two
2349 formulas that differ only in structure (e.g. an exponent vs. an inline
2350 multiplier) rendered to the same visible characters and were counted/
2351 matched as the same choice. See MultipleChoiceOption.jsx /
2352 getChoiceComparisonKey on the client for the same fix.
2353 """
2354 if isinstance(text, dict):
2355 text = text.get("answer", "")
2356 if not isinstance(text, str):
2357 text = "" if text is None else str(text)
2358 text = MFE_FORMULA_RE.sub(lambda m: m.group(1), text)
2359 return re.sub(r"<.*?>", "", text).strip()
2361 def choice_text_to_letter(self, text: str, choices: list) -> str:
2362 """Convert correct answer text to its corresponding letter (A, B, C...)."""
2363 clean_text = self.clean_html(text)
2364 for idx, choice in enumerate(choices):
2365 if self.clean_html(choice.get("text", "")) == clean_text:
2366 return chr(ord("A") + idx)
2367 return None
2369 async def mark_student_answer(
2370 self,
2371 class_code: str,
2372 assignment_uuid: str,
2373 student_id: str,
2374 question_id: str,
2375 points: float,
2376 feedback: str | None,
2377 request: Request,
2378 ) -> dict:
2379 """
2380 Mark ONE student's answer to ONE written question by hand.
2382 This is deliberately not `update_question_points`. That route changes what a
2383 question is worth for EVERY student in the assignment; marking is the opposite —
2384 one student's answer, awarded on its merits, leaving everyone else untouched.
2386 Partial credit is the point: a written answer is usually part right, and a
2387 correct/incorrect toggle would throw that away.
2389 Grades are recomputed over the questions that have actually been MARKED. An
2390 answer still waiting on a person contributes to neither the score nor the total,
2391 so the grade reported is an honest grade of marked work rather than a low one
2392 that silently rises later.
2394 `pre_penalty_grade` is rewritten too whenever the submission carries one. It is
2395 what `approve_late_submission` subtracts the late penalty from, and leaving it
2396 stale is how a regrade gets silently discarded: mark first, approve second, and
2397 the approval would overwrite `grade` from a number calculated before the marking.
2399 Args:
2400 class_code (str): Code of the class the assignment/student are scoped to.
2401 assignment_uuid (str): The assignment's ObjectId string.
2402 student_id (str): The target student's ObjectId string.
2403 question_id (str): The question's ObjectId string.
2404 points (float): Points awarded, 0 to the question's own point value.
2405 feedback (str | None): Optional note shown to the student with the mark.
2406 request (Request): FastAPI request carrying the authenticated teacher.
2408 Returns:
2409 dict: the new per-answer and submission-level numbers.
2411 Raises:
2412 HTTPException:
2413 - 400: bad ObjectId, a question type that is not marked by hand, or
2414 points outside 0..the question's value.
2415 - 404: assignment/class not owned by the teacher, student not enrolled,
2416 no submission, or the question is not part of this submission.
2417 """
2418 self._validate_comment_path_ids(assignment_uuid, student_id, question_id)
2420 teacher_id = str(request.state.user_details["uuid"])
2421 await self._authorize_teacher_student_access(
2422 class_code, assignment_uuid, student_id, teacher_id
2423 )
2425 assignment_oid = ObjectId(assignment_uuid)
2426 student_oid = ObjectId(student_id)
2428 submission = await self._find_submission_for_comment(
2429 assignment_oid, student_oid, question_id
2430 )
2431 if not submission:
2432 raise HTTPException(
2433 status_code=404, detail="Submission not found for this student."
2434 )
2436 answers = submission.get("last_student_answers") or []
2437 entry = next(
2438 (a for a in answers if str(a.get("questionId")) == question_id), None
2439 )
2440 if entry is None:
2441 raise HTTPException(
2442 status_code=404, detail="That question is not part of this submission."
2443 )
2445 if entry.get("questionType") not in MANUALLY_MARKED_TYPES:
2446 # Reweighting a multiple-choice question is what update_question_points is
2447 # for. Awarding it arbitrary points here would leave `isCorrect` saying one
2448 # thing and the score saying another.
2449 raise HTTPException(
2450 status_code=400,
2451 detail="Only written responses are marked by hand.",
2452 )
2454 questions = submission.get("questions") or []
2455 points_by_question = {
2456 str(q.get("_id")): (q.get("points", 0) or 0) for q in questions
2457 }
2458 max_points = points_by_question.get(question_id, 0)
2459 if points < 0 or points > max_points:
2460 raise HTTPException(
2461 status_code=400,
2462 detail=f"Award between 0 and {max_points} points for this question.",
2463 )
2465 entry["earnedPoints"] = points
2466 # Full marks is the only thing that counts as "correct". Partial credit keeps
2467 # isCorrect False and still adds to the score, exactly as automatic partial
2468 # credit already behaves for multi-part questions.
2469 entry["isCorrect"] = points >= max_points and max_points > 0
2470 entry.pop("needsMarking", None)
2471 entry["markedBy"] = teacher_id
2472 entry["markedAt"] = datetime.now(timezone.utc)
2473 if feedback is not None:
2474 entry["markFeedback"] = feedback
2476 totals = self._recount_marked_submission(answers, points_by_question)
2478 update: dict = {
2479 "last_student_answers": answers,
2480 "total_score": totals["total_score"],
2481 "grade": totals["grade"],
2482 "total_correct_answers": totals["correct_count"],
2483 "pending_marking_count": totals["pending_count"],
2484 "pending_marking_points": totals["pending_points"],
2485 }
2486 if "pre_penalty_grade" in submission:
2487 update["pre_penalty_grade"] = totals["grade"]
2488 # A submission still awaiting late-submission approval keeps its own remarks —
2489 # "pending_review" is about the teacher approving the LATENESS, and marking an
2490 # answer must not quietly resolve that.
2491 if submission.get("review_status") not in ("pending", "rejected"):
2492 assignment = await db["assignments_collection"].find_one(
2493 {"_id": assignment_oid}
2494 )
2495 passing_grade = (assignment or {}).get("passing_grade", 75)
2496 update["remarks"] = (
2497 "passed" if totals["grade"] >= passing_grade else "failed"
2498 )
2500 await db["submission_collection"].update_one(
2501 {"_id": submission["_id"]}, {"$set": update}
2502 )
2504 return {
2505 "submission_id": str(submission["_id"]),
2506 "questionId": question_id,
2507 "earnedPoints": points,
2508 "maxPoints": max_points,
2509 "isCorrect": entry["isCorrect"],
2510 "feedback": entry.get("markFeedback"),
2511 "grade": totals["grade"],
2512 "totalScore": totals["total_score"],
2513 "pendingMarkingCount": totals["pending_count"],
2514 "markedAt": entry["markedAt"].isoformat(),
2515 }
2517 @staticmethod
2518 def _recount_marked_submission(answers: list, points_by_question: dict) -> dict:
2519 """Total a submission from its answers, ignoring anything not yet marked.
2521 Held questions are left out of the score AND out of the total it is divided by,
2522 which is what makes a partly-marked submission report the grade of the work that
2523 has been marked rather than a low grade that later moves on its own.
2524 """
2525 total_score = 0.0
2526 correct_count = 0
2527 pending_points = 0.0
2528 pending_count = 0
2530 for answer in answers:
2531 question_points = (
2532 points_by_question.get(str(answer.get("questionId")), 0) or 0
2533 )
2534 if answer.get("needsMarking"):
2535 pending_points += question_points
2536 pending_count += 1
2537 continue
2538 total_score += answer.get("earnedPoints", 0) or 0
2539 if answer.get("isCorrect"):
2540 correct_count += 1
2542 markable = sum(points_by_question.values()) - pending_points
2543 return {
2544 "total_score": total_score,
2545 "grade": (total_score / markable * 100) if markable > 0 else 0,
2546 "correct_count": correct_count,
2547 "pending_points": pending_points,
2548 "pending_count": pending_count,
2549 }
2551 async def update_question_points(
2552 self, assignment_uuid: str, question_id: str, points: float, request: Request
2553 ) -> dict:
2554 """
2555 Override the point value of one question within a teacher's assignment.
2557 This edits ONLY assignments_collection.question_points_overrides — the
2558 underlying question document in teacher_questionbank / global_questionbank
2559 (the "main" question record) is never touched, so the same question used
2560 in other assignments is unaffected.
2562 Args:
2563 assignment_uuid (str): The assignment's ObjectId string.
2564 question_id (str): The question's ObjectId string (must be one of
2565 this assignment's questions).
2566 points (float): New point value for this question, within this
2567 assignment only (>= 0).
2568 request (Request): FastAPI request carrying the authenticated teacher's details.
2570 Returns:
2571 dict: {"assignment_id": str, "questionId": str, "points": float, "updatedAt": str (ISO 8601)}.
2573 Raises:
2574 HTTPException:
2575 - 400: If `assignment_uuid` / `question_id` is not a valid ObjectId.
2576 - 404: If the assignment is not owned by the teacher (or is
2577 deleted), or the question is not part of this assignment.
2578 """
2579 if not ObjectId.is_valid(assignment_uuid):
2580 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
2581 if not ObjectId.is_valid(question_id):
2582 raise HTTPException(status_code=400, detail="Invalid question ID format")
2584 teacher_id = str(request.state.user_details["uuid"])
2585 assignment_oid = ObjectId(assignment_uuid)
2587 assignment = await db["assignments_collection"].find_one(
2588 {
2589 "_id": assignment_oid,
2590 "created_by": teacher_id,
2591 "deleted": {"$ne": True},
2592 }
2593 )
2594 if not assignment:
2595 raise HTTPException(status_code=404, detail="Assignment not found.")
2597 question_ids = self._assignment_question_ids(assignment)
2598 if ObjectId(question_id) not in question_ids:
2599 raise HTTPException(
2600 status_code=404, detail="Question not found in this assignment."
2601 )
2603 now = datetime.now(timezone.utc)
2604 await db["assignments_collection"].update_one(
2605 {"_id": assignment_oid},
2606 {
2607 "$set": {
2608 f"question_points_overrides.{question_id}": points,
2609 "updated_at": now,
2610 "updated_by": teacher_id,
2611 }
2612 },
2613 )
2615 recounted = await self._recount_submissions_for_question(
2616 assignment_oid,
2617 question_id,
2618 points,
2619 float(assignment.get("passing_grade") or 0),
2620 )
2622 return {
2623 "assignment_id": assignment_uuid,
2624 "questionId": question_id,
2625 "points": points,
2626 "updatedAt": now.isoformat(),
2627 "submissionsRecounted": recounted,
2628 }
2630 async def _recount_submissions_for_question(
2631 self,
2632 assignment_oid: ObjectId,
2633 question_id: str,
2634 new_points: float,
2635 passing_grade: float,
2636 ) -> int:
2637 """
2638 Re-score every already-graded submission for this assignment after a
2639 teacher edits one question's per-assignment point value, so
2640 total_score / grade / pass-fail stay correct immediately instead of
2641 going stale until some other regrade event touches the document.
2643 Only the edited question's contribution changes — every other
2644 question's earned points are left untouched. Only fully-submitted
2645 attempts (the same `last_submitted_answers` + `last_student_answers`
2646 pair analytics_student_submission_fetch treats as "graded") are
2647 recounted; in-progress attempts have no stored score to correct — they
2648 are scored live on every read and already pick up the latest points.
2650 Args:
2651 assignment_oid (ObjectId): The assignment's _id.
2652 question_id (str): The edited question's id (string form, as
2653 stored in submission.questions[]._id and
2654 last_student_answers[].questionId).
2655 new_points (float): The new point value for this question.
2656 passing_grade (float): The assignment's passing_grade, used to
2657 recompute `remarks` ("passed"/"failed") for the new grade —
2658 same convention as approve_late_submission.
2660 Returns:
2661 int: Number of submissions whose stored score/grade changed.
2662 """
2663 submissions = (
2664 await db["submission_collection"]
2665 .find({"assignment_id": assignment_oid})
2666 .to_list(length=None)
2667 )
2669 updated_count = 0
2670 for sub in submissions:
2671 last_submitted = sub.get("last_submitted_answers")
2672 last_student_ans = sub.get("last_student_answers")
2673 if not last_submitted or last_student_ans is None:
2674 continue # not a graded attempt — nothing stored to recount
2676 questions = sub.get("questions", [])
2677 old_points = None
2678 for q in questions:
2679 if str(q.get("_id")) == question_id:
2680 old_points = q.get("points", 0) or 0
2681 break
2682 if old_points is None:
2683 continue # this question isn't part of this submission's snapshot
2685 answer_entry = next(
2686 (
2687 a
2688 for a in last_student_ans
2689 if str(a.get("questionId")) == question_id
2690 ),
2691 None,
2692 )
2693 if answer_entry is None:
2694 continue
2696 is_correct = bool(answer_entry.get("isCorrect"))
2697 old_earned = answer_entry.get("earnedPoints", 0) or 0
2698 new_earned = new_points if is_correct else 0
2700 if old_points == new_points:
2701 continue # no actual change for this submission
2703 total_points_old = sum((q.get("points", 0) or 0) for q in questions)
2704 total_points_new = total_points_old - old_points + new_points
2706 old_total_score = sub.get("total_score", 0) or 0
2707 new_total_score = old_total_score - old_earned + new_earned
2708 new_grade = (
2709 (new_total_score / total_points_new * 100) if total_points_new else 0
2710 )
2711 new_remarks = "passed" if new_grade >= passing_grade else "failed"
2713 # Keep the per-question snapshot and per-answer earnedPoints
2714 # internally consistent with the new aggregate numbers, not just
2715 # total_score/grade in isolation.
2716 for q in questions:
2717 if str(q.get("_id")) == question_id:
2718 q["points"] = new_points
2719 break
2720 for a in last_student_ans:
2721 if str(a.get("questionId")) == question_id:
2722 a["earnedPoints"] = new_earned
2723 break
2725 recount: dict = {
2726 "questions": questions,
2727 "last_student_answers": last_student_ans,
2728 "total_score": new_total_score,
2729 "grade": new_grade,
2730 "remarks": new_remarks,
2731 }
2732 # A late submission's grade is NOT `grade` — approve_late_submission
2733 # recomputes it as `pre_penalty_grade - late_penalty`, and prefers
2734 # pre_penalty_grade whenever the key exists. Writing only `grade` here left
2735 # that stale, so recounting a pending late submission and THEN approving it
2736 # silently threw the recount away: the approval overwrote `grade` with a
2737 # number derived from the point value the teacher had just changed. The
2738 # order a teacher happens to click in should not decide whether their edit
2739 # survives.
2740 if "pre_penalty_grade" in sub:
2741 recount["pre_penalty_grade"] = new_grade
2743 await db["submission_collection"].update_one(
2744 {"_id": sub["_id"]}, {"$set": recount}
2745 )
2746 updated_count += 1
2748 return updated_count
2750 async def update(
2751 self,
2752 assignment_uuid: str,
2753 updated_assignment: UpdateAssignment,
2754 request: Request,
2755 ):
2756 """
2757 Update details of an existing assignment.
2759 Args:
2760 assignment_uuid (str): Unique identifier of the assignment.
2761 updated_assignment (UpdateAssignment): Updated assignment details.
2762 request (Request): The incoming request object containing teacher context.
2764 Returns:
2765 dict: Updated assignment details and success message.
2767 Raises:
2768 HTTPException: If assignment not found or update fails.
2769 """
2770 # Modified by Allan Ninal — a malformed id 500'd. ObjectId() raises
2771 # bson.errors.InvalidId for anything that is not 24 hex chars, and the only
2772 # handler below is a bare `except Exception` that turns it into a 500
2773 # "Internal Server Error". A client sending a bad id gets a server-fault
2774 # status for what is plainly a bad request, and every occurrence is logged
2775 # as an unhandled exception. Guarded before the try, matching the sibling
2776 # student service (student_assignment.py:305).
2777 if not ObjectId.is_valid(assignment_uuid):
2778 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
2780 try:
2781 teacher_id = str(request.state.user_details["uuid"])
2783 assignment_oid = ObjectId(assignment_uuid)
2785 # Set audit fields
2786 updated_assignment.updated_by = teacher_id
2787 updated_assignment.updated_at = datetime.now(timezone.utc)
2789 # Ensure assignment is not marked as deleted
2790 fetched_assignment = await db["assignments_collection"].find_one(
2791 {
2792 "_id": assignment_oid,
2793 "created_by": teacher_id,
2794 "deleted": {"$ne": True},
2795 }
2796 )
2797 if not fetched_assignment:
2798 raise HTTPException(
2799 status_code=404,
2800 detail="Assignment not found or already marked as deleted",
2801 )
2803 # Convert to dictionary and exclude unset values
2804 update_data = updated_assignment.model_dump(exclude_unset=True)
2806 # Added by Allan Ninal — 2026-10-03. Same rule the common update route has
2807 # had since 2026-09-24: CREATE refuses date_close < date_open (422) but this
2808 # route accepted it (200, measured on QA 0.0.0.425), and an inverted range
2809 # fails the document's own validation on load. Resolve each date against
2810 # the stored one, since an update may send only one of them.
2811 if dates_inverted(update_data, fetched_assignment):
2812 raise HTTPException(
2813 status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
2814 detail="date_open must be before date_close",
2815 )
2817 # EI-3437 (Allan Ninal, 2026-10-03): a reused global assignment keeps its title and questions.
2818 if is_reused_global_copy(fetched_assignment) and changes_title_or_questions(
2819 update_data, fetched_assignment
2820 ):
2821 raise HTTPException(
2822 status_code=status.HTTP_403_FORBIDDEN,
2823 detail=REUSED_GLOBAL_LOCKED_DETAIL,
2824 )
2825 # A resent, unchanged title/questions is a no-op: keep the stored values.
2826 if is_reused_global_copy(fetched_assignment):
2827 update_data.pop("title", None)
2828 update_data.pop("questions", None)
2830 # Only the settings keys sent change (see utilities/assignment_update.py).
2831 set_settings_per_key(update_data)
2833 # Perform update
2834 result = await db["assignments_collection"].update_one(
2835 {"_id": assignment_oid, "created_by": teacher_id}, {"$set": update_data}
2836 )
2838 # Check if the document was updated
2839 if result.modified_count == 0:
2840 raise HTTPException(
2841 status_code=404,
2842 detail="Assignment not found or the teacher has no access to this assignment",
2843 )
2845 # Fetch updated assignment
2846 updated_assignment_doc = await db["assignments_collection"].find_one(
2847 {"_id": assignment_oid}
2848 )
2849 if not updated_assignment_doc:
2850 raise HTTPException(
2851 status_code=404, detail="Assignment not found after update"
2852 )
2854 updated_assignment_dict = {
2855 "_id": str(updated_assignment_doc["_id"]),
2856 **UpdateAssignment(**updated_assignment_doc).model_dump(),
2857 }
2859 return {
2860 "detail": "Successfully updated assignment",
2861 "updated_assignment": serialized_response_object(
2862 updated_assignment_dict
2863 ),
2864 }
2866 except HTTPException as e:
2867 raise e
2868 except Exception as exc:
2869 # `as e` with e never read: the cause was discarded and replaced by a bare
2870 # "Internal Server Error", so a failed delete left nothing to diagnose it
2871 # with. Chained instead, which keeps the response identical and puts the
2872 # original traceback in the log.
2873 raise HTTPException(
2874 status_code=500,
2875 detail="Internal Server Error",
2876 ) from exc
2878 async def delete(self, assignment_uuid: str, request: Request):
2879 """
2880 Soft delete a specific assignment by setting 'deleted' field to True.
2882 Args:
2883 assignment_uuid (str): Unique identifier of the assignment to delete.
2884 request (Request): The incoming request object containing teacher context.
2886 Returns:
2887 dict: Soft deletion confirmation message.
2889 Raises:
2890 HTTPException: If assignment not found or deletion unauthorized.
2891 """
2892 # Modified by Allan Ninal — same malformed-id 500 as update() above; verified
2893 # against QA (DELETE with a non-ObjectId returned 500, not 400).
2894 if not ObjectId.is_valid(assignment_uuid):
2895 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
2897 try:
2898 teacher_id = str(request.state.user_details["uuid"])
2899 assignment_oid = ObjectId(assignment_uuid)
2901 # Attempt to update the document in one query
2902 result = await db["assignments_collection"].update_one(
2903 {
2904 "_id": assignment_oid,
2905 "created_by": teacher_id,
2906 "deleted": {"$ne": True},
2907 },
2908 {
2909 "$set": {
2910 "deleted": True,
2911 "deleted_at": datetime.now(timezone.utc),
2912 "deleted_by": teacher_id,
2913 "category": "trashed",
2914 }
2915 },
2916 )
2918 if result.matched_count == 0:
2919 raise HTTPException(
2920 status_code=404,
2921 detail="Assignment not found or already marked as deleted",
2922 )
2924 return {
2925 "detail": "Assignment marked as deleted successfully",
2926 "assignment_id": assignment_uuid,
2927 }
2929 except HTTPException:
2930 raise
2931 except Exception as e:
2932 raise HTTPException(status_code=500, detail=safe_detail(e))
2934 # -----------------------------------------------------------------------
2935 # EI-1210: Late-submission teacher-approval methods
2936 # -----------------------------------------------------------------------
2938 async def list_pending_late_submissions(
2939 self, assignment_uuid: str, request: Request
2940 ) -> dict:
2941 """
2942 List all late submissions that are pending teacher review for a given assignment.
2944 GET /v1/teacher/assignment/{assignment_uuid}/late-submissions/pending/fetch
2946 Ownership: the assignment must have been created by the requesting teacher.
2947 Returns 404 (not 403) when the assignment is not owned — matches the analytics convention.
2948 Returns 400 for an invalid ObjectId.
2949 """
2950 if not ObjectId.is_valid(assignment_uuid):
2951 raise HTTPException(status_code=400, detail="Invalid assignment ID format")
2953 teacher_id = _teacher_id_from_request(request)
2954 assignment_oid = ObjectId(assignment_uuid)
2956 assignment = await db["assignments_collection"].find_one(
2957 {
2958 "_id": assignment_oid,
2959 "created_by": {"$in": _created_by_values(teacher_id)},
2960 "deleted": {"$ne": True},
2961 }
2962 )
2963 if not assignment:
2964 raise HTTPException(status_code=404, detail="Assignment not found.")
2966 pending_submissions = (
2967 await db["submission_collection"]
2968 .find(
2969 {
2970 "assignment_id": assignment_oid,
2971 "review_status": "pending",
2972 }
2973 )
2974 .to_list(length=None)
2975 )
2977 # Batch-join student names in ONE query instead of a find_one per row
2978 # (EI-1210 review fix: the per-submission lookup was an N+1).
2979 student_ids = [
2980 s["student_id"] for s in pending_submissions if s.get("student_id")
2981 ]
2982 name_map: dict = {}
2983 if student_ids:
2984 # Modified by Allan Ninal — 2026-09-24 (EI-T73).
2985 # WAS: db["users"] — a collection that does not exist. Users live in
2986 # `user_collection`: the Auth0 reconciliation worker creates and
2987 # soft-deletes real rows there, the identity-sync worker reads it,
2988 # and so does common/users.py. `db["users"]` appeared exactly ONCE in
2989 # the whole codebase, right here.
2990 # EFFECT: name_map was always empty, so every pending late submission
2991 # came back with "student_name": null. No error, no log — the
2992 # endpoint answered 200 with the student silently unidentified, which
2993 # is precisely what EI-T73 asks for ("each identified by its
2994 # submission identifier and student").
2995 user_docs = (
2996 await db["user_collection"]
2997 .find(
2998 {"_id": {"$in": student_ids}},
2999 {"first_name": 1, "last_name": 1},
3000 )
3001 .to_list(length=None)
3002 )
3003 name_map = {
3004 u["_id"]: f"{u.get('first_name', '')} {u.get('last_name', '')}".strip()
3005 for u in user_docs
3006 }
3008 pending_list = []
3009 for sub in pending_submissions:
3010 student_id = sub.get("student_id")
3011 student_name = name_map.get(student_id) if student_id else None
3013 pending_list.append(
3014 {
3015 "submission_id": str(sub["_id"]),
3016 "student_id": str(student_id) if student_id else None,
3017 "student_name": student_name,
3018 "date_submitted": (
3019 sub.get("date_submitted").isoformat()
3020 if sub.get("date_submitted")
3021 else None
3022 ),
3023 "pre_penalty_grade": sub.get("pre_penalty_grade"),
3024 "total_score": sub.get("total_score"),
3025 }
3026 )
3028 return {
3029 "assignment_uuid": assignment_uuid,
3030 "pending": pending_list,
3031 }
3033 async def approve_late_submission(
3034 self,
3035 submission_id: str,
3036 late_penalty: int,
3037 request: Request,
3038 ) -> dict:
3039 """
3040 Approve a pending late submission, applying an optional late penalty.
3042 POST /v1/teacher/assignment/late-submission/{submission_id}/approve
3044 Ownership: resolved via submission -> assignment -> created_by check.
3045 Returns 400 for invalid ObjectId, 404 if not owned, 409 if not pending.
3046 """
3047 if not ObjectId.is_valid(submission_id):
3048 raise HTTPException(status_code=400, detail="Invalid submission ID format")
3050 teacher_id = _teacher_id_from_request(request)
3051 submission_oid = ObjectId(submission_id)
3053 sub = await db["submission_collection"].find_one({"_id": submission_oid})
3054 if not sub:
3055 raise HTTPException(status_code=404, detail="Submission not found.")
3057 assignment_id = sub.get("assignment_id")
3058 if not assignment_id:
3059 raise HTTPException(
3060 status_code=404, detail="Submission has no associated assignment."
3061 )
3063 assignment = await db["assignments_collection"].find_one(
3064 {
3065 "_id": assignment_id,
3066 "created_by": {"$in": _created_by_values(teacher_id)},
3067 "deleted": {"$ne": True},
3068 }
3069 )
3070 if not assignment:
3071 raise HTTPException(status_code=404, detail="Assignment not found.")
3073 pre_penalty_grade = sub.get("pre_penalty_grade", sub.get("grade", 0)) or 0
3074 final_grade = max(0.0, float(pre_penalty_grade) - float(late_penalty))
3075 passing_grade = float(assignment.get("passing_grade", 75))
3076 final_remarks = "passed" if final_grade >= passing_grade else "failed"
3078 now_utc = datetime.now(timezone.utc)
3079 result = await db["submission_collection"].update_one(
3080 {"_id": submission_oid, "review_status": "pending"},
3081 {
3082 "$set": {
3083 "review_status": "approved",
3084 "late_penalty": late_penalty,
3085 "grade": final_grade,
3086 "remarks": final_remarks,
3087 "reviewed_by": teacher_id,
3088 "reviewed_at": now_utc,
3089 }
3090 },
3091 )
3093 if result.matched_count == 0:
3094 raise HTTPException(
3095 status_code=409, detail="Submission is not pending review."
3096 )
3098 return {
3099 "submission_id": submission_id,
3100 "review_status": "approved",
3101 "grade": final_grade,
3102 "late_penalty": late_penalty,
3103 }
3105 async def reject_late_submission(
3106 self,
3107 submission_id: str,
3108 reason: str | None,
3109 request: Request,
3110 ) -> dict:
3111 """
3112 Reject a pending late submission.
3114 POST /v1/teacher/assignment/late-submission/{submission_id}/reject
3116 Ownership: resolved via submission -> assignment -> created_by check.
3117 Returns 400 for invalid ObjectId, 404 if not owned, 409 if not pending.
3118 """
3119 if not ObjectId.is_valid(submission_id):
3120 raise HTTPException(status_code=400, detail="Invalid submission ID format")
3122 teacher_id = _teacher_id_from_request(request)
3123 submission_oid = ObjectId(submission_id)
3125 sub = await db["submission_collection"].find_one({"_id": submission_oid})
3126 if not sub:
3127 raise HTTPException(status_code=404, detail="Submission not found.")
3129 assignment_id = sub.get("assignment_id")
3130 if not assignment_id:
3131 raise HTTPException(
3132 status_code=404, detail="Submission has no associated assignment."
3133 )
3135 assignment = await db["assignments_collection"].find_one(
3136 {
3137 "_id": assignment_id,
3138 "created_by": {"$in": _created_by_values(teacher_id)},
3139 "deleted": {"$ne": True},
3140 }
3141 )
3142 if not assignment:
3143 raise HTTPException(status_code=404, detail="Assignment not found.")
3145 # EI-3448: `reason` is shown verbatim to the STUDENT in their own submission
3146 # remarks (unlike the teacher-only comment field) — a plain-text rejection
3147 # reason (LateRejectRequest.reason, a MUI TextField), so strip_html rather
3148 # than sanitize_rich_text, same reasoning as EI-3446/EI-3447. A pure-markup
3149 # reason (e.g. only a <script> tag) collapses to "" after stripping — treat
3150 # that the same as no reason at all, not a blank remarks value.
3151 sanitized_reason = strip_html(reason) if reason else None
3152 remarks_value = sanitized_reason if sanitized_reason else "rejected"
3153 now_utc = datetime.now(timezone.utc)
3154 # EI-1210 review fix: rejecting frees the consumed attempt so the student
3155 # is not stranded (with the default allowed_attempts=1 a rejection would
3156 # otherwise permanently lock them out via the attempt-cap guard). Clearing
3157 # is_submitted + decrementing total_attempts lets them fix and resubmit;
3158 # review_status stays "rejected" (shown as "Rejected") until they do.
3159 result = await db["submission_collection"].update_one(
3160 {"_id": submission_oid, "review_status": "pending"},
3161 {
3162 "$set": {
3163 "review_status": "rejected",
3164 "remarks": remarks_value,
3165 "reviewed_by": teacher_id,
3166 "reviewed_at": now_utc,
3167 "is_submitted": False,
3168 },
3169 "$inc": {"total_attempts": -1},
3170 },
3171 )
3173 if result.matched_count == 0:
3174 raise HTTPException(
3175 status_code=409, detail="Submission is not pending review."
3176 )
3178 return {
3179 "submission_id": submission_id,
3180 "review_status": "rejected",
3181 }
3183 async def submission_fetch(self, submission_id: str, request: Request):
3184 try:
3185 student_id = to_user_id(request.state.user_details["uuid"])
3187 # Fetch the submission by submission_id and student_id
3188 submission = await Submission.find_one(
3189 {"_id": ObjectId(submission_id), "student_id": student_id}
3190 )
3192 if not submission:
3193 raise HTTPException(
3194 status_code=404,
3195 detail="Submission not found or the student has no access to this submission",
3196 )
3198 # Fetch the assignment using the assignment_id from the submission
3199 fetched_assignment = await Assignment.find(
3200 {"_id": ObjectId(submission.assignment_id)}
3201 ).to_list()
3203 if not fetched_assignment:
3204 raise HTTPException(
3205 status_code=404,
3206 detail="Assignment not found or the student has no access to this assignment",
3207 )
3209 fetched_assignment = fetched_assignment[0]
3211 # Fetch all questions related to the assignment
3212 all_questions = (
3213 await db["question_collection"]
3214 .find({"_id": {"$in": fetched_assignment.question_ids}})
3215 .to_list(None)
3216 )
3218 fetched_assignment = fetched_assignment.model_dump()
3219 fetched_assignment["submission"] = submission.model_dump()
3221 del fetched_assignment["question_ids"]
3222 del fetched_assignment["submission_ids"]
3223 all_questions = model_parser.parse_response(
3224 all_questions, exclude_dates=True
3225 )
3227 fetched_assignment["questions"] = all_questions
3228 return {"Assignment": fetched_assignment}
3230 except HTTPException:
3231 # Let deliberate HTTP errors through; without this the method's own
3232 # 400/403/404 was swallowed by the catch-all and re-thrown as a 500,
3233 # which the frontend renders as a maintenance dialog.
3234 raise
3235 except Exception as e:
3236 raise HTTPException(status_code=500, detail=safe_detail(e))