diff --git a/README.md b/README.md index ba27299..ce22fa7 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,4 @@ Various tricks with Django - some silly, some quite useful. 19. [Negative Indexing QuerySets](./negative_indexing_querysets) 20. [CloneDbTestCase](./clone_db_testcase) 21. [Additional Code Formatters](./isort_migrations) +22. [Bulk Create Form](./bulk_create_form) diff --git a/bulk_create_form/README.md b/bulk_create_form/README.md new file mode 100644 index 0000000..fc10206 --- /dev/null +++ b/bulk_create_form/README.md @@ -0,0 +1,207 @@ +Bulk Create Form +================ + +May 2024 + + +A dynamic bulk create form like the mockup displayed here is not immediately obviously doable in Django but it is fairly +simple to setup something both as a server-managed or js-managed solution. + +![Dynamic bulk create form mockup](./mockup.png) + + +Django Formsets +--------------- + +[Django Formsets](https://docs.djangoproject.com/en/stable/topics/forms/formsets/) are the go-to for the handling of a +series of forms like this, though there are some tweaks we need to make: + + - We are only interesting in creating new records, not editing existing ones. **Django has this important gotcha that it + will load existing records into the formset without you telling it to so** - you need to explicitly tell it not to + do that. + - We want to dynamically add more rows to the formset to allow the user to create as many records as they wish (up + until the hard limits preventing DoS). + - We want validation to including checking that at least one record has been entered. + +With that in mind we can do the following: + + +Prevent Loading Existing Records +-------------------------------- + +Django's `BaseModelFormset` will load data into the formset by using the class's `queryset` attribute (obtained via +`get_queryset()`), or the default manager from the model if no `queryset` attribute is defined. We need to stop the +formset from using the default manager: + + - We must prevent the formset from loading data by using `QuerySet.none()` + - There's no way to define `queryset` from `modelformset_factory()`, meaning we need to define a subclass of + `BaseModelFormset` + - `BaseModelFormset.__init__()` will override any class attribute named `queryset` + +```python +# modelformset_factory() unfortunately has no way to set queryset & prevent loading from the db +class BaseUserFormSet(BaseModelFormSet): + + # option 1: supply none() to __init__() + def __init__(self, *args, **kwargs): + super().__init__(*args, queryset=User.objects.none(), **kwargs) + + # option 2: override get_queryset() + def get_queryset(self): + return User.objects.none() + +def bulk_create_users(request): + UserFormSet = modelformset_factory( + model=User, + formset=BaseUserFormSet, + fields="__all__", + ) +``` + + +Validation to ensure at least 1 record was entered +-------------------------------------------------- + +Formsets allow for creation of new records by adding a number of "empty" forms; forms where `empty_permitted = True`. +Validation will pass for any of these forms that have no values entered. To make sure that a certain number of forms are +not "empty" we can fortunately set the `min_num` attribute on the formset. By doing so though we need to tell the +formset to not render any additional empty forms with `extra = 0`: + +```python +def bulk_create_users(request): + UserFormSet = modelformset_factory( + model=User, + formset=BaseUserFormSet, + fields="__all__", + min_num=1, + extra=0, + ) +``` + + +Server Managed Dynamic Formset +------------------------------ + +The trick with getting a dynamic formset to work is to increase `min_num` to a higher value; this will cause the formset +to render additional forms. Each formset is accompanied by a "management form" which holds some meta-data about the +formset including the total number of forms; we can use this to know what to set the new `min_num` as. + +If you define your form's "Add row" button to have the name `add-row`: + +```html +
+ ... render formset ... + + +
+``` + +Then we can detect that in `request.POST` and increment the `min_num` accordingly: + +```python +class BaseUserFormSet(BaseModelFormSet): + # modelformset_factory() unfortunately has no way to set queryset & prevent loading from the db + def get_queryset(self): + return User.objects.none() + + +def bulk_create_users(request): + UserFormSet = modelformset_factory( + model=User, + formset=BaseUserFormSet, + fields="__all__", + min_num=1, + extra=0, + ) + + if request.method == "POST" and "add-row" in request.POST: + data = request.POST.dict() + UserFormSet.min_num = int(data["form-TOTAL_FORMS"]) + 1 + formset = UserFormSet() + + elif request.method == "POST": + formset = UserFormSet(data=request.POST) + if formset.is_valid(): + formset.save() + users = [form.instance for form in formset.forms] + return render(request, "bulk_create_form/success.html", {"users": users}) + else: + formset = UserFormSet() + + return render(request, "bulk_create_form/bulk_create.html", {"formset": formset}) +``` + + +JavaScript Managed Dynamic Formset +---------------------------------- + +Adding new forms with JavaScript is also quite straight forward by doing the following: + +1. `FormSet` provides an [`empty_form`](https://docs.djangoproject.com/en/5.0/topics/forms/formsets/#empty-form) + attribute for this purpose. We can embed this in a + [`