【Django】フォロー・フォロワー機能を実装してみた

#はじめに

今回はSNSに必須となるフォロー・フォロワー機能の実装について備忘録を残していきます。
色々と書いておりますが、参照したサイトと本記事で書いた内容で不一致、もしくは勘違いしている点などがありましたら是非ともコメント欄でご指摘をお願いします。

#開発環境

#### OS
- Windows10

#### 開発言語  
- Python 【version = 3.9.2】

#### フレームワーク  
- Django　【version = 3.1.7】


#完成イメージ
フォロー・フォロー解除はタイムライン上に表示されたユーザーアイコンをクリックする必要があります。
![フォロワー①.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/583f57aa-863a-4fda-3230-c754e4c939c5.png)
上の画像はタイムライン上に表示される、他ユーザーが投稿したツイートになります。
アイコンをクリックし、以下のユーザープロフィール画面(detail.html)へと遷移します。

![フォロワー②.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/94e15ea9-5c33-c78d-2cc9-03d8aa4d062f.png)

フォローボタンがあり、かつユーザーのフォロー数、フォロワー数が表示される仕組みです。
本当は数字部分をクリックすれば、下にフォローユーザー、フォロワーユーザーの名前、アイコン一覧を表示できるようにしたかったのですが、そこはまだ実装できていない状態となります。。。

![フォロワー③.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/6205ea40-bcda-5626-2784-a6325581d3da.png)

上の画面は自分のプロフィール画面となります。
自分なので当然フォローするボタンは存在しませんが、フォロー数、フォロワー数は表示されるようになっています。
ここで先ほどのユーザー『editor』のプロフィール画面にあるフォローボタンを押すことにより、yoshidaのフォロー数がカウントされます。さっそく押してみましょう。

![フォロワー④.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/2f9527d8-f8d0-8ee6-706c-b01ea68157a9.png)

はい、押してみました。
しっかりと『〇〇をフォローしました』というメッセージが表示され、無事にフォローできた形となります。
editorユーザーのフォロワー数にもちゃんとカウントが入っていますね（最初確認したときは凄く感動しました）。

![フォロワー⑤.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/5c0f81e1-b1c7-f32c-80c4-baa34a660ed1.png)

フォロー解除を押せばユーザーのフォロー解除をすることも可能です。
さてさて、それではyoshidaのプロフィール画面に戻ってみます。

![フォロワー⑥.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/8e77d946-0cb2-495e-748d-51a7de2bc64e.png)

このように、フォロー数に先ほどフォローしたユーザー分がカウントされています。
今回はここまでの実装ロジックを残していきます。


#参考サイト

[ゆーじぇい 様](https://jyouj.hatenablog.com/entry/2018/06/04/000311)

今回はゆーじぇい様の実装ロジックをお借りしました。
ところどころ初めて見る関数などがありましたので、そこは自分用として詳しい解説を乗っけときました。かなり冗長に感じるかもしれませんが、なるべく簡潔的に記述するよう心がけております。

他にも多くのサイトを参考にしたのですが、そちらは後述する文章の中にリンクを付けておきます。


#①モデル

それではさっそく説明していきます。まずはモデルからですね。

```models.py
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.db.models.base import Model
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFill, ResizeToFit
from django.utils import timezone
from django.urls import reverse

#ユーザーモデル
class CustomUser(AbstractUser):
    description = models.TextField(verbose_name='プロフィール', null=True, blank=True)
    photo = models.ImageField(verbose_name='写真', blank=True, null=True, upload_to='images/')
    thumbnail = ImageSpecField(source='photo', processors=[ResizeToFill(256,256)], format='JPEG', options={'quality': 60})
    email = models.EmailField('メールアドレス', unique = True)

    def get_absolute_url(self):
        return reverse('pento_app:detail', kwargs={'username': self.username})

    class Meta:
        verbose_name_plural = 'CustomUser'

#フォローモデル
class Connection(models.Model):
    follower = models.ForeignKey(CustomUser, related_name='follower', on_delete=models.CASCADE)
    following = models.ForeignKey(CustomUser, related_name='following', on_delete=models.CASCADE)
    date_created = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return "{} : {}".format(self.follower.username, self.following.username)
```

***

### (1)ユーザーモデル
フォローに必要なモデルを作成する前に、まずはフォロー対象であり、自分自身でもあるユーザーモデルを作成します。
ちなみにAbstractUserを継承することにより、usernameフィールドは既に設定されている仕組みです。
各フィールドのオプション等につきましては、書籍『プロフェッショナルWebプログラミング Django』を参考にしておりますので、是非とも読んでみてください。下記にAmazonリンクを貼っておきます。

>[プロフェッショナルWebプログラミング Django](https://www.amazon.co.jp/%E3%83%97%E3%83%AD%E3%83%95%E3%82%A7%E3%83%83%E3%82%B7%E3%83%A7%E3%83%8A%E3%83%ABWeb%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0-Django-%E7%94%B0%E4%B8%AD-%E6%BD%A4/dp/4295201065)

***

### (2)フォローモデル
フォローモデルについては、ゆーじぇい様のロジックと全く同じ内容となります。
なお管理画面にて"フォロワー名：フォロー名"という名前で表示される仕組みとなっておりますが、実際どういう形で表示されるのか。
実装した際に確認した管理画面を以下に残しておきます。

……あ。ちなみに管理画面を見る前に、必ずadmin.pyの設定を忘れないようにしましょう。
Connectionモデルをインポートし、管理画面にConnectionの項目が反映されるようにします。

```admin.py
from .models import Connection

admin.site.register(Connection)

```
それでは気を取り直して、実際どういった状態で保存されるのかだけ見ていきます。

![管理画面①.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/e79f0251-6d79-8c2e-c2fb-3823ea0c661c.png)
完成イメージで登場したyoshida（自分）、editor(フォロー相手)が表示されておりますね。
クリックして中身を見てみましょう。

![管理画面②.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1608896/eddd6c0f-8cd8-ab69-9ad0-0b0fa0da21b8.png)
このように、フォロワーユーザー×フォローユーザーというセットで登録されていることが分かります。
ここまでがモデルの設定となり、次はビューの作成に移ります。




#②ビュー(フォロー)

```views.py
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.urls import reverse_lazy
from django.http import HttpResponseRedirect
from .models import CustomUser, Connection


"""フォロー"""
@login_required
def follow_view(request, *args, **kwargs):

    try:
        #request.user.username = ログインユーザーのユーザー名を渡す。
        follower = CustomUser.objects.get(username=request.user.username)
　　　　 #kwargs['username'] = フォロー対象のユーザー名を渡す。
        following = CustomUser.objects.get(username=kwargs['username'])
    #例外処理：もしフォロー対象が存在しない場合、警告文を表示させる。
    except CustomUser.DoesNotExist:
        messages.warning(request, '{}は存在しません'.format(kwargs['username']))　#（※1）（※2）
        return HttpResponseRedirect(reverse_lazy('pento_app:index'))
　　#フォローしようとしている対象が自分の場合、警告文を表示させる。
    if follower == following:
        messages.warning(request, '自分自身はフォローできません')
    else:
        #フォロー対象をまだフォローしていなければ、DBにフォロワー(自分)×フォロー(相手)という組み合わせで登録する。
        #createdにはTrueが入る
        _, created = Connection.objects.get_or_create(follower=follower, following=following)　#（※3）

　　　　 #もしcreatedがTrueの場合、フォロー完了のメッセージを表示させる。
        if (created):
            messages.success(request, '{}をフォローしました'.format(following.username))
        #既にフォロー相手をフォローしていた場合、createdにはFalseが入る。
        #フォロー済みのメッセージを表示させる。
        else:
            messages.warning(request, 'あなたはすでに{}をフォローしています'.format(following.username))

    return HttpResponseRedirect(reverse_lazy('pento_app:detail', kwargs={'username': following.username}))

"""フォロー解除"""
@login_required
def unfollow_view(request, *args, **kwargs):
    
    try:
        follower = CustomUser.objects.get(username=request.user.username)
        following = CustomUser.objects.get(username=kwargs['username'])
        if follower == following:
            messages.warning(request, '自分自身のフォローを外せません')
        else:
            unfollow = Connection.objects.get(follower=follower, following=following)
            #フォロワー(自分)×フォロー(相手)という組み合わせを削除する。
            unfollow.delete()
            messages.success(request, 'あなたは{}のフォローを外しました'.format(following.username))
    except CustomUser.DoesNotExist:
        messages.warning(request, '{}は存在しません'.format(kwargs['username']))
        return HttpResponseRedirect(reverse_lazy('pento_app:index'))
    except Connection.DoesNotExist:
        messages.warning(request, 'あなたは{0}をフォローしませんでした'.format(following.username))

    return HttpResponseRedirect(reverse_lazy('pento_app:detail', kwargs={'username': following.username}))
```

ビューも同じく、ゆーじぇい様のサイトと同じロジックとなっております。
お恥ずかしいことに、読解に時間を費やしてしまいました。ここでは各処理ごとに使用されているメソッドや関数の説明、処理自体の仕組みについて、引用サイトを挟みつつ学習した知識を残していきます。

それでは、コード上に※を付けた箇所を中心に説明していきます。

***

#### ※1 : Djangoの通知メッセージ（メッセージフレームワーク）
>Djangoのメッセージフレームワークとは、 Djangoを使って作成したウェブアプリケーション上でユーザのアクションに対し、そのプロセスの結果を画面上の通知メッセージとして返すための機能。
>　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　【dot blog様より抜粋】

>[参考サイト : dot blog様](https://dot-blog.jp/news/django-messages-frame-work/)

####　使用方法

【1】以下のライブラリをインポートする。

  ```
  from django.contrib import messages
  ```

【2】下記コードを入力する。

  ```
  messages.warning(request, 'ここに警告文を記入する')
  ```

【3】警告文はrequestに格納されているため、return文でメッセージ内容をテンプレートに送る。
※ちなみにwarningの部分がsuccessの場合は、ユーザのアクションが成功したことを通知するメッセージとなるようです。

【4】テンプレートにメッセージ内容を表示させるには、以下のようにコードを記述していきます。

``` template.html
{% if messages %}
<ul>
    {% for message in messages %}
    <li>{{ message }}</li>
    {% endfor %}
</ul>
{% endif %}
```

***

#### ※2 : formatメソッド
文字列の中に、波かっこ{}で囲まれた「置換フィールド」が埋め込まれていれば、format( )メソッドの引数で{}の部分を置換できます。例えば{}が文字列内に三つある場合、メソッドの引数は三つ指定する必要があります。
今回は{}が一つのため、引数は一つとなります。
　
***

#### ※3 : ここのアンダースコアの意味
調べてみたところ、もし関数からの戻り値が複数あって使わない部分があった場合、アンダースコアを使って戻り値を無視することが出来るようです。今回の場合は~~引数follower=followerは使用せず、引数following=following（フォロー対象ユーザー）は使用するという意味合いとなります。~~

※2021/10/30訂正
　すいません。取り消し線の部分ですが、※4でも説明した通り、Modelのオブジェクトを返す変数を省略しております。
　
>[参考サイト : こへいブログ様](https://kohei.life/python-use-underbar/#i-4)

***

#### ※4 : get_or_create
詳しい解説は下記のサイトに載っていますので、こちらでは引用先のサイトに書かれた情報を簡易的に説明していきます。

>[参考サイト : Tech Code様](https://www.nblog09.com/w/2019/02/03/django-get-or-create/)

djangoのget_or_createとは、とどのつまり指定したデータがDB上にあるか否かで処理が変化します。

:::note
指定したデータがDBに存在する場合　➡　DB上に存在する指定データを返す
指定したデータがDBに存在しない場合　➡　DBに新規データを追加し、追加したデータを返す
:::

使用例としては以下の形です。

```
result, created = Model.objects.get_or_create(**kwargs)
```

もしkwargsに指定したデータが存在した場合、DBにオブジェクトは登録されません。そうなるとresultには指定したModelのオブジェクトが返される形となります。逆にデータが存在しない場合、DBに新たなレコードとして挿入され、resultには挿入されたModelのオブジェクトが返されます。
今回の場合、もしフォロワー（自分） × フォロー(相手)という組み合わせがDB上に存在しなければ、新規データとして登録されます。そしてcreatedにはTrueが入る形となります。resultは※3で説明した通り、今回は使用しないものとして省きます。

***

# ③ビュー(プロフィール詳細)

次はプロフィール画面の説明をしていきます。
フォロー結果をテンプレートに反映させる必要がありますので、ビューに設定を入れていきます。

```views.py

"""プロフィール画面"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import DetailView
from .models import User, Connection
from .helpers import get_current_user

class ProifileDetail(LoginRequiredMixin, generic.DetailView):
    model = CustomUser
    template_name = 'detail.html'

    #slug_field = urls.pyに渡すモデルのフィールド名
    slug_field = 'username'
    # urls.pyでのキーワードの名前
    slug_url_kwarg = 'username'

    def get_context_data(self, **kwargs): # ※(1)
        context = super(ProifileDetail, self).get_context_data(**kwargs)
        username = self.kwargs['username']
        context['username'] = username
        context['user'] = get_current_user(self.request) # ※(2)
        context['following'] = Connection.objects.filter(follower__username=username).count()
        context['follower'] = Connection.objects.filter(following__username=username).count()

        if username is not context['user'].username:
            result = Connection.objects.filter(follower__username=context['user'].username).filter(following__username=username)
            context['connected'] = True if result else False

        return context

```

***

#### ※1 : get_context_data
contextという変数に辞書型のデータを入れるときに使用するメソッドです。

>contextは辞書型のデータですので、データを追加することもできます。
例えば、context['weather'] = '晴れ'とすれば、keyがweather、valueが晴れというデータを追加することができます。
>　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　【Code for Django様より抜粋】
>[参考サイト : Code for Django様](https://codor.co.jp/django/about-context)

上記コードにおいては、keyとなる'username'、'user'、'following'、'follower'をそれぞれ設定し、valueに値を入れています。

'following'、'follower'にはそれぞれ、フォローしているユーザーのカウント数、フォローされているユーザーのカウント数が代入されている形となります。

***

#### ※2 : get_current_user

ビューに「from .helpers import get_current_user」をインポートさせています。
インポート元となるhelpers.pyはアプリ直下に作成し、下記のように記述していきます。

```helpers.py
#ファイルはアプリ直下に作成(views.py、models.pyと同階層)

from django.contrib.sessions.models import Session

from .models import CustomUser

def get_current_user(request=None):
    if not request:
        return None

    session_key = request.session.session_key
    session = Session.objects.get(session_key=session_key).get_decoded()
    uid = session.get('_auth_user_id')

    return CustomUser.objects.get(id=uid)
```

***

#④ルーティング

モデル、ビューの作成が完了したところで、今度はアプリ直下のurls.pyを下記のように記述していきます。

```urls.py
#本記事に関するルーティング設定以外は省略しております。

from django.urls import path
from . import views

app_name = 'pento_app'

urlpatterns = [
　　#pkの代わりにslugを扱うことにより、username(ユーザー名)がURLに表示されることになります。
    path('<slug:username>', views.detail, name='detail'),
    path('<slug:username>/follow', views.follow_view, name='follow'),
    path('<slug:username>/unfollow', views.unfollow_view, name='unfollow'),
]
```
urlpatternsの中身を上から順に確認していきます。
まず一番上は、プロフィール画面に遷移するためのルーティングとなります。
二番目がフォローボタンを押した際の処理に進むルーティング、三番目がフォロー解除ボタンを押した際の処理に進むルーティングです。

***

#⑤テンプレート

最後はテンプレートの設定となります。
以下にコードを記載しますが、こちらは完成イメージでいうところのプロフィール編集、フォロー、フォロー解除ボタン。およびフォロー、フォロワー数のカウント部分のみになります。

```detail.html

     <div class="button_field">

        <!--もしプロフィールの遷移先が自分のプロフィールである場合の表示ボタン-->
        {% if user.username == username %}
            <a href="{% url 'pento_app:edit' username %}" class="btn btn-info">プロフィール編集</a>
　　　　 <!--プロフィールの遷移先が自分がフォローしているユーザーの場合の表示ボタン-->
        {% elif connected %}
            <a href="{% url 'pento_app:unfollow' username %}" class="btn btn-dark">フォロー解除</a>
        <!--自分がフォローしていないユーザーの場合の表示ボタン-->
        {% else %}
            <a href="{% url 'pento_app:follow' username %}" class="btn btn-light">フォロー</a>
        {% endif %}

    </div>

    <div class="follow_count">
        <ul>
            <li>
　　　　　　　   <!--フォロー数を表示-->
                <p>フォロー数</p><h2>{{ following }}</h2>
            </li>
            <li>
                <!--フォロワー数を表示-->
                <p>フォロワー数</p><h2>{{ follower }}</h2>
            </li>
        </ul>
    </div>

```
これでviews.pyでの処理結果がテンプレートに反映される形になります。

***

#おわりに

かなり長くなりましたが、フォロー機能の実装に関する一連の流れを記述しました。
……とはいえ、ほぼコードに対する解説記事のような形になってしまいましたね。
いずれは自分で考えたロジックを記事にできるよう、今後も頑張っていきます。