Skip to content

Commit 3a00e2e

Browse files
authored
Merge: 상품 좋아요 기능 구현
[Feature/like] 상품 좋아요 기능 구현
2 parents b9b3660 + 71e771c commit 3a00e2e

19 files changed

+324
-33
lines changed

.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -496,9 +496,10 @@ $RECYCLE.BIN/
496496
data/
497497
dockerfail/
498498

499-
#nginx/
499+
# others
500500
failed_files/
501501
config/settings/settings.py
502502
temp-action/
503+
#diagrams/
503504

504505
# End of https://www.toptal.com/developers/gitignore/api/python,django,pycharm,vim,visualstudiocode,venv,macos,windows

apps/like/__init__.py

Whitespace-only changes.

apps/like/admin.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.contrib import admin
2+
3+
# Register your models here.

apps/like/apps.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class LikeConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "apps.like"

apps/like/models.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from django.db import models
2+
3+
from apps.common.models import BaseModel
4+
from apps.product.models import Product
5+
from apps.user.models import Account
6+
7+
8+
class Like(BaseModel):
9+
user = models.ForeignKey(Account, on_delete=models.CASCADE)
10+
product = models.ForeignKey(Product, on_delete=models.CASCADE)
11+
12+
class Meta:
13+
constraints = [
14+
models.UniqueConstraint(fields=["user", "product"], name="unique_user_product"),
15+
]

apps/like/permissions.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from typing import Any
2+
3+
from rest_framework import permissions
4+
from rest_framework.request import Request
5+
from rest_framework.views import APIView
6+
7+
8+
class IsUserOrReadOnly(permissions.BasePermission):
9+
def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool:
10+
if request.method in permissions.SAFE_METHODS:
11+
return True
12+
# return str(request.user) == obj.lender.email
13+
return request.user == getattr(obj, "user", None)

apps/like/serializers.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from rest_framework import serializers
2+
3+
from apps.like.models import Like
4+
from apps.product.models import Product
5+
from apps.product.serializers import ProductSerializer
6+
7+
8+
class LikeSerializer(serializers.ModelSerializer[Like]):
9+
product_id = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all(), write_only=True)
10+
product = ProductSerializer(read_only=True)
11+
12+
class Meta:
13+
model = Like
14+
fields = ("id", "product_id", "product")

apps/like/tests.py

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from django.test import TestCase
2+
from django.urls import reverse
3+
from rest_framework import status
4+
from rest_framework.test import APIClient, APITestCase
5+
from rest_framework_simplejwt.tokens import AccessToken
6+
7+
from apps.category.models import Category
8+
from apps.like.models import Like
9+
from apps.product.models import Product
10+
from apps.user.models import Account
11+
12+
13+
class TestLikeListCreateView(APITestCase):
14+
def setUp(self) -> None:
15+
# self.client = APIClient()
16+
self.url = reverse("likes")
17+
data = {
18+
"email": "[email protected]",
19+
"password": "fels3570",
20+
"nickname": "nick",
21+
"phone": "1234",
22+
}
23+
self.user = Account.objects.create_user(**data)
24+
# self.client.force_login(user=self.user)
25+
# self.token = AccessToken.for_user(self.user)
26+
self.category = Category.objects.create(name="test category")
27+
self.product = Product.objects.create(
28+
name="test product",
29+
lender=self.user,
30+
brand="test brand",
31+
condition="good",
32+
purchase_date="2024-01-01",
33+
purchase_price=10000,
34+
rental_fee=1000,
35+
size="xs",
36+
product_category=self.category,
37+
)
38+
39+
def test_list_likes(self) -> None:
40+
self.client.force_authenticate(user=self.user)
41+
Like.objects.create(user=self.user, product=self.product)
42+
res = self.client.get(self.url)
43+
self.assertEqual(res.status_code, status.HTTP_200_OK)
44+
self.assertEqual(res.data.get("count"), 1)
45+
46+
def test_create_like(self) -> None:
47+
self.client.force_authenticate(user=self.user)
48+
data = {"product_id": self.product.uuid}
49+
res = self.client.post(self.url, data, format="json")
50+
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
51+
self.assertTrue(Like.objects.filter(user=self.user, product=self.product).exists())
52+
53+
def test_create_like_without_product_id(self) -> None:
54+
self.client.force_authenticate(user=self.user)
55+
res = self.client.post(self.url, {})
56+
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
57+
self.assertFalse(Like.objects.filter(user=self.user, product=self.product).exists())
58+
59+
def test_create_like_already_exists(self) -> None:
60+
self.client.force_authenticate(user=self.user)
61+
Like.objects.create(user=self.user, product=self.product)
62+
data = {"product_id": self.product.uuid}
63+
res = self.client.post(self.url, data, format="json")
64+
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
65+
self.assertEqual(res.data, ["Already liked this product."])
66+
67+
def test_create_list_without_login(self) -> None:
68+
data = {"product_id": self.product.uuid}
69+
res = self.client.post(self.url, data, format="json")
70+
self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
71+
72+
73+
class TestLikeDestroyView(APITestCase):
74+
def setUp(self) -> None:
75+
data = {
76+
"email": "[email protected]",
77+
"password": "fels3570",
78+
"nickname": "nick",
79+
"phone": "1234",
80+
}
81+
self.user = Account.objects.create_user(**data)
82+
self.category = Category.objects.create(name="test category")
83+
self.product = Product.objects.create(
84+
name="test product",
85+
lender=self.user,
86+
brand="test brand",
87+
condition="good",
88+
purchase_date="2024-01-01",
89+
purchase_price=10000,
90+
rental_fee=1000,
91+
size="xs",
92+
product_category=self.category,
93+
likes=1,
94+
)
95+
self.like = Like.objects.create(user=self.user, product=self.product)
96+
self.url = reverse("like_delete", kwargs={"pk": self.product.pk})
97+
98+
def test_delete_like(self) -> None:
99+
self.client.force_authenticate(user=self.user)
100+
res = self.client.delete(self.url)
101+
self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
102+
self.assertFalse(Like.objects.filter(user=self.user, product=self.product).exists())
103+
104+
# def test_like_count_decreased(self) -> None:
105+
# self.client.force_authenticate(user=self.user)
106+
# initial_count = self.product.likes
107+
# res = self.client.delete(self.url)
108+
# self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
109+
# self.assertEqual(self.product.likes, initial_count - 1)
110+
111+
def test_delete_like_not_found(self) -> None:
112+
self.client.force_authenticate(user=self.user)
113+
self.like.delete()
114+
res = self.client.delete(self.url)
115+
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
116+
117+
def test_delete_like_by_other_user(self) -> None:
118+
data = {
119+
"email": "[email protected]",
120+
"password": "fels3570",
121+
"nickname": "dfk",
122+
"phone": "1234",
123+
}
124+
other_user = Account.objects.create_user(**data)
125+
self.client.force_authenticate(other_user)
126+
res = self.client.delete(self.url)
127+
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
128+
129+
130+
class TestPermission(APITestCase):
131+
pass

apps/like/urls.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.urls import path
2+
3+
from apps.like.views import LikeDestroyView, LikeListCreateView
4+
5+
urlpatterns = [
6+
path("", LikeListCreateView.as_view(), name="likes"),
7+
# path("<uuid:pk>/", LikeCreateView.as_view(), name="like_create"),
8+
path("<uuid:pk>/", LikeDestroyView.as_view(), name="like_delete"),
9+
]

apps/like/views.py

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import Union
2+
from uuid import UUID
3+
4+
from django.db import IntegrityError, transaction
5+
from django.db.models import F, QuerySet
6+
from rest_framework import generics, permissions
7+
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
8+
from rest_framework.serializers import BaseSerializer
9+
10+
from apps.like.models import Like
11+
from apps.like.permissions import IsUserOrReadOnly
12+
from apps.like.serializers import LikeSerializer
13+
from apps.product.models import Product
14+
from apps.user.models import Account
15+
16+
# class LikeListView(generics.ListAPIView):
17+
# serializer_class = LikeSerializer
18+
# permission_classes = [permissions.IsAuthenticated]
19+
#
20+
# def get_queryset(self) -> QuerySet[Like]:
21+
# user = self.request.user
22+
# return Like.objects.filter(user=user).order_by("-created_at")
23+
24+
25+
class LikeListCreateView(generics.ListCreateAPIView[Like]):
26+
serializer_class = LikeSerializer
27+
permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly]
28+
29+
def get_queryset(self) -> QuerySet[Like]:
30+
user = self.request.user
31+
if not isinstance(user, Account):
32+
raise PermissionDenied("You must be logged in to view your likes.")
33+
return Like.objects.filter(user=user).order_by("-created_at")
34+
35+
def perform_create(self, serializer: BaseSerializer[Like]) -> None:
36+
product_id = self.request.data.get("product_id")
37+
# product = Product.objects.get(pk=product_id)
38+
if not product_id:
39+
raise ValidationError("You must provide a product ID.")
40+
41+
try:
42+
with transaction.atomic():
43+
serializer.save(user=self.request.user, product_id=product_id)
44+
Product.objects.filter(pk=product_id).update(likes=F("likes") + 1)
45+
# product.likes = F("likes") + 1
46+
# product.save(update_fields=["likes"])
47+
except IntegrityError:
48+
raise ValidationError("Already liked this product.")
49+
50+
51+
class LikeDestroyView(generics.DestroyAPIView[Like]):
52+
# serializer_class = LikeSerializer
53+
permissions_classes = [permissions.IsAuthenticated, IsUserOrReadOnly]
54+
55+
def get_object(self) -> Like:
56+
product_id = self.kwargs.get("pk")
57+
user = self.request.user if isinstance(self.request.user, Account) else None
58+
# like = Like.objects.filter(user=user, product_id=product_id).first()
59+
# if not like:
60+
# raise NotFound("No Like matches the given query.")
61+
# return like
62+
try:
63+
like = Like.objects.get(user=user, product_id=product_id)
64+
return like
65+
except Like.DoesNotExist:
66+
raise NotFound("No Like matches the given query.")
67+
68+
@transaction.atomic
69+
def perform_destroy(self, instance: Like) -> None:
70+
product_id = self.kwargs.get("pk")
71+
if instance:
72+
instance.delete()
73+
Product.objects.filter(pk=product_id).update(likes=F("likes") - 1)
74+
# product.likes = F("likes") - 1
75+
# product.save(update_fields=["likes"])

apps/product/models.py

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Product(BaseModel):
2626
status = models.BooleanField(default=True) # 대여 가능 여부
2727
amount = models.IntegerField(default=1)
2828
region = models.CharField(max_length=30, default="None")
29+
likes = models.IntegerField(default=0)
2930

3031
def __str__(self) -> str:
3132
return self.name

apps/product/serializers.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ class Meta:
5858
"created_at",
5959
"updated_at",
6060
"images",
61+
"likes",
6162
# "rental_history",
6263
)
63-
read_only_fields = ("created_at", "updated_at", "views", "lender", "status")
64+
read_only_fields = ("created_at", "updated_at", "views", "lender", "status", "likes")
6465

6566
@transaction.atomic
6667
def create(self, validated_data: Any) -> Product:

apps/user/tests.py

+20-23
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,12 @@ def generate_image_file(self) -> BytesIO:
250250
file.seek(0)
251251
return file
252252

253-
def generate_image_to_base64(self) -> bytes:
254-
file = BytesIO()
255-
image = Image.new("RGBA", size=(100, 100), color=(155, 0, 0))
256-
image.save(file, "png")
257-
img_str = base64.b64encode(file.getvalue())
258-
return img_str
253+
# def generate_image_to_base64(self) -> bytes:
254+
# file = BytesIO()
255+
# image = Image.new("RGBA", size=(100, 100), color=(155, 0, 0))
256+
# image.save(file, "png")
257+
# img_str = base64.b64encode(file.getvalue())
258+
# return img_str
259259

260260
def test_get_user_info(self) -> None:
261261
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:
323323
res = self.client.patch(self.url, data, headers={"Authorization": f"Bearer {self.token}"})
324324
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
325325

326-
# def test_upload_profile_image(self) -> None:
327-
# image_file = self.generate_image_file()
328-
# data = {"profile_img": image_file}
329-
# res = self.client.patch(self.url, data, format="multipart")
330-
# self.assertEqual(res.status_code, status.HTTP_200_OK)
326+
def test_upload_profile_image(self) -> None:
327+
image_file = self.generate_image_file()
328+
data = {"profile_img": image_file}
329+
res = self.client.patch(self.url, data, format="multipart", headers={"Authorization": f"Bearer {self.token}"})
330+
self.assertEqual(res.status_code, status.HTTP_200_OK)
331331

332332
# TODO: 테스트 돌릴 때마다 S3에 올라감 이슈
333-
# def test_update_profile_image(self) -> None:
334-
# profile_img = self.generate_image_file()
335-
# data = {
336-
# "email": "[email protected]",
337-
# "profile_img": profile_img
338-
# }
339-
# # Account.objects.create(**data)
340-
# res = self.client.patch(self.url, data, format="multipart")
341-
# image_file = self.generate_image_file()
342-
# data = {"email": "[email protected]", "profile_img": image_file}
343-
# res = self.client.patch(self.url, data, format="multipart")
344-
# self.assertEqual(res.status_code, status.HTTP_200_OK)
333+
def test_update_profile_image(self) -> None:
334+
profile_img = self.generate_image_file()
335+
data = {"email": "[email protected]", "profile_img": profile_img}
336+
# Account.objects.create(**data)
337+
res = self.client.patch(self.url, data, format="multipart", headers={"Authorization": f"Bearer {self.token}"})
338+
image_file = self.generate_image_file()
339+
data = {"email": "[email protected]", "profile_img": image_file}
340+
res = self.client.patch(self.url, data, format="multipart", headers={"Authorization": f"Bearer {self.token}"})
341+
self.assertEqual(res.status_code, status.HTTP_200_OK)
345342

346343

347344
class DeleteUserViewTests(TestCase):

apps/user/urls.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
CustomLoginView,
1818
CustomSignupView,
1919
DeleteUserView,
20-
KakaoLoginView,
2120
GoogleLoginView,
21+
KakaoLoginView,
2222
SendCodeView,
2323
)
2424

0 commit comments

Comments
 (0)