Fixed #31181 -- Added links to related models for admin's readonly fields.
This commit is contained in:
parent
855fc06236
commit
b790883065
|
@ -3,12 +3,15 @@ import json
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.admin.utils import (
|
from django.contrib.admin.utils import (
|
||||||
display_for_field, flatten_fieldsets, help_text_for_field, label_for_field,
|
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.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.forms.utils import flatatt
|
||||||
from django.template.defaultfilters import capfirst, linebreaksbr
|
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.html import conditional_escape, format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
|
@ -187,6 +190,17 @@ class AdminReadonlyField:
|
||||||
label = self.field['label']
|
label = self.field['label']
|
||||||
return format_html('<label{}>{}{}</label>', flatatt(attrs), capfirst(label), self.form.label_suffix)
|
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):
|
def contents(self):
|
||||||
from django.contrib.admin.templatetags.admin_list import _boolean_icon
|
from django.contrib.admin.templatetags.admin_list import _boolean_icon
|
||||||
field, obj, model_admin = self.field['field'], self.form.instance, self.model_admin
|
field, obj, model_admin = self.field['field'], self.form.instance, self.model_admin
|
||||||
|
@ -212,6 +226,11 @@ class AdminReadonlyField:
|
||||||
else:
|
else:
|
||||||
if isinstance(f.remote_field, ManyToManyRel) and value is not None:
|
if isinstance(f.remote_field, ManyToManyRel) and value is not None:
|
||||||
result_repr = ", ".join(map(str, value.all()))
|
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:
|
else:
|
||||||
result_repr = display_for_field(value, f, self.empty_value_display)
|
result_repr = display_for_field(value, f, self.empty_value_display)
|
||||||
result_repr = linebreaksbr(result_repr)
|
result_repr = linebreaksbr(result_repr)
|
||||||
|
|
|
@ -72,6 +72,9 @@ Minor features
|
||||||
* :attr:`.ModelAdmin.search_fields` now allows searching against quoted phrases
|
* :attr:`.ModelAdmin.search_fields` now allows searching against quoted phrases
|
||||||
with spaces.
|
with spaces.
|
||||||
|
|
||||||
|
* Read-only related fields are now rendered as navigable links if target models
|
||||||
|
are registered in the admin.
|
||||||
|
|
||||||
:mod:`django.contrib.admindocs`
|
:mod:`django.contrib.admindocs`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
|
|
@ -37,8 +37,8 @@ from .models import (
|
||||||
Person, Persona, Picture, Pizza, Plot, PlotDetails, PlotProxy,
|
Person, Persona, Picture, Pizza, Plot, PlotDetails, PlotProxy,
|
||||||
PluggableSearchPerson, Podcast, Post, PrePopulatedPost,
|
PluggableSearchPerson, Podcast, Post, PrePopulatedPost,
|
||||||
PrePopulatedPostLargeSlug, PrePopulatedSubPost, Promo, Question,
|
PrePopulatedPostLargeSlug, PrePopulatedSubPost, Promo, Question,
|
||||||
ReadablePizza, ReadOnlyPizza, Recipe, Recommendation, Recommender,
|
ReadablePizza, ReadOnlyPizza, ReadOnlyRelatedField, Recipe, Recommendation,
|
||||||
ReferencedByGenRel, ReferencedByInline, ReferencedByParent,
|
Recommender, ReferencedByGenRel, ReferencedByInline, ReferencedByParent,
|
||||||
RelatedPrepopulated, RelatedWithUUIDPKModel, Report, Reservation,
|
RelatedPrepopulated, RelatedWithUUIDPKModel, Report, Reservation,
|
||||||
Restaurant, RowLevelChangePermissionModel, Section, ShortMessage, Simple,
|
Restaurant, RowLevelChangePermissionModel, Section, ShortMessage, Simple,
|
||||||
Sketch, Song, State, Story, StumpJoke, Subscriber, SuperVillain, Telegram,
|
Sketch, Song, State, Story, StumpJoke, Subscriber, SuperVillain, Telegram,
|
||||||
|
@ -539,6 +539,10 @@ class PizzaAdmin(admin.ModelAdmin):
|
||||||
readonly_fields = ('toppings',)
|
readonly_fields = ('toppings',)
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlyRelatedFieldAdmin(admin.ModelAdmin):
|
||||||
|
readonly_fields = ('chapter', 'language', 'user')
|
||||||
|
|
||||||
|
|
||||||
class StudentAdmin(admin.ModelAdmin):
|
class StudentAdmin(admin.ModelAdmin):
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
@ -1061,6 +1065,7 @@ site.register(GenRelReference)
|
||||||
site.register(ParentWithUUIDPK)
|
site.register(ParentWithUUIDPK)
|
||||||
site.register(RelatedPrepopulated, search_fields=['name'])
|
site.register(RelatedPrepopulated, search_fields=['name'])
|
||||||
site.register(RelatedWithUUIDPKModel)
|
site.register(RelatedWithUUIDPKModel)
|
||||||
|
site.register(ReadOnlyRelatedField, ReadOnlyRelatedFieldAdmin)
|
||||||
|
|
||||||
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
|
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
|
||||||
# That way we cover all four cases:
|
# That way we cover all four cases:
|
||||||
|
|
|
@ -365,6 +365,9 @@ class Language(models.Model):
|
||||||
english_name = models.CharField(max_length=50)
|
english_name = models.CharField(max_length=50)
|
||||||
shortlist = models.BooleanField(default=False)
|
shortlist = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.iso
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('iso',)
|
ordering = ('iso',)
|
||||||
|
|
||||||
|
@ -999,3 +1002,9 @@ class UserProxy(User):
|
||||||
"""Proxy a model with a different app_label."""
|
"""Proxy a model with a different app_label."""
|
||||||
class Meta:
|
class Meta:
|
||||||
proxy = True
|
proxy = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReadOnlyRelatedField(models.Model):
|
||||||
|
chapter = models.ForeignKey(Chapter, models.CASCADE)
|
||||||
|
language = models.ForeignKey(Language, models.CASCADE)
|
||||||
|
user = models.ForeignKey(User, models.CASCADE)
|
||||||
|
|
|
@ -48,12 +48,12 @@ from .models import (
|
||||||
Parent, ParentWithDependentChildren, ParentWithUUIDPK, Person, Persona,
|
Parent, ParentWithDependentChildren, ParentWithUUIDPK, Person, Persona,
|
||||||
Picture, Pizza, Plot, PlotDetails, PluggableSearchPerson, Podcast, Post,
|
Picture, Pizza, Plot, PlotDetails, PluggableSearchPerson, Podcast, Post,
|
||||||
PrePopulatedPost, Promo, Question, ReadablePizza, ReadOnlyPizza,
|
PrePopulatedPost, Promo, Question, ReadablePizza, ReadOnlyPizza,
|
||||||
Recommendation, Recommender, RelatedPrepopulated, RelatedWithUUIDPKModel,
|
ReadOnlyRelatedField, Recommendation, Recommender, RelatedPrepopulated,
|
||||||
Report, Restaurant, RowLevelChangePermissionModel, SecretHideout, Section,
|
RelatedWithUUIDPKModel, Report, Restaurant, RowLevelChangePermissionModel,
|
||||||
ShortMessage, Simple, Song, State, Story, SuperSecretHideout, SuperVillain,
|
SecretHideout, Section, ShortMessage, Simple, Song, State, Story,
|
||||||
Telegram, TitleTranslation, Topping, UnchangeableObject, UndeletableObject,
|
SuperSecretHideout, SuperVillain, Telegram, TitleTranslation, Topping,
|
||||||
UnorderedObject, UserProxy, Villain, Vodcast, Whatsit, Widget, Worker,
|
UnchangeableObject, UndeletableObject, UnorderedObject, UserProxy, Villain,
|
||||||
WorkHour,
|
Vodcast, Whatsit, Widget, Worker, WorkHour,
|
||||||
)
|
)
|
||||||
|
|
||||||
ERROR_MESSAGE = "Please enter the correct username and password \
|
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,)))
|
response = self.client.get(reverse('admin:admin_views_choice_change', args=(choice.pk,)))
|
||||||
self.assertContains(response, '<div class="readonly">No opinion</div>', html=True)
|
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):
|
def test_readonly_manytomany_backwards_ref(self):
|
||||||
"""
|
"""
|
||||||
Regression test for #16433 - backwards references for related objects
|
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,)))
|
response = self.client.get(reverse('admin:admin_views_plotproxy_change', args=(pl.pk,)))
|
||||||
field = self.get_admin_readonly_field(response, 'plotdetails')
|
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.
|
# The reverse relation also works if the OneToOneField is null.
|
||||||
pd.plot = None
|
pd.plot = None
|
||||||
|
|
Loading…
Reference in New Issue