Skip to content

Commit

Permalink
Added bulk create form
Browse files Browse the repository at this point in the history
  • Loading branch information
David Sanders committed May 6, 2024
1 parent c857cea commit 5e54772
Show file tree
Hide file tree
Showing 13 changed files with 451 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
207 changes: 207 additions & 0 deletions bulk_create_form/README.md
Original file line number Diff line number Diff line change
@@ -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
<form>
... render formset ...
<button>Save</button>
<button name="add-row">Add row</button>
</form>
```

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
[`<template/>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template) and clone it when needed. The
`__prefix__` prefix needs to be replaced with the new index; these are zero-based so it will be simply the current
number of forms.
2. The management form contains a record of the total number of forms in the formset. This needs to be incremented when
adding new forms.
3. The "add rows" button must become a regular button to avoid submitting the form.


```html
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
<table id="form-table">
<tr>
<th>Name</th>
<th>Email</th>
</tr>
{% for form in formset %}
<tr>
<td valign="top">
{{ form.name }}
{% for error in form.name.errors %}
<br /><span style="color:firebrick">{{ error }}</span>
{% endfor %}
</td>
<td valign="top">
{{ form.email }}
{% for error in form.email.errors %}
<br /><span style="color:firebrick">{{ error }}</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</table>
<template id="new-row">
<tr>
<td valign="top">
{{ formset.empty_form.name }}
</td>
<td valign="top">
{{ formset.empty_form.email }}
</td>
</tr>
</template>
<button>Save</button>
<button name="add-row" type="button" onclick="addRow()">Add row</button>
<script>
function addRow() {
// get the current number of rows from the "management form"
const totalForms = document.getElementById('id_form-TOTAL_FORMS');
// clone the template containing the "empty form" and append it to the formset table
const newRow = document.getElementById('new-row').content.cloneNode(true);
newRow.querySelectorAll('[name*="__prefix__"]').forEach(el => {
el.name = el.name.replace("__prefix__", totalForms.value);
});
document.getElementById('form-table').appendChild(newRow);
// increment the management form's record of rows
totalForms.value = parseInt(totalForms.value) + 1;
}
</script>
</form>
```


Empty file added bulk_create_form/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions bulk_create_form/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BulkCreateFormConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "bulk_create_form"
29 changes: 29 additions & 0 deletions bulk_create_form/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated by Django 5.0 on 2024-05-05 11:39

from django.db import migrations
from django.db import models


class Migration(migrations.Migration):
initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField()),
("email", models.EmailField(max_length=254, unique=True)),
],
),
]
Empty file.
Binary file added bulk_create_form/mockup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions bulk_create_form/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.db import models


class User(models.Model):
name = models.CharField()
email = models.EmailField(unique=True)

def __str__(self):
return self.name
41 changes: 41 additions & 0 deletions bulk_create_form/templates/bulk_create_form/bulk_create.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bulk create</title>
<style></style>
</head>

<body>
<h1>Bulk create</h1>
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
<table>
<tr>
<th>Name</th>
<th>Email</th>
</tr>
{% for form in formset %}
<tr>
<td valign="top">
{{ form.name }}
{% for error in form.name.errors %}
<br /><span style="color:firebrick">{{ error }}</span>
{% endfor %}
</td>
<td valign="top">
{{ form.email }}
{% for error in form.email.errors %}
<br /><span style="color:firebrick">{{ error }}</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</table>
<button>Save</button>
<button name="add-row">Add row</button>
</form>
</body>
</html>
67 changes: 67 additions & 0 deletions bulk_create_form/templates/bulk_create_form/bulk_create_js.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bulk create</title>
<style></style>
</head>

<body>
<h1>Bulk create</h1>
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
<table id="form-table">
<tr>
<th>Name</th>
<th>Email</th>
</tr>
{% for form in formset %}
<tr>
<td valign="top">
{{ form.name }}
{% for error in form.name.errors %}
<br /><span style="color:firebrick">{{ error }}</span>
{% endfor %}
</td>
<td valign="top">
{{ form.email }}
{% for error in form.email.errors %}
<br /><span style="color:firebrick">{{ error }}</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</table>
<template id="new-row">
<tr>
<td valign="top">
{{ formset.empty_form.name }}
</td>
<td valign="top">
{{ formset.empty_form.email }}
</td>
</tr>
</template>
<button>Save</button>
<button name="add-row" type="button" onclick="addRow()">Add row</button>
<script>
function addRow() {
// get the current number of rows from the "management form"
const totalForms = document.getElementById('id_form-TOTAL_FORMS');

// clone the template containing the "empty form" and append it to the formset table
const newRow = document.getElementById('new-row').content.cloneNode(true);
newRow.querySelectorAll('[name*="__prefix__"]').forEach(el => {
el.name = el.name.replace("__prefix__", totalForms.value);
});
document.getElementById('form-table').appendChild(newRow);

// increment the management form's record of rows
totalForms.value = parseInt(totalForms.value) + 1;
}
</script>
</form>
</body>
</html>
26 changes: 26 additions & 0 deletions bulk_create_form/templates/bulk_create_form/success.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Success</title>
<style>
</style>
</head>

<body>
<h1>Success</h1>
<table>
<tr>
<th>Name</th>
<th>Email</th>
</tr>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</tr>
{% endfor %}
</table>
</body>
</html>
Loading

0 comments on commit 5e54772

Please sign in to comment.