Django超初心者シリーズ

9回 / 全10

【Django ModelForm入門】記事の作成・編集・削除機能をWeb上で実装する方法

2025/07/26に公開

はじめに:管理画面以外で記事を管理したい

これまで8回にわたってDjangoの様々な機能を学んできました。ブログ記事の表示、テンプレートの継承、データベース操作、検索機能、そしてクラスベースビュー...

でも、ちょっと待ってください。記事を作成・編集するときは、いつも管理画面(/admin/)を使っていましたよね?

「普通のブログサービスみたいに、Web画面から記事を投稿したい!」

そう思ったあなたは正しいです。今回は、ModelFormを使って、この願いを実現します!

今回作る機能のデモ

完成すると、こんなことができるようになります:

  1. 記事の新規作成:Web画面から記事を投稿
  2. 記事の編集:既存の記事を修正
  3. 記事の削除:不要な記事を削除(確認画面付き)
  4. 成功メッセージ:操作完了をユーザーに通知

つまり、CRUD(Create, Read, Update, Delete) 操作すべてをWeb上で実現します!

Note

初めてこの記事を読む方へ:

この記事は連載の第9回目です。クラスベースビューの基本がまだの方は、第8回の記事をご覧ください。もしすぐにModelFormの実装を始めたい方は、前回GitHubコードを参考にしながら進めることもできます。

この記事で学べること

  • ModelFormとforms.Formの違い
  • CreateViewを使った記事作成機能
  • UpdateViewを使った記事編集機能
  • DeleteViewを使った記事削除機能
  • Django messagesフレームワークの使い方
  • フォームのカスタマイズとバリデーション
  • CRUD操作のベストプラクティス

それでは、本格的なブログシステムを完成させてみましょう!

ModelFormとは?forms.Formとの違い

forms.Formの復習

第7回で学んだforms.Formを覚えていますか?検索フォームやコメントフォームで使いました:

# モデルの定義(models.py) class Comment(models.Model): name = models.CharField(max_length=50, verbose_name="名前") email = models.EmailField(verbose_name="メールアドレス") content = models.TextField(verbose_name="コメント内容") # 他のフィールド... # フォームの定義(forms.py)- forms.Formを使った場合 class CommentForm(forms.Form): name = forms.CharField(label="お名前", max_length=50) email = forms.EmailField(label="メールアドレス") content = forms.CharField(label="コメント", widget=forms.Textarea)

見てください!同じようなフィールド定義を2箇所で書いています:

  1. models.pyCharFieldEmailFieldTextFieldを定義
  2. forms.pyでもCharFieldEmailFieldCharFieldを定義

さらに、このフォームを使ってコメントを保存するには、手動でデータを移す必要がありました:

if form.is_valid(): # フォームのデータを一つずつ取り出して、モデルに設定 comment = Comment( name=form.cleaned_data["name"], # フォーム → モデル email=form.cleaned_data["email"], # フォーム → モデル content=form.cleaned_data["content"], # フォーム → モデル post=post ) comment.save()

つまり、モデルで定義したフィールドと同じものを、フォームでも定義し直しているんです。これは無駄が多いですよね!

ModelFormの登場

ModelFormを使えば、モデルから自動的にフォームを生成できます:

# ModelFormの例 class CommentForm(forms.ModelForm): class Meta: model = Comment fields = ["name", "email", "content"]

たったこれだけ!フィールドの型も自動的に判定されます。

使い分けの基準

状況使うべきフォーム理由
モデルと1対1で対応ModelFormフィールド定義の重複を避けられる
複数モデルにまたがるforms.Form柔軟にフィールドを定義できる
モデルと関係ない(検索など)forms.Formデータベース保存が不要
特殊なバリデーションが多いforms.Form完全にコントロールできる
Tip

実際の開発では?

実際のDjango開発では、ModelFormを使うケースが圧倒的に多いです。理由は:

  • コードが短くて済む
  • モデルの変更が自動的に反映される
  • Djangoの規約に従いやすい

forms.Formは、検索フォームやお問い合わせフォームなど、データベースに保存しない場合に使います。

最初のModelForm:記事作成フォーム

PostFormクラスの作成

では、記事(Post)用のModelFormを作りましょう。

blog/forms.pyに追加:

blog/forms.py
from django import forms from .models import Category, Post # Postを追加 # 既存のフォームクラス(PostSearchForm、CommentForm)はそのまま class PostForm(forms.ModelForm): """記事作成・編集フォーム""" class Meta: model = Post fields = ["title", "content", "category", "tags", "is_featured", "is_published"] widgets = { "title": forms.TextInput( attrs={"class": "form-control", "placeholder": "記事のタイトルを入力"} ), "content": forms.Textarea( attrs={ "class": "form-control", "rows": 10, "placeholder": "記事の本文を入力", } ), "category": forms.Select(attrs={"class": "form-control"}), "tags": forms.CheckboxSelectMultiple(attrs={"class": "form-check-input"}), "is_featured": forms.CheckboxInput(attrs={"class": "form-check-input"}), "is_published": forms.CheckboxInput(attrs={"class": "form-check-input"}), } labels = { "title": "タイトル", "content": "本文", "category": "カテゴリー", "tags": "タグ", "is_featured": "注目記事にする", "is_published": "公開する", } help_texts = { "title": "魅力的なタイトルを付けましょう", "tags": "複数選択できます", "is_published": "チェックを外すと下書きとして保存されます", }

Metaクラスの詳しい説明

ModelFormのMetaクラスでは、様々な設定ができます:

属性説明
model対象のモデルmodel = Post
fields使用するフィールドfields = ["title", "content"]
exclude除外するフィールドexclude = ["created_at"]
widgetsフィールドの見た目widgets = {"title": forms.TextInput()}
labelsラベルの変更labels = {"title": "タイトル"}
help_textsヘルプテキストhelp_texts = {"title": "ヒント"}
Note

fieldsとexcludeの使い分け

  • fields = "__all__":すべてのフィールドを使用(非推奨)
  • fields = ["title", "content"]:指定したフィールドのみ使用(推奨)
  • exclude = ["created_at"]:指定したフィールド以外を使用

セキュリティの観点から、必要なフィールドを明示的に指定するfieldsの使用が推奨されています。

CreateViewを使った記事作成ページ

クラスベースビューのCreateViewを使って、記事作成ページを実装します。

blog/views.pyに追加:

blog/views.py
from django.urls import reverse_lazy # 追加 from django.views.generic import TemplateView, ListView, DetailView, CreateView # CreateViewを追加 from .forms import PostSearchForm, CommentForm, PostForm # PostFormを追加 # 既存のビューはそのまま class PostCreateView(CreateView): """記事作成ビュー""" model = Post form_class = PostForm template_name = "blog/post_form.html" success_url = reverse_lazy("post_list") def form_valid(self, form): """フォームのバリデーション成功時""" messages.success(self.request, "記事を作成しました!") return super().form_valid(form) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["page_title"] = "新規記事作成" context["button_text"] = "作成" return context
Tip

reverse_lazyって何?

reverse_lazyは、URLパターン名からURLを生成する関数です。reverseとの違いは:

  • reverse:すぐにURLを生成(ビュー関数内で使用)
  • reverse_lazy:必要になったときにURLを生成(クラス変数で使用)

クラスベースビューでは、クラス定義時にはまだURLパターンが読み込まれていない可能性があるため、reverse_lazyを使います。

記事作成フォームのテンプレート

blog/templates/blog/post_form.htmlを作成:

blog/templates/blog/post_form.html
{% extends 'blog/base.html' %} {% load static %} {% block title %}{{ page_title }} - My Blog{% endblock %} {% block content %} <div class="form-page"> <div class="form-header"> <h2>{{ page_title }}</h2> </div> <div class="form-container"> <form method="post" class="post-form"> {% csrf_token %} <!-- エラーメッセージ --> {% if form.non_field_errors %} <div class="alert alert-danger"> {{ form.non_field_errors }} </div> {% endif %} <!-- タイトル --> <div class="form-group"> {{ form.title.label_tag }} {{ form.title }} {% if form.title.help_text %} <small class="form-help">{{ form.title.help_text }}</small> {% endif %} {% if form.title.errors %} <div class="error-message">{{ form.title.errors }}</div> {% endif %} </div> <!-- 本文 --> <div class="form-group"> {{ form.content.label_tag }} {{ form.content }} {% if form.content.help_text %} <small class="form-help">{{ form.content.help_text }}</small> {% endif %} {% if form.content.errors %} <div class="error-message">{{ form.content.errors }}</div> {% endif %} </div> <!-- カテゴリー --> <div class="form-group"> {{ form.category.label_tag }} {{ form.category }} {% if form.category.errors %} <div class="error-message">{{ form.category.errors }}</div> {% endif %} </div> <!-- タグ --> <div class="form-group"> {{ form.tags.label_tag }} <div class="checkbox-group"> {{ form.tags }} </div> {% if form.tags.help_text %} <small class="form-help">{{ form.tags.help_text }}</small> {% endif %} {% if form.tags.errors %} <div class="error-message">{{ form.tags.errors }}</div> {% endif %} </div> <!-- オプション --> <div class="form-group"> <div class="form-check"> {{ form.is_featured }} {{ form.is_featured.label_tag }} </div> <div class="form-check"> {{ form.is_published }} {{ form.is_published.label_tag }} {% if form.is_published.help_text %} <small class="form-help">{{ form.is_published.help_text }}</small> {% endif %} </div> </div> <!-- ボタン --> <div class="form-actions"> <button type="submit" class="btn btn-primary"> {{ button_text }} </button> <a href="{% url 'post_list' %}" class="btn btn-secondary"> キャンセル </a> </div> </form> </div> </div> {% endblock %}

URLパターンの追加

blog/urls.pyに追加:

blog/urls.py
urlpatterns = [ path("", views.PostListView.as_view(), name="post_list"), path("post/new/", views.PostCreateView.as_view(), name="post_create"), # 追加 path("post/<int:post_id>/", views.PostDetailView.as_view(), name="post_detail"), path("category/<slug:slug>/", views.category_posts, name="category_posts"), path("search/", views.post_search, name="post_search"), path("about/", views.AboutView.as_view(), name="about"), path("archive/<int:year>/<int:month>/", views.MonthArchiveView.as_view(), name="month_archive"), ]
Important

URLパターンの順序に注意!

URLパターンの順序は非常に重要です。Djangoは上から順にURLをマッチングするため、より具体的なパターンを先に、汎用的なパターンを後に配置するのが基本です。

実は<int:post_id>の場合は大丈夫ですが...

# この順序でも動作する(intは数字のみマッチするため) path("post/<int:post_id>/", views.PostDetailView.as_view(), name="post_detail"), path("post/new/", views.PostCreateView.as_view(), name="post_create"),

しかし、<str:post_id><slug:slug>の場合は問題が発生します:

# この順序だとエラーになる! path("post/<str:post_id>/", views.PostDetailView.as_view(), name="post_detail"), path("post/new/", views.PostCreateView.as_view(), name="post_create"), # ValueError: Field 'id' expected a number but got 'new'.

ベストプラクティス:常に具体的なパターンを先に配置

将来的にURLパターンを変更する可能性もあるため、バグを防ぐために常に具体的なパターン(post/new/)を汎用的なパターン(post/<int:post_id>/)より前に配置する習慣をつけましょう。

動作確認してみよう!

さあ、ここまでで記事作成機能が実装できました。実際に動かしてみましょう!

開発サーバーを起動:

ターミナル
$ python manage.py runserver

ブラウザで http://localhost:8000/post/new/ にアクセスしてみてください。

記事作成フォーム(CSS適用前)

フォームが表示されましたね!まだ見た目は簡素ですが、機能は完璧です。

試しに以下のような内容を入力してみましょう:

  • タイトル:ModelFormでCRUD機能を実装した!
  • 本文
    DjangoのModelFormとCreateViewを使って、 たった数十行のコードで記事作成機能が実装できました! これがフレームワークの力ですね。
  • カテゴリー:Django入門
  • タグ:Django、ORM
  • 公開する:チェック

「作成」ボタンをクリックすると...

記事作成成功

記事が作成され、記事一覧ページにリダイレクトされました!

Tip

ModelForm + CreateViewの驚くべきシンプルさ

ちょっと待ってください。今何が起きたか分かりますか?

たったこれだけのコードで:

  • ModelForm:約40行
  • CreateView:約15行
  • テンプレート:基本的なHTML

以下の機能がすべて実装されました:

  • フォームの自動生成
  • 入力値のバリデーション
  • エラーメッセージの表示
  • データベースへの保存
  • 成功後のリダイレクト
  • CSRF対策

これを手作業で実装したら、何百行ものコードが必要になるでしょう。 Djangoの力を実感した瞬間ですね!

記事編集機能の実装

UpdateViewの使い方

記事を編集する機能を追加してみましょう。UpdateViewを使います。

blog/views.pyに追加:

blog/views.py
from django.views.generic import ( TemplateView, ListView, DetailView, CreateView, UpdateView, # 追加 ) class PostUpdateView(UpdateView): """記事編集ビュー""" model = Post form_class = PostForm template_name = "blog/post_form.html" def get_success_url(self): """更新成功時のリダイレクト先""" return reverse_lazy("post_detail", kwargs={"post_id": self.object.pk}) def form_valid(self, form): messages.success(self.request, "記事を更新しました!") return super().form_valid(form) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["page_title"] = f"記事編集: {self.object.title}" context["button_text"] = "更新" return context

UpdateViewの素晴らしいポイント

ここで注目していただきたいのは、新規作成時のテンプレート(post_form.html)とModelFormをそのまま使っていることです!

なぜ同じものが使えるのか?

UpdateViewは、以下の処理を自動的に行ってくれます:

  1. 既存データの読み込み

    • URLパラメータ(pk)から対象の記事を取得
    • 記事のデータをフォームに自動的にセット(初期値として表示)
  2. フォームの初期化

    # UpdateViewが内部で行っていること(イメージ) post = Post.objects.get(pk=pk) form = PostForm(instance=post) # instanceに既存オブジェクトを渡す
  3. 保存処理の違い

    • CreateView:新しいオブジェクトを作成(INSERT)
    • UpdateView:既存のオブジェクトを更新(UPDATE)

実際の動作を図解

UpdateViewのデータフロー

この図が示すように:

  1. ユーザーが編集リンクをクリック(例:/post/1/edit/
  2. UpdateViewがデータベースから記事を取得
  3. PostFormに既存データを自動的にセット
  4. ユーザーは既存の内容を見ながら編集可能
  5. 保存時は新規作成ではなく、既存レコードの更新

つまり、CreateViewとUpdateViewは同じフォームとテンプレートを共有できるんです!これがDjangoの「DRY原則」の素晴らしい実例です。

URLパターンの追加

blog/urls.pyに追加:

blog/urls.py
urlpatterns = [ # 既存のパターン path("post/<int:pk>/edit/", views.PostUpdateView.as_view(), name="post_edit"), ]
Note

なぜ今回はpkを使うの?

これまではpost_idを使っていましたが、今回からpkを使っています。

pkとは?

  • pkPrimary Key(主キー) の略
  • Djangoのモデルで自動的に作られるidフィールドのこと
  • つまりpkidは同じものを指す

なぜpkを使うべきか?

# これまでの書き方(関数ベースビュー) def post_detail(request, post_id): # 自由に名前を決められる post = get_object_or_404(Post, id=post_id) # クラスベースビューのデフォルト class PostDetailView(DetailView): model = Post # デフォルトでURLから'pk'を探す!

UpdateView、DeleteViewはpkを想定している

DjangoのCBV(UpdateView、DeleteView、DetailView)は、デフォルトでpkという名前のURLパラメータを探します。

もしpost_idを使いたい場合は、追加の設定が必要です:

class PostUpdateView(UpdateView): model = Post pk_url_kwarg = 'post_id' # URLパラメータ名を指定

でも、Djangoの規約に従ってpkを使う方がシンプルです!

編集リンクの追加

記事詳細ページに編集リンクを追加してみましょう。

blog/templates/blog/post_detail.htmlを修正:

blog/templates/blog/post_detail.html
<article class="post-detail"> <!-- 既存のコンテンツの後に追加 --> <div class="post-actions"> <a href="{% url 'post_edit' pk=post.pk %}" class="btn btn-primary"> 編集 </a> </div> </article>

編集機能を実際に確認してみよう

開発サーバーが起動していることを確認して、実際に編集機能を試してみましょう。

  1. 記事一覧ページから任意の記事をクリック
  2. 記事詳細ページの「編集」ボタンをクリック
  3. 編集画面(http://localhost:8000/post/1/edit/)が表示される

記事編集画面(データが復元されている)

見てください!すべてのフィールドに既存のデータが自動的に入力されています

  • タイトル:既存のタイトルが表示
  • 本文:既存の本文が表示
  • カテゴリー:選択されていたカテゴリーが選択状態
  • タグ:チェックされていたタグがチェック状態
  • 注目記事・公開状態:設定されていた状態が復元

これがUpdateViewの魔法です。何も特別なコードを書いていないのに、データの復元が完璧に動作しています

試しに以下のような変更をしてみましょう:

  1. タイトルを少し変更
  2. 本文に追記
  3. 新しいタグを追加

「更新」ボタンをクリックすると...

更新成功後の画面

更新が成功し、変更内容が反映されています!

記事削除機能の実装

DeleteViewの使い方

最後に、記事を削除する機能を追加します。

blog/views.pyに追加:

blog/views.py
from django.views.generic import ( TemplateView, ListView, DetailView, CreateView, UpdateView, DeleteView, # 追加 ) class PostDeleteView(DeleteView): """記事削除ビュー""" model = Post template_name = "blog/post_confirm_delete.html" def get_success_url(self): """削除成功時のリダイレクト先""" messages.success(self.request, "記事を削除しました。") return reverse_lazy("post_list")

削除確認テンプレート

blog/templates/blog/post_confirm_delete.htmlを作成:

blog/templates/blog/post_confirm_delete.html
{% extends 'blog/base.html' %} {% block title %}記事の削除確認 - My Blog{% endblock %} {% block content %} <div class="delete-confirm-page"> <div class="delete-confirm-container"> <h2>記事の削除確認</h2> <div class="delete-warning"> <p>以下の記事を削除しようとしています:</p> <div class="post-info"> <h3>{{ post.title }}</h3> <p class="post-meta"> カテゴリー: {{ post.category|default:"未分類" }} | 作成日: {{ post.created_at|date:"Y年m月d日" }} </p> </div> <p class="warning-text"> この操作は取り消せません。本当に削除しますか? </p> </div> <form method="post" class="delete-form"> {% csrf_token %} <div class="form-actions"> <button type="submit" class="btn btn-danger" onclick="return confirm('本当に削除しますか?');"> 削除する </button> <a href="{% url 'post_list' %}" class="btn btn-secondary"> キャンセル </a> </div> </form> </div> </div> {% endblock %}

URLパターンの追加

blog/urls.pyに追加:

blog/urls.py
urlpatterns = [ # 既存のパターン path("post/<int:pk>/delete/", views.PostDeleteView.as_view(), name="post_delete"), # 追加 ]

CRUD操作の統合

記事一覧に管理リンクを追加

記事一覧ページから直接編集・削除できるようにしてみましょう。

blog/templates/blog/includes/post_card.htmlを修正:

blog/templates/blog/includes/post_card.html
<article class="post-card"> <!-- 既存のコンテンツ --> <div class="post-card-footer"> {% if post.tags.exists %} <div class="tags"> {% for tag in post.tags.all %} <span class="tag">#{{ tag.name }}</span> {% endfor %} </div> {% endif %} <div class="post-actions"> <a href="{% url 'post_detail' post_id=post.pk %}" class="read-more"> 続きを読む → </a> <!-- 管理リンクを追加 --> <div class="admin-actions"> <a href="{% url 'post_edit' pk=post.pk %}" class="btn-link btn-edit"> 編集 </a> <a href="{% url 'post_delete' pk=post.pk %}" class="btn-link btn-delete"> 削除 </a> </div> </div> </div> </article>

ナビゲーションに新規作成リンクを追加

blog/templates/blog/base.htmlのヘッダー部分を修正:

blog/templates/blog/base.html
<header> <nav> <h1> <a href="{% url 'post_list' %}"> <img src="{% static 'blog/images/logo.png' %}" alt="My Blog" height="40"> <span>My Blog</span> </a> </h1> <ul> <li><a href="{% url 'post_list' %}">ホーム</a></li> <li><a href="{% url 'post_search' %}">検索</a></li> <!-- <li><a href="#">記事一覧</a></li> 記事一覧は使わないため削除 --> <li><a href="{% url 'post_create' %}">新規投稿</a></li> <!-- 追加 --> <li><a href="{% url 'about' %}">About</a></li> </ul> </nav> </header>

成功メッセージの表示

Django messagesフレームワークを使って、操作の成功をユーザーに通知します。

blog/templates/blog/base.htmlのmainタグの直後に追加:

blog/templates/blog/base.html
<main class="{% if categories and posts %}with-sidebar{% else %}no-sidebar{% endif %}"> <!-- メッセージ表示エリア --> {% if messages %} <div class="messages-container"> {% for message in messages %} <div class="alert alert-{{ message.tags }} alert-dismissible"> {{ message }} <button type="button" class="close" data-dismiss="alert">×</button> </div> {% endfor %} </div> {% endif %} <!-- 既存のコンテンツ --> </main>

既存のメッセージ表示を削除

第7回でpost_detail.htmlにコメント投稿用のメッセージ表示を個別に実装していました。共通のメッセージ表示エリアを追加したので、個別の実装を削除してみましょう。

blog/templates/blog/post_detail.htmlから以下の部分を削除:

blog/templates/blog/post_detail.html(削除する部分)
<!-- コメント投稿フォーム --> <div class="comment-form-section"> <h4>コメントを投稿</h4> {% if messages %} <!-- この部分を削除 --> {% for message in messages %} <div class="alert alert-{{ message.tags }}"> {{ message }} </div> {% endfor %} {% endif %} <!-- ここまで削除 --> <form method="post" class="comment-form"> {% csrf_token %} <!-- 以下省略 -->
Note

なぜ個別のメッセージ表示を削除するのか?

共通のメッセージ表示エリアをbase.htmlに追加したことで:

  • すべてのページで統一的なメッセージ表示が可能に
  • メッセージのスタイルも一箇所で管理できる
  • DRY原則に従った実装になる

これにより、コメント投稿成功時のメッセージも、記事作成・更新・削除時のメッセージも、すべて同じ場所(ページ上部)に表示されるようになります。

スタイルの追加

フォーム関連のCSS

blog/static/blog/css/style.cssに追加:

blog/static/blog/css/style.css(追加分)
/* フォームページ共通 */ .form-page { max-width: 800px; margin: 0 auto; padding: 2rem; } .form-header { text-align: center; margin-bottom: 3rem; } .form-header h2 { color: #2c3e50; font-size: 2rem; } .form-container { background: white; padding: 3rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); } /* フォーム要素 */ .post-form .form-group { margin-bottom: 2rem; } .post-form label { display: block; margin-bottom: 0.5rem; color: #333; font-weight: 600; } /* チェックボックスグループ */ .checkbox-group { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 4px; } .checkbox-group label { display: flex; align-items: center; font-weight: normal; margin-bottom: 0; } .checkbox-group input[type="checkbox"] { margin-right: 0.5rem; } .form-check { margin-bottom: 1rem; } .form-check label { display: inline-flex; align-items: center; font-weight: normal; } .form-check input { margin-right: 0.5rem; } /* 削除確認ページ */ .delete-confirm-page { max-width: 600px; margin: 0 auto; padding: 2rem; } .delete-confirm-container { background: white; padding: 3rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); text-align: center; } .delete-warning { margin: 2rem 0; } .post-info { background: #f8f9fa; padding: 1.5rem; border-radius: 4px; margin: 1.5rem 0; text-align: left; } .post-info h3 { color: #2c3e50; margin-bottom: 0.5rem; } .warning-text { color: #e74c3c; font-weight: 600; font-size: 1.1rem; margin-top: 1.5rem; } /* ボタンスタイル */ .btn-danger { background: #e74c3c; color: white; } .btn-danger:hover { background: #c0392b; } /* 管理アクション */ .post-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; } .admin-actions { display: flex; gap: 1rem; margin-left: 1rem; } .btn-link { color: #7f8c8d; text-decoration: none; font-size: 0.875rem; transition: color 0.3s; } .btn-link:hover { color: #3498db; } .btn-delete:hover { color: #e74c3c; } /* 記事詳細ページのアクション */ .post-detail .post-actions { margin-top: 2rem; padding-top: 2rem; border-top: 1px solid #ecf0f1; justify-content: flex-start; } /* メッセージ */ .messages-container { max-width: 1200px; margin: 0 auto; padding: 0 2rem; } .alert { padding: 1rem 1.5rem; border-radius: 4px; position: relative; } .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .alert-dismissible { padding-right: 3rem; } .alert .close { position: absolute; top: 50%; right: 1rem; transform: translateY(-50%); background: none; border: none; font-size: 1.5rem; line-height: 1; color: inherit; opacity: 0.5; cursor: pointer; } .alert .close:hover { opacity: 0.8; }

メッセージを閉じる機能の実装

メッセージの×ボタンをクリックして閉じられるように、JavaScriptを追加します。

blog/static/blog/js/main.jsに以下のコードを追加:

blog/static/blog/js/main.js(追加)
// メッセージの閉じるボタン処理 document.addEventListener('DOMContentLoaded', function() { // 閉じるボタンを取得 const closeButtons = document.querySelectorAll('.alert .close'); closeButtons.forEach(button => { button.addEventListener('click', function() { // 親要素(.alert)を取得 const alert = this.closest('.alert'); // フェードアウトアニメーション alert.style.transition = 'opacity 0.3s ease'; alert.style.opacity = '0'; // アニメーション完了後に要素を削除 setTimeout(() => { alert.remove(); }, 300); }); }); // オプション:一定時間後に自動的にメッセージを非表示にする const alerts = document.querySelectorAll('.alert'); alerts.forEach(alert => { // 5秒後に自動的にフェードアウト setTimeout(() => { if (alert && alert.parentNode) { alert.style.transition = 'opacity 0.5s ease'; alert.style.opacity = '0'; setTimeout(() => { if (alert.parentNode) { alert.remove(); } }, 500); } }, 5000); // 5000ミリ秒 = 5秒 }); });
Tip

メッセージの自動非表示について

上記のコードには、5秒後に自動的にメッセージを非表示にする機能も含まれています。

  • 自動非表示のメリット:ユーザーが手動で閉じなくても、画面がすっきりする
  • カスタマイズ5000の値を変更することで、表示時間を調整可能
  • 無効化:自動非表示が不要な場合は、該当部分のコードを削除してみてください

実際のプロジェクトでは、メッセージの重要度に応じて表示時間を変えることもあります:

  • 成功メッセージ:3〜5秒
  • エラーメッセージ:手動で閉じるまで表示
  • 警告メッセージ:10秒程度

ModelFormの高度な使い方

カスタムバリデーションの追加

blog/forms.pyのPostFormクラスに、カスタムバリデーションを追加してみましょう:

blog/forms.py
class PostForm(forms.ModelForm): """記事作成・編集フォーム""" class Meta: # 既存のMetaクラス設定 def clean_title(self): """タイトルのバリデーション""" title = self.cleaned_data.get("title") # 最小文字数チェック if len(title) < 5: raise forms.ValidationError("タイトルは5文字以上で入力してください。") # NGワードチェック ng_words = ["test", "テスト"] for word in ng_words: if word.lower() in title.lower(): raise forms.ValidationError( "タイトルに使用できない単語が含まれています。" ) return title def clean(self): """フォーム全体のバリデーション""" cleaned_data = super().clean() is_published = cleaned_data.get("is_published") category = cleaned_data.get("category") # 公開する場合はカテゴリー必須 if is_published and not category: raise forms.ValidationError( "公開する記事にはカテゴリーを設定してください。" ) return cleaned_data

save()メソッドのオーバーライド

特別な保存処理が必要な場合は、save()メソッドをオーバーライドできます:

blog/forms.py
class PostForm(forms.ModelForm): # 既存のコード def save(self, commit=True): """保存処理のカスタマイズ""" post = super().save(commit=False) # 例:タイトルを自動的に整形 post.title = post.title.strip() # 例:初回保存時のみの処理 if not post.pk: # 新規作成の場合 # 何か特別な処理 pass if commit: post.save() self.save_m2m() # ManyToManyフィールドの保存 return post

セキュリティとベストプラクティス

CSRFトークンの重要性

POSTフォームには必ず{% csrf_token %}を含めましょう:

<form method="post"> {% csrf_token %} <!-- これを忘れない! --> <!-- フォームフィールド --> </form>

フォームの二重送信防止

JavaScriptで簡単な二重送信防止を実装できます:

blog/static/blog/js/main.js(追加)
// フォーム送信時の処理 document.addEventListener('DOMContentLoaded', function() { const forms = document.querySelectorAll('.post-form, .delete-form'); forms.forEach(form => { form.addEventListener('submit', function(e) { const submitButton = form.querySelector('button[type="submit"]'); // ボタンを無効化 submitButton.disabled = true; submitButton.textContent = '処理中...'; }); }); });

入力値のサニタイゼーション

DjangoのModelFormは自動的に入力値をサニタイズ(無害化)してくれますが、追加の対策も可能です:

from django.utils.html import strip_tags def clean_content(self): content = self.cleaned_data.get('content') # HTMLタグを除去する例(必要に応じて) # content = strip_tags(content) return content

まとめ

今回は、ModelFormとクラスベースビューを使って、本格的なCRUD機能を実装しました:

実装できた機能

  1. 記事の新規作成(CreateView)

    • ModelFormで自動的にフォーム生成
    • バリデーション付き
    • 成功メッセージ表示
  2. 記事の編集(UpdateView)

    • 既存データの自動読み込み
    • 更新後の詳細ページへリダイレクト
  3. 記事の削除(DeleteView)

    • 確認画面付き
    • JavaScript確認ダイアログ
  4. メッセージ通知

    • Django messagesフレームワーク
    • 操作結果をユーザーに通知

ModelFormのメリット

  • コードの削減:モデルからフォームを自動生成
  • 一貫性:モデルの変更が自動的に反映
  • バリデーション:モデルの制約を自動チェック
  • セキュリティ:CSRFやXSS対策が組み込み済み

完成したコード

もし実行でエラーが出たり、不明な点がある場合は、以下の完成後のコードと比較してみてください:

https://github.com/techarm/django-blog-tutorial/tree/09-modelform-crud

特にforms.pyのModelForm定義、views.pyのCBV実装、テンプレートのフォーム表示を確認することで、問題を解決できるでしょう。

次回予告

いよいよ最終回!第10回では:

「Markdownエディタとユーザー認証で本格的なブログシステムを完成させる」

  • django-markdownxでリアルタイムプレビュー付きMarkdownエディタを実装
  • シンタックスハイライト対応のコードブロック
  • Djangoの認証システムを使ったログイン機能
  • 記事の作成・編集を管理者限定にする
  • 記事に著者情報を追加
  • より本格的なブログシステムの完成

10回の集大成として、プロ仕様のブログシステムを完成させましょう!🚀

この記事はいかがでしたか?

もしこの記事が参考になりましたら、
高評価をいただけると大変嬉しいです!

--

皆様からの応援が励みになります。ありがとうございます! ✨

Django超初心者シリーズについて

このシリーズでは、プログラミング初心者の方でもDjangoを使ったWebアプリケーション開発ができるようになることを目標としています。わからないことがあれば、コメント欄でお気軽にご質問ください!