summaryrefslogtreecommitdiff
path: root/django/contrib/admin
diff options
context:
space:
mode:
authorseanhelvey <sean.helvey@gmail.com>2024-12-13 11:56:53 -0800
committerJacob Walls <jacobtylerwalls@gmail.com>2026-01-22 21:12:23 -0500
commitb1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010 (patch)
tree0fcfd9b90c788e21e58cb9249f4119062b8bfc4e /django/contrib/admin
parent3851601b2e080df34fb9227fe5d2fd43af604263 (diff)
Fixed #13883 -- Rendered named choice groups with <optgroup> in FilteredSelectMultiple.
This patch adds support for <optgroup>s in FilteredSelectMultiple widgets. When a popup returns a new object, if the source field contains optgroup choices, the optgroup is now also included in the response data. Additionally, this adds error handling for invalid source_model parameters to prevent crashes and display user-friendly error messages instead. Co-authored-by: Michael McLarnon <mmclar@gmail.com>
Diffstat (limited to 'django/contrib/admin')
-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
7 files changed, 102 insertions, 22 deletions
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 = {