2-4. DjangoのAPIとAjax通信する「いいねボタン」を作成する

今回のテーマは「DjangoのAPIとAjax通信する「いいねボタン」を作成する」です。今回はDjangoで作られたAPIにjavascriptでAjax通信する処理を見ていきたいと思います。APIの設計等は本題から外れるためお粗末だとは思いますが、気になる点があればコメントいただければと思います。

※本ページはテンプレートのフィルターを使うまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


いいねボタンを作るための準備

いいねボタンがどんなものかは説明不要だと思いますが、想定を簡単に説明しておきます。ユーザーがいいねボタンを押すと1POINT加点されリアルタイムで数字が変化します。連打防止はjavascriptおよびIPアドレスで判定します。

これを実装するにあたりvoteモデルを用意します。
thread/models.py(一部抜粋)


class VoteManager(models.Manager):
    def create_vote(self, ip_address, comment_id):
        vote = self.model(
            ip_address=ip_address,
            comment_id = comment_id
        )
        try:
            vote.save()
        except:
            return False
        return True

class Vote(models.Model):
    comment = models.ForeignKey(
        Comment,
        on_delete=models.CASCADE,
        null=True,
    )
    ip_address = models.CharField(
        'IPアドレス',
        max_length=50,
    )

    objects = VoteManager()

    def __str__(self):
        return '{}-{}'.format(self.comment.topic.title, self.comment.no)

ここはモデル作成の部分で説明済みですので特に問題ないと思います。モデルの準備が出来たらマイグレーションをしましょう。もう大丈夫ですね。


(venv)$ ./manage.py makemigrations
(venv)$ ./manage.py migrate

voteの数を数えるようにthread/views.pyを変更しましょう。
thread/views.py(一部抜粋)


  class TopicAndCommentView(FormView):
      template_name = 'thread/detail_topic.html'
      form_class = CommentModelForm
      
      def form_valid(self, form):
          # comment = form.save(commit=False)
          # comment.topic = Topic.objects.get(id=self.kwargs['pk'])
          # comment.no = Comment.objects.filter(topic=self.kwargs['pk']).count() + 1
          # comment.save()
          Comment.objects.create_comment(
              user_name=form.cleaned_data['user_name'],
              message=form.cleaned_data['message'],
              topic_id=self.kwargs['pk'],
          )
          return super().form_valid(form)
  
      def get_success_url(self):
          return reverse_lazy('thread:topic', kwargs={'pk': self.kwargs['pk']})
      
      def get_context_data(self):
          ctx = super().get_context_data()
          ctx['topic'] = Topic.objects.get(id=self.kwargs['pk'])
-         ctx['comment_list'] = Comment.objects.filter(
-                 topic_id=self.kwargs['pk']).order_by('no')
+         ctx['comment_list'] = Comment.objects.filter(
+                 topic_id=self.kwargs['pk']).annotate(vote_count=Count('vote')).order_by('no')
          return ctx

特に説明は不要かと思います。annotateで各コメントが参照しているVoteモデルの数を数えて追加しています。このvote_countが投票されたポイントとなります。

テンプレート側の準備

いいねボタンのUIを準備しましょう。templates/thread/detail_topic.htmlを修正します。
templates/thread/detail_topic.html(一部抜粋)



  {% if comment.pub_flg %}
+ <p>{{comment.message | comment_filter | safe}}</p>
+ <div class="ui right aligned vertical segment">
+     <div class="vote_button" style="cursor: pointer;"
+         data-comment-id="{{comment.id}}" data-count="{{comment.vote_count}}">
+         <i class="heart outline icon"></i>
+         <span class="vote_counter">
+             {% if comment.vote_count > 0 %}{{comment.vote_count}}{% endif %}
+         </span>
+     </div>
+ </div>
+ {% else %}
+ <p style="color: grey">コメントは非表示とされました</p>
  {% endif %}

こんなハートマークが表示されると思います。

APIの作成

さて、これで事前準備ができました。API用のアプリケーションを用意しましょう。


(venv)$ ./manage.py startapp api

アプリケーションを追加したのでmysite/settings.py, mysite/urls.pyをいつもどおり設定します。

mysite/settings.py


  INSTALLED_APPS = [
      'django.contrib.admin',
      'django.contrib.auth',
      'django.contrib.contenttypes',
      'django.contrib.sessions',
      'django.contrib.messages',
      'django.contrib.staticfiles',
      'debug_toolbar',
      'base',
      'thread',
+     'api',
  ]

mysite/uls.py


  urlpatterns = [
      path('admin/', admin.site.urls),
      path('accounts/', include('django.contrib.auth.urls')),
      path('', include('base.urls')),
      path('thread/', include('thread.urls')),
+     path('api/', include('api.urls')),
  ]

ここまでは問題ないですね。ではAPIを作成していきましょう。
api/views.pyを以下のようにします。
api/views.py


from django.views.generic import View
from django.http import JsonResponse

from thread.models import Vote

class CreateVoteView(View):
    '''
    いいね投票作成処理を行う
    '''
    def post(self, request, *args, **kwargs):
        res = {
            'result': False,
            'message': '処理に失敗しました。'
        }
        # POST値に'comment_id'がなければBAD REQUESTとする
        if not 'comment_id' in request.POST:
            return JsonResponse(res, status=400)
        
        # コメントIDとIPアドレスの取得
        comment_id = request.POST['comment_id']
        ip_address = get_client_ip(request)

        # 既にIP登録があればコンフリクト
        if Vote.objects.filter(comment_id=comment_id, ip_address=ip_address):
            res['message'] = '投票済みです'
            return JsonResponse(res, status=409)
        
        # Voteの保存に成功した場合のみ成功
        if Vote.objects.create_vote(ip_address, comment_id):
            res['result'] = True
            res['message'] = 'ポイント追加しました'
            return JsonResponse(res, status=201)      
        else:
            return JsonResponse(res, status=500)

def get_client_ip(request):
    '''
    IPアドレスを取得する
    '''
    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
    if x_forwarded_for:
        ip = x_forwarded_for.split(',')[0]
    else:
        ip = request.META.get('REMOTE_ADDR')
    return ip

ソースを追っていただければ処理自体は非常に簡単だと思います。コメントIDとIPアドレスをセットで格納し、新たな投票時にはIPアドレスによって重複投票を防いでいます。今回はViewを継承したクラスベースビューを用意しました。関数タイプで書いても問題ないですよ。今回はテンプレートがないですが、ユーザーに見せるべき情報を用意するというビューの役割は変わりません。

JSON形式でレスポンスを返すためにJsonResponseを用いています。JsonResponseは辞書型の変数を引数としてJSON形式にエンコードしたボディを返します。また、HTTPステータスコードをstatus引数で渡しています。API設計の拙い点は目をつぶっていただければと思います。また、一般的に、HTTPレスポンスコード201の場合はヘッダに作成したオブジェクトを示すURLを入れることが推奨されていますが、今回は投票ということで結果のみを返しています。

このビューにアクセスするためのURLを設定します。api/urls.pyを生成します。

api/urls.py


from django.urls import path
from . import views
# from django.views.decorators.csrf import csrf_exempt

app_name = 'api'

urlpatterns = [
    path('v1/vote/', views.CreateVoteView.as_view(), name='create_vote'),
]

これでAPI側の準備はできました。

javascriptの実装を見てみましょう。今回はjQueryフレームワークを使用して実装します。使いたくない方は生のjavascriptでも問題ありません。static/js/vote.jsを用意しましょう。
static/js/vote.js


$(function(){
    // setup for ajax
    var csrftoken = getCookie('csrftoken');
    $.ajaxSetup({
        beforeSend: function(xhr, settings) {
            if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRFToken", csrftoken);
            }
        }
    });

    var votedList = [];// 連打防止用のコメントID格納リスト
    // いいねボタン押下時の処理
    onClickVoteButton();

    function getCookie(name) {
        var cookieValue = null;
        if (document.cookie && document.cookie !== '') {
            var cookies = document.cookie.split(';');
            for (var i = 0; i < cookies.length; i++) {
                var cookie = jQuery.trim(cookies[i]);
                // Does this cookie string begin with the name we want?
                if (cookie.substring(0, name.length + 1) === (name + '=')) {
                    cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                    break;
                }
            }
        }
        return cookieValue;
    }

    function csrfSafeMethod(method) {
        // these HTTP methods do not require CSRF protection
        return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
    }

    function onClickVoteButton() {
        $('.vote_button').on('click', function() {
            var commentId = $(this).data('comment-id');
            var currentCount = $(this).data('count');
            var countViewer = $(this).find('.vote_counter');
            if (votedList.indexOf(commentId) < 0) {
                vote(commentId, currentCount, countViewer);
            }
        });
    }

    // ajax通信して投票結果を反映する
    function vote(commentId, currentCount, countViewer) {
        let url = '/api/v1/vote/';
        $.ajax({
            type: 'POST',
            url: url,
            data: {
                comment_id: commentId
            }
        }).then(
            data => {
                if (data.result) {
                    countViewer.text(currentCount + 1);
                    votedList.push(commentId);
                }
            },
            error => {
                if (error.responseJSON.message) {
                    alert(error.responseJSON.message);
                }
            }
        );
    }
});

POSTメソッドでAPIにアクセスする場合は注意が必要です。DjangoのPOSTメソッドにはクロススクリプトフォージェリ対策が必要であるためにキャッシュからトークンを取得してヘッダにセットする処理をしています。これさえ注意すれば後は特に問題ありません。Ajaxの通信結果に応じてUIに変更を加える処理をしています。尚、GETでアクセスする場合にはCSRFの認証を気にする必要はありません。

ではこのvote.jsが読み込まれるようにtemplates/thread/detail_topic.htmlに追加しましょう。

templates/thread/detail_topic.html(一部抜粋)


<--!ファイルの末尾に追加-->
{% block js %}
<script src="{% static 'js/vote.js' %}" type='text/javascript'></script>
{% endblock %}

これで、vote.jsが読み込まれるようになったはずです。ではブラウザで確認してみましょう。ハートマークを押してポイントがプラス1されればOKです。同一コメントに対しては同じIPからは一回しか投票できないはずです。

最後に

単にAjax通信を説明する割には大げさなサンプルになってしまいました。Ajax通信だけでなく、JSONレスポンスを返す場合の処理についても紹介できたのではないかと思います。尚、csrfを解除してPOSTしたいという要望もあると思います。これに関しては別の機会に扱おうと思います。

Sponsored Link


「2-4. DjangoのAPIとAjax通信する「いいねボタン」を作成する」への2件のフィードバック

  1. vote.jsの追加のところが、

    {% block js %}
    {% endblock %}
    となっており、何も読み込まれていません。

    {% block js %}
    {% load static %}

    {% endblock %}
    とするのが正しいでしょうか?

    1. TTSSさん
      コメントありがとうございます。scriptタグがエスケープされてしまい表示されていませんでした。
      修正しました。
      ここの部分でvote.jsを読み込む予定でした。 

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です