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
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1import logging
2import os
3from typing import Any
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
36load_dotenv()
38logger = logging.getLogger(__name__)
40router = APIRouter()
42_MINIO_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
44common_service = UsersService()
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.
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.
91 Authorization Rules:
92 - Students can only access their own profile data
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)
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
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
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)
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.
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)
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
223 Raises:
224 HTTPException:
225 - 401: Invalid or missing authentication token
226 - 422: Invalid query parameters
227 - 500: Search operation failed
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 )
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.
293 This endpoint allows students to update their contact person/guardian information.
294 The update is restricted by authentication and authorization checks.
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
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
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
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)
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.
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.
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
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
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
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)
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.
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.
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
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
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
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)
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.
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.
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
459 Returns:
460 dict: Deletion confirmation containing:
461 - message (str): Success confirmation
462 - timestamp (str): ISO format timestamp of deletion
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
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)
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."}
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.
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 )
556 result_url = f"{os.getenv('APP_URL', 'http://localhost:3000').rstrip('/')}/login"
557 ttl_sec = int(os.getenv("INVITATION_TTL_SECONDS", "432000"))
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 )
573 return {
574 "ticket_url": result["ticket"],
575 "ticket_expires_at": result["expires_at"],
576 }