見出し画像

【サンプルコード付き】いちばん丁寧な”ユーザー機能”解説

1. 今回の解説内容

今回はユーザー機能の実装についての説明記事です。
初学者の方にも「分かりやすい!」と思ってもらえる様、なるべく詳しく書いていますので、最後まで読んでいただけると幸いです。

読者のみなさんは、どんなサイトを作りたくてDjangoを勉強していますか?
ECサイトですか?ブログサイトですか?それともSNSですか?どんなサイトを作りたいとしても、避けて通れない道がユーザー機能の実装ですよね。

このパートでは、様々なジャンルのサイトで共通して適用できるユーザー機能の実装について説明します。このパートで紹介する事例を実践すれば、誰でも簡単にユーザー機能の実装を行うことができる様になります。

2. ユーザー機能概要の解説

今回実装するユーザー機能を以下にあげます。

◆実装するユーザー機能
・ユーザー新規作成
・ログイン
・ログアウト
・ユーザー削除

今回紹介する方法により、みなさんの身近な例で例えると、ZOZOTOWN・Twitterの様にユーザーの情報をまとめられる機能を実装できます。

3. 実装の方針

それでは実装の方針、方法を説明します。
ユーザー機能の実装にあたって難解な点は2つあります。

◆難解なポイント
①ユーザーモデルのカスタム
②ログイン/ログアウト機能の実装

結論、①の解決策としてはAbstractUserを、②の解決策としてはlogin/logoutメソッドを使いましょう。それぞれの使用理由を解説します。↓

①の解決策、AbstractUserを使用する理由は2つあります。
Django標準のUserモデル自体へのフィールド追加が面倒だから
Django標準のUserモデルは、ログイン認証の形式がユーザー名 & パスワードによる認証固定で、e-mail等の他の認証ができないから
※なお、管理画面で確認できますが、このUserモデルはmodels.pyで記述しなくても実装されています。

②の解決策、login/logoutメソッドを使う理由は1つで、とてもシンプルです。
メソッドを使わずに実装するのがとても面倒だから

4. ログイン、ログアウトってどういう仕組み?

ここで、ログインやログアウトの仕組みを簡単に説明しておきます。

まずログインとは、特定のユーザーしか知り得ない情報(ユーザー名・パスワードのセット等)を質問し、登録のあるユーザー情報と照合する作業です。このログイン成功時には、セッションIDというランダム文字列がログイン作業を行なったリクエストの所有者宛に発行されます。

このセッションIDは発行から一定時間経過・もしくはログアウトするまで残ります。そして同Webアプリ内のアクセス時には、このセッションIDが連れ回って送信されます。それにより、ページを移動するたびにログイン認証をしなくともWebアプリ側で「あ、さっきログインしたユーザーさんだから、認証はパスしよう!」と判断してもらえるのです。

まとめると、このセッションIDがあるおかげで、普段私たちは様々なサービスで何度も本人確認をしたりせずに特定のユーザーさんとして様々な機能を享受できるんです。
(身近な仕組みに例えるとイベント会場に入場する際にもらえるリストバンドや、ブラックライト感応式のスタンプに似ていますよね。)

少し説明が長くなりましたが、"3.実装の方針"で説明したlogin/logoutメソッドは、このセッションIDの扱いを非常に短縮できる便利なメソッドなので、今回はそれを活用していきます。

5.1 実装~AbstractUserの実装~

それでは手順を確認していきましょう

まずaccounts.models.pyにユーザーモデルの定義を記述します。
※前提となるフォルダ構成は、同マガジンのDjango ディレクトリ構成とファイル設定を確認してください。

accounts/models.py

from django.db import models
from django.contrib.auth.models import AbstractUser

​class CustomUser(AbstractUser):
   email = models.CharField(verbose_name='メールアドレス', max_length=100)
   password = models.CharField(verbose_name='パスワード', max_length=100)

   def __str__(self):
       return self.username

余談ですが、Django.contrib.auth.modelsにはAbstractBaseUserというよりカスタム性に富んだモデルもあります。今回は簡潔なコードで実装可能という理由で、AbstractUserを使用していきます。

これで実装完了です。。。
と言いたいところですが、ここで思い出して欲しいのが、Djangoには標準のUserモデルがあるという話です。DjangoアプリはUserモデルを使用すると思い込んでいるので、作成したCustomUserモデルを使用することを宣言しなければなりません。

その宣言文として、以下の一文をsettings.pyの末尾に書き足してください。

config/settings.py

AUTH_USER_MODEL = 'accounts.CustomUser'
#''内部は(アプリディレクトリ名).(使用するユーザーモデル名)

ここまで記述し、ターミナル上でコマンド入力です。
$python manage.py makemigrations

$python manage.py migrate
これでCustomUserが使用可能になります。
管理画面から確認してみましょう!

5.2 実装~新規登録機能の実装~

◆forms.pyの作成
まず、ユーザー新規作成フォームの作成です。
accounts/forms.pyに記述していきます。
※forms.pyはstartappコマンド時には作成されないファイルなので、もしディレクトリにない場合はファイルを新規作成してください。

forms.pyは2つの特徴を持っています。
入力データを保持できる
入力データを検証できる
この2つの特徴を持っているからこそ、ユーザーの新規登録やログインの操作を行うためにはforms.pyを使うことにしています。

作成したら編集していきます。
まずは新規作成フォーム作成に必要なメソッドのインポートと、フォームのコーディングをしていきます。

フォームは”定義文””検証文”で構成されます。
長くなるのでまずは定義文からコードと解説文を書いていきます。

◆定義文
accounts/forms.py

#インポート
from django import forms
from .models import CustomUser

#新規作成用のフォーム定義
class CreateForm(forms.ModelForm):
   
   #利用するモデル/フィールド宣言、既存フィールドからの変更点を記述
   class Meta:
       model = CustomUser
       fields = ('username', 'email', 'password')
       widgets = {
           'password': forms.PasswordInput(attrs={'placeholder': 'パスワード'})
       }

   #CustomUserに存在しないpassword2は追記
   password2 = forms.CharField(
       label='確認用パスワード',
       required=True,
       strip=False,
       widget=forms.PasswordInput(attrs={'placeholder': '確認用パスワード'}),
   )
   
   #初期化状態を定義
   def __init__(self, *args, **kwargs):
       super().__init__(*args, **kwargs)
       self.fields['username'].widget.attrs = {'placeholder': 'ユーザー名'}
       self.fields['email'].required = True
       self.fields['email'].widget.attrs = {'placeholder': 'メールアドレス'}

新規作成フォームのコード、定義文までの解説です。

ModelForm
を継承したフォームクラスを定義することで、既存のモデルのフィールドを活用することができます。それが冒頭のClass Metaパート付近に当たりますが、作成するフォームと対応させるモデルを記述しています。
なお、attrs~の箇所は、フォームの入力ガイドラインになる情報を、あらかじめフォームに薄字で表示しておくための記述です。

ここで注目して欲しいのは、passwordとpassword2の扱いです。

passwordはclass Meta内で記述されています。これは既存のCustomUserのpasswordフィールドをアレンジするためですね。passwordフィールドをPasswordInput用のフォームとして認識させることで、入力文字を”・”でマスキング表示してくれます。
また、Password2はclass Meta外に記述されています。これはCustomUserのフィールドにない要素を指定しているためです。

本事例では、usernameとemailは既存のCustomUserのフィールドをそのまま使って問題ないので、初期化子の__init__メソッド内で記述しました。
実はusernameとemailは文法的にはclass Metaで書いても問題ありません。
しかし、どうしてもclass Metaの使い方上、フィールドをアレンジしないものを記述してしまうとコードが分かりにくくなってしまいます。
なので、今回は__init__メソッド内で記述を行いました。

◆検証文
config/settings.py

class CreateForm(forms.ModelForm):
   ...  #上述定義文の続きから

   #以下、入力データの検証
   #usernameの検証ルール
   def clean_username(self):
       value = self.cleaned_data['username']
       if len(value) < 3:
           raise forms.ValidationError(
               '%(min_length)s文字以上で入力してください', params={'min_length': 3})
       return value

   #emailの検証ルール
   def clean_email(self):
       value = self.cleaned_data['email']
       return value

   #passwordの検証ルール
   def clean_password(self):
       value = self.cleaned_data['password']
       return value

   #フォーム全体の検証ルール
   def clean(self):
       password = self.cleaned_data['password']
       password2 = self.cleaned_data['password2']
       if password != password2:
           raise forms.ValidationError("パスワードと確認用パスワードが合致しません")
       super().clean()

検証文の解説です。

まず各メソッドの役割ですが、clean_(フィールド名)のメソッドは、フィールド単体の入力内容を検証しています。末尾のcleanメソッドは、フォーム全体や既存データに対して守ってほしい制約を書きます。
これらの検証メソッドにて、cleaned_dataを渡して入力内容の検証をします。なお、cleaned_dataはフォーム入力されたデータが格納されています。

今回、単体フィールドでの検証は、usernameのみ”3文字以上”の制約を加えたかったので、この検証メソッド内でリジェクトする様にしました。
そして、今回、cleanメソッドでは、passwordとpassword2が一致していることと、superクラスのclean()を呼ぶことで既存データとダブりがないかを検証しています。

◆views.pyの作成
次にCreateFormを反映させたcreate.html(後に作成)を新規登録ページとして表示するビュークラスを作成していきます。

ビュークラス作成のコーディングと解説を記述します。

accounts/views.py

from django.views import View                    #Create, Loginビュー用
from django.views.generic import TemplateView    #LoginConfビュー用
from django.shortcuts import render, redirect    #URL遷移系

#自作のモデル、フォーム
from .models import CustomUser
from .forms import CreateForm, LoginForm

#login,logoutメソッド
from django.contrib.auth import login as auth_login, logout as auth_logout


#トップページ表示用のビュー
class LoginConf(TemplateView):
   template_name = 'login_conf.html'


#新規作成ページ表示用のビュー
class Create(View):
   def get(self, request, *args, **kwargs):
       if request.user.is_authenticated:
           return redirect('login_conf')
       context = {'form': CreateForm()}
       return render(request, 'create.html', context)

   def post(self, request, *args, **kwargs):
       form = CreateForm(request.POST)
       if not form.is_valid():
           return render(request, 'create.html', {'form': form})
       user = form.save(commit=False)
       user.set_password(form.cleaned_data['password'])
       user.save()
       auth_login(request, user)
       return redirect('login_conf')

順を追って解説していきます。

まず、LoginConfの役割ですが、ログイン後の動作確認用に使います。
これは、ただlogin_conf.html(後に作成)を割り当てるだけのTemplateViewを使用すればOKです。

CreateにはViewクラスを継承したビューを定義します。
このビューでは、ビューの呼び出しがGET(別URLからのアクセス)リクエストでなされた場合と、POST(URL遷移なし)リクエストでなされた場合の動作を記述します。
実はフォーム用のページには、フォームを表示する動作と、フォームに入力された値をモデルなどに保存する動作の2通りの挙動が求められます。
この際に、GET(⇨フォーム表示)とPOST(⇨フォーム入力操作)にそれぞれの動作を割り当てることで、余計なURLやビューを増やさずに機能を拡充できるのでフォームと相性がいいと考え、使用しました。

◆GET文
GET
で呼ばれた際は、事前に準備したフォームを表示させる機能を加えたいので、create.htmlを表示する際にformの情報を引数として渡す必要がありますね。

一般にforms.pyからインポートしたフォームクラスはメソッドとして呼ばれると、登録されたフォームフィールドを持つインスタンスになります。そこで今回は、フォームをテンプレートに表示させるためにインスタンスをcontextというリスト型変数に代入し、renderメソッドの引数に渡すことで表示できる様にしました。

なお、すでにログインしている場合は、このページにアクセスする必要はないので、if request.user.is_authenticated:文によりログインユーザーがいる場合の条件分岐でlogin_confにリダイレクトをさせています。

◆POST文
POSTで呼ばれた際は、その瞬間にWebページ上のフォームに入力されているデータの検証を行い、続いてユーザー作成ログインを行います。

まず、データの検証ですが、CreateForm(request.POST)を受け取れる時点でform.pyで定義した検証メソッドを通過してきており、完了しています。

次にユーザーの作成ですが、まずフォームで作成予定のユーザーを呼び出します。form.save(commit=false)がその動作にあたります。commit=falseオプションでsaveを呼び出すと該当するモデルオブジェクトを戻り値にでき、今回はuserに格納しました。
呼び出した後、パスワードを暗号化する処理を行います。ここでは、set_passwordメソッドを使いました。これは、引数をハッシュ化してユーザーオブジェクトのパスワードに登録できるメソッドで、これを行うことで管理画面上からでもパスワードを不詳にすることができます。

最後にログインをしていきますが、auth_loginメソッドを呼び出して終わりですね。auth_loginは冒頭に説明したセッションIDを扱う動作をしてくれる有益なメソッドです。

◆urls.pyの記述
先ほど作成したviewを呼び出すURLを設定します。

accounts/urls.py

from django.urls import path
from .views import LoginConf, Create

urlpatterns = [
   path('', LoginConf.as_view(), name='login_conf'),
   path('create/', Create.as_view(), name='create'),
]


◆create.htmlの作成
新規登録フォーム表示用のページを書いていきます。

templates/create.html

<!doctype html>
<html lang="ja">
 <body>
   <div class="create_form">
       <h1>新規会員登録画面</h1>
       <h2>新規登録したいユーザー情報を入力してください</h2>

       #createビューをpostで呼ぶことを宣言したフォーム
       <form action="{% url 'create' %}" method="post" class="form_create">
         <div class="input">

           #form標準のフォーム検証エラーメッセージ表示エリア
           {% if form.non_field_errors %}
             <div class="header">エラー</div>
             <ul class="list">
               {% for non_field_error in form.non_field_errors %}
               <li>{{ non_field_error }}</li>
               {% endfor %}
             </ul>
           {% endif %}

           #自作のフォームフィールド表示エリア
           {% for field in form %}
             <div class="input_field">
               <div class="field_item">{{ field }}</div>
               #自作のエラーメッセージ表示エリア
               {% if field.errors %}
                 <p class="field_error">{{ field.errors.0 }}</p>
               {% endif %}
             </div>
           {% endfor %}

           {% csrf_token %}
           <button type="submit" class="user_button">ユーザー登録</button>
         </div>
       </form>
     </div>
 </body>
</html>

解説です。

まずフォーム表示用のエリアは<form></form>タグで囲います。
その上で、このフォームで呼び出すアクションを宣言しています。
ここでは'create'を"post"で紐づけています。

後は、HTML外の記述箇所がフォーム表示のために重要です。

POSTで呼ばれた際に、検証を通らなかった場合は同じページを再表示しますが、Form.non_field_errorsには、その際に表示されるエラーメッセージが格納されています。
これはform標準の機能のため、実は先ほどのformで記述していない挙動になります。皆さんが各自で決めたエラーメッセージが出力されることがありますが、そのためです。

formはviewから引数で渡されたフォームフィールドの集合体です。

そして、filed.errorsは皆さんが各自で決めたエラーメッセージです。

色々と入力状態を変えながら、どのような動作をするか皆さんも確かめてみてください。

◆login_conf.htmlの作成
Createビューの動作確認用のlogin_conf.htmlを作ります。
ログインユーザーが存在する場合、userという変数にログインユーザーのオブジェクトが格納されているので、usernameを表示させて動作確認です。

templates/login_conf.html

<!doctype html>
<html lang="ja">
   <head>
       <meta charset="utf-8">
       <title>Plus IT</title>
   </head>
   <body>
       <div>Hello World!</div>

       #ログインユーザーがいれば、usernameをHello World!直下に表示
       {% if user %}
       <div>{{user.username}}</div>
       {% endif %}

   </body>
</html>

5.3 成果物確認~新規登録機能~

これで新規登録ページの準備が整いました!
長かったですよね...!みなさん、お疲れ様でした!

実際に確認していきましょう。
$python manage.py runserverでサーバーを起動し、/accounts/createにアクセスすると、新規登録フォームを表示させたページが作成できています。
実際に入力していき、ユーザー登録ボタンをクリックすると、login_conf.htmlにリダイレクトされます。

先ほど登録したユーザー名が表示されているので、新規登録とログインが成功していることがわかりました。

画像5

5.4 実装~ログイン機能~

Createformの作成と同様の手順で作成していきます。
forms.py、views.py、urls.pyへの追記、login.htmlの作成をしてください。

accounts/forms.py

from django import forms
from .models import CustomUser

#追加インポート
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UsernameField
from django.core.exceptions import ObjectDoesNotExist


#LoginFormの追記
class LoginForm(forms.Form):

   username = UsernameField(
       label='ユーザー名',
       max_length=255,
       widget=forms.TextInput(
           attrs={'placeholder': 'ユーザー名', 'autofocus': True}),
   )

   password = forms.CharField(
       label='パスワード',
       strip=False,
       widget=forms.PasswordInput(
           attrs={'placeholder': 'パスワード'}, render_value=True),
   )

   def __init__(self, *args, **kwargs):
       super().__init__(*args, **kwargs)
       self.user_cache = None

   def clean_password(self):
       value = self.cleaned_data['password']
       return value

   def clean_username(self):
       value = self.cleaned_data['username']
       if len(value) < 3:
           raise forms.ValidationError(
               '%(min_length)s文字以上で入力してください', params={'min_length': 3})
       return value

   def clean(self):
       username = self.cleaned_data.get('username')
       password = self.cleaned_data.get('password')
       try:
           user = get_user_model().objects.get(username=username)
       except ObjectDoesNotExist:
           raise forms.ValidationError("正しいユーザー名を入力してください")
       if not user.check_password(password):
           raise forms.ValidationError("正しいユーザー名とパスワードを入力してください")
       self.user_cache = user

   def get_user(self):
       return self.user_cache


accounts/views.py

from django.views import View
from django.views.generic import TemplateView
from django.shortcuts import render, redirect
from .models import CustomUser
from .forms import CreateForm, LoginForm        #LoginFormのみ追加インポート
from django.contrib.auth import login as auth_login, logout as auth_logout


class Login(View):
   def get(self, request, *args, **kwargs):
       if request.user.is_authenticated:
           return redirect('login_conf')
       context = {'form': LoginForm()}
       return render(request, 'login.html', context)

   def post(self, request, *args, **kwargs):
       form = LoginForm(request.POST)
       if not form.is_valid():
           return render(request, 'login.html', {'form': form})
       user = form.get_user()
       auth_login(request, user)
       return redirect('login_conf')


accounts/urls.py

from django.urls import path
from .views import LoginConf, Create, Login    #Loginの追加インポート

urlpatterns = [
   path('', LoginConf.as_view(), name='login_conf'),
   path('create/', Create.as_view(), name='create'),
   path('login/', Login.as_view(), name='login'),    #login追記
]


templates/login.html

<!doctype html>
<html lang="ja">
<body>
 <div class="login_form">
   <h1>ログイン画面</h1>
   <h2>ログインしたいユーザー情報を入力してください</h2>
   <form action="{% url 'login' %}" method="post" class="form_create">
     <div class="input">
       {% if form.non_field_errors %}
       <div class="header">エラー</div>
       <ul class="list">
         {% for non_field_error in form.non_field_errors %}
         <li>{{ non_field_error }}</li>
         {% endfor %}
       </ul>
       {% endif %}
       {% for field in form %}
       <div class="input_field">
         <div class="field_item">{{ field }}</div>
         {% if field.errors %}
         <p class="field_error">{{ field.errors.0 }}</p>
         {% endif %}
       </div>
       {% endfor %}
       {% csrf_token %}
       <button type="submit" class="user_button">ユーザー登録</button>
     </div>
   </form>
 </div>
</body>
</html>

ほとんど新規作成機能と同じなので解説もMINでいきます。

ただ、注意点が1つだけあります。
LoginFormの検証メソッドの中で、cleanに記述するpassword検証では、ckeck_passwordメソッドを使用することにしています。
実は、新規登録時にパスワードをハッシュ化した都合、この様なメソッドを用いないと入力値と照合できないんです。

あと、工夫点が1つあります。
LoginFormの末尾にて、取得できたuserをuser_cacheに格納して、get_userで戻り値を返すメソッド化しています。View側で該当のユーザーオブジェクトを取得したい時に渡せるメソッドがあると便利だと考え、この構造にしています。

しかもcacheは、処理の高速化にもつながるので一石二鳥です!
こうした高速化のノウハウはサービスの満足度にも直結するので非常に重要です。こうしたTips紹介を、本シリーズの中でいくつか織り込んでいくので是非別のシリーズも読んでいただけると嬉しいです。

5.5 成果物確認~ログイン機能~

さて、動作を確認していきましょう。

画像5

期待通りの動作をしており、成功していますね。

5.6 実装~ログアウト機能~

今回は簡単です。必要最低限の機能として、”特定のURLアクセスを受けて、logout処理をする”ということを目指します。
それに当たっては、forms.pyとtemplateの準備が不要なので、views.pyとurls.pyへの追記をしていきます。

◆view.pyの記述

accounts/views.py

#追加インポートなし

class Logout(View):
   def get(self, request, *args, **kwargs):
       if not request.user.is_authenticated:
           return redirect('login')
       auth_logout(request)
       return redirect('login_conf')

ログインユーザーがいる場合のみ、auth_logoutを呼び出して終わりです。
既存メソッドを使いこなすことの重要さが分かるviewの事例だと思います。

なお、ログアウト後は、トップページのユーザー名が消えていることを確認するためにリダイレクトさせています。

◆urls.pyの記述
もはや説明不要かと思います。

accounts/urls.py

from django.urls import path
from .views import LoginConf, Create, Login, Logout    #Logout追記

urlpatterns = [
   path('', LoginConf.as_view(), name='login_conf'),
   path('create/', Create.as_view(), name='create'),
   path('login/', Login.as_view(), name='login'),
   path('logout/', Logout.as_view(), name='logout'),    #logout追記
]

5.7 成果物確認~ログアウト機能~

さて、動作を確認していきましょう。

画像4

ログアウト実施後はトップページにリダイレクトされ、ユーザー名が消えており、成功です。

5.8 実装~ユーザー削除~

こちらも必要最低限の機能として、"特定のURLアクセスを受けて、ユーザーを削除する"ということを目指します。

◆Views.pyの記述

accounts/views.py

#追加インポートなし
class Delete(View):
   def get(self, request, *args, **kwargs):
       if not request.user.is_authenticated:
           return redirect('login')
       user = request.user
       user.delete()
       return redirect('login_conf')

ログインユーザーがいる場合のみ、リクエストユーザーを取得していき、deleteメソッドを呼び出しています。deleteメソッドは関連させて呼び出したオブジェクトのデータを削除するメソッドのため、これでユーザー削除を実現させています。
ユーザー削除後は、トップページのユーザー名が消えていることを確認するためにリダイレクトさせています。

◆urls.pyの記述
もはや説明不要ですね。

accounts/urls.py

from django.urls import path
from .views import LoginConf, Create, Login, Logout, Delete    #Delete追記

urlpatterns = [
   path('', LoginConf.as_view(), name='login_conf'),
   path('create/', Create.as_view(), name='create'),
   path('login/', Login.as_view(), name='login'),
   path('logout/', Logout.as_view(), name='logout'),
   path('delete/', Delete.as_view(), name='delete'),    #delete追記
]

実際に動作を確認していきましょう。

画像4

下図の通り、ユーザー削除実施後はトップページにリダイレクトされ、ユーザー名は消えています。Loginと決定的に異なるのは、この機能を適用したユーザーオブジェクトではもうログインできないところにあります。

実際に確認すると、この通りエラーメッセージが表示されるようになってしまいますね。エラーは出ていますが、狙い通りのため成功です。

6. まとめ

いかがでしたか?
今回は、Django初学者の方向けにユーザー機能導入を深掘りした内容でした。

このユーザー機能を活用してより魅力的な機能を作成可能ですが、どんなサイトにも共通して適用可能なユーザー機能の実装に必要な情報はお伝えできたかと思います。私なりにより高機能を実装した内容も紹介したいと思いますが、少しニッチな内容になると思うので続きはシリーズの記事で公開しています。

Djangoは日本語で検索している限り、なかなかわかりやすいネット情報を見つけるのに苦戦すると思います。初学者の方の学習の一助になればと思い、本記事を公開してますのでぜひご活用いただきたいです。
今回の記事を読んでみて、「分かりやすかった!」「もっと詳しい情報を知りたい!」と感じてくださった方は、ぜひ同マガジンの他の記事も皆さんの開発の参考になると思います。ぜひ購読検討していただけると嬉しいです。

よろしければサポートをお願いします🙇‍♂️ いただいたサポートは、クリエイターの活動資金として使わせていただきます😌 活動を通してえた経験を、また記事として皆さんと共有します👍