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 5, 2024
1 parent c857cea commit 307983a
Show file tree
Hide file tree
Showing 12 changed files with 277 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)
120 changes: 120 additions & 0 deletions bulk_create_form/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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 `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:

```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})
```
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>
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>
3 changes: 3 additions & 0 deletions bulk_create_form/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.test import TestCase

# Create your tests here.
42 changes: 42 additions & 0 deletions bulk_create_form/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.forms import BaseModelFormSet
from django.forms import modelformset_factory
from django.shortcuts import render

from .models import User

# todo:
# ✓ check if at least one new object
# ✓ validation inline?
# ✓ none qs


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})

0 comments on commit 307983a

Please sign in to comment.