Djangoでいいね！をつくる 2023リファクタリング版

# はじめに
いいね！機能はライブラリで提供されるようなものではないので基本的に自作する必要がある。
例えば [python,djangoによるいいねボタンの作り方](https://qiita.com/sr2460/items/6bbbb9d2d1dd00d33d1c) は、当該記事の筆者も言っているとおり、データベースに更新はかけるものの、単純に form の機能でカウントアップするだけのものです（何回も押せてしまう）。今回つくるものは、いわゆるfacebook式のいいね！です。

実際のサンプルはリポジトリをみてね

https://github.com/duri0214/portfolio/blob/master/mysite/vietnam_research/templates/vietnam_research/index.html

:::note info
2023年に入ってからサイトをいろいろ改修しててその際に「なんかわかりにくいんだよなー」ってのがきっかけでリファクタリングしようとしたら意外に手こずっちゃったけどいい勉強になった。わかりやすくなったと思う
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/1db84abe-82ed-e33f-4391-21a300c745be.png)

# 検索材料
[google検索: django いいね ajax](https://www.google.com/search?q=django+%E3%81%84%E3%81%84%E3%81%AD+ajax&sxsrf=ACYBGNSE7aR8OxiIzs2hiTcuMt7MFOIKjA:1578807482622&ei=urAaXqHXJcuiwAOn7LWgBw&start=20&sa=N&ved=2ahUKEwihnK6ZrP3mAhVLEXAKHSd2DXQ4ChDw0wN6BAgMEEQ&biw=1920&bih=937)
[Djangoでいいね機能をAjax通信で実装 (IPアドレスで連打を防止)](https://www.haaam-blog.com/post/5/)

# 成果物のイメージ
要はシステムがユーザーIDを保持していて、いいね！は `ON` or `OFF` の二者択一。これ Ajax をよく知ってる人は簡単かもしれないけど結構総花的な知識が必要で難しいんですよ。。。サンプルを調べても無駄に複雑に作ってあったりして。だから可能な限りミニマルな形に削りました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/e6dffaa1-6952-87b6-afc5-05b96ff8f080.png)

# 処理の流れのイメージ
今回は「いいね！」の機能に注力するため、Djangoの基本的な部分は省略しています。まぁ情報過多になっちゃうししょうがないよね。なのでDjango基礎は「[一気通貫 Django startup ～ローカル環境にグラフアプリを添えて～](https://qiita.com/YoshitakaOkada/items/f434bb0bc493ff9cd65b)」を見てください。

さて、下図は左下の chrome アイコンから始まる、処理の流れのイメージだ。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/d6337932-3dfa-2008-c36d-cd6e4d9da15d.png)

# Userを作成
少なくともユーザがひとりいる状態にしてください。今回はスーパーユーザだけにします。
## superuser作成

| 項目 | 値 |
|:-----------|:------------|
| ユーザー名 | yoshi |
| メールアドレス | yoshi@gmail.com |

```
(appname)\mysite> python manage.py createsuperuser

  ユーザー名 (leave blank to use 'yoshi'):
  メールアドレス: yoshi@gmail.com
  Password:
  Password (again):
  このパスワードは ユーザー名 と似すぎています。
  このパスワードは短すぎます。最低 8 文字以上必要です。
  Bypass password validation and create user anyway? [y/N]: y
  Superuser created successfully.

(appname)\mysite>
```
# Articles(記事)とLikesのテーブルを作ろう
## model
```python:(appname)/models.py
  :
class Articles(models.Model):
    """いいね！機能つきの記事"""
    title = models.CharField(verbose_name='タイトル', max_length=200)
    note = models.TextField(verbose_name='投稿内容')
    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
    created_at = models.DateTimeField('公開日時', auto_now_add=True)

    @staticmethod
    def with_state(user_id: int) -> QuerySet:
        return Articles.objects.annotate(
                likes_cnt=Count('likes'),
                liked_by_me=Case(
                    When(id__in=Likes.objects.filter(user_id=user_id).values('articles_id'),
                         then=1),
                    default=0,
                    output_field=IntegerField()
                )
            )


class Likes(models.Model):
    """いいね"""
    articles = models.ForeignKey('Articles', on_delete=models.CASCADE)
    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=['articles_id', 'user_id'],
                name='articles_user_unique'
            )
        ]
  :
```

## migrate
```terminal:makemigrationsとmigrateでテーブルの定義を変更
(appname)\mysite> python manage.py makemigrations appname
  Migrations for '(appname)':
    (appname)\migrations\0003_articles_likes.py
      - Create model Articles
      - Create model Likes

(appname)\mysite> python manage.py migrate
  Operations to perform:
    Apply all migrations: admin, auth, contenttypes, sessions, appname
  Running migrations:
    Applying appname.0003_articles_likes... OK
```

## 確認
ArticlesとLikesのテーブルができた！（まだなんの処理とも紐付いてないよ）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94562/e9c522eb-2a4c-c46b-85f8-c223f01aef34.png)

:::note info
ダミーの記事をつくっておく。dbeaverとかmysqlworkbenchなどのクライアントを使って画面上からレコード追加しちゃうほうがかんたん
:::

## urls.py
```python:mysite/(appname)/urls.py
app_name = 'xxx'
urlpatterns = [
    path('', index, name='index'),
    path('likes/create/<int:article_id>/<int:user_id>/', LikesCreateView.as_view(), name='likes_create'),
    path('likes/delete/<int:article_id>/<int:user_id>/', LikesDeleteView.as_view(), name='likes_delete'),
    path('article/create/', ArticleCreateView.as_view(), name="article_create"),
      :
]

```

## views.py
```python:mysite/(appname)/views.py

def index(request):
      :
    login_user = User.objects.filter(email=request.user).first()
    login_id = None
    if login_user:
        login_id = login_user.id

    # TODO: articlesは試作のため3投稿のみ
    context = {
          :
        'articles': Articles.with_state(login_id).annotate(user_name=F('user__email')).order_by('-created_at')[:3],
          :
    }

```

## index.html
```html:mysite/(appname)/templates/(appname)/index.html
<!-- index.html -->
<div class="col-sm">
  <h2>nice! function</h2>
  <h6>'like' function</h6>
  {% for article in articles %}
    <div class="card" style="width: 18rem;">
      <div class="card-header">
        {{ article.title }}
      </div>
      <ul class="list-group list-group-flush">
        <li class="list-group-item">
          <p style="font-size: small">{{ article.note | truncatechars:80 }}</p>
          <p class="author text-right text-secondary" style="font-size: xx-small">投稿者: {{ article.user_name | truncatechars:3 }} さん</p>
          <div class="card-body">
            <button
                type="button"
                class="like_toggle btn btn-outline-secondary {{ article.liked_by_me|yesno:'active,' }}"
                data-article-id="{{ article.id }}"
                data-liked-by-me="{{ article.liked_by_me }}"
            >いいね！<span>({{ article.likes_cnt }})</span></button>
          </div>
        </li>
      </ul>
    </div>
  {% endfor %}
  <a href="{% url 'vnm:article_create' %}" class="btn btn-default btn-sm" role="button">post!</a>
</div>

  :

<script lang="js">
  const likeToggles = document.getElementsByClassName('like_toggle');
  for (let i = 0; i < likeToggles.length; i++) {
      likeToggles[i].addEventListener('click', () => {
          const articleId = likeToggles[i].dataset.articleId
          const userId = {{ user.id }}
          if (!userId) {
              location.href = myurl.login
          }
          const createOrDelete = parseInt(likeToggles[i].dataset.likedByMe) ? 'delete' : 'create'
          fetch(`${myurl.base}likes/${createOrDelete}/${articleId}/${userId}/`, {
              method: 'POST',
              headers: {
                  "Content-Type": "application/json; charset=utf-8",
                  "X-CSRFToken": Cookies.get('csrftoken')
              },
              body: JSON.stringify({"status": "requested from javascript."})
          })
          .then(response => response.json())
          .then(json => {
              likeToggles[i].getElementsByTagName('span')[0].innerHTML = `(${json.likes_cnt})`
              likeToggles[i].dataset.likedByMe = json.liked_by_me
              likeToggles[i].classList.remove('active')
              if (json.liked_by_me) {
                  likeToggles[i].classList.add('active')
              }
          })
          .catch((error) => {
              console.error('Ooops!! There has been a problem with your fetch operation:', error)
          })
      })
  }
</script>
```