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

1# Python Standard Library 

2import logging 

3import re 

4 

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 

18 

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 

46 

47 

48class UsersService: 

49 """ 

50 Service class handling user-related operations including authentication, registration, 

51 profile management, and password operations. 

52 

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 

59 

60 Attributes: 

61 _db_initialized: Track database initialization status 

62 

63 Dependencies: 

64 - MongoDB database connection 

65 - MinIO for file storage 

66 - Email service for password reset functionality 

67 """ 

68 

69 def __init__(self): 

70 """Initialize UsersService with database connection tracking.""" 

71 self._db_initialized = False 

72 

73 async def ensure_db_initialized(self): 

74 """ 

75 Ensures database connection is initialized before performing operations. 

76 

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 

83 

84 async def init_db(self): 

85 """ 

86 Initialize database connection and Beanie ODM models. 

87 

88 Establishes connection to MongoDB and initializes document models for: 

89 - User accounts 

90 - Password reset requests 

91 - Activation tokens 

92 - School information 

93 

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") 

102 

103 client = AsyncIOMotorClient(settings.MONGODB_URL) 

104 

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 ) 

115 

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 

120 

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") 

129 

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 

136 

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 

146 

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 

155 

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 

166 

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)) 

174 

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 

180 

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"] 

189 

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 

197 

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). 

201 

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 

207 

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 

213 

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. 

219 

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 } 

231 

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 

243 

244 Example: 

245 ```python 

246 # Self lookup 

247 user_data = await users_service.get_user_data(request) 

248 

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() 

256 

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 ) 

264 

265 role = request.state.user_details["role"] 

266 

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 ) 

284 

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 

294 

295 if not account_data: 

296 raise HTTPException( 

297 status_code=status.HTTP_404_NOT_FOUND, 

298 detail="Account not found", 

299 ) 

300 

301 if "client_id" in account_data and account_data["client_id"] is None: 

302 del account_data["client_id"] 

303 

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 ) 

310 

311 school_name = account_data.get("school") 

312 school_id = account_data.get("school_id") 

313 

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) 

322 

323 return {"data": account_data} 

324 

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 

335 

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 

342 

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. 

348 

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 

359 

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"]) 

370 

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 ) 

376 

377 if search is None and role is None: 

378 return await self.user_account_fetch(request) 

379 

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) 

386 

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 ) 

392 

393 if role.lower() == "student": 

394 teacher_classes = await ClassModel.find( 

395 {"teacher._id": user_id} 

396 ).to_list() 

397 

398 student_ids = set() 

399 for class_doc in teacher_classes: 

400 for student in class_doc.students: 

401 student_ids.add(student.id) 

402 

403 if not student_ids: 

404 return { 

405 "data": [], 

406 "count": 0, 

407 "total": 0, 

408 "page": page, 

409 "no_of_pages": 0, 

410 } 

411 

412 filters = {"role": role.lower(), "_id": {"$in": list(student_ids)}} 

413 else: 

414 filters = { 

415 "role": role.lower(), 

416 "_id": {"$ne": user_id}, 

417 } 

418 

419 search_conditions = [] 

420 

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 ) 

436 

437 # Add search conditions to filters if any exist 

438 if search_conditions: 

439 filters["$and"] = search_conditions 

440 

441 # Use direct collection access for search to bypass model validation 

442 collection = db["user_collection"] 

443 

444 # Count total matches 

445 total_count = await collection.count_documents(filters) 

446 

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 } 

464 

465 # Paginate results 

466 skip = (page - 1) * page_size 

467 

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) 

479 

480 num_pages = (total_count + page_size - 1) // page_size 

481 

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 } 

502 

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 

510 

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 ] 

528 

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"] 

537 

538 user_dict["classes"] = classes 

539 user_dict["school"] = school_name 

540 user_data.append(user_dict) 

541 

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 

550 

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 ) 

558 

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. 

567 

568 Validates: 

569 1. User has student role 

570 2. User exists in system 

571 3. Authorization to update specified account 

572 

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 

577 

578 Returns: 

579 dict: Response containing: 

580 - message: Success confirmation 

581 - updated_user_account: Updated user data (excluding password) 

582 

583 Raises: 

584 HTTPException: 

585 - 400: User not student role or invalid data 

586 - 404: User not found 

587 - 500: Database update errors 

588 """ 

589 

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") 

606 

607 user_id = to_user_id(id or request.state.user_details["uuid"]) 

608 role = request.state.user_details["role"] 

609 

610 if role != "student": 

611 raise HTTPException( 

612 status.HTTP_400_BAD_REQUEST, detail="User must be a student" 

613 ) 

614 

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 ) 

622 

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 } 

642 

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") 

652 

653 if "Id must be of type PydanticObjectId" in str(e): 

654 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="User not found") 

655 

656 raise HTTPException( 

657 status.HTTP_400_BAD_REQUEST, detail="An error occured: " + str(e) 

658 ) 

659 

660 async def user_picture_update(self, request: Request, file: UploadFile = File(...)): 

661 """ 

662 Upload and update user profile picture. 

663 

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 

670 

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 

680 

681 Returns: 

682 dict: Response containing: 

683 - message: Success confirmation 

684 - data: Updated user account information including profile picture URL 

685 

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"]) 

697 

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 

703 

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 ) 

711 

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 ) 

719 

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) 

723 

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}" 

727 

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 ) 

739 

740 # Store the private object KEY; a presigned URL is minted at read time. 

741 cdn_url = storage_dir 

742 

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 

770 

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 ) 

783 

784 del account.password 

785 account.profile_picture = profile_picture_url(account.profile_picture) 

786 return {"message": "Updated Profile Picture", "data": account} 

787 

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) 

794 

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() 

804 

805 async def user_picture_delete(self, request: Request): 

806 """ 

807 Remove user's profile picture and clean up storage. 

808 

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 

815 

816 Args: 

817 request (Request): FastAPI request object containing: 

818 - JWT token in Authorization header 

819 - User authentication details 

820 - User context and permissions 

821 

822 Returns: 

823 dict: Response containing: 

824 - message: Deletion confirmation 

825 - data: Updated user account information with profile_picture set to None 

826 

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"]) 

835 

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 ) 

843 

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 } 

851 

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 

860 

861 storage_dir = f"user-images/{user_id}/{file_name}" 

862 

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/") :] 

866 

867 # Delete the file from the private bucket 

868 s3.delete_object(Bucket=MINIO_PRIVATE_BUCKET, Key=storage_dir) 

869 

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 ) 

890 

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. 

894 

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 

900 

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 

910 

911 Returns: 

912 dict: Response containing: 

913 - message: Success confirmation 

914 - data: Updated user account information including profile picture URL 

915 

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"]) 

927 

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 

933 

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 ) 

941 

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 ) 

949 

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) 

953 

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}" 

957 

958 # Upload new file 

959 s3.upload_fileobj( 

960 compressed_file, 

961 MINIO_PRIVATE_BUCKET, 

962 storage_dir, 

963 ExtraArgs={"ContentType": "image/jpeg"}, 

964 ) 

965 

966 # Store the private object KEY; a presigned URL is minted at read time. 

967 cdn_url = storage_dir 

968 

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 

989 

990 del account.password 

991 account.profile_picture = profile_picture_url(account.profile_picture) 

992 return {"message": "Added Profile Picture", "data": account} 

993 

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) 

1000 

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()