From 373a60bc80db9354e525479d4d407ef710978246 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 24 Jan 2025 20:27:11 -0500 Subject: [PATCH 1/3] add tests for nested embedded model form fields --- tests/model_forms_/forms.py | 8 +- tests/model_forms_/models.py | 10 +++ tests/model_forms_/test_embedded_model.py | 92 ++++++++++++++++++++++- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/tests/model_forms_/forms.py b/tests/model_forms_/forms.py index 7bfed3fbb..1ac7b92a9 100644 --- a/tests/model_forms_/forms.py +++ b/tests/model_forms_/forms.py @@ -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 diff --git a/tests/model_forms_/models.py b/tests/model_forms_/models.py index df3bd580f..4e7cd0d6c 100644 --- a/tests/model_forms_/models.py +++ b/tests/model_forms_/models.py @@ -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) diff --git a/tests/model_forms_/test_embedded_model.py b/tests/model_forms_/test_embedded_model.py index 240f8c6d8..d7ab5c4fe 100644 --- a/tests/model_forms_/test_embedded_model.py +++ b/tests/model_forms_/test_embedded_model.py @@ -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): @@ -128,3 +128,91 @@ def test_rendering(self): """, ) + + +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."]) + + 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)."], + ) + + 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."]) From 332f70c7a5b4df06226a84e0d223aaa95d0b66d1 Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 24 Jan 2025 20:31:01 -0500 Subject: [PATCH 2/3] fix form element names of nested embedded model fields --- .../forms/fields/embedded_model.py | 12 +++++- tests/model_forms_/test_embedded_model.py | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/django_mongodb_backend/forms/fields/embedded_model.py b/django_mongodb_backend/forms/fields/embedded_model.py index b86e85e78..552008181 100644 --- a/django_mongodb_backend/forms/fields/embedded_model.py +++ b/django_mongodb_backend/forms/fields/embedded_model.py @@ -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 @@ -53,7 +61,9 @@ 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: diff --git a/tests/model_forms_/test_embedded_model.py b/tests/model_forms_/test_embedded_model.py index d7ab5c4fe..f53865851 100644 --- a/tests/model_forms_/test_embedded_model.py +++ b/tests/model_forms_/test_embedded_model.py @@ -216,3 +216,40 @@ def test_all_missing_data(self): 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")), + """ +
+ + +
+
+
+ Address: +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
""", + ) From 009d66ccf0dd3e938588e96e99772e0492605adf Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Wed, 29 Jan 2025 09:57:42 -0500 Subject: [PATCH 3/3] fix crash when rendering an invalid form with nested embedded model fields --- .../forms/fields/embedded_model.py | 9 ++ tests/model_forms_/test_embedded_model.py | 94 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/django_mongodb_backend/forms/fields/embedded_model.py b/django_mongodb_backend/forms/fields/embedded_model.py index 552008181..bbfa9c02c 100644 --- a/django_mongodb_backend/forms/fields/embedded_model.py +++ b/django_mongodb_backend/forms/fields/embedded_model.py @@ -70,3 +70,12 @@ def bound_data(self, data, initial): 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 diff --git a/tests/model_forms_/test_embedded_model.py b/tests/model_forms_/test_embedded_model.py index f53865851..4447b59f2 100644 --- a/tests/model_forms_/test_embedded_model.py +++ b/tests/model_forms_/test_embedded_model.py @@ -173,6 +173,53 @@ def test_some_missing_data(self): form = BookForm(data, instance=book) self.assertFalse(form.is_valid()) self.assertEqual(form.errors["publisher"], ["Enter all required values."]) + self.assertHTMLEqual( + str(form), + """ +
+ + +
+
+
+ Publisher: +
    +
  • Enter all required values.
  • +
+
+ + +
+
+
+ Address: +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
""", + ) def test_invalid_field_data(self): """A field's data (state) is too long.""" @@ -196,6 +243,53 @@ def test_invalid_field_data(self): form.errors["publisher"], ["Ensure this value has at most 2 characters (it has 8)."], ) + self.assertHTMLEqual( + str(form), + """ +
+ + +
+
+
+ Publisher: +
    +
  • Ensure this value has at most 2 characters (it has 8).
  • +
+
+ + +
+
+
+ Address: +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
""", + ) def test_all_missing_data(self): """An embedded model with all data missing triggers a required error."""