diff --git a/customk/classes/admin.py b/customk/classes/admin.py index 0db94a2..a03b22b 100644 --- a/customk/classes/admin.py +++ b/customk/classes/admin.py @@ -84,17 +84,42 @@ class ExchangeRateAdmin(admin.ModelAdmin): # type: ignore @admin.register(ClassImages) class ClassImagesAdmin(admin.ModelAdmin): form = ClassImagesForm - list_display = ["class_id", "image_url"] + list_display = [ + "class_id", + "thumbnail_image_urls", + "description_image_urls", + "detail_image_urls", + ] search_fields = ["class_id"] - def save_model(self, request, obj, form, change): # + def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) - if "images" in request.FILES: - image_file = request.FILES["images"] - image_url = upload_image_to_object_storage(image_file) - obj.image_url = image_url - obj.save() + # Handle thumbnail image + if form.cleaned_data.get("thumbnail_image"): + thumbnail_image = form.cleaned_data["thumbnail_image"] + thumbnail_url = upload_image_to_object_storage(thumbnail_image) + thumbnail_image_urls = obj.thumbnail_image_urls or [] + thumbnail_image_urls.append(thumbnail_url) + obj.thumbnail_image_urls = thumbnail_image_urls + + # Handle description image + if form.cleaned_data.get("description_image"): + description_image = form.cleaned_data["description_image"] + description_url = upload_image_to_object_storage(description_image) + description_image_urls = obj.description_image_urls or [] + description_image_urls.append(description_url) + obj.description_image_urls = description_image_urls + + # Handle detail image + if form.cleaned_data.get("detail_image"): + detail_image = form.cleaned_data["detail_image"] + detail_url = upload_image_to_object_storage(detail_image) + detail_image_urls = obj.detail_image_urls or [] + detail_image_urls.append(detail_url) + obj.detail_image_urls = detail_image_urls + + obj.save() @admin.register(Genre) diff --git a/customk/classes/forms.py b/customk/classes/forms.py index f9fb86d..59b7249 100644 --- a/customk/classes/forms.py +++ b/customk/classes/forms.py @@ -4,8 +4,10 @@ class ClassImagesForm(forms.ModelForm): - images = forms.ImageField() + thumbnail_image = forms.ImageField(required=False) + detail_image = forms.ImageField(required=False) + description_image = forms.ImageField(required=False) class Meta: model = ClassImages - fields = ["class_id", "images"] + fields = ["class_id", "thumbnail_image", "detail_image", "description_image"] diff --git a/customk/classes/migrations/0016_remove_classimages_image_url_and_more.py b/customk/classes/migrations/0016_remove_classimages_image_url_and_more.py new file mode 100644 index 0000000..1d136bd --- /dev/null +++ b/customk/classes/migrations/0016_remove_classimages_image_url_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1 on 2024-09-02 06:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("classes", "0015_class_discount_rate"), + ] + + operations = [ + migrations.RemoveField( + model_name="classimages", + name="image_url", + ), + migrations.AddField( + model_name="classimages", + name="description_image_url", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="classimages", + name="detail_image_url", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name="classimages", + name="thumbnail_image_url", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + ] diff --git a/customk/classes/migrations/0017_remove_classimages_description_image_url_and_more.py b/customk/classes/migrations/0017_remove_classimages_description_image_url_and_more.py new file mode 100644 index 0000000..e98a0cb --- /dev/null +++ b/customk/classes/migrations/0017_remove_classimages_description_image_url_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1 on 2024-09-02 07:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("classes", "0016_remove_classimages_image_url_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="classimages", + name="description_image_url", + ), + migrations.RemoveField( + model_name="classimages", + name="detail_image_url", + ), + migrations.RemoveField( + model_name="classimages", + name="thumbnail_image_url", + ), + migrations.AddField( + model_name="classimages", + name="description_image_urls", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="classimages", + name="detail_image_urls", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="classimages", + name="thumbnail_image_urls", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/customk/classes/migrations/0018_remove_classimages_description_image_urls_and_more.py b/customk/classes/migrations/0018_remove_classimages_description_image_urls_and_more.py new file mode 100644 index 0000000..7d6a8f9 --- /dev/null +++ b/customk/classes/migrations/0018_remove_classimages_description_image_urls_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1 on 2024-09-02 08:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("classes", "0017_remove_classimages_description_image_url_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="classimages", + name="description_image_urls", + ), + migrations.RemoveField( + model_name="classimages", + name="detail_image_urls", + ), + migrations.RemoveField( + model_name="classimages", + name="thumbnail_image_urls", + ), + migrations.AddField( + model_name="classimages", + name="description_image_url", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="classimages", + name="detail_image_url", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name="classimages", + name="thumbnail_image_url", + field=models.CharField(default="", max_length=255), + preserve_default=False, + ), + ] diff --git a/customk/classes/migrations/0019_alter_classimages_detail_image_url_and_more.py b/customk/classes/migrations/0019_alter_classimages_detail_image_url_and_more.py new file mode 100644 index 0000000..8c4bfe3 --- /dev/null +++ b/customk/classes/migrations/0019_alter_classimages_detail_image_url_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1 on 2024-09-02 08:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("classes", "0018_remove_classimages_description_image_urls_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="classimages", + name="detail_image_url", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AlterField( + model_name="classimages", + name="thumbnail_image_url", + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/customk/classes/migrations/0020_remove_classimages_description_image_url_and_more.py b/customk/classes/migrations/0020_remove_classimages_description_image_url_and_more.py new file mode 100644 index 0000000..c98b1e5 --- /dev/null +++ b/customk/classes/migrations/0020_remove_classimages_description_image_url_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1 on 2024-09-02 08:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("classes", "0019_alter_classimages_detail_image_url_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="classimages", + name="description_image_url", + ), + migrations.RemoveField( + model_name="classimages", + name="detail_image_url", + ), + migrations.RemoveField( + model_name="classimages", + name="thumbnail_image_url", + ), + migrations.AddField( + model_name="classimages", + name="description_image_urls", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="classimages", + name="detail_image_urls", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="classimages", + name="thumbnail_image_urls", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/customk/classes/models.py b/customk/classes/models.py index d318aa2..96e96fd 100644 --- a/customk/classes/models.py +++ b/customk/classes/models.py @@ -2,9 +2,8 @@ from typing import Any, Dict, Optional from django.core.serializers.json import DjangoJSONEncoder -from django.core.validators import MinValueValidator from django.db import models -from django.db.models import Avg +from django.db.models import Avg, JSONField from common.models import CommonModel @@ -82,7 +81,9 @@ class ClassDate(models.Model): class ClassImages(models.Model): class_id = models.ForeignKey(Class, related_name="images", on_delete=models.CASCADE) - image_url = models.CharField() + description_image_urls = JSONField(blank=True, default=list) + detail_image_urls = JSONField(blank=True, default=list) + thumbnail_image_urls = JSONField(blank=True, default=list) def __str__(self) -> str: return f"{self.class_id.title}" diff --git a/customk/classes/serializers.py b/customk/classes/serializers.py index 6343ce8..2727b42 100644 --- a/customk/classes/serializers.py +++ b/customk/classes/serializers.py @@ -55,13 +55,20 @@ class Meta: class ClassImagesSerializer(serializers.ModelSerializer): class_id = serializers.PrimaryKeyRelatedField(read_only=True) + thumbnail_image_url = serializers.CharField(required=False) + description_image_url = serializers.CharField(required=False) + detail_image_url = serializers.CharField(required=False) class Meta: model = ClassImages fields = "__all__" - def get_image_url(self, obj): - return obj.image_url + def get_image_urls(self, obj): + return { + "thumbnail_image_url": obj.thumbnail_image_url, + "description_image_url": obj.description_image_url, + "additional_image_url": obj.additional_image_url, + } class ClassSerializer(serializers.ModelSerializer): @@ -123,7 +130,21 @@ def create(self, validated_data): ClassDate.objects.create(class_id=class_instance, **date_data) for image_data64 in images_data64: - image_url = upload_image_to_object_storage(image_data64["image_url"]) - ClassImages.objects.create(class_id=class_instance, image_url=image_url) + thumbnail_url = upload_image_to_object_storage( + image_data64["thumbnail_image_url"] + ) + description_url = upload_image_to_object_storage( + image_data64["description_image_url"] + ) + detail_image_url = upload_image_to_object_storage( + image_data64["detail_image_url"] + ) + + ClassImages.objects.create( + class_id=class_instance, + thumbnail_image_url=thumbnail_url, + description_image_url=description_url, + detail_image_url=detail_image_url, + ) return class_instance diff --git a/customk/classes/urls.py b/customk/classes/urls.py index 2b11138..3013427 100644 --- a/customk/classes/urls.py +++ b/customk/classes/urls.py @@ -1,7 +1,8 @@ from django.urls import re_path -from classes.views import ClassListView +from classes.views import ClassDetailView, ClassListView urlpatterns = [ re_path(r"^$", ClassListView.as_view(), name="class-list"), + re_path(r"^(?P\d+)/$", ClassDetailView.as_view(), name="class-detail"), ] diff --git a/customk/classes/views.py b/customk/classes/views.py index f6ee55b..42be6e8 100644 --- a/customk/classes/views.py +++ b/customk/classes/views.py @@ -1,6 +1,7 @@ from typing import Any -from drf_spectacular.utils import OpenApiResponse, extend_schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -120,3 +121,50 @@ def delete(self, request: Request, *args: Any, **kwargs: Any) -> Response: return Response( {"status": "error", "message": "삭제 실패했습니다"}, status=404 ) + + +class ClassDetailView(APIView): + def get_permissions(self): + if self.request.method == "GET": + return [AllowAny()] + return [IsAuthenticated()] + + @extend_schema( + methods=["GET"], + summary="특정 클래스 조회", + description="특정 클래스의 세부 정보를 조회하는 API입니다.", + parameters=[ + OpenApiParameter( + name="class_id", + description="클래스 ID", + required=True, + type=OpenApiTypes.INT, + location=OpenApiParameter.PATH, + ), + ], + responses={ + 200: OpenApiResponse( + description="클래스 조회 성공", response=ClassSerializer + ), + 404: OpenApiResponse(description="클래스가 존재하지 않음"), + }, + ) + def get(self, request: Request, *args: Any, **kwargs: Any) -> Response: + class_id = kwargs.get("class_id") + if class_id is None: + return Response( + {"status": "error", "message": "Class ID not provided"}, status=400 + ) + try: + class_instance = Class.objects.get(id=class_id) + serializer = ClassSerializer(class_instance) + response_data = { + "status": "success", + "message": "Class fetched successfully", + "data": serializer.data, + } + return Response(response_data, status=200) + except Class.DoesNotExist: + return Response( + {"status": "error", "message": "Class not found"}, status=404 + ) diff --git a/customk/config/settings.py b/customk/config/settings.py index 34795d5..c67b686 100644 --- a/customk/config/settings.py +++ b/customk/config/settings.py @@ -33,6 +33,7 @@ "reviews", "reactions", "corsheaders", + "favorites", ] diff --git a/customk/config/urls.py b/customk/config/urls.py index 436337a..5a61b7e 100644 --- a/customk/config/urls.py +++ b/customk/config/urls.py @@ -1,5 +1,5 @@ from django.contrib import admin -from django.urls import include, path, re_path +from django.urls import include, re_path from drf_spectacular.views import ( SpectacularAPIView, SpectacularRedocView, @@ -27,5 +27,6 @@ re_path(r"^v1/classes/?", include("classes.urls")), re_path(r"^v1/question/?", include("questions.urls")), re_path(r"^v1/reviews/?", include("reviews.urls")), + re_path(r"^v1/favorites/?", include("favorites.urls")), re_path(r"^v1/reactions/?", include("reactions.urls")), ] diff --git a/customk/favorites/__init__.py b/customk/favorites/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/customk/favorites/admin.py b/customk/favorites/admin.py new file mode 100644 index 0000000..c16617a --- /dev/null +++ b/customk/favorites/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import Favorite + + +@admin.register(Favorite) +class FavoriteAdmin(admin.ModelAdmin): # type: ignore + pass diff --git a/customk/favorites/apps.py b/customk/favorites/apps.py new file mode 100644 index 0000000..1fc39c6 --- /dev/null +++ b/customk/favorites/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FavoritesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "favorites" diff --git a/customk/favorites/migrations/0001_initial.py b/customk/favorites/migrations/0001_initial.py new file mode 100644 index 0000000..623775c --- /dev/null +++ b/customk/favorites/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1 on 2024-09-02 04:59 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Favorite", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("class_id", models.IntegerField()), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="favorites", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("user", "class_id"), name="unique_users_class" + ) + ], + }, + ), + ] diff --git a/customk/favorites/migrations/__init__.py b/customk/favorites/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/customk/favorites/models.py b/customk/favorites/models.py new file mode 100644 index 0000000..411f018 --- /dev/null +++ b/customk/favorites/models.py @@ -0,0 +1,21 @@ +from django.db import models + +from classes.models import Class +from common.models import CommonModel +from users.models import User + + +class Favorite(CommonModel): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="favorites") + class_id = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "class_id"], name="unique_users_class" + ) + ] + + def __str__(self): + klass = Class.objects.filter(pk=self.class_id).first() + return f"{self.user.username} likes {klass.title}" diff --git a/customk/favorites/serializers.py b/customk/favorites/serializers.py new file mode 100644 index 0000000..409f259 --- /dev/null +++ b/customk/favorites/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from classes.models import Class +from classes.serializers import ClassSerializer + +from .models import Favorite + + +class FavoriteSerializer(serializers.ModelSerializer): + klass = serializers.SerializerMethodField() + + class Meta: + model = Favorite + fields = ["id", "user", "class_id", "klass"] + + def get_klass(self, obj): + try: + class_instance = Class.objects.get(id=obj.class_id) + return ClassSerializer(class_instance).data + except Class.DoesNotExist: + return None diff --git a/customk/favorites/services.py b/customk/favorites/services.py new file mode 100644 index 0000000..f7af511 --- /dev/null +++ b/customk/favorites/services.py @@ -0,0 +1,12 @@ +from django.db import transaction + +from .models import Favorite + + +@transaction.atomic +def add_favorite_class(user_id: int, class_id: int) -> tuple[Favorite, bool]: + return Favorite.objects.get_or_create(user_id=user_id, class_id=class_id) + + +def delete_favorite_class(favorite_id: int) -> None: + Favorite.objects.filter(id=favorite_id).delete() diff --git a/customk/favorites/tests/__init__.py b/customk/favorites/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/customk/favorites/tests/conftest.py b/customk/favorites/tests/conftest.py new file mode 100644 index 0000000..0170e2e --- /dev/null +++ b/customk/favorites/tests/conftest.py @@ -0,0 +1,23 @@ +import pytest + +from classes.models import Class +from favorites.services import add_favorite_class + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def favorite_instance(sample_user, sample_class): + return add_favorite_class(user_id=sample_user.id, class_id=sample_class.id) + + +@pytest.fixture +def sample_class2(): + return Class.objects.create( + title="Sample Class2", + description="This is a sample class2", + max_person=100, + require_person=50, + price=50000, + address={"state": "Seoul", "city": "Gangnam", "street": "Teheran-ro"}, + ) diff --git a/customk/favorites/tests/test_favorite_api.py b/customk/favorites/tests/test_favorite_api.py new file mode 100644 index 0000000..618d5fb --- /dev/null +++ b/customk/favorites/tests/test_favorite_api.py @@ -0,0 +1,50 @@ +# ruff: noqa: F811 + +import pytest +from django.urls import reverse +from rest_framework import status + +from classes.tests.conftest import sample_class +from favorites.models import Favorite +from users.tests.conftest import ( + access_token, + api_client_with_token, + refresh_token, + sample_user, +) + +pytestmark = pytest.mark.django_db + + +def test_get_favorites(api_client_with_token, sample_class, favorite_instance): + url = reverse("favorite") + response = api_client_with_token.get(url, {"page": 1, "size": 10}) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["total_count"] == 1 + assert len(data["results"]) == 1 + + +def test_post_favorite(api_client_with_token, sample_user, sample_class2): + url = reverse("favorite") + f"?class_id={sample_class2.id}" + response = api_client_with_token.post(url, data={}) + + assert response.status_code == status.HTTP_201_CREATED + + +def test_delete_favorite( + api_client_with_token, sample_user, sample_class, favorite_instance +): + data, _ = favorite_instance + + url = reverse("favorite") + f"?favorite_id={data.id}" + response = api_client_with_token.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert ( + Favorite.objects.filter( + user_id=sample_user.id, class_id=sample_class.id + ).count() + == 0 + ) diff --git a/customk/favorites/urls.py b/customk/favorites/urls.py new file mode 100644 index 0000000..b15010a --- /dev/null +++ b/customk/favorites/urls.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from .views import FavoriteView + +urlpatterns = [ + re_path(r"^$", FavoriteView.as_view(), name="favorite"), +] diff --git a/customk/favorites/views.py b/customk/favorites/views.py new file mode 100644 index 0000000..4b946ad --- /dev/null +++ b/customk/favorites/views.py @@ -0,0 +1,154 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers, status +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from classes.serializers import ClassSerializer + +from .models import Favorite +from .serializers import FavoriteSerializer +from .services import add_favorite_class, delete_favorite_class + + +class FavoriteView(APIView): + @extend_schema( + methods=["GET"], + summary="찜한 클래스 목록 조회", + description="유저가 찜한 클래스 목록을 페이지네이션형태로 가져옵니다", + parameters=[ + OpenApiParameter( + name="page", + description="페이지", + required=False, + default=1, + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + ), + OpenApiParameter( + name="size", + description="사이즈", + required=False, + default=10, + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + ), + ], + responses={ + 200: inline_serializer( + name="FavoriteListResponse", + fields={ + "total_count": serializers.IntegerField(), + "total_pages": serializers.IntegerField(), + "current_page": serializers.IntegerField(), + "results": ClassSerializer(many=True), + }, + ), + 400: OpenApiResponse(description="Page input error"), + }, + ) + def get(self, request: Request, *args, **kwargs) -> Response: + page = int(request.GET.get("page", "1")) + size = int(request.GET.get("size", "10")) + offset = (page - 1) * size + user = request.user + if page < 1: + return Response("page input error", status=status.HTTP_400_BAD_REQUEST) + + total_count = Favorite.objects.filter(user_id=user.id).count() + total_pages = (total_count // size) + 1 + + favorites = Favorite.objects.filter(user_id=user.id).order_by("-id")[ + offset : offset + size + ] + + serializer = FavoriteSerializer(favorites, many=True) + + return Response( + { + "total_count": total_count, + "total_pages": total_pages, + "current_page": page, + "results": serializer.data, + }, + status=status.HTTP_200_OK, + ) + + @extend_schema( + methods=["POST"], + summary="찜 클래스 생성", + description="찜 할 클래스를 생성하는 API", + parameters=[ + OpenApiParameter( + name="class_id", + description="찜할 클래스 id", + required=True, + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + ) + ], + request=None, + responses={ + 200: OpenApiResponse(description="이미 찜한 클래스입니다."), + 201: OpenApiResponse( + description="클래스 생성 성공", response=ClassSerializer + ), + 400: OpenApiResponse(description="class_id is required"), + }, + ) + def post(self, request: Request, *args, **kwargs) -> Response: + class_id = int(request.GET.get("class_id", 0)) + + if class_id == 0: + return Response( + {"error": "class_id is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + favorite, created = add_favorite_class( + user_id=request.user.id, class_id=class_id + ) + serializer = FavoriteSerializer(favorite) + + if created: + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response({"message": "Already favorited"}, status=status.HTTP_200_OK) + + @extend_schema( + methods=["DELETE"], + summary="찜 한 클래스 삭제", + description="찜 한 클래스를 삭제하는 API", + parameters=[ + OpenApiParameter( + name="favorite_id", + description="삭제할 찜 id", + required=True, + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + ) + ], + request=None, + responses={ + 204: OpenApiResponse(description="클래스 삭제 성공"), + 400: OpenApiResponse(description="class_id is required"), + }, + ) + def delete(self, request: Request, *args, **kwargs) -> Response: + favorite_id = int(request.GET.get("favorite_id", 0)) + + if favorite_id == 0: + return Response( + {"error": "class_id is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + delete_favorite_class(favorite_id=favorite_id) + + return Response( + {"message": "success delete"}, status=status.HTTP_204_NO_CONTENT + ) diff --git a/customk/users/serializers/user_serializer.py b/customk/users/serializers/user_serializer.py index dbe3312..34089a6 100644 --- a/customk/users/serializers/user_serializer.py +++ b/customk/users/serializers/user_serializer.py @@ -63,7 +63,7 @@ class Meta: "profile_image_url", ) - def get_profile_image_url(self, obj): + def get_profile_image_url(self, obj) -> str: return obj.profile_image def velidate(self, data): @@ -107,7 +107,7 @@ class Meta: model = User fields = ("name", "profile_image", "profile_image_url") - def get_profile_image_url(self, obj): + def get_profile_image_url(self, obj) -> str: return obj.profile_image def update(self, instance, validated_data): diff --git a/customk/users/services/token_service.py b/customk/users/services/token_service.py index 206a02e..f5f606a 100644 --- a/customk/users/services/token_service.py +++ b/customk/users/services/token_service.py @@ -65,10 +65,13 @@ def set_cookies(request: Request, response: Response, token: Token) -> Response: domain=domain, ) - if hasattr(response, 'data'): - response.data['access_token'] = token.access_token - response.data['refresh_token'] = token.refresh_token + if hasattr(response, "data"): + response.data["access_token"] = token.access_token + response.data["refresh_token"] = token.refresh_token else: - response.data = {'access_token': token.access_token, 'refresh_token': token.refresh_token} + response.data = { + "access_token": token.access_token, + "refresh_token": token.refresh_token, + } return response diff --git a/customk/users/tests/conftest.py b/customk/users/tests/conftest.py index 4672271..b74f410 100644 --- a/customk/users/tests/conftest.py +++ b/customk/users/tests/conftest.py @@ -4,6 +4,8 @@ from users.serializers.user_serializer import UserSerializer +pytestmark = pytest.mark.django_db + @pytest.fixture def api_client(): @@ -11,7 +13,7 @@ def api_client(): @pytest.fixture -def sample_user(django_db_setup): +def sample_user(): data = { "name": "testname", "email": "test@example.com", diff --git a/customk/users/tests/test_token_api.py b/customk/users/tests/test_token_api.py index c4e779a..0ca5519 100644 --- a/customk/users/tests/test_token_api.py +++ b/customk/users/tests/test_token_api.py @@ -5,53 +5,53 @@ pytestmark = pytest.mark.django_db -class TestCustomTokenRefreshView: - def test_refresh_token_success(self, api_client, refresh_token): - api_client.cookies["refresh_token"] = refresh_token - url = reverse("token_refresh") - response = api_client.post(url) +def test_refresh_token_success(api_client, refresh_token): + api_client.cookies["refresh_token"] = refresh_token + url = reverse("token_refresh") + response = api_client.post(url) - assert response.status_code == status.HTTP_200_OK - assert "access" not in response.data - assert "refresh" not in response.data - assert "access_token" in response.cookies + assert response.status_code == status.HTTP_200_OK + assert "access_token" in response.cookies - def test_refresh_token_missing(self, api_client): - url = reverse("token_refresh") - response = api_client.post(url) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["detail"] == "Refresh token not found in cookies." +def test_refresh_token_missing(api_client): + url = reverse("token_refresh") + response = api_client.post(url) - def test_refresh_token_invalid(self, api_client): - api_client.cookies["refresh_token"] = "invalid_token" - url = reverse("token_refresh") - response = api_client.post(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["detail"] == "Refresh token not found in cookies." - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["detail"] == "Invalid refresh token." +def test_refresh_token_invalid(api_client): + api_client.cookies["refresh_token"] = "invalid_token" + url = reverse("token_refresh") + response = api_client.post(url) -class TestCustomTokenVerifyView: - def test_verify_token_success(self, api_client, access_token): - api_client.cookies["access_token"] = access_token - url = reverse("token_verify") - response = api_client.post(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["detail"] == "Invalid refresh token." - assert response.status_code == status.HTTP_200_OK - assert response.data["detail"] == "Token is valid" - def test_verify_token_missing(self, api_client): - url = reverse("token_verify") - response = api_client.post(url) +def test_verify_token_success(api_client, access_token): + api_client.cookies["access_token"] = access_token + url = reverse("token_verify") + response = api_client.post(url) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["detail"] == "Access token not found in cookies." + assert response.status_code == status.HTTP_200_OK + assert response.data["detail"] == "Token is valid" - def test_verify_token_invalid(self, api_client): - api_client.cookies["access_token"] = "invalid_token" - url = reverse("token_verify") - response = api_client.post(url) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.data["detail"] == "Token is invalid or expired" +def test_verify_token_missing(api_client): + url = reverse("token_verify") + response = api_client.post(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["detail"] == "Access token not found in cookies." + + +def test_verify_token_invalid(api_client): + api_client.cookies["access_token"] = "invalid_token" + url = reverse("token_verify") + response = api_client.post(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["detail"] == "Token is invalid or expired" diff --git a/customk/users/views/line.py b/customk/users/views/line.py index b11e5f5..e6ab3e2 100644 --- a/customk/users/views/line.py +++ b/customk/users/views/line.py @@ -70,25 +70,9 @@ def callback(request: Request) -> Response: profile_response.raise_for_status() profile = profile_response.json() - email = profile.get("email") + email = profile.get("email", "") name = profile.get("name") - profile_image = profile.get("picture") - - if not profile_image: - profile_image = "default.image.uri" # TODO: 기본 이미지 필요 - - if not email: - logger.warning("line email empty") - return Response( - data={ - "message": "이메일이 존재하지 않습니다.", - "result": { - "name": name, - "profile_image": profile_image, - }, - }, - status=status.HTTP_400_BAD_REQUEST, - ) + profile_image = profile.get("picture", "") user_data = { "email": email, diff --git a/customk/users/views/token.py b/customk/users/views/token.py index fe73dea..e291034 100644 --- a/customk/users/views/token.py +++ b/customk/users/views/token.py @@ -1,4 +1,5 @@ -from typing import Any +from datetime import timedelta +from typing import Any, cast from drf_spectacular.utils import ( OpenApiExample, @@ -12,6 +13,7 @@ from rest_framework_simplejwt.exceptions import InvalidToken from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView +from config import settings from config.logger import logger @@ -68,14 +70,23 @@ def post(self, request: Request, *args: Any, **kwargs: Any) -> Response: if response.status_code == 200: access_token = response.data.get("access") + from users.services.token_service import get_domain + + domain = get_domain(request=request) + access_max_age = int( + cast( + timedelta, settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] + ).total_seconds() + ) response.set_cookie( key="access_token", value=access_token, + max_age=access_max_age, + domain=domain, httponly=True, secure=True, - samesite="Lax", ) - del response.data["access"] + # del response.data["access"] del response.data["refresh"] return response