26
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Djangoでいいね!をつくる2026

26
Last updated at Posted at 2020-01-08

はじめに

Webアプリをいったん作れるようになったあとに、まず知りたくなる機能があります。

それが「いいね!」です。

ログイン、投稿、ボタン、非同期通信、DB保存、件数表示が小さくまとまっているので、Djangoの練習題材としてちょうどいいです。しかも、作るとアプリが急に「使えるもの」っぽくなります。

この記事は、もともと書いていた「Djangoでいいね!をつくる 2023リファクタリング版」を2026年版として更新するものです。

元記事の構成はシンプルです。

  • 記事モデルを作る
  • いいねモデルを作る
  • ボタンを押したらAjaxでいいねする
  • いいね数を画面上で更新する

2026年版では、その考え方を残しつつ、次の点を少しだけ現代寄りにします。

  • いいねするユーザーは request.user から取る
  • 同じユーザーが同じ記事に複数回いいねできないようにDB制約を置く
  • 一覧表示では、いいね数と自分が押したかどうかをまとめて取得する
  • fetchJsonResponse で小さく返す

参考

作るもの

記事に対して、ログインユーザーが「いいね!」を付けたり外したりできるようにします。

画面としては、記事カードに次のようなボタンを置くイメージです。

いいね! (12)

押すたびに状態を切り替えます。

  • 未いいねなら、Like レコードを作成する
  • いいね済みなら、Like レコードを削除する
  • 最新のいいね数を返す
  • ボタンの見た目と件数を画面上で更新する

ここではアプリ名を blog として説明します。既存アプリに入れる場合は、blog を自分のアプリ名に読み替えてください。

モデル

まず、記事モデルといいねモデルを作ります。

ポイントは、Like を「ユーザーと記事の組み合わせ」として扱うことです。

blog/models.py
from django.conf import settings
from django.db import models
from django.db.models import Count, Exists, OuterRef


class Article(models.Model):
    """ユーザーが投稿する記事。"""

    title = models.CharField("タイトル", max_length=200)
    body = models.TextField("本文")
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="投稿者",
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField("公開日時", auto_now_add=True)

    @classmethod
    def with_like_state(cls, user):
        """記事ごとのいいね数と、ログインユーザーのいいね状態を付けて返す。"""
        queryset = cls.objects.annotate(likes_count=Count("likes"))
        if not user.is_authenticated:
            return queryset.annotate(liked_by_me=models.Value(False))

        liked_articles = Like.objects.filter(article=OuterRef("pk"), user=user)
        return queryset.annotate(liked_by_me=Exists(liked_articles))


class Like(models.Model):
    """記事に対するユーザーのいいね。"""

    article = models.ForeignKey(
        Article,
        verbose_name="記事",
        related_name="likes",
        on_delete=models.CASCADE,
    )
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name="ユーザー",
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField("作成日時", auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["article", "user"],
                name="unique_article_like_user",
            )
        ]

UniqueConstraint を入れておくと、同じユーザーが同じ記事に複数回いいねすることをDB側で防げます。

アプリ側で exists() を見てから作成していても、二重クリックや同時リクエストで重複する可能性はあります。こういう「絶対に守りたいルール」は、モデルにも置いておくと安心です。

モデルを変更したら、マイグレーションを作成して反映します。

console
$ python manage.py makemigrations
$ python manage.py migrate

一覧表示

記事一覧では、記事本体に加えて次の2つが必要です。

  • その記事のいいね数
  • ログイン中の自分がいいね済みかどうか

テンプレートの中で記事ごとに Like.objects.filter(...) を呼びたくなりますが、それをやると記事数ぶんクエリが増えます。

なので、一覧を作る時点で with_like_state() を使います。

blog/views.py
from django.views.generic import ListView

from blog.models import Article


class ArticleListView(ListView):
    model = Article
    template_name = "blog/index.html"
    context_object_name = "articles"

    def get_queryset(self):
        return Article.with_like_state(self.request.user).order_by("-created_at")

これで、テンプレート側では article.likes_countarticle.liked_by_me をそのまま使えます。

いいねを切り替えるView

いいねボタンはPOSTで処理します。

ここで大事なのは、user_id をURLやPOST本文から受け取らないことです。

NG: /likes/1/10/  # article_id と user_id をURLに載せる
OK: /likes/1/     # article_id だけをURLに載せる

「誰が押したか」は、ログイン済みの request.user から分かります。

blog/views.py
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views import View

from blog.models import Article, Like


class LikeToggleView(LoginRequiredMixin, View):
    """記事へのいいねを登録または解除する。"""

    def post(self, request, article_id):
        article = get_object_or_404(Article, pk=article_id)
        like = Like.objects.filter(article=article, user=request.user).first()

        if like:
            like.delete()
            liked_by_me = False
        else:
            Like.objects.create(article=article, user=request.user)
            liked_by_me = True

        return JsonResponse(
            {
                "liked_by_me": liked_by_me,
                "likes_count": article.likes.count(),
            }
        )

未ログインユーザーに対しては LoginRequiredMixin がログインページへ誘導します。

JavaScript側でJSONの 401 を受けたい場合は、明示的に JsonResponse({"error": "login_required"}, status=401) を返す形でもよいです。画面全体のログイン導線に合わせて選びます。

URL

一覧画面といいね切り替え用のURLを用意します。

blog/urls.py
from django.urls import path

from blog.views import ArticleListView, LikeToggleView

app_name = "blog"

urlpatterns = [
    path("", ArticleListView.as_view(), name="index"),
    path("likes/<int:article_id>/", LikeToggleView.as_view(), name="likes"),
]

テンプレート

記事ごとにいいねボタンを表示します。

data-article-id に記事IDを入れておき、JavaScriptから読み取れるようにします。

blog/templates/blog/index.html
{% for article in articles %}
  <article>
    <h2>{{ article.title }}</h2>
    <p>{{ article.body }}</p>

    <button
      type="button"
      class="like-toggle{% if article.liked_by_me %} is-liked{% endif %}"
      data-article-id="{{ article.id }}"
      {% if not user.is_authenticated %}disabled{% endif %}
    >
      いいね!
      <span class="like-count">({{ article.likes_count }})</span>
    </button>
  </article>
{% endfor %}

未ログイン時はボタンを disabled にしています。

ここは好みで、ログインページへのリンクを出してもよいです。

blog/templates/blog/index.html
{% if not user.is_authenticated %}
  <a href="{% url 'login' %}?next={{ request.path }}">ログインしていいねする</a>
{% endif %}

fetchでPOSTする

ボタンを押したら、いいね用URLへPOSTします。

DjangoでPOSTするので、CSRFトークンを X-CSRFToken に入れます。

blog/templates/blog/index.html
<script>
  const likeButtons = document.querySelectorAll(".like-toggle");

  for (const button of likeButtons) {
    button.addEventListener("click", async () => {
      const articleId = button.dataset.articleId;
      const response = await fetch(`/blog/likes/${articleId}/`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRFToken": "{{ csrf_token }}",
        },
      });

      if (!response.ok) {
        return;
      }

      const data = await response.json();
      const count = button.querySelector(".like-count");

      count.textContent = `(${data.likes_count})`;
      button.classList.toggle("is-liked", data.liked_by_me);
    });
  }
</script>

これで、画面遷移なしでいいね数とボタン状態が切り替わります。

テスト

最低限、次の3つは確認しておくと安心です。

  • ログインユーザーがいいねできる
  • もう一度押すと解除できる
  • 未ログインユーザーはいいねできない
blog/tests/test_views.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse

from blog.models import Article, Like


class LikeToggleViewTests(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(
            username="user",
            password="password",
        )
        self.article = Article.objects.create(
            title="Djangoでいいね!",
            body="いいね機能のテストです。",
            author=self.user,
        )

    def test_login_user_can_like_article(self):
        """
        シナリオ:
        - 入力: ログイン済みユーザーと記事。
        - 処理: いいねAPIへPOSTする。
        - 期待値: Likeが作成され、いいね数1件のJSONが返ること。
        """
        self.client.login(username="user", password="password")

        response = self.client.post(
            reverse("blog:likes", kwargs={"article_id": self.article.pk})
        )

        self.assertEqual(response.status_code, 200)
        self.assertEqual(Like.objects.count(), 1)
        self.assertJSONEqual(
            response.content,
            {"liked_by_me": True, "likes_count": 1},
        )

    def test_login_user_can_unlike_article(self):
        """
        シナリオ:
        - 入力: すでにいいね済みの記事。
        - 処理: 同じユーザーでいいねAPIへPOSTする。
        - 期待値: Likeが削除され、いいね数0件のJSONが返ること。
        """
        Like.objects.create(article=self.article, user=self.user)
        self.client.login(username="user", password="password")

        response = self.client.post(
            reverse("blog:likes", kwargs={"article_id": self.article.pk})
        )

        self.assertEqual(response.status_code, 200)
        self.assertEqual(Like.objects.count(), 0)
        self.assertJSONEqual(
            response.content,
            {"liked_by_me": False, "likes_count": 0},
        )

    def test_anonymous_user_cannot_like_article(self):
        """
        シナリオ:
        - 入力: 未ログインユーザーと記事。
        - 処理: いいねAPIへPOSTする。
        - 期待値: ログインが必要になり、Likeが作成されないこと。
        """
        response = self.client.post(
            reverse("blog:likes", kwargs={"article_id": self.article.pk})
        )

        self.assertEqual(response.status_code, 302)
        self.assertEqual(Like.objects.count(), 0)

実行します。

console
$ python manage.py test blog

実装するときの注意点

user_idを画面から送らない

いいね機能で一番避けたいのは、画面から送られてきた user_id をそのまま信用することです。

ログインユーザーはサーバー側のセッションで分かるので、request.user を使います。

user = request.user

これだけで十分です。

重複いいねはDBでも防ぐ

アプリ側でチェックしていても、二重クリックや複数タブからの操作で同時にPOSTされることがあります。

UniqueConstraint を入れておけば、最後の砦としてDBが守ってくれます。

一覧画面でクエリを増やさない

いいね機能は、作った直後は簡単に見えます。

ただし、記事一覧で1件ずつ「この人はいいねした?」を問い合わせると、記事数が増えたときに急に重くなります。

CountExists で一覧に状態を付けてからテンプレートへ渡すと、後から困りにくいです。

既存実装に合わせるなら

このリポジトリでは、vietnam_research に記事投稿といいね機能があります。

考え方はこの記事と同じです。

  • Articles: 投稿記事
  • Likes: 記事とユーザーの組み合わせ
  • LikesView: POSTでいいねを切り替える
  • LikeRepository: いいねの作成、削除、存在確認、件数取得

既存実装を更新するなら、まずは以下の観点で見るとよいです。

  • URLに user_id が含まれていないか
  • Likes に一意制約があるか
  • 未ログイン時のレスポンスが画面の期待と合っているか
  • 一覧表示で記事ごとの追加クエリが増えていないか
  • フロント側でCSRFトークンを送っているか

まとめ

いいね機能は小さいですが、Webアプリの大事な要素が詰まっています。

  • ログインユーザーとデータを結びつける
  • 中間テーブルで多対多っぽい関係を表現する
  • DB制約で重複を防ぐ
  • POSTで状態を変更する
  • JSONを返して画面だけ更新する

Webアプリを一度作れるようになったあと、「次に何を作ると一段わかるか」と聞かれたら、いいね機能はかなり良い題材です。

26
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?