久しぶりのフレームワークの勉強になります。かたりぃなです。
環境: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の段階で作られるファイルによって世代管理が行われてるようです。
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'