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

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 

6 

7 

8class TeacherDashboardService: 

9 

10 def __init__(self): 

11 pass 

12 

13 async def class_statistics_fetch(self, request: Request): 

14 """ 

15 Fetch the total number of classes associated with the authenticated teacher. 

16 

17 This method counts all class documents in the `class_collection` where the 

18 `teacher._id` matches the current user's ID. 

19 

20 Args: 

21 request (Request): The incoming FastAPI request object containing user details. 

22 

23 Returns: 

24 dict: A response containing a success message and the total number of classes. 

25 

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

31 

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

41 

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

48 

49 

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. 

54 

55 This method aggregates student IDs from the `students` array in the `class_collection` 

56 and filters them based on their status. 

57 

58 Args: 

59 request (Request): The incoming FastAPI request object containing user details. 

60 

61 Returns: 

62 dict: A response with a breakdown of total enrolled, pending, and overall students. 

63 

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

69 

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) 

77 

78 # Track students by status 

79 enrolled_ids = set() 

80 pending_ids = set() 

81 

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

86 

87 if not student_id: 

88 continue 

89 

90 if status == "enrolled": 

91 enrolled_ids.add(student_id) 

92 elif status == "pending": 

93 pending_ids.add(student_id) 

94 

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

105 

106 

107 async def assignment_statistics_fetch(self, request: Request): 

108 """ 

109 Fetch the total number of assignments created by the authenticated teacher. 

110 

111 This method counts both active (not deleted) and deleted assignments in the 

112 `assignments_collection` based on the teacher's `created_by` field. 

113 

114 Args: 

115 request (Request): The incoming FastAPI request object containing user details. 

116 

117 Returns: 

118 dict: A response with counts of active, deleted, and total assignments. 

119 

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) 

126 

127 # Count not deleted assignments 

128 active_assignments = await db["assignments_collection"].count_documents({ 

129 "created_by": teacher_object_id, 

130 "deleted": False 

131 }) 

132 

133 # Count deleted assignments 

134 deleted_assignments = await db["assignments_collection"].count_documents({ 

135 "created_by": teacher_object_id, 

136 "deleted": True 

137 }) 

138 

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

149 

150 

151 async def submission_statistics_fetch(self, request: Request): 

152 try: 

153 teacher_id = to_user_id(request.state.user_details["uuid"]) 

154 

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 ] 

245 

246 class_stats = await db["class_collection"].aggregate(pipeline).to_list(length=None) 

247 

248 return { 

249 "details": "Successfully fetched submission statistics.", 

250 "data": class_stats, 

251 } 

252 

253 except Exception as error: 

254 raise HTTPException( 

255 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

256 detail=safe_detail(error) 

257 )