From 26cab4e8c113b873b99b747128a064ba72d40c74 Mon Sep 17 00:00:00 2001
From: Baptiste Mispelon <bmispelon@gmail.com>
Date: Fri, 29 Nov 2019 17:54:03 +0100
Subject: [PATCH] Fixed #31046 -- Allowed RelatedManager.add()/create()/set()
 to accept callable values in through_defaults.

---
 .../db/models/fields/related_descriptors.py   |  3 +-
 docs/ref/models/relations.txt                 | 23 ++++++++--
 docs/releases/3.1.txt                         |  4 ++
 tests/m2m_through/tests.py                    | 45 +++++++++++++++++++
 4 files changed, 71 insertions(+), 4 deletions(-)

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 <intermediary-manytomany>` 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 <intermediary-manytomany>` 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 <intermediary-manytomany>` 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)