メモ〜テンプレートビューの中で多数対多数の関連付けを差し込もうとして困った話

今回の話題
-
今回は、CreateViewの中で多数対多数の関連付けを差し込もうとして困ったという話をします。

やろうとしたこと
-
- お菓子屋さんのサイトを作成しており、モデルとして`User`, `Menu`, `Shop`, `Campaign`, `Order`が存在。
- `Orderモデル`は他の4つを外部キーとしてもち、ユーザーがある商品を注文した時点で購入店舗・購入商品・購入者・実施中のキャンペーンが全て`Orderモデルインスタンス`の情報として記録される。
- 商品名・購入店舗・キャンペーンなどに関してはフォームで入力するのではなく、商品ページから購入ページに飛ぶことで自動で設定されるようにする。

モデルなど
-

```python:order/models.py
from django.db import models
from django.contrib.auth import get_user_model

# Create your models here.
class Order(models.Model):
  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"
  )
```
見ての通り、`Campaign`のみ`ManyToMany`で他は`ForeignKey`です。

```python:order/forms.py
from django.forms import ModelForm
from .models import Order
class OrderForm(ModelForm):
  class Meta:
    model = Order
    fields = ["payment"]
```
上記の通り外部キー設定している情報に関してはフォームでは入力せずにパラメータで拾います。

キャンペーンや店舗などはメニューと紐づいているので、urlから拾うのは`menu_id`のみで大丈夫です。

```python:order/urls.py
from django.urls import path
from . import views
urlpatterns = [
    path("create/<int:menu_id>/", views.OrderCreateView.as_view(), name="create"),
    path("detail/<int:pk>", views.OrderDetailView.as_view(), name="detail"),
]
```

ビューでの処理
-
とりあえずはフォームに外部キーを差し込む場合の基本に則り、`form_valid`メソッドを上書きしました。

```python:order/views.py
class OrderCreateView(OnlyCustomerMixin, CreateView):
  template_name = "order/create.html"
  model = Order
  form_class = OrderForm

  def get_context_data(self, **kwargs):
    context = super().get_context_data(**kwargs)
    menu_id = self.kwargs["menu_id"]
    import sys
    sys.path.append("..")
    from menu.models import Menu
    menu = Menu.objects.get(pk=menu_id)
    context["menu"] = menu
    now_campaigns = menu.get_now_campaigns()
    discount_price = menu.get_discount_price()
    context["now_campaigns"] = now_campaigns
    context["discount_price"] = discount_price
    return context
  
  def form_valid(self, form):
    menu_id = self.kwargs["menu_id"]
    import sys
    sys.path.append("..")
    from menu.models import Menu
    menu = Menu.objects.get(pk=menu_id)
    form.instance.menu = menu
    form.instance.shop = menu.shop
    form.instance.user = self.request.user
    form.instance.price = menu.get_discount_price()
    form.instance.campaigns = menu.get_now_campaigns()
    return super().form_valid(form)

  def get_success_url(self) -> str:
    return reverse_lazy("order:detail", kwargs={"pk": self.object.pk })
```
`menu.get_now_campaigns()`と`menu.get_discount_price()`はそれぞれ適用中のキャンペーンとキャンペーン込みの価格を返すメソッドです。

で、これで動かしてみた結果がこちら。

```shell
TypeError: Direct assignment to the forward side of a many-to-many set is prohibited. Use campaigns.set() instead.
```

つまり`ManyToMany`のリレーションを組んでいる時には、代入ではなく`set`を使えということらしい。

で、`set`を使うように変更しました。

```python:views.py
  def form_valid(self, form):
    menu_id = self.kwargs["menu_id"]
    import sys
    sys.path.append("..")
    from menu.models import Menu
    menu = Menu.objects.get(pk=menu_id)
    form.instance.menu = menu
    form.instance.shop = menu.shop
    form.instance.user = self.request.user
    form.instance.price = menu.get_discount_price()
    form.instance.campaigns.set(menu.get_now_campaigns()) # ここを変更
    return super().form_valid(form)
```
その結果がこちら。

```shell
ValueError: "<Order: Order object (None)>" needs to have a value for field "id" before this many-to-many relationship can be used.
```

>idがないとManyToManyの関連付けができないよ。

とのこと。

form_valid()メソッドの途中だとまだ`form.save()`が実行されていないので`orderインスタンスがまだidを持っていない`、すなわちform.save()よりも後に実行されるメソッドの中でリレーションを設定するしかないということですね。

色々と考えましたが、`get_success_url`メソッドの中でリレーションを設定する以外に良い方法が思いつきませんでした。

```python:order/views.py
  def get_success_url(self) -> str:
    menu_id = self.kwargs["menu_id"]
    import sys
    sys.path.append("..")
    from menu.models import Menu
    menu = Menu.objects.get(pk=menu_id)
    self.object.campaigns.set(menu.get_now_campaigns())
    return reverse_lazy("order:detail", kwargs={"pk": self.object.pk })
```
ここなら確実に`form.save()`の後に実行されるのでその点では安心ですが、今回のように他のアプリケーションフォルダからインポートするとコードが長くなって見栄えが悪いですね・・・