Coverage for server / routes / teacher / teacher_account.py: 98%

62 statements  

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

1# Python standard library 

2import logging 

3import os 

4from typing import Any 

5 

6# Third-party imports 

7import httpx 

8from dotenv import load_dotenv 

9from fastapi import ( 

10 APIRouter, 

11 Body, 

12 Depends, 

13 File, 

14 HTTPException, 

15 Query, 

16 Request, 

17 UploadFile, 

18 status, 

19) 

20from pydantic import BaseModel, Field 

21 

22# Authentication 

23from server.authentication.auth0_bearer import Auth0Bearer 

24from server.authentication.auth0_config import get_auth0_settings 

25from server.rate_limit import PASSWORD_UPDATE_RATE_LIMIT, limiter 

26from server.services.common.password_update import ( 

27 PasswordUpdateRequest, 

28 update_auth0_password, 

29) 

30 

31# Models 

32from server.models.users import ContactPerson, EducationList, OfficeDetails 

33 

34# Services 

35from server.services.common.auth0_management import auth0_management 

36from server.services.common.users import UsersService 

37from server.services.teacher.teacher_account import TeacherAccountService 

38 

39# Utilities 

40from server.utilities import sample_payloads 

41 

42load_dotenv() 

43 

44logger = logging.getLogger(__name__) 

45 

46router = APIRouter() 

47 

48common_service = UsersService() 

49teacher_account_service = TeacherAccountService() 

50 

51 

52@router.get( 

53 "/find", 

54 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

55 status_code=status.HTTP_200_OK, 

56 description="Search for users by name/email and role. When no query params are provided, returns the current user's account.", 

57 summary="Search for users by name/email and role, or return current user account.", 

58 responses={ 

59 200: {"description": "Successfully retrieved user data"}, 

60 401: {"description": "Unauthorized access"}, 

61 403: {"description": "Insufficient permissions"}, 

62 422: {"description": "Validation error"}, 

63 }, 

64) 

65async def teacher_find( 

66 request: Request, 

67 search: str | None = Query( 

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

69 ), 

70 role: str | None = Query( 

71 None, description="User role to filter by: 'teacher' or 'student'" 

72 ), 

73 page: int | None = Query( 

74 None, description="Page number for pagination, starting from 1" 

75 ), 

76 page_size: int | None = Query( 

77 None, description="Number of results per page (1-100)" 

78 ), 

79) -> dict: 

80 return await common_service.teacher_data_search( 

81 request, search, role, page=page, page_size=page_size 

82 ) 

83 

84 

85@router.post( 

86 "/picture/add", 

87 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

88 status_code=status.HTTP_201_CREATED, 

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

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

91) 

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

93 """ 

94 Add a teacher's profile picture. 

95 

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

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

98 the request will be rejected. 

99 

100 Args: 

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

102 - JWT token in Authorization header 

103 - User authentication details 

104 - Teacher context and permissions 

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

106 - Supported formats: JPG, PNG 

107 - Maximum file size: 10MB 

108 - Will be automatically resized if needed 

109 

110 Returns: 

111 dict: Added profile picture details including: 

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

113 - message (str): Success confirmation 

114 - data (object): Updated user information 

115 

116 Raises: 

117 HTTPException: 

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

119 - 401: If authentication token is invalid 

120 - 403: If user doesn't have teacher permissions 

121 - 413: If file size exceeds limit 

122 - 415: If unsupported media type 

123 - 422: If file upload/processing fails 

124 

125 Notes: 

126 - Images are processed to standardized dimensions 

127 - File names are sanitized and made unique 

128 - Upload progress can be monitored via request events 

129 """ 

130 return await common_service.user_picture_add(request, file) 

131 

132 

133@router.patch( 

134 "/picture/update", 

135 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

136 status_code=status.HTTP_200_OK, 

137 description="As a teacher, I can update my profile picture.", 

138 summary="As a teacher, I can update my profile picture.", 

139) 

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

141 """ 

142 Update teacher's profile picture. 

143 

144 This endpoint allows teachers to upload and update their profile picture. The image file 

145 will be validated, processed, and stored securely. The previous profile picture, if any, 

146 will be automatically deleted. 

147 

148 Args: 

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

150 - JWT token in Authorization header 

151 - User authentication details 

152 - Teacher context and permissions 

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

154 - Supported formats: JPG, PNG 

155 - Maximum file size: 5MB 

156 - Will be automatically resized if needed 

157 

158 Returns: 

159 dict: Updated profile picture details including: 

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

161 - file_name (str): Name of the stored file 

162 - upload_timestamp (str): ISO format timestamp of the update 

163 

164 Raises: 

165 HTTPException: 

166 - 400: If file format or size is invalid 

167 - 401: If authentication token is invalid 

168 - 403: If user doesn't have teacher permissions 

169 - 413: If file size exceeds limit 

170 - 422: If file upload/processing fails 

171 

172 Notes: 

173 - Previous profile pictures are automatically deleted 

174 - Images are processed to standardized dimensions 

175 - File names are sanitized and made unique 

176 - Upload progress can be monitored via request events 

177 """ 

178 return await common_service.user_picture_update(request, file) 

179 

180 

181# TODO: add this to the service ---------------------------------------------------------------------------------- 

182 

183 

184@router.delete( 

185 "/picture/delete", 

186 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

187 status_code=status.HTTP_200_OK, 

188 description=( 

189 "As a teacher, I can delete my profile picture. " 

190 "Optional query parameter `teacherId` may be supplied for explicit caller-side " 

191 "identification; when present it must match the authenticated teacher (otherwise 403). " 

192 "When omitted, the picture deleted is the one owned by the bearer-token user." 

193 ), 

194 summary="As a teacher, I can delete my profile picture.", 

195) 

196async def picture_delete( 

197 request: Request, 

198 teacherId: str | None = Query( 

199 None, 

200 description=( 

201 "Optional teacher UUID. If supplied, must match the authenticated " 

202 "teacher. When omitted, the bearer-token user's picture is deleted." 

203 ), 

204 ), 

205) -> dict: 

206 """Delete the authenticated teacher's profile picture (EI-569). 

207 

208 Path no longer carries `{teacher_uuid}` — frontend was already calling 

209 `/picture/delete` directly, so the path-based variant produced 404. The 

210 deleted picture is always owned by the authenticated teacher (resolved 

211 from the bearer); the optional `teacherId` query param exists for Swagger 

212 documentation parity and explicit caller-side identification. 

213 

214 Returns dict with deletion confirmation; idempotent when no picture exists. 

215 

216 Raises HTTPException: 

217 - 400 if `teacherId` is supplied but is not a valid ObjectId 

218 - 401 if Auth0 token is missing/invalid (handled by Auth0Bearer) 

219 - 403 if `teacherId` is supplied and does not match the authenticated teacher 

220 - 404 if the user account is not found 

221 - 500 if storage deletion fails 

222 """ 

223 return await teacher_account_service.teacher_picture_delete(teacherId, request) 

224 

225 

226@router.patch( 

227 "/education/update", 

228 include_in_schema=False, 

229 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

230 status_code=status.HTTP_200_OK, 

231 description="As a teacher, I can update my educational background information.", 

232 summary="As a teacher, I can update my educational background information.", 

233) 

234async def education_update(request: Request, updated_education: EducationList) -> dict: 

235 """ 

236 Update teacher's educational background information. 

237 

238 This endpoint allows updating a teacher's education history including degrees, 

239 institutions, and graduation years. 

240 

241 Args: 

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

243 updated_education (EducationList): Updated education details containing: 

244 - List of education entries with: 

245 - degree (str): Degree or certification name 

246 - institution (str): Name of educational institution 

247 - year (int): Year of completion 

248 

249 Returns: 

250 dict: Updated education information containing: 

251 - education (list): List of updated education entries 

252 - message (str): Success confirmation 

253 

254 Raises: 

255 HTTPException: 

256 - 401: Invalid or missing authentication token 

257 - 403: Insufficient permissions to update education details 

258 - 404: Teacher ID not found 

259 - 422: Invalid request body format 

260 

261 Example request body: 

262 { 

263 "education": [ 

264 { 

265 "degree": "Master of Education", 

266 "institution": "State University", 

267 "year": 2018 

268 }, 

269 { 

270 "degree": "Bachelor of Science", 

271 "institution": "City College", 

272 "year": 2015 

273 } 

274 ] 

275 } 

276 """ 

277 return await teacher_account_service.education_update(request, updated_education) 

278 

279 

280@router.patch( 

281 "/office/details/update", 

282 include_in_schema=False, 

283 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

284 status_code=status.HTTP_200_OK, 

285 description="As a teacher, I can update my office details including location and office hours.", 

286 summary="As a teacher, I can update my office details including location and office hours.", 

287) 

288async def office_details_update( 

289 request: Request, updated_office_details: OfficeDetails 

290) -> dict: 

291 """ 

292 Update a teacher's office details including location and office hours. 

293 

294 Args: 

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

296 updated_office_details (OfficeDetails): Updated office information containing: 

297 - building (str): Building name or number 

298 - room (str): Room number or identifier 

299 - office_hours (list): List of office hour time slots 

300 id (str, optional): Teacher ID whose office details are being updated 

301 - If None: Updates authenticated teacher's details 

302 - If provided: Updates specified teacher's details (subject to permissions) 

303 

304 Returns: 

305 dict: Updated office information containing: 

306 - office_details (dict): Updated office details 

307 - message (str): Success confirmation 

308 

309 Raises: 

310 HTTPException: 

311 - 401: Invalid or missing authentication token 

312 - 403: Insufficient permissions to update office details 

313 - 404: Teacher ID not found 

314 - 422: Invalid request body format 

315 

316 Example request body: 

317 { 

318 "building": "Main Campus", 

319 "room": "204B", 

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

321 } 

322 """ 

323 return await teacher_account_service.office_details_update( 

324 request, updated_office_details 

325 ) 

326 

327 

328@router.patch( 

329 "/{teacher_uuid:str}/update", 

330 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

331 status_code=status.HTTP_200_OK, 

332 description="As a teacher, I can update my account information.", 

333 summary="As a teacher, I can update my account information.", 

334) 

335async def account_update( 

336 teacher_uuid: str, 

337 request: Request, 

338 updated_teacher: Any = Body( 

339 examples=[sample_payloads.teacher_update_payload], 

340 description="Update teacher's account information", 

341 ), 

342) -> dict: 

343 """ 

344 Update teacher's account information. 

345 

346 This endpoint allows teachers to update their account details such as personal information 

347 and contact details. The update is restricted by authentication and authorization checks. 

348 

349 Args: 

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

351 - JWT token in Authorization header 

352 - User authentication details 

353 - Teacher context and permissions 

354 updated_teacher (Any): Updated teacher information containing fields such as: 

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

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

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

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

359 - And potentially other updatable teacher profile fields 

360 

361 Returns: 

362 dict: Updated account information including: 

363 - message (str): Success confirmation 

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

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

366 

367 Raises: 

368 HTTPException: 

369 - 401: If authentication token is invalid 

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

371 - 404: If teacher ID is not found 

372 - 422: If update data validation fails 

373 

374 Example request body: 

375 { 

376 "first_name": "John", 

377 "middle_name": "Dee", 

378 "last_name": "Doe" 

379 } 

380 """ 

381 return await teacher_account_service.account_update( 

382 teacher_uuid, request, updated_teacher 

383 ) 

384 

385 

386@router.post( 

387 "/password/update", 

388 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

389 status_code=status.HTTP_200_OK, 

390 summary="As a teacher, I can change my password by providing my current password", 

391 description=( 

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

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

394 ), 

395 responses={ 

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

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

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

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

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

401 }, 

402) 

403@limiter.limit(PASSWORD_UPDATE_RATE_LIMIT) 

404async def teacher_password_update( 

405 request: Request, body: PasswordUpdateRequest 

406) -> dict: 

407 user_details = request.state.user_details 

408 await update_auth0_password( 

409 user_details.get("auth0_user_id"), 

410 user_details.get("email"), 

411 body.current_password, 

412 body.new_password, 

413 who="teacher", 

414 ) 

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

416 

417 

418@router.post( 

419 "/password/change", 

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

421 dependencies=[Depends(Auth0Bearer(access_levels=["teacher"]))], 

422 status_code=status.HTTP_200_OK, 

423 summary="As a teacher, I can change my password via Auth0 ticket flow", 

424 description=( 

425 "Generates an Auth0 password-change ticket for the authenticated teacher " 

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

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

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

429 "touch this backend." 

430 ), 

431 responses={ 

432 200: { 

433 "description": "Ticket generated", 

434 "content": { 

435 "application/json": { 

436 "example": { 

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

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

439 } 

440 } 

441 }, 

442 }, 

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

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

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

446 }, 

447) 

448async def teacher_password_change(request: Request) -> dict: 

449 """ 

450 Generate an Auth0 password-change ticket for the authenticated teacher. 

451 

452 Developer: Allan Ninal 

453 """ 

454 user_details = request.state.user_details 

455 auth0_user_id = user_details.get("auth0_user_id") 

456 if not auth0_user_id: 

457 raise HTTPException( 

458 status_code=status.HTTP_401_UNAUTHORIZED, 

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

460 ) 

461 

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

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

464 

465 try: 

466 result = await auth0_management.generate_password_change_ticket( 

467 auth0_user_id=auth0_user_id, 

468 result_url=result_url, 

469 ttl_sec=ttl_sec, 

470 mark_email_as_verified=False, 

471 ) 

472 except Exception as e: 

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

474 raise HTTPException( 

475 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

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

477 ) 

478 

479 return { 

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

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

482 }