From 98b734b83189e69dfdf39a0bda3376769d2ecc68 Mon Sep 17 00:00:00 2001 From: Tzu-ping Chung Date: Mon, 20 Oct 2014 10:57:13 +0800 Subject: [PATCH] Chapter 22 --- 22-formsets.md | 204 ++++++++++++++++++ lunch/lunch/stores/forms.py | 17 +- .../stores/static/stores/js/store_update.js | 30 +++ .../stores/templates/stores/store_update.html | 24 ++- lunch/lunch/stores/views.py | 12 +- 5 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 22-formsets.md create mode 100644 lunch/lunch/stores/static/stores/js/store_update.js diff --git a/22-formsets.md b/22-formsets.md new file mode 100644 index 0000000..5c8632b --- /dev/null +++ b/22-formsets.md @@ -0,0 +1,204 @@ +其實這個內容跟本應該放在更前面,只是我**忘記了**,所以只好現在補講。昨天我們說要做讓使用者修改點餐的功能,就先延到明天。 + +我們現在可以在 admin 新增修改店家與菜單內容,但是在一般頁面中只能新增修改店家,不能修改菜單(雖然已經加入的會被列出)。我們可以像店家的表單一樣,把它獨立成一個 model form,但是這種做法有個問題:一次只能新增一個項目。要怎麼像 Django Admin 那樣,有個表格可以一次建立很多個項目? + +答案就是 formsets。事實上 admin 裡的那個表格就是用它做的!顧名思義,一個 formset 就是「很多表單的集合體」。這有很多用途,例如一次讓使用者上傳很多圖片,或者更常見的,是用來處理一對多關係中的多個項目。 + +在這裡,我們想要建立指向某個 `Store` object 的 `MenuItem` model instances。所以我們可以使用內建的 factory method: + +```python +# stores/views.py + +from django.forms.models import inlineformset_factory +from .models import MenuItem + +def store_update(request, pk): + # ... + MenuItemFormSet = inlineformset_factory( + parent_model=Store, model=MenuItem, extra=1, + ) + menu_item_formset = MenuItemFormSet(instance=store) + return render(request, 'stores/store_update.html', { + 'form': form, 'store': store, 'menu_item_formset': menu_item_formset, + }) +``` + +之前已經看過 `modelform_factory`,這裡的用法類似,只是我們需要多指定一個 `parent_model` 參數,Django 才知道要用哪一個 foreign key 建立一對多關聯。[註 1] 在 `instance` 指定 foreign key 指向的 parent instance,Django 就會自動幫你把關聯的物件預先取出,並根據 `extra` 的數值增加空白欄位。 + +在 template 中顯示這個 formset: + +```html +{# stores/templates/stores/store_update.html #} + +{# ... #} + +{% crispy form %} +{% crispy menu_item_formset %} +``` + +選一個店家,按「更新店家資訊」進去,應該會看到下面多出了可以填入菜單的欄位!不過這些欄位還沒有作用,因為它們沒有在 form tag 裡面,我們也還沒在 post method 實作儲存。 + +首先為 menu item formset 實作一個 helper,把我們不需要的東西清掉: + +```python +# stores/forms.py + +from django.forms.models import inlineformset_factory +from .models import MenuItem + +BaseMenuItemFormSet = inlineformset_factory( + parent_model=Store, model=MenuItem, fields=('name', 'price',), extra=1 +) + +class MenuItemFormSet(BaseMenuItemFormSet): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False # 我們要自己包。 + self.helper.disable_csrf = True # StoreForm 已經有 CSRF token,不需要重複產生。 +``` + +把 `store_update` 改成下面這樣: + +```python +from .forms import MenuItemFormSet + +def store_update(request, pk): + try: + store = Store.objects.get(pk=pk) + except Store.DoesNotExist: + raise Http404 + + if request.method == 'POST': + form = StoreForm(request.POST, instance=store, submit_title='更新') + + # 用 post data 建立 formset,並在其(與 store form)合法時儲存。 + menu_item_formset = MenuItemFormSet(request.POST, instance=store) + if form.is_valid() and menu_item_formset.is_valid(): + store = form.save() + menu_item_formset.save() + return redirect(store.get_absolute_url()) + else: + # 移除 submit button 與 form tag。 + form = StoreForm(instance=store, submit_title=None) + form.helper.form_tag = False + + menu_item_formset = MenuItemFormSet(instance=store) + + return render(request, 'stores/store_update.html', { + 'form': form, 'store': store, 'menu_item_formset': menu_item_formset, + }) +``` + +注意有註解的段落。接著在 template 中手動加上 form tag 與 submit button: + +```html +{# stores/templates/stores/store_update.html #} + +{# 替換 content block 的內容 #} +
+ {% crispy form %} + {% crispy menu_item_formset %} + +
+``` + +這樣結構就正確了!新增刪除幾個項目看看吧。 + +接著我們要實作 admin 裡面新增欄位的功能。在實作之前,我們必須先了解 formset 的運作原理。每個 formset 其實都分為兩個部分: + +1. Management form,裡面包含四個欄位: + * Total forms,代表目前 formset 中有幾個 forms。 + * Initial forms,代表「一開始」有幾個 forms。 + * Minimum 與 maximum forms,代表最多與最少可以有幾個 forms。這可以在建立 formset 時指定,但我們這裡不管,就用預設值(最少 0 個,最多可以有數千個,應該夠用)。 +2. Form 列表,包括原本已經存在的項目,以及 `extra` 參數指定的額外空白項目。表單數量會和 initial forms 的值相等。 + +所以我們可以這樣實作新增欄位按鈕: + +1. 按下按鈕時,clone 一個 formset 中的 form。 +2. 修改 form 中的 ID 與標籤,讓它能被 Django 識別。 +3. 修改 total forms 參數,讓 Django 知道欄位數量有變。 + +這個動作的主要用意是讓我們可以自訂 menu item form 的格式;我們需要用一個 `div` 把每個單獨的 form 包起來,並為它加上 CSS class。 + +修改 `store_update.html`: + +```html + +{% load staticfiles %} + + +{% block content %} +
+ {% crispy form %} + + + {{ menu_item_formset.management_form }} + {% for form in menu_item_formset %} + + {% endfor %} + + + 新增菜單項目 + +
+{% endblock content %} + + +{% block js %} +{{ block.super }} + +{% endblock js %} +``` + +這裡注意到 `crispy` tag 其實可以接受第二個參數,動態在 template 中指定要用的 form helper。我們這裡讓所有 formset 中的 forms 都沿用 formset 的 helper。 + +最後建立 `stores/static/stores/js/store_update.js`,實作新增欄位: + +```javascript +(function ($) { + +$('.menu-item-add').click(function (e) { + e.preventDefault(); + + var lastElement = $('.menu-item:last'); + var totalForms = $('#id_menu_items-TOTAL_FORMS'); + var total = totalForms.val(); + + var newElement = lastElement.clone(true); + newElement.find(':input').each(function() { + var name = $(this).attr('name').replace( + '-' + (total - 1) + '-', + '-' + total + '-' + ); + var id = 'id_' + name; + $(this).attr({'name': name, 'id': id}).val('').removeAttr('checked'); + }); + newElement.find('label').each(function() { + $(this).attr('for', $(this).attr('for').replace( + '-' + (total - 1) + '-', + '-' + total + '-' + )); + }); + + totalForms.val(total + 1); + newElement.insertAfter(lastElement); +}); + +})(jQuery); +``` + +這段 JavaScript 會找到 formset 中的最後一個項目(所以我們前面要用 div 把每個 form 包起來,這裡才能方便使用)clone 一份,修改其中欄位的 ID 與 name,把原本的值清除,把它 insert 到最後面,再修改 total forms 欄位值。這看起來實在非常麻煩,幸好除非你對 form 做了什麼奇怪的實情,否則這個 script 基本上可以一直沿用下去,只要修改 `lastElement` 與 `totalForms` 的 selector 就好了。[註 2] + +重新整理看看!原本的 delete checkbox 應該會被紅色的 delete 刪除按鈕取代,而「更新」旁邊也多了一個「新增」按鈕,可以用來動態新增欄位。 + +終於完成了!下週我們會回到正常進度。 + + +--- + +註 1:你或許會問,如果有不止一個 foreign key 指向同一個 model,Django 要怎麼知道應該使用哪一個?為了避免這種狀況,其實 `inlineformset_factory` 可以接受一個叫 `fk_name` 的參數,讓你指定欄位名稱。但在這裡 `MenuItem` 只有一個 foreign key 指向 `Store`,所以不需要指定這個參數,Django 會自動偵測。 + +註 2:如果你仔細看產生的 HTML,會發現我們沒有改到所有元件的 IDs,只改了 input 欄位。這樣就足夠讓 Django 正確反應,不過如果你有 CSS 或 JavaScript 需求,必須讓所有的元件都被正確修改,或許用 JavaScript template engine(Mustache、Underscore.js 的範本、或者 Handlebars 等等)來產生會是更好的選擇。不過這就超出這個教學的範圍了。 \ No newline at end of file diff --git a/lunch/lunch/stores/forms.py b/lunch/lunch/stores/forms.py index deb5ec1..72c3d7b 100644 --- a/lunch/lunch/stores/forms.py +++ b/lunch/lunch/stores/forms.py @@ -1,5 +1,6 @@ from django import forms -from .models import Store +from django.forms.models import inlineformset_factory +from .models import Store, MenuItem from crispy_forms.helper import FormHelper from crispy_forms.layout import Submit @@ -15,3 +16,17 @@ def __init__(self, *args, submit_title='Submit', **kwargs): self.helper = FormHelper() if submit_title: self.helper.add_input(Submit('submit', submit_title)) + + +BaseMenuItemFormSet = inlineformset_factory( + parent_model=Store, model=MenuItem, fields=('name', 'price',), extra=1 +) + + +class MenuItemFormSet(BaseMenuItemFormSet): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.disable_csrf = True diff --git a/lunch/lunch/stores/static/stores/js/store_update.js b/lunch/lunch/stores/static/stores/js/store_update.js new file mode 100644 index 0000000..18d6874 --- /dev/null +++ b/lunch/lunch/stores/static/stores/js/store_update.js @@ -0,0 +1,30 @@ +(function ($) { + +$('.menu-item-add').click(function (e) { + e.preventDefault(); + + var lastElement = $('.menu-item:last'); + var totalForms = $('#id_menu_items-TOTAL_FORMS'); + var total = totalForms.val(); + + var newElement = lastElement.clone(true); + newElement.find(':input').each(function() { + var name = $(this).attr('name').replace( + '-' + (total - 1) + '-', + '-' + total + '-' + ); + var id = 'id_' + name; + $(this).attr({'name': name, 'id': id}).val('').removeAttr('checked'); + }); + newElement.find('label').each(function() { + $(this).attr('for', $(this).attr('for').replace( + '-' + (total - 1) + '-', + '-' + total + '-' + )); + }); + + totalForms.val(total + 1); + newElement.insertAfter(lastElement); +}); + +})(jQuery); diff --git a/lunch/lunch/stores/templates/stores/store_update.html b/lunch/lunch/stores/templates/stores/store_update.html index 1e2b204..4215615 100644 --- a/lunch/lunch/stores/templates/stores/store_update.html +++ b/lunch/lunch/stores/templates/stores/store_update.html @@ -1,11 +1,31 @@ {% extends 'stores/base.html' %} -{% load crispy_forms_tags %} +{% load staticfiles crispy_forms_tags %} {% block title %}更新 {{ store.name }} | {{ block.super }}{% endblock title %} {% block content %} -{% crispy form %} +
+ + {% crispy form %} + + {{ menu_item_formset.management_form }} + {% for form in menu_item_formset %} + + {% endfor %} + + 新增菜單項目 + + +
{% endblock content %} + + +{% block js %} +{{ block.super }} + +{% endblock js %} diff --git a/lunch/lunch/stores/views.py b/lunch/lunch/stores/views.py index d68a2d7..6f64caa 100644 --- a/lunch/lunch/stores/views.py +++ b/lunch/lunch/stores/views.py @@ -4,8 +4,8 @@ from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required from events.forms import EventForm +from .forms import StoreForm, MenuItemFormSet from .models import Store -from .forms import StoreForm def store_list(request): @@ -46,13 +46,17 @@ def store_update(request, pk): raise Http404 if request.method == 'POST': form = StoreForm(request.POST, instance=store) - if form.is_valid(): + menu_item_formset = MenuItemFormSet(request.POST, instance=store) + if form.is_valid() and menu_item_formset.is_valid(): store = form.save() + menu_item_formset.save() return redirect(store.get_absolute_url()) else: - form = StoreForm(instance=store, submit_title='更新') + form = StoreForm(instance=store, submit_title=None) + form.helper.form_tag = False + menu_item_formset = MenuItemFormSet(instance=store) return render(request, 'stores/store_update.html', { - 'form': form, 'store': store, + 'form': form, 'store': store, 'menu_item_formset': menu_item_formset, })