Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions django/contrib/admin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from django.contrib.admin.utils import (
NestedObjects,
construct_change_message,
display_for_value,
flatten_fieldsets,
get_deleted_objects,
lookup_spawns_duplicates,
Expand Down Expand Up @@ -73,6 +74,7 @@
IS_POPUP_VAR = "_popup"
TO_FIELD_VAR = "_to_field"
IS_FACETS_VAR = "_facets"
EMPTY_VALUE_STRING = "-"


class ShowFacets(enum.Enum):
Expand Down Expand Up @@ -1380,10 +1382,13 @@ def response_add(self, request, obj, post_url_continue=None):
current_app=self.admin_site.name,
)
# Add a link to the object's change form if the user can edit the obj.
obj_display = display_for_value(str(obj), EMPTY_VALUE_STRING, avoid_quote=True)
if self.has_change_permission(request, obj):
obj_repr = format_html('<a href="{}">{}</a>', urlquote(obj_url), obj)
obj_repr = format_html(
'<a href="{}">{}</a>', urlquote(obj_url), obj_display
)
else:
obj_repr = str(obj)
obj_repr = obj_display
msg_dict = {
"name": opts.verbose_name,
"obj": obj_repr,
Expand Down Expand Up @@ -1504,9 +1509,12 @@ def response_change(self, request, obj):
preserved_filters = self.get_preserved_filters(request)
preserved_qsl = self._get_preserved_qsl(request, preserved_filters)

obj_display = display_for_value(str(obj), EMPTY_VALUE_STRING, avoid_quote=True)
msg_dict = {
"name": opts.verbose_name,
"obj": format_html('<a href="{}">{}</a>', urlquote(request.path), obj),
"obj": format_html(
'<a href="{}">{}</a>', urlquote(request.path), obj_display
),
}
if "_continue" in request.POST:
msg = format_html(
Expand Down Expand Up @@ -1685,7 +1693,9 @@ def response_delete(self, request, obj_display, obj_id):
_("The %(name)s “%(obj)s” was deleted successfully.")
% {
"name": self.opts.verbose_name,
"obj": obj_display,
"obj": display_for_value(
str(obj_display), EMPTY_VALUE_STRING, avoid_quote=True
),
},
messages.SUCCESS,
)
Expand Down Expand Up @@ -2215,6 +2225,9 @@ def _delete_view(self, request, object_id, extra_context):
"subtitle": None,
"object_name": object_name,
"object": obj,
"escaped_object": display_for_value(
str(obj), EMPTY_VALUE_STRING, avoid_quote=True
),
"deleted_objects": deleted_objects,
"model_count": dict(model_count).items(),
"perms_lacking": perms_needed,
Expand Down Expand Up @@ -2263,7 +2276,8 @@ def history_view(self, request, object_id, extra_context=None):

context = {
**self.admin_site.each_context(request),
"title": _("Change history: %s") % obj,
"title": _("Change history: %s")
% display_for_value(str(obj), EMPTY_VALUE_STRING),
"subtitle": None,
"action_list": page_obj,
"page_range": page_range,
Expand Down
3 changes: 2 additions & 1 deletion django/contrib/admin/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.conf import settings
from django.contrib.admin import ModelAdmin, actions
from django.contrib.admin.exceptions import AlreadyRegistered, NotRegistered
from django.contrib.admin.options import EMPTY_VALUE_STRING
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import login_not_required
Expand Down Expand Up @@ -50,7 +51,7 @@ class AdminSite:

enable_nav_sidebar = True

empty_value_display = "-"
empty_value_display = EMPTY_VALUE_STRING

login_form = None
index_template = None
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% load admin_urls %}
{% load admin_filters %}

{% block title %}{% if form.errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %}
{% block extrastyle %}
Expand All @@ -15,7 +16,7 @@
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|truncatewords:"18" }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original|to_object_display_value|truncatechars:"80" }}</a>
&rsaquo; {% if form.user.has_usable_password %}{% translate 'Change password' %}{% else %}{% translate 'Set password' %}{% endif %}
</div>
{% endblock %}
Expand Down
4 changes: 2 additions & 2 deletions django/contrib/admin/templates/admin/change_form.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_modify %}
{% load i18n admin_urls static admin_modify admin_filters %}

{% block title %}{% if errors %}{% translate "Error:" %} {% endif %}{{ block.super }}{% endblock %}
{% block extrahead %}{{ block.super }}
Expand All @@ -19,7 +19,7 @@
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; {% if has_view_permission %}<a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
&rsaquo; {% if add %}{% blocktranslate with name=opts.verbose_name %}Add {{ name }}{% endblocktranslate %}{% else %}{{ original|truncatewords:"18" }}{% endif %}
&rsaquo; {% if add %}{% blocktranslate with name=opts.verbose_name %}Add {{ name }}{% endblocktranslate %}{% else %}{{ original|to_object_display_value|truncatechars:"80" }}{% endif %}
</div>
{% endblock %}
{% endif %}
Expand Down
10 changes: 5 additions & 5 deletions django/contrib/admin/templates/admin/delete_confirmation.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% load i18n admin_urls static admin_filters %}

{% block extrahead %}
{{ block.super }}
Expand All @@ -14,25 +14,25 @@
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|to_object_display_value|truncatechars:"80" }}</a>
&rsaquo; {% translate 'Delete' %}
</div>
{% endblock %}

{% block content %}
{% if perms_lacking %}
{% block delete_forbidden %}
<p>{% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} “{{ escaped_object }}” would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}</p>
<p>{% blocktranslate %}Deleting the {{ object_name }} “{{ escaped_object }}” would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}</p>
<ul id="deleted-objects">{{ perms_lacking|unordered_list }}</ul>
{% endblock %}
{% elif protected %}
{% block delete_protected %}
<p>{% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} “{{ escaped_object }}” would require deleting the following protected related objects:{% endblocktranslate %}</p>
<p>{% blocktranslate %}Deleting the {{ object_name }} “{{ escaped_object }}” would require deleting the following protected related objects:{% endblocktranslate %}</p>
<ul id="deleted-objects">{{ protected|unordered_list }}</ul>
{% endblock %}
{% else %}
{% block delete_confirm %}
<p>{% blocktranslate with escaped_object=object %}Are you sure you want to delete the {{ object_name }} “{{ escaped_object }}”? All of the following related items will be deleted:{% endblocktranslate %}</p>
<p>{% blocktranslate %}Are you sure you want to delete the {{ object_name }} “{{ escaped_object }}”? All of the following related items will be deleted:{% endblocktranslate %}</p>
{% include "admin/includes/object_delete_summary.html" %}
<h2>{% translate "Objects" %}</h2>
<ul id="deleted-objects">{{ deleted_objects|unordered_list }}</ul>
Expand Down
6 changes: 3 additions & 3 deletions django/contrib/admin/templates/admin/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% load i18n static admin_filters %}

{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/dashboard.css" %}">{% endblock %}

Expand Down Expand Up @@ -32,9 +32,9 @@ <h3>{% translate 'My actions' %}</h3>
<li class="{% if entry.is_addition %}addlink{% endif %}{% if entry.is_change %}changelink{% endif %}{% if entry.is_deletion %}deletelink{% endif %}">
<span class="visually-hidden">{% if entry.is_addition %}{% translate 'Added:' %}{% elif entry.is_change %}{% translate 'Changed:' %}{% elif entry.is_deletion %}{% translate 'Deleted:' %}{% endif %}</span>
{% if entry.is_deletion or not entry.get_admin_url %}
{{ entry.object_repr }}
{{ entry.object_repr|to_object_display_value }}
{% else %}
<a href="{{ entry.get_admin_url }}">{{ entry.object_repr }}</a>
<a href="{{ entry.get_admin_url }}">{{ entry.object_repr|to_object_display_value }}</a>
{% endif %}
<br>
{% if entry.content_type %}
Expand Down
4 changes: 2 additions & 2 deletions django/contrib/admin/templates/admin/object_history.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls %}
{% load i18n admin_urls admin_filters %}

{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ module_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:"18" }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|to_object_display_value|truncatechars:"80" }}</a>
&rsaquo; {% translate 'History' %}
</div>
{% endblock %}
Expand Down
12 changes: 12 additions & 0 deletions django/contrib/admin/templatetags/admin_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django import template
from django.contrib.admin.options import EMPTY_VALUE_STRING
from django.contrib.admin.utils import display_for_value
from django.template.defaultfilters import stringfilter

register = template.Library()


@register.filter(is_safe=True)
@stringfilter
def to_object_display_value(value):
return display_for_value(str(value), EMPTY_VALUE_STRING)
2 changes: 0 additions & 2 deletions django/contrib/admin/templatetags/admin_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,6 @@ def link_in_col(is_first, field_name, cl):
empty_value_display = getattr(
attr, "empty_value_display", empty_value_display
)
if isinstance(value, str) and value.strip() == "":
value = ""
if f is None or f.auto_created:
if field_name == "action_checkbox":
row_classes = ["action-checkbox"]
Expand Down
51 changes: 48 additions & 3 deletions django/contrib/admin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from django.urls import NoReverseMatch, reverse
from django.utils import formats, timezone
from django.utils.hashable import make_hashable
from django.utils.html import format_html
from django.utils.html import format_html, strip_tags
from django.utils.regex_helper import _lazy_re_compile
from django.utils.text import capfirst
from django.utils.translation import ngettext
Expand Down Expand Up @@ -130,6 +130,8 @@ def get_deleted_objects(objs, request, admin_site):
Return a nested list of strings suitable for display in the
template with the ``unordered_list`` filter.
"""
from django.contrib.admin.options import EMPTY_VALUE_STRING

try:
obj = objs[0]
except IndexError:
Expand Down Expand Up @@ -163,8 +165,12 @@ def format_callback(obj):
return no_edit_link

# Display a link to the admin page.
obj_display = display_for_value(str(obj), EMPTY_VALUE_STRING)
return format_html(
'{}: <a href="{}">{}</a>', capfirst(opts.verbose_name), admin_url, obj
'{}: <a href="{}">{}</a>',
capfirst(opts.verbose_name),
admin_url,
obj_display,
)
else:
# Don't display link to edit, because it either has no
Expand Down Expand Up @@ -428,6 +434,40 @@ def help_text_for_field(name, model):
return help_text


def convert_to_nbsp(value):
"""
Converts spaces in a string to non-breaking spaces (nbsp)
for visual preservation.

Exactly leading and trailing spaces, as well as consecutive
spaces between words, are converted to non-breaking spaces.
"""
result = ""
nbsp = "\xa0"
if not value.strip():
return value.replace(" ", nbsp)

value_length = len(value)
left_space_length = value_length - len(value.lstrip())
right_space_length = value_length - len(value.rstrip())
left = left_space_length * nbsp
right = right_space_length * nbsp
space_cnt = 0
for char in value.strip():
if char == " ":
space_cnt += 1
else:
# Consecutive spaces between words, replaced with nbsp
if space_cnt > 1:
result = result[:-space_cnt]
nbsps = space_cnt * nbsp
result += nbsps
space_cnt = 0
result += char
result = left + result + right
return result


def display_for_field(value, field, empty_value_display, avoid_link=False):
from django.contrib.admin.templatetags.admin_list import _boolean_icon

Expand Down Expand Up @@ -469,7 +509,7 @@ def display_for_field(value, field, empty_value_display, avoid_link=False):
return display_for_value(value, empty_value_display)


def display_for_value(value, empty_value_display, boolean=False):
def display_for_value(value, empty_value_display, boolean=False, avoid_quote=False):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should update the unit tests for display_for_value with the new cases

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add the following test cases....

index 77d6655290..f30589485c 100644
--- a/tests/admin_utils/tests.py
+++ b/tests/admin_utils/tests.py
@@ -266,6 +266,13 @@ class UtilsTests(SimpleTestCase):
                 display_value = display_for_value(value, self.empty_value)
                 self.assertEqual(display_value, self.empty_value)
 
+    def test_list_display_for_value_blank_str(self):
+        value = "   "
+        display_value = display_for_value(value, self.empty_value)
+        self.assertEqual(display_value, "“   ”")
+        display_value = display_for_value(value, self.empty_value, avoid_quote=True)
+        self.assertEqual(display_value, "   ")
+
     def test_label_for_field(self):
         """
         Tests for label_for_field

from django.contrib.admin.templatetags.admin_list import _boolean_icon

if boolean:
Expand All @@ -486,6 +526,11 @@ def display_for_value(value, empty_value_display, boolean=False):
return formats.number_format(value)
elif isinstance(value, (list, tuple)):
return ", ".join(str(v) for v in value)
elif strip_tags(value) == value and isinstance(value, str):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I excluded cases where the value is in tag format.
To determine whether a string is in tag format, I used strip_tags.
Would there happen to be a better approach for this?

converted_value = convert_to_nbsp(value)
if value.strip() != value and not avoid_quote:
return f"“{converted_value}”"
return converted_value
else:
return str(value)

Expand Down
28 changes: 20 additions & 8 deletions tests/admin_changelist/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,14 +1078,26 @@ def test_link_field_display_links(self):

def test_blank_str_display_links(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of deleting the test, can you update the expected value?

Copy link
Member Author

@Antoliny0919 Antoliny0919 Mar 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, sarahboyce!
When I initially worked on it, I removed the existing test_blank_str_display_link and created test_change_list_with_blank_string_object to perform the same test.
I will now remove test_change_list_with_blank_string_object and keep test_blank_str_display_link as it is.

index 8e4557c7c5..78e6f1d93a 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -1076,6 +1076,19 @@ class ChangeListTests(TestCase):
             "http://blues_history.com</a>" % g.pk,
         )
 
+    def test_blank_str_display_links(self):
+        self.client.force_login(self.superuser)
+        gc = GrandChild.objects.create(name=" " * 10)
+        expect_object_str = "“%s”" % str(b"\xc2\xa0" * 10, "utf-8")
+        response = self.client.get(
+            reverse("admin:admin_changelist_grandchild_changelist")
+        )
+        self.assertContains(
+            response,
+            '<a href="/admin/admin_changelist/grandchild/%s/change/">%s</a>'
+            % (gc.pk, expect_object_str),
+        )
+
     def test_clear_all_filters_link(self):
         self.client.force_login(self.superuser)
         url = reverse("admin:auth_user_changelist")
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 4d6b254366..dd8f1848ba 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -3475,16 +3475,6 @@ class AdminBlankStringObjectDisplayTest(TestCase):
             html=True,
         )
 
-    def test_change_list_with_blank_string_object(self):
-        url = reverse("admin:admin_views_coverletter_changelist")
-        response = self.client.get(url)
-        self.assertContains(
-            response,
-            '<th class="field-__str__"><a href="%s">%s</a></th>'
-            % (self.change_link, self.display_object),
-            html=True,
-        )
-

self.client.force_login(self.superuser)
gc = GrandChild.objects.create(name=" ")
response = self.client.get(
reverse("admin:admin_changelist_grandchild_changelist")
)
self.assertContains(
response,
'<a href="/admin/admin_changelist/grandchild/%s/change/">-</a>' % gc.pk,
)
cases = [
(" ", "“     ”"),
("Antoliny ", "“Antoliny    ”"),
(" Antoliny", "“    Antoliny”"),
(" Antoliny ", "“   Antoliny   ”"),
("Anto liny", "Anto      liny"),
("A n t o l i n y", "A n t o l i n y"),
("A n t o l i n y", "A  n  t  o  l  i  n  y"),
]
for value, expect_display_value in cases:
with self.subTest(value=value):
gc = GrandChild.objects.create(name=value)
response = self.client.get(
reverse("admin:admin_changelist_grandchild_changelist")
)
self.assertContains(
response,
'<a href="/admin/admin_changelist/grandchild/%s/change/">%s</a>'
% (gc.pk, expect_display_value),
)

def test_clear_all_filters_link(self):
self.client.force_login(self.superuser)
Expand Down
Loading