Merge pull request #46 from plers-org/sm/#41

Sm/#41
This commit is contained in:
NKEY
2025-05-02 22:22:41 +09:00
committed by GitHub
22 changed files with 198 additions and 56 deletions

View File

@@ -56,6 +56,7 @@ INSTALLED_APPS = [
'portfolios', 'portfolios',
'projects', 'projects',
'codes', 'codes',
'notifications',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -10,6 +10,7 @@ urlpatterns = [
# path('api/code/', include('codes.urls')), # path('api/code/', include('codes.urls')),
path('api/portfolio/', include('portfolios.urls')), path('api/portfolio/', include('portfolios.urls')),
path('api/project/', include('projects.urls')), path('api/project/', include('projects.urls')),
path('api/notification/', include('notifications.urls')),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.DEBUG: if settings.DEBUG:

View File

3
notifications/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
notifications/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'

11
notifications/models.py Normal file
View File

@@ -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)

View File

@@ -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']

19
notifications/services.py Normal file
View File

@@ -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
)

3
notifications/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
notifications/urls.py Normal file
View File

@@ -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)),
]

34
notifications/views.py Normal file
View File

@@ -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)

View File

@@ -8,7 +8,7 @@ class PortfolioListViewSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Portfolio 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 PortfolioCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View File

@@ -6,6 +6,7 @@ from common.models.choiceModels import InvitationStatus
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.conf import settings from django.conf import settings
from notifications.models import Notification
from users.models import User 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') to_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_invitations')
project = models.ForeignKey(Project, on_delete=models.CASCADE) project = models.ForeignKey(Project, on_delete=models.CASCADE)
status = models.CharField(max_length=10, choices=InvitationStatus.choices) 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)

View File

@@ -1,8 +1,12 @@
from .models import * from .models import *
from .services import *
from users.models import User from users.models import User
from rest_framework import serializers from rest_framework import serializers
from notifications.services import *
from common.models.choiceModels import *
class ProjectListViewSerializer(serializers.ModelSerializer): class ProjectListViewSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -32,8 +36,13 @@ class ProjectCreateSerializer(serializers.ModelSerializer):
users = list(users) + [validated_data["owner"]] users = list(users) + [validated_data["owner"]]
for user in users: 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 return project
class ProjectTeamSerializer(serializers.ModelSerializer): class ProjectTeamSerializer(serializers.ModelSerializer):

View File

@@ -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 = { ACTION_FIELD_MAP = {
@@ -38,3 +42,16 @@ class ProjectStateChangeService:
now_count = current_count+1 if add else max(current_count-1, 0) now_count = current_count+1 if add else max(current_count-1, 0)
setattr(project, field_name, now_count) setattr(project, field_name, now_count)
project.save(update_fields=[field_name]) 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
)

View File

@@ -12,5 +12,5 @@ urlpatterns = [
path('<str:pk>/set-represent/', ProjectSetRepresentAPIView.as_view()), path('<str:pk>/set-represent/', ProjectSetRepresentAPIView.as_view()),
path('<str:pk>/set-publish/', ProjectSetPublishAPIView.as_view()), path('<str:pk>/set-publish/', ProjectSetPublishAPIView.as_view()),
path('<str:pk>/change-state/', ProjectChangeState.as_view()), path('<str:pk>/change-state/', ProjectChangeState.as_view()),
path('invite/action/', ProjectInvitationAPIView.as_view()),
] ]

View File

@@ -12,10 +12,12 @@ from rest_framework.generics import ListAPIView
from .serializers import ProjectListViewSerializer, ProjectCreateSerializer, ProjectTeamSerializer from .serializers import ProjectListViewSerializer, ProjectCreateSerializer, ProjectTeamSerializer
from .paginations import ProjectPagination from .paginations import ProjectPagination
from .filters import ProjectFilter from .filters import ProjectFilter
from .services import ProjectStateChangeService, ProjectBeforeRelCheckService from .services import *
from django.db import transaction from django.db import transaction
from notifications.services import *
class ProjectListView(ListAPIView): class ProjectListView(ListAPIView):
queryset = Project.objects.filter(is_published=True).order_by('-created_at') queryset = Project.objects.filter(is_published=True).order_by('-created_at')
@@ -29,6 +31,7 @@ class ProjectListView(ListAPIView):
class ProjectCreateAPIView(APIView): class ProjectCreateAPIView(APIView):
@transaction.atomic @transaction.atomic
def post(self, request): def post(self, request):
user = request.user
serializer = ProjectCreateSerializer(data=request.data, context={"request": request}) serializer = ProjectCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
@@ -36,7 +39,7 @@ class ProjectCreateAPIView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class ProjectTeamManageAPIView(APIView): class ProjectTeamManageAPIView(APIView):
# 팀원 초대 # 팀원 초대 및 알림
@transaction.atomic @transaction.atomic
def post(self, request, pk): def post(self, request, pk):
user = request.user user = request.user
@@ -47,11 +50,14 @@ class ProjectTeamManageAPIView(APIView):
new_member = get_object_or_404(User, nickname=nickname) new_member = get_object_or_404(User, nickname=nickname)
if ProjectTeamList.objects.filter(project=project, user=new_member).exists(): if ProjectTeamList.objects.filter(project=project, user=new_member).exists():
return Response({"message": "already team member"}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "already team member"}, status=status.HTTP_400_BAD_REQUEST)
serializer = ProjectTeamSerializer(data={'user':new_member.id, 'project':project.id}) new_notification = NotifiationService.create_notification(user=new_member, note_type=NotificationType.INVITE)
if serializer.is_valid(): ProjectInvitationService.create_project_invitation(
serializer.save() project=project,
return Response({"message": "invite success"}, status=status.HTTP_200_OK) from_user=user,
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) to_user=new_member,
notification=new_notification
)
return Response({"message": "invite success"}, status=status.HTTP_200_OK)
# 팀원 추방 # 팀원 추방
@transaction.atomic @transaction.atomic
@@ -140,4 +146,27 @@ class ProjectChangeState(APIView):
return Response({ return Response({
'message': f'{action_type} {"added" if add else "removed"}' 'message': f'{action_type} {"added" if add else "removed"}'
}, status=status.HTTP_200_OK) }, 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)

View File

@@ -63,8 +63,3 @@ class User(BaseModel, AbstractBaseUser, PermissionsMixin):
return self.nickname 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)

View File

@@ -62,8 +62,3 @@ class UserMemberInfoSerializer(serializers.ModelSerializer):
'custom_url', 'custom_url',
'job_and_interests' 'job_and_interests'
] ]
class NotificationSerializer(serializers.ModelSerializer):
class Meta:
model = Notification
fields = ['id', 'content', 'note_type', 'is_read']

View File

@@ -16,22 +16,17 @@ class CheckUserFieldDuplicateService:
@staticmethod @staticmethod
def check_custom_url_duplicate(): def check_custom_url_duplicate():
pass pass
# 알림 관련 서비스 로직
class NotifiationService:
@staticmethod
def set_content_by_type():
pass
class UserToNotificationService: class UserToNotificationService:
@staticmethod @staticmethod
def get_new_notification_count(user: User): 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 @staticmethod
def get_all_notification(user: User): 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 @staticmethod
def get_scrap_project(user: User): def get_scrap_project(user: User):
return user.scrapped_projects.filter(is_published=True) return user.scrapped_projects.filter(is_published=True)

View File

@@ -1,14 +1,10 @@
from django.urls import path, include from django.urls import path
from .views import * from .views import *
from rest_framework.routers import DefaultRouter
app_name = 'users' app_name = 'users'
router = DefaultRouter()
router.register(r'notifications', NotificationReadViewSet, basename='notification')
urlpatterns = [ urlpatterns = [
path('refresh-token/', RefreshAPIView.as_view()), path('refresh-token/', RefreshAPIView.as_view()),
path('join/', JoinAPIView.as_view()), path('join/', JoinAPIView.as_view()),
@@ -20,5 +16,4 @@ urlpatterns = [
path('mypage/profile/<str:nickname>/', MyPageProfileAPIView.as_view()), path('mypage/profile/<str:nickname>/', MyPageProfileAPIView.as_view()),
path('mypage/works/<str:nickname>/', MyPageWorkListAPIView.as_view()), path('mypage/works/<str:nickname>/', MyPageWorkListAPIView.as_view()),
path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()), path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()),
path('', include(router.urls)),
] ]

View File

@@ -249,21 +249,3 @@ class MyPageMemberInfoAPIView(APIView):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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)