注意
今回のお題はタイトルのもある通りで、バリデーションの中でリレーション先のレコードの情報を参照する方法です。
結論だけが知りたいという方は「解決方法」以降の項目をご参照ください。
はじめに
djangoアプリにおいてバリデーションを設定する方法は4つあります。
すなわち、入力フィールドのオプションとして設定する・組み込みバリデータを使う・cleanメソッドを用いる・自作のメソッドを作りバリデータとして設定する、ですね。
入力フィールドのオプションとはCharField
のmax_legth
などのことで、組み込みバリデータとはMaxValueValidator
などのことです(詳しい説明は省略します)。
clean
メソッドについては以下をどうぞ。
自作のメソッドを作りバリデータに登録するというのは以下のようなものになります。
def check_price(value):
if not 0 < value <= 1000:
raise ValidationError("価格は0以上1000以下の数字で入力してください。")
class Item(models.Model):
price = models.IntegerField(
verbose_name="価格",
validators = [check_price]
)
Itemモデルのprice属性の値が0未満もしくは1000より大きい場合にエラーを出すようになっています。
ただしこれら4つの方法はいずれもバリデーションの基準となる値が固定されていました。
上記のItemモデルの例ではそれでも問題ありませんでしたが、例えばリレーション先を持っているモデルに対して、リレーション先のモデルの属性値に合わせてバリデーションの基準を変えるといったことを試みた際に少し手こずったのでメモとして残しておきます。
やりたかったこと
以下のようにShopモデル、Menuモデル、Orderモデル、Reservationモデルがあり、ユーザーは店舗のページから商品を指定して注文をかけた後(この時点でOrderモデルのインスタンスが作られる)別途で受け取りの予約を入れるようになっている(=Reservationインスタンスの作成)。
この受け取り予約の際に、店舗の営業時間および休業日の情報に応じてバリデーションがかかるようにしたい。
from django.db import models
from django.db.models.fields import BooleanField, IntegerField
from django.db.models.fields.files import ImageField
from django.core.validators import MaxValueValidator, MinValueValidator
from phonenumber_field.modelfields import PhoneNumberField
# Create your models here.
class Weekday(models.Model):
name = models.CharField(
verbose_name="曜日名",
max_length=3
)
number = models.IntegerField(
verbose_name="曜日コード",
validators=[
MaxValueValidator(6, "6以下の数字で入力してください"),
MinValueValidator(0, "0以上の数字で入力してください")
],
unique=True
)
def __str__(self) -> str:
return f'{self.number}:{self.name}'
class Shop(models.Model):
name = models.CharField(
verbose_name="店舗名",
max_length=30,
)
image = ImageField(
verbose_name="店舗写真",
upload_to="images/shop"
)
explain = models.CharField(
verbose_name="店舗紹介",
max_length=200
)
address = models.CharField(
verbose_name="住所",
max_length=100,
)
phonenumber = PhoneNumberField(
verbose_name="電話番号",
unique=True,
)
open_time = models.TimeField(
verbose_name="営業開始時刻"
)
close_time = models.TimeField(
verbose_name="営業終了時刻"
)
holidays = models.ManyToManyField(
Weekday,
verbose_name="定休日",
)
holiday_open = BooleanField(
verbose_name="祝日に営業している場合はチェックを入れてください",
default=False
)
def __str__(self) -> str:
return f'{self.name}'
def get_uncompleted_orders(self):
uncompleted_orders = []
for order in self.order_set.alive():
for reservation in order.reservation_set.alive():
if not reservation.has_recieve():
uncompleted_orders.append(order)
break
return uncompleted_orders
def get_completed_orders(self):
completed_orders = []
for order in self.order_set.alive():
for reservation in order.reservation_set.alive():
if reservation.has_recieve():
completed_orders.append(order)
break
return completed_orders
def get_unscheduled_orders(self):
unscheduled_orders = []
for order in self.order_set.alive():
if not order.reservation_set.alive():
unscheduled_orders.append(order)
return unscheduled_orders
def get_holiday_numbers(self):
holiday_numbers = []
for holiday in self.holidays.all():
holiday_numbers.append(holiday.number)
return holiday_numbers
def get_holiday_names(self):
holiday_names = ""
for holiday in self.holidays.all():
holiday_names += f',{holiday.name}'
holiday_names = holiday_names.replace(",",'',1)
return holiday_names
import datetime
from django.db import models
from django.db.models.fields import IntegerField
# Create your models here.
class Menu(models.Model):
name = models.CharField(
verbose_name="商品名",
max_length=30
)
image = models.ImageField(
verbose_name="商品画像",
upload_to="images/menu"
)
explain = models.CharField(
verbose_name="商品説明",
max_length=256
)
price = IntegerField(
verbose_name="価格"
)
shop = models.ForeignKey(
"shop.Shop",
verbose_name="店舗名",
on_delete=models.CASCADE,
)
def __str__(self) -> str:
return f'{self.name}({self.price}円・{self.shop.name})'
def get_now_campaigns(self):
return self.campaign_set.filter(start__lte=datetime.date.today()).filter(end__gte=datetime.date.today()).all()
def get_future_campaigns(self):
return self.campaign_set.filter(start__gt=datetime.date.today()).all()
def get_past_campaigns(self):
return self.campaign_set.filter(end__lt=datetime.date.today()).all()
def get_discount_price(self):
discount_price = self.price
now_campaigns = self.get_now_campaigns()
for campaign in now_campaigns:
if campaign.campaign_type == 0:
discount_price -= campaign.value
else:
discount_price -= campaign.value * self.price / 100
return "{:.0f}".format(discount_price)
from django.db import models
from django.contrib.auth import get_user_model
from django_boost.models.mixins import LogicalDeletionMixin
from django.core.exceptions import ObjectDoesNotExist
# Create your models here.
class Order(LogicalDeletionMixin):
user = models.ForeignKey(
get_user_model(),
verbose_name="購入者名",
on_delete=models.CASCADE
)
price = models.IntegerField(
verbose_name="価格"
)
shop = models.ForeignKey(
"shop.Shop",
verbose_name="購入店舗名",
on_delete=models.PROTECT
)
menu = models.ForeignKey(
"menu.Menu",
verbose_name="商品名",
on_delete=models.PROTECT
)
created_at = models.DateTimeField(
verbose_name="注文日時",
auto_now_add=True,
)
payment = models.IntegerField(
verbose_name="お支払い方法",
choices=[
(0, "先払い(カードのみ)"),
(1, "当日支払い")
]
)
campaigns = models.ManyToManyField(
"campaign.Campaign"
)
def get_status(self):
if self.is_dead():
status = "キャンセル済み"
else:
status = "受け取り日未確定"
for reservation in self.reservation_set.alive():
if reservation.has_recieve():
status = "購入済み"
else:
status = "受け取り日確定済み"
return status
from django.db import models
from django_boost.models.mixins import LogicalDeletionMixin
from django.core.exceptions import ObjectDoesNotExist
# Create your models here.
class Reservation(LogicalDeletionMixin):
order = models.ForeignKey(
"order.Order",
verbose_name="対象注文",
on_delete=models.PROTECT
)
datetime = models.DateTimeField(
verbose_name="受け取り日時",
)
created_at = models.DateTimeField(
verbose_name="予約手続き日時",
auto_now_add=True,
null=True,
blank=True
)
class Meta:
ordering = ["-id"]
def has_recieve(self):
status = True
try:
self.recieve
except ObjectDoesNotExist:
status = False
return status
def __str__(self) -> str:
return f'{self.datetime.strftime("%Y年%m月%d日")}({self.order.shop.name})'
Reservationモデルのインスタンス生成の処理は以下。
from django.shortcuts import render
from django.urls import reverse_lazy
from .models import Reservation
from .forms import ReservationForm
from django.views.generic import CreateView, DeleteView
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
# Create your views here.
class OrderReservableMixin(LoginRequiredMixin, UserPassesTestMixin):
login_url = reverse_lazy("account_login")
def test_func(self):
user = self.request.user
order_id = self.kwargs["order_id"]
import sys
sys.path.append("..")
from order.models import Order
order = Order.objects.get(pk=order_id)
return (user.pk == order.user.pk or (user.user_type in [1, 2] and user.shop.pk == order.shop.pk)) and order.is_alive() and order.get_status() == "受け取り日未確定"
class ReservationCreateView(OrderReservableMixin, CreateView):
model = Reservation
form_class = ReservationForm
template_name = "reservation/create.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
import sys
sys.path.append("..")
from order.models import Order
context["order"] = Order.objects.get(pk=self.kwargs["order_id"])
return context
def form_valid(self, form):
form.instance.order_id = self.kwargs["order_id"]
return super().form_valid(form)
def get_success_url(self) -> str:
self.success_url = reverse_lazy("order:detail", kwargs={"pk": self.kwargs["order_id"]})
return super().get_success_url()
困ったこと
今回困ったことはタイトルにもあげた通りで、バリデーションの中でリレーション先のレコードを参照する方法がわからなかったことです。
冒頭でバリデーションの方法を4つ紹介しましたが、この中でバリデーションの基準を動的に定義できそうなのはカスタムバリデーションとcleanメソッドになります。
そしてそれらのメソッドが引数として取れるのはカスタムバリデーションであればvalue(=対象のフィールドの値そのもの)
、cleanメソッドであればself(=formインスタンス)
となります。
このうち前者に関しては、フィールドの値以外の引数を取れない(=リレーション先のレコードの値を参照できない)ので却下。
バリデーションの設定方法をcleanメソッドに絞りました。
しかしcleanメソッドにはcleanメソッドで問題がありました。
それは、cleanメソッドが呼ばれた時点ではform.instance.order.shop=None
となっており、リレーション先の店舗の休業日を参照できないということです。
これはなぜかというと、formにリレーション先のレコードの情報を差し込むのがform_validメソッドであり、こいつが呼ばれるのがcleanメソッドの後だからです。
def form_valid(self, form):
form.instance.order_id = self.kwargs["order_id"]
return super().form_valid(form)
解決方法
ここまでで、「cleanメソッドの前にform.instance
の値を操作できればバリデーションメソッド内でリレーション先の情報を参照できそうだ」ということがわかります。
なので、汎用ビューの組み込みメソッドの中で何かそういったことができそうなものはないかを探したところ、それらしきものとしてFormMixin
クラスのget_form_kwargs
メソッドというものを見つけました。
class FormMixin(ContextMixin):
"""Provide a way to show and handle a form in a request."""
initial = {}
form_class = None
success_url = None
prefix = None
def get_initial(self):
"""Return the initial data to use for forms on this view."""
return self.initial.copy()
def get_prefix(self):
"""Return the prefix to use for forms."""
return self.prefix
def get_form_class(self):
"""Return the form class to use."""
return self.form_class
def get_form(self, form_class=None):
"""Return an instance of the form to be used in this view."""
if form_class is None:
form_class = self.get_form_class()
return form_class(**self.get_form_kwargs())
def get_form_kwargs(self):
"""Return the keyword arguments for instantiating the form."""
kwargs = {
'initial': self.get_initial(),
'prefix': self.get_prefix(),
}
if self.request.method in ('POST', 'PUT'):
kwargs.update({
'data': self.request.POST,
'files': self.request.FILES,
})
return kwargs
def get_success_url(self):
"""Return the URL to redirect to after processing a valid form."""
if not self.success_url:
raise ImproperlyConfigured("No URL to redirect to. Provide a success_url.")
return str(self.success_url) # success_url may be lazy
def form_valid(self, form):
"""If the form is valid, redirect to the supplied URL."""
return HttpResponseRedirect(self.get_success_url())
def form_invalid(self, form):
"""If the form is invalid, render the invalid form."""
return self.render_to_response(self.get_context_data(form=form))
def get_context_data(self, **kwargs):
"""Insert the form into the context dict."""
if 'form' not in kwargs:
kwargs['form'] = self.get_form()
FormMixin
は何度か継承を繰り返してCreateView
やUpdateView
に行き着きます。
色々と定義されているメソッドの呼び出し順序なのですが、まずget_form_class
メソッドで汎用ビュー(ReservationCreateView)のform_class
の値(ReservationForm)を参照します。
def get_form_class(self):
"""Return the form class to use."""
return self.form_class
次にget_form
メソッドが呼び出され、formインスタンスが作成されます。
def get_form(self, form_class=None):
"""Return an instance of the form to be used in this view."""
if form_class is None:
form_class = self.get_form_class()
return form_class(**self.get_form_kwargs())
# form_class = ReservationFormなので最終的な戻り値は今回であれば
# ReservationForm(**self.get_form_kwargs())
そして最後にget_context_data
メソッドが呼び出され、上記で生成されたフォームがテンプレートに渡ります(self.get_form_kwargs()に関しては後述)。
def get_context_data(self, **kwargs):
"""Insert the form into the context dict."""
if 'form' not in kwargs:
kwargs['form'] = self.get_form()
フォーム生成時の引数となっているself.get_form_kwargs()
については以下の通りでフォームインスタンス生成時のキーワード引数をrequest
の中から拾う役割を果たしています。
なのでここにkwargs["instance"] = xxx
という形でインスタンスを追加できれば、参照先のレコードの情報を差し込むことができるようになります。
完成品
from django.shortcuts import render
from django.urls import reverse_lazy
from .models import Reservation
from .forms import ReservationForm
from django.views.generic import CreateView, DeleteView
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
# Create your views here.
class OrderReservableMixin(LoginRequiredMixin, UserPassesTestMixin):
login_url = reverse_lazy("account_login")
def test_func(self):
user = self.request.user
order_id = self.kwargs["order_id"]
import sys
sys.path.append("..")
from order.models import Order
order = Order.objects.get(pk=order_id)
return (user.pk == order.user.pk or (user.user_type in [1, 2] and user.shop.pk == order.shop.pk)) and order.is_alive() and order.get_status() == "受け取り日未確定"
class ReservationCreateView(OrderReservableMixin, CreateView):
model = Reservation
form_class = ReservationForm
template_name = "reservation/create.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
import sys
sys.path.append("..")
from order.models import Order
context["order"] = Order.objects.get(pk=self.kwargs["order_id"])
return context
def get_form_kwargs(self):
import sys
sys.path.append("..")
from order.models import Order
order = Order.objects.get(pk=self.kwargs["order_id"])
reservation = Reservation(order=order)
kwargs = {
"instance": reservation
}
if self.request.method in ('POST', 'PUT'):
kwargs.update({
'data': self.request.POST,
'files': self.request.FILES,
})
return kwargs
def get_success_url(self) -> str:
self.success_url = reverse_lazy("order:detail", kwargs={"pk": self.kwargs["order_id"]})
return super().get_success_url()
やや冗長な感もありますが、get_form_kwargs
メソッドの中で外部キーの情報を設定することに成功しております。
forms.pyは以下の通り。
from django.core.exceptions import ValidationError
from django.forms import ModelForm, widgets, SplitDateTimeField
from .models import Reservation
import datetime
import jpholiday
class ReservationForm(ModelForm):
def clean(self):
cleaned_data = super().clean()
datetime = cleaned_data.get("datetime")
shop = self.instance.order.shop
holidays = shop.get_holiday_numbers()
if not shop.open_time <= datetime.time() <= shop.close_time:
raise ValidationError(f"お受け取り希望時刻が店舗の営業時間外です。{shop.open_time}から{shop.close_time}の間の時刻を入力してください。")
elif datetime.weekday() in holidays:
raise ValidationError(f'{shop.get_holiday_names()}は{shop.name}の休業日です。他の曜日を選択してください。')
elif jpholiday.is_holiday(datetime) and not shop.holiday_open:
raise ValidationError(f'{shop.name}店は祝日は営業しておりません。他の日付を入力してください。')
datetime = SplitDateTimeField(
label="受け取り希望日"
)
class Meta:
model = Reservation
fields = ["datetime"]
widgets = {
"datetime": widgets.SplitDateTimeWidget
}
help_texts = {
"datetime": "受け取り店舗の定休日以外の日付を入力してください"
}