Help us understand the problem. What is going on with this article?

Djangoチュートリアル(ブログアプリ作成)⑦ - フロントエンド完成編

前回、Djangoチュートリアル(ブログアプリ作成)⑥ - 記事詳細・編集・削除機能編では記事個別の詳細、編集、削除画面を作成しました。

今回は template を大幅に調整していきますが、大きく分けると以下のことをやっていきます。

  1. 全ページ共通画面の作成

  2. ナビゲーションバーの作成

  3. 各templateの修正

  4. 不要なtemplateや処理の削除

全ページ共通画面の作成

Django に限らず、ホームページには画面遷移しても共通的に表示される箇所ってありますよね。
Qiita でいえば上部に表示されている、緑色のナビゲーションバーなんかが良い例ですね。

↓これ
image.png
ただ、これをすべての template に毎回書くなんてのは大変ですよね。
一回コードを書いて終わりならまだしも、修正が入ったときのことを考えるともう…。

そこで、Django の便利な機能として共通テンプレートを使います。
簡単にいうと、共通的な部分は一つのファイルにまとめて、
画面ごとに異なる部分は別の template を呼び出して使うということです。

そのために、まずは template フォルダ直下にはじめてファイルを作成します。
今回は /template/base.html というファイルを作成しましょう。

└── templates
    ├── base.html # 追加
    └── blog
        ├── index.html
        ├── post_confirm_delete.html
        ├── post_detail.html
        ├── post_form.html
        └── post_list.html

このファイルに共通の処理を書き、画面ごとに異なる部分は
post_detail.html などのファイルを呼び出していくことにします。

base.html の修正

中身はこのようにしていきます。

base.html
<!doctype html>
<html lang="ja">
<head>
    <title>tmasuyama のブログ</title>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css" integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb" crossorigin="anonymous">
</head>
<body>

<div class="container">

    <nav class="navbar navbar-expand-lg navbar-light bg-light">
        <a class="navbar-brand" href="{% url 'blog:post_list' %}">トップ</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>

        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav mr-auto">
                <li class="nav-item">
                    <a class="nav-link" href="{% url 'blog:post_create' %}">投稿</a>
                </li>
            </ul>
        </div>
    </nav>

    <!-- このblockの中で各templateの記載内容が呼び出される -->
    {% block content %} # 注目!
    {% endblock %}      # 注目!
</div>

<!-- Optional JavaScript -->
<!-- jQuery first, then Popper.js, then Bootstrap JS -->
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js" integrity="sha384-alpBpkh1PFOepccYVYDB4do5UnbKysX5WZXm3XxPqe5iKTfUKjNkCk9SaVuEZflJ" crossorigin="anonymous"></script>

</body>
</html>

このチュートリアルはフロントエンドについて詳細な解説はしませんが、
Bootstrap を使って見た目を整えるために CDN から Bootstrap を呼び出して使えるようにしたり、
ナビゲーションバーを 内で表させるようにしています。
このようにどの画面でも使いたいものは base.html で書くようにします。

注目してほしいのは 「# 注目!」 とメモをした部分です。

    <!-- このblockの中で各templateの記載内容が呼び出される -->
    {% block content %} # 注目!
    {% endblock %}      # 注目!

View に従って template を呼び出す際、各 template はここに格納されていくことになります。
逆にいうと、各 template は各ページに特徴的な部分だけを書いておけばよいのです。

また、呼び出される側の template では親となる template (base.html) を明示してあげる必要があります。
基本的な書き方は次の通りです。

各template
{% extends 'base.html' %} # 親 template の指定

{% block content %} # 中身の記述開始
...各 template 固有の記述...
{% endblock %}    # 中身の記述終了

これで親templateと子templateの役割を明確に分けることが出来るようになりました。
次は親templateで用意するナビゲーションバーについて解説します。

ナビゲーションバーの説明

上述の base.html を使うと以下のようなナビゲーションバーが表示されるようになります。
なお、今回は Bootstrap4 の Cheat Sheat を参考にしています。
https://hackerthemes.com/bootstrap-cheatsheet/#navbar

image.png
トップ を選択すると post_list.html の画面へ遷移し、
投稿 を選択すると post_form.html の画面(新規投稿画面)へ遷移するようにしています。

base.html のうち、ナビゲーションバーを表示させるための部分はここでした。

base.html
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <a class="navbar-brand" href="{% url 'blog:post_list' %}">トップ</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>

        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav mr-auto">
                <li class="nav-item">
                    <a class="nav-link" href="{% url 'blog:post_create' %}">投稿</a>
                </li>
            </ul>
        </div>
    </nav>

ここで大事なのは「href="{% url 'blog:post_list' %}"」という部分です。

これまで template ではリンクを記述してきませんでしたが、
上記のようなフォーマットで逆引き用の URL を記述すると
に相当する URL へリダイレクトされるようになります。

※urls.py でいうとこの部分です。

urls.py
...
path('post_list', views.PostListView.as_view(), name='post_list'),
...

ここで name='post_list' というように、各URLに名前をつけていたおかげで
template側ではその名前を指定することで、自動でルーティングしてくれるようになります。

各templateの修正

さて、各 template で親 template の指定をしつつ、Bootstrapで見た目の調整をしていきましょう。
完成形を載せていきます。

post_detail.html
{% extends 'base.html' %}

{% block content %}
<table class="table">
    <tr>
      <th>タイトル</th>
      <td>{{ post.title }}</td>
    </tr>
    <tr>
      <th>本文</th>
      <!-- linebreaksbk を入れると改行タグでちゃんと改行して表示されるようになる -->
      <td>{{ post.text | linebreaksbr}}</td>
    </tr>
    <tr>
      <th>日付</th>
      <td>{{ post.date }}</td>
    </tr>
</table>
{% endblock %}
post_form.html
{% extends 'base.html' %}

{% block content %}
<p>{{ post.title }}</p>
<!-- actionにはサーバのどのURLに対して情報を送信する -->
<!-- actionを空欄にすると現在開いている URL = /blog/post_create に値を返すので、views.py の PostCreateView が再度呼び出されることになる -->
<form action="" method="POST">
    <table class="table">
        <tr>
        <th>タイトル</th>
        <td>{{ form.title }}</td>
        </tr>
        <tr>
        <th>本文</th>
        <td>{{ form.text }}</td>
        </tr>
    </table>
    <button type="submit" class="btn btn-primary">送信</button>
    {% csrf_token %}
</form>
{% endblock %}
post_confirm_delete.html
{% extends 'base.html' %}

{% block content %}
<form action="" method="POST">
  <table class="table">
      <tr>
        <th>タイトル</th>
        <td>{{ post.title }}</td>
      </tr>
      <tr>
        <th>本文</th>
        <td>{{ post.text }}</td>
      </tr>
      <tr>
        <th>日付</th>
        <td>{{ post.date }}</td>
      </tr>
  </table>
  <p>こちらのデータを削除します。</p>
  <button type="submit">送信</button>
  {% csrf_token %}
</form>
{% endblock %}
post_list.html
{% extends 'base.html' %}

{% block content %}
<table class="table">
  <thead>
    <tr>
      <th>タイトル</th>
      <th>日付</th>
      <th></th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    {% for post in post_list %}
    <tr>
      <!-- 「url 'アプリ名:逆引きURL' 渡されるモデル.pk」 という描き方 -->
      <td><a href="{% url 'blog:post_detail' post.pk %}">{{ post.title }}</a></td>
      <td>{{ post.date }}</td>
      <td>
        <!-- superuserでログインしている時にのみ表示 -->
        {% if user.is_superuser %}
        <!-- HTMLを アプリ名_モデル名_change にすると admin でそのまま編集できる -->
          <a href="{% url 'blog:post_update' post.pk %}">編集</a>
        {% endif %}
      </td>
      <td>
        {% if user.is_superuser %}
          <a href="{% url 'blog:post_delete' post.pk %}">削除</a>
        {% endif %}
      </td>
    </tr>
  {% endfor %}
  </tbody>
</table>

{% endblock %}

なお、最後の post_list.html はいくつか変更を追加で加えました。

ひとつは、一覧に表示させる各記事ごとに詳細、編集、削除のリンクをつけるようにしました。
プライマリキーに基づきリンク先が決まるような場合は 変数名.pk の形でプライマリキーを指定できます。
※templateがviewから変数を受け取る時の名前は post_list となっていますが、
 for文で回しているので post という変数で展開しています。
 そのため post.pk と指定しています。

逆引きする用URLの名前で指定する形は、決まった書き方なので覚えてしまいいましょう。

<a href="{% url 'blog:post_detail' post.pk %}">...

また、今回のチュートリアルではユーザ登録機能を実装していませんが、
誰でも記事の編集や削除をできてしまわないようにぐらいの制限はかけておきましょう。

今回はユーザは superuser しか存在していませんので、
superuser で管理画面 (127.0.0.1:8000/admin) からログインしている場合にのみ
記事の編集および削除のリンクを表示させるようにします。
※厳密にいえば、この状態でもアドレスから直接アクセスすると編集・削除画面には飛べてしまいます。
 この制限も Django で実装することはできます。念の為。

{% if user.is_superuser %} # superuser でログインしているときのみ、if文の中身を表示
  <a href="{% url 'blog:post_update' post.pk %}">編集</a>
{% endif %}

上記では superuser の時でしたが、他にも何かしらのユーザでログインしている時の表示や、
特定のユーザでログインしている時にのみ表示させるということも出来たりします。

不要なtemplateや処理の削除

さて、これまで Hello を表示させるためだけの index.html ページを練習用に残しておきましたが、
これ以上残しておくと管理の手間が増えるため、このタイミングで削除しておきましょう。

└── templates
    ├── base.html
    └── blog
        ├── index.html # これを削除する
        ├── post_confirm_delete.html
        ├── post_detail.html
        ├── post_form.html
        └── post_list.html

urls.py test_urls.py、、views.py、test_views.py も忘れずに編集しておきます。

urls.py
...
urlpatterns = [
    path('', views.IndexView.as_view(), name='index'), # ここを削除
...
test_urls.py
...
class TestUrls(TestCase):

  """index ページへのURLでアクセスする時のリダイレクトをテスト"""
  def test_post_index_url(self): # このメソッドを丸々削除
    view = resolve('/blog/')
    self.assertEqual(view.func.view_class, IndexView)

class TestUrls(TestCase):

  """index ページへのURLでアクセスする時のリダイレクトをテスト"""
  def test_post_index_url(self): # このメソッドを削除
    view = resolve('/blog/')
    self.assertEqual(view.func.view_class, IndexView)
...
views.py
...
class IndexView(generic.TemplateView): # この汎用ビューを削除
    template_name = 'blog/index.html'
...
test_views.py
...
class IndexTests(TestCase): # このテストクラスごと削除
    """IndexViewのテストクラス"""

    def test_get(self):
        """GET メソッドでアクセスしてステータスコード200を返されることを確認"""
        response = self.client.get(reverse('blog:index'))
        self.assertEqual(response.status_code, 200)
...

一気に変更を行ったので、最後にユニットテストを実行してエラーが出ないかを確認しておきましょう。

(blog) bash-3.2$ python3 manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..............E
======================================================================
ERROR: blog.tests.test_urls (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: blog.tests.test_urls
Traceback (most recent call last):
  File "/usr/local/Cellar/python@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/lib/python3.8/unittest/loader.py", line 436, in _find_test_path
    module = self._get_module_from_name(name)
  File "/usr/local/Cellar/python@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/lib/python3.8/unittest/loader.py", line 377, in _get_module_from_name
    __import__(name)
  File "/Users/masuyama/workspace/MyPython/MyDjango/blog/mysite/blog/tests/test_urls.py", line 3, in <module>
    from ..views import IndexView, PostListView
ImportError: cannot import name 'IndexView' from 'blog.views' (/Users/masuyama/workspace/MyPython/MyDjango/blog/mysite/blog/views.py)


----------------------------------------------------------------------
Ran 15 tests in 0.283s

FAILED (errors=1)
Destroying test database for alias 'default'...

エラーが確認されました。
エラーメッセージは区切り文字などを使って表示されているので、どこでエラーが起きているかも置いやすいようになっています。

エラーメッセージを追っていくと、test_urls.pyで import できないものがあり、エラーが起きていることが分かります。

ImportError: cannot import name 'IndexView' from 'blog.views' 

もう一度 test_urls.py を読んでみると、たしかに冒頭で IndexView の読み込みを残してしまっていることが分かりました。

test_urls.py
from django.test import TestCase
from django.urls import reverse, resolve
from ..views import IndexView, PostListView # この行

これを消して、次のようにしてあげます。

test_urls.py
from django.test import TestCase
from django.urls import reverse, resolve
from ..views import PostListView

これでユニットテストをもう一度実行しましょう。

(blog) bash-3.2$ python3 manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...............
----------------------------------------------------------------------
Ran 15 tests in 0.223s

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

今度はエラーなくテストが完了しました。
また、テストの数は 15 となっており、前回は 17 だったテストから2つのテストメソッドを減らしたこととも一致していますね。

これまではユニットテストが通るようにした結果しかお見せしませんでしたが、
今回のように一気に複数のファイルで変更を起きたときでも
予めユニットテストを用意しておくと、コマンド一つで問題箇所を特定することができると分かっていただけたかと思います。

これで無事にローカルの Django アプリとしては完成しました!

次回はいよいよ環境の整備というところで、今回作ったアプリを Docker 化していきましょう。

tmasuyama
もふもふエンジニア (個人開発) です。 Django、Ruby on Rails、CircleCI、AWS、Docker つかってます。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away