diff --git a/AUTHORS b/AUTHORS index f6d4c5fe26cd..1896dad90813 100644 --- a/AUTHORS +++ b/AUTHORS @@ -953,6 +953,7 @@ answer newbie questions, and generally made Django that much better: Scott Pashley scott@staplefish.com Sean Brant + Sean Helvey Sebastian Hillig Sebastian Spiegel Segyo Myung 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/static/admin/js/SelectBox.js b/django/contrib/admin/static/admin/js/SelectBox.js index 3db4ec7fa661..17c182c53f93 100644 --- a/django/contrib/admin/static/admin/js/SelectBox.js +++ b/django/contrib/admin/static/admin/js/SelectBox.js @@ -1,5 +1,6 @@ 'use strict'; { + const getOptionGroupName = (option) => option.parentElement.label; const SelectBox = { cache: {}, init: function(id) { @@ -7,7 +8,12 @@ SelectBox.cache[id] = []; const cache = SelectBox.cache[id]; for (const node of box.options) { - cache.push({value: node.value, text: node.text, displayed: 1}); + const group = getOptionGroupName(node); + cache.push({group, value: node.value, text: node.text, displayed: 1}); + } + // Only sort if there are any groups (to preserve existing behavior for non-grouped selects) + if (cache.some(item => item.group)) { + SelectBox.sort(id); } }, redisplay: function(id) { @@ -15,12 +21,25 @@ const box = document.getElementById(id); const scroll_value_from_top = box.scrollTop; box.innerHTML = ''; - for (const node of SelectBox.cache[id]) { - if (node.displayed) { - const new_option = new Option(node.text, node.value, false, false); + let node = box; + let currentOptgroup = null; + for (const option of SelectBox.cache[id]) { + if (option.displayed) { + // Create a new optgroup when the group changes + if (option.group && option.group !== currentOptgroup) { + currentOptgroup = option.group; + node = document.createElement('optgroup'); + node.setAttribute('label', option.group); + box.appendChild(node); + } else if (!option.group && currentOptgroup !== null) { + // Back to ungrouped options + currentOptgroup = null; + node = box; + } + const new_option = new Option(option.text, option.value, false, false); // Shows a tooltip when hovering over the option - new_option.title = node.text; - box.appendChild(new_option); + new_option.title = option.text; + node.appendChild(new_option); } } box.scrollTop = scroll_value_from_top; @@ -57,7 +76,7 @@ cache.splice(delete_index, 1); }, add_to_cache: function(id, option) { - SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + SelectBox.cache[id].push({group: option.group, value: option.value, text: option.text, displayed: 1}); }, cache_contains: function(id, value) { // Check if an item is contained in the cache @@ -73,10 +92,15 @@ for (const option of from_box.options) { const option_value = option.value; if (option.selected && SelectBox.cache_contains(from, option_value)) { - SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + const group = getOptionGroupName(option); + SelectBox.add_to_cache(to, {group, value: option_value, text: option.text, displayed: 1}); SelectBox.delete_from_cache(from, option_value); } } + // Only sort if there are any groups (to preserve existing behavior for non-grouped selects) + if (SelectBox.cache[to].some(item => item.group)) { + SelectBox.sort(to); + } SelectBox.redisplay(from); SelectBox.redisplay(to); }, @@ -85,17 +109,22 @@ for (const option of from_box.options) { const option_value = option.value; if (SelectBox.cache_contains(from, option_value)) { - SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + const group = getOptionGroupName(option); + SelectBox.add_to_cache(to, {group, value: option_value, text: option.text, displayed: 1}); SelectBox.delete_from_cache(from, option_value); } } + // Only sort if there are any groups (to preserve existing behavior for non-grouped selects) + if (SelectBox.cache[to].some(item => item.group)) { + SelectBox.sort(to); + } SelectBox.redisplay(from); SelectBox.redisplay(to); }, sort: function(id) { SelectBox.cache[id].sort(function(a, b) { - a = a.text.toLowerCase(); - b = b.text.toLowerCase(); + a = (a.group && a.group.toLowerCase() || '') + a.text.toLowerCase(); + b = (b.group && b.group.toLowerCase() || '') + b.text.toLowerCase(); if (a > b) { return 1; } diff --git a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js index 1fc03c6232ab..f366f30f7e64 100644 --- a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js +++ b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js @@ -113,6 +113,10 @@ // Update SelectBox cache for related fields. if (window.SelectBox !== undefined && !SelectBox.cache[currentSelect.id]) { SelectBox.add_to_cache(select.id, option); + // Sort if there are any groups present + if (SelectBox.cache[select.id].some(item => item.group)) { + SelectBox.sort(select.id); + } SelectBox.redisplay(select.id); } return; @@ -123,7 +127,7 @@ }); } - function dismissAddRelatedObjectPopup(win, newId, newRepr) { + function dismissAddRelatedObjectPopup(win, newId, newRepr, optgroup) { const name = removePopupIndex(win.name); const elem = document.getElementById(name); if (elem) { @@ -143,8 +147,13 @@ } else { const toId = name + "_to"; const toElem = document.getElementById(toId); - const o = new Option(newRepr, newId); - SelectBox.add_to_cache(toId, o); + const newOption = new Option(newRepr, newId); + newOption.group = optgroup; + SelectBox.add_to_cache(toId, newOption); + // Sort if there are any groups present + if (SelectBox.cache[toId].some(item => item.group)) { + SelectBox.sort(toId); + } SelectBox.redisplay(toId); if (toElem && toElem.nodeName.toUpperCase() === 'SELECT') { const skipIds = [name + "_from"]; diff --git a/django/contrib/admin/static/admin/js/popup_response.js b/django/contrib/admin/static/admin/js/popup_response.js index fecf0f479841..bdd93a6eb559 100644 --- a/django/contrib/admin/static/admin/js/popup_response.js +++ b/django/contrib/admin/static/admin/js/popup_response.js @@ -9,7 +9,7 @@ opener.dismissDeleteRelatedObjectPopup(window, initData.value); break; default: - opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj, initData.optgroup); break; } } diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index f6edffb4d4f6..7116f1b8b8e4 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -38,6 +38,7 @@
{% if is_popup %}{% endif %} {% if to_field %}{% endif %} +{% if source_model %}{% endif %} {% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %} {% if errors %}

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 = { diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 57129bfb666f..fddbf6518a14 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -95,6 +95,10 @@ Minor features * The admin site login view now redirects authenticated users to the next URL, if available, instead of always redirecting to the admin index page. +* The admin's ``FilteredSelectMultiple`` widget now uses ````\s to + preserve :ref:`named groups ` (e.g. + ``choices=[("Group", [("1", "Item")]), ...]``). + :mod:`django.contrib.admindocs` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/js_tests/admin/SelectBox.test.js b/js_tests/admin/SelectBox.test.js index 7d127b5d599e..4915ba6b9bd5 100644 --- a/js_tests/admin/SelectBox.test.js +++ b/js_tests/admin/SelectBox.test.js @@ -45,3 +45,163 @@ QUnit.test('preserve scroll position', function(assert) { assert.equal(toSelectBox.options.length, selectedOptions.length); assert.notEqual(fromSelectBox.scrollTop, 0); }); + +QUnit.test('retain optgroups', function(assert) { + const $ = django.jQuery; + $('').appendTo('#qunit-fixture'); + const grp = $('').appendTo('#id'); + $('').appendTo(grp); + $('').appendTo('#id'); + $('').appendTo('#id'); + SelectBox.init('id'); + SelectBox.redisplay('id'); + assert.equal($('#id option').length, 2); + assert.equal($('#id optgroup').length, 1); +}); + +QUnit.test('sort optgroups', function(assert) { + const $ = django.jQuery; + $('').appendTo('#qunit-fixture'); + // Add optgroups in non-alphabetical order + const grp2 = $('').appendTo('#id'); + $('').appendTo(grp2); + $('').appendTo(grp2); + const grp1 = $('').appendTo('#id'); + $('').appendTo(grp1); + $('').appendTo(grp1); + + SelectBox.init('id'); + + // Verify cache is sorted by group then by item + assert.equal(SelectBox.cache.id.length, 4); + assert.equal(SelectBox.cache.id[0].group, 'Group A'); + assert.equal(SelectBox.cache.id[0].text, 'Item 1'); + assert.equal(SelectBox.cache.id[1].group, 'Group A'); + assert.equal(SelectBox.cache.id[1].text, 'Item 2'); + assert.equal(SelectBox.cache.id[2].group, 'Group B'); + assert.equal(SelectBox.cache.id[2].text, 'Item 3'); + assert.equal(SelectBox.cache.id[3].group, 'Group B'); + assert.equal(SelectBox.cache.id[3].text, 'Item 4'); +}); + +QUnit.test('do not sort when no optgroups', function(assert) { + const $ = django.jQuery; + $('').appendTo('#qunit-fixture'); + // Add options in non-alphabetical order + $('').appendTo('#id'); + $('').appendTo('#id'); + $('').appendTo('#id'); + + SelectBox.init('id'); + + // Verify cache preserves original order (not sorted) + assert.equal(SelectBox.cache.id.length, 3); + assert.equal(SelectBox.cache.id[0].text, 'Zebra'); + assert.equal(SelectBox.cache.id[1].text, 'Apple'); + assert.equal(SelectBox.cache.id[2].text, 'Banana'); +}); + +QUnit.test('move with optgroups sorts', function(assert) { + const $ = django.jQuery; + $('').appendTo('#qunit-fixture'); + $('').appendTo('#qunit-fixture'); + + // Add options with optgroups to from_id in non-alphabetical order + const grp2 = $('').appendTo('#from_id'); + $('').appendTo(grp2); + const grp1 = $('').appendTo('#from_id'); + $('').appendTo(grp1); + + SelectBox.init('from_id'); + SelectBox.init('to_id'); + + // Select and move item + document.getElementById('from_id').options[0].selected = true; + SelectBox.move('from_id', 'to_id'); + + // Verify to_id cache is sorted (even though we only added one item) + assert.equal(SelectBox.cache.to_id.length, 1); + assert.equal(SelectBox.cache.to_id[0].group, 'Group B'); + assert.equal(SelectBox.cache.to_id[0].text, 'Item 2'); +}); + +QUnit.test('move without optgroups does not sort', function(assert) { + const $ = django.jQuery; + $('').appendTo('#qunit-fixture'); + $('').appendTo('#qunit-fixture'); + + // Add options without optgroups in non-alphabetical order + $('').appendTo('#from_id'); + $('').appendTo('#from_id'); + + SelectBox.init('from_id'); + SelectBox.init('to_id'); + + // Select and move first item (Zebra) + document.getElementById('from_id').options[0].selected = true; + SelectBox.move('from_id', 'to_id'); + + // Verify to_id cache preserves order (not sorted) + assert.equal(SelectBox.cache.to_id.length, 1); + assert.equal(SelectBox.cache.to_id[0].text, 'Zebra'); + + // Move second item (Apple) + document.getElementById('from_id').options[0].selected = true; + SelectBox.move('from_id', 'to_id'); + + // Verify items are in order they were added, not alphabetical + assert.equal(SelectBox.cache.to_id.length, 2); + assert.equal(SelectBox.cache.to_id[0].text, 'Zebra'); + assert.equal(SelectBox.cache.to_id[1].text, 'Apple'); +}); + +QUnit.test('move_all with optgroups sorts', function(assert) { + const $ = django.jQuery; + $('').appendTo('#qunit-fixture'); + $('').appendTo('#qunit-fixture'); + + // Add options with optgroups in non-alphabetical order + const grp2 = $('').appendTo('#from_id'); + $('').appendTo(grp2); + const grp1 = $('').appendTo('#from_id'); + $('').appendTo(grp1); + $('').appendTo(grp1); + + SelectBox.init('from_id'); + SelectBox.init('to_id'); + + // Move all items + SelectBox.move_all('from_id', 'to_id'); + + // Verify to_id cache is sorted by group + assert.equal(SelectBox.cache.to_id.length, 3); + assert.equal(SelectBox.cache.to_id[0].group, 'Group A'); + assert.equal(SelectBox.cache.to_id[0].text, 'Apple'); + assert.equal(SelectBox.cache.to_id[1].group, 'Group A'); + assert.equal(SelectBox.cache.to_id[1].text, 'Banana'); + assert.equal(SelectBox.cache.to_id[2].group, 'Group B'); + assert.equal(SelectBox.cache.to_id[2].text, 'Zebra'); +}); + +QUnit.test('move_all without optgroups does not sort', function(assert) { + const $ = django.jQuery; + $('').appendTo('#qunit-fixture'); + $('').appendTo('#qunit-fixture'); + + // Add options without optgroups in non-alphabetical order + $('').appendTo('#from_id'); + $('').appendTo('#from_id'); + $('').appendTo('#from_id'); + + SelectBox.init('from_id'); + SelectBox.init('to_id'); + + // Move all items + SelectBox.move_all('from_id', 'to_id'); + + // Verify to_id cache preserves original order (not sorted) + assert.equal(SelectBox.cache.to_id.length, 3); + assert.equal(SelectBox.cache.to_id[0].text, 'Zebra'); + assert.equal(SelectBox.cache.to_id[1].text, 'Apple'); + assert.equal(SelectBox.cache.to_id[2].text, 'Banana'); +}); 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, ""optgroup": "Published"") + + 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, ""optgroup"") + + 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, ""optgroup": "Test section"") + + 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, ""optgroup": "Category A"") + + 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): + href="/admin_widgets/releaseevent/add/?_to_field=album&_popup=1&_source_model=admin_widgets.videostream">