2つのモデルを1つのフォームで扱う方法(CreateView、UpdateView)

Django

はじめに

DjangoでOneToOneで関連付けた複数のモデルを1つのフォームに表示して、同時に保存、更新する必要がありました。
django-extra-viewsを使ってもできるようですが、今回はモデルは2個だけなので何とかできないかと調べました。
クラスを使って作成しています。
ユーザ認証を使わない場合は、各クラスに記載しているLoginRequiredMixinを削除してください。

環境

Debian bullseye 64bit
Python 3.10.2
Djnago 4.0.3
Bootstrap 5.0.2
project
 ┣ -- fee
      ┣ -- admin.py
      ┣ -- forms.py
      ┣ -- models.py
      ┣ -- urls.py
      ┣ -- views.py
 ┣ -- template
      ┣ -- base.html
      ┣ -- fee
           ┣ -- admfee_create.html

CreateView

CreateViewの部分は下記をそのまま使わせていただきました。

Django 1画面に同じフォームを複数表示させる – igreks開発日記

UpdateView

UpdateViewはCreateViewを元にして作成しました。
CreateViewの部分と同様に基本となるモデルは通常通り取得できますが、OneToOneなどのモデルは工夫する必要があります。

models.py

feeというアプリケーションを作成して、AdmFeeとUserFeeの2個のモデルを定義します。
UserFeeからAdmFeeをOneToOneで参照します。

from django.db import models
import uuid

class AdmFee(models.Model):
    id           = models.UUIDField('UUID', default=uuid.uuid4, primary_key=True, editable=False)
    created_time = models.DateTimeField('登録日時', auto_now_add=True)
    updated_time = models.DateTimeField(verbose_name='更新日時', auto_now=True)
    year         = models.IntegerField(verbose_name='年', choices=list(zip(range(2022, 2040), range(2022, 2040))))

    # 管理画面の表示名を変更する。
    class Meta:
        verbose_name        = "負担金額"
        verbose_name_plural = "負担金額"

class UserFee(models.Model):
    id           = models.UUIDField('UUID', default=uuid.uuid4, primary_key=True, editable=False)
    created_time = models.DateTimeField('登録日時', auto_now_add=True)
    updated_time = models.DateTimeField(verbose_name='更新日時', auto_now=True)
    # CASCADE:元データも削除
    admfee = models.OneToOneField(AdmFee, verbose_name='負担金額', on_delete=models.CASCADE, related_name='admfee')

    exp_total = models.IntegerField(verbose_name='合計', default=0, blank=True)

    # 管理画面の表示名を変更する。
    class Meta:
        verbose_name        = "ユーザー支出先"
        verbose_name_plural = "ユーザー支出先"

views.py

2個のモデルを同時に作成、アップデートするクラスを記述します。

from django.shortcuts import render
from django.views.generic import CreateView, UpdateView
from django.views.generic.edit import ModelFormMixin
from django.http import HttpResponse, HttpResponseRedirect
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.urls import reverse

class AdmFeeCreateView(LoginRequiredMixin, CreateView, ModelFormMixin):
    model         = AdmFee
    fields        = ()
    template_name = 'fee/admfee_create.html'

    # 更新ページ表示用の変数をtemplateに渡す。
    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        # contextは辞書型
        ctx.update({
            'admfee_form': AdmFeeForm(**self.get_form_kwargs()),
            'userfee_form': UserFeeForm(**self.get_form_kwargs()),
        })
        return ctx

    def post(self, request, *args, **kwargs):
        # POSTデータをコピー
        admfee_form_set = request.POST.copy()
        userfee_form_set = request.POST.copy()
        # 'form-TOTAL_FORMS'を上書き
        admfee_form_set['form-TOTAL_FORMS']  = 3
        userfee_form_set['form-TOTAL_FORMS'] = 5
        # 新規オブジェクト生成
        admfee_form  = AdmFeeForm(admfee_form_set)
        userfee_form = UserFeeForm(userfee_form_set)
        # validのチェック
        if admfee_form.is_valid() and userfee_form.is_valid():
            admfee_query  = admfee_form .save(commit=False)
            userfee_query = userfee_form.save(commit=False)
            # トランザクションの設定
            with transaction.atomic():
                admfee_query.save()
                # OneToOneのインスタンスを代入
                userfee_query.admfee = admfee_query
                userfee_query.save()
        # リダイレクト先を記載
        # urls.pyのname変数で定義した名前を指定する。
        return HttpResponseRedirect(reverse('admfee_create'))

class AdmFeeUpdateView(LoginRequiredMixin, UpdateView):
    model         = AdmFee
    form_class    = AdmFeeForm
    success_url   = reverse_lazy('admfee_update')
    template_name = 'fee/admfee_create.html'

    # 更新ページ表示用の変数をtemplateに渡す。
    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        admfee_form = AdmFeeForm(**self.get_form_kwargs())
        # AdmFeeのpkから対応するUserFeeを探す。
        userfee = UserFee.objects.get(admfee=self.kwargs['pk'])
        # UserFeeのデータをinstanceで渡す。
        # https://jpdebug.com/p/2145748
        userfee_form = UserFeeForm(instance=userfee)
        ctx.update({
            'admfee_form': admfee_form,
            'userfee_form': userfee_form,
        })
        return ctx

    # 2個のform(AdmFee、UserFee)を同時に更新する。
    def form_valid(self, form):
        # form変数はmodelで指定したフォーム。
        admfee_form = form
        # 2個目のフォームの内容を取得してinstanceを指定する。
        userfee      = UserFee.objects.get(admfee=self.kwargs['pk'])
        userfee_form = UserFeeForm(self.request.POST or None, instance=userfee)
        # validのチェック
        # form_valid関数に記述しているのでadmfee_form.is_valid()は不要かもしれない。
        if admfee_form.is_valid() and userfee_form.is_valid():
            admfee_query  = admfee_form.save(commit=False)
            userfee_query = userfee_form.save(commit=False)
            # トランザクションの設定
            with transaction.atomic():
                admfee_query.save()
                userfee_query.save()
        # urls.pyのname変数で定義した名前を指定する。
        return HttpResponseRedirect(reverse('admfee_create'))

    # バリデーションに失敗
    def form_invalid(self, form):
        messages.warning(self.request, 'Error:Could not save to database.')
        return super().form_invalid(form)

forms.py

2個のモデルについて、それぞれフォームを作成します。

from django import forms
from .models import UserFee, AdmFee

class AdmFeeForm(forms.ModelForm):
    class Meta:
        model  = AdmFee
        fields = '__all__'

        # フォームで非表示にする項目は「exclude」で指定する。
        # admin画面では表示される。
        exclude = ['',]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Bootstrap
        # class名の先頭に「form-control」を付加する。
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'

class UserFeeForm(forms.ModelForm):
    class Meta:
        model  = UserFee
        fields = '__all__'

        # フォームで非表示にする項目は「exclude」で指定する。
        # admin画面では表示される。
        exclude = ['admfee',]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Bootstrap
        # class名の先頭に「form-control」を付加する。
        for field in self.fields.values():
            field.widget.attrs['class'] = 'form-control'

urls.py

AdmFeeCreateViewとAdmFeeUpdateViewを設定します。

from django.urls import path
from . import views

urlpatterns = [
    path('admfee_create', views.AdmFeeCreateView.as_view(), name='admfee_create'),
    path('update_admfee/<uuid:pk>/', views.AdmFeeUpdateView.as_view(), name='admfee_update'),
]

admin.py

管理画面で関連付けた2個のモデルを同時に表示するために設定します。
こうすることで、OneToOneで関連付けた2つのモデルを管理画面で同時に編集することが可能です。
Djangoの管理画面では今回と同様のことができるので、時間があればソースを追っかけてみようと思います。

from django.contrib import admin

# UserFeeを同じ画面に表示する設定
class UserFeeInline(admin.StackedInline):
    model      = UserFee
    max_num    = 1
    can_delete = False
    verbose_name_plural = 'ユーザー負担金'

class AdmFeeAdmin(admin.ModelAdmin):
    # UserFeeを同じ画面に表示する設定
    inlines = [UserFeeInline,]

    # 保存ボタンの配置を上部に変更
    save_on_top = True
    # デフォルトの「保存して1つ追加」を「新規保存」に変更。
    # 追加されているデータを元に新しくデータを保存する。
    save_as = True

    list_display  =  'year', 'created_time', 'updated_time')
    search_fields = ('id', 'year')

admin.site.register(AdmFee, AdmFeeAdmin)

admfee_create.html

データの作成とアップデートに使います。
views.pyで設定したadmfee_formとuserfee_formを使って変数を参照します。
また、リダイレクト先はurls.pyで設定したadmfee_createとadmfee_updateを使います。
admfee_formとuserfee_formはfor文で回しても良いです。

{% extends 'base.html' %}

{% block title %}
データ作成・更新
{% endblock title %}

{% block h1_title %}
データ作成・更新
{% endblock h1_title %}

<!-- 本体 -->
{% block body %}

<form method="post">
{% csrf_token %}

<p class="text-center">
<input type="submit" class="btn btn-primary" value="登録する">
<input type="button" class="btn btn-danger" value="戻る " onClick="javascript:history.go(-1);">
</p>

<div class="bg-light bg-gradient">
<p class="fs-3 fw-bold">
基本データ
</p>
<table class="table table-border align-middle text-center">
<tr>
<th class="table-warning">{{ admfee_form.year.label }}</th>
<td>{{ admfee_form.year }}</td>
</tr>
</table>
</div>

<div class="bg-light bg-gradient">
<p class="fs-3 fw-bold">
支出先データ
</p>
<table class="table table-bordered table-striped align-middle text-center">
<tr>
<th class="table-danger"></th>
<td>{{ userfee_form.exp_total }}</td>
</tr>
</table>
</form>
</div>

{% endblock body %}

base.html

他のテンプレートの元となるファイルです。
admfee_create.htmlの先頭で読み込みます。

{% load i18n static %}
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">

<link rel="shortcut icon" type="image/png" href="{% static 'favicon/favicon.ico' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap.min.css' %}">

<!-- Google Web font start -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap" rel="stylesheet">
<!-- Google Web font end -->

<script src="{% static 'js/jquery-3.6.0.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>

<title>
{% block title %}
{% endblock %}
</title>

</head>

<body>

<!-- container: 表示幅を可変にする。-->
<div class="container-fluid">

<!-- formのエラー表示 -->
{% if form.errors %}
    {% for field, errors in form.errors.items %}
        {% for error in errors %}
            <div class="alert alert-danger" role="alert">
                <p class="mb-0">{{ field }} {{ error }}</p>
            </div>
        {% endfor %}
    {% endfor %}
{% endif %}

<!-- 「form.errors」以外のエラー表示(ex:カスタムバリデーションなど)  -->
{% if non_field_errors %}
    <div class="alert alert-danger" role="alert">
        {% for error in form.non_field_errors %}
            <p{% if forloop.last %} class="mb-0"{% endif %}>{{ error }}</p>
        {% endfor %}
    </div>
{% endif %}

<!-- SuccessMessageMixinのメッセージ表示 -->
{% if messages %}
<div class="text-center messages alert alert-primary" role="alert">
    {% for message in messages %}
      {{ message }}
    {% endfor %}
</div>
{% endif %}

<h1>hoge</h1>

{% block allauth %}
{% endblock %}

{% block charge_404 %}
{% endblock %}

{% block body %}
{% endblock %}

<!-- フッタ開始 -->
<footer class="container-fluid">
<small>
&copy;hoge
</small>
</footer>
<!-- フッタ終了 -->

<!-- end container -->
</div>

</body>
</html>

Comments