Skip to content

Commit 6181715

Browse files
committed
edits
1 parent 5a3e6ae commit 6181715

File tree

3 files changed

+142
-74
lines changed

3 files changed

+142
-74
lines changed
Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,49 @@
11
from django import forms
22
from django.core.exceptions import ValidationError
3-
from django.db.models import Model
43
from django.forms import formset_factory, model_to_dict
54
from django.forms.models import modelform_factory
65
from django.utils.html import format_html, format_html_join
7-
from django.utils.translation import gettext_lazy as _
86

97

10-
class MultipleEmbeddedModelFormField(forms.Field):
11-
default_error_messages = {"incomplete": _("Enter all required values.")}
8+
def models_to_dicts(models):
9+
"""
10+
Convert initial data (which is a list of model instances or None) to a
11+
list of dictionary data suitable for a formset.
12+
"""
13+
return [model_to_dict(model) for model in models or []]
14+
1215

16+
class MultipleEmbeddedModelFormField(forms.Field):
1317
def __init__(self, model, prefix, max_length=None, *args, **kwargs):
1418
kwargs.pop("base_field")
1519
self.model = model
1620
self.prefix = prefix
17-
self.model_form_cls = modelform_factory(model, fields="__all__")
1821
self.formset = formset_factory(
19-
form=self.model_form_cls, can_delete=True, max_num=max_length
22+
form=modelform_factory(model, fields="__all__"),
23+
can_delete=True,
24+
max_num=max_length,
2025
)
21-
kwargs["widget"] = MultipleEmbeddedModelWidget(self.model_form_cls.__name__)
26+
kwargs["widget"] = MultipleEmbeddedModelWidget()
2227
super().__init__(*args, **kwargs)
2328

2429
def clean(self, value):
2530
if not value:
31+
# TODO: null or empty list?
2632
return []
2733
formset = self.formset(value, prefix=self.prefix)
2834
if not formset.is_valid():
29-
raise ValidationError(formset.errors + formset.non_form_errors())
35+
raise ValidationError(formset.errors)
3036
cleaned_data = []
3137
for data in formset.cleaned_data:
38+
# The fallback to True skips empty forms.
3239
if data.get("DELETE", True):
3340
continue
34-
data.pop("DELETE")
35-
cleaned_data.append(self.model_form_cls._meta.model(**data))
41+
data.pop("DELETE") # The "delete" checkbox isn't part of model data.
42+
cleaned_data.append(self.model(**data))
3643
return cleaned_data
3744

3845
def has_changed(self, initial, data):
39-
formset_initial = []
40-
for initial_data in initial or []:
41-
formset_initial.append(forms.model_to_dict(initial_data))
42-
formset = self.formset(data, initial=formset_initial, prefix=self.prefix)
46+
formset = self.formset(data, initial=models_to_dicts(initial), prefix=self.prefix)
4347
return formset.has_changed()
4448

4549
def get_bound_field(self, form, field_name):
@@ -49,46 +53,24 @@ def get_bound_field(self, form, field_name):
4953
class MultipleEmbeddedModelBoundField(forms.BoundField):
5054
def __init__(self, form, field, name):
5155
super().__init__(form, field, name)
52-
data = self.data if form.is_bound else None
53-
formset_initial = []
54-
if self.initial is not None:
55-
for initial in self.initial:
56-
if isinstance(initial, Model):
57-
formset_initial.append(model_to_dict(initial))
58-
self.formset = field.formset(data, initial=formset_initial, prefix=self.html_name)
59-
60-
def __getitem__(self, idx):
61-
if not isinstance(idx, (int | slice)):
62-
raise TypeError
63-
return self.formset[idx]
64-
65-
def __iter__(self):
66-
yield from self.formset
56+
self.formset = field.formset(
57+
self.data if form.is_bound else None,
58+
initial=models_to_dicts(self.initial),
59+
prefix=self.html_name,
60+
)
6761

6862
def __str__(self):
69-
table = format_html_join(
63+
body = format_html_join(
7064
"\n", "<tbody>{}</tbody>", ((form.as_table(),) for form in self.formset)
7165
)
72-
table = format_html("\n<table>" "\n{}" "\n</table>", table)
73-
return format_html("{}\n{}", table, self.formset.management_form)
74-
75-
def __len__(self):
76-
return len(self.formset)
66+
return format_html("<table>\n{}\n</table>\n{}", body, self.formset.management_form)
7767

7868

7969
class MultipleEmbeddedModelWidget(forms.Widget):
80-
def __init__(self, field_id, attrs=None):
81-
self.field_id = field_id
82-
super().__init__(attrs)
83-
84-
def render(self, name, value, attrs=None, renderer=None):
85-
raise NotImplementedError("This widget is not meant to be rendered.")
86-
87-
def id_for_label(self, id_):
88-
return f"{id_}-0-{self.field_id}"
70+
"""
71+
This widget extracts the data for MultipleEmbeddedModelFormField's formset.
72+
It is never rendered.
73+
"""
8974

9075
def value_from_datadict(self, data, files, name):
91-
return {key: data[key] for key in data if key.startswith(name)}
92-
93-
def value_omitted_from_data(self, data, files, name):
94-
return any(key.startswith(name) for key in data)
76+
return {key: data[key] for key in data if key.startswith(f"{name}-")}

tests/model_forms_/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ def __str__(self):
3838

3939
class Movie(models.Model):
4040
title = models.CharField(max_length=255)
41-
reviews = MultipleEmbeddedModelField(Review, null=True)
41+
reviews = MultipleEmbeddedModelField(Review)
42+
featured_reviews = MultipleEmbeddedModelField(Review, null=True, blank=True)
4243

4344
def __str__(self):
4445
return self.title

tests/model_forms_/test_multiple_embedded_model.py

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ def test_add_another(self):
3232
self.assertEqual(review.title, "Not so great")
3333
self.assertEqual(review.rating, 1)
3434

35+
def test_no_change(self):
36+
movie = Movie.objects.create(
37+
title="Lion King",
38+
reviews=[Review(title="Great!", rating=10)],
39+
)
40+
data = {
41+
"title": "Lion King",
42+
"reviews-0-title": "Great!",
43+
"reviews-0-rating": "10",
44+
"reviews-TOTAL_FORMS": 2,
45+
"reviews-INITIAL_FORMS": 1,
46+
}
47+
form = MovieForm(data, instance=movie)
48+
self.assertTrue(form.is_valid())
49+
form.save()
50+
self.assertEqual(form.changed_data, [])
51+
movie.refresh_from_db()
52+
self.assertEqual(len(movie.reviews), 1)
53+
review = movie.reviews[0]
54+
self.assertEqual(review.title, "Great!")
55+
self.assertEqual(review.rating, 10)
56+
3557
def test_update(self):
3658
movie = Movie.objects.create(
3759
title="Lion King",
@@ -47,6 +69,7 @@ def test_update(self):
4769
form = MovieForm(data, instance=movie)
4870
self.assertTrue(form.is_valid())
4971
form.save()
72+
self.assertEqual(form.changed_data, ["reviews"])
5073
movie.refresh_from_db()
5174
self.assertEqual(len(movie.reviews), 1)
5275
review = movie.reviews[0]
@@ -149,34 +172,38 @@ def test_delete_required(self):
149172
self.assertFalse(form.is_valid())
150173
self.assertEqual(form.errors["reviews"], ["This field cannot be blank."])
151174

152-
# def test_nullable_field(self):
153-
# """A nullable EmbeddedModelField is removed if all fields are empty."""
154-
# author = Author.objects.create(
155-
# name="Bob",
156-
# age=50,
157-
# address=Address(city="NYC", state="NY", zip_code="10001"),
158-
# billing_address=Address(city="NYC", state="NY", zip_code="10001"),
159-
# )
160-
# data = {
161-
# "name": "Bob",
162-
# "age": 51,
163-
# "address-po_box": "",
164-
# "address-city": "New York City",
165-
# "address-state": "NY",
166-
# "address-zip_code": "10001",
167-
# "billing_address-po_box": "",
168-
# "billing_address-city": "",
169-
# "billing_address-state": "",
170-
# "billing_address-zip_code": "",
171-
# }
172-
# form = AuthorForm(data, instance=author)
173-
# self.assertTrue(form.is_valid())
174-
# form.save()
175-
# author.refresh_from_db()
176-
# self.assertIsNone(author.billing_address)
175+
def test_nullable_field(self):
176+
"""A nullable field is emptied if all rows are deleted."""
177+
movie = Movie.objects.create(
178+
title="Lion King",
179+
reviews=[Review(title="Great!", rating=10)],
180+
featured_reviews=[Review(title="Okay", rating=5)],
181+
)
182+
data = {
183+
"title": "Lion King",
184+
"reviews-0-title": "Not so great",
185+
"reviews-0-rating": "1",
186+
"reviews-0-DELETE": "",
187+
"reviews-TOTAL_FORMS": 2,
188+
"reviews-INITIAL_FORMS": 1,
189+
"featured-reviews-0-title": "Okay",
190+
"featured-reviews-0-rating": "5",
191+
"featured-reviews-0-DELETE": "1",
192+
"featured-reviews-TOTAL_FORMS": 2,
193+
"featured-reviews-INITIAL_FORMS": 1,
194+
}
195+
form = MovieForm(data, instance=movie)
196+
self.assertTrue(form.is_valid())
197+
form.save()
198+
movie.refresh_from_db()
199+
self.assertEqual(len(movie.featured_reviews), 0)
177200

178201
def test_rendering(self):
179202
form = MovieForm()
203+
self.assertHTMLEqual(
204+
str(form.fields["reviews"].get_bound_field(form, "reviews").label_tag()),
205+
'<label for="id_reviews">Reviews:</label>',
206+
)
180207
self.assertHTMLEqual(
181208
str(form.fields["reviews"].get_bound_field(form, "reviews")),
182209
"""
@@ -209,6 +236,64 @@ def test_rendering(self):
209236
name="reviews-MAX_NUM_FORMS" value="1000" id="id_reviews-MAX_NUM_FORMS">""",
210237
)
211238

239+
def test_rendering_initial(self):
240+
movie = Movie.objects.create(
241+
title="Lion King",
242+
reviews=[Review(title="Great!", rating=10)],
243+
)
244+
form = MovieForm(instance=movie)
245+
self.assertHTMLEqual(
246+
str(form.fields["reviews"].get_bound_field(form, "reviews")),
247+
"""
248+
<table>
249+
<tbody><tr>
250+
<th><label for="id_reviews-0-title">Title:</label></th>
251+
<td>
252+
<input type="text" name="reviews-0-title" maxlength="255"
253+
id="id_reviews-0-title" value="Great!">
254+
</td>
255+
</tr>
256+
<tr>
257+
<th><label for="id_reviews-0-rating">Rating:</label></th>
258+
<td>
259+
<input type="number" name="reviews-0-rating"
260+
id="id_reviews-0-rating" value="10">
261+
</td>
262+
</tr>
263+
<tr>
264+
<th><label for="id_reviews-0-DELETE">Delete:</label></th>
265+
<td>
266+
<input type="checkbox" name="reviews-0-DELETE" id="id_reviews-0-DELETE">
267+
</td>
268+
</tr></tbody>
269+
<tbody><tr>
270+
<th><label for="id_reviews-1-title">Title:</label></th>
271+
<td>
272+
<input type="text" name="reviews-1-title" maxlength="255" id="id_reviews-1-title">
273+
</td>
274+
</tr>
275+
<tr>
276+
<th><label for="id_reviews-1-rating">Rating:</label></th>
277+
<td>
278+
<input type="number" name="reviews-1-rating" id="id_reviews-1-rating">
279+
</td>
280+
</tr>
281+
<tr>
282+
<th><label for="id_reviews-1-DELETE">Delete:</label></th>
283+
<td>
284+
<input type="checkbox" name="reviews-1-DELETE" id="id_reviews-1-DELETE">
285+
</td>
286+
</tr></tbody>
287+
</table>
288+
<input type="hidden" name="reviews-TOTAL_FORMS" value="2"
289+
id="id_reviews-TOTAL_FORMS"><input type="hidden"
290+
name="reviews-INITIAL_FORMS" value="1"
291+
id="id_reviews-INITIAL_FORMS">
292+
<input type="hidden" name="reviews-MIN_NUM_FORMS" value="0"
293+
id="id_reviews-MIN_NUM_FORMS"><input type="hidden"
294+
name="reviews-MAX_NUM_FORMS" value="1000" id="id_reviews-MAX_NUM_FORMS">""",
295+
)
296+
212297

213298
# class NestedFormTests(TestCase):
214299
# def test_update(self):

0 commit comments

Comments
 (0)