Skip to content

Latest commit

 

History

History
189 lines (134 loc) · 8.45 KB

21-class-based-view-overrides.md

File metadata and controls

189 lines (134 loc) · 8.45 KB

前情提要:

  1. 已登入的使用者可以在 store detail view 看到一個按鈕,按下去可以根據該店家建立新 event 讓大家來點餐。
  2. 建立完 event 後進入 event detail view。
  3. 所有人的點餐都會記錄在 event detail view 裡面。
  4. 已登入的使用者可以進入 event detail view 填 form 點餐。點完之後頁面會重新整理顯示最新狀態。
  5. 使用者也可以在同一頁面修改或刪除自己的 order。

所以接下來是 3。

打開 events/templates/events/event_detail.html,在

<h1>今天吃:{{ event }}。快點餐!</h1>

下面加入一個 table,用來列出點餐記錄:

<table class="table">
  <thead>
    <tr><th>使用者</th><th>項目</th></tr>
  </thead>
  <tbody>
    {% for order in event.orders.all %}
    <tr><td>{{ order.user }}</td><td>{{ order.item }}</td></tr>
    {% endfor %}
  </tbody>
</table>

這個東西現在應該沒什麼好解釋的了。但它目前什麼都不會顯示——因為還沒有辦法點餐。所以首先我們要在新增點餐用的表單:

# events/forms.py

from .models import Order

class OrderForm(forms.ModelForm):

    class Meta:
        model = Order
        fields = ('item', 'notes',)

    def __init__(self, *args, submit_title='Submit', **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['item'].empty_label = None
        self.helper = FormHelper()
        self.helper.add_input(Submit('submit', submit_title))

雖然 Order 有四個欄位,但我們這裡只顯示兩個,因為 userevent 要自動帶入。但注意看 __init__,似乎有一行不認識的。

前面說過(好像不止一次),Django 會自動把 foreign key render 成 HTML select widget。但 Django 預設會為每個可能的物件(在這裡就是所有的 menu items)建立一個 option tag,並在最前面加上一個代表空值的 option(預設顯示 ------ 這樣子)。但點餐時根本不可能什麼都不點吧!所以我們在這裡用 form 的 fields attribute 取出 item 欄位(fields 會回傳一個 dict,其中包含所有表單中的欄位),然後把空值的顯示值設成 None,讓 Django 直接把這個 option tag 拿掉。

接著我們要想辦法把這個 form 丟進 EventDetailView。在 function-based views 中,我們必須初始化一個 form instance,然後把它丟進 render 的 context 參數。在 class-based views 中,則是要 override(暫停三秒製造高潮氣氛)get_context_data

# events/views.py

from .forms import OrderForm

class EventDetailView(DetailView):

    model = Event

    def get_context_data(self, **kwargs):
        data = super().get_context_data(**kwargs)
        order_form = OrderForm()
        data['order_form'] = order_form
        return data

我們先用 super() 呼叫 superclass 的 get_context_data 實作,然後在裡面多加一個 OrderForm instance。這樣就可以在 template 中使用了:

{# events/templates/events/event_detail.html #}

{# 千萬要記得它!不然沒辦法用 "crispy" tag! #}
{% load crispy_forms_tags %}

{# ... #}

{# 加在 content block 的最後面 #}
{% crispy order_form %}

{% endblock content %}

看起來不錯,不過不太對。我們希望使用者可以點任何這個店家的產品,但不能點其他店的東西。現在這樣使用者可以亂點!所以我們要限制使用者能選擇的項目。Django 的 select widget 可以擁有一個 queryset attribute,用來限制能選擇的選項。我們這裡要限制成所屬店家與目前 event 店家相同的項目,所以可以這樣寫:

# events/views.py

# ...
def get_context_data(self, **kwargs):
    data = super().get_context_data(**kwargs)
    order_form = OrderForm()
    # 注意這行!
    order_form.fields['item'].queryset = self.object.store.menu_items.all()
    data['order_form'] = order_form
    return data
# ...

我們限制它必須從當下 event(self.object)所屬店家(store)中的 menu_items 裡面選擇。記得 menu_items 是一個 reverse key,所以會回傳一個 manager;後面的 all 會回傳這個 manager 中的所有物件,所以就是我們想要的限制。

重新整理看看!現在使用者應該只能從當下 event 所屬店家的菜單中選擇了。

記得我們的需求:已登入的使用者可以進入 event detail view 填 form 點餐。但現在所有使用者都可以看到 EventDetailView。而且雖然未登入的使用者不會在店家頁面看到按鈕,但如果他直接送一個 POST request(例如用 cURL)給 EventCreateView,還是能建立 event。

在 function-based views 中,我們可以使用 login_required decorator。在 CBV 中也可以這麼做——但要記得,真正被使用的是 as_view() 回傳的 view,所以我們必須覆寫它,自己把 login_required 加上去:

from django.utils.decorators import classonlymethod
from django.contrib.auth.decorators import login_required

class EventDetailView(DetailView):
    # ...
    @classonlymethod
    def as_view(cls, **initkwargs):
        view = super().as_view(**initkwargs)
        return login_required(view)

不過這如果每次都得這麼做,實在也很麻煩。因為這種權限管理太常見,所以也已經有人把它(與其他常見功能)包成可重用的 app。

我們安裝這個 app:

pip install django-braces

braces 加入 INSTALL_APPS

# lunch/settings/base.py

INSTALLED_APPS = (
    # ...
    'braces',
    # ...
)

然後就可以這樣用:[註 1]

from braces.views import LoginRequiredMixin

class EventCreateView(LoginRequiredMixin, CreateView):
    # ...

class EventDetailView(LoginRequiredMixin, DetailView):
    # ...

方便多了吧。Django Braces 包含了許多常見的功能性 mixin,例如限定只有已登入才能看、只有未登入才能看、可以處理 Ajax(類似之前我們在 FBV 用的 request.is_ajax 技巧)等等一大堆。如果你覺得某個功能很常見,或許可以去他們的文件看看有沒有現成的 mixin 可用!

現在我們可以保證使用者已經登入,就可以來實作 post 處理使用者點餐:

from django.http import HttpResponseBadRequest
from django.shortcuts import redirect

class EventDetailView(LoginRequiredMixin, DetailView):
    # ...
    def post(self, request, *args, **kwargs):
        form = OrderForm(request.POST)
        if not form.is_valid():
            return HttpResponseBadRequest()
        order = form.save(commit=False)
        order.user = request.user
        order.event = self.get_object()
        order.save()
        return redirect(order.event.get_absolute_url())

和之前的做法差不多,我們用 request.POST 建立 form,產生 object(但先不要存進資料庫),帶入我們想要的資訊,然後儲存物件,接著重導向回自己(以達到刷新頁面需求)。注意因為 select widget 一定會選擇某個項目(我們之前把空值選項拿掉了),而且 notes 可以為空,使得這個表單的值應該永遠合法,所以我們這裡就不處理。[註 2] 不過如果使用者亂搞,我們會在執行 is_valid 時發現,並回傳一個 400 Bad Request 給他。

在帶入額外資訊時,注意這裡我們不是使用 self.object。如果仔細看看 DetailView實作,會發現它在 get method 中才呼叫 get_object,並把它的值賦給 self.object。在進入 post 時,我們不會經過 get method,所以必須自己呼叫 get_object

大功告成!現在使用者可以點餐,並在上面看到自己與別人的記錄。不過如果使用者已經點過餐,第二次再點就會錯誤——因為我們有設定 unique_together,一個使用者在一個 event 只能點一次餐。根據規格,在這時應該要讓使用者能修改自己的點餐內容。明天繼續!


註 1:LoginRequiredMixin 必須要放在最前面,才能達到效果。詳情請參閱 Django Braces 文件。

註 2:其實有部分原因是要處理錯誤很麻煩,懶得寫。如果你需要在這裡處理錯誤,繼承 FormView 而非 DetailView 會比較容易實作——或者直接改用一個 function-based view 也可以。Class-based views 不是萬能。