summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AUTHORS1
-rw-r--r--django/contrib/admin/options.py43
-rw-r--r--django/contrib/admin/static/admin/js/SelectBox.js51
-rw-r--r--django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js15
-rw-r--r--django/contrib/admin/static/admin/js/popup_response.js2
-rw-r--r--django/contrib/admin/templates/admin/change_form.html1
-rw-r--r--django/contrib/admin/views/main.py2
-rw-r--r--django/contrib/admin/widgets.py10
-rw-r--r--docs/releases/6.1.txt4
-rw-r--r--js_tests/admin/SelectBox.test.js160
-rw-r--r--tests/admin_views/admin.py33
-rw-r--r--tests/admin_views/forms.py63
-rw-r--r--tests/admin_views/tests.py122
-rw-r--r--tests/admin_views/urls.py3
-rw-r--r--tests/admin_widgets/tests.py2
15 files changed, 487 insertions, 25 deletions
diff --git a/AUTHORS b/AUTHORS
index f6d4c5fe26..1896dad908 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -953,6 +953,7 @@ answer newbie questions, and generally made Django that much better:
Scott Pashley <github@scottpashley.co.uk>
scott@staplefish.com
Sean Brant
+ Sean Helvey <me@seanhelvey.com>
Sebastian Hillig <sebastian.hillig@gmail.com>
Sebastian Spiegel <https://www.tivix.com/>
Segyo Myung <myungsekyo@gmail.com>
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 2de07fde7e..69b7031e82 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -8,6 +8,7 @@ from urllib.parse import quote as urlquote
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.decorators.csrf import csrf_protect
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 @@ class ModelAdmin(BaseModelAdmin):
"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 @@ class ModelAdmin(BaseModelAdmin):
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 @@ class ModelAdmin(BaseModelAdmin):
"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 3db4ec7fa6..17c182c53f 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 1fc03c6232..f366f30f7e 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 fecf0f4798..bdd93a6eb5 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 f6edffb4d4..7116f1b8b8 100644
--- a/django/contrib/admin/templates/admin/change_form.html
+++ b/django/contrib/admin/templates/admin/change_form.html
@@ -38,6 +38,7 @@
<div>
{% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1">{% endif %}
{% if to_field %}<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}">{% endif %}
+{% if source_model %}<input type="hidden" name="{{ source_model_var }}" value="{{ source_model }}">{% endif %}
{% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %}
{% if errors %}
<p class="errornote">
diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
index 40a6b3bf3a..cd40f14ce3 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -11,6 +11,7 @@ from django.contrib.admin.exceptions import (
from django.contrib.admin.options import (
IS_FACETS_VAR,
IS_POPUP_VAR,
+ SOURCE_MODEL_VAR,
TO_FIELD_VAR,
IncorrectLookupParameters,
ShowFacets,
@@ -49,6 +50,7 @@ IGNORED_PARAMS = (
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 f5c3939012..67eac083e7 100644
--- a/django/contrib/admin/widgets.py
+++ b/django/contrib/admin/widgets.py
@@ -333,16 +333,24 @@ class RelatedFieldWidgetWrapper(forms.Widget):
)
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 57129bfb66..fddbf6518a 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 ``<optgroup>``\s to
+ preserve :ref:`named groups <field-choices-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 7d127b5d59..4915ba6b9b 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;
+ $('<select id="id"></select>').appendTo('#qunit-fixture');
+ const grp = $('<optgroup label="group one">').appendTo('#id');
+ $('<option value="0">A</option>').appendTo(grp);
+ $('</optgroup>').appendTo('#id');
+ $('<option value="1">B</option>').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;
+ $('<select id="id"></select>').appendTo('#qunit-fixture');
+ // Add optgroups in non-alphabetical order
+ const grp2 = $('<optgroup label="Group B">').appendTo('#id');
+ $('<option value="3">Item 3</option>').appendTo(grp2);
+ $('<option value="4">Item 4</option>').appendTo(grp2);
+ const grp1 = $('<optgroup label="Group A">').appendTo('#id');
+ $('<option value="1">Item 1</option>').appendTo(grp1);
+ $('<option value="2">Item 2</option>').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;
+ $('<select id="id"></select>').appendTo('#qunit-fixture');
+ // Add options in non-alphabetical order
+ $('<option value="3">Zebra</option>').appendTo('#id');
+ $('<option value="1">Apple</option>').appendTo('#id');
+ $('<option value="2">Banana</option>').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;
+ $('<select id="from_id"></select>').appendTo('#qunit-fixture');
+ $('<select id="to_id"></select>').appendTo('#qunit-fixture');
+
+ // Add options with optgroups to from_id in non-alphabetical order
+ const grp2 = $('<optgroup label="Group B">').appendTo('#from_id');
+ $('<option value="2">Item 2</option>').appendTo(grp2);
+ const grp1 = $('<optgroup label="Group A">').appendTo('#from_id');
+ $('<option value="1">Item 1</option>').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;
+ $('<select id="from_id"></select>').appendTo('#qunit-fixture');
+ $('<select id="to_id"></select>').appendTo('#qunit-fixture');
+
+ // Add options without optgroups in non-alphabetical order
+ $('<option value="3">Zebra</option>').appendTo('#from_id');
+ $('<option value="1">Apple</option>').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;
+ $('<select id="from_id"></select>').appendTo('#qunit-fixture');
+ $('<select id="to_id"></select>').appendTo('#qunit-fixture');
+
+ // Add options with optgroups in non-alphabetical order
+ const grp2 = $('<optgroup label="Group B">').appendTo('#from_id');
+ $('<option value="3">Zebra</option>').appendTo(grp2);
+ const grp1 = $('<optgroup label="Group A">').appendTo('#from_id');
+ $('<option value="1">Apple</option>').appendTo(grp1);
+ $('<option value="2">Banana</option>').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;
+ $('<select id="from_id"></select>').appendTo('#qunit-fixture');
+ $('<select id="to_id"></select>').appendTo('#qunit-fixture');
+
+ // Add options without optgroups in non-alphabetical order
+ $('<option value="3">Zebra</option>').appendTo('#from_id');
+ $('<option value="1">Apple</option>').appendTo('#from_id');
+ $('<option value="2">Banana</option>').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 69570a8062..64e0e23229 100644
--- a/tests/admin_views/admin.py
+++ b/tests/admin_views/admin.py
@@ -18,7 +18,12 @@ from django.utils.html import format_html
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 @@ site2.register(Language)
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 3a3566c10f..f15a5b3ac1 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 @@ class CustomAdminPasswordChangeForm(AdminPasswordChangeForm):
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 f7eaad659e..3377a6d441 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -11,7 +11,7 @@ from django.contrib import admin
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 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
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 c1e673d811..3c43b8721d 100644
--- a/tests/admin_views/urls.py
+++ b/tests/admin_views/urls.py
@@ -32,6 +32,9 @@ urlpatterns = [
),
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 7588c2cc32..e0ae5b7747 100644
--- a/tests/admin_widgets/tests.py
+++ b/tests/admin_widgets/tests.py
@@ -971,7 +971,7 @@ class RelatedFieldWidgetWrapperTests(SimpleTestCase):
</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>