Skip to content

Commit

Permalink
Merge: 상품 좋아요 기능 구현
Browse files Browse the repository at this point in the history
[Feature/like] 상품 좋아요 기능 구현
  • Loading branch information
yyysolhhh authored May 26, 2024
2 parents b9b3660 + 71e771c commit 3a00e2e
Show file tree
Hide file tree
Showing 19 changed files with 324 additions and 33 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Empty file added apps/like/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions apps/like/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions apps/like/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class LikeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.like"
15 changes: 15 additions & 0 deletions apps/like/models.py
Original file line number Diff line number Diff line change
@@ -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"),
]
13 changes: 13 additions & 0 deletions apps/like/permissions.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 14 additions & 0 deletions apps/like/serializers.py
Original file line number Diff line number Diff line change
@@ -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")
131 changes: 131 additions & 0 deletions apps/like/tests.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions apps/like/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.urls import path

from apps.like.views import LikeDestroyView, LikeListCreateView

urlpatterns = [
path("", LikeListCreateView.as_view(), name="likes"),
# path("<uuid:pk>/", LikeCreateView.as_view(), name="like_create"),
path("<uuid:pk>/", LikeDestroyView.as_view(), name="like_delete"),
]
75 changes: 75 additions & 0 deletions apps/like/views.py
Original file line number Diff line number Diff line change
@@ -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"])
1 change: 1 addition & 0 deletions apps/product/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/product/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 20 additions & 23 deletions apps/user/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"})
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion apps/user/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
CustomLoginView,
CustomSignupView,
DeleteUserView,
KakaoLoginView,
GoogleLoginView,
KakaoLoginView,
SendCodeView,
)

Expand Down
Loading

0 comments on commit 3a00e2e

Please sign in to comment.