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
« 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
33logger = logging.getLogger(__name__)
36class InvalidStudentRequest(Exception):
37 """
38 Custom exception for invalid student-related requests.
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 """
44 def __init__(self, message="Student already in the class."):
45 self.message = message
46 super().__init__(self.message)
49def _own_roster_entry(cls: ClassModel, caller_id) -> ClassModel:
50 """Narrow ``cls.students`` in place to the caller's own entry (EI-SEC-003).
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
60class StudentClassesService:
61 """
62 Service class for managing class-related operations.
64 Handles creation, retrieval, and management of classes, including student enrollment,
65 teacher assignments, and class status updates.
66 """
68 async def class_create(self, request: Request, new_class: RegisterClass):
69 """
70 Create a new class with a teacher assignment.
72 Args:
73 request (Request): The incoming request object containing teacher context
74 new_class (RegisterClass): Class details for creation
76 Returns:
77 dict: Created class details and success message
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"])
85 new_class.teacher = None
86 teacher = (
87 await User.find({"_id": user_id}).project(TeacherModel).to_list(None)
88 )[0]
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()
96 registered_class = ClassModel.model_validate(new_class.model_dump())
97 await registered_class.insert()
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"]
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"]
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))
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.
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
139 Returns:
140 dict: Paginated list of classes with metadata
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)
152 user_id = to_user_id(request.state.user_details["uuid"])
153 email = request.state.user_details.get("email")
155 id_conditions = [{"students._id": user_id}]
156 if email:
157 id_conditions.append({"students.email": email})
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")
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
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 )
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)
209 total_count = await ClassModel.find(query_filter).count()
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
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.
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
246 Returns:
247 dict: Class details or appropriate message if not found
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))
302 async def class_fetch(self, request: Request, class_code: str):
303 """
304 Retrieve a class using its class code.
306 Args:
307 request (Request): The incoming request object
308 class_code (str): Unique code identifying the class
310 Returns:
311 dict: Class details or not found message
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)
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)
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))
359 async def get_class_gradebook(self, request: Request, class_uuid: str):
360 """
361 Retrieve the gradebook for a specific class.
363 Args:
364 request (Request): The incoming request object containing teacher context
365 class_uuid (str): Unique identifier of the class
367 Returns:
368 dict: Gradebook containing student records
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()
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))
384 async def get_class_assignments(self, request: Request, class_uuid: str):
385 """
386 Retrieve all assignments for a specific class.
388 Args:
389 request (Request): The incoming request object containing user context
390 class_uuid (str): Unique identifier of the class
392 Returns:
393 dict: List of assignments for the class
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))
423 async def get_class_roster(self, request: Request, class_uuid: str):
424 """
425 Retrieve the student roster for a specific class.
427 Args:
428 request (Request): The incoming request object containing teacher context
429 class_uuid (str): Unique identifier of the class
431 Returns:
432 dict: List of enrolled students
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))
457 async def messages_fetch(self, request: Request, class_uuid: str):
458 """
459 Retrieve class messages/announcements for an enrolled student.
461 Args:
462 request (Request): The incoming request object containing student context
463 class_uuid (str): Unique identifier of the class
465 Returns:
466 dict: List of class messages and count
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")
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 )
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 )
497 messages = (
498 await ClassMessage.find({"class_id": class_uuid})
499 .sort(-ClassMessage.created_at)
500 .to_list()
501 )
503 return {"messages": messages, "count": len(messages)}
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 )
517 async def update_class(
518 self, updated_class: UpdateClassModel, request: Request, class_uuid: str
519 ):
520 """
521 Update class details.
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
528 Returns:
529 dict: Updated class details and success message
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))
538 if not class_obj:
539 raise HTTPException(status_code=404, detail="Class not found")
541 if not class_obj.teacher:
542 raise HTTPException(
543 status_code=400, detail="Class has no assigned teacher"
544 )
546 if class_obj.teacher.id == ObjectId(user_id):
547 if hasattr(updated_class, "class_code"):
548 class_code = updated_class.class_code
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 )
558 updated_data = updated_class.model_dump(exclude_none=True)
559 class_obj = await class_obj.update({"$set": updated_data})
561 return {
562 "detail": "Class updated successfully",
563 "updated_class": class_obj,
564 }
566 raise HTTPException(
567 status_code=status.HTTP_403_FORBIDDEN,
568 detail="You are not authorized to update this class.",
569 )
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 )
583 async def delete_class(self, request: Request, class_uuid: str):
584 """
585 Delete a class.
587 Args:
588 request (Request): The incoming request object containing teacher context
589 class_uuid (str): Unique identifier of the class to delete
591 Returns:
592 dict: Deletion confirmation message
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))
601 if not class_obj:
602 raise HTTPException(status_code=404, detail="Class not found")
604 if not class_obj.teacher:
605 raise HTTPException(
606 status_code=400, detail="Class has no assigned teacher"
607 )
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))
627 async def join(self, class_code: str, request: Request):
628 """
629 Join a class using a class code.
631 Args:
632 class_code (str): Unique code identifying the class
633 request (Request): The incoming request object containing student context
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]
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")
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 )
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 )
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))
700 async def leave(self, class_code: str, request: Request):
701 """
702 Request to leave a class.
704 Args:
705 class_code (str): Unique code identifying the class
706 request (Request): The incoming request object containing student context
708 Returns:
709 dict: Leave request confirmation message
710 """
711 try:
712 student_id = to_user_id(request.state.user_details["uuid"])
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")
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."}
732 raise InvalidStudentRequest(
733 "Invalid action. Student is not part of this class."
734 )
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))
741 async def join_cancel(self, class_code: str, request: Request):
742 """
743 Cancel a student's pending request to join a class.
745 Args:
746 class_code (str): Code of the class to cancel join request
747 request (Request): The incoming request object containing student context
749 Returns:
750 dict: Cancellation confirmation message
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]
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")
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 )
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 )
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))
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.
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
813 Returns:
814 dict: Acceptance confirmation message
815 Example: {"detail": "Successfully accepted student to the class"}
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
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"])
832 class_obj = await ClassModel.find(
833 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)}
834 ).to_list(None)
836 if not class_obj or len(class_obj) == 0:
837 raise HTTPException(status_code=404, detail="Class not found")
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")
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"}
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))
861 async def remove_student(self, request: Request, class_uuid: str, student_id: str):
862 """
863 Remove a student from a class.
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
870 Returns:
871 dict: Removal confirmation message
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)
882 if not class_obj:
883 raise HTTPException(status_code=404, detail="Class not found")
885 class_obj = class_obj[0]
887 if not class_obj.teacher:
888 raise HTTPException(
889 status_code=400, detail="Class has no assigned teacher"
890 )
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 )
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 }
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))
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.
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
931 Returns:
932 dict: Leave request processing confirmation
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
940 if student_id.strip() == "":
941 raise HTTPException(status_code=400, detail="Missing student ID.")
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)
948 if not class_obj:
949 raise HTTPException(status_code=404, detail="Class not found")
951 class_obj = class_obj[0]
953 if not class_obj.teacher:
954 raise HTTPException(
955 status_code=400, detail="Class has no assigned teacher"
956 )
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 )
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."}
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 )
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))