Coverage for server / services / teacher / teacher_dashboard.py: 94%
51 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1from fastapi import Depends, HTTPException, Request, status
2from server.connection.database import db
3from bson import ObjectId
4from server.utilities.user_id_helper import to_user_id
5from server.utilities.error_detail import safe_detail
8class TeacherDashboardService:
10 def __init__(self):
11 pass
13 async def class_statistics_fetch(self, request: Request):
14 """
15 Fetch the total number of classes associated with the authenticated teacher.
17 This method counts all class documents in the `class_collection` where the
18 `teacher._id` matches the current user's ID.
20 Args:
21 request (Request): The incoming FastAPI request object containing user details.
23 Returns:
24 dict: A response containing a success message and the total number of classes.
26 Raises:
27 HTTPException: If an error occurs during database access or ID conversion.
28 """
29 try:
30 teacher_id = to_user_id(request.state.user_details["uuid"])
32 # Soft-deleted classes must not be counted. `teacher._id` alone also
33 # matches classes the teacher has DELETED — they keep the embedded
34 # teacher and are only flagged `deleted: True` — so the dashboard
35 # would report classes that no longer exist anywhere else in the UI.
36 # Matches the filter in TeacherClassesService.all_classes_fetch.
37 total_classes = await db["class_collection"].count_documents({
38 "teacher._id": ObjectId(teacher_id),
39 "deleted": {"$ne": True},
40 })
42 return {
43 "details": "Successfully fetched total classes.",
44 "total_classes": total_classes
45 }
46 except Exception as error:
47 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(error))
50 async def student_statistics_fetch(self, request: Request):
51 """
52 Fetch the total number of unique students with status 'Enrolled' and 'Pending'
53 across all classes taught by the authenticated teacher.
55 This method aggregates student IDs from the `students` array in the `class_collection`
56 and filters them based on their status.
58 Args:
59 request (Request): The incoming FastAPI request object containing user details.
61 Returns:
62 dict: A response with a breakdown of total enrolled, pending, and overall students.
64 Raises:
65 HTTPException: If an error occurs during database access or processing.
66 """
67 try:
68 teacher_id = to_user_id(request.state.user_details["uuid"])
70 # Fetch all classes handled by the teacher (excluding soft-deleted
71 # ones — otherwise students only ever enrolled in a DELETED class
72 # still count toward "Enrolled Students").
73 classes = await db["class_collection"].find(
74 {"teacher._id": ObjectId(teacher_id), "deleted": {"$ne": True}},
75 {"students": 1}
76 ).to_list(length=None)
78 # Track students by status
79 enrolled_ids = set()
80 pending_ids = set()
82 for class_doc in classes:
83 for student in class_doc.get("students", []):
84 student_id = str(student.get("_id"))
85 status = student.get("status", "").strip().lower()
87 if not student_id:
88 continue
90 if status == "enrolled":
91 enrolled_ids.add(student_id)
92 elif status == "pending":
93 pending_ids.add(student_id)
95 return {
96 "details": "Successfully fetched student statistics.",
97 "total_students": {
98 "enrolled": len(enrolled_ids),
99 "pending": len(pending_ids),
100 "all": len(enrolled_ids.union(pending_ids))
101 }
102 }
103 except Exception as error:
104 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(error))
107 async def assignment_statistics_fetch(self, request: Request):
108 """
109 Fetch the total number of assignments created by the authenticated teacher.
111 This method counts both active (not deleted) and deleted assignments in the
112 `assignments_collection` based on the teacher's `created_by` field.
114 Args:
115 request (Request): The incoming FastAPI request object containing user details.
117 Returns:
118 dict: A response with counts of active, deleted, and total assignments.
120 Raises:
121 HTTPException: If an error occurs during database access or ID conversion.
122 """
123 try:
124 teacher_id = to_user_id(request.state.user_details["uuid"])
125 teacher_object_id = ObjectId(teacher_id)
127 # Count not deleted assignments
128 active_assignments = await db["assignments_collection"].count_documents({
129 "created_by": teacher_object_id,
130 "deleted": False
131 })
133 # Count deleted assignments
134 deleted_assignments = await db["assignments_collection"].count_documents({
135 "created_by": teacher_object_id,
136 "deleted": True
137 })
139 return {
140 "details": "Successfully fetched assignment statistics.",
141 "total_assignments": {
142 "active": active_assignments,
143 "deleted": deleted_assignments,
144 "all": active_assignments + deleted_assignments
145 }
146 }
147 except Exception as error:
148 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(error))
151 async def submission_statistics_fetch(self, request: Request):
152 try:
153 teacher_id = to_user_id(request.state.user_details["uuid"])
155 # Single aggregation pipeline replaces the previous N×3+1 query loop
156 # (1 query per class + 3 count_documents per assignment).
157 pipeline = [
158 # Excluding soft-deleted classes is a correctness fix AND the
159 # performance fix. Without it this aggregation fans out over every
160 # class the teacher has ever created, then $lookups assignments and
161 # submissions for each: on a QA account with 1,620 soft-deleted
162 # classes it returned 414 KB in 18.4 s, versus 0.9 s for the sibling
163 # endpoints. The frontend sets no axios timeout, so the dashboard
164 # simply showed loading skeletons until it eventually resolved.
165 {"$match": {"teacher._id": ObjectId(teacher_id), "deleted": {"$ne": True}}},
166 {"$lookup": {
167 "from": "assignments_collection",
168 "let": {"class_id": "$_id"},
169 "pipeline": [
170 {"$match": {"$expr": {"$in": ["$$class_id", "$assigned_class"]}}},
171 {"$project": {"_id": 1, "title": 1}},
172 {"$lookup": {
173 "from": "submission_collection",
174 "let": {"asgn_id": "$_id"},
175 "pipeline": [
176 {"$match": {"$expr": {"$eq": ["$assignment_id", "$$asgn_id"]}}},
177 {"$group": {
178 "_id": None,
179 "total": {"$sum": 1},
180 "passed": {"$sum": {"$cond": [{"$eq": ["$remarks", "passed"]}, 1, 0]}},
181 "failed": {"$sum": {"$cond": [{"$eq": ["$remarks", "failed"]}, 1, 0]}},
182 }},
183 ],
184 "as": "sub_stats",
185 }},
186 {"$addFields": {
187 "sub_stats": {
188 "$ifNull": [
189 {"$arrayElemAt": ["$sub_stats", 0]},
190 {"total": 0, "passed": 0, "failed": 0},
191 ]
192 }
193 }},
194 ],
195 "as": "assignments",
196 }},
197 {"$addFields": {
198 "total_assignments": {"$size": "$assignments"},
199 "total_submissions": {
200 "$reduce": {
201 "input": "$assignments",
202 "initialValue": 0,
203 "in": {"$add": ["$$value", {"$ifNull": ["$$this.sub_stats.total", 0]}]},
204 }
205 },
206 "total_passed_submissions": {
207 "$reduce": {
208 "input": "$assignments",
209 "initialValue": 0,
210 "in": {"$add": ["$$value", {"$ifNull": ["$$this.sub_stats.passed", 0]}]},
211 }
212 },
213 "total_failed_submissions": {
214 "$reduce": {
215 "input": "$assignments",
216 "initialValue": 0,
217 "in": {"$add": ["$$value", {"$ifNull": ["$$this.sub_stats.failed", 0]}]},
218 }
219 },
220 }},
221 {"$project": {
222 "_id": 0,
223 "class_id": {"$toString": "$_id"},
224 "class_title": {"$ifNull": ["$title", "No Class Title"]},
225 "class_section": {"$ifNull": ["$section", "No Section"]},
226 "class_code": {"$ifNull": ["$class_code", "No Class Code"]},
227 "total_assignments": 1,
228 "total_submissions": 1,
229 "total_passed_submissions": 1,
230 "total_failed_submissions": 1,
231 "assignments": {
232 "$map": {
233 "input": "$assignments",
234 "as": "a",
235 "in": {
236 "_id": {"$toString": "$$a._id"},
237 "title": "$$a.title",
238 "passed_submissions": "$$a.sub_stats.passed",
239 "failed_submissions": "$$a.sub_stats.failed",
240 },
241 }
242 },
243 }},
244 ]
246 class_stats = await db["class_collection"].aggregate(pipeline).to_list(length=None)
248 return {
249 "details": "Successfully fetched submission statistics.",
250 "data": class_stats,
251 }
253 except Exception as error:
254 raise HTTPException(
255 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
256 detail=safe_detail(error)
257 )