diff --git a/config/settings.py b/config/settings.py index be08536..f31faa0 100644 --- a/config/settings.py +++ b/config/settings.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ 'portfolios', 'projects', 'codes', + 'notifications', ] MIDDLEWARE = [ diff --git a/config/urls.py b/config/urls.py index af7d012..b17ad1b 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ # path('api/code/', include('codes.urls')), path('api/portfolio/', include('portfolios.urls')), path('api/project/', include('projects.urls')), + path('api/notification/', include('notifications.urls')), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) if settings.DEBUG: diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..001b4f9 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notifications' diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..e426773 --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,11 @@ +from django.db import models + +from users.models import * + +from common.models.baseModels import * + +class Notification(BaseModel): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') + content = models.TextField(blank=True) + is_read = models.BooleanField(default=False) + note_type = models.CharField(max_length=10, choices=NotificationType.choices) \ No newline at end of file diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 0000000..4bbd23c --- /dev/null +++ b/notifications/serializers.py @@ -0,0 +1,32 @@ +from .models import * +from projects.models import * +from rest_framework import serializers + +class NotificationSerializer(serializers.ModelSerializer): + meta = serializers.SerializerMethodField() + + class Meta: + model = Notification + fields = ['id', 'content', 'note_type', 'is_read', 'meta'] + + def get_meta(self, obj): + + REL_SERIALIZER_MAP = { + 'project_invitation' : ProjectInvitationMetaSerializer, + } + + for rel_name, serializer_cls in REL_SERIALIZER_MAP.items(): + rel_obj = getattr(obj, rel_name, None) + if rel_obj is not None: + return serializer_cls(rel_obj).data + return None + +class ProjectInvitationMetaSerializer(serializers.ModelSerializer): + project_invitation_id = serializers.CharField(source='id') + project_title = serializers.CharField(source='project.title') + from_user_nickname = serializers.CharField(source='from_user.nickname') + + class Meta: + model = ProjectInvitation + fields = ['project_invitation_id', 'project_title', 'from_user_nickname', 'status'] + \ No newline at end of file diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 0000000..ff353af --- /dev/null +++ b/notifications/services.py @@ -0,0 +1,19 @@ +from users.models import * +from .models import * + +from common.models.choiceModels import * + + + +# 알림 관련 서비스 로직 +class NotifiationService: + @staticmethod + def set_content(): + pass + + @staticmethod + def create_notification(user: User, note_type: NotificationType): + return Notification.objects.create( + user = user, + note_type=note_type + ) \ No newline at end of file diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..c2d898b --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,14 @@ +from django.urls import path, include + +from .views import * + +from rest_framework.routers import DefaultRouter + +app_name = 'notifications' + +router = DefaultRouter() +router.register(r'', NotificationReadViewSet, basename='notification') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..aae2dfb --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,34 @@ +from django.shortcuts import get_object_or_404 +from django.db import transaction + +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from .serializers import * +from .models import * +from .services import * + +from users.services import * + + +# Create your views here. +class NotificationReadViewSet(ReadOnlyModelViewSet): + serializer_class = NotificationSerializer + + # 30일 이전 알림만 가져옴 + def get_queryset(self): + qs = UserToNotificationService.get_all_notification(self.request.user) + print(qs) + return qs.select_related( + 'project_invitation__project', + 'project_invitation__from_user', + ) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + instance.is_read = True + instance.save() + serializer = self.get_serializer(instance) + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/portfolios/serializers.py b/portfolios/serializers.py index 35948f8..6d8e8bc 100644 --- a/portfolios/serializers.py +++ b/portfolios/serializers.py @@ -8,7 +8,7 @@ class PortfolioListViewSerializer(serializers.ModelSerializer): class Meta: model = Portfolio - fields = ['id', 'category', 'thumbnail', 'nickname', 'profile_image', 'view_count', 'like_count', 'scrap_count'] + fields = ['id', 'category', 'thumbnail', 'title', 'nickname', 'profile_image', 'view_count', 'like_count', 'scrap_count'] class PortfolioCreateSerializer(serializers.ModelSerializer): class Meta: diff --git a/projects/models.py b/projects/models.py index 88ed65d..d034525 100644 --- a/projects/models.py +++ b/projects/models.py @@ -6,6 +6,7 @@ from common.models.choiceModels import InvitationStatus from django.contrib.postgres.fields import ArrayField from django.conf import settings +from notifications.models import Notification from users.models import User @@ -35,3 +36,4 @@ class ProjectInvitation(BaseModel): to_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_invitations') project = models.ForeignKey(Project, on_delete=models.CASCADE) status = models.CharField(max_length=10, choices=InvitationStatus.choices) + notification = models.OneToOneField(Notification, on_delete=models.CASCADE, related_name='project_invitation', null=True, blank=True) \ No newline at end of file diff --git a/projects/serializers.py b/projects/serializers.py index a8d3af0..a5f8902 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -1,8 +1,12 @@ from .models import * +from .services import * from users.models import User from rest_framework import serializers +from notifications.services import * + +from common.models.choiceModels import * class ProjectListViewSerializer(serializers.ModelSerializer): class Meta: @@ -32,8 +36,13 @@ class ProjectCreateSerializer(serializers.ModelSerializer): users = list(users) + [validated_data["owner"]] for user in users: - ProjectTeamList.objects.create(user=user, project=project) - + new_notification = NotifiationService.create_notification(user=user, note_type=NotificationType.INVITE) + ProjectInvitationService.create_project_invitation( + project=project, + from_user=validated_data['owner'], + to_user=user, + notification=new_notification + ) return project class ProjectTeamSerializer(serializers.ModelSerializer): diff --git a/projects/services.py b/projects/services.py index 50b9a64..e993e06 100644 --- a/projects/services.py +++ b/projects/services.py @@ -1,4 +1,8 @@ -from .models import Project +from .models import * +from .serializers import * + +from users.models import * +from common.models.choiceModels import InvitationStatus ACTION_FIELD_MAP = { @@ -38,3 +42,16 @@ class ProjectStateChangeService: 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]) + + +class ProjectInvitationService: + @staticmethod + def create_project_invitation(project: Project, from_user: User, to_user: User, notification: Notification): + return ProjectInvitation.objects.create( + project=project, + from_user=from_user, + to_user=to_user, + status= InvitationStatus.PENDING, + notification=notification + ) + \ No newline at end of file diff --git a/projects/urls.py b/projects/urls.py index 47b7f66..df727f8 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -12,5 +12,5 @@ urlpatterns = [ path('/set-represent/', ProjectSetRepresentAPIView.as_view()), path('/set-publish/', ProjectSetPublishAPIView.as_view()), path('/change-state/', ProjectChangeState.as_view()), - + path('invite/action/', ProjectInvitationAPIView.as_view()), ] \ No newline at end of file diff --git a/projects/views.py b/projects/views.py index 64c8da8..6409d85 100644 --- a/projects/views.py +++ b/projects/views.py @@ -12,10 +12,12 @@ 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 .services import * from django.db import transaction +from notifications.services import * + class ProjectListView(ListAPIView): queryset = Project.objects.filter(is_published=True).order_by('-created_at') @@ -29,6 +31,7 @@ class ProjectListView(ListAPIView): class ProjectCreateAPIView(APIView): @transaction.atomic def post(self, request): + user = request.user serializer = ProjectCreateSerializer(data=request.data, context={"request": request}) if serializer.is_valid(): serializer.save() @@ -36,7 +39,7 @@ class ProjectCreateAPIView(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class ProjectTeamManageAPIView(APIView): - # 팀원 초대 + # 팀원 초대 및 알림 @transaction.atomic def post(self, request, pk): user = request.user @@ -47,11 +50,14 @@ class ProjectTeamManageAPIView(APIView): 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) + new_notification = NotifiationService.create_notification(user=new_member, note_type=NotificationType.INVITE) + ProjectInvitationService.create_project_invitation( + project=project, + from_user=user, + to_user=new_member, + notification=new_notification + ) + return Response({"message": "invite success"}, status=status.HTTP_200_OK) # 팀원 추방 @transaction.atomic @@ -140,4 +146,27 @@ class ProjectChangeState(APIView): return Response({ 'message': f'{action_type} {"added" if add else "removed"}' }, status=status.HTTP_200_OK) - + +class ProjectInvitationAPIView(APIView): + # 팀원 초대 수락/거절 + @transaction.atomic + def post(self, request): + accept = request.data['accept'] + project_invitation = get_object_or_404(ProjectInvitation, id=request.data['project_invitation_id']) + new_member = project_invitation.to_user + project = project_invitation.project + if request.user != new_member: + return Response({"message": "Not account owner"}, status=status.HTTP_403_FORBIDDEN) + if project_invitation.status != InvitationStatus.PENDING: + return Response({"message": "already handled invitation"}, status=status.HTTP_400_BAD_REQUEST) + if accept: + project_invitation.status = InvitationStatus.ACCEPTED + project_invitation.save() + serializer = ProjectTeamSerializer(data={'user':new_member.id, 'project':project.id}) + if serializer.is_valid(): + serializer.save() + return Response({"message": "invitation accepted"}, status=status.HTTP_200_OK) + else: + project_invitation.status = InvitationStatus.REJECTED + project_invitation.save() + return Response({"message": "invitation rejected"}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/users/models.py b/users/models.py index ac5c4ab..9f62dcd 100644 --- a/users/models.py +++ b/users/models.py @@ -63,8 +63,3 @@ class User(BaseModel, AbstractBaseUser, PermissionsMixin): return self.nickname -class Notification(BaseModel): - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') - content = models.TextField() - is_read = models.BooleanField(default=False) - note_type = models.CharField(max_length=10, choices=NotificationType.choices) diff --git a/users/serializers.py b/users/serializers.py index 05f9dc5..817c753 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -62,8 +62,3 @@ class UserMemberInfoSerializer(serializers.ModelSerializer): 'custom_url', 'job_and_interests' ] - -class NotificationSerializer(serializers.ModelSerializer): - class Meta: - model = Notification - fields = ['id', 'content', 'note_type', 'is_read'] \ No newline at end of file diff --git a/users/services.py b/users/services.py index 9ea3ba6..8dee258 100644 --- a/users/services.py +++ b/users/services.py @@ -16,22 +16,17 @@ class CheckUserFieldDuplicateService: @staticmethod def check_custom_url_duplicate(): pass - -# 알림 관련 서비스 로직 -class NotifiationService: - @staticmethod - def set_content_by_type(): - pass + class UserToNotificationService: @staticmethod def get_new_notification_count(user: User): - return user.notifications.filter(created_at__lt=thirty_days_ago, is_read=False).count() + return user.notifications.filter(created_at__gt=thirty_days_ago, is_read=False).count() @staticmethod def get_all_notification(user: User): - return user.notifications.filter(created_at__lt=thirty_days_ago) + return user.notifications.filter(created_at__gt=thirty_days_ago) # 유저 -> 포트폴리오 관련 서비스 로직 @@ -82,4 +77,3 @@ class UserToProjectService: @staticmethod def get_scrap_project(user: User): return user.scrapped_projects.filter(is_published=True) - diff --git a/users/urls.py b/users/urls.py index a73f9a1..f7c8f47 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,14 +1,10 @@ -from django.urls import path, include +from django.urls import path from .views import * -from rest_framework.routers import DefaultRouter app_name = 'users' -router = DefaultRouter() -router.register(r'notifications', NotificationReadViewSet, basename='notification') - urlpatterns = [ path('refresh-token/', RefreshAPIView.as_view()), path('join/', JoinAPIView.as_view()), @@ -20,5 +16,4 @@ urlpatterns = [ path('mypage/profile//', MyPageProfileAPIView.as_view()), path('mypage/works//', MyPageWorkListAPIView.as_view()), path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()), - path('', include(router.urls)), ] \ No newline at end of file diff --git a/users/views.py b/users/views.py index 5713225..3c10136 100644 --- a/users/views.py +++ b/users/views.py @@ -249,21 +249,3 @@ class MyPageMemberInfoAPIView(APIView): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class NotificationReadViewSet(ReadOnlyModelViewSet): - serializer_class = NotificationSerializer - - # 30일 이전 알림만 가져옴 - def get_queryset(self): - return UserToNotificationService.get_all_notification(self.request.user) - - def list(self, request, *args, **kwargs): - queryset = self.get_queryset() - serializer = self.get_serializer(queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - def retrieve(self, request, *args, **kwargs): - instance = self.get_object() - instance.is_read = True - instance.save() - serializer = self.get_serializer(instance) - return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file