From 50cc28cac0f12cf3c73d0704ff9d2f3e514b2301 Mon Sep 17 00:00:00 2001 From: yyysolhhh Date: Fri, 24 May 2024 22:17:21 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Feat:=20like=20=EC=95=B1=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20model,=20view,=20serializer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Like - Like 모델 생성 - 유저가 한 상품에 여러번 좋아요 할수 없게 user, product 묶어서 Unique 제약 조건 사용 IsUserOrReadOnly - 좋아요 생성한 사용자만 취소할수 있게 권한 설정 LikeSerializer - 시리얼라이저 생성 LikeListView - 좋아요한 상품 목록 api LikeCreateView - 좋아요 생성 api LikeDestroyView - 좋아요 취수 api urls.py - like view url 등록 base.py - like 앱 등록 --- apps/like/__init__.py | 0 apps/like/admin.py | 3 +++ apps/like/apps.py | 6 ++++++ apps/like/models.py | 16 ++++++++++++++++ apps/like/permissions.py | 13 +++++++++++++ apps/like/serializers.py | 12 ++++++++++++ apps/like/tests.py | 3 +++ apps/like/urls.py | 9 +++++++++ apps/like/views.py | 35 +++++++++++++++++++++++++++++++++++ config/settings/base.py | 1 + config/urls.py | 1 + 11 files changed, 99 insertions(+) create mode 100644 apps/like/__init__.py create mode 100644 apps/like/admin.py create mode 100644 apps/like/apps.py create mode 100644 apps/like/models.py create mode 100644 apps/like/permissions.py create mode 100644 apps/like/serializers.py create mode 100644 apps/like/tests.py create mode 100644 apps/like/urls.py create mode 100644 apps/like/views.py 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..4e5a212 --- /dev/null +++ b/apps/like/models.py @@ -0,0 +1,16 @@ +from django.db import models + +from apps.common.models import BaseModel +from apps.product.models import Product +from apps.user.models import Account + + +# Create your models here. +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..dd4347c --- /dev/null +++ b/apps/like/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from apps.like.models import Like +from apps.product.serializers import ProductSerializer + + +class LikeSerializer(serializers.ModelSerializer[Like]): + product = ProductSerializer(read_only=True) + + class Meta: + model = Like + fields = ("id", "product") diff --git a/apps/like/tests.py b/apps/like/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/like/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/like/urls.py b/apps/like/urls.py new file mode 100644 index 0000000..0d0c7b3 --- /dev/null +++ b/apps/like/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from apps.like.views import LikeListView, LikeCreateView, LikeDestroyView + +urlpatterns = [ + path("", LikeListView.as_view(), name="like_list"), + 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..d9920b5 --- /dev/null +++ b/apps/like/views.py @@ -0,0 +1,35 @@ +from django.db.models import QuerySet +from rest_framework import generics, permissions + +from apps.like.models import Like +from apps.like.permissions import IsUserOrReadOnly +from apps.like.serializers import LikeSerializer +from apps.product.models import Product + + +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 LikeCreateView(generics.CreateAPIView): + serializer_class = LikeSerializer + permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] + + def perform_create(self, serializer: LikeSerializer) -> None: + product_id = self.kwargs.get("pk") + product = Product.objects.get(pk=product_id) + serializer.save(user=self.request.user, product=product) + + +class LikeDestroyView(generics.DestroyAPIView): + serializer_class = LikeSerializer + permission_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] + + def get_queryset(self) -> QuerySet[Like]: + product_id = self.kwargs.get("pk") + return Like.objects.filter(user=self.request.user, pk=product_id) 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/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: From a41e22f9a3efe98acb75798e48e559c9f25346fe Mon Sep 17 00:00:00 2001 From: yyysolhhh Date: Sat, 25 May 2024 23:06:57 +0900 Subject: [PATCH 2/7] =?UTF-8?q?Feat:=20product=EC=97=90=20likes=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LikeCreateView - 좋아요 생성 시 product likes 1 증가 - 트랜잭션으로 묶음 - 이미 좋아요 했을 경우 에러 처리 LikeDestroyView - 좋아요 취소 시 product likes 1 감소 - 트랜잭션으로 묶음 - Like 오브젝트 삭제 Product - likes 필드 추가 - default는 0으로 설정 ProductSerializer - likes 필드 read_only로 추가 local.py - whitenoise 다시 추가 - static 파일 경로 못찾아서 추가함 .gitignore - diagrams 디렉토리 추가 --- .gitignore | 3 ++- apps/like/tests.py | 2 +- apps/like/urls.py | 2 +- apps/like/views.py | 31 +++++++++++++++++++++++++------ apps/product/models.py | 1 + apps/product/serializers.py | 3 ++- config/settings/local.py | 6 ++++-- 7 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 204ab2b..71238b0 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/tests.py b/apps/like/tests.py index 7ce503c..9b15ff0 100644 --- a/apps/like/tests.py +++ b/apps/like/tests.py @@ -1,3 +1,3 @@ from django.test import TestCase -# Create your tests here. + diff --git a/apps/like/urls.py b/apps/like/urls.py index 0d0c7b3..c4875e8 100644 --- a/apps/like/urls.py +++ b/apps/like/urls.py @@ -5,5 +5,5 @@ urlpatterns = [ path("", LikeListView.as_view(), name="like_list"), path("/", LikeCreateView.as_view(), name="like_create"), - path("/", LikeDestroyView.as_view(), name="like_delete") + path("/delete/", LikeDestroyView.as_view(), name="like_delete") ] diff --git a/apps/like/views.py b/apps/like/views.py index d9920b5..2b80f51 100644 --- a/apps/like/views.py +++ b/apps/like/views.py @@ -1,5 +1,7 @@ -from django.db.models import QuerySet +from django.db import transaction, IntegrityError +from django.db.models import QuerySet, F from rest_framework import generics, permissions +from rest_framework.exceptions import ValidationError from apps.like.models import Like from apps.like.permissions import IsUserOrReadOnly @@ -17,19 +19,36 @@ def get_queryset(self) -> QuerySet[Like]: class LikeCreateView(generics.CreateAPIView): + queryset = Like.objects.all() serializer_class = LikeSerializer permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] def perform_create(self, serializer: LikeSerializer) -> None: product_id = self.kwargs.get("pk") product = Product.objects.get(pk=product_id) - serializer.save(user=self.request.user, product=product) + + try: + with transaction.atomic(): + Product.objects.filter(pk=product_id).update(likes=F("likes") + 1) + serializer.save(user=self.request.user, product=product) + # product.likes = F("likes") + 1 + # product.save(update_fields=["likes"]) + except IntegrityError: + raise ValidationError("Already liked this product.") class LikeDestroyView(generics.DestroyAPIView): - serializer_class = LikeSerializer - permission_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] + # serializer_class = LikeSerializer + permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] - def get_queryset(self) -> QuerySet[Like]: + def get_object(self) -> QuerySet[Like]: + product_id = self.kwargs.get("pk") + return Like.objects.filter(user=self.request.user, product_id=product_id) + + @transaction.atomic + def perform_destroy(self, instance): product_id = self.kwargs.get("pk") - return Like.objects.filter(user=self.request.user, pk=product_id) + Product.objects.filter(pk=product_id).update(likes=F("likes") + 1) + # product.likes = F("likes") - 1 + # product.save(update_fields=["likes"]) + instance.delete() 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/config/settings/local.py b/config/settings/local.py index 58a4315..fbaccae 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -17,7 +17,7 @@ MIDDLEWARE = ( ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE - # + ["whitenoise.middleware.WhiteNoiseMiddleware"] + + ["whitenoise.middleware.WhiteNoiseMiddleware"] ) INTERNAL_IPS = ["127.0.0.1"] @@ -120,7 +120,9 @@ # "cloudfront_key_id": env("AWS_CLOUDFRONT_KEY_ID") }, }, - "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, } # django 이메일 인증 설정 From 518bdf60e3f52f9e351b1da82de8a320f46d2033 Mon Sep 17 00:00:00 2001 From: yyysolhhh Date: Sun, 26 May 2024 01:34:53 +0900 Subject: [PATCH 3/7] =?UTF-8?q?Fix:=20like=20=EC=83=9D=EC=84=B1,=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LikeSerializer - product_id write_only 필드로 추가 urls.py - LikeListView와 LikeCreateView 합침 LikeListView - CreateView와 합쳐서 주석처리함 LikeListCreateView - ListView를 추가함 - 기존 쿼리 파라미터로 받던 product 정보를 request body로 받음 - Product의 likes가 3씩 증가하는 버그 발생 - Like모델이 먼저 수정되도록 순서 변경 ListDestroyView - delete를 여러번 했을때 인스턴스가 없는데도 product의 likes가 계속 감소하는 버그 발생 - 이미 삭제된 인스턴스일 경우 에러 발생시키고 동작 수행 못하게 함 --- apps/like/serializers.py | 4 +++- apps/like/urls.py | 8 ++++---- apps/like/views.py | 37 ++++++++++++++++++++++--------------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/like/serializers.py b/apps/like/serializers.py index dd4347c..09e2730 100644 --- a/apps/like/serializers.py +++ b/apps/like/serializers.py @@ -1,12 +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") + fields = ("id", "product_id", "product") diff --git a/apps/like/urls.py b/apps/like/urls.py index c4875e8..f1855b4 100644 --- a/apps/like/urls.py +++ b/apps/like/urls.py @@ -1,9 +1,9 @@ from django.urls import path -from apps.like.views import LikeListView, LikeCreateView, LikeDestroyView +from apps.like.views import LikeDestroyView, LikeListCreateView urlpatterns = [ - path("", LikeListView.as_view(), name="like_list"), - path("/", LikeCreateView.as_view(), name="like_create"), - path("/delete/", LikeDestroyView.as_view(), name="like_delete") + path("", LikeListCreateView.as_view(), name="like_list"), + # 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 index 2b80f51..6019713 100644 --- a/apps/like/views.py +++ b/apps/like/views.py @@ -1,7 +1,7 @@ from django.db import transaction, IntegrityError from django.db.models import QuerySet, F from rest_framework import generics, permissions -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import ValidationError, NotFound from apps.like.models import Like from apps.like.permissions import IsUserOrReadOnly @@ -9,28 +9,31 @@ from apps.product.models import Product -class LikeListView(generics.ListAPIView): +# 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): serializer_class = LikeSerializer - permission_classes = [permissions.IsAuthenticated] + permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] def get_queryset(self) -> QuerySet[Like]: user = self.request.user return Like.objects.filter(user=user).order_by("-created_at") - -class LikeCreateView(generics.CreateAPIView): - queryset = Like.objects.all() - serializer_class = LikeSerializer - permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] - def perform_create(self, serializer: LikeSerializer) -> None: - product_id = self.kwargs.get("pk") - product = Product.objects.get(pk=product_id) + product_id = self.request.data.get("product_id") + # product = Product.objects.get(pk=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) - serializer.save(user=self.request.user, product=product) # product.likes = F("likes") + 1 # product.save(update_fields=["likes"]) except IntegrityError: @@ -43,12 +46,16 @@ class LikeDestroyView(generics.DestroyAPIView): def get_object(self) -> QuerySet[Like]: product_id = self.kwargs.get("pk") - return Like.objects.filter(user=self.request.user, product_id=product_id) + like = Like.objects.filter(user=self.request.user, product_id=product_id) + if not like: + raise NotFound("No Like matches the given query.") + return like @transaction.atomic def perform_destroy(self, instance): product_id = self.kwargs.get("pk") - Product.objects.filter(pk=product_id).update(likes=F("likes") + 1) + 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"]) - instance.delete() From 861de8f0524ec5a0003618445f4e5ffa09a69625 Mon Sep 17 00:00:00 2001 From: yyysolhhh Date: Sun, 26 May 2024 02:10:52 +0900 Subject: [PATCH 4/7] =?UTF-8?q?Style:=20black,=20isort,=20mypy=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드 포맷팅 LikeListCreateView - user 정보 없을 때 예외처리 - product_id 없을때 예외처리 LikeDestroyView - like 없을때 예외처리 --- apps/like/models.py | 2 +- apps/like/tests.py | 2 -- apps/like/urls.py | 2 +- apps/like/views.py | 42 ++++++++++++++++++++++++++-------------- config/settings/local.py | 4 +--- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/apps/like/models.py b/apps/like/models.py index 4e5a212..cbca50d 100644 --- a/apps/like/models.py +++ b/apps/like/models.py @@ -12,5 +12,5 @@ class Like(BaseModel): class Meta: constraints = [ - models.UniqueConstraint(fields=['user', 'product'], name='unique_user_product'), + models.UniqueConstraint(fields=["user", "product"], name="unique_user_product"), ] diff --git a/apps/like/tests.py b/apps/like/tests.py index 9b15ff0..2e9cb5f 100644 --- a/apps/like/tests.py +++ b/apps/like/tests.py @@ -1,3 +1 @@ from django.test import TestCase - - diff --git a/apps/like/urls.py b/apps/like/urls.py index f1855b4..6d44bbc 100644 --- a/apps/like/urls.py +++ b/apps/like/urls.py @@ -5,5 +5,5 @@ urlpatterns = [ path("", LikeListCreateView.as_view(), name="like_list"), # path("/", LikeCreateView.as_view(), name="like_create"), - path("/", LikeDestroyView.as_view(), name="like_delete") + path("/", LikeDestroyView.as_view(), name="like_delete"), ] diff --git a/apps/like/views.py b/apps/like/views.py index 6019713..b4ce0cc 100644 --- a/apps/like/views.py +++ b/apps/like/views.py @@ -1,13 +1,17 @@ -from django.db import transaction, IntegrityError -from django.db.models import QuerySet, F +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 ValidationError, NotFound +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 @@ -18,17 +22,21 @@ # return Like.objects.filter(user=user).order_by("-created_at") -class LikeListCreateView(generics.ListCreateAPIView): +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: LikeSerializer) -> None: + 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(): @@ -40,22 +48,28 @@ def perform_create(self, serializer: LikeSerializer) -> None: raise ValidationError("Already liked this product.") -class LikeDestroyView(generics.DestroyAPIView): +class LikeDestroyView(generics.DestroyAPIView[Like]): # serializer_class = LikeSerializer permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly] - def get_object(self) -> QuerySet[Like]: + def get_object(self) -> Like: product_id = self.kwargs.get("pk") - like = Like.objects.filter(user=self.request.user, product_id=product_id) - if not like: + 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.") - return like @transaction.atomic - def perform_destroy(self, instance): + 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"]) + # product.likes = F("likes") - 1 + # product.save(update_fields=["likes"]) diff --git a/config/settings/local.py b/config/settings/local.py index fbaccae..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"] From b7a6e72811f442a3de66234525dbbd1d267470b8 Mon Sep 17 00:00:00 2001 From: yyysolhhh Date: Sun, 26 May 2024 03:35:51 +0900 Subject: [PATCH 5/7] =?UTF-8?q?Tests:=20like=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestLikeListCreateView - list 테스트 - 정상 create 테스트 - product 없이 테스트 - 이미 좋아요한 상태 테스트 - 로그인 안하고 테스트 TestLikeDestroyView - 정상 삭제 테스트 - 이미 삭제한 좋아요 테스트 - 좋아요하지 않은 유저로 테스트 UserDetailViewTests - 이미지 업로드 테스트 다시 주석 해제 --- apps/like/models.py | 1 - apps/like/tests.py | 130 ++++++++++++++++++++++++++++++++++++++++++++ apps/like/urls.py | 2 +- apps/user/tests.py | 43 +++++++-------- 4 files changed, 151 insertions(+), 25 deletions(-) diff --git a/apps/like/models.py b/apps/like/models.py index cbca50d..7f4585e 100644 --- a/apps/like/models.py +++ b/apps/like/models.py @@ -5,7 +5,6 @@ from apps.user.models import Account -# Create your models here. class Like(BaseModel): user = models.ForeignKey(Account, on_delete=models.CASCADE) product = models.ForeignKey(Product, on_delete=models.CASCADE) diff --git a/apps/like/tests.py b/apps/like/tests.py index 2e9cb5f..58b35d7 100644 --- a/apps/like/tests.py +++ b/apps/like/tests.py @@ -1 +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 index 6d44bbc..45b2dee 100644 --- a/apps/like/urls.py +++ b/apps/like/urls.py @@ -3,7 +3,7 @@ from apps.like.views import LikeDestroyView, LikeListCreateView urlpatterns = [ - path("", LikeListCreateView.as_view(), name="like_list"), + path("", LikeListCreateView.as_view(), name="likes"), # path("/", LikeCreateView.as_view(), name="like_create"), path("/", LikeDestroyView.as_view(), name="like_delete"), ] 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): From 963326c5b24d79b6b5d880d1b79b48ac9bf42e0e Mon Sep 17 00:00:00 2001 From: yyysolhhh Date: Sun, 26 May 2024 17:46:31 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Docs:=20like=20=ED=94=8C=EB=A1=9C=EC=9A=B0?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8,=20=EC=8B=9C=ED=80=80=EC=8A=A4=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 간략하게 그려봄 --- .gitignore | 2 +- diagrams/like_diagram.md | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 diagrams/like_diagram.md diff --git a/.gitignore b/.gitignore index 71238b0..90192f8 100644 --- a/.gitignore +++ b/.gitignore @@ -500,6 +500,6 @@ dockerfail/ failed_files/ config/settings/settings.py temp-action/ -diagrams/ +#diagrams/ # End of https://www.toptal.com/developers/gitignore/api/python,django,pycharm,vim,visualstudiocode,venv,macos,windows 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 From 71e771cfca88af759a3d9729653a233d055f27d6 Mon Sep 17 00:00:00 2001 From: yyysolhhh Date: Sun, 26 May 2024 23:14:13 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Style:=20black,=20isort=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쉼표 추가됨 --- apps/user/urls.py | 2 +- apps/user/views.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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