فصل ۵: فرم‌ها و اعتبارسنجی — ورودی امن و فارسی

Formset و Inline Formset: سفارش با چند قلم در یک صفحه

یک فرم، چند ردیف

فاکتور سفارش فرش یک سربرگ دارد (مشتری، تاریخ تحویل) و چند ردیف قلم (طرح، ابعاد، تعداد). ساختن این صفحه با فرم‌های جدا و نام‌گذاری دستی فیلدها کابوس است. 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 متفاوت الزامی است؛ وگرنه نام فیلدها تداخل می‌کند و داده‌ی یکی در دیگری می‌نشیند.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.