EY-Office ブログ

やはりPythonでWebアプリを作るならDjangoなのかな?

先々週は軽量なWebフレームワークFlask、先週はAPIサーバーFastAPIを使って小遣い帳Webアプリを作りましたが、PythonでWebアプリといえばDjangoですよね。
Djangoに付いてはAIのアシストしてもらいPython、Djangoを学びましたで使ってみましたが、小遣い帳Webアプリのレベルでは大げさかな?と思っていました・・・

Astro Gemini AI image generatorが生成した画像を使っています

Django版

今回もAIのアシストしてもらいPython、Djangoを学びましたと同じく、標準構成+スタイリングにはdjango-tailwindを使いました。

主要なコードを載せます、AIにコメントを追加してもらいました。

allowance_book/models.py

小遣い帳のモデル

  • モデルのフィールド定義には、最大長やnull許可等を設定しました
  • 残高はSQLで残高を計算するので、フィールではなくインスタンス変数でもちました
  • ① SQLで残高を計算してもらいために cursor.execute メソッドを使いました
    • モデルに対応するテーブル名はアプリ名_モデル名になりますが、cls._meta.db_tableで取得できます
  • ② ただし、結果がTupleになってしまうのでカラム名:値の辞書になるコードを追加しました
from django.db import models
from django.db import connection

# お小遣い帳のレコードを表すモデル
class Allowance(models.Model):

  # インスタンス化
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.balance = 0

  # --- モデルフィールドの定義 ---
  date = models.DateField()                              # 日付
  description = models.CharField(max_length=255)         # 内容
  money_in = models.IntegerField(null=True, blank=True)  # 入金額
  money_out = models.IntegerField(null=True, blank=True) # 出金額
  created_at = models.DateTimeField(auto_now_add=True)   # 作成日時 (自動追加)
  updated_at = models.DateTimeField(auto_now=True)       # 更新日時 (自動更新)

  # オブジェクトを文字列で表現する際の形式
  def __str__(self):
    return f"id:{self.id}, date:{self.date}, description:{self.description}, money_in:{self.money_in}, money_out:{self.money_out}"

  # 全てのレコードを残高付きで取得するクラスメソッド
  @classmethod
  def get_all_with_balance(cls):
    # DB接続カーソルを取得
    with connection.cursor() as cursor:
      # SQLウィンドウ関数を使って、日付とIDでソートし、累積残高を計算する ↓ ①
      cursor.execute(f'''
        SELECT id, date, description, money_in, money_out,
        SUM(COALESCE(money_in, 0) - COALESCE(money_out, 0)) OVER (ORDER BY date, id) AS balance
        FROM "{cls._meta.db_table}"
      ''')
      # カラム名を取得
      columns = [col[0] for col in cursor.description]
      # 全ての行を取得
      rows = cursor.fetchall()
      # カラム名と行データを組み合わせて辞書のリストを作成して返す  ← ②
      return [dict(zip(columns, row)) for row in rows]
allowance_book/views.py

小遣い帳のコントローラー

  • ① フォーム値のバリデーションはdjangoのバリデーション機能を使いました、コードは次に説明します
  • /deleteはPOSTリクエスト以外ならエラーにしています(AIが作ってくれました😃)
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from allowance_book.forms import AllowanceForm
from allowance_book.models import Allowance

#  GET /, POST /
def index(request):
  # POSTリクエストなら、フォームからデータを保存
  if request.method == 'POST':
    form = AllowanceForm(request.POST)       # ← ①
    if form.is_valid():
      # フォームのデータをAllowanceモデルに保存
      allowance = Allowance(**form.cleaned_data)
      allowance.save()
      # 一覧ページにリダイレクト
      return HttpResponseRedirect(request.path_info)
  # GETリクエストなら、エラーが無いので空のフォームを準備
  else:
    form = AllowanceForm()

  # 全データを取得してテンプレートに渡す
  allowances = Allowance.get_all_with_balance()
  context = {"allowances": allowances, "form": form}
  return render(request, "allowance_book/index.html", context)

# POST /delete
def delete(request):
  # POSTで送られたIDを元にデータを削除
  if request.method != 'POST':
    return HttpResponse(status=405)
  allowance_id = request.POST.get('id')
  Allowance.objects.get(id=allowance_id).delete()
  # 一覧ページにリダイレクト
  return HttpResponseRedirect(reverse("allowance_book:index"))
allowance_book/forms.py

djangoのバリデーションを使ったバリデーション定義クラスです

  • 各フィールドの型と必須や最大長、最初値を定義しています
  • ① 入金・出金の両方が入力されてない、両方が入力されている場合のチェックコード
  • 戻り値には入力値.フィールド名.valueやフィールド名.フィールド名.label、エラーメッセージ.errors等が入っています
from django import forms

# お小遣い帳の入力フォームを定義するクラス
class AllowanceForm(forms.Form):

  # 日付フィールド: 必須入力
  date = forms.DateField(label='日付', required=True)
  # 内容フィールド: 文字列、最大255文字、必須入力
  description = forms.CharField(max_length=255, label='内容', required=True)
  # 入金フィールド: 整数、任意入力、最小値は1
  money_in = forms.IntegerField(label='入金', required=False, min_value=1)
  # 出金フィールド: 整数、任意入力、最小値は1
  money_out = forms.IntegerField(label='出金', required=False, min_value=1)

  # フォーム全体のバリデーションを行うメソッド
  def clean(self):                              # ← ①
    cleaned_data = super().clean()
    # cleaned_dataから入金と出金の値を取得
    money_in = cleaned_data.get("money_in")
    money_out = cleaned_data.get("money_out")

    # 入金と出金の両方が未入力の場合
    if money_in is None and money_out is None:
      raise forms.ValidationError("入金または出金のいずれかを入力してください。")
    # 入金と出金の両方が入力されている場合
    if money_in is not None and money_out is not None:
      raise forms.ValidationError("入金と出金の両方を入力しないでください。")

    return cleaned_data
allowance_book/templates/allowance_book/index.html

テンプレート

テンプレート内のコメントは通常レンダリング結果に残らない {% comment %} ・・・ {% endcomment %} を使いますが、ここでは見やすさを優先して <!-- ・・・ --> を使っています。

{% extends 'base.html' %}
{% block title %}小遣い帳{% endblock %}

{% block content %}

<div class="m-6 w-1/2">
  <h1 class="text-3xl font-bold">小遣い帳</h1>
  <table class="mt-4 min-w-full divide-y divide-gray-200 shadow-sm rounded-lg overflow-hidden">
    <thead class="bg-gray-50">
      <tr>
        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日付</th>
        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内容</th>
        <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">入金</th>
        <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">出金</th>
        <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">残高</th>
        <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
      </tr>
    </thead>
    <tbody class="bg-white divide-y divide-gray-200">
      <!-- データベースから渡されたallowancesをループして表示 -->
      {% for allowance in allowances %}
        <tr>
          <td class="px-6 py-4 whitespace-nowrap">{{ allowance.date|date:"Y-m-d" }}</td>
          <td class="px-6 py-4 whitespace-nowrap">{{ allowance.description }}</td>
          <td class="px-6 py-4 whitespace-nowrap text-right">{{ allowance.money_in|default:"" }}</td>
          <td class="px-6 py-4 whitespace-nowrap text-right">{{ allowance.money_out|default:"" }}</td>
          <td class="px-6 py-4 whitespace-nowrap text-right">{{ allowance.balance }}</td>
          <td>
            <!-- 削除ボタン -->
            <form action="/delete" method="POST">
              <!-- CSRFトークンを追加 -->
              {% csrf_token %}
              <input type="hidden" name="id" value="{{ allowance.id }}">
              <button type="submit" class="px-2 py-1 text-xs font-medium text-center text-white bg-red-700 rounded hover:bg-red-800">削除</button>
            </form>
          </td>
        </tr>
      {% endfor %}
    </tbody>
  </table>

  <!-- 新しい項目を追加するフォーム -->
  <form action="/" method="POST" class="my-6 p-6 bg-white shadow-md rounded-lg">
    <!-- CSRFトークンを追加 -->
    {% csrf_token %}
    <h2 class="text-2xl font-bold mb-4">新しい項目を追加</h2>

    <!-- バリデーションエラーがあった場合に表示 -->
    {% if form.errors %}
      <div <div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 " role="alert">
        <ul>
        {% for field in form %}
          {% for error in field.errors %}
            <li>{{ field.label }}: {{ error }}</li>
          {% endfor %}
        {% endfor %}
        {% for error in form.non_field_errors %}
          <li>{{ error }}</li>
        {% endfor %}
        </ul>
      </div>
    {% endif %}

    <!-- 各入力フィールド、エラー時の入力値はform.dateに入っているので設定 -->
    <div class="space-y-4">
      <div>
        <label for="date" class="block text-sm font-medium text-gray-700">日付</label>
        <input type="date" value="{{ form.date.value }}"
          name="date" id="date" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
      </div>
      <div>
        <label for="description" class="block text-sm font-medium text-gray-700">内容</label>
        <input type="text" value="{{ form.description.value|default:"" }}"
          name="description" id="description" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="例: おこづかい">
      </div>
      <div>
        <label for="money_in" class="block text-sm font-medium text-gray-700">入金</label>
        <input type="text" value="{{ form.money_in.value|default:"" }}"
          name="money_in" id="money_in" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0">
      </div>
      <div>
        <label for="money_out" class="block text-sm font-medium text-gray-700">出金</label>
        <input type="text" value="{{ form.money_out.value|default:"" }}"
          name="money_out" id="money_out" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0">
      </div>
    </div>
    <div class="mt-6 text-right">
      <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
        追加
      </button>
    </div>
  </form>

</div>

{% endblock %}

まとめ

前々回からFlask, FastAPI, Djangoを使って簡単なWebアプリを作ってみましたが、これらのWebフレームワークの違いをAIにまとめてもらいました。

フレームワークタイプ特徴/用途例学習難易度コード
Djangoフルスタック管理画面・認証など標準装備
大規模サイト向き
やや高い今回
Flaskマイクロ必要最小限で拡張自由
小~中規模・プロトタイプ向き
低い前々回
FastAPIマイクロ非同期/高性能API、高速ドキュメント生成
API向き
比較的低い前回

フロントエンドをReact等で書く場合のバックエンドはFastAPIで良いでしょう。

しかし、従来のバックエンド主体で動作するWebアプリの場合。 Djangoは、このレベルのアプリにはやや大げさですが、バリデーションを始めWebアプリに必要な機能が全て入っているので開発は楽でした。もちろん知らなければならない知識は多いですが、AIに助けてもらう事で助かりました。😅

バリデーションのエラーメッセージの日本語化は設定ファイル(settings.py)でLANGUAGE_CODE = 'ja'と設定するだけで出来たのは感動しました。Flaskでは全部自前で書きましたし、FastAPIではメッセージの日本語化は面倒でした、

長年、Ruby on RailsでWebアプリを開発してきましたが、Djangoも同じような安心感がありますね。通常必要になるプラグイン等は充実しているでしょうし、規約を守ればセキュリティーも保証されます。また、ネット上の情報も充実しています。
このレベルのアプリでも、やや複雑なDjangoの知識をAIの助けを借りながら学び、使っていくのが良いかもしれませんね。

ただし、以下のような場合はFlaskも良いかと思います。

  • Webアプリの学習目的
  • ユーザビリティを要求されない、自分専用ツール
  • 他人のスタイルは嫌だ!何としても自分のスタイルで作りたい人

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ