장고 - ajax를 이용한 회원 검색 및 초대 기능 적용

  • 회원 초대를 위한 모델링 설계 방법과 Ajax를 이용한 회원 검색 및 초대 방법에 대해 학습한 내용에 대해 알아볼 것입니다.

ex) extore_project

1. 모델링 설계

  • 경로 : extore > models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
from django.db import models
from django.utils.text import slugify
from accounts.models import User

# 회원들을 초대할 그룹 테이블 작성
class Group(models.Model):
# 그룹명
title = models.CharField(max_length=10)
slug = models.SlugField(max_length=30, unique=True, allow_unicode=True, db_index=True, default="")
# 그룹 방장
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='author_groups')
# 그룹 멤버
member = models.ManyToManyField(User, related_name='members_groups', blank=True)
# 그룹 대표 사진
image = models.ImageField(upload_to='group_images/%Y/%m/%d', blank=True, null=True)
# 그룹 생성일
created = models.DateTimeField(auto_now_add=True)

# title 새로 저장 시, slug 에 해당 title slugify하여 저장
def save(self, *args, **kwargs):
if not self.id:
self.slug = slugify(self.title, allow_unicode=True)
super(Group, self).save(*args, **kwargs)

def __str__(self):
return self.title

# 그룹별 초대받은 회원 상태(진행중, 승인, 거절) 저장할 테이블 설정
class InviteStatus(models.Model):
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
invited = models.ManyToManyField(User, related_name='invited', blank=True)
accepted = models.ManyToManyField(User, related_name='accepted', blank=True)
rejected = models.ManyToManyField(User, related_name='rejected', blank=True)
created = models.DateTimeField(auto_now_add=True)

def __str__(self):
return self.group.title


# 회원 초대할 때의 시점 기준으로 얼마나 시간이 흘렀는지 초대받은 사용자에게 표시해주기 위한 테이블 설정
class InviteDate(models.Model):
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invitedDate')
invited = models.ForeignKey(User, on_delete=models.CASCADE, related_name='invitedDate')
created = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f'{self.invited.last_name}{self.invited.first_name} invited from {self.group.title} in {self.created}'

2. 초대 버튼 및 ajax 통신 위한 템플릿 작성

  • 경로 : layout > post-base.html (회원 검색 및 초대)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    <!-- 초대 버튼 -->
    <div class="plus-icon text-center" data-toggle="modal" data-target="#inviteModal">
    <img src="{% static 'images/plus_icon.png' %}">
    </div>

    <!-- Modal -->
    <div class="modal fade bd-example-modal-lg" id="inviteModal" tabindex="-1" role="dialog" aria-labelledby="inviteModalTitle" aria-hidden="true">
    <div class="modal-dialog modal-dialog-scrollable modal-lg" role="document">
    <div class="modal-content" style="z-index:1000;">
    <div class="modal-header">
    <h5 class="modal-title" id="inviteModalTitle">회원 초대</h5>
    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
    <span aria-hidden="true">&times;</span>
    </button>
    </div>
    <div class="modal-body">
    <form action="{% url 'accounts:search' %}" class="userSearch container">
    {% csrf_token %}
    <div class="row">
    <div class="col-10">
    <input type="text" name="userSearch" class="form-control" placeholder="회원을 검색해주세요.">
    </div>
    <input type="submit" class="btn btn-outline-primary pull-right col-2" id="selectBtn" value="검색">
    </div>
    </form>
    <div style="height:300px;" class="userSearched"></div>
    </div>
    <div class="modal-footer">
    <button type="button" class="btn btn-secondary btn-back" data-dismiss="modal">돌아가기</button>
    <a href="{% url 'accounts:invite' %}" type="button" class="btn btn-primary btn-invite">초대하기</a>
    </div>
    </div>
    </div>
    </div>

    <script>
    $(function(){
    var state;
    var user_number_list;
    var user_name_list;
    var extore_title;
    var url;
    var phoneNumber;
    var userName;

    // 초대할 사용자를 찾기 위해 회원 검색 버튼을 누른 경우
    $('.userSearch').submit(function(e){
    e.preventDefault();
    url = $(this).attr('action');
    // 검색한 키워드 값을 userKeyword 변수에 저장
    userKeyword = $('input[name=userSearch]').val();

    $.ajax({
    url:url,
    method:"POST",
    data:{
    // POST 방식이라면 'csrfmiddlewaretoken':'{{csrf_token}}' 입력 필수
    'csrfmiddlewaretoken': '{{csrf_token}}',
    'userKeyword': userKeyword,
    },
    // ajax 요청이 성공한다면 진행되는 콜백 함수
    success: function(data){
    // ajax 요청이 성공한다면, callbackFirst(data) 함수가 실행
    callbackFirst(data);
    }
    });
    });

    // 초대하고 싶은 유저 선택하여, 초대 버튼을 클릭한 경우
    $(".btn-invite").on('click', function(e) {
    e.preventDefault();
    user_number_list = new Array();
    user_name_list = new Array();
    extore_title = $('.extore-title').text();
    url = $('.btn-invite').attr('href');

    // 배열을 데이터로 전송하기 위해 jQuery.ajaxSettings.traditional = true 설정 필수
    jQuery.ajaxSettings.traditional = true;
    // 검색된 유저가 선택되어 있다면, 선택되어있는 유저의 연락처, 이름을 각각 phoneNumber, userName 변수에 저장한 이후, phoneNumber를 user_number_list 배열에 추가하고 userName을 user_name_list 배열에 추가한다.
    $('.searched-user').each(function(){
    if ($(this).data('check') == true){
    phoneNumber = $('td:nth-child(4)', this).text();
    userName = $('td:nth-child(2)', this).text();
    user_number_list.push(phoneNumber);
    user_name_list.push(userName);
    }
    });

    $.ajax({
    url: url,
    method: 'POST',
    data: {
    'csrfmiddlewaretoken':'{{csrf_token}}',
    'user_number_list[]': user_number_list,
    'extore_title': extore_title,
    },
    // 초대 ajax 요청이 성공한다면, callbackSecond(data) 실행
    success: function(data){
    callbackSecond(data);
    }
    });
    });

    // 회원 검색 ajax 요청이 성공한 경우 실행되는 callbackFirst 함수 implementation
    function callbackFirst(data){
    // 뒤로가기 버튼 클릭한 경우
    $('.btn-back').click(function(e){
    $('.userSearched').html("");
    $('.form-control').val("");
    });
    // 닫기(x) 버튼 클릭한 경우
    $('.close').click(function(e){
    $('.userSearched').html("");
    $('.form-control').val("");
    });
    // 요청한 키워드가 없는 경우
    if(data.noKeyword){
    alert('회원의 이름, 연락처, 닉네임 중 하나를 선택하여 입력해주세요.');
    }
    // 해당 키워드에 맞는 사용자가 검색된 경우
    if(data.isSearched){
    $('.userSearched').html("");
    $('.userSearched').prepend(data.html);
    $(".searched-user").hover(function(){
    $(this).css('cursor', 'pointer');
    });
    }
    // 검색된 유저의 div 를 클릭하는 경우(선택된 유저의 배경색이 whitesmoke와 white로 toggle되는 효과 발생)
    state = true;
    $(".searched-user").on('click', function(e) {
    $(e.currentTarget).css("background-color", state ? 'whitesmoke' : 'white');
    $(e.currentTarget).data('check', state);
    state = !state;
    });
    }

    // 특정 회원을 초대하는 ajax 요청이 성공한 경우 실행되는 callbackSecond 함수 implementation
    function callbackSecond(data){
    // 어떤 유저도 선택하지 않고 초대버튼을 누른 경우
    if(data.notSelect){
    alert('초대할 회원을 선택해주세요.')
    }
    // 이미 해당 그룹에 초대되어있는 유저를 다시 초대한 경우
    if(data.already_exists){
    console.log(data.already_exists);
    alert("'"+data.already_exists+"'"+'님은 이미 '+"'"+extore_title+"'"+'에 초대되어 있습니다.\n초대되지 않은 회원을 초대해주세요.');
    }
    // 이미 초대 요청을 보낸 유저를 다시 초대한 경우
    if(data.already_requested){
    alert("'"+data.already_requested+"'"+'님에게 이미 초대 요청을 보낸 상태입니다. \n상대방이 초대 승락할 때까지 기다려주세요.');
    }
    // 초대 요청 성공
    if(data.works){
    userName = user_name_list.toString();
    alert(userName+'님에게 초대 요청을 보냈습니다.\n상대방의 초대 승락 시, 바로 '+"'"+extore_title+"'"+'에 초대됩니다.');
    }
    // 초대 요청 이후, 특정 메시지 수신하고나서 선택된 유저의 배경색이 white로 변경
    state = false;
    // 선택했던 유저의 class에 data로, false 를 값으로 가지는 check 변수 저장
    $('.searched-user').data('check', state);
    $('.searched-user').css('background-color', 'white');
    }
    </script>
    • 경로 : extore > templates > extore > extore_index.html (받은 초대에 대한 승인 및 거절)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      <!-- exoterInvited Modal -->
      <div class="modal fade" id="extoreInvited" tabindex="-1" role="dialog" aria-labelledby="extoreInvitedLabel" aria-hidden="true">
      <div class="modal-dialog" role="document">
      <div class="modal-content" style="z-index:1000;">
      <div class="modal-header">
      <h5 class="modal-title" id="extoreInvitedLabel">초대받은 익스토어</h5>
      <button type="button" class="close" data-dismiss="modal" aria-label="Close">
      <span aria-hidden="true">&times;</span>
      </button>
      </div>
      <div class="modal-body">
      {% load calc %}
      {% if invited_groups %}
      {% for invited_group in invited_groups %}
      <div class="card invitation">
      <div class="card-body" style="padding:15px;">
      <div class="extore-img" style="background-image:url({{invited_group.group.image.url}});"></div>
      <div style="float:left;">
      <h5 style="margin-left:20px; margin-bottom:0;">{{invited_group.group.title}}</h5>
      <p style="display:inline-block; margin:10px 15px 0 20px; color:grey;">방장: {{invited_group.group.author.last_name}}{{invited_group.group.author.first_name}}</p>
      <p style="display:inline-block; margin:10px 0 0 0; color:grey;">인원: {{invited_group.group.member.count}}</p>
      </div>
      <p style="display:inline-block; float:right;">{{invited_group|same_invitation_time_since:invited_dates}}</p>
      <div style="clear:left;"></div>
      <div style="float:right;">
      <a href="{% url 'accounts:accept' invited_group.id %}" class="invite-response btn btn-primary">승락</a>
      <a href="{% url 'accounts:accept' invited_group.id %}" class="invite-response btn btn-secondary">거부</a>
      </div>
      </div>
      </div>
      {% endfor %}
      {% else %}
      <p style="text-align:center;">초대받은 익스토어가 없습니다.</p>
      {% endif %}
      </div>
      </div>
      </div>
      </div>

      <script>
      $(function(){
      // 받은 초대를 확인하기 위해 '초대 알림' 버튼 클릭한 경우
      $('.invite-response').click(function(e){
      e.preventDefault();
      url = $(e.currentTarget).attr('href');
      // '승인' 혹은 '거절' 버튼 클릭 시, 해당 데이터가 result 변수에 저장
      result = $(e.currentTarget).text();
      keepGoing = confirm(result+'하시겠습니까?');

      if(keepGoing == true){
      $.ajax({
      url : url,
      method : "POST",
      data : {
      'csrfmiddlewaretoken': '{{csrf_token}}',
      'result' : result,
      },
      }).done(function(data){
      // 유저가 '승인' 버튼 클릭한 경우
      if(data.accepted){
      alert('축하합니다!\n'+"'"+data.groupTitle+"'"+'의 회원이 되었습니다.');
      location.reload();
      }
      // 유저가 '거절 버튼 클릭한 경우
      if(data.rejected){
      alert("'"+data.groupTitle+"'"+'의 초대 요청을 거절하였습니다.');
      location.reload();
      }
      });
      }
      });
      </script>

3. ajax 요청을 수신할 url 경로 설정

  • 경로 : accounts > urls.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    from django.urls import path
    from .views import *


    app_name = 'accounts'

    urlpatterns = [
    path('search/', user_search, name='search'),
    path('invite/', user_invite, name='invite'),
    ...
    ]

4. ajax 요청 수신 및 response data 송신하기 위한 view 작성

  • 경로 : accounts > views.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    # 특정 익스토어에 사용자 초대 위한 검색
    def user_search(request):
    if request.is_ajax():
    user_keyword = request.POST.get('userKeyword')
    # 유저가 어떠한 키워드도 입력하지 않았을 때
    if not user_keyword:
    return JsonResponse({'noKeyword':True})
    # 한글, 영어, 숫자가 아닌 문자들 제외
    not_hangul = re.compile('[^가-힣0-9a-z]+')
    converted_keyword = not_hangul.sub('', user_keyword)

    # 키워드 앞에 3자리가 010일 경우, 연락처 입력으로 간주
    if converted_keyword[:3] == '010':
    # 010만 검색한 경우, 조회 불가
    if converted_keyword == '010':
    html = render_to_string('accounts/user-search.html')
    return JsonResponse({'isSearched':True, 'html':html})
    users = User.objects.filter(Q(phone_number__icontains=converted_keyword))
    # 연락처를 입력하지 않고, 이름 혹은 닉네임으로 검색한 경우
    else:
    consistent_name = Q(last_name=converted_keyword[0]) & Q(first_name__icontains=converted_keyword[1:])
    consistent_name = consistent_name | Q(first_name__icontains=converted_keyword)
    users = User.objects.filter(Q(phone_number__icontains=converted_keyword)|Q(username__icontains=converted_keyword)|consistent_name)
    html = render_to_string('accounts/user-search.html', {'users':users})
    return JsonResponse({'isSearched':True, 'html':html})

    raise Http404


    # 특정 익스토어에 사용자 초대
    def user_invite(request):
    if request.is_ajax():
    # 선택한 유저의 연락처 정보들을 배열로 저장
    users_number = request.POST.getlist('user_number_list[]')

    if not users_number:
    return JsonResponse({'notSelect':True})

    extore_title = request.POST.get('extore_title')
    # User 객체에서 phone_number가 user_number 배열에 속해있는 객체들을 users 변수가 참조
    users = User.objects.filter(phone_number__in=users_number)

    # 초대 요청한 익스토어 그룹명이 title인 Group 객체를 group 변수가 참조
    group = Group.objects.get(title=extore_title)

    if not request.user in group.member.all():
    raise Http404

    # InviteStatus 객체 중, group_id가 group.id와 일치하는 객체를 invite_status 변수(queryset 형태)가 참조
    invite_status = InviteStatus.objects.filter(group_id=group.id)

    # invite_status가 존재한다면, invite_status[0](클래스 형태) 을 invite_status 변수가 참조
    if invite_status.exists():
    invite_status = invite_status[0]
    # invite_status가 존재하지 않는다면, None을 invite_status 변수가 참조
    else:
    invite_status = None
    # 초대한 유저가 해당 그룹에 이미 존재하고 있는 경우, already_exists 배열에 유저 이름 추가
    already_exists = []
    # 초대한 유저가 이미 초대 요청받은 경우(승락, 거부하지 않은 상태), already_requested 배열에 유저 이름 추가
    already_requested = []
    for user in users:
    # 유저가 이미 해당 그룹 멤버인 경우
    if user in group.member.all():
    user_name = user.last_name + user.first_name
    already_exists.append(user_name)
    # invite_status 객체가 이미 존재하고, 유저가 이미 전에 초대 요청받은(승락, 거부하지 않은 상태) 경우
    if invite_status and user in invite_status.invited.all():
    user_name = user.last_name + user.first_name
    already_requested.append(user_name)

    if already_exists:
    already_exists = ','.join(already_exists)
    return JsonResponse({'already_exists':already_exists})

    if already_requested:
    already_requested = ','.join(already_requested)
    return JsonResponse({'already_requested':already_requested})

    if invite_status is None:
    invite_status = InviteStatus.objects.create(group_id=group.id)
    for user in users:
    invite_status.invited.add(user)
    InviteDate.objects.create(group_id=group.id, invited_id=user.id)

    return JsonResponse({'works':True})

    raise Http404


    # 익스토어 초대 요청에 대한 사용자 승인
    def user_accept(request, inviteStatus_id):
    if request.is_ajax():
    if request.POST.get('result', None) == '승락':
    # InviteStatus id 데이터 전달받아야 함
    invite_status = InviteStatus.objects.get(pk=inviteStatus_id)
    if not request.user in invite_status.invited.all():
    raise Http404
    invite_status.invited.remove(request.user)

    # InviteDate 객체중, 초대 승낙한 그룹 id와 유저 id가 일치하는 객체를 삭제
    invite_date = InviteDate.objects.filter(Q(group_id=invite_status.group.id) and Q(invited_id=request.user.id))
    invite_date.delete()

    if request.user in invite_status.rejected.all():
    invite_status.rejected.remove(request.user)
    if request.user in invite_status.accepted.all():
    pass
    if not request.user in invite_status.accepted.all():
    invite_status.accepted.add(request.user)

    group = Group.objects.get(pk=invite_status.group.id)
    group.member.add(request.user)

    return JsonResponse({'accepted':True, 'groupTitle':group.title})

    elif request.POST.get('result', None) == '거부':
    invite_status = InviteStatus.objects.get(pk=inviteStatus_id)
    if not request.user in invite_status.invited.all():
    raise Http404
    invite_status.invited.remove(request.user)

    # InviteDate 객체중, 초대 승낙한 그룹 id와 유저 id가 일치하는 객체를 삭제
    invite_date = InviteDate.objects.filter(Q(group_id=invite_status.group.id) and Q(invited_id=request.user.id))
    invite_date.delete()

    if request.user in invite_status.accepted.all():
    invite_status.accepted.remove(request.user)
    if request.user in invite_status.rejected.all():
    pass
    else:
    invite_status.rejected.add(request.user)

    group = Group.objects.get(pk=invite_status.group.id)

    return JsonResponse({'rejected':True, 'groupTitle':group.title})

    raise Http404