もた日記

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

Djangoメモ(16) : フォームAPIを使わずにフォームを作成

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にフォームを作成してみる。


フォームを表示するページ作成

DjangoではフォームAPIを使ってフォームを作成するが、理解を深めるためにまずはフォームAPIを使わずにフォームを作成してみる。そして、その後でフォームAPIを使ってフォームを作成する。
今回作成するフォームは新しいTopic(Board内のスレッド)とPost(Topicに対する返信だが、Topic作成時のメッセージも含む)を作成するフォームで完成形は下図。実際は誰がTopicとPostを作成したかを管理するがユーザ認証等については後で考える。

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

最初にフォームを表示するページを作成するためにURLconfを修正してnew_topicの行を追加する。

from django.contrib import admin
from django.conf import settings
from django.urls import path, include
from boards import views

urlpatterns = [
    path('', views.home, name='home'),
    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),
]

次にboards/views.pynew_topic()を追加する。

from django.shortcuts import render, get_object_or_404
from .models import Board

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)
    return render(request, 'new_topic.html', {'board': board})

そしてtemplates/new_topic.htmlというテンプレートを作成する。

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}

{% endblock %}

これでboards/1/new/にアクセスするとパンくずリストが表示されるようになる。

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

new_topic用のテストを追加しておく。前に作成したテストとほぼ同じだがnew_topicのimportを忘れないこと。

from django.urls import reverse, resolve
from django.test import TestCase
from .views import home, board_topics, new_topic
from .models import Board

class HomeTests(TestCase):
    # ...

class BoardTopicsTests(TestCase):
    # ...

class NewTopicTests(TestCase):
    def setUp(self):
        Board.objects.create(name='Django', description='Django board.')

    def test_new_topic_view_success_status_code(self):
        url = reverse('new_topic', kwargs={'pk': 1})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 200)

    def test_new_topic_view_not_found_status_code(self):
        url = reverse('new_topic', kwargs={'pk': 99})
        response = self.client.get(url)
        self.assertEquals(response.status_code, 404)

    def test_new_topic_url_resolves_new_topic_view(self):
        view = resolve('/boards/1/new/')
        self.assertEquals(view.func, new_topic)

    def test_new_topic_view_contains_link_back_to_board_topics_view(self):
        new_topic_url = reverse('new_topic', kwargs={'pk': 1})
        board_topics_url = reverse('board_topics', kwargs={'pk': 1})
        response = self.client.get(new_topic_url)
        self.assertContains(response, 'href="{0}"'.format(board_topics_url))

テストが通ることを確認。

$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
----------------------------------------------------------------------
Ran 11 tests in 0.051s

OK
Destroying test database for alias 'default'...


フォーム作成

ページができたのでフォームを作成する。
templates/new_topic.html{% block content %}内を以下のように編集する。

{% extends 'base.html' %}

{% block title %}Start a New Topic{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item"><a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a></li>
  <li class="breadcrumb-item active">New topic</li>
{% endblock %}

{% block content %}
  <form method="post">
    {% csrf_token %}
    <div class="form-group">
      <label for="id_subject">Subject</label>
      <input type="text" class="form-control" id="id_subject" name="subject">
    </div>
    <div class="form-group">
      <label for="id_message">Message</label>
      <textarea class="form-control" id="id_message" name="message" rows="5"></textarea>
    </div>
    <button type="submit" class="btn btn-success">Post</button>
  </form>
{% endblock %}

これでBootstrap 4のCSSクラスが適用されたフォームが表示される。

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

新しいTopicを作成するので<form method="post">とPOSTメソッドを使用するが、DjangoではPOSTメソッドを使用する場合はCSRF(Cross-site Request Forgery)対策としてCSRFトークンを渡す必要がある。
{% csrf_token %}がそのタグで以下のようなHTMLに変換される。

<input type='hidden' name='csrfmiddlewaretoken' value='a9tL0UDbdkCA5R372UD4zbUHH3qxx6hq2zcov43t5rhUDWQY9BRduzLfIHUmq5KB' />

その他、inputタグのname属性で指定した値は、

<input type="text" class="form-control" id="id_subject" name="subject">
<textarea class="form-control" id="id_message" name="message" rows="5"></textarea>

ビュー側で以下のように指定することで参照できる。

subject = request.POST['subject']
message = request.POST['message']


ビューの処理

フォームで入力されたデータを受け取って新しいTopicとPostを作成するビューの処理をboards/views.pyに記述する。

from django.contrib.auth.models import User
from django.shortcuts import render, redirect, get_object_or_404
from .models import Board, Topic, Post

def new_topic(request, pk):
    board = get_object_or_404(Board, pk=pk)

    if request.method == 'POST':
        subject = request.POST['subject']
        message = request.POST['message']

        user = User.objects.first()  # TODO: get the currently logged in user

        topic = Topic.objects.create(
            subject=subject,
            board=board,
            starter=user
        )

        post = Post.objects.create(
            message=message,
            topic=topic,
            created_by=user
        )

        return redirect('board_topics', pk=board.pk)  # TODO: redirect to the created topic page

    return render(request, 'new_topic.html', {'board': board})

フォームAPIを使えば未入力チェックや、max_lengthを超える場合のバリデーションができるが、上記の処理は入力が適切な場合しか考慮されていないので注意。
if request.method == 'POST':の分岐はPOSTメソッドの場合、つまりフォームで入力されて送信された場合はこの処理を通る。一方、GETメソッドだった場合、つまりブラウザで普通にアクセスした場合はこの処理は通らずにrender()が呼ばれるのでフォームが表示されることになる。

ユーザ認証についてはまだ検討していないのでUser.objects.first()で一番最初のユーザ(この場合はadminユーザ)を取得している。
Topic.objects.createboard=boardPost.objects.createtopic=topicのようにモデルでForeignKey()を指定した箇所はこのようにして関連付ける。
また、TopicとPost一覧を表示するページはまだ作成していないので処理が完了したらboard_topicsページにリダイレクトするようにする。

ビューの処理を記述したのでSubjectとMessageに値を入力した後でPostボタンをクリックする。

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

ページがリダイレクトされ登録は完了しているようだが、Topic一覧を表示する処理を書いていないので何も表示されない。

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


Topic一覧表示

Topic一覧を表示するようにtemplates/topics.htmlを編集する。

{% extends 'base.html' %}

{% block title %}
  {{ board.name }} - {{ block.super }}
{% endblock %}

{% block breadcrumb %}
  <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li>
  <li class="breadcrumb-item active">{{ board.name }}</li>
{% endblock %}

{% block content %}
  <table class="table">
    <thead class="thead-dark">
      <tr>
        <th>Topic</th>
        <th>Starter</th>
        <th>Replies</th>
        <th>Views</th>
        <th>Last Update</th>
      </tr>
    </thead>
    <tbody>
      {% for topic in board.topics.all %}
        <tr>
          <td>{{ topic.subject }}</td>
          <td>{{ topic.starter.username }}</td>
          <td>0</td>
          <td>0</td>
          <td>{{ topic.last_updated }}</td>
        </tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock %}

これで先ほど登録したTopicが表示されるようになる。
BoardとTopicは1対多の関係のため、Boardには複数のTopicが関連付けられている。それらは{% for topic in board.topics.all %}のようにして取り出すことができる(テンプレート言語ではall()ではなくallとなる)。
また、関連付けられているモデルの属性には{{ topic.starter.username }}のように.で繋げてアクセスできる。

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


Topic作成ボタン追加

フォームは作成できたのでtemplates/topics.htmlを編集してTopicを作成するボタンを追加する。

{% block content %}
  <div class="mb-4">
    <a href="{% url 'new_topic' board.pk %}" class="btn btn-primary">New topic</a>
  </div>

  <table class="table">
    <!-- コードは省略 -->
  </table>
{% endblock %}

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

そして、このボタンリンクをテストするために既に作成しているtest_board_topics_view_contains_link_back_to_homepage()を下記テスト名にリネームしてassertContains()を追加しておく。

class BoardTopicsTests(TestCase):
    # ...

    def test_board_topics_view_contains_navigation_links(self):
        board_topics_url = reverse('board_topics', kwargs={'pk': 1})
        homepage_url = reverse('home')
        new_topic_url = reverse('new_topic', kwargs={'pk': 1})

        response = self.client.get(board_topics_url)

        self.assertContains(response, 'href="{0}"'.format(homepage_url))
        self.assertContains(response, 'href="{0}"'.format(new_topic_url))

以上のようにフォームAPIを使わずにフォームを作成することはできたがバリデーションなどができていないため、次回はフォームAPIを使ってフォームを作成する。


まとめ

  • POSTメソッドではCSRF対策としてCSRFトークンを渡す必要
  • {% csrf_token %}でCSRFトークンを生成
  • ビュー側ではinputタグのname属性の値をrequest.POST['subject']のように指定
  • テンプレート言語では{% for topic in board.topics.all %}のようにループ
  • 関連付けられているモデルの属性には{{ topic.starter.username }}のようにアクセス