Repository: django/django

Issue:
# Refs #26709 -- Made Index raise ValueError on non-string fields.



Implementation patch:
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 2de07fde7e33..69b7031e8260 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -8,6 +8,7 @@
 from urllib.parse import urlsplit
 
 from django import forms
+from django.apps import apps
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.admin import helpers, widgets
@@ -71,6 +72,7 @@
 from django.views.generic import RedirectView
 
 IS_POPUP_VAR = "_popup"
+SOURCE_MODEL_VAR = "_source_model"
 TO_FIELD_VAR = "_to_field"
 IS_FACETS_VAR = "_facets"
 
@@ -1342,6 +1344,7 @@ def render_change_form(
                 "save_on_top": self.save_on_top,
                 "to_field_var": TO_FIELD_VAR,
                 "is_popup_var": IS_POPUP_VAR,
+                "source_model_var": SOURCE_MODEL_VAR,
                 "app_label": app_label,
             }
         )
@@ -1398,12 +1401,39 @@ def response_add(self, request, obj, post_url_continue=None):
             else:
                 attr = obj._meta.pk.attname
             value = obj.serializable_value(attr)
-            popup_response_data = json.dumps(
-                {
-                    "value": str(value),
-                    "obj": str(obj),
-                }
-            )
+            popup_response = {
+                "value": str(value),
+                "obj": str(obj),
+            }
+
+            # Find the optgroup for the new item, if available
+            source_model_name = request.POST.get(SOURCE_MODEL_VAR)
+
+            if source_model_name:
+                app_label, model_name = source_model_name.split(".", 1)
+                try:
+                    source_model = apps.get_model(app_label, model_name)
+                except LookupError:
+                    msg = _('The app "%s" could not be found.') % source_model_name
+                    self.message_user(request, msg, messages.ERROR)
+                else:
+                    source_admin = self.admin_site._registry[source_model]
+                    form = source_admin.get_form(request)()
+                    if self.opts.verbose_name_plural in form.fields:
+                        field = form.fields[self.opts.verbose_name_plural]
+                        for option_value, option_label in field.choices:
+                            # Check if this is an optgroup (label is a sequence
+                            # of choices rather than a single string value).
+                            if isinstance(option_label, (list, tuple)):
+                                # It's an optgroup:
+                                # (group_name, [(value, label), ...])
+                                optgroup_label = option_value
+                                for choice_value, choice_display in option_label:
+                                    if choice_display == str(obj):
+                                        popup_response["optgroup"] = str(optgroup_label)
+                                        break
+
+            popup_response_data = json.dumps(popup_response)
             return TemplateResponse(
                 request,
                 self.popup_response_template
@@ -1913,6 +1943,7 @@ def _changeform_view(self, request, object_id, form_url, extra_context):
             "object_id": object_id,
             "original": obj,
             "is_popup": IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET,
+            "source_model": request.GET.get(SOURCE_MODEL_VAR),
             "to_field": to_field,
             "media": media,
             "inline_admin_formsets": inline_formsets,
diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
index 40a6b3bf3a86..cd40f14ce37a 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -11,6 +11,7 @@
 from django.contrib.admin.options import (
     IS_FACETS_VAR,
     IS_POPUP_VAR,
+    SOURCE_MODEL_VAR,
     TO_FIELD_VAR,
     IncorrectLookupParameters,
     ShowFacets,
@@ -49,6 +50,7 @@
     SEARCH_VAR,
     IS_FACETS_VAR,
     IS_POPUP_VAR,
+    SOURCE_MODEL_VAR,
     TO_FIELD_VAR,
 )
 
diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py
index f5c393901254..67eac083e793 100644
--- a/django/contrib/admin/widgets.py
+++ b/django/contrib/admin/widgets.py
@@ -333,16 +333,24 @@ def get_related_url(self, info, action, *args):
         )
 
     def get_context(self, name, value, attrs):
-        from django.contrib.admin.views.main import IS_POPUP_VAR, TO_FIELD_VAR
+        from django.contrib.admin.views.main import (
+            IS_POPUP_VAR,
+            SOURCE_MODEL_VAR,
+            TO_FIELD_VAR,
+        )
 
         rel_opts = self.rel.model._meta
         info = (rel_opts.app_label, rel_opts.model_name)
         related_field_name = self.rel.get_related_field().name
+        app_label = self.rel.field.model._meta.app_label
+        model_name = self.rel.field.model._meta.model_name
+
         url_params = "&".join(
             "%s=%s" % param
             for param in [
                 (TO_FIELD_VAR, related_field_name),
                 (IS_POPUP_VAR, 1),
+                (SOURCE_MODEL_VAR, f"{app_label}.{model_name}"),
             ]
         )
         context = {


Test patch:
diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py
index 69570a806233..64e0e232290a 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -18,7 +18,12 @@
 from django.utils.safestring import mark_safe
 from django.views.decorators.common import no_append_slash
 
-from .forms import MediaActionForm
+from .forms import (
+    MediaActionForm,
+    SectionFormWithDynamicOptgroups,
+    SectionFormWithObjectOptgroups,
+    SectionFormWithOptgroups,
+)
 from .models import (
     Actor,
     AdminOrderedAdminMethod,
@@ -1345,6 +1350,32 @@ class CourseAdmin(admin.ModelAdmin):
 site7 = admin.AdminSite(name="admin7")
 site7.register(Article, ArticleAdmin2)
 site7.register(Section)
+
+
+# Admin for testing optgroup in popup response
+class SectionAdminWithOptgroups(admin.ModelAdmin):
+    form = SectionFormWithOptgroups
+
+
+class SectionAdminWithObjectOptgroups(admin.ModelAdmin):
+    form = SectionFormWithObjectOptgroups
+
+
+class SectionAdminWithDynamicOptgroups(admin.ModelAdmin):
+    form = SectionFormWithDynamicOptgroups
+
+
+site11 = admin.AdminSite(name="admin11")
+site11.register(Article, ArticleAdmin2)
+site11.register(Section, SectionAdminWithOptgroups)
+
+site12 = admin.AdminSite(name="admin12")
+site12.register(Article, ArticleAdmin2)
+site12.register(Section, SectionAdminWithObjectOptgroups)
+
+site13 = admin.AdminSite(name="admin13")
+site13.register(Article, ArticleAdmin2)
+site13.register(Section, SectionAdminWithDynamicOptgroups)
 site7.register(PrePopulatedPost, PrePopulatedPostReadOnlyAdmin)
 site7.register(
     Pizza,
diff --git a/tests/admin_views/forms.py b/tests/admin_views/forms.py
index 3a3566c10f12..f15a5b3ac195 100644
--- a/tests/admin_views/forms.py
+++ b/tests/admin_views/forms.py
@@ -1,7 +1,10 @@
+from django import forms
 from django.contrib.admin.forms import AdminAuthenticationForm, AdminPasswordChangeForm
 from django.contrib.admin.helpers import ActionForm
 from django.core.exceptions import ValidationError
 
+from .models import Section
+
 
 class CustomAdminAuthenticationForm(AdminAuthenticationForm):
     class Media:
@@ -23,3 +26,63 @@ def __init__(self, *args, **kwargs):
 class MediaActionForm(ActionForm):
     class Media:
         js = ["path/to/media.js"]
+
+
+class SectionFormWithOptgroups(forms.ModelForm):
+    articles = forms.ChoiceField(
+        choices=[
+            ("Published", [("1", "Test Article")]),
+            ("Draft", [("2", "Other Article")]),
+        ],
+        required=False,
+    )
+
+    class Meta:
+        model = Section
+        fields = ["name", "articles"]
+
+
+class SectionFormWithObjectOptgroups(forms.ModelForm):
+    """Form with model instances as optgroup keys (tests str() conversion)."""
+
+    articles = forms.ChoiceField(required=False)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Use Section instances as optgroup keys
+        sections = Section.objects.all()[:2]
+        if sections:
+            self.fields["articles"].choices = [
+                (sections[0], [("1", "Article 1")]),
+                (
+                    sections[1] if len(sections) > 1 else sections[0],
+                    [("2", "Article 2")],
+                ),
+            ]
+
+    class Meta:
+        model = Section
+        fields = ["name", "articles"]
+
+
+class SectionFormWithDynamicOptgroups(forms.ModelForm):
+    """
+    Form where the field with optgroups is added dynamically in __init__.
+    This tests that the implementation doesn't rely on accessing the
+    uninstantiated form class's _meta or fields, which wouldn't work here.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Dynamically add a field with optgroups after instantiation.
+        self.fields["articles"] = forms.ChoiceField(
+            choices=[
+                ("Category A", [("1", "Item 1"), ("2", "Item 2")]),
+                ("Category B", [("3", "Item 3"), ("4", "Item 4")]),
+            ],
+            required=False,
+        )
+
+    class Meta:
+        model = Section
+        fields = ["name"]
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index f7eaad659e6c..3377a6d44177 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -11,7 +11,7 @@
 from django.contrib.admin import AdminSite, ModelAdmin
 from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.contrib.admin.models import ADDITION, DELETION, LogEntry
-from django.contrib.admin.options import TO_FIELD_VAR
+from django.contrib.admin.options import SOURCE_MODEL_VAR, TO_FIELD_VAR
 from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
 from django.contrib.admin.tests import AdminSeleniumTestCase
 from django.contrib.admin.utils import quote
@@ -468,6 +468,126 @@ def test_popup_add_POST(self):
         response = self.client.post(reverse("admin:admin_views_article_add"), post_data)
         self.assertContains(response, "title with a new\\nline")
 
+    def test_popup_add_POST_with_valid_source_model(self):
+        """
+        Popup add with a valid source_model returns a successful response.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            SOURCE_MODEL_VAR: "admin_views.section",
+            "title": "Test Article",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        response = self.client.post(reverse("admin:admin_views_article_add"), post_data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "data-popup-response")
+        messages = list(response.wsgi_request._messages)
+        self.assertEqual(len(messages), 0)
+
+    def test_popup_add_POST_with_optgroups(self):
+        """
+        Popup add with source_model containing optgroup choices includes
+        the optgroup in the response.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            SOURCE_MODEL_VAR: "admin_views.section",
+            "title": "Test Article",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        response = self.client.post(
+            reverse("admin11:admin_views_article_add"), post_data
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "&quot;optgroup&quot;: &quot;Published&quot;")
+
+    def test_popup_add_POST_without_optgroups(self):
+        """
+        Popup add where source_model form exists but doesn't have the field
+        should work without crashing.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            SOURCE_MODEL_VAR: "admin_views.section",
+            "title": "Test Article 2",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        # Use regular admin (not admin11) where Section doesn't have optgroups.
+        response = self.client.post(reverse("admin:admin_views_article_add"), post_data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "data-popup-response")
+        self.assertNotContains(response, "&quot;optgroup&quot;")
+
+    def test_popup_add_POST_with_object_optgroups(self):
+        """
+        Popup add with source_model containing optgroups where the optgroup
+        keys are model instances (not strings) still serialize to strings.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            SOURCE_MODEL_VAR: "admin_views.section",
+            "title": "Article 1",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        response = self.client.post(
+            reverse("admin12:admin_views_article_add"), post_data
+        )
+        self.assertEqual(response.status_code, 200)
+        # Check that optgroup is in the response with str() of Section instance
+        # The form uses Section.objects.all()[:2] which includes cls.s1
+        # ("Test section") as the first optgroup key (HTML encoded).
+        self.assertContains(response, "&quot;optgroup&quot;: &quot;Test section&quot;")
+
+    def test_popup_add_POST_with_dynamic_optgroups(self):
+        """
+        Popup add with source_model where optgroup field is added dynamically
+        in __init__. This ensures the implementation doesn't rely on accessing
+        the uninstantiated form class's _meta or fields, but instead properly
+        instantiates the form with get_form(request)() to access field info.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            SOURCE_MODEL_VAR: "admin_views.section",
+            "title": "Item 1",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        response = self.client.post(
+            reverse("admin13:admin_views_article_add"), post_data
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "&quot;optgroup&quot;: &quot;Category A&quot;")
+
+    def test_popup_add_POST_with_invalid_source_model(self):
+        """
+        Popup add with an invalid source_model (non-existent app/model)
+        shows an error message instead of crashing.
+        """
+        post_data = {
+            IS_POPUP_VAR: "1",
+            SOURCE_MODEL_VAR: "admin_views.nonexistent",
+            "title": "Test Article",
+            "content": "some content",
+            "date_0": "2010-09-10",
+            "date_1": "14:55:39",
+        }
+        response = self.client.post(reverse("admin:admin_views_article_add"), post_data)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "data-popup-response")
+        messages = list(response.wsgi_request._messages)
+        self.assertEqual(len(messages), 1)
+        self.assertIn("admin_views.nonexistent", str(messages[0]))
+        self.assertIn("could not be found", str(messages[0]))
+
     def test_basic_edit_POST(self):
         """
         A smoke test to ensure POST on edit_view works.
diff --git a/tests/admin_views/urls.py b/tests/admin_views/urls.py
index c1e673d81105..3c43b8721dc4 100644
--- a/tests/admin_views/urls.py
+++ b/tests/admin_views/urls.py
@@ -32,6 +32,9 @@ def non_admin_view(request):
     ),
     path("test_admin/admin9/", admin.site9.urls),
     path("test_admin/admin10/", admin.site10.urls),
+    path("test_admin/admin11/", admin.site11.urls),
+    path("test_admin/admin12/", admin.site12.urls),
+    path("test_admin/admin13/", admin.site13.urls),
     path("test_admin/has_permission_admin/", custom_has_permission_admin.site.urls),
     path("test_admin/autocomplete_admin/", autocomplete_site.urls),
     # Shares the admin URL prefix.
diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py
index 7588c2cc32a1..e0ae5b77471c 100644
--- a/tests/admin_widgets/tests.py
+++ b/tests/admin_widgets/tests.py
@@ -971,7 +971,7 @@ def test_data_model_ref_when_model_name_is_camel_case(self):
           </select>
           <a class="related-widget-wrapper-link add-related" id="add_id_stream"
              data-popup="yes" title="Add another release event"
-             href="/admin_widgets/releaseevent/add/?_to_field=album&amp;_popup=1">
+             href="/admin_widgets/releaseevent/add/?_to_field=album&amp;_popup=1&_source_model=admin_widgets.videostream">
             <img src="/static/admin/img/icon-addlink.svg" alt="" width="24" height="24">
           </a>
         </div>