Fixed #13165 -- Added edit and delete links to admin foreign key widgets.

Thanks to Collin Anderson for the review and suggestions and Tim for the
final review.
This commit is contained in:
Simon Charette 2013-11-17 17:26:20 -05:00 committed by Tim Graham
parent 48ad288679
commit 07988744b3
17 changed files with 427 additions and 70 deletions

View File

@ -188,11 +188,16 @@ class BaseModelAdmin(six.with_metaclass(forms.MediaDefiningClass)):
# OneToOneField with parent_link=True or a M2M intermediary. # OneToOneField with parent_link=True or a M2M intermediary.
if formfield and db_field.name not in self.raw_id_fields: if formfield and db_field.name not in self.raw_id_fields:
related_modeladmin = self.admin_site._registry.get(db_field.rel.to) related_modeladmin = self.admin_site._registry.get(db_field.rel.to)
can_add_related = bool(related_modeladmin and wrapper_kwargs = {}
related_modeladmin.has_add_permission(request)) if related_modeladmin:
wrapper_kwargs.update(
can_add_related=related_modeladmin.has_add_permission(request),
can_change_related=related_modeladmin.has_change_permission(request),
can_delete_related=related_modeladmin.has_delete_permission(request),
)
formfield.widget = widgets.RelatedFieldWidgetWrapper( formfield.widget = widgets.RelatedFieldWidgetWrapper(
formfield.widget, db_field.rel, self.admin_site, formfield.widget, db_field.rel, self.admin_site, **wrapper_kwargs
can_add_related=can_add_related) )
return formfield return formfield
@ -703,17 +708,18 @@ class ModelAdmin(BaseModelAdmin):
from django.contrib.admin.views.main import ChangeList from django.contrib.admin.views.main import ChangeList
return ChangeList return ChangeList
def get_object(self, request, object_id): def get_object(self, request, object_id, from_field=None):
""" """
Returns an instance matching the primary key provided. ``None`` is Returns an instance matching the field and value provided, the primary
returned if no match is found (or the object_id failed validation key is used if no field is provided. Returns ``None`` if no match is
against the primary key field). found or the object_id fails validation.
""" """
queryset = self.get_queryset(request) queryset = self.get_queryset(request)
model = queryset.model model = queryset.model
field = model._meta.pk if from_field is None else model._meta.get_field(from_field)
try: try:
object_id = model._meta.pk.to_python(object_id) object_id = field.to_python(object_id)
return queryset.get(pk=object_id) return queryset.get(**{field.name: object_id})
except (model.DoesNotExist, ValidationError, ValueError): except (model.DoesNotExist, ValidationError, ValueError):
return None return None
@ -1186,6 +1192,19 @@ class ModelAdmin(BaseModelAdmin):
Determines the HttpResponse for the change_view stage. Determines the HttpResponse for the change_view stage.
""" """
if IS_POPUP_VAR in request.POST:
to_field = request.POST.get(TO_FIELD_VAR)
attr = str(to_field) if to_field else obj._meta.pk.attname
# Retrieve the `object_id` from the resolved pattern arguments.
value = request.resolver_match.args[0]
new_value = obj.serializable_value(attr)
return SimpleTemplateResponse('admin/popup_response.html', {
'action': 'change',
'value': escape(value),
'obj': escapejs(obj),
'new_value': escape(new_value),
})
opts = self.model._meta opts = self.model._meta
pk_value = obj._get_pk_val() pk_value = obj._get_pk_val()
preserved_filters = self.get_preserved_filters(request) preserved_filters = self.get_preserved_filters(request)
@ -1324,17 +1343,23 @@ class ModelAdmin(BaseModelAdmin):
self.message_user(request, msg, messages.WARNING) self.message_user(request, msg, messages.WARNING)
return None return None
def response_delete(self, request, obj_display): def response_delete(self, request, obj_display, obj_id):
""" """
Determines the HttpResponse for the delete_view stage. Determines the HttpResponse for the delete_view stage.
""" """
opts = self.model._meta opts = self.model._meta
if IS_POPUP_VAR in request.POST:
return SimpleTemplateResponse('admin/popup_response.html', {
'action': 'delete',
'value': escape(obj_id),
})
self.message_user(request, self.message_user(request,
_('The %(name)s "%(obj)s" was deleted successfully.') % { _('The %(name)s "%(obj)s" was deleted successfully.') % {
'name': force_text(opts.verbose_name), 'name': force_text(opts.verbose_name),
'obj': force_text(obj_display) 'obj': force_text(obj_display),
}, messages.SUCCESS) }, messages.SUCCESS)
if self.has_change_permission(request, None): if self.has_change_permission(request, None):
@ -1355,6 +1380,10 @@ class ModelAdmin(BaseModelAdmin):
app_label = opts.app_label app_label = opts.app_label
request.current_app = self.admin_site.name request.current_app = self.admin_site.name
context.update(
to_field_var=TO_FIELD_VAR,
is_popup_var=IS_POPUP_VAR,
)
return TemplateResponse(request, return TemplateResponse(request,
self.delete_confirmation_template or [ self.delete_confirmation_template or [
@ -1409,7 +1438,7 @@ class ModelAdmin(BaseModelAdmin):
obj = None obj = None
else: else:
obj = self.get_object(request, unquote(object_id)) obj = self.get_object(request, unquote(object_id), to_field)
if not self.has_change_permission(request, obj): if not self.has_change_permission(request, obj):
raise PermissionDenied raise PermissionDenied
@ -1654,7 +1683,11 @@ class ModelAdmin(BaseModelAdmin):
opts = self.model._meta opts = self.model._meta
app_label = opts.app_label app_label = opts.app_label
obj = self.get_object(request, unquote(object_id)) to_field = request.POST.get(TO_FIELD_VAR, request.GET.get(TO_FIELD_VAR))
if to_field and not self.to_field_allowed(request, to_field):
raise DisallowedModelAdminToField("The field %s cannot be referenced." % to_field)
obj = self.get_object(request, unquote(object_id), to_field)
if not self.has_delete_permission(request, obj): if not self.has_delete_permission(request, obj):
raise PermissionDenied raise PermissionDenied
@ -1676,10 +1709,12 @@ class ModelAdmin(BaseModelAdmin):
if perms_needed: if perms_needed:
raise PermissionDenied raise PermissionDenied
obj_display = force_text(obj) obj_display = force_text(obj)
attr = str(to_field) if to_field else opts.pk.attname
obj_id = obj.serializable_value(attr)
self.log_deletion(request, obj, obj_display) self.log_deletion(request, obj, obj_display)
self.delete_model(request, obj) self.delete_model(request, obj)
return self.response_delete(request, obj_display) return self.response_delete(request, obj_display, obj_id)
object_name = force_text(opts.verbose_name) object_name = force_text(opts.verbose_name)
@ -1700,6 +1735,9 @@ class ModelAdmin(BaseModelAdmin):
opts=opts, opts=opts,
app_label=app_label, app_label=app_label,
preserved_filters=self.get_preserved_filters(request), preserved_filters=self.get_preserved_filters(request),
is_popup=(IS_POPUP_VAR in request.POST or
IS_POPUP_VAR in request.GET),
to_field=to_field,
) )
context.update(extra_context or {}) context.update(extra_context or {})

View File

@ -576,3 +576,13 @@ ul.orderer li.deleted:hover, ul.orderer li.deleted a.selector:hover {
font-size: 11px; font-size: 11px;
border-top: 1px solid #ddd; border-top: 1px solid #ddd;
} }
/* RELATED WIDGET WRAPPER */
.related-widget-wrapper-link {
opacity: 0.3;
}
.related-widget-wrapper-link:link {
opacity: 1;
}

View File

@ -56,11 +56,16 @@ function dismissRelatedLookupPopup(win, chosenId) {
win.close(); win.close();
} }
function showAddAnotherPopup(triggeringLink) { function showRelatedObjectPopup(triggeringLink) {
return showAdminPopup(triggeringLink, /^add_/); var name = triggeringLink.id.replace(/^(change|add|delete)_/, '');
name = id_to_windowname(name);
var href = triggeringLink.href;
var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
win.focus();
return false;
} }
function dismissAddAnotherPopup(win, newId, newRepr) { function dismissAddRelatedObjectPopup(win, newId, newRepr) {
// newId and newRepr are expected to have previously been escaped by // newId and newRepr are expected to have previously been escaped by
// django.utils.html.escape. // django.utils.html.escape.
newId = html_unescape(newId); newId = html_unescape(newId);
@ -81,6 +86,8 @@ function dismissAddAnotherPopup(win, newId, newRepr) {
elem.value = newId; elem.value = newId;
} }
} }
// Trigger a change event to update related links if required.
django.jQuery(elem).trigger('change');
} else { } else {
var toId = name + "_to"; var toId = name + "_to";
o = new Option(newRepr, newId); o = new Option(newRepr, newId);
@ -89,3 +96,35 @@ function dismissAddAnotherPopup(win, newId, newRepr) {
} }
win.close(); win.close();
} }
function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) {
objId = html_unescape(objId);
newRepr = html_unescape(newRepr);
var id = windowname_to_id(win.name).replace(/^edit_/, '');
var selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]);
var selects = django.jQuery(selectsSelector);
selects.find('option').each(function() {
if (this.value == objId) {
this.innerHTML = newRepr;
this.value = newId;
}
});
win.close();
};
function dismissDeleteRelatedObjectPopup(win, objId) {
objId = html_unescape(objId);
var id = windowname_to_id(win.name).replace(/^delete_/, '');
var selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]);
var selects = django.jQuery(selectsSelector);
selects.find('option').each(function() {
if (this.value == objId) {
django.jQuery(this).remove();
}
}).trigger('change');
win.close();
};
// Kept for backward compatibility
showAddAnotherPopup = showRelatedObjectPopup;
dismissAddAnotherPopup = dismissAddRelatedObjectPopup;

View File

@ -0,0 +1,23 @@
django.jQuery(function($){
function updateLinks() {
var $this = $(this);
var siblings = $this.nextAll('.change-related, .delete-related');
if (!siblings.length) return;
var value = $this.val();
if (value) {
siblings.each(function(){
var elm = $(this);
elm.attr('href', elm.attr('data-href-template').replace('__fk__', value));
});
} else siblings.removeAttr('href');
}
var container = $(document);
container.on('change', '.related-widget-wrapper select', updateLinks);
container.find('.related-widget-wrapper select').each(updateLinks);
container.on('click', '.related-widget-wrapper-link', function(event){
if (this.href) {
showRelatedObjectPopup(this);
}
event.preventDefault();
});
});

View File

@ -36,6 +36,8 @@
<form action="" method="post">{% csrf_token %} <form action="" method="post">{% csrf_token %}
<div> <div>
<input type="hidden" name="post" value="yes" /> <input type="hidden" name="post" value="yes" />
{% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1" />{% endif %}
{% if to_field %}<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}" />{% endif %}
<input type="submit" value="{% trans "Yes, I'm sure" %}" /> <input type="submit" value="{% trans "Yes, I'm sure" %}" />
<a href="#" onclick="window.history.back(); return false;" class="button cancel-link">{% trans "No, take me back" %}</a> <a href="#" onclick="window.history.back(); return false;" class="button cancel-link">{% trans "No, take me back" %}</a>
</div> </div>

View File

@ -3,7 +3,13 @@
<head><title></title></head> <head><title></title></head>
<body> <body>
<script type="text/javascript"> <script type="text/javascript">
opener.dismissAddAnotherPopup(window, "{{ value }}", "{{ obj }}"); {% if action == 'change' %}
opener.dismissChangeRelatedObjectPopup(window, "{{ value }}", "{{ obj }}", "{{ new_value }}");
{% elif action == 'delete' %}
opener.dismissDeleteRelatedObjectPopup(window, "{{ value }}");
{% else %}
opener.dismissAddRelatedObjectPopup(window, "{{ value }}", "{{ obj }}");
{% endif %}
</script> </script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,30 @@
{% load i18n admin_static %}
<div class="related-widget-wrapper">
{{ widget }}
{% block links %}
{% if can_change_related %}
<a class="related-widget-wrapper-link change-related" id="change_id_{{ name }}"
data-href-template="{{ change_related_template_url }}?{{ url_params }}"
title="{% blocktrans %}Change selected {{ model }}{% endblocktrans %}">
<img src="{% static 'admin/img/icon_changelink.gif' %}" width="10" height="10"
alt="{% trans 'Change' %}"/>
</a>
{% endif %}
{% if can_add_related %}
<a class="related-widget-wrapper-link add-related" id="add_id_{{ name }}"
href="{{ add_related_url }}?{{ url_params }}"
title="{% blocktrans %}Add another {{ model }}{% endblocktrans %}">
<img src="{% static 'admin/img/icon_addlink.gif' %}" width="10" height="10"
alt="{% trans 'Add' %}"/>
</a>
{% endif %}
{% if can_delete_related %}
<a class="related-widget-wrapper-link delete-related" id="delete_id_{{ name }}"
data-href-template="{{ delete_related_template_url }}?{{ url_params }}"
title="{% blocktrans %}Delete selected {{ model }}{% endblocktrans %}">
<img src="{% static 'admin/img/icon_deletelink.gif' %}" width="10" height="10"
alt="{% trans 'Delete' %}"/>
</a>
{% endif %}
{% endblock %}
</div>

View File

@ -8,8 +8,10 @@ import copy
from django import forms from django import forms
from django.contrib.admin.templatetags.admin_static import static from django.contrib.admin.templatetags.admin_static import static
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.forms.widgets import RadioFieldRenderer from django.db.models.deletion import CASCADE
from django.forms.widgets import Media, RadioFieldRenderer
from django.forms.utils import flatatt from django.forms.utils import flatatt
from django.template.loader import render_to_string
from django.utils.html import escape, format_html, format_html_join, smart_urlquote from django.utils.html import escape, format_html, format_html_join, smart_urlquote
from django.utils.text import Truncator from django.utils.text import Truncator
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -232,7 +234,10 @@ class RelatedFieldWidgetWrapper(forms.Widget):
This class is a wrapper to a given widget to add the add icon for the This class is a wrapper to a given widget to add the add icon for the
admin interface. admin interface.
""" """
def __init__(self, widget, rel, admin_site, can_add_related=None): template = 'admin/related_widget_wrapper.html'
def __init__(self, widget, rel, admin_site, can_add_related=None,
can_change_related=False, can_delete_related=False):
self.needs_multipart_form = widget.needs_multipart_form self.needs_multipart_form = widget.needs_multipart_form
self.attrs = widget.attrs self.attrs = widget.attrs
self.choices = widget.choices self.choices = widget.choices
@ -243,6 +248,12 @@ class RelatedFieldWidgetWrapper(forms.Widget):
if can_add_related is None: if can_add_related is None:
can_add_related = rel.to in admin_site._registry can_add_related = rel.to in admin_site._registry
self.can_add_related = can_add_related self.can_add_related = can_add_related
# XXX: The UX does not support multiple selected values.
multiple = getattr(widget, 'allow_multiple_selected', False)
self.can_change_related = not multiple and can_change_related
# XXX: The deletion UX can be confusing when dealing with cascading deletion.
cascade = getattr(rel, 'on_delete', None) is CASCADE
self.can_delete_related = not multiple and not cascade and can_delete_related
# so we can check if the related object is registered with this AdminSite # so we can check if the related object is registered with this AdminSite
self.admin_site = admin_site self.admin_site = admin_site
@ -259,22 +270,47 @@ class RelatedFieldWidgetWrapper(forms.Widget):
@property @property
def media(self): def media(self):
return self.widget.media media = Media(js=['admin/js/related-widget-wrapper.js'])
return self.widget.media + media
def get_related_url(self, info, action, *args):
return reverse("admin:%s_%s_%s" % (info + (action,)),
current_app=self.admin_site.name, args=args)
def render(self, name, value, *args, **kwargs): def render(self, name, value, *args, **kwargs):
from django.contrib.admin.views.main import TO_FIELD_VAR from django.contrib.admin.views.main import IS_POPUP_VAR, TO_FIELD_VAR
rel_opts = self.rel.to._meta
info = (rel_opts.app_label, rel_opts.model_name)
self.widget.choices = self.choices self.widget.choices = self.choices
output = [self.widget.render(name, value, *args, **kwargs)] url_params = '&'.join("%s=%s" % param for param in [
(TO_FIELD_VAR, self.rel.get_related_field().name),
(IS_POPUP_VAR, 1),
])
context = {
'widget': self.widget.render(name, value, *args, **kwargs),
'name': name,
'url_params': url_params,
'model': rel_opts.verbose_name,
}
if self.can_change_related:
change_related_template_url = self.get_related_url(info, 'change', '__fk__')
context.update(
can_change_related=True,
change_related_template_url=change_related_template_url,
)
if self.can_add_related: if self.can_add_related:
rel_to = self.rel.to add_related_url = self.get_related_url(info, 'add')
info = (rel_to._meta.app_label, rel_to._meta.model_name) context.update(
related_url = reverse('admin:%s_%s_add' % info, current_app=self.admin_site.name) can_add_related=True,
url_params = '?%s=%s' % (TO_FIELD_VAR, self.rel.get_related_field().name) add_related_url=add_related_url,
# TODO: "add_id_" is hard-coded here. This should instead use the )
# correct API to determine the ID dynamically. if self.can_delete_related:
output.append('<a href="%s%s" class="add-another" id="add_id_%s" title="%s"></a>' delete_related_template_url = self.get_related_url(info, 'delete', '__fk__')
% (related_url, url_params, name, _('Add Another'))) context.update(
return mark_safe(''.join(output)) can_delete_related=True,
delete_related_template_url=delete_related_template_url,
)
return mark_safe(render_to_string(self.template, context))
def build_attrs(self, extra_attrs=None, **kwargs): def build_attrs(self, extra_attrs=None, **kwargs):
"Helper function for building an attribute dictionary." "Helper function for building an attribute dictionary."

View File

@ -1736,7 +1736,7 @@ templates used by the :class:`ModelAdmin` views:
been saved. You can override it to change the default been saved. You can override it to change the default
behavior after the object has been changed. behavior after the object has been changed.
.. method:: ModelAdmin.response_delete(request, obj_display) .. method:: ModelAdmin.response_delete(request, obj_display, obj_id)
.. versionadded:: 1.7 .. versionadded:: 1.7
@ -1750,6 +1750,13 @@ templates used by the :class:`ModelAdmin` views:
``obj_display`` is a string with the name of the deleted ``obj_display`` is a string with the name of the deleted
object. object.
``obj_id`` is the serialized identifier used to retrieve the object to be
deleted.
.. versionadded:: 1.8
The ``obj_id`` parameter was added.
.. method:: ModelAdmin.get_changeform_initial_data(request) .. method:: ModelAdmin.get_changeform_initial_data(request)
.. versionadded:: 1.7 .. versionadded:: 1.7

View File

@ -161,6 +161,9 @@ Minor features
its value from :meth:`~django.contrib.admin.AdminSite.has_permission`, its value from :meth:`~django.contrib.admin.AdminSite.has_permission`,
indicates whether the user may access the site. indicates whether the user may access the site.
* Foreign key dropdowns now have buttons for changing or deleting related
objects using a popup.
:mod:`django.contrib.admindocs` :mod:`django.contrib.admindocs`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -913,6 +916,23 @@ those writing third-party backends in updating their code:
``data_type_check_constraints`` attributes have moved from the ``data_type_check_constraints`` attributes have moved from the
``DatabaseCreation`` class to ``DatabaseWrapper``. ``DatabaseCreation`` class to ``DatabaseWrapper``.
:mod:`django.contrib.admin`
~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ``AdminSite`` no longer takes an ``app_name`` argument and its ``app_name``
attribute has been removed. The application name is always ``admin`` (as
opposed to the instance name which you can still customize using
``AdminSite(name="...")``.
* The ``ModelAdmin.get_object()`` method (private API) now takes a third
argument named ``from_field`` in order to specify which field should match
the provided ``object_id``.
* The :meth:`ModelAdmin.response_delete()
<django.contrib.admin.ModelAdmin.response_delete>` method
now takes a second argument named ``obj_id`` which is the serialized
identifier used to retrieve the object before deletion.
Miscellaneous Miscellaneous
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
@ -945,11 +965,6 @@ Miscellaneous
``'username'``, using the the ``'unique'`` key in its ``'username'``, using the the ``'unique'`` key in its
:attr:`~django.forms.Field.error_messages` argument. :attr:`~django.forms.Field.error_messages` argument.
* ``AdminSite`` no longer takes an ``app_name`` argument and its ``app_name``
attribute has been removed. The application name is always ``admin`` (as
opposed to the instance name which you can still customize using
``AdminSite(name="...")``.
* The block ``usertools`` in the ``base.html`` template of * The block ``usertools`` in the ``base.html`` template of
:mod:`django.contrib.admin` now requires the ``has_permission`` context :mod:`django.contrib.admin` now requires the ``has_permission`` context
variable to be set. If you have any custom admin views that use this variable to be set. If you have any custom admin views that use this

View File

@ -50,7 +50,7 @@ class AdminCustomUrlsTest(TestCase):
} }
response = self.client.post('/admin/admin_custom_urls/action/!add/', post_data) response = self.client.post('/admin/admin_custom_urls/action/!add/', post_data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'dismissAddAnotherPopup') self.assertContains(response, 'dismissAddRelatedObjectPopup')
self.assertContains(response, 'Action added through a popup') self.assertContains(response, 'Action added through a popup')
def test_admin_URLs_no_clash(self): def test_admin_URLs_no_clash(self):

View File

@ -53,6 +53,7 @@ callable_year.admin_order_field = 'date'
class ArticleInline(admin.TabularInline): class ArticleInline(admin.TabularInline):
model = Article model = Article
fk_name = 'section'
prepopulated_fields = { prepopulated_fields = {
'title': ('content',) 'title': ('content',)
} }
@ -93,7 +94,7 @@ class ArticleAdmin(admin.ModelAdmin):
}), }),
('Some other fields', { ('Some other fields', {
'classes': ('wide',), 'classes': ('wide',),
'fields': ('date', 'section') 'fields': ('date', 'section', 'sub_section')
}) })
) )

View File

@ -32,6 +32,7 @@ class Article(models.Model):
content = models.TextField() content = models.TextField()
date = models.DateTimeField() date = models.DateTimeField()
section = models.ForeignKey(Section, null=True, blank=True) section = models.ForeignKey(Section, null=True, blank=True)
sub_section = models.ForeignKey(Section, null=True, blank=True, on_delete=models.SET_NULL, related_name='+')
def __str__(self): def __str__(self):
return self.title return self.title
@ -545,7 +546,7 @@ class Pizza(models.Model):
class Album(models.Model): class Album(models.Model):
owner = models.ForeignKey(User) owner = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL)
title = models.CharField(max_length=30) title = models.CharField(max_length=30)

View File

@ -180,7 +180,7 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
} }
response = self.client.post('/test_admin/%s/admin_views/article/add/' % self.urlbit, post_data) response = self.client.post('/test_admin/%s/admin_views/article/add/' % self.urlbit, post_data)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'dismissAddAnotherPopup') self.assertContains(response, 'dismissAddRelatedObjectPopup')
self.assertContains(response, 'title with a new\\u000Aline') self.assertContains(response, 'title with a new\\u000Aline')
# Post data for edit inline # Post data for edit inline
@ -648,8 +648,8 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
response = self.client.get("/test_admin/admin/admin_views/referencedbyinline/", {TO_FIELD_VAR: 'name'}) response = self.client.get("/test_admin/admin/admin_views/referencedbyinline/", {TO_FIELD_VAR: 'name'})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# We also want to prevent the add and change view from leaking a # We also want to prevent the add, change, and delete views from
# disallowed field value. # leaking a disallowed field value.
with patch_logger('django.security.DisallowedModelAdminToField', 'error') as calls: with patch_logger('django.security.DisallowedModelAdminToField', 'error') as calls:
response = self.client.post("/test_admin/admin/admin_views/section/add/", {TO_FIELD_VAR: 'name'}) response = self.client.post("/test_admin/admin/admin_views/section/add/", {TO_FIELD_VAR: 'name'})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@ -661,6 +661,11 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(len(calls), 1) self.assertEqual(len(calls), 1)
with patch_logger('django.security.DisallowedModelAdminToField', 'error') as calls:
response = self.client.post("/test_admin/admin/admin_views/section/%d/delete/" % section.pk, {TO_FIELD_VAR: 'name'})
self.assertEqual(response.status_code, 400)
self.assertEqual(len(calls), 1)
def test_allowed_filtering_15103(self): def test_allowed_filtering_15103(self):
""" """
Regressions test for ticket 15103 - filtering on fields defined in a Regressions test for ticket 15103 - filtering on fields defined in a
@ -1472,21 +1477,75 @@ class AdminViewPermissionsTest(TestCase):
login_url = reverse('admin:login') + '?next=/test_admin/admin/' login_url = reverse('admin:login') + '?next=/test_admin/admin/'
# Set up and log in user. # Set up and log in user.
url = '/test_admin/admin/admin_views/article/add/' url = '/test_admin/admin/admin_views/article/add/'
add_link_text = ' class="add-another"' add_link_text = 'add_id_section'
self.client.get('/test_admin/admin/')
self.client.post(login_url, self.adduser_login) self.client.post(login_url, self.adduser_login)
# The add user can't add sections yet, so they shouldn't see the "add # The user can't add sections yet, so they shouldn't see the "add
# section" link. # section" link.
response = self.client.get(url) response = self.client.get(url)
self.assertNotContains(response, add_link_text) self.assertNotContains(response, add_link_text)
# Allow the add user to add sections too. Now they can see the "add # Allow the user to add sections too. Now they can see the "add
# section" link. # section" link.
add_user = User.objects.get(username='adduser') user = User.objects.get(username='adduser')
perm = get_perm(Section, get_permission_codename('add', Section._meta)) perm = get_perm(Section, get_permission_codename('add', Section._meta))
add_user.user_permissions.add(perm) user.user_permissions.add(perm)
response = self.client.get(url) response = self.client.get(url)
self.assertContains(response, add_link_text) self.assertContains(response, add_link_text)
def test_conditionally_show_change_section_link(self):
"""
The foreign key widget should only show the "change related" button if
the user has permission to change that related item.
"""
def get_change_related(response):
return response.context['adminform'].form.fields['section'].widget.can_change_related
login_url = reverse('admin:login')
# Set up and log in user.
url = '/test_admin/admin/admin_views/article/add/'
change_link_text = 'change_id_section'
self.client.post(login_url, self.adduser_login)
# The user can't change sections yet, so they shouldn't see the "change
# section" link.
response = self.client.get(url)
self.assertFalse(get_change_related(response))
self.assertNotContains(response, change_link_text)
# Allow the user to change sections too. Now they can see the "change
# section" link.
user = User.objects.get(username='adduser')
perm = get_perm(Section, get_permission_codename('change', Section._meta))
user.user_permissions.add(perm)
response = self.client.get(url)
self.assertTrue(get_change_related(response))
self.assertContains(response, change_link_text)
def test_conditionally_show_delete_section_link(self):
"""
The foreign key widget should only show the "delete related" button if
the user has permission to delete that related item.
"""
def get_delete_related(response):
return response.context['adminform'].form.fields['sub_section'].widget.can_delete_related
login_url = reverse('admin:login')
# Set up and log in user.
url = '/test_admin/admin/admin_views/article/add/'
delete_link_text = 'delete_id_sub_section'
self.client.get('/test_admin/admin/')
self.client.post(login_url, self.adduser_login)
# The user can't delete sections yet, so they shouldn't see the "delete
# section" link.
response = self.client.get(url)
self.assertFalse(get_delete_related(response))
self.assertNotContains(response, delete_link_text)
# Allow the user to delete sections too. Now they can see the "delete
# section" link.
user = User.objects.get(username='adduser')
perm = get_perm(Section, get_permission_codename('delete', Section._meta))
user.user_permissions.add(perm)
response = self.client.get(url)
self.assertTrue(get_delete_related(response))
self.assertContains(response, delete_link_text)
def test_custom_model_admin_templates(self): def test_custom_model_admin_templates(self):
login_url = reverse('admin:login') + '?next=/test_admin/admin/' login_url = reverse('admin:login') + '?next=/test_admin/admin/'
self.client.get('/test_admin/admin/') self.client.get('/test_admin/admin/')
@ -4140,12 +4199,12 @@ class UserAdminTest(TestCase):
self.assertEqual(adminform.form.errors['password2'], self.assertEqual(adminform.form.errors['password2'],
["The two password fields didn't match."]) ["The two password fields didn't match."])
def test_user_fk_popup(self): def test_user_fk_add_popup(self):
"""Quick user addition in a FK popup shouldn't invoke view for further user customization""" """User addition through a FK popup should return the appropriate JavaScript response."""
response = self.client.get('/test_admin/admin/admin_views/album/add/') response = self.client.get('/test_admin/admin/admin_views/album/add/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, '/test_admin/admin/auth/user/add') self.assertContains(response, '/test_admin/admin/auth/user/add')
self.assertContains(response, 'class="add-another" id="add_id_owner"') self.assertContains(response, 'class="related-widget-wrapper-link add-related" id="add_id_owner"')
response = self.client.get('/test_admin/admin/auth/user/add/?_popup=1') response = self.client.get('/test_admin/admin/auth/user/add/?_popup=1')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'name="_continue"') self.assertNotContains(response, 'name="_continue"')
@ -4159,7 +4218,52 @@ class UserAdminTest(TestCase):
} }
response = self.client.post('/test_admin/admin/auth/user/add/?_popup=1', data, follow=True) response = self.client.post('/test_admin/admin/auth/user/add/?_popup=1', data, follow=True)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'dismissAddAnotherPopup') self.assertContains(response, 'dismissAddRelatedObjectPopup')
def test_user_fk_change_popup(self):
"""User change through a FK popup should return the appropriate JavaScript response."""
response = self.client.get('/test_admin/admin/admin_views/album/add/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, '/test_admin/admin/auth/user/__fk__/')
self.assertContains(response, 'class="related-widget-wrapper-link change-related" id="change_id_owner"')
user = User.objects.get(username='changeuser')
url = "/test_admin/admin/auth/user/%s/?_popup=1" % user.pk
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'name="_continue"')
self.assertNotContains(response, 'name="_addanother"')
data = {
'username': 'newuser',
'password1': 'newpassword',
'password2': 'newpassword',
'last_login_0': '2007-05-30',
'last_login_1': '13:20:10',
'date_joined_0': '2007-05-30',
'date_joined_1': '13:20:10',
'_popup': '1',
'_save': '1',
}
response = self.client.post(url, data, follow=True)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'dismissChangeRelatedObjectPopup')
def test_user_fk_delete_popup(self):
"""User deletion through a FK popup should return the appropriate JavaScript response."""
response = self.client.get('/test_admin/admin/admin_views/album/add/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, '/test_admin/admin/auth/user/__fk__/delete/')
self.assertContains(response, 'class="related-widget-wrapper-link change-related" id="change_id_owner"')
user = User.objects.get(username='changeuser')
url = "/test_admin/admin/auth/user/%s/delete/?_popup=1" % user.pk
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = {
'post': 'yes',
'_popup': '1',
}
response = self.client.post(url, data, follow=True)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'dismissDeleteRelatedObjectPopup')
def test_save_add_another_button(self): def test_save_add_another_button(self):
user_count = User.objects.count() user_count = User.objects.count()

View File

@ -108,7 +108,8 @@ class Individual(models.Model):
related instances (rendering will be called programmatically in this case). related instances (rendering will be called programmatically in this case).
""" """
name = models.CharField(max_length=20) name = models.CharField(max_length=20)
parent = models.ForeignKey('self', null=True) parent = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
soulmate = models.ForeignKey('self', null=True, on_delete=models.CASCADE, related_name='soulmates')
class Company(models.Model): class Company(models.Model):

View File

@ -510,6 +510,32 @@ class RelatedFieldWidgetWrapperTests(DjangoTestCase):
w = widgets.RelatedFieldWidgetWrapper(w, rel, widget_admin_site) w = widgets.RelatedFieldWidgetWrapper(w, rel, widget_admin_site)
self.assertFalse(w.can_add_related) self.assertFalse(w.can_add_related)
def test_select_multiple_widget_cant_change_delete_related(self):
rel = models.Individual._meta.get_field('parent').rel
widget = forms.SelectMultiple()
wrapper = widgets.RelatedFieldWidgetWrapper(
widget, rel, widget_admin_site,
can_add_related=True,
can_change_related=True,
can_delete_related=True,
)
self.assertTrue(wrapper.can_add_related)
self.assertFalse(wrapper.can_change_related)
self.assertFalse(wrapper.can_delete_related)
def test_on_delete_cascade_rel_cant_delete_related(self):
rel = models.Individual._meta.get_field('soulmate').rel
widget = forms.Select()
wrapper = widgets.RelatedFieldWidgetWrapper(
widget, rel, widget_admin_site,
can_add_related=True,
can_change_related=True,
can_delete_related=True,
)
self.assertTrue(wrapper.can_add_related)
self.assertTrue(wrapper.can_change_related)
self.assertFalse(wrapper.can_delete_related)
@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',), @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',),
ROOT_URLCONF='admin_widgets.urls') ROOT_URLCONF='admin_widgets.urls')
@ -1134,9 +1160,27 @@ class RelatedFieldWidgetSeleniumFirefoxTests(AdminSeleniumWebDriverTestCase):
# The field now contains the new user # The field now contains the new user
self.wait_for('#id_user option[value="newuser"]') self.wait_for('#id_user option[value="newuser"]')
# Click the Change User button to change it
self.selenium.find_element_by_id('change_id_user').click()
self.selenium.switch_to_window('id_user')
self.wait_page_loaded()
username_field = self.selenium.find_element_by_id('id_username')
username_value = 'changednewuser'
username_field.clear()
username_field.send_keys(username_value)
save_button_css_selector = '.submit-row > input[type=submit]'
self.selenium.find_element_by_css_selector(save_button_css_selector).click()
self.selenium.switch_to_window(main_window)
# Wait up to 2 seconds for the new option to show up after clicking save in the popup.
self.selenium.implicitly_wait(2)
self.selenium.find_element_by_css_selector('#id_user option[value=changednewuser]')
self.selenium.implicitly_wait(0)
# Go ahead and submit the form to make sure it works # Go ahead and submit the form to make sure it works
self.selenium.find_element_by_css_selector(save_button_css_selector).click() self.selenium.find_element_by_css_selector(save_button_css_selector).click()
self.wait_for_text('li.success', 'The profile "newuser" was added successfully.') self.wait_for_text('li.success', 'The profile "changednewuser" was added successfully.')
profiles = models.Profile.objects.all() profiles = models.Profile.objects.all()
self.assertEqual(len(profiles), 1) self.assertEqual(len(profiles), 1)
self.assertEqual(profiles[0].user.username, username_value) self.assertEqual(profiles[0].user.username, username_value)

View File

@ -355,30 +355,30 @@ class ModelAdminTests(TestCase):
form = ma.get_form(request)() form = ma.get_form(request)()
self.assertHTMLEqual(str(form["main_band"]), self.assertHTMLEqual(str(form["main_band"]),
'<select name="main_band" id="id_main_band">\n' '<div class="related-widget-wrapper">'
'<option value="" selected="selected">---------</option>\n' '<select name="main_band" id="id_main_band">'
'<option value="%d">The Beatles</option>\n' '<option value="" selected="selected">---------</option>'
'<option value="%d">The Doors</option>\n' '<option value="%d">The Beatles</option>'
'</select>' % (band2.id, self.band.id)) '<option value="%d">The Doors</option>'
'</select></div>' % (band2.id, self.band.id))
class AdminConcertForm(forms.ModelForm): class AdminConcertForm(forms.ModelForm):
pass
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(AdminConcertForm, self).__init__(*args, **kwargs) super(AdminConcertForm, self).__init__(*args, **kwargs)
self.fields["main_band"].queryset = Band.objects.filter(name='The Doors') self.fields["main_band"].queryset = Band.objects.filter(name='The Doors')
class ConcertAdmin(ModelAdmin): class ConcertAdminWithForm(ModelAdmin):
form = AdminConcertForm form = AdminConcertForm
ma = ConcertAdmin(Concert, self.site) ma = ConcertAdminWithForm(Concert, self.site)
form = ma.get_form(request)() form = ma.get_form(request)()
self.assertHTMLEqual(str(form["main_band"]), self.assertHTMLEqual(str(form["main_band"]),
'<select name="main_band" id="id_main_band">\n' '<div class="related-widget-wrapper">'
'<option value="" selected="selected">---------</option>\n' '<select name="main_band" id="id_main_band">'
'<option value="%d">The Doors</option>\n' '<option value="" selected="selected">---------</option>'
'</select>' % self.band.id) '<option value="%d">The Doors</option>'
'</select></div>' % self.band.id)
def test_regression_for_ticket_15820(self): def test_regression_for_ticket_15820(self):
""" """