Coverage for server / services / student / student_dashboard.py: 100%
134 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1import math
2from datetime import datetime, timezone
3from fastapi import Depends, HTTPException, Request, status
4# from server.models.question import Activity
5from server.validators.query_params_validators import validate_query_params
6from bson.objectid import ObjectId
7from server.connection.database import db
8from server.models.classes import (ClassModel)
9from server.utilities.helpers import serialized_response_object
10from server.utilities.user_id_helper import to_user_id, enrollment_id_conditions
11from server.utilities.gradebook import (
12 select_canonical_submission,
13 resolve_cell_grade,
14 extract_live_question_ids,
15 recalculate_submitted_grade,
16 STATUS_MISSED,
17 STATUS_REJECTED,
18)
19from server.services.student.student_assignment import format_grade
20from server.utilities.error_detail import safe_detail
25def _live_class_submission_stages():
26 """Pipeline stages keeping only submissions whose assignment belongs to at least one LIVE class."""
27 return [
28 {
29 "$lookup": {
30 "from": "assignments_collection",
31 "localField": "assignment_id",
32 "foreignField": "_id",
33 "as": "_assignment",
34 }
35 },
36 {"$unwind": "$_assignment"},
37 {
38 "$lookup": {
39 "from": "class_collection",
40 "localField": "_assignment.assigned_class",
41 "foreignField": "_id",
42 "as": "_classes",
43 }
44 },
45 # EI-3386 (Allan Ninal, 2026-10-04): the dashboard counts live classes only.
46 {"$match": {"_classes": {"$elemMatch": {"deleted": {"$ne": True}}}}},
47 {"$project": {"_assignment": 0, "_classes": 0}},
48 ]
51class DashboardService:
52 def __init__(self):
53 pass
56 async def class_statistics_fetch(self, request: Request):
57 """
58 Fetch the total number of classes in which the current user (student) is enrolled.
60 Matches enrollment by student `_id` OR `email` (same fallback
61 `all_classes_fetch` uses in `student_classes.py`) — some accounts have
62 a duplicate/stale user document sharing the same email, whose `_id`
63 ends up on a class's enrollment record instead of the live account's,
64 so an `_id`-only match undercounts. Soft-deleted classes are excluded,
65 since the email fallback can otherwise pick up stale enrollments left
66 behind on deleted test/duplicate classes.
68 Args:
69 request (Request): The incoming FastAPI request object containing user details
70 in `request.state.user_details`.
71 Returns:
72 dict: A dictionary containing the total number of classes the student is enrolled in.
73 Example: {"totalClasses": 3}
74 Raises:
75 HTTPException: Raises a 400 Bad Request if any error occurs during the database query.
76 """
77 try:
78 user_id = to_user_id(request.state.user_details["uuid"])
79 email = request.state.user_details.get("email")
81 cursor = ClassModel.find({
82 "students": {
83 "$elemMatch": {
84 "$or": enrollment_id_conditions(user_id, email),
85 "status": "Enrolled"
86 }
87 },
88 "deleted": {"$ne": True}
89 })
90 fetched_classes = await cursor.to_list(length=None)
91 return {
92 # "classes": fetched_classes,
93 "totalClasses": len(fetched_classes)
94 }
95 except Exception as e:
96 raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e))
99 async def assignment_statistics_fetch(self, request: Request):
100 """
101 Fetch the total number of assignments assigned to classes where the current user (student) is enrolled.
102 This uses an aggregation pipeline to:
103 - Find classes where the student is enrolled.
104 - Lookup assignments linked to those classes.
105 - Filter out deleted assignments.
106 - Return the total count of valid assignments.
107 Args:
108 request (Request): The incoming FastAPI request object containing user details
109 in `request.state.user_details`.
110 Returns:
111 dict: A dictionary containing the total number of assignments.
112 Example: {"totalAssignments": 5}
113 Raises:
114 HTTPException: Raises a 400 Bad Request if any error occurs during the aggregation.
115 """
116 try:
117 student_id = to_user_id(request.state.user_details["uuid"])
118 email = request.state.user_details.get("email")
120 pipeline = [
121 # Step 1: Filter classes where student is enrolled. Excludes
122 # soft-deleted classes — the email fallback in
123 # enrollment_id_conditions can otherwise pick up a stale
124 # enrollment left on an old deleted/duplicate-account class.
125 {
126 "$match": {
127 "students": {
128 "$elemMatch": {
129 "$or": enrollment_id_conditions(student_id, email),
130 "status": "Enrolled"
131 }
132 },
133 "deleted": {"$ne": True}
134 }
135 },
136 # Step 2: Project only the class _id
137 {
138 "$project": {
139 "_id": 1
140 }
141 },
142 # Step 3: Lookup assignments assigned to these classes
143 {
144 "$lookup": {
145 "from": "assignments_collection", # collection name of assignments
146 "localField": "_id",
147 "foreignField": "assigned_class",
148 "as": "assignments"
149 }
150 },
151 # Step 4: Unwind the assignments array to get individual assignment docs
152 {
153 "$unwind": "$assignments"
154 },
155 # Step 5: Filter out deleted assignments if needed
156 {
157 "$match": {
158 "assignments.deleted": False
159 }
160 },
161 # Step 6: Replace root to return assignments directly
162 {
163 "$replaceRoot": {
164 "newRoot": "$assignments"
165 }
166 }
167 ]
169 cursor = db["class_collection"].aggregate(pipeline)
170 assignments = [assignment async for assignment in cursor]
172 return {
173 # "assignments": serialized_response_object(assignments),
174 "totalAssignments": len(assignments)
175 }
177 except Exception as e:
178 raise HTTPException(
179 status.HTTP_500_INTERNAL_SERVER_ERROR,
180 detail=safe_detail(e)
181 )
183 async def achievements_fetch(self, request: Request):
184 """EI-2966 — Derive achievements from the student's submission history.
186 On-the-fly derivation (no new model / no event-based award flow):
187 each badge is a pure predicate over the student's submitted
188 submissions. Returns earned + locked badges with progress
189 indicators where applicable (streak-style badges).
190 """
191 try:
192 student_id = to_user_id(request.state.user_details["uuid"])
194 pipeline = [
195 {
196 "$match": {
197 "student_id": student_id,
198 "is_submitted": True,
199 }
200 },
201 # EI-3386 (Allan Ninal, 2026-10-04): the dashboard counts live classes only.
202 *_live_class_submission_stages(),
203 {
204 "$addFields": {
205 "_grade_num": {
206 "$convert": {
207 "input": "$grade",
208 "to": "double",
209 "onError": None,
210 "onNull": None,
211 }
212 }
213 }
214 },
215 {
216 "$project": {
217 "_id": 0,
218 "grade": "$_grade_num",
219 "submitted_at": 1,
220 }
221 },
222 {"$sort": {"submitted_at": 1}},
223 ]
225 cursor = db["submission_collection"].aggregate(pipeline)
226 subs = [doc async for doc in cursor]
228 total_subs = len(subs)
229 graded = [s for s in subs if isinstance(s.get("grade"), (int, float))]
230 graded_count = len(graded)
231 perfect = [s for s in graded if s["grade"] == 100]
232 avg = (sum(s["grade"] for s in graded) / graded_count) if graded_count else None
233 first_submitted_at = subs[0].get("submitted_at") if subs else None
234 first_perfect_at = perfect[0].get("submitted_at") if perfect else None
236 def _badge(badge_id, title, description, icon, earned, earned_at=None, progress=None, target=None):
237 return {
238 "id": badge_id,
239 "title": title,
240 "description": description,
241 "icon": icon,
242 "earned": bool(earned),
243 "earned_at": earned_at,
244 "progress": progress,
245 "target": target,
246 }
248 achievements = [
249 _badge(
250 "first_submission",
251 "Getting Started",
252 "Submit your first assignment.",
253 "rocket",
254 earned=total_subs >= 1,
255 earned_at=first_submitted_at,
256 progress=min(total_subs, 1),
257 target=1,
258 ),
259 _badge(
260 "perfect_score",
261 "Perfect Score",
262 "Earn 100% on any assignment.",
263 "star",
264 earned=len(perfect) >= 1,
265 earned_at=first_perfect_at,
266 ),
267 _badge(
268 "high_achiever",
269 "High Achiever",
270 "Maintain an average grade of 90 or higher.",
271 "trophy",
272 earned=(avg is not None and avg >= 90),
273 ),
274 _badge(
275 "consistent_3",
276 "Building Momentum",
277 "Submit 3 graded assignments.",
278 "flame",
279 earned=graded_count >= 3,
280 progress=min(graded_count, 3),
281 target=3,
282 ),
283 _badge(
284 "consistent_10",
285 "Dedicated Learner",
286 "Submit 10 graded assignments.",
287 "medal",
288 earned=graded_count >= 10,
289 progress=min(graded_count, 10),
290 target=10,
291 ),
292 ]
293 earned_count = sum(1 for a in achievements if a["earned"])
294 return {
295 "achievements": achievements,
296 "earned_count": earned_count,
297 "total_count": len(achievements),
298 }
299 except Exception as e:
300 raise HTTPException(
301 status.HTTP_500_INTERNAL_SERVER_ERROR,
302 detail=safe_detail(e),
303 )
306 async def grade_distribution_fetch(self, request: Request):
307 """EI-2966 — Compute the student's grade histogram across all graded submissions.
309 Buckets: <60, 60-69, 70-79, 80-89, 90-100. Returns the count
310 per bucket plus the total count of graded submissions. An empty
311 student (no graded subs) returns all zero counts so the FE can
312 render an empty-state chart.
313 """
314 try:
315 student_id = to_user_id(request.state.user_details["uuid"])
317 pipeline = [
318 {
319 "$match": {
320 "student_id": student_id,
321 "is_submitted": True,
322 "grade": {"$ne": None, "$exists": True},
323 }
324 },
325 {
326 "$addFields": {
327 "_grade_num": {
328 "$convert": {
329 "input": "$grade",
330 "to": "double",
331 "onError": None,
332 "onNull": None,
333 }
334 }
335 }
336 },
337 {"$match": {"_grade_num": {"$ne": None}}},
338 # EI-3386 (Allan Ninal, 2026-10-04): the dashboard counts live classes only.
339 *_live_class_submission_stages(),
340 {
341 "$bucket": {
342 "groupBy": "$_grade_num",
343 "boundaries": [0, 60, 70, 80, 90, 101],
344 "default": "other",
345 "output": {"count": {"$sum": 1}},
346 }
347 },
348 ]
350 cursor = db["submission_collection"].aggregate(pipeline)
351 raw = [doc async for doc in cursor]
353 counts_by_min = {doc["_id"]: doc["count"] for doc in raw if doc["_id"] != "other"}
354 buckets = [
355 {"label": "<60", "min": 0, "max": 59, "count": counts_by_min.get(0, 0)},
356 {"label": "60-69", "min": 60, "max": 69, "count": counts_by_min.get(60, 0)},
357 {"label": "70-79", "min": 70, "max": 79, "count": counts_by_min.get(70, 0)},
358 {"label": "80-89", "min": 80, "max": 89, "count": counts_by_min.get(80, 0)},
359 {"label": "90-100", "min": 90, "max": 100, "count": counts_by_min.get(90, 0)},
360 ]
361 total_graded = sum(b["count"] for b in buckets)
362 return {"buckets": buckets, "total_graded": total_graded}
363 except Exception as e:
364 raise HTTPException(
365 status.HTTP_500_INTERNAL_SERVER_ERROR,
366 detail=safe_detail(e),
367 )
370 async def gpa_fetch(self, request: Request):
371 """EI-2966 — Compute the student's overall + per-class average grade.
373 Aggregates submitted submissions (`is_submitted: True`) where the
374 `grade` field is set and numeric, joining to assignments + classes
375 for the per-class breakdown. Returns ``None`` for `overall_average`
376 when no graded submissions exist so the FE can render an empty
377 state rather than a misleading 0%.
378 """
379 try:
380 student_id = to_user_id(request.state.user_details["uuid"])
382 pipeline = [
383 {
384 "$match": {
385 "student_id": student_id,
386 "is_submitted": True,
387 "grade": {"$ne": None, "$exists": True},
388 }
389 },
390 {
391 "$addFields": {
392 "_grade_num": {
393 "$convert": {
394 "input": "$grade",
395 "to": "double",
396 "onError": None,
397 "onNull": None,
398 }
399 }
400 }
401 },
402 {"$match": {"_grade_num": {"$ne": None}}},
403 {
404 "$lookup": {
405 "from": "assignments_collection",
406 "localField": "assignment_id",
407 "foreignField": "_id",
408 "as": "assignment",
409 }
410 },
411 {"$unwind": "$assignment"},
412 {
413 "$lookup": {
414 "from": "class_collection",
415 "localField": "assignment.assigned_class",
416 "foreignField": "_id",
417 "as": "class_info",
418 }
419 },
420 {"$unwind": "$class_info"},
421 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students.
422 {"$match": {"class_info.deleted": {"$ne": True}}},
423 {
424 "$group": {
425 "_id": "$class_info._id",
426 "class_title": {"$first": "$class_info.title"},
427 "class_code": {"$first": "$class_info.class_code"},
428 "sum_grade": {"$sum": "$_grade_num"},
429 "count": {"$sum": 1},
430 }
431 },
432 {
433 "$project": {
434 "_id": 0,
435 "class_id": {"$toString": "$_id"},
436 "class_title": 1,
437 "class_code": 1,
438 "average": {"$round": [{"$divide": ["$sum_grade", "$count"]}, 2]},
439 "count": 1,
440 }
441 },
442 {"$sort": {"class_title": 1}},
443 ]
445 cursor = db["submission_collection"].aggregate(pipeline)
446 per_class = [doc async for doc in cursor]
448 total_graded = sum(c["count"] for c in per_class)
449 if total_graded == 0:
450 overall_average = None
451 else:
452 total_sum = sum(c["average"] * c["count"] for c in per_class)
453 overall_average = round(total_sum / total_graded, 2)
455 return {
456 "overall_average": overall_average,
457 "total_graded": total_graded,
458 "per_class": per_class,
459 }
460 except Exception as e:
461 raise HTTPException(
462 status.HTTP_500_INTERNAL_SERVER_ERROR,
463 detail=safe_detail(e),
464 )
467 async def upcoming_assignments_fetch(self, request: Request, limit: int = 20):
468 """EI-2966 — Fetch the student's upcoming assignments by due date.
470 Filters by:
471 - Student is enrolled in the class.
472 - Assignment is not soft-deleted.
473 - Assignment `date_close` (due date) >= now.
475 Sorts ascending by `date_close` and caps to `limit`. Backs the
476 Student Dashboard Calendar widget shipped in eruditiontx-client-
477 mvp PR `feat/ei-2966-calendar-widget`.
478 """
479 try:
480 student_id = to_user_id(request.state.user_details["uuid"])
481 email = request.state.user_details.get("email")
482 now = datetime.now(timezone.utc)
484 pipeline = [
485 {
486 "$match": {
487 "students": {
488 "$elemMatch": {
489 "$or": enrollment_id_conditions(student_id, email),
490 "status": "Enrolled",
491 }
492 },
493 "deleted": {"$ne": True}
494 }
495 },
496 {"$project": {"_id": 1, "title": 1, "section": 1, "class_code": 1}},
497 {
498 "$lookup": {
499 "from": "assignments_collection",
500 "localField": "_id",
501 "foreignField": "assigned_class",
502 "as": "assignments",
503 }
504 },
505 {"$unwind": "$assignments"},
506 {
507 "$match": {
508 "assignments.deleted": False,
509 "assignments.date_close": {"$ne": None, "$gte": now},
510 }
511 },
512 {
513 "$project": {
514 "_id": {"$toString": "$assignments._id"},
515 "title": "$assignments.title",
516 "date_close": "$assignments.date_close",
517 "date_open": "$assignments.date_open",
518 "class_id": {"$toString": "$_id"},
519 "class_title": "$title",
520 "class_section": "$section",
521 "class_code": "$class_code",
522 }
523 },
524 {"$sort": {"date_close": 1}},
525 {"$limit": int(limit)},
526 ]
528 cursor = db["class_collection"].aggregate(pipeline)
529 upcoming = [doc async for doc in cursor]
530 return {"upcoming": serialized_response_object(upcoming)}
531 except Exception as e:
532 raise HTTPException(
533 status.HTTP_500_INTERNAL_SERVER_ERROR,
534 detail=safe_detail(e),
535 )
538 async def submissions_statistics_fetch(self, request: Request):
539 """
540 Fetch class-wise submissions for the current student with associated assignment and class details.
542 Counts are resolved with the same shared helpers the student assignment
543 list uses (`server/utilities/gradebook.py`, EI-1195/EI-1210), so this
544 endpoint's `totalSubmissions` agrees with what `/assignment/{class_code}/all/fetch`
545 shows as submitted for the same student:
546 - Duplicate submission docs for one (student, assignment) are deduped to
547 a single canonical attempt instead of each counting separately.
548 - Assignments that were soft-deleted are excluded.
549 - A submission counts only when its RESOLVED status (graded / pending /
550 incomplete) is not "missed" or "rejected" — matching the assignment
551 list's own classification exactly. A submitted-but-ungraded attempt
552 whose due date has since passed resolves to "missed" on the
553 assignment list even though `is_submitted` is True on the raw doc,
554 so it must not count as answered here either.
555 - An assignment shared across multiple classes still counts once toward
556 `totalSubmissions`, even though it appears under each of those classes
557 in the per-class breakdown.
559 Args:
560 request (Request): The FastAPI request object that contains `user_details` in `request.state`,
561 including the current student's `uuid`.
562 Raises:
563 HTTPException: If an error occurs during the database query or data processing.
564 """
565 try:
566 student_id = to_user_id(request.state.user_details["uuid"])
567 now = datetime.now(timezone.utc)
569 submissions = await db["submission_collection"].find(
570 {"student_id": student_id},
571 {
572 "assignment_id": 1, "grade": 1, "is_submitted": 1, "review_status": 1,
573 "date_submitted": 1, "date_updated": 1, "date_created": 1,
574 # Needed only to recalculate the grade against the
575 # assignment's current question list — see
576 # recalculate_submitted_grade.
577 "questions": 1, "last_student_answers": 1,
578 }
579 ).to_list(length=None)
581 if not submissions:
582 return {"classes": [], "totalSubmissions": 0}
584 assignment_ids = list({sub["assignment_id"] for sub in submissions})
586 assignments = await db["assignments_collection"].find(
587 {"_id": {"$in": assignment_ids}, "deleted": {"$ne": True}},
588 {"_id": 1, "assigned_class": 1, "date_close": 1, "questions": 1}
589 ).to_list(length=None)
591 submissions_by_assignment: dict = {}
592 for sub in submissions:
593 submissions_by_assignment.setdefault(sub["assignment_id"], []).append(sub)
595 class_ids = {class_id for a in assignments for class_id in a.get("assigned_class", [])}
596 # EI-3386 (Allan Ninal, 2026-10-04): the dashboard counts live classes only.
597 classes = await db["class_collection"].find(
598 {"_id": {"$in": list(class_ids)}, "deleted": {"$ne": True}},
599 {"_id": 1, "title": 1, "section": 1, "class_code": 1}
600 ).to_list(length=None)
601 classes_by_id = {c["_id"]: c for c in classes}
603 class_map = {}
604 total_submissions = 0
606 for assignment in assignments:
607 assignment_id = assignment["_id"]
608 # EI-3386 (Allan Ninal, 2026-10-04): the dashboard counts live classes only.
609 if not any(cid in classes_by_id for cid in assignment.get("assigned_class", [])):
610 continue
611 canonical = select_canonical_submission(submissions_by_assignment.get(assignment_id, []))
612 # Same recalculation the gradebook/assignment list apply — a
613 # question the teacher removed after grading must not keep
614 # affecting the score shown here either (EI-1195).
615 if canonical is not None:
616 recalculated_grade = recalculate_submitted_grade(
617 canonical, extract_live_question_ids(assignment.get("questions", []))
618 )
619 if recalculated_grade is not None:
620 canonical = {**canonical, "grade": recalculated_grade}
621 resolved = resolve_cell_grade(canonical, assignment.get("date_close"), now)
623 # Count exactly what the assignment list would NOT display as
624 # "missed" (or "rejected", excluded from analytics per EI-1195).
625 # A submitted-but-ungraded attempt whose due date has since
626 # passed resolves to "missed" there even though `is_submitted`
627 # is True on the raw doc, so it must not count as answered here.
628 if resolved["status"] in (STATUS_MISSED, STATUS_REJECTED):
629 continue
631 # Count this qualifying submission once, even if the assignment
632 # fans out to multiple classes below — totalSubmissions is a count
633 # of answered assignments, not a count of (assignment, class) rows.
634 total_submissions += 1
636 for class_id in assignment.get("assigned_class", []):
637 class_doc = classes_by_id.get(class_id)
638 if not class_doc:
639 continue
641 class_key = str(class_id)
643 if class_key not in class_map:
644 class_map[class_key] = {
645 "_id": class_key,
646 "title": class_doc.get("title"),
647 "section": class_doc.get("section"),
648 "class_code": class_doc.get("class_code"),
649 "assignment_id": str(assignment_id),
650 "submissions": []
651 }
653 class_map[class_key]["submissions"].append({
654 "_id": str(canonical["_id"]),
655 "is_submitted": canonical.get("is_submitted"),
656 # Same display rule as the assignment list (EI-1195): a
657 # real grade formats to a number string, otherwise fall
658 # back to the resolved status word (e.g. "incomplete")
659 # instead of a raw null for an ungraded submission.
660 "grade": format_grade(resolved["grade"]) if resolved["grade"] is not None else resolved["status"]
661 })
663 return {
664 "classes": list(class_map.values()),
665 "totalSubmissions": total_submissions
666 }
668 except Exception as e:
669 raise HTTPException(
670 status.HTTP_500_INTERNAL_SERVER_ERROR,
671 detail=safe_detail(e)
672 )