header image

枝折

Flask で EC サイトの機能を作ってみる①

CREATED: 2024 / 04 / 05 Fri

UPDATED: 2024 / 04 / 06 Sat

商品一覧の表示、商品の購入、カートの実装まで

なぜ Flask で作る?

Flask で作るのは最近ちょっと趣味でいじってたからであって特に深い意味はないです。 何を使うにせよ、考え方は特に変わらないと思う。

作るものは簡単な EC サイト。 実際の購入機能を含めるのであれば Stripe を組み込むなどすれば良さそう。 Stripe も本業でよく触ってるので今度カード決済やコンビニ決済の実装について書こうと思う。

今回は DB に登録した商品情報の一覧画面と詳細画面、またその購入(形だけ)と購入履歴の表示、およびショッピングカートの実装を行う。 認証、エラーハンドリング、テストについては別の記事に分けるつもり。

Docker で Flask アプリを起動する

docker 使って作っていきます。 こういう時はまず Dockerfile と docker-compose.yml を作って、コンテナの中で flask アプリの原型を作り、その過程のコマンドをメモするようにしてます。

FROM python:3.12.2-slim-bookworm
WORKDIR /app
RUN apt update -y && pip install --upgrade pip
version: "3.8"
services:
  app:
    build: .
    tty: true

以上のファイルを2つ作って docker compose builddocker compose up -d でコンテナを起動する。 あとは中に入って作業していきます。

まず、flask アプリを作ってみる。

$ docker exec -it flask_web-app-1 /bin/bash
root@f7c40238f45c:/app# pip install Flask
root@f7c40238f45c:/app# touch main.py
root@f7c40238f45c:/app# apt install vim

QuickStart を参考に main.py を作成する。 ついでに vim を入れる。

# main.py
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

あとは flask アプリを起動する。

root@f7c40238f45c:/app# flask --app main run
 * Serving Flask app 'main'
 * Debug mode: off
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

5000 ポートで起動しているので、こちらを解放してあげればホスト側からもアクセスできるとわかる。

依存関係は requirements.txt で管理したいので、Dockerfile をいじる。 最近は poetry も良さそう?

イメージは慣れてるので debian で、slim があれば使う。 本番環境で使う時は root 以外の権限で起動したいところだが、今回はその辺は考えない。 この記事では脆弱性があってもイメージを利用するが、気になれば別のイメージを使ったり、マルチステージビルドすれば良い。

FROM python:3.12.2-slim-bookworm
WORKDIR /app
COPY . /app
RUN apt update -y \
  && pip install --upgrade pip \
  && pip install -r requirements.txt
Flask>=3.0,<4.0
version: "3.8"
services:
  app:
    build: .
    volumes:
      - .:/app
    ports:
      - 5001:5000
    command: bash -c "flask --app main run --host=0.0.0.0"
    environment:
      - FLASK_APP=main
      - FLASK_DEBUG=on
      - TZ=Asia/Tokyo

main.py もホスト側で作っておいてディレクトリに入れておく。 ホスト側のポートは 5001 にしておく。 んで、再度コンテナを作り直す。 ちなみに environmentFLASK_APP を指定しておかないと、のちの flask shell でエラーになるのでこのタイミングで指定する。 FLASK_APP を指定すれば、そのモジュールがアプリの起点となるので、起動時に --app を指定する必要もなくなる。 FLASK_DEBUG=on はデバッグモードの設定で、デバッグモードではホットリロードが効くようになるから設定しておく。

$ docker compose build
$ docker up -d
$ docker ps
CONTAINER ID   IMAGE           COMMAND                  CREATED         STATUS         PORTS                    NAMES
d7590bc86543   flask_web-app   "bash -c 'flask --ap…"   6 seconds ago   Up 5 seconds   0.0.0.0:5001->5000/tcp   flask_web-app-1

localhost:5001 を確認して Hello, World! が出てきたら問題なし。 ここからスタート。

よくある CRUD API を作る

データを処理する API を作っていく。 これを進めるためにはまず、データベースの設定とモデルの構築が必要になる。

flask はインタラクティブな flask shell なるものを搭載しているので、設定後はこちらで動作確認してみる。

ほんとはテストファーストで作っていくのが良いのだろうが、テストはメイントピックではないので順序的に後にする。

データベースは PostgreSQL を使う。 データモデルの作成には Flask-SQLAlchemy を、データベースのマイグレーションには Flask-Migrate を使う。

まず、requirements.txt に Flask-SQLAlchemy と Flask-Migrate を追加してコンテナを作り直す。 あと、PostgreSQL アダプターである psycopg2-binary も含める(コンパイル不要なバイナリ版である psycopg2-binary は開発やテスト環境では良いが、本番環境では psycopg2 の方が推奨されている)。 普通に pip install -r requirements.txt を実行しても良い。

Flask-Migrate>=4.0,<5.0
Flask-SQLAlchemy>=3.1,<4.0
psycopg2-binary>=2.9,<3.0

DB への接続

DB にデータを保存するデータモデルを作成していくが、その前に DB コンテナを追加する。 パスワードやユーザーを environment に指定し、あとで DBeaver などの DB クライアントでデータ確認ができるよう 5432 ポートを公開する。 また、volumes にはデータ同期するディレクトリを指定する。 ここでは main.py が存在するディレクトリ内に db/data ディレクトリを作成している。

version: "3.8"
services:
  app:
    build: .
    volumes:
      - .:/app
    ports:
      - 5001:5000
    command: bash -c "flask run --host=0.0.0.0"
    environment:
      - FLASK_APP=main
      - FLASK_DEBUG=on
      - TZ=Asia/Tokyo
  postgres:
    image: postgres:16.2-bookworm
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_DB=postgres
      - TZ=Asia/Tokyo
    ports:
     - 5432:5432
    volumes:
     - ./db/data:/var/lib/postgresql/data

加えて、main.py で SQLAlchemy を利用した接続設定を行う。

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

app = Flask(__name__)

app.config["SQLALCHEMY_DATABASE_URI"] = 'postgresql://postgres:postgres@postgres/postgres'

db = SQLAlchemy()
db.init_app(app)

migrate = Migrate()
migrate.init_app(app, db)

# 以下省略

そうすると、docker compose up -d で PostgreSQL が起動します。

$ docker compose up -d
$ docker ps
CONTAINER ID   IMAGE                    COMMAND                  CREATED          STATUS          PORTS                    NAMES
e09f5794e54b   postgres:16.2-bookworm   "docker-entrypoint.s…"   40 seconds ago   Up 39 seconds   0.0.0.0:5432->5432/tcp   flask_web-postgres-1
a26ae3f560f2   flask_web-app            "bash -c 'flask run …"   40 seconds ago   Up 39 seconds   0.0.0.0:5001->5000/tcp   flask_web-app-1

DBeaver で接続すると空っぽの中身が覗けるでしょう。

データモデルの作成

モデルは main.py に書いていくが、後でファイルを分割する。 まず、事前に定義した dbModel を継承して ProductUser を作成する。 Product は名前、価格、作成日時、更新日時を持つ。

# 省略
from datetime import datetime, timezone
from sqlalchemy.orm import Mapped, mapped_column

# 省略

class Product(db.Model):
    __tablename__ = 'products'
    id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
    name: Mapped[str] = mapped_column(db.String(20), nullable=False)
    price: Mapped[int] = mapped_column(db.Integer, nullable=False)
    created: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    updated: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    def __repr__(self) -> str:
        return f'Product(id={self.id!r}, name={self.name!r}, price={self.price!r}, created={self.created!r}, updated={self.updated!r})'

class User(db.Model):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
    name: Mapped[str] = mapped_column(db.String(20), nullable=False)
    created: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    updated: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    def __repr__(self) -> str:
        return f'User(id={self.id!r}, name={self.name!r}, created={self.created!r}, updated={self.updated!r})'

DB マイグレーション

main.py にモデルを記述したら DB にテーブルを作成する。 Flask アプリを立ち上げるときに DB は作られているはずなので、あとは以下のコマンドを実行する。

# docker コンテナの中に入っておく
$ flask db migrate -m 'create products and users'
$ flask db upgrade

DBeaver で DB の中身を覗くと products テーブルと users テーブルが作られていることを確認できるかと思う。

Shell での確認

DB にテーブルができたので flask shell で SQLAlchemy の ORM を利用してデータの作成や取得を試してみる。

まずは Product の作成する。

$ flask shell
Python 3.12.2 (main, Mar 12 2024, 08:11:26) [GCC 12.2.0] on linux
App: main
Instance: /app/instance
>>> product = Product(name='Snack', price=100)
>>> db.session.add(product)
>>> db.session.commit()

Product を取得する。

>> product = db.session.execute(db.select(Product).filter_by(name='Snack')).scalar_one()
>> product
Product(id=1, name='Snack', price=100, created=datetime.datetime(2024, 3, 24, 18, 39, 26, 238131), updated=datetime.datetime(2024, 3, 24, 18, 39, 26, 238193))
>> products = db.session.query(Product).all()
>> products
[Product(id=1, name='Snack', price=100, created=datetime.datetime(2024, 3, 24, 18, 39, 26, 238131), updated=datetime.datetime(2024, 3, 24, 18, 39, 26, 238193))]

うまくいってそう。

EC サイトの画面を作る

DB のデータを用いて EC サイトの画面を作っていきます。 作るのは、商品一覧画面、商品詳細画面、商品購入履歴一覧画面、商品購入画面の4つ。

まず、ベースのテンプレートを作成しておく。 templates/base.html の中身は以下の通り。

<!-- base.html -->
<!DOCTYPE html>
<head>
  <title>{% block title %}{% endblock %}</title>
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
  <header>
    {% block header %}{% endblock %}
  </header>
  <main>
    {% block content %}{% endblock %}
  </main>
</body>

この block header とか block content の部分に個別のページの要素を作っていく。

商品一覧画面

base.html を拡張する形で templates/products.html を作成する。 このあと詳細画面を作るので詳細画面ページへの導線も作っておく。

<!-- products.html -->
{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}Products{% endblock %}</h1>
{% endblock %}

{% block content %}
  <section>
    {% for product in products %}
      <dt>
        <a href="/products/{{ product.id }}">{{ product.name }}</a>
      </dt>
      <dd>{{ product.price }}</dd>
    {% endfor %}
  </section>
{% endblock %}

main.pyhello_world() メソッドを書いていたところの下あたりに以下を追加する。 ここでやってるのは DB の products テーブルからすべてのデータを取得し、それらを products 変数としてテンプレートに埋め込んでいる。

@app.route("/products")
def products():
    products = db.session.query(Product).all()
    return render_template('products.html', products=products)

上記にある通り、テンプレート側ではこの products 内の product を1つずつループしてその名前と価格を表示している。

商品詳細画面

詳細画面を一覧画面と別に作ることもできるが、ここでは一緒にまとめてみる。 以下のテンプレートは product が渡された場合は詳細画面として振る舞い、products が渡された場合には一覧画面として振る舞う。

<!-- products.html -->
{% block content %}
  <section>
    {% if product %}
      <dt>{{ product.name }}</dt>
      <dd>{{ product.price }}</dd>
    {% elif products %}
      {% for product in products %}
        <dt>
          <a href="/products/{{ product.id }}">{{ product.name }}</a>
        </dt>
        <dd>{{ product.price }}</dd>
      {% endfor %}
    {% endif %}
  </section>
{% endblock %}

main.py 側では product を返却するメソッドを作成する。

@app.route("/products/<product_id>")
def product(product_id):
    product = db.session.execute(db.select(Product).filter_by(id=product_id)).scalar_one()
    return render_template('products.html', product=product)

商品をカートに含める

カートの情報はセッションで記録しておく。 実際の運用では DB にカート内の情報を永続化したり色々工夫すれば良いと思う。 認証機能を含める場合は認証前はセッションで、認証後は DB に永続化とかできそう。 認証後もセッションで管理する方法もありうる。

なんでも良いが、add_cart() メソッドを以下のように追加する。 flask から session をインポートできるので、こいつに cart を持たせて指定された product_id を突っ込んでいく。

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

// 省略

@app.post('/add_cart')
def add_cart():
    cart_items = []
    if 'cart' in session:
        cart_items = session['cart']
    if request.form['product_id'] not in cart_items:
        cart_items.append(request.form['product_id'])
    session['cart'] = cart_items
    return redirect(url_for('cart'))

Flask のセッションについてはこちらを参照のこと。

で、商品の詳細画面にカートに加えるためのボタンを配置してあげる。 以下では in_cart という変数(Boolean)を確認してこれが false なら「カートに加える」ボタンが表示されるようにしている。 in_cart 変数は product() メソッドから返す。

<!-- products.html -->
<!-- 省略 -->
<dt>{{ product.name }}</dt>
<dd>{{ product.price }}</dd>
{% if not in_cart %}
  <form action="/add_cart" method="post">
    <input type="hidden" name="product_id" value="{{ product.id }}">
    <input type="submit" value="カートに加える">
  </form>
{% endif %}
<!-- 省略 -->
@app.route("/products/<product_id>")
def product(product_id):
    product = db.session.execute(db.select(Product).filter_by(id=product_id)).scalar_one()
    in_cart = False
    # session の cart に product_id が含まれる場合はここで True が返るようになっている
    if 'cart' in session and product_id in session['cart']:
        in_cart = True
    return render_template('products.html', product=product, in_cart=in_cart)

次はカート内の商品一覧画面だ。

<!-- cart.html -->
{% block content %}
  <section>
    {% for product in products %}
      <p>{{ product.name }}</p>
      <form action="/remove_cart" method="post">
        <input type="hidden" name="product_id" value="{{ product.id }}">
        <input type="submit" value="カートから取り除く">
      </form>
    {% endfor %}
    <a href="/products">商品を見る</a>
  </section>
{% endblock %}

API 側は以下の通り。 remove_cart()cart() を追加している。

@app.post('/remove_cart')
def remove_cart():
    # session に cart が含まれる場合に限り、その中の product_id を削除するようにしている
    if 'cart' in session:
        cart_items = session['cart']
        cart_items.remove(request.form['product_id'])
        session['cart'] = cart_items
    return redirect(url_for('cart'))

@app.route("/cart")
def cart():
    # 空の products を初期化
    products = []
    if 'cart' in session:
        # cart に含まれる product_id から Product を DB から抽出して products に代入する
        cart_items = session['cart']
        app.logger.info(cart_items)
        products = db.session.query(Product).filter(Product.id.in_(cart_items)).all()
    # products を html 側でも使えるようにする
    return render_template('cart.html', products=products)

以上でカートへの追加と削除、および一覧表示ができるだろう。

商品の購入

商品の購入を実装する前に、ユーザーが商品を購入したという事実を記録するテーブルを作成する。 事前に users テーブルは作成していたので、productsusers の中間テーブルである purchase_transactions テーブルを作成する。

class PurchaseTransaction(db.Model):
    __tablename__ = 'purchase_transactions'
    id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
    product_id: Mapped[int] = mapped_column(db.ForeignKey('products.id'))
    user_id: Mapped[int] = mapped_column(db.ForeignKey('users.id'))
    product: Mapped['Product'] = db.relationship(back_populates='purchase_transactions')
    user: Mapped['User'] = db.relationship(back_populates='purchase_transactions')
    created: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    updated: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    def __repr__(self) -> str:
        return f'PurchaseTransaction(id={self.id!r}, product_id={self.product_id!r}, user_id={self.user_id!r} created={self.created!r}, updated={self.updated!r})'

合わせて、ProductUser の方からも PurchaseTransaction を参照できるようにする。

class Product(db.Model):
    __tablename__ = 'products'
    id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
    name: Mapped[str] = mapped_column(db.String(20), nullable=False)
    price: Mapped[int] = mapped_column(db.Integer, nullable=False)
    purchase_transactions: Mapped[List['PurchaseTransaction']] = db.relationship(back_populates='product', cascade='all, delete-orphan') # product.purchase_transactions というふうに参照することができる
    created: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    updated: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    def __repr__(self) -> str:
        return f'Product(id={self.id!r}, name={self.name!r}, price={self.price!r}, created={self.created!r}, updated={self.updated!r})'

class User(db.Model):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(db.Integer, primary_key=True)
    name: Mapped[str] = mapped_column(db.String(20), nullable=False)
    purchase_transactions: Mapped[List['PurchaseTransaction']] = db.relationship(back_populates='user', cascade='all, delete-orphan') # user.purchase_transactions というふうに参照することができる
    created: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    updated: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
    def __repr__(self) -> str:
        return f'User(id={self.id!r}, name={self.name!r}, created={self.created!r}, updated={self.updated!r})'

一応 flask shell で作成テストをしてみる。

root@d4d07ffc9c48:/app# flask shell
Python 3.12.2 (main, Mar 12 2024, 08:11:26) [GCC 12.2.0] on linux
App: main
Instance: /app/instance
>>>
>>> user = db.session.execute(db.select(User).filter_by(id=1)).scalar_one()
>>> product = db.session.execute(db.select(Product).filter_by(id=1)).scalar_one()
>>> purchase_transaction = PurchaseTransaction(user=user, product=product)
>>> db.session.add(purchase_transaction)
>>> db.session.commit()
>>>
>>> user.purchase_transactions
[PurchaseTransaction(id=1, product_id=1, user_id=1 created=datetime.datetime(2024, 1, 1, 12, 0, 0, 0), updated=datetime.datetime(2024, 1, 1, 12, 0, 0, 0))]
>>>
>>> product.purchase_transactions
[PurchaseTransaction(id=1, product_id=1, user_id=1 created=datetime.datetime(2024, 1, 1, 12, 0, 0, 0), updated=datetime.datetime(2024, 1, 1, 12, 0, 0, 0))]

関連はうまくいってそうな感じ。 購入時に呼び出す API は以下の purchase() となる。購入者の user はハードコーディングで id=1 を指定しているが、実際は session などでユーザー情報を保持してそこから id を取り出すような形になりそう。 購入後は success.html を表示する。 実際の EC サイトでは Stripe などに購入リクエストを投げる API を PurchaseTransaction 作成の前後に挟むことになるかと思う。

@app.post("/purchase/<product_id>")
def purchase(product_id):
    user = db.session.execute(db.select(User).filter_by(id=2)).scalar_one()
    product = db.session.execute(db.select(Product).filter_by(id=product_id)).scalar_one()
    if user and product:
        purchase_transaction = PurchaseTransaction(user=user, product=product)
        db.session.add(purchase_transaction)
        db.session.commit()
    return render_template('success.html', product=product)
{% block content %}
  <section>
    <p>{{ product.name }}の購入に成功しました!</p>
    <a href="/products">商品を見る</a>
    <a href="/cart">カートを見る</a>
  </section>
{% endblock %}

一般的に EC サイトではカートに入れた商品を購入することになると思うので、上記 API を呼び出す購入ボタンはカートの画面に配置してみる。 今回は購入は商品ごとに行えるようにする。一括で複数まとめて購入する機能は作らない。

カート画面はこんな感じ。

<!-- cart.html -->
{% block content %}
  <section>
    {% for product in products %}
      <p>{{ product.name }}</p>
      <form action="/purchase/{{ product.id }}" method="post">
        <input type="hidden" name="product_id" value="{{ product.id }}">
        <input type="submit" value="購入する">
      </form>
      <form action="/remove_cart" method="post">
        <input type="hidden" name="product_id" value="{{ product.id }}">
        <input type="submit" value="カートから取り除く">
      </form>
    {% endfor %}
    <a href="/products">商品を見る</a>
  </section>
{% endblock %}

以上でカートからの購入と購入後の成功画面表示ができるようになった。 DBeaver などで DB の中を覗くと purchase_transactions のレコードが作成されていることを確認できるだろう。

商品の購入履歴

最後に、購入した商品の一覧を表示してみる。 transactions() を追加し、transaction.html を表示できるようにしておく。

@app.route("/transactions")
def transactions():
    purchase_transactions = db.session.query(PurchaseTransaction).all()
    return render_template('transactions.html', transactions=purchase_transactions)
<!-- transactions.html -->
{% block content %}
  <section>
    {% for transaction in transactions %}
      <dt>
        {{ transaction.product.name }}を購入しました。
      </dt>
      <dd>{{ transaction.product.price }}円支払いました。</dd>
    {% endfor %}
    <a href="/products">商品を見る</a>
    <a href="/cart">カートを見る</a>
  </section>
{% endblock %}

あとは、cart.html に購入履歴画面への動線を設けておく。

<!-- cart.html -->
<!-- 省略 -->
<a href="/transactions">購入履歴を見る</a>
<!-- 省略 -->

前項で追加した「購入する」ボタンをクリックすると、購入した商品が transactions.html に表示されるのを確認できるだろう。

を仕舞い

ここではフォームについては触れなかったが、html のフォームをバリデーション込みでいい感じにしてくれるライブラリに Flask WTF がある。

WTForm というライブラリを flask 向けに手を加えたライブラリなので、本家のドキュメントも参考になるかも。

これを使えば CSRF の対策を行うこともできるので、次回の Flask 記事で触れることにする。 CSRF の対策としては Origin ヘッダーを使う方法も最近流行ってるっぽいことを聞いたので、その辺も触れるつもり。

あと、ここで書いたソースコードはこちらにまとめてます。