Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix forms with nested embedded model fields #237

Merged
merged 3 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion django_mongodb_backend/forms/fields/embedded_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,17 @@ def decompress(self, value):


class EmbeddedModelBoundField(forms.BoundField):
def __init__(self, form, field, name, prefix_override=None):
super().__init__(form, field, name)
# prefix_override overrides the prefix in self.field.form_kwargs so
# that nested embedded model form elements have the correct name.
self.prefix_override = prefix_override

def __str__(self):
"""Render the model form as the representation for this field."""
form = self.field.model_form_cls(instance=self.value(), **self.field.form_kwargs)
if self.prefix_override:
form.prefix = self.prefix_override
return mark_safe(f"{form.as_div()}") # noqa: S308


Expand Down Expand Up @@ -53,10 +61,21 @@ def compress(self, data_dict):
return self.model_form._meta.model(**values)

def get_bound_field(self, form, field_name):
return EmbeddedModelBoundField(form, self, field_name)
# Nested embedded model form fields need a double prefix.
prefix_override = f"{form.prefix}-{self.model_form.prefix}" if form.prefix else None
return EmbeddedModelBoundField(form, self, field_name, prefix_override)

def bound_data(self, data, initial):
if self.disabled:
return initial
# Transform the bound data into a model instance.
return self.compress(data)

def prepare_value(self, value):
# When rendering a form with errors, nested EmbeddedModelField data
# won't be compressed if MultiValueField.clean() raises ValidationError
# error before compress() is called. The data must be compressed here
# so that EmbeddedModelBoundField.value() returns a model instance
# (rather than a list) for initializing the form in
# EmbeddedModelBoundField.__str__().
return self.compress(value) if isinstance(value, list) else value
8 changes: 7 additions & 1 deletion tests/model_forms_/forms.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
from django import forms

from .models import Author
from .models import Author, Book


class AuthorForm(forms.ModelForm):
class Meta:
fields = "__all__"
model = Author


class BookForm(forms.ModelForm):
class Meta:
fields = "__all__"
model = Book
10 changes: 10 additions & 0 deletions tests/model_forms_/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ class Author(models.Model):
age = models.IntegerField()
address = EmbeddedModelField(Address)
billing_address = EmbeddedModelField(Address, blank=True, null=True)


class Publisher(EmbeddedModel):
name = models.CharField(max_length=50)
address = EmbeddedModelField(Address)


class Book(models.Model):
title = models.CharField(max_length=50)
publisher = EmbeddedModelField(Publisher)
223 changes: 221 additions & 2 deletions tests/model_forms_/test_embedded_model.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.test import TestCase

from .forms import AuthorForm
from .models import Address, Author
from .forms import AuthorForm, BookForm
from .models import Address, Author, Book, Publisher


class ModelFormTests(TestCase):
Expand Down Expand Up @@ -128,3 +128,222 @@ def test_rendering(self):
<input type="number" name="address-zip_code" required id="id_address-zip_code">
</div>""",
)


class NestedFormTests(TestCase):
def test_update(self):
book = Book.objects.create(
title="Learning MongoDB",
publisher=Publisher(
name="Random House", address=Address(city="NYC", state="NY", zip_code="10001")
),
)
data = {
"title": "Learning MongoDB!",
"publisher-name": "Random House!",
"publisher-address-po_box": "",
"publisher-address-city": "New York City",
"publisher-address-state": "NY",
"publisher-address-zip_code": "10001",
}
form = BookForm(data, instance=book)
self.assertTrue(form.is_valid())
form.save()
book.refresh_from_db()
self.assertEqual(book.title, "Learning MongoDB!")
self.assertEqual(book.publisher.name, "Random House!")
self.assertEqual(book.publisher.address.city, "New York City")

def test_some_missing_data(self):
"""A required field (zip_code) is missing."""
book = Book.objects.create(
title="Learning MongoDB",
publisher=Publisher(
name="Random House", address=Address(city="NYC", state="NY", zip_code="10001")
),
)
data = {
"title": "Learning MongoDB!",
"publisher-name": "Random House!",
"publisher-address-po_box": "",
"publisher-address-city": "New York City",
"publisher-address-state": "NY",
"publisher-address-zip_code": "",
}
form = BookForm(data, instance=book)
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["publisher"], ["Enter all required values."])
self.assertHTMLEqual(
str(form),
"""
<div>
<label for="id_title">Title:</label>
<input type="text" name="title" value="Learning MongoDB!" maxlength="50"
required id="id_title">
</div>
<div>
<fieldset>
<legend>Publisher:</legend>
<ul class="errorlist">
<li>Enter all required values.</li>
</ul>
<div>
<label for="id_publisher-name">Name:</label>
<input type="text" name="publisher-name" value="Random House!" maxlength="50"
required id="id_publisher-name">
</div>
<div>
<fieldset>
<legend>Address:</legend>
<div>
<label for="id_publisher-address-po_box">PO Box:</label>
<input type="text" name="publisher-address-po_box" maxlength="50"
id="id_publisher-address-po_box">
</div>
<div>
<label for="id_publisher-address-city">City:</label>
<input type="text" name="publisher-address-city" value="New York City"
maxlength="20" required id="id_publisher-address-city">
</div>
<div>
<label for="id_publisher-address-state">State:</label>
<input type="text" name="publisher-address-state" value="NY"
maxlength="2" required id="id_publisher-address-state">
</div>
<div>
<label for="id_publisher-address-zip_code">Zip code:</label>
<input type="number" name="publisher-address-zip_code"
required id="id_publisher-address-zip_code">
</div>
</fieldset>
</div>
</fieldset>
</div>""",
)

def test_invalid_field_data(self):
"""A field's data (state) is too long."""
book = Book.objects.create(
title="Learning MongoDB",
publisher=Publisher(
name="Random House", address=Address(city="NYC", state="NY", zip_code="10001")
),
)
data = {
"title": "Learning MongoDB!",
"publisher-name": "Random House!",
"publisher-address-po_box": "",
"publisher-address-city": "New York City",
"publisher-address-state": "TOO LONG",
"publisher-address-zip_code": "10001",
}
form = BookForm(data, instance=book)
self.assertFalse(form.is_valid())
self.assertEqual(
form.errors["publisher"],
["Ensure this value has at most 2 characters (it has 8)."],
)
self.assertHTMLEqual(
str(form),
"""
<div>
<label for="id_title">Title:</label>
<input type="text" name="title" value="Learning MongoDB!"
maxlength="50" required id="id_title">
</div>
<div>
<fieldset>
<legend>Publisher:</legend>
<ul class="errorlist">
<li>Ensure this value has at most 2 characters (it has 8).</li>
</ul>
<div>
<label for="id_publisher-name">Name:</label>
<input type="text" name="publisher-name" value="Random House!"
maxlength="50" required id="id_publisher-name">
</div>
<div>
<fieldset>
<legend>Address:</legend>
<div>
<label for="id_publisher-address-po_box">PO Box:</label>
<input type="text" name="publisher-address-po_box"
maxlength="50" id="id_publisher-address-po_box">
</div>
<div>
<label for="id_publisher-address-city">City:</label>
<input type="text" name="publisher-address-city" value="New York City"
maxlength="20" required id="id_publisher-address-city">
</div>
<div>
<label for="id_publisher-address-state">State:</label>
<input type="text" name="publisher-address-state" value="TOO LONG"
maxlength="2" required id="id_publisher-address-state">
</div>
<div>
<label for="id_publisher-address-zip_code">Zip code:</label>
<input type="number" name="publisher-address-zip_code" value="10001"
required id="id_publisher-address-zip_code">
</div>
</fieldset>
</div>
</fieldset>
</div>""",
)

def test_all_missing_data(self):
"""An embedded model with all data missing triggers a required error."""
book = Book.objects.create(
title="Learning MongoDB",
publisher=Publisher(
name="Random House", address=Address(city="NYC", state="NY", zip_code="10001")
),
)
data = {
"title": "Learning MongoDB!",
"publisher-name": "Random House!",
"publisher-address-po_box": "",
"publisher-address-city": "",
"publisher-address-state": "",
"publisher-address-zip_code": "",
}
form = BookForm(data, instance=book)
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["publisher"], ["This field is required."])

def test_rendering(self):
form = BookForm()
self.assertHTMLEqual(
str(form.fields["publisher"].get_bound_field(form, "publisher")),
"""
<div>
<label for="id_publisher-name">Name:</label>
<input type="text" name="publisher-name" maxlength="50"
required id="id_publisher-name">
</div>
<div>
<fieldset>
<legend>Address:</legend>
<div>
<label for="id_publisher-address-po_box">PO Box:</label>
<input type="text" name="publisher-address-po_box" maxlength="50"
id="id_publisher-address-po_box">
</div>
<div>
<label for="id_publisher-address-city">City:</label>
<input type="text" name="publisher-address-city" maxlength="20"
required id="id_publisher-address-city">
</div>
<div>
<label for="id_publisher-address-state">State:</label>
<input type="text" name="publisher-address-state" maxlength="2"
required id="id_publisher-address-state">
</div>
<div>
<label for="id_publisher-address-zip_code">Zip code:</label>
<input type="number" name="publisher-address-zip_code"
required id="id_publisher-address-zip_code">
</div>
</fieldset>
</div>""",
)
Loading