Coverage for server / services / common / users.py: 90%
317 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 re
5from pydantic import ValidationError
6from beanie import init_beanie
7from bson import ObjectId
8from bson.errors import InvalidId
9from fastapi import (
10 File,
11 HTTPException,
12 Query,
13 Request,
14 UploadFile,
15 status,
16)
17from motor.motor_asyncio import AsyncIOMotorClient
19# Local Application Imports
20from server.connection.database import db, settings, staff_admin_db
21from server.connection.storage_bucket import (
22 MINIO_PUBLIC_URL,
23 MINIO_BUCKET,
24 MINIO_PRIVATE_BUCKET,
25 s3,
26 profile_picture_url,
27)
28from server.models.account import Account, AccountResponseModel
29from server.models.clients import School, SchoolResponseSchema
30from server.models.users import (
31 ContactPerson,
32 StudentResponseModel,
33 TeacherResponseModel,
34 User,
35)
36from server.utilities.user_id_helper import (
37 caller_owns,
38 resolve_user_query,
39 to_user_id,
40 user_id_from_request,
41)
42from server.validators.question_request_root_validators import validate_file_size_type
43from server.utilities.image_processing import compress_profile_picture
44from server.models.classes import ClassModel
45from server.utilities.error_detail import safe_detail
48class UsersService:
49 """
50 Service class handling user-related operations including authentication, registration,
51 profile management, and password operations.
53 This class provides comprehensive user management functionality including:
54 - User authentication and JWT token generation
55 - User registration and account activation
56 - Password management (change, reset, forgot password flows)
57 - Profile management (update contact info, education details, office details)
58 - Profile picture management
60 Attributes:
61 _db_initialized: Track database initialization status
63 Dependencies:
64 - MongoDB database connection
65 - MinIO for file storage
66 - Email service for password reset functionality
67 """
69 def __init__(self):
70 """Initialize UsersService with database connection tracking."""
71 self._db_initialized = False
73 async def ensure_db_initialized(self):
74 """
75 Ensures database connection is initialized before performing operations.
77 This method prevents multiple database initializations and ensures
78 the connection is available when needed.
79 """
80 if not self._db_initialized:
81 await self.init_db()
82 self._db_initialized = True
84 async def init_db(self):
85 """
86 Initialize database connection and Beanie ODM models.
88 Establishes connection to MongoDB and initializes document models for:
89 - User accounts
90 - Password reset requests
91 - Activation tokens
92 - School information
94 Raises:
95 ValueError: If MONGODB_URL environment variable is not set
96 Exception: For any database connection or initialization errors
97 """
98 try:
99 # Create motor client
100 if not settings.MONGODB_URL:
101 raise ValueError("MONGODB_URL environment variable is not set")
103 client = AsyncIOMotorClient(settings.MONGODB_URL)
105 # Initialize beanie with the document models
106 await init_beanie(
107 database=client[settings.DB_NAME],
108 document_models=[
109 User,
110 Account,
111 School,
112 # Add any other document models you're using
113 ],
114 )
116 except Exception:
117 # A bare re-raise already preserves the original exception and its
118 # traceback; binding it to a name added nothing.
119 raise
121 # Credential fields that must never leave the API, whichever path built the
122 # payload. Added by Allan Ninal 2026-08-16 — GET /v1/student/account/fetch and
123 # GET /v1/teacher/account/find were BOTH returning `password`. On QA the value
124 # is null (Auth0 owns passwords post-migration), but the field is serialized
125 # and DOES carry a real bcrypt hash for any account that still has one, which
126 # is reproducible in the test database. EI-1134 lists "password should not be
127 # in response" as an explicit validation point.
128 _NEVER_SERIALIZE = ("password", "repeat_password")
130 @classmethod
131 def _strip_credentials(cls, account_data: dict) -> dict:
132 """Drop credential fields from an outgoing account payload."""
133 for field in cls._NEVER_SERIALIZE:
134 account_data.pop(field, None)
135 return account_data
137 @staticmethod
138 def _serialize_raw_document(raw: dict) -> dict:
139 """Convert a raw MongoDB document into JSON-safe account data."""
140 account_data = dict(raw)
141 account_data["id"] = str(account_data.pop("_id"))
142 for key, value in list(account_data.items()):
143 if isinstance(value, ObjectId):
144 account_data[key] = str(value)
145 return account_data
147 async def _fetch_account_document(
148 self,
149 user_id_str: str,
150 role: str,
151 ) -> dict | None:
152 """Load account data, falling back to raw Mongo for legacy documents."""
153 query = resolve_user_query(user_id_str)
154 model = Account if role == "staff" else User
156 try:
157 account = await model.find_one(query)
158 if account is not None:
159 account_data = account.model_dump(mode="json")
160 account_data["id"] = str(account.id)
161 return self._strip_credentials(account_data)
162 except ValidationError:
163 pass
164 except (ValueError, InvalidId):
165 raise
167 try:
168 raw = await model.get_pymongo_collection().find_one(query)
169 except Exception:
170 return None
171 if not raw:
172 return None
173 return self._strip_credentials(self._serialize_raw_document(raw))
175 async def _resolve_school_name(self, school_id) -> str | None:
176 """Resolve school display name from admin DB, then teacher/student DB."""
177 school_id_str = str(school_id)
178 if not ObjectId.is_valid(school_id_str):
179 return None
181 school_oid = ObjectId(school_id_str)
182 if staff_admin_db is not None:
183 school_doc = await staff_admin_db["school_collection"].find_one(
184 {"_id": school_oid},
185 {"school_name": 1},
186 )
187 if school_doc and school_doc.get("school_name"):
188 return school_doc["school_name"]
190 # Read only the name: projecting the whole SchoolResponseSchema failed
191 # validation (500 on the profile fetch) for a school stored without the
192 # optional ``street``. Allan Ninal, 2026-10-03.
193 school_doc = await School.get_pymongo_collection().find_one(
194 {"_id": school_oid}, {"school_name": 1}
195 )
196 return school_doc.get("school_name") if school_doc else None
198 async def user_account_fetch(self, request: Request, id: str | None = None) -> dict:
199 """
200 Retrieve detailed user data including associated school information (if applicable).
202 This method implements a comprehensive user data retrieval system that:
203 1. Handles multiple user roles (student, teacher, staff)
204 2. Provides role-specific data projections
205 3. Includes associated school information when relevant
206 4. Implements security measures for data access
208 Technical Details:
209 - Uses MongoDB's find_one with role-specific projections
210 - Handles ObjectId validation and conversion
211 - Implements proper error handling with detailed messages
212 - Supports both self-lookup and admin-initiated lookups
214 Args:
215 request (Request): FastAPI request object containing:
216 - state.user_details.uuid: Authenticated user's ID
217 - state.user_details.role: User's role (student/teacher/staff)
218 id (str | None): Optional target user ID. If None, returns authenticated user's data.
220 Returns:
221 dict: Structured user profile containing:
222 data: {
223 id: str # User's unique identifier
224 email: str # User's email address
225 first_name: str # User's first name
226 last_name: str # User's last name
227 role: str # User's role (student/teacher/staff)
228 school: dict | None # School details if applicable
229 [role-specific fields] # Additional fields based on user role
230 }
232 Raises:
233 HTTPException:
234 - 404 NOT_FOUND:
235 - User account not found
236 - Associated school not found
237 - 400 BAD_REQUEST:
238 - Invalid user ID format
239 - Invalid school ID format
240 - 500 INTERNAL_SERVER_ERROR:
241 - Database connection issues
242 - Unexpected server errors
244 Example:
245 ```python
246 # Self lookup
247 user_data = await users_service.get_user_data(request)
249 # Admin lookup of specific user
250 user_data = await users_service.get_user_data(request, id="user123")
251 ```
252 """
253 try:
254 # Initialize database connection if not already established
255 await self.ensure_db_initialized()
257 # Determine if this is a self-lookup or admin lookup of another user
258 user_id_str = user_id_from_request(request, id)
259 if not user_id_str:
260 raise HTTPException(
261 status_code=status.HTTP_400_BAD_REQUEST,
262 detail="Invalid user ID format",
263 )
265 role = request.state.user_details["role"]
267 # EI-SEC-001. This check existed, but was gated on
268 # `role == "teacher"`, so a student caller skipped it entirely and
269 # `?id=<anyone>` returned that user's account. The guard was
270 # written; it was just applied to one role. No role in this API may
271 # read another user's account — admin and staff are a separate
272 # service — so it applies to every caller now.
273 #
274 # 404, not the 403 the teacher branch used to raise: a caller who is
275 # not the owner should not learn whether the account exists. It also
276 # keeps this identical to the unknown-id response the Zephyr
277 # coverage already pins (EI-T654, EI-T655), so nothing moves
278 # downstream.
279 if not caller_owns(request, user_id_str):
280 raise HTTPException(
281 status_code=status.HTTP_404_NOT_FOUND,
282 detail="Account not found",
283 )
285 # Fetch user account — use base User model to avoid strict projection
286 # validation failures on legacy documents (e.g. malformed school_id).
287 try:
288 account_data = await self._fetch_account_document(user_id_str, role)
289 except (ValueError, InvalidId) as exc:
290 raise HTTPException(
291 status_code=status.HTTP_400_BAD_REQUEST,
292 detail="Invalid user ID format",
293 ) from exc
295 if not account_data:
296 raise HTTPException(
297 status_code=status.HTTP_404_NOT_FOUND,
298 detail="Account not found",
299 )
301 if "client_id" in account_data and account_data["client_id"] is None:
302 del account_data["client_id"]
304 profile_pic = account_data.get("profile_picture")
305 if profile_pic and "digitaloceanspaces.com" in profile_pic:
306 account_data["profile_picture"] = None
307 account_data["profile_picture"] = profile_picture_url(
308 account_data.get("profile_picture")
309 )
311 school_name = account_data.get("school")
312 school_id = account_data.get("school_id")
314 if school_name and isinstance(school_name, str):
315 account_data.pop("school_id", None)
316 elif school_id:
317 account_data.pop("school_id", None)
318 account_data["school"] = await self._resolve_school_name(school_id)
319 else:
320 account_data.pop("school_id", None)
321 account_data.setdefault("school", None)
323 return {"data": account_data}
325 except HTTPException as http_err:
326 raise http_err
327 except Exception as e:
328 # `from e` keeps the original cause on the traceback. Without it the
329 # underlying failure was discarded entirely — the client got a
330 # generic 500 and the logs showed nothing about why.
331 raise HTTPException(
332 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
333 detail="An unexpected error occurred while fetching user data",
334 ) from e
336 # The route documents "page starting from 1" and "page_size (1-100)". These
337 # bound that contract in one place so an out-of-range value is clamped to a
338 # documented default rather than reaching MongoDB, where a negative skip is
339 # a BSON error and a zero page_size is a division by zero.
340 DEFAULT_PAGE_SIZE = 100
341 MAX_PAGE_SIZE = 100
343 @classmethod
344 def _clamp_pagination(
345 cls, page: int | None, page_size: int | None
346 ) -> tuple[int, int]:
347 """Coerce page/page_size into the documented range.
349 Out-of-range values are clamped, not rejected: this is a search listing,
350 and a student who sends ``page=0`` wants the first page, not a 422. The
351 cap also means no caller can ask for an unbounded result set.
352 """
353 safe_page = page if page is not None and page >= 1 else 1
354 if page_size is None or page_size < 1:
355 safe_page_size = cls.DEFAULT_PAGE_SIZE
356 else:
357 safe_page_size = min(page_size, cls.MAX_PAGE_SIZE)
358 return safe_page, safe_page_size
360 async def teacher_data_search(
361 self,
362 request: Request,
363 search: str | None = None,
364 role: str | None = None,
365 page: int | None = None,
366 page_size: int | None = None,
367 ) -> dict:
368 try:
369 user_id = to_user_id(request.state.user_details["uuid"])
371 if not user_id:
372 raise HTTPException(
373 status_code=status.HTTP_401_UNAUTHORIZED,
374 detail="User details not found in request state.",
375 )
377 if search is None and role is None:
378 return await self.user_account_fetch(request)
380 role = role or "teacher"
381 # Previously hardcoded to 1/100, which silently discarded the page
382 # and page_size the routes accept and document — every request
383 # returned the first 100 matches, so nothing beyond them was
384 # reachable at all.
385 page, page_size = self._clamp_pagination(page, page_size)
387 if role.lower() not in ["teacher", "student"]:
388 raise HTTPException(
389 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
390 detail="Role must be either 'teacher' or 'student'",
391 )
393 if role.lower() == "student":
394 teacher_classes = await ClassModel.find(
395 {"teacher._id": user_id}
396 ).to_list()
398 student_ids = set()
399 for class_doc in teacher_classes:
400 for student in class_doc.students:
401 student_ids.add(student.id)
403 if not student_ids:
404 return {
405 "data": [],
406 "count": 0,
407 "total": 0,
408 "page": page,
409 "no_of_pages": 0,
410 }
412 filters = {"role": role.lower(), "_id": {"$in": list(student_ids)}}
413 else:
414 filters = {
415 "role": role.lower(),
416 "_id": {"$ne": user_id},
417 }
419 search_conditions = []
421 if search:
422 # Each word is matched as literal text (re.escape): a "(" in a
423 # search was an invalid pattern (500), and an arbitrary pattern
424 # ran against the whole user collection. Allan Ninal, 2026-10-03.
425 for word in (re.escape(w) for w in search.strip().split()):
426 search_conditions.append(
427 {
428 "$or": [
429 {"first_name": {"$regex": word, "$options": "i"}},
430 {"middle_name": {"$regex": word, "$options": "i"}},
431 {"last_name": {"$regex": word, "$options": "i"}},
432 {"email": {"$regex": word, "$options": "i"}},
433 ]
434 }
435 )
437 # Add search conditions to filters if any exist
438 if search_conditions:
439 filters["$and"] = search_conditions
441 # Use direct collection access for search to bypass model validation
442 collection = db["user_collection"]
444 # Count total matches
445 total_count = await collection.count_documents(filters)
447 if total_count == 0:
448 # A search that matches nothing is a SUCCESSFUL search that
449 # found nothing. 404 says the endpoint does not exist; it is
450 # not the answer to "no teacher is called that" (ADR-002 #1,
451 # Zalando #148, AIP-158).
452 #
453 # The sibling branch eleven lines above already returns this
454 # exact envelope when a teacher's student list is empty — the
455 # two halves of ONE function disagreed, which is how this
456 # survived.
457 return {
458 "data": [],
459 "count": 0,
460 "total": 0,
461 "page": page,
462 "no_of_pages": 0,
463 }
465 # Paginate results
466 skip = (page - 1) * page_size
468 # PAGING AN UNSORTED QUERY IS NOT PAGING. MongoDB gives no
469 # ordering guarantee without an explicit sort, so skip/limit over
470 # natural order can hand the same teacher back on two pages, or
471 # skip one entirely, with no concurrent writes involved at all.
472 #
473 # This was latent until the page/page_size parameters started
474 # working: before they were forwarded, every caller got page 1 and
475 # the instability had nowhere to show. _id is unique and immutable,
476 # which is exactly what a paging sort needs.
477 cursor = collection.find(filters).sort("_id", 1).skip(skip).limit(page_size)
478 users_data = await cursor.to_list(length=page_size)
480 num_pages = (total_count + page_size - 1) // page_size
482 user_data = []
483 for user_doc in users_data:
484 # Convert MongoDB document to dictionary
485 user_dict = {
486 "_id": str(user_doc["_id"]),
487 "first_name": user_doc.get("first_name", ""),
488 "middle_name": user_doc.get("middle_name", ""),
489 "last_name": user_doc.get("last_name", ""),
490 "role": user_doc.get("role", ""),
491 "status": user_doc.get("status", ""),
492 "email": user_doc.get("email", ""),
493 "profile_picture": profile_picture_url(
494 pic
495 if (pic := user_doc.get("profile_picture"))
496 and "digitaloceanspaces.com" not in pic
497 else None
498 ),
499 "date_registered": user_doc.get("created_at", ""),
500 "date_updated": user_doc.get("updated_at", ""),
501 }
503 # Handle classes - Get class information for this user
504 if role.lower() == "student":
505 class_filter = {"students._id": ObjectId(user_dict["_id"])}
506 elif role.lower() == "teacher":
507 class_filter = {"teacher._id": ObjectId(user_dict["_id"])}
508 else:
509 class_filter = None
511 classes = []
512 if class_filter:
513 class_collection = db["class_collection"]
514 class_docs = await class_collection.find(class_filter).to_list(
515 length=None
516 )
517 classes = [
518 {
519 "_id": str(class_doc["_id"]),
520 "title": class_doc.get("title", ""),
521 "description": class_doc.get("description", ""),
522 "section": class_doc.get("section", ""),
523 "class_code": class_doc.get("class_code", ""),
524 "schedules": class_doc.get("schedules", []),
525 }
526 for class_doc in class_docs
527 ]
529 # Get school information
530 school_name = "Unknown"
531 if "school_id" in user_doc and user_doc["school_id"]:
532 school = await db["school_collection"].find_one(
533 {"_id": user_doc["school_id"]}
534 )
535 if school:
536 school_name = school["school_name"]
538 user_dict["classes"] = classes
539 user_dict["school"] = school_name
540 user_data.append(user_dict)
542 response = {
543 "data": user_data,
544 "count": len(user_data),
545 "total": total_count,
546 "page": page,
547 "no_of_pages": num_pages,
548 }
549 return response
551 except HTTPException as e:
552 raise e
553 except Exception as e:
554 raise HTTPException(
555 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
556 detail=safe_detail(e),
557 )
559 async def account_update(
560 self,
561 request: Request,
562 updated_contact_person: ContactPerson,
563 id: str | None = None,
564 ):
565 """
566 Update contact person information for a student account.
568 Validates:
569 1. User has student role
570 2. User exists in system
571 3. Authorization to update specified account
573 Args:
574 request (Request): FastAPI request object containing user authentication
575 updated_contact_person (ContactPerson): Updated contact information
576 id (str): User ID to update
578 Returns:
579 dict: Response containing:
580 - message: Success confirmation
581 - updated_user_account: Updated user data (excluding password)
583 Raises:
584 HTTPException:
585 - 400: User not student role or invalid data
586 - 404: User not found
587 - 500: Database update errors
588 """
590 # EI-SEC-002. `id` came straight from the query string and was used as
591 # the write target, so any student could overwrite any other student's
592 # contact_person — guardian name, email and phone — with `?id=<victim>`.
593 # The two role checks below read as authorization but constrain the
594 # CLASS of victim, never the identity: they assert the caller is a
595 # student and the target is a student, both of which a student attacking
596 # another student satisfies.
597 #
598 # This method's own docstring already promised the missing rule:
599 # "3. Authorization to update specified account".
600 #
601 # 404 with the same detail as the not-found branch below, so a non-owned
602 # id is indistinguishable from an absent one and the Zephyr case pinning
603 # that response (EI-T659) is unaffected.
604 if id is not None and not caller_owns(request, id):
605 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
607 user_id = to_user_id(id or request.state.user_details["uuid"])
608 role = request.state.user_details["role"]
610 if role != "student":
611 raise HTTPException(
612 status.HTTP_400_BAD_REQUEST, detail="User must be a student"
613 )
615 try:
616 fetched_account = await User.get(user_id)
617 if fetched_account:
618 if fetched_account.role != "student":
619 raise HTTPException(
620 status.HTTP_400_BAD_REQUEST, detail="User must be a student"
621 )
623 await fetched_account.update(
624 {
625 "$set": {
626 "contact_person": updated_contact_person.model_dump(),
627 }
628 },
629 )
630 # Build a JSON-safe response: model_dump(mode="json") stringifies
631 # ObjectId/datetime fields, and we override the two values that
632 # would otherwise be non-serializable (the ObjectId id and the raw
633 # ContactPerson), which previously 500'd during response encoding.
634 account = fetched_account.model_dump(mode="json")
635 account["id"] = str(user_id)
636 account["contact_person"] = updated_contact_person.model_dump()
637 account.pop("password", None)
638 return {
639 "message": "Successfully updated contact person",
640 "updated_user_account": account,
641 }
643 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
644 except HTTPException:
645 # Let deliberate HTTP errors through; without this the method's own
646 # 400/403/404 was swallowed by the catch-all and re-thrown as a 500,
647 # which the frontend renders as a maintenance dialog.
648 raise
649 except Exception as e:
650 if str(e) == "404":
651 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
653 if "Id must be of type PydanticObjectId" in str(e):
654 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found")
656 raise HTTPException(
657 status.HTTP_400_BAD_REQUEST, detail="An error occured: " + str(e)
658 )
660 async def user_picture_update(self, request: Request, file: UploadFile = File(...)):
661 """
662 Upload and update user profile picture.
664 Process flow:
665 1. Validates file size and type
666 2. Checks if user already has a profile picture to update
667 3. Uploads file to cloud storage (MinIO)
668 4. Updates user profile with CDN URL
669 5. Handles existing profile picture cleanup
671 Args:
672 request (Request): FastAPI request object containing:
673 - JWT token in Authorization header
674 - User authentication details
675 - User context and permissions
676 file (UploadFile): Image file to upload
677 - Supported formats: JPG, PNG
678 - Maximum file size: 10MB
679 - Will be automatically validated
681 Returns:
682 dict: Response containing:
683 - message: Success confirmation
684 - data: Updated user account information including profile picture URL
686 Raises:
687 HTTPException:
688 - 400: Invalid file format or size, or user doesn't have a profile picture to update
689 - 404: User account not found
690 - 413: File size exceeds limit
691 - 415: Unsupported media type
692 - 500: File upload or database update errors
693 """
694 try:
695 # Get user details
696 user_id = to_user_id(request.state.user_details["uuid"])
698 # Save original file position and validate
699 file_obj = file.file
700 file_obj.seek(0) # Ensure we're at the start of the file
701 validate_file_size_type(file_obj)
702 file_obj.seek(0) # Reset position for upload
704 # Get user account to check for existing profile picture
705 fetched_account = await User.get(user_id)
706 if not fetched_account:
707 raise HTTPException(
708 status_code=status.HTTP_404_NOT_FOUND,
709 detail="User account not found",
710 )
712 # Check if user has a profile picture to update
713 old_picture_url = getattr(fetched_account, "profile_picture", None)
714 if not old_picture_url:
715 raise HTTPException(
716 status_code=status.HTTP_400_BAD_REQUEST,
717 detail="User doesn't have a profile picture to update. Use the add endpoint instead.",
718 )
720 # Compress to a fixed size/format regardless of the uploaded image's
721 # original dimensions, so storage size stays predictable.
722 compressed_file = compress_profile_picture(file_obj)
724 # Generate filenames and paths (always .jpg — compression output is fixed JPEG)
725 file_name = f"{user_id}-profilepic.jpg"
726 storage_dir = f"user-images/{user_id}/{file_name}"
728 # Upload the new object FIRST. The storage key is deterministic per user
729 # ({user_id}-profilepic.jpg), so this overwrites the current object in
730 # place. The previous code deleted the old object before uploading — since
731 # the key is the same, that only opened a window where an interruption
732 # left the account pointing at a file that had already been removed.
733 s3.upload_fileobj(
734 compressed_file,
735 MINIO_PRIVATE_BUCKET,
736 storage_dir,
737 ExtraArgs={"ContentType": "image/jpeg"},
738 )
740 # Store the private object KEY; a presigned URL is minted at read time.
741 cdn_url = storage_dir
743 # Update user profile. If the write fails after the upload, the new object
744 # would be orphaned only when it sits under a DIFFERENT key than the one the
745 # account already references (a legacy filename) — in that case roll it back.
746 # When the key is unchanged (the common case) the account still points at the
747 # same key, so there is nothing to clean up and nothing is lost.
748 try:
749 account = await fetched_account.update(
750 {
751 "$set": {
752 "profile_picture": cdn_url,
753 }
754 },
755 )
756 except Exception:
757 old_key_on_fail = (
758 old_picture_url[old_picture_url.index("user-images/") :]
759 if old_picture_url and "user-images/" in old_picture_url
760 else None
761 )
762 if old_key_on_fail != storage_dir:
763 try:
764 s3.delete_object(Bucket=MINIO_PRIVATE_BUCKET, Key=storage_dir)
765 except Exception as cleanup_err:
766 logging.error(
767 f"Failed to roll back orphaned profile picture {storage_dir}: {cleanup_err}"
768 )
769 raise
771 # Only AFTER the DB commit, clean up a LEGACY object stored under a
772 # different key (older uploads used a different filename). Never delete the
773 # key we just wrote, and never delete before the reference is persisted.
774 if old_picture_url and "user-images/" in old_picture_url:
775 old_key = old_picture_url[old_picture_url.index("user-images/") :]
776 if old_key != storage_dir:
777 try:
778 s3.delete_object(Bucket=MINIO_PRIVATE_BUCKET, Key=old_key)
779 except Exception as e:
780 logging.error(
781 f"Failed to delete legacy profile picture {old_key}: {str(e)}"
782 )
784 del account.password
785 account.profile_picture = profile_picture_url(account.profile_picture)
786 return {"message": "Updated Profile Picture", "data": account}
788 except HTTPException as e:
789 # Re-raise HTTP exceptions as-is
790 raise e
791 except Exception as e:
792 # Log the detailed error
793 logging.error(f"Profile picture update failed: {str(e)}", exc_info=True)
795 # Return a user-friendly error
796 raise HTTPException(
797 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
798 detail="Failed to update profile picture. Please try again later.",
799 )
800 finally:
801 # Ensure file is closed properly
802 if hasattr(file, "file"):
803 file.file.close()
805 async def user_picture_delete(self, request: Request):
806 """
807 Remove user's profile picture and clean up storage.
809 Process flow:
810 1. Retrieves user account from database
811 2. Identifies profile picture location in storage
812 3. Deletes image from cloud storage
813 4. Updates user profile to remove picture reference
814 5. Handles error cases gracefully
816 Args:
817 request (Request): FastAPI request object containing:
818 - JWT token in Authorization header
819 - User authentication details
820 - User context and permissions
822 Returns:
823 dict: Response containing:
824 - message: Deletion confirmation
825 - data: Updated user account information with profile_picture set to None
827 Raises:
828 HTTPException:
829 - 404: User account not found
830 - 500: Storage deletion or database update errors
831 """
832 try:
833 # Get user details
834 user_id = to_user_id(request.state.user_details["uuid"])
836 # Get user account
837 fetched_account = await User.get(user_id)
838 if not fetched_account:
839 raise HTTPException(
840 status_code=status.HTTP_404_NOT_FOUND,
841 detail="User account not found",
842 )
844 # If there's no profile picture, return early
845 old_picture_url = getattr(fetched_account, "profile_picture", None)
846 if not old_picture_url:
847 return {
848 "message": "No profile picture to delete",
849 "data": fetched_account,
850 }
852 # Generate storage path for deletion
853 file_name = f"{user_id}-profilepic" # Base filename without extension
854 # Try to extract the extension from the URL if possible
855 if old_picture_url and "." in old_picture_url.split("/")[-1]:
856 file_name = old_picture_url.split("/")[-1]
857 else:
858 # Fall back to default extensions
859 file_name = f"{file_name}.png" # Default to png if we can't determine
861 storage_dir = f"user-images/{user_id}/{file_name}"
863 # If the stored value references a user-images object, use its exact key
864 if old_picture_url and "user-images/" in old_picture_url:
865 storage_dir = old_picture_url[old_picture_url.index("user-images/") :]
867 # Delete the file from the private bucket
868 s3.delete_object(Bucket=MINIO_PRIVATE_BUCKET, Key=storage_dir)
870 # Update user profile
871 account = await fetched_account.update(
872 {
873 "$set": {
874 "profile_picture": None,
875 }
876 },
877 )
878 del account.password
879 return {"message": "Deleted Profile Picture", "data": account}
880 except HTTPException as e:
881 # Re-raise HTTP exceptions
882 raise e
883 except Exception as e:
884 # Log the error
885 logging.error(f"Profile picture deletion failed: {str(e)}", exc_info=True)
886 raise HTTPException(
887 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
888 detail="Failed to delete profile picture. Please try again later.",
889 )
891 async def user_picture_add(self, request: Request, file: UploadFile = File(...)):
892 """
893 Add a new profile picture for a user who doesn't have one.
895 Process flow:
896 1. Validates file size and type
897 2. Checks if user already has a profile picture
898 3. Uploads file to cloud storage (MinIO)
899 4. Updates user profile with CDN URL
901 Args:
902 request (Request): FastAPI request object containing:
903 - JWT token in Authorization header
904 - User authentication details
905 - User context and permissions
906 file (UploadFile): Image file to upload
907 - Supported formats: JPG, PNG
908 - Maximum file size: 10MB
909 - Will be automatically validated
911 Returns:
912 dict: Response containing:
913 - message: Success confirmation
914 - data: Updated user account information including profile picture URL
916 Raises:
917 HTTPException:
918 - 400: Invalid file format or size, or user already has a profile picture
919 - 404: User account not found
920 - 413: File size exceeds limit
921 - 415: Unsupported media type
922 - 500: File upload or database update errors
923 """
924 try:
925 # Get user details
926 user_id = to_user_id(request.state.user_details["uuid"])
928 # Save original file position and validate
929 file_obj = file.file
930 file_obj.seek(0) # Ensure we're at the start of the file
931 validate_file_size_type(file_obj)
932 file_obj.seek(0) # Reset position for upload
934 # Get user account to check for existing profile picture
935 fetched_account = await User.get(user_id)
936 if not fetched_account:
937 raise HTTPException(
938 status_code=status.HTTP_404_NOT_FOUND,
939 detail="User account not found",
940 )
942 # Check if user already has a profile picture
943 old_picture_url = getattr(fetched_account, "profile_picture", None)
944 if old_picture_url:
945 raise HTTPException(
946 status_code=status.HTTP_400_BAD_REQUEST,
947 detail="User already has a profile picture. Use the update endpoint instead.",
948 )
950 # Compress to a fixed size/format regardless of the uploaded image's
951 # original dimensions, so storage size stays predictable.
952 compressed_file = compress_profile_picture(file_obj)
954 # Generate filenames and paths (always .jpg — compression output is fixed JPEG)
955 file_name = f"{user_id}-profilepic.jpg"
956 storage_dir = f"user-images/{user_id}/{file_name}"
958 # Upload new file
959 s3.upload_fileobj(
960 compressed_file,
961 MINIO_PRIVATE_BUCKET,
962 storage_dir,
963 ExtraArgs={"ContentType": "image/jpeg"},
964 )
966 # Store the private object KEY; a presigned URL is minted at read time.
967 cdn_url = storage_dir
969 # Update user profile. The object is already in the bucket, so if this DB
970 # write fails (or the request is interrupted here) the uploaded file would
971 # be an orphan — stored, but with no account referencing it. Roll the object
972 # back on failure so an interrupted add leaves nothing behind.
973 try:
974 account = await fetched_account.update(
975 {
976 "$set": {
977 "profile_picture": cdn_url,
978 }
979 },
980 )
981 except Exception:
982 try:
983 s3.delete_object(Bucket=MINIO_PRIVATE_BUCKET, Key=storage_dir)
984 except Exception as cleanup_err:
985 logging.error(
986 f"Failed to roll back orphaned profile picture {storage_dir}: {cleanup_err}"
987 )
988 raise
990 del account.password
991 account.profile_picture = profile_picture_url(account.profile_picture)
992 return {"message": "Added Profile Picture", "data": account}
994 except HTTPException as e:
995 # Re-raise HTTP exceptions as-is
996 raise e
997 except Exception as e:
998 # Log the detailed error
999 logging.error(f"Profile picture addition failed: {str(e)}", exc_info=True)
1001 # Return a user-friendly error
1002 raise HTTPException(
1003 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1004 detail="Failed to add profile picture. Please try again later.",
1005 )
1006 finally:
1007 # Ensure file is closed properly
1008 if hasattr(file, "file"):
1009 file.file.close()