From 8db4dbf101d8247df4a51caf8eb6180a79b20b31 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Tue, 8 Apr 2025 15:49:45 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20url,=20intere?= =?UTF-8?q?sts=20serializer=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/users/serializers.py b/users/serializers.py index 7b05ed5..ac6d8c5 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -20,4 +20,9 @@ class JoinSerializer(serializers.ModelSerializer): ] def create(self, validated_data): - return User.objects.create_user(**validated_data) \ No newline at end of file + return User.objects.create_user(**validated_data) + +class SetPortofolioRequiredInfoSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['custom_url', 'job_and_interests'] \ No newline at end of file From 519a1560a7946c3ad5c0a3aaca2877edec5f4615 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Tue, 8 Apr 2025 15:51:07 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20custom=5Furl?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=EC=B2=B4=ED=81=AC=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20url=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/urls.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/users/urls.py b/users/urls.py index 8d3fc65..9179f1b 100644 --- a/users/urls.py +++ b/users/urls.py @@ -9,4 +9,6 @@ urlpatterns = [ path('join/', JoinAPIView.as_view()), path('login/', LoginAPIView.as_view()), path('check/', NicknameAPIView.as_view()), + path('portfolio-info/', SetPortofolioRequiredInfoAPIView.as_view()), + path('portfolio-info/check/', SetPortofolioRequiredInfoAPIView.as_view()) ] \ No newline at end of file From b30c0991d93581cede99dba500080453f71c03b8 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Tue, 8 Apr 2025 15:52:05 +0900 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20custom=5Furl?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=EC=B2=B4=ED=81=AC=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/views.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/users/views.py b/users/views.py index 68efe8a..0d5d232 100644 --- a/users/views.py +++ b/users/views.py @@ -94,4 +94,23 @@ class NicknameAPIView(APIView): return Response({"message": "해당 닉네임은 사용할 수 없습니다."}, status=status.HTTP_400_BAD_REQUEST) except: return Response({"message": "사용할 수 있는 닉네임입니다."}, status=status.HTTP_200_OK) - \ No newline at end of file + +class SetPortofolioRequiredInfoAPIView(APIView): + def get(self, request): + custom_url = request.GET.get('custom_url', None) + if not custom_url: + return Response({"message": "no url"}, status=status.HTTP_400_BAD_REQUEST) + if User.objects.filter(custom_url=custom_url).exists(): + return Response({"message": "already used url"}, status=status.HTTP_400_BAD_REQUEST) + else: + return Response({"message": "can use this url"}, status=status.HTTP_200_OK) + + def patch(self, request): + user = request.user + serializer = SetPortofolioRequiredInfoSerializer(user, data=request.data) + if serializer.is_valid(): + serializer.save() + user.is_custom_url = True + user.save() + return Response({"message": "updated successfully"}, status=status.HTTP_202_ACCEPTED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file From e78936ea6bd76f98ccbcdcda88e5f5534e548e73 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Wed, 9 Apr 2025 12:08:51 +0900 Subject: [PATCH 04/13] =?UTF-8?q?=E2=9E=95=20Dependency:=20[#22]=20django-?= =?UTF-8?q?filter=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | Bin 870 -> 912 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3db3c27f420438fd4303226af4344a1751da55bd..d45ea3262e2926b0bff6929bdb8c7ca626508b6b 100644 GIT binary patch delta 42 wcmaFHHi3P^GDb;VhBSsuh8%_xhE#?k23sIBVlZXUV=$b2o>6=A0mfyF0Og+wkN^Mx delta 12 UcmbQh{)}zIGRDmp7!NQ403x0RAOHXW From d187e6a0aba57f7fc9841f54f8ec016c92740edd Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:44:04 +0900 Subject: [PATCH 05/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20default=20fil?= =?UTF-8?q?ter=20backends=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/settings.py b/config/settings.py index bdd23db..be08536 100644 --- a/config/settings.py +++ b/config/settings.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ 'django.contrib.postgres', 'django_apscheduler', 'django_extensions', + 'django_filters', 'rest_framework', 'rest_framework_simplejwt', 'rest_framework_simplejwt.token_blacklist', @@ -162,6 +163,11 @@ REST_FRAMEWORK = { # 'rest_framework.permissions.IsAdminUser', # 관리자만 접근 # 'rest_framework.permissions.AllowAny', # 누구나 접근 ), + + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter', + ], } REST_USE_JWT = True From d7385066a13dbf3a2ff222ee0bfb3f822edc72aa Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:44:47 +0900 Subject: [PATCH 06/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20portfolio=20r?= =?UTF-8?q?oot=20url=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/config/urls.py b/config/urls.py index 10a0e72..b1b21c2 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,22 +1,15 @@ -""" -URL configuration for config project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" from django.contrib import admin -from django.urls import path +from django.urls import path, include + +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), -] + path('api/user/', include('users.urls')), + path('api/code/', include('codes.urls')), + path('api/portfolio/', include('portfolios.urls')), +] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) From 823de530b224bfef8bca4574d7929e54de8e4126 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:45:41 +0900 Subject: [PATCH 07/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20category=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20filter=20class=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portfolios/filters.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 portfolios/filters.py diff --git a/portfolios/filters.py b/portfolios/filters.py new file mode 100644 index 0000000..197faf5 --- /dev/null +++ b/portfolios/filters.py @@ -0,0 +1,17 @@ +from django_filters import rest_framework as filters +from django.db.models import Q +from .models import Portfolio + +class PortfolioFilter(filters.FilterSet): + category = filters.CharFilter(method='filter_category') + + def filter_category(self, queryset, name, value): + categories = value.split(',') + q = Q() + for c in categories: + q |= Q(category__contains=[c]) + return queryset.filter(q) + + class Meta: + model = Portfolio + fields = ['category'] From 7b1fdbe4d0bf1d2cec265d80a66009d9b99f338a Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:46:55 +0900 Subject: [PATCH 08/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20scrap=20?= =?UTF-8?q?=EC=B2=A0=EC=B0=A8=20=EC=88=98=EC=A0=95,=20likers,=20scrapper?= =?UTF-8?q?=20m2m=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portfolios/models.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/portfolios/models.py b/portfolios/models.py index 51db6ee..ca8601e 100644 --- a/portfolios/models.py +++ b/portfolios/models.py @@ -9,13 +9,15 @@ from users.models import User class Portfolio(BaseModel): - name = models.CharField(max_length=20) + title = models.CharField(max_length=20) category = ArrayField(models.CharField(max_length=20), default=list) is_published = models.BooleanField(default=False) view_count = models.IntegerField(default=0) like_count = models.IntegerField(default=0) - scrab_count = models.IntegerField(default=0) + scrap_count = models.IntegerField(default=0) is_represent = models.BooleanField(default=False) thumbnail = models.ImageField(upload_to='', blank=True) code_id = models.CharField(max_length=26, blank=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='portfolios', to_field="id") \ No newline at end of file + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_portfolios', to_field="id") + likers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='liked_portfolios', blank=True) + scrappers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='scrapped_portfolios', blank=True) \ No newline at end of file From d132823bac8a0938613accf79439926f6841e4cd Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:47:40 +0900 Subject: [PATCH 09/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20pagination=20?= =?UTF-8?q?class=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portfolios/paginations.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 portfolios/paginations.py diff --git a/portfolios/paginations.py b/portfolios/paginations.py new file mode 100644 index 0000000..1428f61 --- /dev/null +++ b/portfolios/paginations.py @@ -0,0 +1,5 @@ +from rest_framework.pagination import PageNumberPagination + +class PortfolioPagination(PageNumberPagination): + page_size = 12 + page_query_param = 'page' From 8066b5f69445f2bb89c70e64e591407a67204e46 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:48:32 +0900 Subject: [PATCH 10/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20list,=20creat?= =?UTF-8?q?e=20serializer=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portfolios/serializers.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 portfolios/serializers.py diff --git a/portfolios/serializers.py b/portfolios/serializers.py new file mode 100644 index 0000000..35948f8 --- /dev/null +++ b/portfolios/serializers.py @@ -0,0 +1,16 @@ +from .models import * + +from rest_framework import serializers + +class PortfolioListViewSerializer(serializers.ModelSerializer): + nickname = serializers.CharField(source='owner.nickname') + profile_image = serializers.ImageField(source='owner.profile_image') + + class Meta: + model = Portfolio + fields = ['id', 'category', 'thumbnail', 'nickname', 'profile_image', 'view_count', 'like_count', 'scrap_count'] + +class PortfolioCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Portfolio + fields = ['title', 'category', 'owner'] \ No newline at end of file From 17ef64a767d7bab492667384fa25de9df19b184a Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:50:10 +0900 Subject: [PATCH 11/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20stateChangeSe?= =?UTF-8?q?rvice,checkRelService=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portfolios/services.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 portfolios/services.py diff --git a/portfolios/services.py b/portfolios/services.py new file mode 100644 index 0000000..2d879b4 --- /dev/null +++ b/portfolios/services.py @@ -0,0 +1,40 @@ +from .models import Portfolio + + +ACTION_FIELD_MAP = { + 'like': 'likers', + 'scrap': 'scrappers', + } + +class PortfolioBeforeRelCheckService: + @staticmethod + def check_user_portfolio_rel(action_type: str, portfolio: Portfolio, user): + action = ACTION_FIELD_MAP.get(action_type) + if not action: + raise ValueError(f"'{action_type}' is unsupported action type") + + if getattr(portfolio, action).filter(id=user.id).exists(): + return True + else: + return False + +class PortfolioStateChangeService: + @staticmethod + def change_user_portfolio_rel(action_type: str, portfolio: Portfolio, user, add=True): + action = ACTION_FIELD_MAP.get(action_type) + if not action: + raise ValueError(f"'{action_type}' is unsupported action type") + + user_method = getattr(getattr(portfolio, action), 'add' if add else 'remove') + user_method(user) + + @staticmethod + def change_count_portfolio_state(action_type: str, portfolio: Portfolio, add=True): + action = ACTION_FIELD_MAP.get(action_type) + if not action: + raise ValueError(f"'{action_type}' is unsupported action type") + field_name = action_type + '_count' + current_count = getattr(portfolio, field_name) + now_count = current_count+1 if add else max(current_count-1, 0) + setattr(portfolio, field_name, now_count) + portfolio.save(update_fields=[field_name]) From da9a60192df5b5afc509c533b9984d69a509f78b Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:52:30 +0900 Subject: [PATCH 12/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=EC=83=9D=EC=84=B1,=EC=82=AD=EC=A0=9C,=EB=8C=80?= =?UTF-8?q?=ED=91=9C/=EB=B0=B0=ED=8F=AC=20=EA=B4=80=EB=A6=AC,=EC=83=81?= =?UTF-8?q?=ED=83=9C=EA=B4=80=EB=A6=AC=20url=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portfolios/urls.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 portfolios/urls.py diff --git a/portfolios/urls.py b/portfolios/urls.py new file mode 100644 index 0000000..55acd52 --- /dev/null +++ b/portfolios/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from .views import * + +app_name = 'portfolios' + +urlpatterns = [ + path('list/', PortfolioListView.as_view()), + path('create/', PortfolioCreateAPIView.as_view()), + path('/', PortfolioDeleteAPIView.as_view()), + path('/set-represent/', PortfolioSetRepresentAPIView.as_view()), + path('/set-publish/', PortfolioSetPublishAPIView.as_view()), + path('/change-state/', PortfolioChangeState.as_view()), + +] \ No newline at end of file From dea03d90991797619523b0ea09a8451a64febc39 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Sat, 12 Apr 2025 01:53:23 +0900 Subject: [PATCH 13/13] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#22]=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C,=EC=83=9D=EC=84=B1,=EC=82=AD=EC=A0=9C,=EB=8C=80?= =?UTF-8?q?=ED=91=9C/=EB=B0=B0=ED=8F=AC=20=EA=B4=80=EB=A6=AC,=EC=83=81?= =?UTF-8?q?=ED=83=9C=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portfolios/views.py | 106 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/portfolios/views.py b/portfolios/views.py index 91ea44a..a7a2e08 100644 --- a/portfolios/views.py +++ b/portfolios/views.py @@ -1,3 +1,105 @@ -from django.shortcuts import render +from django.shortcuts import get_object_or_404 -# Create your views here. +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from .models import * + +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import OrderingFilter +from rest_framework.generics import ListAPIView +from .serializers import PortfolioListViewSerializer, PortfolioCreateSerializer +from .paginations import PortfolioPagination +from .filters import PortfolioFilter +from .services import PortfolioStateChangeService, PortfolioBeforeRelCheckService + +from django.db import transaction + + +class PortfolioListView(ListAPIView): + queryset = Portfolio.objects.filter(is_published=True).order_by('-created_at') + serializer_class = PortfolioListViewSerializer + pagination_class = PortfolioPagination + filterset_class = PortfolioFilter + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering_fields = ['view_count', 'like_count', 'scrap_count', 'created_at'] # 허용할 필드 추천순 제외 + ordering = ['-created_at'] # 기본 정렬은 최신순 + +class PortfolioCreateAPIView(APIView): + @transaction.atomic + def post(self, request): + data = request.data.copy() + data['owner'] = request.user.id + serializer = PortfolioCreateSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return Response({"message": "create portfolio success"}, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class PortfolioDeleteAPIView(APIView): + @transaction.atomic + def delete(self, request, pk): + user = request.user + portfolio = get_object_or_404(Portfolio, id=pk) + if user != portfolio.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + portfolio.delete() + return Response({"message": "delete success"}, status=status.HTTP_200_OK) + +class PortfolioSetRepresentAPIView(APIView): + @transaction.atomic + def patch(self, request, pk): + user = request.user + portfolio = get_object_or_404(Portfolio, id=pk) + if user != portfolio.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + if before_represent := Portfolio.objects.filter(owner=user, is_represent=True).first(): + before_represent.is_represent = False + portfolio.is_represent = True + return Response({"message": "change represent success"}, status=status.HTTP_200_OK) + + +class PortfolioSetPublishAPIView(APIView): + @transaction.atomic + def patch(self, request, pk): + user = request.user + portfolio = get_object_or_404(Portfolio, id=pk) + if user != portfolio.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + portfolio.is_published = True + portfolio.save() + return Response({"message": "publish success"}, status=status.HTTP_200_OK) + +class PortfolioChangeState(APIView): + @transaction.atomic + def patch(self, request, pk): + portfolio = get_object_or_404(Portfolio, pk=pk) + user = request.user + action_type = request.query_params.get('type') + if PortfolioBeforeRelCheckService.check_user_portfolio_rel(action_type, portfolio, user): + return Response({"message": "already done"}, status=status.HTTP_400_BAD_REQUEST) + return self._handle_action(action_type, portfolio, user, add=True) + + @transaction.atomic + def delete(self, request, pk): + portfolio = get_object_or_404(Portfolio, pk=pk) + user = request.user + action_type = request.query_params.get('type') + if not PortfolioBeforeRelCheckService.check_user_portfolio_rel(action_type, portfolio, user): + return Response({"message": "never done before"}, status=status.HTTP_400_BAD_REQUEST) + return self._handle_action(action_type, portfolio, user, add=False) + + def _handle_action(self, action_type, portfolio, user, add=True): + if not action_type: + return Response({'message': 'Missing action type'}, status=status.HTTP_400_BAD_REQUEST) + + try: + PortfolioStateChangeService.change_user_portfolio_rel(action_type, portfolio, user, add) + PortfolioStateChangeService.change_count_portfolio_state(action_type, portfolio, add) + except ValueError as e: + return Response({'message': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + return Response({ + 'message': f'{action_type} {"added" if add else "removed"}' + }, status=status.HTTP_200_OK) \ No newline at end of file