diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py index 56d7970d2a..6f0be6b148 100644 --- a/django/contrib/admin/helpers.py +++ b/django/contrib/admin/helpers.py @@ -3,12 +3,15 @@ import json 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 _ @@ -187,6 +190,17 @@ class AdminReadonlyField: label = self.field['label'] return format_html('{}{}', 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('{}', 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 @@ -212,6 +226,11 @@ class AdminReadonlyField: 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) diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index d82c9c719a..ab1d4dfb9a 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -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` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index 44623e483f..4a72e3070f 100644 --- a/tests/admin_views/admin.py +++ b/tests/admin_views/admin.py @@ -37,8 +37,8 @@ from .models import ( 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, @@ -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',) @@ -1061,6 +1065,7 @@ site.register(GenRelReference) 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: diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py index ba818ea10e..eed3cae306 100644 --- a/tests/admin_views/models.py +++ b/tests/admin_views/models.py @@ -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',) @@ -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) diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 6842b02d3e..94646f2034 100644 --- a/tests/admin_views/tests.py +++ b/tests/admin_views/tests.py @@ -48,12 +48,12 @@ from .models import ( 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 \ @@ -5042,6 +5042,45 @@ class ReadonlyTest(AdminFieldExtractionMixin, TestCase): response = self.client.get(reverse('admin:admin_views_choice_change', args=(choice.pk,))) self.assertContains(response, '
No opinion
', 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, + '
super
' % 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, + '
_40
' % language_url, + html=True, + ) + # Related ForeignKey object not registered in admin. + self.assertContains(response, '
Chapter 1
', html=True) + def test_readonly_manytomany_backwards_ref(self): """ Regression test for #16433 - backwards references for related objects @@ -5071,7 +5110,8 @@ class ReadonlyTest(AdminFieldExtractionMixin, TestCase): 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(), 'Brand New Plot' % pd_url) # The reverse relation also works if the OneToOneField is null. pd.plot = None