-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
David Sanders
committed
May 5, 2024
1 parent
c857cea
commit 307983a
Showing
12 changed files
with
277 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
41
bulk_create_form/templates/bulk_create_form/bulk_create.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
from django.test import TestCase | ||
|
||
# Create your tests here. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}) |