【Flask】TODOアプリ作成その6:クライアント側で入力チェックの実装

概要

Pythonのflaskフレームワークを使用して、TODOリストを管理するWEBアプリを作成したのでまとめた。
flaskのGET/POSTの方法やセッション等の扱い、Jinjaテンプレートの使い方について、当アプリを作成しながら振り返ることが目的。
今回はJavaScriptを使用して入力チェック機能を追加した。
尚、実装はJavaScriptのライブラリであるjQueryを使用している。

前提

flaskフレームワークを使用して作成したTODOアプリに対して改修を行う。
あわせて読みたい

概要 Pythonのflaskフレームワークを使用して、TODOリストを管理するWEBアプリを作成したのでまとめた。 flaskのGET/POSTの方法やセッション等の扱い、Jinjaテンプレートの使い方について、当アプリを作成しながら[…]

【Flask】TODOアプリ作成その3:タスク登録機能の実装

機能説明

入力チェック機能

・クライアント側でJavaScriptを使用して改修する
・サーバー側で行っていた入力チェックと同じ内容のチェックを行う
・サーバー側でエラーが起きた場合は、一律エラー画面に遷移させる

対象画面

・TODO登録画面
・TODO編集画面

入力チェック概要

以下の赤文字または赤線箇所について改修を行う。
【flaskその6】アプリ概要

入力チェック機能

画面状態

◆TODO登録画面
未入力の状態で【登録】ボタンを押下すると、サーバー通信は行われずクライアント側で入力チェックエラーとなる。
【flaskその6】タスク登録画面エラー
▲入力チェック仕様はサーバー側で行っていたときと同じ
◆サーバー側にて入力チェックエラーの場合
【flaskその6】処理続行エラー
▲クライアント側のチェックをすり抜けて、サーバー側でエラーが起きることはありえないため、処理続行エラーとする
◆TODO編集画面
TODO編集画面はTODO登録画面と同じ入力チェックだが、以下は独自のチェックとなる。
【flaskその6】タスク編集画面エラー
▲未更新チェック。TODO編集画面にて何も変更しない状態で【更新】ボタンを押下するとエラーメッセージを表示する

ファイル構成

主に以下の赤文字箇所について、追加修正等を行う。
【flaskその6】ファイル構成パス

入力チェック機能解説

クライアント側

画面側共通ファイル

jQueryやbootstrap、共通処理のjsを各画面で参照するのは冗長となる。
そのため、base.htmlを作成して各htmlの共通部分をまとめた。

base.html


(略)
<head>
  <title>{% block title %}{% endblock %}</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/css/bootstrap.min.css">
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>

<body>
  {% block main %}{% endblock %}
  {% block extra_js %}{% endblock %}
  <script src="{{ url_for('static', filename='js/checkUtils.js') }}"></script>
</body>
(略)

{% block XXXX %}という部分については、Jinjaテンプレートの機能となる。
base.htmlを継承して各画面を作成する際に、子画面にて{% block XXXX %}を呼び出して中身を設定できる

TODO登録画面

create.html


{% extends "base.html" %}
{% block title %}タスク登録{% endblock %}

{% block main %}
<div class="container">
    <div class="row justify-content-center mt-3">
(略)

{% extends “base.html” %}と記述することで、base.htmlを継承できる。
また、{% block title %}{% block main %}と記述することで、base.htmlに定義した領域へ子画面から値を設定できる。

◆エラーメッセージ表示領域

create.html


<div class="form-group mt-3">
  <label for="title">タイトル <span class="text-primary ml-3">※必須</span></label>
  <input type="text" class="form-control" id="title" name="title"
    value="{{ request.form.get('title', '') }}">
</div>
<div id="title-error" class="text-danger mb-3 error-msg"></div>

各フォーム部品直下に、以下のようにエラーメッセージ表示領域を定義する。

<div id="title-error" class="text-danger mb-3 error-msg"></div>

JavaScriptにてエラーになった場合、エラメッセージ領域にエラー内容を当てはめる。

◆JSの参照

create.html


{% block extra_js %}
<script src="{{ url_for('static', filename='js/create.js') }}"></script>
{% endblock %}

TODO登録画面の【登録】ボタン押下時イベントを定義したcreate.jsを参照している。
Flaskでjsファイルを参照する場合、staticフォルダを用意してその配下に静的ファイルを格納することで参照できる

 

TODO登録画面まわりのイベント

create.js


$(document).on("click", "#regist", function(e) {
    // エラーメッセージの初期化
    $(".error-msg").text("");

    // 入力値検証
    if (!validateRegist()) {
        e.preventDefault();
    }
});

【登録】ボタン押下イベントを定義している。

画面上のエラーメッセージをすべて削除し、validateRegist関数を呼び出して入力値検証を行う。
入力エラーの場合、サーバー通信はしない。

 

◆入力チェックイベント

create.js


/**
 * タスク登録入力チェック
 * 
 * @returns is_valid(true:正常/false:異常)
 */
function validateRegist() {
    let is_valid = true;
    let errors = {};
    // カテゴリ 必須チェック
    let category = $("select[id=category").val().trim();
    if (!checkInputReq(category)) {
        is_valid = false;
        errors["category-error"] = "カテゴリを選択してください。";
    }
(略)
    // エラーメッセージ表示
    for (let key in errors) {
        $("#" + key).text(errors[key]);
    }

    return is_valid;
}

入力チェックを行うvalidateRegist関数。
正常の場合true、異常の場合falseを返却する。

各フォーム部品の画面入力値を取得し、必須チェック等を行う。
エラーの場合、画面側にて定義したエラーメッセージ表示領域のidをキーにしたエラーメッセージを連想配列(errors )に格納する。

連想配列に値が存在する場合、画面のエラーメッセージ表示領域にエラーメッセージを設定する。

 

共通チェック処理

checkUtils.js


/**
 * 必須チェック
 * 
 * @param val チェック対象の値
 * @returns is_valid(true:正常/false:異常)
 */
function checkInputReq(val) {
    let is_valid = true;
    if (val === null || val === "") {
        is_valid = false;
    }

    return is_valid;
}

共通のチェック処理を集約するファイル。
必須チェックを行う関数を保持している。

 

サーバー側

タスク登録処理

views.py


@app.route("/todo_apps/create_todo", methods=["POST"])
def create_todo():
    """ タスクを新規登録する """
    try:
        # 入力値検証
        validate_views.validate_input_todo(request.form)
    except ValueError:
        # 改ざんエラー
        return redirect(url_for("show_error", error_id=consts.KAIZAN_ERROR))
(略)

以前は入力値検証の結果はbool型(真偽値)で取得していた。
しかし、クライアント側と同じ入力チェックでサーバー側だけエラーになるということは、値を無理やり改ざんされたということになるため、一律エラー画面に遷移するよう変更した。

 

入力チェック処理

validate_views.py


def validate_input_todo(form):
    """ タスク登録または編集の入力チェックを行う """
    # カテゴリ 必須
    c_id = form.get("category")
    if not c_id:
        raise ValueError()
(略)

基本的にクライアント側と同じ入力チェックを行う。
エラーになった場合、例外をスローする

 

TODO編集画面の未更新チェック機能解説

未更新処理の概要

①TODO編集画面初期表示時、DBの値を別途保持
②TODO編集画面にて【更新】ボタンを押下時、各入力フィールドと①の別途保持した値を比較
③画面入力値に更新がある場合はDB更新、ない場合はエラーメッセージ表示
クライアント側

DBの値を別途保持

edit.html


(略)
 <input type="hidden" id="init_category" value="{{ todo.get('category_id', '') }}">
 <input type="hidden" id="init_title" value="{{ todo.get('title', '') }}">
 <input type="hidden" id="init_content" value="{{ todo.get('content', '') }}">
 <input type="hidden" id="init_memo" value="{{ todo.get('memo', '') }}">
 <input type="hidden" id="init_due_date" value="{{ todo.get('due_date', '') }}">
 (略)

TODO編集画面の初期表示時、サーバー側から受け取った各入力情報を隠しフィールドに保持しておく。

◆突合処理

edit.js


(略)
// 初期表示時の値
let defaultVal = {
    category: $("#init_category").val().trim(),
    title: $("#init_title").val().trim(),
    content: $("#init_content").val().trim(),
    memo: $("#init_memo").val().trim(),
    due_date: $("#init_due_date").val().trim()
}
// 画面入力値
let updateVal = {
    category: $("select[id=category").val().trim(),
    title: $("#title").val().trim(),
    content: $("#content").val().trim(),
    memo: $("#memo").val().trim(),
    due_date: $("#due_date").val().trim()
};
// 更新内容比較
let updateFlg = false;
for (let [key, val] of Object.entries(defaultVal)) {
    if (val !== updateVal[key]) {
        updateFlg = true;
        break;
    }
}
if (!updateFlg) {
    e.preventDefault();
    $("#error-message").text("値をどれか変更して更新ボタンを押してください。")
}
(略)

【更新】ボタン押下時、入力チェックを行い問題ない場合は更新チェックを行う。

画面初期表示時の隠しフィールドの値を取得し、画面入力値を取得した後に両者を突合チェックする。
突合した結果、両者とも値がすべて等しい場合はエラーメッセージを表示する。

ファイル詳細

base.html

<!DOCTYPE html>
<html>

<head>
  <title>{% block title %}{% endblock %}</title>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/5.0.0-alpha1/css/bootstrap.min.css">
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>

<body>
  {% block main %}{% endblock %}
  {% block extra_js %}{% endblock %}
  <script src="{{ url_for('static', filename='js/checkUtils.js') }}"></script>
</body>

</html>

 

create.html

{% extends "base.html" %}
{% block title %}タスク登録{% endblock %}

{% block main %}
<div class="container">
  <div class="row justify-content-center mt-3">
    <div class="col-md-8">
      <a href="{{ url_for('top') }}" class="btn btn-primary mb-3">戻る</a>
      <div class="card">
        <div class="card-header text-center">
          <h4>タスク登録</h4>
        </div>
        <div class="card-body">
          <div id="error-message" class="text-danger mb-3">{{ error_msg }}</div>
          <form method="post" action="{{ url_for('create_todo') }}">
            <div class="form-group">
              <label for="category">カテゴリ<span class="text-primary ml-3">※必須</span></label>
              <select class="form-control" id="category" name="category">
                <option value="">選択してください</option>
                {% for category in categories %}
                <option value="{{category.id}}" {% if category.id|string()==request.form.get('category') %} selected {%
                  endif %}>{{category.category_name}}</option>
                {% endfor %}
              </select>
            </div>
            <div id="category-error" class="text-danger mb-3 error-msg"></div>
            <div class="form-group mt-3">
              <label for="title">タイトル <span class="text-primary ml-3">※必須</span></label>
              <input type="text" class="form-control" id="title" name="title"
                value="{{ request.form.get('title', '') }}">
            </div>
            <div id="title-error" class="text-danger mb-3 error-msg"></div>
            <div class="form-group mt-3">
              <label for="task">タスク内容 <span class="text-primary ml-3">※必須</span></label>
              <input type="text" class="form-control" id="content" name="content"
                value="{{ request.form.get('content', '') }}">
            </div>
            <div id="content-error" class="text-danger mb-3 error-msg"></div>
            <div class="form-group mt-3">
              <label for="memo">メモ</label>
              <textarea class="form-control" id="memo" name="memo"
                rows="3">{{ request.form.get('memo', '') }}</textarea>
            </div>
            <div class="form-group mt-3">
              <label for="due_date">タスク期日<span class="text-primary ml-3">※必須</span></label>
              <input type="date" class="form-control" id="due_date" name="due_date"
                value="{{ request.form.get('due_date', '') }}">
            </div>
            <div id="due_date-error" class="text-danger mb-3 error-msg"></div>
            <button type="submit" id="regist" class="btn btn-primary mt-3">登録</button>
          </form>
        </div>
      </div>
    </div>
  </div>
</div>
{% endblock %}

{% block extra_js %}
<script src="{{ url_for('static', filename='js/create.js') }}"></script>
{% endblock %}

 

edit.html

{% extends "base.html" %}
{% block title %}タスク編集{% endblock %}

{% block main %}
<div class="container">
  <div class="row justify-content-center mt-3">
    <div class="col-md-8">
      <a href="{{ url_for('show_detail', t_id=todo.get('id')) }}" class="btn btn-primary mb-3">戻る</a>
      <div class="card">
        <div class="card-header text-center">
          <h4>タスク編集</h4>
        </div>
        <div class="card-body">
          <div id="error-message" class="text-danger mb-3 error-msg">{{ error_msg }}</div>
          <form method="post" action="{{ url_for('edit_todo') }}">
            <div class="form-group">
              <label for="category">カテゴリ<span class="text-primary ml-3">※必須</span></label>
              <select class="form-control" id="category" name="category">
                <option value="">選択してください</option>
                {% for category in categories %}
                <option value="{{category.id}}" {% if category.id|string()==todo.get('category_id') %} selected {% endif
                  %}>{{ category.category_name }}</option>
                {% endfor %}
              </select>
            </div>
            <div id="category-error" class="text-danger mb-3 error-msg"></div>
            <div class="form-group mt-3">
              <label for="title">タイトル <span class="text-primary ml-3">※必須</span></label>
              <input type="text" class="form-control" id="title" name="title" value="{{ todo.get('title', '') }}">
            </div>
            <div id="title-error" class="text-danger mb-3 error-msg"></div>
            <div class="form-group mt-3">
              <label for="task">タスク内容 <span class="text-primary ml-3">※必須</span></label>
              <input type="text" class="form-control" id="content" name="content" value="{{ todo.get('content', '') }}">
            </div>
            <div id="content-error" class="text-danger mb-3 error-msg"></div>
            <div class="form-group mt-3">
              <label for="memo">メモ</label>
              <textarea class="form-control" id="memo" name="memo" rows="3">{{ todo.get('memo', '') }}</textarea>
            </div>
            <div class="form-group mt-3">
              <label for="due_date">タスク期日<span class="text-primary ml-3">※必須</span></label>
              <input type="date" class="form-control" id="due_date" name="due_date"
                value="{{ todo.get('due_date', '') }}">
            </div>
            <div id="due_date-error" class="text-danger mb-3 error-msg"></div>
            <button type="submit" id="update" class="btn btn-primary mt-3">更新</button>
            <input type="hidden" name="id" value="{{ todo.get('id') }}">
          </form>
        </div>
      </div>
    </div>
  </div>
</div>
<input type="hidden" id="init_category" value="{{ todo.get('category_id', '') }}">
<input type="hidden" id="init_title" value="{{ todo.get('title', '') }}">
<input type="hidden" id="init_content" value="{{ todo.get('content', '') }}">
<input type="hidden" id="init_memo" value="{{ todo.get('memo', '') }}">
<input type="hidden" id="init_due_date" value="{{ todo.get('due_date', '') }}">

{% endblock %}

{% block extra_js %}
<script src="{{ url_for('static', filename='js/edit.js') }}"></script>
{% endblock %}

 

create.js

"use strict";
$(document).on("click", "#regist", function(e) {
    // エラーメッセージの初期化
    $(".error-msg").text("");

    // 入力値検証
    if (!validateRegist()) {
        e.preventDefault();
    }
});

/**
 * タスク登録入力チェック
 * 
 * @returns is_valid(true:正常/false:異常)
 */
function validateRegist() {
    let is_valid = true;
    let errors = {};
    // カテゴリ 必須チェック
    let category = $("select[id=category").val().trim();
    if (!checkInputReq(category)) {
        is_valid = false;
        errors["category-error"] = "カテゴリを選択してください。";
    }
    // タイトル 必須チェック
    let title = $("#title").val();
    if (!checkInputReq(title)) {
        is_valid = false;
        errors["title-error"] = "タイトルを入力してください。";
    }
    // タスク内容 必須チェック
    let content = $("#content").val();
    if (!checkInputReq(content)) {
        is_valid = false;
        errors["content-error"] = "タスク内容を入力してください。";
    }
    // タスク期日 必須チェック
    let due_date = $("#due_date").val();
    if (!checkInputReq(due_date)) {
        is_valid = false;
        errors["due_date-error"] = "タスク期日を入力してください。";
    }

    // エラーメッセージ表示
    for (let key in errors) {
        $("#" + key).text(errors[key]);
    }

    return is_valid;

}

 

edit.js

"use strict";
$(document).on("click", "#update", function(e) {
    // エラーメッセージの初期化
    $(".error-msg").text("");

    // 入力値検証
    if (!validateUpdate()) {
        e.preventDefault();
    }

    // 初期表示時の値
    let defaultVal = {
        category: $("#init_category").val().trim(),
        title: $("#init_title").val().trim(),
        content: $("#init_content").val().trim(),
        memo: $("#init_memo").val().trim(),
        due_date: $("#init_due_date").val().trim()
    }
    // 画面入力値
    let updateVal = {
        category: $("select[id=category").val().trim(),
        title: $("#title").val().trim(),
        content: $("#content").val().trim(),
        memo: $("#memo").val().trim(),
        due_date: $("#due_date").val().trim()
    };
    // 更新内容比較
    let updateFlg = false;
    for (let [key, val] of Object.entries(defaultVal)) {
        if (val !== updateVal[key]) {
            updateFlg = true;
            break;
        }
    }
    if (!updateFlg) {
        e.preventDefault();
        $("#error-message").text("値をどれか変更して更新ボタンを押してください。")
    }
});

/**
 * タスク更新入力チェック
 * 
 * @returns is_valid(true:正常/false:異常)
 */
function validateUpdate() {
    let is_valid = true;
    let errors = {};
    // カテゴリ 必須チェック
    let category = $("select[id=category").val().trim();
    if (!checkInputReq(category)) {
        is_valid = false;
        errors["category-error"] = "カテゴリを選択してください。";
    }
    // タイトル 必須チェック
    let title = $("#title").val();
    if (!checkInputReq(title)) {
        is_valid = false;
        errors["title-error"] = "タイトルを入力してください。";
    }
    // タスク内容 必須チェック
    let content = $("#content").val();
    if (!checkInputReq(content)) {
        is_valid = false;
        errors["content-error"] = "タスク内容を入力してください。";
    }
    // タスク期日 必須チェック
    let due_date = $("#due_date").val();
    if (!checkInputReq(due_date)) {
        is_valid = false;
        errors["due_date-error"] = "タスク期日を入力してください。";
    }

    // エラーメッセージ表示
    for (let key in errors) {
        $("#" + key).text(errors[key]);
    }

    return is_valid;

}

 

checkUtils.js

"use strict";

/**
 * 必須チェック
 * 
 * @param val チェック対象の値
 * @returns is_valid(true:正常/false:異常)
 */
function checkInputReq(val) {
    let is_valid = true;
    if (val === null || val === "") {
        is_valid = false;
    }

    return is_valid;
}

 

views.py

@app.route("/todo_apps/create_todo", methods=["POST"])
def create_todo():
    """ タスクを新規登録する """
    try:
        # 入力値検証
        validate_views.validate_input_todo(request.form)
    except ValueError:
        # 改ざんエラー
        return redirect(url_for("show_error", error_id=consts.KAIZAN_ERROR))

    # タスク登録
    is_valid = business_logic.insert_todo(request.form, session["u_id"])
    if not is_valid:
        # DBエラーの場合
        categories = business_logic.get_category_all()
        error_msg = "タスクの登録に失敗しました。時間を空けて再度実行してください。"
        return render_template("create.html", categories=categories, error_msg=error_msg) 
    else:
        return redirect(url_for("top"))


@app.route("/todo_apps/error")
def show_error():
    """ エラー画面を表示する """
    session.clear()
    error_id = request.args.get("error_id", "")
    if error_id == consts.DB_ERROR:
        # DBエラーの場合
        error_msg = "DBを更新中にエラーが発生しました。時間を空けて、再度ログインからやりなおしてください。"
    elif error_id == consts.KAIZAN_ERROR:
        # 改ざんエラーの場合
        error_msg = "改ざんされた可能性があります。再度ログインからやりなおしてください。"
    else:
        error_msg = "エラーが発生しました。時間を空けて、再度ログインからやりなおしてください。"

    return render_template("error.html", error_msg=error_msg)

 

validate_views.py

def validate_input_todo(form):
    """ タスク登録または編集の入力チェックを行う """
    # カテゴリ 必須
    c_id = form.get("category")
    if not c_id:
        raise ValueError()

    # カテゴリ 範囲
    categories = business_logic.get_category_all()
    id_list = list(map(str, [category.id for category in categories]))
    if not c_id in id_list:
        raise ValueError()

    # タイトル 必須
    title = form.get("title")
    if not title:
        raise ValueError()

    # タスク内容 必須
    content = form.get("content")
    if not content:
        raise ValueError()

    # タスク期日 必須
    due_date = form.get("due_date")
    if not due_date:
        raise ValueError()

    # タスク期日 存在する日付 ※変換できない場合、ValueErrorが発生
    datetime.strptime(due_date, consts.DATE_FORMAT)

 

consts.py

# エラー画面フラグ
# DBエラー
DB_ERROR = "1"
# 改ざんエラー
KAIZAN_ERROR = "2"
スポンサーリンク