😸

【Django】ブログアプリを作ろう!2

2024/09/23に公開

はじめに

この記事を書くきっかけは、Django の勉強を始めて、いざブログアプリを作ろうとしたらどうにもうまくいかなかったことにあります。
初学者なので間違っている部分、効率の悪い部分などあるかもしれませんがご了承ください。
勉強しながら、ブログアプリを作りながらこれを書いているので、おそらくなぞっていけば私と同じように進められるはずです。

こちらの記事の続編です。一度完成まで持っていったブログアプリに、新しい機能をつけてみましょう!

要件

今回追加する機能は画像アップロード機能です。アイキャッチ画像があると華やかですからね。

追加で作りたいブログアプリのページ・機能は以下の通りです。

  • 記事の一覧画面( / と /articles)
    • アイキャッチ画像の表示
  • 記事の登録画面(/articles/create)
    • 画像の入力フォーム
    • 画像のバリデーション
      • 必須
      • 画像サイズ
      • 拡張子
  • 記事の登録内容確認画面(/articles/create_confirm)
    • 画像の表示
  • 記事の編集画面(/articles/{id}/edit)
    • 画像の編集フォーム
      • 編集対象の画像が表示されている
    • 画像のバリデーション
      • 必須
      • 画像サイズ
      • 拡張子
  • 記事の編集内容確認画面(/articles/{id}/edit_confirm)
    • 画像の表示
  • 記事の詳細画面(/articles/{id})
    • アイキャッチ画像の表示
  • 記事の削除確認画面(/articles/{id}/delete_confirm)
    • 削除対象のアイキャッチ画像を表示
    • 削除実行時に画像ファイルも削除

アイキャッチ画像は以下の条件とします。

  • 画像サイズ:横 1,200px × 縦 630px
  • 拡張子:png か gif のみ

Docker の準備

仮想環境を Docker でやっているので、その準備です。
他の環境をお使いの方は、そちらに合わせて読み替えてください。

ディレクトリ構成

ディレクトリ構成の確認をしましょう。
前回の内容を終えた時点で、以下のようになっているはずです。

Django
    ┣ articles
    ┃  ├ migrations
    ┃  │  ├ __init__.py
    ┃  │  └ 0001_initial.py
    ┃  ├ templates
    ┃  │  └ articles
    ┃  │     ├ base.html
    ┃  │     ├ articles_list.html
    ┃  │     ├ article_create.html
    ┃  │     ├ article_create_confirm.html
    ┃  │     ├ article_detail.html
    ┃  │     ├ article_edit.html
    ┃  │     ├ article_edit_confirm.html
    ┃  │     └ article_delete.html
    ┃  ├ __init__.py
    ┃  ├ admin.py
    ┃  ├ apps.py
    ┃  ├ forms.py
    ┃  ├ models.py
    ┃  ├ urls.py
    ┃  └ views.py
    ┣ blog
    ┃  ├ __init__.py
    ┃  ├ asgi.py
    ┃  ├ settings.py
    ┃  ├ urls.py
    ┃  └ wsgi.py
    ┣ manage.py
    ┗ requirements.txt

今回の内容を終えると、以下のディレクトリ構成になりますので、さらっと確認しておきましょう。

Django
    ┣ articles
    ┃  ├ migrations
    ┃  │  ├ __init__.py
    ┃  │  ├ 0001_initial.py
    ┃  │  └ 0002_articles_image.py
    ┃  ├ templates
    ┃  │  └ articles
    ┃  │     ├ base.html
    ┃  │     ├ articles_list.html
    ┃  │     ├ article_create.html
    ┃  │     ├ article_create_confirm.html
    ┃  │     ├ article_detail.html
    ┃  │     ├ article_edit.html
    ┃  │     ├ article_edit_confirm.html
    ┃  │     └ article_delete.html
    ┃  ├ __init__.py
    ┃  ├ admin.py
    ┃  ├ apps.py
    ┃  ├ forms.py
    ┃  ├ models.py
    ┃  ├ urls.py
    ┃  └ views.py
    ┣ blog
    ┃  ├ __init__.py
    ┃  ├ asgi.py
    ┃  ├ settings.py
    ┃  ├ urls.py
    ┃  └ wsgi.py
    ┣ media
    ┣ manage.py
    ┗ requirements.txt

設定まわり

ライブラリのインストール

画像保存のライブラリと、ファイルの削除処理のためのライブラリをインストールしましょう!

ターミナルで以下を実行してください。

pip install Pillow
pip install django-cleanup

続いて、requirements.txtに以下を追記してください。

Pillow
django-cleanup

settings.py

settings.pyを編集しましょう。INSTALLED_APPSに以下を追記します。

INSTALLED_APPS = [
    'django_cleanup.apps.CleanupConfig',
        ・
        ・
        ・
]

続いて、適当な場所に以下を追記します。

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

画像を保存する場所を指定してあげます。

BASE_DIRmanage.pyのあるディレクトリを指しています。

model の定義

続いてmodels.pyを編集しましょう。

カラム

アイキャッチ画像を追加したカラムは以下としましょう。

カラム 概要 オプション
title タイトル 文字数制限:50
article_body 本文 文字数制限:10,000
image 画像 拡張子制限:png/gif
サイズ制限:1200×630
created_at 作成日時 現在日時を入れる
updated_at 更新日時 現在日時を入れる

models.py

上記のカラムを実現するために、以下のモジュールをインポートしておきます。

from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator

ValidationErrorはバリデーションエラー時の文言表示用で、FileExtensionValidatorは拡張子のバリデーション用です。

インポートを書いたら、画像サイズのバリデーションを行う関数を定義しましょう。

def validate_bad_size(image):
    if not (image.width == 1200 and image.height == 630):
        raise ValidationError('画像サイズは 1200px × 630px にしてください')

これで、画像サイズが 1200×630 じゃないとき、エラー文を出してくれるようになります。

続いてカラムの追加をしましょう。Articles クラスに追記してください。

class Articles(models.Model):
    image = models.ImageField(
        verbose_name='アイキャッチ画像',
        validators=[FileExtensionValidator(['png', 'gif']), validate_bad_size]
    )

settings.pyMEDIA_ROOTに指定したディレクトリ下に画像が保存されていきます。

マイグレーション

私と同じく docker を利用している方は、最初に以下のコマンドを打って、コンテナ内に入りましょう。

docker exec -it django-web-1 bash

マイグレーションフォルダがない場合は以下を実行してください。記事通りに進めていればおそらくこの実行は不要だとは思いますが。

python3 manage.py makemigrations articles

以下のコマンドを実行して、models.pyに書いた内容を反映させましょう!

python3 manage.py migrate

OK がたくさん出てきたら完了です!

今までのデータが残っていてエラーが出てしまう場合は、以下をmodels.pyの画像のカラムの部分に以下を追記して、

null=True

こうしてやれば、エラーが出なくなると思います。

image = models.ImageField(
    verbose_name='アイキャッチ',
    upload_to='%Y/%m/%d/',
    validators=[FileExtensionValidator(['png', 'gif']), validate_bad_size],
    null=True
    )

ルーティング

blog/urls.py

必要なものをインポートします。以下を追記してください。

from django.conf import settings
from django.conf.urls.static import static

続いて、urlpatternsに画像関連の URL を増やしたいので、以下を末尾に追加します。

urlpatterns += static(
    settings.MEDIA_URL,
    document_root=settings.MEDIA_ROOT
)

forms.py

models.pyでカラムを増やしたので、フォームでもフィールドを増やしてあげましょう。

fieldslabelsを以下のように書き換えてください。

# 登録するカラムの指定
fields = ['title', 'image', 'article_body']
# ラベルを設定
labels = {
    'title': 'タイトル',
    'image': 'アイキャッチ',
    'article_body': '記事本文',
}

views.py

views.pyに追記をしていきます。

まずは新たに色々インポートしましょう。以下を適当な場所に書き加えてください。

import os
import shutil

from django.conf import settings

一時保存関連の関数

アップロードした画像を確認画面で表示するために、画像ファイルをどこかに一時保存する必要があります。
その後、登録ボタンが押されたタイミングで画像を保存するディレクトリにファイルを移動させ、データベースに登録する形になります。

そのため、これらの処理を関数にまとめて各クラスで使えるようにしてあげましょう。

記載場所はviews.pyの末尾で大丈夫です。

アップロードされた画像を一時保存ディレクトリに格納してセッションに渡す

def move_image_to_tmp_and_add_session(request):

このような名前の関数を定義していきます。

  1. セッションに諸々を追加していく

まずは画像のファイル名をセッションに渡してあげます。1 行で書こうと思えば書けますが、image_nameという変数に入れてあげれば、そのまま後で使えるので 2 行で書きましょう。

image_name = str(request.FILES["image"])
request.session['image_name'] = image_name

一時保存用ディレクトリのパスを渡してあげます。

tmp_path = os.path.join(str(settings.MEDIA_ROOT), 'tmp')
os.makedirs(tmp_path, exist_ok=True)
request.session['tmp_path'] = tmp_path

引数にexist_ok=Trueを指定すると、そのディレクトリが既存でもエラーにならず、そこをそのまま指定してくれます(Python3.2 以降に実装されたものなので、古い Python を使っている方は if 分岐が必要になります)。

画像の一時保存先パスを渡してあげます。

saved_tmp_path = os.path.join(tmp_path, image_name)
request.session['saved_tmp_path'] = saved_tmp_path
  1. 画像を一時保存ディレクトリに書き出す
with open(saved_tmp_path, 'wb+') as image:
    for chunk in request.FILES['image'].chunks():
        image.write(chunk)

openの引数wb+は、バイナリ形式でファイルの読み書きをすることを指しています(ざっくりと)。

chunksメソッドは指定チャンク(デフォルトは 64KB)ごと読み出すメソッドです。

確認画面で表示したい画像のパスをcontextに追加

これまでcontextという変数にフォームの内容を格納して、確認画面で表示していました。ここに画像のパスを追加する関数を定義します。

def add_image_path_to_context(self, context):

contextの中身を読み込めるようにしてあげます。

saved_tmp_pathから不要な部分(BASE_DIR)を削除したパスをcontextに格納します。

context['show_image_path'] = self.request.session[
    'saved_tmp_path'
].replace(str(settings.BASE_DIR), '', 1)

.replaceBASE_DIRを空文字にして、削除しています。

return context

最後にcontextを返します。

これで、この関数を呼び出した後のcontextには、画像のパスが入ることになります。

一時保存用ディレクトリとセッションを削除

def clear_tmp_and_session(request):

という関数を定義します。

if request.session.get('tmp_path', False) and os.path.isdir(
    request.session['tmp_path']
    ):
    shutil.rmtree(request.session['tmp_path'])

request.session.getでセッションからデータを持ってくることができます。Falseはデータを持って来られなかったときの値だと思います。違っていたらすみません。

andでディレクトリの有無も条件にします。

どちらもあれば、shutil.rmtreeで指定したパスの中身ごとディレクトリを削除します。

request.session.clear()

if 文の外でセッションをクリアして、この関数は終わりです。

記事の一覧・登録・詳細・編集画面

ArticlesListViewArticleCreateViewArticleDetailViewArticleUpdateViewクラスのそれぞれの末尾に、以下を追記します。

# 一時保存用ディレクトリとセッションを削除
def get(self, request, *args, **kwargs):
    clear_tmp_and_session(request)
    return super().get(request, *args, **kwargs)

記事の登録内容確認画面

ArticleCreateConfirmクラスのpost関数を編集していきます。

def post(self, request, *args, **kwargs):
    form = ArticleForm(request.POST or None, request.FILES or None)

まずはファイルを受け付けるため、上記のようにrequest.FILES or Noneを追記します。

その下は、ざっくりとこのような構造になっているはずです。

if 'confirm' in request.POST:
    ・・・
if 'back' in request.POST:
        ・・・
if 'create' in request.POST:
    if form.is_valid():
        ・・・
    else:
        ・・・

どう分岐しているかを、あらためて確認しましょう。

  • confirm = 確認 の POST があった場合
  • back = 戻る の POST があった場合
  • create = 作成 の POST があった場合
    • バリデーションの結果、正常値(True)だった場合
    • バリデーションの結果、異常値(False)だった場合

という分岐が設定されています。

この分岐の中を編集していきます。

confirm

if 'confirm' in request.POST:
    # フォームのバリデーションを実行
    if form.is_valid():
        # アップロードされた画像を一時保存ディレクトリに格納してセッションに渡す
        move_image_to_tmp_and_add_session(request)
        # 確認画面で表示したい画像のパスをcontextに追加
        context = add_image_path_to_context(self, context)
        return render(
            request,
            'articles/article_create_confirm.html',
            context
        )
    else:
        context['not_error'] = False
        return render(
            request,
            'articles/article_create.html',
            context
        )

back

if 'back' in request.POST:
    context['not_error'] = True
    clear_tmp_and_session(request)
    return render(
        request,
        'articles/article_create.html',
        context
        )

create

POST されたものを一旦格納します。

new_article = Articles.objects.create(
    image='',
    title=request.POST['title'],
    article_body=request.POST['article_body'],
)

画像は格納するのにパスを直す必要があるので、空欄で大丈夫です。

画像のパスを設定します。

path = os.path.join(
    str(settings.MEDIA_ROOT),
    str(new_article.id)
)
os.makedirs(path, exist_ok=True)
shutil.move(request.session['saved_tmp_path'], path)
new_article.image = os.path.join(
    str(new_article.id),
    request.session['image_name']
)

保存して、一時ファイルを削除、リダイレクトをします。

new_article.save()
clear_tmp_and_session(request)
return redirect('articles:articles_list')

記事の編集内容確認画面

ArticleUpdateConfirmViewクラスのpost関数を編集していきます。

def post(self, request, *args, **kwargs):
    article_instance = get_object_or_404(Articles, pk=kwargs['pk'])
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article_instance)

まずはファイルを受け付けるため、上記のようにrequest.FILES or Noneを追記します。

その下は、記事登録確認画面と同じように、ざっくりとこのような構造になっているはずです。

if 'confirm' in request.POST:
    ・・・
if 'back' in request.POST:
        ・・・
if 'create' in request.POST:
    if form.is_valid():
        ・・・
    else:
        ・・・

この分岐の中を編集していきます。

confirm

選択された記事を一時格納します。

old_article = Articles.objects.get(pk=kwargs['pk'])

if 文で分岐処理を行います。

  • バリデーションで弾かれた場合

    if not form.is_valid():
        context['article'] = old_article
        return render(
            request,
            'articles/article_edit.html',
            context
        )
    
  • 画像が変更された場合

    elif self.request.FILES.get('image', False):
        # アップロードされた画像を一時保存ディレクトリに格納してセッションに渡す
        move_image_to_tmp_and_add_session(request)
        # 確認画面で表示したい画像のパスをcontextに追加
        context = add_image_path_to_context(self, context)
    
  • 画像に変更がなかった場合

    else:
        context['show_image_path'] = old_article.image.url
    

最後にreturnを書きます。

return render(
    request,
    'articles/article_edit_confirm.html',
    context
    )

back

context['not_error'] = True
# 一時保存用ディレクトリとセッションを削除
clear_tmp_and_session(request)
return render(
    request,
    'articles/article_edit.html',
    context
    )

戻った時エラーを吐かれないよう、値をcontextに追加

create

  • if form.is_valid():

    アイキャッチ画像の更新がある場合とない場合で分岐します。まず、ある場合。

    if 'saved_tmp_path' in request.session:
        # POSTされたものを一旦格納
        article = Articles.objects.get(pk=kwargs['pk'])
    

    POST されたものを一旦articleという変数に格納します。

    次に古い画像ファイルを削除します。

    remove_image_path = os.path.join(
        str(settings.MEDIA_ROOT),
        str(article.image)
    )
    os.remove(remove_image_path)
    

    続いてアップロードされた新しい画像を/media/idに移動させます。

    str_id = str(kwargs['pk'])
    path = os.path.join(str(settings.MEDIA_ROOT), str_id)
    shutil.move(request.session['saved_tmp_path'], path)
    

    最後に全てを更新して、一時保存のディレクトリとセッションを削除します。

    # 更新
    article.image = os.path.join(
        str_id,
        request.session['image_name']
    )
    article.title = request.POST['title']
    article.article_body = request.POST['article_body']
    article.save()
    
    # 一時保存用ディレクトリとセッションを削除
    clear_tmp_and_session(request)
    

    画像の更新がなかった場合は、これまで書いていたform.save()を持ってくれば OK です。

    else:
        form.save()
    

    一覧ページへのリダイレクトを返して終わりです。

    return redirect('articles:articles_list')
    
  • else:

    前回から変える必要はありませんが、再掲だけしておきます。

    return render(
        request,
        'articles/article_edit.html',
        context
    )
    

記事の削除確認画面

レコード削除と同時にアイキャッチ画像のファイル自体も削除するためのdjango.cleanupライブラリが入っているので、何も追記しなくて大丈夫です。

テンプレート

続いてテンプレートも更新していきましょう!

記事の一覧画面 articles_list.html

アイキャッチ画像を表示する場所を作ります。表見出しとセルを以下のようにしてみてください。

タイトル 本文 作成日時 最終更新日時
{% for article in articles_list %} {% if article.image %} {% endif %} {{ article.title }} {{ article.article_body }} {{ article.created_at }} {{ article.updated_at }} 詳細 {% endfor %}

記事の登録画面 article_create.html

formタグの上に、エラーを表示させる場所を作ります。

{% if form.errors and not_error is False %} {{ form.errors }} {% endif %}

エラーがない場合は不要なエリアなので、if 文で囲みましょう。
ちなみにnot_error is Falseがないと、確認画面から戻ってきた時にもエラーを吐かれてしまいます。

文字以外のファイル(=画像)を送信したいので、formタグに以下の記述を追加します。

enctype="multipart/form-data"

このままinputを増やしてもいいのですが、せっかくなのでループ処理に変えてみましょう。

これだけで 3 つのinputを生成してくれます。

{% for field in form %} {{ field.label }} {% render_field field class="form-control" %} {% endfor %}

記事の登録内容確認画面 article_create_confirm.html

画像の表示エリアを作ってあげます。

{% extends 'articles/base.html' %} {% block content %} 投稿内容確認 以下の内容で投稿します {{ form.title.value }} {{ form.article_body.value }} 文字数: {{ form.article_body.value | length }} {%
csrf_token %} {% for field in form %} {{ field.as_hidden }} {% endfor %} {% endblock %}

記事の詳細画面 article_detail.html

同じく、画像の表示エリアを作ります。

{% extends 'articles/base.html' %} {% block content %} 投稿詳細 {{ article.title }} {{ article.article_body }} 最終更新日時:{{ article.updated_at }} 作成日時:{{ article.created_at }} 編集 削除 {%
endblock %}

記事の編集画面 article_edit.html

記事作成画面と同じく、フォームを for 文で書いてみましょう。

{% extends 'articles/base.html' %} {% load widget_tweaks %} {% block content %} 投稿を編集 {% if form.errors %} {{ form.errors }} {% endif %} {% csrf_token %} {% for field in form %} {{ field.label }}
{% render_field field class="form-control" %} {% endfor %} {% endblock %}

記事の編集内容確認画面 article_edit_confirm.html

画像の表示エリアを作ってあげます。

{% extends 'articles/base.html' %} {% block content %} 編集内容確認 以下の内容に更新します {{ form.title.value }} {{ form.article_body.value }} 文字数: {{ form.article_body.value | length }} {%
csrf_token %} {% for field in form %} {{ field.as_hidden }} {% endfor %} {% endblock %}

記事の削除確認画面 article_delete.html

最後も、画像の表示エリアの作成です。

投稿削除確認

以下の投稿を削除します






{{ article_delete.title }}



{{ article_delete.article_body }}



            最終更新日時:{{ article_delete.updated_at }}

            作成日時:{{ article_delete.created_at }}




    {% csrf_token %}

    戻る

{% endblock %}

テスト

  1. 実装機能の洗い出し

前回と同じく、箇条書きで洗い出しましょう。と言いつつ、冒頭で既に出しているので再掲になります。今回実装した機能は以下の通りです。

  • 記事の一覧画面( / と /articles)
    • アイキャッチ画像の表示
  • 記事の登録画面(/articles/create)
    • 画像の入力フォーム
    • 画像のバリデーション
      • 必須
      • 画像サイズ
      • 拡張子
  • 記事の登録内容確認画面(/articles/create_confirm)
    • 画像の表示
  • 記事の編集画面(/articles/{id}/edit)
    • 画像の編集フォーム
      • 編集対象の画像が表示されている
    • 画像のバリデーション
      • 必須
      • 画像サイズ
      • 拡張子
  • 記事の編集内容確認画面(/articles/{id}/edit_confirm)
    • 画像の表示
  • 記事の詳細画面(/articles/{id})
    • アイキャッチ画像の表示
  • 記事の削除確認画面(/articles/{id}/delete_confirm)
    • 削除対象のアイキャッチ画像を表示
    • 削除実行時に画像ファイルも削除
  1. テストケースの洗い出し

洗い出した機能に求められる要件を洗い出しましょう。スプレッドシートなどでまとめるとリストアップもテストもしやすいと思います。

終わり!

テストも無事終了したら、ブログアプリ作成完了です。

お疲れ様でした!

(自分向けの)ヒント

Docker の起動

  1. docker イメージをビルドする
docker compose build --no-cache
  1. コンテナの構築&起動
docker compose up -d
  1. マイグレーション
docker exec -it django-web-1 bash

python3 manage.py makemigrations articles
python3 manage.py migrate
  1. ウィジェットインストール(多分必要…?)
pip install django-widgets-improved
  1. 実行画面の確認
open http://localhost:8000

管理者ユーザーの作成

  1. http://localhost/admin にアクセス

  2. ターミナルにて以下のコマンドを実行

python3 manage.py createsuperuser
  1. 適当に以下の名前、メアド、パスワードを設定

  2. yを入力すればそのまま登録できる

Docker の終了

  1. コンテナを終了&削除
docker compose down
  1. イメージの削除(念のため)
docker rmi $(docker images -q)
  1. ボリュームの削除(念のため)
docker volume rm django_db-store

挙動がおかしいとき

docker logs [コンテナ名]

でログを見よう!

参考

Discussion