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

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 

45 

46 

47logger = logging.getLogger(__name__) 

48 

49 

50class InvalidStudentRequest(Exception): 

51 

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

53 self.message = message 

54 super().__init__(self.message) 

55 

56 

57class TeacherClassesService: 

58 """ 

59 Service class for managing class-related operations. 

60 

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

62 teacher assignments, and class status updates. 

63 """ 

64 

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. 

69 

70 Args: 

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

72 new_class (RegisterClass): Class details for creation 

73 

74 Returns: 

75 dict: Created class details and success message 

76 

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

82 

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 ) 

91 

92 teacher_dict = teacher_data[0].model_dump() 

93 teacher_dict["_id"] = teacher_dict.pop("id", None) 

94 new_class.teacher = TeacherModel(**teacher_dict) 

95 

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 ) 

116 

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 ) 

141 

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 } 

147 

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

155 

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 ) 

171 

172 # Generate unique class code 

173 new_class.class_code = await generate_unique_code() 

174 

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 

191 

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) 

197 

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 ) 

202 

203 return { 

204 "detail": "Successfully Created Class", 

205 "new_class": serialized_response_object(response_class), 

206 } 

207 

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

218 

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. 

228 

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 

236 

237 Returns: 

238 dict: Paginated list of classes with metadata 

239 

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

245 

246 query_filter: Dict[str, Any] = { 

247 "teacher._id": ObjectId(teacher_id), 

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

249 } 

250 

251 title = normalized_params.get("title") 

252 status = normalized_params.get("status") 

253 

254 if title: 

255 query_filter["title"] = { 

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

257 "$options": "i", 

258 } 

259 

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 

266 

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 

273 

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 ) 

287 

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) 

292 

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

294 

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

305 

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 } 

315 

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 

326 

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. 

336 

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 

344 

345 Returns: 

346 dict: Paginated list of classes with metadata 

347 

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

354 

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 ) 

378 

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

393 

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. 

402 

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 

409 

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 } 

418 

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 } 

432 

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

465 

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. 

474 

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 

479 

480 Returns: 

481 dict: Class details or appropriate message if not found 

482 

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

520 

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

522 """ 

523 Retrieve a class using its class code. 

524 

525 Args: 

526 request (Request): The incoming request object 

527 class_code (str): Unique code identifying the class 

528 

529 Returns: 

530 dict: Class details 

531 

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 ) 

546 

547 if fetched_class: 

548 fetched_class.class_photo = class_photo_url(fetched_class.class_photo) 

549 return {"Class": fetched_class} 

550 

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

558 

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

562 

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

573 

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 ) 

580 

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 } 

595 

596 assignment_ids = [ObjectId(aid) for aid in assignment_map.keys()] 

597 

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 ) 

604 

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) 

612 

613 now = datetime.now(timezone.utc) 

614 

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 } 

623 

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 ] 

630 

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 = [] 

636 

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 ) 

642 

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} 

660 

661 resolved = resolve_cell_grade( 

662 submission, assignment_data.get("date_close"), now 

663 ) 

664 grade = resolved["grade"] 

665 

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 ) 

682 

683 if grade is not None: 

684 grade_values.append(grade) 

685 

686 average_grade = round(mean(grade_values), 2) if grade_values else 0 

687 

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 ) 

698 

699 return response 

700 

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 ) 

721 

722 async def class_assignments_fetch(self, request: Request, class_uuid: str) -> dict: 

723 """ 

724 Retrieve all assignments for a specific class. 

725 

726 Fetches assignments that belong to the specified class and were created by 

727 the authenticated teacher. Returns assignments sorted by their creation date. 

728 

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 

733 

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 } 

753 

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

762 

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 ) 

771 

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 ) 

777 

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 ) 

812 

813 return {"assignments": assignments, "count": len(assignments)} 

814 

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 ) 

826 

827 async def class_roster_fetch(self, request: Request, class_uuid: str): 

828 """ 

829 Retrieve the student roster for a specific class. 

830 

831 Args: 

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

833 class_uuid (str): Unique identifier of the class 

834 

835 Returns: 

836 dict: List of enrolled students 

837 

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

846 

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

863 

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

882 

883 async def class_messages_fetch(self, request: Request, class_uuid: str): 

884 """ 

885 Retrieve class messages/announcements. 

886 

887 Args: 

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

889 class_uuid (str): Unique identifier of the class 

890 

891 Returns: 

892 dict: List of class messages and count 

893 

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

899 

900 class_exists = await ClassModel.find_one( 

901 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(teacher_id)} 

902 ) 

903 

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 ) 

909 

910 messages = ( 

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

912 .sort(-ClassMessage.created_at) 

913 .to_list() 

914 ) 

915 

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

917 

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 ) 

929 

930 async def class_message_create( 

931 self, request: Request, class_uuid: str, body: CreateClassMessage 

932 ): 

933 """ 

934 Create a new class message/announcement. 

935 

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 

940 

941 Returns: 

942 dict: Success detail and message_id 

943 

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

950 

951 class_exists = await ClassModel.find_one( 

952 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(teacher_id)} 

953 ) 

954 

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 ) 

960 

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

968 

969 return { 

970 "detail": "Message created successfully", 

971 "message_id": str(message.id), 

972 } 

973 

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 ) 

985 

986 async def class_message_delete( 

987 self, request: Request, class_uuid: str, message_id: str 

988 ): 

989 """ 

990 Delete a class message/announcement. 

991 

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 

996 

997 Returns: 

998 dict: Success detail 

999 

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

1005 

1006 class_exists = await ClassModel.find_one( 

1007 {"_id": ObjectId(class_uuid), "teacher._id": ObjectId(teacher_id)} 

1008 ) 

1009 

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 ) 

1015 

1016 message = await ClassMessage.find_one( 

1017 {"_id": ObjectId(message_id), "class_id": class_uuid} 

1018 ) 

1019 

1020 if not message: 

1021 raise HTTPException( 

1022 status_code=status.HTTP_404_NOT_FOUND, detail="Message not found" 

1023 ) 

1024 

1025 await message.delete() 

1026 

1027 return {"detail": "Message deleted successfully"} 

1028 

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 ) 

1039 

1040 async def class_update( 

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

1042 ): 

1043 """ 

1044 Update class details. 

1045 

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 

1050 

1051 Returns: 

1052 dict: Updated class details and success message 

1053 

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

1059 

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 ) 

1068 

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

1070 

1071 if not class_obj or getattr(class_obj, "deleted", False): 

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

1073 

1074 if not class_obj.teacher: 

1075 raise HTTPException( 

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

1077 ) 

1078 

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

1080 if hasattr(updated_class, "class_code"): 

1081 class_code = updated_class.class_code 

1082 

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 ) 

1090 

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 ) 

1112 

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 } 

1123 

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

1132 

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 ) 

1148 

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) 

1170 

1171 return { 

1172 "detail": "Class updated successfully", 

1173 "updated_class": class_obj, 

1174 } 

1175 

1176 raise HTTPException( 

1177 status_code=status.HTTP_403_FORBIDDEN, 

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

1179 ) 

1180 

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 ) 

1199 

1200 async def class_delete(self, request: Request, class_uuid: str): 

1201 """ 

1202 Delete a class. 

1203 

1204 Args: 

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

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

1207 

1208 Returns: 

1209 dict: Deletion confirmation message 

1210 

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

1217 

1218 if not class_obj or getattr(class_obj, "deleted", False): 

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

1220 

1221 if not class_obj.teacher: 

1222 raise HTTPException( 

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

1224 ) 

1225 

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

1248 

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 ) 

1260 

1261 if not class_obj or getattr(class_obj, "deleted", False): 

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

1263 

1264 if not class_obj.teacher: 

1265 raise HTTPException(status_code=400, detail="Class has no assigned teacher") 

1266 

1267 if class_obj.teacher.id != ObjectId(user_id): 

1268 raise HTTPException( 

1269 status_code=403, detail="Not authorized to modify this class" 

1270 ) 

1271 

1272 return class_obj 

1273 

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. 

1279 

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/``. 

1283 

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) 

1288 

1289 Returns: 

1290 dict: {"detail": str, "updated_class": ClassModel} with class_photo 

1291 rewritten to a presigned display URL. 

1292 

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) 

1302 

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 ) 

1308 

1309 file_obj = file.file 

1310 file_obj.seek(0) 

1311 validate_file_size_type(file_obj) 

1312 file_obj.seek(0) 

1313 

1314 compressed_file = compress_profile_picture(file_obj) 

1315 storage_dir = f"class-images/{class_uuid}/{class_uuid}-cover.jpg" 

1316 

1317 s3.upload_fileobj( 

1318 compressed_file, 

1319 MINIO_PRIVATE_BUCKET, 

1320 storage_dir, 

1321 ExtraArgs={"ContentType": "image/jpeg"}, 

1322 ) 

1323 

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 } 

1330 

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

1342 

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. 

1348 

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) 

1358 

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 ) 

1365 

1366 file_obj = file.file 

1367 file_obj.seek(0) 

1368 validate_file_size_type(file_obj) 

1369 file_obj.seek(0) 

1370 

1371 compressed_file = compress_profile_picture(file_obj) 

1372 storage_dir = f"class-images/{class_uuid}/{class_uuid}-cover.jpg" 

1373 

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

1380 

1381 s3.upload_fileobj( 

1382 compressed_file, 

1383 MINIO_PRIVATE_BUCKET, 

1384 storage_dir, 

1385 ExtraArgs={"ContentType": "image/jpeg"}, 

1386 ) 

1387 

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 } 

1394 

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

1406 

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. 

1411 

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) 

1420 

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 } 

1427 

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

1434 

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 } 

1440 

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 ) 

1449 

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

1459 

1460 if not class_obj: 

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

1462 

1463 if not class_obj.teacher: 

1464 raise HTTPException( 

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

1466 ) 

1467 

1468 if class_obj.teacher.id != ObjectId(user_id): 

1469 raise HTTPException( 

1470 status_code=403, detail="Not authorized to restore this class" 

1471 ) 

1472 

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 ) 

1478 

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

1512 

1513 async def class_join(self, class_code: str, request: Request): 

1514 """ 

1515 Join a class using a class code. 

1516 

1517 Args: 

1518 class_code (str): Unique code identifying the class 

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

1520 

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] 

1529 

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

1535 

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

1552 

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 ) 

1561 

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

1567 

1568 async def class_leave_request(self, class_code: str, request: Request): 

1569 """ 

1570 Request to leave a class. 

1571 

1572 Args: 

1573 class_code (str): Unique code identifying the class 

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

1575 

1576 Returns: 

1577 dict: Leave request confirmation message 

1578 """ 

1579 try: 

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

1581 

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

1587 

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."} 

1600 

1601 raise InvalidStudentRequest( 

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

1603 ) 

1604 

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

1609 

1610 async def join_request_cancel(self, class_code: str, request: Request): 

1611 """ 

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

1613 

1614 Args: 

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

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

1617 

1618 Returns: 

1619 dict: Cancellation confirmation message 

1620 

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] 

1630 

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

1636 

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 ) 

1653 

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 ) 

1662 

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

1668 

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. 

1674 

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 

1679 

1680 Returns: 

1681 dict: Acceptance confirmation message 

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

1683 

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 

1691 

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

1715 

1716 class_obj_list = await ClassModel.find( 

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

1718 ).to_list(length=1) 

1719 

1720 if not class_obj_list: 

1721 raise HTTPException( 

1722 status_code=status.HTTP_404_NOT_FOUND, detail="Class not found" 

1723 ) 

1724 

1725 class_obj = class_obj_list[0] 

1726 

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 ) 

1736 

1737 student_found = False 

1738 

1739 for student in class_obj.students: 

1740 if student.id == ObjectId(student_uuid): 

1741 student_found = True 

1742 

1743 if student.status == "Enrolled": 

1744 return InvalidStudentRequest() 

1745 

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

1750 

1751 raise HTTPException( 

1752 status_code=status.HTTP_400_BAD_REQUEST, 

1753 detail=f"Cannot accept student with status: {student.status}", 

1754 ) 

1755 

1756 if not student_found: 

1757 raise HTTPException( 

1758 status_code=status.HTTP_404_NOT_FOUND, 

1759 detail="Student not found in class.", 

1760 ) 

1761 

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 ) 

1782 

1783 async def student_remove(self, request: Request, class_uuid: str, student_id: str): 

1784 """ 

1785 Remove a student from a class. 

1786 

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 

1791 

1792 Returns: 

1793 dict: Removal confirmation message 

1794 

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) 

1818 

1819 if not class_obj: 

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

1821 

1822 class_obj = class_obj[0] 

1823 

1824 if not class_obj.teacher: 

1825 raise HTTPException( 

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

1827 ) 

1828 

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 ) 

1834 

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 } 

1843 

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 } 

1850 

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

1866 

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. 

1876 

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 

1882 

1883 Returns: 

1884 dict: Leave request processing confirmation 

1885 

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

1892 

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) 

1897 

1898 if not class_obj: 

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

1900 

1901 class_obj = class_obj[0] 

1902 

1903 if not class_obj.teacher: 

1904 raise HTTPException( 

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

1906 ) 

1907 

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 ) 

1913 

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."} 

1921 

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 ) 

1933 

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