diff --git a/.gitignore b/.gitignore index 204ab2b..90192f8 100644 --- a/.gitignore +++ b/.gitignore @@ -496,9 +496,10 @@ $RECYCLE.BIN/ data/ dockerfail/ -#nginx/ +# others failed_files/ config/settings/settings.py temp-action/ +#diagrams/ # End of https://www.toptal.com/developers/gitignore/api/python,django,pycharm,vim,visualstudiocode,venv,macos,windows diff --git a/apps/like/__init__.py b/apps/like/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/like/admin.py b/apps/like/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/like/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/like/apps.py b/apps/like/apps.py new file mode 100644 index 0000000..ce65e1a --- /dev/null +++ b/apps/like/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LikeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.like" diff --git a/apps/like/models.py b/apps/like/models.py new file mode 100644 index 0000000..7f4585e --- /dev/null +++ b/apps/like/models.py @@ -0,0 +1,15 @@ +from django.db import models + +from apps.common.models import BaseModel +from apps.product.models import Product +from apps.user.models import Account + + +class Like(BaseModel): + user = models.ForeignKey(Account, on_delete=models.CASCADE) + product = models.ForeignKey(Product, on_delete=models.CASCADE) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["user", "product"], name="unique_user_product"), + ] diff --git a/apps/like/permissions.py b/apps/like/permissions.py new file mode 100644 index 0000000..4022189 --- /dev/null +++ b/apps/like/permissions.py @@ -0,0 +1,13 @@ +from typing import Any + +from rest_framework import permissions +from rest_framework.request import Request +from rest_framework.views import APIView + + +class IsUserOrReadOnly(permissions.BasePermission): + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + if request.method in permissions.SAFE_METHODS: + return True + # return str(request.user) == obj.lender.email + return request.user == getattr(obj, "user", None) diff --git a/apps/like/serializers.py b/apps/like/serializers.py new file mode 100644 index 0000000..09e2730 --- /dev/null +++ b/apps/like/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from apps.like.models import Like +from apps.product.models import Product +from apps.product.serializers import ProductSerializer + + +class LikeSerializer(serializers.ModelSerializer[Like]): + product_id = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all(), write_only=True) + product = ProductSerializer(read_only=True) + + class Meta: + model = Like + fields = ("id", "product_id", "product") diff --git a/apps/like/tests.py b/apps/like/tests.py new file mode 100644 index 0000000..58b35d7 --- /dev/null +++ b/apps/like/tests.py @@ -0,0 +1,131 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import AccessToken + +from apps.category.models import Category +from apps.like.models import Like +from apps.product.models import Product +from apps.user.models import Account + + +class TestLikeListCreateView(APITestCase): + def setUp(self) -> None: + # self.client = APIClient() + self.url = reverse("likes") + data = { + "email": "user@email.com", + "password": "fels3570", + "nickname": "nick", + "phone": "1234", + } + self.user = Account.objects.create_user(**data) + # self.client.force_login(user=self.user) + # self.token = AccessToken.for_user(self.user) + self.category = Category.objects.create(name="test category") + self.product = Product.objects.create( + name="test product", + lender=self.user, + brand="test brand", + condition="good", + purchase_date="2024-01-01", + purchase_price=10000, + rental_fee=1000, + size="xs", + product_category=self.category, + ) + + def test_list_likes(self) -> None: + self.client.force_authenticate(user=self.user) + Like.objects.create(user=self.user, product=self.product) + res = self.client.get(self.url) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data.get("count"), 1) + + def test_create_like(self) -> None: + self.client.force_authenticate(user=self.user) + data = {"product_id": self.product.uuid} + res = self.client.post(self.url, data, format="json") + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + self.assertTrue(Like.objects.filter(user=self.user, product=self.product).exists()) + + def test_create_like_without_product_id(self) -> None: + self.client.force_authenticate(user=self.user) + res = self.client.post(self.url, {}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Like.objects.filter(user=self.user, product=self.product).exists()) + + def test_create_like_already_exists(self) -> None: + self.client.force_authenticate(user=self.user) + Like.objects.create(user=self.user, product=self.product) + data = {"product_id": self.product.uuid} + res = self.client.post(self.url, data, format="json") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data, ["Already liked this product."]) + + def test_create_list_without_login(self) -> None: + data = {"product_id": self.product.uuid} + res = self.client.post(self.url, data, format="json") + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TestLikeDestroyView(APITestCase): + def setUp(self) -> None: + data = { + "email": "user@email.com", + "password": "fels3570", + "nickname": "nick", + "phone": "1234", + } + self.user = Account.objects.create_user(**data) + self.category = Category.objects.create(name="test category") + self.product = Product.objects.create( + name="test product", + lender=self.user, + brand="test brand", + condition="good", + purchase_date="2024-01-01", + purchase_price=10000, + rental_fee=1000, + size="xs", + product_category=self.category, + likes=1, + ) + self.like = Like.objects.create(user=self.user, product=self.product) + self.url = reverse("like_delete", kwargs={"pk": self.product.pk}) + + def test_delete_like(self) -> None: + self.client.force_authenticate(user=self.user) + res = self.client.delete(self.url) + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Like.objects.filter(user=self.user, product=self.product).exists()) + + # def test_like_count_decreased(self) -> None: + # self.client.force_authenticate(user=self.user) + # initial_count = self.product.likes + # res = self.client.delete(self.url) + # self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + # self.assertEqual(self.product.likes, initial_count - 1) + + def test_delete_like_not_found(self) -> None: + self.client.force_authenticate(user=self.user) + self.like.delete() + res = self.client.delete(self.url) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_like_by_other_user(self) -> None: + data = { + "email": "otuser@email.com", + "password": "fels3570", + "nickname": "dfk", + "phone": "1234", + } + other_user = Account.objects.create_user(**data) + self.client.force_authenticate(other_user) + res = self.client.delete(self.url) + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + + +class TestPermission(APITestCase): + pass diff --git a/apps/like/urls.py b/apps/like/urls.py new file mode 100644 index 0000000..45b2dee --- /dev/null +++ b/apps/like/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from apps.like.views import LikeDestroyView, LikeListCreateView + +urlpatterns = [ + path("", LikeListCreateView.as_view(), name="likes"), + # path("/", LikeCreateView.as_view(), name="like_create"), + path("/", LikeDestroyView.as_view(), name="like_delete"), +] diff --git a/apps/like/views.py b/apps/like/views.py new file mode 100644 index 0000000..b4ce0cc --- /dev/null +++ b/apps/like/views.py @@ -0,0 +1,75 @@ +from typing import Union +from uuid import UUID + +from django.db import IntegrityError, transaction +from django.db.models import F, QuerySet +from rest_framework import generics, permissions +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.serializers import BaseSerializer + +from apps.like.models import Like +from apps.like.permissions import IsUserOrReadOnly +from apps.like.serializers import LikeSerializer +from apps.product.models import Product +from apps.user.models import Account + +# class LikeListView(generics.ListAPIView): +# serializer_class = LikeSerializer +# permission_classes = [permissions.IsAuthenticated] +# +# def get_queryset(self) -> QuerySet[Like]: +# user = self.request.user +# return Like.objects.filter(user=user).order_by("-created_at") + + +class LikeListCreateView(generics.ListCreateAPIView[Like]): + serializer_class = LikeSerializer + permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] + + def get_queryset(self) -> QuerySet[Like]: + user = self.request.user + if not isinstance(user, Account): + raise PermissionDenied("You must be logged in to view your likes.") + return Like.objects.filter(user=user).order_by("-created_at") + + def perform_create(self, serializer: BaseSerializer[Like]) -> None: + product_id = self.request.data.get("product_id") + # product = Product.objects.get(pk=product_id) + if not product_id: + raise ValidationError("You must provide a product ID.") + + try: + with transaction.atomic(): + serializer.save(user=self.request.user, product_id=product_id) + Product.objects.filter(pk=product_id).update(likes=F("likes") + 1) + # product.likes = F("likes") + 1 + # product.save(update_fields=["likes"]) + except IntegrityError: + raise ValidationError("Already liked this product.") + + +class LikeDestroyView(generics.DestroyAPIView[Like]): + # serializer_class = LikeSerializer + permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] + + def get_object(self) -> Like: + product_id = self.kwargs.get("pk") + user = self.request.user if isinstance(self.request.user, Account) else None + # like = Like.objects.filter(user=user, product_id=product_id).first() + # if not like: + # raise NotFound("No Like matches the given query.") + # return like + try: + like = Like.objects.get(user=user, product_id=product_id) + return like + except Like.DoesNotExist: + raise NotFound("No Like matches the given query.") + + @transaction.atomic + def perform_destroy(self, instance: Like) -> None: + product_id = self.kwargs.get("pk") + if instance: + instance.delete() + Product.objects.filter(pk=product_id).update(likes=F("likes") - 1) + # product.likes = F("likes") - 1 + # product.save(update_fields=["likes"]) diff --git a/apps/product/models.py b/apps/product/models.py index c522fab..0f5be58 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -26,6 +26,7 @@ class Product(BaseModel): status = models.BooleanField(default=True) # 대여 가능 여부 amount = models.IntegerField(default=1) region = models.CharField(max_length=30, default="None") + likes = models.IntegerField(default=0) def __str__(self) -> str: return self.name diff --git a/apps/product/serializers.py b/apps/product/serializers.py index 353fc4c..90c15f4 100644 --- a/apps/product/serializers.py +++ b/apps/product/serializers.py @@ -58,9 +58,10 @@ class Meta: "created_at", "updated_at", "images", + "likes", # "rental_history", ) - read_only_fields = ("created_at", "updated_at", "views", "lender", "status") + read_only_fields = ("created_at", "updated_at", "views", "lender", "status", "likes") @transaction.atomic def create(self, validated_data: Any) -> Product: diff --git a/apps/user/tests.py b/apps/user/tests.py index 43c7b11..474c983 100644 --- a/apps/user/tests.py +++ b/apps/user/tests.py @@ -250,12 +250,12 @@ def generate_image_file(self) -> BytesIO: file.seek(0) return file - def generate_image_to_base64(self) -> bytes: - file = BytesIO() - image = Image.new("RGBA", size=(100, 100), color=(155, 0, 0)) - image.save(file, "png") - img_str = base64.b64encode(file.getvalue()) - return img_str + # def generate_image_to_base64(self) -> bytes: + # file = BytesIO() + # image = Image.new("RGBA", size=(100, 100), color=(155, 0, 0)) + # image.save(file, "png") + # img_str = base64.b64encode(file.getvalue()) + # return img_str def test_get_user_info(self) -> None: res = self.client.get(self.url, headers={"Authorization": f"Bearer {self.token}"}) @@ -323,25 +323,22 @@ def test_update_user_info_with_different_passwords(self) -> None: res = self.client.patch(self.url, data, headers={"Authorization": f"Bearer {self.token}"}) self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) - # def test_upload_profile_image(self) -> None: - # image_file = self.generate_image_file() - # data = {"profile_img": image_file} - # res = self.client.patch(self.url, data, format="multipart") - # self.assertEqual(res.status_code, status.HTTP_200_OK) + def test_upload_profile_image(self) -> None: + image_file = self.generate_image_file() + data = {"profile_img": image_file} + res = self.client.patch(self.url, data, format="multipart", headers={"Authorization": f"Bearer {self.token}"}) + self.assertEqual(res.status_code, status.HTTP_200_OK) # TODO: 테스트 돌릴 때마다 S3에 올라감 이슈 - # def test_update_profile_image(self) -> None: - # profile_img = self.generate_image_file() - # data = { - # "email": "user@email.com", - # "profile_img": profile_img - # } - # # Account.objects.create(**data) - # res = self.client.patch(self.url, data, format="multipart") - # image_file = self.generate_image_file() - # data = {"email": "user@email.com", "profile_img": image_file} - # res = self.client.patch(self.url, data, format="multipart") - # self.assertEqual(res.status_code, status.HTTP_200_OK) + def test_update_profile_image(self) -> None: + profile_img = self.generate_image_file() + data = {"email": "user@email.com", "profile_img": profile_img} + # Account.objects.create(**data) + res = self.client.patch(self.url, data, format="multipart", headers={"Authorization": f"Bearer {self.token}"}) + image_file = self.generate_image_file() + data = {"email": "user@email.com", "profile_img": image_file} + res = self.client.patch(self.url, data, format="multipart", headers={"Authorization": f"Bearer {self.token}"}) + self.assertEqual(res.status_code, status.HTTP_200_OK) class DeleteUserViewTests(TestCase): diff --git a/apps/user/urls.py b/apps/user/urls.py index a93c561..52907a6 100644 --- a/apps/user/urls.py +++ b/apps/user/urls.py @@ -17,8 +17,8 @@ CustomLoginView, CustomSignupView, DeleteUserView, - KakaoLoginView, GoogleLoginView, + KakaoLoginView, SendCodeView, ) diff --git a/apps/user/views.py b/apps/user/views.py index 96ace95..14af4ac 100644 --- a/apps/user/views.py +++ b/apps/user/views.py @@ -262,7 +262,7 @@ def post(self, request: Request) -> Response: "client_secret": client_secret, "code": code, "grant_type": "authorization_code", - "redirect_uri": redirect_uri + "redirect_uri": redirect_uri, }, ) # 요청의 응답을 json 파싱 @@ -297,7 +297,7 @@ def post(self, request: Request) -> Response: "access": str(access_token), "refresh": str(refresh_token), "email": user.email, - "nickname": user.nickname + "nickname": user.nickname, } if user.profile_img: response_data["profile_image"] = user.profile_img.url @@ -318,7 +318,7 @@ def post(self, request: Request) -> Response: "access": str(access_token), "refresh": str(refresh_token), "email": user.email, - "nickname": user.nickname + "nickname": user.nickname, } if user.profile_img: response_data["profile_image"] = user.profile_img.url diff --git a/config/settings/base.py b/config/settings/base.py index 563bed3..b0da574 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -67,6 +67,7 @@ "apps.product", "apps.chat", "apps.notification", + "apps.like", ] INSTALLED_APPS = DJANGO_SYSTEM_APPS + CUSTOM_USER_APPS diff --git a/config/settings/local.py b/config/settings/local.py index 58a4315..375953c 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -15,9 +15,7 @@ INSTALLED_APPS += ["debug_toolbar"] MIDDLEWARE = ( - ["debug_toolbar.middleware.DebugToolbarMiddleware"] - + MIDDLEWARE - # + ["whitenoise.middleware.WhiteNoiseMiddleware"] + ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE + ["whitenoise.middleware.WhiteNoiseMiddleware"] ) INTERNAL_IPS = ["127.0.0.1"] @@ -120,7 +118,9 @@ # "cloudfront_key_id": env("AWS_CLOUDFRONT_KEY_ID") }, }, - "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, } # django 이메일 인증 설정 diff --git a/config/urls.py b/config/urls.py index 2e93e74..9cbad72 100644 --- a/config/urls.py +++ b/config/urls.py @@ -19,6 +19,7 @@ path("api/categories/", include("apps.category.urls")), path("api/chat/", include("apps.chat.urls")), path("api/products/", include("apps.product.urls")), + path("api/likes/", include("apps.like.urls")), ] if settings.DEBUG: diff --git a/diagrams/like_diagram.md b/diagrams/like_diagram.md new file mode 100644 index 0000000..c93661a --- /dev/null +++ b/diagrams/like_diagram.md @@ -0,0 +1,23 @@ +```mermaid +flowchart TB + u([user]) --> + p[상품페이지] --> + l[좋아요 버튼 클릭] --> + l-1{좋아요 이미 함} -- yes --> error + l-1{좋아요 이미 함} -- no --> db-l + db-l[(Like)] --> db-p + db-p[(Product)] +``` + +```mermaid +sequenceDiagram + participant User + participant API + participant DB + + User ->> API: 좋아요 버튼 클릭 + API ->> DB: INSERT INTO Like (user_id, product_id) VALUES (user, product); + activate DB + API ->> DB: UPDATE Product p SET likes = p.likes + 1 WHERE p.id = "" + +``` \ No newline at end of file