diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index cc1721c9e5..a9445d5d10 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -68,6 +68,7 @@ from django.db import connections, router, transaction from django.db.models import Q, signals from django.db.models.query import QuerySet from django.db.models.query_utils import DeferredAttribute +from django.db.models.utils import resolve_callables from django.utils.functional import cached_property @@ -1116,7 +1117,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): if not objs: return - through_defaults = through_defaults or {} + through_defaults = dict(resolve_callables(through_defaults or {})) target_ids = self._get_target_ids(target_field_name, objs) db = router.db_for_write(self.through, instance=self.instance) can_ignore_conflicts, must_send_signals, can_fast_add = self._get_add_plan(db, source_field_name) diff --git a/docs/ref/models/relations.txt b/docs/ref/models/relations.txt index d50e3891dc..2560a8e81c 100644 --- a/docs/ref/models/relations.txt +++ b/docs/ref/models/relations.txt @@ -71,7 +71,13 @@ Related objects reference Use the ``through_defaults`` argument to specify values for the new :ref:`intermediate model ` instance(s), if - needed. + needed. You can use callables as values in the ``through_defaults`` + dictionary and they will be evaluated once before creating any + intermediate instance(s). + + .. versionchanged:: 3.1 + + ``through_defaults`` values can now be callables. .. method:: create(through_defaults=None, **kwargs) @@ -105,7 +111,12 @@ Related objects reference Use the ``through_defaults`` argument to specify values for the new :ref:`intermediate model ` instance, if - needed. + needed. You can use callables as values in the ``through_defaults`` + dictionary. + + .. versionchanged:: 3.1 + + ``through_defaults`` values can now be callables. .. method:: remove(*objs, bulk=True) @@ -193,7 +204,13 @@ Related objects reference Use the ``through_defaults`` argument to specify values for the new :ref:`intermediate model ` instance(s), if - needed. + needed. You can use callables as values in the ``through_defaults`` + dictionary and they will be evaluated once before creating any + intermediate instance(s). + + .. versionchanged:: 3.1 + + ``through_defaults`` values can now be callables. .. note:: diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index dc16b95f79..b4179883ab 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -209,6 +209,10 @@ Models * :attr:`.CheckConstraint.check` now supports boolean expressions. +* The :meth:`.RelatedManager.add`, :meth:`~.RelatedManager.create`, and + :meth:`~.RelatedManager.set` methods now accept callables as values in the + ``through_defaults`` argument. + Pagination ~~~~~~~~~~ diff --git a/tests/m2m_through/tests.py b/tests/m2m_through/tests.py index deb9015ba6..dd40e9760c 100644 --- a/tests/m2m_through/tests.py +++ b/tests/m2m_through/tests.py @@ -62,6 +62,40 @@ class M2mThroughTests(TestCase): self.assertSequenceEqual(self.rock.members.all(), [self.bob]) self.assertEqual(self.rock.membership_set.get().invite_reason, 'He is good.') + def test_add_on_m2m_with_intermediate_model_callable_through_default(self): + def invite_reason_callable(): + return 'They were good at %s' % datetime.now() + + self.rock.members.add( + self.bob, self.jane, + through_defaults={'invite_reason': invite_reason_callable}, + ) + self.assertSequenceEqual(self.rock.members.all(), [self.bob, self.jane]) + self.assertEqual( + self.rock.membership_set.filter( + invite_reason__startswith='They were good at ', + ).count(), + 2, + ) + # invite_reason_callable() is called once. + self.assertEqual( + self.bob.membership_set.get().invite_reason, + self.jane.membership_set.get().invite_reason, + ) + + def test_set_on_m2m_with_intermediate_model_callable_through_default(self): + self.rock.members.set( + [self.bob, self.jane], + through_defaults={'invite_reason': lambda: 'Why not?'}, + ) + self.assertSequenceEqual(self.rock.members.all(), [self.bob, self.jane]) + self.assertEqual( + self.rock.membership_set.filter( + invite_reason__startswith='Why not?', + ).count(), + 2, + ) + def test_add_on_m2m_with_intermediate_model_value_required(self): self.rock.nodefaultsnonulls.add(self.jim, through_defaults={'nodefaultnonull': 1}) self.assertEqual(self.rock.testnodefaultsornulls_set.get().nodefaultnonull, 1) @@ -75,6 +109,17 @@ class M2mThroughTests(TestCase): self.assertSequenceEqual(self.rock.members.all(), [annie]) self.assertEqual(self.rock.membership_set.get().invite_reason, 'She was just awesome.') + def test_create_on_m2m_with_intermediate_model_callable_through_default(self): + annie = self.rock.members.create( + name='Annie', + through_defaults={'invite_reason': lambda: 'She was just awesome.'}, + ) + self.assertSequenceEqual(self.rock.members.all(), [annie]) + self.assertEqual( + self.rock.membership_set.get().invite_reason, + 'She was just awesome.', + ) + def test_create_on_m2m_with_intermediate_model_value_required(self): self.rock.nodefaultsnonulls.create(name='Test', through_defaults={'nodefaultnonull': 1}) self.assertEqual(self.rock.testnodefaultsornulls_set.get().nodefaultnonull, 1)