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

Djangoチュートリアル(ブログアプリ作成)⑥ - 記事詳細・編集・削除機能編

前回、Djangoチュートリアル(ブログアプリ作成)⑤ - 記事作成機能編では
アプリ内で記事を作成する機能を追加しました。

今回は CRUD でいうところの C(Create) が出来るようになったので、残りの機能 (詳細、編集、削除) 機能を追加していきます。

記事の詳細表示、編集、削除機能もクラスベース汎用ビューで実装していきます。

urls.py の修正

これから盛り込む機能では、各記事の固有となるキーをもとにアクセスをして編集することになります。
そのキーとなる値が各記事のプライマリキーです。

Django のクラスベース汎用ビューの機能を使うと、urls.py 内でのルーティング設定において
各記事のプライマリキーint:pk という文字列で URL パターンを指定することができます。

例えば詳細表示機能でいうと 'blog/post_detail/int:pk' というURLパターンを設定するだけで
作られた記事をプライマリキーで判別し、詳細・編集・削除を司る View へのルーティングが可能になります。

view クラスの名前はそれぞれ下記の名前で設定していきます。

  • 詳細:PostDetailView
  • 編集:PostUpdateView
  • 削除:PostDeleteView

この時、urls.py は次のようになります。

urls.py
from django.urls import path
from . import views

app_name = 'blog'

urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('post_list', views.PostListView.as_view(), name='post_list'),
    path('post_create', views.PostCreateView.as_view(), name='post_create'),
    path('post_detail/<int:pk>/', views.PostDetailView.as_view(), name='post_detail'), # 追加 (例) /blog/detail/1 ※特定のレコードに対して処理を行うので pk で識別
    path('post_update/<int:pk>/', views.PostUpdateView.as_view(), name='post_update'), # 追加 (例) /blog/update/1 ※同上
    path('post_delete/<int:pk>/', views.PostDeleteView.as_view(), name='post_delete'), # 追加 (例) /blog/delete/1 ※同上
]

各記事の detail, update, delete についてはスラッシュ区切りのプライマリキーで指定していることが分かるかと思います。
これで、例えば一番最初の記事の詳細であれば /blog/post_detail/1/ でアクセスできるようになります。
(ローカルホストであれば 127.0.0.1:8000/blog/post_detail/1 という URL でブラウザからアクセスできます)

views.py の修正

続けて、各ビューを作成していきます。
先に完成形をお見せします。

views.py
...
class PostDetailView(generic.DetailView): # 追加
    model = Post  # pk(primary key)はurls.pyで指定しているのでここではmodelを呼び出すだけで済む

class PostUpdateView(generic.UpdateView): # 追加
    model = Post
    form_class = PostCreateForm # PostCreateFormをほぼそのまま活用できる
    success_url = reverse_lazy('blog:post_detail')
class PostDeleteView(generic.DeleteView): # 追加
    model = Post
    success_url = reverse_lazy('blog:post_list')

PostDetailView の解説

コード量の少なさに驚いたのではないでしょうか?
特にプライマリキーのことなどは記述する必要がなく、汎用ビューの力のおかげでたったこれだけで詳細ページを作成できるようになります。
また、template を post_detail.html というファイル名にしてあげることで自動的に識別してくれるようになります。

PostUpdateView

実は UpdateView は、新規作成時と同じフォームをそのまま使えるという特徴もあり CreateView と酷似しています。

python:views.py の解説
...
class PostCreateView(generic.CreateView):
model = Post
form_class = PostCreateForm
success_url = reverse_lazy('blog:post_list')
...

違うのは、編集成功時の success_url を UpdateView では記事詳細画面としているぐらいですが、
これも記事一覧 (post_list) と同じでよければ全く中身は同じコートでいけてしまいます。

また、実は PostCreate で使っている post_form.html をそのまま流用することができるため、
template を新たに用意する必要はありません。

PostDetailView の解説

削除では、入力フォームを用いるわけではないのでフォームの呼び出しは必要ありません。
ただし、Update とは違う点で2つ注意が必要になります。

ひとつは、template 名は post_confirm_delete.html とすることです。
これまでの流れから post_delete.html としてしまいそうになりますが、
アクセスしてもいきなり削除するわけではないことから少し名前の定義が異なります。

もう一つは、削除成功時のリダイレクト先です。
UpdateView では一覧画面か詳細画面、どちらでもよかったのですが
削除をしてしまうとアクセス対象の記事がなくなってしまうので、詳細画面 (post_detail) にはリダイレクトさせないように注意してください。

template の準備

では views.py でも少し触れた通り、template/blog/ 配下に詳細、削除用の template (html) を作ります。

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

では、まずは post_detail.html を編集していきます。

post_detail.html
<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>

post という変数を template 側で受け取って {{ post.title }} & {{ post.text }} & {{ post.date }} で表示させています。
なお post.text 後にある 「| linebreaksbr」 を入れると、記事本文のように長いものでも自動で改行してくれるようになります。

次に post_confirm_delete.html を編集します。

post_confirm_delete.html
<form action="" method="POST">
  <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>

最初の行では、記事の削除という POST メソッドを使うため、form では POST メソッドを指定しています。
POST 送信先など、処理は view 側で調整をしてくれるので template 側では記述する必要はありません。
最後に CSRF 対策で {% csrf_token %} を記述することにも注意しましょう。

これで template の編集は終了です。

ユニットテストの作成

先に追加

test_views.py
...
class PostDetailTests(TestCase): # 追加
    """PostDetailView のテストクラス"""

    def test_not_fount_pk_get(self):
        """記事を登録せず、空の状態で存在しない記事のプライマリキーでアクセスした時に 404 が返されることを確認"""
        response = self.client.get(
            reverse('blog:post_detail', kwargs={'pk': 1}),
        )
        self.assertEqual(response.status_code, 404)

    def test_get(self):
        """GET メソッドでアクセスしてステータスコード200を返されることを確認"""
        post = Post.objects.create(title='test_title', text='test_text')
        response = self.client.get(
            reverse('blog:post_detail', kwargs={'pk': post.pk}),
        )
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, post.title)
        self.assertContains(response, post.text)

class PostUpdateTests(TestCase): # 追加
    """PostUpdateView のテストクラス"""

    def test_not_fount_pk_get(self):
        """記事を登録せず、空の状態で存在しない記事のプライマリキーでアクセスした時に 404 が返されることを確認"""
        response = self.client.get(
            reverse('blog:post_update', kwargs={'pk': 1}),
        )
        self.assertEqual(response.status_code, 404)

    def test_get(self):
        """GET メソッドでアクセスしてステータスコード200を返されることを確認"""
        post = Post.objects.create(title='test_title', text='test_text')
        response = self.client.get(
            reverse('blog:post_update', kwargs={'pk': post.pk}),
        )
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, post.title)
        self.assertContains(response, post.text)

class PostDeleteTests(TestCase): # 追加
    """PostDeleteView のテストクラス"""

    def test_not_fount_pk_get(self):
        """記事を登録せず、空の状態で存在しない記事のプライマリキーでアクセスした時に 404 が返されることを確認"""
        response = self.client.get(
            reverse('blog:post_delete', kwargs={'pk': 1}),
        )
        self.assertEqual(response.status_code, 404)

    def test_get(self):
        """GET メソッドでアクセスしてステータスコード200を返されることを確認"""
        post = Post.objects.create(title='test_title', text='test_text')
        response = self.client.get(
            reverse('blog:post_delete', kwargs={'pk': post.pk}),
        )
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, post.title)
        self.assertContains(response, post.text)

GET メソッドで 200 が返ってくることを確認するあたりはこれまでのユニットテストと大きく変わりはありませんが、
存在はずの記事に reponse を得るあたりの処理が大きく違っています。

...
def test_not_fount_pk_get(self):
    """記事を登録せず、空の状態で存在しない記事のプライマリキーでアクセスした時に 404 が返されることを確認"""
    response = self.client.get(
        reverse('blog:post_delete', kwargs={'pk': 1}),
    )
    self.assertEqual(response.status_code, 404)
...

ユニットテストの中でデータベースにデータを登録(ここでいえば記事を作成)できることは既にお伝えしました。
しかし、記事が存在していない状態でプライマリキーが 1 の記事=最初の記事の詳細・編集・削除画面へアクセスしようとすると
当然ページは存在していないので、ページが存在していないことを示す HTTPステータスコードの 404 を返してきます。

上記のテストメソッドでは、あえて存在しない記事の個別ページへアクセスして操作をしようとしても
エラーが返ってくることを確認しています。

これでユニットテストを実行すると、エラーもなく通るはずです。

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

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

これで CRUD 機能を無事に追加することができたため、大きな機能としてはおしまいです。

次は template を修正し、全画面に共通のナビゲーションバーをつけることで全ページで表示させていきましょう。

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

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