もた日記

くだらないことを真面目にやる

Djangoメモ(22) : ユーザー認証を実装する〜ログインと独自テンプレートタグ、フィルタ

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にログイン機能を実装する。


ログインページ作成

前回ログアウト機能を実装したので次にログイン機能を実装するが、ログインもログアウト同様にビューが提供されているのでそれを利用する。
myproject/urls.pylogin/の行を追加。

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/にアクセスすると下図のフォームが表示されるようになり、登録済みのユーザー名とパスワードを入力すればログインできる。

f:id:wonder-wall:20180320231732p:plain

以前に作成したサインアップフォームとこのログインフォームでは共通部分があるので、共通部分を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 %}


バリデーションメッセージ

ログインフォームのバリデーションメッセージを確認するために何も入力せずに送信してみる。

f:id:wonder-wall:20180320232310p:plain

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

f:id:wonder-wall:20180320232531p:plain

当然ログインはできないが、枠が緑色(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 %}については表示されるメッセージの見た目が良くなるようにマージンを調整しているとのこと。
再度送信するとエラーメッセージが表示されるようになる。

f:id:wonder-wall:20180320232828p:plain


独自テンプレートタグ、フィルタを作成

次にパスワードの枠が緑色にならないようにしてみるが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を再起動する)。
このようにテンプレートタグ、フィルタを活用するとテンプレートがシンプルに記述できる。

f:id:wonder-wall:20180320234110p:plain


独自テンプレートタグ、フィルタのテスト

テストを作成する前にディレクトリ構成を変える。
testsというディレクトリを作成し、その中に__init__.pyファイルを作成。そして、tests.pytest_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デコレータで独自テンプレートフィルタを作成