Djangoでイイね機能を実装する方法について

今回はDjangoサイトで、イイね!機能を実装する方法についてまとめていきます。
掲示板システムを例にします。

アプリ

registerアプリとbbsアプリを使っています。

python manage.py startapp register
python manage.py startapp bbs


モデル


registerアプリと、bbsアプリのmodels.pyは以下のようにしました。

# register/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
    pass

# bbs/models.py
from django.db import models
from django.utils import timezone
from register.models import User


class Post(models.Model):
    """投稿"""
    writer = models.CharField('投稿者', default='名無し', max_length=32)
    title = models.CharField('タイトル', max_length=256)
    text = models.TextField('本文')
    created_at = models.DateTimeField('作成日', default=timezone.now)

    def __str__(self):
        return self.title


class Comment(models.Model):
    """コメント"""
    writer = models.CharField('名前', default='名無し', max_length=32)
    text = models.TextField('本文')
    target = models.ForeignKey(Post, on_delete=models.CASCADE, verbose_name='対象記事')
    created_at = models.DateTimeField('作成日', default=timezone.now)

    def __str__(self):
        return self.text[:20]


class LikeForPost(models.Model):
    """投稿に対するいいね"""
    target = models.ForeignKey(Post, on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    timestamp = models.DateTimeField(default=timezone.now)


class LikeForComment(models.Model):
    """コメントに対するいいね"""
    target = models.ForeignKey(Comment, on_delete=models.CASCADE)
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    timestamp = models.DateTimeField(default=timezone.now)


投稿と投稿に対するコメントを定義しています。
イイねは投稿とコメント両方につけられるようにします。
誰がイイねしたのか、ということを保存したいので、registerアプリのUserを外部キーであてています。

モデルを書いたらmigrateします。

python manage.py makemigrations
python manage.py migrate


画面の前準備


まずはイイね機能を実装する前に適当に画面を作ります。
CSSフレームワークはMDBを使っています。

# bbs/urls.py
from django.urls import path
from . import views

app_name = 'bbs'

urlpatterns = [
    path('', views.PostList.as_view(), name='post_list'),
    path('post_detail/<int:pk>/', views.PostDetail.as_view(), name='post_detail'), 
]


# bbs/views.py
from django.views import generic
from .models import Post, Comment, LikeForPost, LikeForComment


class PostList(generic.ListView):
    template_name = 'bbs/post_list.html'
    model = Post


class PostDetail(generic.DetailView):
    template_name = 'bbs/post_detail.html'
    model = Post


テンプレートですが、まずbase.htmlを作成します。
MDBのフレームワークを読み込む形です。

<!-- bbs/templates/bbs/base.html -->
{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
  <!-- Required meta tags -->
  <meta charset="UTF-8">
  <meta http-equiv="Content-Type" content="text / html; charset = utf-8" />
  <title>いいねテスト</title>
  <!-- Font Awesome -->
  <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css" rel="stylesheet" />
  <!-- Google Fonts -->
  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
  <!-- MDB -->
  <link href="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/3.10.1/mdb.min.css" rel="stylesheet" />
  {% block extrahead %}{% endblock %}
</head>
<body style="background-color:#eee;">
  <!-- Header -->
  <nav class="navbar navbar-expand-lg navbar-light bg-light sticky-top">
    <div class="container">
      <a class="navbar-brand" href="{% url 'bbs:post_list' %}">いいねテスト掲示板</a>
    </div>
  </nav>
  <div class="container pt-5 pb-5" style="max-width:720px;">
    {% block content %}{% endblock %}
  </div>
  <!-- MDB -->
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/4.0.0/mdb.min.js"></script>
  {% block extrajs %}{% endblock %}
</body>
</html>


次に一覧ページですね。

<!-- bbs/templates/bbs/post_list.html -->
{% extends 'bbs/base.html' %}

{% block content %}
<section class="mt-5">
  {% for post in post_list %}
  <article class="article mb-5">
    <h2 class="mt-2">
      <a class="text-dark" href="{% url 'bbs:post_detail' post.pk %}"> #{{ post.pk }} {{ post.title }}</a>
    </h2>
    <i class="far fa-calendar-alt"></i>
    <span class="ms-1">{{ post.created_at | date:'Y-m-d' }}</span>
  </article>
  {% endfor %}
</section>
{% endblock %}


最後に詳細ページです。
今回は詳細ページでイイね機能を実装しますので、ここからはpost_detail.htmlのみ編集していきます。

<!--  bbs/templates/bbs/post_detail.html -->
{% extends 'bbs/base.html' %}

{% block content %}
<section class="mt-5">
  <div class="card">
    <div class="card-header">
      ここに投稿に対するいいね機能を入れる
    </div>
    <div class="card-body">
      <div class="card-title">
        <h2>#{{ post.pk }} {{ post.title }}</h2>
      </div>
      <div class="card-text">
        <span class="fs-6">{{ post.created_at | date:'Y-m-d'}}</span>
        <p class="fs-6">{{ post.writer }}</p>
        <div class="mt-5">
          {{ post.text }}
        </div>
      </div>
    </div>
  </div>
  {% for comment in post.comment_set.all %}
  <div class="card mt-4">
    <div class="card-body">
      <div class="card-title">
        <span class="fs-6">{{ comment.created_at | date:'Y-m-d'  }}</span>
        <p class="fs-6">{{ comment.writer }}</p>
      </div>
      <div class="card-text">
        <div class="mt-4 mb-4">
          {{ comment.text }}
        </div>
        <div class="card-footer">
          <p>ここにコメントに対するいいね機能をいれる</p>
        </div>
      </div>
    </div>
  </div>
  {% endfor %}
</section>
{% endblock %}


詳細ページにアクセスすると、とりあえずこのように表示されるでしょう。



ディオのテスト投稿に対してジョルノ・ジョヴァーナが2ゲットとコメントをしている例です。

投稿に対するイイねの実装

前置きが長くなりましたがイイねを実装していきます。
※ここから、ログインが必須になってきますので、superuserでログインをするようにしてください。

まずはviews.pyを次のように編集します。

class PostDetail(generic.DetailView):
    template_name = 'bbs/post_detail.html'
    model = Post

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        like_for_post_count = self.object.likeforpost_set.count()
        # ポストに対するイイね数
        context['like_for_post_count'] = like_for_post_count
        # ログイン中のユーザーがイイねしているかどうか
        if self.object.likeforpost_set.filter(user=self.request.user).exists():
            context['is_user_liked_for_post'] = True
        else:
            context['is_user_liked_for_post'] = False

        return context


投稿のイイねに対して、イイねの数とログイン中のユーザーがイイねしているかどうか、という情報を持たせます。
次にpost_detail.html内のイイね機能を持たせたいエリアを以下のようにします。

    <div class="card-header">
      {% if is_user_liked_for_post %}
      <button type="button" id="ajax-like-for-post" style="border:none;background:none">
        <!-- すでにイイねしている時はfasクラス -->
        <i class="fas fa-heart text-danger" id="like-for-post-icon"></i>
      </button>
      {% else %}
      <button type="button" id="ajax-like-for-post" style="border:none;background:none">
        <!-- イイねしていないときはfarクラス -->
        <i class="far fa-heart text-danger" id="like-for-post-icon"></i>
      </button>
      {% endif %}
      <!-- イイねの数 -->
      <span id="like-for-post-count">{{ like_for_post_count }}</span>
      <span>件のイイね</span>
    </div>


イイねしている場合としていない場合に応じてアイコンが変わるようになります。

まだしていない場合


既にイイねしている場合。

非同期でイイねを追加する

次にイイねを追加する処理を書いていきます。
DjangoのCreateView等を使うと簡単ですが、この手の機能は非同期通信で行うのが一般的です。
まずはアプリのurls.pyにポストを受けるurlを設定します。

urlpatterns = [
    path('', views.PostList.as_view(), name='post_list'),
    path('post_detail/<int:pk>/', views.PostDetail.as_view(), name='post_detail'),
    path('like_for_post/', views.like_for_post, name='like_for_post'),  # 追加
]


次にviews.pyです。

from django.http import JsonResponse  # 追加
from django.shortcuts import get_object_or_404  # 追加


def like_for_post(request):
    post_pk = request.POST.get('post_pk')
    context = {
        'user': f'{request.user.last_name} {request.user.first_name}',
    }
    post = get_object_or_404(Post, pk=post_pk)
    like = LikeForPost.objects.filter(target=post, user=request.user)

    if like.exists():
        like.delete()
        context['method'] = 'delete'
    else:
        like.create(target=post, user=request.user)
        context['method'] = 'create'

    context['like_for_post_count'] = post.likeforpost_set.count()

    return JsonResponse(context)


そのユーザーのイイねが存在していたら、削除し、存在していなかったら追加する、という処理をしています。

次にpost_detail.html内にじJavaScriptを追加して、このviewを呼び出します。

{% block extrajs %}
<script type="text/javascript">
  /* ポストに対するイイね */
  document.getElementById('ajax-like-for-post').addEventListener('click', e => {
    e.preventDefault();
    const url = '{% url "bbs:like_for_post" %}';
    fetch(url, {
      method: 'POST',
      body: `post_pk={{post.pk}}`,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
        'X-CSRFToken': '{{ csrf_token }}',
      },
    }).then(response => {
      return response.json();
    }).then(response => {
      // 通信後の処理、とりあえずレスポンスを表示
      console.log(response);
    }).catch(error => {
      console.log(error);
    });
  });
</script>
{% endblock %}


アイコンボタンをクリックしたら非同期通信を投げるようなイメージです。

ためしにハートアイコンをクリックしてみます。
削除した場合

{user: 'ディオ・ ブランドー', method: 'delete', like_for_post_count: 0}

作成した場合

{user: 'ディオ・ ブランドー', method: 'create', like_for_post_count: 1}


とりあえず、正しくデータが登録できているみたいです。

後は作成した場合と削除した場合に応じて、表示を切り替えるイメージです。
処理を以下のように追加します。

<script type="text/javascript">
  /* ポストに対するイイね */
  document.getElementById('ajax-like-for-post').addEventListener('click', e => {
    fetch(url, {
      //...省略
      },
    }).then(response => {
      return response.json();
    }).then(response => {
      // イイね数を書き換える
      const counter = document.getElementById('like-for-post-count')
      counter.textContent = response.like_for_post_count
      const icon = document.getElementById('like-for-post-icon')
      // 作成した場合はハートを塗る
      if (response.method == 'create') {
        icon.classList.remove('far')
        icon.classList.add('fas')
        icon.id = 'like-for-post-icon'
      } else {
        icon.classList.remove('fas')
        icon.classList.add('far')
        icon.id = 'like-for-post-icon'
      }
    }).catch(error => {
      console.log(error);
    });
  });
</script>


コメントに対するイイね

次にコメントに対するイイねです。
やることはほぼ同じですが、コメントは一つの投稿に対して複数紐づくので少し処理が多いです。
まずはviews.pyに追記をします。

class PostDetail(generic.DetailView):
    template_name = 'bbs/post_detail.html'
    model = Post

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # ...省略
        # {'pk':{'count':コメント数,'is_user_like_for_comment':bool},}という辞書を追加していく
        d = {}
        for comment in self.object.comment_set.all():
            like_for_comment_count = comment.likeforcomment_set.count()
            if comment.likeforcomment_set.filter(user=self.request.user).exists():
                is_user_liked_for_comment = True
            else:
                is_user_liked_for_comment = False
            d[comment.pk] = {'count': like_for_comment_count, 'is_user_liked_for_comment': is_user_liked_for_comment}

        context[f'comment_like_data'] = d

        return context


次にbbsアプリ内にtemplatetagsディレクトリを作り、bbs.pyを作成します。

# bbs/templatetags/bbs.py
from django import template

register = template.Library()

@register.filter
def get_item(dictionary, key):
    return dictionary.get(key)


テンプレート内での辞書の扱いを楽にするために追加しています。
次にtemplateで読み込んで、card-footer内を以下のようにします。

{% load bbs %}

{% for comment in post.comment_set.all %}

<!-- 省略 -->
<div class="card-footer">
  <!-- templatetagのメソッドで、中の辞書をdicという変数に変換 -->
  {% with comment_like_data|get_item:comment.pk as dic %}
  {% if dic.is_user_liked_for_comment %}
  <button type="button" name="ajax-like-for-comment" data-pk="{{ comment.pk }}" style="border:none;background:none">
    <i class="fas fa-heart text-danger" id="like-for-comment-icon-{{comment.pk}}"></i>
  </button>
  {% else %}
  <button type="button" name="ajax-like-for-comment" data-pk="{{ comment.pk }}" style="border:none;background:none">
    <i class="far fa-heart text-danger" id="like-for-comment-icon-{{comment.pk}}"></i>
  </button>
  {% endif %}
  <!-- イイねの数 -->
  <span id="like-for-comment-count-{{comment.pk}}">{{ dic.count }}</span>
  <span>件のイイね</span>
  {% endwith %}
</div>
<!-- 省略 -->

{% endfor %}


このように表示されるでしょう。



ポイントはiconとカウント数の要素のIDにコメントのpkをつけることで一意にしています。
こうしておくことで、後に要素を書き換える際に、スムーズになります。

なのでクリックするbutton要素のdata属性にコメントのpkを持たせて後から取り出せるようにしています。

<button type="button" name="ajax-like-for-comment" data-pk="{{ comment.pk }}" style="border:none;background:none">
    <i class="fas fa-heart text-danger" id="like-for-comment-icon-{{comment.pk}}"></i>
</button>


非同期で追加

コメントも同様に非同期通信部分の処理を実装していきましょう。

urlパターンにコメントイイね用のurlを追加します。

urlpatterns = [
    path('', views.PostList.as_view(), name='post_list'),
    path('post_detail/<int:pk>/', views.PostDetail.as_view(), name='post_detail'),
    path('like_for_post/', views.like_for_post, name='like_for_post'),
    path('like_for_comment/', views.like_for_comment, name='like_for_comment'),  # 追加
]


viewもほぼ投稿の時と同じです。

def like_for_comment(request):
    comment_pk = request.POST.get('comment_pk')
    context = {
        'user': f'{request.user.last_name} {request.user.first_name}',
    }
    comment = get_object_or_404(Comment, pk=comment_pk)
    like = LikeForComment.objects.filter(target=comment, user=request.user)
    if like.exists():
        like.delete()
        context['method'] = 'delete'
    else:
        like.create(target=comment, user=request.user)
        context['method'] = 'create'

    context['like_for_comment_count'] = comment.likeforcomment_set.count()

    return JsonResponse(context)


最後にテンプレート内のjavascriptです。

/* コメントに対するイイね */
  const likeCommentButtons = document.getElementsByClassName('ajax-like-for-comment');
  for (const button of likeCommentButtons) {
    button.addEventListener('click', e => {
      const pk = button.dataset.pk
      e.preventDefault();
      const url = '{% url "bbs:like_for_comment" %}';
      fetch(url, {
        method: 'POST',
        body: `comment_pk=${pk}`,
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
          'X-CSRFToken': '{{ csrf_token }}',
        },
      }).then(response => {
        return response.json();
      }).then(response => {
        const counter = document.getElementById(`like-for-comment-count-${pk}`)
        const icon = document.getElementById(`like-for-comment-icon-${pk}`)
        counter.textContent = response.like_for_comment_count
        if (response.method == 'create') {
          icon.classList.remove('far')
          icon.classList.add('fas')
          icon.id = `like-for-comment-icon-${pk}`
        } else {
          icon.classList.remove('fas')
          icon.classList.add('far')
          icon.id = `like-for-comment-icon-${pk}`
        }
      }).catch(error => {
        console.log(error);
      });
    });
  }


ここも投稿の時とほぼ同じです。
コメント内のbuttonのクリックに反応して、リクエストを投げるようにしています。

 const pk = button.dataset.pk


ここの部分で押されたbuttonのpkを取り出して、その後の処理をしています。


おわりに

よく見るイイね機能ですが、実装してみると意外に複雑だったという感想です。
Twitterとかfacebookとか、やっぱりすごい。
ユーザーがログインしていることを前提としているので、適宜ログイン制御をかけるといいでしょう。

ソースコード

細かい部分は端折ったので、全文をgithubにあげました。
https://github.com/qlitre/django-iine-project

TOPページ