【Django】ブログアプリを作ろう!2
はじめに
この記事を書くきっかけは、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_DIR
はmanage.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.py
でMEDIA_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
でカラムを増やしたので、フォームでもフィールドを増やしてあげましょう。
fields
とlabels
を以下のように書き換えてください。
# 登録するカラムの指定
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 行で書こうと思えば書けますが、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
- 画像を一時保存ディレクトリに書き出す
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)
.replace
でBASE_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 文の外でセッションをクリアして、この関数は終わりです。
記事の一覧・登録・詳細・編集画面
ArticlesListView
、ArticleCreateView
、ArticleDetailView
、ArticleUpdateView
クラスのそれぞれの末尾に、以下を追記します。
# 一時保存用ディレクトリとセッションを削除
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 %}
テスト
- 実装機能の洗い出し
前回と同じく、箇条書きで洗い出しましょう。と言いつつ、冒頭で既に出しているので再掲になります。今回実装した機能は以下の通りです。
- 記事の一覧画面( / と /articles)
- アイキャッチ画像の表示
- 記事の登録画面(/articles/create)
- 画像の入力フォーム
- 画像のバリデーション
- 必須
- 画像サイズ
- 拡張子
- 記事の登録内容確認画面(/articles/create_confirm)
- 画像の表示
- 記事の編集画面(/articles/{id}/edit)
- 画像の編集フォーム
- 編集対象の画像が表示されている
- 画像のバリデーション
- 必須
- 画像サイズ
- 拡張子
- 画像の編集フォーム
- 記事の編集内容確認画面(/articles/{id}/edit_confirm)
- 画像の表示
- 記事の詳細画面(/articles/{id})
- アイキャッチ画像の表示
- 記事の削除確認画面(/articles/{id}/delete_confirm)
- 削除対象のアイキャッチ画像を表示
- 削除実行時に画像ファイルも削除
- テストケースの洗い出し
洗い出した機能に求められる要件を洗い出しましょう。スプレッドシートなどでまとめるとリストアップもテストもしやすいと思います。
終わり!
テストも無事終了したら、ブログアプリ作成完了です。
お疲れ様でした!
(自分向けの)ヒント
Docker の起動
- docker イメージをビルドする
docker compose build --no-cache
- コンテナの構築&起動
docker compose up -d
- マイグレーション
docker exec -it django-web-1 bash
python3 manage.py makemigrations articles
python3 manage.py migrate
- ウィジェットインストール(多分必要…?)
pip install django-widgets-improved
- 実行画面の確認
open http://localhost:8000
管理者ユーザーの作成
-
http://localhost/admin にアクセス
-
ターミナルにて以下のコマンドを実行
python3 manage.py createsuperuser
-
適当に以下の名前、メアド、パスワードを設定
- admin
- admin@sample.com
- adminpass
-
y
を入力すればそのまま登録できる
Docker の終了
- コンテナを終了&削除
docker compose down
- イメージの削除(念のため)
docker rmi $(docker images -q)
- ボリュームの削除(念のため)
docker volume rm django_db-store
挙動がおかしいとき
docker logs [コンテナ名]
でログを見よう!
参考
- [Django] ファイルアップロード機能の使い方 [基本設定編] #Python - Qiita
- [Django] Form の Validation の方法と設定箇所まとめ。ValidationError の動作確認方法や FormClass,ModelClass での検証方法 | Libproc
- Django で画像及びファイルをアップロードする方法【ImageField と FileField】【python-magic で MIME の判定あり】 - 自動化無しに生活無し
- 【django-cleanup】画像等のファイルを自動的に削除する - 自動化無しに生活無し
- python - Shell saying that django_cleanup module I've installed don't exist - Stack Overflow
- 【Django】セッション Session とは:使用方法(保存や読み込み)と各種設定について | OFFICE54
- 【Python】open()の mode について - プログラミング勉強の備忘録
- Django/Python での chunk/.chunks()とは何を意味しているのでしょうか。
- Python でディレクトリを(存在するかどうか確かめて存在しなかったら)作成する #Python - Qiita
- python - django request.session.get("name", False) - What does this code mean? - Stack Overflow
- Python でファイル・ディレクトリを削除する os.remove, shutil.rmtree など | note.nkmk.me
- django - Explain return super().get(request, *args, **kwargs) - Stack Overflow
- 【完全版】Django form(フォーム)の表示・保存・編集
- enctype='multipart/form-data'ってなんだ? - MUGENUP 技術ブログ
- [Q&A] Django Form.is_valid()が常に False となる。 - Qiita
- 【Django】Session の使い方(基本編) | idealive tech blog
- django - django2.1.1 で画像投稿がうまくできない。 - スタック・オーバーフロー
- DjangoAdmin で forms.ImageField の初期値を models.ImageField のように | fragment
- はじめての Django (7) 画像データの管理やページへの表示,アップロードの方法などについて知ろう #Python - Qiita
- [Q&A] [Django]アドレスバーの URL が更新されない問題について - Qiita
- Django で action=""で遷移した後のページで、context を受け取りたい
- Django でブラウザバック時のフォーム内容の保持
- Django でフォームの値を設定する方法 - 知的好奇心
- Django 前回のフォーム入力値を保持・出力する – igreks 開発日記
- python - アップロードファイルが確認画面への POST 後消えてしまう - スタック・オーバーフロー
- Django で Form のエラーメッセージをテンプレートで取得する方法 | エンジニアの眠れない夜
Discussion