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

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 

21 

22 

23 

24 

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 ] 

49 

50 

51class DashboardService: 

52 def __init__(self): 

53 pass 

54 

55 

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. 

59 

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. 

67 

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") 

80 

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)) 

97 

98 

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") 

119 

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 ] 

168 

169 cursor = db["class_collection"].aggregate(pipeline) 

170 assignments = [assignment async for assignment in cursor] 

171 

172 return { 

173 # "assignments": serialized_response_object(assignments), 

174 "totalAssignments": len(assignments) 

175 } 

176 

177 except Exception as e: 

178 raise HTTPException( 

179 status.HTTP_500_INTERNAL_SERVER_ERROR, 

180 detail=safe_detail(e) 

181 ) 

182 

183 async def achievements_fetch(self, request: Request): 

184 """EI-2966 — Derive achievements from the student's submission history. 

185 

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"]) 

193 

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 ] 

224 

225 cursor = db["submission_collection"].aggregate(pipeline) 

226 subs = [doc async for doc in cursor] 

227 

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 

235 

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 } 

247 

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 ) 

304 

305 

306 async def grade_distribution_fetch(self, request: Request): 

307 """EI-2966 — Compute the student's grade histogram across all graded submissions. 

308 

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"]) 

316 

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 ] 

349 

350 cursor = db["submission_collection"].aggregate(pipeline) 

351 raw = [doc async for doc in cursor] 

352 

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 ) 

368 

369 

370 async def gpa_fetch(self, request: Request): 

371 """EI-2966 — Compute the student's overall + per-class average grade. 

372 

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"]) 

381 

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 ] 

444 

445 cursor = db["submission_collection"].aggregate(pipeline) 

446 per_class = [doc async for doc in cursor] 

447 

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) 

454 

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 ) 

465 

466 

467 async def upcoming_assignments_fetch(self, request: Request, limit: int = 20): 

468 """EI-2966 — Fetch the student's upcoming assignments by due date. 

469 

470 Filters by: 

471 - Student is enrolled in the class. 

472 - Assignment is not soft-deleted. 

473 - Assignment `date_close` (due date) >= now. 

474 

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) 

483 

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 ] 

527 

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 ) 

536 

537 

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. 

541 

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. 

558 

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) 

568 

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) 

580 

581 if not submissions: 

582 return {"classes": [], "totalSubmissions": 0} 

583 

584 assignment_ids = list({sub["assignment_id"] for sub in submissions}) 

585 

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) 

590 

591 submissions_by_assignment: dict = {} 

592 for sub in submissions: 

593 submissions_by_assignment.setdefault(sub["assignment_id"], []).append(sub) 

594 

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} 

602 

603 class_map = {} 

604 total_submissions = 0 

605 

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) 

622 

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 

630 

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 

635 

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 

640 

641 class_key = str(class_id) 

642 

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 } 

652 

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 }) 

662 

663 return { 

664 "classes": list(class_map.values()), 

665 "totalSubmissions": total_submissions 

666 } 

667 

668 except Exception as e: 

669 raise HTTPException( 

670 status.HTTP_500_INTERNAL_SERVER_ERROR, 

671 detail=safe_detail(e) 

672 )