From ae565a564a11fd38e3a24cc5c7a71d1976a76d47 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:21:50 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EB=A3=A8=ED=8A=B8=20url=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/config/urls.py b/config/urls.py index 0e9fdf3..af7d012 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('api/user/', include('users.urls')), # path('api/code/', include('codes.urls')), path('api/portfolio/', include('portfolios.urls')), + path('api/project/', include('projects.urls')), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) if settings.DEBUG: From 0fe2094ec1f70d8c669cf5f76156ea497098e06b Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:24:40 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=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 --- projects/filters.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 projects/filters.py diff --git a/projects/filters.py b/projects/filters.py new file mode 100644 index 0000000..1a8e099 --- /dev/null +++ b/projects/filters.py @@ -0,0 +1,17 @@ +from django_filters import rest_framework as filters +from django.db.models import Q +from .models import Project + +class ProjectFilter(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 = Project + fields = ['category'] From a32c463f66d93ed4b03d256fbf264ae63ff18aec Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:26:38 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=B3=80=EC=88=98=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=83=81=ED=83=9C=20=EC=97=B0=EA=B4=80?= =?UTF-8?q?=EA=B4=80=EA=B3=84,=20=EC=98=A4=EB=84=88=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/models.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/projects/models.py b/projects/models.py index 39cd280..8d40c5b 100644 --- a/projects/models.py +++ b/projects/models.py @@ -8,16 +8,20 @@ from django.conf import settings from users.models import User class Project(BaseModel): - name = models.CharField(max_length=20) + title = models.CharField(max_length=20) is_team = models.BooleanField(default=False) + team_name = models.CharField(max_length=20, blank=True) 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) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_projects', to_field="id") + likers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='liked_projects', blank=True) + scrappers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='scrapped_projects', blank=True) class ProjectTeamList(BaseModel): project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='project_team_list', to_field='id') From d7bb6522437d92fb97da1edaaef346dd1d580e88 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:27:22 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/paginations.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 projects/paginations.py diff --git a/projects/paginations.py b/projects/paginations.py new file mode 100644 index 0000000..0ed067c --- /dev/null +++ b/projects/paginations.py @@ -0,0 +1,5 @@ +from rest_framework.pagination import PageNumberPagination + +class ProjectPagination(PageNumberPagination): + page_size = 12 + page_query_param = 'page' From 509d51bde622d97aaf86998268e888f9f33d0d3b Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:28:14 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=A1=B0=ED=9A=8C,=20=ED=8C=80=EC=9B=90?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=20serializer=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/serializers.py | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 projects/serializers.py diff --git a/projects/serializers.py b/projects/serializers.py new file mode 100644 index 0000000..a8d3af0 --- /dev/null +++ b/projects/serializers.py @@ -0,0 +1,42 @@ +from .models import * +from users.models import User + +from rest_framework import serializers + +class ProjectListViewSerializer(serializers.ModelSerializer): + + class Meta: + model = Project + fields = ['id', 'category', 'thumbnail', 'title', 'view_count', 'like_count', 'scrap_count'] + +class ProjectCreateSerializer(serializers.ModelSerializer): + members = serializers.ListField( + child=serializers.CharField(), write_only=True, required=False + ) + team_name = serializers.CharField(required=False) + + class Meta: + model = Project + fields = ['is_team', 'team_name', 'title', 'category', 'members'] + read_only_fields = ['owner'] + + def create(self, validated_data): + nicknames = validated_data.pop('members', []) + validated_data['owner'] = self.context['request'].user + project = Project.objects.create(**validated_data) + + if not validated_data['is_team']: + return project + + users = User.objects.filter(nickname__in=nicknames) + users = list(users) + [validated_data["owner"]] + + for user in users: + ProjectTeamList.objects.create(user=user, project=project) + + return project + +class ProjectTeamSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectTeamList + fields = '__all__' \ No newline at end of file From 92f21b5159996c403ff6c3b62f5fdaa9dfd837b9 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:29:51 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=ED=99=94=20=ED=95=A8=EC=88=98(?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EC=B2=B4=ED=81=AC,=20=EC=9C=A0=EC=A0=80/?= =?UTF-8?q?=EA=B0=9C=EC=88=98=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/services.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 projects/services.py diff --git a/projects/services.py b/projects/services.py new file mode 100644 index 0000000..50b9a64 --- /dev/null +++ b/projects/services.py @@ -0,0 +1,40 @@ +from .models import Project + + +ACTION_FIELD_MAP = { + 'like': 'likers', + 'scrap': 'scrappers', + } + +class ProjectBeforeRelCheckService: + @staticmethod + def check_user_project_rel(action_type: str, project: Project, user): + action = ACTION_FIELD_MAP.get(action_type) + if not action: + raise ValueError(f"'{action_type}' is unsupported action type") + + if getattr(project, action).filter(id=user.id).exists(): + return True + else: + return False + +class ProjectStateChangeService: + @staticmethod + def change_user_project_rel(action_type: str, project: Project, 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(project, action), 'add' if add else 'remove') + user_method(user) + + @staticmethod + def change_count_project_state(action_type: str, project: Project, 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(project, field_name) + now_count = current_count+1 if add else max(current_count-1, 0) + setattr(project, field_name, now_count) + project.save(update_fields=[field_name]) From ec4c6271af3273861624a7fb2157f999eb406e93 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:31:28 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20cru?= =?UTF-8?q?d=20=EA=B4=80=EB=A0=A8=20url=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/urls.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 projects/urls.py diff --git a/projects/urls.py b/projects/urls.py new file mode 100644 index 0000000..47b7f66 --- /dev/null +++ b/projects/urls.py @@ -0,0 +1,16 @@ +from django.urls import path + +from .views import * + +app_name = 'projects' + +urlpatterns = [ + path('list/', ProjectListView.as_view()), + path('create/', ProjectCreateAPIView.as_view()), + path('/manage-team/', ProjectTeamManageAPIView.as_view()), + path('/', ProjectDeleteAPIView.as_view()), + path('/set-represent/', ProjectSetRepresentAPIView.as_view()), + path('/set-publish/', ProjectSetPublishAPIView.as_view()), + path('/change-state/', ProjectChangeState.as_view()), + +] \ No newline at end of file From d1f033460a369f944ace2b25b0bc43339294ae75 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:32:24 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20cru?= =?UTF-8?q?d=20=EA=B4=80=EB=A0=A8=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 --- projects/views.py | 142 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/projects/views.py b/projects/views.py index 91ea44a..56e466b 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,3 +1,141 @@ -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 ProjectListViewSerializer, ProjectCreateSerializer, ProjectTeamSerializer +from .paginations import ProjectPagination +from .filters import ProjectFilter +from .services import ProjectStateChangeService, ProjectBeforeRelCheckService + +from django.db import transaction + + +class ProjectListView(ListAPIView): + queryset = Project.objects.filter(is_published=True).order_by('-created_at') + serializer_class = ProjectListViewSerializer + pagination_class = ProjectPagination + filterset_class = ProjectFilter + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering_fields = ['view_count', 'like_count', 'scrap_count', 'created_at'] # 허용할 필드 추천순 제외 + ordering = ['-created_at'] # 기본 정렬은 최신순 + +class ProjectCreateAPIView(APIView): + @transaction.atomic + def post(self, request): + serializer = ProjectCreateSerializer(data=request.data, context={"request": request}) + if serializer.is_valid(): + serializer.save() + return Response({"message": "create project success"}, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class ProjectTeamManageAPIView(APIView): + # 팀원 초대 + @transaction.atomic + def post(self, request, pk): + user = request.user + project = get_object_or_404(Project, id=pk) + if user != project.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + nickname = request.data.get('nickname') + new_member = get_object_or_404(User, nickname=nickname) + if ProjectTeamList.objects.filter(project=project, user=new_member).exists(): + return Response({"message": "already team member"}, status=status.HTTP_400_BAD_REQUEST) + serializer = ProjectTeamSerializer(data={'user':new_member.id, 'project':project.id}) + if serializer.is_valid(): + serializer.save() + return Response({"message": "invite success"}, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # 팀원 추방 + @transaction.atomic + def delete(self, request, pk): + user = request.user + project = get_object_or_404(Project, id=pk) + if user != project.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + nickname = request.data.get('nickname') + member = get_object_or_404(User, nickname=nickname) + user_in_project = get_object_or_404(ProjectTeamList, project=project, user=member) + user_in_project.delete() + return Response({"message": "delete user in project success"}) + +class ProjectDeleteAPIView(APIView): + @transaction.atomic + def delete(self, request, pk): + user = request.user + project = get_object_or_404(Project, id=pk) + if user != project.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + project.delete() + return Response({"message": "delete success"}, status=status.HTTP_200_OK) + +class ProjectSetRepresentAPIView(APIView): + @transaction.atomic + def patch(self, request, pk): + user = request.user + project = get_object_or_404(Project, id=pk) + if user != project.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + if before_represent := Project.objects.filter(owner=user, is_represent=True).first(): + before_represent.is_represent = False + project.is_represent = True + return Response({"message": "change represent success"}, status=status.HTTP_200_OK) + + +class ProjectSetPublishAPIView(APIView): + @transaction.atomic + def patch(self, request, pk): + user = request.user + project = get_object_or_404(Project, id=pk) + if user != project.owner: + return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN) + project.is_published = True + project.save() + return Response({"message": "publish success"}, status=status.HTTP_200_OK) + +class ProjectChangeState(APIView): + @transaction.atomic + def patch(self, request, pk): + project = get_object_or_404(Project, pk=pk) + user = request.user + action_type = request.query_params.get('type') + try: + if ProjectBeforeRelCheckService.check_user_project_rel(action_type, project, user): + return Response({"message": "already done"}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as e: + return Response({'message': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return self._handle_action(action_type, project, user, add=True) + + @transaction.atomic + def delete(self, request, pk): + project = get_object_or_404(Project, pk=pk) + user = request.user + action_type = request.query_params.get('type') + try: + if not ProjectBeforeRelCheckService.check_user_project_rel(action_type, project, user): + return Response({"message": "never done before"}, status=status.HTTP_400_BAD_REQUEST) + except ValueError as e: + return Response({'message': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return self._handle_action(action_type, project, user, add=False) + + def _handle_action(self, action_type, project, user, add=True): + if not action_type: + return Response({'message': 'Missing action type'}, status=status.HTTP_400_BAD_REQUEST) + + try: + ProjectStateChangeService.change_user_project_rel(action_type, project, user, add) + ProjectStateChangeService.change_count_project_state(action_type, project, 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) + From 2f24ab973b08a00ca03b5fb10096e0480852c393 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:33:45 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20?= =?UTF-8?q?=ED=8C=80=EC=9B=90=20=ED=83=9C=EA=B7=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?serializer=20=EA=B5=AC=ED=98=84?= 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 ac6d8c5..6fe0281 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -25,4 +25,9 @@ class JoinSerializer(serializers.ModelSerializer): class SetPortofolioRequiredInfoSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['custom_url', 'job_and_interests'] \ No newline at end of file + fields = ['custom_url', 'job_and_interests'] + +class TagUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['nickname', 'profile_image'] \ No newline at end of file From 761b0c1344ba2616e619251f44ec4c8d3ed59d83 Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:34:24 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20?= =?UTF-8?q?=ED=8C=80=EC=9B=90=20=ED=83=9C=EA=B7=B8=20url=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/users/urls.py b/users/urls.py index 9179f1b..5cb23f5 100644 --- a/users/urls.py +++ b/users/urls.py @@ -10,5 +10,6 @@ urlpatterns = [ path('login/', LoginAPIView.as_view()), path('check/', NicknameAPIView.as_view()), path('portfolio-info/', SetPortofolioRequiredInfoAPIView.as_view()), - path('portfolio-info/check/', SetPortofolioRequiredInfoAPIView.as_view()) + path('portfolio-info/check/', SetPortofolioRequiredInfoAPIView.as_view()), + path('teamtag/', TagUserAPIView.as_view()), ] \ No newline at end of file From e810ec5f0ead135831cb320f1ae5095b3f339c8e Mon Sep 17 00:00:00 2001 From: sm4640 Date: Mon, 28 Apr 2025 21:35:08 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=A8=20Feat:=20[#32]=20project=20?= =?UTF-8?q?=ED=8C=80=EC=9B=90=20=ED=83=9C=EA=B7=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- users/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users/views.py b/users/views.py index 6c88955..c382a2e 100644 --- a/users/views.py +++ b/users/views.py @@ -100,7 +100,7 @@ class NicknameAPIView(APIView): class TagUserAPIView(APIView): def get(self, request): - nickname = request.query_params.get(nickname) + nickname = request.query_params.get('nickname') users = User.objects.filter(nickname__icontains=nickname).annotate( priority=Case( When(nickname__iexact=nickname, then=Value(0)),