Coverage for server / routes / student / student_account.py: 97%

60 statements  

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

1import logging 

2import os 

3from typing import Any 

4 

5import httpx 

6from dotenv import load_dotenv 

7from fastapi import ( 

8 APIRouter, 

9 Body, 

10 Depends, 

11 File, 

12 HTTPException, 

13 Query, 

14 Request, 

15 UploadFile, 

16 status, 

17 BackgroundTasks, 

18) 

19from pydantic import BaseModel, Field 

20from server.authentication.auth0_bearer import Auth0Bearer 

21from server.authentication.auth0_config import get_auth0_settings 

22from server.rate_limit import PASSWORD_UPDATE_RATE_LIMIT, limiter 

23from server.models.users import ( 

24 ContactPerson, 

25 EducationList, 

26 OfficeDetails, 

27 UserBio, 

28) 

29from server.services.common.auth0_management import auth0_management 

30from server.services.common.password_update import ( 

31 PasswordUpdateRequest, 

32 update_auth0_password, 

33) 

34from server.services.common.users import UsersService 

35 

36load_dotenv() 

37 

38logger = logging.getLogger(__name__) 

39 

40router = APIRouter() 

41 

42_MINIO_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000") 

43 

44common_service = UsersService() 

45 

46 

47@router.get( 

48 "/fetch", 

49 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

50 status_code=status.HTTP_200_OK, 

51 description="As a student, I can fetch my account data", 

52 summary="As a student, I can fetch my account data", 

53 responses={ 

54 200: { 

55 "description": "Successfully retrieved user data", 

56 "content": { 

57 "application/json": { 

58 "example": { 

59 "id": "507f1f77bcf86cd799439011", 

60 "email": "user@domain.com", 

61 "role": "teacher", 

62 "first_name": "John", 

63 "last_name": "Doe", 

64 "profile_picture": "https://storage.example.com/profiles/user123.jpg", 

65 # Role-specific fields included based on user type 

66 } 

67 } 

68 }, 

69 }, 

70 401: {"description": "Unauthorized access or invalid token"}, 

71 403: { 

72 "description": "Insufficient permissions to access requested student data" 

73 }, 

74 404: {"description": "Student not found"}, 

75 }, 

76) 

77async def student_account_fetch( 

78 request: Request, 

79 id: str = Query( 

80 default=None, 

81 description="Optional student ID to fetch specific student's data. If not provided, returns authenticated student's data", 

82 ), 

83) -> dict: 

84 """ 

85 Retrieve detailed student profile data with role-based access control. 

86 

87 This endpoint returns student profile information based on the authenticated student's role 

88 and the requested student ID. If no ID is provided, it returns the authenticated student's 

89 data. Teachers and staff can access additional student details based on their permissions. 

90 

91 Authorization Rules: 

92 - Students can only access their own profile data 

93 

94 Args: 

95 request (Request): The incoming request object containing: 

96 - JWT token in Authorization header 

97 - Student context including role and permissions 

98 id (str, optional): Target user ID to fetch data for 

99 - If None: Returns authenticated student's data 

100 - If provided: Returns specified student's data (subject to permissions) 

101 

102 Returns: 

103 dict: User profile information including: 

104 - Basic Info: 

105 - id (str): Unique user identifier 

106 - email (str): Student's email address 

107 - role (str): Student role (teacher/student/staff) 

108 - first_name (str): Student's first name 

109 - last_name (str): student's last name 

110 - profile_picture (str, optional): URL to profile picture 

111 - Role-Specific Info: 

112 For Students: 

113 - grade_level (int): Current grade 

114 - contact_person (dict): Guardian information 

115 For Teachers: 

116 - department (str): Academic department 

117 - office_details (dict): Office location and hours 

118 - education (list): Educational background 

119 

120 Raises: 

121 HTTPException: 

122 - 401: Invalid or missing authentication token 

123 - 403: Insufficient permissions to access requested data 

124 - 404: Requested student not found 

125 - 422: Invalid student ID format 

126 

127 Example Response: 

128 { 

129 "id": "507f1f77bcf86cd799439011", 

130 "email": "student@school.edu", 

131 "role": "student", 

132 "first_name": "John", 

133 "last_name": "Doe", 

134 "profile_picture": "https://storage.example.com/profiles/user123.jpg", 

135 "grade_level": 10, 

136 "contact_person": { 

137 "name": "Jane Doe", 

138 "email": "jane.doe@example.com", 

139 "phone": "123-456-7890" 

140 }, 

141 "office_details": { 

142 "building": "Main", 

143 "room": "204", 

144 "office_hours": ["Mon 10-12", "Wed 14-16"] 

145 } 

146 } 

147 """ 

148 # TODO: add role-based call to the service 

149 return await common_service.user_account_fetch(request, id) 

150 

151 

152@router.get( 

153 "/teacher_find", 

154 status_code=status.HTTP_200_OK, 

155 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

156 description="As a student, I can search for teachers", 

157 summary="As a student, I can search for teachers", 

158) 

159async def student_teacher_find( 

160 request: Request, 

161 search: str | None = Query( 

162 None, description="Search across first name, middle name, and last name" 

163 ), 

164 first_name: str | None = Query(None, description="Filter by first name (legacy)"), 

165 middle_name: str | None = Query(None, description="Filter by middle name (legacy)"), 

166 last_name: str | None = Query(None, description="Filter by last name (legacy)"), 

167 email: str | None = Query(None, description="Filter by email"), 

168 page: int | None = Query(None, description="Page number for pagination"), 

169 page_size: int | None = Query(None, description="Number of results per page"), 

170) -> dict: 

171 """ 

172 This endpoint allows students to search for teachers using a partial name match. 

173 Results are paginated for efficient data transfer and display. 

174 

175 Args: 

176 request (Request): FastAPI request object containing: 

177 - JWT token in Authorization header 

178 - Student authentication context 

179 first_name (str): Search string to match against teacher's first names 

180 - required 

181 - Must be at least 1 character 

182 - Must be at most 25 characters 

183 - Case-insensitive partial matching 

184 middle_name (str): Search string to match against teacher's middle names 

185 - Must be at least 1 character 

186 - Must be at most 25 characters 

187 - Case-insensitive partial matching 

188 last_name (str): Search string to match against teacher's last names 

189 - required 

190 - Must be at least 1 character 

191 - Must be at most 25 characters 

192 - Case-insensitive partial matching 

193 page (int): Page number for pagination, starting from 1 

194 page_size (int): Number of results per page (1-100) 

195 

196 Returns: 

197 dict: Paginated search results containing: 

198 - items (list): List of matching teacher profiles with basic info: 

199 - id (str): Teacher's unique identifier 

200 - first_name (str): Teacher's first name 

201 - middle_name (str): Teacher's middle name 

202 - last_name (str): Teacher's last name 

203 - email (str): Teacher's email address 

204 - department (str): Academic department 

205 - role (str): Teacher's role 

206 - status (str): Teacher's account status 

207 - profile_picture (str): Teacher's photo 

208 - date_registered (str): Teacher's date of registration 

209 - school (str): Teacher's registered school 

210 - date_updated (str): Teacher's account last updated 

211 - classes (list): List of matching teacher's classes with basic info 

212 - id (str): Class unique identifier 

213 - title (str): Class title 

214 - description (str): Class description 

215 - section (str): Class section 

216 - class_code (str): Class code 

217 - schedules (str): Class schedules 

218 - count (int): Total number of items in current page 

219 - total (int): Total number of search results 

220 - page (int): Current page number 

221 - no_of_pages (int): Total number of pages 

222 

223 Raises: 

224 HTTPException: 

225 - 401: Invalid or missing authentication token 

226 - 422: Invalid query parameters 

227 - 500: Search operation failed 

228 

229 Example Response: 

230 { 

231 "data": [ 

232 { 

233 "_id": "65cf623a4ff073c101b15692", 

234 "first_name": "Chuy", 

235 "middle_name": null, 

236 "last_name": "Rizal", 

237 "role": "teacher", 

238 "status": "active", 

239 "email": "teacher@gmail.com", 

240 "profile_picture": f"{_MINIO_URL}/eruditiontx-images/user-images/65cf623a4ff073c101b15692/65cf623a4ff073c101b15692-profilepic.png", 

241 "date_registered": "2024-02-16T13:25:14.311000", 

242 "date_updated": "2024-06-23T10:12:27.296000", 

243 "school": "Unknown" 

244 "classes": [ 

245 { 

246 "_id": "6660821c60082c7a7abc6242", 

247 "title": "Algebra 1", 

248 "description": "", 

249 "section": "Section 1", 

250 "class_code": "nYiI9o", 

251 "schedules": [] 

252 }, 

253 ], 

254 }, 

255 ], 

256 "count": 5, 

257 "total": 5, 

258 "page": 1, 

259 "no_of_pages": 1 

260 } 

261 """ 

262 # teacher_data_search was refactored to (request, search, role): the separate 

263 # name/email filters collapse into one space-joined search string (the service 

264 # ORs each word across first/middle/last/email). 

265 if not search: 

266 search_terms = [ 

267 term for term in (first_name, middle_name, last_name, email) if term 

268 ] 

269 search = " ".join(search_terms) if search_terms else None 

270 # page/page_size are forwarded rather than dropped. They used to be accepted 

271 # and documented here but never reached the service, which pinned every 

272 # response to the first 100 matches. 

273 return await common_service.teacher_data_search( 

274 request, search, "teacher", page=page, page_size=page_size 

275 ) 

276 

277 

278@router.patch( 

279 "/update", 

280 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

281 status_code=status.HTTP_200_OK, 

282 description="As a student, I can update my contact person information", 

283 summary="As a student, I can update my contact person information", 

284) 

285async def student_account_update( 

286 request: Request, 

287 updated_contact_person: ContactPerson, 

288 id: str | None = Query(None, description="Update by Id"), 

289) -> dict: 

290 """ 

291 Update student's contact person information. 

292 

293 This endpoint allows students to update their contact person/guardian information. 

294 The update is restricted by authentication and authorization checks. 

295 

296 Args: 

297 request (Request): The incoming request object containing: 

298 - JWT token in Authorization header 

299 - User authentication details 

300 - Student context and permissions 

301 updated_contact_person (ContactPerson): Updated contact details containing: 

302 - name (str): Contact person's full name 

303 - relationship (str): Relationship to the student 

304 - phone (str): Contact phone number 

305 - email (str): Contact email address 

306 - address (str): Physical address 

307 id (str): Student ID whose information is being updated 

308 - Must match authenticated user's ID unless admin 

309 

310 Returns: 

311 dict: Updated account information including: 

312 - message (str): Success confirmation 

313 - updated_fields (dict): New values for updated fields 

314 - timestamp (str): ISO format timestamp of update 

315 

316 Raises: 

317 HTTPException: 

318 - 401: If authentication token is invalid 

319 - 403: If user lacks permission to update specified account 

320 - 404: If student ID is not found 

321 - 422: If update data validation fails 

322 

323 Example request body: 

324 { 

325 "name": "John Smith", 

326 "relationship": "Parent", 

327 "phone": "+1-555-0123", 

328 "email": "john.smith@email.com", 

329 "address": "123 Main St, City, State 12345" 

330 } 

331 """ 

332 return await common_service.account_update(request, updated_contact_person, id) 

333 

334 

335@router.post( 

336 "/picture/add", 

337 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

338 status_code=status.HTTP_201_CREATED, 

339 description="As a student, I can add a profile picture if I don't have one already.", 

340 summary="As a student, I can add a profile picture if I don't have one already.", 

341) 

342async def profile_picture_add(request: Request, file: UploadFile = File(...)) -> dict: 

343 """ 

344 Add a student's profile picture. 

345 

346 This endpoint allows students to upload a profile picture for the first time. The image file 

347 will be validated, processed, and stored securely. If the student already has a profile picture, 

348 the request will be rejected. 

349 

350 Args: 

351 request (Request): The incoming request object containing: 

352 - JWT token in Authorization header 

353 - User authentication details 

354 - Student context and permissions 

355 file (UploadFile): Image file to use as profile picture 

356 - Supported formats: JPG, PNG 

357 - Maximum file size: 10MB 

358 - Will be automatically resized if needed 

359 

360 Returns: 

361 dict: Added profile picture details including: 

362 - url (str): URL to access the new profile picture 

363 - message (str): Success confirmation 

364 - data (object): Updated user information 

365 

366 Raises: 

367 HTTPException: 

368 - 400: If file format or size is invalid, or user already has a profile picture 

369 - 401: If authentication token is invalid 

370 - 403: If user doesn't have student permissions 

371 - 413: If file size exceeds limit 

372 - 415: If unsupported media type 

373 - 422: If file upload/processing fails 

374 

375 Example response: 

376 { 

377 "message": "Added Profile Picture", 

378 "data": { 

379 "email": "student@example.com", 

380 "profile_picture": "https://storage.example.com/profiles/user123.jpg", 

381 ... 

382 } 

383 } 

384 """ 

385 return await common_service.user_picture_add(request, file) 

386 

387 

388@router.patch( 

389 "/picture/update", 

390 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

391 status_code=status.HTTP_200_OK, 

392 description="As a student, I can update my profile picture", 

393 summary="As a student, I can update my profile picture", 

394) 

395async def profile_picture_update( 

396 request: Request, file: UploadFile = File(...) 

397) -> dict: 

398 """ 

399 Update a student's profile picture. 

400 

401 This endpoint allows students to upload and update their profile picture. The image file 

402 is validated, processed, and stored securely. The previous profile picture, if any, is 

403 automatically deleted. 

404 

405 Args: 

406 request (Request): The incoming request object containing: 

407 - JWT token in Authorization header 

408 - Student authentication details 

409 - User context and permissions 

410 file (UploadFile): Image file to use as new profile picture 

411 - Supported formats: JPG, PNG 

412 - Maximum file size: 5MB 

413 - Will be automatically resized if too large 

414 

415 Returns: 

416 dict: Updated profile information containing: 

417 - url (str): URL of the new profile picture 

418 - message (str): Success confirmation 

419 - timestamp (str): ISO format timestamp of update 

420 

421 Raises: 

422 HTTPException: 

423 - 400: If file format or size is invalid 

424 - 401: If authentication token is invalid 

425 - 403: If user lacks permission to update profile 

426 - 413: If file size exceeds limit 

427 - 422: If file upload fails validation 

428 

429 Example response: 

430 { 

431 "url": "https://storage.example.com/profiles/user123.jpg", 

432 "message": "Profile picture updated successfully", 

433 "timestamp": "2024-01-20T15:30:00Z" 

434 } 

435 """ 

436 return await common_service.user_picture_update(request, file) 

437 

438 

439@router.delete( 

440 "/picture/delete", 

441 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

442 status_code=status.HTTP_200_OK, 

443 description="As a student, I can delete my profile picture", 

444 summary="As a student, I can delete my profile picture", 

445) 

446async def profile_picture_delete(request: Request) -> dict: 

447 """ 

448 Delete a student's profile picture. 

449 

450 This endpoint allows students to remove their current profile picture and revert to the default avatar. 

451 The existing profile picture file is permanently deleted from storage. 

452 

453 Args: 

454 request (Request): The incoming request object containing: 

455 - JWT token in Authorization header 

456 - Student authentication details 

457 - User context and permissions 

458 

459 Returns: 

460 dict: Deletion confirmation containing: 

461 - message (str): Success confirmation 

462 - timestamp (str): ISO format timestamp of deletion 

463 

464 Raises: 

465 HTTPException: 

466 - 401: If authentication token is invalid 

467 - 403: If user lacks permission to delete profile picture 

468 - 404: If no profile picture exists to delete 

469 - 500: If deletion from storage fails 

470 

471 Example response: 

472 { 

473 "message": "Profile picture deleted successfully", 

474 "timestamp": "2024-01-20T15:30:00Z" 

475 } 

476 """ 

477 return await common_service.user_picture_delete(request) 

478 

479 

480@router.post( 

481 "/password/update", 

482 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

483 status_code=status.HTTP_200_OK, 

484 summary="As a student, I can change my password by providing my current password", 

485 description=( 

486 "Verifies the current password via Auth0 Resource Owner Password Grant, " 

487 "then updates the password in Auth0 directly. No redirect is required." 

488 ), 

489 responses={ 

490 200: {"description": "Password updated successfully"}, 

491 400: {"description": "Current password is incorrect"}, 

492 401: {"description": "Token invalid or identity missing"}, 

493 403: {"description": "Insufficient role"}, 

494 500: {"description": "Failed to update password via Auth0 Management API"}, 

495 }, 

496) 

497@limiter.limit(PASSWORD_UPDATE_RATE_LIMIT) 

498async def student_password_update( 

499 request: Request, body: PasswordUpdateRequest 

500) -> dict: 

501 user_details = request.state.user_details 

502 await update_auth0_password( 

503 user_details.get("auth0_user_id"), 

504 user_details.get("email"), 

505 body.current_password, 

506 body.new_password, 

507 who="student", 

508 ) 

509 return {"message": "Password updated successfully."} 

510 

511 

512@router.post( 

513 "/password/change", 

514 include_in_schema=False, # legacy Auth0 ticket/redirect flow; superseded by /password/update 

515 dependencies=[Depends(Auth0Bearer(access_levels=["student"]))], 

516 status_code=status.HTTP_200_OK, 

517 summary="As a student, I can change my password via Auth0 ticket flow", 

518 description=( 

519 "Generates an Auth0 password-change ticket for the authenticated student " 

520 "and returns the ticket URL. The frontend should redirect the browser to " 

521 "this URL — Auth0's hosted page takes over, the user sets their new " 

522 "password, and Auth0 redirects back to the login page. No passwords " 

523 "touch this backend." 

524 ), 

525 responses={ 

526 200: { 

527 "description": "Ticket generated", 

528 "content": { 

529 "application/json": { 

530 "example": { 

531 "ticket_url": "https://eruditiontxdev.us.auth0.com/lo/reset?ticket=abc...", 

532 "ticket_expires_at": "2026-04-22T12:00:00+00:00", 

533 } 

534 } 

535 }, 

536 }, 

537 401: {"description": "Unauthenticated or invalid token"}, 

538 403: {"description": "Insufficient role"}, 

539 500: {"description": "Failed to generate ticket via Auth0 Management API"}, 

540 }, 

541) 

542async def student_password_change(request: Request) -> dict: 

543 """ 

544 Generate an Auth0 password-change ticket for the authenticated student. 

545 

546 Developer: Allan Ninal 

547 """ 

548 user_details = request.state.user_details 

549 auth0_user_id = user_details.get("auth0_user_id") 

550 if not auth0_user_id: 

551 raise HTTPException( 

552 status_code=status.HTTP_401_UNAUTHORIZED, 

553 detail="Could not determine Auth0 user id from token.", 

554 ) 

555 

556 result_url = f"{os.getenv('APP_URL', 'http://localhost:3000').rstrip('/')}/login" 

557 ttl_sec = int(os.getenv("INVITATION_TTL_SECONDS", "432000")) 

558 

559 try: 

560 result = await auth0_management.generate_password_change_ticket( 

561 auth0_user_id=auth0_user_id, 

562 result_url=result_url, 

563 ttl_sec=ttl_sec, 

564 mark_email_as_verified=False, 

565 ) 

566 except Exception as e: 

567 logger.error(f"Failed to generate student password-change ticket: {e}") 

568 raise HTTPException( 

569 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

570 detail="Failed to initiate password change. Please try again later.", 

571 ) 

572 

573 return { 

574 "ticket_url": result["ticket"], 

575 "ticket_expires_at": result["expires_at"], 

576 }