diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py index 7cc22c063707..0c2e215fcd5a 100644 --- a/django/db/migrations/autodetector.py +++ b/django/db/migrations/autodetector.py @@ -1339,7 +1339,10 @@ def generate_altered_fields(self): if old_field_dec != new_field_dec and old_field_name == field_name: both_m2m = old_field.many_to_many and new_field.many_to_many neither_m2m = not old_field.many_to_many and not new_field.many_to_many - if both_m2m or neither_m2m: + target_changed = ( + both_m2m and new_field_dec[2]["to"] != old_field_dec[2]["to"] + ) + if (both_m2m or neither_m2m) and not target_changed: # Either both fields are m2m or neither is preserve_default = True if ( diff --git a/django/forms/forms.py b/django/forms/forms.py index 760ba7b76792..ce64f6286e57 100644 --- a/django/forms/forms.py +++ b/django/forms/forms.py @@ -288,7 +288,7 @@ def add_error(self, field, error): if field is not None: raise TypeError( "The argument `field` must be `None` when the `error` " - "argument contains errors for multiple fields." + "argument is a dictionary." ) else: error = error.error_dict diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index b72d6f3f0a2b..2e6e7235d7d9 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -274,6 +274,9 @@ Models (:attr:`~django.db.models.GeneratedField.db_persist` set to ``True``) on Oracle 23ai/26ai (23.7+). +* The :data:`~django.db.models.signals.m2m_changed` signal now receives a + ``raw`` argument. + Pagination ~~~~~~~~~~ diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index 8cc1fecf0a17..7f698ad706cd 100644 --- a/tests/forms_tests/tests/test_forms.py +++ b/tests/forms_tests/tests/test_forms.py @@ -1660,6 +1660,13 @@ def clean(self): with self.assertRaisesMessage(ValueError, "has no field named"): f.add_error("missing_field", "Some error.") + with self.assertRaisesMessage( + TypeError, + "The argument `field` must be `None` when the `error` argument is a " + "dictionary.", + ): + f.add_error("password1", ValidationError({"password1": "Some error."})) + def test_update_error_dict(self): class CodeForm(Form): code = CharField(max_length=10) diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py index c044cc9a9926..e33362185555 100644 --- a/tests/migrations/test_autodetector.py +++ b/tests/migrations/test_autodetector.py @@ -741,6 +741,13 @@ class AutodetectorTests(BaseAutodetectorTests): ("publishers", models.ManyToManyField("testapp.Publisher", blank=True)), ], ) + other_publisher = ModelState( + "testapp", + "OtherPublisher", + [ + ("id", models.AutoField(primary_key=True)), + ], + ) author_with_m2m_through = ModelState( "testapp", "Author", @@ -4557,6 +4564,46 @@ def test_many_to_many_changed_to_concrete_field(self): ) self.assertOperationFieldAttributes(changes, "testapp", 0, 2, max_length=100) + def test_m2m_target_change_generates_remove_and_add(self): + before = [ + self.publisher, + self.other_publisher, + self.author_with_m2m, # m2m to self.publisher. + ] + + after = [ + self.publisher, + self.other_publisher, + ModelState( + "testapp", + "Author", + [ + ("id", models.AutoField(primary_key=True)), + # Repoint m2m to self.other_publisher. + ("publishers", models.ManyToManyField("testapp.OtherPublisher")), + ], + ), + ] + changes = self.get_changes(before, after) + self.assertNumberMigrations(changes, "testapp", 1) + self.assertOperationTypes(changes, "testapp", 0, ["RemoveField", "AddField"]) + self.assertOperationAttributes( + changes, + "testapp", + 0, + 0, + name="publishers", + model_name="author", + ) + self.assertOperationAttributes( + changes, + "testapp", + 0, + 1, + name="publishers", + model_name="author", + ) + def test_non_circular_foreignkey_dependency_removal(self): """ If two models with a ForeignKey from one to the other are removed at