Skip to content
Merged
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
23 changes: 21 additions & 2 deletions django/contrib/admin/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
from django import forms
from django.contrib.admin.utils import (
display_for_field, flatten_fieldsets, help_text_for_field, label_for_field,
lookup_field,
lookup_field, quote,
)
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import ManyToManyRel
from django.db.models.fields.related import (
ForeignObjectRel, ManyToManyRel, OneToOneField,
)
from django.forms.utils import flatatt
from django.template.defaultfilters import capfirst, linebreaksbr
from django.urls import NoReverseMatch, reverse
from django.utils.html import conditional_escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _
Expand Down Expand Up @@ -187,6 +190,17 @@ def label_tag(self):
label = self.field['label']
return format_html('<label{}>{}{}</label>', flatatt(attrs), capfirst(label), self.form.label_suffix)

def get_admin_url(self, remote_field, remote_obj):
url_name = 'admin:%s_%s_change' % (
remote_field.model._meta.app_label,
remote_field.model._meta.model_name,
)
try:
url = reverse(url_name, args=[quote(remote_obj.pk)])
return format_html('<a href="{}">{}</a>', url, remote_obj)
except NoReverseMatch:
return str(remote_obj)

def contents(self):
from django.contrib.admin.templatetags.admin_list import _boolean_icon
field, obj, model_admin = self.field['field'], self.form.instance, self.model_admin
Expand All @@ -212,6 +226,11 @@ def contents(self):
else:
if isinstance(f.remote_field, ManyToManyRel) and value is not None:
result_repr = ", ".join(map(str, value.all()))
elif (
isinstance(f.remote_field, (ForeignObjectRel, OneToOneField)) and
value is not None
):
result_repr = self.get_admin_url(f.remote_field, value)
else:
result_repr = display_for_field(value, f, self.empty_value_display)
result_repr = linebreaksbr(result_repr)
Expand Down
3 changes: 3 additions & 0 deletions docs/releases/3.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ Minor features
* :attr:`.ModelAdmin.search_fields` now allows searching against quoted phrases
with spaces.

* Read-only related fields are now rendered as navigable links if target models
are registered in the admin.

:mod:`django.contrib.admindocs`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
9 changes: 7 additions & 2 deletions tests/admin_views/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
Person, Persona, Picture, Pizza, Plot, PlotDetails, PlotProxy,
PluggableSearchPerson, Podcast, Post, PrePopulatedPost,
PrePopulatedPostLargeSlug, PrePopulatedSubPost, Promo, Question,
ReadablePizza, ReadOnlyPizza, Recipe, Recommendation, Recommender,
ReferencedByGenRel, ReferencedByInline, ReferencedByParent,
ReadablePizza, ReadOnlyPizza, ReadOnlyRelatedField, Recipe, Recommendation,
Recommender, ReferencedByGenRel, ReferencedByInline, ReferencedByParent,
RelatedPrepopulated, RelatedWithUUIDPKModel, Report, Reservation,
Restaurant, RowLevelChangePermissionModel, Section, ShortMessage, Simple,
Sketch, Song, State, Story, StumpJoke, Subscriber, SuperVillain, Telegram,
Expand Down Expand Up @@ -539,6 +539,10 @@ class PizzaAdmin(admin.ModelAdmin):
readonly_fields = ('toppings',)


class ReadOnlyRelatedFieldAdmin(admin.ModelAdmin):
readonly_fields = ('chapter', 'language', 'user')


class StudentAdmin(admin.ModelAdmin):
search_fields = ('name',)

Expand Down Expand Up @@ -1061,6 +1065,7 @@ def get_formsets_with_inlines(self, request, obj=None):
site.register(ParentWithUUIDPK)
site.register(RelatedPrepopulated, search_fields=['name'])
site.register(RelatedWithUUIDPKModel)
site.register(ReadOnlyRelatedField, ReadOnlyRelatedFieldAdmin)

# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
# That way we cover all four cases:
Expand Down
9 changes: 9 additions & 0 deletions tests/admin_views/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,9 @@ class Language(models.Model):
english_name = models.CharField(max_length=50)
shortlist = models.BooleanField(default=False)

def __str__(self):
return self.iso

class Meta:
ordering = ('iso',)

Expand Down Expand Up @@ -999,3 +1002,9 @@ class UserProxy(User):
"""Proxy a model with a different app_label."""
class Meta:
proxy = True


class ReadOnlyRelatedField(models.Model):
chapter = models.ForeignKey(Chapter, models.CASCADE)
language = models.ForeignKey(Language, models.CASCADE)
user = models.ForeignKey(User, models.CASCADE)
54 changes: 47 additions & 7 deletions tests/admin_views/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@
Parent, ParentWithDependentChildren, ParentWithUUIDPK, Person, Persona,
Picture, Pizza, Plot, PlotDetails, PluggableSearchPerson, Podcast, Post,
PrePopulatedPost, Promo, Question, ReadablePizza, ReadOnlyPizza,
Recommendation, Recommender, RelatedPrepopulated, RelatedWithUUIDPKModel,
Report, Restaurant, RowLevelChangePermissionModel, SecretHideout, Section,
ShortMessage, Simple, Song, State, Story, SuperSecretHideout, SuperVillain,
Telegram, TitleTranslation, Topping, UnchangeableObject, UndeletableObject,
UnorderedObject, UserProxy, Villain, Vodcast, Whatsit, Widget, Worker,
WorkHour,
ReadOnlyRelatedField, Recommendation, Recommender, RelatedPrepopulated,
RelatedWithUUIDPKModel, Report, Restaurant, RowLevelChangePermissionModel,
SecretHideout, Section, ShortMessage, Simple, Song, State, Story,
SuperSecretHideout, SuperVillain, Telegram, TitleTranslation, Topping,
UnchangeableObject, UndeletableObject, UnorderedObject, UserProxy, Villain,
Vodcast, Whatsit, Widget, Worker, WorkHour,
)

ERROR_MESSAGE = "Please enter the correct username and password \
Expand Down Expand Up @@ -5042,6 +5042,45 @@ def test_change_form_renders_correct_null_choice_value(self):
response = self.client.get(reverse('admin:admin_views_choice_change', args=(choice.pk,)))
self.assertContains(response, '<div class="readonly">No opinion</div>', html=True)

def test_readonly_foreignkey_links(self):
"""
ForeignKey readonly fields render as links if the target model is
registered in admin.
"""
chapter = Chapter.objects.create(
title='Chapter 1',
content='content',
book=Book.objects.create(name='Book 1'),
)
language = Language.objects.create(iso='_40', name='Test')
obj = ReadOnlyRelatedField.objects.create(
chapter=chapter,
language=language,
user=self.superuser,
)
response = self.client.get(
reverse('admin:admin_views_readonlyrelatedfield_change', args=(obj.pk,)),
)
# Related ForeignKey object registered in admin.
user_url = reverse('admin:auth_user_change', args=(self.superuser.pk,))
self.assertContains(
response,
'<div class="readonly"><a href="%s">super</a></div>' % user_url,
html=True,
)
# Related ForeignKey with the string primary key registered in admin.
language_url = reverse(
'admin:admin_views_language_change',
args=(quote(language.pk),),
)
self.assertContains(
response,
'<div class="readonly"><a href="%s">_40</a></div>' % language_url,
html=True,
)
# Related ForeignKey object not registered in admin.
self.assertContains(response, '<div class="readonly">Chapter 1</div>', html=True)

def test_readonly_manytomany_backwards_ref(self):
"""
Regression test for #16433 - backwards references for related objects
Expand Down Expand Up @@ -5071,7 +5110,8 @@ def test_readonly_onetoone_backwards_ref(self):

response = self.client.get(reverse('admin:admin_views_plotproxy_change', args=(pl.pk,)))
field = self.get_admin_readonly_field(response, 'plotdetails')
self.assertEqual(field.contents(), 'Brand New Plot')
pd_url = reverse('admin:admin_views_plotdetails_change', args=(pd.pk,))
self.assertEqual(field.contents(), '<a href="%s">Brand New Plot</a>' % pd_url)

# The reverse relation also works if the OneToOneField is null.
pd.plot = None
Expand Down