diff --git a/makourse/account/models.py b/makourse/account/models.py index e2969cb..ccba3c2 100644 --- a/makourse/account/models.py +++ b/makourse/account/models.py @@ -126,4 +126,48 @@ def __str__(self): # user와 group은 다대다 관계인듯 # 한 user가 여러 그룹에 들어갈 수 있고, 한 group 안에도 여러 유저가 있을 수 있으니까 -# 그래서 GroupMembership으로 이어주는걸로 짬 \ No newline at end of file +# 그래서 GroupMembership으로 이어주는걸로 짬 + + +class Notification(models.Model): + NOTIFICATION_TYPES = [ + ('invite', '초대'), + ('message', '일반 메시지'), + ('system', '시스템'), + ] + + sender = models.ForeignKey(CustomUser, on_delete=models.SET_NULL, null=True, blank=True, related_name='sent_notifications') + receiver = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name='notifications') + notification_type = models.CharField(max_length=10, choices=NOTIFICATION_TYPES, default='message') + group = models.ForeignKey(UserGroup, on_delete=models.CASCADE, null=True, blank=True) # 그룹 관련 알림이면 설정 + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + is_read = models.BooleanField(default=False) # 읽음 여부 + status = models.CharField( + max_length=10, + choices=[('pending', '대기 중'), ('accepted', '수락됨'), ('rejected', '거절됨')], + default='pending', + null=True, + blank=True # 초대 요청만 필요 + ) + + def __str__(self): + return f"{self.receiver.name}에게 '{self.content[:20]}' 알림" + + def mark_as_read(self): + """알림을 읽음 처리""" + self.is_read = True + self.save() + + def accept_invite(self): + """초대 요청 수락""" + if self.notification_type == 'invite' and self.group: + GroupMembership.objects.create(user=self.receiver, group=self.group, role='member') + self.status = 'accepted' + self.save() + + def reject_invite(self): + """초대 요청 거절""" + if self.notification_type == 'invite': + self.status = 'rejected' + self.save() \ No newline at end of file diff --git a/makourse/account/serializers.py b/makourse/account/serializers.py index e632afd..1eeed79 100644 --- a/makourse/account/serializers.py +++ b/makourse/account/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from course.models import Schedule -from .models import UserGroup, GroupMembership, CustomUser +from .models import UserGroup, GroupMembership, CustomUser,Notification class UserGroupSerializer(serializers.ModelSerializer): @@ -36,4 +36,13 @@ class Meta: class ScheduleSerializer(serializers.ModelSerializer): class Meta: model = Schedule - fields = ['id', 'group', 'course_name', 'meet_date_first', 'meet_place', 'latitude', 'longitude'] \ No newline at end of file + fields = ['id', 'group', 'course_name', 'meet_date_first', 'meet_place', 'latitude', 'longitude'] + + +class NotificationSerializer(serializers.ModelSerializer): + sender_name = serializers.CharField(source="sender.name", read_only=True) + group_name = serializers.CharField(source="group.schedule.course_name", read_only=True, default="일정 없음") + + class Meta: + model = Notification + fields = ["id", "sender", "sender_name", "receiver", "group", "group_name", "notification_type", "content", "created_at", "is_read", "status"] \ No newline at end of file diff --git a/makourse/account/urls.py b/makourse/account/urls.py index ccd7792..04674c4 100644 --- a/makourse/account/urls.py +++ b/makourse/account/urls.py @@ -17,6 +17,13 @@ path('groups//join', GroupMembershipJoinView.as_view(), name='group-join'), # 그룹원 등록 path('groups//members/', GroupMembershipDeleteView.as_view(), name='group-member-delete'), # 그룹원 삭제 + # 초대 알림 + path('groups//invite', GroupMembershipInviteView.as_view(), name='group-invite'), # 그룹원 초대 + path('groups//invite/respond', GroupMembershipInviteResponseView.as_view(), name='group-respond'), # 그룹원 응답 + + # 알림 + path("notifications", NotificationListView.as_view(), name="notification-list"), + # access token 재발급 path('token/refresh/', CustomTokenRefreshView.as_view(), name='token_refresh'), diff --git a/makourse/account/views.py b/makourse/account/views.py index 2d21a1c..cbfa84f 100644 --- a/makourse/account/views.py +++ b/makourse/account/views.py @@ -505,4 +505,153 @@ class CustomTokenRefreshView(TokenRefreshView): }, ) def post(self, request, *args, **kwargs): - return super().post(request, *args, **kwargs) \ No newline at end of file + return super().post(request, *args, **kwargs) + + +class GroupMembershipInviteView(APIView): + @swagger_auto_schema( + tags=['그룹'], + operation_summary="유저에게 그룹 초대 알림 보내기", + operation_description="그룹장이 특정 유저에게 초대 요청을 보냅니다.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'user_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='User ID to invite') + }, + required=['user_id'] + ), + responses={201: "Invitation sent", 400: "Error message"} + ) + def post(self, request, group_id, *args, **kwargs): + # 그룹 가져오기 + group = get_object_or_404(UserGroup, pk=group_id) + user_id = request.data.get('user_id') + sender_id = request.data.get('sender_id') # 지금 토큰 발급을 안 해서 이렇게 하고 개발하고 끝나면 request.user로 받을 예정 + + + + if not user_id: + return Response({"error": "User ID가 필요합니다."}, status=status.HTTP_400_BAD_REQUEST) + + # 초대 대상 사용자 가져오기 + user = get_object_or_404(CustomUser, email=user_id) + sender = get_object_or_404(CustomUser, email=sender_id) # 지금 토큰 발급을 안 해서 이렇게 하고 개발하고 끝나면 request.user로 받을 예정 + + # 이미 그룹에 속해 있는지 확인 + if GroupMembership.objects.filter(user=user, group=group).exists(): + return Response({"error": "이미 유저가 그룹에 속해 있습니다."}, status=status.HTTP_400_BAD_REQUEST) + + # 기존 초대 요청이 있는지 확인 + existing_invite = Notification.objects.filter( + receiver=user, group=group, notification_type='invite', status='pending' + ) + if existing_invite.exists(): + return Response({"error": "이미 해당 유저를 그룹에 초대했습니다."}, status=status.HTTP_400_BAD_REQUEST) + + # 그룹의 일정 가져오기 (없으면 기본값) + schedule = getattr(group, 'schedule', None) # group.schedule이 없으면 None 반환 + course_name = schedule.course_name if schedule and schedule.course_name else "일정 없음" + + # 초대 알림 생성 + Notification.objects.create( + sender=sender, # 현재 요청을 보낸 유저 + #sender = request.user + receiver=user, + notification_type='invite', + group=group, + content=f"{sender.name}님이 '{course_name}' 일정에 초대했습니다." + ) + + return Response({ + "message": "Invitation sent successfully.", + "group": { + "id": group.id, + "code": group.code + }, + "invited_user": { + "id": user.id, + "name": user.name + }, + "course_name": course_name # 초대된 일정 이름 추가 + }, status=status.HTTP_201_CREATED) + + + +class GroupMembershipInviteResponseView(APIView): + @swagger_auto_schema( + tags=['그룹'], + operation_summary="초대 요청 수락/거절", + operation_description="초대받은 유저가 그룹 초대 요청을 수락하거나 거절합니다.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema( + type=openapi.TYPE_STRING, + enum=['accepted', 'rejected'], + description='Invitation response (accepted/rejected)' + ) + }, + required=['status'] + ), + responses={200: "Response recorded", 400: "Error message"} + ) + def post(self, request, group_id, *args, **kwargs): + # 그룹 가져오기 + group = get_object_or_404(UserGroup, pk=group_id) + + # 현재 로그인된 사용자 가져오기 (초대받은 사람) + # user = request.user 로 바꿀 예정정 + user_id = request.data.get("user_id") + user = get_object_or_404(CustomUser,email=user_id) + + # 초대 알림 찾기 (대기 상태인 초대만) + invite_notification = Notification.objects.filter( + receiver=user, + group=group, + notification_type='invite', + status='pending' + ).first() + + if not invite_notification: + return Response({"error": "보낸 알림이 없습니다."}, status=status.HTTP_400_BAD_REQUEST) + + # 요청에서 응답 상태 가져오기 + status_response = request.data.get('status') + + if status_response == 'accepted': + # 그룹에 사용자 추가 + GroupMembership.objects.create(user=user, group=group, role='member') + invite_notification.status = 'accepted' + invite_notification.save() + return Response({"message": "그룹에 가입했습니다."}, status=status.HTTP_200_OK) + + elif status_response == 'rejected': + invite_notification.status = 'rejected' + invite_notification.save() + return Response({"message": "그룹에 가입을 거절했습니다."}, status=status.HTTP_200_OK) + + return Response({"error": "Invalid status. Use 'accepted' or 'rejected'."}, status=status.HTTP_400_BAD_REQUEST) + +class NotificationListView(APIView): + # permission_classes = [IsAuthenticated] # 로그인한 사용자만 접근 가능 + + def get(self, request, *args, **kwargs): + # user = request.user # 현재 로그인한 유저 + + user_id = request.data.get("user_id") + user = get_object_or_404(CustomUser,email = user_id) + + + is_unread = request.query_params.get("unread", None) # 읽지 않은 알림 필터 + + # 기본적으로 해당 유저의 모든 알림 조회 (최신순) + notifications = Notification.objects.filter(receiver=user).order_by("-created_at") + + # 만약 "unread=true"가 요청에 포함되면 읽지 않은 알림만 반환 + if is_unread and is_unread.lower() == "true": + notifications = notifications.filter(is_read=False) + + # 시리얼라이저를 이용해 데이터 변환 + serializer = NotificationSerializer(notifications, many=True) + + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/makourse/course/admin.py b/makourse/course/admin.py index 1962335..7f0543e 100644 --- a/makourse/course/admin.py +++ b/makourse/course/admin.py @@ -16,3 +16,22 @@ class ScheduleEntryAdmin(admin.ModelAdmin): class AlternativePlaceAdmin(admin.ModelAdmin): list_display = ('pk', 'schedule_entry', 'name') admin.site.register(AlternativePlace) + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ('id', 'receiver', 'sender', 'notification_type', 'group', 'content', 'created_at', 'is_read', 'status') + list_filter = ('notification_type', 'is_read', 'status', 'created_at') + search_fields = ('receiver__email', 'sender__email', 'content') + ordering = ('-created_at',) + fieldsets = ( + ('알림 정보', { + 'fields': ('receiver', 'sender', 'notification_type', 'group', 'content', 'is_read', 'status') + }), + ('추가 정보', { + 'fields': ('created_at',), + 'classes': ('collapse',), + }), + ) + + readonly_fields = ('created_at',) # 생성된 날짜는 읽기 전용