Djangoで簡単なWebアプリを作ってみる - catalinaの備忘録

catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

Djangoで簡単なWebアプリを作ってみる

久しぶりのフレームワークの勉強になります。かたりぃなです。

今回はpythonDjangoを試してみたいと思います。

環境:vscode dev container (python3 + posgresql)上でのdjango5.1

マニュアルが充実しているので、基本はその通りにやっていればいけるので、実際にアプリを作ってみて躓いた点を主にまとめていきます。

アプリの内容としてはカードゲームなんかのデッキ構築アプリになります。

とはいえ細かいところまで考え始めるとキリがないので、フレームワークや言語の感覚をざっくりとつかむための簡単なものに留めます。

具体的には

  • ログイン認証
  • カード情報はアプリ側で用意済みの想定(djangoの管理ページから登録するだけ)
  • ユーザーは用意されたカードを自身のデッキに組み込んで、デッキを構築できる
  • ユーザーは自身のデッキ情報やカード情報を見れる
  • デザインはbootstrapの適当なものを割り当ててみる程度に留める

くらいの簡単な骨組みだけ作ってみます。

djangoの基本

MVC(Model, View, Controller)じゃなくてMTV(Model, Template, View)らしいです。

  • Modelというデータ管理があって
  • 表示はテンプレートによって行う
  • ViewがModelからデータを引っ張ってきて、表示用テンプレートにはめ込んで完成

みたいな感じです。MVCとの違いはよくわかりませんが、まずは似たような雰囲気だと理解しました。

model関係の使い方

djangoのMTVのうちM(Model)の扱い方です。 modelはdbに格納するデータ構造の定義です。たとえば以下のような形です。

class ModelName(models.Model):
    name = models.CharField(max_length=30, default='名前なし')
    num = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(4)])

マイグレーション

一般的にはプログラムやフレームワークの(今回はpython)で表現したデータ構造(model)をDBに反映する作業のことです。

python manage.py makemigrations
python manage.py migrate

DjangoではDBに反映するには2つの段階があって、

  1. makemigrationsでマイグレーションファイルを生成する
  2. migrateで、1で作ったマイグレーションファイルを使ってDBに実際に変更を加える

1の段階で作られるファイルによって世代管理が行われてるようです。

0001_initial.py
0002_table.py
0003_remove_table_newtable.py
0003_alter_newtable.py

みたいに、ファイル名が変更のサマリ、具体的な変更の内容はファイル内に記載されています。

外部キー制約

ForeignKeyで外部キー制約を指定することができます。

  • 第一引数は他のモデルクラス
  • on_deleteは削除されたときの挙動
  • related_nameは逆引きで使う
class Deck(models.Model):
    userid = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="deck")

リレーション(n対n関係, 1対n関係)

DBの正規化の文脈でよく出てくるやつです。1対n関係は↑のとおりForeignKeyで定義できました。 n対n関係(=多対多)の関係をモデルに定義するにはどうするかというと、Djangoが良い感じに面倒見てくれるみたいです。

DBでいうERDでの設計において多対多の関係が現れたとき、中間にエンティティを定義してそれぞれのテーブルへの外部キーを持たせるというアレです。 たとえば、カードゲームにおいて、

  • デッキには複数種類のカードを入れることができる
  • 1つのカードは多数のデッキで使われる

を安直に表現するとこうなりました。

class deck(model):
    userid = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="deck")
    name = models.CharField(max_length=30, default='名前なし')
    cards = models.ManyToManyField(Card, verbose_name="card")       # カードは複数のカードリストから参照される(多対多の関係)

こう定義してマイグレーションするとDjangoが中間テーブルを作ってくれる。 正引き・逆引きともに可能。

なお、many-to-manyのレコードは重複しないように管理されるみたい。

中間テーブルに情報を追加したい

よくある話で、中間テーブルに情報を持たせたいという話。 たとえば先ほどのカードゲームにおいては

  • 1つのデッキに同一のカードは4枚までしか含めることができない

というルールがあります。 何枚のカードがデッキに入ったかを管理するには、上記の中間テーブルに情報を追加するのが簡単です。

Djangoでは中間テーブルを自前で定義することをDjangoに伝えることができます。 https://docs.djangoproject.com/ja/5.1/ref/models/fields/#django.db.models.ManyToManyField.through

もしくは、自力で中間テーブル作ってしまえばいいかもしれません。

class Deck(models.Model):
    userid = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="deck")
    name = models.CharField(max_length=30, default='名前なし')

class CardsInDeck(models.Model):
    cardid = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='card2deck')
    deckid = models.ForeignKey(Deck, on_delete=models.CASCADE, related_name='deck2card')
    count = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(4)])

オブジェクトの絶対URL

get_absolute_urlは、このモデルの詳細ビューのページURLを決めるためにフレームワークから呼ばれるものです。

class deck(models.Model):
    ~~略~~
    def get_absolute_url(self):
        return reverse("deck_detail", kwargs={"pk": self.pk})

正引きと逆引き

related_nameの例。いわゆる逆引きの例です。 (参考:https://docs.djangoproject.com/ja/5.1/topics/db/models/#be-careful-with-related-name-and-related-query-name)

class Deck(models.Model):
    userid = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="deck")
    name = models.CharField(max_length=30, default='名前なし')

class InDeck(models.Model):
    cardid = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='card2deck')
    deckid = models.ForeignKey(Deck, on_delete=models.CASCADE, related_name='deck2card')
    count = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(4)])

class Card(models.Model):
    name = models.CharField(max_length=100)
# 正引き:特定のデッキ(デッキID=2)に含まれているInDeckすべて
InDeck.objects.filter(deckid=2)
# 逆引き:カードすべてを取得する。フィルタ条件:deckid=2のInDeck(related_name=card2deck)
Card.objects.filter(card2deck__deckid=2)

dbshellの接続でエラーになる

python manage.py dbshell vscodeのdev環境でエラーになりました。sqlite3が入ってなかったので、インストールして解決です。

sudo apt update
sudo apt install sqlite3

dbshellの使い方(sqlite)

デフォルト状態だとヘッダが表示されないので、.headers ONしてからクエリ発行。

sh> python manage.py  dbshell
sqlite> .headers ON
sqlite> .table
~~テーブル表示される~~
sqlite> select * from テーブル名;
sqlite> .schema
~~スキーマが表示される~~

SQLのクエリを確認する

pip install django-debug-toolbarデバッグ用のツールバーをインストール。

公式サイトhttps://django-debug-toolbar.readthedocs.io/en/latest/installation.htmlを見ながら設定すればOKです。 このツールバーは、htmlのbody要素に差し込んでくる挙動らしく、body要素がないhtmlだと表示されないので注意。

デバッグ時のみ表示されるので、デバッグ接続設定をsettings.pyに追加する必要があります。

INTERNAL_IPS = [
    # ...
    "127.0.0.1",
    # ...
]

トランザクション

DBのACID特性の話です。 Modelのリレーションの保存のコードを書いていて、トランザクション張ったほうがいいのでは?と思って調べてみました。

https://docs.djangoproject.com/ja/5.1/topics/db/transactions/

設計によりますが、状況によっては使ったほうがよさそうですね。

使い方めも(view)

MTVにおけるV(View)ですね。MVCでいうコントローラみたいなことをやるみたいです。

大別してファクションベースとクラスベースの2種類がある。 クラスベースにはさらにCRUDに関する基本的な機能を備えたクラスごとに細分化されています。

  • C = CreateView
  • R = ListView, DetailView
  • U = UpdateView
  • D = DeleteView

ファンクションベースのビュー

リクエストを受けてレスポンスを返す。シンプル故に何でもできるけど、何もできない。

def hello_world(request):
    return HttpResponse("hello world")

クラスベースのビュー

ログインユーザーだけ閲覧できるページにしたいときはLoginRequiredMixinを付与すればよいです。

CreateView

あるmodelで定義されたすべてのフィールドを入力してmodelを作成するためのビュー。 allはよくないので、必要なフィールドのみリストに列挙するのがベストです。

class DeckCreateView(LoginRequiredMixin, CreateView):
    model = Deck
    fields = '__all__'
    template_name = 'deck_create.html'

テンプレートのdeck_create.htmlはこれだけ。

<body>
    <form method="POST">
    {% csrf_token %}
    {{ form }}
    <input type="submit" value="これで作成">
    </form>
</body>

DetailView

あるモデルの詳細ビュー(閲覧用)を作る例。 ちょっと詰まったポイントがあったのでまとめておきます。

  • modelとtemplate_nameを指定する。
  • get_context_dataで必要な情報を集めて、返してあげる
  • テンプレート側からはそのままアクセスできる。中身はcontextのキーとして指定した値(ここでは'cards')でアクセスできる。
# デッキの詳細ビュー
class DeckDetailView(LoginRequiredMixin, DetailView):
    model = Deck
    template_name = 'deck_detail.html'

    # カード一覧を追加情報として渡す
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        cards = CardsInDeck.objects.filter(deckid=self.kwargs['pk'])
        context['cards'] = cards
        return context
    <h1>デッキ名:({{deck.name}})</h1>
    <ul>
        {% for card in cards.all %}
            {{card.cardid.name}}
            {{ card.cardid.text }}
        {% endfor %}
    </ul>

urls.py

path('<int:pk>/', DeckDetailView.as_view(), name='deck_detail'),

ListView

詳細ビューに移る前のオブジェクトの一覧ビュー。ブログの記事一覧ページとか。 表示対象を絞り込みたいときは、get_querysetをオーバーロードする。(self.request.userに入っているログインユーザーIDでフィルタする例) テンプレート側では、get_querysetの結果はobject_listでアクセスできる。

class DeckListView(LoginRequiredMixin, ListView):
    model = Deck
    paginate_by = 10
    template_name = 'deck_list.html'

    # 表示するのはログインユーザーのデッキだけ
    def get_queryset(self):
        return Deck.objects.filter(userid=self.request.user)

    # 追加で渡したい情報を載せていく
    def get_context_data(self, **kwargs):
        content = super().get_context_data(**kwargs)
        return content
    {% for deck in object_list %}
    <li>デッキ名:  <a href="{% url 'deck_detail' deck.id %}">{{deck.name}} </a>, 所有者={{deck.userid}}</li>
    {% endfor %}

使い方めも(テンプレート)

MTVのTemplatです。表示周りですね。

ちょっとしたtipsをめもしておきます

ログイン状態を判断して表示を切り替える

{% if user.is_authenticated %}
<p>{{ user.get_username }}でログイン中</p>
{% else %}
<p>ログインしていません</p>
{% endif %}

参考( https://docs.djangoproject.com/ja/5.1/topics/auth/default/#user-objects )

独自のテンプレートタグ

表示するデータの加工は基本的にはViewで行うことができるのですが、テンプレート側でちょっと工夫したいことがあったりします。

どうしてもテンプレートの実装方法がわからない等、いざというときの回避手段として試しておきたいと思います。

公式:https://docs.djangoproject.com/ja/5.1/howto/custom-template-tags/

単純なパターンで試してみます。 テンプレートに渡されたidを、それに紐づいた文字列に変換して返すテンプレートタグ。

ファイル:templatetags/deck_extras.py

from django import template
from cards.models import Card

register = template.Library()

@register.filter
def cardname(id):
    try:
        name = Card.objects.get(id=id).name
        return name
    except:
        return "cardname unknown"

テンプレートからは

カード名 {{ form.cardid.value|cardname }}

でidに紐づいた名前に変換して表示できます。

ハマりどころ。

  • registerまで書かないとテンプレートタグとして認識されない
  • 開発サーバ再起動しないと認識されない
  • init.pyやtemplatetagsディレクトリは自分で作って配置する必要がある

manage.pyの機能に追加してほしい気もします。

使い方メモ(form)

いわゆるhtmlのform。 概念的にはシステム内の何か(よくあるのはmodel)とformのマッピングを定義するもの。たとえばmodelformはmodelとformのマッピングを定義する等。

具体的な表示はviewとtemplateのお仕事みたい。 POSTされた内容からformを再構築したり、変更点を検出したりもできる。

バリデーションについてはDjangoが標準で提供しているやり方に沿って実装すればよさそう。バリデーションの詳細は公式ドキュメントを。 https://docs.djangoproject.com/ja/5.1/ref/validators/

formset

あるモデルを複数同時に表示・編集などをしたい場合に。 概念的にはグリッド。(表示はその限りではない)

modelに紐づいているものがmodelformset そうではなく独立したものはformsetというらしいです。

formの項目がなぜか必須項目扱いにされる問題

手元の環境ではHTMLのinput要素にrequiredフィールドが設定されていて、未入力の場合エラーになってしまいまう現象が起きました。(ブラウザの挙動) https://developer.mozilla.org/ja/docs/Web/HTML/Attributes/required

formsetの場合はこの現象は起きないみたいで、単体のformの場合だけ発生していました。 これはdjangoによってhtmlに required="" が付与されるのが原因のようです。

form全体もしくはフィールド単位で設定すればOK。

addform.use_required_attribute = False      # form全体のinput要素のrequiredを無効化
field.required = False                      # 特定のフィールドのrequiredを無効化

formにhidden要素を含めたい。

{{ form.card.as_hidden }}

でhiddenになる。

ちなみにcsrftokenはおまじない的なコード書くだけで自動的にやってくれるようです。すごいですね。

modelformset

https://docs.djangoproject.com/ja/5.1/topics/forms/modelforms/ 思ったより手こずったのでコード全体をのせておきます。

modelとformsetfactoryの定義

class CardsForm(forms.ModelForm):
    class Meta:
        model = CardsInDeck
        fields = ['cardid', 'count']

CardListFormset = forms.modelformset_factory(CardsInDeck, CardsForm, extra=0, can_delete=True)

formsetのView

getのときはformsetをレンダリングするだけ。必要に応じてquery_setやinitialパラメータで初期値を設定してあげる。 postの処理はPOSTを受け取ってvalid, saveしたうえで外部キーを設定したり、削除対象は削除したりとか。

def manage_cards(request, pk):
    deck = get_object_or_404(Deck, pk=pk)
    if request.method == 'POST':
        formset = CardListFormset(request.POST)
        if formset.is_valid():
            # 保存
            instances = formset.save(commit=False)
            for instance in instances:
                instance.deckid = deck
                instance.save()
            # 削除
            for obj in formset.deleted_objects:
                obj.delete()
            # 詳細ページへ戻る
            return redirect( reverse('deck_detail', kwargs={'pk': pk}) )
    else:
        formset = CardListFormset(queryset=CardsInDeck.objects.filter(deckid=pk))

    return render(request, 'manage_cards.html', {'formset': formset})

formsetのtemplate

htmlはこうなりました。 ポイントは

  • formsetにformがたくさん入っている
  • formの中に各フィールドが入っている
  • {% csrf_token %} と {{ formset.management_form }} は忘れずに

です

テンプレートの書き方は公式を参考に。 https://docs.djangoproject.com/ja/5.1/topics/forms/formsets/

困ったときは{{ formset }}とか{{ form.as_p }} とかで中身見えるので、意外となんとかなります。

<body>
    <h1>Manage Cards for {{ deck.name }}</h1>
    <form method="post">
        {% csrf_token %}
        {{ formset.management_form }}
        <div id="cardlist-formset">
            {% for form in formset %}
                <div class="form-group">
                    {{ form.id }}
                    {{ form.cardid }}
                    枚数 {{ form.count }}
                    削除 {{ form.DELETE }}
                    {% if form.non_field_errors %}
                        <div class="alert alert-danger">
                            {% for error in form.non_field_errors %}
                                <p>{{ error }}</p>
                            {% endfor %}
                        </div>
                    {% endif %}
                    {% for field in form %}
                        {% if field.errors %}
                            <div class="alert alert-danger">
                                {% for error in field.errors %}
                                    <p>{{ error }}</p>
                                {% endfor %}
                            </div>
                        {% endif %}
                    {% endfor %}
                </div>
            {% endfor %}
        </div>
    </form>
</body>

1つのformに2つのsubmitボタンをつけたい

POST操作が複数あるページ。たとえばブログで「投稿」「プレビュー」があるみたいな話。

  • htmlのformactionでurlを指定する
  • formにPOSTされたボタン名で判断する

おそらく後者のほうが簡単。前者だとURL管理が入ってくるため。

<form>
    ~~~~ なんかの入力フォーム ~~~~
    <button type="submit" name="preview">preview</button>
    <button type="submit" name="post">post</button>
</form>
def manage_cards(request, pk):
    if request.method == 'POST':
        if "preview" in request.POST:
            # プレビューを生成
        elif "post" in request.POST:
            # 投稿。modelを生成してsave
    else:
        # GETなので入力フォームを生成する

formsetに追加のformをつけたい

formsetの機能では足りないときに追加のformとかをつけてデータをやりとりする方法。

長くなったので該当箇所そのまま掲載します。

ポイントは

  • htmlにパラメータを確実に渡しておくこと
  • POSTとformをバインドするには AddCardForm(request.POST) や CardListFormset(request.POST)で可能

です。 パラメータ名がユニークであれば大丈夫みたい。

# カードを追加するためのフォームを独自定義する(formsetのextraは使わない)
class AddCardForm(forms.ModelForm):
    class Meta:
        model = CardsInDeck
        fields = ['cardid', 'count']
# formsetを使ってみる
def manage_cards(request, pk):
    deck = get_object_or_404(Deck, pk=pk)
    if request.method == 'POST':
        formset = CardListFormset(request.POST)
        if "update" in request.POST:
            # formsetに関する処理
            pass
        if "add" in request.POST:
            # 独自に定義したカード追加用のformの処理
            addform = AddCardForm(request.POST)
            addform.use_required_attribute = False      # input要素のrequiredを無効化しておく。追加カードの入力は必須ではない
            if addform.is_valid():
                cardid = addform.cleaned_data["cardid"]
                count = addform.cleaned_data["count"]
                new_card = CardsInDeck(deckid=deck, cardid=cardid, count=count)
                new_card.save()
                return redirect( reverse('deck_detail', kwargs={'pk': pk}) )
            return render(request, 'manage_cards.html', {'formset': formset, 'addform':addform, 'deckid':pk})

    else:
        formset = CardListFormset(queryset=CardsInDeck.objects.filter(deckid=pk))
        addform = AddCardForm()
        addform.use_required_attribute = False

    return render(request, 'manage_cards.html', {'formset': formset, 'addform':addform, 'deckid':pk})

htmlではviewで設定しているaddformという名前で参照して使うことができました。

    <form method="post">
        {% csrf_token %}
        {{ formset.management_form }}
        {% comment %} デッキ内のカード編集用フォームセット {% endcomment %}
            {% for form in formset %}
                {% comment %} 上記のformsetのコード {% endcomment %}
            {% endfor %}
        <button type="submit" name="update">Update</button>
        {% comment %} デッキにカードを追加するためのフォーム {% endcomment %}
        <div>
            カード {{ addform.cardid }}<br>
            枚数 {{ addform.count }}<br>
        </div>
        <button type="submit" name="add">Add</button>
    </form>

アプリケーション間の連携設定

忘れがちなのでメモ。

基本設定

プロジェクトディレクトリのsettings.pyに追加したアプリケーションを記載する

  • アプリケーション名=deck
  • アプリケーションディレクトリ配下のapps.pyのAppConfig継承クラス名=DeckConfig

の場合はこうなります。

INSTALLED_APPS = [
    ~~略~~
    "deck.apps.DeckConfig",
]

他アプリケーションにurlルーティングしたい

プロジェクトのurls.pyにincludeで書きます。このurlsもimportしておく必要があります。

    path('deck/', include('deck.urls') ),

urlにキーを含めたい

ブログでいう記事IDとか、そういうのが含まれるURLと、それを扱う方法です。

まず、urls.pyはこうなります。

urlpatterns = [
    path('<int:pk>/update/', manage_cards, name='deck_update'),
    path('<int:pk>/', DeckDetailView.as_view(), name='deck_detail'),
]

viewは関数ベースとクラスベースでちょっと違ってるのでそれぞれ。 クラスベースの場合はkwargsの中とかに入ってました。

class DeckDetailView(LoginRequiredMixin, DetailView):
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        id=self.kwargs['pk']
        context['cards'] = CardsInDeck.objects.filter(deckid=id)
        return context

関数ベースの場合は引数に渡されてきます。

def manage_cards(request, pk):
    deck = get_object_or_404(Deck, pk=pk)

他アプリケーションのモデルを参照したい

# アプリ名とモデル名は適当に置き換えること
from アプリ名.models.モデル名

Djangoの管理画面でmodelを閲覧・操作できるようにしたい

アプリケーションディレクトリのadmin.pyに記載する。お約束みたいです。

from django.contrib import admin
from .models import Deck

admin.site.register(Deck)

ログイン関連の設定で必要なものは?

settings.pyの末尾にこういう設定入れることが多い

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

AUTH_USER_MODEL = "accounts.CustomUser"
LOGIN_REDIRECT_URL = "list_entry"
LOGIN_URL = 'accounts:login'