Coverage for server / services / student / student_classes.py: 99%

345 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-10-04 09:33 +0000

1import logging 

2import math 

3import re 

4from datetime import datetime, timezone 

5from typing import Dict, Optional, Any 

6from bson import ObjectId, errors 

7from fastapi import Body, Depends, HTTPException, Request, status 

8from pydantic import ValidationError 

9from server.models.assignment import Assignment 

10from server.models.class_message import ClassMessage 

11from server.models.users import User 

12from server.models.classes import ( 

13 ClassModel, 

14 ClassModelRequestSchema, 

15 LeaveRequestData, 

16 RegisterClass, 

17 StudentModel, 

18 TeacherModel, 

19 UpdateClassModel, 

20 UpdateStudentStatus, 

21) 

22from server.utilities.model_parser import ( 

23 generate_unique_code, 

24 normalize_query_params, 

25 validate_params, 

26) 

27from server.utilities.user_id_helper import to_user_id, enrollment_id_conditions 

28from server.connection.storage_bucket import class_photo_url 

29from server.utilities.pagination import page_count, resolve_pagination 

30from beanie import SortDirection 

31from server.utilities.error_detail import safe_detail 

32 

33logger = logging.getLogger(__name__) 

34 

35 

36class InvalidStudentRequest(Exception): 

37 """ 

38 Custom exception for invalid student-related requests. 

39 

40 Raised when attempting operations on students that are already in a specific state, 

41 such as trying to enroll an already enrolled student. 

42 """ 

43 

44 def __init__(self, message="Student already in the class."): 

45 self.message = message 

46 super().__init__(self.message) 

47 

48 

49def _own_roster_entry(cls: ClassModel, caller_id) -> ClassModel: 

50 """Narrow ``cls.students`` in place to the caller's own entry (EI-SEC-003). 

51 

52 StudentModel carries first_name, last_name, email and auth0_user_id, so a 

53 student must never receive classmates. The caller's own entry is kept: the 

54 SPA reads the caller's enrolment status from it. 

55 """ 

56 cls.students = [s for s in (cls.students or []) if str(s.id) == str(caller_id)] 

57 return cls 

58 

59 

60class StudentClassesService: 

61 """ 

62 Service class for managing class-related operations. 

63 

64 Handles creation, retrieval, and management of classes, including student enrollment, 

65 teacher assignments, and class status updates. 

66 """ 

67 

68 async def class_create(self, request: Request, new_class: RegisterClass): 

69 """ 

70 Create a new class with a teacher assignment. 

71 

72 Args: 

73 request (Request): The incoming request object containing teacher context 

74 new_class (RegisterClass): Class details for creation 

75 

76 Returns: 

77 dict: Created class details and success message 

78 

79 Raises: 

80 HTTPException: If validation fails or creation error occurs 

81 """ 

82 try: 

83 user_id = to_user_id(request.state.user_details["uuid"]) 

84 

85 new_class.teacher = None 

86 teacher = ( 

87 await User.find({"_id": user_id}).project(TeacherModel).to_list(None) 

88 )[0] 

89 

90 teacher = teacher.model_dump() 

91 teacher["_id"] = teacher["id"] 

92 del teacher["id"] 

93 new_class.teacher = TeacherModel(**teacher) 

94 new_class.class_code = await generate_unique_code() 

95 

96 registered_class = ClassModel.model_validate(new_class.model_dump()) 

97 await registered_class.insert() 

98 

99 response_class = registered_class.model_dump() 

100 response_class["id"] = str(registered_class.id) 

101 if "_id" in response_class: 

102 del response_class["_id"] 

103 

104 if response_class["teacher"] and "_id" in response_class["teacher"]: 

105 response_class["teacher"]["id"] = str(response_class["teacher"]["_id"]) 

106 del response_class["teacher"]["_id"] 

107 

108 return { 

109 "detail": "Successfully Created Class", 

110 "new_class": response_class, 

111 } 

112 except ValidationError as e: 

113 print(e) 

114 raise HTTPException( 

115 status.HTTP_400_BAD_REQUEST, 

116 detail=e.errors()[0]["msg"], 

117 ) 

118 except Exception as e: 

119 raise HTTPException(status_code=500, detail=safe_detail(e)) 

120 

121 async def all_classes_fetch( 

122 self, 

123 request: Request, 

124 page_num: int = 1, 

125 page_size: int = 10, 

126 normalized_params: Dict[str, Optional[str]] = Depends(normalize_query_params), 

127 ): 

128 """ 

129 Retrieve all classes for a student with filtering and pagination. 

130 

131 Args: 

132 request (Request): The incoming request object containing student context 

133 page_num (int): Page number for pagination 

134 page_size (int): Number of items per page 

135 normalized_params (Dict[str, Optional[str]]): Query parameters for filtering 

136 - title: Filter by class title 

137 - status: Filter by enrollment status 

138 

139 Returns: 

140 dict: Paginated list of classes with metadata 

141 

142 Raises: 

143 HTTPException: If invalid status provided or retrieval fails 

144 """ 

145 try: 

146 # Bound the request before it reaches Mongo. A negative page_num 

147 # arrives as a negative `skip` and the driver takes the query down 

148 # with a 500; page_size=0 divides by zero computing no_of_pages; 

149 # page_size=999999 returned 9,956 rows and 7.8 MB. 

150 page_num, page_size = resolve_pagination(page_num, page_size) 

151 

152 user_id = to_user_id(request.state.user_details["uuid"]) 

153 email = request.state.user_details.get("email") 

154 

155 id_conditions = [{"students._id": user_id}] 

156 if email: 

157 id_conditions.append({"students.email": email}) 

158 

159 query_filter: Dict[str, Any] = ( 

160 {"$or": id_conditions} if len(id_conditions) > 1 else id_conditions[0] 

161 ) 

162 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

163 query_filter["deleted"] = {"$ne": True} 

164 title = normalized_params.get("title") 

165 status = normalized_params.get("status") 

166 

167 if title: 

168 query_filter["title"] = { 

169 "$regex": re.escape(title), # partial match, literal text 

170 "$options": "i", 

171 } 

172 if status: 

173 if status not in ["Enrolled", "Pending", "Removed"]: 

174 raise HTTPException( 

175 status_code=400, detail="Invalid status provided." 

176 ) 

177 query_filter["students.status"] = status 

178 

179 classes = ( 

180 await ClassModel.find(query_filter) 

181 # A UNIQUE TIEBREAKER IS WHAT MAKES PAGING STABLE. 

182 # Sorting on updated_at alone leaves the order of any two 

183 # classes sharing that value undefined, so Mongo may return 

184 # them in a different order on the next query — and with 

185 # skip/limit paging that means a class can appear on two pages 

186 # or on none. It needs no volume to bite: two classes touched 

187 # in the same millisecond, or one class updated between the 

188 # student's page 1 and page 2, is enough. The teacher 

189 # equivalent already sorts this way; the student list did not. 

190 .sort( 

191 [ 

192 ("updated_at", SortDirection.DESCENDING), 

193 ("created_at", SortDirection.DESCENDING), 

194 ("_id", SortDirection.DESCENDING), 

195 ] 

196 ) 

197 .skip((page_num - 1) * page_size) 

198 .limit(page_size) 

199 .to_list(None) 

200 ) 

201 

202 # Rewrite each class's stored object key to a short-lived presigned 

203 # display URL so the class list can render <img src=...> directly. 

204 for class_doc in classes: 

205 class_doc.class_photo = class_photo_url(class_doc.class_photo) 

206 # EI-SEC-003 (Allan Ninal, 2026-10-04): a student sees only their own roster entry — the class list leaked classmates. 

207 _own_roster_entry(class_doc, user_id) 

208 

209 total_count = await ClassModel.find(query_filter).count() 

210 

211 response = { 

212 "data": classes, 

213 "count": len(classes), 

214 "total": total_count, 

215 "page": page_num, 

216 "no_of_pages": page_count(total_count, page_size), 

217 } 

218 return response 

219 except HTTPException as e: 

220 raise e 

221 except Exception as e: 

222 # Never hand the caller str(e): a PyMongo OperationFailure carries 

223 # the driver message, our field names and the query shape 

224 # (OWASP API8:2023, CWE-209). This leaked 

225 # "BSON field 'skip' value must be >= 0, actual value '-10'". 

226 logger.exception("Student all-classes fetch failed") 

227 raise HTTPException( 

228 status_code=500, 

229 detail="An unexpected error occurred while fetching classes.", 

230 ) from e 

231 

232 async def get_specific_class_student( 

233 self, 

234 request: Request, 

235 class_uuid: Optional[str] = None, 

236 class_code: Optional[str] = None, 

237 ): 

238 """ 

239 Retrieve a specific class for a student by either UUID or class code. 

240 

241 Args: 

242 request (Request): The incoming request object containing student context 

243 class_uuid (Optional[str]): Unique identifier of the class 

244 class_code (Optional[str]): Class code for identification 

245 

246 Returns: 

247 dict: Class details or appropriate message if not found 

248 

249 Raises: 

250 HTTPException: If retrieval fails or invalid parameters provided 

251 """ 

252 try: 

253 user_id = to_user_id(request.state.user_details["uuid"]) 

254 email = request.state.user_details.get("email") 

255 if class_uuid and class_code: 

256 return { 

257 "message": "You can choose only one parameter at a time. You can't choose both class_uuid and class_code at the same time." 

258 } 

259 if not class_uuid and not class_code: 

260 return { 

261 "message": "You have to choose one parameter between class_code or class_uuid to fetch a specific class." 

262 } 

263 if class_uuid: 

264 fetched_class = await ClassModel.find( 

265 { 

266 "_id": ObjectId(class_uuid), 

267 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

268 "deleted": {"$ne": True}, 

269 "students": { 

270 "$elemMatch": { 

271 "$or": enrollment_id_conditions( 

272 ObjectId(user_id), email 

273 ), 

274 "status": "Enrolled", 

275 } 

276 }, 

277 } 

278 ).to_list() 

279 if class_code: 

280 fetched_class = await ClassModel.find( 

281 { 

282 "class_code": class_code, 

283 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

284 "deleted": {"$ne": True}, 

285 "students": { 

286 "$elemMatch": { 

287 "$or": enrollment_id_conditions( 

288 ObjectId(user_id), email 

289 ), 

290 "status": "Enrolled", 

291 } 

292 }, 

293 } 

294 ).to_list() 

295 if fetched_class: 

296 return {"Class": fetched_class[0]} 

297 else: 

298 return {"message": "Class not found on your list of enrolled class."} 

299 except Exception as e: 

300 raise HTTPException(status_code=500, detail=safe_detail(e)) 

301 

302 async def class_fetch(self, request: Request, class_code: str): 

303 """ 

304 Retrieve a class using its class code. 

305 

306 Args: 

307 request (Request): The incoming request object 

308 class_code (str): Unique code identifying the class 

309 

310 Returns: 

311 dict: Class details or not found message 

312 

313 Raises: 

314 HTTPException: If retrieval fails 

315 """ 

316 try: 

317 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

318 fetched_class = await ClassModel.find( 

319 {"class_code": class_code, "deleted": {"$ne": True}} 

320 ).to_list() 

321 if fetched_class: 

322 class_data = fetched_class[0] 

323 class_data.class_photo = class_photo_url(class_data.class_photo) 

324 

325 # EI-SEC-003 (roster disclosure). This returned the whole 

326 # ClassModel, `students` included — and StudentModel carries 

327 # first_name, last_name, email and auth0_user_id. Any student 

328 # holding a class code therefore received a directory of every 

329 # enrolled classmate. These are minors; that is a FERPA problem 

330 # and not merely an IDOR. 

331 # 

332 # The endpoint itself must stay reachable by a NON-member: it is 

333 # the pre-enrolment lookup behind SearchClassPage, which calls it 

334 # before /join to show "Do you want to enroll in <title>?". 

335 # Requiring enrolment here would make it impossible to ever join 

336 # a class. Knowing the code is the intended authorisation to see 

337 # the class; it was never intended to expose who is in it. 

338 # 

339 # So the roster is narrowed to the caller's own entry rather than 

340 # dropped. That is all any consumer actually reads: SearchClassPage's 

341 # isPendingOrEnrolled() and getStudentStatusValue() both do 

342 # students.findIndex(s => s.email === <self>) and look at .status. 

343 # The three other pages reading this endpoint (ClassDetails, 

344 # ViewAssignment, SubmissionDetails) never touch .students at all. 

345 # 

346 # Matching on id as ObjectId mirrors join() above (`s.id == 

347 # ObjectId(student_id)`), which is the canonical comparison for 

348 # membership in this file. 

349 # EI-SEC-003 (Allan Ninal, 2026-10-04): a student sees only their own roster entry — the class list leaked classmates. 

350 caller_id = to_user_id(request.state.user_details["uuid"]) 

351 _own_roster_entry(class_data, caller_id) 

352 

353 return {"Class": class_data} 

354 else: 

355 return {"message": "Class not found."} 

356 except Exception as e: 

357 raise HTTPException(status_code=500, detail=safe_detail(e)) 

358 

359 async def get_class_gradebook(self, request: Request, class_uuid: str): 

360 """ 

361 Retrieve the gradebook for a specific class. 

362 

363 Args: 

364 request (Request): The incoming request object containing teacher context 

365 class_uuid (str): Unique identifier of the class 

366 

367 Returns: 

368 dict: Gradebook containing student records 

369 

370 Raises: 

371 HTTPException: If class not found or unauthorized access 

372 """ 

373 try: 

374 user_id = to_user_id(request.state.user_details["uuid"]) 

375 class_obj = await ClassModel.find( 

376 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)} 

377 ).to_list() 

378 

379 gradebook = class_obj[0].students 

380 return {"gradebook": gradebook} 

381 except Exception as e: 

382 raise HTTPException(status_code=500, detail=safe_detail(e)) 

383 

384 async def get_class_assignments(self, request: Request, class_uuid: str): 

385 """ 

386 Retrieve all assignments for a specific class. 

387 

388 Args: 

389 request (Request): The incoming request object containing user context 

390 class_uuid (str): Unique identifier of the class 

391 

392 Returns: 

393 dict: List of assignments for the class 

394 

395 Raises: 

396 HTTPException: If retrieval fails 

397 """ 

398 try: 

399 # NOTE: this query is scoped ONLY by class_id — it does not restrict 

400 # results to the caller. The extracted caller id used to sit here 

401 # unused, which is the shape of an authorization check that was never 

402 # written. This function is currently UNREACHABLE (see the PR), so it 

403 # is not a live gap; anyone wiring it up must scope the query first. 

404 # `class_id` corrected to `assigned_class` by Allan Ninal — 2026-09-24. 

405 # Assignment has no `class_id` field; the class link is 

406 # `assigned_class` (a list of ObjectIds). The old key matched nothing, 

407 # so this would have returned [] even once it was reachable. Fixing 

408 # the field does NOT make the function safe to wire up — the 

409 # authorization gap described above is still unwritten. 

410 assignment_obj = await Assignment.find( 

411 { 

412 "assigned_class": ( 

413 ObjectId(class_uuid) 

414 if ObjectId.is_valid(str(class_uuid)) 

415 else class_uuid 

416 ) 

417 } 

418 ).to_list() 

419 return {"assignments": assignment_obj} 

420 except Exception as e: 

421 raise HTTPException(status_code=500, detail=safe_detail(e)) 

422 

423 async def get_class_roster(self, request: Request, class_uuid: str): 

424 """ 

425 Retrieve the student roster for a specific class. 

426 

427 Args: 

428 request (Request): The incoming request object containing teacher context 

429 class_uuid (str): Unique identifier of the class 

430 

431 Returns: 

432 dict: List of enrolled students 

433 

434 Raises: 

435 HTTPException: If class not found or invalid ID format 

436 """ 

437 try: 

438 user_id = to_user_id(request.state.user_details["uuid"]) 

439 class_obj = await ClassModel.find( 

440 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)} 

441 ).to_list() 

442 roster = class_obj[0].students 

443 return {"class_roster": roster} 

444 except errors.InvalidId: 

445 # Modified by Allan Ninal — 2026-09-24: was `detail=str(e)`, which 

446 # returned pymongo's own text ("'x' is not a valid ObjectId, it must be a 

447 # 12-byte input or a 24-character hex string"). 

448 # All six handlers of this shape validate a class_uuid, and the codebase 

449 # already answers the same condition with a static message: "Invalid 

450 # <entity> ID format" appears at 37 sites across six entities, while the 

451 # raw driver phrasing appeared only here. Driver text in a response is 

452 # neither the house convention nor useful to the caller. 

453 raise HTTPException(status_code=400, detail="Invalid class ID format") 

454 except Exception as e: 

455 raise HTTPException(status_code=500, detail=safe_detail(e)) 

456 

457 async def messages_fetch(self, request: Request, class_uuid: str): 

458 """ 

459 Retrieve class messages/announcements for an enrolled student. 

460 

461 Args: 

462 request (Request): The incoming request object containing student context 

463 class_uuid (str): Unique identifier of the class 

464 

465 Returns: 

466 dict: List of class messages and count 

467 

468 Raises: 

469 HTTPException: If class not found, student not enrolled, or invalid ID 

470 """ 

471 try: 

472 student_id = to_user_id(request.state.user_details["uuid"]) 

473 email = request.state.user_details.get("email") 

474 

475 class_exists = await ClassModel.find_one( 

476 { 

477 "_id": ObjectId(class_uuid), 

478 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

479 "deleted": {"$ne": True}, 

480 "students": { 

481 "$elemMatch": { 

482 "$or": enrollment_id_conditions( 

483 ObjectId(student_id), email 

484 ), 

485 "status": "Enrolled", 

486 } 

487 }, 

488 } 

489 ) 

490 

491 if not class_exists: 

492 raise HTTPException( 

493 status_code=status.HTTP_404_NOT_FOUND, 

494 detail="Class not found or you are not enrolled", 

495 ) 

496 

497 messages = ( 

498 await ClassMessage.find({"class_id": class_uuid}) 

499 .sort(-ClassMessage.created_at) 

500 .to_list() 

501 ) 

502 

503 return {"messages": messages, "count": len(messages)} 

504 

505 except errors.InvalidId: 

506 raise HTTPException( 

507 status_code=status.HTTP_400_BAD_REQUEST, 

508 detail="Invalid class ID format", 

509 ) 

510 except HTTPException as e: 

511 raise e 

512 except Exception as e: 

513 raise HTTPException( 

514 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e) 

515 ) 

516 

517 async def update_class( 

518 self, updated_class: UpdateClassModel, request: Request, class_uuid: str 

519 ): 

520 """ 

521 Update class details. 

522 

523 Args: 

524 updated_class (UpdateClassModel): Updated class information 

525 request (Request): The incoming request object containing teacher context 

526 class_uuid (str): Unique identifier of the class 

527 

528 Returns: 

529 dict: Updated class details and success message 

530 

531 Raises: 

532 HTTPException: If class not found, unauthorized access, or validation fails 

533 """ 

534 try: 

535 user_id = to_user_id(request.state.user_details["uuid"]) 

536 class_obj = await ClassModel.get(ObjectId(class_uuid)) 

537 

538 if not class_obj: 

539 raise HTTPException(status_code=404, detail="Class not found") 

540 

541 if not class_obj.teacher: 

542 raise HTTPException( 

543 status_code=400, detail="Class has no assigned teacher" 

544 ) 

545 

546 if class_obj.teacher.id == ObjectId(user_id): 

547 if hasattr(updated_class, "class_code"): 

548 class_code = updated_class.class_code 

549 

550 if class_code == "" or ( 

551 class_code is not None and class_code.strip() != "" 

552 ): 

553 raise HTTPException( 

554 status_code=status.HTTP_400_BAD_REQUEST, 

555 detail="class_code should not be provided.", 

556 ) 

557 

558 updated_data = updated_class.model_dump(exclude_none=True) 

559 class_obj = await class_obj.update({"$set": updated_data}) 

560 

561 return { 

562 "detail": "Class updated successfully", 

563 "updated_class": class_obj, 

564 } 

565 

566 raise HTTPException( 

567 status_code=status.HTTP_403_FORBIDDEN, 

568 detail="You are not authorized to update this class.", 

569 ) 

570 

571 except ValidationError as e: 

572 raise HTTPException( 

573 status_code=status.HTTP_400_BAD_REQUEST, 

574 detail=e.errors()[0]["msg"], 

575 ) 

576 except HTTPException as e: 

577 raise e 

578 except Exception as e: 

579 raise HTTPException( 

580 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e) 

581 ) 

582 

583 async def delete_class(self, request: Request, class_uuid: str): 

584 """ 

585 Delete a class. 

586 

587 Args: 

588 request (Request): The incoming request object containing teacher context 

589 class_uuid (str): Unique identifier of the class to delete 

590 

591 Returns: 

592 dict: Deletion confirmation message 

593 

594 Raises: 

595 HTTPException: If class not found or unauthorized access 

596 """ 

597 try: 

598 user_id = to_user_id(request.state.user_details["uuid"]) 

599 class_obj = await ClassModel.get(ObjectId(class_uuid)) 

600 

601 if not class_obj: 

602 raise HTTPException(status_code=404, detail="Class not found") 

603 

604 if not class_obj.teacher: 

605 raise HTTPException( 

606 status_code=400, detail="Class has no assigned teacher" 

607 ) 

608 

609 if class_obj.teacher.id == ObjectId(user_id): 

610 await class_obj.delete() 

611 return {"detail": "Class deleted successfully"} 

612 raise HTTPException( 

613 status_code=403, detail="Not authorized to delete this class" 

614 ) 

615 except errors.InvalidId: 

616 raise HTTPException( 

617 status.HTTP_400_BAD_REQUEST, detail="Invalid class ID provided!" 

618 ) 

619 except HTTPException: 

620 # Let deliberate HTTP errors through; without this the method's own 

621 # 400/403/404 was swallowed by the catch-all and re-thrown as a 500, 

622 # which the frontend renders as a maintenance dialog. 

623 raise 

624 except Exception as e: 

625 raise HTTPException(status_code=500, detail=safe_detail(e)) 

626 

627 async def join(self, class_code: str, request: Request): 

628 """ 

629 Join a class using a class code. 

630 

631 Args: 

632 class_code (str): Unique code identifying the class 

633 request (Request): The incoming request object containing student context 

634 

635 Returns: 

636 dict: Join confirmation message 

637 """ 

638 try: 

639 student_id = to_user_id(request.state.user_details["uuid"]) 

640 student = ( 

641 await User.find({"_id": student_id}).project(StudentModel).to_list(None) 

642 )[0] 

643 

644 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

645 class_res = await ClassModel.find( 

646 {"class_code": class_code, "deleted": {"$ne": True}} 

647 ).to_list(None) 

648 if not class_res: 

649 raise InvalidStudentRequest("Class not found") 

650 

651 class_res = class_res[0] 

652 if class_res.students: 

653 for s in class_res.students: 

654 if s.id == ObjectId(student_id): 

655 # EI-2713 (Allan Ninal, 2026-10-03): a rejected student may request to join again, like a removed one. 

656 if s.status in ("Removed", "Rejected"): 

657 s.status = "Pending" 

658 await class_res.save() 

659 return { 

660 "detail": "Successfully requested to join the class." 

661 } 

662 elif s.status == "Pending": 

663 raise InvalidStudentRequest( 

664 message="You already requested to join this class." 

665 ) 

666 else: 

667 # Student is already enrolled - return 409 Conflict 

668 raise HTTPException( 

669 status_code=status.HTTP_409_CONFLICT, 

670 detail={ 

671 "error": "Already enrolled", 

672 "message": "Student is already enrolled in this class", 

673 "current_status": s.status, 

674 "enrollment_date": ( 

675 s.created_at.isoformat() 

676 if hasattr(s, "created_at") and s.created_at 

677 else datetime.now(timezone.utc).isoformat() 

678 ), 

679 }, 

680 ) 

681 

682 student.status = "Pending" 

683 await ClassModel.find_one({"class_code": class_code}).update_one( 

684 { 

685 "$push": { 

686 "students": {"$each": [student], "$position": 0}, 

687 } 

688 } 

689 ) 

690 

691 return {"detail": "Successfully requested to join the class"} 

692 except InvalidStudentRequest as e: 

693 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e)) 

694 except HTTPException: 

695 # Re-raise HTTPException without modification 

696 raise 

697 except Exception as e: 

698 raise HTTPException(status_code=500, detail=safe_detail(e)) 

699 

700 async def leave(self, class_code: str, request: Request): 

701 """ 

702 Request to leave a class. 

703 

704 Args: 

705 class_code (str): Unique code identifying the class 

706 request (Request): The incoming request object containing student context 

707 

708 Returns: 

709 dict: Leave request confirmation message 

710 """ 

711 try: 

712 student_id = to_user_id(request.state.user_details["uuid"]) 

713 

714 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

715 class_res = await ClassModel.find( 

716 {"class_code": class_code, "deleted": {"$ne": True}} 

717 ).to_list(None) 

718 if not class_res: 

719 raise InvalidStudentRequest("Class not found") 

720 

721 class_res = class_res[0] 

722 if class_res.students: 

723 for s in class_res.students: 

724 if s.id == ObjectId(student_id): 

725 if s.status == "Enrolled": 

726 if s.is_requesting_to_leave: 

727 return {"detail": "Student already requested to leave."} 

728 s.is_requesting_to_leave = True 

729 await class_res.save() 

730 return {"detail": "Successfully requested to leave class."} 

731 

732 raise InvalidStudentRequest( 

733 "Invalid action. Student is not part of this class." 

734 ) 

735 

736 except InvalidStudentRequest as e: 

737 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e)) 

738 except Exception as e: 

739 raise HTTPException(status_code=500, detail=safe_detail(e)) 

740 

741 async def join_cancel(self, class_code: str, request: Request): 

742 """ 

743 Cancel a student's pending request to join a class. 

744 

745 Args: 

746 class_code (str): Code of the class to cancel join request 

747 request (Request): The incoming request object containing student context 

748 

749 Returns: 

750 dict: Cancellation confirmation message 

751 

752 Raises: 

753 HTTPException: If class not found or invalid request state 

754 InvalidStudentRequest: If student has no pending request or is already enrolled 

755 """ 

756 try: 

757 student_id = to_user_id(request.state.user_details["uuid"]) 

758 student = ( 

759 await User.find({"_id": student_id}).project(StudentModel).to_list(None) 

760 )[0] 

761 

762 # EI-3386 (Allan Ninal, 2026-10-03): a soft-deleted class is invisible to students. 

763 class_res = await ClassModel.find( 

764 {"class_code": class_code, "deleted": {"$ne": True}} 

765 ).to_list(None) 

766 if not class_res: 

767 raise InvalidStudentRequest("Class not found") 

768 

769 class_res = class_res[0] 

770 if class_res.students: 

771 for s in class_res.students: 

772 if s.id == ObjectId(student_id): 

773 if s.status == "Pending": 

774 s.status = "Removed" 

775 await class_res.save() 

776 return {"detail": "Cancelled request to join the class."} 

777 # EI-2713 (Allan Ninal, 2026-10-03): a rejected student may request to join again, like a removed one. 

778 elif s.status in ("Removed", "Rejected"): 

779 raise InvalidStudentRequest( 

780 message="You don't have pending request to join this class." 

781 ) 

782 else: 

783 raise InvalidStudentRequest( 

784 message="Request cannot be cancelled, already accepted by teacher. Request to leave instead." 

785 ) 

786 

787 student.status = "Removed" 

788 await ClassModel.find_one({"class_code": class_code}).update_one( 

789 { 

790 "$push": { 

791 "students": {"$each": [student], "$position": 0}, 

792 } 

793 } 

794 ) 

795 

796 return {"detail": "Cancelled request to join the class."} 

797 except InvalidStudentRequest as e: 

798 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e)) 

799 except Exception as e: 

800 raise HTTPException(status_code=500, detail=safe_detail(e)) 

801 

802 async def accept_student( 

803 self, request: Request, class_uuid: str, payload: UpdateStudentStatus 

804 ): 

805 """ 

806 Accept a pending student's request to join a class. 

807 

808 Args: 

809 request (Request): The incoming request object containing teacher context 

810 class_uuid (str): Unique identifier of the class 

811 payload (UpdateStudentStatus): Contains student_id of the student to accept 

812 

813 Returns: 

814 dict: Acceptance confirmation message 

815 Example: {"detail": "Successfully accepted student to the class"} 

816 

817 Raises: 

818 HTTPException: 

819 - 404: If class not found 

820 - 400: If no students in class or invalid class ID 

821 - 500: For unexpected server errors 

822 InvalidStudentRequest: If student is already enrolled 

823 

824 Note: 

825 Only students with "Pending" status can be accepted. The method updates 

826 their status to "Enrolled" upon successful acceptance. 

827 """ 

828 try: 

829 student_id = payload.student_id 

830 user_id = to_user_id(request.state.user_details["uuid"]) 

831 

832 class_obj = await ClassModel.find( 

833 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)} 

834 ).to_list(None) 

835 

836 if not class_obj or len(class_obj) == 0: 

837 raise HTTPException(status_code=404, detail="Class not found") 

838 

839 class_obj = class_obj[0] 

840 if not hasattr(class_obj, "students") or not class_obj.students: 

841 raise HTTPException(status_code=400, detail="No students in class") 

842 

843 for student in class_obj.students: 

844 if student.id == ObjectId(student_id): 

845 if student.status == "Enrolled": 

846 return InvalidStudentRequest() 

847 elif student.status == "Pending": 

848 student.status = "Enrolled" 

849 await class_obj.save() 

850 return {"detail": "Successfully accepted student to the class"} 

851 

852 except errors.InvalidId: 

853 raise HTTPException( 

854 status.HTTP_400_BAD_REQUEST, detail="Invalid class ID provided" 

855 ) 

856 except HTTPException as e: 

857 raise e 

858 except Exception as e: 

859 raise HTTPException(status_code=500, detail=safe_detail(e)) 

860 

861 async def remove_student(self, request: Request, class_uuid: str, student_id: str): 

862 """ 

863 Remove a student from a class. 

864 

865 Args: 

866 request (Request): The incoming request object containing teacher context 

867 class_uuid (str): Unique identifier of the class 

868 student_id (str): Identifier of the student to remove 

869 

870 Returns: 

871 dict: Removal confirmation message 

872 

873 Raises: 

874 HTTPException: If class not found, student not found, or unauthorized action 

875 """ 

876 try: 

877 user_id = to_user_id(request.state.user_details["uuid"]) 

878 class_obj = await ClassModel.find( 

879 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)} 

880 ).to_list(None) 

881 

882 if not class_obj: 

883 raise HTTPException(status_code=404, detail="Class not found") 

884 

885 class_obj = class_obj[0] 

886 

887 if not class_obj.teacher: 

888 raise HTTPException( 

889 status_code=400, detail="Class has no assigned teacher" 

890 ) 

891 

892 if class_obj.teacher.id == ObjectId(user_id): 

893 if not class_obj.students: 

894 raise HTTPException( 

895 status_code=400, detail="No students found in class" 

896 ) 

897 

898 for student in class_obj.students: 

899 if student.id == ObjectId(student_id): 

900 if student.status == "Pending" or student.status == "Enrolled": 

901 student.status = "Removed" 

902 await class_obj.save() 

903 return { 

904 "detail": "Successfully removed student from the class" 

905 } 

906 

907 raise HTTPException( 

908 status_code=400, 

909 detail="Invalid action. Student is not part of the class.", 

910 ) 

911 except HTTPException as e: 

912 raise e 

913 except Exception as e: 

914 raise HTTPException(status_code=500, detail=safe_detail(e)) 

915 

916 async def accept_leave_request( 

917 self, 

918 request: Request, 

919 class_uuid: str, 

920 leave_request_data: LeaveRequestData, 

921 ): 

922 """ 

923 Process a student's request to leave a class. 

924 

925 Args: 

926 request (Request): The incoming request object containing teacher context 

927 class_uuid (str): Unique identifier of the class 

928 leave_request_data (LeaveRequestData): Leave request details including 

929 student ID and whether the request is granted 

930 

931 Returns: 

932 dict: Leave request processing confirmation 

933 

934 Raises: 

935 HTTPException: If class not found, invalid student ID, or unauthorized action 

936 """ 

937 try: 

938 student_id = leave_request_data.student_id 

939 

940 if student_id.strip() == "": 

941 raise HTTPException(status_code=400, detail="Missing student ID.") 

942 

943 user_id = to_user_id(request.state.user_details["uuid"]) 

944 class_obj = await ClassModel.find( 

945 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)} 

946 ).to_list(None) 

947 

948 if not class_obj: 

949 raise HTTPException(status_code=404, detail="Class not found") 

950 

951 class_obj = class_obj[0] 

952 

953 if not class_obj.teacher: 

954 raise HTTPException( 

955 status_code=400, detail="Class has no assigned teacher" 

956 ) 

957 

958 if class_obj.teacher.id == ObjectId(user_id): 

959 if not class_obj.students: 

960 raise HTTPException( 

961 status_code=400, detail="No students found in class" 

962 ) 

963 

964 for student in class_obj.students: 

965 if student.id == ObjectId(student_id): 

966 if student.is_requesting_to_leave: 

967 if not leave_request_data.is_granted: 

968 student.is_requesting_to_leave = False 

969 await class_obj.save() 

970 return {"detail": "Student leave request decline."} 

971 

972 student.status = "Removed" 

973 student.is_requesting_to_leave = False 

974 await class_obj.save() 

975 return { 

976 "detail": "Successfully removed student from the class" 

977 } 

978 else: 

979 raise HTTPException( 

980 status_code=400, 

981 detail="Invalid action. Student is not requesting to leave the class.", 

982 ) 

983 

984 raise HTTPException( 

985 status_code=400, 

986 detail="Invalid action. Student is not part of the class.", 

987 ) 

988 except errors.InvalidId: 

989 raise HTTPException( 

990 status.HTTP_400_BAD_REQUEST, detail="Invalid class ID provided" 

991 ) 

992 except HTTPException as e: 

993 raise e 

994 except Exception as e: 

995 raise HTTPException(status_code=500, detail=safe_detail(e))