Flaskでのフォーム処理の基礎

2025-12-26

はじめに

Webアプリケーションにおいて、フォームはユーザーからの入力を受け付けるための重要な要素です。Flaskでは、シンプルかつ効果的な方法でフォーム処理を実装できます。この記事では、Flaskを使ったフォーム処理の基本から実践的なテクニックまで、初心者の方にもわかりやすく解説します。実際のコード例を交えながら、フォームの作成方法、データの送信と受け取り、そして基本的なバリデーションについて詳しく学んでいきましょう。これらの知識を身につけることで、対話型のWebアプリケーションを開発するための強固な基盤が築けます。

基本的なフォーム作成

Flaskでフォームを作成するには、まずHTMLフォームの基本構造を理解する必要があります。フォームはユーザーがデータを入力し、サーバーに送信するためのインターフェースを提供します。

最もシンプルなフォームから始めてみましょう。Flaskアプリケーションのテンプレートファイルにフォームを記述します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>基本フォーム</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">お問い合わせフォーム</h1>

                <form method="POST" action="/submit">
                    <div class="mb-3">
                        <label for="name" class="form-label">お名前</label>
                        <input type="text" class="form-control" id="name" name="name" required>
                    </div>

                    <div class="mb-3">
                        <label for="email" class="form-label">メールアドレス</label>
                        <input type="email" class="form-control" id="email" name="email" required>
                    </div>

                    <div class="mb-3">
                        <label for="message" class="form-label">メッセージ</label>
                        <textarea class="form-control" id="message" name="message" rows="4" required></textarea>
                    </div>

                    <div class="mb-3">
                        <label for="category" class="form-label">お問い合わせカテゴリ</label>
                        <select class="form-select" id="category" name="category">
                            <option value="general">一般質問</option>
                            <option value="technical">技術的な質問</option>
                            <option value="support">サポート</option>
                            <option value="other">その他</option>
                        </select>
                    </div>

                    <div class="mb-3">
                        <div class="form-check">
                            <input class="form-check-input" type="checkbox" id="newsletter" name="newsletter" value="yes">
                            <label class="form-check-label" for="newsletter">
                                ニュースレターを購読する
                            </label>
                        </div>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">送信する</button>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

このフォームには、テキスト入力、メール入力、テキストエリア、セレクトボックス、チェックボックスなど、よく使用されるフォーム要素が含まれています。各入力フィールドにはname属性が設定されており、この値を使ってサーバー側でデータを識別します。

次に、このフォームを表示するためのFlaskルートを作成します。

from flask import Flask, render_template, request, redirect, url_for, flash

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'  # フラッシュメッセージ用の秘密鍵

@app.route('/')
def index():
    return render_template('form.html')

if __name__ == '__main__':
    app.run(debug=True)

フォームのmethod属性は「POST」に設定されています。これは、フォームデータがHTTP POSTリクエストとして送信されることを意味します。GETメソッドを使用することもできますが、データがURLに表示されるため、パスワードなどの機密情報には適していません。

アクセシビリティを考慮したフォーム作成も重要です。各入力フィールドには適切なlabel要素を関連付け、required属性を使用して必須項目を明示します。これにより、ユーザーはどの項目が必須かわかりやすくなり、スクリーンリーダーを使用するユーザーにも親切な設計となります。

データの送信と受け取り

フォームから送信されたデータをFlaskで処理する方法について詳しく見ていきましょう。フォームデータの受け取りには、Flaskのrequestオブジェクトを使用します。

まず、フォーム送信を処理するルートを追加します。

from flask import Flask, render_template, request, redirect, url_for, flash

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

@app.route('/')
def index():
    return render_template('form.html')

@app.route('/submit', methods=['POST'])
def submit():
    # フォームデータの取得
    name = request.form.get('name')
    email = request.form.get('email')
    message = request.form.get('message')
    category = request.form.get('category')
    newsletter = request.form.get('newsletter')

    # 取得したデータの表示(実際のアプリケーションではデータベースに保存など)
    print(f"名前: {name}")
    print(f"メール: {email}")
    print(f"メッセージ: {message}")
    print(f"カテゴリ: {category}")
    print(f"ニュースレター: {'購読希望' if newsletter else '未購読'}")

    # フラッシュメッセージでユーザーにフィードバック
    flash('お問い合わせありがとうございます。正常に送信されました。', 'success')

    # 別のページにリダイレクト
    return redirect(url_for('index'))

if __name__ == '__main__':
    app.run(debug=True)

request.formオブジェクトは、POSTリクエストで送信されたフォームデータを含む辞書のようなオブジェクトです。getメソッドを使用して個々のフィールドの値を取得できます。この方法では、該当するキーが存在しない場合でもエラーが発生せず、代わりにNoneが返されます。

フラッシュメッセージを表示するために、ベーステンプレートを更新しましょう。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>基本フォーム</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <!-- フラッシュメッセージの表示 -->
        {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
            {% raw %}{% if messages %}{% endraw %}
                {% raw %}{% for category, message in messages %}{% endraw %}
                    <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
                        {{ message }}
                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                    </div>
                {% raw %}{% endfor %}{% endraw %}
            {% raw %}{% endif %}{% endraw %}
        {% raw %}{% endwith %}{% endraw %}

        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">お問い合わせフォーム</h1>

                <form method="POST" action="/submit">
                    <!-- フォーム内容は前回と同じ -->
                </form>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

GETメソッドとPOSTメソッドの違いを理解することも重要です。同じURLでフォームの表示と送信処理の両方を扱う場合は、1つのルートで両方のメソッドを処理できます。

@app.route('/contact', methods=['GET', 'POST'])
def contact():
    if request.method == 'POST':
        # フォーム送信の処理
        name = request.form.get('name')
        email = request.form.get('email')
        message = request.form.get('message')

        # 処理ロジック(データベース保存、メール送信など)
        print(f"受信したデータ: {name}, {email}, {message}")

        flash('メッセージが送信されました', 'success')
        return redirect(url_for('contact'))

    # GETリクエストの場合、フォームを表示
    return render_template('contact.html')

ファイルのアップロードを処理する方法も理解しておきましょう。ファイルアップロードに対応したフォームを作成するには、enctype属性を「multipart/form-data」に設定する必要があります。

<form method="POST" action="/upload" enctype="multipart/form-data">
    <div class="mb-3">
        <label for="file" class="form-label">ファイルを選択</label>
        <input type="file" class="form-control" id="file" name="file">
    </div>
    <button type="submit" class="btn btn-primary">アップロード</button>
</form>

ファイルアップロードを処理するFlaskルートは以下のようになります。アップロードされたファイルを保存するディレクトリとして uploads を設定し、受け付ける拡張子の種類を指定しています。

import os
from werkzeug.utils import secure_filename

app.config['UPLOAD_FOLDER'] = 'uploads'
app.config['ALLOWED_EXTENSIONS'] = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'}

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        flash('ファイルが選択されていません', 'error')
        return redirect(request.url)

    file = request.files['file']

    if file.filename == '':
        flash('ファイルが選択されていません', 'error')
        return redirect(request.url)

    if file and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
        flash('ファイルが正常にアップロードされました', 'success')
        return redirect(url_for('index'))

    flash('許可されていないファイル形式です', 'error')
    return redirect(request.url)

allowed_file 関数は、ファイル名に拡張子が含まれているかどうかを確認し、その拡張子が許可された一覧に含まれている場合に True を返す役割を持ちます。/upload という URL に POST メソッドでアクセスがあった際には、アップロードされたファイルがリクエストに含まれているかどうかを確認し、ファイル名が空でないこともチェックしてます。

許可されている形式のファイルであれば、secure_filename を使って安全なファイル名に変換し、アップロード用ディレクトリに保存します。ファイルが正常に保存された場合には成功メッセージを通知してトップページへリダイレクトし、問題がある場合にはエラーメッセージとともに同じページへ戻す仕様になっています。

バリデーション初歩

フォームバリデーションは、ユーザーが入力したデータが正しい形式であることを確認する重要なプロセスです。適切なバリデーションを実装することで、不正なデータによるエラーを防ぎ、ユーザー体験を向上させられます。

まず、基本的なサーバーサイドバリデーションから実装してみましょう。

@app.route('/submit', methods=['POST'])
def submit():
    # フォームデータの取得
    name = request.form.get('name', '').strip()
    email = request.form.get('email', '').strip()
    message = request.form.get('message', '').strip()

    # バリデーションエラーを格納するリスト
    errors = []

    # 名前のバリデーション
    if not name:
        errors.append('名前を入力してください')
    elif len(name) < 2:
        errors.append('名前は2文字以上で入力してください')
    elif len(name) > 50:
        errors.append('名前は50文字以内で入力してください')

    # メールアドレスのバリデーション
    if not email:
        errors.append('メールアドレスを入力してください')
    elif '@' not in email or '.' not in email:
        errors.append('有効なメールアドレスを入力してください')

    # メッセージのバリデーション
    if not message:
        errors.append('メッセージを入力してください')
    elif len(message) < 10:
        errors.append('メッセージは10文字以上で入力してください')
    elif len(message) > 1000:
        errors.append('メッセージは1000文字以内で入力してください')

    # バリデーションエラーがある場合
    if errors:
        for error in errors:
            flash(error, 'error')
        return redirect(url_for('index'))

    # バリデーション成功時の処理
    flash('お問い合わせありがとうございます。正常に送信されました。', 'success')

    # 実際のアプリケーションではここでデータベースに保存などの処理
    print(f"検証済みデータ - 名前: {name}, メール: {email}, メッセージ: {message}")

    return redirect(url_for('index'))

クライアントサイドでのバリデーションも重要です。HTML5の組み込みバリデーション機能を使用すると、ユーザーがフォームを送信する前に入力エラーを検出できます。

<form method="POST" action="/submit" novalidate>
    <div class="mb-3">
        <label for="name" class="form-label">お名前</label>
        <input type="text" class="form-control" id="name" name="name" 
               minlength="2" maxlength="50" required>
        <div class="invalid-feedback">
            名前は2文字以上50文字以内で入力してください
        </div>
    </div>

    <div class="mb-3">
        <label for="email" class="form-label">メールアドレス</label>
        <input type="email" class="form-control" id="email" name="email" required>
        <div class="invalid-feedback">
            有効なメールアドレスを入力してください
        </div>
    </div>

    <div class="mb-3">
        <label for="message" class="form-label">メッセージ</label>
        <textarea class="form-control" id="message" name="message" 
                  minlength="10" maxlength="1000" required></textarea>
        <div class="invalid-feedback">
            メッセージは10文字以上1000文字以内で入力してください
        </div>
    </div>

    <button type="submit" class="btn btn-primary w-100">送信する</button>
</form>

<script>
// クライアントサイドバリデーションの強化
(function() {
    'use strict';

    const forms = document.querySelectorAll('form');

    Array.from(forms).forEach(function(form) {
        form.addEventListener('submit', function(event) {
            if (!form.checkValidity()) {
                event.preventDefault();
                event.stopPropagation();
            }

            form.classList.add('was-validated');
        }, false);
    });
})();
</script>

より高度なバリデーションが必要な場合は、WTFormsなどのライブラリを使用することも検討できます。WTFormsは、Flaskと統合しやすい強力なフォームバリデーションライブラリです。

from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SelectField, BooleanField
from wtforms.validators import DataRequired, Email, Length, Optional

class ContactForm(FlaskForm):
    name = StringField('お名前', validators=[
        DataRequired(message='名前は必須です'),
        Length(min=2, max=50, message='名前は2文字以上50文字以内で入力してください')
    ])

    email = StringField('メールアドレス', validators=[
        DataRequired(message='メールアドレスは必須です'),
        Email(message='有効なメールアドレスを入力してください')
    ])

    message = TextAreaField('メッセージ', validators=[
        DataRequired(message='メッセージは必須です'),
        Length(min=10, max=1000, message='メッセージは10文字以上1000文字以内で入力してください')
    ])

    category = SelectField('カテゴリ', choices=[
        ('general', '一般質問'),
        ('technical', '技術的な質問'),
        ('support', 'サポート'),
        ('other', 'その他')
    ], validators=[DataRequired()])

    newsletter = BooleanField('ニュースレターを購読する', validators=[Optional()])

WTFormsを使用したルートの実装例です。

@app.route('/wtf-contact', methods=['GET', 'POST'])
def wtf_contact():
    form = ContactForm()

    if form.validate_on_submit():
        # フォームデータの処理
        name = form.name.data
        email = form.email.data
        message = form.message.data
        category = form.category.data
        newsletter = form.newsletter.data

        # 処理ロジック
        print(f"WTFormsで処理されたデータ: {name}, {email}, {message}")

        flash('お問い合わせありがとうございます。正常に送信されました。', 'success')
        return redirect(url_for('wtf_contact'))

    return render_template('wtf_contact.html', form=form)

対応するテンプレートファイルでは、WTFormsのフィールドをレンダリングします。

<form method="POST" novalidate>
    {{ form.hidden_tag() }}

    <div class="mb-3">
        {{ form.name.label(class="form-label") }}
        {{ form.name(class="form-control") }}
        {% if form.name.errors %}
            <div class="invalid-feedback d-block">
                {% for error in form.name.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
    </div>

    <div class="mb-3">
        {{ form.email.label(class="form-label") }}
        {{ form.email(class="form-control") }}
        {% if form.email.errors %}
            <div class="invalid-feedback d-block">
                {% for error in form.email.errors %}
                    {{ error }}
                {% endfor %}
            </div>
        {% endif %}
    </div>

    <!-- 他のフィールドも同様に -->

    <button type="submit" class="btn btn-primary w-100">送信する</button>
</form>

バリデーションを設計する際のベストプラクティスとして、ユーザーに明確なエラーメッセージを提供すること、必須項目と任意項目を明確に区別すること、複数階層のバリデーションを実装することなどが挙げられます。また、常にサーバーサイドバリデーションを実施し、クライアントサイドバリデーションはユーザー体験向上のための補助として扱うことが重要です。

まとめ

Flaskでのフォーム処理の基礎について、基本的なフォーム作成からデータの送信と受け取り、そしてバリデーションの初歩までを詳しく解説してきました。フォームはWebアプリケーションとユーザーとの重要な接点であり、適切に設計・実装することで、使いやすく安全なアプリケーションを構築できます。

フォーム作成では、適切なHTML構造とアクセシビリティへの配慮が重要です。データの送信と受け取りでは、requestオブジェクトを活用した効率的な処理方法と、ユーザーへのフィードバックの提供方法を学びました。バリデーションでは、基本的なサーバーサイドバリデーションからWTFormsを使用したより構造化されたアプローチまで、さまざまな手法を紹介しました。

これらの技術を組み合わせることで、ユーザーからの入力を効果的に処理し、エラーの少ない堅牢なWebアプリケーションを開発できるようになります。実際のプロジェクトでは、ここで学んだ基礎を土台にして、より複雑なフォーム処理や高度なバリデーションルールに挑戦してみてください。Flaskの柔軟性を活かせば、あらゆる種類のフォーム要件に対応できるでしょう。

演習問題

各問題の解答例を作成する際は、以下の点に注意してください。

  1. 初学者向けの詳細な説明を付ける
  2. 完全な動作コードを提示する
  3. エラーハンドリングを適切に実装する
  4. ベストプラクティスに沿ったコードを書く
  5. セキュリティ考慮事項を説明する
  6. 実際の使用例を想定した実用的なコードにする

初級問題(3問)

初級1: 基本的なフォーム作成

問題
名前(name)とメールアドレス(email)を入力する簡単なコンタクトフォームを作成してください。Flaskアプリケーションで以下の要件を満たすコードを書いてください。

  • ルート / でフォームを表示する
  • フォームはPOSTメソッドで /submit に送信する
  • 送信後は「ありがとうございます」というメッセージを表示する

解答例の要件

  • 基本的なHTMLフォーム構造
  • Flaskルートの設定
  • データ受け取りの実装

初級2: フォームデータの取得

問題
以下のフォームフィールドを持つユーザー登録フォームを作成してください。

  • ユーザー名(username)
  • パスワード(password)
  • 年齢(age)
  • 性別(gender:ラジオボタン)

フォーム送信後、受け取ったデータをコンソールに表示するFlaskアプリケーションを作成してください。

解答例の要件

  • 複数種類のフォーム要素の実装
  • request.formからのデータ取得
  • データの型変換(年齢は数値に)

初級3: 基本的なバリデーション

問題
名前(name)とメールアドレス(email)のフォームに以下のバリデーションを追加してください。

  • 名前は必須入力、2文字以上
  • メールアドレスは必須入力、@を含む
  • バリデーションエラー時はエラーメッセージを表示

解答例の要件

  • サーバーサイドバリデーションの実装
  • エラーメッセージの表示
  • フラッシュメッセージの使用

中級問題(6問)

中級1: ファイルアップロード

問題
画像ファイルをアップロードするフォームを作成してください。

  • アップロード可能なファイル形式はjpg, png, gifのみ
  • ファイルサイズは2MB以下に制限
  • アップロード成功時はファイル名を表示

解答例の要件

  • enctype=”multipart/form-data”の設定
  • ファイル形式のチェック
  • 安全なファイル名の処理

中級2: クライアントサイドバリデーション

問題
ユーザー登録フォームにHTML5のクライアントサイドバリデーションを追加してください。

  • ユーザー名:必須、3文字以上
  • メールアドレス:必須、email形式
  • パスワード:必須、8文字以上
  • Bootstrapのバリデーションスタイルを適用

解答例の要件

  • HTML5バリデーション属性の使用
  • Bootstrapのwas-validatedクラスの適用
  • カスタムバリデーションメッセージ

中級3: セッションを使ったフォームデータの保持

問題
マルチページフォームを作成し、セッションを使ってデータを保持してください。

  1. 1ページ目:基本情報(名前、メール)
  2. 2ページ目:詳細情報(年齢、職業)
  3. 確認ページ:全データの表示と送信

解答例の要件

  • Flaskセッションの使用
  • 複数ページでのデータ保持
  • データの確認表示

中級4: WTFormsの基本

問題
WTFormsを使用して以下のフォームを作成してください。

  • 商品名(product_name):必須、最大100文字
  • 価格(price):必須、数値、0以上
  • 在庫数(stock):必須、整数、0以上
  • カテゴリ(category):選択式(電子機器、書籍、衣類)

解答例の要件

  • FlaskFormクラスの定義
  • 各種バリデーターの使用
  • テンプレートでのフォームレンダリング

中級5: 動的フォーム要素

問題
JavaScriptを使用して動的にフォーム要素を追加できる機能を実装してください。

  • 「スキル追加」ボタンをクリックすると新しいスキル入力欄を追加
  • 各スキル入力欄には削除ボタンを設置
  • 最小1つ、最大5つのスキルを入力可能

解答例の要件

  • JavaScriptでのDOM操作
  • 動的な要素の追加・削除
  • サーバーサイドでの配列データ処理

中級6: 条件付き表示フォーム

問題
ユーザータイプによって表示するフォーム項目を変更してください。

  • ユーザータイプ(個人/企業)を選択
  • 個人選択時:個人情報(姓名、年齢)を表示
  • 企業選択時:企業情報(会社名、業種、従業員数)を表示

解答例の要件

  • JavaScriptでの表示切り替え
  • 条件に応じたフォーム項目の動的表示
  • サーバーサイドでの条件分岐処理

上級問題(3問)

上級1: 非同期フォーム送信(Ajax)

問題
Ajaxを使用して非同期でフォームを送信する機能を実装してください。

  • フォーム送信時にページ遷移しない
  • 送信中はローディング表示を出す
  • 成功/エラーに応じてメッセージを表示
  • バリデーションエラーはフォームに表示

解答例の要件

  • Fetch APIまたはjQuery Ajaxの使用
  • JSONレスポンスの処理
  • 動的なエラーメッセージ表示

上級2: カスタムバリデーション

問題
WTFormsで以下のカスタムバリデーションを作成してください。

  1. ユーザー名の重複チェック(仮想的なデータベースを想定)
  2. パスワードの強度チェック(大文字、小文字、数字を含む)
  3. 開始日と終了日の期間チェック

解答例の要件

  • カスタムバリデーター関数の作成
  • データベースチェックの模擬
  • 複数フィールドにまたがるバリデーション

上級3: フォームセキュリティ対策

問題
以下のセキュリティ対策を実装したフォームを作成してください。

  • CSRFトークンの実装
  • XSS対策(入力値のエスケープ)
  • クリックジャッキング対策
  • レート制限(1分間に3回まで送信可能)

解答例の要件

  • Flask-WTFでのCSRF保護
  • テンプレートエンジンでの自動エスケープ
  • ヘッダー設定によるクリックジャッキング対策
  • Flask-Limiterによるレート制限

演習問題 解答例

初級問題 解答例

初級1: 基本的なフォーム作成

app.py

from flask import Flask, render_template, request, redirect, url_for

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/submit', methods=['POST'])
def submit():
    name = request.form.get('name')
    email = request.form.get('email')

    print(f"受信したデータ: 名前={name}, メール={email}")

    return render_template('thankyou.html', name=name)

if __name__ == '__main__':
    app.run(debug=True)

templates/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>コンタクトフォーム</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">お問い合わせ</h1>

                <form method="POST" action="/submit">
                    <div class="mb-3">
                        <label for="name" class="form-label">お名前</label>
                        <input type="text" class="form-control" id="name" name="name" required>
                    </div>

                    <div class="mb-3">
                        <label for="email" class="form-label">メールアドレス</label>
                        <input type="email" class="form-control" id="email" name="email" required>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">送信</button>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

templates/thankyou.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>送信完了</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="alert alert-success text-center">
                    <h2>ありがとうございます</h2>
                    <p class="mb-0">{{ name }}様、お問い合わせを受け付けました。</p>
                </div>
                <a href="/" class="btn btn-secondary w-100">戻る</a>
            </div>
        </div>
    </div>
</body>
</html>

初級2: フォームデータの取得

app.py

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route('/')
def registration_form():
    return render_template('registration.html')

@app.route('/register', methods=['POST'])
def register():
    # フォームデータの取得
    username = request.form.get('username')
    password = request.form.get('password')
    age = request.form.get('age')
    gender = request.form.get('gender')

    # データの検証と変換
    try:
        age = int(age) if age else 0
    except ValueError:
        age = 0

    # コンソールに表示
    print("=== ユーザー登録情報 ===")
    print(f"ユーザー名: {username}")
    print(f"パスワード: {'*' * len(password)}")
    print(f"年齢: {age}")
    print(f"性別: {gender}")

    return f"""
    <div class='container mt-5'>
        <h2>登録完了</h2>
        <p>ユーザー名: {username}</p>
        <p>年齢: {age}</p>
        <p>性別: {gender}</p>
        <a href='/' class='btn btn-secondary'>戻る</a>
    </div>
    """

if __name__ == '__main__':
    app.run(debug=True)

templates/registration.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ユーザー登録</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">ユーザー登録</h1>

                <form method="POST" action="/register">
                    <div class="mb-3">
                        <label for="username" class="form-label">ユーザー名</label>
                        <input type="text" class="form-control" id="username" name="username" required>
                    </div>

                    <div class="mb-3">
                        <label for="password" class="form-label">パスワード</label>
                        <input type="password" class="form-control" id="password" name="password" required>
                    </div>

                    <div class="mb-3">
                        <label for="age" class="form-label">年齢</label>
                        <input type="number" class="form-control" id="age" name="age" min="0" max="120">
                    </div>

                    <div class="mb-3">
                        <label class="form-label">性別</label>
                        <div>
                            <div class="form-check form-check-inline">
                                <input class="form-check-input" type="radio" id="male" name="gender" value="male">
                                <label class="form-check-label" for="male">男性</label>
                            </div>
                            <div class="form-check form-check-inline">
                                <input class="form-check-input" type="radio" id="female" name="gender" value="female">
                                <label class="form-check-label" for="female">女性</label>
                            </div>
                            <div class="form-check form-check-inline">
                                <input class="form-check-input" type="radio" id="other" name="gender" value="other">
                                <label class="form-check-label" for="other">その他</label>
                            </div>
                        </div>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">登録</button>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

初級3: 基本的なバリデーション

app.py

from flask import Flask, render_template, request, flash, redirect, url_for

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

@app.route('/')
def contact_form():
    return render_template('contact_validation.html')

@app.route('/submit', methods=['POST'])
def submit_contact():
    name = request.form.get('name', '').strip()
    email = request.form.get('email', '').strip()

    # バリデーション
    errors = []

    # 名前のバリデーション
    if not name:
        errors.append('名前を入力してください')
    elif len(name) < 2:
        errors.append('名前は2文字以上で入力してください')

    # メールアドレスのバリデーション
    if not email:
        errors.append('メールアドレスを入力してください')
    elif '@' not in email:
        errors.append('有効なメールアドレスを入力してください')

    # エラーがある場合
    if errors:
        for error in errors:
            flash(error, 'error')
        return redirect(url_for('contact_form'))

    # 成功時の処理
    flash('お問い合わせありがとうございます。正常に送信されました。', 'success')
    return redirect(url_for('contact_form'))

if __name__ == '__main__':
    app.run(debug=True)

templates/contact_validation.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>コンタクトフォーム(バリデーション付き)</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">お問い合わせ</h1>

                <!-- フラッシュメッセージの表示 -->
                {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
                    {% raw %}{% if messages %}{% endraw %}
                        {% raw %}{% for category, message in messages %}{% endraw %}
                            <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
                                {{ message }}
                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                            </div>
                        {% raw %}{% endfor %}{% endraw %}
                    {% raw %}{% endif %}{% endraw %}
                {% raw %}{% endwith %}{% endraw %}

                <form method="POST" action="/submit">
                    <div class="mb-3">
                        <label for="name" class="form-label">お名前</label>
                        <input type="text" class="form-control" id="name" name="name" 
                               value="{{ request.form.name if request.form }}" required>
                    </div>

                    <div class="mb-3">
                        <label for="email" class="form-label">メールアドレス</label>
                        <input type="email" class="form-control" id="email" name="email"
                               value="{{ request.form.email if request.form }}" required>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">送信</button>
                </form>
            </div>
        </div>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

中級問題 解答例

中級1: ファイルアップロード

app.py

import os
from flask import Flask, render_template, request, flash, redirect, url_for
from werkzeug.utils import secure_filename

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

# 設定
app.config['UPLOAD_FOLDER'] = 'uploads'
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024  # 2MB
app.config['ALLOWED_EXTENSIONS'] = {'jpg', 'jpeg', 'png', 'gif'}

# アップロードフォルダの作成
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']

@app.route('/')
def upload_form():
    return render_template('upload.html')

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'file' not in request.files:
        flash('ファイルが選択されていません', 'error')
        return redirect(request.url)

    file = request.files['file']

    if file.filename == '':
        flash('ファイルが選択されていません', 'error')
        return redirect(request.url)

    if file and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
        file.save(filepath)

        flash(f'ファイル "{filename}" が正常にアップロードされました', 'success')
        return redirect(url_for('upload_form'))
    else:
        flash('許可されていないファイル形式です。jpg, png, gif形式のみアップロードできます。', 'error')
        return redirect(request.url)

if __name__ == '__main__':
    app.run(debug=True)

templates/upload.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ファイルアップロード</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">画像アップロード</h1>

                {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
                    {% raw %}{% if messages %}{% endraw %}
                        {% raw %}{% for category, message in messages %}{% endraw %}
                            <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
                                {{ message }}
                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                            </div>
                        {% raw %}{% endfor %}{% endraw %}
                    {% raw %}{% endif %}{% endraw %}
                {% raw %}{% endwith %}{% endraw %}

                <form method="POST" action="/upload" enctype="multipart/form-data">
                    <div class="mb-3">
                        <label for="file" class="form-label">画像ファイルを選択</label>
                        <input type="file" class="form-control" id="file" name="file" accept=".jpg,.jpeg,.png,.gif" required>
                        <div class="form-text">
                            対応形式: JPG, PNG, GIF(最大2MB)
                        </div>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">アップロード</button>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

中級2: クライアントサイドバリデーション

app.py

from flask import Flask, render_template, request, flash, redirect, url_for

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

@app.route('/')
def registration_form():
    return render_template('client_validation.html')

@app.route('/register', methods=['POST'])
def register():
    username = request.form.get('username', '').strip()
    email = request.form.get('email', '').strip()
    password = request.form.get('password', '')

    # サーバーサイドバリデーション(念のため)
    errors = []

    if len(username) < 3:
        errors.append('ユーザー名は3文字以上で入力してください')

    if '@' not in email:
        errors.append('有効なメールアドレスを入力してください')

    if len(password) < 8:
        errors.append('パスワードは8文字以上で入力してください')

    if errors:
        for error in errors:
            flash(error, 'error')
        return redirect(url_for('registration_form'))

    flash('登録が完了しました', 'success')
    return redirect(url_for('registration_form'))

if __name__ == '__main__':
    app.run(debug=True)

templates/client_validation.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ユーザー登録(クライアントバリデーション)</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">ユーザー登録</h1>

                {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
                    {% raw %}{% if messages %}{% endraw %}
                        {% raw %}{% for category, message in messages %}{% endraw %}
                            <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
                                {{ message }}
                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                            </div>
                        {% raw %}{% endfor %}{% endraw %}
                    {% raw %}{% endif %}{% endraw %}
                {% raw %}{% endwith %}{% endraw %}

                <form method="POST" action="/register" class="needs-validation" novalidate>
                    <div class="mb-3">
                        <label for="username" class="form-label">ユーザー名</label>
                        <input type="text" class="form-control" id="username" name="username" 
                               minlength="3" required>
                        <div class="invalid-feedback">
                            ユーザー名は3文字以上で入力してください
                        </div>
                    </div>

                    <div class="mb-3">
                        <label for="email" class="form-label">メールアドレス</label>
                        <input type="email" class="form-control" id="email" name="email" required>
                        <div class="invalid-feedback">
                            有効なメールアドレスを入力してください
                        </div>
                    </div>

                    <div class="mb-3">
                        <label for="password" class="form-label">パスワード</label>
                        <input type="password" class="form-control" id="password" name="password" 
                               minlength="8" required>
                        <div class="invalid-feedback">
                            パスワードは8文字以上で入力してください
                        </div>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">登録</button>
                </form>
            </div>
        </div>
    </div>

    <script>
    // クライアントサイドバリデーション
    (function() {
        'use strict'

        const forms = document.querySelectorAll('.needs-validation')

        Array.from(forms).forEach(function(form) {
            form.addEventListener('submit', function(event) {
                if (!form.checkValidity()) {
                    event.preventDefault()
                    event.stopPropagation()
                }

                form.classList.add('was-validated')
            }, false)
        })
    })()
    </script>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

中級3: セッションを使ったフォームデータの保持

app.py

from flask import Flask, render_template, request, session, redirect, url_for

app = Flask(__name__)
app.secret_key = 'your_very_secret_key_here'

@app.route('/')
def index():
    session.clear()  # セッションをクリア
    return redirect(url_for('step1'))

@app.route('/step1', methods=['GET', 'POST'])
def step1():
    if request.method == 'POST':
        # 基本情報をセッションに保存
        session['name'] = request.form.get('name', '')
        session['email'] = request.form.get('email', '')
        return redirect(url_for('step2'))

    return render_template('step1.html')

@app.route('/step2', methods=['GET', 'POST'])
def step2():
    if request.method == 'POST':
        # 詳細情報をセッションに保存
        session['age'] = request.form.get('age', '')
        session['occupation'] = request.form.get('occupation', '')
        return redirect(url_for('confirm'))

    return render_template('step2.html')

@app.route('/confirm', methods=['GET', 'POST'])
def confirm():
    if request.method == 'POST':
        # 最終送信処理
        print("最終データ:", dict(session))
        return redirect(url_for('complete'))

    return render_template('confirm.html', data=session)

@app.route('/complete')
def complete():
    data = dict(session)
    session.clear()  # セッションをクリア
    return render_template('complete.html', data=data)

if __name__ == '__main__':
    app.run(debug=True)

templates/step1.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ステップ1 - 基本情報</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="progress mb-4">
                    <div class="progress-bar" style="width: 25%">25%</div>
                </div>

                <h1 class="text-center mb-4">基本情報</h1>

                <form method="POST">
                    <div class="mb-3">
                        <label for="name" class="form-label">お名前</label>
                        <input type="text" class="form-control" id="name" name="name" 
                               value="{% raw %}{{ session.name if session.name }}{% endraw %}" required>
                    </div>

                    <div class="mb-3">
                        <label for="email" class="form-label">メールアドレス</label>
                        <input type="email" class="form-control" id="email" name="email"
                               value="{% raw %}{{ session.email if session.email }}{% endraw %}" required>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">次へ</button>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

templates/step2.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ステップ2 - 詳細情報</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="progress mb-4">
                    <div class="progress-bar" style="width: 50%">50%</div>
                </div>

                <h1 class="text-center mb-4">詳細情報</h1>

                <form method="POST">
                    <div class="mb-3">
                        <label for="age" class="form-label">年齢</label>
                        <input type="number" class="form-control" id="age" name="age" 
                               value="{% raw %}{{ session.age if session.age }}{% endraw %}">
                    </div>

                    <div class="mb-3">
                        <label for="occupation" class="form-label">職業</label>
                        <select class="form-select" id="occupation" name="occupation">
                            <option value="">選択してください</option>
                            <option value="student" {% raw %}{{ 'selected' if session.occupation == 'student' }}{% endraw %}>学生</option>
                            <option value="company" {% raw %}{{ 'selected' if session.occupation == 'company' }}{% endraw %}>会社員</option>
                            <option value="self" {% raw %}{{ 'selected' if session.occupation == 'self' }}{% endraw %}>自営業</option>
                            <option value="other" {% raw %}{{ 'selected' if session.occupation == 'other' }}{% endraw %}>その他</option>
                        </select>
                    </div>

                    <div class="d-flex gap-2">
                        <a href="{% raw %}{{ url_for('step1') }}{% endraw %}" class="btn btn-secondary w-50">戻る</a>
                        <button type="submit" class="btn btn-primary w-50">次へ</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

templates/confirm.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>確認画面</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="progress mb-4">
                    <div class="progress-bar" style="width: 75%">75%</div>
                </div>

                <h1 class="text-center mb-4">入力内容確認</h1>

                <div class="card mb-4">
                    <div class="card-body">
                        <h5 class="card-title">基本情報</h5>
                        <p><strong>名前:</strong> {% raw %}{{ data.name }}{% endraw %}</p>
                        <p><strong>メール:</strong> {% raw %}{{ data.email }}{% endraw %}</p>
                    </div>
                </div>

                <div class="card mb-4">
                    <div class="card-body">
                        <h5 class="card-title">詳細情報</h5>
                        <p><strong>年齢:</strong> {% raw %}{{ data.age }}{% endraw %}</p>
                        <p><strong>職業:</strong> {% raw %}{{ data.occupation }}{% endraw %}</p>
                    </div>
                </div>

                <form method="POST">
                    <div class="d-flex gap-2">
                        <a href="{% raw %}{{ url_for('step2') }}{% endraw %}" class="btn btn-secondary w-50">戻る</a>
                        <button type="submit" class="btn btn-success w-50">送信</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

templates/complete.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>完了</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="progress mb-4">
                    <div class="progress-bar" style="width: 100%">100%</div>
                </div>

                <div class="alert alert-success text-center">
                    <h2>登録完了</h2>
                    <p class="mb-0">登録が正常に完了しました</p>
                </div>

                <div class="card">
                    <div class="card-body">
                        <h5 class="card-title">登録内容</h5>
                        {% raw %}{% for key, value in data.items() %}{% endraw %}
                            <p><strong>{% raw %}{{ key }}{% endraw %}:</strong> {% raw %}{{ value }}{% endraw %}</p>
                        {% raw %}{% endfor %}{% endraw %}
                    </div>
                </div>

                <a href="{% raw %}{{ url_for('index') }}{% endraw %}" class="btn btn-primary w-100 mt-3">最初から</a>
            </div>
        </div>
    </div>
</body>
</html>

中級4: WTFormsの基本

app.py

from flask import Flask, render_template, flash, redirect, url_for
from flask_wtf import FlaskForm
from wtforms import StringField, DecimalField, IntegerField, SelectField, SubmitField
from wtforms.validators import DataRequired, NumberRange, Length

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

class ProductForm(FlaskForm):
    product_name = StringField('商品名', validators=[
        DataRequired(message='商品名は必須です'),
        Length(max=100, message='商品名は100文字以内で入力してください')
    ])

    price = DecimalField('価格', validators=[
        DataRequired(message='価格は必須です'),
        NumberRange(min=0, message='価格は0以上で入力してください')
    ], places=2)

    stock = IntegerField('在庫数', validators=[
        DataRequired(message='在庫数は必須です'),
        NumberRange(min=0, message='在庫数は0以上で入力してください')
    ])

    category = SelectField('カテゴリ', choices=[
        ('electronics', '電子機器'),
        ('books', '書籍'),
        ('clothing', '衣類'),
        ('other', 'その他')
    ], validators=[DataRequired(message='カテゴリを選択してください')])

    submit = SubmitField('登録')

@app.route('/', methods=['GET', 'POST'])
def add_product():
    form = ProductForm()

    if form.validate_on_submit():
        # フォームデータの処理
        product_data = {
            'product_name': form.product_name.data,
            'price': float(form.price.data),
            'stock': form.stock.data,
            'category': form.category.data
        }

        print("商品データ:", product_data)
        flash('商品が正常に登録されました', 'success')
        return redirect(url_for('add_product'))

    return render_template('product_form.html', form=form)

if __name__ == '__main__':
    app.run(debug=True)

templates/product_form.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>商品登録</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">商品登録</h1>

                {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
                    {% raw %}{% if messages %}{% endraw %}
                        {% raw %}{% for category, message in messages %}{% endraw %}
                            <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
                                {% raw %}{{ message }}{% endraw %}
                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                            </div>
                        {% raw %}{% endfor %}{% endraw %}
                    {% raw %}{% endif %}{% endraw %}
                {% raw %}{% endwith %}{% endraw %}

                <form method="POST">
                    {% raw %}{{ form.hidden_tag() }}{% endraw %}

                    <div class="mb-3">
                        {% raw %}{{ form.product_name.label(class="form-label") }}{% endraw %}
                        {% raw %}{{ form.product_name(class="form-control") }}{% endraw %}
                        {% raw %}{% if form.product_name.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.product_name.errors %}{% endraw %}
                                    <small>{% raw %}{{ error }}{% endraw %}</small>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                    </div>

                    <div class="mb-3">
                        {% raw %}{{ form.price.label(class="form-label") }}{% endraw %}
                        {% raw %}{{ form.price(class="form-control", step="0.01") }}{% endraw %}
                        {% raw %}{% if form.price.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.price.errors %}{% endraw %}
                                    <small>{% raw %}{{ error }}{% endraw %}</small>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                    </div>

                    <div class="mb-3">
                        {% raw %}{{ form.stock.label(class="form-label") }}{% endraw %}
                        {% raw %}{{ form.stock(class="form-control") }}{% endraw %}
                        {% raw %}{% if form.stock.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.stock.errors %}{% endraw %}
                                    <small>{% raw %}{{ error }}{% endraw %}</small>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                    </div>

                    <div class="mb-3">
                        {% raw %}{{ form.category.label(class="form-label") }}{% endraw %}
                        {% raw %}{{ form.category(class="form-select") }}{% endraw %}
                        {% raw %}{% if form.category.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.category.errors %}{% endraw %}
                                    <small>{% raw %}{{ error }}{% endraw %}</small>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                    </div>

                    {% raw %}{{ form.submit(class="btn btn-primary w-100") }}{% endraw %}
                </form>
            </div>
        </div>
    </div>
</body>
</html>

中級5: 動的フォーム要素

app.py

from flask import Flask, render_template, request, flash, redirect, url_for
import json

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

@app.route('/')
def skill_form():
    return render_template('dynamic_form.html')

@app.route('/submit-skills', methods=['POST'])
def submit_skills():
    # スキルデータの取得(配列として送信される)
    skills = request.form.getlist('skills[]')

    # 空の要素を除去
    skills = [skill.strip() for skill in skills if skill.strip()]

    # バリデーション
    if len(skills) == 0:
        flash('少なくとも1つのスキルを入力してください', 'error')
        return redirect(url_for('skill_form'))

    if len(skills) > 5:
        flash('スキルは最大5つまでです', 'error')
        return redirect(url_for('skill_form'))

    # 処理
    print("登録されたスキル:", skills)
    flash(f'{len(skills)}個のスキルが登録されました: {", ".join(skills)}', 'success')
    return redirect(url_for('skill_form'))

if __name__ == '__main__':
    app.run(debug=True)

templates/dynamic_form.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>スキル登録</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <h1 class="text-center mb-4">スキル登録</h1>

                {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
                    {% raw %}{% if messages %}{% endraw %}
                        {% raw %}{% for category, message in messages %}{% endraw %}
                            <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
                                {% raw %}{{ message }}{% endraw %}
                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                            </div>
                        {% raw %}{% endfor %}{% endraw %}
                    {% raw %}{% endif %}{% endraw %}
                {% raw %}{% endwith %}{% endraw %}

                <form method="POST" action="/submit-skills" id="skills-form">
                    <div id="skills-container">
                        <div class="skill-group mb-3">
                            <label class="form-label">スキル 1</label>
                            <div class="input-group">
                                <input type="text" class="form-control" name="skills[]" placeholder="例: Python, JavaScript, デザイン...">
                                <button type="button" class="btn btn-outline-danger remove-skill" disabled>削除</button>
                            </div>
                        </div>
                    </div>

                    <div class="mb-3">
                        <button type="button" id="add-skill" class="btn btn-outline-primary">スキルを追加</button>
                        <small class="text-muted">(最大5つまで)</small>
                    </div>

                    <button type="submit" class="btn btn-primary w-100">登録</button>
                </form>
            </div>
        </div>
    </div>

    <script>
    document.addEventListener('DOMContentLoaded', function() {
        const skillsContainer = document.getElementById('skills-container');
        const addSkillButton = document.getElementById('add-skill');
        let skillCount = 1;
        const maxSkills = 5;

        addSkillButton.addEventListener('click', function() {
            if (skillCount >= maxSkills) {
                alert(`スキルは最大${maxSkills}つまでです`);
                return;
            }

            skillCount++;
            const newSkillGroup = document.createElement('div');
            newSkillGroup.className = 'skill-group mb-3';
            newSkillGroup.innerHTML = `
                <label class="form-label">スキル ${skillCount}</label>
                <div class="input-group">
                    <input type="text" class="form-control" name="skills[]" placeholder="例: Python, JavaScript, デザイン...">
                    <button type="button" class="btn btn-outline-danger remove-skill">削除</button>
                </div>
            `;

            skillsContainer.appendChild(newSkillGroup);
            updateRemoveButtons();
        });

        skillsContainer.addEventListener('click', function(e) {
            if (e.target.classList.contains('remove-skill')) {
                if (skillCount > 1) {
                    e.target.closest('.skill-group').remove();
                    skillCount--;
                    updateLabels();
                    updateRemoveButtons();
                }
            }
        });

        function updateLabels() {
            const groups = skillsContainer.querySelectorAll('.skill-group');
            groups.forEach((group, index) => {
                group.querySelector('label').textContent = `スキル ${index + 1}`;
            });
        }

        function updateRemoveButtons() {
            const removeButtons = skillsContainer.querySelectorAll('.remove-skill');
            removeButtons.forEach(button => {
                button.disabled = removeButtons.length === 1;
            });

            addSkillButton.disabled = skillCount >= maxSkills;
        }

        updateRemoveButtons();
    });
    </script>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

中級6: 条件付き表示フォーム

app.py

from flask import Flask, render_template, request, flash, redirect, url_for

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

@app.route('/')
def user_form():
    return render_template('conditional_form.html')

@app.route('/submit-user', methods=['POST'])
def submit_user():
    user_type = request.form.get('user_type')

    if user_type == 'personal':
        # 個人情報の処理
        first_name = request.form.get('first_name', '').strip()
        last_name = request.form.get('last_name', '').strip()
        age = request.form.get('age', '').strip()

        if not first_name or not last_name:
            flash('姓名を入力してください', 'error')
            return redirect(url_for('user_form'))

        user_data = {
            'type': '個人',
            'first_name': first_name,
            'last_name': last_name,
            'age': age
        }

    elif user_type == 'business':
        # 企業情報の処理
        company_name = request.form.get('company_name', '').strip()
        industry = request.form.get('industry', '').strip()
        employees = request.form.get('employees', '').strip()

        if not company_name:
            flash('会社名を入力してください', 'error')
            return redirect(url_for('user_form'))

        user_data = {
            'type': '企業',
            'company_name': company_name,
            'industry': industry,
            'employees': employees
        }

    else:
        flash('ユーザータイプを選択してください', 'error')
        return redirect(url_for('user_form'))

    print("登録データ:", user_data)
    flash('登録が完了しました', 'success')
    return redirect(url_for('user_form'))

if __name__ == '__main__':
    app.run(debug=True)

templates/conditional_form.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ユーザー登録</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <h1 class="text-center mb-4">ユーザー登録</h1>

                {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
                    {% raw %}{% if messages %}{% endraw %}
                        {% raw %}{% for category, message in messages %}{% endraw %}
                            <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
                                {% raw %}{{ message }}{% endraw %}
                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                            </div>
                        {% raw %}{% endfor %}{% endraw %}
                    {% raw %}{% endif %}{% endraw %}
                {% raw %}{% endwith %}{% endraw %}

                <form method="POST" action="/submit-user">
                    <!-- ユーザータイプ選択 -->
                    <div class="mb-4">
                        <label class="form-label fw-bold">ユーザータイプ</label>
                        <div class="row">
                            <div class="col-md-6">
                                <div class="form-check card">
                                    <input class="form-check-input" type="radio" name="user_type" 
                                           id="personal" value="personal" onchange="toggleForm()">
                                    <label class="form-check-label card-body" for="personal">
                                        <h5 class="card-title">個人</h5>
                                        <p class="card-text text-muted">個人でご利用の場合</p>
                                    </label>
                                </div>
                            </div>
                            <div class="col-md-6">
                                <div class="form-check card">
                                    <input class="form-check-input" type="radio" name="user_type" 
                                           id="business" value="business" onchange="toggleForm()">
                                    <label class="form-check-label card-body" for="business">
                                        <h5 class="card-title">企業</h5>
                                        <p class="card-text text-muted">法人でご利用の場合</p>
                                    </label>
                                </div>
                            </div>
                        </div>
                    </div>

                    <!-- 個人情報フォーム -->
                    <div id="personal-form" class="form-section" style="display: none;">
                        <h4 class="mb-3">個人情報</h4>
                        <div class="row">
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="first_name" class="form-label">姓</label>
                                    <input type="text" class="form-control" id="first_name" name="first_name">
                                </div>
                            </div>
                            <div class="col-md-6">
                                <div class="mb-3">
                                    <label for="last_name" class="form-label">名</label>
                                    <input type="text" class="form-control" id="last_name" name="last_name">
                                </div>
                            </div>
                        </div>
                        <div class="mb-3">
                            <label for="age" class="form-label">年齢</label>
                            <input type="number" class="form-control" id="age" name="age" min="0" max="120">
                        </div>
                    </div>

                    <!-- 企業情報フォーム -->
                    <div id="business-form" class="form-section" style="display: none;">
                        <h4 class="mb-3">企業情報</h4>
                        <div class="mb-3">
                            <label for="company_name" class="form-label">会社名</label>
                            <input type="text" class="form-control" id="company_name" name="company_name">
                        </div>
                        <div class="mb-3">
                            <label for="industry" class="form-label">業種</label>
                            <select class="form-select" id="industry" name="industry">
                                <option value="">選択してください</option>
                                <option value="it">IT・テクノロジー</option>
                                <option value="manufacturing">製造業</option>
                                <option value="retail">小売業</option>
                                <option value="service">サービス業</option>
                                <option value="other">その他</option>
                            </select>
                        </div>
                        <div class="mb-3">
                            <label for="employees" class="form-label">従業員数</label>
                            <select class="form-select" id="employees" name="employees">
                                <option value="">選択してください</option>
                                <option value="1-10">1-10名</option>
                                <option value="11-50">11-50名</option>
                                <option value="51-200">51-200名</option>
                                <option value="201-500">201-500名</option>
                                <option value="501+">501名以上</option>
                            </select>
                        </div>
                    </div>

                    <button type="submit" class="btn btn-primary w-100 mt-4" id="submit-btn" style="display: none;">
                        登録
                    </button>
                </form>
            </div>
        </div>
    </div>

    <script>
    function toggleForm() {
        const personalRadio = document.getElementById('personal');
        const businessRadio = document.getElementById('business');
        const personalForm = document.getElementById('personal-form');
        const businessForm = document.getElementById('business-form');
        const submitBtn = document.getElementById('submit-btn');

        if (personalRadio.checked) {
            personalForm.style.display = 'block';
            businessForm.style.display = 'none';
            submitBtn.style.display = 'block';
        } else if (businessRadio.checked) {
            personalForm.style.display = 'none';
            businessForm.style.display = 'block';
            submitBtn.style.display = 'block';
        } else {
            personalForm.style.display = 'none';
            businessForm.style.display = 'none';
            submitBtn.style.display = 'none';
        }
    }

    document.addEventListener('DOMContentLoaded', function() {
        toggleForm();
    });
    </script>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

上級問題 解答例

上級1: 非同期フォーム送信(Ajax)

app.py

from flask import Flask, render_template, request, jsonify
import time

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

@app.route('/')
def ajax_form():
    return render_template('ajax_form.html')

@app.route('/api/register', methods=['POST'])
def api_register():
    # 擬似的な処理時間
    time.sleep(1)

    data = request.get_json()
    username = data.get('username', '').strip()
    email = data.get('email', '').strip()
    password = data.get('password', '')

    # バリデーション
    errors = {}

    if not username:
        errors['username'] = 'ユーザー名を入力してください'
    elif len(username) < 3:
        errors['username'] = 'ユーザー名は3文字以上で入力してください'

    if not email:
        errors['email'] = 'メールアドレスを入力してください'
    elif '@' not in email:
        errors['email'] = '有効なメールアドレスを入力してください'

    if not password:
        errors['password'] = 'パスワードを入力してください'
    elif len(password) < 8:
        errors['password'] = 'パスワードは8文字以上で入力してください'

    if errors:
        return jsonify({'success': False, 'errors': errors})

    # 成功時の処理
    return jsonify({
        'success': True, 
        'message': f'ユーザー {username} が登録されました'
    })

if __name__ == '__main__':
    app.run(debug=True)

templates/ajax_form.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>非同期フォーム</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">非同期ユーザー登録</h1>

                <div id="message-container"></div>

                <form id="register-form">
                    <div class="mb-3">
                        <label for="username" class="form-label">ユーザー名</label>
                        <input type="text" class="form-control" id="username" name="username" required>
                        <div class="invalid-feedback" id="username-error"></div>
                    </div>

                    <div class="mb-3">
                        <label for="email" class="form-label">メールアドレス</label>
                        <input type="email" class="form-control" id="email" name="email" required>
                        <div class="invalid-feedback" id="email-error"></div>
                    </div>

                    <div class="mb-3">
                        <label for="password" class="form-label">パスワード</label>
                        <input type="password" class="form-control" id="password" name="password" required>
                        <div class="invalid-feedback" id="password-error"></div>
                    </div>

                    <button type="submit" class="btn btn-primary w-100" id="submit-btn">
                        登録
                    </button>

                    <div class="text-center mt-3">
                        <div class="spinner-border text-primary" role="status" id="loading-spinner" style="display: none;">
                            <span class="visually-hidden">読み込み中...</span>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    </div>

    <script>
    document.addEventListener('DOMContentLoaded', function() {
        const form = document.getElementById('register-form');
        const submitBtn = document.getElementById('submit-btn');
        const loadingSpinner = document.getElementById('loading-spinner');
        const messageContainer = document.getElementById('message-container');

        form.addEventListener('submit', async function(e) {
            e.preventDefault();

            submitBtn.disabled = true;
            loadingSpinner.style.display = 'block';
            submitBtn.innerHTML = '送信中...';

            clearErrors();
            clearMessage();

            const formData = {
                username: document.getElementById('username').value,
                email: document.getElementById('email').value,
                password: document.getElementById('password').value
            };

            try {
                const response = await fetch('/api/register', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(formData)
                });

                const result = await response.json();

                if (result.success) {
                    showMessage(result.message, 'success');
                    form.reset();
                } else {
                    showErrors(result.errors);
                    showMessage('入力内容にエラーがあります', 'error');
                }

            } catch (error) {
                showMessage('通信エラーが発生しました', 'error');
                console.error('Error:', error);
            } finally {
                submitBtn.disabled = false;
                loadingSpinner.style.display = 'none';
                submitBtn.innerHTML = '登録';
            }
        });

        function showErrors(errors) {
            for (const field in errors) {
                const input = document.getElementById(field);
                const errorDiv = document.getElementById(field + '-error');

                if (input && errorDiv) {
                    input.classList.add('is-invalid');
                    errorDiv.textContent = errors[field];
                }
            }
        }

        function clearErrors() {
            const inputs = form.querySelectorAll('input');
            inputs.forEach(input => input.classList.remove('is-invalid'));

            const errorDivs = form.querySelectorAll('.invalid-feedback');
            errorDivs.forEach(div => div.textContent = '');
        }

        function showMessage(message, type) {
            const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
            messageContainer.innerHTML = `
                <div class="alert ${alertClass} alert-dismissible fade show">
                    ${message}
                    <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                </div>
            `;
        }

        function clearMessage() {
            messageContainer.innerHTML = '';
        }
    });
    </script>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

上級2: カスタムバリデーション

app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, DateField, SubmitField
from wtforms.validators import DataRequired, ValidationError
from datetime import datetime, date
import re

app = Flask(__name__)
app.secret_key = 'your_secret_key_here'

# 仮想的なユーザーデータベース
existing_usernames = ['admin', 'user123', 'testuser']

def validate_username_availability(form, field):
    """ユーザー名の重複チェック"""
    if field.data in existing_usernames:
        raise ValidationError('このユーザー名は既に使用されています')

def validate_password_strength(form, field):
    """パスワードの強度チェック"""
    password = field.data

    if len(password) < 8:
        raise ValidationError('パスワードは8文字以上で入力してください')

    if not re.search(r'[A-Z]', password):
        raise ValidationError('パスワードには大文字を含めてください')

    if not re.search(r'[a-z]', password):
        raise ValidationError('パスワードには小文字を含めてください')

    if not re.search(r'[0-9]', password):
        raise ValidationError('パスワードには数字を含めてください')

def validate_date_range(form, field):
    """開始日と終了日の期間チェック"""
    if form.start_date.data and form.end_date.data:
        if form.start_date.data >= form.end_date.data:
            raise ValidationError('終了日は開始日より後に設定してください')

        # 期間が長すぎないかチェック
        days_diff = (form.end_date.data - form.start_date.data).days
        if days_diff > 365:
            raise ValidationError('期間は1年以内に設定してください')

class AdvancedForm(FlaskForm):
    username = StringField('ユーザー名', validators=[
        DataRequired(message='ユーザー名は必須です'),
        validate_username_availability
    ])

    password = PasswordField('パスワード', validators=[
        DataRequired(message='パスワードは必須です'),
        validate_password_strength
    ])

    start_date = DateField('開始日', validators=[
        DataRequired(message='開始日は必須です')
    ], format='%Y-%m-%d')

    end_date = DateField('終了日', validators=[
        DataRequired(message='終了日は必須です'),
        validate_date_range
    ], format='%Y-%m-%d')

    submit = SubmitField('登録')

@app.route('/', methods=['GET', 'POST'])
def advanced_form():
    form = AdvancedForm()

    if form.validate_on_submit():
        # フォームデータの処理
        user_data = {
            'username': form.username.data,
            'start_date': form.start_date.data.isoformat(),
            'end_date': form.end_date.data.isoformat()
        }

        print("登録データ:", user_data)
        return render_template('advanced_success.html', data=user_data)

    return render_template('advanced_form.html', form=form)

if __name__ == '__main__':
    app.run(debug=True)

templates/advanced_form.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>高度なバリデーション</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">高度なバリデーション</h1>

                <form method="POST">
                    {{ form.hidden_tag() }}

                    <div class="mb-3">
                        {{ form.username.label(class="form-label") }}
                        {{ form.username(class="form-control", placeholder="ユーザー名を入力") }}
                        {% raw %}{% if form.username.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.username.errors %}{% endraw %}
                                    <small>{{ error }}</small><br>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                        <div class="form-text">
                            既存のユーザー名は使用できません
                        </div>
                    </div>

                    <div class="mb-3">
                        {{ form.password.label(class="form-label") }}
                        {{ form.password(class="form-control", placeholder="パスワードを入力") }}
                        {% raw %}{% if form.password.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.password.errors %}{% endraw %}
                                    <small>{{ error }}</small><br>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                        <div class="form-text">
                            パスワードは8文字以上で、大文字・小文字・数字を含めてください
                        </div>
                    </div>

                    <div class="row">
                        <div class="col-md-6">
                            <div class="mb-3">
                                {{ form.start_date.label(class="form-label") }}
                                {{ form.start_date(class="form-control", type="date") }}
                                {% raw %}{% if form.start_date.errors %}{% endraw %}
                                    <div class="text-danger">
                                        {% raw %}{% for error in form.start_date.errors %}{% endraw %}
                                            <small>{{ error }}</small><br>
                                        {% raw %}{% endfor %}{% endraw %}
                                    </div>
                                {% raw %}{% endif %}{% endraw %}
                            </div>
                        </div>
                        <div class="col-md-6">
                            <div class="mb-3">
                                {{ form.end_date.label(class="form-label") }}
                                {{ form.end_date(class="form-control", type="date") }}
                                {% raw %}{% if form.end_date.errors %}{% endraw %}
                                    <div class="text-danger">
                                        {% raw %}{% for error in form.end_date.errors %}{% endraw %}
                                            <small>{{ error }}</small><br>
                                        {% raw %}{% endfor %}{% endraw %}
                                    </div>
                                {% raw %}{% endif %}{% endraw %}
                            </div>
                        </div>
                    </div>

                    {% raw %}{% if form.end_date.errors %}{% endraw %}
                        <div class="text-danger mb-3">
                            {% raw %}{% for error in form.end_date.errors %}{% endraw %}
                                {% raw %}{% if "開始日" in error %}{% endraw %}
                                    <small>{{ error }}</small><br>
                                {% raw %}{% endif %}{% endraw %}
                            {% raw %}{% endfor %}{% endraw %}
                        </div>
                    {% raw %}{% endif %}{% endraw %}

                    {{ form.submit(class="btn btn-primary w-100") }}
                </form>
            </div>
        </div>
    </div>
</body>
</html>

templates/advanced_success.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登録完了</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="alert alert-success text-center">
                    <h2>登録完了</h2>
                    <p class="mb-0">登録が正常に完了しました</p>
                </div>

                <div class="card">
                    <div class="card-body">
                        <h5 class="card-title">登録内容</h5>
                        <p><strong>ユーザー名:</strong> {{ data.username }}</p>
                        <p><strong>開始日:</strong> {{ data.start_date }}</p>
                        <p><strong>終了日:</strong> {{ data.end_date }}</p>
                    </div>
                </div>

                <a href="/" class="btn btn-primary w-100 mt-3">最初から</a>
            </div>
        </div>
    </div>
</body>
</html>

上級3: フォームセキュリティ対策

app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm, CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length
from markupsafe import escape

app = Flask(__name__)
app.secret_key = 'your_very_secret_key_here_12345'

# CSRF保護の設定
csrf = CSRFProtect()
csrf.init_app(app)

# レート制限の設定
limiter = Limiter(
    get_remote_address,
    app=app,
    default_limits=["200 per day", "50 per hour"],
    storage_uri="memory://",
)

class SecureForm(FlaskForm):
    name = StringField('お名前', validators=[
        DataRequired(message='名前は必須です'),
        Length(max=50, message='名前は50文字以内で入力してください')
    ])

    message = TextAreaField('メッセージ', validators=[
        DataRequired(message='メッセージは必須です'),
        Length(max=1000, message='メッセージは1000文字以内で入力してください')
    ])

    submit = SubmitField('送信')

@app.route('/')
def secure_form():
    form = SecureForm()
    return render_template('secure_form.html', form=form)

@app.route('/submit', methods=['POST'])
@limiter.limit("3 per minute")  # 1分間に3回まで
def submit_secure():
    form = SecureForm()

    if form.validate_on_submit():
        # XSS対策: 入力値のエスケープ
        safe_name = escape(form.name.data)
        safe_message = escape(form.message.data)

        # 安全なデータの処理
        print(f"安全なデータ - 名前: {safe_name}, メッセージ: {safe_message}")

        return render_template('secure_success.html', 
                             name=safe_name, 
                             message=safe_message)

    return render_template('secure_form.html', form=form)

# クリックジャッキング対策
@app.after_request
def set_security_headers(response):
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-XSS-Protection'] = '1; mode=block'
    return response

if __name__ == '__main__':
    app.run(debug=True)

templates/secure_form.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>セキュアなフォーム</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <h1 class="text-center mb-4">セキュアなコンタクトフォーム</h1>

                {% raw %}{% with messages = get_flashed_messages(with_categories=true) %}{% endraw %}
                    {% raw %}{% if messages %}{% endraw %}
                        {% raw %}{% for category, message in messages %}{% endraw %}
                            <div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
                                {{ message }}
                                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                            </div>
                        {% raw %}{% endfor %}{% endraw %}
                    {% raw %}{% endif %}{% endraw %}
                {% raw %}{% endwith %}{% endraw %}

                <form method="POST">
                    {{ form.hidden_tag() }}

                    <div class="mb-3">
                        {{ form.name.label(class="form-label") }}
                        {{ form.name(class="form-control") }}
                        {% raw %}{% if form.name.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.name.errors %}{% endraw %}
                                    <small>{{ error }}</small>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                    </div>

                    <div class="mb-3">
                        {{ form.message.label(class="form-label") }}
                        {{ form.message(class="form-control", rows="5") }}
                        {% raw %}{% if form.message.errors %}{% endraw %}
                            <div class="text-danger">
                                {% raw %}{% for error in form.message.errors %}{% endraw %}
                                    <small>{{ error }}</small>
                                {% raw %}{% endfor %}{% endraw %}
                            </div>
                        {% raw %}{% endif %}{% endraw %}
                    </div>

                    {{ form.submit(class="btn btn-primary w-100") }}

                    <div class="text-center mt-3">
                        <small class="text-muted">
                            セキュリティ対策: CSRF保護, XSS対策, クリックジャッキング対策, レート制限
                        </small>
                    </div>
                </form>
            </div>
        </div>
    </div>
</body>
</html>

templates/secure_success.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>送信完了</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container mt-5">
        <div class="row justify-content-center">
            <div class="col-md-6">
                <div class="alert alert-success text-center">
                    <h2>送信完了</h2>
                    <p class="mb-0">メッセージが正常に送信されました</p>
                </div>

                <div class="card">
                    <div class="card-body">
                        <h5 class="card-title">送信内容</h5>
                        <p><strong>名前:</strong> {{ name }}</p>
                        <p><strong>メッセージ:</strong></p>
                        <div class="border p-3 bg-light">
                            {{ message|safe }}
                        </div>
                    </div>
                </div>

                <a href="/" class="btn btn-primary w-100 mt-3">戻る</a>
            </div>
        </div>
    </div>
</body>
</html>

requirements.txt

Flask==2.3.3
Flask-WTF==1.1.1
Flask-Limiter==3.3.0
WTForms==3.0.1