Djangoメモ(27) : 掲示板アプリの返信機能を実装する
A Complete Beginner's Guide to Djangoのチュートリアルを参考に掲示板アプリの返信機能を実装する。
返信一覧ページの作成
このチュートリアルで作成している掲示板アプリには以下のモデルがある。
- Board : カテゴリのようなもの(例:"Python"に関するBoard)
- Topic : スレッドのようなもの(例:"Python"Board内の"インストール方法"に関するTopic)
- Post : Topicに対する返信
これまでにBoardとTopicについては実装しているので、今回はPostに関して実装する。
最初にTopicに対するPostの一覧を表示するページのURLConfをmyproject/urls.py
に追加する。
path('boards/<int:pk>/topics/<int:topic_pk>/', views.topic_posts, name='topic_posts'),
pk
はBoardに対するプライマリキーでtopic_pk
はTopicに対するプライマリキー。
続いてtopic_posts
をboards/views.py
に追加する。
from django.shortcuts import get_object_or_404, render from .models import Topic def topic_posts(request, pk, topic_pk): topic = get_object_or_404(Topic, board__pk=pk, pk=topic_pk) return render(request, 'topic_posts.html', {'topic': topic})
templates/topic_posts.html
テンプレートを作成する。
{% extends 'base.html' %} {% block title %}{{ topic.subject }}{% endblock %} {% block breadcrumb %} <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li> <li class="breadcrumb-item"><a href="{% url 'board_topics' topic.board.pk %}">{{ topic.board.name }}</a></li> <li class="breadcrumb-item active">{{ topic.subject }}</li> {% endblock %} {% block content %} {% endblock %}
これでまずはパンくずリストが表示されるようになる。
返信一覧ページのテスト
テストをboards/tests/test_view_topic_posts.py
ファイルに作成する。
from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse from ..models import Board, Post, Topic from ..views import topic_posts class TopicPostsTests(TestCase): def setUp(self): board = Board.objects.create(name='Django', description='Django board.') user = User.objects.create_user(username='john', email='john@doe.com', password='123') topic = Topic.objects.create(subject='Hello, world', board=board, starter=user) Post.objects.create(message='Lorem ipsum dolor sit amet', topic=topic, created_by=user) url = reverse('topic_posts', kwargs={'pk': board.pk, 'topic_pk': topic.pk}) self.response = self.client.get(url) def test_status_code(self): self.assertEquals(self.response.status_code, 200) def test_view_function(self): view = resolve('/boards/1/topics/1/') self.assertEquals(view.func, topic_posts)
テストコードが多くなってきたので特定のテストのみを実行する方法を以下に説明する。
特定のアプリのみを対象にする方法。
$ python manage.py test boards Creating test database for alias 'default'... System check identified no issues (0 silenced). ....................... ---------------------------------------------------------------------- Ran 23 tests in 2.979s OK Destroying test database for alias 'default'...
特定のファイルのみを対象にする方法。
$ python manage.py test boards.tests.test_view_topic_posts Creating test database for alias 'default'... System check identified no issues (0 silenced). .. ---------------------------------------------------------------------- Ran 2 tests in 0.318s OK Destroying test database for alias 'default'...
特定のテストケースのみを対象にする方法。
$ python manage.py test boards.tests.test_view_topic_posts.TopicPostsTests.test_status_code Creating test database for alias 'default'... System check identified no issues (0 silenced). . ---------------------------------------------------------------------- Ran 1 test in 0.156s OK Destroying test database for alias 'default'...
返信一覧の表示
templates/topic_posts.html
を以下のように編集して返信一覧を表示するようにする。
{% extends 'base.html' %} {% load static %} {% block title %}{{ topic.subject }}{% endblock %} {% block breadcrumb %} <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li> <li class="breadcrumb-item"><a href="{% url 'board_topics' topic.board.pk %}">{{ topic.board.name }}</a></li> <li class="breadcrumb-item active">{{ topic.subject }}</li> {% endblock %} {% block content %} <div class="mb-4"> <a href="#" class="btn btn-primary" role="button">Reply</a> </div> {% for post in topic.posts.all %} <div class="card mb-2"> <div class="card-body p-3"> <div class="row"> <div class="col-2"> <img src="{% static 'img/avatar.svg' %}" alt="{{ post.created_by.username }}" class="w-100"> <small>Posts: {{ post.created_by.posts.count }}</small> </div> <div class="col-10"> <div class="row mb-3"> <div class="col-6"> <strong class="text-muted">{{ post.created_by.username }}</strong> </div> <div class="col-6 text-right"> <small class="text-muted">{{ post.created_at }}</small> </div> </div> {{ post.message }} {% if post.created_by == user %} <div class="mt-3"> <a href="#" class="btn btn-primary btn-sm" role="button">Edit</a> </div> {% endif %} </div> </div> </div> </div> {% endfor %} {% endblock %}
{{ post.created_by.posts.count }}
は正しくカウントはされるが重いクエリを実行しているため後のチュートリアルで改善するとのこと。
また、{% if post.created_by == user %}
によりPostの作成者のみにEditボタンを表示するようにしている。
一覧は表示されるようになったがユーザーのアイコンが表示されていない。
ユーザーにアイコンをアップロードしてもらって表示するようにしたいが、ひとまずは固定のアイコンを表示するようにする。
下記サイトなどでSVG形式のファイルをダウンロードして、static/img/avatar.svg
として置くとアイコンが表示されるようになる。
返信一覧ページのURLが決まったのでtemplates/topics.html
にあるリンクを修正しておく。
{% for topic in board.topics.all %} <tr> <td><a href="{% url 'topic_posts' board.pk topic.pk %}">{{ topic.subject }}</a></td> <td>{{ topic.starter.username }}</td> <td>0</td> <td>0</td> <td>{{ topic.last_updated }}</td> </tr> {% endfor %}
以下のように返信一覧ページへのリンクが付く。
返信フォーム作成
返信フォームを作成するためにmyproject/urls.py
に下記行を追加。
path('boards/<int:pk>/topics/<int:topic_pk>/reply/', views.reply_topic, name='reply_topic'),
返信フォームに関するboards/forms.py
ファイルを作成。
from django import forms from .models import Post class PostForm(forms.ModelForm): class Meta: model = Post fields = ['message', ]
boards/views.py
に返信フォームの処理を追加する(@login_required
でログイン済みユーザーに限定)。
from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, redirect, render from .forms import PostForm from .models import Topic @login_required def reply_topic(request, pk, topic_pk): topic = get_object_or_404(Topic, board__pk=pk, pk=topic_pk) if request.method == 'POST': form = PostForm(request.POST) if form.is_valid(): post = form.save(commit=False) post.topic = topic post.created_by = request.user post.save() return redirect('topic_posts', pk=pk, topic_pk=topic_pk) else: form = PostForm() return render(request, 'reply_topic.html', {'topic': topic, 'form': form})
また、既に作成しているnew_topic
ビューのリダイレクト先を変更しておく。
@login_required def new_topic(request, pk): board = get_object_or_404(Board, pk=pk) if request.method == 'POST': form = NewTopicForm(request.POST) if form.is_valid(): topic = form.save(commit=False) # code suppressed ... return redirect('topic_posts', pk=pk, topic_pk=topic.pk) # <- here # code suppressed ...
templates/reply_topic.html
テンプレートを作成する。
{% extends 'base.html' %} {% load static %} {% block title %}Post a reply{% endblock %} {% block breadcrumb %} <li class="breadcrumb-item"><a href="{% url 'home' %}">Boards</a></li> <li class="breadcrumb-item"><a href="{% url 'board_topics' topic.board.pk %}">{{ topic.board.name }}</a></li> <li class="breadcrumb-item"><a href="{% url 'topic_posts' topic.board.pk topic.pk %}">{{ topic.subject }}</a></li> <li class="breadcrumb-item active">Post a reply</li> {% endblock %} {% block content %} <form method="post" class="mb-4" novalidate> {% csrf_token %} {% include 'includes/form.html' %} <button type="submit" class="btn btn-success">Post a reply</button> </form> {% for post in topic.posts.all %} <div class="card mb-2"> <div class="card-body p-3"> <div class="row mb-3"> <div class="col-6"> <strong class="text-muted">{{ post.created_by.username }}</strong> </div> <div class="col-6 text-right"> <small class="text-muted">{{ post.created_at }}</small> </div> </div> {{ post.message }} </div> </div> {% endfor %} {% endblock %}
これで以下の返信フォームが表示される。
返信すると返信一覧ページにリダイレクトされ、返信が追加されていることが確認できる。
そして、一番最初のPost(Topic作成者によるPost)を目立たせるためにtemplates/topic_posts.html
を改良する。
{% for post in topic.posts.all %} <div class="card mb-2 {% if forloop.first %}border-dark{% endif %}"> {% if forloop.first %} <div class="card-header text-white bg-dark py-2 px-3">{{ topic.subject }}</div> {% endif %} <div class="card-body p-3"> <!-- code suppressed --> </div> </div> {% endfor %}
返信機能のテスト
返信機能のテストについては下記コードを参照。
boards/tests/test_view_reply_topic.py
(クリックで展開)from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import resolve, reverse
from ..forms import PostForm
from ..models import Board, Post, Topic
from ..views import reply_topic
class ReplyTopicTestCase(TestCase):
'''
Base test case to be used in all `reply_topic` view tests
'''
def setUp(self):
self.board = Board.objects.create(name='Django', description='Django board.')
self.username = 'john'
self.password = '123'
user = User.objects.create_user(username=self.username, email='john@doe.com', password=self.password)
self.topic = Topic.objects.create(subject='Hello, world', board=self.board, starter=user)
Post.objects.create(message='Lorem ipsum dolor sit amet', topic=self.topic, created_by=user)
self.url = reverse('reply_topic', kwargs={'pk': self.board.pk, 'topic_pk': self.topic.pk})
class LoginRequiredReplyTopicTests(ReplyTopicTestCase):
def test_redirection(self):
login_url = reverse('login')
response = self.client.get(self.url)
self.assertRedirects(response, '{login_url}?next={url}'.format(login_url=login_url, url=self.url))
class ReplyTopicTests(ReplyTopicTestCase):
def setUp(self):
super().setUp()
self.client.login(username=self.username, password=self.password)
self.response = self.client.get(self.url)
def test_status_code(self):
self.assertEquals(self.response.status_code, 200)
def test_view_function(self):
view = resolve('/boards/1/topics/1/reply/')
self.assertEquals(view.func, reply_topic)
def test_csrf(self):
self.assertContains(self.response, 'csrfmiddlewaretoken')
def test_contains_form(self):
form = self.response.context.get('form')
self.assertIsInstance(form, PostForm)
def test_form_inputs(self):
'''
The view must contain two inputs: csrf, message textarea
'''
self.assertContains(self.response, '<input', 1)
self.assertContains(self.response, '<textarea', 1)
class SuccessfulReplyTopicTests(ReplyTopicTestCase):
def setUp(self):
super().setUp()
self.client.login(username=self.username, password=self.password)
self.response = self.client.post(self.url, {'message': 'hello, world!'})
def test_redirection(self):
'''
A valid form submission should redirect the user
'''
topic_posts_url = reverse('topic_posts', kwargs={'pk': self.board.pk, 'topic_pk': self.topic.pk})
self.assertRedirects(self.response, topic_posts_url)
def test_reply_created(self):
'''
The total post count should be 2
The one created in the `ReplyTopicTestCase` setUp
and another created by the post data in this class
'''
self.assertEquals(Post.objects.count(), 2)
class InvalidReplyTopicTests(ReplyTopicTestCase):
def setUp(self):
'''
Submit an empty dictionary to the `reply_topic` view
'''
super().setUp()
self.client.login(username=self.username, password=self.password)
self.response = self.client.post(self.url, {})
def test_status_code(self):
'''
An invalid form submission should return to the same page
'''
self.assertEquals(self.response.status_code, 200)
def test_form_errors(self):
form = self.response.context.get('form')
self.assertTrue(form.errors)
テストが通ることを確認する。
$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). ................................................................................ ---------------------------------------------------------------------- Ran 80 tests in 14.120s OK Destroying test database for alias 'default'...
まとめ
- 返信機能を実装した
- テストの対象を指定する方法
python manage.py test boards
python manage.py test boards.tests.test_view_topic_posts
python manage.py test boards.tests.test_view_topic_posts.TopicPostsTests.test_status_code