From dfa9cdd4fb8423211ab997975fe9eee9c1074da1 Mon Sep 17 00:00:00 2001 From: anuj chhikara Date: Fri, 17 Apr 2026 23:52:41 +0530 Subject: [PATCH 1/3] feat(user): add user profile picture update functionality - Introduced a new endpoint to update the user's profile picture. - Implemented a serializer to validate the uploaded image. - Integrated Cloudinary for image upload and storage. - Updated user repository to handle picture URL updates. - Added integration tests for the new profile picture update feature. - Updated .env.example to include Cloudinary configuration variables. - Added cloudinary dependency to requirements.txt. --- .env.example | 6 +- requirements.txt | 1 + todo/repositories/user_repository.py | 32 +++++++++ todo/serializers/update_profile_serializer.py | 18 +++++ todo/services/cloudinary_service.py | 59 ++++++++++++++++ .../integration/test_user_profile_api.py | 23 +++++++ todo/urls.py | 3 +- todo/views/user.py | 69 +++++++++++++++++++ 8 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 todo/serializers/update_profile_serializer.py create mode 100644 todo/services/cloudinary_service.py diff --git a/.env.example b/.env.example index 4411d03a..7a50c1f3 100644 --- a/.env.example +++ b/.env.example @@ -35,4 +35,8 @@ POSTGRES_DB=todo_postgres POSTGRES_HOST=postgres POSTGRES_PASSWORD=todo_password POSTGRES_PORT=5432 -POSTGRES_USER=todo_user \ No newline at end of file +POSTGRES_USER=todo_user + +CLOUDINARY_CLOUD_NAME="your_cloud_name" +CLOUDINARY_API_KEY="123456789012345" +CLOUDINARY_API_SECRET="your_api_secret" diff --git a/requirements.txt b/requirements.txt index 832c173c..5ee8c766 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,3 +29,4 @@ testcontainers[mongodb]==4.10.0 drf-spectacular==0.28.0 debugpy==1.8.14 psycopg2-binary==2.9.9 +cloudinary==1.44.1 diff --git a/todo/repositories/user_repository.py b/todo/repositories/user_repository.py index a4e2ffe0..61e197dd 100644 --- a/todo/repositories/user_repository.py +++ b/todo/repositories/user_repository.py @@ -97,6 +97,38 @@ def create_or_update(cls, user_data: dict) -> UserModel: raise raise APIException(RepositoryErrors.USER_CREATE_UPDATE_FAILED.format(str(e))) + @classmethod + def update_picture_by_id(cls, user_id: str, picture_url: str) -> UserModel: + collection = cls._get_collection() + now = datetime.now(timezone.utc) + object_id = PyObjectId(user_id) + + result = collection.find_one_and_update( + {"_id": object_id}, + {"$set": {"picture": picture_url, "updated_at": now}}, + return_document=ReturnDocument.AFTER, + ) + + if not result: + raise UserNotFoundException() + + user_model = UserModel(**result) + + dual_write_service = EnhancedDualWriteService() + dual_write_success = dual_write_service.update_document( + collection_name="users", + mongo_id=user_id, + data={"picture": picture_url, "updated_at": now}, + ) + + if not dual_write_success: + import logging + + logger = logging.getLogger(__name__) + logger.warning(f"Failed to sync picture update for user {user_id} to Postgres") + + return user_model + @classmethod def search_users(cls, query: str, page: int = 1, limit: int = 10) -> tuple[List[UserModel], int]: """ diff --git a/todo/serializers/update_profile_serializer.py b/todo/serializers/update_profile_serializer.py new file mode 100644 index 00000000..3d2e2c43 --- /dev/null +++ b/todo/serializers/update_profile_serializer.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + + +ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"] +MAX_FILE_SIZE = 5 * 1024 * 1024 + + +class UpdateProfileSerializer(serializers.Serializer): + picture = serializers.FileField(required=True) + + def validate_picture(self, value): + if value.content_type not in ALLOWED_IMAGE_TYPES: + raise serializers.ValidationError(f"Invalid file type. Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}") + + if value.size > MAX_FILE_SIZE: + raise serializers.ValidationError("File size must be under 5MB") + + return value diff --git a/todo/services/cloudinary_service.py b/todo/services/cloudinary_service.py new file mode 100644 index 00000000..cd3a2629 --- /dev/null +++ b/todo/services/cloudinary_service.py @@ -0,0 +1,59 @@ +import io +import os + +from cloudinary import uploader +import cloudinary + +from todo.exceptions.auth_exceptions import APIException + + +class CloudinaryService: + @staticmethod + def _require_config() -> tuple[str, str, str]: + cloud_name = os.getenv("CLOUDINARY_CLOUD_NAME") + api_key = os.getenv("CLOUDINARY_API_KEY") + api_secret = os.getenv("CLOUDINARY_API_SECRET") + + if not cloud_name or not api_key or not api_secret: + raise APIException( + "Cloudinary is not configured. Set CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, and CLOUDINARY_API_SECRET." + ) + + return str(cloud_name), str(api_key), str(api_secret) + + @staticmethod + def _configure() -> None: + cloud_name, api_key, api_secret = CloudinaryService._require_config() + cloudinary.config( + cloud_name=cloud_name, + api_key=api_key, + api_secret=api_secret, + ) + + @classmethod + def upload_image( + cls, + *, + file_data: bytes, + user_id: str, + image_name: str, + ) -> str: + cls._configure() + + if not image_name.strip(): + raise APIException("imageName must be a non-empty string") + + upload_folder = f"todo/users/{user_id}" + public_id = f"{user_id}/{image_name.strip()}" + + file_obj = io.BytesIO(file_data) + + result = uploader.upload( + file_obj, + public_id=public_id, + folder=upload_folder, + overwrite=True, + resource_type="image", + ) + + return result["secure_url"] diff --git a/todo/tests/integration/test_user_profile_api.py b/todo/tests/integration/test_user_profile_api.py index c7c20196..3b238c8a 100644 --- a/todo/tests/integration/test_user_profile_api.py +++ b/todo/tests/integration/test_user_profile_api.py @@ -1,12 +1,15 @@ from http import HTTPStatus +from io import BytesIO from django.urls import reverse from todo.tests.integration.base_mongo_test import AuthenticatedMongoTestCase +from unittest.mock import patch class UserProfileAPIIntegrationTest(AuthenticatedMongoTestCase): def setUp(self): super().setUp() self.url = reverse("users") + self.profile_url = reverse("user_profile") def test_user_profile_true_requires_auth(self): client = self.client.__class__() @@ -19,3 +22,23 @@ def test_user_profile_true_returns_user_info(self): data = response.json()["data"] self.assertEqual(data["id"], str(self.user_id)) self.assertEqual(data["email"], self.user_data["email"]) + + def test_update_profile_picture_requires_auth(self): + client = self.client.__class__() + response = client.patch(self.profile_url, data={"picture": BytesIO(b"fake")}, format="multipart") + self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) + + def test_update_profile_picture_persists_picture_url(self): + new_picture = "https://res.cloudinary.com/test_cloud/image/upload/v1/todo/users/abc/profile/picture.png" + + with patch("todo.services.cloudinary_service.CloudinaryService.upload_image", return_value=new_picture): + response = self.client.patch( + self.profile_url, data={"picture": BytesIO(b"fake_image_data")}, format="multipart" + ) + + self.assertEqual(response.status_code, HTTPStatus.OK) + + profile_response = self.client.get(self.url + "?profile=true") + self.assertEqual(profile_response.status_code, HTTPStatus.OK) + profile_data = profile_response.json()["data"] + self.assertEqual(profile_data["picture"], new_picture) diff --git a/todo/urls.py b/todo/urls.py index 7b4f2277..9f9d835d 100644 --- a/todo/urls.py +++ b/todo/urls.py @@ -1,7 +1,7 @@ from django.urls import path from todo.views.task import TaskListView, TaskDetailView, TaskUpdateView from todo.views.health import HealthView -from todo.views.user import UsersView +from todo.views.user import UsersView, UserDetailView from todo.views.auth import GoogleLoginView, GoogleCallbackView, LogoutView from todo.views.role import RoleListView, RoleDetailView from todo.views.user_role import UserRoleListView, TeamUserRoleListView, TeamUserRoleDetailView, TeamUserRoleDeleteView @@ -57,6 +57,7 @@ path("auth/google/callback", GoogleCallbackView.as_view(), name="google_callback"), path("auth/logout", LogoutView.as_view(), name="google_logout"), path("users", UsersView.as_view(), name="users"), + path("users/profile", UserDetailView.as_view(), name="user_profile"), path("users//roles", UserRoleListView.as_view(), name="user_roles"), path("team-invite-codes/generate", GenerateTeamCreationInviteCodeView.as_view(), name="generate_team_invite_code"), path("team-invite-codes/verify", VerifyTeamCreationInviteCodeView.as_view(), name="verify_team_invite_code"), diff --git a/todo/views/user.py b/todo/views/user.py index d866d91d..31180c06 100644 --- a/todo/views/user.py +++ b/todo/views/user.py @@ -3,11 +3,18 @@ from rest_framework.request import Request from todo.constants.messages import ApiErrors from todo.services.user_service import UserService +from todo.repositories.user_repository import UserRepository +from todo.services.cloudinary_service import CloudinaryService +from todo.exceptions.auth_exceptions import APIException from rest_framework import status from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse from drf_spectacular.types import OpenApiTypes from todo.dto.user_dto import UserSearchResponseDTO, UsersDTO from todo.dto.responses.error_response import ApiErrorResponse +from drf_spectacular.utils import OpenApiExample, inline_serializer +from rest_framework import serializers +from todo.serializers.update_profile_serializer import UpdateProfileSerializer +import logging class UsersView(APIView): @@ -117,3 +124,65 @@ def get(self, request: Request): }, status=status.HTTP_200_OK, ) + + +class UserDetailView(APIView): + @extend_schema( + operation_id="update_user_profile", + summary="Update current user profile picture", + description="Updates the profile picture of the currently authenticated user.", + tags=["users"], + request=inline_serializer( + name="UserProfilePictureRequest", + fields={ + "picture": serializers.FileField(), + }, + ), + responses={ + 200: OpenApiResponse(description="Profile picture updated"), + 400: OpenApiResponse(description="Bad request"), + 401: OpenApiResponse(description="Unauthorized"), + 500: OpenApiResponse(description="Internal server error"), + }, + examples=[ + OpenApiExample( + name="Update profile image", + value={"picture": ""}, + request_only=True, + ) + ], + ) + def patch(self, request: Request): + serializer = UpdateProfileSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + picture_file = serializer.validated_data["picture"] + + try: + profile_url = CloudinaryService.upload_image( + file_data=picture_file.read(), + user_id=request.user_id, + image_name="profile-picture", + ) + except APIException: + return Response( + {"message": "Image upload configuration error"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + except Exception as e: + logger = logging.getLogger(__name__) + logger.error(f"Failed to upload image for user {request.user_id}: {str(e)}") + return Response( + {"message": "Failed to upload image"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + user = UserRepository.update_picture_by_id(request.user_id, profile_url) + userData = user.model_dump(mode="json", exclude_none=True) + userResponse = { + "id": userData["id"], + "email": userData["email_id"], + "name": userData.get("name"), + "picture": userData.get("picture"), + } + return Response({"message": "User updated successfully", "data": userResponse}, status=200) From 940ef741fbcacf7a7ace861fee2560a1503ae6bc Mon Sep 17 00:00:00 2001 From: anuj chhikara Date: Fri, 17 Apr 2026 23:58:20 +0530 Subject: [PATCH 2/3] test(user): enhance integration tests for profile picture updates - Replaced BytesIO with SimpleUploadedFile for simulating image uploads in tests. - Added tests for invalid file types and missing files during profile picture updates. - Ensured proper handling of HTTP status codes for various scenarios. --- .../integration/test_user_profile_api.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/todo/tests/integration/test_user_profile_api.py b/todo/tests/integration/test_user_profile_api.py index 3b238c8a..a1326f49 100644 --- a/todo/tests/integration/test_user_profile_api.py +++ b/todo/tests/integration/test_user_profile_api.py @@ -1,5 +1,5 @@ from http import HTTPStatus -from io import BytesIO +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from todo.tests.integration.base_mongo_test import AuthenticatedMongoTestCase from unittest.mock import patch @@ -25,7 +25,11 @@ def test_user_profile_true_returns_user_info(self): def test_update_profile_picture_requires_auth(self): client = self.client.__class__() - response = client.patch(self.profile_url, data={"picture": BytesIO(b"fake")}, format="multipart") + response = client.patch( + self.profile_url, + data={"picture": SimpleUploadedFile("test.jpg", b"fake", "image/jpeg")}, + format="multipart", + ) self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED) def test_update_profile_picture_persists_picture_url(self): @@ -33,7 +37,9 @@ def test_update_profile_picture_persists_picture_url(self): with patch("todo.services.cloudinary_service.CloudinaryService.upload_image", return_value=new_picture): response = self.client.patch( - self.profile_url, data={"picture": BytesIO(b"fake_image_data")}, format="multipart" + self.profile_url, + data={"picture": SimpleUploadedFile("test.jpg", b"fake_image_data", "image/jpeg")}, + format="multipart", ) self.assertEqual(response.status_code, HTTPStatus.OK) @@ -42,3 +48,16 @@ def test_update_profile_picture_persists_picture_url(self): self.assertEqual(profile_response.status_code, HTTPStatus.OK) profile_data = profile_response.json()["data"] self.assertEqual(profile_data["picture"], new_picture) + + def test_update_profile_picture_invalid_file_type(self): + response = self.client.patch( + self.profile_url, + data={"picture": SimpleUploadedFile("test.txt", b"fake", "text/plain")}, + format="multipart", + ) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertIn("Invalid file type", response.json().get("message", "")) + + def test_update_profile_picture_missing_file(self): + response = self.client.patch(self.profile_url, data={}, format="multipart") + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) From a71504736d34699e7c37e59d047ff1d293c253a7 Mon Sep 17 00:00:00 2001 From: anuj chhikara Date: Sat, 18 Apr 2026 22:32:10 +0530 Subject: [PATCH 3/3] fix(cloudinary): enable secure uploads and adjust public ID generation - Set secure uploads to true for Cloudinary integration. - Modified public ID generation to use only the image name, removing the user ID prefix. --- todo/services/cloudinary_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/todo/services/cloudinary_service.py b/todo/services/cloudinary_service.py index cd3a2629..be96a98a 100644 --- a/todo/services/cloudinary_service.py +++ b/todo/services/cloudinary_service.py @@ -28,6 +28,7 @@ def _configure() -> None: cloud_name=cloud_name, api_key=api_key, api_secret=api_secret, + secure=True, ) @classmethod @@ -44,7 +45,7 @@ def upload_image( raise APIException("imageName must be a non-empty string") upload_folder = f"todo/users/{user_id}" - public_id = f"{user_id}/{image_name.strip()}" + public_id = image_name.strip() file_obj = io.BytesIO(file_data)