【Webアプリ作成の入門】Python + Flask + Bootstrap + DynamoDB

記事タイトルとURLをコピーする

こんにちは。AWS CLIが好きな福島です。

はじめに

インフラエンジニアのキャリアがメインの私が社内でPythonを使ったアプリ開発の学習をする機会があったため、先日から学んだことをアウトプットしています。

今回は以下の技術を活用し、TODO管理ができるWebアプリを作ってみたいと思います。

  • Python
    • プログラミング言語
  • Flask
    • Webアプリフレームワーク
  • Jinja2
    • テンプレートエンジン
  • Bootstrap
    • フロントエンドツールキット
  • DynamoDB
    • AWSのマネージドなNoSQLサービス

各技術の入門に関するブログも書いているため、ご興味ある方は以下もご確認ください。




完成イメージ

完成イメージは以下の通りです。

設計

まずは、簡単に設計します。

機能

今回は、TODO管理として以下の機能を実装することにします。

  • 一覧表示
  • 追加
  • 更新
  • 削除

画面および画面遷移図

画面および遷移図に加え、機能もまとめて書いちゃってます。

URLパス設計

URLのパスは以下の通りにします。

No パス設計 メソッド 備考
1 / GET トップページとなります。
TODO一覧を表示するページになります。
2 /todos GET 追加/更新するTODOの内容を入力するページになります。
3 /todos/add POST TODOを追加し、トップページにリダイレクトします。
4 /todos/update POST TODOを更新し、トップページにリダイレクトします。
5 /todos/delete POST TODOを削除し、トップページにリダイレクトします。

3-5でリダイレクトしているのは、POSTメソッドを送信した後にブラウザの再読み込みにより、フォームの二重送信されるのを防ぐためです。 この仕組みをPost/Redirect/Getと呼びます。

アーキテクチャ図

アーキテクチャ図は以下の通りです。

  • WebサーバのWerkzeugは、Flaskに組み込まれているライブラリになります。あくまで開発用途なので、本番ではgnicornなどを使うのが推奨されています。
  • アプリケーションにおける3層アーキテクチャ(プレゼンテーション層,ビジネスロジック層,データアクセス層)とFlaskMTVモデル(Model,Template,View)を組み合わせると上記イメージかなと思います。
  • Modelがビジネスロジックやデータベースのアクセスを担当し、TemplateがHTMLなどのテンプレートを管理、Viewがユーザーからのリクエストを受け取り、TemplateModelをコントロールするイメージです。

アーキテクチャの部分は、以下の記事を参考にさせていただきました。

機能ごとのシーケンス

各機能における処理の詳細は、以下の通りです。 ()で記載しているA-Hは、アーキテクチャ図の記号とリンクしています。

TODO一覧の表示

  • ①ユーザーがWebサーバにリクエスト(A)
  • ②Webサーバからアプリケーションサーバ(View)にリクエスト(B)
  • ③ViewからModelにデータの取得を指示(C)
  • ④ModelがデータベースからTODO一覧を取得(D)
  • ⑤Modelが処理結果をViewに返す(E)
  • ⑥ViewがTemplateからHTMLテンプレートを取得(F)
  • ⑦⑤,⑥を基にWebページをレンダリングして、Webサーバにレスポンス(G)
  • ⑧Webサーバからユーザーにレスポンス(H)

TODOの追加/更新/削除

TODOの追加/更新/削除処理はほぼ同じ流れになるため、まとめて記載します。

  • ①ユーザーがWebサーバにリクエスト(A)
  • ②Webサーバからアプリケーションサーバ(View)にリクエスト(B)
  • ③ViewからModelにデータの追加/更新/削除を指示(C)
  • ④ModelがデータベースにTODOを追加/更新/削除(D)
  • ⑤Modelが処理結果をViewに返す(E)
  • ⑥ViewがWebサーバにTODO一覧ページへのリダイレクトを指示(G)
  • ⑦WebサーバからユーザーにTODO一覧ページへのリダイレクトを指示(H)
  • ⑧TODO一覧の表示と同様の処理

TODOに含める情報

TODOには以下の情報を含めることにします。

  • タイトル
    • 任意の文字列
  • 詳細
    • 任意の文字列
  • ステータス
    • In Progress or Pending or Completed

テーブル設計

テーブル名は、TodoTableにします。 また保存するデータは、TODOに含める情報に加え、アイテムを特定するためキーとして、TodoIDを設定します。

No キー 備考
1 TodoID str パーティションキー
2 Title str -
3 Detail str -
4 TodoStatus str -

ディレクトリ/ファイル構成

今回は、以下の構成にします。

$ tree todo-app
todo-app
├── models.py
├── templates
│   ├── index.html
│   └── todo_form.html
└── views.py
$ 

実装

まずは、DynamoDBを作成します。

aws dynamodb create-table \
    --table-name todo-table \
    --attribute-definitions \
        AttributeName=TodoID,AttributeType=S \
    --key-schema \
        AttributeName=TodoID,KeyType=HASH \
    --provisioned-throughput \
        ReadCapacityUnits=5,WriteCapacityUnits=5

Flaskをインストールします。

pip install flask

各プログラムファイルを作成します。

models.py

import uuid
 
import boto3
from boto3.dynamodb.types import TypeDeserializer
 
TABLE_NAME = "TodoTable"
dynamodb_client = boto3.client("dynamodb")
 
 
def dynamo_to_python(dynamo_object):
    deserializer = TypeDeserializer()
    return {k: deserializer.deserialize(v) for k, v in dynamo_object.items()}
 
 
def get_item(args):
    if not args.get("todo_id"):
        return {}
 
    todo_id = args.get("todo_id")
 
    item = dynamodb_client.get_item(
        TableName=TABLE_NAME, Key={"TodoID": {"S": todo_id}}
    )["Item"]
 
    return dynamo_to_python(item)
 
 
def scan_items():
    items = []
    response = dynamodb_client.scan(TableName=TABLE_NAME)
    for dynamo_object in response["Items"]:
        items.append(dynamo_to_python(dynamo_object))
 
    return items
 
 
def put_item(form):
    todo_id = str(uuid.uuid4())
    title = form["title"]
    detail = form["detail"]
    status = form["status"]
 
    dynamodb_client.put_item(
        TableName=TABLE_NAME,
        Item={
            "TodoID": {"S": todo_id},
            "Title": {"S": title},
            "Detail": {"S": detail},
            "TodoStatus": {"S": status},
        },
    )
 
 
def update_item(form):
    todo_id = form["todo_id"]
    title = form["title"]
    detail = form["detail"]
    status = form["status"]
 
    dynamodb_client.update_item(
        TableName=TABLE_NAME,
        Key={"TodoID": {"S": todo_id}},
        UpdateExpression="SET Title=:title, Detail=:detail, TodoStatus=:satus",
        ExpressionAttributeValues={
            ":title": {"S": title},
            ":detail": {"S": detail},
            ":satus": {"S": status},
        },
    )
 
 
def delete_item(todo_id):
    dynamodb_client.delete_item(TableName=TABLE_NAME, Key={"TodoID": {"S": todo_id}})

views.py

from flask import Flask, redirect, render_template, request, url_for
from models import delete_item, get_item, put_item, scan_items, update_item
 
app = Flask(__name__)
 
 
@app.route("/")
def index():
    items = scan_items()
    return render_template("index.html", items=items)
 
 
@app.route("/todos")
def todo_form():
    item = get_item(request.args)
    return render_template("todo_form.html", item=item)
 
 
@app.route("/todos/add", methods=["POST"])
def add_todo():
    put_item(request.form)
    return redirect(url_for("index"))
 
 
@app.route("/todos/update", methods=["POST"])
def update_todo():
    update_item(request.form)
    return redirect(url_for("index"))
 
 
@app.route("/todos/delete", methods=["POST"])
def delete_todo():
    delete_item(request.form["todo_id"])
    return redirect(url_for("index"))

templates/index.html

<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
    <title>TODO管理</title>
</head>
<body>
<div class="container mt-5">
    <h1 class="text-center">TODO管理</h1>
    <div class="text-end">
        <a href="{{ url_for('todo_form') }}" class="btn btn-info mb-3">追加</a>
    </div>
    <table class="table table-bordered text-center">
        <thead class="table-success">
        <tr>
            <th>タイトル</th>
            <th>詳細</th>
            <th>ステータス</th>
            <th>アクション</th>
        </tr>
        </thead>
        <tbody>
        {% for item in items %}
        <tr>
            <td>{{ item.Title }}</td>
            <td>{{ item.Detail }}</td>
            <td>{{ item.TodoStatus }}</td>
            <td>
                <a href="{{ url_for('todo_form', todo_id=item.TodoID) }}" class="btn btn-primary">更新</a>
                <form action="{{ url_for('delete_todo') }}" method="post" class="d-inline">
                    <input type="hidden" name="todo_id" value="{{ item.TodoID }}">
                    <button type="submit" class="btn btn-danger">削除</button>
                </form>
            </td>
        </tr>
        {% endfor %}
        </tbody>
    </table>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>

templates/todo_form.html

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
    <title>TODO追加/更新</title>
</head>
<body>
<div class="container mt-5">
    <h1 class="text-center">TODO追加/更新</h1>
    <form action="{{ url_for('add_todo') if not item.get('TodoID') else url_for('update_todo') }}" method="post">
        <div class="form-group">
            <label for="title">タイトル</label>
            <input type="text" class="form-control" id="title" name="title" value="{{ item.get('Title', '') }}" required>
        </div>
        <div class="form-group">
            <label for="detail">詳細</label>
            <textarea class="form-control" id="detail" name="detail" rows="3" required>{{ item.get('Detail', '') }}</textarea>
        </div>
        <div class="form-group">
            <label for="status">ステータス</label>
            <select class="form-control" id="status" name="status" required>
                <option value="Pending" {% if item.get('TodoStatus') == 'Pending' %}selected{% endif %}>Pending</option>
                <option value="In Progress" {% if item.get('TodoStatus') == 'In Progress' %}selected{% endif %}>In Progress</option>
                <option value="Completed" {% if item.get('TodoStatus') == 'Completed' %}selected{% endif %}>Completed</option>
            </select>
        </div>
        {% if item.get('TodoID') %}
            <input type="hidden" name="todo_id" value="{{ item.get('TodoID') }}">
        {% endif %}
        <button type="submit" class="btn btn-primary">保存</button>
    </form>
</div>
</body>
</html>

動作確認

以下のコマンドでFlaskを起動します。

flask --app views run --debug
  • 実行例
$ pwd
/blog-sample/todo-app
$ 
$ flask --app views run --debug
 * Serving Flask app 'views'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 114-794-361

http://127.0.0.0:5000にアクセスします。

TODO追加の確認

追加ボタンを押下します。

任意の値を入力し、保存ボタンを押下します。

値が保存されていることを確認します。


TODO更新の確認

更新ボタンを押下します。

任意で値を変更し、保存ボタンを押下します。

値が更新されていることを確認します。


TODOの削除

削除ボタンを押下します。

データが削除されることを確認します。

終わりに

今回は、WebアプリとしてTODOアプリを作成してみました。 どなたかのお役に立てれば幸いです。

福島 和弥 (記事一覧)

2019/10 入社

AWS CLIが好きです。