From 5402f3ab09413a571fd9d3aa27f6c76ec42ff891 Mon Sep 17 00:00:00 2001
From: Matthew Schinckel <matt@schinckel.net>
Date: Mon, 18 Apr 2016 11:25:15 +0930
Subject: [PATCH] Fixed #26475 -- Added functools.partial() support to
 migrations autodetector.

---
 django/db/migrations/autodetector.py  |  3 ++
 docs/releases/1.9.6.txt               |  3 ++
 tests/migrations/test_autodetector.py | 54 +++++++++++++++++++++++++++
 3 files changed, 60 insertions(+)

diff --git a/django/db/migrations/autodetector.py b/django/db/migrations/autodetector.py
index 943bd35d34..96aa230b24 100644
--- a/django/db/migrations/autodetector.py
+++ b/django/db/migrations/autodetector.py
@@ -1,6 +1,7 @@
 from __future__ import unicode_literals
 
 import datetime
+import functools
 import re
 from itertools import chain
 
@@ -63,6 +64,8 @@ class MigrationAutodetector(object):
                 key: self.deep_deconstruct(value)
                 for key, value in obj.items()
             }
+        elif isinstance(obj, functools.partial):
+            return (obj.func, self.deep_deconstruct(obj.args), self.deep_deconstruct(obj.keywords))
         elif isinstance(obj, COMPILED_REGEX_TYPE):
             return RegexObject(obj)
         elif isinstance(obj, type):
diff --git a/docs/releases/1.9.6.txt b/docs/releases/1.9.6.txt
index c22e99f48e..8b48f803c7 100644
--- a/docs/releases/1.9.6.txt
+++ b/docs/releases/1.9.6.txt
@@ -15,3 +15,6 @@ Bugfixes
 
 * Fixed ``TimeField`` microseconds round-tripping on MySQL and SQLite
   (:ticket:`26498`).
+
+* Prevented ``makemigrations`` from generating infinite migrations for a model
+  field that references a ``functools.partial`` (:ticket:`26475`).
diff --git a/tests/migrations/test_autodetector.py b/tests/migrations/test_autodetector.py
index ba9b4c8caa..25b6a3190b 100644
--- a/tests/migrations/test_autodetector.py
+++ b/tests/migrations/test_autodetector.py
@@ -1,4 +1,5 @@
 # -*- coding: utf-8 -*-
+import functools
 import re
 
 from django.apps import apps
@@ -657,6 +658,59 @@ class AutodetectorTests(TestCase):
         self.assertOperationTypes(changes, 'testapp', 0, ["AlterField"])
         self.assertOperationAttributes(changes, "testapp", 0, 0, name="name", preserve_default=True)
 
+    def test_supports_functools_partial(self):
+        def _content_file_name(instance, filename, key, **kwargs):
+            return '{}/{}'.format(instance, filename)
+
+        def content_file_name(key, **kwargs):
+            return functools.partial(_content_file_name, key, **kwargs)
+
+        # An unchanged partial reference.
+        before = self.make_project_state([ModelState("testapp", "Author", [
+            ("id", models.AutoField(primary_key=True)),
+            ("file", models.FileField(max_length=200, upload_to=content_file_name('file'))),
+        ])])
+        after = self.make_project_state([ModelState("testapp", "Author", [
+            ("id", models.AutoField(primary_key=True)),
+            ("file", models.FileField(max_length=200, upload_to=content_file_name('file'))),
+        ])])
+        autodetector = MigrationAutodetector(before, after)
+        changes = autodetector._detect_changes()
+        self.assertNumberMigrations(changes, 'testapp', 0)
+
+        # A changed partial reference.
+        args_changed = self.make_project_state([ModelState("testapp", "Author", [
+            ("id", models.AutoField(primary_key=True)),
+            ("file", models.FileField(max_length=200, upload_to=content_file_name('other-file'))),
+        ])])
+        autodetector = MigrationAutodetector(before, args_changed)
+        changes = autodetector._detect_changes()
+        self.assertNumberMigrations(changes, 'testapp', 1)
+        self.assertOperationTypes(changes, 'testapp', 0, ['AlterField'])
+        # Can't use assertOperationFieldAttributes because we need the
+        # deconstructed version, i.e., the exploded func/args/keywords rather
+        # than the partial: we don't care if it's not the same instance of the
+        # partial, only if it's the same source function, args, and keywords.
+        value = changes['testapp'][0].operations[0].field.upload_to
+        self.assertEqual(
+            (_content_file_name, ('other-file',), {}),
+            (value.func, value.args, value.keywords)
+        )
+
+        kwargs_changed = self.make_project_state([ModelState("testapp", "Author", [
+            ("id", models.AutoField(primary_key=True)),
+            ("file", models.FileField(max_length=200, upload_to=content_file_name('file', spam='eggs'))),
+        ])])
+        autodetector = MigrationAutodetector(before, kwargs_changed)
+        changes = autodetector._detect_changes()
+        self.assertNumberMigrations(changes, 'testapp', 1)
+        self.assertOperationTypes(changes, 'testapp', 0, ['AlterField'])
+        value = changes['testapp'][0].operations[0].field.upload_to
+        self.assertEqual(
+            (_content_file_name, ('file',), {'spam': 'eggs'}),
+            (value.func, value.args, value.keywords)
+        )
+
     @mock.patch('django.db.migrations.questioner.MigrationQuestioner.ask_not_null_alteration',
                 side_effect=AssertionError("Should not have prompted for not null addition"))
     def test_alter_field_to_not_null_with_default(self, mocked_ask_method):