Coverage for server / services / teacher / teacher_classes.py: 83%
627 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 Depends, File, HTTPException, Request, UploadFile, status
8from pydantic import ValidationError
9from server.models.assignment import Assignment
10from server.models.class_message import ClassMessage, CreateClassMessage
11from server.models.users import User
12from server.models.classes import (
13 ClassModel,
14 LeaveRequestData,
15 RegisterClass,
16 Semesters,
17 StudentModel,
18 TeacherModel,
19 UpdateClassModel,
20)
21from server.utilities.model_parser import (
22 generate_unique_code,
23 normalize_query_params,
24 validate_params,
25)
26from beanie import SortDirection
27from server.connection.database import db
28from statistics import mean
29from server.utilities.helpers import serialized_response_object
30from server.utilities.user_id_helper import to_user_id
31from server.utilities.class_limits import TEACHER_CLASS_LIMIT
32from server.utilities.class_dedupe import class_dedupe_key, is_class_dedupe_violation
33from pymongo.errors import DuplicateKeyError
34from server.utilities.gradebook import (
35 select_canonical_submission,
36 resolve_cell_grade,
37 extract_live_question_ids,
38 recalculate_submitted_grade,
39)
40from server.utilities.image_processing import compress_profile_picture
41from server.validators.question_request_root_validators import validate_file_size_type
42from server.connection.storage_bucket import s3, MINIO_PRIVATE_BUCKET, class_photo_url
43from server.utilities.pagination import page_count, resolve_pagination
44from server.utilities.error_detail import safe_detail
47logger = logging.getLogger(__name__)
50class InvalidStudentRequest(Exception):
52 def __init__(self, message="Student already in the class."):
53 self.message = message
54 super().__init__(self.message)
57class TeacherClassesService:
58 """
59 Service class for managing class-related operations.
61 Handles creation, retrieval, and management of classes, including student enrollment,
62 teacher assignments, and class status updates.
63 """
65 async def create_new_class(self, request: Request, new_class: RegisterClass):
66 """
67 Create a new class with a teacher assignment.
68 Limits creation to a maximum of 10 classes per teacher.
70 Args:
71 request (Request): The incoming request object containing teacher context
72 new_class (RegisterClass): Class details for creation
74 Returns:
75 dict: Created class details and success message
77 Raises:
78 HTTPException: If validation fails or creation error occurs
79 """
80 try:
81 user_id = to_user_id(request.state.user_details["uuid"])
83 # Fetch teacher
84 teacher_data = (
85 await User.find({"_id": user_id}).project(TeacherModel).to_list(None)
86 )
87 if not teacher_data:
88 raise HTTPException(
89 status.HTTP_404_NOT_FOUND, detail="Teacher not found"
90 )
92 teacher_dict = teacher_data[0].model_dump()
93 teacher_dict["_id"] = teacher_dict.pop("id", None)
94 new_class.teacher = TeacherModel(**teacher_dict)
96 # Check existing classes count (live only — soft-deleted don't count).
97 # Cap is centralized in `server.utilities.class_limits`; the frontend
98 # reads it from `/v1/teacher/class/all/fetch` (see `class_limit` field)
99 # to render an "X/N used" indicator + disable Create at the cap.
100 existing_classes_count = await ClassModel.find(
101 {"teacher._id": ObjectId(user_id), "deleted": {"$ne": True}}
102 ).count()
103 if existing_classes_count >= TEACHER_CLASS_LIMIT:
104 raise HTTPException(
105 status.HTTP_400_BAD_REQUEST,
106 detail={
107 "message": (
108 f"You've reached the limit of {TEACHER_CLASS_LIMIT} classes "
109 f"per teacher. Delete an existing class to create a new one."
110 ),
111 "reason": "class_limit_reached",
112 "class_limit": TEACHER_CLASS_LIMIT,
113 "active_class_count": existing_classes_count,
114 },
115 )
117 # Check for duplicate title and section (live only — re-creating
118 # title+section after a soft delete is allowed). Both values are
119 # matched LITERALLY: re.escape keeps "(", "[", "." etc. in a title
120 # from acting as regex (unescaped, "Algebra (A)" never matched its
121 # own duplicate and "Algebra [" was a 500). Allan Ninal, 2026-10-03.
122 existing_class_title_section = await ClassModel.find_one(
123 {
124 "teacher._id": ObjectId(user_id),
125 "deleted": {"$ne": True},
126 "title": {
127 "$regex": f"^{re.escape(new_class.title)}$",
128 "$options": "i",
129 },
130 "section": {
131 "$regex": f"^{re.escape(new_class.section)}$",
132 "$options": "i",
133 },
134 }
135 )
136 if existing_class_title_section:
137 raise HTTPException(
138 status.HTTP_409_CONFLICT,
139 detail=f"A class with title '{new_class.title}' and section '{new_class.section}' already exists.",
140 )
142 # Check for duplicate schedules
143 if new_class.schedules:
144 new_schedules_set = {
145 (s.day.value, s.time_start, s.time_end) for s in new_class.schedules
146 }
148 existing_classes = await ClassModel.find(
149 {
150 "teacher._id": ObjectId(user_id),
151 "deleted": {"$ne": True},
152 "schedules": {"$exists": True, "$ne": []},
153 }
154 ).to_list()
156 for existing_class in existing_classes:
157 if existing_class.schedules:
158 existing_schedules_set = {
159 (
160 s.day.value if hasattr(s.day, "value") else s.day,
161 s.time_start,
162 s.time_end,
163 )
164 for s in existing_class.schedules
165 }
166 if new_schedules_set == existing_schedules_set:
167 raise HTTPException(
168 status.HTTP_409_CONFLICT,
169 detail=f"A class with the same schedule already exists: '{existing_class.title}' ({existing_class.section}).",
170 )
172 # Generate unique class code
173 new_class.class_code = await generate_unique_code()
175 # Validate and insert class. dedupe_key lets the database refuse the
176 # second of two concurrent identical creates (EI-T473): the read check
177 # above cannot, since both requests read before either inserts.
178 registered_class = ClassModel.model_validate(new_class.model_dump())
179 registered_class.dedupe_key = class_dedupe_key(
180 user_id, registered_class.title, registered_class.section
181 )
182 try:
183 await registered_class.insert()
184 except DuplicateKeyError as dup:
185 if not is_class_dedupe_violation(dup):
186 raise
187 raise HTTPException(
188 status.HTTP_409_CONFLICT,
189 detail=f"A class with title '{new_class.title}' and section '{new_class.section}' already exists.",
190 ) from dup
192 # Prepare response
193 response_class = registered_class.model_dump()
194 response_class.pop("dedupe_key", None)
195 response_class["id"] = str(registered_class.id)
196 response_class.pop("_id", None)
198 if response_class.get("teacher") and "_id" in response_class["teacher"]:
199 response_class["teacher"]["id"] = str(
200 response_class["teacher"].pop("_id")
201 )
203 return {
204 "detail": "Successfully Created Class",
205 "new_class": serialized_response_object(response_class),
206 }
208 except ValidationError as e:
209 print(e)
210 raise HTTPException(
211 status.HTTP_400_BAD_REQUEST,
212 detail=e.errors()[0]["msg"],
213 )
214 except HTTPException:
215 raise
216 except Exception as e:
217 raise HTTPException(status_code=500, detail=safe_detail(e))
219 async def all_classes_fetch(
220 self,
221 request: Request,
222 page_num: int = 1,
223 page_size: int = 10,
224 normalized_params: Dict[str, Optional[str]] = Depends(normalize_query_params),
225 ):
226 """
227 Retrieve all classes for a student with filtering and pagination.
229 Args:
230 request (Request): The incoming request object containing student context
231 page_num (int): Page number for pagination
232 page_size (int): Number of items per page
233 normalized_params (Dict[str, Optional[str]]): Query parameters for filtering
234 - title: Filter by class title
235 - status: Filter by enrollment status
237 Returns:
238 dict: Paginated list of classes with metadata
240 Raises:
241 HTTPException: If invalid status provided or retrieval fails
242 """
243 try:
244 teacher_id = to_user_id(request.state.user_details["uuid"])
246 query_filter: Dict[str, Any] = {
247 "teacher._id": ObjectId(teacher_id),
248 "deleted": {"$ne": True},
249 }
251 title = normalized_params.get("title")
252 status = normalized_params.get("status")
254 if title:
255 query_filter["title"] = {
256 "$regex": re.escape(title), # partial match, literal text
257 "$options": "i",
258 }
260 if status:
261 if status not in ["Enrolled", "Pending", "Removed"]:
262 raise HTTPException(
263 status_code=400, detail="Invalid status provided."
264 )
265 query_filter["students.status"] = status
267 # Bound before Mongo. The ad hoc max() floored a negative PAGE but
268 # left page_size unbounded, so page_size=-5 still reached the driver
269 # as a negative limit ("length must be non-negative", 500) and
270 # page_size=999999 was accepted outright.
271 page_num, page_size = resolve_pagination(page_num, page_size)
272 skip = (page_num - 1) * page_size
274 classes = (
275 await ClassModel.find(query_filter)
276 .sort(
277 [
278 ("updated_at", SortDirection.DESCENDING),
279 ("created_at", SortDirection.DESCENDING),
280 ("_id", SortDirection.DESCENDING),
281 ]
282 )
283 .skip(skip)
284 .limit(page_size)
285 .to_list(length=page_size) # IMPORTANT
286 )
288 # Rewrite each class's stored object key to a short-lived presigned
289 # display URL so the class list can render <img src=...> directly.
290 for class_doc in classes:
291 class_doc.class_photo = class_photo_url(class_doc.class_photo)
293 total_count = await ClassModel.find(query_filter).count()
295 # Active-class count IGNORES filters (title/status) so the cap UX
296 # always reflects how close the teacher is to the limit, not how
297 # many results match their current view. The FE displays
298 # "active_class_count / class_limit used" on the Classes page.
299 active_class_count = await ClassModel.find(
300 {
301 "teacher._id": ObjectId(teacher_id),
302 "deleted": {"$ne": True},
303 }
304 ).count()
306 return {
307 "data": classes,
308 "count": len(classes),
309 "total": total_count,
310 "page": page_num,
311 "no_of_pages": page_count(total_count, page_size),
312 "class_limit": TEACHER_CLASS_LIMIT,
313 "active_class_count": active_class_count,
314 }
316 except HTTPException:
317 raise
318 except Exception as e:
319 # str(e) on a PyMongo failure carries the driver message and our
320 # field names to the caller (OWASP API8:2023, CWE-209).
321 logger.exception("Teacher all-classes fetch failed")
322 raise HTTPException(
323 status_code=500,
324 detail="An unexpected error occurred while fetching classes.",
325 ) from e
327 async def my_classes_fetch(
328 self,
329 request: Request,
330 page_num: int = 1,
331 page_size: int = 10,
332 normalized_params: Dict[str, Optional[str]] = Depends(normalize_query_params),
333 ):
334 """
335 Retrieve all classes for a teacher with filtering and pagination.
337 Args:
338 request (Request): The incoming request object containing teacher context
339 page_num (int): Page number for pagination
340 page_size (int): Number of items per page
341 normalized_params (Dict[str, Optional[str]]): Query parameters for filtering
342 - title: Filter by class title
343 - class_code: Filter by class code
345 Returns:
346 dict: Paginated list of classes with metadata
348 Raises:
349 HTTPException: If parameter validation fails or retrieval error occurs
350 """
351 try:
352 title = normalized_params.get("title")
353 class_code = normalized_params.get("class_code")
355 validate_params(page_num, page_size)
356 user_id = to_user_id(request.state.user_details["uuid"])
357 query_filter: Dict[str, Any] = {
358 "teacher._id": ObjectId(user_id),
359 "deleted": {"$ne": True},
360 }
361 if title:
362 query_filter["title"] = {
363 "$regex": re.escape(title), # partial match, literal text
364 "$options": "i",
365 }
366 if class_code:
367 query_filter["class_code"] = {
368 "$regex": re.escape(class_code),
369 "$options": "i",
370 }
371 classes = (
372 await ClassModel.find(query_filter)
373 .sort([("updated_at", SortDirection.DESCENDING)])
374 .skip((page_num - 1) * page_size)
375 .limit(page_size)
376 .to_list(None)
377 )
379 response = {
380 "data": classes,
381 "count": len(classes),
382 "total": await ClassModel.find(
383 {"teacher._id": ObjectId(user_id), "deleted": {"$ne": True}}
384 ).count(),
385 "page": page_num,
386 "no_of_pages": math.ceil(len(classes) / page_size),
387 }
388 return response
389 except HTTPException as e:
390 raise e
391 except Exception as e:
392 raise HTTPException(status_code=500, detail=safe_detail(e))
394 async def specific_class_fetch(
395 self,
396 request: Request,
397 class_uuid: Optional[str] = None,
398 class_code: Optional[str] = None,
399 ):
400 """
401 Retrieves paginated list of classes where the authenticated user is the teacher.
403 Args:
404 request: FastAPI request with user authentication
405 page_num: Current page number (default: 1)
406 page_size: Items per page (default: 10)
407 normalized_params: Query filters for 'title' and 'class_code'
408 Both support case-insensitive partial matching
410 Returns:
411 dict: {
412 "data": List[ClassModel], # Classes for current page
413 "count": int, # Items in current page
414 "total": int, # Total classes for teacher
415 "page": int, # Current page number
416 "no_of_pages": int # Total pages
417 }
419 Raises:
420 HTTPException(500): For server errors
421 """
422 try:
423 user_id = to_user_id(request.state.user_details["uuid"])
424 if class_uuid and class_code:
425 return {
426 "message": "You can choose only one parameter at a time. You can't choose both class_uuid and class_code at the same time."
427 }
428 if not class_uuid and not class_code:
429 return {
430 "message": "You have to choose one parameter between class_code or class_uuid to fetch a specific class."
431 }
433 if class_uuid:
434 fetched_class = await ClassModel.find(
435 {
436 "_id": ObjectId(class_uuid),
437 "teacher._id": ObjectId(user_id),
438 "deleted": {"$ne": True},
439 }
440 ).to_list()
441 if class_code:
442 fetched_class = await ClassModel.find(
443 {
444 "class_code": class_code,
445 "teacher._id": ObjectId(user_id),
446 "deleted": {"$ne": True},
447 }
448 ).to_list()
449 if fetched_class:
450 return {"Class": fetched_class[0]}
451 else:
452 return {"message": "Class not found on your list of enrolled class."}
453 except errors.InvalidId:
454 # Modified by Allan Ninal — 2026-09-24: was `detail=str(e)`, which
455 # returned pymongo's own text ("'x' is not a valid ObjectId, it must be a
456 # 12-byte input or a 24-character hex string").
457 # All six handlers of this shape validate a class_uuid, and the codebase
458 # already answers the same condition with a static message: "Invalid
459 # <entity> ID format" appears at 37 sites across six entities, while the
460 # raw driver phrasing appeared only here. Driver text in a response is
461 # neither the house convention nor useful to the caller.
462 raise HTTPException(status_code=400, detail="Invalid class ID format")
463 except Exception as e:
464 raise HTTPException(status_code=500, detail=safe_detail(e))
466 async def student_class_fetch(
467 self,
468 request: Request,
469 class_uuid: Optional[str] = None,
470 class_code: Optional[str] = None,
471 ):
472 """
473 Retrieve a specific class for a student by either UUID or class code.
475 Args:
476 request (Request): The incoming request object containing student context
477 class_uuid (Optional[str]): Unique identifier of the class
478 class_code (Optional[str]): Class code for identification
480 Returns:
481 dict: Class details or appropriate message if not found
483 Raises:
484 HTTPException: If retrieval fails or invalid parameters provided
485 """
486 try:
487 user_id = to_user_id(request.state.user_details["uuid"])
488 if class_uuid and class_code:
489 return {
490 "message": "You can choose only one parameter at a time. You can't choose both class_uuid and class_code at the same time."
491 }
492 if not class_uuid and not class_code:
493 return {
494 "message": "You have to choose one parameter between class_code or class_uuid to fetch a specific class."
495 }
496 if class_uuid:
497 fetched_class = await ClassModel.find(
498 {
499 "_id": ObjectId(class_uuid),
500 "students._id": ObjectId(user_id),
501 "students.status": "Enrolled",
502 "deleted": {"$ne": True},
503 }
504 ).to_list()
505 if class_code:
506 fetched_class = await ClassModel.find(
507 {
508 "class_code": class_code,
509 "students._id": ObjectId(user_id),
510 "students.status": "Enrolled",
511 "deleted": {"$ne": True},
512 }
513 ).to_list()
514 if fetched_class:
515 return {"Class": fetched_class[0]}
516 else:
517 return {"message": "Class not found on your list of enrolled class."}
518 except Exception as e:
519 raise HTTPException(status_code=500, detail=safe_detail(e))
521 async def class_fetch(self, request: Request, class_code: str):
522 """
523 Retrieve a class using its class code.
525 Args:
526 request (Request): The incoming request object
527 class_code (str): Unique code identifying the class
529 Returns:
530 dict: Class details
532 Raises:
533 HTTPException:
534 - 404: If class not found
535 - 500: If retrieval fails
536 """
537 try:
538 teacher_id = to_user_id(request.state.user_details["uuid"])
539 fetched_class = await ClassModel.find_one(
540 {
541 "class_code": class_code,
542 "teacher._id": ObjectId(teacher_id),
543 "deleted": {"$ne": True},
544 }
545 )
547 if fetched_class:
548 fetched_class.class_photo = class_photo_url(fetched_class.class_photo)
549 return {"Class": fetched_class}
551 raise HTTPException(
552 status_code=status.HTTP_404_NOT_FOUND, detail="Class not found."
553 )
554 except HTTPException as e:
555 raise e
556 except Exception as e:
557 raise HTTPException(status_code=500, detail=safe_detail(e))
559 async def class_gradebook_fetch(self, request: Request, class_code: str):
560 try:
561 teacher_id = to_user_id(request.state.user_details["uuid"])
563 # Fetch class document for this teacher (live only)
564 class_doc = await db["class_collection"].find_one(
565 {
566 "class_code": class_code,
567 "teacher._id": ObjectId(teacher_id),
568 "deleted": {"$ne": True},
569 }
570 )
571 if not class_doc:
572 raise HTTPException(status_code=404, detail="Class not found")
574 # Fetch all non-deleted assignments for this class
575 assignments = (
576 await db["assignments_collection"]
577 .find({"assigned_class": class_doc["_id"], "deleted": {"$ne": True}})
578 .to_list(length=None)
579 )
581 assignment_map = {
582 str(a["_id"]): {
583 "assignment_id": str(a["_id"]),
584 "title": a.get("title", ""),
585 "date_close": a.get("date_close"),
586 # Needed only to recalculate a submission's grade against a
587 # question the teacher may have since removed — see
588 # recalculate_submitted_grade below.
589 "live_question_ids": extract_live_question_ids(
590 a.get("questions", [])
591 ),
592 }
593 for a in assignments
594 }
596 assignment_ids = [ObjectId(aid) for aid in assignment_map.keys()]
598 # Fetch all submissions linked to these assignments
599 submissions = (
600 await db["submission_collection"]
601 .find({"assignment_id": {"$in": assignment_ids}})
602 .to_list(length=None)
603 )
605 # EI-1195: group submissions per (student, assignment). Duplicate docs
606 # (insert-on-start race) are resolved to ONE canonical submission via the
607 # shared helper, so the gradebook and the student list can't disagree.
608 submissions_by_key: Dict[str, list] = {}
609 for sub in submissions:
610 key = f"{str(sub['student_id'])}_{str(sub['assignment_id'])}"
611 submissions_by_key.setdefault(key, []).append(sub)
613 now = datetime.now(timezone.utc)
615 # Initialize gradebook response
616 response = {
617 "class_id": str(class_doc["_id"]),
618 "class_title": class_doc.get("title", ""),
619 "class_section": class_doc.get("section", ""),
620 "class_code": class_doc.get("class_code", ""),
621 "students": [],
622 }
624 # Filter out students with "Pending" status
625 active_students = [
626 s
627 for s in class_doc.get("students", [])
628 if s.get("status") == "Enrolled"
629 ]
631 # Build gradebook entries for each active student
632 for student in active_students:
633 student_id = str(student["_id"])
634 test_grades = []
635 grade_values = []
637 for assignment_id, assignment_data in assignment_map.items():
638 key = f"{student_id}_{assignment_id}"
639 submission = select_canonical_submission(
640 submissions_by_key.get(key, [])
641 )
643 # A question the teacher removed after grading must stop
644 # contributing to (or costing) the score shown here —
645 # recalculate against the assignment's current questions
646 # before resolving the display grade, rather than trusting
647 # the stored `grade` verbatim. Keeps this grid in lockstep
648 # with the student's own submission review/average, which
649 # apply the identical recalculation (see
650 # server.utilities.gradebook.recalculate_submitted_grade).
651 recalculated_grade = (
652 recalculate_submitted_grade(
653 submission, assignment_data["live_question_ids"]
654 )
655 if submission
656 else None
657 )
658 if submission and recalculated_grade is not None:
659 submission = {**submission, "grade": recalculated_grade}
661 resolved = resolve_cell_grade(
662 submission, assignment_data.get("date_close"), now
663 )
664 grade = resolved["grade"]
666 test_grades.append(
667 {
668 "assignment_id": assignment_id,
669 "title": assignment_data["title"],
670 "submission_id": (
671 str(submission["_id"]) if submission else None
672 ),
673 "grade": grade,
674 "status": resolved["status"],
675 "is_submitted": (
676 bool(submission.get("is_submitted"))
677 if submission
678 else False
679 ),
680 }
681 )
683 if grade is not None:
684 grade_values.append(grade)
686 average_grade = round(mean(grade_values), 2) if grade_values else 0
688 response["students"].append(
689 {
690 "first_name": student["first_name"],
691 "middle_name": student.get("middle_name", ""),
692 "last_name": student["last_name"],
693 "email": student["email"],
694 "test_grades": test_grades,
695 "average": average_grade,
696 }
697 )
699 return response
701 except HTTPException:
702 raise
703 # Removed by Allan Ninal — 2026-09-24, one commit after adding it (#362).
704 # I put an `except errors.InvalidId -> 400 "Invalid class ID format"` here.
705 # It was wrong. This function's parameter is a class CODE, not an ObjectId,
706 # and the only two ObjectId() conversions it makes are
707 # `ObjectId(teacher_id)` — from the auth context — and `ObjectId(aid)` over
708 # assignment ids read from the class document. NEITHER is client-supplied,
709 # so an InvalidId here always means corrupt server state or a bad auth
710 # context. Answering 400 would blame the caller for the server's problem,
711 # which is the exact defect #362 set out to fix. It also named "class ID"
712 # for a failure that would actually be a teacher or assignment id.
713 # The broad handler below is the right destination: 500, with the real
714 # error logged. The guard is correct in `student_accepted`, where
715 # ObjectId(class_uuid) really does parse a client path parameter.
716 except Exception as error:
717 raise HTTPException(
718 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
719 detail=safe_detail(error),
720 )
722 async def class_assignments_fetch(self, request: Request, class_uuid: str) -> dict:
723 """
724 Retrieve all assignments for a specific class.
726 Fetches assignments that belong to the specified class and were created by
727 the authenticated teacher. Returns assignments sorted by their creation date.
729 Args:
730 request (Request): The incoming request object containing:
731 - user_details.uuid: Authenticated teacher's ID
732 class_uuid (str): Unique identifier of the class to fetch assignments from
734 Returns:
735 dict: {
736 "assignments": List[Assignment] where each assignment contains:
737 - _id (ObjectId): Unique assignment identifier
738 - title (str): Assignment title
739 - description (str): Assignment description
740 - semester (str): Academic semester
741 - class_id (str): Associated class identifier
742 - teacher_id (str): Teacher who created the assignment
743 - date_open (datetime): Assignment start date
744 - date_close (datetime): Assignment due date
745 - status (str): Assignment status (e.g., "Assigned")
746 - total_submissions (int): Number of student submissions
747 - question_ids (list): Associated question identifiers
748 - submission_ids (list): Student submission identifiers
749 - settings (dict): Assignment configuration
750 - created_at (datetime): Creation timestamp
751 - updated_at (datetime): Last update timestamp
752 }
754 Raises:
755 HTTPException:
756 - 404: If class not found
757 - 403: If teacher not authorized for this class
758 - 500: If database query fails
759 """
760 try:
761 teacher_id = to_user_id(request.state.user_details["uuid"])
763 # Verify class exists and teacher has access (live only)
764 class_exists = await ClassModel.find_one(
765 {
766 "_id": ObjectId(class_uuid),
767 "teacher._id": ObjectId(teacher_id),
768 "deleted": {"$ne": True},
769 }
770 )
772 if not class_exists:
773 raise HTTPException(
774 status_code=status.HTTP_404_NOT_FOUND,
775 detail="Class not found or you don't have access to it",
776 )
778 # Modified by Allan Ninal — 2026-09-24 (found while fixing EI-T120/T121).
779 # WAS: {"class_id": class_uuid, "teacher_id": teacher_id}
780 # NEITHER field exists on Assignment. The class link is
781 # `assigned_class` (a list of ObjectIds) and the owner is
782 # `created_by`. Filtering on two fields the document does not have
783 # matched nothing, so this endpoint answered
784 # 200 {"assignments": [], "count": 0} for a class that DID have
785 # assignments — verified live on QA 0.0.0.373 against a class the
786 # teacher owns with one assignment in it. No error, no log, just an
787 # empty list, which reads exactly like "no assignments yet".
788 assignments = (
789 await Assignment.find(
790 {
791 "assigned_class": (
792 ObjectId(class_uuid)
793 if ObjectId.is_valid(str(class_uuid))
794 else class_uuid
795 ),
796 "created_by": (
797 {"$in": [str(teacher_id), ObjectId(str(teacher_id))]}
798 if ObjectId.is_valid(str(teacher_id))
799 else str(teacher_id)
800 ),
801 "deleted": {"$ne": True},
802 }
803 )
804 .sort(
805 [
806 ("date_close", SortDirection.ASCENDING),
807 ("created_at", SortDirection.DESCENDING),
808 ]
809 )
810 .to_list()
811 )
813 return {"assignments": assignments, "count": len(assignments)}
815 except errors.InvalidId:
816 raise HTTPException(
817 status_code=status.HTTP_400_BAD_REQUEST,
818 detail="Invalid class ID format",
819 )
820 except HTTPException as e:
821 raise e
822 except Exception as e:
823 raise HTTPException(
824 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e)
825 )
827 async def class_roster_fetch(self, request: Request, class_uuid: str):
828 """
829 Retrieve the student roster for a specific class.
831 Args:
832 request (Request): The incoming request object containing teacher context
833 class_uuid (str): Unique identifier of the class
835 Returns:
836 dict: List of enrolled students
838 Raises:
839 HTTPException: If class not found or invalid ID format
840 """
841 try:
842 user_id = to_user_id(request.state.user_details["uuid"])
843 class_obj = await ClassModel.find(
844 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)}
845 ).to_list()
847 if not class_obj:
848 # EI-T172 / EI-E840. An empty result is "no such class", not a
849 # crash: indexing [0] raised IndexError, the broad handler below
850 # relabelled it 500, and a teacher opening a stale roster link got
851 # a server error. The route already documents 404.
852 #
853 # 404 also when the class exists but belongs to another teacher:
854 # the query filters on teacher._id, so the two are indistinguishable
855 # here by design, and answering differently would let a teacher
856 # discover which class ids are real.
857 #
858 # This is the shape the rest of this class already uses —
859 # student_remove, leave_approved, class_gradebook_fetch and
860 # class_messages_fetch all guard before they index. Roster was the
861 # one that never got it.
862 raise HTTPException(status_code=404, detail="Class not found")
864 roster = class_obj[0].students
865 return {"class_roster": roster}
866 except HTTPException:
867 # Before the broad handler: HTTPException IS an Exception, so without
868 # this the 404 above is caught below and returned as a 500.
869 raise
870 except errors.InvalidId:
871 # Modified by Allan Ninal — 2026-09-24: was `detail=str(e)`, which
872 # returned pymongo's own text ("'x' is not a valid ObjectId, it must be a
873 # 12-byte input or a 24-character hex string").
874 # All six handlers of this shape validate a class_uuid, and the codebase
875 # already answers the same condition with a static message: "Invalid
876 # <entity> ID format" appears at 37 sites across six entities, while the
877 # raw driver phrasing appeared only here. Driver text in a response is
878 # neither the house convention nor useful to the caller.
879 raise HTTPException(status_code=400, detail="Invalid class ID format")
880 except Exception as e:
881 raise HTTPException(status_code=500, detail=safe_detail(e))
883 async def class_messages_fetch(self, request: Request, class_uuid: str):
884 """
885 Retrieve class messages/announcements.
887 Args:
888 request (Request): The incoming request object containing teacher context
889 class_uuid (str): Unique identifier of the class
891 Returns:
892 dict: List of class messages and count
894 Raises:
895 HTTPException: If class not found or invalid ID format
896 """
897 try:
898 teacher_id = to_user_id(request.state.user_details["uuid"])
900 class_exists = await ClassModel.find_one(
901 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(teacher_id)}
902 )
904 if not class_exists:
905 raise HTTPException(
906 status_code=status.HTTP_404_NOT_FOUND,
907 detail="Class not found or you don't have access to it",
908 )
910 messages = (
911 await ClassMessage.find({"class_id": class_uuid})
912 .sort(-ClassMessage.created_at)
913 .to_list()
914 )
916 return {"messages": messages, "count": len(messages)}
918 except errors.InvalidId:
919 raise HTTPException(
920 status_code=status.HTTP_400_BAD_REQUEST,
921 detail="Invalid class ID format",
922 )
923 except HTTPException as e:
924 raise e
925 except Exception as e:
926 raise HTTPException(
927 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e)
928 )
930 async def class_message_create(
931 self, request: Request, class_uuid: str, body: CreateClassMessage
932 ):
933 """
934 Create a new class message/announcement.
936 Args:
937 request (Request): The incoming request object containing teacher context
938 class_uuid (str): Unique identifier of the class
939 body (CreateClassMessage): Message content
941 Returns:
942 dict: Success detail and message_id
944 Raises:
945 HTTPException: If class not found or invalid ID format
946 """
947 try:
948 teacher_id = to_user_id(request.state.user_details["uuid"])
949 teacher_name = request.state.user_details.get("name", "Teacher")
951 class_exists = await ClassModel.find_one(
952 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(teacher_id)}
953 )
955 if not class_exists:
956 raise HTTPException(
957 status_code=status.HTTP_404_NOT_FOUND,
958 detail="Class not found or you don't have access to it",
959 )
961 message = ClassMessage(
962 class_id=class_uuid,
963 author_id=str(teacher_id),
964 author_name=teacher_name,
965 content=body.content,
966 )
967 await message.insert()
969 return {
970 "detail": "Message created successfully",
971 "message_id": str(message.id),
972 }
974 except errors.InvalidId:
975 raise HTTPException(
976 status_code=status.HTTP_400_BAD_REQUEST,
977 detail="Invalid class ID format",
978 )
979 except HTTPException as e:
980 raise e
981 except Exception as e:
982 raise HTTPException(
983 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e)
984 )
986 async def class_message_delete(
987 self, request: Request, class_uuid: str, message_id: str
988 ):
989 """
990 Delete a class message/announcement.
992 Args:
993 request (Request): The incoming request object containing teacher context
994 class_uuid (str): Unique identifier of the class
995 message_id (str): Unique identifier of the message
997 Returns:
998 dict: Success detail
1000 Raises:
1001 HTTPException: If class or message not found, or invalid ID format
1002 """
1003 try:
1004 teacher_id = to_user_id(request.state.user_details["uuid"])
1006 class_exists = await ClassModel.find_one(
1007 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(teacher_id)}
1008 )
1010 if not class_exists:
1011 raise HTTPException(
1012 status_code=status.HTTP_404_NOT_FOUND,
1013 detail="Class not found or you don't have access to it",
1014 )
1016 message = await ClassMessage.find_one(
1017 {"_id": ObjectId(message_id), "class_id": class_uuid}
1018 )
1020 if not message:
1021 raise HTTPException(
1022 status_code=status.HTTP_404_NOT_FOUND, detail="Message not found"
1023 )
1025 await message.delete()
1027 return {"detail": "Message deleted successfully"}
1029 except errors.InvalidId:
1030 raise HTTPException(
1031 status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid ID format"
1032 )
1033 except HTTPException as e:
1034 raise e
1035 except Exception as e:
1036 raise HTTPException(
1037 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e)
1038 )
1040 async def class_update(
1041 self, updated_class: UpdateClassModel, request: Request, class_uuid: str
1042 ):
1043 """
1044 Update class details.
1046 Args:
1047 updated_class (UpdateClassModel): Updated class information
1048 request (Request): The incoming request object containing teacher context
1049 class_uuid (str): Unique identifier of the class
1051 Returns:
1052 dict: Updated class details and success message
1054 Raises:
1055 HTTPException: If class not found, unauthorized access, or validation fails
1056 """
1057 try:
1058 user_id = to_user_id(request.state.user_details["uuid"])
1060 # Same allow-list RegisterClass enforces on create (Semesters enum);
1061 # UpdateClassModel types semester as a bare str, so check it here.
1062 allowed_semesters = [semester.value for semester in Semesters]
1063 if updated_class.semester not in allowed_semesters:
1064 raise HTTPException(
1065 status_code=status.HTTP_400_BAD_REQUEST,
1066 detail=f"semester must be one of: {', '.join(allowed_semesters)}",
1067 )
1069 class_obj = await ClassModel.get(ObjectId(class_uuid))
1071 if not class_obj or getattr(class_obj, "deleted", False):
1072 raise HTTPException(status_code=404, detail="Class not found")
1074 if not class_obj.teacher:
1075 raise HTTPException(
1076 status_code=400, detail="Class has no assigned teacher"
1077 )
1079 if class_obj.teacher.id == ObjectId(user_id):
1080 if hasattr(updated_class, "class_code"):
1081 class_code = updated_class.class_code
1083 if class_code == "" or (
1084 class_code is not None and class_code.strip() != ""
1085 ):
1086 raise HTTPException(
1087 status_code=status.HTTP_400_BAD_REQUEST,
1088 detail="class_code should not be provided.",
1089 )
1091 # Check for duplicate title and section (excluding current class)
1092 existing_class_title_section = await ClassModel.find_one(
1093 {
1094 "_id": {"$ne": ObjectId(class_uuid)},
1095 "teacher._id": ObjectId(user_id),
1096 "deleted": {"$ne": True},
1097 "title": {
1098 "$regex": f"^{re.escape(updated_class.title)}$",
1099 "$options": "i",
1100 },
1101 "section": {
1102 "$regex": f"^{re.escape(updated_class.section)}$",
1103 "$options": "i",
1104 },
1105 }
1106 )
1107 if existing_class_title_section: # literal match, see create
1108 raise HTTPException(
1109 status.HTTP_409_CONFLICT,
1110 detail=f"A class with title '{updated_class.title}' and section '{updated_class.section}' already exists.",
1111 )
1113 # Check for duplicate schedules (excluding current class)
1114 if updated_class.schedules:
1115 new_schedules_set = {
1116 (
1117 s.day.value if hasattr(s.day, "value") else s.day,
1118 s.time_start,
1119 s.time_end,
1120 )
1121 for s in updated_class.schedules
1122 }
1124 existing_classes = await ClassModel.find(
1125 {
1126 "_id": {"$ne": ObjectId(class_uuid)},
1127 "teacher._id": ObjectId(user_id),
1128 "deleted": {"$ne": True},
1129 "schedules": {"$exists": True, "$ne": []},
1130 }
1131 ).to_list()
1133 for existing_class in existing_classes:
1134 if existing_class.schedules:
1135 existing_schedules_set = {
1136 (
1137 s.day.value if hasattr(s.day, "value") else s.day,
1138 s.time_start,
1139 s.time_end,
1140 )
1141 for s in existing_class.schedules
1142 }
1143 if new_schedules_set == existing_schedules_set:
1144 raise HTTPException(
1145 status.HTTP_409_CONFLICT,
1146 detail=f"A class with the same schedule already exists: '{existing_class.title}' ({existing_class.section}).",
1147 )
1149 updated_data = updated_class.model_dump(exclude_none=True)
1150 # Re-key on the new title/section so the index also covers an edit
1151 # racing a create onto the same pair (EI-T473).
1152 updated_data["dedupe_key"] = class_dedupe_key(
1153 user_id, updated_class.title, updated_class.section
1154 )
1155 try:
1156 # On the collection: Beanie's update() re-raises a duplicate key as
1157 # RevisionIdWasChanged (a 500), hiding the 409 below.
1158 await ClassModel.get_pymongo_collection().update_one(
1159 {"_id": class_obj.id}, {"$set": updated_data}
1160 )
1161 class_obj = await ClassModel.get(class_obj.id)
1162 except DuplicateKeyError as dup:
1163 if not is_class_dedupe_violation(dup):
1164 raise
1165 raise HTTPException(
1166 status.HTTP_409_CONFLICT,
1167 detail=f"A class with title '{updated_class.title}' and section '{updated_class.section}' already exists.",
1168 ) from dup
1169 class_obj.class_photo = class_photo_url(class_obj.class_photo)
1171 return {
1172 "detail": "Class updated successfully",
1173 "updated_class": class_obj,
1174 }
1176 raise HTTPException(
1177 status_code=status.HTTP_403_FORBIDDEN,
1178 detail="You are not authorized to update this class.",
1179 )
1181 except ValidationError as e:
1182 raise HTTPException(
1183 status_code=status.HTTP_400_BAD_REQUEST,
1184 detail=e.errors()[0]["msg"],
1185 )
1186 except HTTPException as e:
1187 raise e
1188 except errors.InvalidId:
1189 # A malformed class_uuid makes ObjectId() raise bson InvalidId; that is
1190 # the caller's error (400), not a server fault.
1191 raise HTTPException(
1192 status_code=status.HTTP_400_BAD_REQUEST,
1193 detail="Invalid class ID provided!",
1194 )
1195 except Exception as e:
1196 raise HTTPException(
1197 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=safe_detail(e)
1198 )
1200 async def class_delete(self, request: Request, class_uuid: str):
1201 """
1202 Delete a class.
1204 Args:
1205 request (Request): The incoming request object containing teacher context
1206 class_uuid (str): Unique identifier of the class to delete
1208 Returns:
1209 dict: Deletion confirmation message
1211 Raises:
1212 HTTPException: If class not found or unauthorized access
1213 """
1214 try:
1215 user_id = to_user_id(request.state.user_details["uuid"])
1216 class_obj = await ClassModel.get(ObjectId(class_uuid))
1218 if not class_obj or getattr(class_obj, "deleted", False):
1219 raise HTTPException(status_code=404, detail="Class not found")
1221 if not class_obj.teacher:
1222 raise HTTPException(
1223 status_code=400, detail="Class has no assigned teacher"
1224 )
1226 if class_obj.teacher.id == ObjectId(user_id):
1227 # Soft delete: mark deleted=True instead of removing the
1228 # document. Keeps `class_code` reserved against reissue
1229 # by `generate_unique_code` (the collision check has no
1230 # `deleted` filter, so soft-deleted codes still match).
1231 class_obj.deleted = True
1232 # Free the title + section for a new class (EI-T473 dedupe index).
1233 class_obj.dedupe_key = None
1234 class_obj.updated_at = datetime.now(timezone.utc)
1235 await class_obj.save()
1236 return {"detail": "Class deleted successfully"}
1237 raise HTTPException(
1238 status_code=403, detail="Not authorized to delete this class"
1239 )
1240 except errors.InvalidId:
1241 raise HTTPException(
1242 status.HTTP_400_BAD_REQUEST, detail="Invalid class ID provided!"
1243 )
1244 except HTTPException:
1245 raise
1246 except Exception as e:
1247 raise HTTPException(status_code=500, detail=safe_detail(e))
1249 async def _authorize_class_owner(
1250 self, request: Request, class_uuid: str
1251 ) -> ClassModel:
1252 """Shared fetch + ownership check for the class-photo endpoints."""
1253 try:
1254 user_id = to_user_id(request.state.user_details["uuid"])
1255 class_obj = await ClassModel.get(ObjectId(class_uuid))
1256 except errors.InvalidId:
1257 raise HTTPException(
1258 status.HTTP_400_BAD_REQUEST, detail="Invalid class ID provided!"
1259 )
1261 if not class_obj or getattr(class_obj, "deleted", False):
1262 raise HTTPException(status_code=404, detail="Class not found")
1264 if not class_obj.teacher:
1265 raise HTTPException(status_code=400, detail="Class has no assigned teacher")
1267 if class_obj.teacher.id != ObjectId(user_id):
1268 raise HTTPException(
1269 status_code=403, detail="Not authorized to modify this class"
1270 )
1272 return class_obj
1274 async def class_photo_add(
1275 self, request: Request, class_uuid: str, file: UploadFile = File(...)
1276 ) -> dict:
1277 """
1278 Upload a cover photo for a class that doesn't have one yet.
1280 Mirrors UsersService.user_picture_add — same validate/compress/upload
1281 flow — but scoped to a class the authenticated teacher owns, and
1282 stored under the ``class-images/`` prefix instead of ``user-images/``.
1284 Args:
1285 request (Request): The incoming request object containing teacher context
1286 class_uuid (str): Unique identifier of the class
1287 file (UploadFile): Image file to upload (JPG/PNG, max 10MB)
1289 Returns:
1290 dict: {"detail": str, "updated_class": ClassModel} with class_photo
1291 rewritten to a presigned display URL.
1293 Raises:
1294 HTTPException:
1295 - 400: Invalid class ID, or the class already has a cover photo
1296 - 403: Teacher does not own this class
1297 - 404: Class not found
1298 - 413/415: File too large or unsupported type
1299 """
1300 try:
1301 class_obj = await self._authorize_class_owner(request, class_uuid)
1303 if class_obj.class_photo:
1304 raise HTTPException(
1305 status_code=status.HTTP_400_BAD_REQUEST,
1306 detail="Class already has a cover photo. Use the update endpoint instead.",
1307 )
1309 file_obj = file.file
1310 file_obj.seek(0)
1311 validate_file_size_type(file_obj)
1312 file_obj.seek(0)
1314 compressed_file = compress_profile_picture(file_obj)
1315 storage_dir = f"class-images/{class_uuid}/{class_uuid}-cover.jpg"
1317 s3.upload_fileobj(
1318 compressed_file,
1319 MINIO_PRIVATE_BUCKET,
1320 storage_dir,
1321 ExtraArgs={"ContentType": "image/jpeg"},
1322 )
1324 class_obj = await class_obj.update({"$set": {"class_photo": storage_dir}})
1325 class_obj.class_photo = class_photo_url(class_obj.class_photo)
1326 return {
1327 "detail": "Class photo added successfully",
1328 "updated_class": class_obj,
1329 }
1331 except HTTPException:
1332 raise
1333 except Exception as e:
1334 logging.error(f"Class photo upload failed: {str(e)}", exc_info=True)
1335 raise HTTPException(
1336 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1337 detail="Failed to upload class photo. Please try again later.",
1338 )
1339 finally:
1340 if hasattr(file, "file"):
1341 file.file.close()
1343 async def class_photo_update(
1344 self, request: Request, class_uuid: str, file: UploadFile = File(...)
1345 ) -> dict:
1346 """
1347 Replace an existing class cover photo. Mirrors UsersService.user_picture_update.
1349 Raises:
1350 HTTPException:
1351 - 400: Invalid class ID, or the class has no cover photo to update
1352 - 403: Teacher does not own this class
1353 - 404: Class not found
1354 - 413/415: File too large or unsupported type
1355 """
1356 try:
1357 class_obj = await self._authorize_class_owner(request, class_uuid)
1359 old_photo = class_obj.class_photo
1360 if not old_photo:
1361 raise HTTPException(
1362 status_code=status.HTTP_400_BAD_REQUEST,
1363 detail="Class doesn't have a cover photo to update. Use the add endpoint instead.",
1364 )
1366 file_obj = file.file
1367 file_obj.seek(0)
1368 validate_file_size_type(file_obj)
1369 file_obj.seek(0)
1371 compressed_file = compress_profile_picture(file_obj)
1372 storage_dir = f"class-images/{class_uuid}/{class_uuid}-cover.jpg"
1374 if "class-images/" in old_photo:
1375 try:
1376 old_key = old_photo[old_photo.index("class-images/") :]
1377 s3.delete_object(Bucket=MINIO_PRIVATE_BUCKET, Key=old_key)
1378 except Exception as e:
1379 logging.error(f"Failed to delete old class photo: {str(e)}")
1381 s3.upload_fileobj(
1382 compressed_file,
1383 MINIO_PRIVATE_BUCKET,
1384 storage_dir,
1385 ExtraArgs={"ContentType": "image/jpeg"},
1386 )
1388 class_obj = await class_obj.update({"$set": {"class_photo": storage_dir}})
1389 class_obj.class_photo = class_photo_url(class_obj.class_photo)
1390 return {
1391 "detail": "Class photo updated successfully",
1392 "updated_class": class_obj,
1393 }
1395 except HTTPException:
1396 raise
1397 except Exception as e:
1398 logging.error(f"Class photo update failed: {str(e)}", exc_info=True)
1399 raise HTTPException(
1400 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1401 detail="Failed to update class photo. Please try again later.",
1402 )
1403 finally:
1404 if hasattr(file, "file"):
1405 file.file.close()
1407 async def class_photo_delete(self, request: Request, class_uuid: str) -> dict:
1408 """
1409 Remove a class's cover photo and delete it from storage.
1410 Mirrors UsersService.user_picture_delete.
1412 Raises:
1413 HTTPException:
1414 - 400: Invalid class ID
1415 - 403: Teacher does not own this class
1416 - 404: Class not found
1417 """
1418 try:
1419 class_obj = await self._authorize_class_owner(request, class_uuid)
1421 old_photo = class_obj.class_photo
1422 if not old_photo:
1423 return {
1424 "detail": "No class photo to delete",
1425 "updated_class": class_obj,
1426 }
1428 if "class-images/" in old_photo:
1429 try:
1430 old_key = old_photo[old_photo.index("class-images/") :]
1431 s3.delete_object(Bucket=MINIO_PRIVATE_BUCKET, Key=old_key)
1432 except Exception as e:
1433 logging.error(f"Failed to delete class photo: {str(e)}")
1435 class_obj = await class_obj.update({"$set": {"class_photo": None}})
1436 return {
1437 "detail": "Class photo deleted successfully",
1438 "updated_class": class_obj,
1439 }
1441 except HTTPException:
1442 raise
1443 except Exception as e:
1444 logging.error(f"Class photo deletion failed: {str(e)}", exc_info=True)
1445 raise HTTPException(
1446 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1447 detail="Failed to delete class photo. Please try again later.",
1448 )
1450 async def class_restore(self, request: Request, class_uuid: str):
1451 """
1452 Restore a soft-deleted class. The reserved `class_code` is
1453 unchanged. Mirror of `class_delete` — same auth + ownership
1454 checks; flips `deleted` False instead of True.
1455 """
1456 try:
1457 user_id = to_user_id(request.state.user_details["uuid"])
1458 class_obj = await ClassModel.get(ObjectId(class_uuid))
1460 if not class_obj:
1461 raise HTTPException(status_code=404, detail="Class not found")
1463 if not class_obj.teacher:
1464 raise HTTPException(
1465 status_code=400, detail="Class has no assigned teacher"
1466 )
1468 if class_obj.teacher.id != ObjectId(user_id):
1469 raise HTTPException(
1470 status_code=403, detail="Not authorized to restore this class"
1471 )
1473 if not getattr(class_obj, "deleted", False):
1474 raise HTTPException(
1475 status_code=status.HTTP_400_BAD_REQUEST,
1476 detail="Class is not soft-deleted; nothing to restore.",
1477 )
1479 # Re-key on restore, so restoring onto a live twin is refused rather than
1480 # producing two identical live classes (EI-T473 dedupe index). An explicit
1481 # $set on the collection, not save()/update(): Beanie re-raises a duplicate
1482 # key as RevisionIdWasChanged, which would surface as a 500.
1483 try:
1484 await ClassModel.get_pymongo_collection().update_one(
1485 {"_id": class_obj.id},
1486 {
1487 "$set": {
1488 "deleted": False,
1489 "dedupe_key": class_dedupe_key(
1490 user_id, class_obj.title, class_obj.section
1491 ),
1492 "updated_at": datetime.now(timezone.utc),
1493 }
1494 },
1495 )
1496 except DuplicateKeyError as dup:
1497 if not is_class_dedupe_violation(dup):
1498 raise
1499 raise HTTPException(
1500 status.HTTP_409_CONFLICT,
1501 detail=f"A live class with title '{class_obj.title}' and section '{class_obj.section}' already exists.",
1502 ) from dup
1503 return {"detail": "Class restored successfully"}
1504 except errors.InvalidId:
1505 raise HTTPException(
1506 status.HTTP_400_BAD_REQUEST, detail="Invalid class ID provided!"
1507 )
1508 except HTTPException:
1509 raise
1510 except Exception as e:
1511 raise HTTPException(status_code=500, detail=safe_detail(e))
1513 async def class_join(self, class_code: str, request: Request):
1514 """
1515 Join a class using a class code.
1517 Args:
1518 class_code (str): Unique code identifying the class
1519 request (Request): The incoming request object containing student context
1521 Returns:
1522 dict: Join confirmation message
1523 """
1524 try:
1525 student_id = to_user_id(request.state.user_details["uuid"])
1526 student = (
1527 await User.find({"_id": student_id}).project(StudentModel).to_list(None)
1528 )[0]
1530 class_res = await ClassModel.find(
1531 {"class_code": class_code, "deleted": {"$ne": True}}
1532 ).to_list(None)
1533 if not class_res:
1534 raise InvalidStudentRequest("Class not found")
1536 class_res = class_res[0]
1537 if class_res.students:
1538 for s in class_res.students:
1539 if s.id == ObjectId(student_id):
1540 if s.status == "Removed":
1541 s.status = "Pending"
1542 await class_res.save()
1543 return {
1544 "detail": "Successfully requested to join the class."
1545 }
1546 elif s.status == "Pending":
1547 raise InvalidStudentRequest(
1548 message="You already requested to join this class."
1549 )
1550 else:
1551 raise InvalidStudentRequest()
1553 student.status = "Pending"
1554 await ClassModel.find_one({"class_code": class_code}).update_one(
1555 {
1556 "$push": {
1557 "students": {"$each": [student], "$position": 0},
1558 }
1559 }
1560 )
1562 return {"detail": "Successfully requested to join the class"}
1563 except InvalidStudentRequest as e:
1564 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e))
1565 except Exception as e:
1566 raise HTTPException(status_code=500, detail=safe_detail(e))
1568 async def class_leave_request(self, class_code: str, request: Request):
1569 """
1570 Request to leave a class.
1572 Args:
1573 class_code (str): Unique code identifying the class
1574 request (Request): The incoming request object containing student context
1576 Returns:
1577 dict: Leave request confirmation message
1578 """
1579 try:
1580 student_id = to_user_id(request.state.user_details["uuid"])
1582 class_res = await ClassModel.find(
1583 {"class_code": class_code, "deleted": {"$ne": True}}
1584 ).to_list(None)
1585 if not class_res:
1586 raise InvalidStudentRequest("Class not found")
1588 class_res = class_res[0]
1589 if class_res.students:
1590 for s in class_res.students:
1591 if s.id == ObjectId(student_id):
1592 if s.status == "Enrolled":
1593 if s.is_requesting_to_leave:
1594 raise InvalidStudentRequest(
1595 "Student already requested to leave."
1596 )
1597 s.is_requesting_to_leave = True
1598 await class_res.save()
1599 return {"detail": "Successfully requested to leave class."}
1601 raise InvalidStudentRequest(
1602 "Invalid action. Student is not part of this class."
1603 )
1605 except InvalidStudentRequest as e:
1606 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e))
1607 except Exception as e:
1608 raise HTTPException(status_code=500, detail=safe_detail(e))
1610 async def join_request_cancel(self, class_code: str, request: Request):
1611 """
1612 Cancel a student's pending request to join a class.
1614 Args:
1615 class_code (str): Code of the class to cancel join request
1616 request (Request): The incoming request object containing student context
1618 Returns:
1619 dict: Cancellation confirmation message
1621 Raises:
1622 HTTPException: If class not found or invalid request state
1623 InvalidStudentRequest: If student has no pending request or is already enrolled
1624 """
1625 try:
1626 student_id = to_user_id(request.state.user_details["uuid"])
1627 student = (
1628 await User.find({"_id": student_id}).project(StudentModel).to_list(None)
1629 )[0]
1631 class_res = await ClassModel.find(
1632 {"class_code": class_code, "deleted": {"$ne": True}}
1633 ).to_list(None)
1634 if not class_res:
1635 raise InvalidStudentRequest("Class not found")
1637 class_res = class_res[0]
1638 if class_res.students:
1639 for s in class_res.students:
1640 if s.id == ObjectId(student_id):
1641 if s.status == "Pending":
1642 s.status = "Removed"
1643 await class_res.save()
1644 return {"detail": "Cancelled request to join the class."}
1645 elif s.status == "Removed":
1646 raise InvalidStudentRequest(
1647 message="You don't have pending request to join this class."
1648 )
1649 else:
1650 raise InvalidStudentRequest(
1651 message="Request cannot be cancelled, already accepted by teacher. Request to leave instead."
1652 )
1654 student.status = "Removed"
1655 await ClassModel.find_one({"class_code": class_code}).update_one(
1656 {
1657 "$push": {
1658 "students": {"$each": [student], "$position": 0},
1659 }
1660 }
1661 )
1663 return {"detail": "Cancelled request to join the class."}
1664 except InvalidStudentRequest as e:
1665 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e))
1666 except Exception as e:
1667 raise HTTPException(status_code=500, detail=safe_detail(e))
1669 async def student_accepted(
1670 self, request: Request, class_uuid: str, student_uuid: str
1671 ):
1672 """
1673 Accept a pending student's request to join a class.
1675 Args:
1676 request (Request): The incoming request object containing teacher context
1677 class_uuid (str): Unique identifier of the class
1678 payload (UpdateStudentStatus): Contains student_id of the student to accept
1680 Returns:
1681 dict: Acceptance confirmation message
1682 Example: {"detail": "Successfully accepted student to the class"}
1684 Raises:
1685 HTTPException:
1686 - 404: If class not found, or the student is not in the class
1687 (including when the class has no students)
1688 - 400: If class_uuid or student_uuid is not a valid ObjectId
1689 - 500: For unexpected server errors
1690 InvalidStudentRequest: If student is already enrolled
1692 Note:
1693 Only students with "Pending" status can be accepted. The method updates
1694 their status to "Enrolled" upon successful acceptance.
1695 """
1696 # Added by Allan Ninal — 2026-10-03 (Zephyr EI-T194 / EI-T196).
1697 # Validate both ids BEFORE any lookup. student_uuid used to be checked only
1698 # while looping over the roster, so for a class with no students the
1699 # "No students in class" 400 fired first and a malformed or 500-char
1700 # student id was never reported as malformed; and the one InvalidId
1701 # handler below always said "Invalid class ID format", even when it was
1702 # the student id that was wrong.
1703 if not ObjectId.is_valid(class_uuid):
1704 raise HTTPException(
1705 status_code=status.HTTP_400_BAD_REQUEST,
1706 detail="Invalid class ID format",
1707 )
1708 if not ObjectId.is_valid(student_uuid):
1709 raise HTTPException(
1710 status_code=status.HTTP_400_BAD_REQUEST,
1711 detail="Invalid student ID format",
1712 )
1713 try:
1714 user_id = request.state.user_details.get("uuid")
1716 class_obj_list = await ClassModel.find(
1717 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)}
1718 ).to_list(length=1)
1720 if not class_obj_list:
1721 raise HTTPException(
1722 status_code=status.HTTP_404_NOT_FOUND, detail="Class not found"
1723 )
1725 class_obj = class_obj_list[0]
1727 # Modified by Allan Ninal — 2026-10-03 (Zephyr EI-T196).
1728 # WAS: 400 "No students in class". A well-formed student id that is not
1729 # on the roster is "not found" whether the roster is empty or not; the
1730 # non-empty case below already answers 404 "Student not found in class.".
1731 if not class_obj.students:
1732 raise HTTPException(
1733 status_code=status.HTTP_404_NOT_FOUND,
1734 detail="Student not found in class.",
1735 )
1737 student_found = False
1739 for student in class_obj.students:
1740 if student.id == ObjectId(student_uuid):
1741 student_found = True
1743 if student.status == "Enrolled":
1744 return InvalidStudentRequest()
1746 elif student.status in ["Pending", "Rejected", "Removed"]:
1747 student.status = "Enrolled"
1748 await class_obj.save()
1749 return {"detail": "Successfully accepted student to the class"}
1751 raise HTTPException(
1752 status_code=status.HTTP_400_BAD_REQUEST,
1753 detail=f"Cannot accept student with status: {student.status}",
1754 )
1756 if not student_found:
1757 raise HTTPException(
1758 status_code=status.HTTP_404_NOT_FOUND,
1759 detail="Student not found in class.",
1760 )
1762 except HTTPException as http_error:
1763 raise http_error
1764 except errors.InvalidId:
1765 # Added by Allan Ninal — 2026-09-24.
1766 # WHY: this handler's broad `except Exception` used to answer 400, so a
1767 # malformed class_uuid/student_id — which raises bson InvalidId —
1768 # came back 400 by ACCIDENT. Now that the broad catch correctly
1769 # answers 500 (an unexpected failure is the server's fault), that
1770 # accident would turn a genuine client error into a server error.
1771 # An explicit narrow guard keeps the 400 for the case that really
1772 # IS the caller's, and uses the house message rather than pymongo's.
1773 raise HTTPException(
1774 status_code=status.HTTP_400_BAD_REQUEST,
1775 detail="Invalid class ID format",
1776 )
1777 except Exception as error:
1778 raise HTTPException(
1779 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1780 detail=safe_detail(error),
1781 )
1783 async def student_remove(self, request: Request, class_uuid: str, student_id: str):
1784 """
1785 Remove a student from a class.
1787 Args:
1788 request (Request): The incoming request object containing teacher context
1789 class_uuid (str): Unique identifier of the class
1790 student_id (str): Identifier of the student to remove
1792 Returns:
1793 dict: Removal confirmation message
1795 Raises:
1796 HTTPException: If class not found, student not found, or unauthorized action
1797 """
1798 # Added by Allan Ninal — 2026-10-03 (Zephyr EI-T197 / EI-T199, cycle EI-R8).
1799 # Validate both ids BEFORE the lookup. student_id used to be checked only
1800 # while looping over the roster, so for a class with no students the
1801 # "No students found in class" 400 fired first and a malformed student id
1802 # was never reported as malformed. Same fix as student_accepted (#407).
1803 if not ObjectId.is_valid(class_uuid):
1804 raise HTTPException(
1805 status_code=status.HTTP_400_BAD_REQUEST,
1806 detail="Invalid class ID format",
1807 )
1808 if not ObjectId.is_valid(student_id):
1809 raise HTTPException(
1810 status_code=status.HTTP_400_BAD_REQUEST,
1811 detail="Invalid student ID format",
1812 )
1813 try:
1814 user_id = to_user_id(request.state.user_details["uuid"])
1815 class_obj = await ClassModel.find(
1816 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)}
1817 ).to_list(None)
1819 if not class_obj:
1820 raise HTTPException(status_code=404, detail="Class not found")
1822 class_obj = class_obj[0]
1824 if not class_obj.teacher:
1825 raise HTTPException(
1826 status_code=400, detail="Class has no assigned teacher"
1827 )
1829 if class_obj.teacher.id == ObjectId(user_id):
1830 if not class_obj.students:
1831 raise HTTPException(
1832 status_code=400, detail="No students found in class"
1833 )
1835 for student in class_obj.students:
1836 if student.id == ObjectId(student_id):
1837 if student.status == "Pending":
1838 student.status = "Rejected"
1839 await class_obj.save()
1840 return {
1841 "detail": "Successfully rejected student from the class"
1842 }
1844 elif student.status == "Enrolled":
1845 student.status = "Removed"
1846 await class_obj.save()
1847 return {
1848 "detail": "Successfully removed student from the class"
1849 }
1851 raise HTTPException(
1852 status_code=400,
1853 detail="Invalid action. Student is not part of the class.",
1854 )
1855 except HTTPException as e:
1856 raise e
1857 except errors.InvalidId:
1858 # A malformed class_uuid/student_id makes ObjectId() raise bson
1859 # InvalidId; that is the caller's error (400), not a server fault.
1860 raise HTTPException(
1861 status_code=status.HTTP_400_BAD_REQUEST,
1862 detail="Invalid class or student ID format",
1863 )
1864 except Exception as e:
1865 raise HTTPException(status_code=500, detail=safe_detail(e))
1867 async def leave_approved(
1868 self,
1869 request: Request,
1870 class_uuid: str,
1871 student_id: str,
1872 leave_request_data: LeaveRequestData,
1873 ):
1874 """
1875 Process a student's request to leave a class.
1877 Args:
1878 request (Request): The incoming request object containing teacher context
1879 class_uuid (str): Unique identifier of the class
1880 leave_request_data (LeaveRequestData): Leave request details including
1881 student ID and whether the request is granted
1883 Returns:
1884 dict: Leave request processing confirmation
1886 Raises:
1887 HTTPException: If class not found, invalid student ID, or unauthorized action
1888 """
1889 try:
1890 if student_id.strip() == "":
1891 raise HTTPException(status_code=400, detail="Missing student ID.")
1893 user_id = to_user_id(request.state.user_details["uuid"])
1894 class_obj = await ClassModel.find(
1895 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(user_id)}
1896 ).to_list(None)
1898 if not class_obj:
1899 raise HTTPException(status_code=404, detail="Class not found")
1901 class_obj = class_obj[0]
1903 if not class_obj.teacher:
1904 raise HTTPException(
1905 status_code=400, detail="Class has no assigned teacher"
1906 )
1908 if class_obj.teacher.id == ObjectId(user_id):
1909 if not class_obj.students:
1910 raise HTTPException(
1911 status_code=400, detail="No students found in class"
1912 )
1914 for student in class_obj.students:
1915 if student.id == ObjectId(student_id):
1916 if student.is_requesting_to_leave:
1917 if not leave_request_data.is_granted:
1918 student.is_requesting_to_leave = False
1919 await class_obj.save()
1920 return {"detail": "Student leave request decline."}
1922 student.status = "Removed"
1923 student.is_requesting_to_leave = False
1924 await class_obj.save()
1925 return {
1926 "detail": "Successfully removed student from the class"
1927 }
1928 else:
1929 raise HTTPException(
1930 status_code=400,
1931 detail="Invalid action. Student is not requesting to leave the class.",
1932 )
1934 raise HTTPException(
1935 status_code=400,
1936 detail="Invalid action. Student is not part of the class.",
1937 )
1938 except errors.InvalidId:
1939 raise HTTPException(
1940 status.HTTP_400_BAD_REQUEST, detail="Invalid class ID provided"
1941 )
1942 except HTTPException as e:
1943 raise e
1944 except Exception as e:
1945 raise HTTPException(status_code=500, detail=safe_detail(e))