0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

バリデーションの中でリレーション先のレコードを参照したい

Posted at

注意

今回のお題はタイトルのもある通りで、バリデーションの中でリレーション先のレコードの情報を参照する方法です。
結論だけが知りたいという方は「解決方法」以降の項目をご参照ください。

はじめに

djangoアプリにおいてバリデーションを設定する方法は4つあります。

すなわち、入力フィールドのオプションとして設定する・組み込みバリデータを使う・cleanメソッドを用いる・自作のメソッドを作りバリデータとして設定する、ですね。

入力フィールドのオプションとはCharFieldmax_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インスタンスの作成)。

この受け取り予約の際に、店舗の営業時間および休業日の情報に応じてバリデーションがかかるようにしたい。

shop/models.py
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
menu/models.py
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)
order/models.py
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
reservation/models.py
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モデルのインスタンス生成の処理は以下。

views.py
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メソッドの後だからです。

form_validメソッド
  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は何度か継承を繰り返してCreateViewUpdateViewに行き着きます。

色々と定義されているメソッドの呼び出し順序なのですが、まず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は以下の通り。

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": "受け取り店舗の定休日以外の日付を入力してください"
    }
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?