From f9844f484186fa13399bf8b96f58616687fe158a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois=20Freitag?= <mail+github@franek.fr>
Date: Wed, 29 Nov 2017 22:33:24 -0800
Subject: [PATCH] Fixed #28748 -- Made model field choices check more strict
 for named groups.

---
 django/db/models/fields/__init__.py           | 68 +++++++++++++------
 .../test_ordinary_fields.py                   | 38 +++++++++++
 2 files changed, 84 insertions(+), 22 deletions(-)

diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py
index da3fca1213..b511b01d17 100644
--- a/django/db/models/fields/__init__.py
+++ b/django/db/models/fields/__init__.py
@@ -241,31 +241,55 @@ class Field(RegisterLookupMixin):
             return []
 
     def _check_choices(self):
-        if self.choices:
-            if isinstance(self.choices, str) or not is_iterable(self.choices):
-                return [
-                    checks.Error(
-                        "'choices' must be an iterable (e.g., a list or tuple).",
-                        obj=self,
-                        id='fields.E004',
-                    )
-                ]
-            elif any(isinstance(choice, str) or
-                     not is_iterable(choice) or len(choice) != 2
-                     for choice in self.choices):
-                return [
-                    checks.Error(
-                        "'choices' must be an iterable containing "
-                        "(actual value, human readable name) tuples.",
-                        obj=self,
-                        id='fields.E005',
-                    )
-                ]
-            else:
-                return []
+        if not self.choices:
+            return []
+
+        def is_value(value):
+            return isinstance(value, str) or not is_iterable(value)
+
+        if is_value(self.choices):
+            return [
+                checks.Error(
+                    "'choices' must be an iterable (e.g., a list or tuple).",
+                    obj=self,
+                    id='fields.E004',
+                )
+            ]
+
+        # Expect [group_name, [value, display]]
+        for choices_group in self.choices:
+            try:
+                group_name, group_choices = choices_group
+            except ValueError:
+                # Containing non-pairs
+                break
+            try:
+                if not all(
+                    is_value(value) and is_value(human_name)
+                    for value, human_name in group_choices
+                ):
+                    break
+            except (TypeError, ValueError):
+                # No groups, choices in the form [value, display]
+                value, human_name = group_name, group_choices
+                if not is_value(value) or not is_value(human_name):
+                    break
+
+            # Special case: choices=['ab']
+            if isinstance(choices_group, str):
+                break
         else:
             return []
 
+        return [
+            checks.Error(
+                "'choices' must be an iterable containing "
+                "(actual value, human readable name) tuples.",
+                obj=self,
+                id='fields.E005',
+            )
+        ]
+
     def _check_db_index(self):
         if self.db_index not in (None, True, False):
             return [
diff --git a/tests/invalid_models_tests/test_ordinary_fields.py b/tests/invalid_models_tests/test_ordinary_fields.py
index 2c78d60924..75e6148f98 100644
--- a/tests/invalid_models_tests/test_ordinary_fields.py
+++ b/tests/invalid_models_tests/test_ordinary_fields.py
@@ -210,6 +210,44 @@ class CharFieldTests(TestCase):
 
         self.assertEqual(Model._meta.get_field('field').check(), [])
 
+    def test_choices_named_group_non_pairs(self):
+        class Model(models.Model):
+            field = models.CharField(
+                max_length=10,
+                choices=[['knights', [['L', 'Lancelot', 'Du Lac']]]],
+            )
+
+        field = Model._meta.get_field('field')
+        self.assertEqual(field.check(), [
+            Error(
+                "'choices' must be an iterable containing (actual value, "
+                "human readable name) tuples.",
+                obj=field,
+                id='fields.E005',
+            ),
+        ])
+
+    def test_choices_named_group_bad_structure(self):
+        class Model(models.Model):
+            field = models.CharField(
+                max_length=10, choices=[
+                    ['knights', [
+                        ['Noble', [['G', 'Galahad']]],
+                        ['Combative', [['L', 'Lancelot']]],
+                    ]],
+                ],
+            )
+
+        field = Model._meta.get_field('field')
+        self.assertEqual(field.check(), [
+            Error(
+                "'choices' must be an iterable containing (actual value, "
+                "human readable name) tuples.",
+                obj=field,
+                id='fields.E005',
+            ),
+        ])
+
     def test_bad_db_index_value(self):
         class Model(models.Model):
             field = models.CharField(max_length=10, db_index='bad')