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
« 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
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
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)
31# Models
32from server.models.users import ContactPerson, EducationList, OfficeDetails
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
39# Utilities
40from server.utilities import sample_payloads
42load_dotenv()
44logger = logging.getLogger(__name__)
46router = APIRouter()
48common_service = UsersService()
49teacher_account_service = TeacherAccountService()
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 )
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.
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.
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
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
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
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)
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.
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.
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
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
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
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)
181# TODO: add this to the service ----------------------------------------------------------------------------------
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).
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.
214 Returns dict with deletion confirmation; idempotent when no picture exists.
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)
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.
238 This endpoint allows updating a teacher's education history including degrees,
239 institutions, and graduation years.
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
249 Returns:
250 dict: Updated education information containing:
251 - education (list): List of updated education entries
252 - message (str): Success confirmation
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
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)
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.
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)
304 Returns:
305 dict: Updated office information containing:
306 - office_details (dict): Updated office details
307 - message (str): Success confirmation
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
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 )
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.
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.
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
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
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
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 )
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."}
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.
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 )
462 result_url = f"{os.getenv('APP_URL', 'http://localhost:3000').rstrip('/')}/login"
463 ttl_sec = int(os.getenv("INVITATION_TTL_SECONDS", "432000"))
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 )
479 return {
480 "ticket_url": result["ticket"],
481 "ticket_expires_at": result["expires_at"],
482 }