【Django ModelForm入門】記事の作成・編集・削除機能をWeb上で実装する方法
はじめに:管理画面以外で記事を管理したい
これまで8回にわたってDjangoの様々な機能を学んできました。ブログ記事の表示、テンプレートの継承、データベース操作、検索機能、そしてクラスベースビュー...
でも、ちょっと待ってください。記事を作成・編集するときは、いつも管理画面(/admin/
)を使っていましたよね?
「普通のブログサービスみたいに、Web画面から記事を投稿したい!」
そう思ったあなたは正しいです。今回は、ModelFormを使って、この願いを実現します!
▶今回作る機能のデモ
完成すると、こんなことができるようになります:
- 記事の新規作成:Web画面から記事を投稿
- 記事の編集:既存の記事を修正
- 記事の削除:不要な記事を削除(確認画面付き)
- 成功メッセージ:操作完了をユーザーに通知
つまり、CRUD(Create, Read, Update, Delete) 操作すべてをWeb上で実現します!
初めてこの記事を読む方へ:
この記事は連載の第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箇所で書いています:
- models.pyで
CharField
、EmailField
、TextField
を定義 - forms.pyでも
CharField
、EmailField
、CharField
を定義
さらに、このフォームを使ってコメントを保存するには、手動でデータを移す必要がありました:
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 | 完全にコントロールできる |
実際の開発では?
実際のDjango開発では、ModelFormを使うケースが圧倒的に多いです。理由は:
- コードが短くて済む
- モデルの変更が自動的に反映される
- Djangoの規約に従いやすい
forms.Formは、検索フォームやお問い合わせフォームなど、データベースに保存しない場合に使います。
最初のModelForm:記事作成フォーム
▶PostFormクラスの作成
では、記事(Post)用のModelFormを作りましょう。
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": "ヒント"} |
fieldsとexcludeの使い分け
fields = "__all__"
:すべてのフィールドを使用(非推奨)fields = ["title", "content"]
:指定したフィールドのみ使用(推奨)exclude = ["created_at"]
:指定したフィールド以外を使用
セキュリティの観点から、必要なフィールドを明示的に指定するfields
の使用が推奨されています。
▶CreateViewを使った記事作成ページ
クラスベースビューのCreateView
を使って、記事作成ページを実装します。
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
reverse_lazyって何?
reverse_lazy
は、URLパターン名からURLを生成する関数です。reverse
との違いは:
reverse
:すぐにURLを生成(ビュー関数内で使用)reverse_lazy
:必要になったときにURLを生成(クラス変数で使用)
クラスベースビューでは、クラス定義時にはまだURLパターンが読み込まれていない可能性があるため、reverse_lazy
を使います。
▶記事作成フォームのテンプレート
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
に追加:
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"),
]
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/ にアクセスしてみてください。
フォームが表示されましたね!まだ見た目は簡素ですが、機能は完璧です。
試しに以下のような内容を入力してみましょう:
- タイトル:ModelFormでCRUD機能を実装した!
- 本文:
DjangoのModelFormとCreateViewを使って、 たった数十行のコードで記事作成機能が実装できました! これがフレームワークの力ですね。
- カテゴリー:Django入門
- タグ:Django、ORM
- 公開する:チェック
「作成」ボタンをクリックすると...
記事が作成され、記事一覧ページにリダイレクトされました!
ModelForm + CreateViewの驚くべきシンプルさ
ちょっと待ってください。今何が起きたか分かりますか?
たったこれだけのコードで:
- ModelForm:約40行
- CreateView:約15行
- テンプレート:基本的なHTML
以下の機能がすべて実装されました:
- フォームの自動生成
- 入力値のバリデーション
- エラーメッセージの表示
- データベースへの保存
- 成功後のリダイレクト
- CSRF対策
これを手作業で実装したら、何百行ものコードが必要になるでしょう。 Djangoの力を実感した瞬間ですね!
記事編集機能の実装
▶UpdateViewの使い方
記事を編集する機能を追加してみましょう。UpdateView
を使います。
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は、以下の処理を自動的に行ってくれます:
-
既存データの読み込み
- URLパラメータ(pk)から対象の記事を取得
- 記事のデータをフォームに自動的にセット(初期値として表示)
-
フォームの初期化
# UpdateViewが内部で行っていること(イメージ) post = Post.objects.get(pk=pk) form = PostForm(instance=post) # instanceに既存オブジェクトを渡す
-
保存処理の違い
- CreateView:新しいオブジェクトを作成(INSERT)
- UpdateView:既存のオブジェクトを更新(UPDATE)
実際の動作を図解
この図が示すように:
- ユーザーが編集リンクをクリック(例:
/post/1/edit/
) - UpdateViewがデータベースから記事を取得
- PostFormに既存データを自動的にセット
- ユーザーは既存の内容を見ながら編集可能
- 保存時は新規作成ではなく、既存レコードの更新
つまり、CreateViewとUpdateViewは同じフォームとテンプレートを共有できるんです!これがDjangoの「DRY原則」の素晴らしい実例です。
▶URLパターンの追加
blog/urls.py
に追加:
urlpatterns = [
# 既存のパターン
path("post/<int:pk>/edit/", views.PostUpdateView.as_view(), name="post_edit"),
]
なぜ今回はpk
を使うの?
これまではpost_id
を使っていましたが、今回からpk
を使っています。
pk
とは?
pk
は Primary Key(主キー) の略- Djangoのモデルで自動的に作られる
id
フィールドのこと - つまり
pk
とid
は同じものを指す
なぜ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
を修正:
<article class="post-detail">
<!-- 既存のコンテンツの後に追加 -->
<div class="post-actions">
<a href="{% url 'post_edit' pk=post.pk %}" class="btn btn-primary">
編集
</a>
</div>
</article>
▶編集機能を実際に確認してみよう
開発サーバーが起動していることを確認して、実際に編集機能を試してみましょう。
- 記事一覧ページから任意の記事をクリック
- 記事詳細ページの「編集」ボタンをクリック
- 編集画面(http://localhost:8000/post/1/edit/)が表示される
見てください!すべてのフィールドに既存のデータが自動的に入力されています:
- タイトル:既存のタイトルが表示
- 本文:既存の本文が表示
- カテゴリー:選択されていたカテゴリーが選択状態
- タグ:チェックされていたタグがチェック状態
- 注目記事・公開状態:設定されていた状態が復元
これがUpdateViewの魔法です。何も特別なコードを書いていないのに、データの復元が完璧に動作しています。
試しに以下のような変更をしてみましょう:
- タイトルを少し変更
- 本文に追記
- 新しいタグを追加
「更新」ボタンをクリックすると...
更新が成功し、変更内容が反映されています!
記事削除機能の実装
▶DeleteViewの使い方
最後に、記事を削除する機能を追加します。
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
を作成:
{% 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
に追加:
urlpatterns = [
# 既存のパターン
path("post/<int:pk>/delete/", views.PostDeleteView.as_view(), name="post_delete"), # 追加
]
CRUD操作の統合
▶記事一覧に管理リンクを追加
記事一覧ページから直接編集・削除できるようにしてみましょう。
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
のヘッダー部分を修正:
<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タグの直後に追加:
<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
から以下の部分を削除:
<!-- コメント投稿フォーム -->
<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 %}
<!-- 以下省略 -->
なぜ個別のメッセージ表示を削除するのか?
共通のメッセージ表示エリアをbase.html
に追加したことで:
- すべてのページで統一的なメッセージ表示が可能に
- メッセージのスタイルも一箇所で管理できる
- DRY原則に従った実装になる
これにより、コメント投稿成功時のメッセージも、記事作成・更新・削除時のメッセージも、すべて同じ場所(ページ上部)に表示されるようになります。
スタイルの追加
▶フォーム関連の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
に以下のコードを追加:
// メッセージの閉じるボタン処理
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秒
});
});
メッセージの自動非表示について
上記のコードには、5秒後に自動的にメッセージを非表示にする機能も含まれています。
- 自動非表示のメリット:ユーザーが手動で閉じなくても、画面がすっきりする
- カスタマイズ:
5000
の値を変更することで、表示時間を調整可能 - 無効化:自動非表示が不要な場合は、該当部分のコードを削除してみてください
実際のプロジェクトでは、メッセージの重要度に応じて表示時間を変えることもあります:
- 成功メッセージ:3〜5秒
- エラーメッセージ:手動で閉じるまで表示
- 警告メッセージ:10秒程度
ModelFormの高度な使い方
▶カスタムバリデーションの追加
blog/forms.py
のPostFormクラスに、カスタムバリデーションを追加してみましょう:
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()
メソッドをオーバーライドできます:
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で簡単な二重送信防止を実装できます:
// フォーム送信時の処理
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機能を実装しました:
▶実装できた機能
-
記事の新規作成(CreateView)
- ModelFormで自動的にフォーム生成
- バリデーション付き
- 成功メッセージ表示
-
記事の編集(UpdateView)
- 既存データの自動読み込み
- 更新後の詳細ページへリダイレクト
-
記事の削除(DeleteView)
- 確認画面付き
- JavaScript確認ダイアログ
-
メッセージ通知
- 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回の集大成として、プロ仕様のブログシステムを完成させましょう!🚀
この記事はいかがでしたか?
もしこの記事が参考になりましたら、
高評価をいただけると大変嬉しいです!
皆様からの応援が励みになります。ありがとうございます! ✨