Djangoメモ(22) : ユーザー認証を実装する〜ログインと独自テンプレートタグ、フィルタ
A Complete Beginner's Guide to Djangoのチュートリアルを参考にログイン機能を実装する。
ログインページ作成
前回ログアウト機能を実装したので次にログイン機能を実装するが、ログインもログアウト同様にビューが提供されているのでそれを利用する。
myproject/urls.pyにlogin/の行を追加。
from django.contrib import admin from django.conf import settings from django.urls import path, include from django.contrib.auth import views as auth_views from accounts import views as accounts_views from boards import views urlpatterns = [ path('', views.home, name='home'), path('signup/', accounts_views.signup, name='signup'), path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'), path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('boards/<int:pk>/', views.board_topics, name='board_topics'), path('boards/<int:pk>/new/', views.new_topic, name='new_topic'), path('admin/', admin.site.urls), ]
ログインもクラスベースビューなのでauth_views.LoginView.as_view(template_name='login.html')のように記述するが、引数でテンプレートファイル名を指定する。
次にmyproject/settings.pyにログイン後のリダイレクト先URLを設定する。
LOGIN_REDIRECT_URL = 'home'
そして、ログインページのURLが決まったのでtemplates/base.htmlのログインボタンの遷移先を指定しておく。
<a href="{% url 'login' %}" class="btn btn-outline-secondary">Log in</a>
ログインフォーム作成
templates/login.htmlを新規作成してログインフォームのコードを記述する。
{% extends 'base.html' %}
{% load static %}
{% block stylesheet %}
<link rel="stylesheet" href="{% static 'css/accounts.css' %}">
{% endblock %}
{% block body %}
<div class="container">
<h1 class="text-center logo my-4">
<a href="{% url 'home' %}">Django Boards</a>
</h1>
<div class="row justify-content-center">
<div class="col-lg-4 col-md-6 col-sm-8">
<div class="card">
<div class="card-body">
<h3 class="card-title">Log in</h3>
<form method="post" novalidate>
{% csrf_token %}
{% include 'includes/form.html' %}
<button type="submit" class="btn btn-primary btn-block">Log in</button>
</form>
</div>
<div class="card-footer text-muted text-center">
New to Django Boards? <a href="{% url 'signup' %}">Sign up</a>
</div>
</div>
<div class="text-center py-2">
<small>
<a href="#" class="text-muted">Forgot your password?</a>
</small>
</div>
</div>
</div>
</div>
{% endblock %}
これでlogin/にアクセスすると下図のフォームが表示されるようになり、登録済みのユーザー名とパスワードを入力すればログインできる。

以前に作成したサインアップフォームとこのログインフォームでは共通部分があるので、共通部分をtemplates/base_accounts.htmlとして抽出する。
{% extends 'base.html' %}
{% load static %}
{% block stylesheet %}
<link rel="stylesheet" href="{% static 'css/accounts.css' %}">
{% endblock %}
{% block body %}
<div class="container">
<h1 class="text-center logo my-4">
<a href="{% url 'home' %}">Django Boards</a>
</h1>
{% block content %}
{% endblock %}
</div>
{% endblock %}
そしてtemplates/login.htmlではこのテンプレート使用するように変更。
{% extends 'base_accounts.html' %}
{% block title %}Log in to Django Boards{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-4 col-md-6 col-sm-8">
<div class="card">
<div class="card-body">
<h3 class="card-title">Log in</h3>
<form method="post" novalidate>
{% csrf_token %}
{% include 'includes/form.html' %}
<button type="submit" class="btn btn-primary btn-block">Log in</button>
</form>
</div>
<div class="card-footer text-muted text-center">
New to Django Boards? <a href="{% url 'signup' %}">Sign up</a>
</div>
</div>
<div class="text-center py-2">
<small>
<a href="#" class="text-muted">Forgot your password?</a>
</small>
</div>
</div>
</div>
{% endblock %}
templates/signup.htmlもテンプレートを使用するように変更する(ログインリンク部分を<a href="{% url 'login' %}">Log in</a>に変更しているので注意)。
{% extends 'base_accounts.html' %}
{% block title %}Sign up to Django Boards{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10 col-sm-12">
<div class="card">
<div class="card-body">
<h3 class="card-title">Sign up</h3>
<form method="post" novalidate>
{% csrf_token %}
{% include 'includes/form.html' %}
<button type="submit" class="btn btn-primary btn-block">Create an account</button>
</form>
</div>
<div class="card-footer text-muted text-center">
Already have an account? <a href="{% url 'login' %}">Log in</a>
</div>
</div>
</div>
</div>
{% endblock %}
バリデーションメッセージ
ログインフォームのバリデーションメッセージを確認するために何も入力せずに送信してみる。

「このフィールドは必須です。」という適切なエラーメッセージが表示された。
次に存在しないユーザー名と適当なパスワードで送信してみる。

当然ログインはできないが、枠が緑色(OKという意味)で何のエラーメッセージも表示されないためログインできない理由がわかりにくい。
だが、実際にはnon_field_errorsという特定のフィールドに結びつかないエラーは返ってきているので、そのエラーを表示するようにtemplates/includes/form.htmlを編集する。
{% load widget_tweaks %}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
<p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% for field in form %}
<!-- code suppressed -->
{% endfor %}
{% if forloop.last %} class="mb-0"{% endif %}については表示されるメッセージの見た目が良くなるようにマージンを調整しているとのこと。
再度送信するとエラーメッセージが表示されるようになる。

独自テンプレートタグ、フィルタを作成
次にパスワードの枠が緑色にならないようにしてみるがtemplates/includes/form.htmlが複雑になってきているので独自テンプレートタグ、フィルタを作成してみる。
独自のテンプレートタグ、フィルタを作成するためにboardsアプリ内にtemplatetagsディレクトリを作成する。そして、ディレクトリ内に__init__.pyと独自テンプレートタグ、フィルタを記述するform_tags.pyを作成する。
ディレクトリ構成は以下の通り。
├── boards/ │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── migrations/ │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── templatetags/ │ │ ├── __init__.py │ │ └── form_tags.py │ ├── tests.py │ └── views.py
boards/templatetags/form_tags.pyには下記コードを記述する。
from django import template register = template.Library() @register.filter def field_type(bound_field): return bound_field.field.widget.__class__.__name__ @register.filter def input_class(bound_field): css_class = '' if bound_field.form.is_bound: if bound_field.errors: css_class = 'is-invalid' elif field_type(bound_field) != 'PasswordInput': css_class = 'is-valid' return 'form-control {}'.format(css_class)
ドキュメントに書いてあるように、独自テンプレートタグ、フィルタを追加する場合は@register.filterデコレータ(この場合はフィルタ。タグの場合は@register.simple_tagなど)を使用する。
これでテンプレートで|field_typeと|input_classというフィルタが使えるようになる。
各フィルタの意味については、|field_typeはフィールドのクラス名を返すので{{ form.username|field_type }}の場合は'TextInput'が返却される。
|input_classはエラーの有無、パスワードフィールドかどうかによりform-control, form-control is-valid, form-control is-invalidを返す。パスワードフィールドの場合はエラーがなくてもis-validにならない所がポイント。
準備が完了したので独自テンプレートタグを使うようにtemplates/includes/form.htmlを編集する。最初に{% load form_tags %}でのロードを忘れないこと。
{% load form_tags widget_tweaks %}
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}
<p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% for field in form %}
<div class="form-group">
{{ field.label_tag }}
{% render_field field class=field|input_class %}
{% for error in field.errors %}
<div class="invalid-feedback">
{{ error }}
</div>
{% endfor %}
{% if field.help_text %}
<small class="form-text text-muted">
{{ field.help_text|safe }}
</small>
{% endif %}
</div>
{% endfor %}
再度送信してみるとパスワードフィールドの枠が緑色にならないことが確認できる(反映されていない場合はrunserverを再起動する)。
このようにテンプレートタグ、フィルタを活用するとテンプレートがシンプルに記述できる。

独自テンプレートタグ、フィルタのテスト
テストを作成する前にディレクトリ構成を変える。
testsというディレクトリを作成し、その中に__init__.pyファイルを作成。そして、tests.pyをtest_views.pyという名前にリネームし、test_templatetags.py空ファイルを作成。
ディレクトリ、ファイル構成は以下の通り。
├── boards │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── templatetags │ │ ├── __init__.py │ │ └── form_tags.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_templatetags.py │ │ └── test_views.py │ └── views.py
構成を変更したのでboards/tests/test_views.pyのimport文をを修正する。
from ..views import home, board_topics, new_topic from ..models import Board, Topic, Post from ..forms import NewTopicForm
boards/tests/test_templatetags.pyにはテンプレートフィルタのテストを追加する。
from django import forms from django.test import TestCase from ..templatetags.form_tags import field_type, input_class class ExampleForm(forms.Form): name = forms.CharField() password = forms.CharField(widget=forms.PasswordInput()) class Meta: fields = ('name', 'password') class FieldTypeTests(TestCase): def test_field_widget_type(self): form = ExampleForm() self.assertEquals('TextInput', field_type(form['name'])) self.assertEquals('PasswordInput', field_type(form['password'])) class InputClassTests(TestCase): def test_unbound_field_initial_state(self): form = ExampleForm() # unbound form self.assertEquals('form-control ', input_class(form['name'])) def test_valid_bound_field(self): form = ExampleForm({'name': 'john', 'password': '123'}) # bound form (field + data) self.assertEquals('form-control is-valid', input_class(form['name'])) self.assertEquals('form-control ', input_class(form['password'])) def test_invalid_bound_field(self): form = ExampleForm({'name': '', 'password': '123'}) # bound form (field + data) self.assertEquals('form-control is-invalid', input_class(form['name']))
最後にテストが通ることを確認する。
$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). ................................ ---------------------------------------------------------------------- Ran 32 tests in 1.896s OK Destroying test database for alias 'default'...
まとめ
auth_views.LoginView.as_view(template_name='login.html')はログインビューLOGIN_REDIRECT_URL = 'home'でログイン後のリダイレクト先を指定non_field_errorsという特定のフィールドに結びつかないエラーがあるtemplatetagsに独自テンプレートタグ、フィルタを作成@register.filterデコレータで独自テンプレートフィルタを作成