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 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) 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'] 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 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' 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 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]) 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 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 diff --git a/requirements.txt b/requirements.txt index 3db3c27..d45ea32 100644 Binary files a/requirements.txt and b/requirements.txt differ 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 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 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