Coverage for server / utilities / gradebook.py: 98%
63 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"""
2EI-1195 — shared submission→grade resolution.
4The teacher gradebook and the student assignment list must ALWAYS agree on the
5score for the same (student, assignment). They diverged because:
7 1. Duplicate submission documents can exist for one (student, assignment)
8 (no unique index + an insert-on-start race), and
9 2. the two endpoints resolved those duplicates differently — each built an
10 unordered "last-write-wins" map, and with *different* null filters — so the
11 teacher could land on the graded (100) attempt while the student landed on
12 a different one (0 / "missed").
14These helpers give BOTH endpoints one deterministic canonical submission per
15(student, assignment) and one consistent display rule, so the two views can
16never contradict each other.
17"""
19from datetime import datetime, timezone
20from typing import Optional
22# Non-numeric cell states, shared verbatim by both views.
23STATUS_GRADED = "graded"
24STATUS_MISSED = "missed"
25STATUS_INCOMPLETE = "incomplete"
26STATUS_PENDING = "pending" # late submission awaiting teacher approval (EI-1210)
27STATUS_REJECTED = "rejected" # late submission the teacher declined (EI-1210)
30def _to_utc_aware(dt):
31 if not dt:
32 return None
33 return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
36def _canonical_sort_key(sub):
37 """Deterministic ranking so the same attempt wins regardless of the order a
38 given query returned the duplicates in. Prefer a real submitted attempt,
39 then the most recent activity, then the _id (stable final tie-break)."""
40 submitted = 1 if sub.get("is_submitted") else 0
41 ts = (
42 _to_utc_aware(sub.get("date_submitted"))
43 or _to_utc_aware(sub.get("date_updated"))
44 or _to_utc_aware(sub.get("date_created"))
45 or datetime.min.replace(tzinfo=timezone.utc)
46 )
47 return (submitted, ts, str(sub.get("_id", "")))
50def select_canonical_submission(submissions):
51 """Pick ONE submission from a list of 0+ duplicates for the same
52 (student, assignment). Both endpoints call this, so they never disagree on
53 which attempt to display. Returns None when the list is empty."""
54 if not submissions:
55 return None
56 return max(submissions, key=_canonical_sort_key)
59def resolve_cell_grade(submission, date_close, now=None):
60 """Resolve the canonical display for one (student, assignment) cell.
62 Returns ``{"grade": <float|None>, "status": <str>}``:
63 - a real graded submission → numeric grade, status "graded"
64 - a late submission pending teacher approval → grade None, status "pending"
65 - a started-but-ungraded submission, or none at all → grade None, and
66 status "missed" (past the close date) or "incomplete" (still open).
68 ``submission`` should already be the canonical one (see
69 :func:`select_canonical_submission`)."""
70 now = now or datetime.now(timezone.utc)
71 close = _to_utc_aware(date_close)
72 is_past_close = bool(close and close < now)
73 ungraded_status = STATUS_MISSED if is_past_close else STATUS_INCOMPLETE
75 if not submission:
76 return {"grade": None, "status": ungraded_status}
78 # A late submission awaiting review has a provisional grade that is withheld
79 # on BOTH views until the teacher approves it (EI-1210).
80 if submission.get("review_status") == STATUS_PENDING:
81 return {"grade": None, "status": STATUS_PENDING}
83 # A rejected late submission must NOT award the student its provisional grade
84 # on either view (the raw score is still stored for audit but never shown) —
85 # otherwise the gradebook/student list would contradict analytics, which
86 # excludes rejected submissions (EI-1195 review finding).
87 if submission.get("review_status") == STATUS_REJECTED:
88 return {"grade": None, "status": STATUS_REJECTED}
90 grade = submission.get("grade")
91 if grade is not None:
92 try:
93 return {"grade": float(grade), "status": STATUS_GRADED}
94 except (TypeError, ValueError):
95 pass
97 return {"grade": None, "status": ungraded_status}
100def extract_live_question_ids(raw_questions) -> set:
101 """String ids from an assignment's CURRENT `questions` list — tolerates
102 {'id': ...}/{'_id': ...} dicts, a bare ObjectId, or a string entry.
103 Used to filter a submission's answers down to only the questions the
104 assignment still references (see recalculate_submitted_grade)."""
105 ids = set()
106 for q in raw_questions or []:
107 qid = (q.get("id") or q.get("_id")) if isinstance(q, dict) else q
108 if qid is not None:
109 ids.add(str(qid))
110 return ids
113def recalculate_submitted_grade(submission: dict, live_question_ids) -> Optional[float]:
114 """Percentage grade for a FULLY-SUBMITTED attempt, recomputed using only
115 the ids in `live_question_ids` — the assignment's CURRENT question refs.
117 The stored `submission["grade"]` is frozen from submit time, so it goes
118 stale the moment a teacher removes a question from the assignment
119 afterward: that question must stop contributing to (or costing) the
120 score on every later view, on BOTH the student's own submission review
121 and the teacher's gradebook/analytics — the same reason this lives
122 alongside select_canonical_submission/resolve_cell_grade rather than in
123 just one caller.
125 Uses the already-scored `submission["last_student_answers"]` (each entry
126 carries isCorrect/earnedPoints from submit time — score_question is
127 never re-run, since that scoring never changes; only which questions
128 still count does) and each surviving question's point value from the
129 submission's own stored `questions` snapshot.
131 Returns None when the submission was never fully submitted (nothing to
132 recompute from) — callers should then fall back to submission.get("grade").
133 """
134 last_student_answers = submission.get("last_student_answers")
135 if not last_student_answers:
136 return None
138 live_ids = {str(qid) for qid in live_question_ids}
139 question_map = {str(q["_id"]): q for q in submission.get("questions", [])}
141 total_score = 0
142 total_points = 0
143 for answer in last_student_answers:
144 q_id = str(answer.get("questionId"))
145 if q_id not in live_ids:
146 continue
147 q_doc = question_map.get(q_id)
148 if not q_doc:
149 continue
150 total_points += q_doc.get("points", 0) or 0
151 if answer.get("isCorrect") or answer.get("earnedPoints"):
152 total_score += answer.get("earnedPoints") or 0
154 return (total_score / total_points * 100) if total_points > 0 else 0.0