یک فرم، چند ردیف
فاکتور سفارش فرش یک سربرگ دارد (مشتری، تاریخ تحویل) و چند ردیف قلم (طرح، ابعاد، تعداد). ساختن این صفحه با فرمهای جدا و نامگذاری دستی فیلدها کابوس است. Formset مجموعهای از فرمهای همشکل را مدیریت میکند و Inline Formset همین کار را برای اشیای وابسته به یک والد (ForeignKey) انجام میدهد: ساخت، ویرایش و حذف ردیفها با یک فرم.
# apps/orders/forms.py
from django import forms
from django.forms import BaseInlineFormSet, inlineformset_factory
from .models import Order, OrderItem
class OrderItemForm(forms.ModelForm):
class Meta:
model = OrderItem
fields = ["carpet", "quantity"] # unit_price عمداً نیست؛ سرور تعیین میکند
class BaseItemFormSet(BaseInlineFormSet):
def clean(self):
super().clean()
seen = set()
for form in self.forms:
if not form.cleaned_data or form.cleaned_data.get("DELETE"):
continue
carpet = form.cleaned_data["carpet"]
if carpet in seen:
raise forms.ValidationError("هر طرح فقط یک بار در سفارش بیاید؛ تعداد را زیاد کنید.")
seen.add(carpet)
OrderItemFormSet = inlineformset_factory(
Order, OrderItem, form=OrderItemForm, formset=BaseItemFormSet,
extra=1, can_delete=True, min_num=1, validate_min=True, max_num=20,
)
View
@login_required
def order_edit(request, pk):
order = get_object_or_404(Order, pk=pk, customer__user=request.user, status="pending")
if request.method == "POST":
form = OrderForm(request.POST, instance=order)
formset = OrderItemFormSet(request.POST, instance=order, prefix="items")
if form.is_valid() and formset.is_valid():
with transaction.atomic():
form.save()
items = formset.save(commit=False)
for obj in formset.deleted_objects:
obj.delete()
for item in items:
item.unit_price = item.carpet.price # قیمت از سرور
item.save()
order.recalculate_total()
return redirect(order)
else:
form = OrderForm(instance=order)
formset = OrderItemFormSet(instance=order, prefix="items")
return render(request, "orders/edit.html", {"form": form, "formset": formset})
قالب
<form method="post">
{% csrf_token %}
{{ form.as_div }}
{{ formset.management_form }}
{{ formset.non_form_errors }}
<table id="items">
{% for f in formset %}
<tr>{{ f.id }}<td>{{ f.carpet }}</td><td>{{ f.quantity }}</td><td>{{ f.DELETE }}</td></tr>
{% endfor %}
</table>
<template id="empty-row"><tr>{{ formset.empty_form.id }}<td>{{ formset.empty_form.carpet }}</td><td>{{ formset.empty_form.quantity }}</td></tr></template>
<button type="submit">ذخیره</button>
</form>
برای دکمهی «افزودن ردیف» با جاوااسکریپت، محتوای empty_form را کپی کنید، رشتهی __prefix__ را با شمارهی ردیف جایگزین کنید و مقدار فیلد مخفی items-TOTAL_FORMS را یکی زیاد کنید.
نکتههایی که کمتر کسی میداند
- فراموش کردن
{{ formset.management_form }}خطای «ManagementForm data is missing or has been tampered with» میدهد؛ پرتکرارترین باگ formset. - فیلد مخفی
{{ f.id }}را در هر ردیف رندر کنید؛ بدونش جنگو ردیفهای موجود را «جدید» میبیند و در هر ذخیره اقلام تکراری ساخته میشود. - الگوی رایج
Form(request.POST or None)یک دام دارد: POST بدون هیچ فیلد (مثلاً فقط یک دکمه) یک QueryDict خالی و falsy است و فرم unbound میماند؛ در formsetها صریحاًrequest.methodرا بررسی کنید. max_numفقط تعداد ردیفهای نمایشی را محدود میکند؛ سقف امنیتی واقعیabsolute_maxاست (پیشفرض max_num + 1000) که جلوی ارسال هزاران فرم جعلی را میگیرد.- اگر دو formset در یک صفحه دارید،
prefixمتفاوت الزامی است؛ وگرنه نام فیلدها تداخل میکند و دادهی یکی در دیگری مینشیند.