From 2a3e1a543210ea1829b95ed7be3cf57c7ed3a113 Mon Sep 17 00:00:00 2001 From: Frankie Dintino Date: Mon, 8 Apr 2019 17:57:13 -0400 Subject: [PATCH] Suppress validation errors of descendent inlines of deleted forms fixes #101 --- CHANGELOG.rst | 3 ++ nested_admin/__init__.py | 32 +++++++++++++++ .../__init__.py | 0 .../nested_delete_validationerrors/admin.py | 20 ++++++++++ .../nested_delete_validationerrors/models.py | 39 +++++++++++++++++++ .../nested_delete_validationerrors/tests.py | 37 ++++++++++++++++++ 6 files changed, 131 insertions(+) create mode 100644 nested_admin/tests/nested_delete_validationerrors/__init__.py create mode 100644 nested_admin/tests/nested_delete_validationerrors/admin.py create mode 100644 nested_admin/tests/nested_delete_validationerrors/models.py create mode 100644 nested_admin/tests/nested_delete_validationerrors/tests.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d607b89..443ef0b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,8 +10,11 @@ Changelog ``autocomplete_lookup_fields`` (`#114`_) * Fixed: (grappelli) Collapsible tabular inlines with ``NestedTabularInline.classes`` now work. (`#90`_) +* Fixed: Suppress validation errors of inlines nested beneath deleted inlines + (`#101`_) .. _#90: https://github.com/theatlantic/django-nested-admin/issues/90 +.. _#101: https://github.com/theatlantic/django-nested-admin/issues/101 .. _#114: https://github.com/theatlantic/django-nested-admin/issues/114 .. _#118: https://github.com/theatlantic/django-nested-admin/issues/118 .. _#122: https://github.com/theatlantic/django-nested-admin/issues/122 diff --git a/nested_admin/__init__.py b/nested_admin/__init__.py index 4eea0a8..40e9873 100644 --- a/nested_admin/__init__.py +++ b/nested_admin/__init__.py @@ -95,6 +95,30 @@ def __getattr__(self, name): all_valid_patch_modules.append(admin_module) +def descend_form(form): + for formset in getattr(form, 'nested_formsets', None) or []: + for child_formset, child_form in descend_formset(formset): + yield (child_formset, child_form) + + +def descend_formset(formset): + for form in formset: + yield (formset, form) + for child_formset, child_form in descend_form(form): + yield child_formset, child_form + + +def patch_delete_children_empty_permitted(formsets): + """Set empty_permitted=True for descendent forms of forms that are to be deleted""" + for top_level_formset in formsets: + for formset, form in descend_formset(top_level_formset): + formset._errors = None + form._errors = None + if formset.can_delete and formset._should_delete_form(form): + for _, child_form in descend_form(form): + child_form.empty_permitted = True + + @monkeybiz.patch(all_valid_patch_modules) def all_valid(original_all_valid, formsets): """ @@ -104,8 +128,16 @@ def all_valid(original_all_valid, formsets): This causes a bug when one of the parent forms has empty_permitted == True, which happens if it is an "extra" form in the formset and its index is >= the formset's min_num. + + Also hooks into the original validation to suppress validation errors thrown + by descendent inlines of deleted forms. """ if not original_all_valid(formsets): + if len(formsets) and getattr(formsets[0], 'data', None): + has_delete = any(k for k in formsets[0].data if k.endswith('-DELETE')) + if has_delete: + patch_delete_children_empty_permitted(formsets) + return original_all_valid(formsets) return False for formset in formsets: diff --git a/nested_admin/tests/nested_delete_validationerrors/__init__.py b/nested_admin/tests/nested_delete_validationerrors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nested_admin/tests/nested_delete_validationerrors/admin.py b/nested_admin/tests/nested_delete_validationerrors/admin.py new file mode 100644 index 0000000..886fc33 --- /dev/null +++ b/nested_admin/tests/nested_delete_validationerrors/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin +import nested_admin +from .models import Parent, Child, GrandChild + + +class GrandChildInline(nested_admin.NestedStackedInline): + model = GrandChild + extra = 0 + min_num = 1 + + +class ChildInline(nested_admin.NestedStackedInline): + model = Child + inlines = [GrandChildInline] + extra = 0 + + +@admin.register(Parent) +class ParentAdmin(nested_admin.NestedModelAdmin): + inlines = [ChildInline] diff --git a/nested_admin/tests/nested_delete_validationerrors/models.py b/nested_admin/tests/nested_delete_validationerrors/models.py new file mode 100644 index 0000000..f97eb77 --- /dev/null +++ b/nested_admin/tests/nested_delete_validationerrors/models.py @@ -0,0 +1,39 @@ +from __future__ import unicode_literals + +from django.db import models +from django.db.models import ForeignKey, CASCADE +from nested_admin.tests.compat import python_2_unicode_compatible + + +@python_2_unicode_compatible +class Parent(models.Model): + name = models.CharField(max_length=128) + + def __str__(self): + return self.name + + +@python_2_unicode_compatible +class Child(models.Model): + name = models.CharField(max_length=128) + parent = ForeignKey(Parent, on_delete=CASCADE, related_name='children') + position = models.PositiveIntegerField() + + class Meta: + ordering = ['position'] + + def __str__(self): + return self.name + + +@python_2_unicode_compatible +class GrandChild(models.Model): + name = models.CharField(max_length=128) + parent = ForeignKey(Child, on_delete=CASCADE, related_name='children') + position = models.PositiveIntegerField() + + class Meta: + ordering = ['position'] + + def __str__(self): + return self.name diff --git a/nested_admin/tests/nested_delete_validationerrors/tests.py b/nested_admin/tests/nested_delete_validationerrors/tests.py new file mode 100644 index 0000000..a1f2273 --- /dev/null +++ b/nested_admin/tests/nested_delete_validationerrors/tests.py @@ -0,0 +1,37 @@ +import time +from unittest import SkipTest + +from django.conf import settings + +from nested_admin.tests.base import BaseNestedAdminTestCase +from .models import Parent, Child, GrandChild + + +class TestDeleteNestedInlineMinNumRequirement(BaseNestedAdminTestCase): + + root_model = Parent + nested_models = (Child, GrandChild) + + def test_min_num_delete_bug(self): + """It should be possible to delete inlines, even if min_num requirement not met""" + rhea = Parent.objects.create(name='Rhea') + poseidon = Child.objects.create(name='Poseidon', parent=rhea, position=0) + zeus = Child.objects.create(name='Zeus', parent=rhea, position=1) + demeter = Child.objects.create(name='Demeter', parent=rhea, position=2) + + GrandChild.objects.create(name='Apollo', parent=zeus, position=0) + GrandChild.objects.create(name='Persephone', parent=demeter, position=0) + + self.load_admin(rhea) + self.delete_inline([0]) + self.save_form() + + validation_errors = self.selenium.execute_script( + "return $('ul.errorlist li').length") + + self.assertEqual( + 0, validation_errors, "Save should have completed without validation errors") + + children = Child.objects.filter(parent=rhea) + self.assertNotEqual(3, len(children), "Child with empty grandchild was not deleted") + self.assertEqual(2, len(children))