Fixed #8261 -- ModelAdmin hook for customising the "show on site" button

``ModelAdmin.view_on_site`` defines wether to show a link to the object on the
admin detail page. If ``True``, cleverness (i.e. ``Model.get_absolute_url``) is
used to get the url. If it's a callable, the callable is called with the object
as the only parameter. If ``False``, not link is displayed.

With the aim of maitaining backwards compatibility, ``True`` is the default.
This commit is contained in:
Unai Zalakain 2013-10-24 17:28:09 +02:00 committed by Simon Charette
parent 497930b7f6
commit fd219fa24c
12 changed files with 266 additions and 10 deletions

View File

@ -202,9 +202,10 @@ class InlineAdminFormSet(object):
def __iter__(self): def __iter__(self):
for form, original in zip(self.formset.initial_forms, self.formset.get_queryset()): for form, original in zip(self.formset.initial_forms, self.formset.get_queryset()):
view_on_site_url = self.opts.get_view_on_site_url(original)
yield InlineAdminForm(self.formset, form, self.fieldsets, yield InlineAdminForm(self.formset, form, self.fieldsets,
self.prepopulated_fields, original, self.readonly_fields, self.prepopulated_fields, original, self.readonly_fields,
model_admin=self.opts) model_admin=self.opts, view_on_site_url=view_on_site_url)
for form in self.formset.extra_forms: for form in self.formset.extra_forms:
yield InlineAdminForm(self.formset, form, self.fieldsets, yield InlineAdminForm(self.formset, form, self.fieldsets,
self.prepopulated_fields, None, self.readonly_fields, self.prepopulated_fields, None, self.readonly_fields,
@ -242,13 +243,14 @@ class InlineAdminForm(AdminForm):
A wrapper around an inline form for use in the admin system. A wrapper around an inline form for use in the admin system.
""" """
def __init__(self, formset, form, fieldsets, prepopulated_fields, original, def __init__(self, formset, form, fieldsets, prepopulated_fields, original,
readonly_fields=None, model_admin=None): readonly_fields=None, model_admin=None, view_on_site_url=None):
self.formset = formset self.formset = formset
self.model_admin = model_admin self.model_admin = model_admin
self.original = original self.original = original
if original is not None: if original is not None:
self.original_content_type_id = ContentType.objects.get_for_model(original).pk self.original_content_type_id = ContentType.objects.get_for_model(original).pk
self.show_url = original and hasattr(original, 'get_absolute_url') self.show_url = original and view_on_site_url is not None
self.absolute_url = view_on_site_url
super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields, super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields,
readonly_fields, model_admin) readonly_fields, model_admin)

View File

@ -98,6 +98,7 @@ class BaseModelAdmin(six.with_metaclass(RenameBaseModelAdminMethods)):
formfield_overrides = {} formfield_overrides = {}
readonly_fields = () readonly_fields = ()
ordering = None ordering = None
view_on_site = True
# validation # validation
validator_class = validation.BaseValidator validator_class = validation.BaseValidator
@ -243,6 +244,19 @@ class BaseModelAdmin(six.with_metaclass(RenameBaseModelAdminMethods)):
return db_field.formfield(**kwargs) return db_field.formfield(**kwargs)
def get_view_on_site_url(self, obj=None):
if obj is None or not self.view_on_site:
return None
if callable(self.view_on_site):
return self.view_on_site(obj)
elif self.view_on_site:
# use the ContentType lookup if view_on_site is True
return reverse('admin:view_on_site', kwargs={
'content_type_id': ContentType.objects.get_for_model(obj).pk,
'object_id': obj.pk
})
@property @property
def declared_fieldsets(self): def declared_fieldsets(self):
warnings.warn( warnings.warn(
@ -971,6 +985,7 @@ class ModelAdmin(BaseModelAdmin):
app_label = opts.app_label app_label = opts.app_label
preserved_filters = self.get_preserved_filters(request) preserved_filters = self.get_preserved_filters(request)
form_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, form_url) form_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, form_url)
view_on_site_url = self.get_view_on_site_url(obj)
context.update({ context.update({
'add': add, 'add': add,
'change': change, 'change': change,
@ -978,7 +993,8 @@ class ModelAdmin(BaseModelAdmin):
'has_change_permission': self.has_change_permission(request, obj), 'has_change_permission': self.has_change_permission(request, obj),
'has_delete_permission': self.has_delete_permission(request, obj), 'has_delete_permission': self.has_delete_permission(request, obj),
'has_file_field': True, # FIXME - this should check if form or formsets have a FileField, 'has_file_field': True, # FIXME - this should check if form or formsets have a FileField,
'has_absolute_url': hasattr(self.model, 'get_absolute_url'), 'has_absolute_url': view_on_site_url is not None,
'absolute_url': view_on_site_url,
'form_url': form_url, 'form_url': form_url,
'opts': opts, 'opts': opts,
'content_type_id': ContentType.objects.get_for_model(self.model).id, 'content_type_id': ContentType.objects.get_for_model(self.model).id,

View File

@ -32,7 +32,7 @@
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %} {% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a> <a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a>
</li> </li>
{% if has_absolute_url %}<li><a href="{% url 'admin:view_on_site' content_type_id original.pk %}" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%} {% if has_absolute_url %}<li><a href="{{ absolute_url }}" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%}
{% endblock %} {% endblock %}
</ul> </ul>
{% endif %}{% endif %} {% endif %}{% endif %}

View File

@ -6,7 +6,7 @@
{% for inline_admin_form in inline_admin_formset %}<div class="inline-related{% if forloop.last %} empty-form last-related{% endif %}" id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}"> {% for inline_admin_form in inline_admin_formset %}<div class="inline-related{% if forloop.last %} empty-form last-related{% endif %}" id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
<h3><b>{{ inline_admin_formset.opts.verbose_name|capfirst }}:</b>&nbsp;<span class="inline_label">{% if inline_admin_form.original %}{{ inline_admin_form.original }}{% else %}#{{ forloop.counter }}{% endif %}</span> <h3><b>{{ inline_admin_formset.opts.verbose_name|capfirst }}:</b>&nbsp;<span class="inline_label">{% if inline_admin_form.original %}{{ inline_admin_form.original }}{% else %}#{{ forloop.counter }}{% endif %}</span>
{% if inline_admin_form.show_url %}<a href="{% url 'admin:view_on_site' inline_admin_form.original_content_type_id inline_admin_form.original.pk %}">{% trans "View on site" %}</a>{% endif %} {% if inline_admin_form.show_url %}<a href="{{ inline_admin_form.absolute_url }}">{% trans "View on site" %}</a>{% endif %}
{% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %} {% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}<span class="delete">{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}</span>{% endif %}
</h3> </h3>
{% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %} {% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %}

View File

@ -27,7 +27,7 @@
<td class="original"> <td class="original">
{% if inline_admin_form.original or inline_admin_form.show_url %}<p> {% if inline_admin_form.original or inline_admin_form.show_url %}<p>
{% if inline_admin_form.original %} {{ inline_admin_form.original }}{% endif %} {% if inline_admin_form.original %} {{ inline_admin_form.original }}{% endif %}
{% if inline_admin_form.show_url %}<a href="{% url 'admin:view_on_site' inline_admin_form.original_content_type_id inline_admin_form.original.pk %}">{% trans "View on site" %}</a>{% endif %} {% if inline_admin_form.show_url %}<a href="{{ inline_admin_form.absolute_url }}">{% trans "View on site" %}</a>{% endif %}
</p>{% endif %} </p>{% endif %}
{% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %} {% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
{{ inline_admin_form.fk_field.field }} {{ inline_admin_form.fk_field.field }}

View File

@ -164,6 +164,11 @@ class BaseValidator(object):
for idx, f in enumerate(val): for idx, f in enumerate(val):
get_field(cls, model, "prepopulated_fields['%s'][%d]" % (field, idx), f) get_field(cls, model, "prepopulated_fields['%s'][%d]" % (field, idx), f)
def validate_view_on_site_url(self, cls, model):
if hasattr(cls, 'view_on_site'):
if not callable(cls.view_on_site) and not isinstance(cls.view_on_site, bool):
raise ImproperlyConfigured("%s.view_on_site is not a callable or a boolean value." % cls.__name__)
def validate_ordering(self, cls, model): def validate_ordering(self, cls, model):
" Validate that ordering refers to existing fields or is random. " " Validate that ordering refers to existing fields or is random. "
# ordering = None # ordering = None

View File

@ -1091,6 +1091,37 @@ subclass::
:meth:`ModelAdmin.get_search_results` to provide additional or alternate :meth:`ModelAdmin.get_search_results` to provide additional or alternate
search behavior. search behavior.
.. attribute:: ModelAdmin.view_on_site
.. versionadded:: 1.7
Set ``view_on_site`` to control whether or not to display the "View on site" link.
This link should bring you to a URL where you can display the saved object.
This value can be either a boolean flag or a callable. If ``True`` (the
default), the object's :meth:`~django.db.models.Model.get_absolute_url`
method will be used to generate the url.
If your model has a :meth:`~django.db.models.Model.get_absolute_url` method
but you don't want the "View on site" button to appear, you only need to set
``view_on_site`` to ``False``::
from django.contrib import admin
class PersonAdmin(admin.ModelAdmin):
view_on_site = False
In case it is a callable, it accepts the model instance as a parameter.
For example::
from django.contrib import admin
from django.core.urlresolvers import reverse
class PersonAdmin(admin.ModelAdmin):
def view_on_site(self, obj):
return 'http://example.com' + reverse('person-detail',
kwargs={'slug': obj.slug})
Custom template options Custom template options
~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -168,6 +168,10 @@ Minor features
<django.contrib.admin.ModelAdmin.list_display_links>` ``= None`` to disable <django.contrib.admin.ModelAdmin.list_display_links>` ``= None`` to disable
links on the change list page grid. links on the change list page grid.
* You may now specify :attr:`ModelAdmin.view_on_site
<django.contrib.admin.ModelAdmin.view_on_site>` to control whether or not to
display the "View on site" link.
:mod:`django.contrib.auth` :mod:`django.contrib.auth`
^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^

View File

@ -31,7 +31,7 @@ from .models import (Article, Chapter, Account, Media, Child, Parent, Picture,
AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated, AdminOrderedCallable, Report, Color2, UnorderedObject, MainPrepopulated,
RelatedPrepopulated, UndeletableObject, UnchangeableObject, UserMessenger, Simple, Choice, RelatedPrepopulated, UndeletableObject, UnchangeableObject, UserMessenger, Simple, Choice,
ShortMessage, Telegram, FilteredManager, EmptyModelHidden, ShortMessage, Telegram, FilteredManager, EmptyModelHidden,
EmptyModelVisible, EmptyModelMixin) EmptyModelVisible, EmptyModelMixin, State, City, Restaurant, Worker)
def callable_year(dt_value): def callable_year(dt_value):
@ -74,6 +74,7 @@ class ChapterXtra1Admin(admin.ModelAdmin):
class ArticleAdmin(admin.ModelAdmin): class ArticleAdmin(admin.ModelAdmin):
list_display = ('content', 'date', callable_year, 'model_year', 'modeladmin_year') list_display = ('content', 'date', callable_year, 'model_year', 'modeladmin_year')
list_filter = ('date', 'section') list_filter = ('date', 'section')
view_on_site = False
fieldsets = ( fieldsets = (
('Some fields', { ('Some fields', {
'classes': ('collapse',), 'classes': ('collapse',),
@ -735,6 +736,35 @@ class EmptyModelMixinAdmin(admin.ModelAdmin):
form = FormWithVisibleAndHiddenField form = FormWithVisibleAndHiddenField
fieldsets = EmptyModelVisibleAdmin.fieldsets fieldsets = EmptyModelVisibleAdmin.fieldsets
class CityInlineAdmin(admin.TabularInline):
model = City
view_on_site = False
class StateAdmin(admin.ModelAdmin):
inlines = [CityInlineAdmin]
class RestaurantInlineAdmin(admin.TabularInline):
model = Restaurant
view_on_site = True
class CityAdmin(admin.ModelAdmin):
inlines = [RestaurantInlineAdmin]
view_on_site = True
class WorkerAdmin(admin.ModelAdmin):
def view_on_site(self, obj):
return '/worker/%s/%s/' % (obj.surname, obj.name)
class WorkerInlineAdmin(admin.TabularInline):
model = Worker
def view_on_site(self, obj):
return '/worker_inline/%s/%s/' % (obj.surname, obj.name)
class RestaurantAdmin(admin.ModelAdmin):
inlines = [WorkerInlineAdmin]
view_on_site = False
site = admin.AdminSite(name="admin") site = admin.AdminSite(name="admin")
site.register(Article, ArticleAdmin) site.register(Article, ArticleAdmin)
site.register(CustomArticle, CustomArticleAdmin) site.register(CustomArticle, CustomArticleAdmin)
@ -785,6 +815,10 @@ site.register(MainPrepopulated, MainPrepopulatedAdmin)
site.register(UnorderedObject, UnorderedObjectAdmin) site.register(UnorderedObject, UnorderedObjectAdmin)
site.register(UndeletableObject, UndeletableObjectAdmin) site.register(UndeletableObject, UndeletableObjectAdmin)
site.register(UnchangeableObject, UnchangeableObjectAdmin) site.register(UnchangeableObject, UnchangeableObjectAdmin)
site.register(State, StateAdmin)
site.register(City, CityAdmin)
site.register(Restaurant, RestaurantAdmin)
site.register(Worker, WorkerAdmin)
# 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:

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<django-objects version="1.0">
<object pk="1" model="admin_views.state">
<field type="CharField" name="name">New York</field>
</object>
<object pk="2" model="admin_views.state">
<field type="CharField" name="name">Illinois</field>
</object>
<object pk="3" model="admin_views.state">
<field type="CharField" name="name">California</field>
</object>
<object pk="1" model="admin_views.city">
<field to="admin_views.state" name="state" rel="ManyToOneRel">1</field>
<field type="CharField" name="name">New York</field>
</object>
<object pk="2" model="admin_views.city">
<field to="admin_views.state" name="state" rel="ManyToOneRel">2</field>
<field type="CharField" name="name">Chicago</field>
</object>
<object pk="3" model="admin_views.city">
<field to="admin_views.state" name="state" rel="ManyToOneRel">3</field>
<field type="CharField" name="name">San Francisco</field>
</object>
<object pk="1" model="admin_views.restaurant">
<field to="admin_views.city" name="city" rel="ManyToOneRel">1</field>
<field type="CharField" name="name">Italian Pizza</field>
</object>
<object pk="2" model="admin_views.restaurant">
<field to="admin_views.city" name="city" rel="ManyToOneRel">1</field>
<field type="CharField" name="name">Boulevard</field>
</object>
<object pk="3" model="admin_views.restaurant">
<field to="admin_views.city" name="city" rel="ManyToOneRel">2</field>
<field type="CharField" name="name">Chinese Dinner</field>
</object>
<object pk="4" model="admin_views.restaurant">
<field to="admin_views.city" name="city" rel="ManyToOneRel">2</field>
<field type="CharField" name="name">Angels</field>
</object>
<object pk="5" model="admin_views.restaurant">
<field to="admin_views.city" name="city" rel="ManyToOneRel">2</field>
<field type="CharField" name="name">Take Away</field>
</object>
<object pk="6" model="admin_views.restaurant">
<field to="admin_views.city" name="city" rel="ManyToOneRel">3</field>
<field type="CharField" name="name">The Unknown Restaurant</field>
</object>
<object pk="1" model="admin_views.worker">
<field to="admin_views.restaurant" name="work_at" rel="ManyToOneRel">1</field>
<field type="CharField" name="name">Mario</field>
<field type="CharField" name="surname">Rossi</field>
</object>
<object pk="2" model="admin_views.worker">
<field to="admin_views.restaurant" name="work_at" rel="ManyToOneRel">1</field>
<field type="CharField" name="name">Antonio</field>
<field type="CharField" name="surname">Bianchi</field>
</object>
<object pk="3" model="admin_views.worker">
<field to="admin_views.restaurant" name="work_at" rel="ManyToOneRel">1</field>
<field type="CharField" name="name">John</field>
<field type="CharField" name="surname">Doe</field>
</object>
</django-objects>

View File

@ -717,3 +717,19 @@ class EmptyModelHidden(models.Model):
class EmptyModelMixin(models.Model): class EmptyModelMixin(models.Model):
""" See ticket #11277. """ """ See ticket #11277. """
class State(models.Model):
name = models.CharField(max_length=100)
class City(models.Model):
state = models.ForeignKey(State)
name = models.CharField(max_length=100)
class Restaurant(models.Model):
city = models.ForeignKey(City)
name = models.CharField(max_length=100)
class Worker(models.Model):
work_at = models.ForeignKey(Restaurant)
name = models.CharField(max_length=50)
surname = models.CharField(max_length=50)

View File

@ -9,6 +9,7 @@ import unittest
from django.conf import settings, global_settings from django.conf import settings, global_settings
from django.core import mail from django.core import mail
from django.core.files import temp as tempfile from django.core.files import temp as tempfile
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
# Register auth models with the admin. # Register auth models with the admin.
from django.contrib.auth import get_permission_codename from django.contrib.auth import get_permission_codename
@ -48,8 +49,8 @@ from .models import (Article, BarAccount, CustomArticle, EmptyModel, FooAccount,
AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable, AdminOrderedModelMethod, AdminOrderedAdminMethod, AdminOrderedCallable,
Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject, Report, MainPrepopulated, RelatedPrepopulated, UnorderedObject,
Simple, UndeletableObject, UnchangeableObject, Choice, ShortMessage, Simple, UndeletableObject, UnchangeableObject, Choice, ShortMessage,
Telegram, Pizza, Topping, FilteredManager) Telegram, Pizza, Topping, FilteredManager, City, Restaurant, Worker)
from .admin import site, site2 from .admin import site, site2, CityAdmin
ERROR_MESSAGE = "Please enter the correct username and password \ ERROR_MESSAGE = "Please enter the correct username and password \
@ -4597,3 +4598,87 @@ class TestLabelVisibility(TestCase):
def assert_fieldline_hidden(self, response): def assert_fieldline_hidden(self, response):
self.assertContains(response, '<div class="form-row hidden') self.assertContains(response, '<div class="form-row hidden')
@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
class AdminViewOnSiteTest(TestCase):
urls = "admin_views.urls"
fixtures = ['admin-views-users.xml', 'admin-views-restaurants.xml']
def setUp(self):
self.client.login(username='super', password='secret')
def tearDown(self):
self.client.logout()
def test_validate(self):
"Ensure that the view_on_site value is either a boolean or a callable"
CityAdmin.view_on_site = True
CityAdmin.validate(City)
CityAdmin.view_on_site = False
CityAdmin.validate(City)
CityAdmin.view_on_site = lambda obj: obj.get_absolute_url()
CityAdmin.validate(City)
CityAdmin.view_on_site = []
with self.assertRaisesMessage(ImproperlyConfigured, 'CityAdmin.view_on_site is not a callable or a boolean value.'):
CityAdmin.validate(City)
def test_false(self):
"Ensure that the 'View on site' button is not displayed if view_on_site is False"
response = self.client.get('/test_admin/admin/admin_views/restaurant/1/')
content_type_pk = ContentType.objects.get_for_model(Restaurant).pk
self.assertNotContains(response,
'"/test_admin/admin/r/%s/1/"' % content_type_pk,
)
def test_true(self):
"Ensure that the default behaviour is followed if view_on_site is True"
response = self.client.get('/test_admin/admin/admin_views/city/1/')
content_type_pk = ContentType.objects.get_for_model(City).pk
self.assertContains(response,
'"/test_admin/admin/r/%s/1/"' % content_type_pk,
)
def test_callable(self):
"Ensure that the right link is displayed if view_on_site is a callable"
response = self.client.get('/test_admin/admin/admin_views/worker/1/')
worker = Worker.objects.get(pk=1)
self.assertContains(response,
'"/worker/%s/%s/"' % (worker.surname, worker.name),
)
@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
class InlineAdminViewOnSiteTest(TestCase):
urls = "admin_views.urls"
fixtures = ['admin-views-users.xml', 'admin-views-restaurants.xml']
def setUp(self):
self.client.login(username='super', password='secret')
def tearDown(self):
self.client.logout()
def test_false(self):
"Ensure that the 'View on site' button is not displayed if view_on_site is False"
response = self.client.get('/test_admin/admin/admin_views/state/1/')
content_type_pk = ContentType.objects.get_for_model(City).pk
self.assertNotContains(response,
'/test_admin/admin/r/%s/1/' % content_type_pk,
)
def test_true(self):
"Ensure that the 'View on site' button is displayed if view_on_site is True"
response = self.client.get('/test_admin/admin/admin_views/city/1/')
content_type_pk = ContentType.objects.get_for_model(Restaurant).pk
self.assertContains(response,
'/test_admin/admin/r/%s/1/' % content_type_pk,
)
def test_callable(self):
"Ensure that the right link is displayed if view_on_site is a callable"
response = self.client.get('/test_admin/admin/admin_views/restaurant/1/')
worker = Worker.objects.get(pk=1)
self.assertContains(response,
'"/worker_inline/%s/%s/"' % (worker.surname, worker.name),
)