【Django】非同期通信でイイね機能を実装してみた 

## はじめに
Djangoでイイね機能を実装する上で学んだ事についてまとめる。調べた限り、イイね機能の実装をサポートするクラスビューが存在しなかったので、独自で設計する必要がある。

## 要件
イイね機能の要件として下記の点を押さえる必要がある。
* ユーザが既にイイねを押した状態と、まだイイねを押していない状態でテンプレート表示を切り替える必要がある
* 既にイイねを押した状態でもう一度イイねを押すと、そのイイねが取り消される
* イイねされた総数を表示する
* 同じユーザが1つのコンテンツに対して複数のイイねを押せないようにする


## 下準備
#### モデルの準備
ユーザーはCustomUserモデル、記事はArticleモデル、記事に対するイイねはLikeForArticleモデルを用いる。

```models.py
from django.db import models
from mdeditor.fields import MDTextField
from django.utils import timezone

class CustomUser(AbstractUser):

    username = models.CharField(
        _("username"),
        max_length=30,
        help_text='Required 30 characters or fewer.',
        unique=True,
        error_messages={
            'unique': _("This Username already exists."),
        },)

    email = models.EmailField(
        _('email'),
        unique=True,
        error_messages={
            'unique': _("A user with that email address already exists."),
        },)
    
    class Meta:
        verbose_name_plural = 'CustomUser'


class Article(models.Model):

    post_user = models.ForeignKey(CustomUser, verbose_name='Post User', on_delete=models.CASCADE, related_name='name',)
    title = models.CharField(verbose_name='title', max_length=50,)
    content = MDTextField()
    created_at = models.DateField(verbose_name='created_at', auto_now_add=True,)

    class Meta:
        verbose_name_plural = 'Article'

    def __str__(self):
        return self.title


class LikeForArticle(models.Model):
    target = models.ForeignKey(Article, on_delete=models.CASCADE,)
    user = models.ForeignKey(CustomUser, on_delete=models.CASCADE,)
    timestamp = models.DateTimeField(default=timezone.now,)

```

:::note info
CustomUserモデルは拡張ユーザモデルを利用している。また本来であればCustomUserモデルは他のモデルとは別のアプリケーション直下のmodels.pyで管理する方が望ましいが、今回は簡略化の為並べて記載している。
:::

:::note info
Articleモデルのcontentフィールドはマークダウン形式をサポートするモジュールを利用している。
:::

#### テンプレートとルーティングの準備

今回はCSSフレームワークはBootstrapとMDBを使う。

```urls.py
from django.urls import path
from . import views

app_name = 'article'

urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('article_detail/<int:pk>', views.ArticleDetailView.as_view(), name='article_detail'),
]
```

今回は記事の詳細ページにイイね機能を実装するので、以降はarticle_detail.htmlの編集を行っていく。また、各種CSSフレームワークの呼び出しはbase.htmlで行なっておく。
```base.html
{% load static %}
<!DOCTYPE html>
<html lang="en" class="no-js">
  <head>
    <title>{% block title %}{% endblock %}</title>

    <!-- Meta -->
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta http-equiv="x-ua-compatible" content="ie=edge">
    <meta name="keywords" content="">
    <meta name="description" content="">
    <meta name="author" content="">

    <!-- Favicon -->
    <link rel="shortcut icon" href="{% static 'img/favicon.ico' %}" type="image/x-icon">

    <!-- Web Fonts -->
    <link href="//fonts.googleapis.com/css?family=Playfair+Display:400,700%7COpen+Sans:300,400,600,700" rel="stylesheet">

    <!-- Bootstrap Styles -->
    <link rel="stylesheet" type="text/css" href="{% static 'vendors/bootstrap/css/bootstrap.css' %}">

    <!-- Components Vendor Styles -->
    <link rel="stylesheet" type="text/css" href="{% static 'vendors/font-awesome/css/fontawesome-all.min.css' %}">
    <link rel="stylesheet" type="text/css" href="{% static 'vendors/slick-carousel/slick.css' %}">

    <!-- Theme Styles -->
    <link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}">

    <!-- MDB -->
    <link href="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/3.10.1/mdb.min.css" rel="stylesheet">

    {% block head %}{% endblock %}
  </head>

-----略------
    
    <!-- Global Vendor -->
    <script src="{% static 'vendors/jquery.min.js' %}"></script>
    <script src="{% static 'vendors/jquery.migrate.min.js' %}"></script>
    <script src="{% static 'vendors/popper.min.js' %}"></script>
    <script src="{% static 'vendors/bootstrap/js/bootstrap.min.js' %}"></script>

    <!-- Components Vendor  -->
    <script src="{% static 'vendors/jquery.parallax.js' %}"></script>
    <script src="{% static 'vendors/typedjs/typed.min.js' %}"></script>
    <script src="{% static 'vendors/slick-carousel/slick.min.js' %}"></script>
    <script src="{% static 'vendors/counters/waypoint.min.js' %}"></script>
    <script src="{% static 'vendors/counters/counterup.min.js' %}"></script>

    <!-- Theme Settings and Calls -->
    <script src="{% static 'js/global.js' %}"></script>

    <!-- Theme Components and Settings -->
    <script src="{% static 'js/vendors/parallax.js' %}"></script>
    <script src="{% static 'js/vendors/carousel.js' %}"></script>
    <script src="{% static 'js/vendors/counters.js' %}"></script>

    <!-- MDB -->
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/4.0.0/mdb.min.js"></script>

    {% block js %}{% endblock %}
  </body>
</html>
```
```article_detail.html
{% extends 'base.html' %}
{% load markdown_extras %}
{% load static %}
{% block title %}{{ object.title|truncatechars:6 }}{% endblock %}
{% block head %}{% endblock %}

{% block background %}
<section class="js-parallax u-promo-block u-promo-block--mheight-500 u-overlay u-overlay--dark text-white" style="background-image:url('{{ object.thumbnail.url }}')">
  <!-- タイトル -->
  <div class="container u-overlay__inner u-ver-center u-content-space">
    <div class="row justify-content-center">
      <div class="col-12">
        <div class="text-center">
          <h1 class="display-sm-4 display-lg-3">{{ object.title }}</h1>
          <p class="h6 text-uppercase u-letter-spacing-sm mb-2">by {{ object.post_user }}</p>
          <p class="h6 text-uppercase u-letter-spacing-sm mb-2">{{ object.view_count }} view</p>
          <p class="h6 text-uppercase u-letter-spacing-sm mb-2">Posted:{{ object.created_at }} | {% if object.updated_at %}Updated:{{ object.updated_at }}{% endif %}</p>
                    ----ここにイイね機能を入れる----
        </div>
      </div>
    </div>
  </div>
</section>
{% endblock %}

{% block contents %}
<section>
  <div class="container">
    <!-- コンテンツエリア -->
    <div class="row u-content-space-bottom">
      <div class="col-lg-6 mb-5 mb-lg-5 pl-lg-5 mx-auto">
        {{ object.content|markdown|safe }}
      </div>
    </div>
  </div>
</section>
{% endblock %}

{% block js %}{% endblock %}
```

## イイね機能の実装
viewsへイイね機能の実装を行います。get_context_dataメソッドのオーバーライドでは、ユーザが既にイイねしているかどうか、という情報を持たせる処理を記述しています。
```views.py
from django.shortcuts import render, get_object_or_404
from django.views import generic
from django.http import JsonResponse
from .models import Article, LikeForArticle

class ArticleDetailView(generic.DetailView):
    model = Article
    template_name = 'article_detail.html'

    def get_context_data(self, **kwargs): #ユーザが既にイイねしているかどうかの判断
        context = super().get_context_data(**kwargs)
        
        like_for_article_count = self.object.likeforarticle_set.count()
        #記事に対するイイね数
        context['like_for_article_count'] = like_for_article_count

        #ユーザがイイねしているかどうか
        if self.object.likeforarticle_set.filter(user=self.request.user).exists():
            context['is_user_liked_for_article'] = True
        else:
            context['is_user_liked_for_article'] = False

        return context
```

次にarticle_detail.htmlでイイねを表示させたいエリアを以下のようにします。ArticleDetailViewのcontextから、ユーザが既にイイねしているか、まだイイねしていないかの情報を受け取り、それによってアイコンの表示を切り替えています。
```article_detail.html
{% block background %}
<section class="js-parallax u-promo-block u-promo-block--mheight-500 u-overlay u-overlay--dark text-white" style="background-image:url('{{ object.thumbnail.url }}')">
  <!-- タイトル -->
  <div class="container u-overlay__inner u-ver-center u-content-space">
    <div class="row justify-content-center">
      <div class="col-12">
        <div class="text-center">
          <h1 class="display-sm-4 display-lg-3">{{ object.title }}</h1>
          <p class="h6 text-uppercase u-letter-spacing-sm mb-2">by {{ object.post_user }}</p>
          <p class="h6 text-uppercase u-letter-spacing-sm mb-2">{{ object.view_count }} view</p>
          <p class="h6 text-uppercase u-letter-spacing-sm mb-2">Posted:{{ object.created_at }} | {% if object.updated_at %}Updated:{{ object.updated_at }}{% endif %}</p>


          {% if is_user_liked_for_article %}
          <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_article_count }}</span>
          <span>Likes</span>


        </div>
      </div>
    </div>
  </div>
</section>
{% endblock %}
```

次にそのユーザが既にイイねしていたら削除、していなかったら追加を行う処理を実装していきます。
まずはPOST処理を受け付けるためのルーティングを設定します。
```urls.py
from django.urls import path
from . import views

app_name = 'article'

urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('article_detail/<int:pk>', views.ArticleDetailView.as_view(), name='article_detail'),
    path('like_for_article/', views.like_for_article, name='like_for_article'), #追加
]
```

次にビューの作成を行います。
```views.py
from django.shortcuts import render, get_object_or_404
from django.views import generic
from django.http import JsonResponse
from .models import Article, LikeForArticle

#記事に対するイイねの非同期処理
def like_for_article(request):
    article_pk = request.POST.get('article_pk')
    context = {
        'user': request.user,
    }
    article = get_object_or_404(Article, pk=article_pk)
    like = LikeForArticle.objects.filter(target=article, user=request.user)

    #既にイイねしていたら削除、していなかったらイイね
    if like.exists():
        like.delete()
        context['method'] = 'delete'
    else:
        like.create(target=article, user=request.user)
        context['method'] = 'create'

    context['like_for_article_count'] = article.likeforarticle_set.count()

    return JsonResponse(context)
```

最後にarticle_detail.htmlのJavascriptからこのビューを呼び出す処理と、イイねした場合と削除した場合とで表示を切り替える処理を入れます。

```article_detail.html
{% block js %}
<script type="text/javascript">
  /* ポストに対するイイね */
  document.getElementById('ajax-like-for-post').addEventListener('click', e => {
    e.preventDefault();
    const url = '{% url "article:like_for_article" %}';
    fetch(url, {
      method: 'POST',
      body: `article_pk={{article.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-post-count')
      counter.textContent = response.like_for_article_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>
{% endblock %}
```

## 参考
・Djangoでイイね機能を実装する方法について
https://qlitre-weblog.com/django-iine-ajax-create/#h592eb86d4c

## 追記
非ログインユーザーがArticleDetailViewへリクエストすると、'AnonymousUser' object is not iterable エラーが発生してしまうことが確認できました。

このエラーの解決方法は
A.イイね機能が関わるページにログイン済ユーザーしかアクセスできないようにする
B.非ログインユーザーでもイイね機能が関わるページへアクセスできるようにする(ただし、イイねのPOST処理には制限をかける)

の2つの方向性で修正案が考えられます。A案であれば、ArticleDetailViewに`LoginRequiredMixin`クラスを継承したり、`@login_required`デコレータを追記することで修正ができます。一方でB案に関しては、AnonymousUserについて知る必要があり、詳しくは[こちらの記事](https://qiita.com/ponponmaru1012/items/1513b288988640137496)を参照していただければと思います。